React 19.3 把「这个组件别在服务端渲染」变成了一行 API——那些 mounted 标志位可以删了

每个 React SSR 项目里,大概都躺着一段这样的代码:`const [mounted, setMounted] = useState(false)`,配一个 `useEffect(() => setMounted(true), [])`,然后 `if (!mounted) return null`。写这段代码的原因只有一个——某个组件在服务端渲染不出有用的东西,只好先假装它不存在,等进了浏览器再让它真渲染。

这件事从 2026 年 9 月 9 日 React 19.3 发布起,有了官方答案:`use(browser())`。它会替你把”这个组件别在服务端渲染”讲清楚,不用再手写标志位,也不用再拿 `typeof window` 去猜环境。

## 先说结论

组件里调一次 `use(browser())`,效果是:

– **服务端**:这个组件挂起(suspend),HTML 里留下最近的 “ fallback。
– **客户端**:`use(browser())` 立刻返回 `undefined`,组件正常渲染,水合(hydration)阶段也不挂起。

关键是——**没有第二次渲染,没有标志位,没有 environment 判断**。它就是你这些年手写的那套 `useEffect + isClient` 的正式版。

“`jsx
import { use } from ‘react’;
import { browser } from ‘react-dom’;

function LocalClock() {
use(browser());
const zone = Intl.DateTimeFormat().resolvedOptions().timeZone;
return

{zone}

;
}
“`

外层照旧用 Suspense 包一下,服务端首屏就显示 fallback,客户端水合后换成真实时区。

## 为什么以前那套写法是在埋雷

不用这个 API,你其实只有三条老路,每条都有代价。

**第一条:mounted 标志位。** `useState(false)` + `useEffect` 里置 true。问题是服务端和客户端首帧都得渲染一次空内容,用户会看到一次明显的内容闪动;而且如果 fallback 和真实内容差异大,布局会跳。

**第二条:`typeof window === ‘undefined’` 环境判断。** 这个更隐蔽——它恰恰是 hydration mismatch 的经典成因。服务端走一个分支,客户端走另一个分支,两边产出的 DOM 不一致,React 会直接丢掉服务端 HTML 重建这棵子树。

**第三条:框架层的 `dynamic(…, { ssr: false })`。** 能用,但它绑在框架上(Next.js 专有),你在纯 React 或换框架时就得重写,而且它是在模块粒度上做开关,不是你组件里的一行逻辑。

hydration mismatch 的代价不只是控制台一条警告。React 的报错原文是 **”this tree will be regenerated on the client”**——意思是这一段服务端渲染的 HTML 被整个丢弃,在浏览器里从头再渲染一遍。你为 SSR 付出的首屏收益,在这一段上直接归零,用户还可能看到内容可见地跳一下。

## use(browser()) 到底怎么做的

`browser()` 本身返回一个不透明的值,你不该去读它。把它的返回值交给 `use()`,在服务端渲染期间就等价于”抛一个 Promise”,于是组件挂起,最近 Suspense 边界的 fallback 被写进 HTML。到了浏览器,`use(browser())` 返回 `undefined`,组件照常跑。

React 在生成的 HTML 里会留一个标记,表示”这个边界需要客户端渲染”。开发构建下这个标记还带一条信息:`data-msg=”Switched to client rendering because a component requested it.”`——注意措辞是 **requested**(主动请求),不是 failed(失败)。它不是错误降级,是你明确要的行为。

它还可以带一个”原因”参数:

“`jsx
use(browser(‘时区来自设备,服务端拿不到。’));
“`

这个字符串永远不会发到浏览器,只在服务端被报告出来。如果你传一个函数,React 会把它当作 Error 处理,附上堆栈——排查时很有用。

服务端还有一个钩子能接住这件事:

“`js
renderToReadableStream(, {
onBrowserBailout(error, info) {
console.log(‘browser bailout:’, error.message);
},
});
“`

`onBrowserBailout` 会在组件因 `use(browser())` 退出服务端渲染时触发。这意味着你可以精确统计”这一次 SSR 里有多少组件主动转成了客户端渲染”——而不是靠猜。

## 什么时候该用它,什么时候别用

**该用**:组件依赖浏览器独有的东西——`localStorage`、`Intl` 解析出的本地时区、`window.innerWidth` 画的图表、`navigator` 能力探测。这些在服务端本来就产不出有意义的 UI,让它们挂起并由 fallback 占位,比渲染一个错误内容再水合修复干净得多。

**别滥用**:它是”opt out of server rendering”,本质是给组件开小灶。如果大量组件都这么干,SSR 的优势就被你一点点掏空了——每个 bailout 的边界都意味着首屏少一段内容、多一次客户端渲染。能用稳定的服务端数据渲染,就别退到客户端。

还有一个组合点值得记:`use(browser())` 可以放在条件语句里,也可以放在 early return 之后。这让”有默认值就用默认值、没有才转客户端”这种写法变得自然:

“`jsx
function TimeZone({ defaultValue }) {
if (defaultValue) return

{defaultValue}

;
use(browser());
const local = Intl.DateTimeFormat().resolvedOptions().timeZone;
return

{local}

;
}
“`

服务端有默认值就直接渲染进 HTML,没有才退回 fallback。这比一刀切地”整个组件别 SSR”精细得多。

## 下一步怎么落地

如果你手上正有 SSR 项目,可以按这个顺序做:

1. **全局搜 `useState(false)` 配 `setMounted(true)`**,再看一眼 `typeof window` 判断——这些基本就是 `use(browser())` 的候选点。
2. **挑一个非关键组件先试**(比如时区显示、用户本地化日期),确认服务端 HTML 里 fallback 正常、客户端水合无警告。
3. **接上 `onBrowserBailout`**,观察线上到底有多少组件在 bailout——如果数量超预期,说明你的组件边界该重新划了。
4. 升级到 `react@19.3` / `react-dom@19.3`,这是个小版本,无破坏性变更;`use(browser())` 和 Trusted Types 都是可选的新能力。

顺带一提,react-dom/server 已经把这件事变成一等公民了——组件”要求只在客户端渲染”不再是一个需要你手写 hack 的灰色地带,而是一个有名字、有标记、有回调、可观测的正式机制。你那段 `mounted` 标志位,真的可以删了。

评论区

0 条评论

登录后可评论。

阿速·性能优化 685 阅读