2020 年是社区团购风起云涌的一年,互联网大厂纷纷抓紧一分一秒跑步进场。“京喜拼拼”(微信搜京喜拼拼)是京东旗下的社区团购平台,依托京东供应链体系,精选低价好货,为社区用户提供次日达等优质服务。
京喜拼拼团队技术选型使用 Taro 以便于实现多端需求,因此 Taro 团队有幸参与到 “京喜拼拼” 小程序的性能体验优化工作。
我们全面体验后和熟悉业务代码后梳理出一系列 Taro3 写法的最佳实践:
对小程序的性能影响较大的有两个因素,分别是 setData
的数据量和单位时间 setData
函数的调用次数。
当遇到性能问题时,在项目中打印 setData
的数据将非常有利于帮助定位问题。开发者可以通过进入 Taro 项目的 dist/taro.js
文件,搜索定位 .setData
的调用位置,然后对数据进行打印。
在 Taro 中,会对 setData
做 batch 捆绑更新操作,因此更多时候只需要考虑 setData 的数据量大小问题。
以下是我们梳理的开发者需要注意的写法问题,有一些问题需要开发者手动调整,一些问题 Taro 可以帮助自动化规避:
假设有一种这样一种结构:
<View>
<!-- 轮播 -->
<Slider />
<!-- 商品组 -->
<Goods />
<!-- 模态弹窗 -->
{isShowModal && <Modal />}
</View>
Taro3 目前对节点的删除处理是有缺陷的。当 isShowModal
由 true
变为 false
时,模态弹窗会从消失。此时 Modal
组件的兄弟节点都会被更新,setData
的数据是 Slider
+ Goods
组件的 DOM 节点信息。
一般情况下,影响不会太大,开发者无须由此产生心智负担。但倘若待删除节点的兄弟节点的 DOM 结构非常复杂,如一个个楼层组件,删除操作的副作用会导致 setData
数据量较大,从而影响性能。
目前我们可以这样优化,隔离删除操作:
<View>
<!-- 轮播 -->
<Slider />
<!-- 商品组 -->
<Goods />
<!-- 模态弹窗 -->
<View>
{isShowModal && <Modal />}
</View>
</View>
我们正在对删除节点的算法进行优化,完全规避这种不必要的 setData,于 v3.1 推出。
假设基础组件(如 View
、Input
等)的属性值为非基本类型时,尽量保持对象的引用。
假设有以下写法:
<Map
latitude={22.53332}
longitude={113.93041}
markers={[{
latitude: 22.53332,
longitude: 113.93041
}]}
/>
每次渲染时,React 会对基础组件的属性做浅对比,这时发现 markers
的引用不同,就会去更新组件属性。最后导致 setData
次数增多、setData
数据量增大。
可以通过 state
、闭包等手段保持对象的引用:
<Map
latitude={22.53332}
longitude={113.93041}
markers={this.state.markers}
/>
基础组件(如 View
、Input
等)如若设置了非标准的属性,目前这些额外属性会被一并进行 setData
,而实际上小程序并不会理会这些属性,所以 setData
的这部分数据是冗余的。
例如 Text
组件的标准属性有 selectable
、user-select
、space
、decode
四个,如果我们为它设置一个额外属性 something
,那么这个额外的属性也是会被 setData。
<Text something='extra' />
Taro v3.1 将会自动过滤这些额外属性,届时这个限制将不再存在。
在小程序开发中,滑动蒙层、弹窗等覆盖式元素时,滑动事件会冒泡到页面,使页面元素也跟着滑动,往往我们的解决办法是设置 catchTouchMove
从而阻止冒泡。
由于 Taro3 事件机制[1]的限制,小程序事件都以 bind
的形式进行绑定。所以和 Taro1、Taro2 不同,调用 e.stopPropagation()
并不能阻止滚动穿透。
给需要禁用滚动的组件写一个样式,类似于:
{
overflow:hidden;
height: 100vh;
}
对于 Map
等极个别组件,使用样式固定宽高也无法阻止滚动,因为这些组件本身就具有滚动的能力。所以第一种办法处理不了冒泡到 Map
组件上的滚动事件。
这时候可以为 View 组件增加 catchMove 属性:
// 这个 View 组件会绑定 catchtouchmove 事件而不是 bindtouchmove
<View catchMove />
在小程序中,从调用 Taro.navigateTo
等跳转类 API,到新页面触发 onLoad
会有一定延时。因此类如网络请求等操作可以提前到调用跳转 API 之前。
熟悉 Taro 的同学可能会想起 Taro1、Taro2 中的 componentWillPreload
钩子。但 Taro3 不再提供这个钩子,开发者可以使用 Taro.preload()
方法实现跳转预加载:
// pages/index.js
Taro.preload(fetchSomething())
Taro.navigateTo({ url: '/pages/detail' })
// pages/detail.js
console.log(getCurrentInstance().preloadData)
开发中我们常常会调用 Taro.getCurrentInstance()
获取小程序的 app、page 对象、路由参数等数据。但频繁调用它可能会导致问题。因此推荐把 Taro.getCurrentInstance()
的结果在组件中保存起来,之后直接使用:
class Index extends React.Component {
inst = Taro.getCurrentInstance()
componentDidMount () {
console.log(this.inst)
}
}
我们在低端机上受到了性能的困扰,尤其是在购物车页面卡顿最为明显。通过分析页面结构和反思 Taro 底层实现,我们主要采取了两项优化措施,提升了低端机型滚动的流畅度,同时将点击延时从 1.5s 降到 300ms。
在 Taro3 中,我们新增了虚拟列表这样一个特殊的组件,帮助很多社区的开发者对超长列表进行优化,相信很多同学对虚拟列表的实现原理、包括下图都已经是很熟悉了,但购物车页却给我们提出了新的需求。
虚拟列表
虚拟列表根据 itemSize
来计算每个节点的位置,如果节点的宽高不确定,在每个节点至少加载完成一次之前,我们很难去判断列表的真实尺寸。这也是为什么在虚拟列表的早期版本中我们并没有支持这样的特性,而是选择固定了每个节点的高度,避免让开发者使用虚拟列表时增加心智负担。
不过这个需求也并非不能完成,简单地调整虚拟列表实现和使用的逻辑,我们就可以轻松实现这个特性。
import VirtualList from `@tarojs/components/virtual-list`
function buildData (offset = 0) {
return Array(100).fill(0).map((_, i) => i + offset);
}
- const Row = React.memo(({ index, style, data }) => {
+ const Row = React.memo(({ id, index, style, data }) => {
return (
- <View className={index % 2 ? 'ListItemOdd' : 'ListItemEven'} style={style}>
+ <View id={id} className={index % 2 ? 'ListItemOdd' : 'ListItemEven'} style={style}>
Row {index}
</View>
);
})
export default class Index extends Component {
state = {
data: buildData(0),
}
render() {
const { data } = this.state
const dataLen = data.length
return (
<VirtualList
height={500} // 列表的高度
width='100%' // 列表的宽度
itemData={data} // 渲染列表的数据
itemCount={dataLen} // 渲染列表的长度
itemSize={100} // 列表单项的高度
+ unlimitedSize={true} // 解开列表节点大小限制
>
{Row} // 列表单项组件,这里只能传入一个组件
</VirtualList>
);
}
}
可以看到,我们在新增了 id
传入来帮助获取每个节点在首次加载之后读取它的真实大小,得益于 Taro 跨平台的优势,这是重构虚拟列表组件中最简单的一步,有了这个基础,我们就可以将节点的实际大小和它们的位置信息关联到一起,让列表自己调整每个节点的位置,并呈现给用户。
而对于开发者,如果想要使用这个模式,只需要传入 unlimitedSize
就可以让虚拟列表解开高度限制。当然这并不意味着在使用虚拟列表时可以不需要传入节点大小, itemSize
在这个模式下将作为初始值辅助列表中每个节点位置信息的计算。
如果
itemSize
和实际大小差别过大,在超长列表中会有较明显的问题,大家需要小心使用哦~
列表的底部区域可以帮助我们便捷地完成信息的展示,比如上拉加载等,对于虚拟列表也是如此。
return (
<VirtualList
height={500} // 列表的高度
width='100%' // 列表的宽度
itemData={data} // 渲染列表的数据
itemCount={dataLen} // 渲染列表的长度
itemSize={100} // 列表单项的高度
+ renderBottom={<View>我就是底线</View>}
>
{Row} // 列表单项组件,这里只能传入一个组件
</VirtualList>
);
当然也有同学会注意到,在 虚拟列表 文档中是通过 scrollOffset > ((dataLen - 5) * itemSize + 100)
这样的方法来判断是否触底,这是因为我们并没有在 VirtualList
中返回滚动的详细信息,这次我们也返回相关的数据,帮助大家更好地使用虚拟列表。
interface VirtualListEvent<T> {
/** 滚动方向,可能值为 forward 往前, backward 往后。*/
scrollDirection: 'forward' | 'backward'
/** 滚动距离 */
scrollOffset: number
/** 当滚动是由 scrollTo() 或 scrollToItem() 调用时返回 true,否则返回 false */
scrollUpdateWasRequested: boolean
/** 当前只有 React 支持 */
+ detail?: {
+ scrollLeft: number
+ scrollTop: number
+ scrollHeight: number
+ scrollWidth: number
+ clientWidth: number
+ clientHeight: number
+ }
}
在虚拟列表中,无论是使用那种布局方式,都会造成页面的回流,所以不论选择哪一种对于浏览器内核渲染页面而言并没有很大的区别。但是如果使用 relative
,对于列表来说,需要调整的节点样式要少得多。所以我们在新的虚拟列表中也支持了这样的定位模式,供开发者自由选择。对于低端机型来说,在我们完成整体的渲染性能优化之前,relative
模式已经能够让虚拟列表在低端机型上拥有不错的体验。
Taro3 使用小程序的 template
进行渲染,一般情况下并不会使用原生自定义组件。这会导致一个问题,所有的 setData
更新都是由页面对象调用,如果我们的页面结构比较复杂,更新的性能就会下降。
层级过深时 setData
的数据结构:
page.setData({
"root.cn.[0].cn.[0].cn.[0].cn.[0].markers": []
})
针对这个问题,主要的思路是借用小程序的原生自定义组件,以达到局部更新的效果,从而提升更新性能。
期望的 setData
数据结构:
component.setData({
"cn.[0].cn.[0].markers": []
})
开发者有两种办法可以实现这个优化:
对于不支持模板递归的小程序(微信、QQ、京东小程序),在 DOM 层级达到一定数量后,Taro 会使用原生自定义组件协助递归。
简单理解就是 DOM 结构超过 N 层后,会使用原生自定义组件进行渲染。N 默认是 16 层,可以通过修改配置项 baseLevel[2] 修改 N。
把 baseLevel
设置为 8
甚至 4
层,能非常有效地提升更新时的性能。但是设置是全局性的,会带来若干问题:
flex
布局在跨原生自定义组件时会失效,这是影响最大的一个问题。SelectorQuery.select
方法的跨自定义组件的后代选择器[3]写法需要增加 >>>
:.the-ancestor >>> .the-descendant
为了解决全局配置不灵活的问题,我们增加了一个基础组件 CustomWrapper
。它的作用是创建一个原生自定义组件,对后代节点的 setData
将由此自定义组件进行调用,达到局部更新的效果。
开发者可以使用它去包裹遇到更新性能问题的模块,提升更新时的性能。因为 CustomWrapper
组件需要手动使用,开发者能够清楚“这层使用了自定义组件,需要避免自定义组件的两个问题”。
<CustomWrapper>
<GoodsList>
<Item />
<Item />
// ...
</GoodsList>
</CustomWrapper>
把开发者工具的体验评分给拉满,这里我们遇到了一个问题,开发者工具会识别所有绑定了点击事件的组件,如果组件的面积过小则提示点击区域过小,会影响“体验项”的评分。但是 Taro3 默认会为组件绑定上所有属性和事件[4]。这样会“误伤”一些组件,它们虽然面积很小,实际上并没有点击功能,但因为 Taro3 默认绑定的事件,被开发者工具认为点击区域过小,从而拉低体验评分。
Text
组件的模板,默认绑定了所有属性和事件:
<template name="tmpl_0_text">
<text
selectable="{{...}}"
space="{{...}}"
decode="{{...}}"
user-select="{{...}}"
style="{{...}}"
class="{{...}}"
id="{{...}}"
bindtap="..."
>
...
</text>
</template>
因此我们为 View
、Text
、Image
组件各设立了一个 static 模板,当检测到组件没有绑定事件时,则使用 static
模板,避免被“误伤”。
另一方面,这一举动也能减少小程序 DOM 绑定的事件,对性能稍有提升,而且减少了属性让开发者工具的 xml
面板在调试时更加清晰。但这一方案也存在瑕疵,会导致编译后的 base.wxml
体积略微增大,和性能权衡来看,这仍然是值得的。
Text
组件的 static
模板,没有绑定事件:
<template name="tmpl_0_static-text">
<text
selectable="{{...}}"
space="{{...}}"
decode="{{...}}"
user-select="{{...}}"
style="{{...}}"
class="{{...}}"
id="{{...}}"
>
...
</text>
</template>
适配京东小程序的过程比较顺利,需要改动的地方不多。
在此过程中 Taro3 最主要的升级是增强了对 HTML 文本的解析能力,增加了对 <style>
标签的支持。自此完全同步了 wxparse
的能力,开发者使用 React 的 dangerouslySetInnerHTML
或 Vue 的 v-html
即可很好地解析 HTML 文本,不需要单独引入第三方自定义组件去进行解析,统一了多端标准。
过去我们对在 Taro 项目中混合使用原生的支持度较高。相反地,对在原生项目中混合使用 Taro 却没有太重视。但是市面上有着存量的原生开发小程序,他们接入 Taro 开发的改造成本往往非常大,最后只得放弃混合开发的想法。
经过本次项目,也驱使了我们更加关注这部分需求,在 Taro v3.0.25 后推出了一套完整的原生项目混合使用 Taro 的方案[5]。
方案主要支持了三种场景:
希望以上方案能满足希望逐步接入 Taro 的开发同学。更多意见也欢迎在 Github[6] 上给我们留言。
Taro 团队这次参与到 “京喜拼拼” 小程序的性能体验优化工作,让我们了解到 Taro3 的性能瓶颈所在,也体会到复杂业务的多样性。
2021 上半年我们将更加聚焦于提升框架开发体验和运行性能、与原生小程序的混合,还有生态建设的工作上。
最后祝大家春节快乐~新的一年牛气冲天!
[1]
Taro3 事件机制: https://taro-docs.jd.com/taro/docs/react#%E9%98%BB%E6%AD%A2%E6%BB%9A%E5%8A%A8%E7%A9%BF%E9%80%8F
[2]
baseLevel: https://taro-docs.jd.com/taro/docs/next/config-detail#minibaselevel
[3]
跨自定义组件的后代选择器: https://developers.weixin.qq.com/miniprogram/dev/api/wxml/SelectorQuery.select.html
[4]
Taro3 默认会为组件绑定上所有属性和事件: https://taro-docs.jd.com/taro/docs/next/platform-plugin#%E5%B1%9E%E6%80%A7%E7%B2%BE%E7%AE%80
[5]
原生项目混合使用 Taro 的方案: https://taro-docs.jd.com/taro/docs/next/taro-in-miniapp
[6]
Github: https://github.com/NervJS/taro/issues