|

使用 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); 的时候,这便注入了一个子应用。这种注入的应用场景也是很多的,举一个例子。

阿里云的后台,只有我们购买了服务,我们在控制台中才能看到和使用相应的服务,作为一个干干净净的用户,最开始能加入的服务是很少的,所以购买后载入的服务就是动态注入的。

所以明白这里的原理,实现子应用的动态注入就很简单了,这里不再展开。

类似文章

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注