探讨 React Server Component 与 React Client Component 的环境隔离与实践
前言
我们知道,在 React Server Component 环境下,渲染的运行时永远都是在 server 的,而在 RCC 中,两者环境都可能存在。为了控制一个库的引用方只能处于某种环境中,而在另一个环境中报错,我们可以使用 client-only
或者 server-only
库。
Next.js 是最先支持 React Server Component 的,并且遵循了 server-module-conventions rfc 下文都以 Next.js 展开。
这个 rfc 中指出,在 package.json
中 exports
字段新增了 react-server
导出,这个字段的导出位置只会被 RSC 中的引用链使用。
例如,client-only/package.json
是这样定义的。
{
"exports": {
".": {
"react-server": "./error.js",
"default": "./index.js"
}
}
}
react-server
会写在第一行,优先被识别。那么在 RSC 组件中引用 client-only
的话实际引用的就是 ./error.js
,这个时候就会报错。
实际使用场景
在全新的 React 架构中,RSC + RCC 两种环境的结合已经是常态了。那么对于库来说,需要同时兼容两种环境下的使用方式就需要这个特征了。
这里我们已 next-intl 为例,这是一个 i18n 的库。这个库的使用方式在 RSC 中还是在 RCC 中都是相同的。例如
在 RCC 中,我们这样去使用 useTranslations
。
'use client'
import { useTranslations } from "next-intl";
export default function Page() {
const t = useTranslations();
return // ReactNode
}
在 RSC 中,我们这样去使用 useTranslations
。
import { useTranslations } from "next-intl";
export default async function Page({ params: { locale } }: PageParams) {
unstable_setRequestLocale(locale);
const t = useTranslations();
return // ReactNode
}
你可能发现了,这两种环境下,使用的方法是一样的, 方法的导出也是一样的。但是你注意到了,在 RSC 中是不能用 hooks 的,但是这里却没有问题。
这里就设计到了前面提到的 server-module-conventions 了,在 RSC 中导入的 useTranslations
其实并不是一个 hook 而只是一个普通的方法,只不过为了保证方法调用的一致性,名称也是保证了一致。
继续挖掘 next-intl
的 package.json
发现他的 exports
是这样定义的。
{
"exports": {
".": {
"types": "./dist/types/src/index.react-client.d.ts",
"react-server": "./dist/esm/index.react-server.js",
"default": "./dist/index.react-client.js"
}
}
}
而在 RSC 中真正指向的其实是 https://github.com/amannn/next-intl/blob/main/packages/next-intl/src/react-server/useTranslations.tsx ,这些都是对 RSC 下的实现。
在业务中使用场景
这里列举一个最近遇到的场景,关于需要在两个场景下区分 ofetch 实例。我们知道,在 Next.js 的 RCC 和 RSC 下获取 Cookie 的方式是不一样的,另外在预渲染页面时请求的接口总是在 RSC 下发出的,我们或许需要在请求发出时,附加一些请求信息,比如鉴权相关的 header、user-agent、或者转发真实请求者的 IP 信息等等。
这种情况下,我们可以针对两个环境编写两个不同的实例。
首先,建立一个内部包。例如 packages/fetch
。
建立 package.json
。
{
"name": "@shiro/fetch",
"exports": {
".": {
"react-server": "./src/fetch.server.ts",
"default": "./src/fetch.client.ts"
}
},
"devDependencies": {}
}
编写 fetch.server.ts
用于 RSC。
import 'server-only'
import { nanoid } from 'nanoid'
import { cookies, headers as nextHeaders } from 'next/headers'
export const $fetch = createFetch({
defaults: {
timeout: 8000,
onRequest(context) {
const cookie = cookies() // 使用 Next.js cookies() 取得 cookie
const token = cookie.get(TokenKey)?.value
const headers: any = context.options.headers ?? {}
if (token) {
headers['Authorization'] = `bearer ${token}`
}
context.options.params ??= {}
if (token) {
context.options.params.r = nanoid()
}
if (context.options.params.token || token) {
context.options.cache = 'no-store' // 命中鉴权后不要告诉 Next.js 不缓存这个请求,反正数据外泄
}
if (isDev) {
console.info(`[Request/Server]: ${context.request}`)
}
const { get } = nextHeaders() // 使用 headers() 方法获取原始请求头
const ua = get('user-agent')
const ip =
get('x-real-ip') ||
get('x-forwarded-for') ||
get('remote-addr') ||
get('cf-connecting-ip')
if (ip) {
headers['x-real-ip'] = ip
headers['x-forwarded-for'] = ip
}
headers['User-Agent'] =
`${ua} NextJS/v${PKG.dependencies.next} ${PKG.name}/${PKG.version}`
context.options.headers = headers
},
onResponse(context) {
if (isDev) { // 这里一定是 ServerSide
console.info(
`[Response/Server]: ${context.request}`,
context.response.status,
)
}
},
},
})
编写 fetch.client.ts
用于 RCC。
import 'client-only'
import Cookies from 'js-cookie'
function getToken(): string | null { // 这里只能使用 js-cookie 去获取浏览器端 cookie
const token = Cookies.get(TokenKey)
return token || null
}
export const $fetch = createFetch({
defaults: {
timeout: 8000,
onRequest(context) {
const token = getToken()
const headers: any = context.options.headers ?? {}
if (token) {
headers['Authorization'] = `bearer ${token}`
}
headers['x-session-uuid'] =
globalThis?.sessionStorage?.getItem(uuidStorageKey) ?? uuid
context.options.params ??= {}
if (context.options.params.token) {
context.options.cache = 'no-store'
}
if (isDev && isServerSide) {
// eslint-disable-next-line no-console
console.info(`[Request]: ${context.request}`)
}
context.options.headers = headers
},
onResponse(context) {
if (isDev && isServerSide) { // 这里还是有必要区分 ServerSide
console.info(`[Response]: ${context.request}`, context.response.status)
}
},
},
})
现在,两者都有了相同的方法,并且都导出了。那么我们就可以在业务中使用了。为了更好的 TypeScript 支持,我们需要修改 "moduleResolution": "Bundler"
。对了,别忘记,在 package.json
中 link 这个依赖。例如这里,"@shiro/fetch": "link:./packages/fetch"
添加到项目的 package.json
的 dependencies
中。
使用为:
import { $fetch } from '@shiro/fetch' // 在 RCC 中 RSC 中使用方式一致
参考代码:https://github.com/Innei/Shiro/tree/main/packages/fetch