在 Next.js 使用模块联邦
认识模块联邦(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/Service 和 order/RecentOrdersWidget。所有远程模块都会被模块联邦并行延迟加载,然后会被缓存。
共享模块(Shared)
一个容器可以将它的依赖(如 react、react-dom)共享给其他容器 使用,也就是共享依赖。
与远程模块一样,模块联邦将在运行时自动延迟加载所有共享依赖项,并支持懒加载。
可以将 package.json
的所有依赖项指定为共享依赖项,指定所需版本并指定为“单例”版本,保证在运行时仅有一个此库的版本。
建议通过在联邦配置中启用“单例”模式来更新“react”、“react-dom”和基于上下文的库(如“react-router”)。
如果指定了requiredVersion
选项,则模块联邦在加载共享模块之前进行版本检查。如果版本不兼容,则可能会引发错误,或者如果启用了“fallback”选项,则会从当前容器中加载所需版本。
文档中还有许多其他可以指定的选项。
使用灵活的共享配置可以降低管理共享依赖关系的复杂性,因为它被封装在每个微应用程序中。此外,它可以提供一个更好的策略来更新依赖项。
以上是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-app
的 sidebar
和 chat
组件,并且 next-blog
中引入。
- 在需要使用 MF 的 NextJS 项目中,都引入
@module-federation/nextjs-mf
插件; - 在
chatgpt-next-app
目录下的next.config.js
中引入插件,定义需要暴露的组件sidebar
和chat
,以及定义要共享的库,这里都用了MUI
作为 UI 库,因此这里将MUI
配置到shared
中;(注意:@module-federation/nextjs-mf
插件默认将react
和react-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;
},
}
- 同样在
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;
}
}
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,
},
]
},
// ...
}
- 在
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 文档。
到这里就基本实现了通过 MF
在 next-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