微前端
使用微前端的挑战: 子应用切换,应用相互隔离,互补干扰,子应用之前的通信,多个子应用并存,用户状态的存储,免登。
常用技术方案
路由分发式微前端
通过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__ = true
const router = new VueRouter({
mode: 'history',
routes: []
});
// 远程加载子应用
function createScript(url) {
return new Promise((resolve, reject) => {
const script = document.createElement('script')
script.src = url
script.onload = resolve
script.onerror = reject
const 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.js
import Vue from 'vue'
import App from './App.vue'
Vue.config.productionTip = false
const 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 = true
evt.singleSpaTrigger = originalMethodName
return 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 时,子应用就可接受到通知,然后更新。