React 的快与慢

在 React 出现之前,web 的性能瓶颈是 js 和 DOM 交互这块,当时很多优化大多集中在如何减少不必要的 DOM 操作,这些优化都是点状的优化,缺乏一个大而全的优化体系,直到 React 出现。众所周知 React 使用 Virtual DOM 来描述 UI 界面,开发者只需要操作改变 Virtual DOM。React 通过对比前后两个 Virtual DOM 的差异(diff 的过程),精准的拿到要发生变化的 UI 描述,这样就避免了不必要的 DOM 操作,减少了页面重绘的次数,从而达到较好的 web 性能,这是我理解的 React 的快(当然 React 还有很多其他优势,比如组件化开发的思想带来良好的开发体验和可维护性的提高,本文只讨论 React 的渲染快慢)。也正因为多一层 Virtual DOM 的比较,在大量节点的场景计算 Virtual DOM 前后变化的差异,使 js 执行时间过长导致浏览器卡顿,这是我理解的 React 的慢。

慢的问题近几年频繁被提及,业界也出现不少库去除 Virtual DOM 这层,如Solidjs。大概做法是在编译阶段将 jsx 动态部分单独提取出来,配合依赖收集,就可以做到变量改变时点对点的更新,实现 DOM 的细粒度更新。最近看到有篇文章介绍了 millionjs,将React性能提升 70%,还有一篇提到useSignal() 是 Web 框架的未来,接下来就详细了解它们是如何提升 React 应用的性能。

millionjs

将 millionjs 集成到 React 工程中非常方便,把 React 组件作为 block 方法的参数传入运行,示例如下。

import { block } from 'million/react';

const Cal = ({ count }) => {
  return (
    <p>
      count: {count}
    </p>
  );
};
const BlockCal = block(Cal);

const App = () => {
  const [count, setCount] = useState(0);
  return (
    <>
      <div>
        <button onClick={() => setCount(c => c + 1)} />
      </div>
      <BlockCal count={count} />
    </>
  );
};

export default App;

从上面的代码中能看到,block 在 js 运行时就执行了,返回处理后的高阶组件 BlockCal,开发者直接使用该组件即可,优化的工作都在 millionjs 内部完成。millionjs 的源码不多,核心就 2 个文件,跟随 block 方法探索 millionjs 的优化方案。执行 block 方法,传入一个 ProxyProps 对象执行 React 的函数式组件,递归 jsx 分析有哪些地方使用了 props且动态创建对应的 DOM 对象,建立好映射关系并存储在 edits 数组中,返回一个 jsx 默认是 slot 的 React 组件。这个高阶组件始终返回 slot,所以 React 的 diff 就会到此结束,Cal 组件的改变逻辑被 BlockCal 内部接管了,一旦 props 发生改变,会判断具体有哪些 props 改变了,遍历 edits 数组去执行对应的 DOM 操作。整体思路和 solidjs 很像,millonjs 相当于是把细粒度更新的思路落地在了 React 生态中。以上面的代码为例,完整的流程图如下。

从上图中能发现,millionjs 就是减少了 Virtual DOM 的数量,缩短 Virtual DOM 比较的耗时。在实践过程中,也发现 milionjs 的局限性。

  • 不支持 Class Component;
  • 组件不能使用 useState,因为 block 的执行时机是在 ReactDOM.render 前,这个时候还有没有 ReactCurrentDispatcher,所以会直接报错;
  • 组件内部不能有逻辑判断,因为 block 只会执行一次,判断条件执行了结果就不会再更新了。

millionjs 只能应用在纯 UI 组件上,UI组件内节点越多作用就越明显。

signals

signals 是 preact 实现的一个响应式状态管理方案,官方案例如下:

import { signal, effect, computed } from '@preact/signals-core';

const name = signal("Jane");
const surname = signal("Doe");
const fullName = computed(() => `${name.value} ${surname.value}`);

effect(() => console.log(fullName.value));
// logs: "Jane Doe"

// Updating
name.value = "John";
// effect logs: "John Doe"

三个 API 对应着响应式状态管理的三要素 singal(数据)、effect(反应)和computed(衍生),下面代码展示在 React 中的使用。

import { useSignal, useSignalEffect } from '@preact/signals-react';

const About = () => {
  console.log('=about render=');
  return (<div>about</div>);
};#

const App = () => {
  const count = useSignal(0);
  console.log('=APP render=');
  useSignalEffect(() => {
    console.log(count.value);
  });
  return (
    <>
      <div>
        <button onClick={() =>count.value += 1} />
      </div>
      <p>
        count: {count}
      </p>
      <About />
    </>
  );
};

执行这段代码,log 运行输出。点击 button 按钮,结果会让你感到惊讶,count 改变并没有导致组件 render log 的执行,直接做到了点对点的更新,而且代码比 millionjs 更优雅。对比 useEffect,useSignalEffect 没有任何心智负担,不需要添加依赖 [count],对开发者友好,这就是 signal 的两大优点。说明一下,从上面代码能看出在 jsx 中写入的是 count 对象而不是 count.value,两者是有区别的,用 count.value 是没有点对点更新的效果。

我们知道 React 是需要通过特定的方法才会执行组件的 render,Class Component 有 setState 和 forceUpdate 方法,Functional Component 有 useReducer 和 useState,React 18 新增了一个 useSyncExternalStore 方法。 所以第一部分讲的 millionjs 中的细粒度更新属于另辟蹊径,直接原生接管组件的 DOM 操作,就不需要依赖 React 的 render 机制。那 @preact/signals-react 是如何融入 React render 机制的?

signal 劫持 ReactCurrentDispatcher 对象,当 dispatcher 更换成一个有效的调度程序(非ContextOnlyDispatcher),则认为当前组件进入渲染阶段。此时为当前组件中使用的 signal 收集依赖,创建 Effect 关联上 store。当 signal 发生改变,如上图的绿色箭头路径,执行对应 store 的 callback,调用 useSyncExternalStore 内部的 handleStoreChange 函数,后面就是 React 内部的 render 逻辑了。这里提一点,signal 对象是模拟成了一个自定义 ReactComponent,因为原生的 html 标签组件是静态的,不存在使用 hooks 方法。

signal 的值是 mutable,React 的理念状态是 immutable,两者各有各的好。不过 signal 为了拦截 ReactCurrentDispatcher 对象而使用了 React 官方不建议的一个属性 __SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED,React 未来的版本可能会更改或者移除,存在风险。

useTransition

以上讲的两个方案,虽然都能提高性能,但是在面对纯 js 执行导致浏览器卡顿的时候也无济于事,而 React 18 推出了 Concurrent Rendering 的方案来优化 js 导致的浏览器卡顿。React 早在发布 16 版本的时候就提出了 Time Slicing(时间切片)的概念,同时 Virtual DOM 也换成了 Fiber 数据结构,实际上 React 16 并没有开启 Concurrent mode,直到 React 18 发布默认就支持了 Concurrent Rendering 的机制,且新增了 useTransition。useTransition 是一个帮助你在不阻塞 UI 的情况下更新状态的 React Hook 函数,开发者可以使用 useTransition 将状态更新标记为非阻塞,一旦有用户交互的任务,React 就会先暂停当前非阻塞任务,立刻执行用户交互的高优先级任务。具体用法这里不再介绍,官方文档写的很详细。

使用 useTransition 也需要注意以下几点:

*组件要细粒度拆分,如我们把这个 demo 中的 PostsTab 换成如下:

import { memo } from 'react';

const PostsTab = memo(function PostsTab() {
  let items = [];
  for (let i = 0; i < 500; i++) {
    let startTime = performance.now();
    while (performance.now() - startTime < 1) {
      // Do nothing for 1 ms per item to emulate extremely slow code
    }
    items.push(<li className="item" key={i}>Post #{i + 1}</li>);
  }
  return (
    <ul className="items">
      {items}
    </ul>
  );
});

你会发现 useTransition 失去了效果,因为任务都在 PostsTab 组件中,React 无法拆分任务。

  • 单个组件内耗时不能过长,否则就存在和上面一样的问题。

总结

React 有两个核心概念 Virtual DOM 和 immutability(不变性),有意思的是 millionjs 是消除 Virtual DOM 的方案,signal 是响应式方案(mutability)。Dan 在多个场合都表达了 Virtual DOM 的重要性,不仅仅是 UI 在内存中的描述,很多特性都是在此基础上去实现,如 Server Component。故 Concurrent Rendering 是重运行时方案的最佳选择,运用浏览器执行机制,及时响应用户交互任务,合理的运行非阻塞任务。

signal 是完全融入 React 渲染流程的,可以和 useTransition 结合使用,实践下来开发体验很好。而且在可视化搭建场景,响应式数据比 immutability 数据开发成本小很多。

参考

最近的文章

搭建whatsapp机器人

在今天的数字时代,智能机器人和自动化任务的需求不断增长。此次分享将深入探讨如何使用 whatsapp-web.js 框架来实现与 WhatsApp Web 客户端的互动,从而创建一个功能强大的 WhatsApp 智能机器人。 还会揭示技术实现原理,媒体数据解密方法,以及如何接入 ChatGPT,使机器人更具智能化。先来看下半成品机器人的效果智能回复手机远程发送锁屏指令智能回复本地知识库媒体资源 背景:WhatsApp 个人管理是目前主站正在做的一个新的产品方向。许多外贸人员联系海外客户的...…

继续阅读
更早的文章

Node event loop 扒皮

时间循环的流程如下┌───────────────────────┐┌─>│ timers ││ └──────────┬────────────┘│ ┌──────────┴────────────┐│ │ I/O callbacks ││ └──────────┬────────────┘│ ┌──────────┴────────────┐│ │ idle, prepare ││ └──────────...…

event loop继续阅读