本文部分内容可能会随技术发展有变化
微前端是什么
微前端是将Web应用由单一的单体应用转变为多个小型前端应用聚合为一的一种手段。本文从微前端的基础理论出发,对其核心技术进行阐述,最后结合项目进行简单的应用实践。
更准确的说,微前端是将微服务的理念应用在了浏览器端,将技术无关的多个独立的前端应用页面聚合为一个对客应用的前端架构。
比如上面这个应用框架,蓝色部分是主应用,包括导航和footer。子应用作为一个单独的应用嵌入到黄色的部分。通过路由选择展示不同的子应用,但给用户的感受就是一个完整的项目。
目前的微前端框架一般都具有以下三个特点:
- 技术栈无关:主框架不限制接入应用的技术栈,子应用具备完全自主权。
- 独立性强:独立开发、独立部署,子应用仓库独立。
-
这不就是 iframe 么?
过去,当我们需要在一个项目中嵌入另一个项目时,基本只能使用 iframe。iframe 是一个天然的沙箱,从样式到逻辑可以100%没有干扰,iframe 最大的特性就是提供了浏览器原生的硬隔离方案,不论是样式隔离、js 隔离这类问题统统都能被完美解决。但他的最大问题也在于他的隔离性无法被突破,导致应用间上下文无法被共享,随之带来的开发体验、产品体验的问题:
开发难:
- iframe内外通信困难,iframe直接通信更困难;
- 受跨域影响,cookie等状态无法共享;
- iframe内部错误无法监控
- 体验差:
- 页面加载速度慢;
- iframe内部弹窗浮层不能展示在页面中心、遮罩只能遮住iframe;
- 刷新无法保持状态,前进后端也并不如期;
于是……
2018年: 第一个微前端工具 single-spa 在 github 上开源
single-spa是一个用于前端微服务化的JavaScript前端解决方案。single-spa的核心就是定义了一套协议。协议包含主应用的配置信息和子应用的生命周期,通过这套协议,主应用可以方便的知道在什么情况下激活哪个子应用。
当前流行的大量框架都是single-spa的上层封装,但是如果作为生产选型,single-spa提供的是较为基础的api,应用在实际项目中需要进行大量封装且入侵性强,使用起来不太方便。
2019年: 基于 single-spa 的 qiankun 问世
single-spa 作为一套基础协议,直接上手开发虽然不难,但应用管理过程还是十分复杂的,qiankun 基于 single-spa 自己做了一套 JS 和 CSS 沙箱,使开发者不再关心应用状态细节,只需要你传入响应的apps的配置即可,会帮助我们去加载。
2020年:webpack 提出 Module Federation
把项目中模块分为本地模块和远程模块,远程模块不属于当前构建,在运行时从所谓的容器加载。加载远程模块是异步操作。当使用远程模块时,这些异步操作将被放置在远程模块和入口之间的下一个chunk的加载操作中,从而实现微前端的构建。
然后……就有了各种微前端的解决方案
结果……
iframe的那些问题基本都解决了,速度也能快一些!
微前端核心技术
如果不用iframe,还想要代替iframe,那么需要面对的问题其实就是如何隔离代码了。隔离方案需要考虑技术栈无关,这才是微前端真实的意义。
CSS环境隔离
experimentalStyleIsolation
首先我们需要了解 CSS Namespace 的概念,所谓 namespace 就是将所有样式放在一个确保不会冲突的 className 之下,已确保子应用样式不会影响父应用和其他子应用:
// global.less
@NAMESPACE: .Subapp_Name-<hash>;
// 所有样式文件.less
@{NAMESPACE}: {
background: gray;
}
上述方案如果纯靠各个子应用手动维护当然是不靠谱的,而微前端可以在编译过程中给子应用统一添加 namespace 以解决人工维护的问题。
CSS Module
近代前端开发,为了让 CSS 也能适用软件工程方法,从最早的Less、SASS,再到 CSS in JS,css被想尽一切办法被书写的更逻辑化,而 CSS Module 反其道而行,只加入了局部作用域和模块依赖,这恰恰是解决CSS冲突的一剂良药。
比如下面这段代码
// JSX File
import React from 'react';
import style from './App.less';
export default () => {
return (
<h1 className={style.title}>
Hello World
</h1>
);
};
// Less File
.title {
color: red;
}
编译以后就是这样了
// DOM Output
<style>
._3zyde4l1yATCOkgn {
color: red;
}
</style>
<h1 class="_3zyde4l1yATCOkgn">
Hello World
</h1>
Shadow DOM
Shadow DOM 可以做到样式之间的真正隔离(而不是依赖分配前缀等约定式隔离),其形式如下:
子应用的样式(包括动态插入的样式)都会被挂载到这个 Shadow Host 节点下,因此微应用的样式只会作用在 Shadow Tree 内部,这样就做到了样式隔离。
但如果子应用中有逻辑将代码直接渲染在 Shadow Tree 以外的部分,如 antd 中的所有浮层默认渲染在 body 上,对导致样式无法正常被使用,不过好在这些都是可以解决的。
| ```html
xxx
yyy
 | | --- | --- | 比如上方这个Demo,只会有`
`背景是黄色的。
> 关于 Shadow DOM 的细节可以移步 [MDN](https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_shadow_DOM)
| | experimentalStyleIsolation | CSS Module | Shadow DOM |
| --- | --- | --- | --- |
| 开发成本 | 实现简单,没有构建依赖 | 覆盖三方库样式不直观 | 需要额外处理一些事件和边界条件 |
| 维护成本 | 框架统一处理,成本低 | 全局编译替换、无遗漏 | 无额外维护负担 |
| 隔离效果 | 父应用可能会影响到子应用 | 各应用之间可保证没有冲突 | 渲染到Shadow DOM之外的元素样式会异常 |
| 兼容性 | 无兼容问题 | 无兼容问题 | 需要polyfill,依赖库要求高 |
| 渐进性 | 子应用接入无需调整 | 子应用需要全面改造 | 子应用接入无需调整 |
这个对比其实完全看具体项目会遇到什么问题,不过qiankun的作者会更倾向于 experimentalStyleIsolation

### JS环境隔离
我们都知道,不同的应用多少都会全局的 window 上放一些东西,所谓JS环境隔离就是要保证在子应用运行期间具有独立的上下文(window)环境。常用的方法包括**快照**和**代理。**
#### 快照沙箱

快照沙箱运行过程如上图所示,在子应用加载前将当前的 window 对象做一份快照,在其卸载时,在用快照重置整个 window 环境。快照生成比较简单,如下所示:
```javascript
this.windowSnapshot = {}; // 存放快照
for (const prop in window) {
if (window.hasOwnProperty(prop)) {
this.windowSnapshot[prop] = window[prop]; // 将window上的属性进行拍照
}
}
```
子应用即使卸载了,也有可能再次执行。因此 unmount 时需要保留沙箱内 window 的修改,以备子应用执行恢复:
```javascript
this.modifyPropsMap = {} // 存放发生变化的值
// unmount 时记录改动
for (const prop in window) {
if (!window.hasOwnProperty(prop)) continue;
if (window[prop] !== this.windowSnapshot[prop]) {
this.modifyPropsMap[prop] = window[prop]; // 保存修改后的结果
window[prop] = this.windowSnapshot[prop]; // 还原window
}
}
// mount 时恢复改动
Object.keys(this.modifyPropsMap).forEach(p => {
window[p] = this.modifyPropsMap[p];
});
```
这里我们也不难发现,快照沙箱是无法支持同时加载多个子应用的,因为同一时间不同子应用使用的上下文window 是公用的,并没有实现隔离。但如果我们吧快照给子应用使用呢,是不是可以解决这个问题?很遗憾,答案是否定的,因为这样会导致主应用无法直接修改window变量和子应用通信。
同时,这里也有一个比较明显的缺点就是每次切换时需要去遍历window,这种做法会有较大的时间消耗。
#### 代理沙箱
代理沙箱解决一个页面内同时运行多个子应用的场景。分两个步骤实现:

ES6 提供了新的 Proxy 在这里刚好可以用来解决这个问题,通过 Proxy 我们代理全局window:
```javascript
class ProxySandbox {
constructor() {
const rawWindow = window;
const fakeWindow = {}
this.proxy = new Proxy(fakeWindow, {
set(target, p, value) {
target[p] = value;
return true
},
get(target, p) {
return target[p] || rawWindow[p];
}
});
}
}
```
代理可以使我们在需要子应用的时候直接创建代理即可,像下面这样
```javascript
const sandbox = new ProxySandbox();
window.a = 1;
((window) => {
window.a = 'hello';
console.log(window.a) // hello
})(sandbox.proxy);
console.log(a); // 1
```
而在实际使用中,proxyWindow 不能直接像快照一样使用,原因是代码中类似`var a = 1`这样的语句还是会逃出 proxyWindow 挂到真实的 window 中,因此需要通过with来解析
```javascript
const javascriptResolver = new Function(`
return function (window, scriptCode) {
with(window) {
eval(scriptCode);
}
}
`);
const scripts = fetch('https://assets.cdn.net/index.js')
.then(resp => resp.text());
const sandbox = new ProxySandbox();
javascriptResolver(sandbox.proxy, scripts);
```
同时,代理使用的是getter/setter模式,也在一定程度上避免了主应用无法直接修改window变量和子应用传递数据的问题。
### 资源隔离
除了代码层面的隔离,主应用还需要处理如 localStorage、sessionStorage、indexDB、cookie 等资源读写的能力,这部分依然可以用上述js的隔离方法,只是控制粒度比 window 更细。
### 数据共享
做了这么多隔离,目的就是尽可能达到 iframe 那样的应用间互不影响的状态;但我们不用 iframe 的初衷之一就是它个隔离太严格,以至于应用内外、应用之间的通信变得异常困难。
上文中我们提到,主应用可以通过修改 window 上的内容来向子应用传递信息。但这只是单向的,同时在微前端环境下也是有问题的。因此,像qiankun这样的框架会提供专门的数据共享机制。
qiankun 提供了 globalState 和 globalError 的方案,并提供了一套用于管理数据的 API
- **initGlobalState** 定义全局状态,并返回通信方法,一般由主应用调用,微应用通过 props 获取通信方法;
- **onGlobalStateChange** 全局依赖监听,为指定应用册回调函数
- **setGlobalState** 修改全局状态,并触发所有监听
- **offGlobalStateChange** 注销应用相关监听
- **addGlobalUncaughtErrorHandler** 设置全局错误监听
- **removeGlobalUncaughtErrorHandler** 移除全局错监听
### 生命周期管理
我们有了隔离CSS和JS的方法以后,重点就在于什么时候去调用了。对于子应用的管理,实际上就是对子应用生命周期的管理。一个应用的加载其实就3个阶段:加载前(Bootstrap) 、运行时(Runtime)、卸载后(Unmount),我们几乎不用关系渲染完成和卸载前,必要场景可以提供相关的钩子,这里暂不做讨论。
#### 组合式应用路由分发
直接加载js和css;也可以是解析html,再解析出js/css依赖。

因此,组合式路由分发分为`Assets Entry`和`HTML Entry`。对于单页应用而言.只要有全部js和css文件,一个应用可以完整的呈现。而对于服务端模板页面,支持过程就有很多限制了。比如可以通过服务端模板预发动态注入脚本,但应该避免直接输出包含内容的DOM元素。
HTML 解析整体流程大致分为如下几个步骤:
1. 通过url请求到子应用的index.html。
1. 用正则匹配到其中的js/css相关标签,进行记录,然后删去。
1. 删去html/head/body等标签。
1. 返回html文本。
JS 资源解析大致分为如下几个步骤:
1. 使用正则匹配
