Skip to content

前言

模块联邦提供了一种运行时的加载其他服务下模块的能力,通过下面几个问题来解决生产中模块联邦的问题

  1. 模块联邦在项目源码中并不会体现,如果packageA引入了packageB,如何保证自动补全、ts类型等功能
  2. 项目工程化如何设计,单仓库还是多仓库,项目如何启动
  3. 开发环境和线上环境如何设计
  4. 和微服务框架如何结合

webpack中的模块联邦

webpack中通过MF插件提供了简单配置容器和加载容器的功能

js
// webpack.config.js
export default {
    .....
    plugins: [new webpack.ModuleFederationPlugin({
        name: "main_app", // 本身对外暴露出的入口
        filename: "remoteEntry.js",  // 入口文件
        exposes: {
            './functionA': './src/xxx/a.js'  // 暴露出的文件
        },
        share: ['vue'], // 共享的模块, 不会重复打包
        remote: {  // 需要加载的远程package
            'packageB': 'packageB@http://localhost:3001/remoteEntry.js'
        }
    })]  
}

在packageA中使用packageB的方法

js
import functionA from "packageB/functionA"
console.log(functionA)

模块联邦

模块联邦提供了加载其他模块的能力,可以用于跨项目共享chunk。

  • 本地模块指的是入口文件构建的模块,远程模块是MF定义出的。相当于共享配置的两个入口文件。
  • 每个构建都是一个容器,容器可以加载远程模块。
  • 远程模块需要使用异步的方式加载。
  • 远程模块也可以作为容器加载其他远程模块。
  • 远程模块中,因为publicPath是简单的拼接,如果设置了publicPath会出现加载宿主域的问题。需要将publicPath设置为auto或则动态设置。
  • 远程模块至少会生成runtime、remote和module 3种chunk文件
js
const MF = webpack.container.ModuleFederationPlugin;

module.exports = {
  plugins: [
    // 打包逻辑 runtime -> remote.js -> ./componentA
    new MF({
      name: 'remote',  // 远程模块名称
      filename: 'remote.js', // 生成的远程模块文件,用于宿主容器加载
      runtime: 'remote.runtime.js', // 默认情况下runtime文件生成在filename 定义的文件中,可以单独抽离出来。
      exposes: [{
        './componentA': './src/componentA.vue'  // 需要使用./前缀,host 通过 import('remote/componentA')
        '.': 'src/componentA/index.js'  // import('remote')
      }]
    })
  ]
}

动态设置publicPath

因为runtime.js 需要加载remote.js和module.js 所以需要提前设置publicPath。MF在打包时如果container chunk和入口chunk同名,会合并。所以需要将name设置为一个入口name相同。

js
module.exports = {
  entry: {
    main: './index.js',
    remote: './public-path.js' // 在入口文件中配置动态的js。
  },
  plugins: [
    new MF({
      name: 'remote',  // 远程模块名称
      filename: 'remote.js', // 生成的远程模块文件,用于宿主容器加载
      runtime: 'remote.runtime.js', // 默认情况下runtime文件生成在filename 定义的文件中,可以单独抽离出来。
      exposes: [{
        './componentA': './src/componentA.vue'
        '.': 'src/componentA/index.js'
      }]
    })
  ]
}

动态加载远程模块

正常情况下远程模块是在webpack配置中制定的,可以手动实现远程模块的加载。

js
const loadModule = async (url, containerName, module) => {
  await loadScript(url);   // 加载remoteEntry js 脚本。
  const container = window[containerName];  // 默认情况下remoteEntry 按照var 导出。 通过window containerName 可以获取到container。
  const factory = await container.get(module)  // get方法是一个异步的方法,获取的是modules里的value。
  return factory()  // 执行一次相当于执行了module文件。
}

上面代码不够完整,缺少共享模块的加载以及重复引用下module不是单例问题。

js
const remoteModuleCache = new set();
let loadModule = async (url, containerName, module) => {
  if(hasCache(container, module)) {
    return getCache()
  }
  await loadScript(url);   // 加载remoteEntry js 脚本
  await __webpack_init_sharing__('default')  // 全局初始化共享模块
  const container = window[containerName];  // 默认情况下remoteEntry library 按照var 导出。 通过window containerName 可以获取到container。
  container.init(__webpack_share_scoped__.default)
  const factory = await container.get(module)  // get方法是一个异步的方法,获取的是modules里的value。
  return factory()  // 执行一次相当于执行了module文件。
}

远程模块加载流程

  1. webpack_modules 声明了remoteEntry入口文件。
  2. 通过__webpack_require__.e 调用 __webpack__require.f.remote 中间件加载入口js文件。
  3. 入口js文件加载好后,在全局暴露remote变量
  4. host中加载远程模块的module会固定编译成 一个chunkid,
  5. host 会调用remote去获取这个chunkId对应的js文件,
  6. 加载完成后回调在remote中,然后host执行这个remote中的回调函数得到module。

远程模块踩坑

  1. 远程模块的异步chunk加载方式务必不要和host应用雷同,会出现覆盖host modules问题。大坑,使用 output.chunkLoadingGlobal、output.uniquename修改
  2. 目前自动加载远程模块的方式仅默认方式能生效,即把远程模块绑定到全局变量上。其他的方式均需要自行加载远程模块

在vite中使用模块联邦

vite社区已经实现@originjs/vite-plugin-federation, 配置方式和webpack差不多

如何保证packageB的自动补全

有如下两种场景,一是packageB和packageA使用了pnpm的monorepo的方案,一种是packageB在另一个地方。第一种场景比较简单,直接在pnpm add packageB作为packageA为依赖,编辑器中就能获得所有补全,但是需要引入的packageB和源码结构类似。虽然在packageA中引用了packageB,但是并不会真正打包,这种情况存在路径映射的问题(通过package新特性支持,通过exports指明对应文件的type,这种方式需要在tsconfig中指定moduleRelution为我为Node16 或者nodenext)。第二种情况是packageB不和packageA在同一个仓库,需要对packageB进行申明文件构建,申明文件选择一个新的目录,在packageA的tsconfig中正常添加这个申明文件的引用,通过typeRoot添加一个。通过设置rootDir还能实现对第一种方案中路径映射的调整

单仓库还是多仓库

新项目无脑单仓库,自带补全功能美滋滋。

但是模块联邦目前不能很好的和monorepo结合,比如monorepo引用的是packageB的dist后的代码,但是模块联邦需要的是源码方便调试(也可以只把申明文件输出到dist目录中),两者存在冲突。模块联邦的这种黑科技方式对项目也是破坏性,所以还是推荐自行添加types到packageA的依赖中(生成声明文件时,声明文件也可以添加resouremap选项也能重定向到源代码,这个特性重要重要重要,需要珍藏)

开发环境和线上环境的结合

模块联邦主要的组件和复用,非应用的复用。在monorepo开发环境中,删除对模块联邦的使用,使用packageA打包packageB依赖(因为已经申明了packageB,可以放心大胆的删除),这个好处是不用启用多个服务,一个服务解决。

和微服务框架如何结合

微服务框架主要是对应用的复用和嵌套,模块联邦目前未能做到样式隔离等功能,只做到了加载器的功能。同时微服务对monorepo本地线上双模式支持也较弱。目前不考虑强行将微服务转换为模块联邦(一定不要)

模块联邦如何动态设置publicPath

模块联邦中,如果子应用简单的设置了publicPath,不管是相对还是绝对路径都是对于主应用而言,这种情况下需要动态的配置publicPath使子应用正常。第一个方法是设置子应用的publicPath为auto。或者通过__webpack_public_path 动态设置,具体看webpack那篇博客