Modern.js

Modern.js 是一个基于 React 的渐进式 Web 开发框架。在字节跳动内部,Modern.js 支撑了数千个 Web 应用的研发。

Module Federation 团队与 Modern.js 团队紧密合作,并提供 @module-federation/modern-js 插件来帮助用户在 Modern.js 中更好的使用 Module Federation。

支持

  • modern.js ^2.56.1
  • 包含服务器端渲染(SSR)

强烈推荐参考下列应用,它提供了 Modern.js 与 Module Federation 结合的最佳实践:

快速开始

安装

你可以通过如下的命令安装插件:

npm
yarn
pnpm
bun
npm add @module-federation/modern-js --save

应用插件

modern.config.tsplugins 中应用此插件:

modern.config.ts
import { appTools, defineConfig } from '@modern-js/app-tools';
import { moduleFederationPlugin } from '@module-federation/modern-js';

export default defineConfig({
  dev: {
    port: 3005,
  },
  runtime: {
    router: true,
  },
  // moduleFederationPlugin 是 modern.js 的插件,可以对构建/运行时做一定的修改
  plugins: [appTools(), moduleFederationPlugin()],
});

随后创建 module-federation.config.ts 文件,并写入需要的配置:

module-federation.config.ts
import { createModuleFederationConfig } from '@module-federation/modern-js';
export default createModuleFederationConfig({
  name: 'host',
  remotes: {
    remote: 'remote@http://localhost:3006/mf-manifest.json',
  },
  shared: {
    react: { singleton: true },
    'react-dom': { singleton: true },
  },
});

服务端渲染(SSR)

注意

为更好的性能体验,Module Federation X Modern.js SSR 仅支持 stream SSR 。

在 SSR 场景与 CSR 场景中使用 Module Federation 没有任何区别,开发者保持按照原有的开发行为即可。

组件级别数据获取

简介

SSR 场景中, useEffect 不会执行,这一行为导致常规情况下,无法先获取数据再渲染组件。

为了支持这一功能,主流框架一般会基于 React Router 提供的 data loader 预取数据,并注入给路由组件,路由组件通过 useLoaderData 获取数据并渲染。

这一行为强依赖路由功能,在 Module Federation 下就无法正常使用。

为了解决这一问题,Module Federation 提供了组件级别数据获取能力,以便开发者可以在 SSR 场景下获取数据并渲染组件。

什么是组件级别?

Module Federation 的使用大体分为两种部分:组件(函数)、应用。两者的区别在于是否带有路由功能。

如何使用

根据角色的不同,需要做不同的行为。

生产者

注意

生产者可以使用 Rslib 来生成 SSR 组件。

不过需要注意的是,因为「数据获取」中的数据由消费者注入。因此如果在 Rslib 中使用了「数据获取」,那么其导出的非 MF 组件无法与 MF 组件保持同构。

每个 expose 模块都可以有一个同名的 .data 文件。这些文件可以导出一个 loader 函数,我们称为 Data Loader,它会在对应的 expose 组件渲染之前执行,为组件提供数据。如下面示例:

.
└── src
    ├── List.tsx
    └── List.data.ts

其中 List.data.ts 需要导出名为 fetchData 的函数,该函数将会在 List 组件渲染前执行,并将其数据注入,示例如下:

List.data.ts
import type { DataFetchParams } from '@module-federation/modern-js/runtime';
export type Data = {
  data: string;
};

export const fetchData = async (params: DataFetchParams): Promise<Data> => {
  console.log('params: ', params);
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve({
        data: `data: ${new Date()}`,
      });
    }, 1000);
  });
};

loader 函数数据会被注入到生产者的 props 中,key 为 mfData,因此生产者需要改动代码,消费此数据,示例如下:

List.tsx
import React from 'react';
import type { Data } from './index.data';

const List = (props: {
  mfData?: Data;
}): JSX.Element => {
  return (
    <div>
     {props.mfData?.data && props.mfData?.data.map((item,index)=><p key={index}>{item}</p>)}
    </div>
  );
};

export default List;
生产者消费自身数据

如果使用了 Modern.js 来开发生产者,并且该生产者页面也会单独访问。那么可以利用 Modern.js 提供的 Data Loader 来注入数据。

其用法与 Module Federation 除了函数名称外,基本一致。因此你可以很方便的在生产者消费 Data Loader,示例如下:

  • 在生产者页面创建 page.data.ts 文件,导出名为 loader 的函数:
page.data.ts
import { fetchData } from '../components/List.data';
import type { Data } from '../components/List.data';

export const loader = fetchData

export type {Data}
  • 在生产者 page 页面消费此数据:
page.tsx
import { useLoaderData } from '@modern-js/runtime/router';
import List from '../components/List';
import './index.css';

import type { Data } from './page.data';

const Index = () => {
  const data = useLoaderData() as Data;
  console.log('page data', data);

  return (
  <div className="container-box">
    <List mfData={data} />
  </div>
)};

export default Index;

消费者

在消费者中,我们需要通过 createRemoteComponent 来加载远程组件,并获取数据。

import { kit, ERROR_TYPE } from '@module-federation/modern-js/runtime';

const { createRemoteComponent } = kit;

const List = createRemoteComponent({
  loader: () => {
    return import('remote/List');
  },
  loading: 'loading...',
  export: 'default',
  fallback: ({error,errorType,dataFetchMapKey}) => {
    console.error(error)
    if(errorType === ERROR_TYPE.LOAD_REMOTE){
      return <div>load remote failed</div>
    }
    if(errorType === ERROR_TYPE.DATA_FETCH){
      return <div>data fetch failed, the dataFetchMapKey key is: {dataFetchMapKey}</div>
    }
    return <div>error type is unknown</div>;
  },
});

const Index = (): JSX.Element => {
  return (
    <div>
      <h1>Basic usage with data fetch</h1>
      <List />
    </div>
  );
};

export default Index;

loader 函数

入参

默认会往 loader 函数传递参数,其类型为 DataFetchParams,包含以下字段:

  • isDowngrade (boolean): 表示当前执行上下文是否处于降级模式。例如,在服务端渲染 (SSR) 失败,在客户端渲染(CSR)中会重新往服务端发起请求,调用 loader 函数,此时该值为 true

除了默认的参数以外,还可以在 createRemoteComponent 中传递 dataFetchParams 字段,该字段会被透传给 loader 函数。

返回值

loader 函数的返回值只能是可序列化的数据对象

在不同环境使用 Data Loader

loader 函数可能会在服务端或浏览器端执行。在服务端执行的 loader 函数,我们称为 Server Loader,在浏览器端执行的称为 Client Loader。

在 CSR 应用中,loader 函数会在浏览器端执行,即默认都是 Client Loader。

在 SSR 应用中,loader 函数只会在服务端执行,即默认都是 Server Loader。在 SSR Module Federation 会直接在服务端调用对应的 loader 函数。在浏览器端切换路由时,Module Federation 会发送一个 http 请求到 SSR 服务,同样在服务端触发 loader 函数。

NOTE

SSR 应用的 loader 函数只在服务端执行可以带来以下好处:

简化使用方式:保证 SSR 应用获取数据的方式是同构的,开发者无需根据环境区分 loader 函数执行的代码。 减少浏览器端 bundle 体积:将逻辑代码及其依赖,从浏览器端移动到了服务端。 提高可维护性:将逻辑代码移动到服务端,减少了数据逻辑对前端 UI 的直接影响。此外,也避免了浏览器端 bundle 中误引入服务端依赖,或服务端 bundle 中误引入浏览器端依赖的问题。

在 SSR 应用中使用 Client Loader

默认情况下,在 SSR 应用中,loader 函数只会在服务端执行。但有些场景下,开发者可能期望在浏览器端发送的请求不经过 SSR 服务,直接请求数据源,例如:

  1. 在浏览器端希望减少网络消耗,直接请求数据源。
  2. 应用在浏览器端有数据缓存,不希望请求 SSR 服务获取数据。

Module Federation 支持在 SSR 应用中额外添加 .data.client 文件,同样具名导出 loader。此时 SSR 应用在服务端执行 Data Loader 报错降级,或浏览器端切换路由时,会像 CSR 应用一样在浏览器端执行该 loader 函数,而不是再向 SSR 服务发送数据请求。

List.data.client.ts
import cache from 'my-cache';

export async function loader({ params }) {
  if (cache.has(params.id)) {
    return cache.get(params.id);
  }
  const res = await fetch('URL_ADDRESS?id={params.id}');
  return {
    message: res.message,
  }
}
WARNING

要使用 Client Loader,必须有对应的 Server Loader,且 Server Loader 必须是 .data 文件约定,不能是 .loader 文件约定。

FAQ

应用级别数据获取?

应用级别的模块,我们更希望使用 RSC 来实现,使其功能更加的完善。目前该功能正在探索中,敬请期待。

是否支持嵌套生产者?

不支持。

API

@module-federation/modern-js/runtime 除了导出了 MF Runtime,还提供供了一系列的 API 来帮助开发者更好的使用 Module Federation。

为防止与 Shared 冲突,需要通过下列方式引用。

import { kit } from '@module-federation/modern-js/runtime';
const { loadRemote ,createRemoteComponent, createRemoteSSRComponent, wrapNoSSR } = kit;

createRemoteComponent

  Type declaration
declare function createRemoteComponent(
  props: CreateRemoteComponentOptions
): (props: ComponentType) => React.JSX.Element;

type CreateRemoteComponentOptions<T, E extends keyof T> = {
  loader: () => Promise<T>;
  loading: React.ReactNode;
  fallback: ReactNode | ((errorInfo: ErrorInfo) => ReactNode);
  export?: E;
  dataFetchParams?: DataFetchParams;
};

type ComponentType = T[E] extends (...args: any) => any
  ? Parameters<T[E]>[0] extends undefined
    ? Record<string, never>
    : Parameters<T[E]>[0]
  : Record<string, never>;

type DataFetchParams = {
  isDowngrade: boolean;
} & Record<string, unknown>;

type ErrorInfo = {
  error: Error;
  errorType: number;
  dataFetchMapKey?: string;
};

该函数除了支持加载组件之外,还支持下列能力:

  1. SSR 模式中会注入对应生产者的样式标签/脚本资源 ,此行为可以帮助避免流式渲染带来的 CSS 闪烁问题以及加速 PID (首屏可交互时间)。
  2. 如果生产者存在数据获取函数,那么会自动调用此函数并注入数据。

示例

import React, { FC, memo, useEffect } from 'react';
import { kit, ERROR_TYPE } from '@module-federation/modern-js/runtime';

const { createRemoteComponent } = kit;

const RemoteComponent = createRemoteComponent({
  loader: () => import('remote/Image'),
  loading: <div>loading...</div>,
  fallback: ({error,errorType,dataFetchMapKey}) => {
    console.error(error)
    if(errorType === ERROR_TYPE.LOAD_REMOTE){
      return <div>load remote failed</div>
    }
    if(errorType === ERROR_TYPE.DATA_FETCH){
      return <div>data fetch failed, the dataFetchMapKey key is: {dataFetchMapKey}</div>
    }
    return <div>error type is unknown</div>;
  },
});

const App: FC = () => {
  return <>
    <RemoteComponent />
  </>;
};
export default App;

loader

  • 类型:() => Promise<T>
  • 是否必填:是
  • 默认值:undefined

加载远程组件的函数,通常为 ()=>loadRemote(id) 或者 ()=>import(id)

loading

  • 类型:React.ReactNode
  • 是否必填:是
  • 默认值:undefined

设置模块载入状态。

fallback

  • 类型:(({ error }: { error: ErrorInfo}) => React.ReactElement)
  • 是否必填:是
  • 默认值:undefined

当组件加载渲染失败时,所渲染的容错组件。

export

  • 类型:string
  • 是否必填:否
  • 默认值:'default'

如果远程组件是具名导出,那么可以通过此参数指定需要导出的组件名称,默认加载 default 导出。

dataFetchParams

  • 类型:DataFetchParams
  • 是否必填:否
  • 默认值:undefined

如果远程组件存在数据获取函数,设置后会传递给数据获取函数。

createRemoteSSRComponent

注意

此函数将被废弃。推荐使用 createRemoteComponent

wrapNoSSR

  Type declaration
declare function wrapNoSSR<T, E extends keyof T>(
  createComponentFn: typeof createRemoteComponent<T, E>,
) : (options: CreateRemoteComponentOptions<T, E>) => (props: ComponentType) => React.JSX.Element

包裹组件,使其在 SSR 场景下不渲染。

使用示例:

import { kit } from '@module-federation/modern-js/runtime';

const { createRemoteComponent, wrapNoSSR } = kit;

const RemoteComponent = wrapNoSSR(createRemoteComponent)({
  loader: () => {
    return import('remote/Content');
  },
  loading: 'loading...',
  export: 'default',
  fallback: ({ error }) => {
    if (error instanceof Error && error.message.includes('not exist')) {
      return <div>fallback - not existed id</div>;
    }
    return <div>fallback</div>;
  },
});

const Index = (): JSX.Element => {
  return (
    <div>
      <h1>
        The component will be render in csr.
      </h1>
      <RemoteComponent />
    </div>
  );
};

export default Index;

配置

ssr

  • 类型:false
  • 是否必填:否
  • 默认值:undefined

@module-federation/modern-js 会根据 modern.js config 中的 server.ssr 来自动添加 SSR 相关的构建预设。

如果当前项目仅需要在 CSR 加载 MF ,那么可以设置 ssr: false 来帮助渐进式迁移。

import { appTools, defineConfig } from '@modern-js/app-tools';
import { moduleFederationPlugin } from '@module-federation/modern-js';

// https://modernjs.dev/en/configure/app/usage
export default defineConfig({
  dev: {
    port: 3050,
  },
  runtime: {
    router: true,
  },
  server: {
    ssr: {
      mode: 'stream',
    },
  },
  plugins: [
    appTools(),
    moduleFederationPlugin({ ssr: false })
  ],
});

fetchServerQuery

  • 类型:Record<string, unknown>
  • 是否必填:否
  • 默认值:undefined

如果发生降级,那么会向服务端发送一个 http 请求,此时可以通过此配置来添加请求的 query。