React 17 RC 版发布:无新特性,却有新期待!

217 阅读14分钟

无新特性 React 17 版本很特别,因为它并没有任何面向开发者的新功能,而是专注在了如何更轻松地升级 React 本身。

我们仍然在积极研发 React 的新特性,只是未在此版本发布。我们后续的策略是不让任何用户错过 React 的新特性,这个版本正是此策略的关键一环。

React 17 的特别之处还在于,它发挥了「跳板」的作用,可以让由某个版本的 React 管理的树,在嵌入另一个版本的 React 管理的树时更加安全。

渐进式升级 在过去的七年中,React 的升级一直很极端。你要么停留在旧的版本,要么将整个应用升级到新版本,不能选择中间态。

这个策略至今运作良好,但我们也遇到了这种「极端」升级策略带来的局限。 某些 API 的更改——比如弃用过时的 context API, 无法自动实现。 即使今天绝大多数应用从未使用过这些 API, React 仍提供了支持。我们必须无限期地使 React 支持它们,或是让某些应用停留在旧版本的 React 之间做出选择。我们认为这两个选项都不是很好。

因此,我们想提供另一种选择。

React 17 带来了渐进式的 React 升级。当你从 React 15 升级到 16(或者很快就可以从 React 16 升级到 17)时,你一般会立即升级整个应用。这对大部分应用都是适用的,但如果你的代码是几年前编写的,并且没有积极维护,你面临的挑战会与日俱增。虽然你可以同时使用两个版本的 React, 但是在 React 17 之前,系统仍会比较脆弱,并且使用事件时会有问题。

我们已经通过 React 17 解决了许多问题,这意味着当 React 18 及后续本问世时,你将拥有更多选择。首先是你可以选择像从前一样,一次性升级整个应用,但也可以选择逐块升级。例如,你可能决定将应用的大部分迁移到 React 18, 但又想保留 React 17 的懒加载对话框或子路由。

当然,这并不是说你必须逐步升级。对于大多数应用来说,一次性完成全部升级仍然是最好的方案。加载两个版本的 React(即使其中一个是按需懒加载)的效果仍不够理想。不过那些不积极维护的大型应用可以考虑使用这么做,React 17 能让它们不被落下。

为了启用渐进式更新,我们需要对 React 事件系统进行改造。React 17 是个主要版本,因为这些改造可能会是 breaking 的。 实际上,我们只需要改造十万多个组件中的不到 20 个,因此我们希望大多数应用可以毫不费事地升级到 React 17. 如果你遇到了问题不妨告诉我们。

渐进升级示例 我们准备了一个示例仓库,以演示如何在必要时懒加载旧版本的 React. 该示例用到了 Create React App, 但用其他工具应该也同样适用。如果有使用其他工具的 demo, 也欢迎给我们 PR.

注意 我们已将其他变更推迟到了 React 17 之后。此版本的目标是实现渐进升级。如果升级到 React 17 太过困难,那将违背它发布的初衷。

事件委托的变更 从技术上讲,嵌套使用不同版本的 React 开发的应用并没有什么问题。但是,React 事件系统的工作原理使它变得相当脆弱。

在 React 组件中,你通常在编写事件处理器会采用内联写法:

与其等价的原生 DOM 代码是这样的:

myButton.addEventListener('click', handleClick); 但是,对于大多数事件来说,React 其实并不会在你声明它们的时候就将它们 attach 到对应 DOM 节点上。 相反地,React 会直接在 document 节点上为每种事件类型 attach 一个处理器. 我们把这叫做事件委托。这种方法不但在大型应用树上有性能优势,还使得添加新功能(如 replaying events)更加容易。

自发布以来,React 的事件委托一直都是自动进行的。 当 DOM 事件被触发时,React 会找出要调用的组件,然后 React 事件会在你的组件中「冒泡」。 在这背后,原生的事件已经冒到了 document 级别 —— 也就是 React 安装其事件处理器的地方。

但是,这对渐进升级来说是个问题。

如果页面上有多个 React 版本,它们都将在顶部注册事件处理器。这会破坏 e.stopPropagation(): 即便嵌套树停止了事件冒泡,外部的树仍会接收到该事件,这就使嵌套不同版本的 React 难以实现。这种担心并非假想,举个例子,Atom 编辑器在四年前就遇到了这个问题。

这就是我们要改变 React attach 事件到 DOM 的底层实现方式的原因。

在 React 17 中,React 将不再在 document 级别 attach 事件处理器,而是 attach 到 React 渲染树的根 DOM 容器中:

const rootNode = document.getElementById('root'); ReactDOM.render(, rootNode); 在 React 16 和更早的版本中,React 会对大多数事件执行 document.addEventListener()。React 17 将在内部调用 rootNode.addEventListener()。

React 16 与 17 事件委托对比

由于此变更,现在由某个版本的 React 管理的树,在嵌入另一个版本的 React 管理的树时更加安全了。但如果要实现此能力,两个 React 版本都必须为 17 或更高版本,这也是升级到 React 17 如此重要的原因。 从某种意义上说,React 17 是一个「跳板」版本,它使得下一个渐进式升级切实可行。

这项变更还简化了将 React 嵌入使用其他技术构建的应用的过程。 例如,如果应用的外部「shell」是用 jQuery 编写的,但其中的较新代码是用 React 编写的,那么 React 代码中的 e.stopPropagation() 将会阻止它执行 jQuery 代码 - 如你所愿。 反之也同样适用。 如果你不喜欢 React 了,想用 jQuery 重写你的应用,你可以从 shell 开始将其从 React 转换为 jQuery, 而不会影响事件冒泡。

我们已经确认,这么些年我们的 issue 跟踪器上报的许多问题 —— 与 React 及非 React 代码集成相关的问题,都被此变更解决了。

解决潜在问题 与其它 breaking change 一样,此次变更我们也需要调整一些代码。 在 Facebook 内部,我们总共得调整约 10 个模块(从成千上万个模块中)以适应此变更。

例如,如果你使用 document.addEventListener(...) 手动添加了 DOM 监听器,你应该是希望它们能捕获所有 React 事件。在 React 16 及更早版本中,即使你在 React 事件处理器中调用了 e.stopPropagation() ,你自定义的 document 监听器仍然会收到事件,因为原生事件已经注册在 document 级别了。而使用 React 17, 冒泡将会停止,因此你的 document 处理器将不会触发:

document.addEventListener('click', function() { // 在调用了 e.stopPropagation() 的 React 中 // 这个自定义处理器将不会再接受 click 事件 }); 你可以通过把你的监听器转换到 capture 阶段来修复此类代码。 将 { capture: true } 作为 document.addEventListener 的第三个参数:

document.addEventListener('click', function() { // 现在这个事件处理器使用了 capture 短语 // 所以它可以接收下面所有的 所有 click 事件 }, { capture: true }); 注意,此策略在整体上更加弹性了 - 举个例子,如果你的代码在 React 事件处理器之外调用 e.stopPropagation() 时出了 bug, 它可能会修复代码中的错误。 换言之,React 17 中的事件冒泡更接近常规 DOM 了。

其它 breaking changes 我们将 React 17 中的重大变更控制到了最低。例如,以前已经被废弃的方法,它不会删除。 但是,它的确包含一些其它的 breaking changes, 但根据我们的经验判断,这些变更还算安全。总的来说,由于这些因素,在十万多个组件中我们只调整了不超过 20 个组件。

与浏览器对齐 我们对事件系统进行了一些小改动:

• onScroll 事件不再冒泡以防止常见的困惑。

• React onFocus 和 onBlur 事件已转换为引擎盖下的原生 focusin 和 focusout 事件,这与 React 的现有实现更为接近,有时还能提供额外的信息。

• 捕获阶段事件(例如 onClickCapture )现在使用真实的浏览器捕获阶段监听器。

这些变更使 React 更加接近浏览器行为,互操作性也有所提升。

无事件池 React 17 移除了「事件池」优化。 它不但不能提高现代浏览器的性能,甚至会连老练的 React 用户都会感到困惑:

function handleChange(e) { setData(data => ({ ...data, // 在 React 16 及以前版本中会引发 crash text: e.target.value
})); } 这是因为 React 在旧浏览器中为了提高性能而复用了不同事件之间的事件对象,并将所有事件字段设置为 null。在 React 16 及更早版本中,你必须调用 e.persist() 才能正确使用该事件,或者你也可以提前读取你需要的属性。

在 React 17 中,此代码会如你期望地运行。旧的事件池优化已被完全删除,你可以在任何时候读取事件字段。

这是一种行为变更,因此我们将其标记为 breaking。但实际上,在 Facebook 上我们还没有发现它造成过什么影响。(或许它还修复了一些错误呢!)请注意, e.persist() 在 React 事件对象上仍然可用,但是现在它什么也没做。

Effect 清理时机 我们正在使 useEffect 清理函数的时间更统一。

useEffect(() => { // 这是 effect 本身 return () => { // 这是它的清理函数 };}); 大多数 effect 都不必延缓屏幕的更新,因此 React 都会在屏幕更新后再异步运行它们。(在极少数情况下,你需要一个 effect 来阻止重绘,比如说测量和定位工具提示的时候,请使用 useLayoutEffect)

但是在 React 16 中,如果有 effect 清理函数,它会同步运行。我们发现,就像 componentWillUnmount 在类中是同步运行的一样,在大型应用中这种方式并不理想,因为它会减慢大屏幕变换的速度(例如切换标签的时候)。

在 React 17 中, effect 清理函数也是异步运行的 - 例如,如果要卸载组件,清理函数将在屏幕更新后运行。

这反映了 effect 本身是如何更紧密运行的。 在极少数情况下,你可能希望依赖同步执行,这时你可以改用 useLayoutEffect.

另外,React 17 会根据 effect 在树中的位置,以相同的顺序执行清理函数。以前,这个顺序会有所不同。

潜在问题 我们只在几个组件中发现了此变更引起的中断问题,当然我们可能需要对可重用的库进行更加彻底的测试。其中一个潜在的问题代码可能如下所示:

useEffect(() => { someRef.current.someSetupMethod();

return () => { someRef.current.someCleanupMethod(); }; });

这里的问题是 someRef.current 是可变的,因此在运行清理功能时,它可能已被设置为 null. 解决方案是在 effect 内部捕获任何的可变值:

useEffect(() => { const instance = someRef.current; instance.someSetupMethod();

return () => { instance.someCleanupMethod(); }; }); 我们不希望这成为一个常见问题,因为ref="github.com/facebook/re… eslint-plugin-react-hooks/exhaustive-deps lint 规则(请确保你用了!)会一直对此发出警告。

返回 undefined 的兼容性错误 在 React 16 及更早版本中,返回 undefined 始终会被当成一个错误:

function Button() { return; // Error: render 什么都没返回 } 这是因为 undefined 很容易被无意地返回:

function Button() { // 我们忘记写 return 了,所以这个组件会返回 undefined. // React 会把它标记为 error 而非忽略它

; } 过去,React 仅对类和函数组件执行此操作,但不检查 forwardRef 和 memo 组件的返回值,这是由于编码错误。

在 React 17 中,forwardRef 和 memo 组件的行为与普通函数和类组件一致。 它们返回 undefined 会被视为错误。

let Button = forwardRef(() => { // 我们忘记写 return 了,所以这个组件会返回 undefined. // React 17 会把它标记为 error 而非忽略它

; }); let Button = memo(() => { // 我们忘记写 return 了,所以这个组件会返回 undefined. // React 17 会把它标记为 error 而非忽略它 ; }); 对于你就是想不渲染任何内容的情况,请返回 null。

原生组件堆栈 当你在浏览器中抛出错误时,浏览器会为你提供带有 JavaScript 函数名及其位置的堆栈跟踪。 但是,JavaScript 堆栈通常不足以诊断问题,因为 React 树的层次结构可能也很关键。你不仅想知道 Button 抛出了错误,还想知道它在 React 树中的哪个位置。

为了解决这个问题,React 16 会在你遇到错误时开始打印「组件堆栈」。不过,它仍然比不上原生 JavaScript 堆栈。甚至它们在控制台中并不可单击,因为 React 不知道该函数在源代码中声明在哪里。 此外,它们在生产环境中几乎没有用。 与常见的最小化 JavaScript 堆栈可以通过 source map 自动复原到原始函数名不同,要使用 React 组件堆栈,你就必须在生产堆栈和 bundle 大小之间抉择。

在 React 17 中,组件堆栈是通过不同的机制生成的,该机制将组件堆栈与原生 JavaScript 堆栈简单结合在一起。这使你可以在生产环境中获得完全符号化的 React 组件堆栈跟踪。

React 实现这一机制的方式有些另类。目前,浏览器并不提供获取函数堆栈框架(源文件和位置)的方法。因此,当 React 捕获到错误时,它将在可能的情况下,通过从上面每个组件内部抛出(并捕获)临时错误来重建其组件堆栈。这会增加少量的崩溃性能损失,但是每个组件类型只会发生一次。

如果你对此感到好奇,可以在此 pull request 中了解更多详细信息,但在大多数情况下,这个具体的机制并不会影响你的代码。从你的角度来看是多了一个可以单击组件堆栈的新特性(因为它们依赖于本机浏览器堆栈框架),并且你可以像解码常规 JavaScript 错误那样在生产环境解码它们。

这里面构成重大变更的部分是,要使此功能正常进行,React 得在捕获错误后在堆栈中重新执行上面某些 React 函数和 React 类构造函数。由于渲染函数和类构造函数不应该有 effect (这对于服务端渲染也很重要),因此这不会造成任何实际问题。

移除私有导出 最后,最后一个值得注意的 breaking change 是我们删除了一些曾暴露给其它项目的 React 内部组件。尤其是,React Native for Web 过去曾经依赖于事件系统的某些内部组件,但是这种依赖关系很脆弱并且经常出问题。

在 React 17 中,这些私有导出已被删除。据我们所知,React Native for Web 是唯一使用它们的项目,并且它们已经迁移到了不依赖于私有导出的其他方法。

这意味着旧版本的 React Native for Web 无法与 React 17 兼容,但是新版本的可以使用。实际上,这并没有太大变化,因为 React Native for Web 必须发布新版本以适应内部的 React 变化。

此外,我们还删除了 ReactTestUtils.SimulateNative 辅助方法。他们从未被记录到文档中,没有按照其名字含义去实现,并且不能与我们对事件系统所做的变更共存。如果你想要更简便地测试触发原生浏览器事件,你还是看看 React 测试库吧。

安装 我们鼓励你尽快尝试 React 17.0 RC 版本,并记录你在迁移过程中遇到的问题。 请记住!RC 版本比稳定版本更可能带有错误,因此请不要将其部署到生产环境中。

使用 npm 安装 React 17 RC, 请运行:

npm install react@17.0.0-rc.0 react-dom@17.0.0-rc.0 使用 Yarn 安装 React 17 RC, 请运行:

yarn add react@17.0.0-rc.0 react-dom@17.0.0-rc.0 我们还提供了 React UMD 构建的 CDN:

🏆 技术专题第六期|谈谈 React 17 的那些事!