webpack5 模块联邦(Module Federation)
Webpack 5 增加了一个新的功能 “模块联邦”,它允许多个 webpack 构建一起工作。 从运行时的角度来看,多个构建的模块将表现得像一个巨大的连接模块图。 从开发者的角度来看,模块可以从指定的远程构建中导入,并以最小的限制来使用。
多个独立的构建可以组成一个应用程序,这些独立的构建之间不应该存在依赖关系,因此可以单独开发和部署它们。这通常被称作微前端,但并不仅限于此。
Webpack5 模块联邦让 Webpack 达到了线上 Runtime 的效果,让代码直接在项目间利用 CDN 直接共享,不再需要本地安装 Npm 包、构建再发布了! 我们知道 Webpack 可以通过 DLL 或者 Externals 做代码共享时 Common Chunk, 但不同应用和项目间这个任务就变得困难了,我们几乎无法在项目之间做到按需热插拔。
模块联邦可以将一个应用的包应用于另一个应用,同时具备整体应用一起打包的公共依赖抽取能力。
实践效果
App2 如下图所示:
将 App2 引入 App1 中:
Vite + React + TypeScript + Webpack5 实践
项目搭建
我们用 Vite 作为脚手架新建两个 React + Ts 项目,分别命名为 mainApp 与 otherApp:
npm init @vitejs/app
安装webpack的两个基本项:
npm i webpack webpack-cli --save-dev
安装webpack:
npm i -D webpack
安装webpack服务器 webpack-dev-server,让启动更方便:
npm i --save-dev webpack-dev-server
自动创建html文件 html-webpack-plugin:
npm i --save-dev html-webpack-plugin
清除无用文件 clean-webpack-plugin,将每次打包多余的文件删除:
npm i --save-dev clean-webpack-plugin
样式编译loader插件:
npm i --save-dev style-loader css-loader // css相关loader
npm i --save-dev node-sass sass-loader // scss 相关loader
npm i --save-dev file-loader url-loader // 加载其他文件,比如图片,字体
( 我的 node-sass 装不上,干脆不装了😕
安装babel:
npm i --save-dev @babel/core @babel/cli @babel/preset-env @babel/preset-react @babel/plugin-proposal-class-properties
npm i --save @babel/polyfill
npm i --save-dev babel-loader
安装TypeScript加载器:
npm install ts-loader --save-dev
项目配置
将两个项目的 package.json 文件中的 scripts 配置更改为:
"scripts": {
"start": "webpack-dev-server --open --mode production",
"dev": "webpack --mode development& webpack-dev-server --open --mode development",
"build": "webpack --mode production",
"preview": "vite preview"
},
在 src 目录下创建 bootstrap.tsx 文件,并将入口文件的内容放到里面,然后将 bootstrap 引入到入口文件中:
bootstrap.tsx
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
)
index.ts
import('./bootstrap')
这时候会有以下报错:
关于这个问题,我们将 tsconfig.json 中 compilerOptions.isolatedModules 改为 false 即可。
然后记得将 index.html 中引入的入口文件改为 index.ts:
给两个项目分别在根目录下新建 webpack.config.js 配置文件,内容如下:
const path = require('path')
const webpack = require('webpack')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const ModuleFederationPlugin = require("webpack/lib/container/ModuleFederationPlugin");
const isDev = process.env.NODE_ENV === 'development'
module.exports = {
mode: 'development',
devtool: isDev ? false : 'eval-cheap-module-source-map',
entry: './src/index.ts', // 配置项目入口文件路径
output: {
filename: 'main.js', // 项目出口文件名称
path: path.resolve(__dirname, 'dist') // 将项目打包出口文件生成至根目录下的 dist 文件夹中
},
devServer: {
port: 4001, // APP1的端口设为4001, APP2的端口设为4002
hot: true
},
resolve: {
alias: {
"@": path.resolve(__dirname, 'src')
},
extensions: ['.ts', '.tsx', '.js', '.jsx']
},
performance: {
hints: "warning", // 枚举
maxAssetSize: 30000000, // 整数类型(以字节为单位)
maxEntrypointSize: 50000000, // 整数类型(以字节为单位)
assetFilter: function(assetFilename) {
// 提供资源文件名的断言函数
return assetFilename.endsWith('.css') || assetFilename.endsWith('.js');
}
},
module: {
rules: [{
test: /\.css$/,
use: ['style-loader', 'css-loader']
}, {
test: /\.scss$/,
use: ['style-loader', 'css-loader', 'sass-loader']
}, {
test: /\.(png|svg|jpg|gif)$/,
loader: 'url-loader',
options: {
limit: 10000,
name: 'img/[name].[hash:7].[ext]'
}
}, {
test: /\.(js|ts)$/,
use: 'babel-loader',
exclude: /node_modules/
},
{ test: /\.tsx?$/,
loader: "ts-loader"
}]
},
plugins: [
new webpack.HotModuleReplacementPlugin(),
new HtmlWebpackPlugin({
template: './src/template/index.html' // 项目html入口文件路径
}),
// 模块联邦
new ModuleFederationPlugin({
// name: 必须, 唯一 ID, 作为输出的模块名, 使用的时通过 ${name}/${expose} 的方式使用
name: '',
// library: 必须, 其中这里的 name 为作为 umd 的 name
// library: { type: "var", name: "mainApp" },
// 调用方引用的文件名称
filename: '',
// remotes: 可选, 表示作为 Host 时, 去消费哪些 Remote
remotes: {},
// exposes: 可选, 表示作为 Remote 时, export 哪些属性被消费
exposes: {},
// shared: 可选, 优先用 Host 的依赖, 如果 Host 没有, 再用自己的
shared: []
})
]
}
修改 otherApp
将 otherApp 项目中的其它多余内容删除,如下:
App.tsx
import './App.css'
function App() {
return (
<div className="App">
我是App2
</div>
)
}
export default App
运行结果如图所示:
配置 otherApp 的模块联邦,将 App.tsx 作为模块 exposes:
webpack.config.js
// 模块联邦
new ModuleFederationPlugin({
// name: 必须, 唯一 ID, 作为输出的模块名, 使用的时通过 ${name}/${expose} 的方式使用
name: 'app2',
// library: 必须, 其中这里的 name 为作为 umd 的 name
library: { type: "var", name: "app2" },
// 调用方引用的文件名称
filename: 'app2.js',
// remotes: 可选, 表示作为 Host 时, 去消费哪些 Remote
remotes: {},
// exposes: 可选, 表示作为 Remote 时, export 哪些属性被消费
exposes: {
// 模块名称
'./APP2' : './src/App.tsx'
},
// shared: 可选, 优先用 Host 的依赖, 如果 Host 没有, 再用自己的
shared: []
})
修改 mainApp
配置 mainApp 的模块联邦,将 otherApp 所 exposes 的 App.tsx 模块引入进来 :
webpack.config.js
// 模块联邦
new ModuleFederationPlugin({
// name: 必须, 唯一 ID, 作为输出的模块名, 使用的时通过 ${name}/${expose} 的方式使用
name: 'app1',
// library: 必须, 其中这里的 name 为作为 umd 的 name
// library: { type: "var", name: "app1" },
// 调用方引用的文件名称
filename: 'app1.js',
// remotes: 可选, 表示作为 Host 时, 去消费哪些 Remote
remotes: {
// '导入别名':'远程应用名称/远程应用地址/导入文件的名称'
app2: `app2@${'http://localhost:4002/app2.js'}`
},
// exposes: 可选, 表示作为 Remote 时, export 哪些属性被消费
exposes: {},
// shared: 可选, 优先用 Host 的依赖, 如果 Host 没有, 再用自己的
shared: []
})
在 App.tsx 中引入:
import { useState } from 'react'
import logo from './logo.svg'
import './App.css'
// 引入 app2 模块作为组件
import RemoteApp from 'app2/APP2'
function App() {
const [count, setCount] = useState(0)
return (
<div className="App">
<header className="App-header">
<img src={logo} className="App-logo" alt="logo" />
<p>Hello App1! This is Vite + React!</p>
{/* 引用该 app2 组件 */}
<RemoteApp />
<p>
<button type="button" onClick={() => setCount((count) => count + 1)}>
count is: {count}
</button>
</p>
<p>
Edit <code>App.tsx</code> and save to test HMR updates.
</p>
<p>
<a
className="App-link"
href="https://reactjs.org"
target="_blank"
rel="noopener noreferrer"
>
Learn React
</a>
{' | '}
<a
className="App-link"
href="https://vitejs.dev/guide/features.html"
target="_blank"
rel="noopener noreferrer"
>
Vite Docs
</a>
</p>
</header>
</div>
)
}
export default App
在引入过程中,我的编译器会有以下报错:
关于这个问题,ts 在引入远程组件时是需要定义类型的,于是我在 src 目录下新建了一个 global.d.ts 文件:
global.d.ts
declare module 'app2/APP2'
就可以进行引入了。
Vite + React + TypeScript 实践
刚刚我们是用 Vite 作为构建工具构建了项目的主体结构,但最终的运行配置还是用的 Webpack,而 Vite 本身也有用于支持模块联邦的插件:
vite-plugin-federation 是一款为 vite 提供,用于支持多个独立构建的应用可以将自己的部分能力作为组件提供出来,组成一个应用程序。他们之间不存在相互依赖,可以进行独立的开发和部署。灵感来源于 webpack 5 提供的 Module Federation 特性。
我们来试试用这个插件,能不能做到模块联邦的效果。
项目搭建
我们用 Vite 作为脚手架新建两个 React + Ts 项目,这次分别命名为 app1 与 app2:
npm init @vitejs/app
项目配置
将两个项目的 package.json 文件中的 scripts 配置更改为:
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"serve": "vite preview",
"preview": "vite preview"
},
在 src 目录下创建 bootstrap.tsx 文件,并将入口文件的内容放到里面,然后将 bootstrap 引入到入口文件中:
bootstrap.tsx
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
)
index.ts
import('./bootstrap')
这时候会有以下报错:
关于这个问题,我们将 tsconfig.json 中 compilerOptions.isolatedModules 改为 false 即可。
然后记得将 index.html 中引入的入口文件改为 index.ts:
安装 vite-plugin-federation:
npm i @originjs/vite-plugin-federation
项目构建完成,我们进行初步的 vite 配置:
vite.config.ts
import {defineConfig} from 'vite'
import {join} from 'path'
import react from '@vitejs/plugin-react'
import federation from '@originjs/vite-plugin-federation' // 引入 vite-plugin-federation
function resolve(dir) {
return join(__dirname, dir)
}
// https://vitejs.dev/config/
export default defineConfig({
root: process.cwd(), // 入口主文件项目的 index.html 当前路径开始
// 配置服务器
server: {
https: true,
cors: true, // 默认启用并允许任何源
host: true, // 在 dev 场景下尽量显示声明 ip、port,防止`vite`启动时ip、port自动获取机制导致不准确的问题
port: 5001, // 端口, APP1的端口设为5001, APP2的端口设为5002
},
// 打包配置
build: {
// assetsInlineLimit: 40960, // 40kb
// outDir: 'dist', // 指定输出路径(相对于项目根目录)
// assetsDir: 'assets', // 指定生成静态资源的存放路径(相对于build.outDir)
polyfillModulePreload: false,
assetsInlineLimit: 40960,
target: 'esnext',
minify: false,
cssCodeSplit: false,
rollupOptions: {
output: {
minifyInternalExports: false
}
}
},
// 插件配置
plugins: [
react(),
federation({
name: '', // 远程模块名称
filename: '', // 远程模块入口文件,与本地模块中`remotes`配置相对应
remotes: {},
exposes: {},
shared: [] // 对外提供的组件所依赖的第三方依赖
})
],
resolve: {
alias: {
'@': resolve('src'),
},
extensions: ['.ts', '.tsx', '.js', '.jsx'],
},
})
此时,我们可以看到 root: process.cwd()
这个入口配置报错了,官网说这个是缺少了 node 的类型定义,运行它提示的命令的就行了: npm i --save-dev @types/node
。
修改 app2
将 app2 项目中的其它多余内容删除,如下:
App.tsx
import './App.css'
function App() {
return (
<div className="App">
我是App2
</div>
)
}
export default App
运行结果如图所示:
配置 app2 的模块联邦,将 App.tsx 作为模块 exposes:
vite.config.ts
// 插件配置
plugins: [
react(),
federation({
name: 'app2', // 远程模块名称
filename: 'app2.js', // 远程模块入口文件,与本地模块中`remotes`配置相对应
exposes: {
'./APP2': './src/App.tsx', // 组件名称及其对应文件
},
shared: [] // 对外提供的组件所依赖的第三方依赖
})
],
修改 app1
配置 app1 的模块联邦,将 app2 所 exposes 的 App.tsx 模块引入进来 :
vite.config.ts
// 插件配置
plugins: [
react(),
federation({
name: 'app1', // 远程模块名称,一个服务既可以作为本地模块使用远程模块组件,可以作为远程模块,对外提供组件
filename: 'app1.js', // 远程模块入口文件,与本地模块中`remotes`配置相对应
remotes: {
app2: 'https://localhost:4174/assets/app2.js', // 远程模块入口文件的网络地址,用于获取远程模块的`remoteEntry.js`来加载组件
},
shared: [], // 远程模块组件使用的第三方依赖,如果本地有可以优先使用本地;在 dev 模式下尽量在本地引用这些第三方依赖,防止第三方组件在 dev 和打包模式下不同导致的问题。
}),
],
在 App.tsx 中引入:
import { useState } from 'react'
import logo from './logo.svg'
import './App.css'
// 引入 app2 模块作为组件
import RemoteApp from 'app2/APP2'
function App() {
const [count, setCount] = useState(0)
return (
<div className="App">
<header className="App-header">
<img src={logo} className="App-logo" alt="logo" />
<p>Hello App1! This is Vite + React!</p>
{/* 引用该 app2 组件 */}
<RemoteApp />
<p>
<button type="button" onClick={() => setCount((count) => count + 1)}>
count is: {count}
</button>
</p>
<p>
Edit <code>App.tsx</code> and save to test HMR updates.
</p>
<p>
<a
className="App-link"
href="https://reactjs.org"
target="_blank"
rel="noopener noreferrer"
>
Learn React
</a>
{' | '}
<a
className="App-link"
href="https://vitejs.dev/guide/features.html"
target="_blank"
rel="noopener noreferrer"
>
Vite Docs
</a>
</p>
</header>
</div>
)
}
export default App
在引入过程中,我的编译器会有以下报错:
关于这个问题,ts 在引入远程组件时是需要定义类型的,于是我在 src 目录下新建了一个 global.d.ts 文件:
global.d.ts
declare module 'app2/APP2'
我们将两个项目进行打包,开启服务之后,就可以看到引入效果了。
当两个项目同时打包开启服务时,APP1 中 dist/main.js 包含了 APP2 的模块,它是通过 CDN 引入的,运行即可看到模块联邦使用成功!验证了模块联邦可以将一个应用的包应用于另一个应用,同时具备整体应用一起打包的公共依赖抽取的能力。
总结
试想一下,你有一个组件包通过 npm 发布后,你的10个业务项目引用这个组件包。当这个组件包更新了版本,你的10个项目想要使用最新功能就必须一一升级版本、编译打包、部署,这很繁琐。但是模块联邦让组件包利用CDN的方式共享给其他项目,这样一来,当你到组件包更新了,你的10个项目中的组件也自然更新了。