使用 single-spa 拆分 Vue 单页面应用
本文主要整理和讨论微前端的知识,起因是最近工业互联网的前端项目开始变的越来越大,由于是一个 toB 项目,前端的模块有最开始的 6 个已经变成了 12 个,原来几十个的页面如今也早已过百;另外,团队之间协同开发也更花时间,一个组件的变动可能影响其他功能模块,加上新需求的输入务必设计到组件的调整,解决代码冲突成了家常便饭,所以拆分项目就是迫在眉睫的需求了。本文将示例一个微前端项目的构建过程。
微前端
微前端主要是借鉴后端微服务的概念。简单地说,就是将一个巨无霸(Monolith)的前端工程拆分成一个一个的小工程。别小看这些小工程,它们也是“麻雀虽小,五脏俱全”,完全具备独立的开发、运行能力。整个系统就将由这些小工程协同合作,实现所有页面的展示与交互。
微前端有两个核心的思想:
- 子应用或子模块不依赖特定方案,即无关技术栈,子应用可以用任何方案自由实现;
- 子应用可以独立开发,独立运行;
由于之前的项目是基于 Vue2.0 编写的,所以为了使改动最小,拆分后的项目依然使用 Vue 来改写。
single-spa
single-spa 是目前较为成熟了一个微前端框架,用于将多个前端应用集合到一个前端应用程序中。首先了解下微前端的基本架构。
前端微服务基本概念:
- 加载器(独立服务):也就是微前端架构的核心,主要用来调度子应用,决定何时展示哪个子应用;
- 包装器(每个服务都需要有自己的包装器):有了包装载器,可以把现有的应用包装,使得加载器可以使用它们;
- 主应用(独立服务):一般是包含所有子应用公共部分的项目,也叫基座;
- 子应用(独立服务):众多展示在主应用内容区的应用;
single-spa
就是来辅助上述工作的。
基座应用
首先来看基座,也就是用户首屏。基座是一个如论点击哪个 URL ,都要先载入的页面,子应用的切换也将基于基座来进行。
假设已经通过 vue-cli 创建好了一个 Vue 应用,基于这个应用,还需要安装 single-spa 。
npm i -S single-spa
然后修改入口文件 main.js
。
在修改 main.js
之前,我们先要明确下 single-spa 的使用流程,其实也简单,大致分为两步:注册子应用和启动路由监听。
下面来看 main.js 的实现,首先,引入 single-spa 并定义两个辅助函数。
import * as singleSpa from 'single-spa'
/**
* 载入子应用脚本
* @param {string} url 子应用脚本文件地址
* @returns Promise
*/
function runScript(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)
})
}
/**
* 加载子应用
* @param {string} url 子应用地址
* @param {string} appName 子应用名,全局唯一
* @returns Promise
*/
function loadApp(url, appName) {
return async () => {
await runScript(url + '/js/app.js')
return window[appName]
}
}
然后定义两个示例的子应用。
let app1 = {
name: 'app1',
app: loadApp('http://localhost:3000', 'app1'),
activeWhen: location => location.pathname.startsWith('/app1'),
customProps: {}
}
let app2 = {
name: 'app2',
app: loadApp('http://localhost:3001', 'app2'),
activeWhen: location => location.pathname.startsWith('/app1'),
customProps: {}
}
最后,在 Vue 主应用的生命周期函数中执行注册和监听。
new Vue({
router,
created() {
singleSpa.registerApplication(app1);
singleSpa.registerApplication(app2);
},
mounted() {
singleSpa.start();
},
render: h => h(App)
}).$mount('#app')
下面定义路由文件,假设基座应用只有两个路由。
// router/index.js
import Vue from 'vue'
import VueRouter from 'vue-router'
import Home from '../views/Home.vue'
Vue.use(VueRouter)
const routes = [
{
path: '/',
name: 'Home',
component: Home
},
{
path: '/about',
name: 'About',
component: () => import(/* webpackChunkName: "about" */ '../views/About.vue')
}
]
const router = new VueRouter({
mode: 'history',
routes
})
export default router
然后修改 App.Vue
。
<template>
<div id="app">
<div id="nav">
<router-link to="/">Home</router-link> |
<router-link to="/about">About</router-link> |
<router-link to="/app1">App1</router-link> |
<router-link to="/app2">App2</router-link>
</div>
<router-view/>
<!-- 定义子应用容器 -->
<div id="child-vue-app"></div>
</div>
</template>
到这里最简单的基座应用就写完了。
子应用
下面编写子应用。
为了方便起见,这里的子应用也使用 Vue 来示例。子应用需要使用 single-spa-vue
来进行包裹,所以适应 vue-cli 创建完子应用后需要继续安装依赖。
npm i -S single-spa-vue
跟基座应用一样,我们也需要修改子应用的入口文件 main.js
。
import Vue from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'
import singleSpaVue from "single-spa-vue";
Vue.config.productionTip = false
const vueOptions = {
// 注意这里的子应用是挂载到 id 为 child-vue-app 的容器上的
el: "#child-vue-app",
router,
store,
render: h => h(App)
};
// 判断当前页面使用singleSpa应用,不是就渲染
if (!window.singleSpaNavigate) {
delete vueOptions.el;
new Vue(vueOptions).$mount('#app');
}
const vueLifecycles = singleSpaVue({
Vue,
appOptions: vueOptions
});
export const bootstrap = vueLifecycles.bootstrap;
export const mount = vueLifecycles.mount;
export const unmount = vueLifecycles.unmount;
export default vueLifecycles;
最简单的子应用就完成了,下来需要修改一下子应用的端口。在 package.json 中指定一个端口号即可。
"scripts": {
"serve": "vue-cli-service serve --port 3000",
"build": "vue-cli-service build",
}
以同样的方式,可以创建多个子应用,子应用创建完成后即可启动子应用。
# path to app1
npm run serve
最后启动基座应用。
# path to main
npm run serve
基座应用启动完成后即可在基座应用的路由中看到 App1 和 App2 ,分别点击 App1 和 App2 将跳转到 /app1 和 /app2 中。由于我们在注册应用的时候做了路由拦截,当发现路由为 /app1 和 /app2 时将分别以异步的形式加载子应用,这便实现了对应用的拆分。
一些问题
虽然上面实现的最简单的应用拆分解决了一些问题,但是还有一些未解决的问题,例如:
- 基座应用和子应用之间如何通讯;
- 基座应用如果与子应用之间进一步解耦;
- 如何实现子应用的预加载;
- 如何实现子应用的动态注入;
- …
其实还有很多问题,都会在实际中遇到,不过由于篇幅这里就不一一列出了,上面的几个文件我来做简单的解答。
基座应用和子应用之间如何通讯
首先,从上面的代码可以看出,子应用是以全局变量的形式导出的,也就是说可以通过 window 访问到,另外,由于微前端不去规定子应用的实现方案,所以即便我们在基座应用中使用了 Vuex 或者 React-Redux 之类的状态管理模块,也无法在基座与子应用之间,或者子应用与子应用之间进行通讯。
其实,应用之间通讯有两种常规方案可以考虑使用;
- 每个应用暴露一个自己的状态对象,给其他应用访问;
- 基于单例模式,创建一个通用型总线,通过发布订阅模式来实现数据监听。
第一种方案可以实现组件之间的通讯,是基于组件注册在全局中,只要每个组件按照预先的规定实现数据获取函数即可;第二种方案是基于单例模式的发布订阅总线,任何应用只需要关心自己想要获得的数据。不过实际的项目中这两个方式会结合起来使用,以增加灵活性。
基座应用如果与子应用之间进一步解耦
上面实现的最简单的微前端应用其实也是存在耦合的,比如我们在基座应用中载入子应用的方式其实是写死的。
function loadApp(url, appName) {
return async () => {
// 这里我们是写死的,我们默认每一个子应用的都有一个入口文件,且都是 app.js
await runScript(url + '/js/app.js')
return window[appName]
}
}
由于微前端不会限制子应用的实现方案和技术栈,所以子应用的入口文件不一定是 app.js ,而且,很多时间入口文件会增加一个 hash 值,如果这里是写死的,就会产生严重的耦合,这是其一。
其二,还记得子应用的挂载容器吗?
<template>
<div id="app">
<!-- 定义子应用容器 -->
<div id="child-vue-app"></div>
</div>
</template>
我们在基座应用中定义的子应用是必须挂载到 child-vue-app
上的,这里也会产生耦合。
我们以第一个应用再入点为例,来进行解耦。
接触载入点耦合,我们可以借助 Webpack 来实现,Webpack 的 stats-webpack-plugin
可以帮助我们生成一个应用的状态文件,同时,通过 output 参数也可以定义输出文件的全局名称。
const StatsPlugin = require('stats-webpack-plugin');
const config = {
output: {
library: "APP1",
libraryTarget: "window",
},
plugins: [
new StatsPlugin('manifest.json', {
chunkModules: false,
entrypoints: true,
source: false,
chunks: false,
modules: false,
assets: false,
children: false,
exclude: [/node_modules/]
}),
]
};
module.exports = config;
基于上述配置可以生成一个 mainfest.json ,如下:
{
"errors": [],
"warnings": [],
"version": "4.46.0",
"hash": "672d005b97fdad632a0f",
"publicPath": "//localhost:3000/",
"outputPath": "",
"entrypoints": {
"app": {
"chunks": ["app"],
"assets": ["js/app.a0ab5a85.js"],
"children": {},
"childAssets": {}
}
},
"namedChunkGroups": {
"app": {
"chunks": ["app"],
"assets": ["js/app.a0ab5a85.js"],
"children": {},
"childAssets": {}
}
},
"logging": {
"webpack.buildChunkGraph.visitModules": {
"entries": [],
"filteredEntries": 5,
"debug": false
}
}
}
那么下来好办了,我们先来请求子应用的 mainfest.json
文件,以便获取到应用的资源信息,这样就可以灵活配置了。
修改 loadApp
函数如下:
funcion loadApp (url, appName) {
return new Promise(async (resolve) => {
const { data } = await axios.get(url);
const { entrypoints, publicPath } = data;
const assets = entrypoints[appName].assets;
for (let i = 0; i < assets.length; i++) {
await runScript(publicPath + assets[i]).then(() => {
if (i === assets.length - 1) {
resolve()
}
})
}
}
};
这便可以基于子应用的配置来灵活加载。
如何实现子应用的预加载
首先明确什么是预加载。我定义预加载就是当用户目前处于一个应用或者子应用的时候,我们判断用户有很大的概略会切换到另外一个子应用,为了减少子应用的载入时间,我可以进行预加载。
既然是预加载,那么子应用就不会真正的显示出来。所以实现方案也是比较简单的,首先,我们通过 ajax 或者 jsonp 的方式请求子应用的入口文件,当这些文件下载完成后,先不要进行渲染,而是缓存起来,等到用户真正切入到子应用的时候在渲染页面。
基于以上,我们上面实现的子应用就需要修改的,我们的子应用在启动时就默认进行了渲染,现在,我们需要自用给基座提供一个函数,可以让基座来确定何时渲染,以及渲染到哪里。
改写子应用的 main.js ,在以子应用的方式启动时提供一个 appRender 函数。
if (!window.singleSpaNavigate) {
delete vueOptions.el;
new Vue(vueOptions).mount('#app');
}
// 如果是以 singleSpa 启动,则全局挂载一个 appRender 函数
else {
window.appRender = (el) => {
new Vue({
router,
store,
render: h => h(App)
}).mount(el);
}
}
以上只是提供思路,并不是最佳方案,还有改进的空间,例如要考虑子应用的名称,子应用的对象管理等。
如何实现子应用的动态注入
首先明确,子应用的动态注入发生在哪里?
很明显的,子应用的动态注入只会发生在基座应用中,也就是我们调用 singleSpa.registerApplication(app1);
的时候,这便注入了一个子应用。这种注入的应用场景也是很多的,举一个例子。
阿里云的后台,只有我们购买了服务,我们在控制台中才能看到和使用相应的服务,作为一个干干净净的用户,最开始能加入的服务是很少的,所以购买后载入的服务就是动态注入的。
所以明白这里的原理,实现子应用的动态注入就很简单了,这里不再展开。