前言
这篇文章笔者足足肝了一周多,多次斟酌修改内容,力求最大程度帮助读者造出一个微前端框架,搞懂原理。觉得内容不错的读者点个赞支持下。
微前端是目前比较热门的一种技术架构,挺多读者私底下问我其中的原理。为了讲清楚原理,我会带着大家从零开始实现一个微前端框架,其中包含了以下功能:
•如何进行路由劫持•如何渲染子应用•如何实现 JS 沙箱及样式隔离•提升体验性的功能
另外在实现的过程中,笔者还会聊聊目前有哪些技术方案可以去实现微前端以及做以上功能的时候有哪些实现方式。
这里是本次文章的最终产出物仓库地址:toy-micro[1]。
微前端实现方案
微前端的实现方案有挺多,比如说:
1.qiankun[2],icestark[3] 自己实现 JS 及样式隔离2.emp[4],Webpack 5 Module Federation(联邦模块)方案3.iframe 、WebComponent 等方案,浏览器原生隔离,但存在一些问题
更新:这里之前有一个错误,笔者错把 icestark 的技术方案说成了 iframe 的。
但是这么多实现方案解决的场景问题还是分为两类:
•单实例:当前页面只存在一个子应用,一般使用 qiankun 就行•多实例:当前页面存在多个子应用,可以使用浏览器原生隔离方案,比如 iframe 或者 WebComponent 这些
当然了,并不是说单实例只能用 qiankun,浏览器原生隔离方案也是可行的,只要你接受它们带来的不足就行:
iframe 最大的特性就是提供了浏览器原生的硬隔离方案,不论是样式隔离、js 隔离这类问题统统都能被完美解决。但他的最大问题也在于他的隔离性无法被突破,导致应用间上下文无法被共享,随之带来的开发体验、产品体验的问题。
上述内容摘自Why Not Iframe[5]。
本文的实现方案和 qiankun 一致,但是其中涉及到的功能及原理方面的东西都是通用的,你换个实现方案也需要这些。
前置工作
在正式开始之前,我们需要搭建一下开发环境,这边大家可以任意选择主 / 子应用的技术栈,比如说主应用用 React,子应用用 Vue,自行选择即可。每个应用用对应的脚手架工具初始化项目就行,这边就不带着大家初始化项目了。记得如果是 React 项目的话,需要另外再执行一次 yarn eject
。
推荐大家直接使用笔者仓库[6]里的 example 文件夹,该配置的都配置好了,大家只需要安心跟着笔者一步步做微前端就行。 例子中主应用为 React,子应用为 Vue,最终我们生成的目录结构大致如下:
正文
在阅读正文前,我假定各位读者已经使用过微前端框架并了解其中的概念,比如说知晓主应用是负责整体布局以及子应用的配置及注册这类内容。如果还未使用过,推荐各位简略阅读下任一微前端框架使用文档。
应用注册
在有了主应用之后,我们需要先在主应用中注册子应用的信息,内容包含以下几块:
•name:子应用名词•entry:子应用的资源入口•container:主应用渲染子应用的节点•activeRule:在哪些路由下渲染该子应用
其实这些信息和我们在项目中注册路由很像,entry
可以看做需要渲染的组件,container
可以看做路由渲染的节点,activeRule
可以看做如何匹配路由的规则。
接下来我们先来实现这个注册子应用的函数:
// src/types.ts
export interface IAppInfo {
name: string;
entry: string;
container: string;
activeRule: string;
}
// src/start.ts
export const registerMicroApps = (appList: IAppInfo[]) => {
setAppList(appList);
};
// src/appList/index.ts
let appList: IAppInfo[] = [];
export const setAppList = (list: IAppInfo[]) => {
appList = list;
};
export const getAppList = () => {
return appList;
};
上述实现很简单,就只需要将用户传入的 appList
保存起来即可。
路由劫持
在有了子应用列表以后,我们需要启动微前端以便渲染相应的子应用,也就是需要判断路由来渲染相应的应用。但是在进行下一步前,我们需要先考虑一个问题:如何监听路由的变化来判断渲染哪个子应用?
对于非 SPA(单页应用) 架构的项目来说,这个完全不是什么问题,因为我们只需要在启动微前端的时候判断下当前 URL 并渲染应用即可;但是在 SPA 架构下,路由变化是不会引发页面刷新的,因此我们需要一个方式知晓路由的变化,从而判断是否需要切换子应用或者什么事都不干。
如果你了解过 Router 库原理的话,应该马上能想到解决方案。如果你并不了解的话,可以先自行阅读笔者之前的文章[7]。
为了照顾不了解的读者,笔者这里先简略的聊一下路由原理。
目前单页应用使用路由的方式分为两种:
1.hash 模式,也就是 URL 中携带 #
2.histroy 模式,也就是常见的 URL 格式了
以下笔者会用两张图例展示这两种模式分别会涉及到哪些事件及 API:
从上述图中我们可以发现,路由变化会涉及到两个事件:
•popstate
•hashchange
因此这两个事件我们肯定是需要去监听的。除此之外,调用 pushState
以及 replaceState
也会造成路由变化,但不会触发事件,因此我们还需要去重写这两个函数。
知道了该监听什么事件以及重写什么函数之后,接下来我们就来实现代码:
// src/route/index.ts
// 保存原有方法
const originalPush = window.history.pushState;
const originalReplace = window.history.replaceState;
export const hijackRoute = () => {
// 重写方法
window.history.pushState = (...args) => {
// 调用原有方法
originalPush.apply(window.history, args);
// URL 改变逻辑,实际就是如何处理子应用
// ...
};
window.history.replaceState = (...args) => {
originalReplace.apply(window.history, args);
// URL 改变逻辑
// ...
};
// 监听事件,触发 URL 改变逻辑
window.addEventListener("hashchange", () => {});
window.addEventListener("popstate", () => {});
// 重写
window.addEventListener = hijackEventListener(window.addEventListener);
window.removeEventListener = hijackEventListener(window.removeEventListener);
};
const capturedListeners: Record<EventType, Function[]> = {
hashchange: [],
popstate: [],
};
const hasListeners = (name: EventType, fn: Function) => {
return capturedListeners[name].filter((listener) => listener === fn).length;
};
const hijackEventListener = (func: Function): any => {
return function (name: string, fn: Function) {
// 如果是以下事件,保存回调函数
if (name === "hashchange" || name === "popstate") {
if (!hasListeners(name, fn)) {
capturedListeners[name].push(fn);
return;
} else {
capturedListeners[name] = capturedListeners[name].filter(
(listener) => listener !== fn
);
}
}
return func.apply(window, arguments);
};
};
// 后续渲染子应用后使用,用于执行之前保存的回调函数
export function callCapturedListeners() {
if (historyEvent) {
Object.keys(capturedListeners).forEach((eventName) => {