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

前言

本文中会提到很多目前数栈中使用的特定名词,统一做下解释描述

  • dt-common:每个子产品都会引入的公共包(类似 NPM 包)

  • AppMenus:在子产品中快速进入到其他子产品的导航栏,统一维护在 dt-common 中,子产品从 dt-common 中引入

  • Portal:所有子产品的统一入口

  • APP_CONF:子产品的一些配置信息存放

背景

由于迭代中,我们有很多需求都是针对 AppMenus 的,这些需求的生效需要各个子产品的配合,进行统一变更。现在的数栈前端的项目当中, AppMenus 的相关逻辑存在于 dt-common 中,dt-common 又以独立的目录存在于每个子项目中, 所以当出现这种需求的时候,变更的分支就包含所有的子产品,这给前端以及测试同学都带来很多重复性的工作。

本文旨在通过 webpack5 Module Federation 的实践,实现 AppMenus 与各个子产品的解耦,即 AppMenus 作为共享资源,将其从 dt-common 中分离出来,保证其更新部署无需其他子产品的配合。

本地实现

Portal 项目

  1. 拆分 AppMenus 到 Portal 下的 Components 中,Portal 内部引用 AppMenus 就是正常组件内部的引用

  2. 配置 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);	
  3. dt-common 中修改 Navigator 组件引用 AppMenus 的方式,通过 props.children 实现

子产品项目

  1. 配置 Module Federation config

    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'],
    },
    },
    };

  2. 修改 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;
    }

  3. 注意本地调试的时候,子产品中需要代理 Portal 的访问路径到 Portal 服务的端口下,才能访问 Portal 暴露出来的组件的相关chunckjs

    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 Ferderation 分支同上,只不过此时 Portal 已经部署了,remote 和代理地址只需要改成部署后的 Portal 地址即可

复制代码
// proxy -> 代理修改
// header 引用的远程地址 -> 修改

window.APP_CONF?.remoteApp = 'xxx'

proxy: {
    '/portal': {
        target: 'xxx'
    }
}

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

子产品 && Portal 分别部署到服务器环境上修改子产品的 config 的配置 ,添加 window.APP_CONF.remoteApp 到 Portal 的服务器环境

异常处理

  1. 当 Portal 部署不当,或者是版本不对应的时候,没有 AppMenus 远程暴露出来的话, 做了异常处理思路是: 当请求 remoteEntry.js 出现 error 的时候,是不会展示 AppMenus 相关组件的

  2. 当 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 的开发流程减少了不少,有效的提高了我们的效率。

文章转载自: ++袋鼠云数栈前端++

原文链接: https://www.cnblogs.com/dtux/p/17898758.html

体验地址: 引迈 - JNPF快速开发平台_低代码开发平台_零代码开发平台_流程设计器_表单引擎_工作流引擎_软件架构

相关推荐
陈广亮21 分钟前
构建具有长期记忆的 AI Agent:从设计模式到生产实践
人工智能
会写代码的柯基犬30 分钟前
DeepSeek vs Kimi vs Qwen —— AI 生成俄罗斯方块代码效果横评
人工智能·llm
Mintopia1 小时前
OpenClaw 是什么?为什么节后热度如此之高?
人工智能
爱可生开源社区1 小时前
DBA 的未来?八位行业先锋的年度圆桌讨论
人工智能·dba
叁两4 小时前
用opencode打造全自动公众号写作流水线,AI 代笔太香了!
前端·人工智能·agent
前端付豪4 小时前
LangChain记忆:通过Memory记住上次的对话细节
人工智能·python·langchain
strayCat232554 小时前
Clawdbot 源码解读 7: 扩展机制
人工智能·开源
王鑫星4 小时前
SWE-bench 首次突破 80%:Claude Opus 4.5 发布,Anthropic 的野心不止于写代码
人工智能
lnix4 小时前
当“大龙虾”养在本地:我们离“反SaaS”的AI未来还有多远?
人工智能·aigc