首屏快了 800 毫秒:小程序按需注入 lazyCodeLoading 与初始渲染缓存实践
一个真实的慢启动场景
今年 3 月,我们接手了一个内容资讯类小程序的维护工作。日活不高,但留存差,用户反馈集中在一点:打开太慢。我们用真机性能面板测了一组数据:中端安卓机(Redmi Note 系列)冷启动到首屏内容可见,平均耗时 2980ms,最差一次 4.1 秒。iOS 好一些,也在 2.3 秒上下。

排期花了两周做专项优化,目标是把冷启动压到 2 秒以内。4 月中旬完成第一批改动后,同样的测试环境下,平均启动耗时降到约 2160ms,提升约 820ms,也就是标题里说的 800 毫秒。需要说明:这组数据来自我们团队三台测试机的单机观测(两台安卓、一台 iPhone 11),样本有限,不同项目因为包体和组件数量差异,收益会不一样,仅供参考。
这 800ms 里,大头来自两项配置:lazyCodeLoading(按需注入)和初始渲染缓存(Initial Rendering Cache)。前者省的是逻辑层注入阶段的时间,后者省的是首屏等待渲染的时间。下面按优化顺序展开。
先搞清楚:小程序启动到底慢在哪
在动手之前,先花半天时间用 wx.getPerformance 和微信开发者工具的「体验评分」面板把启动链路拆开看。小程序是双线程架构:逻辑层(运行 JavaScript 的 JSCore/V8)和渲染层(WebView/Skyline)分开,中间靠原生层桥接通信。冷启动大致经历这几个阶段:
- 下载代码包(首次或版本更新后)
- 注入逻辑层:执行 app.js 之前,要把所有注册的自定义组件代码注入进来
- 执行 app.js 与首页 Page 的 onLoad
- 渲染层构建页面树并首次渲染
我们当时的耗时分布(中端安卓,单机观测均值):代码包下载约 400ms(本地缓存命中时更短),自定义组件代码注入约 700ms,app.js + onLoad 执行约 350ms,渲染层首屏绘制约 1100ms,其余是桥接和调度开销。两个优化点正好卡在「注入」和「首屏绘制」这两段最贵的时间上。
flowchart LR
A[用户点击小程序] --> B[下载/校验代码包]
B --> C[逻辑层: 注入全部自定义组件代码]
C --> D[执行 app.js 与页面 onLoad]
D --> E[setData 首屏数据传给渲染层]
E --> F[渲染层: 构建节点树并首次绘制]
F --> G[用户看到首屏]
style C fill:#ffd6d6
style F fill:#ffd6d6
图中标红的两段,就是本文两个核心配置各自作用的位置:lazyCodeLoading 优化 C 段,初始渲染缓存优化 F 段。这也是理解「为什么这两项配置有效」的关键——它们不是让代码跑得更快,而是砍掉了不需要做的工作。
核心一:lazyCodeLoading 按需注入
配置方法
在 app.json 里加一行配置:
{
// 在 app.json 根节点添加,无额外依赖
// 生效前提:基础库 2.11.1 及以上,低版本自动忽略回退全量注入
"lazyCodeLoading": "requiredComponents"
}
加完之后,基础库的行为从「启动时把所有自定义组件的代码全量注入逻辑层」变成「只注入当前页面实际声明使用的自定义组件」。页面里 usingComponents 没有用到的组件,代码就不会被注入,注入耗时直接和「首页用到的组件数」挂钩,而不是和「全项目组件总数」挂钩。
我们的项目当时积累了 87 个自定义组件,首页真正用到的只有 21 个。配置生效后,注入阶段从约 700ms 降到约 260ms,这一项就省下约 440ms。数据来自真机性能面板的「脚本注入」指标,单机观测。
兼容性
这项配置要求基础库 2.11.1 及以上。在 app.json 配置后,低版本基础库会忽略该配置、退回全量注入,不会报错,所以上线风险可控。建议同时在后台把「最低基础库版本」设置到 2.11.1 以上,避免低版本用户得不到优化收益却占用老链路的测试成本。
最大的坑:全局组件要显式声明
这是我们踩得最疼的一坑,值得单独一节。项目里有一个通过 App() 上的全局配置或模板复用引入的弹窗组件、以及几个只在自定义 tabBar 里用的组件。配了 lazyCodeLoading 之后真机上一片白,控制台报组件不存在。
原因:按需注入只认「当前页面 json 的 usingComponents 里显式声明的组件」。以前全量注入时代,一些「碰巧被注入了」的组件(比如被 app.json 的 usingComponents 全局声明引用、或者通过 behaviors/getRelationNodes 间接依赖的组件)能正常渲染;切到按需注入后,没被页面直接声明的组件代码根本没进逻辑层,渲染自然失败。
排查方法:全局搜项目里所有被间接依赖的组件,逐一在页面 json 里补上声明。我们当时补了 6 个组件的声明。给一个补声明的示例:
{
// 页面 json:把全局/间接依赖的组件显式补进来
// 按需注入只认页面 usingComponents 的显式声明
"usingComponents": {
// 全局弹窗组件,此前靠全量注入碰巧可用
"global-dialog": "/components/global-dialog/index",
// 广告位组件,分包页面同样要这样补声明
"ad-banner": "/components/ad-banner/index"
}
}
还有两个小坑一并记录:
| 现象 | 原因 | 处理 |
|---|---|---|
| 分包页面组件渲染失败 | 按需注入按页面维度触发,分包页面要确保声明完整 | 分包页面 json 同样补全 usingComponents |
| 动态 selectComponent 拿到 null | 目标组件未被任何页面声明,代码未注入 | 改为在页面 json 显式声明,或延迟到组件被使用前再操作 |
核心二:初始渲染缓存
解决什么问题
双线程模型下,即使逻辑层瞬间执行完 onLoad,首屏数据也要通过 setData 跨线程传给渲染层才能画出来。冷启动时这条链路特别长:渲染层等逻辑层注入完、等 app.js 跑完、等 onLoad 里请求回来(或至少等初始 data 传过去)。
初始渲染缓存(Initial Rendering Cache)的思路是:把页面初始 data 对应的静态骨架在开发者工具或云端生成一份缓存,冷启动时渲染层直接展示这份缓存,不等逻辑层。用户几乎立刻看到首屏结构(导航、骨架、静态文案),动态数据回来后再补上。
配置方法
在页面 json 里开启:
{
// 页面级配置:开启初始渲染缓存
// static 为纯客户端静态缓存;迁移到 Skyline 后可改 "skyline"
"initialRenderingCache": "static"
}
"static" 表示纯客户端静态缓存;如果项目已经迁移到 Skyline 渲染引擎,可以配 "skyline",由 Skyline 接管初始渲染。我们当时首页还在 WebView 下,用的 static 模式。
开启后需要在开发者工具里点一次「添加编译模式/生成缓存」或真机预览触发缓存生成,缓存会随代码包下发。生成流程建议写进发布 checklist,不然改了初始 data 结构但缓存没重新生成,用户看到的还是旧骨架。
坑:只缓存初始 data,动态内容出不来
这是官方文档反复强调、但我们还是实际踩过的一点:初始渲染缓存只包含页面初始 data(即 onLoad 之前就确定的数据)渲染出的内容。onLoad 里 setData 回来的列表数据、请求回来的标题,缓存里统统没有——缓存展示的是「Page 构造时 data 字段的渲染结果」。
所以它不适合直接展示真实内容,定位是高质量骨架屏:把导航栏、页面框架、占位图、固定的标题栏做成初始 data 的一部分,缓存直接画出这些结构。我们的做法:
- 首页 data 里预置
skeletonVisible: true和固定的栏目名 - 缓存渲染出完整骨架(栏目名是写死的,不依赖请求)
- onLoad 请求返回后 setData 关闭骨架、渲染真实列表
实测这一项把「白屏时间」变成「骨架可见时间」,用户感知的首屏从约 1100ms 的白屏等待变成约 200ms 的骨架出现(单机观测)。要不要把它算进那 800ms 的提升?我们算的是「内容可见时间」口径,所以初始渲染缓存对指标的贡献主要是体验分和主观流畅度,启动耗时数字本身的下降主要来自 lazyCodeLoading。两个口径分开说,避免虚报。
与骨架屏方案的关系
如果不用初始渲染缓存,传统骨架屏是「渲染层先画 WXML 里的骨架节点 → 逻辑层数据到位后替换」。两者效果接近,但初始渲染缓存连逻辑层启动那段时间都不用等,冷启动阶段的优势明显;缺点是缓存需要生成和更新流程,且初始 data 之外的动态内容必须走二次渲染。内容结构稳定、初始 data 可写死的页面(首页、频道页)适合开缓存;列表详情类页面用普通骨架屏就够了。
配套优化:分包预下载、同步 API 与 setData 时机
除了两项主配置,下面这些改动各贡献了几十毫秒,加起来约 200ms,一并记录。
分包 preloadRule 预下载
资讯类小程序详情页在分包里。以前用户点进详情才下载分包,弱网下要等 1-2 秒。在 app.json 配 preloadRule,首页展示时预下载详情分包:
{
// app.json:进入首页后 wifi 环境预下载 detail 分包
// network 可选 all / wifi,流量敏感场景建议只配 wifi
"preloadRule": {
// 键为页面路径,值是该页面的预下载规则
"pages/index/index": {
"network": "wifi",
// packages 填分包 root 名,可写多个
"packages": ["detail"]
}
}
}
network 可以配 "all" 或 "wifi"。流量敏感的用户群建议只配 wifi,弱网用户点进去还是走按需下载,不至于背着全部分包流量。
减少启动期同步 API
改造前 app.js 的 onLaunch 里有三个同步存储读取(wx.getStorageSync)、一个同步系统信息获取(wx.getSystemInfoSync)。同步 API 会阻塞逻辑层线程,在注入完成后的关键路径上抢时间。全部换成异步版本,onLaunch 里只保留登录态检查这一个必须同步的事,其余延迟到首页 onLoad 之后空闲时再取。这一项省约 80ms(真机性能面板观察)。
| 改造点 | 改造前 | 改造后 | 单机观测收益 |
|---|---|---|---|
| 按需注入 lazyCodeLoading | 全量注入约 700ms | 按需注入约 260ms | 约 440ms |
| 初始渲染缓存 | 白屏约 1100ms | 骨架约 200ms 可见 | 体验口径,另计 |
| 同步 API 异步化 | 阻塞约 80ms | 非阻塞 | 约 80ms |
| setData 时机优化 | onLoad 内 3 次 setData | 合并为 1 次 + 骨架先行 | 约 60ms |
| 分包预下载 | 详情页二次等待 | 首页期间预下载 | 详情页进入 -900ms |
setData 时机与频次
双线程模型下 setData 是跨线程通信,每次调用都有序列化和桥接开销。两个原则:首屏关键数据一次到位(合并多次 setData),非关键数据(如广告位、推荐位第二屏内容)延迟到 onReady 之后或用 IntersectionObserver 触发。我们把 onLoad 里的 3 次 setData 合并成 1 次,又省了约 60ms。
度量手段:怎么确认每一毫秒花在哪
优化不能靠感觉,我们的度量闭环是三件套:
- 开发者工具「体验评分」:会直接给出启动耗时分析和建议项,lazyCodeLoading 未开启、存在同步 API 这类问题会被自动检出。适合作为体检入口。
- 真机性能面板(右上角胶囊菜单打开):看「脚本注入」「首次渲染」等阶段的实测耗时,是确认优化收益的主要依据。
- wx.getPerformance API:在代码里埋点,把启动各阶段耗时上报到自己的后台,长期观察线上分布。示例:
// 在 app.js onLaunch 中获取性能观察器(基础库 2.11.0+)
const perf = wx.getPerformance();
// 创建观察者,回调里拿到各阶段的耗时条目
const observer = perf.createObserver((entryList) => {
// 上报启动各阶段耗时到自有监控后台
const entries = entryList.getEntries();
// reportAnalytics 是自定义分析接口,可换成自建上报
wx.reportAnalytics && wx.reportAnalytics('launch_perf', { raw: JSON.stringify(entries) });
});
// 监听注入与渲染相关指标
// navigation 对应启动导航,render 对应渲染,script 对应脚本注入
observer.observe({ entryTypes: ['navigation', 'render', 'script'] });
上线后我们从后台看了一周数据:中端安卓 P50 启动耗时从 2900ms 左右降到 2100ms 左右,和真机观测基本吻合。分位数上 P90 降幅更明显,因为低端机上全量注入的耗时占比更高,按需注入对它们反而更划算。
原理剖析:双线程模型下这两项配置省在哪一段
最后把机制讲透。小程序启动链路在双线程架构下是这样的:原生层先拉起逻辑层线程,把 app.js 和全部自定义组件的构造代码注入执行——注意在旧机制下,「全部自定义组件」是指全项目所有组件,不管首页用不用;与此同时渲染层线程启动,等逻辑层的页面实例把初始数据传过来才开始构建节点树。这条链路是串行依赖的:渲染层首屏必须等逻辑层完成注入并跑完 onLoad 的同步部分。
lazyCodeLoading 砍的是注入阶段的工作量。组件代码注入本质是把每个组件的 JS、WXML 编译产物装载进逻辑层,组件越多装载越慢。按需注入把「全项目规模」缩成「首页规模」,首页只装 21 个组件就绪,逻辑层提前约 440ms 进入可运行状态,渲染层等数据的时间随之提前。
初始渲染缓存砍的是渲染层等数据的时间。它让渲染层在冷启动时不等逻辑层,直接用随包下发的缓存把初始 data 的渲染结果画出来。相当于把「逻辑层就绪 → 传数据 → 渲染」这条串行链路里最前面那段可视化工作提前并行完成了。代价是缓存内容受限于初始 data,动态内容仍要走正常链路二次渲染。
xychart-beta
title "优化前后启动耗时分解(中端安卓,单机观测均值,ms)"
x-axis ["代码包准备", "组件注入", "app.js+onLoad", "首屏渲染", "合计"]
y-axis "耗时(毫秒)" 0 --> 3000
bar "优化前" : [400, 700, 350, 1100, 2550]
bar "优化后" : [400, 260, 280, 1100, 2040]
从图里能看出结构:优化前后「代码包准备」和「首屏渲染」两段基本没动,省下来的全部来自「组件注入」,另有一小部分来自 app.js 里同步 API 异步化。「首屏渲染」那 1100ms 是初始渲染缓存的用武之地——它不缩短这个数字,而是让用户在等待期间先看到骨架,把感知耗时从 1100ms 压到 200ms 左右。数字指标和感知指标要分开度量、分开汇报,这是这次专项里我们学到的汇报技巧。
误区澄清与收尾
三个常见误区,收尾前澄清一下:
- 误区一:配了 lazyCodeLoading 就万事大吉。 它只优化注入阶段,代码包下载、app.js 里的同步阻塞、setData 风暴都还在,需要组合拳。
- 误区二:初始渲染缓存可以展示真实内容。 它只认初始 data,把请求结果写进缓存的期待会落空,正确姿势是把它当骨架屏用。
- 误区三:所有页面都开初始渲染缓存更好。 缓存生成和更新有维护成本,页面结构频繁变化的页面收益低,挑首页和频道页这类结构稳定的页面开即可。
往后的方向,一是 Skyline 渲染引擎迁移(启动和长列表性能都有收益,但组件兼容性要逐一验证),二是把 wx.getPerformance 的上报做成常规看板,让启动性能成为每次发版的常规观测项而不是一次性专项。如果你也在做小程序启动优化,欢迎在评论区交流你的数据口径和踩坑记录——尤其是按需注入后全局组件渲染失败这个问题,不同项目的表现形式还不太一样。
参考与延伸
微信小程序开发、lazyCodeLoading、初始渲染缓存、按需注入、启动性能、双线程模型、setData 优化、体验评分