最近看了几个微前端框架的源码(single-spa、qiankun、micro-app),感觉收获良多。所以打算造一个迷你版的轮子,来加深自己对所学知识的了解。
这个轮子将分为五个版本,逐步的实现一个最小可用的微前端框架:
- 支持不同框架的子应用(v1 分支)
- 支持子应用 HTML 入口(v2 分支)
- 支持沙箱功能,子应用 window 作用域隔离、元素隔离(v3 分支)
- 支持子应用样式隔离(v4 分支)
- 支持各应用之间的数据通信(main 分支)
每一个版本的代码都是在上一个版本的基础上修改的,所以 V5 版本的代码是最终代码。
Github 项目地址:https://github.com/woai3c/mini-single-spa
V1 版本
V1 版本打算实现一个最简单的微前端框架,只要它能够正常加载、卸载子应用就行。如果将 V1 版本细分一下的话,它主要由以下两个功能组成:
- 监听页面 URL 变化,切换子应用
- 根据当前 URL、子应用的触发规则来判断是否要加载、卸载子应用
监听页面 URL 变化,切换子应用
一个 SPA 应用必不可少的功能就是监听页面 URL 的变化,然后根据不同的路由规则来渲染不同的路由组件。因此,微前端框架也可以根据页面 URL 的变化,来切换到不同的子应用:
// 当 location.pathname 以 /vue 为前缀时切换到 vue 子应用 https://www.example.com/vue/xxx // 当 location.pathname 以 /react 为前缀时切换到 react 子应用 https://www.example.com/react/xxx
这可以通过重写两个 API 和监听两个事件来完成:
- 重写 window.history.pushState()
- 重写 window.history.replaceState()
- 监听 popstate 事件
- 监听 hashchange 事件
其中 pushState()
、replaceState()
方法可以修改浏览器的历史记录栈,所以我们可以重写这两个 API。当这两个 API 被 SPA 应用调用时,说明 URL 发生了变化,这时就可以根据当前已改变的 URL 判断是否要加载、卸载子应用。
// 执行下面代码后,浏览器的 URL 将从 https://www.xxx.com 变为 https://www.xxx.com/vue window.history.pushState(null, '', '/vue')
当用户手动点击浏览器上的前进后退按钮时,会触发 popstate
事件,所以需要对这个事件进行监听。同理,也需要监听 hashchange
事件。
这一段逻辑的代码如下所示:
import { loadApps } from '../application/apps' const originalPushState = window.history.pushState const originalReplaceState = window.history.replaceState export default function overwriteEventsAndHistory() { window.history.pushState = function (state: any, title: string, url: string) { const result = originalPushState.call(this, state, title, url) // 根据当前 url 加载或卸载 app loadApps() return result } window.history.replaceState = function (state: any, title: string, url: string) { const result = originalReplaceState.call(this, state, title, url) loadApps() return result } window.addEventListener('popstate', () => { loadApps() }, true) window.addEventListener('hashchange', () => { loadApps() }, true) }
从上面的代码可以看出来,每次 URL 改变时,都会调用 loadApps()
方法,这个方法的作用就是根据当前的 URL、子应用的触发规则去切换子应用的状态:
export async function loadApps() { // 先卸载所有失活的子应用 const toUnMountApp = getAppsWithStatus(AppStatus.MOUNTED) await Promise.all(toUnMountApp.map(unMountApp)) // 初始化所有刚注册的子应用 const toLoadApp = getAppsWithStatus(AppStatus.BEFORE_BOOTSTRAP) await Promise.all(toLoadApp.map(bootstrapApp)) const toMountApp = [ ...getAppsWithStatus(AppStatus.BOOTSTRAPPED), ...getAppsWithStatus(AppStatus.UNMOUNTED), ] // 加载所有符合条件的子应用 await toMountApp.map(mountApp) }
这段代码的逻辑也比较简单:
- 卸载所有已失活的子应用
- 初始化所有刚注册的子应用
- 加载所有符合条件的子应用
根据当前 URL、子应用的触发规则来判断是否要加载、卸载子应用
为了支持不同框架的子应用,所以规定了子应用必须向外暴露bootstrap()
mount()
unmount()
这三个方法。bootstrap()
方法在第一次加载子应用时触发,并且只会触发一次,另外两个方法在每次加载、卸载子应用时都会触发。
不管注册的是什么子应用,在 URL 符合加载条件时就调用子应用的 mount()
方法,能不能正常渲染交给子应用负责。在符合卸载条件时则调用子应用的 unmount()
方法。
registerApplication({ name: 'vue', // 初始化子应用时执行该方法 loadApp() { return { mount() { // 这里进行挂载子应用的操作 app.mount('#app') }, unmount() { // 这里进行卸载子应用的操作 app.unmount() }, } }, // 如果传入一个字符串会被转为一个参数为 location 的函数 // activeRule: '/vue' 会被转为 (location) => location.pathname === '/vue' activeRule: (location) => location.hash === '#/vue' })
上面是一个简单的子应用注册示例,其中 activeRule()
方法用来判断该子应用是否激活(返回 true
表示激活)。每当页面 URL 发生变化,微前端框架就会调用 loadApps()
判断每个子应用是否激活,然后触发加载、卸载子应用的操作。
何时加载、卸载子应用
首先我们将子应用的状态分为三种:
bootstrap
,调用registerApplication()
注册一个子应用后,它的状态默认为bootstrap
,下一个转换状态为mount
。mount
,子应用挂载成功后的状态,它的下一个转换状态为unmount
。unmount
,子应用卸载成功后的状态,它的下一个转换状态为mount
,即卸载后的应用可再次加载。
现在我们来看看什么时候会加载一个子应用,当页面 URL 改变后,如果子应用满足以下两个条件,则需要加载该子应用:
activeRule()
的返回值为true
,例如 URL 从/
变为/vue
,这时子应用 vue 为激活状态(假设它的激活规则为/vue
)。- 子应用状态必须为
bootstrap
或unmount
,这样才能向mount
状态转换。如果已经处于mount
状态并且activeRule()
返回值为true
,则不作任何处理。
如果页面的 URL 改变后,子应用满足以下两个条件,则需要卸载该子应用:
activeRule()
的返回值为false
,例如 URL 从/vue
变为/
,这时子应用 vue 为失活状态(假设它的激活规则为/vue
)。- 子应用状态必须为
mount
,也就是当前子应用必须处于加载状态(如果是其他状态,则不作任何处理)。然后 URL 改变导致失活了,所以需要卸载它,状态也从mount
变为unmount
。
API 介绍
V1 版本主要向外暴露了两个 API:
registerApplication()
,注册子应用。start()
,注册完所有的子应用后调用,在它的内部会执行loadApps()
去加载子应用。
registerApplication(Application)
接收的参数如下:
interface Application { // 子应用名称 name: string /** * 激活规则,例如传入 /vue,当 url 的路径变为 /vue 时,激活当前子应用。 * 如果 activeRule 为函数,则会传入 location 作为参数,activeRule(location) 返回 true 时,激活当前子应用。 */ activeRule: Function | string // 传给子应用的自定义参数 props: AnyObject /** * loadApp() 必须返回一个 Promise,resolve() 后得到一个对象: * { * bootstrap: () => Promise<any> * mount: (props: AnyObject) => Promise<any> * unmount: (props: AnyObject) => Promise<any> * } */ loadApp: () => Promise<any> }
一个完整的示例
现在我们来看一个比较完整的示例(代码在 V1 分支的 examples 目录):
let vueApp registerApplication({ name: 'vue', loadApp() { return Promise.resolve({ bootstrap() { console.log('vue bootstrap') }, mount() { console.log('vue mount') vueApp = Vue.createApp({ data() { return { text: 'Vue App' } }, render() { return Vue.h( 'div', // 标签名称 this.text // 标签内容 ) }, }) vueApp.mount('#app') }, unmount() { console.log('vue unmount') vueApp.unmount() }, }) }, activeRule:(location) => location.hash === '#/vue', }) registerApplication({ name: 'react', loadApp() { return Promise.resolve({ bootstrap() { console.log('react bootstrap') }, mount() { console.log('react mount') ReactDOM.render( React.createElement(LikeButton), $('#app') ); }, unmount() { console.log('react unmount') ReactDOM.unmountComponentAtNode($('#app')); }, }) }, activeRule: (location) => location.hash === '#/react' }) start()
演示效果如下:
小结
V1 版本的代码打包后才 100 多行,如果只是想了解微前端的最核心原理,只看 V1 版本的源码就可以了。
V2 版本
V1 版本的实现还是非常简陋的,能够适用的业务场景有限。从 V1 版本的示例可以看出,它要求子应用提前把资源都加载好(或者把整个子应用打包成一个 NPM 包,直接引入),这样才能在执行子应用的 mount()
方法时,能够正常渲染。
举个例子,假设我们在开发环境启动了一个 vue 应用。那么如何在主应用引入这个 vue 子应用的资源呢?首先排除掉 NPM 包的形式,因为每次修改代码都得打包,不现实。第二种方式就是手动在主应用引入子应用的资源。例如 vue 子应用的入口资源为:
那么我们可以在注册子应用时这样引入:
registerApplication({ name: 'vue', loadApp() { return Promise.resolve({ bootstrap() { import('http://localhost:8001/js/chunk-vendors.js') import('http://localhost:8001/js/app.js') }, mount() { // ... }, unmount() { // ... }, }) }, activeRule: (location) => location.hash === '#/vue' })
这种方式也不靠谱,每次子应用的入口资源文件变了,主应用的代码也得跟着变。还好,我们有第三种方式,那就是在注册子应用的时候,把子应用的入口 URL 写上,由微前端来负责加载资源文件。
registerApplication({ // 子应用入口 URL pageEntry: 'http://localhost:8081' // ... })