找回密码
立即注册
搜索
发回帖 发新帖

6355

积分

0

好友

770

主题
发表于 15 小时前 | 查看: 6| 回复: 0

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 里,第三条是文档"排队"段落的推论,我实测的是现象不是机制):

  1. 只有 Transition 标记的更新才播动画。startTransition 内、<Suspense> 揭示、useDeferredValue 更新。普通 setState(比如输入框打字)是紧急更新,直接改 DOM。这是设计不是 bug——紧急更新不该被动画拖慢。
  2. enter/exit 只在 ViewTransition 是"该 Transition 里第一个插入/删除的组件"时生效。它上面还有别的 DOM 节点(哪怕一个 <div> 标题),静默不触发。官方原文:"Only top-level ViewTransitions animate on exit/enter."
  3. 连续快速点击会合并: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 代码全可复现



上一篇:AI 时代软件公司靠什么活?OpenAI 下场做咨询,三对底层关系
下一篇:假期看 Agent 分化:ToC、专家与 ToB 协作,哪条路能跑通?
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-10-5 22:17 , Processed in 0.092103 second(s), 41 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表