微前端性能优化的那些事
背景
实际工作中,我负责的业务系统已经迭代了很长的时间了,整个系统成一个复杂的巨石应用,整体技术体系落后,代码体积持续增加。为为了解构巨石应用,同时分模块逐步重构整个应用系统,前端我们引入了 qiankun
架构。
最终我们将核心的模块全部进行重构,并且使用 qiankun
封装成微应用,在各个业务系统和门户之间进行接入,极大降低了开发成本和维护成本,受到了开发者和伙伴部门的欢迎。
但是在实际推广过程中也是遇到一些架构层次的局限性,主要有以下三个主要局限性:
- 1、需要设计新的权限系统,来适配各个业务系统之间的用户鉴权和授权管理。
- 2、外部系统接入子应用时,大部分有一些定制开发的需要,如何让伙伴开发进行快速定制。
- 3、微应用 TTI(Time to Interactive)可交互时间超过3s,用户等待感明显。
本团队针对这个三个方面都做了一些良好的设计尝试,也都想分享给大家。本文就先介绍微前端的性能优化部分,这部分相对独立,另外两部分请允许我后续发文章分享给大家。
如果大家想了解如何将微前端进行落地,可以看这篇《手把手教你用 qiankun
落地微前端利器》文章,了解一些前置知识。
优化思路
我们的核心目标是将加载时间进行压缩,让用户感受不到等待时间,因此设立以下技术核心指标:
1、FCP
400ms
微应用 FCP
(First Contentful Paint)首次内容绘制: 400ms以内。
主应用 FCP
时间不受影响
2、TTI
2000ms
微应用 TTI
(Time to Interactive)可交互时间 压缩到 2s以内(预加载子应用全局数据)。
主应用 TTI
要保证不受微应用的 http 的影响。
接下来梳理整个微前端框架的实际加载过程,这里花了一个微前端执行过程的草图。
对启动可能得优化点进行标注,梳理出一下的性能可以优化的地方:
- 1、提前加载时机,进行预加载和按需加载。
- 2、资源共享,实现微前端应用之间的前端资源共享。
- 3、优化
qiankun
执行性能。 - 4、优化微应用的首屏,进行传统网页优化。
接下来就是按照整体规划进行逐个击破,向着我们的目标前进。
各个击破
1、提前加载时机
<div
:style="{ height: visible ? '0px' : 0 }"
class="micro-app-wrapper"
:class="{
hidden: !visible,
}"
>
<div v-show="loading && visible" class="micro-app-loading">
<a-spin :spinning="loading" />
</div>
<div id="micro-app-container">微应用挂载节点</div>
</div>
这是个vue主应用因为微应用的模板,请将它放到router-view
这一层次,这样能够保证其首次进入页面,且组件渲染后能够进行预加载。
<micro-app-container @visibleChange="onMicroAppVisibleChange"></micro-app-container>
<!-- 并列层级 -->
<router-view :class="{ 'app-content': !hideAppContent, 'micro-app-content': microAppVisible }" />
如果采用在路由的组件内加载就会导致只有进入到这路由页面,才会触发qiankun
的加载和渲染,会导致用户要等待微应用加载完之后才能体验页面。
其次微应用的加载实际并不是越早越好,要在主应用完成加载和渲染之后的空闲时间,再启动微应用的加载。毕竟浏览器的请求并发存在限制,且qiankun
是通过解析 HTML 模板,重构 XHR 的 HTTP 请求,来进行 JS 和 CSS 资源的请求,其 HTTP 的请求压力就更大了。
因此选择在 Layout
组件挂载之后进行预加载。
// container.vue
{
mounted() {
registerMicroApps([...], {
beforeLoad: () => { /** 执行动画 */ },
afterMount: () => { /** 结束动画 */ },
});
start({
prefetch: 'all',
});
}
}
qiankun
预加载的属性是prefetch
,默认为 true
。
- 配置为
true
则会在第一个微应用 mount 完成后开始预加载其他微应用的静态资源 - 配置为
'all'
则主应用start
后即开始预加载所有微应用静态资源 - 配置为
string[]
则会在第一个微应用 mounted 后开始加载数组内的微应用资源 - 配置为
function
则可完全自定义应用的资源加载时机 (首屏应用及次屏应用)
因此设置为'all'
,这里和我们预期中的 true
的含义不同,这里需要我们认真看下 API 的说明。
2、资源共享
接下来我们将优化重点集中到qiankun
的微应用加载过程。
- 首先读取
entry
的 HTML 文件,解析其中的内容。 - 抽取 HTML 文件内的 JS 文件请求,使用重构后的 fetch 请求进行 JS 资源拉取,并且对返回的 JS 代码进行沙箱隔离,上下文设置。
- 抽取 HTML 文件内的 CSS 文件请求,使用重构后的 fetch 请求进行 CSS 资源拉取,并且对返回的 CSS 代码进行样式沙箱隔离(
shadow DOM
)。
上面的描述是简单的描述,实际过程要比这个更加复杂和科学。我们要分析这个过程中,哪些环节是可以进行优化的。
依赖库共享
常规业务系统中,微前端框架下的应用之间往往具有很高的相似性,常见的是应用之间的技术体系和UI体系是保持一致的,那么项目中的组件依赖库是具有可复用的特性的,假如我们可以将这些依赖进行提取公共部分,统一在主应用上进行一次加载,之后的所有微应用可以共享,是否可以提升微应用的加载速度呢?从这个角度出发,我进行了几个方案的提取尝试,这里我之所以将错误方案的研究也给大家讲述了,是因为我觉得这些错误的尝试过程也是宝贵的经验,常言道:失败是成功之母。在这个过程中我们也能触类旁通收获更多的知识。
依赖库共享-splitChunks方案-错误尝试
新版webpack
不推荐使用CommonsChunkPlugin
了,大家使用进行代码分割的时候注意下webpack
的版本。
接下来直接将webpack-chain
的 JS 配置给大家放出来。
提取公共 microbase chunk,将常用的工具库进行提取,当时如果技术栈统一前端框架react
和UI组件库antd
都是可以提取,配置如下:
configChain.optimization.splitChunks({
...configChain.optimization.get('splitChunks'),
...{
chunks: 'all',
...
name: false,
cacheGroups: {
polyfill: {
test: /[\\/]node_modules[\\/](core-js|@babel|regenerator-runtime)/,
name: 'polyfill',
priority: 70,
minChunks: 1,
reuseExistingChunk: true
},
vendor: {
test: /[\\/]node_modules[\\/](redux|react-redux|redux-thunk|react-dom|react-router-dom|dayjs)/,
name: 'vendor',
...
},
microBase: {
test: /[\\/]node_modules[\\/](axios|moment|lodash|@tencent\/ftech-helpers|xss|uuid|qiankun)/,
name: 'microBase',
...
},
antd: {
test: /[\\/]node_modules[\\/](antd|@ant-design)/,
name: 'antd',
...
},
},
},
});
同时关闭微应用中该 JS 的文件的引入。
const MicroBaseReg = /<script src="\/smart-contact-cms\/assets\/js\/microBase(.*?).js"><\/script>/g;
start({
getTemplate(tpl: string) {
const html = tpl.replace(MicroBaseReg, '');
return html;
},
prefetch: 'all',
})
实际到这里大家就很明显发现这个方案是不可行的,因为我忽略了前端模块化已经是前端工程的标配了,其提取的 JS 文件的头部已经基于项目 package.json
的name
生成了webpackJsonp_productName
的 chunk 数组。
// 主应用的 JS 文件
((typeof self !== 'undefined' ? self : this)["webpackJsonp_industry-platform"] = (typeof self !== 'undefined' ? self : this)["webpackJsonp_industry-platform"] || []).push([["microBase"],{})
// 微应用的 JS 文件
(window["webpackJsonp_smart-contact-cms"] = window["webpackJsonp_smart-contact-cms"] || []).push([["microBase"],{
主应用所有模块放入到 webpackJsonp_industry-platform
数组,而微应用项目放入到了webpackJsonp_smart-contact-cms
,相互之间无法引用。
### 依赖库共享-webpack externals方案
webpack externals
能够将一些第三方包通过外部扩展的方式,通过 CDN 的方式引入到项目中,在 NPM 没有成为主流之前,前端 JS 的引入方式就是这种方式的雏形。
<script
src="https://code.jquery.com/jquery-3.1.0.js"
integrity="sha256-slogkvB1K3VOkzAI8QITxV3VzpOnkeNVsKvtkYLMjfk="
crossorigin="anonymous"
></script>
module.exports = {
//...
externals: {
jquery: 'jQuery',
},
};
假如应用之间存在较大的公共依赖库,例如antd/g6
,antd
, react
, jquery
等大型仓库,就可以将这些依赖通过外部 CDN 的方式引入,避免应用之间 JS 文件的重复请求。
但是有些细节需要注意,假如你将子应用中的公共 js 文件请求通过上文代码中getTemplate
进行过滤,节省一次 Http 请求,你需要将qiankun
的沙箱模式关闭,不然会引入不可控的bug
。
const MicroBaseReg = /<script src="\/smart-contact-cms\/assets\/js\/microBase(.*?).js"><\/script>/g;
start({
getTemplate(tpl: string) {
const html = tpl.replace(MicroBaseReg, '');
return html;
},
prefetch: 'all',
sandbox: false,
})
这里实际不将公共的 JS 文件过滤也是可以的,性能影响经过测试,发现实际不大,因为资源是网络地址是完全一样的,因此浏览器会缓存它,最大的性能开销-http请求已经被解决了。
其次是使用 CDN 加载第三方库的数量不宜过多,过多的CDN请求,会占据浏览器并发请求数,导致父应用加载速度变慢,得不偿失了。
目前的方案也是官方推荐的方案,官方承诺在 qiankun2.0
给出更加智能方式使其自动化,让我们拭目以待吧。
请求数据共享
影响微应用尽快让用户体验页面的最后一个流程是数据请求阶段,页面需要后台数据到达之后才能渲染对用户有意义的页面。
那第一个思考方向是将父应用和子应用,或者子应用之间的数据进行跨应用共享,每个数据只需要请求一次,然后所有应用共享,可以极大减少用户等待数据的时间。
目前qiankun
提供了initGlobalState
的方案。
// 主应用
import { initGlobalState, MicroAppStateActions } from 'qiankun';
// 初始化 state
const actions: MicroAppStateActions = initGlobalState(state);
actions.onGlobalStateChange((state, prev) => {
// state: 变更后的状态; prev 变更前的状态
console.log(state, prev);
});
actions.setGlobalState(state);
actions.offGlobalStateChange();
子应用这里做一些优化,将父应用传递下来的参数封装成MainAppContext
,使用react.createContext
进行全局共享。
const renderApp = (props: MainAppProps) => {
const { container } = props;
ReactDOM.render(
<MainAppContext.Provider value={props}>
<Provider store={store}>
<HashRouter basename={prefixPath}>
<ConfigProvider locale={zhCN} prefixCls='cmsAnt'>
<App />
</ConfigProvider>
</HashRouter>
</Provider>
</MainAppContext.Provider>,
container ? container.querySelector('#root') : document.querySelector('#root'),
);
};
export const mount = async (props: any) => {
console.log('[smart-contact-cms] mount', props);
// 删除不能序列化的内容
normalizeObject(props.mainAppState);
// 作为微应用运行
renderApp(props);
};
详细细节参考《qiankun官网initGlobalState》的介绍。
3、优化qiankun
执行性能
这个 topic 确实需要对qiankun
的源码有足够的了解才行,之前的做的优化是参考官方issues
:《# [RFC] qiankun的极致性能优化思路,与import-html-entry有关》的一个讨论来做的,写这个文章的时候本来是想分享这个的,当时最近在帖子的尾部看到官方支持了这次优化,并且开启方式十分简单。
qiankun 2.8 之后增加了性能加载模式,通过
speedy: true
开启,在保留沙箱的模式基础上做了window和document 实例的性能优化,加载速度确实快了点。
这个优化目前是固定到特定版本,例如2.8.0,而最新的版本2.8.3则会执行报错,本人也没去深入研究报错的具体原因,大家先按照qiankun@2.8.0
即可使用这个设计。
start({
prefetch: 'all',
sandbox: {
speedy: true,
},
});
4、微应用的首屏优化
主要集中精力在提升子应用的首屏时间、调整请求加载顺序、父子应用数据共享。例如将initGlobalState
深度应用到微应用代码中,减少数据请求等等。这类的方案的文章也是到处都是,因此也就不鹦鹉学舌了,大家有兴趣的自行检索即可,我日常比较关注 前端加加 和 奇舞团 两个前端公众号,大家有兴趣也可以去看下。
结语
通过上面的设计,我们基本满足了方案之初的性能指标,具体提升的比例并没有进行深入的计算,给大家列下目前的状态。
子应用 FCP:230+ms 子应用 TTI:列表页压缩到 2s,内容编辑页压缩到 3.5s,数据共享的页面 220+ms 基本能渲染结束
微前端是个很好的前端架构,在这个基础上我们衍生了跨应用动态主题的应用场景,所有主应用可以在运行时控制其任意子应用的动态主题,进一步扩展微应用的扩展能力,方便更多的业务系统进行定制接入,有兴趣的同学可以看看我的这篇文章《高度兼容低版本的 antd 的动态主题方案》。
文章到此就结束了,非常感谢你能读到这里,拜拜👋🏻。
转载自:https://juejin.cn/post/7155754068764262414