2021 React Conf 报告,React的未来在哪里

更新于:2021-12-23

新的一年新的React Conf,React18 即将到来新的知识点,异步渲染,自动batch,Streaming Rendering,等等面向未来的React知识点,在这里一应俱全。

Note

本站点包含很多可实时运行的Demo,在PC端阅读将获得更好的体验!

概览

总体上来说今年的 React Conf 也没有什么特别大的惊喜,近年来大概也就 2018 年发布的 hooks 是最让社区惊喜的。不过 React 嘛,一项如此,稳定发展也是我对 React 评价这么高的原因之一。你很难想象一个类库近 10 年的历史,大部分 API 都能向前兼容,5 年前的 React 项目升级到新的版本需要修改的点也不会特别多(前提是你不使用之前版本 React 不推荐的 API)。

即便如此,今年的 React Conf 仍然有一些看点,本篇文章就将为你深度展示未来的 React 会长什么样子。

虽然但是,我的文章确实很棒,不过仍然推荐大家有时间的话就去看一下官方的视频,相信你一定会有额外的收获。React Conf 2021

Upgrade

首先我们来讲一下如何升级到 React18,其实非常简单,就两步:

  • 安装最新版本的 React
  • ReactDOM.render替换成ReactDOM.createRoot,这样自动 batch updates/concurrent rendering 会开启

升级

Terminal
npm install react@next react-dom@next

Note

很可能你看到这篇文章的时候,react18 正式版已经发布,那么你就不需要加上@next,直接安装就行了。

createRoot

更新版本之后,如果你还没有更新代码,很可能你会在 console 看到这样的错误:

升级提醒

这时候,你需要做的是,把之前的代码进行一个替换

ReactDOM.render(<App />, container);

// 替换成

const root = ReactDOM.createRoot(container);
root.render(<App />);

这个更新意味着 React 认为在同一个节点上渲染多个组件,或者在一个应用中有多个挂载点?Anyway,使用createRoot API 之后才会开启 batch update,以及 concurrent render 等 React18 特有的功能。

Automatic Batch Update

在之前版本的 React 已经为我们提供了Batch Update的功能,什么是 batch update?简单解释一下,在 React 中我们知道每次setState(或者调用useState返回的 set 方法),都会引起一次 React 组件的重新渲染,那么如果有如下代码:

const handleClick = () => {
  setState({count: 1});
  setState({count: 2});
  setState({count: 3});
};

在执行这个回调的时候,我们就需要更新三次这个组件,并且更新三次 DOM,我们都知道 DOM 更新是非常缓慢的,因为涉及到 IO,而在这个场景中,因为他们是在一个函数中同步执行的,我们完全可以只更新一次 DOM 把回调最终的结果渲染到 DOM,以此提升性能。之前的 React 版本确实做了这件事,但是仅限于一些场景:

  • 事件回调,比如onChangeonClick
  • 生命周期
  • hooks

简单概括就是,要在 React 主动调用的回调函数中。

而有些场景就不行,比如setTimeout,我们来看下面这个例子:

import React from 'react';
let renderTime = 0;
export default function App() {
  const [number, setNumber] = React.useState(0);
  renderTime += 1;

  const handleClick = React.useCallback(() => {
    setTimeout(() => {
      setNumber(number + 1);
      setNumber(number + 2);
      setNumber(number + 3);
    });
  }, [number]);

  return (
    <div>
      <span>{renderTime}</span>
      <button onClick={handleClick}>Client Me</button>
    </div>
  );
}

我们可以看到每次点击,renderTime都会增加 4,每次setNumber都会让组件重新渲染一次。这是因为在setTimeout中,函数执行已经脱离了 React 事件回调的上下文,React 无法对这个回调进行优化。

Gotcha

注意这里为什么每次增加不是 3 而是 4,因为我们使用的是 development 版本的 React,额外的一次渲染师用于一些 React 开发版的功能,比如错误信息收集等。

现在我们来看同样的例子,在 React18 中会得到什么结果:

import React from 'react';
let renderTime = 0;
export default function App() {
  const [number, setNumber] = React.useState(0);
  renderTime += 1;

  const handleClick = React.useCallback(() => {
    setTimeout(() => {
      setNumber(number + 1);
      setNumber(number + 2);
      setNumber(number + 3);
    });
  }, [number]);

  return (
    <div>
      <span>{renderTime}</span>
      <button onClick={handleClick}>Client Me</button>
    </div>
  );
}

看到了么,每次点击增加变成了 2,setTimeout中的代码也被自动 batch 了,当然要注意的是你需要使用createRoot

flushSync

虽然说大部分情况下,我们希望用到 batch update,但是,事情永远有万一。万一你真的希望每次更新状态都会得到一次更新呢?所以 React 也考虑到了这个问题,并且提供了一个专门的 API,就是flushSync

使用flushSync来包裹你的更新,那么在这里面的更新就都会触发重新渲染,比如在上面的例子中,你改成:

// 这个API要从`react-dom`引入
import {flushSync} from 'react-dom';

setTimeout(() => {
  flushSync(() => {
    setNumber(number + 1);
  });
  flushSync(() => {
    setNumber(number + 2);
  });
  flushSync(() => {
    setNumber(number + 3);
  });
});

再次点击之后,你会看到每次 renderTimes 增加又变成了 4。注意:你可以直接在上面修改代码看效果

New Apis

startTransition

startTransition 也是 concurrent mode(异步渲染)提供的新 API,当你在startTransition的回调中执行状态更新的时候,这个状态更新导致的组件更新,就会被认为是低优先级的,可以被打断的,这也是异步渲染的基本概念。我们来看一下下面的例子:

import React from 'react';

let count = 0;

setInterval(() => {
  count += 1;
}, 5);

const useCount = () => {
  return count;
};

const Counter = () => {
  const x = useCount();

  const start = performance.now();

  while (performance.now() - start < 7) {}

  return <div>Count {x}</div>;
};

export default () => {
  const [num, setNum] = React.useState(0);

  const ins = () => {
    React.startTransition(() => {
      setNum((x) => x + 1);
    });
  };

  return (
    <div>
      {num} <button onClick={ins}>Click</button>
      {[...Array(10).keys()].map((_, index) => (
        <Counter key={index} />
      ))}
    </div>
  );
};

这个例子里面,我们在Counter组件中使用了一个while循环模拟了渲染非常耗时的组件,也就是每个组件都至少要7ms来执行 render。而我们在 App 中一次渲染了 10 个这样的节点,因为 JS 是单线程的,所以越是后面的节点都要等前面的阶段 render 完成才行。

另外我们提供了一个简单的全局 count 的自定义 hook,hook 也很简单,单纯地只是读取一个独立变了 count。

Note

如果你熟悉 React,你应该发现这个代码写法是有问题的,在 count 更新之后我们没有机会通知 React 进行重新渲染,所以你看到的内容不会跟着 count 的更新而更新,这是有意而为之的,因为我希望向你演示你在使用 Concurrent Rendering 的时候可能遇到的问题。

OK,现在你点击按钮,你会看到什么结果呢?你会发现你看到的列表数字是不一样的,并且越到后面越大。这就是startTransition的作用,你可以试着把startTransition去掉,直接更新 num 试试,你会发现最后的结果是一致的。

那么为什么呢?这一下子也讲不太清楚,关于异步渲染我会单独开专题来讲,这里简单概括一下,异步渲染就是某一次更新是可以被打断的,React 为了保证浏览器的流畅性,会把一次耗时很长的 render 过程拆分成数个小的 render,并在这几个小的 render 中间把进程交还给浏览器,让浏览器响应更高优先级的任务,比如用户输入,以防止出现页面卡顿的情况。你可以再试试有没有startTransition的情况下,连续多次点击按钮会有什么区别,哪个体验更好。

但是最终渲染状态不统一确实也是一个问题,React 也提供了对应的方案,后面我们讲useSyncExternalStore你就知道了。

useTranstion

useTranstion是一个Suspense的补全,其作用也是对于正在进行的异步操作提供处理方法,我们来看官方的 demo:

Note

如果在这里操作太小不方便,你可以点击Open Sandbox来打开单独的页面

这是官方 Suspense demo 的useTransition的优化版本,不使用useTransition的版本链接在这里

优化版本的主要区别就在于,当我们点击Next按钮之后,并不会立即显示Loading...而是依然显示老的内容,等到新的内容加载完之后,就会立刻切换到新的界面,减少了破坏性的 Loading 界面展示。

useTransition的返回值类似useState,通过结构数组,可以得到一个 bool 值一个函数:

const [isPending, startTransition] = useTransition({
  timeoutMs: 3000,
});

bool 值代表这个 transition 是否正在执行,startTransition函数则用于启动这个 transition。我们会传递timeoutMs一个毫秒值,在这个时间前我们会停留在老的界面,而超过这个值之后,该组件上层的Suspense才会进入fallback

例子这里就不展示了,各位有兴趣可以自己试试。

useDeferredValue

startTransition类似,用于触发异步渲染,这个 API 通过告诉 React 某一个变量的变化是低优先级的,是可以deferred的,那么后续 React 处理这个变量的变化时就会进行优化,让这个更新引起的渲染可以被中断。

import React from 'react';

const Items = ({name}) => {
  return [...Array(500).keys()].map((_, index) => (
    <Item key={index} name={name} />
  ));
};

const Item = ({name}) => {
  let start = 0;

  while (start < 10000) {
    start += 1;
  }

  return <div>Name: {name}</div>;
};

export default () => {
  const [name, setName] = React.useState('Jokcy');
  const deferredName = React.useDeferredValue(name);
  const handleChange = React.useCallback((e) => setName(e.target.value), []);

  return (
    <div>
      <input value={name} onChange={handleChange}></input>
      <Items name={deferredName} />
    </div>
  );
};

因为 Demo 较为消耗 CPU,所以默认并没有执行,要手动点一下 code 右下角的Run哦。

我们来看这个例子,我们的Item组件又是一个会循环 10000 遍的耗时组件,并且我们一次渲染了 500 个,如果这时候你把传递给Items的 name 改成用name而不是deferredName,你可能在输入框输入的时候已经有点感觉卡顿了。这时候如果还不是很明显,那么你可以尝试打开浏览器的调试工具,选择Performance选项卡,然后在CPU这里选择4X throttling,这时候 Chrome 会模拟 1/4 的 CPU 来运行网页,这时候你应该就能明显感觉到卡顿了。然后你再把name改成使用deferredName,然后再体验一下,应该会有较为明显的好转,一个特征就是现在不是每次输入下面的内容都会更新了。

这就是这个 API 的作用,相比于节流函数,这个 API 能够根据实际情况来调节,比如电脑性能非常强的话,你可能感觉不到延迟渲染,只有在电脑性能较差的容易引起卡顿的时候才会做这一步。

useId

一个用户帮助生成固定唯一 ID 的工具,主要解决的问题是 SSR 时客户端和服务端天同步的问题,因为 React18 提供了 Streaming Rendering,在原先的 SSR 基础上实现了分部分进行渲染的能力,这让以前的一些解决方案比如通过自增加的数字来创建 ID 变得不能解决问题。

具体请看讨论

useSyncExternalStore

使用useSyncExternalStore可以解决上面startTransition遇到的渲染结果不一致的问题:

import React from 'react';

let count = 0;

setInterval(() => {
  count += 1;
}, 5);

const useCount = () => {
  return React.useSyncExternalStore(
    () => {},
    React.useCallback(() => count, [])
  );
};

const Counter = () => {
  const x = useCount();

  const start = performance.now();

  while (performance.now() - start < 7) {}

  return <div>Count {x}</div>;
};

export default () => {
  const [num, setNum] = React.useState(0);

  const ins = () => {
    React.startTransition(() => {
      setNum((x) => x + 1);
    });
  };

  return (
    <div>
      {num} <button onClick={ins}>Click</button>
      {[...Array(10).keys()].map((_, index) => (
        <Counter key={index} />
      ))}
    </div>
  );
};

这个 API 主要接收两个参数:useSyncExternalStore(subscribe, getSnapshot),前一个参数用于监听外部 store 的变化,第二个参数则是计算状态的快照。我们的 demo 中把subscribe设置为空函数,因为我们不希望组件监听到状态变化,不然的话每次count变化组件都会重新渲染,就看不到我们希望的效果。

这里涉及到一个概念,就是异步渲染和同步渲染,我在三年前的React 源码解析中已经有所解释,未来我会针对这部分再出新的教程,来更好地让大家理解。这个 API 的本质就是在发现状态快照发生变化之后,主动出发一次同步渲染,让整个应用都能更新到最新的状态,以此保证状态的同步,具体一两句说不清楚,大家期待未来的详细教程吧。

TODO: follow me

Suspense on Server

简单来说,就是 SSR 的升级版,可以先完成一部分的 SSR 先返回给浏览器展示,然后渲染慢的部分,在渲染完之后再返回到客户端进行展示,以此进一步提升首屏打开速度。

这部分建议有兴趣去看原视频,demo 比较复杂,并且预计国内使用也不会多,而且大概率最后会通过类似 NextJs 这类框架进行使用。

Devloper Tooling

提供 hook 的名字

DevTool 未来可以支持查看 hooks 的名字,如何实现的呢?看:

  1. 压缩之后的代码
    Final Code
  2. 加载SourceMap
    Source Map
  3. 根据SourceMap的到源码
    Transformed Code
  4. 编译成AST
    AST

简单来说就是把你的代码编译成 AST 在去读取你这个 hook 赋值的变量的名字,比如:

const [name, setName] = useState('Jokcy');

这里这个useState的结果会赋值给name这个变量,那么在 DevTool 上就会显示这个 hook 的名字是name,以此来让你调试 hooks 的时候变得更加便利。

Note

有些 SourceMap 的形式是无法支持的,比如 webpack 的cheap-eval-source-map

Timeline

DevTool 的 Timeline 类似 Chrome Devtool 的 Timeline,只不过这里显示的是 React 组件的渲染性能。Timeline 会对组件的渲染进行监控,并提示哪些更新应该进行拆分。这对于支持useDeferredValue等异步渲染的 React 18 来说,能起到非常好的分析和提示作用。

更新内容列表:

  • 提醒渲染性能,对一步渲染进行提醒
  • 显示 Suspense 的内容
  • 展示浏览器渲染的状态

摆脱 memo

这一节是我最感兴趣的部分,核心就在于性能优化。在进入这部分内容之前我们需要先要知道 React Hooks 提供的优化:

  • useCallback
  • useMemo
  • memo

这两个 Api 的本质就是用于帮助我们记录一个组件更新过程中不变的元素,以防止该组件渲染的节点做无谓的更新。比如:

import React from 'react';
import List from './List';
export default function App() {
  const [name, setName] = React.useState('Jokcy');

  const list = [1, 2, 3];

  return (
    <div>
      <input value={name} onChange={(e) => setName(e.target.value)} />
      <List list={list} />
    </div>
  );
}

在这个例子中,只要我们在 input 中进行输入,就会触发onChange,然后更新name state,就会重新渲染 App 组件,也就是说App这个函数会被重新执行,那么const list = [1, 2, 3]这行代码会重新执行,前一次和后一次的 list 其实是完全不同的两个数组,这会导致<List>组件必须重新渲染,虽然数组中的每一项其实是一样的,从我们的视角看其实没必要,但是代码并不能理解这一点。所以你会看到 List 的 renderTimes 渲染次数更新了。

那么我们要如何做才能避免这样的行为呢?答案很简单,使用useMemo

const list = useMemo(() => [1, 2, 3], []);

使用useMemo之后,list 在之后的渲染中都会返回第一次生成的数组,而非每次执行函数的字面量,也就是说 list 始终指向同一个对象,那么List组件就没有必要重新渲染。

Note

你可以在上面修改代码,然后等右侧刷新之后就能看到新的结果,再试着在 input 输入一下看看数字会不会更新?

这就是useMemouseCallback的作用,不得不说这写法非常繁琐,这是 React Hooks 强大带来的副作用。那么有没有办法让我们不需要每个地方useMemo/useCallback,又能得到类似的优化效果呢?

我们的华裔朋友黄玄操着一口略显装逼的英语给我们带来了一些新可能性,他通过编译的方式,把组件所有可优化的点自动增加memo类似的 wrapper,这一方面达成了我们想要优化的目标,另一方面也让我们节省了自己去写这些繁琐代码的时间,而且自动化相比人手动来做,更稳定不容易出错。

另外编译器还能更大程度上帮助我们做一些我们手动来做非常繁琐同时收益又不会很高的工作,比如一些静态节点的缓存,手动剥离还会破坏代码结构,降低代码的可读性,在写代码时做收益太低成本太高,所以一般不会做。但是引入中间编译环节,则非常好地解决了这个问题。

非常期待看到项目开源的一天。

总结

今年的 React Conf 总结来的有点晚了,因为前段时间确实较忙,同时还在准备这个新网站,新网站相信大家也看到了,会是一个学习体验非常棒的工具网站,也希望大家能喜欢,未来会有更多更棒的功能和内容持续推出,也希望大家多多支持。在抛弃对第三方平台的依赖后,我对我自己制作的内容拥有百分百的控制权和定价权,将会以非常便宜的价格为大家提供质量高出非常多倍的内容,相信绝对不会让你失望。

说回 React Conf,今年的 Conf 宣告了一件事,那就是未来对于 Concurrent Mode 的掌握程度将很大程度上区分对于 React 这个类库的理解程度。作为开发者我们必须面向未来,而异步渲染就是未来,我也会第一时间为大家推出相关课程。当然除了 Concurrent Mode 之外,Streaming Rendering 也是一个非常有吸引力的点,让用户能尽快看到我们的内容永远都是前端开发者的目标。还有 memo 优化的方向,我之前也有过相关的考虑,只是一直没有尝试实践,现在看到有了类似的实现,着实小兴奋了一把。

不论怎么说,让我们期待新的 2022 年吧,相信我会真正的 Settle 下来,精心制作优质的内容,也相信我在开源社区能有更多的积累。

说起来其实我 2020 年也获得了 Github 的活跃贡献者勋章

Github Achieve

有兴趣也可以上 Github Follow一下哟!

Follow Me

Jokcy的二维码