快照(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,大前端太大,各种概念、各种技术层出不穷,容易让人摸不着头脑,其实不必要过分追逐,需要用到自然会去学习,想要了解自然就会去学习!!!
希望本篇文章对你有所帮助!!!
