React 19.2 之后,整整 11 个月只出安全补丁。9 月 9 号官方 blog 一发,19.3 带着五个功能落地:<Fragment> 能挂 ref 了、RSC 能直接渲染 Context 了、<ViewTransition> 转正、browser() 一行让客户端组件退出 SSR、Trusted Types 进来了。
我没停在"读了篇文章"的层面。我把五个功能全在我机器上跑了一遍: react@19.3.0 + Vite 8 的静态站装 3 个,Next 16.3.8 最小 RSC 工程装 2 个。跑出来两个地方跟流传的说法对不上:
- 一篇讲 19.3 的公众号文(也是本文选题来源)说 Fragment ref 是"对兄弟节点统一处理",读者很容易理解成
ref.current 是节点数组——第一次照这个理解写,真机直接崩。实际上它给的是一个 FragmentInstance 对象。
- Next 15 的仓库里
react 依赖早就指到 19.3,但 RSC 编译走的是 Next 内嵌的 react-dom 19.2 canary,没有 browser(),build 直接报错。不是你的版本没装对。
这篇讲 5 件事:我测了什么、两个坑怎么踩的、五个功能各自的最小可跑代码、一个五行总结表、以及哪些还轮不到你抄。
我测了什么
环境先摆出来(后面每个数字都对应这台机器):
| 项 |
值 |
| react / react-dom |
19.3.0(npm 正式版,2026-09-09 发) |
| Vite |
8.3.2(静态 SPA,Lab 1/3/5) |
| Next.js |
16.3.8(最小 RSC 工程,Lab 2/4) |
| Node |
24.21.0 |
| 验证浏览器 |
headless Chrome 153(bsk 驱动) |
五个功能的归属(官方 blog 的划分,别记混):
| 功能 |
属于 |
需要什么环境 |
| Fragment + useRef |
react 核心 |
任何 React 19.3 应用 |
| RSC 直渲 Context |
react-server |
RSC 框架(Next 16+) |
| ViewTransition + addTransitionType |
react 核心 |
浏览器,支持 View Transition API(Chrome 111+ / Safari 18+) |
use(browser()) |
react-dom |
RSC 框架(Next 16+) |
| Trusted Types 集成 |
react-dom |
浏览器 + CSP require-trusted-types-for 'script' |
注意第三行:<ViewTransition> 虽然用的是浏览器原生 View Transition API,但实现不在 react-dom 里,在 react 核心里(官方 blog 特意强调过)。将来 React Native 要支持,得等官方移植。
坑一:Fragment 的 ref.current 不是数组
这是五个功能里最容易看错的一个。官方文档的原文是一句话:"React provides a FragmentInstance as the ref value that implements methods for interacting with the DOM nodes wrapped by the Fragment."——ref 值是 FragmentInstance,一组方法,不是一组节点。
我第一版 demo 是这么写的:
const groupRef = useRef(null);
function flashAll() {
groupRef.current.forEach((el, i) => { // ← 想当然:current 是节点数组
el.style.background = '#1b3a5c';
});
}
<Fragment ref={groupRef}>
<span className="chip">卡片 A</span>
<span className="chip">卡片 B</span>
<span className="chip">卡片 C</span>
</Fragment>
真机点按钮,控制台给我:
TypeError: (e.current ?? []).forEach is not a function
查官方 Fragment 文档(/reference/react/Fragment 的 FragmentInstance 节)才知道,groupRef.current 是这样一个对象:
| 方法 |
干什么 |
旧世界等价物 |
addEventListener(type, fn) |
监听挂到所有第一级 DOM 子节点 |
循环 N 个节点逐个挂 |
getClientRects() |
一次拿回所有第一级子节点的包围盒 |
循环 N 个节点逐个 getBoundingClientRect |
observeUsing(observer) |
IntersectionObserver/ResizeObserver 组级挂 |
同上 |
focus() / focusLast() / blur() |
焦点全组管理(深度优先找 focusable) |
手动遍历 |
getRootNode() / scrollIntoView() / compareDocumentPosition() |
组当"一个节点"用 |
— |
按文档改完,组级点击监听只挂一次,点任意一张卡三张一起闪。getClientRects() 实测输出(Chrome 153,2026-10-02 跑的):
#1 x=45 y=291 87×56
#2 x=144 y=291 87×56
#3 x=242 y=291 87×56
一次调用,三个包围盒。旧写法要么多包一层 <div> 当容器(经常破坏 flex/grid,再叠 display: contents 打补丁),要么收集 N 个 ref 逐个挂。两条路 19.3 都省了。
两条 caveat(官方文档写的,我踩过第一条):
- 方法只作用于第一级 host(DOM)子节点——Fragment 里套了组件再套 div,观察的是那一层,不是深层。
<Fragment ref> 必须显式写 <Fragment ref={x}>,<>...</> 简写不接 ref。
坑二:Next 15 里根本没有 19.3
browser() 是这次 react-dom 的新 API:组件里 use(browser()),服务端渲染时它挂起(最近 Suspense 出 fallback),客户端 hydrate 后放行。替代的是 typeof window !== 'undefined' 判断和 useEffect(mounted) 状态机两套路子。
我在 Next 15.5.4 的最小工程里写上它,build 直接炸:
Attempted import error: 'browser' is not exported from 'react-dom'
TypeError: (0 , f.browser) is not a function
at g (.next/server/app/time-zone/page.js:1:2400)
当时第一反应是自己 react-dom 没装 19.3。查了——npm ls 里 react-dom@19.3.0 deduped,明明对。再查炸的是哪一份 react-dom:Next 把 server bundle 里的 react-dom 指向了自己内嵌的 dist/compiled/react-dom。读出它的版本字段:
$ node -p "require('next/dist/compiled/react-dom/server.edge.js').version"
19.2.0-canary-0bdb9206-20250818
19.2 的 canary,2025 年 8 月 18 日那份——没有 browser(),没有 19.3 的任何新 API。结论:Next 15 里装 react@19.3.0 没用,RSC 编译吃的是内嵌那份。
换 Next 16.3.8(npm view 确认它内嵌的 compiled react-dom 已有 browser 导出),同样的代码:
✓ Generating static pages using 1 worker (4/4) in 430ms
Route (app)
┌ ○ /
└ ○ /time-zone
build 通过。SSR HTML 验证(curl 原始响应,不是水合后的):
/time-zone 页:带 defaultValue="America/New_York" 的 TimeZone 组件,服务端 HTML 里直接是 America/New_York(不挂起);不带 defaultValue 的同组件,HTML 里是 Loading…(挂起了,客户端才显示真实时区)。一个页面里两个方向都验了。
这个坑值得单独说,因为它会污染所有"升级了 React 但框架还是 15"的项目:你 npm ls 看着版本对了,实际 RSC 链路吃的还是旧引擎。查内嵌版本一条命令的事:
node -p "require('next/dist/compiled/react-dom/server.edge.js').version"
五个功能,各自的最小可跑代码
1. Fragment + useRef(已真跑)
import { Fragment, useEffect, useRef } from 'react';
function ChipGroup() {
const groupRef = useRef(null);
useEffect(() => {
const inst = groupRef.current; // FragmentInstance,不是数组
if (!inst || typeof inst.addEventListener !== 'function') return;
const onClick = (e) => flashAllChips(e); // 一次挂到 3 个子节点
inst.addEventListener('click', onClick);
return () => inst.removeEventListener('click', onClick);
}, []);
return (
<Fragment ref={groupRef}>
<span className="chip" data-name="A">卡片 A</span>
<span className="chip" data-name="B">卡片 B</span>
<span className="chip" data-name="C">卡片 C</span>
</Fragment>
);
}
2. RSC 直渲 Context(Next 16 真跑)
客户端模块,以前要导出两个东西,现在一个:
// user-context.js('use client')
'use client';
import { createContext } from 'react';
export const UserContext = createContext(null);
// 19.3 前:还得导出 UserProvider 包装组件做中转。现在不用了。
服务端组件 Layout 里直接当标签用:
export default async function RootLayout({ children }) {
const user = await getCurrentUser(); // 服务端查数据库
return (
<html>
<body>
<UserContext value={user}>{children}</UserContext>
{/* ClientTree 里 useContext(UserContext) 直接读到 */}
</body>
</html>
);
}
我验证的完整版在 Next 16.3.8 最小工程里真跑过。服务端组件 Layout await 一个"查用户"的 Promise,把结果直接塞进 <UserContext value>;客户端组件用 useContext 读。curl 拉原始 HTML(浏览器还没跑任何 JS),服务端查出来的值已经明文躺在 HTML 里:
当前用户(useContext 直读,无 Provider 中转):
{
"name": "rex(RSC 查出来的)",
"role": "admin"
}
这段 HTML 是服务端生成的——整条链路没有任何 Provider 组件:Context 对象从客户端模块导入,value 从服务端组件直接传入,RSC 传输协议负责把 value 序列化进客户端树供 useContext 消费。把旧写法里那个纯转发 Provider 文件删掉,页面一个字没变。
3. ViewTransition + addTransitionType(已真跑)
官方 blog 的例子是视频卡轮播,我改成三张彩色卡,前后翻方向差异肉眼可见。核心就两段:
import { ViewTransition, addTransitionType, startTransition, useState } from 'react';
function next() {
startTransition(() => {
addTransitionType('next'); // 给这次更新打标签
setSelected((c) => (c + 1) % 3);
});
}
<ViewTransition
key={slide.id} // 换 key:旧卡 exit、新卡 enter
enter={{ next: 'from-right', previous: 'from-left' }}
exit={{ next: 'to-left', previous: 'to-right' }}
>
<Slide n={selected} />
</ViewTransition>
class 背后的动画是 CSS 伪类(官方文档的写法):
::view-transition-new(.from-right) { animation: vt-in-r .35s ease; }
::view-transition-old(.to-left) { animation: vt-out-l .35s ease; }
@keyframes vt-in-r { from { transform: translateX(100%); } }
三条坑(前两条在官方 Troubleshooting 里,第三条是文档"排队"段落的推论,我实测的是现象不是机制):
- 只有 Transition 标记的更新才播动画。
startTransition 内、<Suspense> 揭示、useDeferredValue 更新。普通 setState(比如输入框打字)是紧急更新,直接改 DOM。这是设计不是 bug——紧急更新不该被动画拖慢。
- enter/exit 只在 ViewTransition 是"该 Transition 里第一个插入/删除的组件"时生效。它上面还有别的 DOM 节点(哪怕一个
<div> 标题),静默不触发。官方原文:"Only top-level ViewTransitions animate on exit/enter."
- 连续快速点击会合并:A→B 动画没播完又触发 C、D 更新,第一个播完后直接 B→D 一次,不叠层。
另外配 onEnter/onExit/onUpdate 事件可以拿 Web Animations API 做 JS 动画,事件参数里 instance.new 就是 ::view-transition-new 伪元素。
4. use(browser())(Next 16 真跑,行为见坑二)
完整语义(服务端挂起、客户端放行)只在 RSC 环境成立。我做的最小例是官方 useQuery 模式的复刻:
// time-zone-client.js('use client')
'use client';
import { use } from 'react';
import { browser } from 'react-dom';
export function TimeZone({ defaultValue }) {
if (defaultValue) return <p>{defaultValue}</p>; // 有默认值就不 opt-out
use(browser()); // 服务端:挂起;客户端:放行
return <p>本设备时区:{new Intl.DateTimeFormat().resolvedOptions().timeZone}</p>;
}
两个方向我都验了——同一个组件,带不带 defaultValue,curl 拉原始 HTML:
带 defaultValue(America/New_York)→ 服务端直接出值,不挂起:
<p>America/New_York</p>
不带 defaultValue → 服务端挂起,HTML 里是 Suspense fallback:
<!--$!--><template data-dgst=""></template>
<p>Loading…(SSR 阶段停在这里)</p>
<!--/$-->
第一段:有默认值,组件早退,use(browser()) 根本没跑到,服务端 HTML 里就是 America/New_York。第二段:没有默认值,use(browser()) 让组件在服务端挂起,HTML 里落的是 Suspense fallback(<!--$!-->...<!--/$--> 是 RSC 的挂起标记),客户端 hydrate 后才把真时区填进去。一行 use(browser()),两个方向的服务端行为各走一边——这就是它替代 typeof window 判断和 useEffect(mounted) 状态机的地方:挂起状态直接进 Suspense 体系,和其他数据请求共用一套加载协调,不再有"服务端出了假值、hydration mismatch"那类事故。
条件调用合法(官方文档明确:use(browser()) 可以在条件语句里、早退之后调),但有个铁律:对同一个组件实例,条件必须恒定(比如 initialData 要么一开始有、要么一开始没有)。写成 if (data === null) use(browser()) 这种随状态翻转的条件——hooks 数量在渲染间变了,必炸。
5. Trusted Types(已真跑,有个 headless 边界)
这条最反直觉,因为它不是新功能,是旧行为的修复。
站点开了 Content-Security-Policy: require-trusted-types-for 'script' 之后,浏览器规定:往 innerHTML 这类"注入口"填的值必须是 sanitizer policy 产出的 TrustedHTML 对象,裸字符串一律拦。旧版 React 把一切值先 '' + value 强转成字符串再交给 DOM API——等于你开好的安全门它拆了。19.3 起 React 原样传递,policy 第一次对 React 生效(官方 blog 原话:以前是 "coerced values to strings",现在 "passes these values through without coercion")。
我的验证姿势:给 Vite preview 挂上这个 CSP 头,然后用"裸字符串塞 innerHTML"复刻 ≤19.2 行为,看它被拦:
{ "trustTypes": false,
"raw": "blocked: Failed to set the 'innerHTML' property on 'Element': This document requires 'TrustedHTML' assignment." }
核心验证点(CSP 真的拦了裸字符串)通过了。但 window.trustTypes 在这个 headless Chrome 里是 null——Chromium 要开 --enable-features=TrustedTypes 才暴露策略 API,所以"走 createPolicy 再注入"那半边我没跑成,页面上如实写着 skipped,不是假装通了。有桌面 Chrome 的读者跑工程里的 Lab 5,按钮②就是完整闭环。
五行总结
| 功能 |
旧写法 |
19.3 写法 |
我验到哪 |
| 兄弟节点统一操作 |
套容器 div / N 个 ref |
<Fragment ref>,ref.current = FragmentInstance |
真机跑通(含一次 forEach 崩溃) |
| RSC 传数据给客户端树 |
客户端导出 Provider 包装组件 |
RSC 直接 <Context value> |
Next 16 SSR HTML 验证 |
| 出入/换位动画 |
手搓 document.startViewTransition |
<ViewTransition> + CSS 伪类 |
真机轮播前后翻 |
| 客户端专属组件 |
typeof window / useEffect(mounted) |
use(browser()) |
Next 16 SSR fallback 验证;Next 15 无此 API |
| CSP Trusted Types |
React 强转字符串绕过 policy |
原样传递,policy 生效 |
CSP 拦截验证;policy 侧 headless 没跑成 |
哪些轮不到你抄
- 没上 RSC 框架(Next 15 / Vite 纯 CSR):Lab 2 和 4 对你不存在,别在纯 CSR 里模拟 browser(),它在那等价于 no-op。
- Next 15 用户:想用上 19.3 的 react-dom 新 API,先升 Next 16,再谈别的。
<ViewTransition> 目前只支持 DOM(React Native 在移植),且要求浏览器原生 View Transition API——Safari 18 以下没有,@keyframes 白写。
FAQ
Q:FragmentInstance 什么时候比事件委托强?
A:要的是"组级状态"而不只是委托监听——比如 getClientRects() 一次拿包围盒布局计算、observeUsing 组级可见性观察。纯监听其实事件委托一个容器就够,FragmentInstance 的价值在"没有任何容器"这个前提成立时。
Q:19.3 值得现在就升吗?
A:对 react 本身是纯增量(五个新 API + 一批 bug fix,changelog 里 useDeferredValue 卡旧值、Suspense fallback 的 context 传播都修了)。但框架侧看:Next 15 内嵌的还是 19.2 canary,"升到 19.3"实际动作是"升到 Next 16",那是个大版本,别只冲着 19.3 上。
Q:Trusted Types 和 React 的关系以后会变吗?
A:方向已经写死在文档里:19.3 起 React 不再强转字符串。你的 CSP policy 从此能拦到 React 的注入口——包括 dangerouslySetInnerHTML。开了 require-trusted-types-for 的生产站,建议把 React 提到 19.3。
诚实声明
- 本机跑的部分:SPA 三个 lab 的交互输出(包围盒坐标、CSP 拦截报错、轮播切换)都是 2026-10-02 在我这台机器上真实输出,不是截图转写。
- 没跑成的部分:① Trusted Types 的
createPolicy 侧,headless Chrome 未启用该 feature,页面上跳过并如实标注;② ViewTransition "连点合并"我观察到的是现象,机制是文档表述,没拆 React 源码验证排队逻辑。
- Next 15.5.4 内嵌 react-dom 版本号
19.2.0-canary-0bdb9206-20250818 是我读的 dist/compiled 里写死的字符串,未对 15 全系逐版本核实——可能 15.5.x 后期 patch 已换,我没全测。
资料出处
- React 官方 blog:react.dev/blog/2026/09/09/react-19-3(五个功能的官方说明与全部示例出处)
- Fragment / ViewTransition / browser / addTransitionType 参考页:react.dev/reference/
- 选题来源:Web情报局《今年 React 终于更新,全是 API 糖!》(2026-09-30)——本文修正它"Fragment ref 统一处理兄弟节点"一句容易读成数组的表述
- 工程:本地
react-19-3-transform 项目(SPA + Next 16 最小 RSC demo),五份 lab 代码全可复现