“自动”加载资源文件
现在我们来看一下如何自动加载子应用的入口文件(只在第一次加载子应用时执行):
export default function parseHTMLandLoadSources(app: Application) { return new Promise<void>(async (resolve, reject) => { const pageEntry = app.pageEntry // load html const html = await loadSourceText(pageEntry) const domparser = new DOMParser() const doc = domparser.parseFromString(html, 'text/html') const { scripts, styles } = extractScriptsAndStyles(doc as unknown as Element, app) // 提取了 script style 后剩下的 body 部分的 html 内容 app.pageBody = doc.body.innerHTML let isStylesDone = false, isScriptsDone = false // 加载 style script 的内容 Promise.all(loadStyles(styles)) .then(data => { isStylesDone = true // 将 style 样式添加到 document.head 标签 addStyles(data as string[]) if (isScriptsDone && isStylesDone) resolve() }) .catch(err => reject(err)) Promise.all(loadScripts(scripts)) .then(data => { isScriptsDone = true // 执行 script 内容 executeScripts(data as string[]) if (isScriptsDone && isStylesDone) resolve() }) .catch(err => reject(err)) }) }
上面代码的逻辑:
- 利用 ajax 请求子应用入口 URL 的内容,得到子应用的 HTML
- 提取 HTML 中
script
style
的内容或 URL,如果是 URL,则再次使用 ajax 拉取内容。最后得到入口页面所有的script
style
的内容 - 将所有 style 添加到
document.head
下,script
代码直接执行 - 将剩下的 body 部分的 HTML 内容赋值给子应用要挂载的 DOM 下。
下面再详细描述一下这四步是怎么做的。
一、拉取 HTML 内容
export function loadSourceText(url: string) { return new Promise<string>((resolve, reject) => { const xhr = new XMLHttpRequest() xhr.onload = (res: any) => { resolve(res.target.response) } xhr.onerror = reject xhr.onabort = reject xhr.open('get', url) xhr.send() }) }
代码逻辑很简单,使用 ajax 发起一个请求,得到 HTML 内容。
上图就是一个 vue 子应用的 HTML 内容,箭头所指的是要提取的资源,方框标记的内容要赋值给子应用所挂载的 DOM。
二、解析 HTML 并提取 style script 标签内容
这需要使用一个 API DOMParser,它可以直接解析一个 HTML 字符串,并且不需要挂到 document 对象上。
const domparser = new DOMParser() const doc = domparser.parseFromString(html, 'text/html')
提取标签的函数 extractScriptsAndStyles(node: Element, app: Application)
代码比较多,这里就不贴代码了。这个函数主要的功能就是递归遍历上面生成的 DOM 树,提取里面所有的 style
script
标签。
三、添加 style 标签,执行 script 脚本内容
这一步比较简单,将所有提取的 style
标签添加到 document.head
下:
export function addStyles(styles: string[] | HTMLStyleElement[]) { styles.forEach(item => { if (typeof item === 'string') { const node = createElement('style', { type: 'text/css', textContent: item, }) head.appendChild(node) } else { head.appendChild(item) } }) }
js 脚本代码则直接包在一个匿名函数内执行:
export function executeScripts(scripts: string[]) { try { scripts.forEach(code => { new Function('window', code).call(window, window) }) } catch (error) { throw error } }
四、将剩下的 body 部分的 HTML 内容赋值给子应用要挂载的 DOM 下
为了保证子应用正常执行,需要将这部分的内容保存起来。然后每次在子应用 mount()
前,赋值到所挂载的 DOM 下。
// 保存 HTML 代码 app.pageBody = doc.body.innerHTML // 加载子应用前赋值给挂载的 DOM app.container.innerHTML = app.pageBody app.mount()
现在我们已经可以非常方便的加载子应用了,但是子应用还有一些东西需要修改一下。
子应用需要做的事情
在 V1 版本里,注册子应用的时候有一个 loadApp()
方法。微前端框架在第一次加载子应用时会执行这个方法,从而拿到子应用暴露的三个方法。现在实现了 pageEntry
功能,我们就不用把这个方法写在主应用里了,因为不再需要在主应用里引入子应用。
但是又得让微前端框架拿到子应用暴露出来的方法,所以我们可以换一种方式暴露子应用的方法:
// 每个子应用都需要这样暴露三个 API,该属性格式为 `mini-single-spa-${appName}` window['mini-single-spa-vue'] = { bootstrap, mount, unmount }
这样微前端也能拿到每个子应用暴露的方法,从而实现加载、卸载子应用的功能。
另外,子应用还得做两件事:
- 配置 cors,防止出现跨域问题(由于主应用和子应用的域名不同,会出现跨域问题)
- 配置资源发布路径
如果子应用是基于 webpack 进行开发的,可以这样配置:
module.exports = { devServer: { port: 8001, // 子应用访问端口 headers: { 'Access-Control-Allow-Origin': '*' } }, publicPath: "//localhost:8001/", }
一个完整的示例
示例代码在 examples 目录。
registerApplication({ name: 'vue', pageEntry: 'http://localhost:8001', activeRule: pathPrefix('/vue'), container: $('#subapp-viewport') }) registerApplication({ name: 'react', pageEntry: 'http://localhost:8002', activeRule:pathPrefix('/react'), container: $('#subapp-viewport') }) start()
V3 版本
V3 版本主要添加以下两个功能:
- 隔离子应用 window 作用域
- 隔离子应用元素作用域
隔离子应用 window 作用域
在 V2 版本下,主应用及所有的子应用都共用一个 window 对象,这就导致了互相覆盖数据的问题:
// 先加载 a 子应用 window.name = 'a' // 后加载 b 子应用 window.name = 'b' // 这时再切换回 a 子应用,读取 window.name 得到的值却是 b console.log(window.name) // b
为了避免这种情况发生,我们可以使用 Proxy 来代理对子应用 window 对象的访问:
app.window = new Proxy({}, { get(target, key) { if (Reflect.has(target, key)) { return Reflect.get(target, key) } const result = originalWindow[key] // window 原生方法的 this 指向必须绑在 window 上运行,否则会报错 "TypeError: Illegal invocation" // e.g: const obj = {}; obj.alert = alert; obj.alert(); return (isFunction(result) && needToBindOriginalWindow(result)) ? result.bind(window) : result }, set: (target, key, value) => { this.injectKeySet.add(key) return Reflect.set(target, key, value) } })
从上述代码可以看出,用 Proxy 对一个空对象做了代理,然后把这个代理对象作为子应用的 window 对象:
- 当子应用里的代码访问
window.xxx
属性时,就会被这个代理对象拦截。它会先看看子应用的代理 window 对象有没有这个属性,如果找不到,就会从父应用里找,也就是在真正的 window 对象里找。 - 当子应用里的代码修改 window 属性时,会直接在子应用的代理 window 对象上修改。
那么问题来了,怎么让子应用里的代码读取/修改 window 时候,让它们访问的是子应用的代理 window 对象?
刚才 V2 版本介绍过,微前端框架会代替子应用拉取 js 资源,然后直接执行。我们可以在执行代码的时候使用 with 语句将代码包一下,让子应用的 window 指向代理对象:
export function executeScripts(scripts: string[], app: Application) { try { scripts.forEach(code => { // ts 使用 with 会报错,所以需要这样包一下 // 将子应用的 js 代码全局 window 环境指向代理环境 proxyWindow const warpCode = ` ;(function(proxyWindow){ with (proxyWindow) { (function(window){${code}\n}).call(proxyWindow, proxyWindow) } })(this); ` new Function(warpCode).call(app.sandbox.proxyWindow) }) } catch (error) { throw error } }
卸载时清除子应用 window 作用域
当子应用卸载时,需要对它的 window 代理对象进行清除。否则下一次子应用重新加载时,它的 window 代理对象会存有上一次加载的数据。刚才创建 Proxy 的代码中有一行代码 this.injectKeySet.add(key)
,这个 injectKeySet
是一个 Set 对象,存着每一个 window 代理对象的新增属性。所以在卸载时只需要遍历这个 Set,将 window 代理对象上对应的 key 删除即可:
for (const key of injectKeySet) { Reflect.deleteProperty(microAppWindow, key as (string | symbol)) }
记录绑定的全局事件、定时器,卸载时清除
通常情况下,一个子应用除了会修改 window 上的属性,还会在 window 上绑定一些全局事件。所以我们要把这些事件记录起来,在卸载子应用时清除这些事件。同理,各种定时器也一样,卸载时需要清除未执行的定时器。
下面的代码是记录事件、定时器的部分关键代码:
// 部分关键代码 microAppWindow.setTimeout = function setTimeout(callback: Function, timeout?: number | undefined, ...args: any[]): number { const timer = originalWindow.setTimeout(callback, timeout, ...args) timeoutSet.add(timer) return timer } microAppWindow.clearTimeout = function clearTimeout(timer?: number): void { if (timer === undefined) return originalWindow.clearTimeout(timer) timeoutSet.delete(timer) } microAppWindow.addEventListener = function addEventListener( type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions | undefined, ) { if (!windowEventMap.get(type)) { windowEventMap.set(type, []) } windowEventMap.get(type)?.push({ listener, options }) return originalWindowAddEventListener.call(originalWindow, type, listener, options) } microAppWindow.removeEventListener = function removeEventListener( type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions | undefined, ) { const arr = windowEventMap.get(type) || [] for (let i = 0, len = arr.length; i < len; i++) { if (arr[i].listener === listener) { arr.splice(i, 1) break } } return originalWindowRemoveEventListener.call(originalWindow, type, listener, options) }
下面这段是清除事件、定时器的关键代码:
for (const timer of timeoutSet) { originalWindow.clearTimeout(timer) } for (const [type, arr] of windowEventMap) { for (const item of arr) { originalWindowRemoveEventListener.call(originalWindow, type as string, item.listener, item.options) } }
缓存子应用快照
之前提到过子应用每次加载的时候会都执行 mount()
方法,由于每个 js 文件只会执行一次,所以在执行 mount()
方法之前的代码在下一次重新加载时不会再次执行。
举个例子:
window.name = 'test' function bootstrap() { // ... } function mount() { // ... } function unmount() { // ... }
上面是子应用入口文件的代码,在第一次执行 js 代码时,子应用可以读取 window.name
这个属性的值。但是子应用卸载时会把 name
这个属性清除掉。所以子应用下一次加载的时候,就读取不到这个属性了。
为了解决这个问题,我们可以在子应用初始化时(拉取了所有入口 js 文件并执行后)将当前的子应用 window 代理对象的属性、事件缓存起来,生成快照。下一次子应用重新加载时,将快照恢复回子应用上。
生成快照的部分代码:
const { windowSnapshot, microAppWindow } = this const recordAttrs = windowSnapshot.get('attrs')! const recordWindowEvents = windowSnapshot.get('windowEvents')! // 缓存 window 属性 this.injectKeySet.forEach(key => { recordAttrs.set(key, deepCopy(microAppWindow[key])) }) // 缓存 window 事件 this.windowEventMap.forEach((arr, type) => { recordWindowEvents.set(type, deepCopy(arr)) })
恢复快照的部分代码:
const { windowSnapshot, injectKeySet, microAppWindow, windowEventMap, onWindowEventMap, } = this const recordAttrs = windowSnapshot.get('attrs')! const recordWindowEvents = windowSnapshot.get('windowEvents')! recordAttrs.forEach((value, key) => { injectKeySet.add(key) microAppWindow[key] = deepCopy(value) }) recordWindowEvents.forEach((arr, type) => { windowEventMap.set(type, deepCopy(arr)) for (const item of arr) { originalWindowAddEventListener.call(originalWindow, type as string, item.listener, item.options) } })