微前端框架实现原理
本文适合对微前端感兴趣、以及想深入微前端原理学习的小伙伴阅读。
欢迎关注前端早茶,与广东靓仔携手共同进阶~
作者:广东靓仔
一、前言
本文转载于掘金:
https://juejin.cn/post/7004661323124441102
这么多实现方案解决的场景问题还是分为两类:
单实例:当前页面只存在一个子应用,一般使用 qiankun 就行
多实例:当前页面存在多个子应用,可以使用浏览器原生隔离方案,比如 iframe 或者 WebComponent 这些
二、前置工作
yarn eject
。三、正文
主应用:负责整体布局以及子应用的配置及注册这类内容。
应用注册
在有了主应用之后,我们需要先在主应用中注册子应用的信息,内容包含以下几块:
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
保存起来即可。
路由劫持
hash 模式,也就是 URL 中携带 #
histroy 模式,也就是常见的 URL 格式了
从上述图中我们可以发现,路由变化会涉及到两个事件:
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) => {
const listeners = capturedListeners[eventName as EventType]
if (listeners.length) {
listeners.forEach((listener) => {
// @ts-ignore
listener.call(this, historyEvent)
})
}
})
historyEvent = null
}
}
以上代码看着很多行,实际做的事情很简单,总体分为以下几步:
重写
pushState
以及replaceState
方法,在方法中调用原有方法后执行如何处理子应用的逻辑监听
hashchange
及popstate
事件,事件触发后执行如何处理子应用的逻辑重写监听 / 移除事件函数,如果应用监听了
hashchange
及popstate
事件就将回调函数保存起来以备后用
应用生命周期
beforeLoad
:挂载子应用前mounted
:挂载子应用后unmounted
:卸载子应用
bootstrap
:首次应用加载触发,常用于配置子应用全局信息mount
:应用挂载时触发,常用于渲染子应用unmount
:应用卸载时触发,常用于销毁子应用
// src/types.ts
export interface ILifeCycle {
beforeLoad?: LifeCycle | LifeCycle[];
mounted?: LifeCycle | LifeCycle[];
unmounted?: LifeCycle | LifeCycle[];
}
// src/start.ts
// 改写下之前的
export const registerMicroApps = (
appList: IAppInfo[],
lifeCycle?: ILifeCycle
) => {
setAppList(appList);
lifeCycle && setLifeCycle(lifeCycle);
};
// src/lifeCycle/index.ts
let lifeCycle: ILifeCycle = {};
export const setLifeCycle = (list: ILifeCycle) => {
lifeCycle = list;
};
因为是主应用的生命周期,所以我们在注册子应用的时候就顺带注册上了。
然后子应用的生命周期:
// src/enums.ts
// 设置子应用状态
export enum AppStatus {
NOT_LOADED = "NOT_LOADED",
LOADING = "LOADING",
LOADED = "LOADED",
BOOTSTRAPPING = "BOOTSTRAPPING",
NOT_MOUNTED = "NOT_MOUNTED",
MOUNTING = "MOUNTING",
MOUNTED = "MOUNTED",
UNMOUNTING = "UNMOUNTING",
}
// src/lifeCycle/index.ts
export const runBeforeLoad = async (app: IInternalAppInfo) => {
app.status = AppStatus.LOADING;
await runLifeCycle("beforeLoad", app);
app = await 加载子应用资源;
app.status = AppStatus.LOADED;
};
export const runBoostrap = async (app: IInternalAppInfo) => {
if (app.status !== AppStatus.LOADED) {
return app;
}
app.status = AppStatus.BOOTSTRAPPING;
await app.bootstrap?.(app);
app.status = AppStatus.NOT_MOUNTED;
};
export const runMounted = async (app: IInternalAppInfo) => {
app.status = AppStatus.MOUNTING;
await app.mount?.(app);
app.status = AppStatus.MOUNTED;
await runLifeCycle("mounted", app);
};
export const runUnmounted = async (app: IInternalAppInfo) => {
app.status = AppStatus.UNMOUNTING;
await app.unmount?.(app);
app.status = AppStatus.NOT_MOUNTED;
await runLifeCycle("unmounted", app);
};
const runLifeCycle = async (name: keyof ILifeCycle, app: IAppInfo) => {
const fn = lifeCycle[name];
if (fn instanceof Array) {
await Promise.all(fn.map((item) => item(app)));
} else {
await fn?.(app);
}
};
以上代码看着很多,实际实现也很简单,总结一下就是:
设置子应用状态,用于逻辑判断以及优化。比如说当一个应用状态为非
NOT_LOADED
时(每个应用初始都为NOT_LOADED
状态),下次渲染该应用时就无需重复加载资源了如需要处理逻辑,比如说
beforeLoad
我们需要加载子应用资源执行主 / 子应用生命周期,这里需要注意下执行顺序,可以参考父子组件的生命周期执行顺序
完善路由劫持
判断当前 URL 与之前的 URL 是否一致,如果一致则继续 利用当然 URL 去匹配相应的子应用,此时分为几种情况: 初次启动微前端,此时只需渲染匹配成功的子应用 未切换子应用,此时无需处理子应用 切换子应用,此时需要找出之前渲染过的子应用做卸载处理,然后渲染匹配成功的子应用 保存当前 URL,用于下一次第一步判断
let lastUrl: string | null = null
export const reroute = (url: string) => {
if (url !== lastUrl) {
const { actives, unmounts } = 匹配路由,寻找符合条件的子应用
// 执行生命周期
Promise.all(
unmounts
.map(async (app) => {
await runUnmounted(app)
})
.concat(
actives.map(async (app) => {
await runBeforeLoad(app)
await runBoostrap(app)
await runMounted(app)
})
)
).then(() => {
// 执行路由劫持小节未使用的函数
callCapturedListeners()
})
}
lastUrl = url || location.href
}
嵌套关系 路径语法
/vue
,那么类似 /vue
或者 /vue/xxx
都能匹配上这个路由,除非我们设置 excart
也就是精确匹配。<Route path="/hello/:name"> // 匹配 /hello/michael 和 /hello/ryan
<Route path="/hello(/:name)"> // 匹配 /hello, /hello/michael 和 /hello/ryan
<Route path="/files/*.*"> // 匹配 /files/hello.jpg 和 /files/path/to/hello.jpg
这样看来路由匹配实现起来还是挺麻烦的,那么我们是否有简便的办法来实现该功能呢?答案肯定是有的,我们只要阅读 Route 库源码就能发现它们内部都使用了path-to-regexp这个库,有兴趣的可以阅读下这个库的文档,这里我们只看其中一个 API 的使用就行。
有了解决方案以后,我们就快速实现下路由匹配的函数:
export const getAppListStatus = () => {
// 需要渲染的应用列表
const actives: IInternalAppInfo[] = []
// 需要卸载的应用列表
const unmounts: IInternalAppInfo[] = []
// 获取注册的子应用列表
const list = getAppList() as IInternalAppInfo[]
list.forEach((app) => {
// 匹配路由
const isActive = match(app.activeRule, { end: false })(location.pathname)
// 判断应用状态
switch (app.status) {
case AppStatus.NOT_LOADED:
case AppStatus.LOADING:
case AppStatus.LOADED:
case AppStatus.BOOTSTRAPPING:
case AppStatus.NOT_MOUNTED:
isActive && actives.push(app)
break
case AppStatus.MOUNTED:
!isActive && unmounts.push(app)
break
}
})
return { actives, unmounts }
}
reroute
函数中调用一下,至此路由劫持功能彻底完成了。完善生命周期
registerMicroApps
函数,我们最开始就给这个函数传入了 entry
参数,这就是子应用的资源入口。JS Entry HTML Entry
加载资源
首先我们需要获取 HTML 的内容,这里我们只需调用原生 fetch
就能拿到东西了。
// src/utils
export const fetchResource = async (url: string) => {
return await fetch(url).then(async (res) => await res.text())
}
// src/loader/index.ts
export const loadHTML = async (app: IInternalAppInfo) => {
const { container, entry } = app
const htmlFile = await fetchResource(entry)
return app
}
切换路由至 /vue
之后,我们可以打印出加载到的 HTML 文件内容。
<!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>sub</title>
<link href="/js/app.js" rel="preload" as="script"><link href="/js/chunk-vendors.js" rel="preload" as="script"></head>
<body>
<noscript>
<strong>We're sorry but sub doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<div id="app"></div>
<!-- built files will be auto injected -->
<script type="text/javascript" src="/js/chunk-vendors.js"></script>
<script type="text/javascript" src="/js/app.js"></script></body>
</html>
// vue.config.js
module.exports = {
devServer: {
headers: {
'Access-Control-Allow-Origin': '*',
},
},
}
接下来我们需要先行处理这些资源的路径,将相对路径拼接成正确的绝对路径,然后再去 fetch
。
// vue.config.js
// src/utils
export function getCompletionURL(src: string | null, baseURI: string) {
if (!src) return src
// 如果 URL 已经是协议开头就直接返回
if (/^(https|http)/.test(src)) return src
// 通过原生方法拼接 URL
return new URL(src, getCompletionBaseURL(baseURI)).toString()
}
// 获取完整的 BaseURL
// 因为用户在注册应用的 entry 里面可能填入 //xxx 或者 https://xxx 这种格式的 URL
export function getCompletionBaseURL(url: string) {
return url.startsWith('//') ? `${location.protocol}${url}` : url
}
以上代码的功能就不再赘述了,注释已经很详细了,接下来我们需要找到 HTML 文件中的资源然后去 fetch
。
既然是找出资源,那么我们就得解析 HTML 内容了:
// src/loader/parse.ts
export const parseHTML = (parent: HTMLElement, app: IInternalAppInfo) => {
const children = Array.from(parent.children) as HTMLElement[]
children.length && children.forEach((item) => parseHTML(item, app))
for (const dom of children) {
if (/^(link)$/i.test(dom.tagName)) {
// 处理 link
} else if (/^(script)$/i.test(dom.tagName)) {
// 处理 script
} else if (/^(img)$/i.test(dom.tagName) && dom.hasAttribute('src')) {
// 处理图片,毕竟图片资源用相对路径肯定也 404 了
dom.setAttribute(
'src',
getCompletionURL(dom.getAttribute('src')!, app.entry)!
)
}
}
return { }
}
解析内容这块还是简单的,我们递归寻找元素,将 link
、script
、img
元素找出来并做对应的处理即可。
首先来看我们如何处理 link
:
// src/loader/parse.ts
// 补全 parseHTML 逻辑
if (/^(link)$/i.test(dom.tagName)) {
const data = parseLink(dom, parent, app)
data && links.push(data)
}
const parseLink = (
link: HTMLElement,
parent: HTMLElement,
app: IInternalAppInfo
) => {
const rel = link.getAttribute('rel')
const href = link.getAttribute('href')
let comment: Comment | null
// 判断是不是获取 CSS 资源
if (rel === 'stylesheet' && href) {
comment = document.createComment(`link replaced by micro`)
// @ts-ignore
comment && parent.replaceChild(comment, script)
return getCompletionURL(href, app.entry)
} else if (href) {
link.setAttribute('href', getCompletionURL(href, app.entry)!)
}
}
处理 link
标签时,我们只需要处理 CSS 资源,其它 preload / prefetch 的这些资源直接替换 href
就行。
// src/loader/parse.ts
// 补全 parseHTML 逻辑
if (/^(link)$/i.test(dom.tagName)) {
const data = parseScript(dom, parent, app)
data.text && inlineScript.push(data.text)
data.url && scripts.push(data.url)
}
const parseScript = (
script: HTMLElement,
parent: HTMLElement,
app: IInternalAppInfo
) => {
let comment: Comment | null
const src = script.getAttribute('src')
// 有 src 说明是 JS 文件,没 src 说明是 inline script,也就是 JS 代码直接写标签里了
if (src) {
comment = document.createComment('script replaced by micro')
} else if (script.innerHTML) {
comment = document.createComment('inline script replaced by micro')
}
// @ts-ignore
comment && parent.replaceChild(comment, script)
return { url: getCompletionURL(src, app.entry), text: script.innerHTML }
}
script
标签时,我们需要区别是 JS 文件还是行内代码,前者还需要 fecth
一次获取内容。parseHTML
中返回所有解析出来的 scripts
, links
, inlineScript
。// src/loader/index.ts
export const loadHTML = async (app: IInternalAppInfo) => {
const { container, entry } = app
const fakeContainer = document.createElement('div')
fakeContainer.innerHTML = htmlFile
const { scripts, links, inlineScript } = parseHTML(fakeContainer, app)
await Promise.all(links.map((link) => fetchResource(link)))
const jsCode = (
await Promise.all(scripts.map((script) => fetchResource(script)))
).concat(inlineScript)
return app
}
loadHTML
改造成这样:export const loadHTML = async (app: IInternalAppInfo) => {
const { container, entry } = app
// template:处理好的 HTML 内容
// getExternalStyleSheets:fetch CSS 文件
// getExternalScripts:fetch JS 文件
const { template, getExternalScripts, getExternalStyleSheets } =
await importEntry(entry)
const dom = document.querySelector(container)
if (!dom) {
throw new Error('容器不存在 ')
}
// 挂载 HTML 到微前端容器上
dom.innerHTML = template
// 加载文件
await getExternalStyleSheets()
const jsCode = await getExternalScripts()
return app
}
运行 JS
当我们拿到所有 JS 内容以后就该运行 JS 了,这步完成以后我们就能在页面上看到子应用被渲染出来了。
这一小节的内容说简单的话可以没几行代码就写完,说复杂的话实现起来会需要考虑很多细节,我们先来实现简单的部分,也就是如何运行 JS。
对于一段 JS 字符串来说,我们想执行的话大致上有两种方式:
eval(js string)
new Function(js string)()
这边我们选用第二种方式来实现:
const runJS = (value: string, app: IInternalAppInfo) => {
const code = `
${value}
return window['${app.name}']
`
return new Function(code).call(window, window)
}
name
属性,这个属性其实很重要,我们在之后的场景中也会用到。另外大家给子应用设置 name
的时候别忘了还需要略微改动下打包的配置,将其中一个选项也设置为同样内容。name: vue
,那么我们还需要在打包配置中进行如下设置:// vue.config.js
module.exports = {
configureWebpack: {
output: {
// 和 name 一样
library: `vue`
},
},
}
这样配置后,我们就能通过 window.vue
访问到应用的 JS 入口文件 export
出来的内容了:
大家可以在上图中看到导出的这些函数都是子应用的生命周期,我们需要拿到这些函数去调用。
最后我们在 loadHTML
中调用一下 runJS
就完事了:
export const loadHTML = async (app: IInternalAppInfo) => {
const { container, entry } = app
const { template, getExternalScripts, getExternalStyleSheets } =
await importEntry(entry)
const dom = document.querySelector(container)
if (!dom) {
throw new Error('容器不存在 ')
}
dom.innerHTML = template
await getExternalStyleSheets()
const jsCode = await getExternalScripts()
jsCode.forEach((script) => {
const lifeCycle = runJS(script, app)
if (lifeCycle) {
app.bootstrap = lifeCycle.bootstrap
app.mount = lifeCycle.mount
app.unmount = lifeCycle.unmount
}
})
return app
}
完成以上步骤后,我们就能看到子应用被正常渲染出来了!
但是到这一步其实还不算完,我们考虑这样一个问题:子应用改变全局变量怎么办? 我们目前所有应用都可以获取及改变 window
上的内容,那么一旦应用之间出现全局变量冲突就会引发问题,因此我们接下来需要来解决这个事儿。
JS 沙箱
window
上的属性又要能访问 window
上的内容,那么就只能做个假的 window
给子应用了,也就是实现一个 JS 沙箱。快照 Proxy
window
上的所有内容,然后接下来就随便让子应用去玩了,直到卸载子应用时恢复挂载前的 window
即可。这种方案实现容易,唯一缺点就是性能慢点,有兴趣的读者可以直接看看 qiankun 的实现,这里就不再贴代码了。export class ProxySandbox {
proxy: any
running = false
constructor() {
// 创建个假的 window
const fakeWindow = Object.create(null)
const proxy = new Proxy(fakeWindow, {
set: (target: any, p: string, value: any) => {
// 如果当前沙箱在运行,就直接把值设置到 fakeWindow 上
if (this.running) {
target[p] = value
}
return true
},
get(target: any, p: string): any {
// 防止用户逃课
switch (p) {
case 'window':
case 'self':
case 'globalThis':
return proxy
}
// 假如属性不存在 fakeWindow 上,但是存在于 window 上
// 从 window 上取值
if (
!window.hasOwnProperty.call(target, p) &&
window.hasOwnProperty(p)
) {
// @ts-ignore
const value = window[p]
if (typeof value === 'function') return value.bind(window)
return value
}
return target[p]
},
has() {
return true
},
})
this.proxy = proxy
}
// 激活沙箱
active() {
this.running = true
}
// 失活沙箱
inactive() {
this.running = false
}
}
window
出来,如果用户设置值的话就设置在 fakeWindow
上,这样就不会影响全局变量了。如果用户取值的话,就判断属性是存在于 fakeWindow
上还是 window
上。runJS
里的代码以便使用沙箱:const runJS = (value: string, app: IInternalAppInfo) => {
if (!app.proxy) {
app.proxy = new ProxySandbox()
// 将沙箱挂在全局属性上
// @ts-ignore
window.__CURRENT_PROXY__ = app.proxy.proxy
}
// 激活沙箱
app.proxy.active()
// 用沙箱替代全局环境调用 JS
const code = `
return (window => {
${value}
return window['${app.name}']
})(window.__CURRENT_PROXY__)
`
return new Function(code)()
}
至此,我们其实已经完成了整个微前端的核心功能。
接下来我们会来做一些改善型功能。
改善型功能
prefetch
import-html-entry
就能马上做完了:// src/start.ts
export const start = () => {
const list = getAppList()
if (!list.length) {
throw new Error('请先注册应用')
}
hijackRoute()
reroute(window.location.href)
// 判断状态为 NOT_LOADED 的子应用才需要 prefetch
list.forEach((app) => {
if ((app as IInternalAppInfo).status === AppStatus.NOT_LOADED) {
prefetch(app as IInternalAppInfo)
}
})
}
// src/utils.ts
export const prefetch = async (app: IInternalAppInfo) => {
requestIdleCallback(async () => {
const { getExternalScripts, getExternalStyleSheets } = await importEntry(
app.entry
)
requestIdleCallback(getExternalStyleSheets)
requestIdleCallback(getExternalScripts)
})
}
接下来主要来聊下 requestIdleCallback
这个函数。
window.requestIdleCallback()
方法将在浏览器的空闲时段内调用的函数排队。这使开发者能够在主事件循环上执行后台和低优先级工作,而不会影响延迟关键事件,如动画和输入响应。
prefetch
,其实这个函数在 React 中也有用到,无非内部实现了一个 polyfill 版本。因为这个 API 有一些问题(最快 50ms 响应一次)尚未解决,但是在我们的场景下不会有问题,所以可以直接使用。资源缓存机制
当我们加载过一次资源后,用户肯定不希望下次再进入该应用的时候还需要再加载一次资源,因此我们需要实现资源的缓存机制。
上一小节我们因为使用到了 import-html-entry
,内部自带了缓存机制。如果你想自己实现的话,可以参考内部的实现方式。
简单来说就是搞一个对象缓存下每次请求下来的文件内容,下次请求的时候先判断对象中存不存在值,存在的话直接拿出来用就行。
全局通信及状态
这部分内容在笔者的代码中并未实现,如果你有兴趣自己做的话,笔者可以提供一些思路。
全局通信及状态实际上完全都可以看做是发布订阅模式的一种实现,只要你自己手写过 Event
的话,实现这个应该不是什么难题。
另外你也可以阅读下 qiankun 的全局状态实现,总共也就 100 行代码。
五、总结
在看源码前,我们先去官方文档复习下框架设计理念、源码分层设计 阅读下框架官方开发人员写的相关文章 借助框架的调用栈来进行源码的阅读,通过这个执行流程,我们就完整的对源码进行了一个初步的了解 接下来再对源码执行过程中涉及的所有函数逻辑梳理一遍
关注我,一起携手进阶
欢迎关注前端早茶,与广东靓仔携手共同进阶~