认识模块联邦及其在 Next.js 中的使用

1,586 阅读9分钟

认识模块联邦(Module Federation)

模块联邦是Zack Jackson提出的一种JavaScript架构,它提供了一种在运行时在前端应用程序之间共享代码的方法。主要思想是将一个大型应用程序细分为微小的部分。模块联邦(MF)是Webpack5提出的概念,用来解决多个应用之间代码共享的问题,让我们更加优雅的实现跨应用的代码共享。

MF和微前端想解决的问题是类似的,把一个应用拆分成多个应用,每个应用可独立开发,独立部署,一个应用可以动态加载并运行另一个应用的代码,并实现应用之间的依赖共享

通过模块联邦实现微前端

许多微前端的复杂性可以通过模块联邦来解决。然而,与微前端一样,模块联邦并不适用于所有情况。以下是关于模块联邦的弊端以及益处。

优点

  1. 运行时代码共享

    这是最重要的一个好处,运行时代码共享是模块联邦最擅长的领域。运行时加载远程模块,这不仅极大地加快了部署速度,而且还使微前端所有 host 模块保持同步。也就是说,假如有 3 个模块引用了联邦模块 A,负责该模块的团队推出了一个 hotfix 或新功能,所有 host 模块会在下次用户进入到的页面时直接获取最新的代码。而如果是编译时加载,可能会存在一个窗口期,host 模块需要重新构建才能够引入最新版本的联邦模块 A。

    传统的前端通常部署单个应用程序,而使用模块联邦下的应用能够独立部署,进行单独的部署,并在运行时将前端应用拼接在一起。

  2. 性能

    模块联邦有助于构建微前端性能优势,你可以定义联合模块的共享库,以避免加载重复依赖项,例如 reactreact-dom 等。另外,远程模块指定暴露的组件或函数在 host 模块加载时,只需要加载最小可执行的代码块,而无需加载整个远程模块应用,与延迟加载相结合,最终只需下载最少的资源。

缺点

  1. 没有版本控制

    模块联邦的一个重要卖点是它是运行时代码共享,但是如果你正在开发 helper 函数或组件库,希望进行版本控制,也可以使用模块联邦并创建不同的 remoteEntry 文件来引入版本控制,但到那时最好使用 npm 包。

  2. 配置项复杂

    在性能上带来的优势下,其背后其实带来的缺点就是维护困难,尤其是随着引用和暴露的模块的增加,这个问题也会越来越明显。

  3. 社区较小

    模块联邦是一项新技术,在 webpack5 中作为核心插件发布;Next 应用的 next-mf 库,于去年底作为开源项目发布。与所有新工具一样,社区较小,因此支持较少。可能会遇到没有答案的问题。

  4. 不支持 ssr

是否要引入模块联邦

模块联邦和微前端通常会引入一定的认知负担,这可能会反而会减慢团队速度。简而言之,先明确你正在做什么,需要的达到什么目的;尝试使用模块联邦的效果,来看看它是否真正适合你的团队。

核心概念

为了实现这样的功能, MF在设计上提出了这几个核心概念。

容器(Container)

一个被 ModuleFederationPlugin 打包出来的模块被称为容器。 通俗点讲就是,如果我们的一个应用使用了 ModuleFederationPlugin 构建,那么它就成为一个容器,它可以加载其他的容器,可以被其他的容器所加载。

Host 和远程容器

从消费者和生产者的角度看容器容器又可被称作Host 容器远程容器

Host 容器:消费者,它动态加载并运行其他 Container 的代码。

远程容器:生产者,它暴露属性(如组件、方法等)供 Host 使用

这里的 Host 和 远程 是相对的,因为一个容器既可以作为 Host,也可以作为远程容器。

要从另一个容器加载远程模块,需要指定远程容器的名称和 URL,该 URL 指向在构建期间由模块联邦生成的 remote-entry.js 文件。该 URL 可以在运行时动态定义,以从本地主机或任何 CDN 域加载远程容器。一旦加载了远程容器,就可以加载远程容器暴露的任何模块。

import { lazy, Suspense, useEffect } from 'react';
import { useServiceContext } from 'shell/Service';

const RecentOrdersWidget = lazy(() => import('order/RecentOrdersWidget'))
export default function DashboardService() {
    const serviceContext = useServiceContext()
    
    useEffect(() => {
        serviceContext.setService({ title: 'Dashboard'})
    }, []);
    return (
        <Suspense fallback={<Loading />}>
            <RecentOrdersWidget />
        </Suspense>
    )
}

示例:从不同的容器导入远程模块

在上面的示例中,DashboardContainer 使用了两个远程模块:shell/Serviceorder/RecentOrdersWidget。所有远程模块都会被模块联邦并行延迟加载,然后会被缓存

共享模块(Shared)

一个容器可以将它的依赖(如 react、react-dom)共享给其他容器 使用,也就是共享依赖。

与远程模块一样,模块联邦将在运行时自动延迟加载所有共享依赖项,并支持懒加载。

可以将 package.json 的所有依赖项指定为共享依赖项,指定所需版本并指定为“单例”版本,保证在运行时仅有一个此库的版本。

建议通过在联邦配置中启用“单例”模式来更新“react”、“react-dom”和基于上下文的库(如“react-router”)。

如果指定了requiredVersion选项,则模块联邦在加载共享模块之前进行版本检查。如果版本不兼容,则可能会引发错误,或者如果启用了“fallback”选项,则会从当前容器中加载所需版本。

微信图片_20220626184254.png

微信图片_20220626184305.png

以上是webpack5与之前版本的模块管理对比图

当远程组件依赖 shared 中的 lib 时,加载远程组件时会去加载远程模块中 shared 的 lib,这就是 shared api 的机制,避免重复加载依赖。

暴露(Exposes)

此属性中指定的所有模块都将对使用者容器可访问。必须提供项目中特定的模块名称和源文件路径。支持组件、CSS、JS 和 TS 等。

new ModuleFederationPlugin({
    name: 'order',
    exposes: {
        './RecentOrdersWidget': './src/RecentOrdersWidget',
        './OrderService': './src/OrderService',
        './page-maps': './page-maps.js',
    }
})

在上面的示例中,OrderContainer 暴露了 OrderService(单独的页面)和RecentOrdersWidget(需要在 dashboard 页面上呈现)。

NextJS 实战

接下来我将演示如何暴露 chatgpt-next-appsidebarchat 组件,并且 next-blog 中引入。

  1. 在需要使用 MF 的 NextJS 项目中,都引入 @module-federation/nextjs-mf 插件;
  2. chatgpt-next-app 目录下的 next.config.js 中引入插件,定义需要暴露的组件sidebarchat ,以及定义要共享的库,这里都用了 MUI 作为 UI 库,因此这里将 MUI 配置到 shared 中;(注意:@module-federation/nextjs-mf 插件默认将 reactreact-dom 作为单例共享库,所以不需要手动添加了,否则会报错)。
const nextConfig = {
  reactStrictMode: true,
  webpack(config, { isServer }) {
    config.plugins.push(
      new NextFederationPlugin({
        name: 'chatgptNext',
        filename: 'static/chunks/remoteEntry.js',
        remotes: remotes(isServer),
        exposes: {
          './chat': './src/remotes/remote-chat.tsx',
          './sidebar': './src/remotes/remote-sidebar.tsx',
        },
        shared: {
          '@mui/icons-material': {
            singleton: true,
          },
          '@mui/material': {
            singleton: true,
          },
        },
        extraOptions:{
          automaticAsyncBoundary: true
        }
      })
    );
    // ...
    return config;
  },
}
  1. 同样在 next-blog 目录下的 next.config.js 中引入插件,定义远程模块的名称和 URL,以及要共享的库;

const chatGPTAppUrl = process.env.CHAT_GPT_APP_URL

const remotes = isServer => {
  const location = isServer ? 'ssr' : 'chunks';
  return {
    'chatgptNext': dynamicRemotes(`${chatGPTAppUrl}/_next/static/${location}/remoteEntry.js`, 'chatgptNext'),
  };
};
const nextConfig = {
    webpack: (config, { isServer }) => {
        if (!isServer) {
          config.resolve.fallback = { fs: false };
        }
        config.plugins.push(
          new NextFederationPlugin({
            name: 'main',
            filename: 'static/chunks/remoteEntry.js',
            remotes: remotes(isServer),
            exposes: {
              './markdown': './components/Markdown.tsx'
            },
            shared: {
              '@mui/icons-material': {
                singleton: true,
                requiredVersion: '5.11.0',
              },
              '@mui/material': {
                singleton: true,
                requiredVersion: false,
              },
            },
            extraOptions:{
              automaticAsyncBoundary: true
            }
          })
        )
        return config;
    }
}
  1. chatgpt-next-app 共享的 chat 组件依赖后台服务的 API,因此还需要解决跨域的问题,这里通过在 next-blog 中还重写请求地址,代理到 chatgpt-next-app 的接口域名地址。
const nextConfig = {
    // ...
    async rewrites() {
    return [
      {
        source: '/api/chat/:path*',
        destination: `${chatGPTAppUrl}/api/chat/:path*`,
        basePath: false,
      },
    ]
  },
    // ...
}
  1. next-blog 中动态引入联邦模块
import { Box, Dialog, Skeleton } from '@mui/material';
import { Theme, useTheme } from '@mui/material/styles';
import dynamic from 'next/dynamic';
import ErrorBoundary from './ErrorBoundary';
import { getErrorFallbackWithProps } from './ErrorFallback';

const Chat = dynamic<{ theme: Theme }>(
  () =>
    import('chatgptNext/chat').catch((err) => {
      return getErrorFallbackWithProps({ error: `Loading remote assets error` });
    }),
  {
    loading: () => <Skeleton variant="rounded" height="100%" style={{ flex: 1 }} />,
  },
);
const Sidebar = dynamic<{ theme: Theme }>(
  () =>
    import('chatgptNext/sidebar').catch((err) => {
      return getErrorFallbackWithProps({ error: `Loading remote assets error` });
    }),
  {
    loading: () => (
      <Skeleton
        variant="rounded"
        height="100%"
        sx={(theme) => ({ width: '300px', [theme.breakpoints.down('sm')]: { width: '100%' } })}
      />
    ),
  },
);

interface Props {
  visible: boolean;
  onClose: (event: {}, reason: 'backdropClick' | 'escapeKeyDown') => void;
}

export default function ChatModal({ visible, onClose }: Props) {
  const theme = useTheme();
  return (
    <Dialog open={visible} onClose={onClose} fullWidth={true} maxWidth="md">
      <Box sx={(theme) => ({ height: theme.breakpoints.values.sm })} className="flex gap-4 p-4">
        <ErrorBoundary>
          <>
            <Sidebar theme={theme} />
            <div className="flex-1">
              <Chat theme={theme} />
            </div>
          </>
        </ErrorBoundary>
      </Box>
    </Dialog>
  );
}

最后,还需要注意的是,在 nextjs 中使用 MF 必须要考虑远程容器不可达的情况,这会导致加载失败,直接导致当前页面崩溃。因此需要在加载远程容器的逻辑中添加错误处理机制,但是加载远程容器的逻辑被封装在了插件中,要怎么插入其他逻辑呢?

一般来说,remotes 是使用 URL 配置的,实际上还可以向 remote 传递一个 promise,它会在运行时被调用。

这里声明了一个 dynamicRemotes 函数,该函数返回一个 Promise,通过 script 标签异步加载远程容器,从而可以很方便的在该函数中添加错误处理机制,避免页面崩溃,代码如下:

function dynamicRemotes(remoteUrl, scope) {
  return `promise new Promise((resolve, reject) => {
    const script = document.createElement('script')
    script.src = '${remoteUrl}'
    script.async = true
    script.onload = () => {
      const proxy = {
        get: (request) => {
          try {
            return ${scope}.get(request)
          } catch(e) {
            console.error('[ERROR] Load remote error: Making request error:', e)
          }
        },
        init: (arg) => {
          try {
            return ${scope}.init(arg)
          } catch(e) {
            console.log('remote container already initialized')
          }
        }
      }
      resolve(proxy)
    }
    script.onerror = (error) => {
      console.error('error loading remote container[${scope}]', error)
      const proxy = {
        get: (request) => {
          return Promise.reject(error);
        },
        init: (arg) => {
          return;
        }
      }
      resolve(proxy)
    }
    document.head.appendChild(script);
  })`
}

const remotes = isServer => {
  const location = isServer ? 'ssr' : 'chunks';
  return {
    'chatgptNext': dynamicRemotes(`${chatGPTAppUrl}/_next/static/${location}/remoteEntry.js`, 'chatgptNext'),
  };
};

需要注意的是该函数必须返回 get/init 接口的模块来调用模块,更多细节参考 webpack 文档

到这里就基本实现了通过 MFnext-blog 中引入远程组件。下面是引入之后的效果:

MF 模块懒加载.gif

在实现的过程中还碰到不少问题,例如在 next-blog 中切换主题后远程模块不生效的问题,主题是依赖于 MUI 提供的 Provider 实现的,目前的解决方式是对要暴露的组件进行再封装,在外面套一层 Provider,然后在 host 中传入 theme 来解决。希望可以找到更好的解决方法,大家有任何想法或问题欢迎一起探讨呀!

另外本文中可能存在不足或错误之处,如果发现了任何问题或有任何建议,欢迎批评指正,我会认真听取并进行改进。

最后

使用 Module Federation 可以让我们更加优雅地实现跨应用的代码共享。通过将一个应用拆分成多个应用,每个应用可独立开发、独立部署,我们可以更好地实现应用之间的解耦和复用。

同时,Module Federation 也为我们提供了一种新的思路,即可以将一个大型的应用拆分成多个小型的应用,并通过 Module Federation 来实现它们之间的协作。这样做不仅可以提高开发效率,还可以更好地保证代码的可维护性和可扩展性。

当然,Module Federation 也存在一些潜在的问题和挑战,比如在处理依赖关系时需要注意版本冲突的问题,同时在多个应用之间共享状态也可能会带来一些困难。因此,在使用 Module Federation 时需要仔细考虑它的适用场景和实际问题。

除了 Module Federation,还有许多其他的技术可以实现应用之间的代码共享,比如 Web Components、npm 包、Git Submodules 等等。每种技术都有其优缺点和适用场景,需要根据具体情况进行选择。

参考文章