微前端框架实现——模块联邦

121 阅读13分钟

微前端框架实现——模块联邦1. 微前端技术2. 模块联邦使用背景2.1 模块联邦如何运作?2.2 模块联邦带来的好处3. 模块联邦准备工作4. 基本配置4.1 远程应用(remote)配置4.2 主应用配置5. 过程所遇问题及解决方案5.1 热更新问题5.2 导入——异步加载5.3 运行时获取参数6. 探索:模块联邦最佳实践6.1 CSS隔离方案6.2 组件CSS响应式设计6.3 JS设计规范

微前端框架实现——模块联邦

1. 微前端技术

微前端是一种架构理念,借鉴了微服务的思想,它使得一个大型的前端应用可以拆分为多个独立的子应用,每个应用可以独立地开发部署。

随着项目的规模和团队人数发展得越来越大,应用微前端对庞大臃肿的项目进行解耦分发成为了一种趋势。

微前端的架构模式

从微前端应用间的关系来看分为两种:基座模式、去中心化模式,分别对应两种不同的架构模式:

  • 基座模式

    也称主从架构,主应用负责路由分发,管理子应用的加载和卸载、全局状态管理和公共依赖共享。子应用独立部署,通过配置注册到主应用。框架方案:Qiankun、single-spa;

  • 去中心化模式

    该模式下无中心主应用,子应用通过约定通信协议直接通信,适用于轻量级组合场景(如多个独立功能模块组合)。框架方案:模块联邦Module Federation

2. 模块联邦使用背景

我们的项目中已经使用到了模块联邦技术:教学运营中台中编辑课时配置的页面和编辑作业的页面其实都是对前台教师端的组件使用模块联邦技术进行了复用。

9d663bdd9ad72e43c15a643f9c0b12e.png

[图1]  教学运营中台编辑课时界面

12d4483ea3a75415d00c548536b11b7.png

[图2]  前台修改教学设置界面

e930c7b81048ed832cf338c02551ee3.png

[图3]  运营中台编辑作业界面

bfcb84c0d860e160ad14a93f6291ee9.png

[图4]  前台教师端创建作业界面

2.1 模块联邦如何运作?

以创建课时页面为例, lel-schedule-web项目的"创建课时页面"需要在axe项目复用:

Webpack 的 Module Federation 通过在构建阶段注入“容器”(container)和在运行时动态加载模块的机制,让多个独立打包的应用(Host 与 Remote)能像本地模块一样互相引用和共享依赖。

明确几个概念:容器,remote和host

【每个参与模块联邦的应用在构建时会被视为一个容器。一个被引用的容器称为remote(本场景中为前台lel-schedule-web项目),引用者被称为host(本场景中为中台axe项目),remote暴露模块给host,host则可以使用这些暴露的模块,这些模块被称为remote模块

e30b7a53cd752a9d697337b0a3508a9d.png

3234479ef2a4ceeb6793209cebc44cef.png

在构建时,lel-schedule-web模块会生成remoteEntry.js,这是其对axe暴露的服务入口文件,包含暴露模块(创建课时页面)的映射清单和动态加载这些模块所需的运行时代码,还会生成一系列chunk,对应的是暴露出去的模块(创建课时页面)及其依赖。

而axe会生成常规的bundle和共享依赖的chunk,同时打包进用于动态加载远程模块的运行时代码(用于远程容器初始化、模块拉取)。

在运行时,axe会在适当的时候下载并执行remoteEntry.js,在全局注册一个lel-schedule-web的容器,通过其提供的映射去按需动态加载创建课时页面对应的chunk,该容器还会注册自己提供的共享依赖及其版本,模块联邦会协商加载依赖,尽量保证相同依赖只加载一次。

79096e69b4fa439cfe911167adb5f46.png

809e2d7a45d81c4ac2b4222ca1c1270.png

2.2 模块联邦带来的好处

  • 组件/模块级共享

    多个前端项目之间需要共享组件库、页面片段等,且想要在主应用中像本地模块一样使用,而无需接入整个应用。

  • 依赖共享,单例模式

    模块联邦能够配置依赖单例共享,对于框架相同的多个前端项目,使用模块联邦能够最大化复用现有依赖(比如我们的lel-schedule-web项目和axe项目均使用Vue3框架),在构建时就锁死依赖的版本并能保证只存在一份运行时实例。

  • 按需延迟加载独立功能模块

    模块联邦将远程模块打包为一个独立的chunk,在用户交互触发再延迟加载,无需主应用先拉取整个子应用,具有更快的加载启动时间。

  • 易于应用的单独开发和版本升级

    远程应用(lel-schedule-web)可以独立开发,单独发布、回滚,不必打断主应用(axe)的完整部署流程。

3. 模块联邦准备工作

webpack升级

模块联邦是webpack5的特性,在使用之前,需要保证项目的webpack版本为5,对于版本低于5的需要进行升级,下面是升级时的步骤

  1. 检查node版本

    Webpack 5 对 Node.js 的版本要求至少是 10.13.0 (LTS),因此如果项目中的Node版本过低,需要进行升级。

  2. 升级webpack及其相关的plugin/loader

    • 升级webpack 4到最新可用版本
    • 升级webpack-cli到最新可用版本
    • 升级所有使用到的plugin和loader到最新可使用的版本
    • 移除废弃的配置项
    // 更新
    yarn add webpack@5.51.1 webpack-dev-server@4.0.0 html-webpack-plugin@5.3.2 webpack-marge@5.8.0 -D
    ​
    // 新增
    yarn add css-minimizer-webpack-plugin@3.0.2 friendly-errors-webpack-plugin@1.7.0 portfinder@1.0.28 -D
    ​
    // 移除
    yarn remove url-loader file-loader optimize-css-assets-webpack-plugin postcss-safe-parser clean-webpack-plugin -D
    ​
    
  3. 升级至webpack 5

    执行指令:npm install webpack@latest

  4. 配置改动

    • 如果使用了类似于 node.fs: 'empty',请使用 resolve.fallback.fs: false 代替
    • 如果你将 target 设置为函数,则应将其更新为 false,然后在 plugins 选项中使用该函数。具体示例如下:
    // for webpack 4
    {
        target: WebExtensionTarget(nodeConfig)
    }
    ​
    // for webpack 5
    {
        target: false,
        plugins: [
            WebExtensionTarget(nodeConfig)
        ]
    }
    

详见官网教程webpack.docschina.org/migrate/5/

4. 基本配置

4.1 远程应用(remote)配置

publicPath是webpack中一个重要的配置项,用于指定打包后生成的静态资源文件在浏览器中的访问路径。

本地调试时,一个主应用可能使用多个远程模块,为防止端口冲突,手动配置端口号和publicPath

const defaultConfig = {
    devServer: {
        //...其他配置
        port: 8180,
        historyApiFallback: true,
    },
    publicPath: isDev ? 'http://localhost:8180/' : getPublicPath(),
    //...其他配置
};

plugin配置参数简介:

name: 必须(驼峰或下划线),当前应用的名字,全局唯一ID,通过 name/{expose} 的方式使用

filename: 可选,打包后的包含所有暴露组件的js文件名

exposes: 可选,表示当前应用是一个 Remote,exposes 内的模块可以被其他的 Host 引用,引用方式为 import(name/{expose})

shared: 可选,依赖的包

如果配置了这个属性。webpack在加载的时候会先判断主应用是否存在对应的包,如果不存在,则加载远程应用的依赖包。shared 配置项指示 remote 应用的输出内容和 host 应用可以共用哪些依赖。 shared 要想生效,则 host 应用和 remote 应用的 shared 配置的依赖要一致。

  • singleton: 是否开启单例模式,true 为开启。启用单例模式后remote 应用组件和 host 应用共享的依赖只加载一次,且与版本无关。 如果版本不一致,会给出警告。不开启单例模式下,如果 remote 应用和 host 应用共享依赖的版本不一致,remote 应用和 host 应用需要分别各自加载依赖。
  • eager: 共享依赖在打包过程中是否被分离为 async chunk。eager 为 false, 共享依赖被单独分离为 async chunk,首次使用时异步加载; eager 为 true, 共享依赖会打包到 main、remoteEntry,不会被分离。默认值为 false。eager: true是一种强制同步加载共享依赖的优化手段,适用于关键、高频使用的库,以牺牲初始加载速度为代价,换取运行时稳定性,适应于框架级依赖。
plugins: [
        //...其他配置
        new webpack.container.ModuleFederationPlugin({
            name: 'lel_schedule_web',
            filename: 'remote-entry-lel-schedule-web.js',
            exposes: {
              './CreateHomework.vue': './src/views/aiManage/homework/CreateHomework.vue',
              './CreateHour.vue': './src/views/aiManage/teachingPlan/Hour.vue',
            },
​
            shared: {
              vue: {
                singleton: true,
                eager: true,
              },
              'vue-router': {
                singleton: true,
                eager: true,
              },
              'element-plus': {
                singleton: true,
                eager: true,
              },
            },
          }),
    ],

设置splitChunks为false,禁用代码分割,Webpack 不会提取任何公共代码,每个入口或异步模块会直接内联其所有依赖。如果不这么设置,公共依赖会被提取生成为chunk-vendors.xxx.js且不会被自动加载,当host应用加载remote应用的remoteEntry.js时,webpack可能会找不到共享依赖项而报错。

optimization: {
        splitChunks: false, //可定制哪些依赖需要拆分,排除共享库
        //...其他配置
    }

4.2 主应用配置

remotes: 可选,表示当前应用是一个 Host,可以引用 Remote 中 expose 的模块。

plugins: [
        //...其他配置
        new webpack.container.ModuleFederationPlugin({
            name: 'axe',
            filename: 'remote-entry-axe.js',
            remotes: {
                lel_schedule_web:
                    `lel_schedule_web@${isDev ? 'http://localhost:8180/' :  getPublicPath()}remote-entry-lel-schedule-web.js`, //本地启动
            },
            shared: {
                vue: {
                    singleton: true,
                    eager: true,
                },
                'vue-router': {
                    singleton: true,
                    eager: true,
                },
                'element-plus': { //版本迭代速度快
                    singleton: true,
                    eager: true,
                },
            },
        }),
    ],

5. 过程所遇问题及解决方案

5.1 热更新问题

引入了模块联邦后远程模块会加载自身的remoteEntry.js文件,导致热更新时无法正确识别当前模块,报错 Cannot set properties of undefined

解决方案:

指定运行时加载的模块,排除remote模块:

config.plugin('html').tap((args) => {
        args[0].title = getConfig().title;
        args[0].baseUrl = getConfig().cdnUrl;
        args[0].static = getAssetPath();
        args[0].cdnUrl = getConfig().frontResCdn;
        args[0].name = getPackageJson().name;
        args[0].chunks = ['chunk-vendors', 'chunk-common', 'app']; //指定运行时模块为这三个
        return args;
    });

5.2 导入——异步加载

模块联邦的设计核心在于异步加载,异步加载的特性赋予模块联邦相比传统微前端方法更少的初始加载时间,允许共享依赖等特性。在组件中异步引入远程模块的方法如下:

const CreateHour = defineAsyncComponent({
    loader: () => import('lel_schedule_web/CreateHour.vue'),
});

5.3 运行时获取参数

当我们的项目中使用了高阶组件时,需要修改相应的配置

例如在创建课时时,通过下面的工厂函数为上传资源组件进行加工,注入请求配置:

针对一些需要在运行时获取参数的情况,需要额外配置方法根据模块所在的应用不同来获取不一样的参数

function withRequestConfig(WrappedComponent) {
    const cookies = new Storage('cookie');
    const apiHost = (window.HostConfig || curConfig)[curEnv]?.apiHost;
    const requestConfig = { // 统一请求配置
        apiHost: apiHost || getApiPrefixV2(),
        accessToken: getCookiesVal('token') || '',
        clientId: 'sanren-teacher-pc',
    };
    //......
}

6. 探索:模块联邦最佳实践

在对项目进行模块联邦改造时,需要考虑的一些问题。

6.1 CSS隔离方案

在使用模块联邦时可能会产生样式污染问题(主应用和远程模块的样式互相影响),下面是一些可能的解决办法

  1. 使用 Vue 的 <style scoped>CSS Modules 限定样式作用域,样式编译后带有自动生成的 data-v-* 属性,不会污染外部样式。

    <template>
      <div class="btn">Click me</div>
    </template><style scoped>
    .btn {
      color: red;
    }
    </style>
    

    ✔️ 强烈推荐用于组件样式隔离

    ❌ 无法解决使用了全局CSS变量导致的样式冲突

  2. Shadow DOM,将组件封装为真正的Web Component,其样式默认隔离在 Shadow DOM 中。

    customElements.define('my-button', MyButtonWebComponent)
    

    ✔️ 真正意义上的样式隔离 ❌ 需要封装、打包支持,复杂度高,对 Vue 项目改造成本大

    Shadow DOM,是Web Component的一种技术,允许将一个DOM树附加到一个元素上,并且使该树的内部对于在页面中允许的Javascript和CSS是隐藏的。通过该技术可以实现高度的封装组件,隔离外部代码对组件内部的影响。常见术语:影子宿主(Shadow host) : 影子 DOM 附加到的常规 DOM 节点;影子树(Shadow tree) : 影子 DOM 内部的 DOM 树;影子边界(Shadow boundary) : 影子 DOM 终止,常规 DOM 开始的地方;影子根(Shadow root) : 影子树的根节点。

综合来看,使用scoped的改造成本最低,效果也较好。在使用模块联邦的组件中,建议都要加上<style scoped> ,且尽量避免使用全局css变量。

6.2 组件CSS响应式设计

由于组件可能会在不同项目上被复用,这些应用上装载组件的容器大小不一定相同,开发人员需要对组件的样式进行响应式设计以在不同的尺寸上自适应。

场景举例

场景: 对远程模块最外层容器的高度响应式设计,达到在不同项目内嵌入的高度自适应效果,完美实现三栏布局(header,body,footer)。

方案1: 在host应用内新建一个组件文件并放置一个外层容器来包裹远程模块,外层容器根据当前项目的header和footer写死高度,远程模块高度充满。

方案2: 规定项目的header和footer的高度值使用CSS变量设定,远程模块高度设定为calc(100vh - (var(--header-height) + var(--footer-height)))

6.3 JS设计规范

  • 组件化开发

组件化开发的最佳实践包括以下几个方面:模块化设计单一职责原则高内聚低耦合优先复用现有组件合理拆分组件

  • Vuex开发规范

通常情况下使用Vuex,一个应用内只注册一个Store的实例,称为全局单例模式管理

场景: 当我们需要被共享的远程模块使用了应用内的store体现其业务逻辑,如homework模块,但也需要使用主应用内的store来实现一些权限判断role该怎么办呢?

方案: 为保证每个应用下只有一个store实例,我们可以将远程模块需要用到的业务store模块动态注册到主应用的store下,实现步骤:

  1. 主应用暴露Store实例

    // 主应用的 store/index.ts
    import { createStore } from 'vuex';
    ​
    const store = createStore({
      state: { role: 'guest' }, // 主应用的全局状态
      modules: {}, // 初始为空,等待远程模块动态注册
    });
    ​
    // 暴露 Store 实例,供远程模块调用
    export const registerModule = (moduleName: string, module: any) => {
      if (!store.hasModule(moduleName)) {
        store.registerModule(moduleName, module);
      }
    };
    ​
    export default store;
    
  2. 远程模块动态注册自己的Store

    // 远程模块的 homeworkStore.ts
    import { registerModule } from 'host/store'; // 主应用暴露的方法const homeworkStore = {
      namespaced: true,
      state: { assignments: [] },
      mutations: {
        setAssignments(state, payload) {
          state.assignments = payload;
        },
      },
      actions: {
        fetchAssignments({ commit }) {
          // 可访问主应用的状态(通过 rootState)
          const role = this.rootState.role; // 主应用的 role 状态
          if (role === 'teacher') {
            commit('setAssignments', [...]);
          }
        },
      },
    };
    ​
    // 动态注册到主应用 Store
    registerModule('homework', homeworkStore);
    
  3. 主应用使用远程模块的Store

    // 主应用的组件中
    import { useStore } from 'vuex';
    ​
    export default {
      setup() {
        const store = useStore();
        // 访问远程模块的状态
        const assignments = computed(() => store.state.homework.assignments);
        // 调用远程模块的 action
        store.dispatch('homework/fetchAssignments');
      },
    };
    

在我们当前的项目中,由于远程模块组件不够纯粹,自带的一个store可以满足其所有需求,因此在打包远程模块时将其使用的store一并打包,然后通过**import store from '@/store'** 引用了具体的Store实例文件,绕过了Vue的依赖注入系统。此时不能使用useStore,它会从当前Vue应用的依赖注入系统中获取Store实例

与远程模块通信

当我们使用模块联邦时,导入的远程模块就像一个普通的组件一般,需要进行通信时,可以借助路由进行传参,或在父组件和远程组件间约定props传值和emit事件。此外,也能使用上文的vuex;