钉钉红包性能优化之路
一、业务背景
请客红包、小礼物作为饿了么自研的业务产品,在钉钉的一方化入口中常驻,作为高UV、PV的toB产品,面对不同设备环境的用户,经常会偶尔得到一些用户反馈,如【页面白屏太久了】、【卡住了】等等,本文将以产品环境为出发点(App中的H5),以前端基础、加载链路、端能力三个方向进行性能优化。
基于现阶段业务架构,在应用层进行通用的性能优化action拆解。
整体优化后接近秒开,效果如下:
二、前端基础优化
2.1. 构建产物瘦身
作为前端基础优化的出水口,可通过webpack analyzer插件分析dist产物的具体分布,主要action如下:
- 按需加载antd,减少79.28kb
- 大型通用库接入cdn,基于externals排出构建包,减少65.08kb
- debug工具生产环境不引入:vconsole,减少一次生产的http js请求
- polyfill拆分,减少28.45kb
- 钉钉、饿了么域接口返回图片裁剪,平均单张图片减少80%大小,请求时间减少80%
- 压缩器从esbuild切换至terser(牺牲时间、提升压缩率),减少100.2kb
- 移除无用的包:deepcopy、md5-js,减少3.58kb
- 按需引入lodash、dingtalk-jsapi、crypto-js,减少49.18kb
关键优化代码:
// case1:按需引入大npm包 import setTitle from '@ali/dingtalk-jsapi/api/biz/navigation/setTitle'; import openLink from '@ali/dingtalk-jsapi/api/biz/util/openLink'; import setScreenKeepOn from '@ali/dingtalk-jsapi/api/biz/util/setScreenKeepOn'; // case2:externals拆包,转cdn引入 externals: { react: 'React', 'react-dom': 'ReactDOM', } // case3:图片裁剪,降低质量、大小降低图片请求耗时 import getActualSize from './getActualSize'; import getImageType from './getImageType'; const getActualEleImageUrl = (url: string, size: number) => { if (typeof url === 'string' && url.includes('cube.elemecdn.com')) { const imageType = getImageType(url); const relSize = getActualSize(size); const end = `?x-oss-process=image/resize,m_mfit,w_${relSize},h_${relSize}/format,${imageType}/quality,q_90`; return `${url}${end}`; } return url; }; // case4:按需加载antd-mobile extraBabelPlugins: [ [ 'import', { libraryName: 'antd-mobile', libraryDirectory: 'es/components', }, ], ],
结果:
- 性能优化构建包gzip压缩后大小减少305.59KB,优化后大小474.74KB,下降39.1%;
- 性能优化首屏加载冷启动FP减少400ms,热启动FP减少1s;
2.2. 预加载&预解析
将应用中所用到的所有请求资源的能力统一前置配置在html head,减少所有资源类请求的耗时。
links: [ { rel: 'dns-prefetch', href: 'https://g.alicdn.com/', }, { rel: 'preconnect', href: 'https://g.alicdn.com/', }, { rel: 'dns-prefetch', href: 'https://gw.alicdn.com/', }, { rel: 'preconnect', href: 'https://gw.alicdn.com/', }, { rel: 'dns-prefetch', href: 'https://img.alicdn.com/', }, { rel: 'preconnect', href: 'https://img.alicdn.com/', }, { rel: 'dns-prefetch', href: 'https://assets.elemecdn.com/', }, { rel: 'preconnect', href: 'https://assets.elemecdn.com/', }, { rel: 'dns-prefetch', href: 'https://static-legacy.dingtalk.com/', }, { rel: 'preconnect', href: 'https://static-legacy.dingtalk.com/', }, { rel: 'dns-prefetch', href: 'https://cube.elemecdn.com/', }, { rel: 'preconnect', href: 'https://cube.elemecdn.com/', }, { rel: 'preload', as: 'script', href: 'https://g.alicdn.com/??/code/lib/react/18.2.0/umd/react.production.min.js,/code/lib/react-dom/18.2.0/umd/react-dom.production.min.js', }, ],
2.3 分包
到的所有请求在整个项目中以页面组件、公共组件、大npm包三个方向进行分包拆解,大体分包策略是尽可能减少SPA首次访问路由的chunk体积,策略如下:
- node_modules里面大于160kb的模块拆分成单独的chunk;
- 公共组件至少被引入3次拆分成单独的chunk;
分包关键代码:
optimization: { moduleIds: 'deterministic', // 确保模块id稳定 chunkIds: 'named', // 确保chunk id稳定 minimizer: [ new TerserJSPlugin({ parallel: true, // 开启多进程压缩 extractComments: false, }), new CssMinimizerPlugin({ minimizerOptions: { parallel: true, // 开启多进程压缩 preset: [ 'default', { discardComments: { removeAll: true }, }, ], }, }), ], splitChunks: { chunks: 'all', maxAsyncRequests: 5, // 同时最大请求数 cacheGroups: { // 第三方依赖 vendors: { test: /[\/]node_modules[\/]/, name(module) { const packageName = module.context.match(/[\/]node_modules[\/](.*?)([\/]|$)/)[1]; return `vendor-${packageName.replace('@', '')}`; }, priority: 20, }, // 公共组件 commons: { test: /[\/]src[\/]components[\/]/, name: 'commons', minChunks: 3, // 共享模块最少被引用次数 priority: 15, reuseExistingChunk: true, }, lib: { // 把node_modules里面大于160kb的模块拆分成单独的chunk test(module) { return ( module.size() > 160 * 1024 && /node_modules[/\]/.test(module.nameForCondition() || '') ); }, // 把剩余的包打成一个chunk name(module) { const packageNameArr = module.context.match(/[\/]node_modules[\/](.*?)([\/]|$)/); const packageName = packageNameArr ? packageNameArr[1] : ''; return `chunk-lib.${packageName.replace('@', '')}`; }, priority: 15, minChunks: 1, reuseExistingChunk: true, }, // 默认配置 default: { minChunks: 2, priority: 10, reuseExistingChunk: true, name(module, chunks) { const allChunksNames = chunks.map((item) => item.name).join('~'); return `common-${allChunksNames}`; }, }, }, }, },
三、加载链路优化
3.1 DOM load前置优化
在SPA所有JS文件解析完成(整个页面呈现),在前置可增加loading态替代白屏减少用户的等待焦虑,具体的思路是在html response -> js chunk全部解析完成中间,增加一个loading状态,提升FP、FCP性能指标,具体行动是编写了一个webpack html构建完的插件,在构建结果中的html手动注入loading组件。
插件实现比较简单:
import { IApi } from 'umi'; export default (api: IApi) => { // 用于在html ready到SPA应用js ready之间增加钉钉标准loading api.modifyHTML(($) => { $('head').prepend(` body, html { width: 100%; height: 100%; margin: 0; padding: 0; border: 0; box-sizing: border-box; } #html-ding-loading-container { width: 100vw; height: 100vh; background: rgba(0, 0, 0, 0.2); opacity: 1; } #ding-loading { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); animation: lightningAnimate 1s steps(1, start) infinite; width: 3.6rem; height: 3.6rem; background-repeat: no-repeat; background-position: 0rem 0rem; background-size: 100%; background-image: url('https://img.alicdn.com/imgextra/i4/O1CN014kqXkX22y9iy5WhI6_!!6000000007188-2-tps-120-3720.png'); } @keyframes lightningAnimate { 0% { background-position: 0rem 0rem; } 3.3% { background-position: 0rem calc(-1 * 3.6rem); } 6.6% { background-position: 0rem calc(-2 * 3.6rem); } 10% { background-position: 0rem calc(-3 * 3.6rem); } 13.3% { background-position: 0rem calc(-4 * 3.6rem); } 16.6% { background-position: 0rem calc(-5 * 3.6rem); } 20% { background-position: 0rem calc(-6 * 3.6rem); } 23.3% { background-position: 0rem calc(-7 * 3.6rem); } 26.6% { background-position: 0rem calc(-8 * 3.6rem); } 30% { background-position: 0rem calc(-9 * 3.6rem); } 33.3% { background-position: 0rem calc(-10 * 3.6rem); } 36.6% { background-position: 0rem calc(-11 * 3.6rem); } 40% { background-position: 0rem calc(-12 * 3.6rem); } 43.3% { background-position: 0rem calc(-13 * 3.6rem); } 46.6% { background-position: 0rem calc(-14 * 3.6rem); } 50% { background-position: 0rem calc(-15 * 3.6rem); } 53.3% { background-position: 0rem calc(-16 * 3.6rem); } 56.6% { background-position: 0rem calc(-17 * 3.6rem); } 60% { background-position: 0rem calc(-18 * 3.6rem); } 63.3% { background-position: 0rem calc(-19 * 3.6rem); } 66.6% { background-position: 0rem calc(-20 * 3.6rem); } 70% { background-position: 0rem calc(-21 * 3.6rem); } 73.3% { background-position: 0rem calc(-22 * 3.6rem); } 76.6% { background-position: 0rem calc(-23 * 3.6rem); } 80% { background-position: 0rem calc(-24 * 3.6rem); } 83.3% { background-position: 0rem calc(-25 * 3.6rem); } 86.6% { background-position: 0rem calc(-26 * 3.6rem); } 90% { background-position: 0rem calc(-27 * 3.6rem); } 93.3% { background-position: 0rem calc(-28 * 3.6rem); } 96.6% { background-position: 0rem calc(-29 * 3.6rem); } 100% { background-position: 0rem calc(-30 * 3.6rem); } } `); $('body').prepend(` `); }); }; // 在umi中注入 plugins: [ '@umijs/plugins/dist/initial-state', '@umijs/plugins/dist/model', './plugins/loading.ts', ],
实现效果:
3.2 session管理持久化
系统中在前端资源全部response解析完成后,对所有的业务接口请求执行前都需要确保getUserInfo接口响应成功并在前端接收sessionId,然后在所有的业务接口中携带在参数中,在系统交互链路的前置流程过长的背景下,前端基于storage实现getUserInfo数据持久化从而节省一次关键串行接口的请求。
关键用户信息读取的代码:
import getCurrentUserInfo$ from '@ali/dingtalk-jsapi/api/internal/user/getCurrentUserInfo'; export async function fetchUserInfo() { const dingUid = await getCurrentUserInfo(); let storageUserInfo = getUser(); let res: UserDto; if (storageUserInfo && +dingUid?.uid === +storageUserInfo?.dingUserId) { // 如果缓存中的用户信息是当前用户,使用缓存 res = { userType: 'dingtalkUid', userId: storageUserInfo.dingUserId, userName: storageUserInfo.nick, name: storageUserInfo.nick, mobile: storageUserInfo.mobile, avatarUrl: storageUserInfo.avatarUrl, }; window.enjoyDrinkTrace.logError('命中storageUserInfo缓存'); } else { // 未命中缓存,走请求用户信息流程 const corpId = getQueryString('corpId'); const userInfo = await getUserInfo({ corpId, userChannel: getQueryString('__from__') || '', }); res = { userType: 'dingtalkUid', userId: userInfo.data.data?.userID, userName: userInfo.data.data?.userName, name: userInfo.data.data?.userName, mobile: userInfo.data.data?.mobile, avatarUrl: userInfo.data.data?.avatarUrl, }; } return res; }
这一步优化在FP节点之后,减少了与FCP中间的耗时,减少量为一次接口请求的时间,约100ms。
四、做好基本的,再借助一下端能力
4.1 离线策略
做好前端基本的优化+H5加载链路的优化后结合cdn自带的缓存,整体的首屏用户体验已经很不错了。
那如native般的秒开,怎么实现?由于业务运行在钉钉中,咨询了钉钉同学,对于产品首页、红包页等页面布局不大的场景中尝试接入离线。
结合实际业务场景,在请客红包中,所有资源都可根据离线预置到App本地,在用户访问页面时,可以尽早为页面渲染铺垫;此外还可以推送相应的 js 缓存文件,减少 js 下载时长,让用户可交互时间提前;页面中的固定图片,也可以通过 zcache 缓存,提升页面图片整体的缓存命中率。
结合了所有的优化后,请客红包的IM消息主入口基本做到秒开。
五、未来规划
结合各类性能优化的手段,沉淀出相对应的代码、文档、prompt、tools等,集成到agent中,在未来的相关新产品设计中,让业务在起步阶段就有相对应稳定、体验较好的体感。
基于ARMS性能插件、钉钉容器性能监控看板,持续提升业务性能,保障业务用户体验。
对于场景投放类页面(目前是MPA多页方案),后续考虑转SSR