基于Webpack5 Module Federation的业务解耦实践示例

作者:橙子

时间:2026-10-31

浏览:314

本文介绍如何利用Webpack5 Module Federation技术,将数栈中的AppMenus导航组件从dt-common中剥离,实现与子产品的业务解耦。通过配置远程暴露与宿主引用,解决多子产品协同变更繁琐的问题,并详细阐述了本地调试、远程部署及异常处理的具体步骤与配置细节。

文章目录

前言

本文涉及数栈项目中使用的特定术语,在此统一进行解释说明。

  • dt-common:各子产品共同引入的公共依赖包,功能类似于NPM包。
  • AppMenus:用于在子产品间快速跳转的导航栏组件,统一维护于dt-common中,由各个子产品引入使用。
  • Portal:所有子产品的统一访问入口。
  • APP_CONF:存储子产品相关配置信息的变量或对象。

背景

在迭代过程中,大量需求涉及AppMenus的修改,这些需求的生效需要各子产品配合进行统一变更。目前数栈前端项目中,AppMenus的相关逻辑位于dt-common内,而dt-common以独立目录形式存在于每个子项目中。因此,此类需求变更的分支需包含所有子产品,导致前端开发和测试工作存在大量重复。

前言

本文旨在通过Webpack5 Module Federation的实践,实现AppMenus与各子产品的解耦。具体做法是将AppMenus作为共享资源从dt-common中分离,确保其更新部署无需其他子产品配合。

前言

本地实现

Portal项目

  • 将AppMenus拆分至Portal项目的Components目录下,Portal内部对AppMenus的引用视为正常的组件内部引用。

配置Module Federation相关参数,以便其他项目能够远程依赖该组件。

const federationConfig = {
     name: 'portal',
     filename: 'remoteEntry.js',
     // 当前组件需要暴露出去的组件
     exposes: {
         './AppMenus': './src/views/components/app-menus',
     },
     shared: {
         react: { 
            singleton: true,
            eager: true, 
            requiredVersion: deps.react
         },
         'react-dom': {
             singleton: true,
             eager: true,
             requiredVersion: deps['react-dom'],
         },
     },
 };

 const plugins = [
     ...baseKoConfig.plugins,
     {
         key: 'WebpackPlugin',
         action: 'add',
         opts: {
             name: 'ModuleFederationPlugin',
             fn: () => new ModuleFederationPlugin({ ...federationConfig }),
         },
     },
 ].filter(Boolean);
  • 在dt-common中修改Navigator组件对AppMenus的引用方式,改为通过props.children实现。

子产品项目

配置Module Federation的配置文件。

const federationConfig = {
name: 'xxx',
filename: 'remoteEntry.js',
// 关联需要引入的其他应用
remotes: {
    // 本地相互访问采取该方式
    portal: 'portal@http://127.0.0.1:8081/portal/remoteEntry.js',
},
shared: {
    antd: {
        singleton: true,
        eager: true,
        requiredVersion: deps.antd,
    },
    react: {
        singleton: true,
        eager: true,
        requiredVersion: deps.react,
    },
    'react-dom': {
        singleton: true,
        eager: true,
        requiredVersion: deps['react-dom'],
    },
},
};

修改AppMenus的引用方式。

const AppMenus = React.lazy(() => import('portal/AppMenus'));
<Navigator
{...this.props}
>
<React.Suspense fallback="loading">
    <AppMenus {...this.props} />
</React.Suspense>
</Navigator>

// 需要 ts 定义 
// typings/app.d.ts 文件
declare module 'portal/AppMenus' {
const AppMenus: React.ComponentType<any>;
export default AppMenus;
}

注意本地调试时,子产品需将Portal的访问路径代理至Portal服务端口,才能访问Portal暴露出的相关chunk.js文件。

module.exports = {
proxy: {
    '/portal': {
        target: 'http://127.0.0.1:8081', // 本地
        //target: 'portal 对应的地址', 本地 -〉 devops 环境
        changeOrigin: true,
        secure: false,
        onProxyReq: ProxyReq,
   },
}
}

远程部署

部署至服务器时,由于Portal项目中的AppMenus属于远程组件(即共享依赖),而子产品为宿主环境,因此部署时需同时部署Portal项目与子产品。上述配置中,主要变更的是加载地址。Portal项目无需变更,需变更的是子产品中的相关逻辑。

//remote.tsx 
import React from 'react';
function loadComponent(scope, module) {
    return async () => {
        // Initializes the share scope. This fills it with known provided modules from this build and all remotes
        await __webpack_init_sharing__('default');
        const container = window[scope]; // or get the container somewhere else
        // Initialize the container, it may provide shared modules
        await container.init(__webpack_share_scopes__.default);
        const factory = await window[scope].get(module);
        const Module = factory();
        return Module;
    };
}
const urlCache = new Set();
const useDynamicScript = (url) => {
    const [ready, setReady] = React.useState(false);
    const [errorLoading, setErrorLoading] = React.useState(false);
    React.useEffect(() => {
        if (!url) return;
        if (urlCache.has(url)) {
            setReady(true);
            setErrorLoading(false);
            return;
        }
        setReady(false);
        setErrorLoading(false);
        const element = document.createElement('script');
        element.src = url;
        element.type = 'text/javascript';
        element.async = true;
        element.onload = () => {
            console.log('onload');
            urlCache.add(url);
            setReady(true);
        };
        element.onerror = () => {
            console.log('error');
            setReady(false);
            setErrorLoading(true);
        };
        document.head.appendChild(element);
        return () => {
            urlCache.delete(url);
            document.head.removeChild(element);
        };
    }, [url]);
    return {
        errorLoading,
        ready,
    };
};
const componentCache = new Map();
export const useFederatedComponent = (remoteUrl, scope, module) => {
    const key = `${remoteUrl}-${scope}-${module}`;
    const [Component, setComponent] = React.useState(null);
    const { ready, errorLoading } = useDynamicScript(remoteUrl);
    React.useEffect(() => {
        if (Component) setComponent(null);
        // Only recalculate when key changes
    }, [key]);
    React.useEffect(() => {
        if (ready && !Component) {
            const Comp = React.lazy(loadComponent(scope, module));
            componentCache.set(key, Comp);
            setComponent(Comp);
        }
        // key includes all dependencies (scope/module)
    }, [Component, ready, key]);
    return { errorLoading, Component };
};
//layout header.tsx
const Header = () => {
    ....
    const url = `${window.APP_CONF?.remoteApp}/portal/remoteEntry.js`;
  const scope = 'portal';
  const module = './AppMenus'
    const { Component: FederatedComponent, errorLoading } = useFederatedComponent(
      url,
      scope,
      module
  );
    return (
        <Navigator logo={<Logo />} menuItems={menuItems} licenseApps={licenseApps} {...props}>
            {errorLoading ? (
                <WarningOutlined />
            ) : (
                FederatedComponent && (
                    <React.Suspense fallback={<Spin />}>
                        {<FederatedComponent {...props} top={64} showBackPortal />}
                    </React.Suspense>
                )
            )}
        </Navigator>
    );
}

如何调试

子产品本地环境 → Portal本地环境

Portal与某个资产在不同端口上运行。

Portal无需变更,子产品需进行以下相关文件配置。

在此场景下,remoteApp指向本地启动的Portal项目环境;同时启动项目时需将/partal请求代理至本地环境。

// proxy -> 代理修改
// header 引用的远程地址 -> 修改
window.APP_CONF?.remoteApp = 'http://127.0.0.1:8081'
proxy: {
    '/portal': {
        target: 'http://127.0.0.1:8081'
    }
}

子产品本地环境 → Portal服务器环境

本地启动console服务。

服务器上部署Portal对应的Module Federation分支。

与上述情况类似,区别在于此时Portal已部署完成,remote和代理地址只需修改为部署后的Portal地址即可。

// proxy -> 代理修改
// header 引用的远程地址 -> 修改
window.APP_CONF?.remoteApp = 'xxx'
proxy: {
    '/portal': {
        target: 'xxx'
    }
}

子产品服务器环境 → Portal服务器环境

子产品与Portal分别部署至服务器环境,修改子产品的config配置,添加window.APP_CONF.remoteApp指向Portal的服务器环境地址。

异常处理

  • 当Portal部署不当或版本不对应,导致未暴露AppMenus远程组件时,采取异常处理思路:若请求remoteEntry.js出现error,则不展示AppMenus相关组件。
  • 若Portal已部署但其他子产品未接入Module Federation,则不影响子产品的正常展示;子产品当前使用的应为dt-common中的AppMenus。

如何开发AppMenus

前言

问题记录

依赖版本不一致

【Error】Could not find "store" in either the context or props of "Connect(N)". Either wrap the root component in a \<Provider>, or explicitly pass "store" as a prop to "Connect(N)".

前言

报错路径指向portal/xxx,可定位到是AppMunes出现问题。原因是子产品React-Redux与Portal React-Redux版本不一致,需在对应子产品的federationConfig中将react-redux处理为共享依赖。

总结

本文主要从业务层面结合Webpack 5 Module Federation,实现AppMenus的解耦。主要涉及dt-common、Portal及子产品的变更。通过解耦,我们发现AppMenus的开发流程大幅简化,有效提高了工作效率。

以上就是基于Webpack5 Module Federation的业务解耦实践示例的详细内容,更多关于Webpack5 Module Federation业务解耦的资料请关注本站其它相关文章!

实用工具推荐

电子包浆

绿化、压缩、噪点和虚拟水印,自由调节做旧程度。

使用 ›
图片压缩

选择画质和输出格式,预览压缩前后的大小。

使用 ›
相关文章