微前端实现原理研究总结
- 前言
- 微前端实现方案
- 子应用生命周期
- 改写子应用
- 子应用打包
- 主应用中注册子应用
- 主应用和子应用的路由模式
- 主应用路由拦截
- 主应用获取子应用并执行生命周期函数
- 主应用加载并解析子应用
- 获取html文件内容
- 解析html内容
- 获取javascript文件内容
- html文件内容添加到子应用容器中
- 执行 js 代码
- 常规 diff 对比
- proxy
- 沙箱环境执行 js 代码
- css 样式隔离
- css modules
- shadow dom
- mini-css-extract-plugin
- 主应用和子应用通信
- props
- CustomEvent
- 子应用之间通信
- 全局状态管理
- 缓存子应用
- 预加载子应用
前言
前段时间研究了一下微前端的实现原理,总结了一些实现的关键点
微前端实现方案
iframe
:浏览器兼容性好,实现起来简单。但是缺点也明显,比如路由状态丢失,通信困难web component
:浏览器兼容性差SPA
:当下比较流行的方案,比如qiankun
,single-spa
本文主要是研究SPA
这种方案
子应用生命周期
子应用需要导出三个生命周期函数,用来给主应用进行初始化,分别如下:
bootstrap
:初始化子应用前,你可以在这个生命周期函数中为子应用做一些前期的准备工作mount
:初始化子应用,在这个阶段你应该对子应用进行初始化unmount
:销毁子应用,在这个阶段你需要对子应用进行销毁,或者是销毁一些具有副作用的代码(比如定时器)
这三个生命周期函数只有在微前端(依附于主应用)的环境下才会被执行,如果是单独启动项目的时候是不会被执行的
改写子应用
我们需要对子应用进行一些改写。我们以vue3
为例,主要是修改入口文件的内容。
- 区分微前端环境和单独启动项目环境。在微前端环境下,主应用会在
window
全局环境下设置一个标志位用标识当前是微前端环境,我们可以通过这个标志位来区分微前端环境和单独启动项目环境 - 在子应用启动的时候,如果是微前端环境,我们需要在
mount
钩子函数中初始化应用,如果是单独启动项目,我们需要立刻初始化应用。 - 导出子应用的三个生命周期钩子函数
改写后的代码如下:
import { App, createApp } from "vue";
import AppComponent from "./App.vue";
import router from "./router";
import store from "./store";
let instance: App | null;
function render() {
instance = createApp(AppComponent);
instance.use(store).use(router).mount("#app");
}
if (!(window as any).__MICRO_WEB__) {
render();
}
export function bootstrap() {
console.log("bootstrap");
}
export function mount() {
console.log("mount");
render();
}
export function unmount() {
console.log("unmount");
instance?.unmount();
}
子应用打包
我们在前面改写了子应用,并导出了子应用的三个生命周期函数,目的是为了可以让主应用可以访问这三个生命周期函数,所以我们需要对子应用的打包进行修改。以vue3
为例,在vue.config.js
中修改,修改后的代码如下:
const { defineConfig } = require("@vue/cli-service");
module.exports = defineConfig({
// ..
devServer: {
port:9094
headers: {
"Access-Control-Allow-Origin": "*",
},
},
configureWebpack: {
output: {
libraryTarget: "umd",
filename: "[name].js",
library: "vue3",
},
},
});
- 首先我们在
headers
中设置了"Access-Control-Allow-Origin": "*"
,这个是为了解决在开发环境下跨域的问题。因为在开发环境下,主应用和子应用都是在不同的端口号中启动的 - 然后就是
output
打包输出的修改。我们先来分析一下libraryTarget
,filename
,library
这三个属性的作用
-
libraryTarget
:文件输出的格式。可以是cjs
,amd
,umd
的格式,es module
格式只在最新版的webpack
中支持。我们选择umd
这种比较通用的模块格式,主要是为了可以在window
全局环境下访问导出的三个生命周期函数 -
filename
:输出的文件名。[name]
是一个占位符,跟入口名称有关。因为vue-cli
会打包出多个文件,所以不能直接写死输出的文件名,打包的时候会报错 -
library
:挂载在window
全局环境下的变量名,我们可以通过这个变量名去访问三个生命周期函数。vue3.bootstrap()
,vue3.mount()
,vue3.unmount()
主应用中注册子应用
子应用需要在主应用中进行注册,用来告诉主应用有那些子应用,注册代码结构如下:
export default [
{
name: "vue3",
entry: "//localhost:9004/",
container: "#micro-container",
activeRule: "/vue3",
},
// ...
];
我们来分析一下每个key
所代表的的含义
-
name
:子应用名称,这个名称需要跟前面子应用打包配置中的output.library
保持一致。目的是为了告诉主应用可以通过这个变量名去访问子应用的三个生命周期函数 -
entry
:子应用的入口地址,开发环境就填写开发环境地址,生产环境就填写生产环境地址 -
container
:子应用的父容器。 -
activeRule
:激活子应用的路由地址。假设当前地址是http://localhost:8080/vue3
,那么vue3
这个子应用将会被激活,进行初始化
主应用和子应用的路由模式
这个问题主要是针对单页面应用,主要是vue
,react
,angular
这些框架
主应用使用的是HTML5 history
路由模式,那么子应用就只能使用hash history
路由模式。
如果主应用和子应用都采用了相同的路由模式,那么就会产生冲突
主应用路由拦截
主应用需要监听地址栏的url
来激活对应的子应用,但是主应用采用的是HTML5 history
路由模式,没有相关的事件来监听url
的变化
但是我们可以知道vue
中使通过history.pushState
方法来修改地址栏的url
,所以我们可以通过拦截改写history.pushState
方法,添加一些我们自定义的逻辑,这也是一种常见做法(比如vue2
中数组的响应式,就是通过改写方法实现的)
代码如下:
const patchRouter = (globalEvent: Function, eventName: string) => {
return function () {
const e = new Event(eventName);
// @ts-ignore
globalEvent.apply(this, arguments);
window.dispatchEvent(e);
};
};
export const rewriteRouter = () => {
window.history.pushState = patchRouter(
window.history.pushState,
"micro_push"
);
window.history.replaceState = patchRouter(
window.history.replaceState,
"micro_replace"
);
window.addEventListener("micro_push", turnApp);
window.addEventListener("micro_replace", turnApp);
window.addEventListener("popstate", turnApp);
};
从上面可以看见,主要做了两件事
- 改写了
pushState
和replaceState
这两个方法,改写后的方法主要做了两件事
- 执行原来的方法
- 派发自定义事件
- 监听派发的自定义事件(
micro_push
,micro_replace
)和popstate
事件就可以知道地址栏的url
发生了改变
主应用获取子应用并执行生命周期函数
主应用路由拦截修改完成之后,我们就可以通过监听事件来知道地址栏的url
发生变化,从而可以根据当前的地址来获取子应用
代码如下:
// 查找子应用
export const findApp = (activeRule: string) => {
// getAppList获取的是注册的子应用列表
return getAppList().find((item) => item.activeRule === activeRule);
};
export const turnApp = async () => {
const pathname = window.location.pathname.replace(/\/$/, "");
// 上一个应用对应地址
const oldAppPath = (window as any).__CURRENT_SUB_APP__;
if (oldAppPath === pathname) {
return;
}
// 获取上一个应用
const prevApp = findApp(oldAppPath);
prevApp?.unmount?.();
// 获取下一个应用
const nextApp = findApp(pathname);
(window as any).__CURRENT_SUB_APP__ = pathname;
if (nextApp) {
// 加载并解析子应用,见下文`主应用加载并解析子应用`章节
const app = await loadHtml(nextApp);
app.bootstrap?.();
app.mount?.();
}
};
主应用加载并解析子应用
主应用加载并解析子应用分为如下几个步骤:
获取html文件内容
根据注册的子应用地址,发送ajax
请求获取html
文件内容。特别注意的是,我们获取的是html
文件内容,而不是javascript
文件内容
子应用的html
文件内容如下(vue3 为例):
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
<link rel="icon" href="/favicon.ico" />
<title>vue3</title>
<script defer src="/chunk-vendors.js"></script>
<script defer src="/app.js"></script>
</head>
<body>
<noscript>
<strong
>We're sorry but vue3 doesn't work properly without JavaScript enabled.
Please enable it to continue.</strong
>
</noscript>
<div id="app"></div>
<!-- built files will be auto injected -->
</body>
</html>
解析html内容
解析html
内容,获取所有的javascript
代码块或者javascript
路径地址
javascript
在html
文件中存在两种形式,一种是代码块,一种是路径地址
代码块
<script>
console.log(11);
</script>
路径地址
<script defer src="/app.js"></script>
获取所有的javascript
代码块或者javascript
路径地址的思路如下:
- 创建一个
div
元素 - 将
html
字符串添加到div
元素中 - 通过
div.querySelectorAll("script")
获取的到所有script
标签 - 对
script
标签进行分类,如果是存在src
属性,说明是javascript
路径地址,反之则是javascript
代码块 -
javascript
路径地址,则需要判断是绝对路径还是相对路径,如果是相对路径,需要根据对应的子应用的入口地址拼接出完整的路径地址 -
javascript
代码块,需要去掉script
标签,只获取script
标签中的内容
解析代码如下:
const parseHtml = async (htmlStr: string, app: AppItem) => {
const div = document.createElement("div");
div.innerHTML = htmlStr;
const scriptUrl: string[] = [];
const script: string[] = [];
const scriptElements = root.querySelectorAll("script");
for (let i = 0; i < scriptElements.length; i++) {
const element = scriptElements[i];
const src = element.getAttribute("src");
if (!src) {
// javascript代码块
script.push(element.innerHTML);
} else {
// 路径地址
if (src.startsWith("http")) {
// 绝对路径
scriptUrl.push(src);
} else {
// 相对路径
scriptUrl.push(`http:${app.entry}/${src}`);
}
}
}
return { scriptUrl, script };
};
获取javascript文件内容
根据javascript
路径地址发送ajax
请求获取javascript
文件内容
解析完html
文件内容之后,我们就可以获取的到javascript
代码块内容和javascript
路径地址。此时我们需要做的就是发送请求获取javascript
路径地址所对应的文件内容,因为我们最终需要的是javascript
代码块,然后执行这些javascript
代码块的内容
代码如下:
export const loadHtml = async (app: AppItem) => {
const htmlStr = await fetchResource(app.entry);
// ...
const { scriptUrl, script } = await parseHtml(htmlStr, app);
const fetchScripts = await Promise.all(scriptUrl.map(fetchResource));
const allScript = [...script, ...fetchScripts];
// ...
return app;
};
html文件内容添加到子应用容器中
在获取完所有的javascript
代码块之后,执行javascript
代码块之前,我们还需要做的操作是把html
字符串添加到子应用容器当中
代码如下:
export const loadHtml = async (app: AppItem) => {
const htmlStr = await fetchResource(app.entry);
// ...
const ct = document.querySelector(app.container);
if (!ct) {
throw new Error("容器不存在,请检查");
}
ct.innerHTML = htmlStr;
// ...
return app;
};
html
文件内容中包含了meta
,title
等额外的标签,通过innerHTML
的方式添加进去不会有什么影响,script
,link
等标签也不会去加载资源
执行 js 代码
所有东西就绪之后,接下来就是执行我们所获取的js
代码块。js
代码块是字符串,我们可以通过eval
,new Function
,script
标签的形式执行js
字符串,这里更为推荐使用new Function
的形式执行
在js
字符串执行的过程中,我们需要考虑一个问题,就是子应用与子应用之间会不会相互影响。比如说 A B 子应用同时使用或者依赖了window
的某个全局属性,当 A 修改了这个全局属性时,会导致 B 受到了影响。为了避免子应用之间相互影响,我们需要一个沙箱环境执行js
代码
沙箱环境可通过常规 diff 对比
,proxy
去实现
常规 diff 对比
常规 diff 对比流程如下:
- 通过
new Map()
创建一个沙箱快照,主要用来保存window
原有的状态 - 在沙箱被激活的时候,遍历
window
上面的所有属性和方法,并保存到沙箱快照中 - 沙箱被销毁的时候,遍历
window
上面的所有属性和方法,对比快照的属性和方法,如果不一致就还原为快照中保存的属性和方法
代码如下:
// 快照沙箱
// 缺点:不支持多实例
// window上面有些属性是不能进行set的
const list = ["window", "document"];
const shouldProxy = (key: string) => {
return window.hasOwnProperty(key) && !list.includes(key);
};
export class SnapShotSandbox {
// 代理对象
proxy = window;
// 创建一个沙箱快照
snapshot: Map<any, any> = new Map();
constructor() {
this.active();
}
// 沙箱激活
active() {
// 遍历全局环境
for (const key in window) {
if (shouldProxy(key)) {
this.snapshot.set(key, window[key]);
}
}
}
// 沙箱销毁
inactive() {
for (const key in window) {
if (shouldProxy(key)) {
if (window[key] !== this.snapshot.get(key)) {
// 还原操作
window[key] = this.snapshot.get(key);
}
}
}
}
}
这种方式实现的沙箱有两个弊端,分别如下:
- 不支持多实例
window
上面有些属性是不能进行set
操作的,比如window
,document
proxy
proxy
代理步骤如下:
- 新增一个缓存对象,用来缓存
set
操作设置的值 - 在沙箱被激活的时候,通过
Proxy
去代理window
对象
- 在
get
操作中,根据key
从缓存对象中获取对应的值,如果不存在,就从window
中获取。如果值是一个函数,需要绑定this
为window
,然后返回函数,如果是一个属性,直接返回即可 - 在
set
操作中,把设置的值存储在缓存对象中,然后返回true
,表示设置成功
- 沙箱被销毁的时候,清空缓存对象的值
代码如下:
export class ProxySandbox {
proxy!: Window & typeof globalThis;
defaultValue: Record<string, any> = {};
constructor() {
this.active();
}
active() {
this.proxy = new Proxy(window, {
get: (target, key: any) => {
const value = this.defaultValue[key] ?? target[key];
if (typeof value === "function") {
return value.bind(target);
}
return value;
},
set: (target, key: any, value: any) => {
this.defaultValue[key] = value;
return true;
},
});
}
inactive() {
this.defaultValue = {};
}
}
Proxy
沙箱环境缺点就是存在兼容性问题,比如ie
等旧版本浏览器不兼容。
我们可以将Proxy
和常规 diff 对比
这两种方式结合使用。优先使用Proxy
,如果浏览器不支持Proxy
,就降级使用常规 diff 对比
沙箱环境执行 js 代码
经过上面的沙箱环境的准备,我们就可以使用沙箱环境执行js
代码。
实现流程如下:
- 初始化沙箱
- 给
js
字符代码包裹一层立即执行函数,函数形参就是window
,实参为代理对象
代码如下:
export const performScriptForEval = (script: string, app: AppItem) => {
if (!app.proxy) {
app.proxy = new ProxySandbox();
}
const global = app.proxy?.proxy ?? window;
(window as any).proxy = global;
const scriptText = `
((window) => {
try {
${script}
} catch (error) {
console.error('run script error: ' + error)
}
return window['${app.name}']
})(window.proxy)
`;
const ret = eval(scriptText);
if (isLifeCycle(ret)) {
app.bootstrap = ret.bootstrap;
app.mount = ret.mount;
app.unmount = ret.unmount;
}
};
export const loadHtml = async (app: AppItem) => {
const htmlStr = await fetchResource(app.entry);
// ...
const { scriptUrl, script } = await parseHtml(htmlStr, app);
const fetchScripts = await Promise.all(scriptUrl.map(fetchResource));
const allScript = [...script, ...fetchScripts];
allScript.forEach((item) => {
performScriptForEval(item, app);
});
// ...
return app;
};
css 样式隔离
为了防止子应用之间的 css 样式会被相互影响,css 样式也需要进行样式隔离
css 样式隔离有三种方案,分别是css modules
,shadow dom
,mini-css-extract-plugin
css modules
这种方式需要借助打包工具实现,比如webpack
的css-loader
。其原理就是将类名转化为一个唯一的hash
值,这样子就不存在样式冲突的问题了
下面以css-loader
为例
webpack
配置如下,关键就是把modules
设置为true
:
module.exports = {
module: {
rules: [
{
test: /\.css$/i,
loader: "css-loader",
options: {
modules: true,
},
},
],
},
};
使用形式如下:
.c1 {
color: red;
}
import style from "./style.css";
const div = document.getElementById("div");
div.className = style.c1;
shadow dom
关于shadow dom
的详细介绍,可以点击这里查看
shadow dom
是实现Web components
的一个重要属性,存在兼容性的问题
mini-css-extract-plugin
mini-css-extract-plugin
是一个webpack
的插件,主要作用是把css
样式提取出来,放置到一个单独的文件,这样子就可以通过link
标签加载样式文件。
子应用加载的时候通过动态添加link
标签加载样式,子应用销毁的时候移除对应的link
标签即可。这样子也可以达到样式隔离
主应用和子应用通信
主应用和子应用通信有两种方案,分别是props
和CustomEvent
。
除此之外,还可以借助localStorage
和sessionStorage
等其他方式来进行通信
我们只讨论props
和CustomEvent
这两种方案
props
我们知道,子应用中有三个生命周期函数,主应用在调用子应用的生命周期函数时,可以通过传参的形式实现父子应用通信
代码如下:
子应用
let store = null;
const render = () => {
console.log(store);
// ...
};
export function bootstrap(store) {
store = store;
}
export function mount() {
render();
}
主应用
const store = {
state: { age: 1 },
};
export const turnApp = async () => {
// ...
const nextApp = findApp(pathname);
// ...
if (nextApp) {
// ...
app.bootstrap?.(store);
app.mount?.();
}
};
CustomEvent
CustomEvent
自定义事件。主应用派发事件,子应用监听事件。或者主应用监听事件,子应用派发事件。这样子就可以实现双向通信。实际上就是发布订阅模式
代码如下:
// 派发事件
const event = new CustomEvent("test-event", {
detail: { name: "张三" },
});
window.dispatchEvent(event);
// 监听事件
window.addEventListener("test-event", (data: any) => {
console.log(data.detail);
});
我们可以在主应用中对代码进行一个统一的封装,然后在挂载到window
上面,这样子主应用和子应用都可以访问,同时也方便进行管理
子应用之间通信
子应用之间的通信跟主应用和子应用通信的通信方案是一致的。都可以使用props
和CustomEvent
。
不同的是,当使用props
时,数据的传递路径为 子 -> 父 -> 子
全局状态管理
对于一些全局的共享数据,比如用户的登录信息。我们需要一个全局的状态管理工具。
如果我们的所有项目都是用vue
,那么我们就可以直接使用vuex
如果每个子项目所使用的的框架不一样,vuex
不是一种通用的解决方案,我们需要自己实现一个全局的状态管理工具。
我们通过发布订阅模式实现一个全局的状态管理工具,代码如下:
export const createStore = (initData = {}) => {
let store = initData;
const observers = [];
const getStore = () => {
return store;
};
const updateStore = (newValue) =>
new Promise((res) => {
if (newValue !== store) {
const oldValue = store;
store = newValue;
res(store);
observers.forEach((fn) => fn(newValue, oldValue));
}
});
const subscribeStore = (fn) => {
observers.push(fn);
};
return { getStore, updateStore, subscribeStore };
};
在主应用中,我们创建这个store
,并且把它挂载到window
下(当然也可以通过传参的形式,把这个store
传给子应用)
const store = createStore();
window.store = store;
然后我们就可以在主应用或者子应用中的任意位置获取得到这个store
。并且通过getStore
获取store
的数据,updateStore
更新store
的数据,subscribeStore
订阅store
的数据变化
缓存子应用
我们在切换子应用的时候,每次都会重新请求子应用的内容,然后解析内容。我们可以在子应用一次加载和解析完成之后,缓存对应的内容。下次在加载和解析子应用的时候,可以缓存的结果,减少网络请求的次数,提高子应用的显示速度,提高系统的性能
代码如下:
// 缓存子应用
const cache: Record<string, any> = {};
export const handleResource = async (app: AppItem) => {
if (cache[app.name]) {
return cache[app.name];
}
const html = await fetchResource(app.entry);
const { scriptUrl, script } = await parseHtml(html, app);
const fetchScripts = await Promise.all(scriptUrl.map(fetchResource));
const allScript = [...script, ...fetchScripts];
const data = { script: allScript, html };
cache[app.name] = data;
return data;
};
export const loadHtml = async (app: AppItem) => {
const { script, html } = handleResource(app);
// ...
script.forEach((item) => {
performScriptForEval(item, app);
});
// ...
return app;
};
预加载子应用
结合上面的缓存子应用
功能,我们可以对其他的子应用进行预加载,这样子在子应用切换的时候,也可以提高子应用的显示速度
代码如下:
export const prefetch = async () => {
// 获取其余子应用
const appPieces = getList().filter(
(item) => !window.location.pathname.startsWith(item.activeRule)
);
// 加载所有子应用
await Promise.all(appPieces.map(handleResource));
};