React 17来了 | 蛋烘糕
蛋烘糕.

不写博客的工程师不是好的搬砖工🧱

React 17来了

Cover Image for React 17来了
蛋烘糕
蛋烘糕

React 17 正式版已经发布,17 版本不像 16 版本中有诸多内容更新。官方对 17 版本的描述也是没有新特性,主要定位为一版技术改造,为下一个大版本的更新减轻负担。

1. 无新特性

React 17 的版本是非比寻常的,因为它没有添加任何面向开发人员的新功能。而主要侧重于升级简化 React 本身。 因此 v17 只是一个铺垫,并不想发布重大的新特性,而是为了 v18、v19……等后续版本能够更平滑、更快速地升上来。但其中有些改造不得不打破向后兼容,于是提出了 v17 这个大版本变更,顺便搭车卸掉两年多积攒的一些历史包袱。

2. 渐进式升级

v17 版本可以使得由一个 React 版本管理的代码树嵌入到另一个 React 版本管理的代码树中,这是该版本提供的渐进升级的能力。 在以往的版本中,React 一直遵循“all-or-nothing”的升级策略。开发者可以继续使用旧版本,也可以将整个应用升级到新版本,没有介于两者之间的情况。这种情况往往会导致两个主要问题:

  1. 想要让业务代码自动化升级过渡,React 团队需要无限期支持过时的 API;
  2. 如果 react 有不兼容版本更新,业务开发者需要在继续使用旧版本和更新 React 之间做取舍,因为往往这种面临较大的风险。

因此,React17 提供了一个新的选项——渐进式升级,允许同一个项目中多个 React 版本并存。这将意味着当 React 18 或未来版本问世时,业务开发人员将有更多的选择。对于大型前端应用将十分友好,比如弹窗组件、部分路由下的页面可以可以一块块地平滑过渡到新版本。(官方 Demo

PS:(按需)加载多个版本的 React 同样存在着性能开销,打包产物过大等问题,同样需要取舍。

2.1 多版本并存与为前端架构

多版本并存、新旧混用的支持让微前端架构所期望的渐进式重构成为了可能。与 React 支持多版本并存、渐进地完成版本升级相比,微前端更在意允许不同技术栈并存,平滑地过渡到升级后的架构,比如对于在一个 JQuery 项目中逐步升级支持 React 等场景。

3. 7 个 Breaking Change

3.1 事件委托不再挂到 document 上

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

因此,v17 之前不同版本的 React 组件嵌套使用时,e.stopPropagation()无法正常工作:如果嵌套树结构中阻止了事件冒泡,但外部树依然能接收到它。这会使不同版本 React 嵌套变得困难重重。这种担忧并不是没有根据的 —— 例如,四年前 Atom 编辑器就遇到了相同的问题。 在 React 17 中,React 将不再向 document 附加事件处理器。而会将事件处理器附加到渲染 React 树的根 DOM 容器中:

const rootNode = document.getElementById("root");
// render
ReactDOM.render(<App />, rootNode);
// Portals
ReactDOM.createPortal(<App />, rootNode);
// React16 事件委托(挂在document上)
document.addEventListener();
// React17 事件委托(挂在 root DOM 上)
rootNode.addEventListener();

在 React16 或更早版本中,React 会对大多数事件执行document.addEventListener()。React17 将会在底层调用 rootNode.addEventListener()image.png 由于此更改,现在可以更加安全地进行新旧版本 React 树的嵌套。请注意,要使其正常工作,两个版本都必须是 17 或更高版本。所以,从某种意义上将,React17 是一个“垫脚石”版本,是逐步升级成为可能。 并且,此更改还是得将 React 嵌入使用其他技术构建的应用程序更加容易。例如,如果应用程序的“外壳”是用 JQuery 编写的,但其中交心的代码是用 React 编写的,则 React 代码中的e.stopPropagation()会阻止它影响 JQuery 的代码——这符合预期。换个角度说,如果你不再喜欢 React 并想重写应用程序(比如,用 JQuery),则可以从外壳开始讲 React 转换为 JQuery,而不会破坏事件冒泡。

3.2 更加靠近浏览器原生事件

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

  1. onScroll 不再冒泡;
  2. onFocus/onBlur直接采用原生focusin/focusout事件;
  3. 捕获阶段的事件监听直接采用原生 DOM 事件监听机制。

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

3.3 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()(或者直接做值缓存)。React17 去掉了了事件复用机制,因为在现代浏览器下这种性能优化没有意义,反而给开发者带来了困扰。

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

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

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

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

PS:对于某些需要同步清理的特殊场景,可以使用 LayoutEffect Hook

3.5 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 />;
}

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

PS:空组件可以返回 null,不会引发报错。

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

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

  1. 缺少源码位置(文件名、行列号等),Console 里无法点击跳闸是你到出错的地方;
  2. 无法在生产环境中使用(displayName 被压缩坏了)。

React17 采用了一种新的组件栈生成机制,能够达到媲美 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 还原回来。

PS:

  1. 重建组件栈的过程中会重新执行 render,以及 Class 组件的构造函数,这部分属于 Breaking Change;
  2. 关于重建组件栈的更多信息见 见Build Component Stacks from Native Stack Frames、以及react/packages/shared/ReactComponentStackFrame.js

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

React17 删除了一些私有的 API,大多是当初暴露给 React Native for Web 使用的,目前 React Native for Web 新版本已经不再依赖这些 API 了。 另外,修改事件系统时还顺手删除了 ReactTestUtils.SimulateNative工具方法,因为其行为与语义不符,建议换用 React Testing Library

4. 总结

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

参考资料

  1. React v17.0 Release Candidate: No New Features
  2. React 17
React-grid-layout 一个支持拖拽的栅格布局库
Webpack5搭建React开发环境
博客日历
2022年10月
26
27
28
29
30
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
01
02
03
04
05
06
更多