# 什么是微前端
微前端是一种多个团队通过独立发布功能的方式来共同构建现代化 web 应用的技术手段及方法策略。
微前端架构特点:
- 无关技术栈
- 开发、仓库、部署独立
- 允许增量升级、渐进式重构
- 微应用之间状态隔离,运行时状态不共享
# 关于 Iframe
如果不考虑体验问题,iframe 几乎是最完美的微前端解决方案了。
iframe 最大的特性就是提供了浏览器原生的硬隔离方案,不论是样式隔离、js 隔离这类问题统统都能被完美解决。但他的最大问题也在于他的隔离性无法被突破,导致应用间上下文无法被共享,随之带来的开发体验、产品体验的问题。
- url 不同步,浏览器刷新 iframe url 状态丢失、后退前进按钮无法使用。
- dom 结构不共享,想象一下屏幕右下角 1/4 的 iframe 里来一个带遮罩层的弹框,同时我们要求这个弹框要浏览器居中显示,还要浏览器 resize 时自动居中..
- 全局上下文完全隔离,内存变量不共享。iframe 内外系统的通信、数据同步等需求,主应用的 cookie 要透传到根域名都不同的子应用中实现免登效果。
- 慢。每次子应用进入都是一次浏览器上下文重建、资源重新加载的过程。
更多关于微前端的相关介绍,推荐大家可以去看这几篇文章:
# 各大框架
# QianKun
QianKun (opens new window) 基于 single-spa ,阿里系开源的微前端框架,应该也是大家接触最多的了,社区比较活跃,这点比较重要。
# 实现方案
single-spa是基于js-entry方案,而qiankun 是基于html-entry 及沙箱设计,使得微应用的接入像使用 iframe 一样简单。
主应用监听路由,加载对应子应用的html,挂载到主应用的元素内,然后解析子应用的html,从中分析出css、js再去沙盒化后加载执行,最终将子应用的内容渲染出来。
qiankun实现样式隔离有两种模式可供开发者选择:
- trictStyleIsolation:这种模式下 qiankun 会为每个微应用的容器包裹上一个 shadow dom 节点,从而确保微应用的样式不会对全局造成影响
- experimentalStyleIsolation: 当 experimentalStyleIsolation 被设置为 true 时,qiankun 会改写子应用所添加的样式,会为所有样式规则增加一个特殊的选择器规则,来限定其影响范围
qiankun 实现js 隔离,采用了两种沙箱,分别为基于Proxy实现的沙箱和快照沙箱,当浏览器不支持Proxy会降级为快照沙箱
流程
1.主应用注册微应用
2.URL 发生变化时匹配激活规则,然后加载对应的微应用
3.获取微应用的入口 HTML 内容(通过 fetch 请求)
4.解析 HTML,获取脚本、样式等资源
5.创建沙箱环境(JS 沙箱和样式沙箱)
6.沙箱环境中执行微应用的 JavaScript 代码,并调用微应用暴露的生命周期函数(bootstrap, mount, unmount)
7.通信通过全局状态 / 自定义事件实现
qiankun 通过 fetch 请求获取子应用资源,通过 import-html-entry 库提取出 html、css 和 js,在 默认直接创建容器通过 scoped 管理样式或选择在 shadow dom 中应用 html 和 css ,通过快照或者 Proxy 沙箱隔离 js 操作
Html Entry 机制
async function fetchSubAppHTML(entry) {
const html = await fetch(entry).then(res => res.text());
return html;
}
// 2. 解析 HTML,执行 JS,应用 CSS
function processHTML(html, app) {
const template = document.createElement('div');
template.innerHTML = html;
// 提取和处理所有资源
extractScripts(template, app);
extractStyles(template, app);
extractHTMLContent(template, app);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Proxy沙箱机制:
- 创建一个假的全局对象(fakeWindow)作为代理的目标对象。
- 用 Proxy 拦截 fakeWindow 上的操作,包括 get、set、has 等。
- 替换全局执行环境,将子应用的代码包裹在一个函数中,然后通过call方法将 fakeWindow 作为this传入,同时将 fakeWindow 作为参数传入。这样,在子应用代码中,如果使用this或者window(参数名)来访问全局对象,就会访问到 fakeWindow
- 当子应用修改全局变量时,实际上修改的是 fakeWindow,不会影响主应用的全局变量。
- 当子应用读取全局变量时,优先从 fakeWindow 上读取,如果 fakeWindow 上没有,则从主应用的全局对象(window)上读取。
// 伪代码
class ProxySandbox {
constructor() {
const rawWindow = window;
const fakeWindow = {}
const proxy = new Proxy(fakeWindow, {
set(target, p, value) {
target[p] = value;
return true
},
get(target, p) {
return target[p] || rawWindow[p];
}
});
this.proxy = proxy
}
}
let sandbox1 = new ProxySandbox();
let sandbox2 = new ProxySandbox();
window.a = 1;
// 伪代码
((window) => {
window.a = 'hello';
console.log(window.a) // hello
})(sandbox1.proxy);
((window) => {
window.a = 'world';
console.log(window.a) // world
})(sandbox2.proxy);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
快照沙箱:
// 伪代码
class SnapshotSandbox {
constructor() {
this.proxy = window;
this.modifyPropsMap = {}; // 修改了那些属性
this.active(); // 调用active保存主应用window快照
}
/**1. 初始化时,在子应用即将mount前,先调用active,保存当前主应用的window快照*/
active() {
this.windowSnapshot = {}; // window对象的快照
for (const prop in window) {
if (window.hasOwnProperty(prop)) {
// 将window上的属性进行拍照
this.windowSnapshot[prop] = window[prop];
}
}
Object.keys(this.modifyPropsMap).forEach(p => {
window[p] = this.modifyPropsMap[p];
});
}
/**
* 子应用卸载时,遍历当前子应用的window属性,和主应用的window快照做对比
* 如果不一致,做两步操作
* 1. 保存 不一致的window属性,
* 2. 还原window
*/
inactive() {
for (const prop in window) { // diff 差异
if (window.hasOwnProperty(prop)) {
// 将上次拍照的结果和本次window属性做对比
if (window[prop] !== this.windowSnapshot[prop]) {
// 保存修改后的结果
this.modifyPropsMap[prop] = window[prop];
// 还原window
window[prop] = this.windowSnapshot[prop];
}
}
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
如和改变子应用全局作用域
// qiankun 内部大致实现思路
function execScript(scriptText, sandbox) {
const { fakeWindow } = sandbox;
// 使用 Function 构造器,将 window 替换为 fakeWindow
const scriptRunner = new Function('window', 'self', scriptText);
scriptRunner.call(fakeWindow, fakeWindow, fakeWindow);
}
2
3
4
5
6
7
8
使用 Function 构造器动态创建一个函数:
- 第一个参数
'window'是新函数的第一个形参名 - 第二个参数
'self'是新函数的第二个形参名 scriptText是函数体(要执行的子应用代码)- 这相当于创建了一个这样的函数:
function scriptRunner(window, self) {
// scriptText 的内容
}
2
3
调用刚创建的函数:
call()方法的第一个参数fakeWindow是this绑定值- 后面的两个
fakeWindow参数分别传递给函数的第一个和第二个形参(即window和self)
工作原理详解
- JavaScript 作用域欺骗
当子应用中有如下代码时:
window.myVariable = 'hello';
console.log(window.myVariable);
2
由于我们通过 new Function('window', scriptText) 创建函数,上面的代码实际上变成了:
function scriptRunner(window) {
window.myVariable = 'hello';
console.log(window.myVariable);
}
2
3
4
当我们调用 scriptRunner.call(null, fakeWindow) 时,就相当于:
function scriptRunner(fakeWindow) {
fakeWindow.myVariable = 'hello';
console.log(fakeWindow.myVariable);
}
2
3
4
- this 指向控制
通过 call(fakeWindow, ...) 确保了在子应用代码中直接使用 this 时也指向 fakeWindow:
// 子应用中的代码
console.log(this === window); // true,在沙箱环境中都指向 fakeWindow
2
- 完整的转换示例
假设子应用有这样一段代码:
// 子应用代码 (scriptText)
var appName = 'sub-app';
window.version = '1.0';
self.description = 'This is sub app';
console.log(this.appName);
2
3
4
5
经过 execScript 处理后,实际执行效果类似于:
// 转换后的执行环境
(function(window, self) {
var appName = 'sub-app';
window.version = '1.0';
self.description = 'This is sub app';
console.log(this.appName);
}).call(fakeWindow, fakeWindow, fakeWindow);
2
3
4
5
6
7
这样,子应用中所有的:
window.xxx操作都变成对fakeWindow的操作self.xxx操作也都变成对fakeWindow的操作- 直接的
this引用也指向fakeWindow
# 优点
html entry的接入方式,不需要自己写load方法,而是直接写子应用的访问链接就可以。
提供js沙箱
提供样式隔离,两种方式可选 资源预加载,在浏览器空闲时间预加载未打开的微应用资源,加速微应用打开速度。
社区活跃 umi 插件,提供了 @umijs/plugin-qiankun 供 umi 应用一键切换成微前端架构系统,除了最后一点拓展以外,微前端想要达到的效果都已经达到。
应用间通信简单,全局注入路由保持,浏览器刷新、前进、后退,都可以作用到子应用
路由保持,浏览器刷新、前进、后退,都可以作用到子应用
# 缺点
改造成本较大,从 webpack、代码、路由等等都要做一系列的适配
对 eval 的争议,eval函数的安全和性能是有一些争议的:MDN的eval介绍;
无法同时激活多个子应用,也不支持子应用保活
无法支持 vite 等 ESM 脚本运行,3.0 版本预计支持
# wujie
无界是腾讯推出的一款微前端解决方式。它是一种基于 Web Components + iframe 的全新微前端方案,继承iframe的优点,补足 iframe 的缺点,让 iframe 焕发新生。
# 实现方案
wujie跟qiankun一样,都是基于html entry加载的,但他们解析html的过程是不一样的。 qiankun是直接解析并执行js、css、html的,而wujie则是先解析html,提取出script脚本放入空的iframe中,提取出css、html放入到web components中,具体来说:
- 解析入口 HTML ,分别得到script、css、模版html
- 创建一个纯净的 iframe,为了实现应用间(iframe 间)通讯,无界子应用 iframe 的 url 会设置为主应用的域名(同域),因此 iframe 的 location.href 并不是子应用的 url。创建好后停止加载iframe
- iframe内插入js,将抽离出来的script脚本,插到iframe中去,在iframe中执行子应用的js
- 创建web component,id为子应用id,将抽离出来的html插入
- 由于iframe内的js有可能操作dom,但是iframe内没有dom,随意wujie框架内对iframe拦截document对象,统一将dom指向shadowRoot,此时比如新建元素、弹窗或者冒泡组件就可以正常约束在shadowRoot内部
wujie 会为每一个子应用创建一个同源的 iframe 以方便主应用修改其内容,也就是说一个子应用对应一个iframe,但这个iframe是隐藏的,会把子应用地址获取到的资源提取的样式和元素(处理过)插入这个 iframe 中(也就是说 iframe 并不是直接渲染子应用的地址)并执行提取的 js,然后把元素和样式挂载到 Shadow DOM 上,创建一个代理对象,替换掉iframe中的 document 以代理 iframe 中的 dom 操作,执行 dom 操作时变为操作 Shadow DOM,然后通过 postMessage 进行跨域通信和数据同步。
简化的代理操作
// 在主应用中
const shadowRoot = document.querySelector('wujie-app').shadowRoot;
// 在子应用的 iframe 中
const originalDocument = iframe.contentWindow.document;
// 重写 createElement
originalDocument.createElement = function(tagName) {
// 在 Shadow DOM 中创建真实元素
const element = shadowRoot.createElement(tagName);
// 返回一个代理对象,拦截对元素的操作
return new Proxy(element, {
set(target, key, value) {
// 如果设置的是样式,需要映射到真实元素上
if (key === 'style') {
for (let styleKey in value) {
target.style[styleKey] = value[styleKey];
}
} else if (key.startsWith('on')) {
// 事件处理,例如 onclick、onchange 等
const eventName = key.slice(2);
target.addEventListener(eventName, value);
} else {
target[key] = value;
}
return true;
},
get(target, key) {
// 如果访问的是 appendChild 等方法,需要重写这些方法以在 Shadow DOM 中操作
if (key === 'appendChild') {
return function(child) {
// 如果 child 是一个代理,那么需要获取其真实元素
const realChild = child instanceof Proxy ? child : child;
return shadowRoot.appendChild(realChild);
};
}
// 其他属性直接返回
return target[key];
}
});
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
# 优点
接入简单,可以以组件的方式引入子应用
纯净无污染
- 无界利用iframe和webcomponent来搭建天然的js隔离沙箱和css隔离沙箱
- 利用iframe的history和主应用的history在同一个top-level browsing context来搭建天然的路由同步机制
- 副作用局限在沙箱内部,子应用切换无需任何清理工作,没有额外的切换成本
支持vite esmoudle加载,由于js是独立在iframe中加载的,所以支持esmodule加载
支持预加载
支持应用保活,子应用状态保留,由于是独立在iframe中的,而切换应用时不会移除iframe,所以子应用的状态会被保留在原来的iframe中,当主应用再次渲染子应用dom时,会显示之前的状态。
多应用同时激活在线
# 缺点
iframe沙箱的src设置了主应用的host,初始化iframe的时候需要等待iframe的location.orign从’about:blank’初始化为主应用的host,这个采用的计时器去等待的不是很优雅
# Micro App
Micro App (opens new window) 是京东出的一款基于 Web Component 原生组件进行渲染的微前端框架,不同于目前流行的开源框架,它从组件化的思维实现微前端,旨在降低上手难度、提升工作效率。
采用iframe沙箱模式,这点和wujie的方案一样了,都是webComponent + iframe
# 实现方案
首先micro-app实现了一个基于WebComponent的组件,并实现了类Shadow Dom 的效果,开发者只需要用来加载子应用,整个对子应用的加载、js隔离、css隔离的逻辑都封装在了web component组件中,具体来说:
- 当调用microApp.start()后,会注册一个名为micro-app 的自定义 webComponent 标签。我们可以从 中拿到子应用的线上入口地址。
- 组件内部,当匹配到路由后,跟qiankun一样加载html,得到html字符串模版
- 分析html字符串,提取头和,并替换为框架自定义标签和
- 在
<micro-app-head>内,会对script标签和link标签的内容进行加载并执行 - 将
<micro-app-head>和<micro-app-body>插入到<micro-app>标签内 <micro-app>内提供了js沙箱方法(v1.0以前跟qiankun沙箱一样),<micro-app-head>挂载到<micro-app>后,内部会逐一对<micro-app-head>内的script标签的 js 绑定作用域,实现 js 隔离
# 优点
接入简单,组件式引入子应用
团队持续更新维护
js隔离、css隔离、路由同步
支持子应用保活, 需要开启keep-alive模式 支持fiber模式,提升主应用的渲染性能。
# 缺点
1.0之前不支持vite,1.0之后支持了
默认css隔离方式,主应用的样式还是会污染到子应用。
子应用和主应用必须相同的路由模式,要么同时hash模式,要么同时history模式
依赖于CustomElements和Proxy两个较新的API。Proxy暂时没有做兼容,所以对于不支持Proxy的浏览器无法运行micro-app。
个人是比较看好 WebComponent 化的,侵入较低,接入更流畅。
它是由google推出的浏览器的原生组件,具体内容查看 链接 (opens new window) 了解
- 复用性:不需要对外抛出加载和卸载的全局 API,可复用能力更强
- 标准化:W3C 的标准,未来能力会得到持续升级
- 插拔性:可以非常便捷的进行移植和组件替换
笔记
切记不要以为微前端而去微前端,老旧项目带来的迁移成本,不同项目技术栈的兼容与边界问题处理,因为没有迫切的需求而接入微前端,只会带来额外的负担,很多时候,iframe 其实就很够用了。