快照(Snapshot)沙箱
所谓 快照沙箱 其实就是基于 diff
方式实现的沙箱:
- 在 激活子应用 时优先将当前的
window
对象进行拷贝存储,再从上一次记录的modifyPropsMap
中恢复该应用 上次的修改 到window
中 - 在 离开子应用 时会与原有的
window
与 快照对象windowSnapshot
进行diff
,将 变更的属性 保存到modifyPropsMap
中,便与下次该 应用激活时 进行数据恢复,即把有变更的属性值同步之前的状态
// qiankun\src\sandbox\snapshotSandbox.ts /** * 基于 diff 方式实现的沙箱,用于不支持 Proxy 的低版本浏览器 */ export default class SnapshotSandbox implements SandBox { 省略代码 constructor(name: string) { this.name = name; this.proxy = window; this.type = SandBoxType.Snapshot; } active() { // 记录当前快照 this.windowSnapshot = {} as Window; iter(window, (prop) => { this.windowSnapshot[prop] = window[prop]; }); // 恢复之前的变更 Object.keys(this.modifyPropsMap).forEach((p: any) => { window[p] = this.modifyPropsMap[p]; }); this.sandboxRunning = true; } inactive() { this.modifyPropsMap = {}; iter(window, (prop) => { if (window[prop] !== this.windowSnapshot[prop]) { // 记录变更,恢复环境 this.modifyPropsMap[prop] = window[prop]; window[prop] = this.windowSnapshot[prop]; } }); if (process.env.NODE_ENV === 'development') { console.info(`[qiankun:sandbox] ${this.name} origin window restore...`, Object.keys(this.modifyPropsMap)); } this.sandboxRunning = false; } } function iter(obj: typeof window, callbackFn: (prop: any) => void) { for (const prop in obj) { // 出于兼容原因,为 clearInterval 打补丁 if (obj.hasOwnProperty(prop) || prop === 'clearInterval') { callbackFn(prop); } } } 复制代码
应用通信
qiankun
中应用通信可以通过 initGlobalState(state)
的方式实现,它用于定义全局状态,并返回通信方法,官方建议在主应用使用,微应用通过 props
获取通信方法。
原理是什么呢,相信你下面用法,即便不看源码也猜得到是怎么实现的:
// 主应用 import { initGlobalState, MicroAppStateActions } from 'qiankun'; const actions: MicroAppStateActions = initGlobalState(state);// 初始化 state actions.onGlobalStateChange((state, prev) => { // state: 变更后的状态; prev 变更前的状态 console.log(state, prev); }); actions.setGlobalState(state); actions.offGlobalStateChange(); // 微应用 // 从生命周期 mount 中获取通信方法,使用方式和 master 一致 export function mount(props) { props.onGlobalStateChange((state, prev) => { // state: 变更后的状态; prev 变更前的状态 console.log(state, prev); }); props.setGlobalState(state); } 复制代码
这不就是妥妥的 发布订阅模式 嘛!!!
是的,毕竟发布订阅模式非常适用于需要通信的场景,就和在 vue2
中使用的 EventBus
核心原理是一样的。
实现微前端框架
这里还是直接沿用在上篇文章中创建的项目内容,具体可见 2022 你还不会微前端吗 (上) — 从巨石应用到微应用,下面一步步开始实现自己的微前端框架吧!
前置处理
简单回顾一下项目的大致结构:
- single-spa
- vue2-main-app(基座应用)
- vue3-micro-app(子应用)
- react-micro-app(子应用)
基座应用 — 入口文件
首先要做的就是修改基座应用的入口文件,将里面用于导入 registerMicroApps、start
方法的部分替换成自己定义的微前端模块 micro-fe
,具体如下:
// single-spa\vue2-main-app\src\registerApplication.ts //- import { registerMicroApps, start } from 'qiankun'; + import { registerMicroApps, start } from './micro-fe'; // 默认子应用 export const applications = [ { name: 'singleVue3', // app name registered entry: 'http://localhost:5000', container: '#micro-content', activeRule: '/vue3-micro-app', }, { name: 'singleReact', // app name registered entry: 'http://localhost:3000', container: '#micro-content', activeRule: '/react-micro-app', }, ] // 注册子应用 export const registerApps = (apps: any[] = applications) => { registerMicroApps(apps); start(); } 复制代码
定义 micro-fe
在 src
目录下新建 micro-fe
目录,其中 index.ts
为入口,文件内容如下:
// single-spa\vue2-main-app\src\micro-fe\index.ts export const registerMicroApps = (apps?: any[]) => { ... } export const start = () => { ... } 复制代码
注册应用 — registerMicroApps
registerMicroApps
函数核心要做的事情很简单,注册子应用 其实就是 保存子应用,这里把外部传入的子应用保存在全局变量 _apps
中,并向外部提供一个可以访问 _apps
的方法 getApps
,具体如下:
// single-spa\vue2-main-app\src\micro-fe\index.ts // 保存子应用 let _apps: any[] // 获取子应用 export const getApps = () => _apps // 注册子应用 export const registerMicroApps = (apps: any[] = []) => { _apps = apps; } 复制代码
启动子应用 — start
启动子应用需要做的核心内容如下:
- 监听路由变化
- 匹配子应用路由
- 加载子应用
- 渲染子应用
监听路由变化 & 匹配子应用路由
通过注册 hashchange
和 popstate
事件就可以实现对 hash
路由 和 history
路由 的监听,这里以 history
路由来实现,因为对其需要做特殊处理。
popstate
事件 可以监听 到window.history.[go | forward | back]()
等方法引起的路由变化,但 无法监听 到window.history.[pushState | replaceState]()
等方法引起的路由变化
因此,需要对 window.history.[pushState | replaceState]()
这两个方法进行 重写,便于在外部调用这两个方法时,也能达到路由监听的效果。
同时需要定义 子应用路由匹配路逻辑,需要在上述重写的方法和监听路由变化的事件中执行,并且匹配到对应的子应用后就需要进行 子应用的加载。
这里将与 history
路由相关的内容都放置到了 src\micro-fe\historyRoute.ts
中:
// src\micro-fe\historyRoute.ts import { getApp } from './index' import { loadApp } from './application' // 监听路由变化 export const listenHistoryRoute = () => { // 监听路由变化 window.addEventListener('popstate', () => { // 匹配路由 matchHistoryRoute() }) // 重写 pushState const rawPushState = window.history.pushState; window.history.pushState = (...args) => { // 调用原始方法 rawPushState.apply(window.history, args) // 匹配路由 matchHistoryRoute() } // 重写 replaceState const rawReplaceState = window.history.pushState; window.history.replaceState = (...args) => { // 调用原始方法 rawReplaceState.apply(window.history, args) // 匹配路由 matchHistoryRoute() } } // 匹配路由 export const matchHistoryRoute = () => { const apps = getApp(); const { pathname } = window.location; const app = apps.find(item => pathname.startsWith(item.activeRule)) if (!app) return // 加载子应用 loadApp(app) } 复制代码
加载子应用
这里将和应用相关的内容放到 src\micro-fe\application.ts
文件中。
加载子应用 实际上对应的是上述的 loadApp(app)
方法,而它需要做的内容如下:
- 加载子应用
html
模板 - 加载并执行
html
中的JS
脚本,包含内联和外链的脚本 - 加载其他资源文件,比如
css
、img
等
在
qiankun
中使用的是 import-html-entry 这个库来处理的,这里我们也自己来实现一下,并将相关内容存放在src\micro-fe\importHtmlEntry.ts
文件中
加载子应用 HTML 模板
加载子应用可以通过 fetch
和 注册子应用时配置的 entry
来实现,具体如下:
// src\micro-fe\importHtmlEntry.ts import { fectResource } from './fetch' import type { MicroApp } from './type' export const importEntry = async (app: MicroApp) => { // 获取模板 const html = await fectResource(app.entry) // 字符串模板 -> DOM 结构,目的是方便使用 DOM API const template = document.createElement('div') template.innerHTML = html // 加载模板中对应的 script 脚本内容 function getExternalScripts() { } // 执行模板中的 script 脚本内容 function execScripts() { } return { template, getExternalScripts, execScripts } } // src\micro-fe\fetch.ts export const fectResource = (url:string) => { return fetch(url).then(res => res.text()) } 复制代码
加载并执行JS
脚本
获取到模板内容之后,把模板内容作为一个 DOM
节点 innerHTML
的内容,方便通过 DOM API
的方式直接获取所有需要的 script
标签,而不需要通过字符串或正则匹配的模式来进行这个操作,具体如下:
getExternalScripts
方法负责获取模板中所有的script
标签,并根据其src
属性是否有值区分 内联 和 外链 脚本
- 内联脚本 直接获取其对应的脚本字符串,即通过
innerHTML
的方式直接获取 - 外链脚本 要区分第三方链接和当前微应用的链接,本质还是通过
fetch
去加载对应的脚本内容
execScripts
方法负责执行获取到的脚本内容,这里为了简便选择通过eval
的方式执行上述获取到的代码字符串,当然也可以通过new Function
的形式
// src\micro-fe\importHtmlEntry.ts import { fectResource } from './fetch' import type { MicroApp } from './type' const Noop = (props?: any) => props export const importEntry = async (app: MicroApp) => { // 获取模板 const html = await fectResource(app.entry) // 字符串模板 -> DOM 结构,目的是方便使用 DOM API const template = document.createElement('div') template.innerHTML = html // 获取模板中所有的 scripts const scripts = Array.from(template.querySelectorAll('script')) // 加载模板中对应的 script 脚本内容 function getExternalScripts() { return Promise.all(scripts.map(script => { const src = script.getAttribute('src') if (!src) return Promise.resolve(script.innerHTML) return fectResource(src.indexOf('//') > -1 ? src : app.entry + src) })) } // 执行模板中对应的 script 脚本内容 async function execScripts() { const scripts = await getExternalScripts(); window.__Micro_App__ = true; // 手动构建 CommonJS 规范 const module = { exports: { bootstrap: Noop, mount: Noop, unmount: Noop } } const exports = module.exports scripts.forEach((code) => { eval(code) }); return module.exports } return { template, getExternalScripts, execScripts } } 复制代码
加载其他资源文件
其他资源文件其实就是外链的样式、图片等,通常情况下只要配置了对应的微应用的 publicPath
自然就能够被正确加载,这一点在 qiankun 中其实有提及,其实还是通过 webpack 来设置运行时的 publicPath
。
值得注意的是,微应用的样式和基座应用冲突的问题,而这个其实也很好解决:
shadow DOM
可将标记结构、样式和行为隐藏起来,并与页面上的其他代码相隔离,最简单的隔离方案- 为每个微应用定义一个唯一的
css
选择器(如:app.name
)来限定样式的作用范围
- 可以在微应用中就定义好这个唯一标识
- 可以在基座应用加载微应用时在动态为其定义唯一标识
css in js
本质是通过JavaScript
来声明,维护样式
- 方式一:
styled-components
const Button = styled.button` border-radius: 3px; padding: 0.25em 1em; color: palevioletred; border: 2px solid palevioletred; `; function Buttons() { return ( <Button>Normal Button</Button> <Button primary>Primary Button</Button> ); } 复制代码
- 方式二:
内联样式
var styles = { base: { color: '#fff', }, primary: { background: '#0074D9' }, warning: { background: '#FF4136' } }; class Button extends React.Component { render() { return ( <button style={[ styles.base, styles[this.props.kind] ]}> {this.props.children} </button> ); } } 复制代码
扩展:为什么要手动构造 CommonJS
规范?
在实现微应用 HTML
模板解析后,需要执行对应的微应用脚本时,人为的手动构造了符合 CommonJS
规范的环境,其目的是为了更普适的获取在微应用中暴露出来的 bootstrap、mount、unmount
等生命周期钩子,便于在特定时机去执行。
// src\micro-fe\importHtmlEntry.ts import { fectResource } from './fetch' import type { MicroApp } from './type' const Noop = (props?: any) => props export const importEntry = async (app: MicroApp) => { // 执行模板中对应的 script 脚本内容 async function execScripts() { const scripts = await getExternalScripts(); window.__Micro_App__ = true; // 手动构建 CommonJS 规范 const module = { exports: { bootstrap: Noop, mount: Noop, unmount: Noop } } const exports = module.exports // 执行代码 scripts.forEach((code) => { eval(code) }); return module.exports } } 复制代码
但毕竟这里选择了 eval
的方式来执行脚本,那么该如何获取其中的导出方法呢?
首先这和在微应用中设置的打包格式为 umd
的方式有关,不防先看一看打包后的具体内容是什么样子的,这里以 vue3-micro-app
项目为例,运行 npm run build
后在 dist
目录下查看和 app.xxx.js
相关的内容:
// dist\js\app.6fa1a50e.js ;(function (t, n) { // CommonJS 规范 'object' === typeof exports && 'object' === typeof module ? (module.exports = n()) // AMD 规模 : 'function' === typeof define && define.amd ? define([], n) : 'object' === typeof exports // ESModule 规范 ? (exports['vue3-micro-app'] = n()) // 全局属性 : (t['vue3-micro-app'] = n()) })(window, function () { return (function () { return ... })() }) 复制代码
会发现 umd
的格式对当前运行时环境做了各种判断:
- 是否符合
CommonJS
规范,若符合则把其内部的返回值赋值给module.exports
,若不符合进入下一个判断 - 是否符合
AMD
规范,若符合则通过define([], n)
实现数据传递,若不符合进入下一个判断 - 是否符合
ESModule
规范,若符合则把其内部的返回值赋值给exports[ouput.library]
,若不符合进入下一个判断 - 上述条件不符合则会直接通过把返回值赋值给
window[ouput.library]
看起来,要获取微应用入口文件中导出的生命周期钩子方式很多呀,为什么要选则 CommonJS
的方式呢?
AMD
规范很少使用了,直接排除ESModule
规范 和window[ouput.library]
的方式,都非常依赖于在微应用和webpack
打包输出时指定的ouput.library
的值,意味着若在微前端框架内部不知道微应用真正的ouput.library
的值,那岂不是没办法获取到其导出的内容了
综上,其实只有 CommonJS
的方式满足不需要提前知道微应用的导出内容时真正对应的名称,也可以获取到其返回值的结果,但运行时环境复杂,并不一定是支持 CommonJS
规范,于是需要手动提供 module.exports
和 exports
对象来达到目的。
效果演示
最后
本篇文章的结束也算是抓住了 2022
的尾巴,把微前端的内容过了一遍,也算是完成了自己定下的一个 flag
,大前端太大,各种概念、各种技术层出不穷,容易让人摸不着头脑,其实不必要过分追逐,需要用到自然会去学习,想要了解自然就会去学习!!!
希望本篇文章对你有所帮助!!!