上一篇文章: 从0实现一个single-spa的前端微服务(中)
中我们已经实现了 single-spa
+ systemJS
的前端微服务以及完善的开发和打包配置,今天主要讲一下这个方案存在的细节问题,以及 qiankun
框架的一些研究对比。
single-spa
的三个生命周期函数 bootstrap
、 mount
、 unmount
分别表示初始化、加载时、卸载时。
bootstrap
、 mount
和 unmount
函数是必需的,但是 unload
是可选的。 Promise
。 promise
解析后再调用下一个。
我们知道,子系统卸载之后,其引入的 css
并不会被删掉,所以在子系统卸载时删掉这些 css
,是一种解决 css
污染的办法,但是不太好记录子系统引入了哪些 css
。
我们可以借助换肤的思路来解决 css
污染,首先 css-scoped
解决95%的样式污染,然后就是全局样式可能会造成污染,我们只需要将全局样式用一个 id/class
包裹着就可以了,这样这些全局样式仅在这个 id/class
范围内生效。
具体做法就是:在子系统加载时( mount
)给 <body>
加一个特殊的 id/class
,然后在子系统卸载时( unmount
)删掉这个 id/class
。而子系统的全局样式都仅在这个 id/class
范围内生效,如果子系统独立运行,只需要在子系统的入口文件 index.html
里面给 <body>
手动加上这个 id/class
即可。
代码如下:
async function mount(props){ //给body加class,以解决全局样式污染 document.body.classList.add('app-vue-history') } async function unmount(props){ //去掉body的class document.body.classList.remove('app-vue-history') } 复制代码
暂时没有很好的办法解决,但是可以靠编码规范来约束:页面销毁之前清除自己页面上的定时器/全局事件,必要的时候,全局变量也应该销毁。
这是一个比较常见的需求,类似还有某个系统需要插入一段特殊的 js/css
,而其他系统不需要,解决办法任然是在子系统加载时( mount
)插入需要的 js/css
,在子系统卸载时( unmount
)删掉。
const headEle = document.querySelector('head'); let linkEle = null ; // 因为新插入的icon会覆盖旧的,所以旧的不用删除,如果需要删除,可以在unmount时再插入进来 async function mount(props){ linkEle = document.createElement("link"); linkEle.setAttribute('rel','icon'); linkEle.setAttribute('href','https://gold-cdn.xitu.io/favicons/favicon.ico'); headEle.appendChild(linkEle); } async function unmount(props){ headEle.removeChild(linkEle); linkEle = null; } 复制代码
系统之间通信一般有两种方式:自定义事件和本地存储。如果是两个系统相互跳转,可以用 URL
传数据。
一般来说,不会同时存在A、B两个子系统,常见的数据共享就是登陆信息,登陆信息一般使用本地存储记录。另外一个常见的场景就是子系统修改了用户信息,主系统需要重新请求用户信息,这个时候一般用自定义事件通信,自定义事件具体如何操作,可以看上一篇文章的例子。
另外, single-spa
的注册函数 registerApplication
,第四个参数可以传递数据给子系统,但传递的数据必须是一个 对象
。
注册子系统的时候:
singleSpa.registerApplication( 'appVueHistory', () => System.import('appVueHistory'), location => location.pathname.startsWith('/app-vue-history/'), { authToken: "d83jD63UdZ6RS6f70D0" } ) 复制代码
子系统( appVueHistory
)接收数据:
export function mount(props) { //官方文档写的是props.customProps.authToken,实际上发现是props.authToken console.log(props.authToken); return vueLifecycles.mount(props); } 复制代码
关于子系统的生命周期函数:
bootstrap
, mount
, unmount
均包含参数 props
props
是一个对象,包含 name
, singleSpa
, mountParcel
, customProps
。不同的版本可能略有差异 customProps
就是注册的时候传递过来的参数
查看 single-spa-vue
源码可以发现,在 unmount
生命周期,它将 vue
实例 destroy
(销毁了)并且清空了 DOM
。所以实现 keep-alive
的关键在于子系统的 unmount
周期中不销毁 vue
实例并且不清空 DOM
,采用 display:none
来隐藏子系统。而在 mount
周期,先判断子系统是否存在,如果存在,则去掉其 display:none
即可。
我们需要修改 single-spa-vue
的部分源代码:
function mount(opts, mountedInstances, props) { let instance = mountedInstances[props.name]; return Promise.resolve().then(() => { //先判断是否已加载,如果是,则直接将其显示出来 if(!instance){ //这里面都是其源码,生成DOM并实例化vue的部分 instance = {}; const appOptions = { ...opts.appOptions }; if (props.domElement && !appOptions.el) { appOptions.el = props.domElement; } let domEl; if (appOptions.el) { if (typeof appOptions.el === "string") { domEl = document.querySelector(appOptions.el); if (!domEl) { throw Error( `If appOptions.el is provided to single-spa-vue, the dom element must exist in the dom. Was provided as ${appOptions.el}` ); } } else { domEl = appOptions.el; } } else { const htmlId = `single-spa-application:${props.name}`; // CSS.escape的文档(需考虑兼容性):https://developer.mozilla.org/zh-CN/docs/Web/API/CSS/escape appOptions.el = `#${CSS.escape(htmlId)}`; domEl = document.getElementById(htmlId); if (!domEl) { domEl = document.createElement("div"); domEl.id = htmlId; document.body.appendChild(domEl); } } appOptions.el = appOptions.el + " .single-spa-container"; // single-spa-vue@>=2 always REPLACES the `el` instead of appending to it. // We want domEl to stick around and not be replaced. So we tell Vue to mount // into a container div inside of the main domEl if (!domEl.querySelector(".single-spa-container")) { const singleSpaContainer = document.createElement("div"); singleSpaContainer.className = "single-spa-container"; domEl.appendChild(singleSpaContainer); } instance.domEl = domEl; if (!appOptions.render && !appOptions.template && opts.rootComponent) { appOptions.render = h => h(opts.rootComponent); } if (!appOptions.data) { appOptions.data = {}; } appOptions.data = { ...appOptions.data, ...props }; instance.vueInstance = new opts.Vue(appOptions); if (instance.vueInstance.bind) { instance.vueInstance = instance.vueInstance.bind(instance.vueInstance); } mountedInstances[props.name] = instance; }else{ instance.vueInstance.$el.style.display = "block"; } return instance.vueInstance; }); } function unmount(opts, mountedInstances, props) { return Promise.resolve().then(() => { const instance = mountedInstances[props.name]; instance.vueInstance.$el.style.display = "none"; }); } 复制代码
而子系统内部页面则和正常 vue
系统一样使用 <keep-alive>
标签来实现缓存。
vue-router
路由配置的时候可以使用按需加载(代码如下),按需加载之后路由文件就会单独打包成一个 js
和 css
。
path: "/about", name: "about", component: () => import( "../views/About.vue") 复制代码
而 vue-cli3
生成的模板打包后的 index.html
中是有使用 prefetch
和 preload
来实现路由文件的预请求的:
<link href=/js/about.js rel=prefetch> <link href=/js/app.js rel=preload as=script> 复制代码
prefetch
预请求就是:浏览器网络空闲的时候请求并缓存文件
systemJs
只能拿到入口文件,其他的路由文件是按需加载的,无法实现预请求。但是如果你没有使用路由的按需加载,则所有路由文件都打包到一个文件( app.js
),则可以实现预请求。
上述完整 demo
文件地址: github.com/gongshun/si…
qiankun
是蚂蚁金服开源的基于 single-spa
的一个前端微服务框架。
我们知道所有的全局的方法( alert
, setTimeout
, isNaN
等)、全局的变/常量( NaN
, Infinity
, var
声明的全局变量等)和全局对象( Array
, String
, Date
等)都属于 window
对象,而能导致 js
污染的也就是这些全局的方法和对象。
所以 qiankun
解决 js
污染的办法是:在子系统加载之前对 window
对象做一个快照(拷贝),然后在子系统卸载的时候恢复这个快照,即可以保证每次子系统运行的时候都是一个全新的 window
对象环境。
那么如何监测 window
对象的变化呢,直接将 window
对象进行一下深拷贝,然后深度对比各个属性显然可行性不高, qiankun
框架采用的是 ES6
新特性, proxy
代理方法。
具体代码如下(源代码是 ts
版的,我简化修改了一些):
// 沙箱期间新增的全局变量 const addedPropsMapInSandbox = new Map(); // 沙箱期间更新的全局变量 const modifiedPropsOriginalValueMapInSandbox = new Map(); // 持续记录更新的(新增和修改的)全局变量的 map,用于在任意时刻做 snapshot const currentUpdatedPropsValueMap = new Map(); const boundValueSymbol = Symbol('bound value'); const rawWindow = window; const fakeWindow = Object.create(null); const sandbox = new Proxy(fakeWindow, { set(target, propKey, value) { if (!rawWindow.hasOwnProperty(propKey)) { addedPropsMapInSandbox.set(propKey, value); } else if (!modifiedPropsOriginalValueMapInSandbox.has(propKey)) { // 如果当前 window 对象存在该属性,且 record map 中未记录过,则记录该属性初始值 const originalValue = rawWindow[propKey]; modifiedPropsOriginalValueMapInSandbox.set(propKey, originalValue); } currentUpdatedPropsValueMap.set(propKey, value); // 必须重新设置 window 对象保证下次 get 时能拿到已更新的数据 rawWindow[propKey] = value; // 在 strict-mode 下,Proxy 的 handler.set 返回 false 会抛出 TypeError,在沙箱卸载的情况下应该忽略错误 return true; }, get(target, propKey) { if (propKey === 'top' || propKey === 'window' || propKey === 'self') { return sandbox; } const value = rawWindow[propKey]; // isConstructablev :监测函数是否是构造函数 if (typeof value === 'function' && !isConstructable(value)) { if (value[boundValueSymbol]) { return value[boundValueSymbol]; } const boundValue = value.bind(rawWindow); Object.keys(value).forEach(key => (boundValue[key] = value[key])); Object.defineProperty(value, boundValueSymbol, { enumerable: false, value: boundValue }); return boundValue; } return value; }, has(target, propKey) { return propKey in rawWindow; }, }); 复制代码
大致原理就是记录 window
对象在子系统运行期间新增、修改和删除的属性和方法,然后会在子系统卸载的时候复原这些操作。
这样处理之后,全局变量可以直接复原,但是事件监听和定时器需要特殊处理:用 addEventListener
添加的事件,需要用 removeEventListener
方法来移除,定时器也需要特殊函数才能清除。所以它重写了事件绑定/解绑和定时器相关函数。
重写定时器( setInterval
)部分代码如下:
const rawWindowInterval = window.setInterval; const hijack = function () { const timerIds = []; window.setInterval = (...args) => { const intervalId = rawWindowInterval(...args); intervalIds.push(intervalId); return intervalId; }; return function free() { window.setInterval = rawWindowInterval; intervalIds.forEach(id => { window.clearInterval(id); }); }; } 复制代码
由于qiankun在js沙箱功能中使用了proxy新特性,所以它的兼容性和vue3一样,不支持IE11及以下版本的IE。不过作者说可以尝试禁用沙箱功能来提高兼容性,但是不保证都能运行。去掉了js沙箱功能,就变得索然无味了。
它解决 css
污染的办法是:在子系统卸载的时候,将子系统引入 css
使用的 <link>
、 <style>
标签移除掉。移除的办法是重写 <head>
标签的 appendChild
方法,办法类似定时器的重写。
子系统加载时,会将所需要的 js/css
文件插入到 <head>
标签,而重写的 appendChild
方法会记录所插入的标签,然后子系统卸载的时候,会移除这些标签。
解决子系统预请求的的根本在于,我们需要知道子系统有哪些 js/css
需要加载,而借助 systemJs
加载子系统,只知道子系统的入口文件( app.js
)。 qiankun
不仅支持 app.js
作为入口文件,还支持 index.html
作为入口文件,它会用正则匹配出 index.html
里面的 js/css
标签,然后实现预请求。
网络不好和移动端访问的时候, qiankun
不会进行预请求,移动端大多是使用数据流量,预请求则会浪费用户流量,判断代码如下:
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent); const isSlowNetwork = navigator.connection ? navigator.connection.saveData || /(2|3)g/.test(navigator.connection.effectiveType) : false; 复制代码
请求 js/css
文件它采用的是 fetch
请求,如果浏览器不支持,还需要 polyfill
。
以下代码就是它请求 js
并进行缓存:
const defaultFetch = window.fetch.bind(window); //scripts是用正则匹配到的script标签 function getExternalScripts(scripts, fetch = defaultFetch) { return Promise.all(scripts.map(script => { if (script.startsWith('<')) { // 内联js代码块 return getInlineCode(script); } else { // 外链js return scriptCache[script] || (scriptCache[script] = fetch(script).then(response => response.text())); } })); } 复制代码
qiankun
的 源码
中已经给出了使用示例,使用起来也非常简单好用。接下来我演示下如何从0开始用 qianklun
框架实现微前端,内容改编自官方使用示例。
vue-cli3
生成一个全新的 vue
项目,注意路由使用 history
模式。 qiankun
框架: npm i qiankun -S
app.vue
,使其成为菜单和子项目的容器。其中两个数据, loading
就是加载的状态,而 content
则是子系统生成的 HTML
片段(子系统独立运行时,这个 HTML
片段会被插入到 #app
里面的) <template> <div id="app"> <header> <router-link to="/app-vue-hash/">app-vue-hash</router-link> <router-link to="/app-vue-history/">app-vue-history</router-link> </header> <div v-if="loading" class="loading">loading</div> <div class="appContainer" v-html="content">content</div> </div> </template> <script> export default { props: { loading: { type: Boolean, default: false }, content: { type: String, default: '' }, }, } </script> 复制代码
main.js
,注册子项目,子项目入口文件采用 index.html
import Vue from 'vue' import App from './App.vue' import router from './router' import { registerMicroApps, start } from 'qiankun'; Vue.config.productionTip = false let app = null; function render({ appContent, loading }) { if (!app) { app = new Vue({ el: '#container', router, data() { return { content: appContent, loading, }; }, render(h){ return h(App, { props: { content: this.content, loading: this.loading, }, }) } }); } else { app.content = appContent; app.loading = loading; } } function initApp() { render({ appContent: '', loading: false }); } initApp(); function genActiveRule(routerPrefix) { return location => location.pathname.startsWith(routerPrefix); } registerMicroApps([ { name: 'app-vue-hash', entry: 'http://localhost:80', render, activeRule: genActiveRule('/app-vue-hash') }, { name: 'app-vue-history', entry: 'http://localhost:1314', render, activeRule: genActiveRule('/app-vue-history') }, ]); start(); 复制代码
注意:主项目中的 index.html
模板里面的 <div id="app"></div>
需要改为 <div id="container"></div>
vue-cli3
生成一个全新的 vue
项目,注意路由使用 hash
模式。 src
目录新增文件 public-path.js
,注意用于修改子项目的 publicPath
。 if (window.__POWERED_BY_QIANKUN__) { __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__; } 复制代码
main.js
,配合主项目导出 single-spa
需要的三个生命周期。注意:路由实例化需要在main.js里面完成,以便于路由的销毁,所以路由文件只需要导出路由配置即可(原模板导出的是路由实例) import './public-path'; import Vue from 'vue'; import VueRouter from 'vue-router'; import App from './App.vue'; import routes from './router'; import store from './store'; Vue.config.productionTip = false; let router = null; let instance = null; function render() { router = new VueRouter({ routes, }); instance = new Vue({ router, store, render: h => h(App), }).$mount('#appVueHash');// index.html 里面的 id 需要改成 appVueHash,否则子项目无法独立运行 } if (!window.__POWERED_BY_QIANKUN__) {//全局变量来判断环境 render(); } export async function bootstrap() { console.log('vue app bootstraped'); } export async function mount(props) { console.log('props from main framework', props); render(); } export async function unmount() { instance.$destroy(); instance = null; router = null; } 复制代码
vue.config.js
,主要是允许跨域、关闭热更新、去掉文件的 hash
值、以及打包成 umd
格式 const path = require('path'); const { name } = require('./package'); function resolve(dir) { return path.join(__dirname, dir); } const port = 7101; // dev port module.exports = { filenameHashing: true, devServer: { hot: true, disableHostCheck: true, port, overlay: { warnings: false, errors: true, }, headers: { 'Access-Control-Allow-Origin': '*', }, }, // 自定义webpack配置 configureWebpack: { output: { // 把子应用打包成 umd 库格式 library: `${name}-[name]`, libraryTarget: 'umd', jsonpFunction: `webpackJsonp_${name}`, }, }, }; 复制代码
history
模式的 vue
项目与 hash
模式只有一个地方不同,其他的一模一样。
即 main.js
里面路由实例化的时候需要加入条件判断,注入路由前缀
function render() { router = new VueRouter({ base: window.__POWERED_BY_QIANKUN__ ? '/app-vue-history' : '/', mode: 'history', routes, }); instance = new Vue({ router, store, render: h => h(App), }).$mount('#appVueHistory'); } 复制代码
js
沙箱和预请求,在 start
函数中配置即可 start({ prefetch: false, //默认是true,可选'all' jsSandbox: false, //默认是true }) 复制代码
registerMicroApps
也可以传递数据给子项目,并且可以设置全局的生命周期函数 // 其中app对象的props属性就是传递给子项目的数据,默认是空对象 registerMicroApps( [ { name: 'app-vue-hash', entry: 'http://localhost:80', render, activeRule: genActiveRule('/app-vue-hash') , props: { data : 'message' } }, { name: 'app-vue-history', entry: 'http://localhost:1314', render, activeRule: genActiveRule('/app-vue-history') }, ], { beforeLoad: [ app => { console.log('before load', app); }, ], beforeMount: [ app => { console.log('before mount', app); }, ], afterUnmount: [ app => { console.log('after unload', app); }, ], }, ); 复制代码
qiankun
的官方文档: qiankun.umijs.org/zh/api/#reg…
上述 demo
的完整代码 github.com/gongshun/qi…