浅析 Webpack Module Federation 在 React.js 中的实践
Motivation
多个独立的构建可以组成一个应用程序,这些独立的构建之间不应该存在依赖关系,因此可以单独开发和部署它们。 这通常被称作微前端,但并不仅限于此。
这是官方给出的 Module Fedration (后面简称 MF) 的动机,很多人说 MF 的出现是 JavaScript 架构中的”game-changer“,我认为是有一定道理的。在 MF 出现之前,JavaScript 中的模块共享更多的是通过 NPM 安装依赖的方式,将 package 下载到项目本地环境,然后通过 ES Module 或者其它模块化方式引入。或者直接在本地的目录创建各种模块,然后在各模块中导入导出。无论是 util,还是 component 的依赖共享,都是基于这种模式。
而 MF 的出现,彻底打破了 JS 模块共享的方式,个人认为,MF 的出现开启了前端微服务架构的新时代。
模块共享演变历史
我们先回顾在 MF 出现之前的一些解决方案。
NPM
当我们在两个项目中需要共享同一个模块时,我们首先想到的就是将模块单独开发,发布到 NPM 上,然后分别在两个项目中引入。以广告目前的项目 smart-ads 和 smart-ads-admin 为例,它们之间共享了一个 Employee 模块,我们通过 Veggie(一个专门封装广告各项目共享组件的 Monorepo) 管理了 Employee 组件:
但是这种方案的弊端是,如果我们升级了 Employee 包,每个项目需要各自更新。虽然通过 Monorepo 能解决重复安装和版本维护的复杂度,但是依然需要本地编译,走重新发版上线流程,另外本地开发调试也有一定的成本。
UMD
目前前端唯一可以在 runtime 时实现模块共享是基于 UMD 的方式,即将需要共享的模块通过打包工具打包成 UMD 格式,然后上传到 CDN,其它项目通过 script
标签引入,目前比较常见的场景就是数据上报 SDK 都会支持 CDN 的引入方式,以我们公司的海度 SDK 为例:
这种方式的问题在于,没法做到编译时的一些优化,例如包与项目之间的相同依赖无法共享,而且有可能出现依赖版本的冲突问题。
DllPlugin 和 Externals
在 MF 之前,Webpack 也出现过 DllPlugin 和 Externals 的一些尝试方案,但是 DllPlugin 也同样无法做到依赖共享,而且只能是基于本地构建环境的一种模块共享的方式。而通过 externals 选项将部分依赖通过 CDN 的方式引入,带来的另外一个问题就是没法使用某个依赖的多个版本。
微前端
这是近几年比较火的一种前端架构方案,微前端就是要解决多项目并存问题,而多项目并存的最大问题就是模块共享,且不能有冲突。从构建的角度看微前端,它有两种方案:
- 每个子应用单独打包,打包的速度能得到提升,但是无法抽取公共依赖;
- 整体应用一起打包,解决了公共依赖的问题,但是打包的速度会随着应用的膨胀越来越慢。
回顾这几年的模块共享方式的演变史,每种方案都或多或少存在一些明显的缺点,所以导致我们在选择方案的时候,只能根据业务场景挑一种折中的方案。下面来看下 MF 的出现,是怎么解决这些问题的。
Gamer-Changer--MF
我们首先看 MF 能解决什么问题。
1.远程模块共享
之前我们更多的是通过 NPM 将模块下载在本地项目,或者项目本身封装一些模块,然后在各页面之间复用,这种方式是一种本地模块共享的方式。而 MF 的出现,使得以后的 JS 模块化开发中,远程模块的共享成为一种常态,以前面的提到的例子 smart-ads 和 smart-ads-admin 为例,如果改成通过 MF 的方式共享 Employee 模块,依赖图如下:
2.公共依赖复用
前面我们提到过通过 UMD 方式远程方式加载模块的问题在于没法实现依赖共享,从而导致一个项目重复加载相同的依赖。而 MF 可以在构建的时候通过配置项指明与远程模块需要的依赖,从而解决了依赖共享的问题。而且需要共享的依赖只有在需要的时候才会发起请求,从而解决了项目重复加载依赖的问题。
3.比较完美的微前端解决方案
我们知道在微前端架构的方案中,最常见的还是基于基座应用的架构方案,例如阿里开源的 qiankun。在一个微前端应用中,都会有一个基座应用,来管理所有的子应用。当然,基于基座的架构方案有它自己的优势,例如可以灵活地管理子应用之间、父子之间的通信、实现沙箱机制、切换应用时装载和卸载子应用等等。然而对于公共依赖加载的问题和父子之间由于版本不一致导致依赖冲突的问题一直没有很好的解决方案。
利用 MF 的 shared 能力 和 MF 的 remotes 能力,即公共依赖加载问题,而通过创建一个应用专门提供库给其它应用消费,也能解决版本冲突的问题。
4.适用于浏览器环境和 Node 环境
应用于浏览器环境,这是基础的能力。MF 同样也适用于 target: "node",这里使用指向其他微前端应用的文件路径,而不是 URLs。这样的话你就可以用同样的代码,外加不一样的 Webpack 配置来在 Node.js 中实现 SSR. MF 的特性在 Node.js 中保持不变,如独立构建、独立部署。
这里总结了我个人认为 MF 比较重要的几个作用,下面会根据这几个作用列举一些 MF 在 React 中的具体使用场景。
MF 实践
接下来介绍几个在 React 中应用的场景。
React with TypeScript
在这例子中,我们有两个应用 APP1 和 APP2,每个应用的目录结构如下:
APP1 消费 APP2 导出的 Button 组件,App.tsx 代码如下:
import * as React from "react";
const RemoteButton = React.lazy(() => import("app2/Button"));
const App = () => (
<div>
<h1>App 1</h1>
<h2>React in Typescript</h2>
{/* 因为是远程加载的模块,需要使用 Suspense 添加 fallback */}
<React.Suspense fallback="Loading Button">
<RemoteButton />
</React.Suspense>
</div>
);
export default App;
APP1 的 webpack.config.js 配置:
module.exports = {
// 省略其它配置
plugins: [
new ModuleFederationPlugin({
name: "app1",
// 这里指明需要加载的远程模块服务地址
remotes: {
app2: "app2@http://localhost:3002/remoteEntry.js",
},
// 指明需要共享的依赖
shared: ["react", "react-dom"],
}),
],
}
接下来我们看 APP2 的 Button.tsx:
import * as React from "react";
const Button = () => <button>App 2 Button</button>;
export default Button;
APP2 也可以自己本地消费 Button 组件,APP2 的 App.tsx:
import * as React from "react";
import LocalButton from "./Button";
const App = () => (
<div>
<h1>Typescript</h1>
<h2>App 2</h2>
<LocalButton />
</div>
);
export default App;
App2 的 webpack.config.js:
module.exports = {
// 省略其它配置
plugins: [
new ModuleFederationPlugin({
name: "app2",
filename: "remoteEntry.js",
// 这里配置需要导出的模块给其它应用共享
exposes: {
"./Button": "./src/Button",
},
// 指明需要共享的依赖
shared: ["react", "react-dom"],
}),
],
}
最后效果:
在这个例子中,我们需要在 APP1 中添加远程模块 ”app2/Button“ 的 TypeScript 类型声明,否则 TS 就会提示
找不到 app2/Button 模块或者其相应的类型声明
所以我们需要在 app.d.ts 添加类型定义:
declare module "app2/Button" {
const Button: React.ComponentType;
export default Button;
}
这是目前 MF 的一个痛点,在 TS 项目使用,TS 没法从远程加载声明文件使用。
Nextjs SSR
我们知道 Nextjs 是一个基于 React 的支持各种渲染方案 CSR、SSR、SSG 等的框架,目前我们广告的落地页项目就是使用 Nextjs + Preact 作为技术栈。
回到主题,我们来看下 MF 在 Nextjs 中使用。
在这个例子中,我们新建了 host 应用和 remoteLib 应用,它们的目录结构如下:
首先我们看 remoteLib ,封装了一个 SmartButton 组件,然后打包的时候,通过 MF 插件导出,暴露给其它应用使用。先看 SmartButton.jsx 代码:
import React from "react";
const SmartButton = () => {
return <button style={{ height: "45px" , fontSize: "18px", lineHeight: "45px",
backgroundColor: "#0070f3", color: "#fff" }}>
Hey, I'm a smart button from remoteLib
</button>
};
export default SmartButton;
看它的 webpack.config.js:
module.exports = {
// 省略其它配置
plugins: [
new ModuleFederationPlugin({
name: "remoteLib",
filename: "remoteEntry.js",
exposes: {
"./SmartButton": "./src/SmartButton.jsx",
},
shared: {
react: {
singleton: true,
requiredVersion: packageJson.dependencies["react"],
},
["react-dom"]: {
singleton: true,
requiredVersion: packageJson.dependencies["react-dom"],
},
},
}),
]
}
导出模块的配置跟前面的例子是没区别的,但是这里共享依赖项的配置新增了 singleton 和 requiredVersion 配置,这里简单介绍下这两个配置的作用:
- singleton,这个配置在共享作用域中,只允许共享模块的有唯一的版本,默认是关闭的。像 react、react-dom 是使用一个全局的内部状态,所以在没有隔离 runtime 的情况下,我们需要确保 react、react-dom 唯一。
- requiredVersion,这个配置就很容易理解了,搭配 singleton,指明共享依赖的版本,一般直接从 package.json 中获取依赖版本。
我们继续看 host 应用的代码,为了方便理解,我们首先需要看 next.config.js 文件,用过 Nextjs 的同学应该知道,它的所有配置都在该文件中,包括对 Webpck 配置的扩展。我们重点看 Webpack 部分:
const { NodeModuleFederation } = require("@telenko/node-mf");
const ModuleFederationPlugin = require("webpack/lib/container/ModuleFederationPlugin");
module.exports = {
future: { webpack5: true },
webpack: (config, options) => {
const { buildId, dev, isServer, defaultLoaders, webpack } = options;
const mfConf = {
remotes: {
remoteLib: isServer
? "remoteLib@http://localhost:3002/node/remoteEntry.js"
:
{
external: `external new Promise((r, j) => {
window['remoteLib'].init({
react: {
"${packageJsonDeps.react}": {
get: () => Promise.resolve().then(() => () => globalThis.React),
}
}
});
r({
get: (request) => window['remoteLib'].get(request),
init: (args) => {}
});
})`,
},
},
shared: {
react: {
eager: true,
requiredVersion: packageJsonDeps["react"],
singleton: true,
},
"react-dom": {
eager: true,
requiredVersion: packageJsonDeps["react-dom"],
singleton: true,
},
},
};
return {
...config,
plugins: [
...config.plugins,
new (isServer ? NodeModuleFederation : ModuleFederationPlugin)(mfConf),
],
experiments: { topLevelAwait: true },
};
},
}
与前面几个配置不同的是,这里我们在引入 remote 的时候需要区分在服务端和浏览器端的配置,在服务端我们通过 remoteLib 的服务地址加载模块,然后真正到客户端的时候,我们把远程模块的 container 挂在 window 对象上。看过 Webpack MF 官方文档的同学应该知道,MF 是支持这种基于 Promise 的动态 Remote。
还有一个差异点就是我们在使用 MF 插件的时候,会需要专门加载为 Node 定制的插件,也就是从上述代码最开始从 @telenko/node-mf 中读取的 MF 插件,这个包封装了专门为 MF 在 Node 端使用的 Webpack 插件集合。
接下来我们来看下具体怎么使用,看下 index.js 代码:
import Head from "next/head";
import Image from "next/image";
import styles from "../styles/Home.module.css";
import dynamic from "next/dynamic";
// 通过 dynamic 的方式远程加载 Button 组件
const RemoteButton = dynamic(
() => {
return window.remoteLib?.get("./SmartButton").then((factory) => factory());
},
{ ssr: false }
);
export default function Home() {
return (
<div className={styles.container}>
<Head>
<title>Create Next App</title>
<meta name="description" content="Generated by create next app" />
<link rel="icon" href="/favicon.ico" />
</Head>
<main className={styles.main}>
<h1 className={styles.title}>
Welcome to <a href="https://nextjs.org">Next.js!</a>
</h1>
{ <RemoteButton /> }
</main>
<footer className={styles.footer}>
<a
href="https://vercel.com?utm_source=create-next-app&utm_medium=default-template&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Powered by{" "}
<span className={styles.logo}>
<Image src="/vercel.svg" alt="Vercel Logo" width={72} height={16} />
</span>
</a>
</footer>
</div>
);
}
看下最后的效果图:
这样我们就实现了基于 Nextjs 中的 MF 远程模块复用。但是这种模式目前还有一些体验不是很好的地方:
- 跟前面一样,TS 的类型没法被识别,只能在 host 应用中的类型文件中声明远程模块的类型;
- 本地开发和调试体验不好,每次修改 remoteLib ,必须重新打包构建,然后重启 remoteLib 服务,效率低、开发体验也比较差。
当然是否有更加合适的开发模式,还需要再慢慢探索。
从广告的业务项目出发挖掘 MF 的使用场景
我们在开发 smart-ads v6.0.0 的过程中,前面在介绍模块共享演变史的时候有提到过,smart-ads 和 smartd-ads-admin 有几个共享的 UI 模块,以 Employee 模块为例。我们是通过创建一个 Monorepo--Veggie 的方式去维护这些通用的模块,开发完后发布到 NPM 上,然后每个项目再分别引入。
在开发过程中,我们遇到两个痛点:
- smart-ads 和 smartd-ads-admin 的 Antd Design(后面简称 antd) 版本不一致,然后 Veggie 也是新创建的仓库,所以 antd 的版本相对来说是比较新的。而 smartd-ads-admin 就相对来说比较旧一点,所以在 Veggie 中开发的组件,出现了同一个 antd 组件,API 不兼容的情况,所以导致在 smartd-ads-admin 中没法使用。这就需要升级 antd 的版本,但是可能带来一定的升级成本。虽然后面我们还是通过升级解决了这个问题,但是从这个问题中引发了我的思考:不光是 smartd-ads-admin 这个 case,就连其它的项目同样可能存在这种问题,这时候我们维护的组件,就可能出现只能在部分项目中使用。所以如何保证每个项目核心的依赖,例如 react、react-dom、antd等的版本一致就是一个需要思考的问题。
- smart-ads 从 v1.0.0 到 v6.0.0 已经历经了6个大版本,随着业务功能日益复杂,前端的模块也日益膨胀,我们本地开发启动和上线构建的速度也越来越慢。现在 smart-ads 本地启动要1分钟以上,然后 CI 的时候,装依赖、构建等如果因为网络波动所有时间加起来可能要十分钟以上,这就严重影响了开发效率、体验和上线的效率等。
对于问题一,我们可能可以通过制定规范,随时关注每个库的更新情况,及时更新。但是这样的缺点也很明显,随着项目越来越多,每个项目手动更新效率太低。
对于问题二,虽然可以通过完善 Webpack 配置进行一定的优化,但是因为 Webpack 的复杂性,其构建性能的提升都不太可能赶得上我们业务的膨胀速度。虽然社区现在也有新的构建工具:Vite 来解决开发体验问题,但是在大型项目的构建场景下,是否能满足所有的需求,这个还需要考证。
通过 MF 的方案,我们可以一定程度上解决以上的两个痛点。
先看一个图:
依赖 MF 的能力,我们可以把一些核心的依赖,例如 react、react-dom、antd,使用一个 remote 服务维护,然后每个项目分别引用这个服务导出的 library。我们只需要维护这个 remote 服务上依赖的版本,就能保证每个项目核心依赖的版本是一致的,而且升级的时候,也不用每个项目自己升级,大大提升了效率。完美解决了上面提到的痛点一。
因为将这些公共依赖的抽离,也使得每个项目之间构建的依赖减少,从而一定程度上优化了构建的时间,所以部分解决了痛点二。如果要做得更加彻底,我们可以为每个项目定制自己的 remote 服务,将大多数不怎么需要变化的依赖抽离出来,通过其它 remote library 构建上传到 CDN,这样每个项目在开发的时候需要构建的依赖就大大减少,从而也基本解决了痛点二。
下面我们沿着这个思路,简单的基于 react 实现一个 demo。
先看各应用的目录结构:
component-app 主要导出每个项目之间共用的组件,替代前面我们项目中的 Veggie。llib-app 主要管理各项目之间的核心依赖,它不做任何其它的工作,就维护核心的依赖版本。main-app 可以看做是我们的一个项目,消费 component-app 和 lib-app 导出的依赖。
对于 component-app 的配置,我们简单看一下,它跟之前我们的例子中的配置基本差不多:
module.exports = {
// 省略一些配置
plugins: [
new ModuleFederationPlugin({
name: "component_app",
filename: "remoteEntry.js",
exposes: {
"./Button": "./src/Button.jsx",
"./Dialog": "./src/Dialog.jsx",
"./Logo": "./src/Logo.jsx",
"./ToolTip": "./src/ToolTip.jsx",
},
remotes: {
"lib-app": "lib_app@http://localhost:3000/remoteEntry.js",
},
}),
],
}
就是导出一堆组件,这里还多了一个使用 lib-app 中的模块配置,这样保证我们在开发组件的时候核心依赖是和项目中的保持一致的,简单看一个组件实现:
import React from 'lib-app/react'
import { Button } from 'lib-app/antd'
import 'lib-app/antd/dist/antd.css'
export default function CustomButton(props) {
const type = props.type || 'primary'
return <Button type={type}>{type} Button</Button>
}
接着我们看下 lib-app 的配置:
module.exports = {
// 省略一些配置
plugins: [
new ModuleFederationPlugin({
name: 'lib_app',
filename: 'remoteEntry.js',
exposes: {
'./react': 'react',
'./react-dom': 'react-dom',
'./antd': 'antd',
'./antd/dist/antd.css': 'antd/dist/antd.css'
}
})
]
}
lib-app 服务只做核心依赖的维护,没有任何其它的代码。
最后再看下 main-app 的实现,先看 webpack.config.js:
module.exports = {
// 省略一些配置
plugins: [
new ModuleFederationPlugin({
name: "main_app",
remotes: {
"lib-app": "lib_app@http://localhost:3000/remoteEntry.js",
"component-app": "component_app@http://localhost:3001/remoteEntry.js",
},
}),
],
}
看下 index.js 中使用远程的模块:
import React from 'lib-app/react';
import Button from 'component-app/Button'
import Dialog from 'component-app/Dialog'
import ToolTip from "component-app/ToolTip"
function App () {
const [visible, setVisible] = useState(false)
return (
<div style={{ padding: '20px' }}>
<h1>Open Dev Tool And Focus On Network,checkout resources details</h1>
<p>
react、react-dom、antd js files hosted on <strong>lib-app</strong>
</p>
<p>
components hosted on <strong>component-app</strong>
</p>
<h4>Buttons:</h4>
<Button type="primary" />
<Button type="warning" />
<h4>Dialog:</h4>
<Button onClick={() => setVisible()}>click me to open Dialog</Button>
<Dialog
switchVisible={(visible) => setVisible(visible)}
visible={visible}
/>
<h4>hover me please!</h4>
<ToolTip content="hover me please" message="Hello,world!" />
</div>
)
}
export default App
最终效果:
这样就完成了整个 demo,实现难度还是不大的。
小结
前面我们用三个例子讲述了 MF 基于 React 的应用场景,这里我们总结一下。首先,我们能从例子中看到 MF 带来的远程模块共享的巨大优势,使得共享的模块从本地化开始像远程服务化的方式演进。而且,它也解决了模块共享带来的依赖复用问题。使得我们在设计前端应用架构的时候有了无限的想象力,从前面的例子中就可见一斑。
当然目前我觉得 MF 还存在一些问题,而且 MF 的使用也给前端应用的维护代码一定的压力:
- 首先,TypeScript 暂不支持从远程加载声明文件,导致我们在引入远程模块的时候,需要自己维护远程模块的类型声明;
- 其次,站在服务的角度来看,以第三个例子说明,当我们所有项目都共享同一个 lib-app,那么 lib-app 的可用性、健壮性就非常重要,如果因为误操作或者其它的原因导致服务不可用,就可能导致大量的项目挂掉或者部分功能不可用。而且在使用这种架构的时候,需要充分考虑 fallback 方案,这一块暂时还没有仔细的研究;
- 还有,就是如果把远程服务拆分得过细,也带来一定的维护成本。除了需要一定的人力进行业务开发之外,也需要保证 remote 服务及时跟新。而且服务拆分得越多,如果某次开发需要同时牵涉到几个服务,那么本地开发调试同样也是一个问题。
结尾
总的来说,MF 的出现,开启了 JS 模块复用的新时代,所以文章开篇提到 MF 是一个 game-changer。 虽然到目前为止,已经有大量的使用场景被挖掘,微前端、多项目直接远程的模块复用、Dynamic Route共享、Core Dependencies 分离等等。我仍然觉得还有可以深挖的使用场景,这需要我们在复杂的项目开发中去挖掘。
Reference
3.Webpack 5 Module Federation: A game-changer in JavaScript architecture
转载自:https://juejin.cn/post/7012990703714172964