揭开微前端的神秘面纱--基于single-spa撸一个微前端框架

7,582 阅读12分钟

image.png
  原文地址
  微前端是我参加工作不久,一个同事大佬在公司尝试推行的,本人有幸参与其中。在微前端的概念出现之前,微服务就已经出现并且大火,而微前端就是借鉴了微服务的架构而产生的,他们很相似,我们可以对比着理解。

微服务 微前端
一个微服务就是由一组接口构成,接口地址一般是 URL。当微服务收到一个接口的请求时,会进行路由找到相应的逻辑,输出响应内容。

后端微服务会有一个网关,作为单一入口接收所有的客户端接口请求,根据接口 URL与服务的匹配关系,路由到对应的服务。
一个微前端则是由一组页面构成,页面地址也是 URL。当微前端收到一个页面 URL 的请求时,会进行路由找到相应的组件,渲染页面内容。

微前端则会有一个加载器,作为单一入口接收所有页面 URL 的访问,根据页面 URL 与微前端的匹配关系,选择加载对应的独立模块

                    表一
  关于微前端网上也有很多讨论,本以为微前端是一个很复杂的东西,然而自己亲自参与过后发现,微前端并不难理解,接下来,我将带你一步步带你搞一个微前端框架。
  微前端的实现方案有很多,今天我们使用的是比较火的 single-spa 的方式。如果你学会了,就可以尝试用微前端的方式重构你公司的应用了。

我们搞微前端的背景

  首先我们需要先知道什么是微前端,如果我在这里按照概念说一遍,相信大家也比较抽象。我这里讲一下我们搞微前端的背景,大家就会知道微前端可以解决哪些问题,进而就会明白微前端是什么了。
  我所在的组有很多2B的业务,而且后来公司运用策略发生变化,出现了很多新的业务线,每个业务线都有自己需要的定制的运营功能,为了满足这些新业务线的运营需求,我们要做的就是:

  1. 将原有的运营后台加入切换业务线的功能
  2. 将分散在其他平台的运营功能迁移过来
  3. 为每个业务线开发定制的功能并能够根据业务线展示
  4. 对于业务线定制的功能,会尽量交给各个业务线负责,这就要求各个业务线都要用统一的技术栈来开发

  最终呈现就像下边这样。

image.png
                    图一

  按照传统方案的设计就是这样,然而这样的话将会造成以下问题:

  1. 原有的运营后台需要重构,主要重构点是鉴权和业务线切换这块,当然后端的某些接口也要做一些处理
  2. 其他平台功能的迁移成本有点高,因为用的是不同的技术栈
  3. 运营需求越来越多,平台就会越来越庞大,会使维护成本越来越高,迭代不够灵活,多人协作造成各种冲突
  4. 某一个模块出了bug有可能造成整个系统崩溃

使用微前端的优势

  俗话说,天下之大,分久必合,合久必分,微前端就是一个既合又分完美方案,在上述场景下,微前端再适合不过了。微前端的方案是指将系统的每个模块都拆分出来,独立开发、独立部署,然后将所有功能集成在一起,而这些功能模块完全解耦,甚至模块之间可以使用不同的技术栈来实现,老平台功能就可以平滑迁移
            

image.png
                    图2
  那么它是如何实现的呢?接下来我们使用Vue+single-spa实现一个微前端系统,你就可以理解微前端的原理了。

微前端的基本原理

  微前端系统需要一个主模块和若干子模块,主模块包含整个项目的入口文件,需要对子项目进行注册,根据路由匹配对子项目进行渲染,其次需要提供首页、登录、菜单、鉴权、业务切换等。子项目不需要有HTML文件,只需要输出资源文件即可,资源包括js/css/img/fonts等。
当用户进入到微前端系统,首先运行的就是加载器(上边表一中提到的),加载器会对每个模块进行注册,模块注册成功之后,当用户进入到这个模块,微前端加载器就会渲染该模块,然后会执行钩子函数:

  • bootstrap 生命周期,只会在挂载的时候执行一遍。
  • mount 生命周期,加载器挂载时执行。
  • unmount 生命周期,子模块卸载的时候执行。

image.png

                   图3

single-spa+Vue+Element实现一个微前端系统

  按照上述的微前端的原理和流程图,我将以single-spa+Vue+Element带领大家分别构建出主项目和子项目,进而完成一个真正的微前端应用。为了方便大家的理解,此次讲解先不加入切换业务线的功能,这个功能重要但是实现起来不麻烦,同学们构建起微前端之后可以自行加入这个逻辑。

主项目

入口文件main.js

  我们的主项目使用的是Vue+Element的技术栈,main.js和传统的Vue项目一样是完成Vue对象的初始化工作,下边先实现一个最基本的入口文件main.js,相信大家也很熟悉了。

import Vue from 'vue';
import ElementUI from 'element-ui';
import 'element-ui/lib/theme-chalk/index.css';
import Router from 'vue-router';
import routerMap from './router/index';
import App from './App';
import util from '@/common/js/util';
Vue.use(ElementUI);
Vue.use(Router);

// 绑定路由
const router = new Router(routerMap);

router.beforeEach((to, from, next) => {
    if (to.fullPath === '/login' || from.fullPath === '/login') {
        next();
    } else {
        // 判断是否登录
        if (!util.login()) {
            login(); // 登录
        } else {
            next();
        }
    }
});

new Vue({
    router,
    render: h => h(App)
}).$mount('#app');

然后你需要在main.js中将子模块的配置引入进来,我们会在加载器注册子模块时用到,配置文件参考如下:

export default {
    test: {
        id: 1,
        parent: {
            id: 1,
            name: 'test管理'
        },
        name: 'test模块',
        path: 'http://xxx.com/app.js',
        router: '/test'
    },
    demo: {
        id: 2,
        parent: {
            id: 1,
            name: 'demo管理'
        },
        name: 'demo模块',
        path: 'http://xxx.com/app.js',
        router: '/demo'
    }
};

然后将其引入进来并存储,在main.js中添加:

import appList from './appList'
util.setCache('appList', appList);

路由配置

  这里需要注意的是关于路由的配置,主项目中提供首页、菜单、登录、404等公共部分逻辑和相关页面,菜单我们使用Elemnet的NavMenu组件,另外就是加载器的逻辑和页面(Portal页面)。所以路由应该是这样的:

import Home from '@/views/Home';
import Portal from '@/views/Portal'; // 加载器
import Login from '@/views/Login';
import NotFound from '@/views/404';
export default {
		routes: [
  			{
            path: '/',
          	redirect: '/portal',
            component: Home,
            children: [
                {
                    name: 'Portal',
                    path: `/portal*`,    
                    component: Portal, // 加载模块
                }
            ]
        },
        {
            path: '/login',
            component: Login,
            meta: {
                label: '登录',
                hidden: true
            }
        },
        {
            path: '/404',
            component: NotFound,
            meta: {
                label: '404',
                hidden: true
            }
        },
        {
            path: '*',
            redirect: '/404',
            meta: {
                hidden: true
            }
        }
    ]
}

加载器Portal页面

  404和login页面大家可以自行设计。接下来的重点是重头戏 Portal 页面的实现,这个页面实际上就是各个子项目的入口,其主要逻辑就是加载器的实现,接下来直接上代码,我们对着代码一步步理解。

<template>
    <div id="MICRO-APP"></div>
</template>
<script>
/* eslint-disable */
import util from '@/common/js/util';
import { registerApplication, start, getAppNames, getAppStatus, unloadApplication } from 'single-spa';

export default {
    data() {
        return {};
    },
    methods: {
        registry(key, app) {
            // 去重
            if (getAppNames().includes(key)) {
                return;
            }
            // registerApplication 用于注册我们的子项目,第一个参数为项目名称(唯一即可),第二个参数为项目地址,第三个参数为匹配的路由,第四参数为初始化传值。
            registerApplication(
                key,
                () => {
                    const render = () => {
                        // 渲染
                        return window.System.import(app.path).then(res => {
                            if (res) {
                                return res;
                            } else {
                                return render();
                            }
                        });
                    };
                    return render();
                },
                location => {
                    if (location.pathname.indexOf(app.router) !== -1) {
                        return true;
                    } else {
                        return false;
                    }
                }
            );
        },
        // 注册模块
        async registerApp() {
            const appList = util.getCache('appList');
            for (const key in appList) {
                if (appList.hasOwnProperty(key)) {
                    const app = appList[key];
                    this.registry(key, app);
                }
            }
        }
    },
    mounted() {
        start();   //  启动项目
        this.registerApp();// 注册模块
    }
};
</script>
<style lang="less" scoped>
#MICRO-APP {
    position: relative;
    width: 100%;
    height: 100%;
    z-index: 10;
}
</style>

  portal页面的HTML部分只有一个id为MICRO-APP的根元素,它作为子项目的容器,我们会在子模块中进行配置。

生命周期

  在讲解registerApplication之前,我们首先对注册子模块的生命周期做一个介绍,这将帮助你可以更加深刻的理解加载器的原理和注册的过程。需要注意的是,我说的生命周期指的是注册子模块这个过程的生命周期,而不是这个页面的生命周期。
注册的子模块会经过下载(loaded)、初始化(initialized)、被挂载(mounted)、卸载(unmounted)和unloaded(被移除)等过程。single-spa会通过“生命周期”为这些过程提供钩子函数。这些钩子函数包括:

  • bootstrap:这个生命周期函数会在子模块第一次挂载前执行一次。
  • mount:在注册某个子模块过程中,当 activityFunction(registerApplication的第三个参数)返回为真,且该子模块处于未挂载状态时,mount生命周期函数就会被调用。调用时,函数会根据URL来确定当前被激活的路由,创建DOM元素、监听DOM事件等以向用户呈现渲染的内容。挂载过之后,接下来任何子路由的改变(如hashchangepopstate等)不会再次触发mount,需要各模块自行处理。
  • unmount:每当应用的 activityFunction 返回假值,但该应用已挂载时,卸载的生命周期函数就会被调用。卸载函数被调用时,会清理在挂载应用时被创建的DOM元素、事件监听、内存、全局变量和消息订阅等。

  页面的其他生命周期不在详细论述,想了解更多请参考官方文档构建应用

  另外还需要注意:这些生命周期函数的调用是需要在各个子模块的入口文件中实现的,我们在讲到子模块的时候在给大家介绍如何实现

registerApplication方法

这个方法的定义如下:

singleSpa.registerApplication(
  app.key,
  application(app.path), 
  activityFunction(app.router),   
  { access_token: 'asnjknfjkds' }
);
function loadingFunction(path) {
  return import(path);
}
function activityFunction(location,router) {
  return location.pathname.indexOf(router) !== -1;
}

参数讲解:

  • 第一个参数是一个key值,需要唯一
  • 第二个参数是一个回调函数,必须是返回promise的函数(或"async function"方法)。这个函数没有入参,会在子模块第一次被下载时调用。返回的Promise resolve之后的结果必须是一个可以被解析的子模块。常见的实现方法是使用import加载:() => import('/path/to/application.js'),为了能够独立部署各个应用,这里的import使用的是 SystemJS
  • 第三个参数也是一个函数,返回bool值,window.location会作为第一个参数被调用,当函数返回的值为真(truthy)值,应用会被激活,通常情况下,Activity function会根据window.location/后面的path来决定该应用是否需要被激活。激活后,如果子模块未挂载,则会执行mount生命周期。
  • 第四个参数属于自定义参数,,然后我们可以在各个生命周期函数中通过props**.**customProps接收到这个参数这个参数也许会很有用,比如以下场景:
    • 各个应用共享一个公共的 参数,比如:access_token
    • 下发初始化信息,如渲染目标
    • 传递对事件总线(eventBus)的引用,方便各应用之间进行通信

小结

  以上就是关于主项目的讲解,原理上还是有点复杂的,但是实现起来代码量不大,总结下来就是:

  • mian.js是主项目入口文件,初始化项目和处理登录逻辑,引入子模块配置文件。
  • 路由的配置
  • 加载Portal页面执行 registerApplication 注册,生命周期有些复杂,不过搞不懂也不影响使用

子项目

  子项目原则上可以采用任何技术栈,这里我以Vue为例和大家一起实现一个子模块。

single-spa-vue

  完成了主项目之后,子项目的实现也就非常简单了,这里主要用到的一个东西就是 single-spa-vue,singleSpaVue 是 single-spa 结合 vue 的方法,我们使用它来实现已经注册该子模块的生命周期逻辑。它的第一个参数是 vue,第二个参数 appOptions 就是我们平时传入的vue配置。

子项目入口mian.js

import Vue from 'vue';
import Router from 'vue-router';
import App from './App';
import ElementUI from 'element-ui';
import 'element-ui/lib/theme-chalk/index.css';
import routerMap from './router';
import store from './store';
import singleSpaVue from 'single-spa-vue';

Vue.use(ElementUI);
Vue.use(Router);

const router = new Router(routerMap);
const vueLifecycles = singleSpaVue({
    Vue,
    appOptions: {
        router,
        store,
        render: h => h(App),
        el:'#MICRO-APP'     // Portal页面中的子模块容器,子模块会被插入到该元素中
    }
});

export const bootstrap = vueLifecycles.bootstrap;
export const mount = vueLifecycles.mount;
export const unmount = vueLifecycles.unmount;


如果你想在生命周期函数之后做些事情可以向下边这样做

const vueLifecycles = singleSpaVue({...})
export const mount = props => vueLifecycles.mount(props).then(instance => {
  // do what you want with the Vue instance
  ...
})

路由配置

import List from '../views/List.vue';
export default {
    mode: 'history',
    base: '/portal',
    routes: [
        {
            path: '/demo',
            redirect: '/demo/list',
            component: List
        },
        {
            name: 'List',
            path: '/demo/list',
            component: List
        }
    ]
};

路由配置一样很常规,只要注意base的设置即可

小结

以上是关于子项目的讲解

最后想在这里说一句,微前端是个好技术,不过也要考虑场景,在适合的场景下用才是好技术。

资源

  • 本项目源码git 地址:
    主项目:github.com/hui-fly/mic…
    子项目demo:github.com/hui-fly/mic…
    后续会逐步完善,优化项目性能,以及增加react子项目,欢迎star交流

  • 快速生成子项目的脚手架:
    rv-cli
    使用方法
    npm i rv-cli -g
    rv create

参考

qiankun.umijs.org/zh/
tech.meituan.com/2018/09/06/…
alili.tech/archive/ea5…
juejin.cn/post/684490…
juejin.cn/post/684490…
juejin.cn/post/684490…