警告:由於該功能在撰寫本文時仍處於 beta 階段, 範例可能因更新而無法正常運作,建議實作時搭配參考官方文件或範例。本文旨在提供一個基本的練習,以提供那些跟我一樣無法直接消化抽象術語的人一點點幫助。
原只想單純的翻譯兼實作 Webpack 5 Module Federation: A game-changer in JavaScript architecturey 一文,但該文的介紹對於比較少自己設定 Webpack 的人來說很多細節可能會讓您學習起來非常挫折,加上部分範例程式碼已經和最新的 Github 範例不同了(當然應該參考 Github 才對)。但閱讀起來不太親民,能抓到概念,但實作上卻一直踩雷…。
因此有了這篇比較像是簡介和資料彙整的文章,建議對於只想概略知道這個 Module Federation 到底是什麼的可以看 Jack Herrington 的介紹影片 。
搭配本篇簡單的實作在回頭看上面提到的文章會比較容易理解。
Module Federation 是什麼?
基本上它就是一個 JavaScript 架構。簡單說它可以讓一個 JavaScript 應用程式動態的從另一個應用程式/專案(另一個 Webpack 建置的 Bundle)匯入程式碼/元件。
為什麼要這麼做?
Jack Herrington 提到:想像一下我們有兩個專案,A 專案有個輪播元件,我們想要在 B 專案使用。這個時候除了抽成函式庫等作法之外,Module Federation 提供了一種更方便有效率的解決方案。
通常我們使用 Webpack 建置 Bundle 也就是 Webpack 幫我們從 src
目錄把檔案編譯輸出到 dist
的 main.js
。產出的 Bundle 通常就是彙整整個 src
JavaScript 。
當我們加入越多的程式碼,最終 main.js
就會越大。然後使用者會在他們的瀏覽器載入這個檔案,越大的檔案表示會需要更多的時間載入。因此我們會計較 Bundle 的大小,但同時我們又無法避免加入新功能。
這個時候,有幾種解決辦法,例如把單一的 main.js
拆成多個較小的檔案(Chunk)- 即所謂的 Code Splitting。
其中一個作法是定義多個 entry
,但缺點是如果以頁面為單位,很多時候我們會在不同的 Chunk 使用相同的模組,這個時候這些 Chunk 裡的模組就會重複導致 Chunk 變大。
因此會利用 import()
語法動態載入 JS 例如:
function go() {
import('./module.js')
.then(module => module.initialize())
.catch(error => console.log(error));
}
雖然延遲載入一樣會建立新的 Chunk,不過我們就可以動態的依據需求載入。
到此,我們可以拆 Chunk,但它們都在同一個專案。如果另一個專案如果需要使用,那還是要部署重複的程式碼(安裝一樣的函式庫)。
假如我們不只能拆 Chunk 還外加可以分佈到不同專案並從不同的專案引用呢?於是 Module Federation 出現了。
下面就讓我們直接通過實作來看看到底怎麼回事
建立專案 A
$ mkdir app_a
$ cd app_a
$ npm init -y
$ npm i @babel/core @babel/preset-react babel-loader html-webpack-plugin webpack@next webpack-cli webpack-dev-server -D
$ npm i react react-dom
編輯 package.json
補上 npm start
的指令
// package.json
{
"name": "app_a",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"start": "webpack-dev-server"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"@babel/core": "^7.11.6",
"@babel/preset-react": "^7.10.4",
"babel-loader": "^8.1.0",
"html-webpack-plugin": "^4.4.1",
"webpack": "^5.0.0-beta.29",
"webpack-cli": "^3.3.12",
"webpack-dev-server": "^3.11.0"
},
"dependencies": {
"react": "^16.13.1",
"react-dom": "^16.13.1"
}
}
新增 webpack.config.js
const HtmlWebpackPlugin = require('html-webpack-plugin');
const { ModuleFederationPlugin } = require('webpack').container;
const deps = require('./package.json').dependencies;module.exports = {
mode: 'development',
entry: './src/index.js',
devServer: {
port: 3000,
},
output: {
publicPath: "http://localhost:3000/",
},
module: {
rules: [
{
test: /\.jsx?$/,
loader: 'babel-loader',
exclude: /node_modules/,
options: {
presets: ['@babel/preset-react'],
},
},
],
},
plugins: [
new ModuleFederationPlugin({
name: 'app_a',
library: {
type: 'var',
name: 'app_a',
},
remotes: {
app_b: 'app_b',
},
shared: {
...deps,
react: {
eager: true,
},
'react-dom': {
eager: true,
},
},
}),
new HtmlWebpackPlugin({
template: './src/index.html',
}),
],
};
新增 src/index.js
import React from 'react';
import { render } from 'react-dom';const App = () => (
<div>
Hello, App A
</div>
);render(
<App />,
document.getElementById('root'),
);
新增 src/index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>App A</title>
</head>
<body>
<div id="root"></div>
</body>
</html>
到這邊我們使用 Webpack 設定了一個很基本的 React 專案。接著,您可以先執行 npm start
測試這個簡易的專案。
錯誤排除 — Uncaught Error: Shared module is not available for eager consumption
如果過程中您遇到了上述錯誤,請參考使用 bootstrap.js
的方式或加入 eager: true
的設定。該方法詳細紀錄在官方文件。
建立第二個專案 B
差不多的步驟建立 app_b
,您可以複製 A 專案的 package.json
和 webpack.config.js
微調之後 npm install
webpack.config.js
注意 port
和名稱的部分,畢竟是要模擬從另一個專案讀取元件來使用,所以會使用不同的 port
。(A: 3000, B: 3001)。
const HtmlWebpackPlugin = require('html-webpack-plugin');
const { ModuleFederationPlugin } = require('webpack').container;
const deps = require('./package.json').dependencies;module.exports = {
mode: 'development',
entry: './src/index.js',
devServer: {
port: 3001,
},
output: {
publicPath: "http://localhost:3001/",
},
module: {
rules: [
{
test: /\.jsx?$/,
loader: 'babel-loader',
exclude: /node_modules/,
options: {
presets: ['@babel/preset-react'],
},
},
],
},
plugins: [
new ModuleFederationPlugin({
name: 'app_b',
library: {
type: 'var',
name: 'app_b',
},
exposes: {
'./Button': './src/components/Button',
},
shared: {
...deps,
react: {
eager: true,
},
'react-dom': {
eager: true,
},
},
}),
new HtmlWebpackPlugin({
template: './src/index.html',
}),
],
};
新增 src/components/Button.js
這裡我們非常簡單的建立一個元件 Button
主要是要示範從 A 專案讀取這個元件來使用。
import React from 'react';const Button = (props) => (
<button>
{props.children}
</button>
);export default Button;
使用其他專案的元件
回到 app_a
專案,在使用其他專案元件之前我們需要先在 index.html
加入。
<script src="http://localhost:3001/app_b.js"></script>
不難理解其實就是 app_b
專案的 Bundle 但絕不只是另一個 entry point 那麼單純,現在我們只要先記住沒那麼單純就好,接著就可以在 A 專案的 index.js
下使用 app_b
專案中的元件了。
import React from 'react';
import { render } from 'react-dom';const Button = React.lazy(() => import('app_b/Button'));const App = () => (
<div>
Hello, App A
<div>
<React.Suspense fallback='Loading...'>
<Button>The Button from app_b</Button>
</React.Suspense>
</div>
</div>
);render(
<App />,
document.getElementById('root'),
);
錯誤排除 — Uncaught ChunkLoadError: Loading chunk [src_components_Button_js] failed.
由於我們使用 webpack-dev-server
所以如果您對 webpack.config.js
進行了其他調整,請記得 publicPath
一定要加。
// app_b 的 webpack.config.js
output: {
publicPath: "http://localhost:3001/",
},
錯誤排除 — Uncaught TypeError: fn is not a function
另外如果您遇到這個錯誤,很有可能是您參考的文章 webpack.config.js
少了下面的設定
library: {
type: 'var',
name: 'app_a',
},
到此我們已經完成了非常簡單示範,是該來說一下關於那個陌生的 ModuleFederationPlugin
了。
ModuleFederationPlugin
不難明白要使用 Module Federation 主要就是靠 ModuleFederationPlugin
這個套件和設定。專案 A 和 B 的 webpack.config.js
都有使用但是扮演的角色不同 。
差異是 remotes
和 exposes
。remotes
設定了讀取的來源,當然您還是需要先載入對方的 Bundle ,而 exposes
則是提供的元件或其他程式碼。必須強調一點 - 到此都只是概略的實作好讓您有些基本概念,實務上您肯定需要在詳讀官方文件。最後讓我們簡單的介紹比較常出現的術語,希望可以對您後續的閱讀學習有些微的幫助。
- host: 上面範例來說,專案 A 就扮演 host 的角色,確切的說被頁面載入並初始化的 Webpack Bundle 就是 host。
- remote: 專案 B 就扮演 remote,也就是其他的 Webpack Bundle,提供別人載入使用的部分
- bi-directional host: 當一個 Webpack Bundle 同時作為 host 和 remote
資源
最後附上踩雷過程中參考的資料