微前端解决方案
前言
随着技术的发展,前端应用承载的内容也日益复杂,基于此而产生的各种问题也应运而生,从 MPA
( Multi-Page Application
,多页应用)到SPA( Single-Page Application
,单页应用),虽然解决了切换体验的延迟问题,但也带来了首次加载时间长,以及工程爆炸增长后带来的巨石应用( Monolithic
)问题;对于 MPA
来说,其部署简单,各应用之间天然硬隔离,并且具备技术栈无关、独立开发、独立部署等特点。要是能够将这两方的特点结合起来,会不会给用户和开发带来更好的用户体验?至此,在借鉴了微服务理念下,微前端便应运而生。
目前社区有很多关于微前端架构的介绍,但大多停留在概念介绍的阶段。而本文会就某一个具体的类型场景,着重介绍微前端架构可以 带来什么价值以及 具体实践过程中需要关注的技术决策,并辅以具体代码,从而能真正意义上帮助你构建一个 生产可用的微前端架构系统。
什么是微前端?
微前端是一种类似于微服务的架构,它将微服务的理念应用于浏览器端,即将单页面前端应用由单一的单体应用转变为把多个小型前端应用聚合为一的应用。各个前端应用还可以独立开发、独立部署。
微前端的价值
微前端架构具备以下几个核心价值:
- 技术栈无关 主框架不限制接入应用的技术栈,子应用具备完全自主权
- 独立开发、独立部署 子应用仓库独立,前后端可独立开发,部署完成后主框架自动完成同步更新
- 独立运行时 每个子应用之间状态隔离,运行时状态不共享
微前端架构旨在解决单体应用在一个相对长的时间跨度下,由于参与的人员、团队的增多、变迁,从一个普通应用演变成一个巨石应用( Frontend Monolith)后,随之而来的应用不可维护的问题。这类问题在企业级 Web 应用中尤其常见。
针对中后台应用的解决方案
中后台应用由于其应用生命周期长(动辄 3+ 年)等特点,最后演变成一个巨石应用的概率往往高于其他类型的 web
应用。这主要带来了 技术栈落后、 编译部署慢两个问题。而从技术实现角度,微前端架构解决方案大概分为以下几类场景:
- 前端容器化:
iframe
能有效地将另一个网页/单页面应用嵌入到当前页面中,两个页面间的CSS
和JavaScript
是相互隔离的。iframe
相当于创建了一个全新的独立的宿主环境,类似于沙箱隔离,它意味着前端应用之间可以相互独立运行。如果我们做一个应用平台,会在系统中集成第三方系统,或多个不同部门团队下的系统,将iframe
作为容器来容纳其他前端应用,显然这依然是一个非常靠谱的方案。 - 微组件:借助于
Web Components
技术,开发者可以创建可重用的定制元素,来构建跨框架的前端应用。通常使用Web Components
来做子应用封装,子应用更像是一个业务组件而不是应用。真正在项目上使用Web Components
技术,离现在的我们还有些距离,可是结合Web Components
来构建前端应用,是一种面向未来演进的架构。 - 微应用:通过软件工程的方式,在部署构建环境中,把多个独立的应用组合成一个单体应用。
- 微模块:开发一个新的构建系统,将部分业务功能构建成一个独立的
chunk
代码,使用时只需要远程加载即可。
微前端架构
微前端架构很好的借鉴了 SPA
无刷新的特点,在 SPA
之上引入新的分层实现应用切换的功能:
微前端架构实践中的问题
可以发现,微前端架构的优势,正是 MPA
与 SPA
架构优势的合集。即保证应用具备独立开发权的同时,又有将它们整合到一起保证产品完整的流程体验的能力。
Stitching layer
作为主框架的核心成员,充当调度者的角色,由它来决定在不同的条件下激活不同的子应用。因此主框架的定位则仅仅是: 导航路由 + 资源加载框架。
而具体要实现这样一套架构,我们需要解决以下几个技术问题:
路由系统及 Future State
我们在一个实现了微前端内核的产品中,正常访问一个子应用的页面时,可能会有这样一个链路:
此时浏览器的地址可能是 https://app.alipay.com/subApp/123/detail
,想象一下,此时我们手动刷新一下浏览器,会发生什么情况?
由于我们的子应用都是 lazy load
的,当浏览器重新刷新时,主框架的资源会被重新加载,同时异步 load
子应用的静态资源,由于此时主应用的路由系统已经激活,但子应用的资源可能还没有完全加载完毕,从而导致路由注册表里发现没有能匹配子应用 /subApp/123/detail
的规则,这时候就会导致跳 NotFound
页或者直接路由报错。
这个问题在所有 lazy load
方式加载子应用的方案中都会碰到,早些年前 angularjs
社区把这个问题统一称之为 Future State。
解决的思路也很简单,我们需要设计这样一套路由机制:
主框架配置子应用的路由为 subApp: { url: '/subApp/**', entry: './subApp.js' }
,则当浏览器的地址为 /subApp/abc
时,框架需要先加载 entry
资源,待 entry
资源加载完毕,确保子应用的路由系统注册进主框架之后后,再去由子应用的路由系统接管 url change
事件。同时在子应用路由切出时,主框架需要触发相应的 destroy
事件,子应用在监听到该事件时,调用自己的卸载方法卸载应用,如 React
场景下 destroy = () => ReactDOM.unmountAtNode(container)
。
要实现这样一套机制,我们可以自己去劫持 url change
事件从而实现自己的路由系统,也可以基于社区已有的 ui router library
,尤其是 react-router
在 v4
之后实现了 Dynamic Routing 能力,我们只需要复写一部分路由发现的逻辑即可。这里我们推荐直接选择社区比较完善的相关实践 single-spa。
App Entry
解决了路由问题后,主框架与子应用集成的方式,也会成为一个需要重点关注的技术决策。
子应用载入方式
Monorepo
使用 single-spa
的最简单方法是拥有一个包含所有代码的仓库。通常,您只有一个 package.json
,一个的 webpack
配置,产生一个包,它在一个 html
文件中通过``标签引用。
NPM包
创建一个父应用, npm
安装每个 single-spa
应用。每个子应用在一个单独的代码仓库中,负责每次更新时发布一个新版本。当 single-spa
应用发生更改时,根应用程序应该重新安装、重新构建和重新部署。
动态加载模块
子应用自己构建打包,主应用运行时动态加载子应用资源。
方案 | 优点 | 缺点 |
---|---|---|
Monorepo | 1、最容易部署 2、单一版本控制 | 1、对于每个单独的项目来说,一个 Webpack 配置和 package.json 意味着的灵活性和自由度不足。 2、当你的项目越来越大时,打包速度越来越慢。 3、构建和部署都是捆绑在一起的,这要求固定的发版计划,而不能临时发布 |
NPM 包 | 1、 npm 安装对于开发中更熟悉,易于搭建 2、独立的 npm 包意味着,每个应用在发布到 npm 仓库之前可以分别打包 | 1、父应用必须重新安装子应用来重新构建或部署 |
动态加载模块 | 主应用与子应用之间完全解耦,子应用可以采用任何技术栈 | 会多出一些运行时的复杂度和 overhead |
很显然,要实现真正的技术栈无关跟独立部署两个核心目标,大部分场景下我们需要使用运行时加载子应用这种方案。
JS Entry vs HTML Entry
在确定了运行时载入的方案后,另一个需要决策的点是,我们需要子应用提供什么形式的资源作为渲染入口?
JS Entry
的方式通常是子应用将资源打成一个 entry script
,比如 single-spa
的 example 中的方式。但这个方案的限制也颇多,如要求子应用的所有资源打包到一个 js bundle
里,包括 css
、图片等资源。除了打出来的包可能体积庞大之外的问题之外,资源的并行加载等特性也无法利用上。
HTML Entry
则更加灵活,直接将子应用打出来 HTML
作为入口,主框架可以通过 fetch html
的方式获取子应用的静态资源,同时将 HTML document
作为子节点塞到主框架的容器中。这样不仅可以极大的减少主应用的接入成本,子应用的开发方式及打包方式基本上也不需要调整,而且可以天然的解决子应用之间样式隔离的问题(后面提到)。想象一下这样一个场景:
<!-- 子应用 index.html -->
<script src="//unpkg/antd.min.js"></script>
<body>
<main id="root"></main>
</body>
// 子应用入口
ReactDOM.render(<App/>, document.getElementById('root'))
如果是 JS Entry
方案,主框架需要在子应用加载之前构建好相应的容器节点(比如这里的 "#root"
节点),不然子应用加载时会因为找不到 container
报错。但问题在于,主应用并不能保证子应用使用的容器节点为某一特定标记元素。而 HTML Entry
的方案则天然能解决这一问题,保留子应用完整的环境上下文,从而确保子应用有良好的开发体验。
HTML Entry
方案下,主框架注册子应用的方式则变成:
framework.registerApp('subApp1', { entry: '//abc.alipay.com/index.html'})
本质上这里 HTML
充当的是应用静态资源表的角色,在某些场景下,我们也可以将 HTML Entry
的方案优化成 Config Entry
,从而减少一次请求,如:
framework.registerApp('subApp1', { html: '', scripts: ['//abc.alipay.com/index.js'], css: ['//abc.alipay.com/index.css']})
总结一下:
远程加载
微前端架构下,我们需要获取到子应用暴露出的一些钩子引用,如 bootstrap
、 mount
、 unmount
等(参考 single-spa
),从而能对接入应用有一个完整的生命周期控制。而由于子应用通常又有集成部署、独立部署两种模式同时支持的需求,使得我们只能选择 umd
这种兼容性的模块格式打包我们的子应用。如何在浏览器运行时获取远程脚本中导出的模块引用也是一个需要解决的问题。
通常我们第一反应的解法,也是 最简单的解法就是与子应用与主框架之间约定好一个全局变量,把导出的钩子引用挂载到这个全局变量上,然后主应用从这里面取生命周期函数。
这个方案很好用,但是最大的问题是, 主应用与子应用之间存在一种强约定的打包协议。那我们是否能找出一种松耦合的解决方案呢?
很简单, 我们只需要走 umd
包格式中的 global export
方式获取子应用的导出即可,大体的思路是通过给 window
变量打标记,记住每次最后添加的全局变量,这个变量一般就是应用 export
后挂载到 global
上的变量。实现方式可以参考 systemjs global import,这里不再赘述。
一般子应用构建后会生成很多个chunk,主应用怎么知道要加载的子应用有哪些chunk呢?又如何将它们一一加载到主应用中呢?
我们的实现思路,就是让子项目使用 stats-webpack-plugin
插件,每次打包后都输出一个 只包含重要信息的 manifest.json
文件,用 create-react-app
搭建的 react
应用中 webpack
配置默认使用 webpack-manifest-plugin
生成资源清单。父项目先 ajax
请求 这个 json
文件,从中读取出需要加载的 js
目录,然后同步加载。
webpack-manifest-plugin
插件生成的资源清单 asset-manifest.json
{
"files": {
"main.js": "/index.js",
"main.js.map": "/index.js.map",
"static/js/1.97da22d3.chunk.js": "/static/js/1.97da22d3.chunk.js",
"static/js/1.97da22d3.chunk.js.map": "/static/js/1.97da22d3.chunk.js.map",
"static/css/2.8e475c3e.chunk.css": "/static/css/2.8e475c3e.chunk.css",
"static/js/2.67d7628e.chunk.js": "/static/js/2.67d7628e.chunk.js",
"static/css/2.8e475c3e.chunk.css.map": "/static/css/2.8e475c3e.chunk.css.map",
"static/js/2.67d7628e.chunk.js.map": "/static/js/2.67d7628e.chunk.js.map",
"static/css/3.5b52ba8f.chunk.css": "/static/css/3.5b52ba8f.chunk.css",
"static/js/3.0e198e04.chunk.js": "/static/js/3.0e198e04.chunk.js",
"static/css/3.5b52ba8f.chunk.css.map": "/static/css/3.5b52ba8f.chunk.css.map",
"static/js/3.0e198e04.chunk.js.map": "/static/js/3.0e198e04.chunk.js.map",
"index.html": "/index.html"
},
"entrypoints": [
"index.js"
]
}
应用隔离
微前端架构方案中有两个非常关键的问题,有没有解决这两个问题将直接标志你的方案是否真的生产可用。比较遗憾的是此前社区在这个问题上的处理都会不约而同选择”绕道“的方式,比如通过主子应用之间的一些默认约定去规避冲突。而今天我们会尝试从纯技术角度,更智能的解决应用之间可能冲突的问题。
样式隔离
由于微前端场景下,不同技术栈的子应用会被集成到同一个运行时中,所以我们必须在框架层确保各个子应用之间不会出现样式互相干扰的问题。
Shadow DOM?
针对 "Isolated Styles"
这个问题,如果不考虑浏览器兼容性,通常第一个浮现到我们脑海里的方案会是 Web Components
。基于 Web Components
的 Shadow DOM
能力,我们可以将每个子应用包裹到一个 Shadow DOM
中,保证其运行时的样式的绝对隔离。
但 Shadow DOM
方案在工程实践中会碰到一个常见问题,比如我们这样去构建了一个在 Shadow DOM
里渲染的子应用:
const shadow = document.querySelector('#hostElement').attachShadow({mode: 'open'});
shadow.innerHTML = '<sub-app>Here is some new text</sub-app><link rel="stylesheet" href="//unpkg.com/antd/antd.min.css">';
由于 子应用的样式作用域仅在 shadow
元素下,那么一旦子应用中出现运行时越界跑到外面构建 DOM
的场景,必定会导致构建出来的 DOM
无法应用子应用的样式的情况。
比如 sub-app
里调用了 antd modal
组件,由于 modal
是动态挂载到 document.body
的,而由于 Shadow DOM
的特性 antd
的样式只会在 shadow
这个作用域下生效,结果就是弹出框无法应用到 antd
的样式。解决的办法是把 antd
样式上浮一层,丢到主文档里,但这么做意味着子应用的样式直接泄露到主文档了。 gg...
CSS Module? BEM?
社区通常的实践是通过约定 css
前缀的方式来避免样式冲突,即各个子应用使用特定的前缀来命名 class
,或者直接基于 css module
方案写样式。对于一个全新的项目,这样当然是可行,但是通常微前端架构更多的目标是解决存量/遗产 应用的接入问题。很显然遗产应用通常是很难有动力做大幅改造的。
最主要的是, 约定的方式有一个无法解决的问题,假如子应用中使用了三方的组件库,三方库在写入了大量的全局样式的同时又不支持定制化前缀?比如 a
应用引入了 antd 2.x
,而 b
应用引入了 antd 3.x
,两个版本的 antd
都写入了全局的 .menu class
,但又彼此不兼容怎么办?
Dynamic Stylesheet !
解 决方案其实很简单,我们只需要在应用切出/卸载后,同时卸载掉其样式表即可,原理是浏览器会对所有的样式表的插入、移除做整个 CSSOM
的重构,从而达到 插入、卸载 样式的目的。这样即能保证,在一个时间点里,只有一个应用的样式表是生效的。
上文提到的 HTML Entry
方案则天生具备样式隔离的特性,因为应用卸载后会直接移除去 HTML
结构,从而自动移除了其样式表。
比如 HTML Entry
模式下,子应用加载完成的后的 DOM
结构可能长这样:
<html>
<body>
<main id="subApp">
// 子应用完整的 html 结构
<link rel="stylesheet" href="//alipay.com/subapp.css">
<div id="root">....</div>
</main>
</body>
</html>
当子应用被替换或卸载时, subApp
节点的 innerHTML
也会被复写, //alipay.com/subapp.css
也就自然被移除样式也随之卸载了。
JS 隔离
解决了样式隔离的问题后,有一个更关键的问题我们还没有解决: 如何确保各个子应用之间的全局变量不会互相干扰,从而保证每个子应用之间的软隔离?
这个问题比样式隔离的问题更棘手,社区的普遍玩法是给一些全局副作用加各种前缀从而避免冲突。但其实我们都明白,这种通过团队间的”口头“约定的方式往往低效且易碎,所有依赖人为约束的方案都很难避免由于人的疏忽导致的线上 bug
。那么我们是否有可能打造出一个好用的且完全无约束的 JS
隔离方案呢?
针对 JS
隔离的问题,我们独创了一个运行时的 JS
沙箱。简单画了个架构图:
即在应用的 bootstrap
及 mount
两个生命周期开始之前分别给全局状态打下快照,然后当应用切出/卸载时,将状态回滚至 bootstrap
开始之前的阶段,确保应用对全局状态的污染全部清零。而当应用二次进入时则再恢复至 mount
前的状态的,从而确保应用在 remount
时拥有跟第一次 mount
时一致的全局上下文。
快照可以理解为暂存mount
前的window
对象,mount
后会产生一个新的window
对象,当umount
后且回到暂存的window
对象
当然沙箱里做的事情还远不止这些,其他的还包括一些对全局事件监听的劫持等,以确保应用在切出之后,对全局事件的监听能得到完整的卸载,同时也会在 remount
时重新监听这些全局事件,从而模拟出与应用独立运行时一致的沙箱环境。
初始加载主应用的脚本时可能全局监听了某些事件,比如window.resize
,当umount
后且回到暂存的window
对象需要重新监听一边
qiankun VS single-spa
上述描述的问题是 qiankun
框架在蚂蚁落地产生的, qiankun
是基于 single-spa
开发的,那它与 single-spa
又有哪些区别呢?
qiankun
在乾坤的角度,微前端就是“微应用加载器”,它主要解决的是:如何 安全、 快速的把多个分散的项目集中起来的问题,这从乾坤自身提点便可看出:
所有这些特性都是服务于“微应用加载器”这个定位。
single-spa
在single-spa的角度,微前端就是“微模块加载器”,它主要解决的是:如何实现前端的“微服务化”,从而让应用、组件、逻辑都成为可共享的微服务,这从single-spa关于微前端的概述中可以看出:
在 single-spa
看来微前端有三种类型:微应用、微组件、微模块,实际上 single-spa
要求它们都以 SystemJS
的形式打包,换句话说它们本质上都是 微模块。
SystemJS
是一个运行时加载模块的工具,是现阶段下(浏览器尚未正式支持importMap
)原生ES Module
的完全替代品。SystemJS
动态加载的模块必须是SystemJS
模块或者UMD
模块。
qiankun与single-spa区别?
乾坤基于 single-spa
,加强了微应用集成能力,却抛弃了微模块的能力。所以,它们的区别就是 微服务的粒度,乾坤的所能服务的粒度是应用级别,而 single-spa
则是模块级别。它们都能将前端进行拆分,只是拆分的粒度不同罢了。
- 微应用加载器:“微”的粒度是应用,也就是
HTML
,它只能做到应用级别的分享 - 微模块加载器:“微”的粒度是模块,也就是
JS
模块,它能做到模块级别的分享
为什么要这么做呢?我们要想清楚这两个框架出现的背景:
qiankun:阿里内部有大量年久失修的项目,业务侧急需工具去把他们快速、安全的集成到一起。在这个角度,乾坤根本没有做模块联邦的需求,它的需求仅仅是如何快速、安全的把项目集成起来。所以乾坤是想做一个微前端工具。
single-spa:学习后端的微服务,实现前端的微服务化,让应用、组件以及逻辑都成为可共享的微服务,实现真正意义上的微前端。所以 single-spa
是想做一个 game-changer
。
这里我还整理了一个图方便理解:
总结
迁移微前端主要考虑三个点:子应用加载、样式隔离、脚本隔离。上文分析后分别给出了一些可行性方案: SystemJS
动态加载脚本、 Dynamic Stylesheet
、快照。