微盟内建设和使用微前端已有两年多, 早在 webpack5 发布之前,qiankun 发布之初,微盟中台的微前端如 qiankun 一样解决了不同业务不同应用的隔离,共存, 支撑着微盟 SaaS 系统现代化的开发和运行。
为了进一步共享资源、提升效率, 微盟技术团队抽象出了 WPM。WPM 很容易理解, 可以简单的视为一个网络版本的 npm,使用 WPM 包不需要安装、不需要参与应用的构建过程, 每个包有自己的版本、环境, 独立构建和发布。
WPM 包可以是一些 json、组件、函数, 实现了资源级别的共享, 并且集成了高效的开发环境。为此, 也需要解决一些现阶段的业界难题。
无法使用 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热更新条件:
react-refresh、react-dom、react 单例 react-dom、react 使用development版本 保证代码执行顺序, react-refresh 必须在 react-dom 之前运行 使用 @pmmmwh/react-refresh-webpack-plugin 插件
其实 import-http-webpack-plugin 能够比module-federation产生更少的 chunk ,但在 HTTP2.0 这并不重要, 倒是 module-federation 对于各个领域(ssr、typescript等)的基础能力做的已经比较强大, 还有第三方开发者提供的 vite 插件, 于是我们也转向了 mf 生态, 实现了 @module-federation/webpack-4 来支撑 WPM 的升级, 现在这个包已经作为 MF 的官方能力公开, 为 MF 的开源生态提供升级方案。
简单的解释下实现原理,webpack4 和 webpack5 是怎么实现互通的呢? 有三个关键点:
// 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 加载流程使其支持加载远程模块
源码地址: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)
}
compiler.options.output.jsonpFunction = `mfrename_webpackJsonp__${this.options.name}`
只是将这4个文件代码模块作为webpack虚拟模块来注册, 可被后续流程引入使用。
初始化所有 container(其他 mf 模块), 并将加载过程以 promise 形式导出, 以标识初始化阶段的完成(所有的 jsonp chunk 需要等待初始化阶段完成)。
module-federation/webpack-4/lib/virtualModule/exposes.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]
}
})
})
`
/* 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
}
}
`
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}`
})
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.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)}")
`
}