微信小程序知识总结这个东西,网上能搜到一大堆,但十篇里有八篇是把官方文档的目录抄了一遍,剩下两篇讲的是三年前的老写法。真正做过两三个完整项目、被审核打回过、被线上白屏吓过的人,总结出来的东西往往不是"有哪些 API",而是"哪些地方一定会出事、为什么出事、怎么提前绕开"。我把自己这几年从零搭项目、重构老项目、接手别人半途跑路的项目攒下来的经验整理成这一篇,重点放在架构选型、双线程机制对写法的约束、从建项目到上线的完整流程,以及一堆只有踩过才知道的坑。不管你是刚接触小程序、只会照着模板改页面的新手,还是已经写过几个页面但总觉得代码越写越乱的半熟手,下面这些内容应该都能直接用上。
1. 小程序项目的整体设计与技术选型思路
1.1 为什么架构要先定、页面要后写
很多人上手小程序的第一反应是打开开发者工具,新建项目,然后开始往pages目录里塞页面。等塞到第八个页面的时候发现:请求方法在每个页面里各写了一遍,登录逻辑复制了三次,改一个接口域名要改二十个文件,公共样式在三个地方互相覆盖。这时候再想重构,成本比一开始设计高五倍以上。
小程序的工程结构和传统前端不太一样,它没有天然的路由配置中心,页面路径分散在app.json和各处跳转代码里;它也没有内置的成熟状态管理,全局数据只能靠getApp()或者自己搭一层 store。所以架构这件事在小程序里反而更重要,因为框架本身给你的约束少,你不管,它就一定乱。
我的习惯是项目初始化后的第一小时不写任何业务页面,只做四件事:定目录约定、定请求层、定全局状态、定样式基线。这四件事定完,后面每加一个页面都是"填空",而不是"重新发明"。具体来说,目录上我会按业务域而不是按文件类型划分,请求层统一收口到一个request.js,全局状态用一个极薄的 store 而不是一上来就引第三方库,样式基线把字号、间距、主色、圆角、安全区一次性定死。
这里有个判断标准:如果你接手的项目里,想找"这个页面的数据从哪来的"需要翻三个以上文件,那这个项目的结构就已经在拖后腿了。
1.2 原生开发、跨端框架与云开发怎么选
选型这件事没有标准答案,但有明确的判断依据。我一般看三个维度:团队技术栈、迭代节奏、是否需要多端。
| 方案 | 适合场景 | 优势 | 代价 |
|---|---|---|---|
| 原生开发 | 只做小程序端、需要用到最新能力、性能敏感 | 能力最全、问题最少、调试链路短 | 代码不能复用到其他端 |
| 跨端框架 | 已有 Web 或 App 团队、要同时出多端 | 一套代码多端产出、生态成熟 | 部分原生能力要写桥接、包体积偏大 |
| 云开发 | 小团队、后端资源紧张、快速验证 | 免服务器、数据库和存储开箱即用 | 复杂业务逻辑受约束、迁移成本高 |
我自己的取舍逻辑很朴素:如果这个项目明确只做小程序,并且要用到支付、订阅消息、地图、蓝牙这类深度能力,直接原生,别犹豫。跨端框架在处理这些能力时往往要额外写一层平台判断,出问题时你还要判断是框架的锅还是平台的锅,排查成本翻倍。反过来,如果公司已经有成熟的 Web 前端团队,业务偏展示和表单,多端复用能省掉一半工作量,那跨端是划算的。
云开发我一般用在"验证期"的项目上:两三天要出一个能点、能提交、能看到数据的原型,云开发是最快的路径。但如果业务里出现了复杂的权限模型、多角色流转、需要和已有系统对接,我会在验证通过后把数据层迁回自建后端,越晚迁越疼。
1.3 目录结构与工程约定
我常用的目录长这样,你可以直接抄:
├── app.js ├── app.json ├── app.wxss ├── components/ # 通用组件 │ ├── empty/ │ └── loading/ ├── pages/ # 主包页面,只放首页、tabBar 页 ├── packages/ # 分包,按业务域划分 │ ├── order/ │ └── user/ ├── services/ # 接口层,一个业务域一个文件 ├── store/ # 全局状态 ├── utils/ # 纯函数工具 └── assets/ # 图片资源,尽量放 CDN几个约定值得单独强调。第一,pages目录只放 tabBar 页面和极少数首屏必经页面,其余全部进分包,这条规则能帮你把主包体积压到可控范围。第二,services里一个文件对应一个业务域,导出的函数名就是接口语义,页面里不出现裸的wx.request。第三,utils里只放纯函数,不依赖getApp(),这样单测好写、复用也安全。
2. 核心机制拆解:双线程、生命周期与数据流
2.1 双线程模型如何决定了你的写法
小程序把逻辑层和渲染层拆开跑,逻辑层在独立的 JS 引擎里执行,渲染层在若干个 WebView 里跑,两边不共享内存,通信要经过中间层。这个设计带来的直接后果是:你在逻辑层改的任何数据,都不会"自动"出现在界面上,必须通过一次显式的通信把数据送过去,这个动作就是setData。
理解这一点之后,很多"为什么"就通了。为什么setData传大数据会卡?因为数据要先序列化,再跨线程传,再在渲染层反序列化,最后触发视图更新,整条链路是有成本的。为什么频繁setData会掉帧?因为通信是异步的,短时间内的多次调用会排队,渲染层处理不过来就堆积。为什么不能直接把this.data当普通对象随便改?因为改了逻辑层的引用,渲染层根本不知道。
所以写小程序的性能意识,本质上就是setData的使用意识。我给自己定的三条铁律:只传变化的部分、控制单次数据量、控制调用频率。这三条听着简单,但真正落到代码里,能挡掉线上八成的卡顿投诉。
2.2 页面与组件生命周期到底谁先谁后
生命周期是新手最容易含糊的地方。页面有onLoad、onShow、onReady、onHide、onUnload,组件有created、attached、ready、detached,应用层还有onLaunch、onShow、onError。
执行顺序上,首次进入一个页面,大致是:应用onLaunch→ 应用onShow→ 页面onLoad→ 页面onShow→ 组件created→ 组件attached→ 页面onReady→ 组件ready。这个顺序决定了你的数据初始化该写在哪。
我的实践规则:页面级参数解析和首次数据请求放onLoad;需要"每次回到页面都刷新"的逻辑放onShow,比如订单列表从详情页返回后要更新状态;需要操作 DOM 或依赖渲染完成的事情放onReady,比如获取节点信息、测量高度;清理定时器、取消事件监听放onUnload。
注意:
onShow会在页面第一次显示时也触发一次,如果你在onLoad和onShow里都写了请求,首屏就会发两次,接口被调用两遍。这种低级错误在现场出现的频率高得离谱。
组件的attached里能拿到properties,但拿不到渲染后的尺寸;需要测量宽高必须等ready。另外宿主的onLoad早于子组件的attached,如果父组件在onLoad里给子组件传值,子组件在attached里能拿到;但如果父组件在onLoad里通过selectComponent去调子组件方法,那一刻子组件还没创建,拿到的是null。
2.3 setData 的性能边界与优化手法
先给一组我在中低端安卓机上实测的经验值:单次setData的数据量控制在几十 KB 以内比较稳,超过几百 KB 就会明显感知到延迟;调用频率上,连续快速滑动场景下每秒超过十几次就会开始掉帧。这些数字不是硬标准,但可以作为自查的红线。
优化的手法就那么几个,但每一个都要用对地方。第一是路径更新,用字符串路径只改深层字段,而不是把整个大对象重新塞一遍:
// 不推荐:整个 list 重新传,几百条数据全量走一遍通信 this.setData({ list: this.data.list }); // 推荐:只改第 i 条的某个字段 this.setData({ [`list[${i}].status`]: 2 });第二是合并调用,同一轮逻辑里多次修改合并成一次setData,而不是每改一个字段调一次。第三是长列表分页,别想着一次渲染五百条,用触底加载每次追加二十条,或者用可回收的长列表组件。第四是避免把不需要渲染的数据放进data,比如一堆只在逻辑里用的配置、缓存对象,全部挂在this上而不是this.data上。
还有一个细节常被忽略:setData的第二个参数是回调,在视图更新完成后触发。如果你需要在渲染完成后再测量某个节点,用这个回调比用setTimeout靠谱得多。
2.4 路由与页面栈的实际使用边界
页面栈有层数上限,印象里是十层,超过了navigateTo会直接失败。这个限制在"列表 → 详情 → 详情 → 详情"这种链式跳转的业务里很容易撞上。撞上之后的典型表现是:用户点了一个商品,页面没反应,控制台也不报错,因为跳转失败是静默的。
我的处理方式分三种。层级浅的用navigateTo,跳转前判断一下getCurrentPages().length,接近上限就先redirectTo掉当前页。需要回到已知页面的用navigateBack配合页面间通信。tabBar 页面之间的切换必须用switchTab,而且switchTab不能带参数,要传参只能借全局变量或者本地缓存中转。
跳转传参也有坑。参数会拼在 URL 上,中文和特殊字符必须encodeURIComponent,接收端decodeURIComponent,否则到手上是乱码。参数长度也有限制,复杂的对象不适合塞 URL,用全局状态或者缓存传递更稳。
3. 实操全流程:从建项目到上线
3.1 环境准备与项目初始化配置
开发工具装好之后,新建项目时那几个选项值得认真对待:AppID 用自己的而不是测试号(测试号很多能力用不了,比如支付、订阅消息);语言选 JavaScript 还是 TypeScript 看团队,我倾向 TS,因为小程序的数据结构一旦复杂起来,没有类型提示写起来很痛苦;模板选"不使用模板",别用官方示例,示例代码里的很多写法是演示用的,不适合当工程起点。
项目建好后第一件事是改app.json,把该配的配置一次性配齐:
{ "pages": ["pages/index/index"], "subpackages": [ { "root": "packages/order", "pages": ["list/index", "detail/index"] } ], "window": { "navigationBarTitleText": "项目名", "navigationBarBackgroundColor": "#ffffff", "backgroundTextStyle": "dark" }, "lazyCodeLoading": "requiredComponents", "preloadRule": { "pages/index/index": { "network": "all", "packages": ["packages/order"] } } }lazyCodeLoading这个配置一定要加,它让小程序只注入当前页面用到的自定义组件代码,对启动速度的提升在小项目里可能只有几十毫秒,在组件多的大项目里能到几百毫秒。preloadRule是分包预下载,用户停留在首页时后台悄悄把订单分包拉下来,点进去就是秒开。
尺寸单位上,rpx的换算是750rpx等于屏幕宽度。拿一台宽 375px 的手机举例,1rpx = 0.5px,所以你写32rpx实际是 16px。设计稿一般按 750 宽出,标注多少就写多少,最省事。
3.2 登录鉴权与用户体系的完整实现
小程序的登录不是"输入账号密码",而是拿一个临时凭证换服务端的身份标识。完整链路是:前端调wx.login拿到code,把code发给自己的后端,后端用code换取用户唯一标识和会话密钥,然后生成自己的登录态(比如一个 token)返回给前端,前端存起来,后续请求带上。
// utils/auth.js export function login() { return new Promise((resolve, reject) => { wx.login({ success: async ({ code }) => { try { const res = await request({ url: '/api/auth/login', method: 'POST', data: { code }, skipAuth: true }); wx.setStorageSync('token', res.token); resolve(res); } catch (e) { reject(e); } }, fail: reject }); }); }这里有几个必须处理好的点。第一,code是一次性的,用过就失效,不能缓存复用。第二,登录态会过期,过期后的表现是接口返回未授权,这时候不能简单地弹个错误提示就完事,要自动重新登录并把原请求重放一次,用户是无感的。第三,如果页面上同时发了五个请求,全都返回未授权,会触发五次登录,这叫并发重登,必须用一个"登录中"的标记把后续请求挂起,等第一次登录完成再统一放行。
// request.js 里的并发重登控制 let isRefreshing = false; let queue = []; async function request(options) { if (!options.skipAuth && !wx.getStorageSync('token')) { await login(); } try { return await rawRequest(options); } catch (err) { if (err.statusCode === 401) { if (isRefreshing) { return new Promise((resolve, reject) => queue.push({ options, resolve, reject })); } isRefreshing = true; try { await login(); const task = queue.splice(0); task.forEach(t => rawRequest(t.options).then(t.resolve, t.reject)); return await rawRequest(options); } finally { isRefreshing = false; } } throw err; } }提示:头像昵称的获取方式这几年调整过,现在主流做法是用"头像昵称填写能力",让用户在专门的组件里选头像、填昵称,而不是调接口直接拿。别再用老教程里的写法,跑不通。
3.3 分包加载与体积控制的实操计算
主包体积是有硬限制的,超了就直接上传失败。控制体积的方法按收益排序:图片资源全部上 CDN、非首屏业务全部分包、能删的依赖删掉、公共库按需引入。
我做过一次具体的瘦身,过程大致是这样:一个项目主包 3.2MB,上传报超限。先用开发工具的体积分析看构成,发现图片占了 1.4MB,第三方 UI 库整包引入占了 800KB,业务页面占 900KB。处理方式是图片全传对象存储换成链接,UI 库改成只引用了三个组件、手写替代,业务页面把订单和用户两个模块拆成独立分包。最终主包降到 900KB 左右,启动时间从 2.3 秒降到 1.4 秒。
拆分包的时候有两个硬约束要记住:tabBar 页面必须在主包里,不能放进分包;分包之间的代码默认不能互相引用,需要共享的东西要么提到主包,要么用分包异步化。另外独立分包是另一回事,它可以不依赖主包运行,适合"分享出去的单页活动",代价是它不能直接用主包的公共代码。
3.4 网络请求的统一封装与异常兜底
请求层如果不在项目初期收口,后面一定会散成一地。我的封装一般包含这几层:基础地址和超时、统一鉴权头、统一错误码处理、loading 计数管理、重试。
const BASE_URL = 'https://api.example.com'; let loadingCount = 0; function showLoading() { if (loadingCount === 0) wx.showLoading({ title: '加载中', mask: true }); loadingCount++; } function hideLoading() { loadingCount--; if (loadingCount <= 0) { loadingCount = 0; wx.hideLoading(); } } function rawRequest({ url, method = 'GET', data = {}, header = {}, showLoad = false, timeout = 10000 }) { if (showLoad) showLoading(); return new Promise((resolve, reject) => { wx.request({ url: BASE_URL + url, method, data, timeout, header: { 'content-type': 'application/json', Authorization: wx.getStorageSync('token') || '', ...header }, success: (res) => { if (res.statusCode >= 200 && res.statusCode < 300) resolve(res.data); else reject({ statusCode: res.statusCode, data: res.data }); }, fail: (err) => reject({ statusCode: -1, message: err.errMsg }), complete: () => { if (showLoad) hideLoading(); } }); }); }loading 用计数器而不是布尔值,原因是并发请求时如果用布尔值,第一个请求结束就hideLoading了,其他请求还在飞,用户会看到界面闪一下。计数器能保证所有请求都结束才收起来,体验上差别很明显。
超时设置上,普通查询给 10 秒,上传类给 60 秒。别用默认值不管,默认值在弱网下会让用户干等很久还没有任何反馈。失败重试我一般只对 GET 做,最多重试一次,写操作绝不自动重试,否则会出现重复下单这种事故。
3.5 上线前的自查清单
提审前我会过一遍这张表,能挡掉大部分打回。
| 检查项 | 具体要求 | 常见问题 |
|---|---|---|
| 页面完整性 | 所有入口可点、所有按钮有响应 | 占位按钮没接逻辑 |
| 空状态 | 列表为空、加载失败、无网络都要有提示 | 只画了有数据的样子 |
| 授权 | 拒绝授权后要有引导,不能卡死 | 拒绝后页面一片空白 |
| 隐私说明 | 收集信息的用途要写清楚 | 收集了但没说明 |
| 类目匹配 | 功能与所选类目一致 | 类目选错被驳回 |
| 真机验证 | iOS 和安卓各过一遍核心流程 | 只在工具里点过 |
| 分享 | 转发标题和封面正常 | 分享出去是默认标题 |
体验版一定要在真机上走一遍,尤其是 iOS。工具里跑得好好的,真机上白屏的情况我遇到过不止一次,原因包括基础库版本过低、分包没下载完就跳转、某个 API 在当前版本不存在。上线前加一层wx.canIUse判断或者基础库版本兜底,能省掉很多半夜的电话。
4. 常见问题与排查技巧实录
4.1 高频问题速查表
下面这些是我这几年被问得最多、也是自己踩得最狠的,按现象查原因基本能定位。
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| 样式不生效 | 组件样式隔离 | 开styleIsolation: 'apply-shared'或用外部样式类 |
| 数据改了界面不动 | 直接改this.data没走setData | 改为路径setData |
| 组件事件不触发 | 方法名拼错 / 事件名大小写 | 检查bind:tap与triggerEvent名称 |
| 真机白屏 | 基础库过低 / 分包未就绪 | 加版本判断、跳转前预下载 |
| 时间显示异常 | iOS 不认带横杠的日期字符串 | 换成斜杠或时间戳 |
| 输入框被键盘挡住 | 没用adjust-position | 设置adjust-position或手动计算 |
| 跳转无反应 | 页面栈超限 | 改用redirectTo |
| 列表滑动卡顿 | setData数据量过大 | 分页 + 路径更新 |
4.2 一个真实的性能排查过程
有个列表页用户反馈"滑起来一卡一卡的",只在安卓低端机上复现。我的排查顺序是:先在开发者工具的性能面板看渲染耗时,发现每次滚动都会触发一次setData,数据量在 200KB 上下。定位到代码是一段监听滚动位置改样式的逻辑,每次滚动事件都同步更新状态。
改法分三步。第一步,滚动事件加节流,间隔 100 毫秒,把调用频率从每秒几十次降到十次以内。第二步,改变的样式只用一个布尔值表达,不再传整个配置对象,单次数据量从 200KB 降到几十字节。第三步,把这个视觉效果改用 CSS 的粘性定位实现,彻底不需要 JS 参与。三步做完,那台低端机上的滚动恢复流畅。
这个过程说明一件事:小程序的性能问题,九成都能归结到"数据通信太频繁或太大"。排查时先看setData的调用次数和数据量,比看别的都有用。
4.3 原生组件与层级问题的处理经验
input、textarea、video、map、canvas这些是原生组件,它们会盖在普通元素上层。这意味着你用z-index调半天,弹窗还是会被 video 挡住。常见的解决思路是把弹窗做成cover-view,或者干脆在弹窗打开时把原生组件隐藏掉。
textarea还有个老问题:它在某些场景下不跟随页面滚动,输入时位置错位。我的处理方式是尽量用input替代,非要用多行文本就在它获得焦点时把页面滚动到可视区域,失焦后恢复。
底部按钮的安全区适配容易被忘掉,全面屏手机上不处理会被系统手势条压住。办法很简单,样式里加padding-bottom: env(safe-area-inset-bottom),一行代码解决,但十有八九的项目第一次都没写。
4.4 几个我觉得最值钱的踩坑记录
第一个坑是本地缓存。缓存写满了会写不进去,而且失败是静默的,你的代码以为存成功了,下次读出来是undefined。所以凡是关键数据,写入后要校验,读不到要有兜底逻辑。缓存也不适合存敏感信息,用户的凭证类数据要谨慎。
第二个坑是页面的onShow重复触发。从详情页返回列表页会触发onShow,从后台切回前台也会触发onShow,如果你的onShow里写了请求,用户切个后台再回来就多刷一次。解法是在onShow里判断数据是否过期,过期才请求。
第三个坑是定时器没清理。页面onUnload里不clearInterval,定时器会一直跑,用户来回进出几次就有好几个定时器同时跑,逻辑开始抽搐。这类问题在工具里看不出来,真机上跑一会儿就暴露。
第四个坑是分享路径写死。分享出去的路径如果没有带上必要的参数,朋友点进来就是个空页面。分享的path要跟正常进入的路径保持一致的参数结构,这一点做小程序电商的应该都有体会。
最后一个,调试阶段一定要打开真机调试,工具和真机的差异比你想象的大:异步时序、动画表现、键盘行为、内存限制全都可能不一样。只在模拟器里点过的功能,我不敢说它能用。