从0到1实现一个微前端框架
日期:2022-01-05 15:17:34
更新:2022-01-05 15:17:34
标签:前端, 架构
分类:Javascript
微前端它是一种由独立交付的多个前端应用组成整体的架构风格,将前端应用分解成颗粒度更小,更简单的能够独立开发、测试、部署的应用。为了深入了解微前端的原理,我会带着大家从零开始实现一个微前端框架。

为什么需要微前端
微前端实现方式与原理分析(principle)
微前端实现方式
- iframe
- why not iframe[2]
- 性能问题,iframe 每次进入子应用时都会重新渲染,资源也会重新加载
- 全局上下文完全隔离,内存变量不共享。cookie 不共享,需要搭建特定的 channel 进行通信
- DOM 结构不共享,无法使用全局弹窗
- url 不同步,刷新页面,无法使用前进/后退
- 基于系统基座实现
- 主应用搭建一个基座,在基座中渲染子应用
- qiankun,singleSPA
微前端框架流程图(基于系统基座)
如上图所示,运行一个微应用需要经过注册,初始化,运行三个步骤。
- 注册是将微应用的信息保存,并且创建全局状态管理;
- 初始化会获取注册的应用所在的地址的HTML文件,并且提取静态 js,css 文件;
- 运行或路由发生变化时,会运行静态的 js,css 文件,调用生命周期渲染微应用;
- 需要实现的功能
- 加载 HTML
- 加载 JS 文件
- 定义生命周期
- 使用沙箱隔绝执行作用域
- 创建应用间通信 channel
- 路由监听
- 样式隔离
实现流程
1.获取应用
-
获取 HTML 文件

在创建构造函数之前,我们需要先实现一个获取 HTML 字符串,并且截取静态 js,css 文件的方法。在这里我做了一种兼容vite的方式
-
loadHtml
async function loadHtml(
entry: string,
type: LoadScriptType
): Promise<LoadHtmlResult> {
const data = await fetch(entry, {
method: "GET",
});
let text = await data.text();
const scriptArr = text
.match(scriptReg)
?.filter((val) => val)
.map((val) => (isHttp.test(val) ? val : `${entry}${val}`));
const styleArr = text
.match(styleReg)
?.filter((val) => val)
.map((val) => (isHttp.test(val) ? val : `${entry}${val}`));
text = text.replace(/(<script.*><\/script>)/g, "");
console.log(scriptArr);
const scriptText: string[] = [];
if (type === "webpack" && scriptArr) {
for (const item of scriptArr) {
let scriptFetch = await fetch(item, { method: "GET" });
scriptText.push(await scriptFetch.text());
}
}
return {
entry,
html: text,
scriptSrc: type === "webpack" ? scriptText : scriptArr || [],
styleSrc: styleArr || [],
};
}
-
运行 JS(runScript),定义生命周期

获取到 HTML 文件以后,需要定义一个执行微应用 js 文件,并且调用生命周期的方法
-
生命周期
- beforeMount 在渲染应用之前调用的函数
- mount 挂载微应用时调用渲染函数
- unmount 卸载微应用时调用
export type LoadFunctionResult = {
beforeMount: () => void;
mount: (props: LoadFunctionMountParam) => void;
unmount: (props: UnloadFunctionParam) => void;
};
export type LoadScriptType = "esbuild" | "webpack";
export function injectEnvironmentStr(context: ProxyParam) {
context[PRODUCT_BY_MICRO_FRONTEND] = true;
context.__vite_plugin_react_preamble_installed__ = true;
return true;
}
export async function loadScriptByImport(scripts: string[]) {
injectEnvironmentStr(window);
let scriptStr = `
return Promise.all([`;
scripts.forEach((val) => {
scriptStr += `import("${val}"),`;
});
scriptStr = scriptStr.substring(0, scriptStr.length - 1);
scriptStr += `]);
`;
return await new Function(scriptStr)();
}
export async function loadScriptByString(
scripts: string[],
context: ProxyParam
) {
const scriptArr: Promise<Record<string, any>>[] = [];
injectEnvironmentStr(context);
scripts.forEach(async (val) => {
scriptArr.push(
await new Function(`
return (window => {
${val}
return window.middleVue;
})(this)
`).call(context)
);
});
return scriptArr;
}
export async function loadFunction<T extends LoadFunctionResult>(
context: Window,
scripts: string[] = [],
type: LoadScriptType = "esbuild"
): Promise<T> {
let result = {};
if (type === "esbuild") {
result = await loadScriptByImport(scripts);
} else {
result = await loadScriptByString(scripts, context);
}
let obj: LoadFunctionResult = {
beforeMount: () => {},
mount: () => {},
unmount: () => {},
};
(<Record<string, any>[]>result).forEach((val) => {
Object.assign(obj, val);
});
return <T>obj;
}
2. 定义构造函数(MicroFront)

上面我们已经,实现了获取 HTML 和运行 JS 文件的方法。接下来需要定义一个构造函数去调用它们。
注册应用时,我们需要传入以下参数的对象数组。

interface MicroFrountendMethod {
init: () => void;
setCurrentRoute: (routeName: string) => void;
start: () => void;
}
export default class MicroFrountend implements MicroFrountendMethod {
private servers: RegisterData[];
private serverLoadData: Record<string, LoadHtmlResult>;
public currentRoute: string;
public currentActiveApp: string[];
public store: Record<string, any>;
constructor(servers: RegisterData[]) {
this.servers = servers;
this.serverLoadData = {};
this.currentRoute = '';
this.currentActiveApp = [];
this.store = createStore();
}
public async init() {
for (let item of this.servers) {
const serverData = await loadHtml(item.entry, item.type);
addNewListener(item.appName);
this.serverLoadData[item.appName] = serverData;
}
return true;
}
public setCurrentRoute(routeName: string) {
const appIndex = this.servers.findIndex(
(val) => val.activeRoute === routeName
);
if (appIndex === -1) return false;
const appName = this.servers[appIndex].appName;
const isInclude = Object.keys(this.serverLoadData).includes(appName);
if (!isInclude) {
return false;
}
this.currentRoute = routeName;
return true;
}
public async start() {
const currentRoute = this.currentRoute || window.location.pathname;
const appList = this.servers.filter(
(val) => val.activeRoute === currentRoute
);
for (let val of appList) {
const appName = val.appName;
const htmlData = this.serverLoadData[appName];
const scriptResult = await runScript(val, htmlData, this.store);
this.serverLoadData[appName].lifeCycle = scriptResult.lifeCycle;
this.serverLoadData[appName].sandbox = scriptResult.sandBox;
}
}
3. JS 沙箱
沙箱的介绍已经有很多文件在介绍了,这里也就不多介绍,具体可以看这里
-
沙箱种类
iframe
<iframe></iframe>
SnapshopSandbox(快照沙箱)

LegacySandbox(单例沙箱)

ProxySandbox(多例沙箱)

-
沙箱实现(多例沙箱)
interface SandBoxImplement {
active: () => void;
inActive: () => void;
}
type ProxyParam = Record<string, any> & Window;
class SandBox implements SandBoxImplement {
public proxy: ProxyParam;
private isSandboxActive: boolean;
public name: string;
active() {
this.isSandboxActive = true;
}
inActive() {
this.isSandboxActive = false;
}
constructor(appName: string, context: Window & Record<string, any>) {
this.name = appName;
this.isSandboxActive = false;
const fateWindow = {};
this.proxy = new Proxy(<ProxyParam>fateWindow, {
set: (target, key, value) => {
if (this.isSandboxActive) {
target[<string>key] = value;
}
return true;
},
get: (target, key) => {
if (target[<string>key]) {
return target[<string>key];
} else if (Object.keys(context).includes(<string>key)) {
return context[<string>key];
}
return undefined;
},
});
}
}
export default SandBox;
-
在沙箱中运行微应用
function injectEnvironmentStr(context: ProxyParam) {
context[PRODUCT_BY_MICRO_FRONTEND] = true;
context.__vite_plugin_react_preamble_installed__ = true;
return true;
}
async function loadScriptByString(scripts: string[], context: ProxyParam) {
const scriptArr: Promise<Record<string, any>>[] = [];
injectEnvironmentStr(context);
scripts.forEach(async (val) => {
scriptArr.push(
await new Function(`
return (window => {
${val}
return window.middleVue;
})(this)
`).call(context)
);
});
return scriptArr;
}
4. 全局通信状态管理
到这里为止,我们已经可以在父应用中运行子应用了。但是很多时候需要再父应用和子应用之间进行数据通信,或者子应用之间的数据通信。
所以我们还需要实现一个全局的状态管理,在调用生命周期时,将状态管理方法传入微应用中。通信流程如下图所示,利用发布——订阅的方法通知订阅了参数改变事件触发对应的业务函数。

-
创建全局状态
export function createStore() {
const globalStore = new Proxy(<Record<string, any>>{}, {
get(target, key: string) {
return target[key];
},
set(target, key: string, value) {
const oldVal = target[key];
target[key] = value;
triggerEvent({ key, value, oldValue: oldVal });
return true;
},
});
return globalStore;
}
-
新增监听器
export type triggerEventParam<T> = {
key: string;
value: T;
oldValue: T;
};
const listener: Map<
string,
Record<string, (data: triggerEventParam<any>) => void>
> = new Map();
export function addNewListener(appName: string) {
if (listener.has(appName)) return;
listener.set(appName, {});
}
-
订阅事件
export function setEventTrigger<T extends any>(
appName: string,
key: string,
callback: (data: triggerEventParam<T>) => void
) {
if (listener.has(appName)) {
const obj = listener.get(appName);
if (obj) {
obj[key] = callback;
}
}
}
-
触发事件
export function triggerEvent<T extends any>(data: triggerEventParam<T>) {
listener.forEach((val) => {
if (val[data.key] && typeof val[data.key] === "function") {
val[data.key](data);
}
});
}
5. 路由监听

路由监听是微前端中非常重要且复杂的一环,我们需要通过监听路由变化去挂载或者卸载应用。我们都知道前端路由分为两种:hash和history路由。
监听 hash 很简单,只需要在监听器中定义hashchange的方法即可。
监听 history 路由时,需要分成两种情况,触发浏览器前进,后退,我们可以使用popstate方法监听到,但是 history 中的pushState,replaceState无法使用popstate监听,我们需要重写这两个方法,将监听器放到这两个方法中。
-
监听 hash 路由变化回调
function listenHash(callback: listenCallback) {
window.addEventListener("hashchange", (ev) => {
callback(getHashPathName(ev.oldURL), getHashPathName(ev.newURL), {});
});
}
function getHashPathName(url: string) {
const pathArr = url.split("#");
return pathArr[1] ? `/${pathArr[1]}` : "/";
}
-
监听 history 路由变化回调
export type listenCallback = (
oldPathName: string,
pathName: string,
param: any
) => void;
const globalPushState = window.history.pushState;
const globalReplaceState = window.history.replaceState;
function listenHistory(callback: listenCallback, currentRoute: string) {
window.history.pushState = historyControlRewrite("pushState", callback);
window.history.replaceState = historyControlRewrite(
"replaceState",
callback
);
window.addEventListener("popstate", (ev) => {
callback(currentRoute, window.location.pathname, ev.state);
});
}
const historyControlRewrite = function (
name: "pushState" | "replaceState",
callback: listenCallback
) {
const method = history[name];
return function (data: any, unused: string, url: string) {
const oldPathName = window.location.pathname;
if (oldPathName === url) return;
method.apply(history, [data, unused, url]);
callback(oldPathName, url || "", data);
};
};
6. 样式隔离
- 样式隔离解决方案
- 在微前端框架中获取 style 样式并添加唯一前缀
- 微应用中约定通过 postcss 处理
- 利用 postcss 处理样式隔离
演示
父应用我使用的是原生JS,分别定义了一个vue框架和react框架的项目

Vue 项目

React 项目

运行

小结

源码地址: https://github.com/JeremyYu-cn/micro_frontend
参考
- 字节跳动是如何落地微前端的
- Why Not Iframe
- qiankun
- singleSpa
- 从零到一实现企业级微前端框架,保姆级教学
- 解密微前端:从 qiankun 看沙箱隔离