module-federation是webpack5更新的一项新特性, 可以更容易的共享前端代码, 本文将介绍@module-federation/webpack-4实现原理及其与webpack5的差异
背景
在公司内我们搭建了微前端包管理平台, 由于有大量webpack4的项目, 我们使用umd规范来共享资源, 也产出了和mf同等作用的插件import-http-webpack-plugin, 但是精力有限我们不打算在umd规范下建立微前端生态, 转而投入到有同样作用的mf, 借助其已有的各领域能力来继续搭建微前端生态。
现阶段mf的优势:
- webpack5内置插件
- webpack4(webpack4插件)、rollup/vite(vite插件)环境也有生态支持
- 非编译环境(usemf)
- 各领域相关能力均有提供(ssr、typescript、hmr、dashboard等)
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支持等待依赖(远程模块)加载
通过插件实现上述流程(图示)
- 增加一个新入口, 用来实现module-federation的加载流程, 并输出container
- 拦截remotes的模块加载, 不再直接加载本地模块, 而是使用远程模块
- 拦截shared的模块加载, 不再直接加载本地模块, 而是使用远程模块
- shared的请求都被拦截, 但仍需要输出shared bundle, 并将加载函数merge shareScopes
其中介绍图中两处红色部分, 如何改变webpack4加载流程使其支持加载远程模块
- 拦截import, 预留依赖标记
- 设置alias, 将remotes转至一个不存在的url(不存在才可在第二步拦截)
- 在compiler.resolverFactory.plugin(“resolver normal”) --> resolver.hooks.resolve.tapAsync钩子将remotes转发至特定loader
- 在loader留下字符串标记当前module依赖远程模块, 获取并导出远程模块的值
- jsonp chunk等待远程依赖加载
- 在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)
- 生成入口文件(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]
}
})
})
- 暴露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编译
- 将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}`
})
- 拦截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()
}
});
});
- 生成请求远程资源的代码(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.exports = window["__mfplugin__${mfName}"].get("${decodeURIComponent(pkgName)}")
`
}
7. 使webpack jsonp chunk等待远程依赖加载
- 使用正则匹配到jsonp chunk依赖的远程模块, 使chunk等待依赖加载
- 使webpack jsonp加载函数支持jsonp等待加载依赖(module-federation/webpack-4/lib/plugin.js)
与webpack5的差异
@module-federation/webpack-4插件已经实现了module-federation的主要能力, 并且可以在webpack4和webpack5互相引用 , 下面说明下哪些参数是插件是未支持的
不支持的参数
options.library
此参数优先级不是很高, 在webpack4种实现较为复杂, 在webpack5中使用也仍有问题, 详见https://github.com/webpack/webpack/issues/16236 , 故在webpack4中的实现类似于设置了library.type = “global”
options.remotes.xxx.shareScope
同一个mf container只可以用一个shareScope初始化, 如果被多次使用shareScope设置的不一致webpack会报错, 并且shareScope可设置处过多比较混乱, 即使在纯webpack5中使用表现也不可预估, 建议使用options.shared.xxx.shareScope、options.shareScope替代
module-federation生态包
webpack-4插件暂未集成webpack-5相关包的能力(ssr、typescript、hmr、dashboard等), 但已实现4、5互通, 可以助您可以放心的使用webpack5实现新项目, 而无需重构已有项目
已支持的参数
- options.remotes
- options.name
- options.shareScope
- options.shared
- options.exposes