微前端
使用微前端的挑战: 子应用切换,应用相互隔离,互补干扰,子应用之前的通信,多个子应用并存,用户状态的存储,免登。
常用技术方案
路由分发式微前端
通过http服务的反向代理
http {server {listen 80;server_name xxx.xxx.com;location /api/ {proxy_pass http://localhost:3001/api }location /web/admin {proxy_pass http://localhost:3002/api}location / {proxy_pass /;}}
}
实现简单,不需要对现有应用进行改造,和技术栈无关。
切换应用的时候,浏览器都需要重新加载页面。
iframe
html的标签
实现简单,css和js隔离,互不干扰。全局上下文完全隔离,内存变量不共享,子应用之间的通信,数据同步过程比较复杂,对seo不友好。切换应用的时候,浏览器都需要重新加载页面。
single-spa
在single-spa方案中,应用被分为两类:基座应用和子应用。
single-spa 会在基座应用中维护一个路由注册表,每个路由对应一个子应用。基座应用启动以后,当我们切换路由时,如果是一个新的子应用,会动态获取子应用的 js 脚本,然后执行脚本并渲染出相应的页面;如果是一个已经访问过的子应用,那么就会从缓存中获取已经缓存的子应用,激活子应用并渲染出对应的页面。
// 基座
import Vue from 'vue'
import App from './App.vue'
import VueRouter from 'vue-router';
const { registerApplication, start } = require('single-spa');Vue.use(VueRouter)Vue.config.productionTip = false// 接入 single-spa 的标志
window.__SINGLE_SPA__ = trueconst router = new VueRouter({mode: 'history',routes: []
});// 远程加载子应用
function createScript(url) {return new Promise((resolve, reject) => {const script = document.createElement('script')script.src = urlscript.onload = resolvescript.onerror = rejectconst firstScript = document.getElementsByTagName('script')[0]firstScript.parentNode.insertBefore(script, firstScript)})
}
// 加载子应用
function loadApp(url, globalVar, entrypoints) {return async () => {for(let i = 0; i < entrypoints.length; i++) {await createScript(url + entrypoints[i])}return window[globalVar]}
}
// 子应用路由注册表
const apps = [{// 子应用名称name: 'app1',// 子应用加载函数app: loadApp('http://localhost:8081', 'app1', [ "/js/chunk-vendors.js", "/js/app.js" ]),// 当路由满足条件时(返回true),激活(挂载)子应用activeWhen: location => location.pathname.startsWith('/app1'),// 传递给子应用的对象customProps: {}},{name: 'app2',app: loadApp('http://localhost:8082', 'app2', [ "/js/chunk-vendors.js", "/js/app.js" ]),activeWhen: location => location.pathname.startsWith('/app2'),customProps: {}},{// 子应用名称name: 'app3',// 子应用加载函数app: loadApp('http://localhost:3000', 'app3', ["/main.js"]),// 当路由满足条件时(返回true),激活(挂载)子应用activeWhen: location => location.pathname.startsWith('/app3'),// 传递给子应用的对象customProps: {}}
]// 注册子应用
for (let i = apps.length - 1; i >= 0; i--) {registerApplication(apps[i])
}new Vue({router,render: h => h(App),mounted() {// 启动start()},
}).$mount('#app')
- name: 子应用的唯一表示
- activeWhen: 子应用激活的条件,当url发生变化的升级后,会遍历执行注册的子应用的activeWhen方法,当activeWhen返回的是true,对应的子应用就会被激活
- app: 用户获取子应用提供给基座应用的生命周期,bootstrap mount unmount等。基座应用切换子应用时,也是同样的操作,即先执行上一个子应用的 unmount 操作,然后再执行下一个子应用的 mount 操作。因此就需要子应用提供 mount、unmount 等生命周期方法,供基座应用调用。和单页应用的懒加载一样,基座应用在激活子应用时,如果子应用是首次激活,就会执行 app 方法,动态去加载子应用的入口 js 文件,然后执行,得到子应用的生命周期方法。
- customProps: 子应用激活的时候,可以传递给子应用的自定义属性,是一个对象
// index.jsimport Vue from 'vue'
import App from './App.vue'Vue.config.productionTip = falseconst appOptions = {render: (h) => h(App)
};let vueInstance;// 子应用没有接入 single-spa
if (!window.__SINGLE_SPA__) {new Vue(appOptions).$mount('#app')
}// 提供 bootstrap 生命周期方法
export function bootstrap () {console.log('app1 bootstrap')return Promise.resolve().then(() => {});
}
// 提供 mount 生命周期方法
export function mount (props) {console.log('app1 mount', props)return Promise.resolve().then(() => {vueInstance = new Vue(appOptions)vueInstance.$mount('#microApp')})
}// 提供 unmount 生命周期方法
export function unmount () {console.log('app1 unmount')return Promise.resolve().then(() => {if (!vueInstance.$el.id) {vueInstance.$el.id = 'microApp'}vueInstance.$destroy()vueInstance.$el.innerHTML = ''})
}// 提供 update 生命周期方法
export function update () {console.log('app1 update');
}
通常通过webpack构建工具生成的js脚本,表现形式都是iiff,就是立即执行函数表达式。各子应用对应的脚本执行的时是相互隔离的,如果是这样,基座应用在激活子应用的时候,是无法获取到子应用的生命周期方法的,也无法挂载子应用。添加libaray,libarayTarget配置项,将子应用入口文件的返回值就是生命周期方法暴露给window,这样基座应用就可以从window中获取子应用的生命周期的方法。
// 项目的构建脚本
module.exports = {configureWebpack: {...publicPath: 'http://localhost:8081'output: {library: 'app1', libraryTarget: 'var'} }
}
- 单页应用的路由切换功能是基于window.history(window.location.hash)实现。在单页面应用中,会给window对象注册popstate(hashchange)事件,在callback中,添加页面切换的逻辑,当通过执行 pushState(replaceState) 方法、修改 hash 值、使用浏览器前进后退(go、back、forward)功能改变 url 时,会触发 popstate(hashchange) 事件,然后切换页面。
- 基座应用加载执行 single-spa 时,也会给 window 对象注册 popstate (hashchange) 事件, popstate(hashchange) 的 calback 中,就是激活子应用的逻辑。当基座应用通过执行pushState(replaceState)、修改 hash、使用浏览器前进后退(go、back、forward)功能的方式修改 url 时,popstate(hashchange) 就会触发,相应的子应用的激活逻辑就会执行。
// 通过原生构造函数 - popStateEvent 创建一个popstate事件对象
function createPopStateEvent(state, originalMethodName) {var evt;try {evt = new PopStateEvent("popstate", {state: state })} catch(err) {evt = document.createEvent('popstateevent')evt.initPopStateEvent("popstate", false, false, state)}evt.singleSpa = trueevt.singleSpaTrigger = originalMethodNamereturn evt
}
// 重写 updateState、replaceState 方法,通过 window.dispatchEvent 方法,手动触发 popstate 事件
function patchedUpdateState(updateState, methodName) {return function () {var urlBefore = window.location.href;var result = updateState.apply(this, arguments);var urlAfter = window.location.href;if (!urlRerouteOnly || urlBefore !== urlAfter) {window.dispatchEvent(createPopStateEvent(window.history.state, methodName));}return result;};
}
// 重写 pushState 方法
window.history.pushState = patchedUpdateState(window.history.pushState, "pushState");
// 重写 replaceState 方法
window.history.replaceState = patchedUpdateState(window.history.replaceState, "replaceState");
...
const router = new VueRouter({mode: 'history',base: '/app1',routes: [{path: '/foo',name: 'foo',component: {...}}, {path: '/bar',name: 'bar',component: {...}}]
})
...
application 模式下,single-spa 的工作流程,application 模式下,我们需要先通过registerApplication 注册子应用,然后在基座应用挂载完成以后执行 start 方法, 这样基座应用就可
以根据 url 的变化来进行子应用切换,激活对应的子应用。
parcel模式下,single-spa的工作流程。mountRootParcel 方法会返回一个parcel实例对象,内部包含update、unmount 方法。当我们需要更新组件时,直接调用parcel对象的update方法,就可以触发组件的update生命周期方法;当我们需要卸载组件时,直接调用parcel对象的unmount方法。在执行mountRootParcel 方法时,传入的第二个参数,会作为组件 mount 生命周期方法的入参;在执行 parcel.update 方法时,传入的参数,会作为组件 update 生命周期方法的入参。
子应用是否被挂载
- NOT_LOADED 未加载/待加载
- LOAD_SOURCE_CODE 加载源代码
- NOT_SOURCE_CODE 未启动/待启动
- BOOTSTRAPPING 子应用启动中
- NOT_MOUNTRED 为挂载/待挂载
- MOUNTING 子应用挂载中
- UNMOUNTING 需要卸载
- UNMOUNTED 已经卸载
- LOAD_ERROR 子应用加载失败
传参
父组件和parcel组件的通信
mount 阶段,父组件在执行 mountRootParcel 时,可以将要传递给 parcel 组件的值作为第二个参数,这个参数会作为 parcel 组件 mount 方法执行时的入参,这样 parcel 组件就可以拿到父组件传递的值。update 阶段也一样,父组件执行 parcel.update 时,传入的参数会作为 parcel 组件 update 方法执行时的入参。
基座应用和子组件之间的通信
基座应用在定义路由注册表的时候,会给每个子应用定义一个customProps,这个customoProps会作为子应用mount方法的入参,在子应用中, customProps(或者 customProps 里面的某个值) 可以作为子应用的共享状态(使用 vuex、mobx、redux 等)。这样,当基座应用修改 customProps 时,子应用就可接受到通知,然后更新。