# 小程序性能优化
小程序的性能和用户的体验之间的关系密不可分。在使用小程序的过程中,用户有时会遇到小程序打开慢、滑动卡顿、响应慢、手机发烫等问题,这些问题都与小程序的性能有关。
广义上讲,小程序的性能又可以分为「启动性能」和「运行时性能」两个主题。「启动性能」让用户能够更快的打开并看到小程序的内容,「运行时性能」保障用户能够流畅的使用小程序的功能。除了本身的功能之外,良好性能带来的良好用户体验,也是小程序能够留住用户的关键。
# 启动性能优化
小程序冷启动即首屏渲染优化
# 代码包体积优化
启动性能优化最直接的手段是降低代码包大小,代码包大小直接影响了下载耗时,影响用户启动小程序时的体验。
# 分包加载
- 独立分包:适用于功能独立、启动性能要求高的页面,如广告页、活动页等。独立分包页面进入小程序时不需要下载主包。
- 分包预下载:解决用户首次进入分包页面时的延迟问题,提升用户体验。
- 分包异步化:将分包细化到组件甚至文件粒度,异步加载插件、组件和代码逻辑,降低启动包大小和代码量。分包异步化能有效解决主包大小过度膨胀的问题。
分包加载是一种优化小程序性能的重要策略,它通过将小程序的页面和功能拆分成不同的代码包来实现按需加载。这样做的优势包括:
- 承载更多功能:由于单个代码包体积上限为2M,分包可以提升总体积上限,从而容纳更多功能。
- 降低代码包下载耗时:分包后,启动时需要下载的代码包大小减少,有效降低了启动耗时。
- 降低代码注入耗时:分包减少了注入和执行的代码量,从而降低了注入耗时。
- 降低页面渲染耗时:避免了不必要的组件和页面初始化,减少了渲染耗时。
- 降低内存占用:分包实现了页面、组件和逻辑的按需加载,减少了内存占用。
通过合理划分代码包,分包优化不仅提升了小程序的启动速度,还改善了用户体验和降低了资源消耗。结合独立分包、分包预下载和分包异步化等扩展功能,可以进一步优化小程序的性能,尤其是在启动速度和页面切换的流畅性方面。开发者应根据小程序的具体需求和特点,合理利用这些分包策略,以达到最佳的优化效果。
# 避免非必要的全局自定义组件和插件
在 app.json 中通过 usingComponents 全局引用的自定义组件和通过plugins全局引入的插件,会在小程序启动时随主包一起下载和注入JS代码,这会增加启动耗时。即使某些扩展库和官方插件不占用主包大小,启动时仍然需要下载和注入JS代码,对启动耗时的影响与其他插件无异。
- 如果自定义组件只在某个分包的页面中使用,应定义在页面的配置文件中,以避免全局引入,这样可以减少不必要的代码注入,优化启动耗时。全局引入的自定义组件会被误认为所有分包、所有页面都需要,这会影响“按需注入”的效果和小程序代码注入的耗时。
- 如果插件只在某个分包中使用,应仅在该分包中引用插件,以减少主包的负担和启动时的代码注入量。例如,对于“小程序直播”插件,如果直播功能不在主包页面中使用或使用频率较低,建议通过分包引入,而不是全局引入。
- 对于确实需要在主包中或被多个分包使用的插件,可以考虑将插件置于一个分包中,并通过“分包异步化”的形式异步引入,这样可以在需要时才加载插件,进一步优化启动耗时。
# 控制代码包内的资源文件
小程序代码包在下载时会使用 ZSTD 算法进行压缩,图片、音频、视频、字体等资源文件会占用较多代码包体积,并且通常难以进一步被压缩,对于下载耗时的影响比代码文件大得多。
代码包内的图片一般应只包含一些体积较小的图标,避免在代码包中包含或在 WXSS 中使用 base64 内联过多、过大的图片等资源文件。这类文件应尽可能部署到 CDN,并使用 URL 引入。对图片资源进行压缩和优化,减少图片文件的大小,加快加载速度。
# 及时清理无用代码和资源
意外引入的第三方库、版本迭代中被废弃的代码或依赖、产品环境不需要的测试代码、未使用的组件、插件、扩展库,这些没有被实际使用到的文件和资源也会被打入到代码包里,从而影响到代码包的大小。
不定期地分析代码包的文件构成和依赖关系,可以利用 tree-shaking 等特性去除冗余代码,也要注意防止打包时引入不需要的库和依赖。
# 代码注入优化
小程序代码注入的优化可以从优化代码量和优化执行耗时两个角度着手:
# 使用按需注入
通常情况下,在小程序启动时,启动页面依赖的所有代码包(主包、分包、插件包、扩展库等)的所有 JS 代码会全部合并注入,包括其他未访问的页面以及未用到自定义组件,同时所有页面和自定义组件的 JS 代码会被立刻执行。这造成很多没有使用的代码在小程序运行环境中注入执行,影响注入耗时和内存占用。
使用按需引入组件,及时移除 JSON 中未使用自定义组件的声明,并尽量避免在全局声明使用率低的自定义组件,否则可能会影响按需注入的效果
# 使用用时注入
在打开上述「按需注入」特性的前提下,可以通过「用时注入」特性使一部分自定义组件不在启动时注入,而是在真正被渲染时才进行注入,进一步降低小程序的启动和首屏时间。
# 启动过程中减少同步 API 的调用
在小程序启动流程中,会注入开发者代码并顺序同步执行 App.onLaunch, App.onShow, Page.onLoad, Page.onShow。
在小程序初始化代码(Page,App 定义之外的内容)和上述启动相关的几个生命周期中,应尽量减少或不调用同步 API。应尽可能的使用异步 API 代替同步,并将启动过程中非必要的同步 API 调用延迟到启动完成后进行。
# 避免启动过程进行复杂运算
在小程序初始化代码(Page,App 定义之外的内容)和启动相关的几个生命周期中,应避免执行复杂的运算逻辑。复杂运算也会阻塞当前 JS 线程,影响启动耗时。建议将复杂的运算延迟到启动完成后进行。
# 首屏渲染优化
页面首屏渲染的优化,目的是让「首页渲染完成」(Page.onReady) 尽可能提前。但很多情况下「首页渲染完成」可能还是空白页面,因此更重要的是让用户能够更早的看到页面内容(First Paint 或 First Contentful Paint)
- 初始渲染缓存 :在非首次启动时,使视图层不需要等待逻辑层初始化完毕,而直接提前将页面渲染结果展示给用户,这可以使「首页渲染完成」和页面对用户可见的时间大大提前。
- 避免引用未使用的自定义组件:未使用的自定义组件会影响渲染耗时,应及时移除。
- 精简首屏数据:对于复杂页面,可以进行渐进式渲染,优先展示关键部分,非关键或不可见部分可以延迟更新。此外,与视图层渲染无关的数据应尽量不要放在 data 中,避免影响页面渲染时间。
- 提前首屏数据请求:建议在 Page.onLoad 或更早时机发起网络请求,而不是等待 Page.onReady 之后。小程序提供数据预拉取和周期性更新能力,以减少用户等待时间。
- 缓存请求数据:使用 wx.setStorage 和 wx.getStorage 等 API 读写本地缓存,优先从缓存中获取数据来渲染视图,网络请求返回后进行更新。
- 使用预渲染或骨架屏:如果初次渲染的数据量非常大,可能会导致页面在加载过程中出现一段时间的白屏,为了解决这个问题,Taro 提供了预渲染功能(Prerender)
# 运行时性能优化
小程序的运行时性能直接决定了用户在使用小程序功能时的体验。如果运行时性能出现问题,很容易出现页面滚动卡顿、响应延迟等问题,影响用户使用。如果内存占用过高,还会出现黑屏、闪退、发烫等问题。
# 合理使用 setData
setData 的过程,大致可以分成几个阶段:
- 逻辑层虚拟 DOM 树的遍历和更新,触发组件生命周期和 observer 等;
- 将 data 从逻辑层传输到视图层;
- 视图层虚拟 DOM 树的更新、真实 DOM 元素的更新并触发页面渲染更新。
由于小程序的逻辑层和视图层是两个独立的运行环境、分属不同的线程或进程,不能直接进行数据共享,需要进行数据的序列化、跨线程/进程的数据传输、数据的反序列化,因此数据传输过程是异步的、非实时的。数据传输的耗时与数据量的大小正相关,如果对端线程处于繁忙状态,数据会在消息队列中等待。
- data 应只包括渲染相关的数据:用 setData 的方式更新渲染无关的字段,会触发额外的渲染流程,或者增加传输的数据量,影响渲染耗时。
- 页面或组件的 data 字段,应用来存放和页面或组件渲染相关的数据(即直接在 wxml 中出现的字段)。
- 页面或组件渲染间接相关的数据可以设置为「纯数据字段」,可以使用 setData 设置并使用 observers 监听变化
- 页面或组件渲染无关的数据,应挂在非 data 的字段下,如
this.userData = {userId: 'xxx'}
- 避免在 data 中包含渲染无关的业务数据
- 避免使用 data 在页面或组件方法间进行数据共享
- 避免滥用纯数据字段来保存可以使用非 data 字段保存的数据
- 控制 setData 的频率:每次 setData 都会触发逻辑层虚拟 DOM 树的遍历和更新,也可能会导致触发一次完整的页面渲染流程。过于频繁(毫秒级)的调用 setData,会导致逻辑层 JS 线程持续繁忙,使得视图层无法及时响应用户操作、渲染延迟卡顿等问题。
- 仅在需要进行页面内容更新时调用 setData
- 对连续的 setData 调用尽可能的进行合并,批量更新
- 避免不必要的 setData
- 避免以过高的频率持续调用 setData,例如毫秒级的倒计时
- 避免在 onPageScroll 回调中每次都调用 setData
- 选择合适的 setData 范围:组件的 setData 只会引起当前组件和子组件的更新,可以降低虚拟 DOM 更新时的计算开销。
- 对于需要频繁更新的页面元素(例如:秒杀倒计时),可以封装为独立的组件,在组件内进行 setData 操作。必要时可以使用 CSS contain 属性限制计算布局、样式和绘制等的范围。
- setData 应只传发生变化的数据:setData 的数据量会影响数据拷贝和数据通讯的耗时,增加页面更新的开销,造成页面更新延迟。
- setData 应只传入发生变化的字段
- 建议以数据路径形式改变数组中的某一项或对象的某个属性,如
this.setData({'array[2].message': 'newVal', 'a.b.c.d': 'newVal'})
,而不是每次都更新整个对象或数组 - 不要在 setData 中偷懒一次性传所有data:
this.setData(this.data)
- 控制后台态页面的 setData:由于小程序逻辑层是单线程运行的,后台态页面去 setData 也会抢占前台页面的运行资源,且后台态页面的的渲染用户是无法感知的,会产生浪费。在某些平台上,小程序渲染层各 WebView 也是共享同一个线程,后台页面的渲染和逻辑执行也会导致前台页面的卡顿。
- 页面切后台后的更新操作,应尽量避免,或延迟到页面 onShow 后延迟进行
- 避免在切后台后仍进行高频的 setData,例如倒计时更新
# 渲染性能优化
适当监听页面或组件的 scroll 事件:只要用户在 Page 构造时传入了 onPageScroll 监听,基础库就会认为开发者需要监听页面 scoll 事件。此时,当用户滑动页面时,事件会以很高的频率从视图层发送到逻辑层,存在一定的通信开销。
- 非必要不监听 scroll 事件
- 在实现与滚动相关的动画时,优先考虑滚动驱动动画(仅
<scroll-view>
)或 WXS 响应事件 - 不需要监听事件时,Page 构造时应不传入 onPageScroll 函数,而不是留空函数
- 避免在 scroll 事件监听函数中执行复杂逻辑
- 避免在 scroll 事件监听中频繁调用 setData 或同步 API
选择高性能的动画实现方式:
- 优先使用 CSS 渐变、CSS 动画、或小程序框架提供的其他动画实现方式完成动画
- 在一些复杂场景下,如果上述方式不能满足,可以使用 WXS 响应事件 动态调整节点的 style 属性做到动画效果。同时,这种方式也可以根据用户的触摸事件来动态地生成动画
- 如果不得不采用 setData 方式,应尽可能将页面的 setData 改为自定义组件中的 setData 来提升性能
- 避免通过连续 setData 改变界面的形式来实现动画。虽然实现起来简单灵活,但是极易出现较大的延迟或卡顿,甚至导致小程序僵死
使用 IntersectionObserver 监听元素曝光:
- 建议使用节点布局相交状态监听 IntersectionObserver 推断某些节点是否可见、有多大比例可见
- 避免通过监听 onPageScroll 事件,并在回调中通过持续查询节点信息 SelectQuery 来判断元素是否可见
控制 WXML 节点数量和层级:一个太大的 WXML 节点树会增加内存的使用,样式重排时间也会更长,影响体验
- 建议一个页面 WXML 节点数量应少于 1000 个,节点树深度少于 30 层,子节点数不大于 60 个
控制在 Page 构造时传入的自定义数据量:为了保证自定义数据在不同的页面实例中也是不同的实例,小程序框架会在页面创建时对这部分数据(函数类型字段除外)做一次深拷贝,如果自定义数据过多或过于复杂,可能带来很大的开销。
- 对于比较复杂的数据对象,建议在 Page onLoad 或 Component created 时手动赋值到 this 上,而不是通过 Page 构造时的参数传入。
# 页面切换优化
- 避免在 onHide/onUnload 执行耗时操作:页面切换时,会先调用前一个页面的 onHide 或 onUnload 生命周期,然后再进行新页面的创建和渲染。如果 onHide 和 onUnload 执行过久,可能导致页面切换的延迟。
- onHide/onUnload 中的逻辑应尽量简单,若必须要进行部分复杂逻辑,可以考虑用 setTimeout 延迟进行。
- 减少或避免在 onHide/onUnload 中执行耗时逻辑,如同步接口调用、setData 等
- 提前发起数据请求:
- 在一些对性能要求比较高的场景下,当使用 JSAPI 进行页面跳转时(例如 wx.navigateTo),可以提前为下一个页面做一些准备工作。页面之间可以通过 EventChannel 进行通信。
- 例如,在页面跳转时,可以同时发起下一个页面的数据请求,而不需要等到页面 onLoad 时再进行,从而可以让用户更早的看到页面内容。尤其是在跳转到分包页面时,从发起页面跳转到页面 onLoad 之间可能有较长的时间间隔,可以加以利用。
- 控制预加载下个页面的时机:
- 程序页面加载完成后,会预加载下一个页面。默认情况下,小程序框架会在当前页面 onReady 触发 200ms 后触发预加载。在安卓上,小程序渲染层所有页面的 WebView 共享同一个线程。很多情况下,小程序的初始数据只包括了页面的大致框架,并不是完整的内容。页面主体部分需要依靠 setData 进行更新。因此,预加载下一个页面可能会阻塞当前页面的渲染,造成 setData 和用户交互出现延迟,影响用户看到页面完整内容的时机。
- 为了让用户能够更早看到完整的页面内容,避免预加载流程对页面加载过程的影响,开发者可以配置
handleWebviewPreload
选项,来控制预加载下个页面的时机。
# Taro Next 优化
# 半编译模式
在节点数量增多到一定量级时,Taro3 的渲染性能会大幅下降,出现白屏时间长、交互延时等问题。经排查发现是目前 Taro 的 <template>
模板语法所造成的。CompileMode 适合长列表 Item 这类会被重复渲染多次的组件使用,在长列表场景能提升 30% 以上的首开速度,同时能有效减少节点过多时产生的交互延时问题。CompileMode 可以说是应对复杂页面性能优化的“银弹”。
CompileMode 在编译阶段对开发者的代码进行扫描,将 JSX 和 Vue template 代码提前编译为相应的小程序模板代码。这样可以减少小程序渲染层虚拟 DOM 树节点的数量,从而提高渲染性能。通过使用 CompileMode,可以有效减少小程序的渲染负担,提升应用的性能表现。
要最大限度的发挥半编译模式的优势,就是要把尽量把静态节点,尽可能的写到同一个 jsx 里面去。自我检查的最简单的方式就是看看编译后的模版数量是否足够少,每个模版是否包含了足够多节点。 如果一个 template 只是包含了少数节点,那其实无法带来很大的提升。可以结合半编译预处理,使用组件内的 render 开头的函数,进行模块化拆分
const config = {
mini: {
experimental: {
compileMode: true
}
}
// ...
}
2
3
4
5
6
7
8
# 优化更新性能
由于 Taro 使用小程序的 template 进行渲染,这会引发一个问题:所有的 setData 更新都需要由页面对象调用。当页面结构较为复杂时,更新的性能可能会下降。
- 全局配置项 baseLevel:DOM 结构超过 N 层后,会使用原生自定义组件进行渲染。N 默认是 16 层,可以通过修改配置项 baseLevel 修改 N,例如设置成 8 或者 4。
- CustomWrapper 组件:创建一个原生自定义组件,对后代节点的 setData 将由此自定义组件进行调用,达到局部更新的效果。使用它去包裹遇到更新性能问题的模块,提升更新时的性能。
# 优化长列表性能
Taro 提供了 VirtualList 组件和 VirtualWaterfall 组件。它们的原理是只渲染当前可见区域(Visible Viewport)的视图,非可见区域的视图在用户滚动到可见区域时再进行渲染,以提高长列表滚动的流畅性。
# 跳转预加载
在小程序中,从调用 Taro.navigateTo 等路由跳转 API 后,到小程序页面触发 onLoad 会有一定延时,因此一些网络请求可以提前到发起跳转的前一刻去请求。Taro 3 提供了 Taro.preload API,可以把需要预加载的内容作为参数传入,然后在新页面加载后通过 Taro.getCurrentInstance().preloadData
获取到预加载的内容。
# 避免 setData 数据量较大
对小程序性能的影响较大的主要有两个因素,即 setData 的数据量和单位时间内调用 setData 函数的次数。在 Taro 中,会对 setData 进行批量更新操作,因此通常只需要关注 setData 的数据量大小。下面通过几个例子来说明如何避免数据量过大的问题:
- 删除楼层节点要谨慎处理
如果待删除节点的兄弟节点的 DOM 结构非常复杂,比如一个个楼层组件,删除操作的副作用会导致 setData 的数据量变大,从而影响性能。通过隔离删除操作来进行优化:
<View>
<!-- 轮播 -->
<Slider />
<!-- 商品组 -->
<Goods />
<!-- 模态弹窗 -->
{isShowModal && <Modal />}
</View>
// 优化后
<View>
<!-- 轮播 -->
<Slider />
<!-- 商品组 -->
<Goods />
<!-- 模态弹窗 -->
<View>
{isShowModal && <Modal />}
</View>
</View>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
- 基础组件的属性要保持引用
当基础组件(例如 View、Input 等)的属性值为非基本类型时,每次渲染时,React 会对基础组件的属性进行浅比较。如果发现 markers 的引用不同,就会触发组件属性的更新。这最终导致了 setData 操作的频繁执行和数据量的增加。为了解决这个问题,可以使用状态(state)或闭包等方法来保持对象的引用,从而避免不必要的更新:
<Map
latitude={22.53332}
longitude={113.93041}
markers={[
{
latitude: 22.53332,
longitude: 113.93041,
},
]}
/>
// 优化后
<Map
latitude={22.53332}
longitude={113.93041}
markers={this.state.markers}
/>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
- 基础组件不要挂载额外属性
基础组件(如 View、Input 等)如若设置了非标准的属性,目前这些额外属性会被一并进行 setData,而实际上小程序并不会理会这些属性,所以 setData 的这部分数据是冗余的。
例如 Text 组件的标准属性有 selectable、user-select、space 、decode 四个,如果我们为它设置一个额外属性 something,那么这个额外的属性也是会被 setData。
# 分包优化
在开发小程序时,Taro 编译器依赖 SplitChunksPlugin 插件抽取公共文件,默认主包、分包依赖的 module 都会打包到根目录 vendors.js 文件中(有一个例外,当只有分包里面有且只有一个页面依赖 module 时,会打包到分包中依赖页面源码中),直接影响到小程序主包大小,很容易超出 2M 的限制大小。
那么应该如何对分包公共依赖的进行抽取,减少主包大小呢?
在打包时通过继承 SplitChunksPlugin 进行相关 module 依赖树分析,过滤出主包中无依赖但分包独自依赖的 module 提取成 sub vendor chunks,过滤出主包中无依赖但多个分包共同依赖的 module 为 sub common chunks,利用 SplitChunksPlugin 的 cacheGroup 功能,将相关分包依赖进行文件 split。
# 依赖预加载
通过 webpack5 可以预先把项目的 node_modules 依赖打包为一个模块联邦(Module Federation) remote 应用,再次编译时 Webpack 则无需再对依赖进行编译,从而提升编译速度。
Taro 参考 Vite 使用了 esbuild 收集用户使用到的第三方依赖,并分别进行打包。打包后的模块会作为 Webpack 的 entry,最终打包为模块联邦 Remote 应用,供主应用(Host)消费。
/** config/index.js */
const config = {
compiler: {
type: 'webpack5',
// 仅 webpack5 支持依赖预编译配置
prebundle: {
enable: true,
},
},
}
2
3
4
5
6
7
8
9
10