Remix.run 与旧版 React 组件库的兼容性指南

去年伴随 React 18 的发布了一系列非常新潮的 API,它们都为更大规模的 Web 程序渲染提供了可能,而这些 看起来非常时髦的技术也带来了很多问题,这在和服务端渲染有关的任务当中尤为严重。尽管 React 18 在开发 过程当中就组建了一个 Work Group 和诸多领域的开发者交换过意见,但因为步子迈的太大导致许多周边生态在 一年之后也依然没能完成适配工作。与之相对的,很多 Meta Framework 却跟进的非常快,一系列官方推进的 最佳实践马上就得到了落实。这之中便产生了某种撕裂,致使诸多 UI 组件库不能正常的在客户端进行渲染,本文 将以微软推出的 Fluent UI V9 为例,简要介绍过渡期间开发者可以完成的一些工作,来确保 Remix.run 这类 元框架可以和你的组件库和谐共处。

渲染器变量传递

Remix 的一大特点是组件封装做的非常严密,开发者无法自行改变打包器的行为以及一些组件的行为。比如,很多 组件库会需要提供一个渲染器来收集渲染过程中生成的所有 CSS 规则,开发者需要把渲染器对应的 Context 包裹 在组件外部以确保它能够收集到所有渲染上下文的信息。因为这个组件只适用于服务端,因此我们需要在 Node Server 的脚本当中进行这部分的工作。

然而,如果你使用过 Remix 的话就会发现这是很难做到的一件事,因为服务端的渲染逻辑被封装到了 <RemixServer /> 这个组件当中,这里面包含了一些和服务端渲染有关的特殊逻辑和异常处理的代码。这个组件内部就只有客户端的 Root 组件了。

当然我们可以尝试将 SSR 对应的上下文组件包裹在 RemixServer 外部,然而这并不总是有效的,比如像 Fluent UI V9 当中的 RendererProviderSSRProvider 两个 Provide,如果开发者尝试将之包裹在 RemixServer 外部,那么在服务端渲染的时候就会报错。

这个时候,我们需要创建一个空白的 React Context,向客户端脚本传递这个渲染器组件,比如:

1
2
3
4
5
6
7
8
9
10
import * as React from "react";

import {
renderToStyleElements,
type GriffelRenderer,
} from "@fluentui/react-components";

export const FluentStyleContext = React.createContext<GriffelRenderer | null>(
null
);

接下来,我们需要改造 entry.server.tsx 文件,确保你的 Context 包裹住了 RemixServer 组件,因为 我们的 Context 没有任何副作用,所以这里的包裹是安全的。

1
2
3
<FluentStyleContext.Provider value={renderer}>
<RemixServer context={remixContext} url={request.url} />
</FluentStyleContext.Provider>

然后在客户端脚本内部,将需要用到的组件包裹起来。但是请注意,我们并不希望客户端脚本当中出现这两个组件, 所以我们需要做一些比较取巧的工作,来将这部分工作隔离出来。在 Remix 当中提供了这样的一个功能:如果一个 JavaScript 文件的文件名当中包含 .server.tsx,那么,他就不会出现在客户端脚本的打包结果当中,因此 利用这个特性,我们可以新建一个叫做 fluent.server.tsx 的文件,并且撰写这样的一个组件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import * as React from "react";

import {
SSRProvider,
RendererProvider,
} from "@fluentui/react-components";
import type { GriffelRenderer } from "@fluentui/react-components";

import { FluentStyleContext } from "~/context/fluentStyleContext";

export const FluentServerWrapper: React.FC<React.PropsWithChildren> = ({
children,
}) => {
const renderer = React.useContext(FluentStyleContext)!;
return (
<RendererProvider renderer={renderer}>
<SSRProvider>{children}</SSRProvider>
</RendererProvider>
);
};

接下来,我们在 root.tsx 当中建立一个与之相对应的客户端组件:

1
2
3
4
5
6
7
8
9
import { FluentServerWrapper } from "./utils/fluent.server";

const FluentClientWrapper: React.FC<React.PropsWithChildren> = ({
children,
}) => {
return <>{children}</>;
};

const FluentWrapper = FluentServerWrapper ?? FluentClientWrapper;

最后,用这个 FluentWrapperbody 标签内的所有内容都包裹住,Context 注入的工作就算完成了。

流式 HTML 生成降级

React 18 提供了一个流式渲染虚拟 DOM 的新方法:renderToReadableStream,然而相当多的 CSS in JS 解决方案并不支持这样的做法,因为它们都假定虚拟 DOM 必须全部渲染完成,最后的 CSS 渲染才算结束,如果 我们使用流式生成的方法输出 HTML 的话,会发现最后生成样式表不包含任何信息,因为通常样式表出现在 <head /> 标签的为止,在虚拟 DOM 生成到这一部分时还没有任何组件被渲染过,所以自然是得不到任何内容的了。

为了解决这个问题,我们需要对服务端渲染的方法进行降级,直到我们所用的前端框架支持了对应的功能再恢复 回来,在这里,我们打开 entry.server.tsx 文件并做如下改动:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import { RemixServer } from "@remix-run/react";
import {
createDOMRenderer,
renderToStyleElements,
} from "@fluentui/react-components";
import { renderToStaticMarkup } from "react-dom/server";

// ...

export default async function handleRequest(
// ...
) {
const renderer = createDOMRenderer();

let body = renderToStaticMarkup(
<FluentStyleContext.Provider value={renderer}>
<RemixServer context={remixContext} url={request.url} />
</FluentStyleContext.Provider>
);

const $style = renderToStaticMarkup(<>{renderToStyleElements(renderer)}</>);
//...
}

样式表的注入

Remix.run 和 Next.js 都在 React 层面接管了完整的 DOM 树生成,但 Remix.run 并没有向我们提供注入样 式表的 API,所以在这里我们需要手动对 HTML 进行操作。

对于服务端,我们需要准备一个标记物,用来搜寻和替换生成的样式表标签,具体的做法是这样的:

fluent.server.tsx 当中加入一个新的组件:

1
2
3
export const FluentServerStyle = () => {
return <style id="fui-hydration-marker" />;
};

entry.server.tsx 文件当中,我们来搜索这个标记物,并替换为生成出来的样式表:

1
2
3
4
5
6
7
  body = body.replace(`<style id="fui-hydration-marker"></style>`, $style);

responseHeaders.set("Content-Type", "text/html");
return new Response(body, {
headers: responseHeaders,
status: responseStatusCode,
});

然而,光是这样做是不够的,因为我们会遇到客户端和服务端渲染不一致的问题,不像旧版的服务端渲染 API,对于 hydrateRoot 函数,如果发现客户端的虚拟 DOM 和服务端返回 HTML 不一致时,React 会抛出错误并拒绝接下来的渲染,所以在客户端我们需要建立一个组件,来「配平」服务端的渲染结果, 具体的组件应该是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import useConstant from "use-constant";

const FluentClientWrapper: React.FC<React.PropsWithChildren> = ({
children,
}) => {
return <>{children}</>;
};

const FluentClientStyle = () => {
const styles = useConstant(() => {
const $styles = [
...document.head.querySelectorAll("style[data-make-styles-bucket]"),
] as HTMLStyleElement[];

const configs = $styles.map((x) => ({
props: x.getAttributeNames().reduce((acc, name) => {
return { ...acc, [name]: x.getAttribute(name) };
}, {}),
children: x.innerHTML,
}));

return configs;
});

const vDom = (
<>
{styles.map(({ props, children }, i) => (
<style key={i} {...props}>{`${children}`}</style>
))}
</>
);

return vDom;
};

请注意,useConstant 并不是一个 React 自带的组件,你需要从 NPM 当中安装它。这个组件完成的任务 非常简单,扫描 HTML 文档当中的 head 区域,找到满足条件的 <style /> 标签,并且生成对应的虚拟 DOM 元素。Fluent UI 对应的筛选标准是组件有 data-make-styles-bucket 这个属性,但其他框架的 样式表标签则可能有不同的特征,开发者需要根据自己的情况来设计不同的筛选标准。

接下来,我们用相同的技术来构建一个在客户端和服务端异构的组件:

1
const FluentStyle = FluentServerStyle ?? FluentClientStyle;

最后,我们只需要将这个组件放到 <head /> 当中的任意位置,就可以帮助水合正确的执行了。

降级 Transition 水合流程

React 18 提供的另外一个新功能是 Transition,它可以将很大的任务分解为更小的微任务,并且按照优先级 排列到一个队列当中,这个机制可以帮助客户端更快速的响应用户输入:在用户操作事件发生时,其对应的异步 任务会被立刻排列到异步队列当中的最前面,以确保它可以获得即刻的相应。

水合过程也适配了这样的机制,传统的水合过程是阻塞的,这意味着水合完成之前用户是不能进行任何操作的, 界面会被卡住。而新版本的 React 改善了这一个过程,它将水和过程转化成了一个流式过程,即 React 会一边 完成 HTML 的解析、事件的绑定,一边等待用户的事件输入,如果用户触发了某个事件,那么水合的过程会被立刻 挂起,待任务执行完毕之后再继续水合。

这带来了显著的性能优势,但也有很多潜在的问题,比如 Fluent UI 使用了 tabster 来处理焦点管理和键盘 导航之类的可访问性任务,然而这个库本身会改变 DOM 结构。

对于传统的水合流程,React 必须先完成水合,tabster 才会介入它的处理工作,包括对 DOM 的修改。然而 新的水合机制破坏了这个假设,组件水和的过程中,tabster 就会被调起。这个时候 DOM 结构一旦被其修改, 接下来的水合工作就会因为 DOM 结构不一致而出现错误,客户端程序将会发生崩溃。

为了解决这个问题,我们需要降级客户端的水合方法,具体的做法很简单,我们打开 entry.client.tsx,将 startTransition 的调用删除,整个文件会变成这样:

1
2
3
4
5
6
7
8
9
10
11
12
import { StrictMode } from "react";
import { RemixBrowser } from "@remix-run/react";
import { hydrateRoot } from "react-dom/client";

window.setTimeout(() => {
hydrateRoot(
document,
<StrictMode>
<RemixBrowser />
</StrictMode>
);
}, 0);

至此,水合过程就不会再出现问题了。

结语

在可以预见的一段时间里,可能各大组件库都没有办法很好的适配 React 18 带来的大范围架构变化,甚至有些 CSS in JS 方案因为过于「动态」无法被「静态分析」而宣布终止开发工作。这对下游开发者来讲,产生的影响 同样是巨大的,笔者希望这些简单的小经验能够帮助开发者平稳的度过这段颠簸的过渡期,同时也祝愿整个 React 生态能够早日适应这次「架构大地震」,重新为开发者带来平稳的开发体验。

For the English version, checkout this link.

Comments

No comments here,

Why not write something?