React 17

写在前面

React 最近发布了v17.0.0-rc.0,距上一个大版本v16.0(发布于 2017/9/27)已经过去近 3 年了

新特性云集的 React 16及先前的大版本相比,React 17 显得格外特殊——没有新特性

React v17.0 Release Candidate: No New Features

不仅如此,还带上来了 7 个 breaking change……

一.真没有新特性?

React 官方对 v17 的定位是一版技术改造,主要目标是降低后续版本的升级成本

This release is primarily focused on making it easier to upgrade React itself.

因此 v17 只是一个铺垫,并不想发布重大的新特性,而是为了 v18、v19……等后续版本能够更平滑、更快速地升上来:

When React 18 and the next future versions come out, you will now have more options.

但其中有些改造不得不打破向后兼容,于是提出了 v17 这个大版本变更,顺便搭车卸掉两年多积攒的一些历史包袱

二.渐进式升级成为了可能

在 v17 之前,不同版本的 React 无法混用(事件系统会出问题),所以,开发者要么沿用旧版本,要么花大力气整个升级到新版本,甚至一些常年没有需求的长尾模块也要整体适配、回归测试。考虑到开发者的升级适配成本,React 维护团队同样束手束脚,废弃 API 不敢轻易下掉,要么长时间、甚至无休止地维护下去,要么选择放弃那些老旧的应用

而 React 17 提供了一个新的选项——渐进式升级,允许 React 多版本并存,对大型前端应用十分友好,比如弹窗组件、部分路由下的长尾页面可以先不升级,一块一块地平滑过渡到新版本(参考官方 Demo

P.S.注意,(按需)加载多个版本的 React 存在着不小的性能开销,同样应该慎重考虑

多版本并存与微前端架构

多版本并存、新旧混用的支持让微前端架构所期望的渐进式重构成为了可能:

渐进地升级、更新甚至重写部分前端功能成为了可能

与 React 支持多版本并存、渐进地完成版本升级相比,微前端更在意的是允许不同技术栈并存,平滑地过渡到升级后的架构,解决的是一个更宽的问题

另一方面,当 React 技术栈下多版本混用难题不复存在时,也有必要对微前端进行反思:

  • 一些问题是不是由技术栈自身来解决更为合适?

  • 多技术栈并存是常态还是短期过渡?

  • 对于短期过渡,是否存在更轻量的解决方案?

关于微前端在解决什么问题的更多思考,见Why micro-frontends?

三.7 个 Breaking change

事件委托不再挂到 document 上

之前多版本并存的主要问题在于React 事件系统默认的委托机制,出于性能考虑,React 只会给document挂上事件监听,DOM 事件触发后冒泡到document,React 找到对应的组件,造一个 React 事件(SyntheticEvent)出来,并按组件树模拟一遍事件冒泡(此时原生 DOM 事件早已冒出document了):

react 16 delegation

react 16 delegation

因此,不同版本的 React 组件嵌套使用时,e.stopPropagation()无法正常工作(两个不同版本的事件系统是独立的,都到document已经太晚了):

If a nested tree has stopped propagation of an event, the outer tree would still receive it.

P.S.实际上,Atom 在早些年就遇到了这个问题

为了解决这个问题,React 17 不再往document上挂事件委托,而是挂到 DOM 容器上

react 17 delegation

react 17 delegation

例如:

const rootNode = document.getElementById('root');
// 以为 render 为例
ReactDOM.render(<App />, rootNode);
// Portals 也一样
// ReactDOM.createPortal(<App />, rootNode)
// React 16 事件委托(挂到 document 上)
document.addEventListener()
// React 17 事件委托(挂到 DOM container 上)
rootNode.addEventListener()

另一方面,将事件系统从document缩回来,也让 React 更容易与其它技术栈共存(至少在事件机制上少了一些差异)

向浏览器原生事件靠拢

此外,React 事件系统还做了一些小的改动,使之更加贴近浏览器原生事件:

  • onScroll不再冒泡

  • onFocus/onBlur直接采用原生focusin/focusout事件

  • 捕获阶段的事件监听直接采用原生 DOM 事件监听机制

注意,onFocus/onBlur的下层实现方案切换并不影响冒泡,也就是说,React 里的onFocus仍然会冒泡(并且不打算改,认为这个特性很有用)

DOM 事件复用池被废弃

之前出于性能考虑,为了复用 SyntheticEvent,维护了一个事件池,导致 React 事件只在传播过程中可用,之后会立即被回收释放,例如:

<button onClick={(e) => {
    console.log(e.target.nodeName);
    // 输出 BUTTON
    // e.persist();
    setTimeout(() => {
      // 报错 Uncaught TypeError: Cannot read property 'nodeName' of null
      console.log(e.target.nodeName);
    });
  }}>
  Click Me!
</button>

传播过程之外的事件对象上的所有状态会被置为null,除非手动e.persist()(或者直接做值缓存)

React 17 去掉了事件复用机制,因为在现代浏览器下这种性能优化没有意义,反而给开发者带来了困扰

Effect Hook 清理操作改为异步执行

useEffect本身是异步执行的,但其清理工作却是同步执行的(就像 Class 组件的componentWillUnmount同步执行一样),可能会拖慢切 Tab 之类的场景,因此 React 17 改为异步执行清理工作:

useEffect(() => {
  // This is the effect itself.
  return () => {
    // 以前同步执行,React 17之后改为异步执行
    // This is its cleanup.
  };
});

同时还纠正了清理函数的执行顺序,按组件树上的顺序来执行(之前并不严格保证顺序)

P.S.对于某些需要同步清理的特殊场景,可换用LayoutEffect Hook

render 返回 undefined 报错

React 里 render 返回undefined会报错:

function Button() {
  return; // Error: Nothing was returned from render
}

初衷是为了把忘写return的常见错误提示出来

function Button() {
  // We forgot to write return, so this component returns undefined.
  // React surfaces this as an error instead of ignoring it.
  <button />;
}

在后来的迭代中却没对forwardRefmemo加以检查,在 React 17 补上了。之后无论类组件、函数式组件,还是forwardRefmemo等期望返回 React 组件的地方都会检查undefined

P.S.空组件可返回null,不会引发报错

报错信息透出组件“调用栈”

React 16 起,遇到 Error 能够透出组件的“调用栈”,辅助定位问题,但比起 JavaScript 的错误栈还有不小的差距,体现在:

  • 缺少源码位置(文件名、行列号等),Console 里无法点击跳转到到出错的地方

  • 无法在生产环境中使用(displayName被压坏了)

React 17 采用了一种新的组件栈生成机制,能够达到媲美 JavaScript 原生错误栈的效果(跳转到源码),并且同样适用于生产环境,大致思路是在 Error 发生时重建组件栈,在每个组件内部引发一个临时错误(对每个组件类型做一次),再从error.stack提取出关键信息构造组件栈:

var prefix;
// 构造div等内置组件的“调用栈”
function describeBuiltInComponentFrame(name, source, ownerFn) {
  if (prefix === undefined) {
    // Extract the VM specific prefix used by each line.
    try {
      throw Error();
    } catch (x) {
      var match = x.stack.trim().match(/\n( *(at )?)/);
      prefix = match && match[1] || '';
    }
  } // We use the prefix to ensure our stacks line up with native stack frames.

  return '\n' + prefix + name;
}
// 以及 describeNativeComponentFrame 用来构造 Class、函数式组件的“调用栈”
// ...太长,不贴了,有兴趣看源码

因为组件栈是直接从 JavaScript 原生错误栈生成的,所以能够点击跳回源码、在生产环境也能按 sourcemap 还原回来

P.S.重建组件栈的过程中会重新执行 render,以及 Class 组件的构造函数,这部分属于 Breaking change

P.S.关于重建组件栈的更多信息,见Build Component Stacks from Native Stack Frames、以及react/packages/shared/ReactComponentStackFrame.js

部分暴露出来的私有 API 被删除

React 17 删除了一些私有 API,大多是当初暴露给React Native for Web使用的,目前 React Native for Web 新版本已经不再依赖这些 API

另外,修改事件系统时还顺手删除了ReactTestUtils.SimulateNative工具方法,因为其行为与语义不符,建议换用React Testing Library

四.总结

总之,React 17 是一个铺垫,这个版本的核心目标是让 React 能够渐进地升级,因此最大的变化是允许多版本混用,为将来新特性的平稳落地做好准备

We’ve postponed other changes until after React 17. The goal of this release is to enable gradual upgrades.

参考资料

React 17》上有1条评论

  1. deverse0502

    看了好几篇博客,确实是个很优秀的前端人!从你的文章里学到了很多谢谢

    回复

发表评论

电子邮件地址不会被公开。 必填项已用*标注

*

code