首屏快了 800 毫秒:小程序按需注入 lazyCodeLoading 与初始渲染缓存实践

2026-09-27 01:19:33 0 次浏览
微信小程序性能优化前端开发小程序框架双线程模型

一个真实的慢启动场景

今年 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)分开,中间靠原生层桥接通信。冷启动大致经历这几个阶段:

  1. 下载代码包(首次或版本更新后)
  2. 注入逻辑层:执行 app.js 之前,要把所有注册的自定义组件代码注入进来
  3. 执行 app.js 与首页 Page 的 onLoad
  4. 渲染层构建页面树并首次渲染

我们当时的耗时分布(中端安卓,单机观测均值):代码包下载约 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 的一部分,缓存直接画出这些结构。我们的做法:

  1. 首页 data 里预置 skeletonVisible: true 和固定的栏目名
  2. 缓存渲染出完整骨架(栏目名是写死的,不依赖请求)
  3. 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。

度量手段:怎么确认每一毫秒花在哪

优化不能靠感觉,我们的度量闭环是三件套:

  1. 开发者工具「体验评分」:会直接给出启动耗时分析和建议项,lazyCodeLoading 未开启、存在同步 API 这类问题会被自动检出。适合作为体检入口。
  2. 真机性能面板(右上角胶囊菜单打开):看「脚本注入」「首次渲染」等阶段的实测耗时,是确认优化收益的主要依据。
  3. 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 优化、体验评分

🤖
本内容由 AI 辅助生成,经人工校对审核;部分素材、资料来源于公开网络,仅作个人观点分享与交流使用,无任何商业侵权意图。若内容、图片、文字涉及您的合法著作权、版权权益,请联系本人,核实后将第一时间删除、修改相关内容。