读 designing react hooks

读 designing react hooks

Angular 是用于开发单页应用(SPA)的 Web 框架。由 Google 在 2010 年发明。Angular 中编写的代码可在运行时接管 HTML 主体,对其中所有元素应用逻辑。所有代码在浏览器级别运行

<body>
  <div ng-app="myApp" ng-controller="myCtrl">
    <p>Name: <input type="text" ng-model="name" /></p>
  </div>
  <script>
    var app = angular.module('myApp', []);
    app.controller('myCtrl', function($scope) {
      $scope.name= "John";
    });
  </script>
</body>

<div ng-app="myApp" ng-controller="myCtrl"> 这行定义了一个 AngularJS 应用,并指定了这个应用的控制器为 "myCtrl"

ng-model="name" 这个指令将 input 元素的值和 "name" 变量进行了双向绑定,也就是说当 input 元素的值改变时,"name" 变量的值也会相应地改变;反过来,当 "name" 变量的值改变时,input 元素的值也会相应地改变

angular.module('myApp', []) 这行 JavaScript 代码创建了一个名为 "myApp" 的新 AngularJS 应用

app.controller('myCtrl', function($scope) {... 这行 JavaScript 代码定义了一个新的控制器 "myCtrl"。控制器是 AngularJS 中用于控制 AngularJS 应用的 JavaScript 对象

$scope.name = "John" 这行 JavaScript 代码设置了 "name" 变量的初始值为 "John"。$scope 是应用在 HTML(视图)和 JavaScript(控制器)之间的桥梁


Polymer 是由 Google 开发并于 2015 年发布的,旨在使用 Web 组件构建 Web 应用程序。2018 年,Polymer 团队宣布将所有未来的开发工作转移到 LitElement 上,以创建快速且轻量级的 Web 组件

React 和 LitElement 之间有很多相似之处,因为它们都允许你使用 render 函数定义类组件。但是 LitElement 的独特之处在于一旦元素被注册

在将 LitElement 集成到 HTML 中时,没有明显的入口点,因为它不需要在使用之前控制 body 元素。我们可以在其他地方设计该元素,在使用时更像是使用 h1 元素。因此,它完美地保留了 HTML 文件的完整性,同时将额外的功能外包给了自定义元素,这些自定义元素可以由他人设计。

LitElement 的目标是使 Web 组件能够在任何框架内的任何网页中工作。

@customElement('my-element')
export class MyElement extends LitElement {
  render() {
    return html`
      <h1>Hello, ${this.name}!</h1>
      <button @click=${this._onClick}>
      Click Count: ${this.count}
      <button>
      <slot></slot>
   `;
}

@customElement('my-element'):这是一个装饰器,它告诉浏览器这个类是一个自定义元素,元素的名称是 'my-element'。当这个元素被添加到 DOM 中时,它将实例化这个类。

export class MyElement extends LitElement: 这是一个类定义,它表示 MyElement 是一个从 LitElement 继承的自定义元素。

render():这是 LitElement 中的一个重要方法,当元素需要更新时,这个方法会被调用。这个方法返回一个模板,描述了元素的 UI。

return html``;:这是一个模板,它使用了 JavaScript 的标签模板字符串。html 是 Lit 的一个函数,它创建了一个 HTML 模板。

<h1>Hello, ${this.name}!</h1>:这里使用了模板字符串插值 ${this.name},当 this.name 的值改变时,元素将自动更新。

<button @click=${this._onClick}>:这里,@click 是一个事件监听器,当按钮被点击时,它会调用 _onClick 方法。

<slot></slot>:这是一个名为 "slot" 的 Web 组件 API,它是 shadow DOM 的一部分,允许你在自定义元素中插入内容。


如今越来越多地使用单页应用程序(SPA),在网站上实时更新页面的部分内容,使其感觉像本机应用程序,追求快速响应时间

无状态纯函数型组件给了我们更多优化性能的机会

当将 React 与其他库进行比较时,使用“单向传递”这个术语来指代这种行为


React 事件处理程序与 DOM 事件处理程序略有不同。React 事件是合成事件,是浏览器原生事件的跨浏览器封装

由于 JavaScript 闭包,可直接在事件处理程序内部访问任何组件变量

“无状态”指函数组件不能携带或共享值给另一个更新。每次更新时该函数重新运行并产生相同输出


组件不是最小单位。更细粒度的结构叫 fiber,用于表示元素片段。fiber 执行该元素的所有任务。这个元素可以是简单的 h1、div 等,也可以是有不同功能的人工元素。如 fragment 元素可将其他元素分组而自身不显示,或 memo 元素可记住上次更新时的所有元素

函数组件是 fiber 代表的一种人工元素。函数组件允许定义它能够显示哪些子级元素,每当被调用时,它都能确定屏幕需要更新哪些 DOM 元素

React 使用 Hook 结构将 state 存储在 memoizedState 属性下

Hook 是用于保存状态的结构(或类)。并不是 React hook(函数)。为了区分,将结构称为 Hook,而将函数称为 hook

Hook 结构主要功能是通过 state 属性来保存单个状态。与数组(或对象)中有多个状态不同,多个状态通过链表连接在一起,如图 3.2 所示。一个 Hook 通过其 next 属性指向另一个 Hook。当到达列表末尾时,最后一个 Hook 的 next 属性被设置为 null。这就是编程中典型链表的工作方式。第一个 Hook (如果有)存储在 fiber 的 memoizedState 下;这样一来,fiber 就可以找到所有跟随第一个 Hook 的 Hooks。

为了让引擎知道屏幕是否有任何变化,需要更新 fiber。在更新函数中,这是初始化 Hook 的地方

React 通过 updateFunctionComponent 函数来更新一个函数组件。输入参数接受一个 Component 函数和它的 props 输入:

let updatingFiber = ...
function updateFunctionComponent(Component, props) {
  prevHook = null
  let children = Component(props)
  ...
}

update 函数的主要工作是调用 Component(props)来了解新的子元素。以 Title 组件为例,当它需要更新时,updateFunctionComponent 函数会调用 Title()。通过这样做,引擎会比较返回的元素和屏幕上显示的内容,并提交差异。

在前面的 update 函数中定义了两个全局变量。它们非常容易理解。updatingFiber 表示引擎正在更新的当前 fiber,prevHook 指向此 fiber 之前使用过的 Hook。在组件被调用之前,引擎会填充 updatingFiber(如 Title),并将 prevHook 设置为 null。

第一次更新组件(即挂载)时,就是创建该 fiber 对应的第一个 Hook。

在挂载一个 Hook 到当前正在更新的 fiber 上时,React 会创建一个新的 Hook 对象并将其追加到链表中:

一个简单的 useState 的实现?

function mountHook() {
  const Hook = {
    state: null
    next: null
  }
  if (prevHook === null) {
    updatingFiber.memoizedState = Hook
    prevHook = Hook
  } else {
    prevHook.next = Hook
    prevHook = prevHook.next
  }
  return Hook
}
 
function updateHook() {
  var Hook
  if (prevHook === null) {
    Hook = updatingFiber.memoizedState
  } else {
    Hook = prevHook.next
  }
  prevHook = Hook
  return Hook
}
 
function _useHook(initialState) {
  let Hook
  if (isFiberMounting) {
    Hook = mountHook()
    Hook.state = initialState
  } else {
    Hook = updateHook()
  }
  return Hook.state
}

根据组件是否通过 isFiberMounting 标志位处于挂载状态,前面的_useHook 函数获取一个持久化的 Hook。如果它在挂载阶段,React 会将初始状态赋给该 Hook。对于任何其他更新,该 Hook 不会被触碰。无论哪种情况,都会返回该 Hook 下的状态。


Fiber 是 React 团队为了改进框架的性能和可用性而引入的新架构,特别是在处理大型和复杂应用程序时。Fiber 首次在 React 16 版本中引入。

在介绍 Fiber 之前,我们需要了解一些背景。React 的工作方式是将应用程序的 UI 视为一系列在不同状态下的“快照”。当应用程序的状态改变时,React 会创建一个新的 UI 树。然后,React 会将新的 UI 树与旧的 UI 树进行比较,找出两者之间的差异,然后将这些差异应用到 DOM 上。这个过程被称为“reconciliation”或者“diffing”。

在旧的 React 版本中,这个过程是同步进行的,一旦开始就不能被打断。然而,对于大型和复杂的应用程序来说,这可能会导致性能问题。例如,如果 React 在浏览器需要更新界面(也就是“帧”)之前无法完成这个过程,那么用户就会看到延迟,这会让应用程序感觉“卡顿”。

Fiber 的引入就是为了解决这个问题。在 Fiber 架构中,reconciliation 过程被分解成许多小的任务单元。React 可以在完成一个任务单元后,根据需要将控制权返回给浏览器,让它有机会更新界面。这意味着 React 可以将工作“暂停”,然后在稍后的一个更合适的时间点“继续”。这就使得 React 能更好地管理其工作负载,从而提供更流畅的用户体验。

Fiber 还引入了一些其他的改进,比如在组件生命周期方法中支持异步执行,以及新的 API 如 React.lazy,它支持组件的动态加载。

总的来说,Fiber 是 React 团队为了改进性能和可用性所做的一次重大的内部重构。尽管它对 React 的使用者来说是透明的,但是了解 Fiber 如何工作可以帮助你更好地理解 React 的行为和性能特性。


钩子是一种特殊的函数,并且有一点需要注意——它们的调用顺序。

基本上,React 不使用显式键值(key),因为列表中的顺序就是键值(key),而这个键值被称为 hooks 调用顺序。只要 a 的第一个 hook 首先调用,b的第二个 hook 其次调用,存储在列表下方状态位置就能正确标记。所以我们不必有意识地跟踪键值(key),因为在编写所有 hook 语句之后,调用顺序应该已经确定。

这个调用顺序并不是在代码编译期间固定下来的;相反,在运行时才决定。有什么区别呢?区别就是运行时可以改变

任何涉及条件的 hook 语句都无法使用。

React hook 是一个特殊函数,在函数组件中允许持久化状态。

Browser DOM dispatch action to -> Render -> Commit apply change to -> Browser DOM

React 对我们的作用是允许分派的动作更新变化以在屏幕上反映出来。React 将每个更新分为两个主要阶段,即渲染和提交,如上图所示。渲染会逐一遍历所有元素并收集所有变化,而提交则会一次性将这些变化应用到 UI 上。

这个引擎有一个代号叫做 Fiber。为了方便处理所有这些,React 创建了一个内部对象称为 fiber 来表示每个元素。正如我们介绍过的那样,元素可以是经典元素(比如 DOM 元素)或者人工创建的元素(比如函数组件)。在物理 DOM 和 React 元素之间添加一个层级有两个好处。

函数组件(或类组件)更容易让开发人员将他们的 UI 和逻辑组织成一个功能单元。拥有一个包装着这样单元的 fiber 可以提供一些通用的元素行为,并且对特定的元素添加特殊处理。例如,我们已经介绍了 updateFunctionComponent 来更新函数组件,但对于其他类型的元素,则需要使用不同的更新函数。

另一方面,在 UI 引擎中增加额外层级可以进行优化。事实上,React Fiber 并不盲目地向屏幕进行更新。在第一次更新中(也就是挂载时),每个 fiber 都会被创建,并且所有的 DOM 元素都是从头开始创建的。这个更新应该与经典更新非常接近。

然而,之后的一切都不同。对于新的更新,假设只有屏幕的一小部分需要调整。因此,在 React 更新屏幕之前,它会遍历存储在上一个更新中的所有 fiber,并将它们与新渲染的元素进行比较。这个比较被称为协调(reconciliation),即将新元素与先前 DOM 元素进行比较,以得出需要在此次更新中应用的新 DOM 更改。React 使这种协调非常高效,以便仅应用必要的更改到屏幕上。

优化不仅限于协调。为了确保事情能够高效完成,每个 fiber 还充当一个工作单元。在每次更新期间,所有 fiber 都被发送到一个流水线中,在那里每个 fiber 逐个进行处理。这样做有一定的优势。现在更新工作不再是全盘接受或拒绝了。引擎可以完成 10 个单位中的 9 个单位而不损害更新任务的完整性,因为当资源耗尽时可以暂停,并且一旦获得足够计算时间就可以回来完成最后一个单位。其中一个直接好处是浏览器对更紧急工作能够快速响应。

实际上,对于每个元素,React 会创建两个 fiber,并且这两个 fiber 的名称分别是 current 和 workInProgress。

为了在提交之前/之后实现屏幕过渡的流畅性,它使用两个内存中的场景,分别命名为 current 和 workInProgress。

最初,这两个场景都是空的,因为节目还没有开始。当 current 为空时,我们在 workInProgress 场景上工作;这一步被称为 Mount(挂载)。这意味着每个组件都需要从头创建,因此挂载相对较重。一旦 workInProgress 准备好了,舞台就会旋转以使其成为 current。在编程中,这只是一个指针赋值操作。

在未来的任何动作中,workInProgress 场景开始再次准备。这次工作 InProgress 不需要从头创建, 因为它可以克隆当前未改变的内容, 这一步被称为 Update(更新)。相对而言,在 DOM 访问方面更新要求比挂载要少得多。一旦 workInProgress 准备好了, 舞台就会旋转以使其成为新的 current 场景。

无论是挂载还是更新期间, 我们选择 workInProgress 来处理未来场景, 同时将 current 视作上次完成工作(除非在 mount 阶段,current 为空) 。因此, 要判断任何组件是否处于 mount 或 update 状态, 我们可以检查 current 是否为空:

const isFiberMounting = current === null

除非你从事引擎开发工作,否则你不会同时获得两个场景,因为核心外部的开发人员使用 workInProgress 进行开发而用户观看 current。对于他们来说,只有一个场景。


至于每个 DOM 元素(例如 div)是否都有配套的两个 Fiber 节点,这其实取决于元素是否有状态变化。如果一个元素没有任何状态的变化,那么 React 并不会为它创建新的 Fiber 节点。只有在元素状态变化时,React 才会创建新的 Fiber 节点。当然,如果一个元素从来没有变化过,那么它的 Fiber 节点就会一直保持不变。

所以说,React 中的每个元素并不总是对应两个 Fiber 节点(current 和 workInProgress)。只有在元素有状态变化时,才会有对应的 workInProgress 节点。


假设我们建立了一个网站,并最终得到了一棵 fiber tree。当用户执行某个操作(比如点击)时,该操作通过事件处理程序向纤维发送一个信号。我们将这个 fiber 称为源 fiber。

现在,假设派发的事件将计数器从 0 更改为 1。React 应该根据这个用户操作安排更新,并准备好屏幕上所有的文档对象模型(DOM)元素。假设红线表示需要更改的纤维,那么 React 是如何找出这些要更改的纤维呢?

收到更新请求后,React 从根节点开始遍历整棵 fiber 树。有很多条 fiber(灰色线条)与此次更新无关,因此它们会从之前的场景中进行克隆。当更新到达源纤维时,让我们想象一下该纤维携带着一个函数组件并调用名为 updateFunctionComponent 的更新函数:

let updatingFiber = ...
function updateFunctionComponent(Component, props) {
  let prevHook = null
  let children = Component(props)
  ...
  reconcileChildren(children)
  return updatingFiber.child
}

第二部分的函数接受 Component 函数返回的子元素,并通过 reconcileChildren 将它们转换为 fiber。在整个过程结束时,第一个子 fiber 告诉引擎下一步要处理什么。这个过程会一直持续,直到访问了源 fiber 下的所有 fibers,即图 4.1 中显示的红色区域。

通过这种方式,状态变化会通过该分支传播到子 fibers 中。当父级更新时,在更新之前,子级会获得一组新的 props,并承载状态的影响。这基本上就是 React 生态系统中状态发挥作用的方式

使 useState 正常工作所需的数据结构包括 Hook 类型、Queue 类型和 Update 类型

Hook 使用一个 state 属性来存储状态,同时还有一个 next 属性指向下一个 hook。现在的新变化是为了支持 dispatch 功能,添加了一个 queue 属性,在其中提供了一个 dispatch 函数来分发带有新状态的 action 对象。在队列中,更新列表被存储在名为 pending 的属性下。队列的工作是维护对该 fiber 的待处理更新列表 - 这样用户就可以向该 fiber 分派多个更新

更新(update)被定义为一个包含由用户提供的用于计算下一个状态的动作函数。每个更新通过一个名为 next 的属性与另一个更新相关联,形成循环链表。这个链表类似于 hooks 是如何链接在一起的,不同之处在于更新以圆形方式链接,其中最后一个更新始终指向第一个更新。

1 -(next)-> 2 -(next)-> 3 -(next)-> 1
pending -> 3

队列中有三个待处理的更新,其中最后一个更新的 pending 属性指向第一个更新。当我们需要在列表的头部或尾部插入或删除更新时,这个循环链表非常方便

useState 的源代码以典型的钩子方式组织,在 mountState 或 updateState 之间选择路径,具体取决于 fiber 是否处于挂载状态还是更新状态

function useState(initialState) {
  if (isFiberMounting) {
    return mountState(initialState)
  }
  else {
    return updateState(initialState)
  }
}
function mountState(initialState) {
    const hook = mountHook ()
    if (typeof initialState === 'function') {
      initialState = intialState()
    }
    hook.state = initialState
    hook.queue = {
      pending: null
      dispatch: dispatchAction.bind(
        null,
        updatingFiber,
        hook.queue
      )
    }
    return [hook.state, hook.queue.dispatch]
}

调度函数被设计用于派发带有新状态的动作。它是通过一个实用函数 dispatchAction 来创建的,该函数接受一个 fiber、一个队列和一个动作作为参数。

在将 dispatchAction 函数分配给队列时,它会绑定更新 fiber 和队列,以便调度函数可以接受动作对象作为唯一输入参数:

function dispatchAction(fiber, queue, action) {
  const update = {
    action
    next: null
  }
  const pending = queue.pending
  if (pending === null) {
    update.next = update
  }
  else {
    update.next = pending.next
    pending.next = update
  }
  queue.pending = update
  // Appendix A: Skip dispatch
  scheduleUpdateOnFiber(fiber)
}

该函数从其输入参数中获取一个动作对象,然后创建一个新的更新对象,并将其附加到队列对象中。与待处理相关的前面代码都是列表操作,所有这些操作都将更新对象追加到列表末尾,同时确保队列继续形成循环链表

在更新队列完成后,它通过 scheduleUpdateOnFiber 函数请求进行更新。这个函数实质上会启动 React 引擎中我们在本章开头介绍过的更新流程。这是 React 处理用户操作的主要途径。

React 引擎内部有许多优化措施。其中一些不对外公开,因为它们属于引擎代码的一部分。例如,在不调用 scheduleUpdateOnFiber 函数的情况下可以取消 dispatch 或整个更新过程的隐藏路径存在

function updateState(initialState) {
    const hook = updateHook()
    const queue = hook.queue
    let updates = queue.pending
    queue.pending = null
    if (updates != null) {
      const first = updates.next
      let newState = hook.state
      let update = first
      do {
        const action = update.action
        newState = typeof action === 'function'
          ? action(newState) : action
        update = update.next
      }
      while (update !== null && update !== first)
      if (!Object.is(newState, hook.state)) { … }
      hook.state = newState
    }
    return [hook.state, hook.queue.dispatch]
}

一旦我们有了钩子,我们可以检查它是否在 queue.pending 对象下有任何待处理的更新。pending 对象之所以可能有任何更新,是因为调用了 dispatch 函数。它遍历第一个 pending.next 更新,并按照 update.next 的顺序迭代它们。对于每个更新,它获取存储的 action 对象并将其应用于先前存储的状态,形成一个新的 newState 对象,并最终将其存储回钩子中。

更新后的 newState 对象与先前状态对象进行比较,以确定是否发生变化

if (!Object.is(newState, hook.state)) {
  didReceiveUpdate = true
}

在更新过程中,当调用useState钩子时,它首先检查是否处于挂载或更新状态。如果处于挂载状态,它会存储初始状态,创建一个dispatch函数,然后返回。如果处于更新状态,则会检查是否有待处理的更新,并将其应用于新的状态。无论哪种情况,都会返回[state, dispatch]。

重新渲染也就是要重新build fiber tree,还是需要遍历所有hook

新的更新是分配状态对象的地方。派发函数的目的仅仅是请求变更,但真正的变更直到下一次更新才应用

调度后的状态与当前状态进行比较,以确定是否发生最终调用。这意味着并不是所有的调度都会导致状态变化

React在比较两个状态时使用了Object.is函数。这是一个JavaScript原生函数,与JavaScript的严格相等运算符(===)非常相似

对于JavaScript中的非原始类型,比如对象或数组,使用引用(也称为指针)来指向特定的内存空间:

{} === {}               false
v === v                 true

这意味着如果你分配了两个新对象,它们不能指向同一个内存空间。因此,即使两个对象包含完全相同的内容,比较它们应该返回false。相反地,比较同一个对象(例如v和v)应该返回true,无论对象的内容如何变化

使用Object.is函数或严格相等运算符(===)可能需要一些时间适应。你可以问自己一个简单问题:要比较的值是否可变?如果是,则按引用进行比较;如果不是,则按值进行比较。

React中,连续的多个调度被设计为一起处理,就像下面的例子:

const [state, dispatch] = useState(1)
  const onClick = () => {
  dispatch(3)
  ...
  dispatch(5)
}

每个dispatch确实会调用scheduleUpdateOnFiber来启动React进行更新过程。然而,这个函数设计成等待同一操作的所有调度完成后才进行最终更新。因此,在这种功能下,多个调度可以合并成一个延迟运行的更新操作。

实际上,并非所有的dispatch都能成功执行。它有一条特殊的路径,当满足条件并且发现没有状态改变时,可以提前返回而不执行dispatch:

function dispatchAction(fiber, queue, action) {
  ...
  if (NoWorkUnderFiber) {
    const currentState = queue.lastRenderedState
    const newState = typeof action === 'function'
      ? action(currentState) : action
    if (Object.is(newState, currentState)) {
      return
    }
  }
  scheduleUpdateOnFiber(fiber)
}

对于任何已更新的fiber,都会添加一个名为didReceiveUpdate的标志,用于指示fiber是否发生了变化。在开始处理fiber后,任何导致变化的hook都可以将此标志设置为true。之后,如果工作完成且标志仍然为false,则意味着该纤程绝对没有发生任何变化,因此React通过从上一个场景克隆它并继续下一个纤程来放弃该纤程

let updatingFiber = ...
function updateFunctionComponent(Component, props) {
  let prevHook = null
  let didReceiveUpdate = false
  let children = Component(props)
  if (!isFiberMounting && !didReceiveUpdate) {
    return bailout(updatingFiber)
  }
  ...
}

在前面的updateFunctionComponent函数中,在调用Component函数之后,它会检查两个标志位。一个是isFiberMounting,当站点处于挂载状态时,由于所有的fiber仍然需要被创建,所以不能放弃任何工作。另一个标志位是didReceiveUpdate。当这两个标志位都为false时,就会进入到fiber的放弃流程。

通过克隆当前树中的子fiber来进行fiber的放弃操作,这样可以将所有已完成的工作包括旧属性和渲染DOM一起传递过去。基本上,在进行放弃操作时,并不需要执行常规协调工作来确定新的子fiber。更好的是,如果发现该fiber下没有任何工作要做,则整个分支都会被放弃


useEffect的副作用,side-effect,就是函数式编程说的那一套

通常有两种策略来解决函数的不纯问题

  • 通过将依赖项添加到输入参数中来消除它们,这样它们就不再是隐藏的
  • 与其去除不纯性质,我们可以将其打包并推迟到实际执行时才执行(useEffect)
function addFunc(c, log) {
  function add(a, b) {
    log(a, b)
    return a + b + c
  }
  return add
}

addFunc函数返回一个add函数。要使用add函数,我们调用addFunc来获取我们的add函数的句柄(也称为回调):

const add = addFunc(3, console.log)

那么这有什么区别呢?c和log的依赖关系出现在输入参数中,所以addFunc是一个纯净函数。实质上,我们将任何不纯之处打包起来,并在更高一级声明它们,因此在addFunc的上下文中,新的add函数看起来更加纯净。

从某种意义上说,我们保留了原始代码,但将其封装起来以获得一个回调函数,以便稍后执行它。这有助于保护主要代码的完整性同时重新定位不纯之处。这种延迟策略通常被称为副作用

在松散连接的开放系统(如Web)中,副作用是不可避免的。如果你想执行一系列操作,并且其中一项操作恰好没有被内部系统定义,则该操作涉及访问外部系统。虽然我们无法避免副作用,但我们可以将其封装起来,在适当的时候访问外部系统。

在React中,副作用指的是我们试图从外部系统读取或写入数据的情况。外部系统可以是DOM元素、文档或窗口对象等对象,也可以是与Web服务器进行通信。

用户操作之后,dispatch被安排以触发渲染,随后进行commit以形成更新。在更新过程中,React不允许立即调用自定义副作用。相反,在提交结束之前,React会等待并在此时才调用它们。

如果在更新过程中遇到两个副作用,则两者都会被延迟执行,并且在提交后逐个被调用。这些效果称为内部的被动效果。被动效果是React支持的多种类型效果之一。

由于其在更新期间的调用方式而称为"被动"。React允许我们每次更新时或根据值变化有条件地调用一个被动效果。因此,该效果与用户事件不同,并且当值发生变化时创建、排队和稍后再次调用该效果。从某种意义上说,通过一个"被动"事件来触发该效果。

一个被动效果可以建模为回调函数。在这个例子中,假设它叫做create函数。调用create函数会执行该效果,并返回一个destroy函数来执行与该效应相关联的清理工作。

为了跟踪effects,React在UpdateQueue类型的fiber下创建一个updateQueue属性。在这个队列中,一系列的效果被存储在lastEffect属性下。effects通过循环链表连接在一起,类似于useState中的待处理队列

useEffect钩子遵循典型的钩子设置,在isFiberMounting标志(如第3章“接入React”所述)的指导下,根据fiber是挂载还是更新,采用mountEffect或updateEffect路径。

function useEffect(create, deps) {
  if (isFiberMounting) {
    mountEffect(create, deps)
  }
  else {
    updateEffect(create, deps)
  }
}
function mountEffect(create, deps) {
  const hook = mountHook()
  hook.state = pushEffect(
    create,
    undefined,
    deps,
  )
}
 
function pushEffect(create, destroy, deps) {
  const effect = {
    create,
    destroy,
    deps,
    next: null,
  }
  let queue =  updatingFiber.updateQueue
  if (queue === null) {
    queue = { lastEffect: null }
    updatingFiber.updateQueue = queue
    queue.lastEffect = effect.next = effect
  }
  else { …
    queue.lastEffect = effect
  }
  return effect
}

pushEffect函数创建一个包含所有效果信息的effect,例如create、destroy、deps和next。然后,在当前更新的fiber下找到updateQueue函数。如果队列为空,则附加新的effect。否则,将新的effect追加到队列中。无论哪种情况,它都将新的效果作为队列中的lastEffect对象追加进去。由于它是一个循环链表,在进行了所有先前指针操作之后,它确保lastEffect.next对象仍然指向列表中第一个效果。

function updateEffect(create, deps) {
  const hook = updateHook()
  let destroy = undefined
  const prevEffect = hook.state
  destroy = prevEffect.destroy
  if (deps) {
    const prevDeps = prevEffect.deps
    if (areDepsEqual(deps, prevDeps)) {
      return
    }
  }
  hook.state = pushEffect(
    create,
    destroy,
    deps,
  )
}

一旦我们有了hook,就可以检查挂载点中设置的前一个effect,并比较deps数组是否发生了变化。如果deps数组与存储在prevEffect中的prevDeps对象没有发生变化,则不会将effect推送到updateQueue。

effects被推入每个fiber下的队列中,但它们是在屏幕即将改变时进行调度。

每个create函数都通过enqueueEffect函数收集到列表中

function enqueueEffect(fiber, effect) {
  effectCreateList.push(effect)
  if (!rootDoesHaveEffects) {
    rootDoesHaveEffects = true
    scheduleCallback(() => { flushEffects() })
  }
}

前面的enqueueEffect函数接收fiber和effect作为参数,并将其推入effectsCreateList数组中。然后,它调度flushEffects回调函数。之所以数组不会立即被处理(或清空)是因为它必须等到更新结束才能进行。在这里,React使用一个全局标志(rootDoesHaveEffects)来确保只触发一次此调度。

销毁函数也遵循同样的过程。对于每个更新,我们最终得到两个效果列表:一个是effectCreateList,另一个是effectDestroyList。尽管相似,但这两个列表并不一定包含相同的效果列表,因为有些效果没有销毁回调函数。此外,在组件卸载时,需要将销毁回调添加到effectDestroyList中。

scheduleCallback函数非常有趣。它不会立即刷新和执行效果,而是稍后执行,就像JavaScript任务中的异步任务一样

React只有在屏幕上更新了DOM更改之后,才能再次访问效果列表

function flushEffects() {
  effectDestroyList.forEach(effect => {
    const destroy = effect.destroy
    effect.destroy = undefined
    if (typeof destroy === 'function') {
      destroy()
    }
  })
  ...
}
 
function flushEffects() {
  ...
  effectCreateList.forEach(effect => {
    const create = effect.create
    effect.destroy = create()
  })
}

注意React遍历这两个列表的顺序-它从destroy开始,然后再到create。由于这两个列表都是从所有fiber收集而来的,所以create函数可能包含对即将被销毁或清理的组件变量的引用。为了让create函数有机会充分意识到这种情况,在此之前需要调用destroy函数。简而言之,在考虑新效果之前,必须清理所有先前的效果

在更新过程中,当useEffect钩子被调用时,如果组件处于挂载状态,则创建该effect。如果组件处于更新状态,则根据依赖项是否有任何更改来创建effect。如果依赖数组没有变化,则跳过该effect的执行。无论何种情况下,当创建了effect后,它都会被追加到fiber的updateQueue中,并存储在hooks的状态下。

在屏幕更新之前,React会从所有fiber中获取所有effects,并安排一个刷新操作(如图5.4中的虚线所示)。在将所有fiber更改应用到DOM后,React通过逐个调用这些effects来清除它们,首先是先前的销毁效果,然后是新建的创建效果。

function areDepsEqual(deps, prevDeps) {
  if (!prevDeps) {
    return false
  }
  for (let i = 0;
       i < prevDeps.length && i < nextDeps.length;
       i++)
  {
    if (Object.is(deps[i], prevDeps[i])) {
      continue
    }
    return false
  }
  return true
}

useEffect(fn, []),对于空的依赖项,在第一次更新之外的所有更新中,areDepsEqual都会返回true,因为对于挂载而言,deps数组仍被认为是从undefined更改过来的。因此,在此之后,effect不再被创建。

因此,以下是一个简要列表总结所有这些情况。

  • 它在挂载后运行一次create。
  • 对于任何deps更改,它会运行一次destroy和create。
  • 它在卸载后运行一次destroy。

如果一个 effect 回调函数使用了一个变量,那么这个变量就需要在 deps 数组中。这个说法实际上是99.9%正确的。如果你有意不想在变量改变时更新屏幕,可以跳过将其添加到依赖数组中。然而,React 不推荐这样做。React 甚至还添加了一个 ESLint 插件(eslint-plugin-react-hooks)来帮助我们发现可能遗漏的潜在依赖。

你可能会想知道为什么 React 不希望我们遗漏任何依赖关系。这是因为在 React 中,每个值(或状态)都应该与当前屏幕保持同步,并且默认情况下不能有例外情况。 在第8章《使用 Ref 隐藏内容》中,我们将向您展示一种推荐的方法,如果您坚持要隐藏某些东西不让 React 知道。

useEffect examples

  • Finding the window size
  • Fetching an API resource

关于React,最为人所知的效果是本章节中详细介绍的被动效果(passive effects)。然而,React支持不同种类的效果,并可能在未来增加更多。目前另外两种效果是变异效果(mutation effects)和布局效果(layout effects)。

所有效果都有一些共享的特性,例如在屏幕更新之前从纤维(fibers)中收集。但是它们在某些方面也存在差异。以变异效果为首例。在引擎之下,这种效果是最重要的效果,因为每个变异效果都跟踪了DOM元素的添加、移除或变化。因此,所有纤维的协调最终都会产生变异效果,这些效果将被提交到屏幕。DOM元素的变异也是更新的一部分,或者更准确地说,是更新的提交阶段的一部分。在被动效果运行之后,所有变异效果都在被动效果之前发生。

为了弥补被动效果在更新之后运行的事实(因为到那时可能为了执行某个操作已经太晚了),布局效果被创建出来,以便稍早一些被调用。布局效果的所有方面都与被动效果相似,只是它在变异效果之后立即被调用,并在更新结束之前被清理。所有三种效果的关系和时序可以在图5中的提交阶段概述中总结。请注意,在提交阶段,只有变异效果和布局效果被清理。被动效果在开始时被调度,后来被排队,但是在提交之后才被清理

我们可以看到三个JavaScript任务。什么是任务呢?任务就是通过标准机制计划执行的任何JavaScript代码。在左侧的第一个任务中,我们完成了一个更新。通常,这就是我们需要知道的关于运行JavaScript代码的所有内容。

然而,由于JavaScript是一个单线程引擎,在执行当前任务期间,可能会有更多的工作被添加到待处理队列中。典型的例子是setTimeout调用,它将回调添加到队列中,而不是在同一个任务中立即调用它。API调用(像promises)通常也归入这一类。这就是这些回调被称为异步操作的主要原因。

对于每个任务应该花费多长时间,并没有具体的规定。当一个任务完成时,它会查找待处理队列中的所有工作,然后一次调用一个任务,等所有的事情都完成后,它会再次查看待处理队列。这个过程会无限重复。这就是JavaScript引擎的工作。

在我们的例子中,我们有一个非常短暂的任务(图5.9中的中间任务),然后是第三个任务。猜猜看——这就是开始清理被动效果的时候。从这里我们可以确定,useEffect回调中的回调是异步调用的。

我们提到,多个状态调度也是以一种延迟的方式打包和执行的——那么setState调度是否也是一个异步调用呢?为了回答这个问题,请继续阅读附录C - 调度是否异步?这一部分。

调度是否异步? 由于被动效果在一个新任务中被调用,你可能会想知道setState的调度是在同一个任务中运行还是在一个新任务中运行。这是一个非常好的问题。

要回答这个问题,我们需要一个时间上的参考点。假设我们有一个事件处理器,并在其中有一个setState调度调用:

onClick = () => { setState(1) }

onClick事件是通过用户操作请求的回调。假设调用onClick事件的任务称为任务1。

在React 17(不是当前版本)中,setState代码是同步的,这意味着它在同一个任务1中运行整个更新。React认为在同一个任务中完成它们所有的效率更高。那么,为什么我们说setState对象通常是延迟的呢?

  onClick = () => {
    setState(1)
    // state value isn't changed yet
  }

这是因为在setState之后,值还没有改变。只有下一个更新会将状态设置为新版本。但是,称setState为异步操作并不完全准确(如果不是错误的话),因为所有这些过程都是在同一个JavaScript任务中执行的。

如果我们将setState放在useEffect钩子中会怎么样呢?这种被动效果是否会改变同步或异步的讨论呢?

  useEffect = (() => {
    setState(1)
  }, [])

到现在为止,我们知道useEffect的回调在一个新的JavaScript任务中被调用——假设这个任务被称为任务1。并且setState在同一个任务1中运行。这使得它的行为与事件处理器非常相似,就像我们之前讨论的onClick一样。因此,我们也可以把被动效果想象成一个比事件处理器更被动的“事件”。

这并不阻止我们在真正需要的时候做一个异步调度。让我们看一个例子:

  const [state, dispatch] = useState(1)
  const onClick = () => {
    setTimeout(() => {
      dispatch(3)
    }, 0)
    dispatch(5)
  }

在前面的例子中,setTimeout被用来以异步方式触发一个回调。在鼠标点击后,先调用dispatch(5)。在更新后,尽管超时时间设定为0,但仍然会调用dispatch(3)。

请记住,如果你这样做,你不仅在以异步方式运行回调,而且还会跳出React的调度周期。你可能想要这样做的原因是在DOM变化期间可能会有冲突,例如在处理拖放过程中。为了在做状态改变之前完成我们的代码,我们可以将调度推到下一个JavaScript任务队列中。


const Title = ({ text, flag }) => {
  const found = matchTextInArray(text)
  ...
}
 
const Title = ({ text, flag }) => {
  const [found, setFound] = useState("")
  useEffect(() => {
    setFound(matchTextInArray(text))
  }, [text])
  ...
}

尽管解决方案有效,但我们希望对其进行改进,因为它使用副作用来处理任务分配,并且我们可以看到这个任务分配不必是一个副作用。由于副作用在更新后被收集起来,并且必须等待下一次更新才能执行,所以它与直接赋值(例如 a = 1)行为非常不同。最后但并非最不重要的是,在找到状态中保存了赋值的值,并且该值不必是一个状态。如果我们能够解决所有这些问题将会更好。

useMemo函数的第一个输入参数是一个create函数。当调用该函数时,它会返回一个新值。第二个参数是deps依赖数组

hook的状态在更新之间保持不变,每个hook函数都可以定义它想要持久化的内容(或以何种格式)。例如,useState hook存储一个状态数组,useEffect hook存储一个effect对象,现在useMemo hook则用于存储与赋值相关的内容。事实上,useMemo采用了[value, deps]形式的赋值和依赖数组。

function useMemo(create, deps) {
  if (isFiberMounting) {
    return mountMemo(create, deps)
  } else {
    return updateMemo(create, deps)
  }
}
 
function mountMemo(create, deps) {
  const hook = mountHook()
  const value = create()
  hook.state = [value, deps]
  return value
}
 
function updateMemo(create, deps) {
  const hook = updateHook()
  const prevState = hook.state
  if (prevState !== null) {
    if (deps !== null) {
      const prevDeps = prevState[1]
      if (areDepsEqual(ndeps, prevDeps)) {
        return prevState[0]
      }
    }
  }
  const value = create()
  hook.state = [value, deps]
  return value
}

空依赖:

如果提供了一个deps数组,但是里面没有元素,那么意味着该值不依赖于任何东西。因此,在挂载后只会创建一次该值:

const v = useMemo(() => {...}, [])

如果您想为所有更新保持静态值,这是一个很好的用法。您可能会想知道为什么我们不能将静态值声明移到组件外部。这是因为分配仍然可以使用组件内部的变量。

当使用useMemo应用于非基本类型的值时(例如对象、数组或函数),会有一些微妙之处。当创建这些值时,你得到一个指向新内存空间的新值。这意味着当依赖条件不满足时,将使用旧的内存空间。

总而言之,useMemo钩子可以用作特殊赋值来返回任何类型的值。

useMemo只是在函数组件内部对计算结果进行了缓存,它并不能直接影响组件是否更新。组件的更新是由React的更新机制控制的,而不是由useMemo控制的。

如果希望在父组件更新时跳过子组件的更新,可以使用React.memo高阶组件或React.PureComponent(对于类组件)。这些方法会对组件的props进行浅比较,如果props没有发生变化,就会跳过子组件的更新,从而实现性能优化。

useMemo examples

  • Clicking to search
  • Debouncing the search
const Title = () => {
  const [text, setText] = useState('')
  const [query, setQuery] = useState('')
  const matched = fruits.filter(v => v.includes(query))
  const onType = e => { setText(e.target.value) }
  const onSearch = () => { setQuery(text) }
  console.log('updated', text)      ➁
  return (
    <>
      <input value={text} onChange={onType} />
      <button onClick={onSearch}>Search</button>
      {{matched.join(',')}
    </>
  )
}

给定一个fn函数,如果有请求要调用它,它不会立即执行。相反,它会等待一段时间。在这段时间内,如果没有更多请求要调用它了,那么就在期限结束时进行调用。这种行为被称为防抖。

防抖最初是为了解决机械开关和继电器的问题——巧合吗?为了避免按键太频繁,在键盘处理器中将按键延迟"组合"成单次击打

const debouncedFn = debounce(fn, dt)
 
const setDebouncedQuery = debounce(
  t => { setQuery(t) }, 300
)
const onType = e => {
  const v = e.target.value
  setText(v)
  setDebouncedQuery(v)
}
 
const setDebouncedQuery = useMemo(() => {
  return debounce(t => {
    console.log('clicked')
    setQuery(t))
  }, 300)
}, [setQuery])

TODO: Appendix A and B in chapter 6


Context通过使用一个持有_currentValue值的ReactContext数据类型进行建模。

上下文提供器(如UserContext.Provider)接受一个value属性来共享值。该提供器是一个特殊的组件。与函数组件不同,它有自己的更新实现。当由于值的变化而更新时,它会启动搜索。

搜索会递归地遍历其子节点和子节点的子节点,直到访问完所有下层内容。对于使用上下文的所有消费者,它们都被标记为需要更新。

const Branch = ({ user, theme }) => {
  return (
    <ThemeContext.Provider value={theme}>
      <UserContext.Provider value={user}>
        ...
      </UserContext.Provider>
    </ThemeContext.Provider>
  )
}

那么,在任何提供程序范围之外(例如,在D处),上下文值是什么呢?由于它恰好不是您可以在Branch组件之前找到的祖先提供程序,这就引入了defaultValue:

const ThemeContext = React.createContext(defaultTheme)

在内部,React使用堆栈来推入和弹出上下文,以跟踪所有提供者范围的上下文值


在上面的代码中,我们给h1元素添加了id。但是很快就遇到了一些问题。首先,Title组件被设计成可重用的。这意味着我们可以在当前屏幕上有多个<Title />实例。假设我们想要操作其中一个 - 我们如何通过id找到它?

其次,更重要的是,假设我们找到了想要的元素。由于它现在被包裹在一个组件中,React管理着它的生命周期,那么我们如何知道它准确地何时挂载和卸载?如果对此不确定,我们如何安全地操作它?

在所有的fiber准备好之后,React执行一次提交操作,在屏幕上创建所有的DOM元素。因此,实际上,直到完整的树在内存中更新之前,并没有真正创建物理DOM元素。只有在那时候,如果我们要求它,React才会将元素实例交给我们:

return <h1 ref={ref}>Hello World</h1>

一个ref对象被传递给h1元素作为属性,并且它充当存储容器的角色,要求React一旦可用就将元素实例存储起来。这个ref容器采用特定的格式:

ref = { current: null }

在DOM元素实例创建后,在Commit阶段的commitAttachRef函数中填充了前面的ref:

function commitAttachRef(fiber, instance) {
  const ref = fiber.ref
  if (ref !== null) {
    ref.current = instance
  }
}

在上述commitAttachRef函数中,当提供并初始化了ref时,它的current属性从DOM实例赋值过来。这是针对挂载操作而言。类似地,在即将移除DOM元素时,在Commit阶段的commitDetachRef函数中,current会再次被赋值为null

只要我们提供一个元素的引用(ref),React在挂载和卸载时就会将该元素的实例分配给ref.current。我们可以使用ref.current来操作该元素,就像以前一样。这是访问DOM元素的React方式。

这里有一个微妙之处。请注意,在准备好传递元素实例时,赋值是通过ref.current = instance而不是ref = instance完成的。这是因为React将ref设计为组件生命周期内始终可用的容器。简单地说,容器始终有效,而current属性下的值可能会在过程中发生变化。

除了基本的fiber hook支持外,useRef不需要额外的数据结构

就像useState和useEffect使用state来存储状态和效果一样,useRef使用state来存储引用。接下来,让我们看看它是如何实现的。

useRef钩子遵循了典型的钩子设置,在isFiberMounting标志指示的mountRef或updateRef路径上进行选择,具体取决于fiber是否处于挂载或更新状态

function useRef(initialValue) {
  if (isFiberMounting) {
    return mountRef(initialValue)
  } else {
    return updateRef()
  }
}
function mountRef(initialValue) {
  const hook = mountHook()
  const ref = { current: initialValue }
  hook.state = ref
  return ref
}

组件挂载后,在下次更新并到达useRef钩子时,通过克隆一个hook获取该hook:

function updateRef() {
  const hook = updateHook()
  return hook.state
}

useRef钩子提供了基本的存储用于持久化引用。在挂载后初始化当前值之后,钩子中存储的引用永远不会被更新。

如果将ref作为属性连接到一个元素上,在元素挂载或卸载时,其DOM实例会在当前属性中得到更新。

尽管ref对象始终有效,但current属性并不总是有效的。在挂载完成之前,它可能存储一个空值。在卸载后,它也可以存储一个空值。因此,在使用之前为了确保不出现运行时错误,通常会添加检查:

if (ref.current) ref.current.focus()

有时候你会看到短路方式:ref.current && ref.current.focus()

ref 对象可以附加到子组件内部的 DOM 元素上:

const Child = ({ childRef }) => {
  return <input ref={childRef} />
}

<Child ref={ref} />

The preceding line would ask the Child function component to assign its instance to ref. But a function component cannot have a ref by default. Therefore, avoid using the ref name when passing it around. There's actually a way to attach a ref to a function component with some work

useRef examples:

  • Clicking outside the menu
  • Avoiding memory leaks
const Menu = () => {
  const ref = useRef()
  const [on, setOOn] = useState(true)
 
  if (!on) return null
  useEffect(() => {
    const listener = e => {
      if (!ref.current) return
      if (!ref.current.contains(e.target)) {
        setOn(false)
      }
    }
    window.addEventListener('mousedown', listener)
    return () => {
      window.removeEventListener('mousedown', listener)
    }
  }, [])
 
  return (
    <ul ref={ref}>
      <li>Home</li>
      <li>Price</li>
      <li>Produce</li>
      <li>Support</li>
      <li>About</li>
    </ul>
  )
}

从历史上看,ref最初是用来保存DOM元素的,但后来人们发现它在解决棘手问题方面非常有效。其中一个问题是内存泄漏,在执行异步操作时会出现。异步操作的特点是回调函数稍后被调用。当处理回调时,组件(或与组件相关联的任何变量)可能已经无效了

const Title = () => {
  const [text, setText] = useState("")
  useEffect(() => {
    fetch("https://google.com").then(res => {
    setText(res.title)
    })
  }, [])
  return <h1>{text}</h1>
}
const App = ({ flag }) => {
  if (flag) return <Title />
  return null
}

假设你有一个App父组件根据标志(flag)来显示Title。某次更新时,标志变为false,因此Title被卸载(unmounted),屏幕变空白。从业务逻辑上看是合理的,那么问题出在哪里呢?

问题出在Title组件内部而不是App。确切地说,当Title挂载时,API请求开始,但是请求可能在卸载之前没有足够的时间完成。标志和请求是两个独立的事物。因此,在卸载后,Title可能还有未完成的任务。比如回调函数 - setText语句会发生什么?当组件已经消失时,它应该再次触发更新吗?

从技术上讲,如果组件被卸载了,则无法再进行更新。此外,每个钩子都注册在fiber下面,如果fiber被移除,则不应再访问其下注册的任何内容。否则就会出现不一致性问题,例如内存泄漏。

所以回到我们的情况,在卸载后异步调用返回 - 这成为一个严重的bug不能忽视。这种bug在条件语句使代码切换到另一个分支进行更新(例如路由切换)时经常发生。大多数内存泄漏很难调试,所以我们应该尽量避免它们。

为了查看内存泄漏消息,您需要打开浏览器的开发者面板并切换到控制台选项卡。

要解决这个错误,我们需要根据Title组件是否仍然挂载来仔细保护回调函数的内容。

const Title = () => {
  const [text, setText] = useState("")
  const mountedRef = useRef(true)
 
  useEffect(() => {
    fetch("https://google.com").then(res => {
      if (!mountedRef.current) return
      setText(res.title)
    })
 
    return () => {
      mountedRef.current = false
    }
  }, [])
}

在上述代码中,我们添加了mountedRef来指示Title是否已挂载。我们最初将其设置为true,因为当组件更新时,我们假设可以安全地触发更多的更新。然后,在通过useEffect进行卸载之后,在destroy函数中将mountedRef标志设置为false。

我们不能使用状态(state)来代替引用(ref)来实现mountRef的目的。因为在卸载后,你要求触发一次新的更新,这正是我们要避免的内存泄漏问题。在第5章《使用Effect处理副作用》中,我们学到被动效果(passive effect)的销毁函数最后调用时DOM元素已经稳定下来了,所以此时我们不应该再访问任何内部方法。

当我们设计一个网页应用程序时,我们往往不使用全局变量,因为我们知道它们的使用很容易导致一些难以管理的副作用。另一方面,如果我们有一些对整个站点有效的全局信息,在幕后与应用程序的其余部分共享这些信息仍然是非常方便的,创建一个上下文来共享站点信息,这可能是一个非常昂贵的操作。如果我们不需要通知用户此更改

从技术上讲,在React中没有比ref更加"current"(即最新)的东西了。

当我们使用useState钩子时,我们想要找到状态的当前更新值。虽然我们使用相同的词语"current",但在某些情况下状态可能并不那么及时更新

function Title() {
  const [count, setCount] = useState(0)
  const onClick = () => {
    setTimeout(() => {
      console.log('clicked', count) ➀
      setCount(count + 1)
    }, 3000)
  }
  console.log('updated', count)     ➁
  return <button onClick={onClick}>+</button>
}

一个常见的误解是将setState称为赋值操作。现在你应该明白这是错误的,因为实际上它是请求赋值而不是执行赋值操作。这个请求需要时间来处理和执行,并且此外,由于优化原因,赋值可以被撤销。从setState得到的赋值命运并不十分清楚,而对于ref情况来说,赋值是明确、即时且无法错过。

fix it:

function Title() {
  const [count, setCount] = useState(0)
  const ref = useRef(0)
  const onClick = () => {
    setTimeout(() => {
      ref.current++
      setCount(ref.current)
      console.log('clicked', ref.current) ➀
    }, 3000)
  }
  console.log('updated', count)    ➁
  ...
}

a simple solution:

const onClick = () => {
    setTimeout(() => {
      setCount(v => v + 1)
    }, 3000)
  }

useState 在多次调用 setState 的时候,更新状态是异步的,不能保证每次拿到的状态值都是最新的。

但是用函数格式的 setState:setCount(prevCount => prevCount + 1)

这里每次拿到的 prevCount 参数都是上一次的状态值,就能保证计算结果是基于最新的状态的,避免了状态的不一致问题。

另外在这个函数里面,我们可以进行各种计算逻辑,同时还可以获取到最新的状态值,这给了我们更大的灵活性。

所以函数格式的 setState 是更推荐的最佳实践

TODO: Appendix A & B