美文网首页
基于Single-Spa的微前端实践

基于Single-Spa的微前端实践

作者: 556ce276a753 | 来源:发表于2016-06-20 15:32 被阅读0次

    转载自: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/

    相关文章

      网友评论

          本文标题:基于Single-Spa的微前端实践

          本文链接:https://www.haomeiwen.com/subject/gkmwdttx.html