微前端实现原理研究总结
Posted 在厕所喝茶
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了微前端实现原理研究总结相关的知识,希望对你有一定的参考价值。
微前端实现原理研究总结
- 前言
- 微前端实现方案
- 子应用生命周期
- 改写子应用
- 子应用打包
- 主应用中注册子应用
- 主应用和子应用的路由模式
- 主应用路由拦截
- 主应用获取子应用并执行生命周期函数
- 主应用加载并解析子应用
- 执行 js 代码
- css 样式隔离
- 主应用和子应用通信
- 子应用之间通信
- 全局状态管理
- 缓存子应用
- 预加载子应用
前言
前段时间研究了一下微前端的实现原理,总结了一些实现的关键点
微前端实现方案
-
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 以上是关于微前端实现原理研究总结的主要内容,如果未能解决你的问题,请参考以下文章