基于Single-Spa的微前端实践

转载自:https://reshub.top/2020/09/03/single-spa-1/

这段时间差不多完成了公司的单页应用项目改造成所谓的微前端的项目架构,当时的技术选型本来是想用Qiankun来做的,但是经过技术调研过后发现并不怎么合适,想要的功能在 issue 中有提到,但是那段时间应该是正好处于 Qiankun 正在开发第 2 个版本,那些新特性得在 2 版本才会发布 T_T,所以就将目标转向 Qiankun 的基础依赖库: Single-SPA,配合SystemJS来从最基础开始撸这个微前端改造。

后来乾坤发布了第 2 个主版本,看起来挺香的,不过改造也差不多了,目的也达到大部分了,所以也没有一味地去追求最新的框架啥的,合适的最好~

本文并非从零开始一步步系列文章,只是记录一些框架搭建的一些做法的记录

项目结构

架构图

如图所示,整个项目大概分为:

  • 一个基座项目
  • N 个子项目
  • 一个公共组件库

其中子项目的入口文件统一导出Single-Spa规定的三个生命周期(bootstrap, mount, unmount),由于我的项目都是Vue项目,所以用了single-spa-vue来做项目的出口配置,当然如果项目中用倒React之类的技术栈的话,可以使用其他的集成库(https://single-spa.js.org/docs/ecosystem)


基座项目

基座项目主要做的事情有:

  • 初始化一个基座应用
  • 注册所有的子项目
  • 注册公共依赖库
  • 注册全局路由
  • 注册全局的 Store(由于我是重构项目,大部分业务都需要依赖一些全局的东西)
  • 初始化一些全局可用的环境(虽然貌似有点违背微前端的初衷,不过实践后还是很多场景需要)

初始化一个基座应用

我这里直接用 Vue 作为框架初始化了一个基座项目:

new Vue({
    router,
    store,
    render: (h) => h(App),
}).$mount('#root-content')

其中 routerstore 就是路由配置和Vuex的配置,将在下面详细解释。

注册所有的子项目

使用 Single-Spa 注册子项目需要用到 singleSpa.registerApplication 这个 API:

// 此处的 appName 就是每个子项目的名字,每个子项目都需要这样注册到 Single-Spa 中

singleSpa.registerApplication(
    // 子项目名
    appName,
    // 当触发某个子应用加载时调用,此处需要实现子项目加载,运行的逻辑
    // 该方法需要返回三个生命周期,每个生命周期都是数组,会在子项目的不同阶段调用
    async () => {
        // 生成一个项目的沙箱环境    ----- 厚颜无耻地搬运了 Qiankun 1 的沙箱代码,魔改了一些逻辑以符合项目需求
        const {
            sandbox,
            mount: mountSandbox,
            unmount: unmountSandbox,
        } = genSandbox(appName)

        // 子项目加载逻辑
        const { mount: resMount, unmount: resUnmount } = resourceLoader(
            appName,
            sandbox
        )

        // 通过SystemJs加载子项目
        const appInstance = await System.import(appName)

        return {
            bootstrap: [
                // 调用子项目的 bootstrap 生命周期
                appInstance.bootstrap,
            ],
            mount: [
                // 子项目激活之前,先激活沙箱
                mountSandbox,
                // 激活子项目
                appInstance.mount,
                // 资源的装载,我在这里处理子项目的样式加载逻辑
                resMount,
            ],
            unmount: [
                // 卸载子项目前先卸载挡墙沙箱
                unmountSandbox,
                // 卸载子项目
                appInstance.unmount,
                // 卸载资源,我在这里清理了当前子项目的样式
                resUnmount,
            ],
        }
    },
    // 判断当前应该使用哪个子项目,此方法返回 true 的话,当前子项目就会被激活
    () => window.activeApp === appName
)

以上就是如何注册一个子项目。

  • 其中的沙箱的概念就是为了保证每个项目加载后,这个子项目获得到的环境都是一个相对‘干净’的环境,而这个子项目对当前环境所做的所有的变动,或者说副作用,都是在该项目自己的生命周期中生效的,这包括以下几个副作用:

    • window 属性的新增,删除,修改;
    • History 上添加的 listener;
    • 创建的 setIntervalsetTimeout;
    • addEventListenerremoveEventListener 添加、取消的事件监听
  • 第二个参数是当当前子项目匹配到时,应该如何加载这个子项目,这里的核心代码就是 resourceLoader 这个函数,将在文章后面的 配置 SystemJs 会详细介绍

  • 第三个参数 () => window.activeApp === appName,在 Single-Spa 的文档中是这样的:

singleSpa.registerApplication(
    'appName',
    () => System.import('appName'),
    (location) => location.pathname.startsWith('appName')
)

意为当路径切换到当前子项目对应的前缀就加载子项目,但是现实是,例如一个一级菜单路径是 /system, 它的子菜单 1 是 /system/users,子菜单 2 是 /system/menus,到此为止用路径前缀的方式都还能实现,但是此时,一个菜单路径为 /shop/settings 的菜单 3 需要加入到这个一级菜单中,这时想把前缀改成 /system/shop-settings 吧,但是它原来的地方也想要这个菜单,只是内容有些许不同而已。除此之外还可以预见有更多类似的坑在前方等着,于是就不跟路径过不去了,转为自己定义一个全局变量在 window 上,名为 activeApp,即为当前运行的子应用,而在切换子项目的时候就分成两步:

// 修改全局变量
window.activeApp = route.meta.appName
// 手动触发 Single-Spa 的加载子项目逻辑
singleSpa.triggerAppChange()

那么这个 appName 怎么来呢? 它的意义是界面跳转到某个路由后,其所展示的内容的子项目的名字,所以我们需要给每个路由设置这个 appName。我们可以把 appName 配置在路由中的 meta 属性:

let config: RouteConfig = {
    name: namuCode,
    path: menuPath,
    meta: {
        // 可以记录当前菜单的名字,方便界面访问记录,面包屑等地方使用
        label: c.menuName,
        appName: c.appName,
    },
}

然后在路由变化的时候触发子项目切换

// 子项目切换的方法
function updateMicroApp(route) {
    if (route?.meta?.appName && route.meta.appName !== window.activeApp) {
        // 修改全局变量
        window.activeApp = route.meta.appName
        // 手动触发 Single-Spa 的加载子项目逻辑
        singleSpa.triggerAppChange()
    }
}

// ...

// 在构建完路由之后,添加路由变化的监听,如果有变动,就获取meta中的appName切换子项目,$watch 是 Vue的监听写法
this.$watch('$route', updateMicroApp)

// 如果需要做类似初始化当前路由并且加载对应子项目的需求的话
updateMicroApp(this.$route)

配置 SystemJs

SystemJs是一个模块加载器,非常适合 Single-Spa 的子项目加载用。我们需要 SystemJs 做一下几件事:

  • 注册以及加载子项目的真实文件
  • 注册以及加载提取出来的公共库

首先我们需要配合 system-importmap 来注册各个模块 (在 SystemJs 这,一个子项目也相当于一个模块)

<script
    type="systemjs-importmap"
    src="<%= BASE_URL %>projectConfigs.json"
></script>
<script src="<%= BASE_URL %>libs/core.min.js"></script>
<script src="<%= BASE_URL %>libs/import-map-overrides.js"></script>
<script src="<%= BASE_URL %>libs/systemjs/system.js"></script>
<script src="<%= BASE_URL %>libs/systemjs/extras/amd.js"></script>
<script src="<%= BASE_URL %>libs/systemjs/extras/named-exports.js"></script>
<script src="<%= BASE_URL %>libs/systemjs/extras/named-register.js"></script>
<script src="<%= BASE_URL %>libs/systemjs/extras/use-default.js"></script>

我们首先注册了一个 type 为 systemjs-importmapscript 标签,文件指向 projectConfigs.json 这个文件,下面加载了 SystemJs 所需的各个依赖。我们关注 projectConfigs.json 这个文件:

{
    "imports": {
        "@app/system": "/path/to/system/metadata.json",
        "@app/shop": "/path/to/shop/metadata.json",

        "vue": "/libs/vue.min.js",
        "vue-router": "/libs/vue-router.js",
        "vuex": "/libs/vuex.js"
    }
}

这样就把这几个模块注册进了 SystemJs 中,那么在子项目中,只要模块在 webpackexternal 中有配置,那么它在 import xxx from 'xxx' 时就不会去关心当前子项目有没有安装这个模块,取而代之的是去取 imports 配置中对应的文件加载成模块回来。

现在,模块加载的配置有了,但可以看到配置中一个模块只能是对应着一个文件这样加载回来,但是一个子项目肯定不止有一个文件的,一般不会有人把所有内容都打包到一个文件中去加载这种情况吧。看了一下乾坤的做法,他们是自己实现了一个 webpack 插件 import-html-entry,将 html 做为入口文件,规避了 JavaScript 为了支持缓存而根据文件内容动态生成文件名,造成入口文件无法锁定的问题。将 html 做为入口文件,其实就是将静态的 html 做为一个资源列表来使用了。

由于我不想子项目导出的内容是从一个 HTML 中提取的,我想子项目打包后可以放更多的信息,而子项目所依赖的文件列表只是其中的一个字段而已,所以我没使用 import-html-entry 而是使用了 webpack-stats-plugin 这个 webpack 自带的一个插件:

new StatsWriterPlugin({
    filename: 'metadata.json',
    fields: ['assetsByChunkName'],
    transform(data) {
        const { main, vendors } = data.assetsByChunkName;
        let result = {
            assets: { main, vendors },
        };
        return JSON.stringify(result, null, 4)
    },
}),

这样配置后,生成的内容就是:

{
    "assets": {
        "main": [
            "main-611b7990b343ba3328e6.css",
            "main-611b7990b343ba3328e6.js"
        ],
        "vendors": [
            "vendors-611b7990b343ba3328e6.css",
            "vendors-611b7990b343ba3328e6.js"
        ]
    }
}

可见,在 StatsWriterPlugintransform 中,result 里面可以配置更多的字段使用(比如当前子项目本次打包时对应的版本号,git branch 等等信息)

那么现在子项目入口有了,但是 SystemJs 并不认为这个文件有啥特殊的,所以前文讲到的 resourceLoader 就排上用场了,它的作用就是重写了 SystemJs 的文件加载逻辑,支持加载到 xxxxxx/metadata.json 的文件时,读取内容,并且找到 assets 字段,然后加载该字段中的所有文件:

  1. 函数接收两个参数,一个是当前注册的子项目名 appName,和当前子项目的代码执行时的沙箱(上下文) sandBox,它是每个子项目都会生成的一个独立环境;

  2. 重写 System.constructor.instantiate 方法,该方法定义了如何加载

    function ( appName: string, sandBox: WindowProxy = window) {
        const systemJSPrototype = window.System.constructor.prototype;
        // 保留原来的加载函数
        const instantiate = systemJSPrototype.instantiate;
        systemJSPrototype.instantiate = async function (url: string, parent: any) {
            if (/metadata\.json/.test(url)) {
                let { assets } = JSON.parse(source);
    
                // 保存沙箱到window上,以供后续使用
                window.sandbox = sandBox;
    
                // 通过 window.fetch 之类的方式加载文件下来
                // ...
            } else {
                // 不关心的文件用原来的方式处理
                return instantiate.call(this, url, parent);
            }
        }
    }
    
  3. 特别注意的是,文件加载进来后是需要通过 eval 执行,然后得到结果给 Single-Spa 的,那么这里执行的时候,沙箱就排上用场了:

    function executeScript(source: string, url: string) {
        eval(
            `;(function(window){;${source}\n}).bind(window.sandbox)(window.sandbox);\n//# sourceURL=${url}`
        )
    }
    

    这里通过闭包的方式将当前的 window.sandBox 传进 eval 中保持着。


本文就先记录到这里,后面可能还需要记录的有:

  1. 各个子项目样式的隔离方案
  2. 子项目 webpack 配置的方案
  3. 子项目路由配置的坑,以及和基座项目路由的联动
  4. 如何做动态菜单,动态路由
  5. 子项目的 Store 如何做到和基座项目是 Store 同步

转载自:https://reshub.top/2020/09/03/single-spa-1/

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 151,829评论 1 331
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 64,603评论 1 273
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 101,846评论 0 226
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 42,600评论 0 191
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 50,780评论 3 272
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 39,695评论 1 192
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,136评论 2 293
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 29,862评论 0 182
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 33,453评论 0 229
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 29,942评论 2 233
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,347评论 1 242
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 27,790评论 2 236
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,293评论 3 221
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 25,839评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,448评论 0 181
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 34,564评论 2 249
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 34,623评论 2 249

推荐阅读更多精彩内容