背景
使用低代码接入了一个移动端老系统,新增页面(以及部分旧页面)使用低代码体系搭建。功能开发完成后才发现性能问题比较严重,所以进行了一次移动端的性能优化。本文就优化过程进行一个记录。
问题分析
为什么一接入低代码体系性能(主要是加载性能)就出现明显的下降,如果首屏访问的是低代码页面则更加明显
- 最主要的原因是比之前额外加载了大量的 js 和 css,初步统计有 10 个 css 和 15 个 js
- 老系统自身 js 资源过大,依赖包 vendor.js 有 8M 多
- 低代码体系下,非静态资源的接口请求也成为影响页面渲染的因素。页面必须等待接口获取到 schema 后才由低代码渲染器进行渲染
低代码体系接入
有必要简单说明下低代码体系是如何接入的,这对后面的优化是有直接影响的
- 低代码体系资源大概分为三方依赖、渲染引擎和组件库资源,都是独立的 npm 库,发布单独的 CDN
- 三方依赖就是像 react、moment、lodash 等最基础的依赖资源
- 渲染引擎要想渲染页面,又直接依赖于两个资源
- 页面 schema:服务端接口返回,schema 本质上是一个 json,描述了一个组件树
- 组件集合:由 CDN 引入的各个组件库集合,它需要先于页面 schema 加载
静态资源为何影响加载性能
静态资源加载如何影响性能,简单分析下,详细的原理可以参考 MDN
- HTML 自上而下解析,遇到 script 标签(不带 defer 和 async 属性)就会暂停解析,等待 script 加载和执行完毕后才会继续
- HTML 解析时如果遇到 css 资源,解析会继续进行。但是在 css 资源加载完成前,页面是不会渲染的,并且如果此时有 JavaScript 正在执行,也会被阻塞
- 所以 js 或 css 体积越大,则在网络传输、下载、浏览器解析和执行上所花的时间就会相应的增加,而这些时间都是会阻塞页面渲染的
- js 或者 css 的个数对于渲染的影响,很大程度上取决于项目和浏览器是否支持 http2
- 如果使用了 http2,则静态资源个数对于加载性能影响不大,除非多到几百个资源
- 如果还是 http1.1,静态资源个数对于加载有明显影响,因为此时浏览器存在并发限制,大概在 4-6 个左右,即一批次只能发送几个请求,等到请求完成后,再发下一批,是个同步的过程
- 本项目已经支持 http2,所以优化加载性能的重点还是在减小总的资源体积上
优化指标
用户对于页面性能的感受是主观的,而优化工作则需要客观的数据。 更重要的是,有些优化措施是否有效果,有多少效果是需要数据说明的。举例来说,去除冗余资源几乎是可以预见性能提升。但是做 CDN 合并在移动端能够有多少优化效果,事前其实并不清楚 这里采用 2 种方式作为优化指标
- 旧版本 chrome(69)的 perfomance
- 使用这个版本是因为后台数据显示该引擎访问量较多
- chrome 的 performance 不仅能获取性能数据,也有助于我们分析,找出具体问题
- 使用 web-vitals 库获得具体的性能数据,主要关注
- FCP,白屏时间
- LCP,页面可视区域渲染完成时间
现状
- 点击 performance 的刷新按钮,就会自动进行一次页面的加载
- 建议使用无痕模式,排除其他干扰
- network 中勾选 Disable cache,虽然最终用户会用到缓存,但在优化非缓存项时,建议先禁用缓存,获取最真实的数据
- 静态资源的加载大概花了 3.5s
- 而后续静态资源的解析则一直持续到页面加载完成,大概在 9 秒多
- 使用 web-vitals 测量的平均数据
- FCP: 5.5s
- LCP: 9s
目标
- performance 页面渲染完成:4s 以内
- web-vitals 平均数据
- FCP:3s 以内
- LCP:4s 以内
如果从绝对性能看,这个目标只能是个中下水平。主要基于以下几点考虑
- 策略上不会对原系统或者低代码体系进行大刀阔斧的改动
- 老系统大概就是这么个性能情况,维持这个水平起码不会降低用户体验。作为内部系统,对性能没有极致的要求
- 考虑到时间成本,性能优化是一项持续性的工作,而实际项目是有时间限制和上线压力的
优化措施
根据以上分析,最重要的就是要减小总的关键资源体积。 低代码体系所需要的直接资源都属于关键资源。因为用户是可能首次直接进入一个低代码页面的(也是本次主要的优化场景)
优化前包分析
CDN 三方库资源直接就能看出哪些是冗余的,或者是公共资源加载了多遍等问题,但是自己的仓库打包后就需要借助 webpack-bundle-analyzer 插件分析了 该项目中有多个 npm 仓库需要分析,这里就举老系统自己的例子,优化前的 bundle 分析图
三方依赖 vendor.min.js 8MB 左右,项目 JS 800 多 KB,下面分析下最严重的几点
- 标 ① 部分, @ali_4ever 开头的是富文本依赖,有接近 2M 左右的大小,优化为懒加载
- 标 ② 部分,echarts5 全量引入了,1M 左右大小,计划优化为按需加载
- 标 ③ 部分,ali-oss,500 多 KB,ali-oss 不支持按需引入。这里因为多个低代码组件库中也用到了该依赖,所以计划提取为 CDN 作为公共依赖,但是大小还是 500 多 KB,只是去掉了重复加载部分
- 标 ④ 部分,antd-mobile 加载了两个版本的全量仓库,按照官方推荐,考虑将 antd-mobile-v2 按需加载
一、移除冗余资源
- 排查 CDN,是否引用了多余的 CDN,比如项目中移动端引用了 PC 端的组件库,引用了已经废弃(迁移)的工具库等等
- 排查项目 bundle,正常情况下是不可能有冗余资源的,因为如果一点没用到这个库,webpack 也不会将其打包进去
- 可能存在使用到了一小部分,却打包了整个库的情况,这个属于下一部分按需引入
- 排查下线上 CDN 是否都使用生产版本或者压缩版本,这点事先没有想到,是在优化过程中意外发现存在非压缩版本
二、按需引入
按需引入即只引入三方库中项目用到的部分。现代的大部分三方库都已经支持 TreeShaking,正常打包即是按需引入。特殊情况在于 CDN、懒加载和一些老的库,这些刚好在项目中都有所实践
按需引入 和 CDN
项目中只用到了 ahooks 中的个别方法,却将整个包作为 CDN 引入,显然是不合理的
- 需要按需引入的库,是不能使用 CDN 引入的,它们之间是互斥的
- 因为 CDN 需要配置 external 才能在项目里使用,external 一般是将一个三方库作为整体配置的
- CDN 自身作为一种优化手段,那是和将静态资源放置在业务服务器对比的。
- 在该场景下,引入 ahooks CDN 导致 TreeShaking 失效,引入了全量包,同时增加了一次 http 请求,总的来看肯定是得不偿失的
- 并且最终项目的 bundle 也会发布 CDN
- 因此去掉了 ahooks 的 CDN,改为直接打进项目 bundle 就行了
按需引入 和 懒加载
在该项目中,echarts 也按需引入了,echarts 的按需引入总体效果就没有 ahooks 那么好了
- echarts 无论绘制哪种类型图表,都需要引入核心库,就有 100 多 KB 的大小了
- 所以 echarts 也可以选择懒加载,懒加载会让没有使用 echarts 的页面加载速度变快,但是最终浏览器解析的资源是全量的,可以根据实际情况选择
- 懒加载 和 按需引入也无法并存。因为懒加载需要动态导入,动态导入 webpack 就没法做静态分析,这是 TreeShaking 的基础,所以就没法按需引入了
利用 babel-import-plugin
有一些老版本的库,可能还不支持按需引入,比方说 antd-mobile-v2,对于这种仓库,可以利用 babel-import-plugin 做按需引入 只需要做一下 babel 配置就行
json
{
"plugins": [
[
"import",
{
"libraryName": "antd-mobile-v2",
"style": "css"
},
"antd-mobile-v2"
]
]
}
- 本项目最终没有那么做,因为体积几乎没有减小。对于一个完整的项目,需要使用到的组件是非常多的
- 对于 antd-mobile 多个版本的问题,最终的优化方案还是合并为最新版,只是开发和测试的工作量大了点
- 注意点:babel-import-plugin 插件并不能让所有仓库都支持按需。本质上还是三方库做了分包才行
三、懒加载
懒加载的资源不同,也可以分为多种类型
- 三方库资源懒加载:比如之前说的,某个组件依赖于 echarts,那么就可以懒加载 echarts,只有页面中使用了该组件时才去请求和加载 echarts 依赖
- 组件懒加载:将整个组件都懒加载,在本项目中没有做组件懒加载
- 低代码体系下,组件本身不能懒加载,否则 schema 解析到这个组件时找不到会报错
- 解决方案也可以给组件套一层,实际内容懒加载,导出的组件不懒加载
- 更重要的原因是组件库本身不大,不是影响性能的关键因素
- 另外低代码页面本身就是由各个组件拼凑而成,如果将组件都懒加载了,那么页面各个部分都会有 Loading 的中间态,效果不好把控
- 路由懒加载:本质上它就是组件懒加载的一种,一个组件就是一个路由页面,项目中对于系统不太访问的页面做了路由懒加载
三方库资源懒加载
懒加载依赖也需要分析具体情况,比方说移动端使用了 antd-mobile 作为组件库,这个依赖就完全没必要等使用的时候再加载。因为几乎进入任意一个页面,都需要用到这个资源。什么情况下合适
- 依赖资源比较大
- 使用的频率较低,只在个别地方使用了
并且这个三方资源也是分两种情况引入,第一种是以 CDN 的形式外部引入,第二种是直接打包入库,这两种引入方式的懒加载处理是不同的,下面分别举例
CDN 引入的三方资源懒加载
比如低代码组件库中存在一个富文本组件,比较特殊,比较适合使用 CDN 的方式懒加载依赖资源
- 富文本组件依赖于公司内部的一个富文本编辑器。鉴于富文本的复杂性,所以它的依赖很大,JS+css 将近有 3M 左右。
- 但是其实只有极少的页面使用到了富文本,对于大多数用户来说,是不需要这个富文本的
下面介绍下具体实现,利用 ahooks 的 useExternal,动态注入 js 或 css 资源(也可以原生实现),封装一个高阶组件,方便调用
typescript
type LoadStatus = 'loading' | 'ready' | 'error';
interface LoadOptions {
url: string;
libraryName: string;
cssUrl?: string;
LoadingRender?: () => React.ReactNode;
errorRender?: () => React.ReactNode;
}
export const LazyLoad = (Component, { url, libraryName, cssUrl, LoadingRender, errorRender }: LoadOptions) => {
const LazyCom = (props) => {
const initStatus = typeof window[libraryName] === 'undefined' ? 'loading' : 'ready';
const [loadStatus, setStatus] = useState<LoadStatus>(initStatus);
const jsStatus = useExternal(url, {
keepWhenUnused: true,
});
const cssStatus = useExternal(cssUrl, {
keepWhenUnused: true,
});
useEffect(() => {
if (loadStatus === 'ready' || loadStatus === 'error') {
return;
}
if (jsStatus === 'error' || cssStatus === 'error') {
setStatus('error');
}
if (jsStatus === 'ready' && (cssStatus === 'ready' || cssStatus === 'unset')) {
setStatus('ready');
}
}, [jsStatus, cssStatus, loadStatus]);
const content = useMemo(() => {
switch (loadStatus) {
case 'loading':
return typeof LoadingRender === 'function' ? LoadingRender() : <div>加载中...</div>;
case 'ready':
return <Component {...props} />;
case 'error':
return typeof errorRender === 'function' ? errorRender() : <div>加载失败</div>;
default:
return null;
}
}, [loadStatus]);
return content;
};
return LazyCom;
};
// 使用示例,BaseEditor即需要懒加载的原组件,BaseEditor组件内部直接通过window取相应依赖
export const FormEditor = LazyLoad(BaseEditor, {
url: 'xxxx',
cssUrl: 'xxxxx',
libraryName: 'xxxxxx',
});
打包入 bundle 依赖懒加载
总体思路是一样的,只是这类资源利用 webpack 的 import 动态导入能力,import 动态导入的资源打包时会单独分包,只在使用到时才会加载 具体实现:
typescript
export const InnerLazyLoad = (Component, loadResource, LoadingRender?) => {
const LazyCom = (props) => {
const [loaded, setLoaded] = useState(false);
const [LazyResource, setResource] = useState({});
useEffect(() => {
if (loaded) {
return;
}
loadResource().then((resource) => {
setResource(resource);
setLoaded(true);
});
}, [loaded]);
const LoadingNode = typeof LoadingRender === 'function' ? LoadingRender() : <div>...加载中</div>;
return loaded ? <Component {...props} LazyResource={LazyResource} /> : LoadingNode;
};
return LazyCom;
};
// 具体使用
const loadResource = async () => {
// 动态导入的资源会单独分包,在使用到时才会加载
const echarts = await import('echarts/core');
const { PieChart } = await import('echarts/charts');
const { TitleComponent } = await import('echarts/components');
const { CanvasRenderer } = await import('echarts/renderers');
return {
echarts,
PieChart,
TitleComponent,
CanvasRenderer,
};
};
const AgentWork = InnerLazyLoad(BaseAgentWork, loadResource);
路由懒加载
路由懒加载原理和内部资源懒加载类似,分包然后首次进入该页面时才请求页面资源 本项目没有把所有页面都懒加载
- 页面懒加载后,进入页面前会有一个短暂的加载过程,需要评估影响
- 还是和通用懒加载一样,使用频率较低、页面 js 又比较大的比较适合懒加载
比如在该项目中
- 应用上存在部分页面是给第三方使用的,不能通过导航点击到达,直接分享地址给第三方
- 这些页面使用频率低,而且基本不影响本应用,因为无法通过导航点击切换到达,是通过 url 的形式直接访问,所以加载中的中间态和页面加载一起
路由懒加载的实现,不同框架都有些差异。本项目中只需在路由配置中增加配置项即可开启,就不再阐述具体代码实现
四、合并公共资源
合并公共资源,即不要重复加载相同资源 一般来说打包工具都会做依赖分析,只会打包一份相同路径的引用依赖。但是如果相同依赖分散在多个仓库中就有可能出现重复资源了 比如该项目中,老系统自身和多个组件库都使用了 ali-oss 库实现上传功能,并且还有一些条件使得将其提取为公共 CDN 是利益最大化的
- ali-oss 打包后 500 多 KB 的大小,已经算是一个不小的包了
- ali-oss 不支持按需引入,所以引用到它的多个仓库,无论引用了什么功能,都将全量打包入 ali-oss
- 如果 ali-oss 支持按需引入,就需要计算是提取为公共 CDN 划算,还是将其按需打入各个仓库中划算
实现步骤比较简单
- 在引用 ali-oss 的仓库配置 external,使仓库本身打包时不打入 ali-oss 依赖
- 在项目 HTML 中提前引入 ali-oss CDN
五、缓存
静态资源缓存
- 该项目静态资源使用 CDN+版本号,本身已经支持了缓存。CDN 的缓存时间是通过 Cache-Control 的 s-maxage 字段控制,这是 CDN 特有的字段
- 如果静态资源是放置在自己的服务器上,需要考虑 http 缓存和缓存更新的事项,这个也是老生常谈的话题,这里不再赘述
如果想要详细了解 http 缓存,推荐看下这篇文章
options 请求缓存
在实际优化过程中发现,该项目的大部分 ajax 请求,都是跨域请求,所以伴随着大量的 options 请求 推动服务端做了这些预检请求的缓存,其原理就是通过 access-control-max-age 响应头设置预检请求的缓存时间
Service Worker
Service Worker 是一项很强大的技术,它能够对网络请求进行缓存和处理,它的最大应用场景是在弱网甚至离线环境下 一旦使用了 Service Worker 技术,用户在首次安装完成后,后续的访问相当于直接在本地读取静态资源,访问速度自然能够得到提升 虽然能够提升使用体验,但是使用 Service Worker 是存在一定限制和风险的
- 必须运行在 https 协议下,调试时允许在 localhost、127.0.0.1
- Service Worker 自身不能跨越,即主线程上注册的 Service Worker 必须在当前域名下
- 一旦被安装成功就永远存在,除非线程被程序主动解除
- Service Worker 的更新是比较复杂的,如果对其了解不深,建议还是只将不常更新的资源使用 Service Worker 缓存,降低风险
项目中直接使用 workbox(对 Service Worker 做了封装,并提供一些插件),以下为示例代码
主线程上注册 Service Worker
typescript
if ('serviceWorker' in navigator) {
navigator.serviceWorker
.register('./sw.js')
.then((reg) => {
navigator.serviceWorker.addEventListener('message', (event) => {
// 处理Worker传递的消息逻辑
});
console.log('注册成功:', reg);
})
.catch((err) => {
console.log('注册成功:', err);
});
}
Service Worker 线程处理缓存逻辑
typescript
//首先是异常处理
self.addEventListener('error', function (e) {
self.clients.matchAll().then(function (clients) {
if (clients && clients.length) {
clients[0].postMessage({
type: 'ERROR',
msg: e.message || null,
stack: e.error ? e.error.stack : null,
});
}
});
});
self.addEventListener('unhandledrejection', function (e) {
self.clients.matchAll().then(function (clients) {
if (clients && clients.length) {
clients[0].postMessage({
type: 'REJECTION',
msg: e.reason ? e.reason.message : null,
stack: e.reason ? e.reason.stack : null,
});
}
});
});
//然后引入workbox
importScripts('https://g.alicdn.com/kg/workbox/3.3.0/workbox-sw.js');
// 预缓存资源示例,不更新的资源使用预缓存
const resources = ['https://g.alicdn.com/dingding/dingtalk-jsapi/2.10.3/dingtalk.open.js'];
// 预缓存功能
workbox.precaching.precacheAndRoute(resources);
// 图片缓存 使用CacheFirst策略
workbox.routing.registerRoute(
/\.(jpe?g|png)/,
new workbox.strategies.CacheFirst({
cacheName: 'image-runtime-cache',
plugins: [
new workbox.expiration.Plugin({
// 对图片资源缓存 1 天
maxAgeSeconds: 24 * 60 * 60,
// 匹配该策略的图片最多缓存 20 张
maxEntries: 20,
}),
],
})
);
// 需要更新的js和css资源使用staleWhileRevalidate策略
workbox.routing.registerRoute(
new RegExp('https://g.alicdn.com/'),
workbox.strategies.staleWhileRevalidate({
cacheName: 'static-runtime-cache',
plugins: [
new workbox.expiration.Plugin({
maxEntries: 20,
}),
],
})
);
- 预缓存功能:
- 正常情况下,Service Worker 是在主程序首次请求时将资源拦截,在之后的请求中根据缓存策略处理
- 预缓存功能是在 Service Worker 在安装阶段主动发起资源请求,并将其缓存下来
- 当页面真正发起预缓存当中的资源请求时,资源已经被缓存了,就可以直接使用了
- 预缓存是使用 Cache Only 策略,即在预缓存主动发起请求并获取缓存后,就只会在缓存中读取资源,不在进行缓存更新,所以适合项目中不更新的静态资源
- 图片缓存:
- 图片一般情况下是不更新的,所以采用 Cache First 缓存优先策略
- 当有缓存时会优先读取缓存,读取成功直接使用本地缓存,不再发起请求
- 读取失败时再发起网络请求,并将结果更新到缓存中
- 对于需要更新的 JS 和 CSS
- 使用 Stale While Revalidate 策略
- 跟 Cache First 策略比较类似,都是优先返回本地缓存的资源
- 区别在于 Stale While Revalidate 策略无论在缓存读取是否成功的时候都会发送网络请求更新本地缓存
- 这是兼顾页面加载速度和缓存更新的策略,相对安全一些
六、其他
以下措施不具备通用性,但是在项目中用到了还是记录下来,仅供参考
- 页面 schema 接口优化:低代码体系存在页面嵌套,每个页面单独请求自己的 schema,所以在嵌套层级较多的情况下,是以同步解析的顺序请求接口,页面渲染速度较慢,优化为服务端拼装完毕后直接返回
- 部分接口的请求合并
- 去除运行时 babel,低代码设计器中存在手写的代码,这部分代码最初在运行时由 babel 转化为 ES5(设计问题),优化为保存时转换
七、项目已经存在的措施
- 静态资源放在 CDN
- 启用 http2,并且浏览器支持,这一步很重要,是否使用 http2 对优化措施有直接的影响
- js 和 css 的代码压缩,并且开启 gzip 压缩
- 使用字体图标 iconfont 代替图片图标
- CDN 合并:利用 CDN 的 combo 技术将多个 CDN 合并成一个发送(在 http2 中无明显效果)
最终优化效果
- performance 表现:页面渲染完成在 3 秒以内
- web-vitals 平均数据
- FCP:2100
- LCP:2400