webpack 5 module federation 实现微前端

1,445 阅读3分钟

前言

开发前提:在前段时间完成了一个项目的开发。但是近期又有一个相似的项目需求,与上一个项目的部分 UI 大致相同,部分组件和功能有所区别。那么为了复用组件,而不是简单的 copy,选择使用微前端来作为这个 monorepo 的技术框架。

在调研了几个微前端框架:icestarkqiankunwebpack 5 module federation后,个人认为,由于两个项目使用的技术栈都是 React + typescript,使用 webpack 5 提供的 module federation,就已经足够满足当前的需求,就没有必要应用到 icestarkqiankun 这么“重”的概念。

而且,相对来说,webpack应用更加广泛,有更多的应用实例来作参考,使用方法也比较简单。

开始学习

两个概念

在开始之前,需要先了解两个概念:remotehost

  • 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-appbuild 目录下的 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_appButton 组件

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 结果:

image.png

可以看到,我们的 Button 组件已经显示在页面中了,并且,组件的点击事件也成功绑定了

image.png

我们来看一下 main_app 的 js 资源请求

image.png 可以看到,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-appmain-app 放入到 lerna 生成的 packages 目录中,我们可以直接复制粘贴,但注意别把 node_modules 复制过来

此时的项目结构就变成了

image.png

然后在主目录下安装一下我们的依赖 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,
+     }
+    }],
});

源码

github.com/wbh13285517…