首页 / 文章

React 19.3的更多细节:submitter属性、全屏相关参数以及打包体积的变化。

React 19.3各项细微变化的实用解析:submitter属性、全屏事件参数、并行过渡效果、onReset函数、重要的错误修复以及实际体积的增加。

发布者:REACTAPP.TOP阅读约 11 分钟2490 词

来源: Everything Else in React 19.3: submitter, Fullscreen Events, Independent Transitions, the Bug Fixes and the Bundle Cost

版本说明通常会重点介绍几项重大新功能,React 19.3也不例外。在那些主要特性之下是一份“其他值得注意的变更”列表,以及一页长长的错误修复内容,其中大部分只是简短的一行文字,很容易被忽略。不过,其中有几条会改变你当前编写组件的方式,解释一些你可能已经绕过的错误,或者影响你交付给用户的版本。本指南将大致按照这些内容对你的重要性顺序进行介绍,并在最后给出一个对升级后大家总会问的问题的解答:打包体积增加了多少?

确定是哪个按钮提交了表单

一个包含两个提交按钮的表单,比如保存和保存并关闭,需要知道用户点击了哪一个按钮。浏览器早已给出了答案:内置的SubmitEvent提供了一个submitter属性,指向触发提交的按钮。不过React的合成事件并未传递这一信息。常见的解决方法是通过读取e.nativeEvent.submitter,或者不太优雅地通过状态记录最后点击的按钮,同时希望没有键盘提交的操作被忽略。React 19.3在合成提交事件中加入了submitter属性(#35590)。

最简单的方法是在两个版本中分别运行同一个表单来观察差异。演示代码从导入部分开始;使用命名空间导入只是为了让组件能够打印出React.version。

import { useState } from 'react';
import * as React from 'react';

该组件会阻止默认的导航行为,然后将 React 版本以及 e.submitter 的值写入状态中。每个按钮都有其自身的 value,这正是实际代码中进行条件判断时所依据的内容。

export default function App() {
  const [msg, setMsg] = useState('press either button');
  return (
    <form onSubmit={e => {
      e.preventDefault();
      setMsg(`${React.version}: e.submitter → ${e.submitter ? e.submitter.value : String(e.submitter)}`);
    }}>
      <button value="save">Save</button>{' '}
      <button value="save-close">Save &amp; close</button>
      <p className="msg">{msg}</p>
    </form>
  );
}

有一条规则用于将输出行设置为等宽字体。

.msg { margin: 10px 0 0; font-family: ui-monospace, monospace; }

19.3 版本中的相同表单

演示的 19.3 版本与之前完全一致,这正是目的所在:只有 React 版本发生了变化,导入的依赖项则保持不变。

import { useState } from 'react';
import * as React from 'react';

组件也是如此。

export default function App() {
  const [msg, setMsg] = useState('press either button');
  return (
    <form onSubmit={e => {
      e.preventDefault();
      setMsg(`${React.version}: e.submitter → ${e.submitter ? e.submitter.value : String(e.submitter)}`);
    }}>
      <button value="save">Save</button>{' '}
      <button value="save-close">Save &amp; close</button>
      <p className="msg">{msg}</p>
    </form>
  );
}

样式表也是。

.msg { margin: 10px 0 0; font-family: ui-monospace, monospace; }

在19.2版本中,由于该合成事件没有此字段,显示的值为undefined。在19.3版本中,根据所点击按钮的不同,会显示save或save-close。需注意这一变化会影响哪条代码路径。如果通过表单的Actions属性而非onSubmit处理程序提交数据,React在构建FormData时就已经读取了提交元素的信息(#29028),因此点击按钮对应的name和value值早已存在于操作数据中。这一新属性对事件处理程序而言非常重要。

实用提示:只有当你在每个按钮上真正设置了value属性(通常还包括name属性)时,提交元素的value值才有意义。没有设置这些属性的按钮会生成空字符串,很容易被误认为是功能缺失。

全屏事件作为一等属性

Fullscreen API会在相关元素上触发fullscreenchange和fullscreenerror事件。React无法识别这些事件名称,因此像视频播放器这样的组件不得不在效应中手动使用addEventListener来添加监听器,并在清理时记得移除它们。React 19.3新增了onFullscreenChange和onFullscreenError作为常规属性(#34621)。

该演示需要一个用于引用全屏元素的ref,以及一个用于存储事件日志的状态。

import { useRef, useState } from 'react';

注意,这两个处理函数都直接作用于 div 元素。状态变化处理函数会检查 document.fullscreenElement,以判断该元素是刚进入全屏状态还是刚退出全屏状态,因为该事件在两种情况下都会触发。进入全屏按钮还会为 requestFullscreen() 返回的承诺添加 .catch 处理逻辑,因为被拒绝的情况既会以承诺被拒绝的形式出现,也会以错误事件的形式出现。

export default function App() {
  const box = useRef(null);
  const [log, setLog] = useState([]);
  const add = line => setLog(l => [...l, line]);  return (
    <div
      ref={box}
      className="stage"
      onFullscreenChange={() => add(document.fullscreenElement ? 'entered fullscreen' : 'left fullscreen')}
      onFullscreenError={e => add('fullscreen error: ' + e.type)}
    >
      <button onClick={() => box.current.requestFullscreen().catch(err => add('rejected: ' + err.message))}>
        Go fullscreen
      </button>{' '}
      <button onClick={() => document.fullscreenElement && document.exitFullscreen()}>Exit</button>
      <pre className="log">{log.join('\n') || 'no events yet'}</pre>
    </div>
  );
}

样式表使用 :fullscreen 伪类在舞台占据整个屏幕时将内容居中显示,同时使用 light-dark() 函数选择与整体配色方案相匹配的背景色。

.stage { padding: 12px; border-radius: 10px; background: light-dark(#f1f5f9, #262a33); }
.stage:fullscreen { display: grid; place-content: center; }
.log { margin: 10px 0 0; font-size: 14px; }

onFullscreenError会在浏览器拒绝全屏请求时触发。常见的原因包括在非用户操作触发的场景下调用requestFullscreen(),以及在该请求位于没有设置allow="fullscreen"属性的<iframe>内部。如果在第三方页面中嵌入了播放器,而全屏功能毫无反应,首先应检查的就是是否缺少iframe的相应权限,而新的属性则为记录这一错误提供了便捷的方式。

过渡效果不再相互等待

更新日志指出,现在的每个过渡效果都会独立渲染,而不会与其他效果合并为一次整体渲染,这样缓慢的过渡就不会阻塞其他无关的过渡效果。这一变更(#37290)还引入了enableParallelTransitions标志。

在 19.3 版之前,如果当 React 还在渲染第一个更新时又收到了第二个 startTransition 更新,这两个更新会被合并处理。正在进行的渲染会重新启动并应用这两个更新,而在该合并后的渲染完成之前,任何一个更新都无法被提交。因此,一个快速且成本低廉的过渡操作可能会被一个与之无关但成本较高的过渡操作所拖累。现在,每个过渡操作都是独立的处理单元。如果你想了解 React 是如何在内部追踪这些处理单元的,我们关于 React 如何通过位掩码编码更新优先级 的文章介绍了其底层机制。

编写过渡逻辑的方式不变,React 的排序保证依然有效:后续的更新永远不会与旧状态同时被提交。不过有一个假设不再成立——通过两次不同的 startTransition 调用发出的更新现在可能会被分配到不同的帧中。如果有多个更新需要同时显示,应将其放在同一个 startTransition 调用中。

不要期望在简单的示例中看到这一现象。尝试使用包含 300 个组件的复杂结构,并在 50 毫秒后安排一个简单的快速更新,结果在 19.2 和 19.3 版本中都是快速更新在慢速更新之后才被提交。这一改进体现在调度器层面,只有在真实负载下的大型应用中才能显现,而在小型演示中则看不出来。

表单:操作后的 onReset 以及新的错误提示文字

当表单的 Action 完成时,React 会自动重置表单。此前它在事件系统关闭的情况下执行重置操作,这意味着 reset 事件会被忽略。原本用于清除受控状态的 onReset 处理函数从未被触发,因此受控输入框会一直显示之前的值(#35176,该修复也解决了 #33630 问题)。在 19.3 版本中,自动重置时也会触发 onReset。如果你之前是通过监听原生的 reset 事件来绕过此漏洞的,现在可以改用该属性并删除相应的效果函数。

还有一个相关的界面改动:useActionState 的错误信息现在使用的是“action state”而非“form state”(#35790)。功能本身没有变化,但需要更新那些基于旧表述的日志搜索规则、警报规则或测试用例。

开发阶段的变化:严格模式、use()函数与快速刷新

有三项变化仅影响开发版本,但在升级后却可能引发意想不到的问题。

  • 水合过程中的严格模式。在严格模式下进行水合时,效果函数现在会被调用两次(#35961),这与React 18以来客户端渲染的根节点的行为一致。如果某个服务器端渲染的应用仅因为水合过程跳过了第二次调用而看似符合严格模式的要求,那么这一变化将会暴露其问题。解决方法仍是常规的:让效果函数具备幂等性,并为其提供适当的清理机制。
  • 关于条件性使用 use() 的警告。 允许在特定条件下调用 use。问题出在某个组件在第一次渲染时通过 use() 暂停执行,而在重试时却选择了根本不会调用 use() 的分支。这样一来,Suspense 边界就会因错误的原因而解决。React 会在此时发出开发警告,说明某个库在之前的渲染中通过 use() 暂停了执行,但在执行完成后又跳过了 use() 调用,这属于对该钩子的错误使用。警告内容暗示问题往往出在数据库而非用户自己的组件上(#37104, #37203, #37491)。
  • 快速刷新问题的修复。热重载现在可以处理三种情况:通过lazy()加载的组件、发生变化的memo()比较函数,以及将某个组件转换为另一种类型的修改(#36965、#36964、#36963、#36950)。如果某些修改之前会导致整个页面重新加载,原因就在于此。
  • 更小的DOM变更

    • resize事件的处理现在会像滚动事件一样被批量处理到下一帧(#35117),因此在帧处理过程中有点击事件发生时,通过resize监听器设置的状态不会再被提前刷新。
    • maskType已被识别为SVG属性(#35921)。
    • 模块资源现在可以携带优先级提示:将 fetchPriority 传递给 ReactDOM.preloadModule 或 ReactDOM.preinitModule,React 会将其作为 fetchpriority 属性渲染出来(#36835)。这样,RSC 打包插件就可以将客户端组件的代码块标记为低优先级。非模块形式的 preload 和 preinit 函数早已支持此功能。
    • credentialless 被视为 <iframe> 上的布尔属性(#36148);如果使用 credentialLess 这种写法,React 会提示正确的大小写形式(#36322)。
    • 当 innerHTML 没有变化时,React 不会再重新赋值(#36949)。使用完全相同的 dangerouslySetInnerHTML 重新渲染时,浏览器不会再解析该标记,从而保留其中的 DOM 状态。
    • type="number" 类型的输入框所对应的 defaultValue 属性现在与其他类型输入框的行为一致了(#36980)。
    • toggle 事件会复制弹出框的 source 属性(#37389)。

    服务器端渲染与服务器组件

    • 现在,服务器端抛出的错误会连同其 cause 信息一起传递给客户端(#35810),而且 AggregateError 对象仍保留其 errors 数组(#36156)。用于处理错误链的错误边界和日志记录代码在服务器端与客户端都能看到相同的结构。
    • 由于 Flight 能识别 <Activity>,服务器组件现在可以渲染该元素了(#34697)。
    • react-dom/server 在 Deno 环境下不再出现卡顿现象(#35235),而且 Bun 的入口点现在已包含所有 Node API(#34193)。

    您可能已经遇到过的错误修复

    完整列表相当长。以下是最可能与你已处理过的错误相匹配的条目。

    • useDeferredValue 可能会卡在过时的值上(#36134)。偶尔无法更新到的延迟值属于 React 的错误,而非你代码中的问题。
    • 上下文变化并不总是能传递到 Suspense 的回退内容(#36160),或是边界之后仍处于挂起状态的子树(#35839)。
    • forwardRef 和 memo 组件中的 useEffectEvent 会读取过时的值(#34831)。
    • 当 <Activity> 树被隐藏时,useSyncExternalStore 会忽略存储中的变更(#36947)。此外,被隐藏的树不会将其 <title> 提升到上层(#34983),也不会将抛出的错误显示在可见界面中(#35074),同时也不再会在入口点展示其渲染的内容(#35091)。
  • 当状态发生无关变化时,表单状态会被重置(#34075)。
  • 使用CSP随机数的应用会在nonce属性上出现错误的初始化不匹配警告(#37030)。
  • 已委托的元素以及原本处于焦点状态的元素的焦点处理问题已得到修复(#36010)。
  • 两个与<ViewTransition>相关的崩溃问题已修复,一个出现在Mobile Safari中(#35337),另一个则是在与SuspenseList结合使用时出现(#35520)。如果您尝试过实验性版本,这些问题尤为重要。
  • 在Server Components方面,decodeReplyFromBusboy函数会丢失FormData中的数据项(#36468),深度的异步调用链可能导致栈溢出(#35612),而调试信息则会呈指数级增长,最终引发RangeError异常(#37481)。
  • 所有拉取请求的链接可在React变更日志中找到。

    升级带来的字节级成本

    发布后不久,r/reactjs 讨论区中有一条评论指出 react-dom-client.production.js 的大小从 523.5 kB 增加到 610.5 kB,并询问原因。通过对比 unpkg 上发布的 19.2.0 和 19.3.0 版本,得到以下数据:

    • cjs/react-dom-client.production.js 的原始大小:19.2.0 版本为 536,016 B,19.3.0 版本为 625,168 B,增加了 89 kB(16.6%)。
    • 相同文件经过级别为 9 的 gzip 压缩后的大小:94.7 kB 对比 110.5 kB,增加了 15.7 kB。
    • cjs/react-dom-server.node.production.js 的原始大小:277,046 B 对比 300,544 B,增加了 23.5 kB。
    • cjs/react.production.js 的原始大小:17,217 B 对比 18,040 B,增加了 0.8 kB。

    Reddit评论中的数字均为以KiB为单位表示的大小。原因很简单:View Transitions、Fragment Refs、browser()以及Trusted Types相关代码路径原本被置于功能开关之后,因此未包含在稳定版本中。而在19.3版本中,这些功能已被加入。通过网络传输时,压缩后的大小差异约为16 kB,相当于一组中等大小的图标集。

    这确实是需要纳入性能预算考量的一项实际开销,但并不会导致React的行为出现退化。如果额外的字节对您的应用有影响,更有效的优化方法通常是代码分割、精简依赖项以及延迟加载非关键的客户端组件,而非推迟升级。

    关键要点

    • 在 onSubmit 处理函数中直接读取 e.submitter;表单的 Actions 已经获取到了按钮的 name 和 value。
    • 用 onFullscreenChange 和 onFullscreenError 替代手动实现的 fullscreenchange 监听器,同时也要处理 requestFullscreen() 返回的被拒绝的 Promise。
    • 现在多次调用的 startTransition 可能会在不同的帧中执行,因此需要将必须同时显示的更新操作归为一组。
    • onReset 会在 Action 的自动重置之后最终执行,而且严格模式在数据加载过程中会两次触发效果函数,因此需要重新检查效果函数的清理工作。
    • 包括卡住的 useDeferredValue 以及过期的 useEffectEvent 值在内的几处长期存在的漏洞已得到修复;请检查代码中是否有相关的临时解决方案可以移除。
  • 客户端 react-dom 的压缩后大小约为 16 kB,此前被标记为可选的功能现已包含在稳定版本中。