京东 LBS 门详目前已经支持了仓网、药急送、天选、小时达 POP 多种业务,并且具备了多端的能力,一套代码可以在京东 app、健康 app、微信小程序中运行,一定程度上研发效率的提升能够更加快速的支持业务迭代。
随着业务需求猛增、各种 AB 场景线上测试,交互复杂度提升,所以对门详的整体交互体验,小程序加载速度、列表的滚动性能以及业务数据层面都有更高的要求,因此作为前端研发团队,我们也迎来了一些新的挑战。
基于以上的业务与用户体验的要求,门详急需解决的问题如下:
1.2.1 页面加载速度缓慢:在中高端机型,实测从点击到店头渲染数据在 1.2s 左右【iphone12,小米 11 青春版】;
1.2.2 门详列表滑动性能差:
1)和美团外卖、京东到家 App 原生相比,我们货架层面不能支持手势滑动;
2)列表滑动多屏卡死,商品列表加载不流畅;
① 曝光埋点监听后,DOM 元素的查找方式耗时,阻塞 UI 线程,导致滑动丢帧
② 和美团外卖进行比较,我们的列表没有联动加载的功能,给用户不好的体验
③ 列表中的分类及商卡数据联动逻辑复杂,不易维护
④ 列表内存占用高,不释放
⑤ 二级列表偶现闪动问题
1.2.3 门详小购物车展开卡顿:加车超过 20 个品,在中高端机型,出现滑动的明显卡顿问题,且打开购物车缓慢,渲染内容缓慢,在 iphone12 机型上首次渲染需要 2s 左右;加车动画卡顿;
1.2.4 门详埋点数据不准确:曝光埋点的上报时机和数据产品要求口径不一致
为更好,更快速的支撑业务诉求,上述的问题解决也迫在眉睫。经过四期的性能优化迭代,我们取得了一定的优化成果。
该部分首先重点介绍一下总结果概览,然后详细阐述一下页面启动和交互结果。
当我们识别到上述的四类问题之后,进行了整体的问题的分析,识别出的需要高优优化的环节分别是:启动耗时、交互体验、能耗优化以及埋点;整体优化完成上线后,我们验证的结果如下:
① 门详加载速度整体提升了约50%;
同一网络环境下,同款机型优化前后的数据对比如下:
② 小程序健康度评分在90左右;
③ 整体的小程序崩失率减少了10%左右;
④ 埋点可用率在95%以上;
① 同 5G 环境,同门店,机型是 iphone14pro,优化前后的效果对比:左侧是优化前;右侧是优化后
② 同 5G 环境,同门店,机型是 iphone14pro,优化后和原生做个对比:左侧是京东到家 app 原生;右侧是优化后的京东门详小程序
左侧是京东到家原生 app,右侧是门详的效果
从上述结果可以看出,在中高端机型上,门详的打开速度达到了秒开的效果。接下来详细介绍门详性能优化的分析历程以及解决方案。
下图是小程序的启动过程:
影响启动的四个关键因素:
1.引擎预热耗时:引擎初始化与小程序框架注入耗时;
2.小程序信息接口耗时:获取小程序权限、基本配置信息、版本等信息,耗时相对固定;
3.小程序加载耗时:跟业务包大小而变化,包含读取业务代码注入到小程序执行环境以及原生 UI 初始化耗时;
4.业务初始化耗时:根据业务初始化逻辑与首屏网络接口耗时而变化;
问题:启动小程序会同步获取小程序信息接口,小程序打开过程存在耗时。
目标:减少小程序信息接口请求耗时。
行动:
1.通过将小程序信息同步接口信息提前缓存;
2.打开小程序优先使用缓存信息,将同步接口异步化处理;
结果:接口改为并行后,平均数据减少 158ms 左右。
问题:随着业务增长,小程序二进制包达到 6MB+(包解压之后),JS 注入执行已经成为耗时主要原因。
目标:降低启动时业务注入时的代码量,从而降低注入耗时。
行动:
1.编译改造:将渲染层、逻辑层注入内容只保留业务公共框架的部分,页面的 JS 代码剥离到对应页面中;
2.启动改造:启动时只注入公共框架与当前页面相关的代码,减少注入额外内容;
3.改变包大小【具体操作可见 3.3 部分门详包瘦身】
结果:优化前:6MB+ 数据,优化后:注入 2.7MB 数据, 使用小米 11 手机测试启动性能,未使用按需加载功能的启动时间为:1200ms 左右,使用按需加载功能的启动时间是:800ms 左右,性能显著提升。
问题:门详包的大小会直接影响小程序的加载时长
目标:降低包大小,从 Release 代码压缩包 1.2M 降低到 0.6M 以下
行动:
1.分析包体积的构成,检查不合理的模块,移除冗余无用的模块;
通过打包工具,查看包中内容,分析得到包体积较大的模块是 vendors 和 common 模块。
vendors
: node_modules 除 Taro 外的公共依赖;
common
: 项目中业务代码公共逻辑;
taro
: node_modules 中 Taro 相关依赖;
runtime
: Webpack 运行时入口;
2.逐个模块优化
① 下线掉无用业务模块;
② 优化 vendors 模块,明显可以看到一些不合理的包被编译进来了,比如 Axios,小程序的请求一般用 jd.request,理论上用不到 Axios,此时我们可以打开 package-lock.json 文件看下,是哪个包依赖了这个模块。例如:@plato这个包应该是在微信域的地址组件,在京东小程序里使用 APP 原生组件,所以可以采用端区分的方式 process.env.TARO_ENV 进行剔除。
重复上述步骤, 检查 pako lego jsencrypt inversify ,将无用的依赖剔除,优化后的结果,由783KB下降到318KB。
③ 优化 common 模块,包括:
common.jxss:经检查,我们的样式文件中有不少 icon 使用的是 base64,会增加包体积,此处我们可以采用雪碧图方案或者网络图进行替换。
common.js:对存量代码进行优化,减少重复代码。此处我们可以借助 eos 进行代码扫描,检查到重复代码块进行代码合并。
3.制定包瘦身规范,长期控制包大小增量;
① 预防为主
在开发初期就要注重代码和资源文件的优化,避免不必要的代码和文件冗余,禁止重复造轮子;
资源引入规范:使用到三方功能,需要将必要的功能拆解后独立引用,媒体资源引入,需以 CDN 方式,不允许直接放入项目本地;
多端区分规范:合理使用 process.env.TARO_ENV 进行代码剔除,不要引入无用的模块;
优化构建工具:使用或开发构建工具来优化最终包的体积,如 webpack 、terser 等工具;
② 防治结合:
定期对小程序进行体积检查和优化,移除无用代码和资源,更新第三方库到更小体积的版本,以及优化图片和视频资源;
定期审查和优化项目依赖,移除不必要或重复的库和框架;
在代码 review 审查过程中检查新增代码对包体积的影响,避免重复的轮子入库;
③ 综合治理:
建立反馈机制,当包体积超标时,能够及时通知相关人员;
借助 eos 扫描,关注代码重复率,重复率高则需要及时制定整改方案;
结果:经过上述过程后,包大小从 1.2M 降低到 0.49M。
问题:由于我们是基于 LBS 的门详业务,首页接口请求完成后,才能进行页面的整体渲染,所以首页主接口请求的耗时时长也成为我们 loading 时间长的原因之一;另外接口和地址获取串行的逻辑也加长了整体的数据链路;
目标:业务加载时长在中高端机型降低到 500ms 以内
行动:
① 将主接口的请求进行提前 -> 主接口预热
一般来说小程序首屏的可交互时间都会受代码包注入执行时间影响,在代码执行完毕后才能开始业务网络请求,请求数据返回后才能开始真正的内容渲染,为了解决这一痛点,京东小程序 Clips 是一种通过三线程架构实现的特色能力,可以在小程序启动过程中注入一个轻量化代码片段,提前做初始化数据,网络请求等非 UI 功能,当小程序主环境准备好后,可直接将数据送给逻辑层,实现小程序业务预热能力。
通过 cilp 文件发起预热请求的功能,以及在小程序实例化后对该请求结果的监听;达到首页的接口请求在我们小程序解析的同时,进行了数据的请求,实例化小程序后,在首页实例完成,准备请求首页数据时,如果预热接口已经返回,则直接用预热的结果,从而达到减少主接口请求的耗时。
② 将地址逻辑前置 -> 实例小程序的同时,获取地址信息
结果:串行逻辑修改成并行之后,业务从开始 loading 到出现渲染内容,整体时长在 iphone12 上可以达到 350ms 左右。
③ 全局渲染优化
主要原因:
① 跳转协议、地址信息、主接口数据都是以 Recoil 状态值的方式存在,且颗粒度细碎,大面积多次造成全屏重绘。
② 页面渲染时,楼层多,细粒度状态触发频繁,导致最终整体 DOM 呈现时间晚,白屏等待时间久。
分析解决:
① 状态归类合并,区别状态和变量
② 分批渲染楼层,局部渲染
实现方式:
采用全局 Context 状态管理的方式,重新改造了页面数据监听方案;从而达到减少多次渲染重绘的效果;
最终实现效果如下图:
上图中左侧是优化前,使用 Recoil 状态绑定组件,改变即变更的渲染方式。
上图中右侧是优化后,使用 React Context 归类合并后的数据源,通过改变 Context 值,一次性触发数据更新,并分批渲染。
新的 Context 将 Recoil 的状态改成值的方式,同时通过观察者模式给予组件订阅变量更新的能力,尽管 Recoil 的状态绑定更方便使用,但是其缺乏变量值的管理,因此可以采用 Context 弥补这一缺失的能力。
Recoil 用法的建议:
1、使用 Recoil 状态的地方,一定是 JSX 中用到的值(状态变更则触发组件刷新),不要在逻辑中使用的变量也使用状态值;
2、Recoil 的颗粒度可以根据独立的业务模块设定,减少频繁无效的设置值,对象变更提前做好数据比对;
3、组件只需要改变状态,不需要依赖状态时:推荐写法:const setName = useSetRecoilState(nameAtom);
以下是美团外卖、京东到家 app 门详和门详小程序的滑动效果对比分析:
通过分析得出以下 3 个关键交互体验问题:
① 手势体验问题;
② 商品列表列表留白,分类切换不流畅、二级列表偶现闪动的问题;
③ 内存占用高;
前期我们做了支持手势的调研:
方案对比 | movable-view 方案 | jds 动态设置样式方案 | scroll-view 嵌套 |
---|---|---|---|
调研结果 | 拖动效果卡 | 部分功能暂时不支持,无法获取自定义组件实例 | 在内层容器到顶部,内层切换滚动属性时,会顿一下 |
根据调研结果综合评估:我们采用了scroll-view 嵌套的方案,ScrollView 嵌套场景下,我们要进行手势的交互,以及跟手的处理,所以我们面临了一些挑战:
① 现存代码使用了大量的 touch 事件拦截,手势重构成本高;
② 页面存在多层吸顶和吸底动画交互效果,需要有合理的替代方案;
原有支持自动吸顶,我们通过动态设置外层 scrollTop 替换原动画方案
③ 多层 scroll-view 嵌套,带来了多处手势冲突问题;
在 Taro3 中小程序逻辑层实现了一套事件系统,包括事件触发和事件冒泡。在小程序模板中绑定的事件都是以bind
的形式。
一般情况下,这套在逻辑层实现的小程序事件系统是可以正常工作的,事件回调能正确触发、冒泡、停止冒泡。小程序原生模板中绑定的catchtouchmove
事件除了可以阻止回调函数冒泡触发外,还能阻止视图的滚动穿透,但是 Taro 的事件系统是做不到的。
Taro 为我们提供的解决方案:可以为View
组件增加catchMove属性:
// 这个 View 组件会绑定 catchtouchmove 事件而不是 bindtouchmove
<View catchMove={catchMove}></View>
基于以上情况,我们采用动态控制 catchmove 的方式,来实现是否向上进行事件冒泡通知外层 ScrollView。
根据用户手势上推和下滑,控制内层 ScrollView 的 scrollY 是否可以滚动。
我们在 Taro 组件中,动态设置 catchMove 来控制是否向上冒泡,但是在一些场景发现一个问题,已经完成渲染的 dom 树在切换 catchMove 时,DOM 元素在视图内不可见。后经过与 Taro 团队沟通,在设置 catchMove 时,Taro 会使用不同的静态模版 ,为了解决该问题我们选择将 catchMove 绑定在原生小程序组件上,采用原生和 Taro 混用解决这个问题。
封装小程序原生组件:
<view id="container" catchtouchmove="{{catchMove}}">
<slot></slot>
</view>
原生组件嵌套Taro组件:
<native-container catchMove={catchMove}>
嵌套Taro组件
</native-container>
经过我们不断的调优,各种机型的测试,最后优化前视频和优化后对比如下:
中低端机型测试:在门详商品列表中浏览 xxx 个品后,页面出现卡屏卡死现象;
所以针对上述问题,我们进行了列表的流畅度优化:
曝光元素查找方式优化
实践发现曝光 createIntersectionObserver 回调获取元素的方式严重影响页面的滑动流畅度。以下给出两种写法:
const categroyNodeObserver = createIntersectionObserver(Taro.getCurrentInstance().page, { observeAll: true })
categroyNodeObserver.observe('#app >>> .className', result => {
// 1、通过class获取列表再遍历查找对应元素
const vnodes = document.getElementsByClassName('className') as unknown as HTMLCollectionOf<Element>[];
const target = vnodes.find(vnode => vnode.uid == result.id)
// 2、通过id直接获取元素
const target = document.getElementById(result.id);
})
通过对比:第 2 种获取元素的耗时在个位数毫秒级,第一种耗时高达上百毫秒。
数据结构优化 - 树结构分类扁平化处理
商品货架分类是一个嵌套树结构,最多有三级,如下图扁平化前黄色区域所示。
在代码逻辑中,多处需要获取末级分类进行逻辑处理,比如:① 点击 1 级分类,需要找到其下的第一个 3 级分类进行接口请求;② 点击 2 级分类,需要找到其下的第一个 3 级分类进行接口请求;以上两种情况还有例外:如果没有 3 级分类,就会变成使用 2 级分类进行接口请求。如下图所示:
未扁平化的数据结构向后查找下一个分类最大时间复杂度 O(n);数据结构扁平化后,将嵌套结构改成单层链式结构,如上图扁平化后绿色区域,后续可以直接在新的数据结构上处理,向后查找下一个分类最大时间复杂度 O(3)。
扁平化递归算法:
普通递归,随着分类数据的增加,递归调用栈很多变量开辟了内存空间未被释放,所以要用到尾递归。
尾递归是一种特殊的递归,它的特点是在函数的最后一步调用自身,而不是在调用后还有其他操作。尾递归可以有效地避免栈溢出的风险,因为它不需要保存每次调用的上下文,只需要保留一个栈帧即可。尾递归也可以提高递归的性能,因为它减少了函数调用的开销。
现有设计方案中,多分类商品无联动加载逻辑,滑动时有顿挫感,不流畅。
因此,我们在分类扁平化的基础上,将多分类商品进行连续请求处理,占满屏幕,如下图右侧效果。
列表优化过程:
① 新的加载流程如下:
商品列表滑动过程中,分类的联动选中,我们的做法是:在上述流程图中的组装数据环节,在分类末尾位置增加标记位,一条 1px 的线,然后利用 createIntersectionObserver,监听该元素上推消失和下拉露出的时机,联动选中对应的 1,2,3 级分类。
② 列表分页加载方式选择
触底加载时机试验对比
监听 Loading 的露出来作为下一页的请求回调时机,如下图:
③ 列表分页渲染层级优化
现状:采用递归的方式 + CustomWrapper 进行渲染列表。有效解决每次 setData 都是全数据耗时的情况。同时引入一些弊端,其中主要的问题是,头部排序条的 position: sticky,在多分类联动商卡页数较多的时候,层级嵌套多层后,sticky 的元素会被推出可视区域外。
为了实现 setData 时,Taro 可以增量给小程序传递数据,我们回到 React 底层 diff 原理,考虑 diff 可以通过 key 值优化,于是在 Render Row 每行元素上增加位置 key 值,结果:Taro 即可进行自动 diff,更新单页数据,这样 DOM 结构由原先的嵌套结构变成一层。解决了上述 sticky 的问题,也提升渲染性能。
加载 10 页,每页 setData 大约消耗时间:
通过 Chrome://inspect 性能检测工具,做了内存快照比对发现主要原因有两点:
① Taro 侧: 经过内存快照对比工具,发现 Taro 侧有两个问题,一是清空列表前后 Taro 生成的 react fiber 树(th 对象)一直持有缓存数据,二是每次 setData 的数据没有释放掉,导致内存持续增长。
② 小程序引擎侧:在自定义组件更新时,diff 前后 virtualTree 计算出需要更新的节点,对于 jd:if 和 jd:for 标记的节点没有识别并清理掉。
经过和引擎侧和 Taro 侧多次沟通联调,最终解决上述两个问题。Taro 侧问题是升级到 3.5.1 解决,引擎侧是商城 App 12.2.4 版本修复。
随着业务增多,门详二级页面的需求量也在不断迭代,需要支撑业务进行更多品,更多功能的展露,例如加价购、搭配购买等等;所以我们针对各种二级页面和弹窗卡片也进行了性能上的体验优化;从中主要解决了以下几类问题:
① 列表页面的整体列表抖动问题的修复;
② 二级页面的渲染时序优化,组件插入方式变更;
③ 小车卡片上大量数据加载的扁平化处理,分页加载功能的支持;
④ 弹窗功能的整体统一;
二级页面的详细优化后续我们会继续发文。
实测:门详商品列表滑动 50 页左右,手机明显发烫,耗电严重,所以高能耗也是我们需要高优解决的问题。
具体分析步骤如下:
① 手机静止不动,手机发热、发烫;
② 列表不断滚动,手机发热、发烫;
① 布局与渲染:说明有 dom 内容一直在变。如倒计时、banner 轮播.
② 媒体与动画:说明有 css 在一直执行动画。如呼吸动画、loading.
③ javascript:如倒计时事件.
基于以上,我们针对站内的倒计时,页面的 Banner 轮播 做了隐藏停止的处理;动画效果的 css 样式由 gif 图进行了替换;最后达成效果如下:
能耗的结果:
静止情况:能耗由高降为低。
在滑动情况下:我们做了滚动防抖和图片大小优化,滑动瞬时能耗降低了 20%,在滑动 70 页后开始发热,在 dom 节点数量以及节点深度上还有优化的空间,后续我们会继续分享。
我们总结了能耗优化的一些方向,并放到了我们的开发规范中:
影响能耗的类型 | 要求 |
---|---|
css 动画类 animation xxx 1s infinite; | 用完移除,不要在后台一直执行动画 |
setInterval 倒计时 | 结合 requestanimation 优化 |
swiper 组件类 | 视口外停止轮播 加长轮播间隔时间 |
image 组件类 | 根据视口图片大小加载适当尺寸的图片 |
scroll-view 滚动类 | 避免节点过多 避免节点层级过深 |
onScroll 回调 | 回调事件防抖 避免复杂逻辑 |
元素监听类 | 监听及时移除 监听回调事件避免做复杂逻辑 |
我们在 9 月份最后一个版本上线了全站的埋点方案的整改。其中包含底层埋点(PV CLICK EP)埋点参数的逻辑优化,以及所有业务上报埋点的机制与时机处理。整改之前,埋点存在部分问题:
① 曝光埋点的上报时机有误,不可用;
② PV 埋点和点击埋点缺少规范,业务添加埋点不规范,成本高;
③ 曝光请求未聚合,导致频繁发起埋点请求;
整改之后,我们输出了门详的整体埋点开发规范;重新整理了所有曝光埋点的上报时机与整体方案;目前门详埋点可用率达到 95% 以上;为业务数据提供了更加准确的方向。
以上是我们近期优化的阶段性成果,不仅得到了业务和产品同学的认可,而且线上用户反馈门详加载慢、卡顿的问题占比降低了约 15%。
用户体验提升是需要长期坚持,技术同学需要具备一定的工匠精神,持续探索,我们在规划中的事情还有:
① 能耗层面的继续探索与优化;
虚拟列表的支持:目前门店列表支持多分类的加载联动,且在同一个分类下进行了组件复用与回收;下一步,我们将继续进行跨分类的组件复用与回收,将门详列表的回收渲染机制形成统一性的解决方案。
嵌套层级过深导致的性能消耗:进一步减少门详组件嵌套的问题,从内到外进行整体组件嵌套层级的优化,减缓手机发热发烫的问题。
② 优化方案同步微信域;
③ 共建能力的支持;
提供门详组件的共建能力,将门详组件与业务进行解耦。
④ 小程序对 H5 的转化;
⑤ 近原生体验的提升:视频能力的支持;
⑥ 小程序侧手势的支持;
期待我们在高效支撑业务迭代的同时,可以给用户带来更极致的体验!!
要特别感谢以下团队和部门:
京喜与新业务研发部 - 京东小程序研发团队,感谢你们在性能优化方面给予的大力支持。
Taro 团队,你们的支持对我们至关重要。
LBS 业务产品组 - 小时购前台产品团队,你们的努力让我们的产品更加完善。
同城研发部 - 质量提升团队,你们的专业测试保证了我们服务的高质量。
同城研发部 - 到家平台研发组,感谢你们在整理工作中的协助。
你们的帮助对我们的成功至关重要,再次表示衷心的感谢!
作者:京东零售 姜微、邓树海、章文顺、王冰洋
来源:京东云开发者社区 转载请注明来源