评论

webpack4 module-federation and more

微前端包管理平台-WPM(Web Package Manager)

微盟内建设和使用微前端已有两年多, 早在 webpack5 发布之前,qiankun 发布之初,微盟中台的微前端如 qiankun 一样解决了不同业务不同应用的隔离,共存, 支撑着微盟 SaaS 系统现代化的开发和运行。

微前端包管理平台-WPM(Web Package Manager)

为了进一步共享资源、提升效率, 微盟技术团队抽象出了 WPM。WPM 很容易理解, 可以简单的视为一个网络版本的 npm,使用 WPM 包不需要安装、不需要参与应用的构建过程, 每个包有自己的版本、环境, 独立构建和发布。

WPM 包可以是一些 json、组件、函数, 实现了资源级别的共享, 并且集成了高效的开发环境。为此, 也需要解决一些现阶段的业界难题。

公司内存在大量webpack4项目, 无法使用module-federation特性

无法使用 module-federation 意味着所有的远程模块只能使用 systemjs 这种异步加载模块的方案, 这在远程模块是组件时, 大概只会影响 ref 获取到的时机, 但是如果远程模块是一个项目配置 (json)、函数时, 后续的任何同步逻辑也只能被迫处理 promise,, 将会十分不便且容易产生BUG。

在公司内我们实现了 import-http-webpack-plugin(底层使用 systemjs 来同步引入 http 资源的插件)对标 MF 的主要能力, 以及 wpm-webpack-plugin(集成开发热更新/上传、发布包版本的插件)、wpmjs sdk(封装 systemjs 实现语义化版本引入包的 SDK )、wpm-dev-panel(自动连接本地开发环境的调试面板)、wpm-cli(包管理命令行工具)、wpm-http-api(权限/版本管理的服务) 来提供完整的WPM 能力。

微前端开发模式组件热更新

由于像 VUE、React这种框架的热更新必须使用其 development 版本, 并且 React 的条件更为苛刻, 而 WPM 的 React、Vue并不经过项目的构建,而是独立打包的, 所以 WPM 提供了开发模式,并且我们维护了 vue-dev、react-dev 两个包, 来支持独立启动任意模块都能够进行热更新。

react热更新条件:
  1. react-refresh、react-dom、react 单例
  2. react-dom、react 使用development版本
  3. 保证代码执行顺序, react-refresh 必须在 react-dom 之前运行
  4. 使用 @pmmmwh/react-refresh-webpack-plugin 插件

@module-federation/webpack-4

其实 import-http-webpack-plugin 能够比module-federation产生更少的 chunk ,但在 HTTP2.0 这并不重要, 倒是 module-federation 对于各个领域(ssr、typescript等)的基础能力做的已经比较强大, 还有第三方开发者提供的 vite 插件, 于是我们也转向了 mf 生态, 实现了 @module-federation/webpack-4 来支撑 WPM 的升级, 现在这个包已经作为 MF 的官方能力公开, 为 MF 的开源生态提供升级方案。

module-federation/webpack-4实现原理

简单的解释下实现原理,webpack4 和 webpack5 是怎么实现互通的呢? 有三个关键点:

  • usemf(使用webpack5 build输出的sdk, 用于在非 webpack5 环境模拟一个webpack5环境来加载module-federation)。
遵循 module-federation 的加载流程(1. init all remote container 2. merge shareScopes 3. 还原webpack5-share的共享规则); 输出module-federation-container。// container
{ 
    async init(shareScope){}, 
    get(name){ return async factory() } 
}

// shareScopes example
{
  [default]: {
      [react]: {
          [18.0.2]: {
              get() {
                return async function factory() {
                    return module
                }
              },
              ...other
          },
          [17.0.2]: {
              get() {
                return async function factory() {
                    return module
                }
              },
              ...other
          }
      }
  }
}

  • 最后是 webpack4 所欠缺的一项能力, 使 jsonp-chunk 支持等待依赖(远程模块)加载。

通过插件实现上述流程(图示)

  1. 增加一个新入口, 用来实现 module-federation 的加载流程, 并输出container;
  2. 拦截 remotes 的模块加载, 不再直接加载本地模块, 而是使用远程模块;
  3. 拦截 shared 的模块加载, 不再直接加载本地模块, 而是使用远程模块;
  4. shared 的请求都被拦截, 但仍需要输出 shared bundle, 并将加载函数 merge shareScopes;

其中介绍图中两处红色部分, 如何改变 webpack4 加载流程使其支持加载远程模块

  • 拦截import, 预留依赖标记
  1. 设置alias, 将remotes转至一个不存在的url(不存在才可在第二步拦截)
  2. 在compiler.resolverFactory.plugin("resolver normal") --> resolver.hooks.resolve.tapAsync钩子将remotes转发至特定loader
  3. 在loader留下字符串标记当前module依赖远程模块, 获取并导出远程模块的值
  • jsonp chunk 等待远程依赖加载
  1. 在compilation.mainTemplate.hooks.jsonpScriptchunk钩子使jsonp chunk等待远程模块加载完成后再执行

源码解析

源码地址:https://github.com/module-federation/webpack-4

// module-federation/webpack-4/lib/plugin.js
apply(compiler) {
    // 1. 生成唯一的jsonpFunction全局变量防止冲突
    compiler.options.output.jsonpFunction = `mfrename_webpackJsonp__${this.options.name}`
    // 2. 生成4个虚拟模块备用
    this.genVirtualModule(compiler)
    // 3. 在entry chunks初始化远程模块映射关系
    // 4. 在entry chunks加载所有的container初始化依赖集合(shareScopes)
    this.watchEntryRecord(compiler)
    this.addLoader(compiler)
    // 5. 生成mf的入口文件(一般是remoteEntry.js)
    this.addEntry(compiler)
    this.genRemoteEntry(compiler)
    // 6. 拦截remotes、shared模块的webpack编译
    this.convertRemotes(compiler)
    this.interceptImport(compiler)
    // 7. 使webpack jsonp chunk等待远程依赖加载
    this.patchJsonpChunk(compiler)
    this.systemParse(compiler)
}

1. 生成唯一的jsonpFunction全局变量防止冲突

compiler.options.output.jsonpFunction = `mfrename_webpackJsonp__${this.options.name}`

2.生成4个虚拟模块备用

只是将这4个文件代码模块作为webpack虚拟模块来注册, 可被后续流程引入使用。

3.在entry chunks初始化远程模块映射关系

4. 在entry chunks加载所有的container初始化依赖集合

初始化所有 container(其他 mf 模块), 并将加载过程以 promise 形式导出, 以标识初始化阶段的完成(所有的 jsonp chunk 需要等待初始化阶段完成)。

module-federation/webpack-4/lib/virtualModule/exposes.js

5. 输出mf的入口文件(一般是remoteEntry.js)

  1. 生成入口文件(module-federation/webpack-4/lib/plugin.js)
// 1. 使用singleEntry添加mf入口
new SingleEntryPlugin(compiler.options.context, virtualExposesPath, "remoteEntry").apply(compiler)

// 2. 复制remoteEntry入口最后生成的文件, 并重命名
entryChunks.forEach(chunk => {
    this.eachJsFiles(chunk, (file) => {
      if (file.indexOf("$_mfplugin_remoteEntry.js") > -1) {
        compilation.assets[file.replace("$_mfplugin_remoteEntry.js"this.options.filename)] = compilation.assets[file]
        // delete compilation.assets[file]
      }
    })
})
  1. 暴露container api(module-federation/webpack-4/lib/virtualModule/exposes.js)
`
  /* eslint-disable */
  ...
  const {setInitShared} = require("${virtualSetSharedPath}")
  
  // 此处使用dynamic-import预设了所有exposes module
  const exposes = {
    [moduleName]: async () {}
  }
  
  // 1. 在全局以类似global的方式注册container
  module.exports = window["${options.name}"] = {
    async get(moduleName) {
      // 2. 使用代码分割来暴露导出的模块
      const module = await exposes[moduleName]()
      return function() {
        return module
      }
    },
    // 此处是某个scope之内的shared
    async init(shared) {
      // 4. 合并share、等待init阶段完成
      setInitShared(shared)
      await window["__mfplugin__${options.name}"].initSharedPromise
      return 1
    }
  }
  
  `

6. 拦截remotes、shared模块的webpack编译

  1. 将 remotes、shared 的模块设置别名, 标识特殊路径, 转发到一个不存在的文件路径(只有不存在的文件路径可以被 resolver 钩子拦截并继续转发)(module-federation/webpack-4/lib/virtualModule/plugin.js)。
const { remotes, shared } = this.options
    Object.keys(remotes).forEach(key => {
      compiler.options.resolve.alias[key] = `wpmjs/$/${key}`
      compiler.options.resolve.alias[`${key}$`] = `wpmjs/$/${key}`
    })
    Object.keys(shared).forEach(key => {
      // 不存在的文件才能拦截
      compiler.options.resolve.alias[key] = `wpmjs/$/mfshare:${key}`
      compiler.options.resolve.alias[`${key}$`] = `wpmjs/$/mfshare:${key}`
    })
  1. 拦截 remotes、shared 的别名, 转发到 import-wpm-loader.js 生成请求远程资源的代码(module-federation/webpack-4/lib/plugin.js)。
compiler.resolverFactory.plugin('resolver normal'resolver => {
  resolver.hooks.resolve.tapAsync(pluginName, (request, resolveContext, cb) => {
      if (是来自remotes、shared的别名) {
          // 携带pkgName参数转发至import-wpm-loader
          cb(null, {
            path: emptyJs,
            request: "",
            query: `?${query.replace('?'"&")}&wpm&type=wpmPkg&mfName=${this.options.name}&pkgName=${encodeURIComponent(pkgName + query)}`,
          })
      } else {
          // 请求本地模块
          cb()
      }
  });
});
  1. 生成请求远程资源的代码(module-federation/webpack-4/lib/import-wpm-loader.js), 2处关键代码。
module.exports = function() {
    `
    /* eslint-disable */
    if (window.__wpm__importWpmLoader__garbage) {
      // 1. 留下代码标识, 标识依赖的远程模块, 用于让chunk等待远程依赖加载
      window.__wpm__importWpmLoader__garbage = "__wpm__importWpmLoader__wpmPackagesTag${pkgName}__wpm__importWpmLoader__wpmPackagesTag";
    }
    // 2. 进入此模块代码时, 远程模块已经加载完毕, 可以使用get获取模块的同步值, 并返回module-federation/webpack-4
    module.exports = window["__mfplugin__${mfName}"].get("${decodeURIComponent(pkgName)}")
    `
}

7. 使webpack jsonp chunk等待远程依赖加载

  1. 使用正则匹配到jsonp chunk依赖的远程模块, 使chunk等待依赖加载。
  2. 使webpack jsonp加载函数支持jsonp等待加载依赖(module-federation/webpack-4/lib/plugin.js)。

附录:

最后一次编辑于  2022-11-04  
点赞 0
收藏
评论
登录 后发表内容