1 背景
1.1 项目
项目需开发移动端,需支持以图表、表格等形式展示数据,对素材进行审核审批等功能。并需支持微信、企微小程序、h5等平台使用。
2 技术选型
2.1 基础框架选择
从落地场景分析,我们需要具备,微信小程序,企微小程序,h5等平台的支持。如果采用小程序/h5等单平台框架开发,在开发效率与人力占用上的成本显然会与需要支持的平台数量成正比。同时小程序在原生开发上也无法使用工程化带来的部分提效功能,所以在选型上会优先考虑跨平台的工程化开发框架。在技术选型过程中,预研了以下几种跨平台方案。
-
Taro :一个基于 React 技术栈的跨平台小程序框架,支持使用
React/Vue
/Nerv 等框架来开发微信
/企微
/京东/百度/支付宝/字节跳动/QQ/飞书小程序 //H5
// RN 等应用。 -
uni-app :一个使用
Vue.js
技术栈的跨平台小程序框架,目前支持iOS、Android、Web(响应式)
、以及各种小程序(微信
/支付宝/百度/头条/飞书/QQ/快手/钉钉/淘宝)、快应用等多个平台。 -
mpvue :由Meituan-Dianping团队开发,基于vue.js设计的跨端框架。目前支持
微信小程序
、 百度智能小程序, 头条小程序 和 支付宝小程序。综合下来,Taro的跨技术栈、平台支持和比较完善的生态会更适合我们当前需要落地的应用。具体的入门教程等可看官方文档,这里不详细讲解。
2.2 项目规范
2.2.1 样式规范
-
tailwind css ,移动端上,ui要求往往比较严格,乃至到1px的偏差,而tailwind可以更灵活的满足自定义样式,同时降低样式开发管理的成本。
-
sass :因为后续使用到的组件库是基于sass来开发样式,所以项目中也使用sass css预处理器。对于公用组件样式,需要使用css modules模式来避免样式冲突。
2.2.2 状态管理
-
全局状态管理:市面上的状态管理库数不胜数,像redux, mobx, recoil等依赖库群魔乱舞,在此就不具体展开,有兴趣的可以看看这个视频 学习下,在项目中最终确定使用较轻量级、学习和使用成本也非常低的jotai 。
-
数据获取状态管理:对比了swr 与react-query 。相较于swr,react-query功能比较丰富,包括请求缓存、生命周期管理、请求取消、错误处理等,比较适用于复杂的应用场景,而swr则比较轻量级,API 设计简单易用,学成本相对较低,适合快速开发。鉴于小程序的场景交互较为简单,同时对于体积严格要求,最终选择轻量易用的swr。
2.2.3 开发流程规范
- 格式化commit:通过
commitizen
来规范commit规范
jsonsudo npm install -g commitizen // commitizen 的首选适配器,提供commit的提交标准配置 sudo npm install -g cz-conventional-changelog echo '{ "path": "cz-conventional-changelog" }' > ~/.czrc
-
husky:提供githook,如在commit之前会触发pre-commit,只要编写对应的校验逻辑即可完成commit的规范检查。
-
lint-staged:通过执行lint-staged,能更高效的过滤出需要进行规范校验的文件
cssnpm install lint-staged //在package.json添加如下配置,表示会过滤出所有ts,tsx后缀的文件,然后对文件执行eslint --fix "lint-staged": { "**/**/*.{ts,tsx}": [ "eslint --fix" ] }
- stylelint:对指定文件的样式进行对应规则 的校验
cssnpm install stylelint // 对src目录下对应后缀的文件进行样式校验 npx stylelint src/**/*.{html,tsx,css,sass,scss} --fix // 配置样式校验规则 .stylelintrc.json
3 Taro底层原理浅析
3.1 Taro 编译时流程
为了方便追踪运行流程,截图为运行时环境,解析中的代码位置为源码位置
- 这里先贴出Taro编译时的流程图以助理解
- package.json: 首先scripts追踪taro命令来源在 @tarojs/cli/bin/taro。
- taro/packages/taro-cli/src/cli.ts: 实例化服务层内核,通过项目config配置的framework和命令传参type,确认了编译过程对应平台需要的插件以及用来触发构建的build钩子
- taro/packages/taro-service/src/Kernel.ts: 服务层内核,执行run比较重要的2件事,通过initPresetsAndPlugins初始化预设插件钩子函数,通过resolvePresetsOrPlugins获取到预设插件钩子函数,分别通过initPreset、initPlugin触发钩子函数执行,并借助tapable库,通过tapPromise来注册钩子函数,通过promise触发钩子执行回调fn。
- taro/packages/taro-cli/src/presets/commands/build.ts: 触发taro构建的钩子。在Kernel中注册了build的钩子。
- taro/packages/taro-weapp/src/index.ts: 用于支持编译为微信小程序的Taro插件。Kernel中注册@tarojs/plugin-platform-weapp插件,注册了weapp的钩子函数。
- taro/packages/taro-service/src/Kernel.ts: 初始化完插件钩子,通过applyPlugins触发build钩子,Kernel中通过tapable中tapPromise来注册前面预设的钩子,并通过promise执行回调,并把项目中配置的config带过去。
- taro/packages/taro-cli/src/presets/commands/build.ts: 触发build钩子的fn回调后,又通过applyPlugins触发weapp钩子,然后向上面一样最终执行了weapp钩子的fn回调,实例化weapp。
- taro/packages/taro-weapp/src/index.ts: 实例化weapp,执行start。
- taro/packages/taro-service/src/platform-plugin-base/mini.ts: weapp实际上继承了TaroPlatformBase类,触发start时进行build阶段,这时候我们会看到实际上运行的是对应配置的编译器(如@tarojs/webpack5-runner)。
- taro/packages/taro-webpack5-runner/src/index.mini.ts: 这时候就会实例化编译器,并按照不同场景执行run/watch,从这里的webpackConfig就可以分析从taro编译到小程序运行之间使用了哪些loader、plugin等配置。
- 通过源码的学习,可以看到代码中大量使用单一职责原则的设计思想抽取抽象类,封装每一个功能模块,而整体流程又通过tapable方式来管理插件的注册以及触发时机,当流程扭转到对应的功能模块时会基于模块的职责触发对应的钩子函数,很好的实现了模块的可扩展性以及独立性。
3.2 Taro 运行时时原理
- 这里贴出Taro运行时的流程图以助理解
4 在用户体验上的思考与改进
4.1 页面loading
- 在前端页面中,在数据加载完页面渲染完成前,需要loading的效果来过渡页面无数据时空白的过程。单独使用loading占位图在交互体验上较差,使用骨架屏可以让用户预先感知页面的结构,提升用户在等待查询结果过程的体验效果。
- 关于请求快而导致loading闪烁的问题,此前在码客上也发过问答征求各位大神的意见,综合下来,可以考虑如此优化。先评估一个数据请求时间的临界值,比如200ms。大部分情况下小于200ms的请求,则可以在小于200ms的情况下,不展示loading效果,在超过200ms之后强制loading时长比如200ms,以此避免loading闪烁。如果大部分情况下请求时间比较长,则在请求时直接展示loading即可。
4.2 缓存页面数据
- 对于离线数据(条件相同的情况下请求返回的数据是不变的),可以采用缓存数据的策略来减少数据的重复请求,避免页面的频繁loading。比如在表格展示中通常会有翻页功能,这个时候如果是展示的离线数据,则可以采用该策略,用户在跳转到已经查询过的页时数据会从缓存读取并瞬间渲染,不会发送请求。这里以swr的数据请求状态库来举例。
kotlinimport useSWR from 'swr/immutable'; const useData = (key) => { // 将查询条件作为key const key = ... // 只要key不变,则data不会变化 const { data, isValidating } = useSWR( key, fetch, ); return { data, isValidating }; };
- 通过离线数据缓存,可以在以下很多场景中节省许多重复请求
-
- 【节省100%】表格分页数据可达到100%重复翻页请求的节省。项目中有一个表格场景,需要按天请求回来某个时间段所有数据,再遍历请求每天的小时数据回来。可以看到一页的查询就有10几个甚至几十个请求,而加上离线数据缓存之后,再跳转到已经请求过的页面时将会渲染缓存数据而不再重复发起请求。
4.3 乐观 UI
- 乐观 UI通俗的讲,就是用户在页面进行一个操作后,并不需要等待服务器响应数据,而是立刻更新ui。如果操作的请求发送异常失败时,则回滚掉ui更新。通过这种策略,可以让用户感受到页面的敏捷性与流畅性。示例:
javascriptimport { mutate } from 'swr'; const useData(){ const key = ... // 只要key不变,则data不会变化 const { data, isValidating } = useSWR( key, fetch, ); return { // 在前端进行数据操作后,将更新的数据传入 refresh(updateParams){ mutate(key, originData => ({ ...originData, ...do something // 利用updateParams来更新原始数据 }), { populateCache: true, // 告诉 SWR 用 mutate 的响应去更新本地数据 revalidate: false, // 不发起请求重新验证 rollbackOnError: true, // 操作失败了进行回滚 }); } } }
4.4 页面切换
- 尽可能将页面打包于主包中,在底部tabBar通过switchTab来代替redirectTo作路由跳转,前者在跳转旧页面时不会重新加载页面资源,而后者则会,会导致用户切换tabBar时频繁白屏。
4.5 预加载
- 资源预加载:基于5.4的优化基础,我们往往会需要尽可能去减少包体积,就会把一些静态资源放于服务端,本地进行远程请求加载。像图片一类的资源非常适合做预加载的优化,比如在项目中我们会有客服悬浮窗功能,而实际上客服按钮的动态效果是由几张图片的切换来实现,这时候可以在src/app.tsx入口处即预加载图片到本地,等登录跳转完按钮即可瞬间完成渲染
scss// app.tsx const preloadImages = (imageUrls): Promise<string[]> => new Promise((resolve, reject) => { const loadedImages: string[] = []; let loadedCount = 0; function loadHandler() { loadedCount += 1; // 当所有图片都完成加载,即返回结果 if (loadedCount === imageUrls.length) { resolve(loadedImages); } } for (let i = 0; i < imageUrls.length; i++) { const imageUrl = imageUrls[i]; if (/http|https/.test(imageUrl)) { wx.downloadFile({ url: imageUrl, success: (res) => { if (res.statusCode === 200) { loadedImages[i] = res.tempFilePath; loadHandler(); } else { reject(new Error(`Failed to download image: ${imageUrl}`)); } }, fail: (error) => { console.log({ error }); reject(error); }, }); } else { loadHandler(); loadedImages[i] = imageUrl; } } }); useEffect(() => { // 预加载客服按钮的图片 preloadImages(customerPic).then((res) => { setCustomerImg(res); }).catch(() => { // 预防加载失败 setCustomerImg(customerPic); }); }, []);
- 数据预加载:对于离线数据的表格数据展示,可以在渲染完首页数据的同时,在网络空闲时预请求后几页的数据,在翻页时即可快速渲染数据。(根据实际情况考虑是否采用该策略,在优化用户体验的同时也会增加服务器压力)。
5 在开发效率上的思考与改进
5.1 全局公用组件
- 在项目中通常会出现许多全局的组件的应用场景 ,如客服悬浮窗,水印,全局弹窗 等。而在小程序中没有像web环境一样具备document能进行dom操作的对象,可以动态的append元素到页面上,所以要声明一个全局组件比较麻烦,除了需要全局组件的声明配置,还得在每个页面手动引入全局组件,无形增加了开发成本和代码维护成本。而在taro中则暴露了类web环境的document对象,通过以下几行代码就可以封装好一个全局元素创建的方法
ini// 通过当前路由获取到当前页面元素 const getParentElement = () => { const currentPages = getCurrentPages(); const currentPage = currentPages[currentPages.length - 1]; const path = currentPage?.$taroPath; return document.getElementById(path); }; // 封装全局元素创建方法 export const appendChild = (Content) => { const view = document.createElement('view'); if (id) { view.className = id; } const pageElement = getParentElement(); const dom = pageElement?.getElementsByClassName(id); if (dom?.length) return; render(<Content />, view); pageElement?.appendChild(view); return () => destroyChild(view); }; // 封装元素销毁方法 export const destroyChild = (node) => { const pageElement = getParentElement(); unmountComponentAtNode(node); pageElement?.removeChild(node); };
然后在/src/app.tsx,也就是页面的入口处进行全局组件的全局插入
javascriptimport { getCurrentPages } from '@tarojs/taro'; export function Entrance(props) { const currentPages = getCurrentPages(); useEffect(() => { // 可自行进行逻辑判断是否在某些页面不执行插入等 appendOnce(WaterMask, watermaskId); }, [currentPages]); .... return props.children }
5.2 结合loader减少组件代码
- 骨架屏: 前期调研了许多市场上常见骨架屏方案,综合考虑现有方案痛点,在优先考虑用户体验的前提下,决定采用以公共组件的方式来实现骨架屏效果,同时需要考虑尽量降低代码侵入性与减少代码量等问题,由于篇幅问题这里不详细展开,这里让我们专注于结合工程化来减少开发成本的思考。
javascriptimport Skeleton from '@/components/skeleton/skeleton'; function Test() { const homeInfo = { avatar: 'xxx.png', name: 'coln', }; const [info, setInfo] = useState({ avatar: '', name: '' }); const [loading, setLoading] = useState(false); useEffect(() => { // do something }, []); const selector = uuid(); return ( <> <Skeleton selector={selector} loading={loading} bgColor="#c9c9c9" /> <View className={`... ${selector}`}> <Image ... className="... skeleton-round" src={info.avatar} /> <View className="... skeleton-rect">{info.name}</View> </View> </> ); }
以上是一个骨架屏组件Skeleton的使用例子,组件的底层实现是将需要骨架屏效果的元素className以selector属性传入,去遍历获取拥有该className的元素,并使其子元素中带有skeleton-round/skeleton-rect,在loading值为true的时候动态加入class样式来实现骨架效果。那这里主要的问题是元素的className取值比较麻烦,需要手动声明一个随机的className。并且需要手动import ,作为一个局部loading的替代方案,使用 的次数也会比较频繁 ,那么减少 这部分代码 的编写成本也会提高 一定的开发效率。
javascriptfunction Test() { ... return ( <> {/* skeleton */[loading, '#c9c9c9']} <View className="..."> ... </View> </> ); }
通过loader来解析
{/* skeleton */[loading, '#c9c9c9']}
变为
javascriptimport Skeletonj4159ada from "@/components/skeleton/skeleton" function Test() { ... return <> <Skeletonj4159ada selector="j4159ada-1caa-46e5-830b-32048ca6de64" bgColor="#c9c9c9" loading={loading} /> <View className="... j4159ada-1caa-46e5-830b-32048ca6de64"> ... </View> </>; }
6 性能优化
6.1 减少react重渲染
- 影响组件rerender的因素主要是props, state, context,所以一般需要将组件分为变与不变的部分,将变的部分分离出组件,在组件里维护自己的state值。
6.2 性能检测工具
- React-Dev-Tools的Profiler面板。可以看到每个组件的重渲染的时长以及原因。没有代码侵入性,只需要启动react-devtools服务。
- React Profiler组件,可以看到组件优化前后的时间对比,通过缩小这个数值来优化渲染速度。有代码侵入性,需要用Profiler组件包裹组件。
- 使用@welldone-software/why-did-you-render,会打印出具体发生改变的prop, state以及重渲染原因,有一定代码侵入性。
目前在用2+3的方案进行性能检测,通过以下封装的性能检测组件来给要检测的组件进行包裹,达到能看到是否重复渲染同时,也可以看到组件渲染实际使用的时间。
ini// wdyr.tsx import React, { Profiler } from 'react'; const elementMap = {}; const onRender = ( id, phase, actualDuration, baseDuration, ) => { if (!elementMap[id]) { elementMap[id] = { duration: actualDuration, count: 1 }; } else { elementMap[id].duration += actualDuration; elementMap[id].count += 1; } console.log({ id, actualDuration, baseDuration, elementMap, }); }; if (process.env.NODE_ENV === 'development') { whyDidYouRender = require('@welldone-software/why-did-you-render'); whyDidYouRender(React, { trackAllPureComponents: true, collapseGroups: true, onlyLogs: true, titleColor: 'green', diffNameColor: 'darkturquoise', trackHooks: true, }); } export const withProfilerAndWdyr = (Component) => { Component.whyDidYouRender = true; function WrappedComponent(props) { return ( <Profiler id={Component.name} onRender={onRender}> <Component {...props} /> </Profiler> ); } WrappedComponent.displayName = `withProfilerAndWdyr(${Component.name})`; return WrappedComponent; }; // example import{ withProfilerAndWdyr } from 'wdyr'; function Example() { const [count, setCount] = useState({ num: 0 }); const add = () => { setCount({ num: 0 }); }; return ( <View> <View style={{ width: '100px', height: '30px', marginTop: 50, textAlign: 'center', lineHeight: '30px', background: 'red', }} onClick={add} > + </View> {count.num} </View> ); } export default withProfilerAndWdyr(Example);
6.3 体积优化
- 由于项目资源较多,如组件库、字体图片等,导致包体积过大,遂做了分包处理,但分包导致了tabbar的切换出现短暂白屏。于是我们又尝试了进行分包合并,并做了以下许多优化:
- 【图片优化,节省了335kb】。将较大的图片资源进行压缩,并上传到cos,335k -> 106k -> cos,本地占用为0
- 【字体库优化,节省443kb】。字体下载后全量引入了工程里,实际上为了兼容浏览器而保留的字体文件并不需要(红框部分347k),将剩下的字体库(蓝框96k)转移到云端,本地占用为0,改用loadFontFace方法进行云端字体的加载。
- 【组件库优化,节省917kb】。taro-ui的全量js和css打包,改为按需的模块打包,按需加载taro-ui,css。在工程化上,允许提取公共css样式。
- 优化后:app.wxss由998k降到181K。
而js体积未优化,因为taro的按需加载有bug,全量打包了,除非大改项目中import的写法,从node_modules包中原生引入。
7 踩坑笔记
7.1 万金油
- 当然也不是全能的,但遇到实在找不出原因的bug,试试删除dist和.swc目录,重新编译。
7.2 底部Tabbar
- 如果需要将首屏页面都打包到主包里,可以在src/custom-tab-bar下管理tabbar。
- 如果采用以上方案导致主包体积超过小程序官方限制(单个分包/主包大小不能超过2M),则需要去掉原生tabbar,自定义组件加到每个页面中。
7.3 登录态
- 小程序不支持设置cookie,所以需要把cookie存储在本地,在请求时带上,如果遇到需同步webview登录态,则在url带上cookie传递给页面,页面再设置cookie达到状态共享。
7.4 根节点
- src/app.ts(x?)无法渲染其他节点,只能渲染children。
7.5 开发工具调试
- 微信小程序开发者工具无法测试跳转qq小程序登录,可以通过真机调试登录完获取用户信息,再写到开发工具storage里进行模拟。
7.6 水印
- 通过canvas直接绘制水印覆盖在最上层,并加上pointer-events: none;后,在开发者工具表现正常,但实际上在真机中会复现屏幕被遮挡无法滑动的问题。可以通过画一个canvas,将其移出屏幕外。然后另外准备一个view,等canvas渲染水印完成,调用canvasToTempFilePath将其变成base64的图片源数据,并设置在view的background里。这时候就可以完成水印的渲染。
- 通过canvas绘制水印可能会遮挡住页面文字,可以给字体设置色值为rgba,并提高透明度减弱遮挡。
- canvasToTempFilePath最好在canvas的draw回调里调用,保证执行时canvas是已经完成绘画。
7.7 安全区域
- 竖屏底部安全区域遮挡住页面,可以通过css来优化
css
padding-bottom: constant(safe-area-inset-bottom);
padding-bottom: env(safe-area-inset-bottom)
7.8 横竖屏切换时字体变大
- 从横屏页跳转到竖屏页时字体会变大,但从竖屏页跳转竖屏页不会,所以可以新开一个空白竖屏页,但需要从横屏页跳转竖屏页时先跳转到空白竖屏页,再立刻从空白竖屏页跳转到目标竖屏页。
7.9 storage
- 小程序正式与测试环境的storage是共享的,所以在登录态失效时要清除缓存。详见小程序体验版和线上版本storage 共享问题。
7.10 createSelectorQuery获取元素失败
- 在做ui框架重构时,由于用了2层tab组件导致层级过多,进而导致echarts图表中的canvas渲染不出来,经翻阅文档,当组件的嵌套层级超过 baseLevel(默认 16 层)时,Taro 内部会创建一个原生自定义组件协助开启更深层次的嵌套,因此获取超过 baseLevel 层级的节点时会失败。
7.11 ScrollView自动回滚顶部
- ScrollView 在Dom结构发生变化会自动回滚到顶部, 这是因为react将所有组件的state统一在page级进行管理,而同级节点增删时会导致框架在diff更新state时将整个数组重新更新了一遍,导致ScrollView被重新创建。目前做法是设置一个状态值,在dom发生变化时将ScrollView的scrollY禁止。
kotlin// 初始化state state = { Page: [ View, ScrollView ] } // diff时导致数组重新赋值 const oldPage = this.state.Page this.setState({ Page: [ ...oldPage, View ] })
7.12 backgroundImage属性
- 要想给backgroundImage的url赋值,得使用base64或者引用资源的相对路径。
7.13 组件库
- 由于项目进度比较急,而taro生态没有太多杀手级组件库,遂尝试接入了官方提供的taro-ui。体验下来仍有许多不足之处,比如FloatLayout 浮动弹层层级混乱、js无法按需加载等。不建议使用