前言
开发前提:在前段时间完成了一个项目的开发。但是近期又有一个相似的项目需求,与上一个项目的部分 UI 大致相同,部分组件和功能有所区别。那么为了复用组件,而不是简单的 copy,选择使用微前端来作为这个 monorepo 的技术框架。
在调研了几个微前端框架:icestark、qiankun、webpack 5 module federation后,个人认为,由于两个项目使用的技术栈都是 React + typescript,使用 webpack 5 提供的 module federation,就已经足够满足当前的需求,就没有必要应用到 icestark、qiankun 这么“重”的概念。
而且,相对来说,webpack应用更加广泛,有更多的应用实例来作参考,使用方法也比较简单。
开始学习
两个概念
在开始之前,需要先了解两个概念:remote 和 host
- remote:可以被别的模块使用、依赖的模块,可以理解为
商家 - host:使用别的模块,依赖别的模块的模块,可以理解为
消费者一个模块,可以是remote,也可以是host,也可以即是remote,也是host
创建实例
mkdir module-federation-demo
cd ./module-federation-demo
这里使用自己写的脚手架工具来创建项目的基础
yarn global add frontend-scaffold-cli
fsc-cli init component-app
fsc-cli init main-app
修改 component_app
然后分别安装各自的依赖后,首先先更改一下 component-app 的 build 目录下的 webpack.base.config.js
//...
const { ModuleFederationPlugin } = require('webpack').container;
const rootDir = process.cwd()
module.exports = {
output: {
//...
publicPath: http://localhost:8080/
}
//...
plugins: [
//...
new ModuleFederationPlugin({
name: 'component_app',
library: { type: "var", name: "component_app" },
filename: 'remoteEntry.js',
exposes: {
"./Button": path.resolve(rootDir, 'src/components/Button')
},
shared: [{
react: {
singleton: true,
},
'react-dom': {
singleton: true,
}
}],
}),
]
}
这里我们在 component-app/src/components 创建一个 Button.tsx 文件作为使用测试
import React from 'react'
const Button: React.FC = () => {
const handleClick = () => {
console.log('click click')
}
return (
<button onClick={handleClick}> hello module federation </button>
)
}
export default Button
在 component-app/src 目录下创建 bootstrap.tsx,并把项目的入口文件写入:
import React from 'react'
import ReactDOM from 'react-dom'
import Apps from './Apps'
import './css/output.css'
const rootId = 'root'
const rootElement = document.getElementById(rootId)
if (!rootElement) {
throw new Error(`Unable to find element with id '${rootId}'`)
}
ReactDOM.render(<Apps />, rootElement)
修改 component-app 的入口文件 index.tsx
import('./bootstrap');
修改 main_app
修改 build/webpack.base.config.js
//...
const { ModuleFederationPlugin } = require('webpack').container;
module.exports = {
output: {
//...
publicPath: 'http://localhost:8081/',
}
//...
plugins: [
//...
new ModuleFederationPlugin({
name: 'main_app',
remotes: {
'component-app': "component_app@http://localhost:8080/remoteEntry.js"
},
shared: [{
react: {
singleton: true,
},
'react-dom': {
singleton: true,
}
}],
}),
]
}
和 component-app 一样,修改入口文件
//bootstrap.tsx
import React from 'react'
import ReactDOM from 'react-dom'
import Apps from './Apps'
import './css/output.css'
const rootId = 'root'
const rootElement = document.getElementById(rootId)
if (!rootElement) {
throw new Error(`Unable to find element with id '${rootId}'`)
}
ReactDOM.render(<Apps />, rootElement)
//index.tsx
import ('./bootstrap')
测试效果
我们在 App.tsx 文件引入我们在 component_app 的 Button 组件
import React from 'react'
import Button from 'component-app/Button'
const Apps = (): React.ReactElement => {
return (
<div>
<Button/>
</div>
)
}
export default Apps
然后,先运行我们的 component_app,再运行 main_app
结果:
可以看到,我们的 Button 组件已经显示在页面中了,并且,组件的点击事件也成功绑定了
我们来看一下 main_app 的 js 资源请求
可以看到,
component_app 生成的 remoteEntry.js 文件也已经成功引入了
项目优化
lerna 引入
如果每次开发,都需要先 cd ./component-app,然后 yarn start 运行,再进入其他目录,重复同样的步骤,那这实在太浪费时间了,这里我们引入 lerna 来帮我们完成多包管理。
首先全局安装并引入
yarn global add lerna
lerna init
然后修改 lerna.json
{
"command": {
"bootstrap": {
/* 当多个包有相同依赖时会提升到顶层的node_modules,避免重复安装,减少体积 */
"hoist": true,
/* 在bootstrap期间,将参数传递给 npm install */
"npmClientArgs": ["--no-package-lock", "--no-ci"]
}
},
/* 使用 yarn 作为包管理工具 */
"npmClient": "yarn",
"packages": [
"packages/*"
],
/* 使用 yarn 的工作区,在 package.json 中指定 */
"useWorkspaces": true,
"version": "0.0.0"
}
修改 package.json
{
//...
"workspaces": [
"packages/*"
],
}
项目迁移
这里我们需要把我们的 component-app 和 main-app 放入到 lerna 生成的 packages 目录中,我们可以直接复制粘贴,但注意别把 node_modules 复制过来
此时的项目结构就变成了
然后在主目录下安装一下我们的依赖 yarn install
在主目录的 package.json 中,修改 scripts
{
"scripts": {
// 单独start component-app
"start:component": "lerna exec --scope component-app -- yarn dev",
// 单独 start main-app
"start:main": "lerna exec --scope main-app -- yarn dev",
// 同时打开 component-app 和 main-app
"start": "npx lerna run dev --parallel"
},
}
踩坑记录
Module "./Button" does not exist in container
new ModuleFederationPlugin({
exposes: {
- 'Button': './src/Button'
+ './Button':'./src/Button'
}
});
Library name base (component-app) must be a valid identifier when using a var declaring library type....
new ModuleFederationPlugin({
- library: { type: "var", name: "component-app" },
+ library: { type: "var", name: "component_app" },
});
React throws the "Invalid hook call" error.
new ModuleFederationPlugin({
- shared: ['react', 'react-dom'],
+ shared: [{
+ react: {
+ singleton: true,
+ },
+ 'react-dom': {
+ singleton: true,
+ }
+ }],
});