RCC 与 RSC 的环境隔离

前言

我们知道,在 React Server Component 环境下,渲染的运行时永远都是在 server 的,而在 RCC 中,两者环境都可能存在。为了控制一个库的引用方只能处于某种环境中,而在另一个环境中报错,我们可以使用 client-only 或者 server-only 库。

Next.js 是最先支持 React Server Component 的,并且遵循了 server-module-conventions rfc 下文都以 Next.js 展开。

这个 rfc 中指出,在 package.jsonexports 字段新增了 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-intlpackage.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

packages/fetch/package.json
{
  "name": "@shiro/fetch",
  "exports": {
    ".": {
      "react-server": "./src/fetch.server.ts",
      "default": "./src/fetch.client.ts"
    }
  },
  "devDependencies": {}
}

编写 fetch.server.ts 用于 RSC。

packages/fetch/src/fetch.server.ts
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。

packages/fetch/src/fetch.client.ts
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.jsondependencies 中。

使用为:

import { $fetch } from '@shiro/fetch' // 在 RCC 中 RSC 中使用方式一致

上面的实现,已经在 Shiroi 中实现,后续计划开源到 Shiro 中。


最后更新于 2024/7/26 16:15:59

更新历史

本书还在编写中..

前往 https://innei.in/posts/tech/my-first-nextjs-book-here#comment 发表你的观点吧。