5个例子带你入门 Webpack5 模块联邦 Module Federation(上篇)

前言

模块联邦 Module Federations 是Webpack5新加的一个概念,官方介绍是:

一个应用可以由多个独立的构建组成,这些构建彼此独立没有依赖关系,他们可以独立开发、部署。这就是常被认为的微前端,但不局限于此。
多个独立的构建可以组成一个应用程序,这些独立的构建之间不应该存在依赖关系,因此可以单独开发和部署它们。
  • 多个应用:比如一个主应用,多个子应用;
  • 独立开发:各应用代码是独立的,可以理解为每个应用都有各自的git repository代码仓库;
  • 独立部署:比如主应用部署到host1上,子应用部署到host2上,可以分别用CICD关联各自的repository独立部署;
  • 没有依赖关系:即使子应用不部署,或者子服务挂掉了,也不会影响主应用功能。
    • 注意下,主应用不依赖子应用,但子应用还是得依赖主应用的,因为在某些情况下,子应用开发时得按主应用的规则进行开发,否则子应用会失效,甚至会影响主应用。

它能做什么

听起来比较像微前端、微服务的概念,比较类似,但具体使用场景不一样,这俩之间的差别会在之后详细介绍下。模块联邦更像是主应用可以动态请求加载远程应用模块的概念。

比如有一个功能比较完整、或者已经上线的产品,然后有客户用的时候想再原有项目里自定制一些需求,比如在主页页面顶部加个滚动图片和文字介绍,再比如想在主导航里加个新导航,点击进去可以展示新的业务功能。但是这种需求只有这个客户需要,然后客户还想不跟随产品的release计划走,想在任何时间点单独部署,还不影响产品功能。

这个需求用模块联邦就很适合了,产品就是主应用,然后再为客户加个子应用,产品增加代码支持子应用扩展功能,并且把自定制功能实现在子应用代码里,然后产品release部署产品功能,子应用可以随时部署单独服务,再加个环境变量开关单独为这一个客户开启子应用服务,这样客户就能看到自定制的功能。

下面就用5个例子,带你入门 Webpack5 模块联邦 Module Federation 的真实使用场景,包括实现动态添加Tab、动态导航路由、共享Common控件等需求,实现在原有项目里自定制业务功能。

源码:github.com/markz-demo/...

参考资料

例子1:动态添加Tab

比如有个例子,见下图,主应用里有个Tab标签页,默认有三个Tab,现在要求把子应用里配置的title和content组件,动态的加到新Tab里,比如有一个名为Sub1的子应用,就会如图追加到Tab中,有多个子应用就追加多个,而且还支持子应用卸载,即比如把Sub1子应用停掉,这里就隐藏对应Tab标签。

下面只是展示了关键code,具体例子详见源码:github.com/markz-demo/...

主应用切图

为了方便,也为了展示模块联邦的shared效果,这里引用了Antd组件库。

jsx 复制代码
// main/src/Home.jsx
import React, { useEffect, useState } from 'react';
import { Tabs } from 'antd';

export default function Home() {
    const [type, setType] = useState('1')
    const [items, setItems] = useState([
        { label: 'Tab 1', key: '1', children: <TabContent index={1} /> },
        { label: 'Tab 2', key: '2', children: <TabContent index={2} /> },
        { label: 'Tab 3', key: '3', children: <TabContent index={3} /> },
    ])
    return (
        <div>
            <div>Main Home</div>
            <Tabs activeKey={type} onChange={setType} items={items}></Tabs>
        </div>
    )
}

function TabContent({ index }) {
    ...
}

定义了3个默认Tabs,以及Content组件。

添加子应用

子应用其实就是另一个服务里的一个静态js,js里export一个组件或者对象,然后在主应用里引用。从需求上看,需要一个title(tab名字),以及content组件,之后可能还需要定义order(tab顺序),颜色啊等等,所以export一个对象比较合理。

jsx 复制代码
// sub1/src/tab.jsx
import React, { useEffect, useState } from 'react';
import { Button } from 'antd';

function Sub1TabContent() {
    ...
}

export default {
    label: 'Sub1 tab', // 对应Tab title
    key: 'sub1', // 对应Tab控件的唯一key
    children: <Sub1TabContent />, // 对应content组件
}

webpack

主应用:

js 复制代码
// main/webpack.config.js|webpack.dev.js
const HtmlWebpackPlugin = require('html-webpack-plugin');
const { ModuleFederationPlugin } = require('webpack').container;

module.exports = {
    ...
    plugins: [
        new HtmlWebpackPlugin(), // html是主应用里渲染的,需要编译生成html
        new ModuleFederationPlugin({
            name: 'main_app', // 主应用名字
            remotes: {
                'sub1': 'sub1_app@http://localhost:3001/sub1.js', // <import时的别名>: <子应用名字@子应用对应文件路径>
            },
            shared: { // 第三方资源共享
                'react': { singleton: true },
                'react-dom': { singleton: true },
                'react-router-dom': { singleton: true },
                'antd': { singleton: true }
            },
        })
    ],

    devServer: {
        port: 3000, // 主应用端口3000,url是http://localhost:3000
    },
};

子应用:

js 复制代码
// sub1/webpack.config.js|webpack.dev.js
const path = require('path');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const { ModuleFederationPlugin } = require('webpack').container;

module.exports = {
    plugins: [
        new MiniCssExtractPlugin({
            filename: "[id].[contenthash:8].css",
        }),
        new ModuleFederationPlugin({
            name: 'sub1_app', // 子应用名字,对应主应用中@前面的名字
            filename: 'sub1.js', // 编译生成的文件名,对应主应用中@后面的文件名
            exposes: {
                './tab': './src/tab.jsx', // <被引用时的subpath>: <对应tab.jsx文件路径>
            },
            shared: { // 第三方资源共享
                'react': { singleton: true },
                'react-dom': { singleton: true },
                'react-router-dom': { singleton: true },
                'antd': { singleton: true }
            },
        })
    ],

    devServer: {
        port: 3001, // 子应用端口3001,url是http://localhost:3001
    },
};

主应用引用:

jsx 复制代码
import sub1Tab from 'sub1/tab'; // import <对应对象变量名> from <import时的别名>/<被引用时的subpath>
...
const [items, setItems] = useState([
    { label: 'Tab 1', key: '1', children: <TabContent index={1} /> },
    { label: 'Tab 2', key: '2', children: <TabContent index={2} /> },
    { label: 'Tab 3', key: '3', children: <TabContent index={3} /> },
    sub1Tab, // 值为子应用的export default
    // 如果有更多的子应用,可以再后面追加
])

结果

例子2:动态import

上面例子有个问题,如果子应用没有启动,主应用运行时会抛错。

原因是找不到子应用,这种情况可以改成动态import子应用,放在useEffect里动态import,也可以选择在合适时机引用,好处是只在需要展示子应用时加载子应用资源,主应用页面加载时并不需要加载,提高效率。

但是这样console里依然会抛错,但是由于是异步的,所以不会影响主线。这时可以对import加catch进行异常处理。

jsx 复制代码
// import sub1Tab from 'sub1/tab';

export default function Home() {
    const [type, setType] = useState('1')
    const [items, setItems] = useState([
        { label: 'Tab 1', key: '1', children: <TabContent index={1} /> },
        { label: 'Tab 2', key: '2', children: <TabContent index={2} /> },
        { label: 'Tab 3', key: '3', children: <TabContent index={3} /> },
    ])

    useEffect(() => {
        import('sub1/tab').then(result => {
            setItems(items => [...items, result.default])
        })
    }, [])

    return (
        <div>
            <Tabs activeKey={type} onChange={setType} items={items}></Tabs>
        </div>
    )
}

简单分析

这里主要是大概分析下js load个数及顺序。

  • 蓝色:分别是主子应用的webpack入口文件,主要是webpack和webpack dev相关。
  • 绿色:分别是主子应用的src下文件编译,对应src/index.js src/tab.jsx
  • 红色:React编译包,可以看到有的引用了主应用3000,有的引用了子应用3001.
  • 黄色:Antd编译包,用的是3001子应用里的。

会发现shared的第三方包,有些会加载主应用的,有些则会加载子应用的,可能跟Module Federation内部加载shared规则有关系,而且主子应用里的第三方包version是一样的。Module Federation也支持shared的包版本不一致的情况。

故障排除

问题1

vbnet 复制代码
Uncaught Error: Shared module is not available for eager consumption

是因为shared的module是异步加载,主应用执行入口处的 import React from 'react' 时还未初始化好shared module,所以需要把入口逻辑放在 bootstrap.js 里面,在 index.js 中使用 import 来异步加载 bootstrap.js,这样就可以在shared初始化好之后再执行bootstrap。

官方文档也提供了解决方案:webpack.docschina.org/concepts/mo...

问题2

sql 复制代码
Warning: Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for one of the following reasons:
1. You might have mismatching versions of React and the renderer (such as React DOM)
2. You might be breaking the Rules of Hooks
3. You might have more than one copy of React in the same app

react.development.js:1622 Uncaught TypeError: Cannot read properties of null (reading 'useState')

如果没有在主子应用里加shared module则会重现。切换Sub1 tab,即加载子应用的content组件时抛的,原因是 You might have more than one copy of React in the same app,因为react是加载各自应用里的包,所以React提示重复了,这时需要用到Module Federationshared,代码上例都有了。

另外网上还有别的方案,比如放到exposes里,或者直接script引用或在引用cdn,然后配置externals。这里不做展开了。

更多例子

由于篇幅有限,本文主要是简单介绍下关于Module Federation的概念,有什么特性,它能做什么,然后以两个简单具体的例子,介绍了入门用法:

  • 例子1:动态添加Tab
  • 例子2:动态import

下篇文章会继续介绍关于Module Federation的更多高级用法,比如导航路由层的动态添加、主应用的导航替换、共享Common控件的引用方式等。

源码:github.com/markz-demo/...

相关推荐
吃杠碰小鸡30 分钟前
lodash常用函数
前端·javascript
emoji11111139 分钟前
前端对页面数据进行缓存
开发语言·前端·javascript
泰伦闲鱼42 分钟前
nestjs:GET REQUEST 缓存问题
服务器·前端·缓存·node.js·nestjs
m0_748250031 小时前
Web 第一次作业 初探html 使用VSCode工具开发
前端·html
一个处女座的程序猿O(∩_∩)O1 小时前
vue3 如何使用 mounted
前端·javascript·vue.js
m0_748235951 小时前
web复习(三)
前端
AiFlutter1 小时前
Flutter-底部分享弹窗(showModalBottomSheet)
java·前端·flutter
麦兜*1 小时前
轮播图带详情插件、uniApp插件
前端·javascript·uni-app·vue
陈大爷(有低保)1 小时前
uniapp小案例---趣味打字坤
前端·javascript·vue.js
m0_748236581 小时前
《Web 应用项目开发:从构思到上线的全过程》
服务器·前端·数据库