Better

Ethan的博客,欢迎访问交流

React batch update

最近开发中,碰到个很奇怪的问题,导致出现了意料之外的 bug,在异步中多个调用 setUpdater,会导致组件 re-render 多次,而且每次调用 setUpdater 都会导致组件同步 re-render,且 useEffect 也会同步执行,从而代码流程不符合预期。顺带提高 React 性能优化与闭包问题。

现象

正常的 event 事件处理是没有问题的,多次调用 setUpdater 只会触发一次的组件的重新渲染,而且是异步的。

import React, { useState, useEffect, memo } from "react";

function DelayedCount() {
  const [count, setCount] = useState(0);

  function handleClickSync() {
    console.log("sync start");
    setCount(count + 1);
    console.log("sync after1");
    setCount(count + 1);
    console.log("sync after2");
  }

  useEffect(() => {
    console.log("effect");
  }, [count]);

  console.log("render", count);

  return (
    <div>
      {count}
      <button onClick={handleClickSync}>Increase sync</button>
    </div>
  );
}

export default memo(DelayedCount);

// 输出结果
// render
// 0
// effect
// sync start
// sync after1
// sync after2
// render
// 1
// effect

但对于 Promise、setTimeout、setInterval 等异步,就会触发组件的多次同步更新。

import React, { useState, useEffect, memo } from "react";

function DelayedCount() {
  const [count, setCount] = useState(0);

  function handleClickAsync() {
    Promise.resolve().then(() => {
      console.log("async start");
      setCount((count) => count + 1);
      console.log("async after1");
      setCount((count) => count + 1);
      console.log("async after2");
    });
  }

  useEffect(() => {
    console.log("effect");
  }, [count]);

  console.log("render", count);

  return (
    <div>
      {count}
      <button onClick={handleClickAsync}>Increase async</button>
    </div>
  );
}

export default memo(DelayedCount);

// 输出
// render
// 0
// effect
// async start
// render
// 1
// async after1
// effect
// render
// 2
// async after2
// effect

结论

其实在 Class 组件中也是同样存在这个问题的,之所以没被发现,是因为对于 Class 组件而言,State 是一个大的单元,我们通常都只会 setState 一次。但是在使用 Hooks 时,state 会被拆成很多小的单元,因此我们很有可能会 setUpdater 多次,因此我们很容易发现这个问题。组件调用多次是最容易被观察到的现象,但组件同步更新更应该收到关注,这在 Class 还是 Hooks 中,都是一样的表现。

看一个 Class 组件的具体表现例子

import React from "react";
import { getData } from "./mock";

interface State {
  data: null | { name: string };
}

export default class Test1 extends React.Component {
  state: State = {
    data: null
  };

  handleClick = () => {
    getData().then((data) => {
      this.setState({
        data
      });
      console.log("fetch finished", JSON.stringify(this.state));
    });
  };

  render() {
    console.log("render", JSON.stringify(this.state));
    return (
      <div>
        <p>name: {this.state.data?.name}</p>
        <button onClick={this.handleClick}>Class Test Btn</button>
      </div>
    );
  }
}

// 输出结果为
// render {"data":null}
// render {"data":{"name":"LiuXinqiong"}}
// fetch finished {"data":{"name":"LiuXinqiong"}}

怎么解决

在 Redux 的文档中提到过一个 batch 函数,解释说明中提到

React's unstable_batchedUpdates() API allows any React updates in an event loop tick to be batched together into a single render pass. React already uses this internally for its own event handler callbacks.

大概意思就是说,unstable_batchedUpdates 该 api 使得在一个事件循环内的 React 更新都被批量处理,因此组件只会更新一次。还提到,React 的事件处理回调已经在内部自行使用。

考虑到 React-Redux 需要跨平台使用,比如 React-DOM,React-Native,因此 React-Redux 会在编译期导入从正确的渲染器中导入该 api,同时从 react-redux 中导出该 api,并重命名为 batch,你可以使用该 api 确保多次 action 只会引发依次组件更新

import { batch } from 'react-redux'

function myThunk() {
  return (dispatch, getState) => {
    // should only result in one combined re-render, not two
    batch(() => {
      dispatch(increment())
      dispatch(increment())
    })
  }
}

为什么

React 性能

React 性能主要做了哪些事情呢

  1. Diff
    • 同层比较
    • 列表优化
    • 相同组件
  2. 事件代理
  3. 渲染
    • 批量处理
    • 子树渲染
    • 可选的子树渲染

关于 Diff 已经聊的很多的,接下来看看其他两个

事件代理

将事件监听器附加到DOM节点非常缓慢,而且非常消耗内存的。

React 采用事件代理的方式,React 更进一步,重新实现了一个符合 W3C 标准的事件系统。这意味着 ie8 事件处理漏洞已经成为过去,所有的事件名称在不同浏览器中都是一致的。

React 是如何实现的呢?

  1. 单个事件监听器被附加到文档的根
  2. 当事件被触发时,浏览器会给我们一个目标 DOM 节点。为了通过 DOM 层次结构传播事件,React 不会在虚拟 DOM 层次结构上迭代
  3. 每个 React 组件都有一个唯一的 id 来编码这个层次结构。我们可以使用简单的字符串操作来获取所有父节点的 id。将事件存储在一个 Map 结构中,
  4. React 在启动时分配一个这些事件对象的池,当需要一个事件对象,它就会从该池中重用。这大大减少了垃圾收集。

渲染

当你在组件上调用 setState 时,React 会将其标记为 dirty。在事件循环的最后,React 会查看所有脏组件并重新渲染它们。

当你调用 setState 时,React 会重新生成虚拟 DOM,如果你是在根节点调用,则整个 App 都会重新渲染(所有的组件,尽管它们没有更新),当然你通常都不会这么做。

当前子组件的更新是可选的,你可以通过 shouldComponentUpdate 或 memo 进行优化。这里的比较通常会设计到一个深浅问题,如果需要深比较,建议使用不可变数据的方式。

Lexical scoping

词法作用域意味着变量的可访问性由嵌套作用域内源代码中变量的位置决定。

它被称为词法(或静态),因为引擎(在词法编写时)只通过查看JavaScript源代码来确定范围嵌套,而不执行它。

闭包是一个函数,它从定义它的地方记住变量,而不管它以后在哪里执行。

闭包是一个访问词法作用域的函数,即使在词法作用域之外执行。

为什么闭包那么有用

  • 事件处理函数
  • 回调函数
  • 函数式编程:柯里化

参考资料



留言