likes
comments
collection
share

在 Next.js 使用模块联邦

作者站长头像
站长
· 阅读数 47

认识模块联邦(Module Federation)

模块联邦(MF)是Webpack5提出的概念,用来解决多个应用之间代码共享的问题,让我们更加优雅的实现跨应用的代码共享。

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

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

容器(Container)

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

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”选项,则会从当前容器中加载所需版本。

文档中还有许多其他可以指定的选项。

使用灵活的共享配置可以降低管理共享依赖关系的复杂性,因为它被封装在每个微应用程序中。此外,它可以提供一个更好的策略来更新依赖项。

在 Next.js 使用模块联邦

在 Next.js 使用模块联邦

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

当使用 shared 的时候遇到一个问题,当 expose 的组件依赖 shared 中的 lib 时,加载 expose 组件时会去加载 remote app 中 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 页面上呈现)。

暴露页面组件

就像暴露组件和其他模块一样,页面也可以从一个 Next.js 应用程序中暴露并在另一个应用程序中使用。要暴露页面,需要在远程应用程序中定义一个页面映射。在根目录下创建 page-maps.(js|ts),在文件中定义映射

// pages-map.js
const pagesMap = {
    "/": "./home", // "route": module "key" you're exposing
};
export default pagesMap;

为了引入联邦页面,需要添加新的页面文件代表联邦页面

// pages/index.js
import dynamic from "next/dynamic";

const page = import("home/home");
const Page = dynamic(() = import("home/home"));
Page.getInitialProps = async (ctx) = {
    const getInitialProps = (await page).default?.getInitialProps;
    if (getInitialProps) {
        return getInitialProps(ctx);
    }
    return {};
};
export default Page;

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 组件依赖其 next 服务的 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 中引入远程组件。下面是引入之后的效果:

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

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

最后

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

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

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

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

参考文章

转载自:https://juejin.cn/post/7239715560699002917
评论
请登录