Coder Social home page Coder Social logo

react-notes's Introduction

react-notes's People

Contributors

douc1998 avatar

Stargazers

HalseySpicy avatar 安琪 avatar Ether Line avatar Mose Zhao avatar Kumiko Lau avatar  avatar  avatar

Watchers

 avatar  avatar

react-notes's Issues

React-使用 forwardRef 转发 Ref

forwardRef

场景需求

先前在 Ref 和 DOM 的文章中提到了 Ref 的的用法,一般我们会通过当前组件来控制自己的 DOM 节点。然而,有时候会出现跨组件控制 DOM 的需求。比如父组件需要获取到子组件下的 DOM 节点,并对它进行一系列操作。

Ref 和 DOM 中我们提到也可以给类组件添加 ref,从而允许我们父组件控制子组件。但是这种方法中 ref 获取到的是子组件实例,并不是精确的 DOM 节点,没有办法满足需求。

因此,这里需要使用到 forwardRef 来实现 ref 传递,从而帮助父组件获得子组件的某个 DOM 节点的引用。

使用

实现 Ref 转发需要使用到 forwardRef 方法,它的回调函数中接收两个参数:

  • props:父组件传递给子组件的属性。
  • ref:父组件传递给子组件的 ref 属性。

注意只有在 forwardRef 中才会有这两个参数,如果只是普通的函数组件或者类组件,只有 props 属性,并且 props 属性中不包括 ref

// 子组件
const Children = React.forwardRef((props, ref) => {
    // ... 子组件中的其他代码
    return(
        <div>
            <p>userName:</p>
            <input ref={ref} defaultValue='dou' />
        </div>
    )
})

// 父组件
const Parent = function(props){
    // 在父组件中创建 ref,并在后续传递给子组件
    const ref = useRef();
    // 点击事件
    const textFocus = () => {
        ref.current.focus();
    }
    // ... 父组件的其他代码
    return(
        <Children ref={ref} />
        <button onClick={}>focus</button>
    )
}

在上述代码中,使用 Parent 组件可以获取底层 DOM 节点 inputref,并在必要时访问,就像其直接使用 input 一样。

此外,子组件 Children 也可以通过 ref.current 来获取到 input 节点,并在必要时使用它。

React-Fiber

React - Fiber

1. 前言

按照人类能感知到的最低限度每秒 60 帧的频率划分时间片,那么每个时间片就是 16 ms。如果浏览器的刷新频率大于 60 次每秒,那么我们能够感受到当前页面是流畅的;反之,如果浏览器的刷新频率小于 60 次每秒,我们就会感受到页面的卡顿。

1.1 浏览器每一帧做了哪些事情

对于一个完整的而言,浏览器会执行哪些操作呢?如下图所示:

image

浏览器的处理流程按顺序是:

  1. input event 事件:处理输入事件(如 click/ input/ scroll 等),让用户得到最早的反馈。
  2. Timers 定时器:处理 JS 定时器,需要检查定时器是否到时间,并执行对应的回调。
  3. Begin Frame开始帧:即每一帧的事件,包括 window.resizescroll 等。
  4. 请求动画帧 requestAnimationFrame,即在每次绘制之前,会执行 requestAnimationFrame 回调。
  5. 布局 Layout 操作:包括计算布局和更新布局,即这个元素的样式是怎样的,它应该在页面如何展示。
  6. 绘制 Paint 操作:得到 DOM 树中每个节点的尺寸与位置等信息,然后针对每个元素进行内容填充。
  7. 空闲时间:当以上的六个任务均完成时,如果还存在时间,则处于空闲阶段,可以在这时执行 requestIdleCallback 里注册的任务。

我们知道,布局操作和绘制操作是交给 GUI 来渲染实现的,然而 JS 引擎线程和 GUI 渲染线程是互斥的,即一个执行,另一个只能等待。因此,如果在某个 JS 任务中执行时间很长(超过 16 ms),就会阻塞 GUI 线程渲染页面,从而出现卡顿。

1.2 React 为什么需要 Fiber

React 为什么需要 Fiber ? 我们知道 React 的更新渲染是通过对比新旧虚拟 DOM 树实现的:深度遍历虚拟 DOM 树的每个节点,找出需要变动的节点,然后同步更新他们。(React 称这个过程为 reconcilation 协调过程)。

但是,这个遍历过程是采用递归实现的,我们知道递归会导致执行栈越来越深,占用大量栈内存;而且一旦触发就不能中断,如果中断了就无法返回中断位置继续执行。因此,reconcilation 过程执行后就会一直占用浏览器资源,这也将导致用户触发的事件得不到相应,从而出现卡顿现象。

2. Fiber 基本原理

2.1 什么是 Fiber

Fiber 是一种执行单元,也是一种数据结构(链表结构)。每个执行单元都包含了对应 DOM 节点的属性信息和更新信息

2.2 执行原理

每次执行完一个执行单元,React 就会检查现在还剩多少时间,如果有时间则继续执行下一个单元,如果没有时间则将控制权还给浏览器。React Fiber 与浏览器的核心交互流程如下:

image

简而言之,先前的 React 更新渲染是需要递归便利整个虚拟 DOM 树,这是一个大型任务;而使用了 Fiber 架构就可以把这个大型任务给划分为一系列小的任务单元。如果浏览器执行完任务之后还存在空闲时间,就会执行这些小任务。虽然小任务执行过程是不可中断的(执行了就必须执行结束),但是碎片化的处理能够大大减少占用浏览器资源,避免阻塞,而不是像之前一样执行一个大任务从而导致浏览器无法响应。

2.3 数据结构

Fiber 是一种采用链表实现的数据结构,在 React 16 后,每一个虚拟 DOM 节点都会对应一个 Fiber 对象。该对象内包含 child(第一个子节点)、sibling(最近的兄弟节点)、return(父节点)等属性。

从代码角度而言,fiber 对象就是对虚拟 DOM 节点的一个描述,它包含了该节点在虚拟 DOM 树中的上下文关系。

2.4 requestIdleCallback

requestIdleCallback 是实现 Fiber 的基础 API(但实际上 Fiber 使用的是 MessageChannl 实现生成宏任务的,因为。requestIdleCallback 的兼容性较差)。浏览器为开发者提供了 requestIdleCallback 方法,该方法能使开发者在主事件循环上执行后台和低优先级的工作,而不影响延迟关键事件,如动画和输入响应。

requestIdleCallback 方法的第一个参数是一个回调函数。如果正常帧任务完成后没超过 16ms,说明有多余的空闲时间,此时就会执行 requestIdleCallback 里注册的任务。

具体的执行流程如下:

  • 开发者采用 requestIdleCallback 方法注册对应的任务,告诉浏览器我的这个任务优先级不高。
  • 如果浏览器在每一帧内执行完主任务后还存在空闲时间,就可以执行注册的这个任务。

当浏览器执行完当前任务后,如果没有剩余时间了,或者已经没有下一个可执行的任务了,React 会归还控制权给浏览器。在下一帧时,同样使用 requestIdleCallback 去申请下一个时间片。具体的流程如下图:

image

此时可能冒出一个疑问:如果一直没有空闲时间那是不是就一直不执行我们注册的任务了?

我们可以在 requestIdlCallback 方法的第二个参数中配置 timeout 属性。该参数属性是用于定义超时时间的,如果到了超时时间了,浏览器必须立即执行。使用方法如下:

window.requestIdleCallback(callback, { timeout: 1000 })

如果是因为 timeout 时间到了必须执行回调函数,那么用户就有可能会感受到页面的卡顿了,因为一帧的时间必然是超过了 16ms。

此外,window.requestIdleCallback(callback) 的 callback 中会接收到默认参数 deadline ,其中包含了以下两个属性:

  • timeRamining 返回当前帧还剩多少时间供用户使用
  • didTimeout 返回 callback 任务是否超时

多帧执行

一帧页面渲染并非只能对应一个 requestIdleCallback 注册的回调,实际上 requestIdleCallback 注册的回调可以在所有帧渲染时被执行。因此,如果前面的帧没有空闲时间,并且每个注册回调的 timeout 并没有达到,这些注册回调可能就会堆积起来,直到某一帧存在空闲时间(或 timeout 超时),就会被执行。相反,如果当前帧的空闲时间足够,那么也可以依次执行多个注册回调,直到没有剩余时间。如果执行某次回调后没有剩余时间或者已经超出剩余时间了,那么就必须将控制权返还给浏览器,同时发起下一次的时间片请求,再下一段空闲时间继续执行剩余的回调函数。

注意:如果执行某个注册回调时超过了当前帧的剩余时间,则会一直卡在这里执行,直到该任务执行完毕。如果代码存在死循环,则浏览器会卡死。

3. Fiber 结构设计

3.1 节点属性

Fiber 拆分单元是 Fiber tree 上的一个节点,Fiber tree 和虚拟 DOM tree 是相对应的,即每一个虚拟 DOM 节点都对应了一个 fiber 对象,而 Fiber tree 也是根据虚拟 DOM 树构建的。fiber对象的属性如下所示:

{
    type: any,  // 对于类组件,它指向构造函数;对于DOM元素,它指定HTML tag
    key: null | string,  // 唯一标识符
    stateNode: any,  // 保存对组件的类实例,DOM节点或与fiber节点关联的其他React元素类型的引用
    child: Fiber | null,  // 第一个儿子节点
    sibling: Fiber | null,  // 下一个兄弟节点
    return: Fiber | null,  // 父节点
    tag: WorkTag,  // 定义fiber操作的类型, 详见https://github.com/facebook/react/blob/master/packages/react-reconciler/src/ReactWorkTags.js
    nextEffect: Fiber | null,  // 指向下一个有副作用的节点的指针,用于维护 effect list
    updateQueue: mixed,  // 用于状态更新,回调函数,DOM 更新的队列
    memoizedState: any,  // 用于创建输出的 fiber 状态
    pendingProps: any,  // 已从React元素中的新数据更新,并且需要应用于子组件或DOM元素的props
    memoizedProps: any,  // 在前一次渲染期间用于创建输出的props
    // ……     
}

fiber 节点的几个主要属性:

(1)type & key

fiber 对象的 type 和 key 属性与 React 元素相对应。type 描述了节点所对应的组件,如果是复合组件(函数组件、类组件),type 是函数或者类组件本身。对于原生标签(div、span 等),type 是标签的字符串形式。key 属性主要使用于 diff 算法中,用于判断对应的 fiber 是否可以重用。

(2)stateNode

保存对组件的类实例,DOM 节点或与 fiber 节点关联的其他 React 元素类型的引用。一般来说,可以认为这个属性用于保存与 fiber 相关的本地状态。

(3)child & sibling & return

所有 fiber 节点都通过 child,sibling 和 return 来构成一个 fiber node 的 linked list (链表结构)。

child 属性指向此节点的第一个儿子节点。
sibling 属性指向此节点的下一个兄弟节点(大儿子指向二儿子、二儿子指向三儿子)。
return 属性指向此节点的父节点,即当前节点处理完毕后,应该向谁提交自己的成果。如果 fiber 具有多个子 fiber,则每个子 fiber 的 return fiber 是 parent 。

3.2 Fiber 链表结构

上面已经多次提到了 Fiber 的结构是链表结构,Fiber tree 实际上就是一个单链表树结构。同样,Fiber tree 的状态更新也是由链表结构实现的,每个 fiber 节点保存自己的状态更新单元,通过更新队列的形式组织成一个链表结构,统一更新状态。下面是模拟 Fiber tree 更新的示意图:

image

如上图所示,每一个单元包含了 payload(数据)和 nextUpdate(指向下一个单元的指针)。因此,我们模拟地定义 fiber 单元结构如下:

class Update {
  constructor(payload, nextUpdate) {
    this.payload = payload  // payload 数据
    this.nextUpdate = nextUpdate  // 指向下一个节点的指针
  }
}

上面已经定义了模拟 fiber 对象的单元结构。接下来需要定义一个队列,把每个单元串联起来。

其中定义了两个指针:

  • 头指针 firstUpdate
  • 尾指针 lastUpdate

作用是指向更新的第一个单元和最后一个单元。

还加入了 baseState 属性存储 React 中的 state 状态。(简而言之,baseState 会收集整个更新队列中的状态变化,类似于虚拟 DOM 树某个节点把自己下面其他节点的 state 更新汇总到该节点上

如下所示:

class UpdateQueue {
  constructor() {
    this.baseState = null;  // 存储state
    this.firstUpdate = null;  // 第一个更新
    this.lastUpdate = null;  // 最后一个更新
  }
}

接下来定义两个方法:

  • 插入节点单元(enqueueUpdate),插入节点单元时需要考虑是否已经存在节点,如果不存在直接将 firstUpdate、lastUpdate 指向此节点即可。

  • 更新队列(forceUpdate),更新队列是遍历整个链表,根据 payload 中的内容去更新 state 的值。

class UpdateQueue {
  //..... 其他内容
  
  enqueueUpdate(update) {
    // 当前链表是空链表
    if (!this.firstUpdate) {
      this.firstUpdate = this.lastUpdate = update
    } else {
      // 当前链表不为空
      this.lastUpdate.nextUpdate = update
      this.lastUpdate = update
    }
  }
  
  // 获取state,然后遍历这个链表,进行更新
  forceUpdate() {
    let currentState = this.baseState || {}
    let currentUpdate = this.firstUpdate
    while (currentUpdate) {
      // 判断是函数还是对象,是函数则需要执行,是对象则直接返回
      let nextState = typeof currentUpdate.payload === 'function' ? currentUpdate.payload(currentState) : currentUpdate.payload
      currentState = { ...currentState, ...nextState }
      currentUpdate = currentUpdate.nextUpdate
    }
    // 更新完成后清空链表
    this.firstUpdate = this.lastUpdate = null
    this.baseState = currentState
    return currentState
  }
}

Demo 示例 Fiber 的更新

写一个 demo:实例化一个队列,向其中加入很多节点,再更新这个队列:

let queue = new UpdateQueue()
queue.enqueueUpdate(new Update({ name: 'www' }))
queue.enqueueUpdate(new Update({ age: 10 }))
queue.enqueueUpdate(new Update(state => ({ age: state.age + 1 })))
queue.enqueueUpdate(new Update(state => ({ age: state.age + 1 })))
queue.forceUpdate()
console.log(queue.baseState);

打印结果如下:

{ name:'www',age:12 }

4. Fiber 在 React 生命周期中的执行流程

我们知道 React 的生命周期可以分为 render/ reconciliation(协调)和 commit(提交)两个阶段。在 render 阶段中,我们需要找出所有节点的变更;而在 commit 阶段中,需要执行所有变更

render / reconciliation 阶段的生命周期钩子:

  • constructor
  • componentWillMount(React 16 废弃)
  • componentWillReceiveProps (React 16 废弃)
  • getDerivedStateFromProps(React 16 新增)
  • shouldComponentUpdate
  • componentWillUpdate(React 16 废弃)
  • render

commit 阶段的生命周期钩子:

  • getSnapshotBeforeUpdate 严格来说,这个是在进入 commit 阶段前调用
  • componentDidMount
  • componentDidUpdate
  • componentWillUnmount

这两个阶段存在最本质的差异在于:能否中断

  • 在 React 16 之前,我们查找虚拟 DOM 节点差异时,需要递归遍历,而递归也无法中断的(因为中断了就无法恢复),此时 render 部分也是无法中断的。
  • 在 React 16之后,出现了 Fiber,由于 Fiber tree 的链式结构,能够以每个虚拟 DOM 节点为单位,找到自己和孩子节点的变更。因此,相当于把递归任务给划分为了若干小任务,能够分段进行。

因此,render 阶段是可中断的,而 commit 阶段一直都是无法中断的,只要提交了就必须执行完,

4.1 render 阶段(reconciliation阶段)

在 render 阶段中,会找出所有节点的变更,如节点新增、删除、属性变更等,这些变更 React 统称为副作用(effect)。

此阶段会构建一棵虚拟 DOM 树以及对应的 Fiber tree,以虚拟 DOM 节点为单元对任务进行拆分,即一个虚拟 DOM 节点对应一个任务(fiber),最后产出的结果是 effect list,从中可以知道哪些节点更新、哪些节点增加、哪些节点删除了。

4.2 深度遍历(后序遍历)

已知在 render 阶段,React 会生成一颗新的虚拟 DOM 树及其对应的 Fiber tree,虚拟 DOM 上每一个节点都对应了一个 fiber 对象,该对象内部包括 childsiblingreturn 等属性。我们可以通过这些属性,进行深度遍历,收集该节点下的每一个 child 的变更(effect),然后汇总传递(effect list)到该节点处。了解二叉树深度遍历的都知道,这种自下而上的需求得用后序遍历来实现。

具体执行流程如下(以二叉树为例,N 叉树逻辑一样):

  1. 从顶点开始递归到叶子节点
  2. 按照左子节点、右子节点、根节点的顺序,逐层向上收集更新任务
  3. 存储到 effect list 中
  4. 回到顶点,遍历结束

4.3 收集 effect list

我们进行深度遍历的目标就是为了获得节点的 effect list,这也就和我们在 3.2 节中提到的队列 baseState 属性类似。只不过对于整棵树来说,我们希望能够获得根节点的 effect list,来收集整个 Fiber tree 上的副作用。

在深度遍历的过程中,我们收集所有节点的变更产出 effect list。注意其中只包含了需要变更的节点,没有变更的节点不存在 effect,不需要收集。通过每个节点更新结束时向上归并 effect list 来收集任务结果,最后根节点的 effect list 里就记录了包括了所有需要变更的结果

具体步骤如下:

  1. 如果当前节点需要更新,则打 tag 更新当前节点状态(props, state, context等)
  2. 为每个子节点创建 fiber。如果没有产生 child fiber,则结束该节点,把 effect list 归并到 return(父节点),把此节点的 sibling 节点作为下一个遍历节点;否则把 child 节点作为下一个遍历节点 (后序遍历)
  3. 如果有剩余时间,则开始下一个节点,否则等下一次主线程空闲再开始下一个节点
  4. 如果没有下一个节点了,此时 effect list 收集完毕,进入 pending Commit 状态,结束。

image

4.4 commit 阶段

commit 阶段需要将上阶段计算出来的需要处理的副作用一次性执行,此阶段不能暂停,否则会出现 UI 更新不连续的现象。此阶段需要根据 effect list,将所有更新都 commit 到DOM树上。

总结:

React-Fiber 通过使用单链表树结构,巧妙地将 查找新旧虚拟 DOM 树节点变更 切分为一系列小任务,允许 React 可以不断地向浏览器提出 requestIdlCallback 请求,注册这些小任务。而不需要一次性递归到底占用大量的浏览器资源,给用户带来不好的使用体验。

详细的代码(或源码分析)可以参考本文的参考资料

参考:

走进 React Fiber 的世界
最通俗的 React Fiber 打开方式

React-useMemo 和 useCallback 带来的性能优化

useCallback

1. 定义

首先我们先看一下官方文档中对 useCallback 的说明:

Pass an inline callback and an array of dependencies. useCallback will return a memoized version of the callback that only changes if one of the dependencies has changed.

简单来说,它和 useEffect 的基本原理类似:有两个参数,第一个参数是 内联回调函数,第二个是 依赖项数组。它将返回该回调函数的 memoized 版本,该回调函数仅在某个依赖项改变时才会更新

一句话总结:缓存函数,仅在依赖项改变的时候才会更新。 由此可知, useCallback 可以有利于我们判断历史和当前的内联回调函数的相等性,从而避免渲染不要渲染的子组件。

2. 为什么 useCallback 能够避免渲染不要渲染的子组件呢?

我们知道,在 React 中,如果父组件自身触发了重新渲染,那么也会重新创建自己内部定义的一些内容(值、函数、数组等等),并重新渲染自己的 render 中的内容

这也就意味着只要父组件重新渲染,那也就必然会引起子组件的重新渲染(哪怕这个子组件的 props 和自身的 state 都没有改变)。

对于上面这种问题,我们可以通过使用 React.memo 包裹子组件实现解决。React.memo 能够保护组件不受到无关状态更新的影响,组件只会在收到新数据或内部状态发生变化时重新渲染。

举个例子:

function App(props) {
    const [name, setName] = useState('dou');
    const [age, setAge] = useState(24);

    // 点击按钮修改年龄
    const changeAge = () => {
        setAge(age + 1);
    }

    return (
        <div>
            {/* 调用 Student 子组件 */}
            <Student name={name} />
            <p>age: {age}</p>
            <button onClick={changeAge}>change</button>
        </div>
    )
}
// 子组件 Student
function Student(props){
    return(
        <p>name: {props.name}</p>
    )
}

export default React.memo(Student);

从上面例子可以得知,在 App 组件中,如果我们点击按钮,<p> 标签和 <button> 标签受到父组件的更新也会引起自己的更新。但是React.memo 包裹的 Student 组件会判断传入的 name 值有没有改变,如果没有改变,则不会被重新渲染


那么如果我们用 React.memo 包裹了子组件,但父组件传递给子组件的是一个函数。后续父组件自身触发更新(但与该子组件无关),那该子组件还会被保护住不更新吗吗?

答案是否定的。因为我们知道,在 JavaScript 中,两个内容完全相同的函数,判等也是 false

所以,父组件更新时,也会重新创建内部的函数(即使该函数没有改变),但是当它传递给子组件时,React.memo 会对旧函数和新传入的函数进行一个判等,而结果也很显然是不等的。因此,即使 React.memo 保护着,也依旧会触发该子组件的重新渲染

此时,我们就需要 useCallback 出场了,它能够保证当父组件更新时,它包裹的函数当且仅当依赖项改变时,才会重新创建,从而避免子组件的不必要渲染。

下面我们举个新的例子:

function App(props) {
  const [count1, setCount1] = useState(0);
  const [count2, setCount2] = useState(0);

  const handleCount1 = () => {
    setCount1(count1 + 1);
  }
  // 用 useCallback 保护 handleCount2 方法
  const handleCount2 = useCallback(() => {
    setCount2(count2 + 1);
  }, [count2])


  return (
    <div>
      <RandomNum onClickButton={handleCount1}>Button1</RandomNum>
      <RandomNum onClickButton={handleCount2}>Button2</RandomNum>
    <div/>
  )
}
// 子组件,具备点击按钮产生随机数的功能
const RandomNum = (props) => {
  return (
    <div>
      <button onClick={props.onClickButton}>{props.children}</button>
      <span>{Math.random()}</span>
    </div>
  );
};
// 用 React.memo 包裹子组件
export default React.memo(RandomNum);

上述代码的实验结果如下:

  1. 点击 Button1,只有 Button1 后面的随机数改变。
  2. 点击 Button2,Button1 和 Button2 后面的随机数均改变。

详细实验结果如下所示:

1、初始化结果:

初始值

2、点击 Button1 后,Button1 后面的随机数改变。

点击 Button1

3、点击 Button2 后,Button1 和 Button2 后面的随机数都改变。

点击 Button2

整个操作流程的步骤解析如下:

  • 当点击 Button1 或者 Button2,会导致 App 这个父组件更新,从而导致两个 RandomNum子组件也更新。
  • 但是,RandomNum 子组件被 React.memo 保护着,如果传递的 props 和内部 state 没有改变,是不会重新渲染的。
  • 已知子组件内部没有 state ,也就不会因为内部状态触发自身重新渲染。此时决定 RandomNum 组件更不更新完全取决于 props
  • 传递给 Button1 的点击函数在父组件更新后也重新创建,即使函数内容完全没有改变,而且有 React.memo 保护,但新旧函数无法判等。因此,只要父组件重新渲染,第一个 RandomNum 也会重新渲染
  • 传递给 Button2 的点击函数被 useCallback 封装了起来,仅仅在 count2 改变时,才会重新创建内联函数,否则该点击函数一直都是之前的缓存React.memo 判等是成功的,也就不会重新渲染。

由此可知,点击 Button1 会导致其后面的随机数改变,而 Button2 的随机数不会改变,因为 Button2 没有更新;点击 Button2 会导致两个按钮后的随机数都改变,因为两个按钮都更新了。

useMemo

1. 定义

首先我们先看一下官方文档中对 useMemo 的说明:

Pass a “create” function and an array of dependencies. useMemo will only recompute the memoized value when one of the dependencies has changed.

简单来说,useMemo 的参数包括一个创建函数和依赖项。创建函数会需要返回一个值,只有在依赖项发生改变的时候,才会重新调用此函数,返回一个新的值

2. 使用-缓存父组件中计算量大的内容

useMemo 实际上更多地用于缓存一些涉及较大计算量的数据。因为我们知道,当父组件更新时,其内部创建各种内容都会重新创建或计算。但有时候一些内容是没有发生改变也不需要重新计算,这也就会影响网页渲染的性能。

因此,使用 useMemo 可以将这一类的内容给包裹起来,当且仅当依赖项改变时,才会重新计算 / 创建

举个例子:

function MyComponent(props){
    const [name, setName] = useState('dou');
    const [age, setAge] = useState(24);

    // 涉及大量计算的复杂数据
    const hugeData = useMemo(() => {
        // ... 大量计算
        return result;
    }, [name])

    return(
        <div>
            {/* 一些组件 */}
        </div>
    )
}

上面的例子就是当且仅当 name 属性改变时,hugeData 才会重新计算。如果是 age 或者其他内容导致的重新渲染,hugeData 不会重新计算。

因此,当父组件中存在一些数据量较大,计算较复杂的内容,我们可以使用 useMemo 来包裹。这样能够避免每当父组件重新渲染时,再一次触发大量且复杂的计算,直接调用缓存即可。

3. 使用-避免子组件的不必要渲染

useMemo 也可以像 useCallback 一样用于解决子组件的不必要渲染,使用方法也类似。如下所示:

import { useState, useMemo } from "react";
// 父组件
export function MyComponent(props) {
    const [name, setName] = useState('dou')
    const [age, setAge] = useState(24);
    // 使用 useMemo 包裹
    const user1 = useMemo(() => {
        return {
            name: name,
            // some other information
        }
    }, [name]);

    // 不使用 useMemo 包裹
    const user2 = {
        name: name,
    }

    /**
     * 其他触发 App 重新渲染的操作
     */
    const changeName = () => {
        const userNames = ['jack', 'mike', 'john', 'dou'];
        const newUser = userNames[Math.floor(Math.random() * userNames.length)];
        setName(newUser);
    }

    const changeAge = () => {
        setAge(Math.floor(Math.random() * 100));
    }

    return (
        <div>
            <Student info={user1} />
            <Student info={user2} />
            <button onClick={changeName}>changeName</button>
            <button onClick={changeAge}>changeAge</button>
        </div>
    )
}
// Student 子组件
function Student(props) {
    return (
        <div>
            <p>name: {props.info.name}, random: {Math.random()}</p>
        </div>
    )
}
export default React.memo(Student);

上面例子的效果如下:

1、初始化结果:

截屏2023-03-04 08 49 49

2、点击 changeName 修改 name 状态。

截屏2023-03-04 09 03 26

3、点击 changeAge 修改 age 状态。

截屏2023-03-04 09 04 27

从上面的结果可以看出:

  • 首先我们使用 React.memo 包裹了 Student 组件,它只会在传入的 props改变或内部 state 改变才会重新渲染。
  • user1 使用了 useMemo,即只有当依赖项 name 属性改变时,user1 的值才会改变,对应的 Student 子组件才会重新渲染
  • user2 没有使用 useMemo,只要父组件 MyComponent 重新渲染,对应的 user2 对象也会重新创建。由于旧的 user2 和新的并不能判等,因此对应的子组件 Student 也会重新渲染。

这也就通过 useMemo 实现避免子组件的不必要渲染

4. 小结

如果没有提供依赖项数组,useMemo 在每次渲染时都会计算新的值,这和 useEffect 没有提供依赖项数组是同样的处理情况。

肯定会有人问这个 useMemouseEffect 有什么关系,看起来挺像的。记住,传入 useMemo 的函数会在渲染期间执行。请不要在这个函数内部执行不应该在渲染期间内执行的操作,诸如副作用这类的操作属于 useEffect 的适用范畴,而不是 useMemo

此外,useMemo 可以通过返回函数,能够起到和 useCallback 同样的效果。

总结

useCallback 可以封装函数,以保存内联函数的不变性,即缓存函数。可以结合 React.memoshouldComponentUpdate 等功能,让子组件避免一些不必要的渲染。

useMemo 缓存的是函数返回值,它既可以优化子组件的不必要渲染,也可以存储当前组件的一些复杂逻辑计算环节。

参考资料

React 官方文档
一篇文章带你理解 React 中最“臭名昭著”的 useMemo 和 useCallback
详解 React useCallback & useMemo

React 如何生成类组件和函数组件

React 如何处理类组件和函数组件

先看一看 React 处理生成类组件的方法(源码精简版):

function constructClassInstance(
    workInProgress, // 当前正在工作的 fiber 对象
    ctor,           // 该类组件
    props           // props 属性
){
     /* 实例化组件,得到组件实例 instance */
     const instance = new ctor(props, context)
}

从上面的源码可知:对于类组件,React 是通过当前类组件的构造函数,new 出一个实例,该实例中保存了组件的 state 等状态。对于每一次的更新,只需要调用 render 方法以及执行对应的生命周期中的回调函数即可。


再看看 React 生成函数组件的方法,也是把源码精简化了,便于理解:

function renderWithHooks(
  current,          // 当前函数组件对应的 `fiber`, 初始化
  workInProgress,   // 当前正在工作的 fiber 对象
  Component,        // 该函数组件
  props,            // props 属性
  secondArg,        // 其他参数
  nextRenderExpirationTime, // 下次渲染过期时间
){
     /* 执行我们的函数组件,得到 return 返回的 React.element对象 */
     let children = Component(props, secondArg);
}

由上可知:在函数组件中,并没有生成实例,因此也就是说函数组件不是实例对象,也没有自己的状态(这也就是为什么需要 hooks 来维持状态)。因此,函数组件每一次更新都是一次新的函数执行,这也就说明了一次函数组件的更新,里面的变量会重新声明

那么,这也就是为什么在函数组件中,我们需要把一些计算量大而复杂的数据装入 useMemo 中,把不需要重新生成的函数装入useCallback中(因为每次更新都会重新声明里面的变量,所以虽然函数内容是一样的,但是地址不一样)。

不过需要注意,函数组件中的 state、ref 等值虽然也会被重新声明,但是由于存在 hooks,所以并不会初始化原始值,而是获取 hooks 中存的值。

Redux 三大原则

Redux 三大原则

今天早上打算重读 Redux 官方文档,在前几章看到了三大原则的字眼,感觉有点儿陌生,好吧其实就是之前学习的时候喜欢直接跳过前沿、简介等章节。后来阅读了一下感觉还是很受益匪浅的,也正是这三大原则,让 Redux 如此好用,并且可以帮我们避免开发里的一些不必要问题。因此,在这里开一篇 issue 来记录这三大原则,以备后续复习。

Redux 的三大原则:单一数据源State 是只读的使用纯函数来执行修改

单一数据源

官方文档对 “单一数据源” 的介绍如下

整个应用的 state 储存在一棵 object tree 中,并且这个 object tree 只存在于唯一一个 store 中。

这让同构应用开发变得非常容易。来自服务端的 state 可以在无需编写更多代码的情况下被序列化并注入到客户端中。由于是单一的 state tree ,调试也变得非常容易。在开发中,你可以把应用的 state 保存在本地,从而加快开发速度。此外,受益于单一的 state tree ,以前难以实现的如 “撤销/重做” 这类功能也变得轻而易举。


个人理解

单一数据源其实在开发中已经是一种默认态了,即我们所有的 state 都会存在 store 中进行管理。虽然我们可以使用 createSlice 的方法来创建 reducer 切片,但是最后所有的切片都会被注册到 store 中,然后使用 state.xxx (reducer 注册的键名) 来调用切片内部的 state。注意,这里提到的 state 并非是store 中提到的 state。这里说的 state 是每个切片内部各自的 state,而store 中提到的 state 是用于描述 store 内部的状态,包含了每个切片的 reducer

State 是只读的

官方文档对 “State 是只读的” 的介绍如下

唯一改变 state 的方法就是触发 actionaction 是一个用于描述已发生事件的普通对象。

这样确保了视图和网络请求都不能直接修改 state,相反它们只能表达想要修改的意图。因为所有的修改都被集中化处理,且严格按照一个接一个的顺序执行,因此不用担心 race condition 的出现。 Action 就是普通对象而已,因此它们可以被日志打印、序列化、储存、后期调试或测试时回放出来。

// 通过 dispatch 来派发 action对象,再触发 reducer 中对应的处理函数来修改 state
store.dispatch({
  type: 'COMPLETE_TODO',
  index: 1
})

store.dispatch({
  type: 'SET_VISIBILITY_FILTER',
  filter: 'SHOW_COMPLETED'
}) 

个人理解

这一点在实际开发过程中也有比较深的印象了,当我们希望修改 state 时,只能通过 dispatch 方法来输入相应的 action 对象(实际开发中可以通过 connectuseDispatch 钩子将我们的输入包装为 action 对象,传入的数据则在actions 对象的 payload 属性中),并触发 reducer 函数来修改 state ,而其他方法是没有办法修改 state 的。这种约束能够帮助我们解决一些不必要的麻烦。

使用纯函数来执行修改

官方文档对 “使用纯函数来执行修改” 的介绍如下

为了描述 action 如何改变 state tree ,你需要编写 reducers

Reducer 只是一些纯函数,它接收先前的 state 和 action,并返回新的 state。刚开始你可以只有一个 reducer,随着应用变大,你可以把它拆成多个小的 reducers,分别独立地操作 state tree 的不同部分,因为 reducer 只是函数,你可以控制它们被调用的顺序,传入附加数据,甚至编写可复用的 reducer 来处理一些通用任务,如分页器。

function visibilityFilter(state = 'SHOW_ALL', action) {
  switch (action.type) {
    case 'SET_VISIBILITY_FILTER':
      return action.filter
    default:
      return state
  }
}

function todos(state = [], action) {
  switch (action.type) {
    case 'ADD_TODO':
      return [
        ...state,
        {
          text: action.text,
          completed: false
        }
      ]
    case 'COMPLETE_TODO':
      return state.map((todo, index) => {
        if (index === action.index) {
          return Object.assign({}, todo, {
            completed: true
          })
        }
        return todo
      })
    default:
      return state
  }
}

import { combineReducers, createStore } from 'redux'
let reducer = combineReducers({ visibilityFilter, todos })
let store = createStore(reducer)

个人理解

在第二个原则中,我就提到了得到 action 对象后,通过执行 reducer 纯函数来修改 state。因此,reducer 函数可以传入两个参数,分别是:旧的 state 和 包含修改内容的 action 对象,返回的结果也将是新的 state。注意,对于引用数据不要直接修改旧的 state,最好的新建一个新的对象,如使用 Object.assign({}, state){...state, ...newState}

reducer纯函数,这也有利于保证不存在副作用,避免了一些意外的数据修改问题。

参考:

useEvent(RFC阶段)

useEvent

2022 年 5 月 4 日,Dan Abramov 在 React RFC 上提交了一个新 Hook 的提案:useEvent。目的是返回一个永远引用不变(always-stable)的事件处理函数。想了解去可以去官方仓库看一看,也可以通过这篇文章了解。

为什么需要?

提出一个新的东西,我们的第一反应当然是:为什么需要?如果现有的方法能够很好地解决问题,引出新东西的必要性就是有待商榷的。

我从我的理解和使用经历,简单总结一下,useEvent 需要解决的场景问题主要涉及了两个内容,包括:

  • Hook 的闭包陷阱(过期闭包)问题。
  • 将函数作为 props 参数传递引起的不必要更新问题。

闭包陷阱

在我上一篇 issue 中我提到了 setState 的更新问题,其中有一部分内容是关于使用 Hook 时遇到的闭包陷阱,也可称为过期闭包。这个问题,最直接要害的问题就是:我们使用的 state 值是哪个时期的值,如果我们的 useEffect 第二个依赖项为 [],如下:

// count 是 useState 创建的一个状态
useEffect(() => {
    setInterval(() => {
        setCount(count + 1);
    }, 1000)
}, [])

上面例子中,我们是通过 count + 1 这种直接赋值的方法来更新状态,由于闭包的问题就会导致每次更新的 count 值都是 1。这是因为依赖项为 [],所以 useEffect 内部的回调函数只会在最开始的时候触发一次,触发的时候 count = 0,所以后续定时器里用到的 count 值也都是 0。

如果对这个概念场景不太理解的可以先去看一看刚刚提到的 issue,也可以去看一看 useEffect 的源码,了解一下他更新判断的机制。

不必要更新

我们知道可以使用 shouldComponentUpdatePureComponentReact.memo 来让组件避免不必要的更新。shouldComponentUpdate 是可以自己设置判断条件,而后面两个是会对组件的新旧 propsstate 进行浅比较,从而避免不必要的更新。

我们也知道 useCallbackuseMemo 常常需要结合上面的三种方法一起使用。当我们将一个函数作为 props 属性传递给子组件时,最好是使用 useCallback 包裹起来,并用 React.memo 包裹子组件,这样就不会因为每次创建的函数虽然相同但不是一个地址从而导致子组件的不必要渲染了。

但是如果是下面这种情况:

const click = useCallback(() => {
    setCount(count + 1);
}, [])

那么子组件中触发这个回调会怎么样呢?很显然受闭包陷阱影响,会每次都是 0 + 1。解决闭包陷阱的一种方法就是添加依赖项,从而当依赖项改变时,也会重新触发钩子函数,拿到最新的值。所以下面的代码又可以改为:

const click = useCallback(() => {
    setCount(count + 1);
}, [count]) // 添加依赖项

但是这个方法虽然能解决闭包陷阱,但是又让每次创建的回调函数不一样,又会导致子组件的重复渲染了...因此,我们需要像 useEvent 这种钩子来解决。

如何实现

useEvent 简易版的代码如下所示:

function useEvent(handler) {
    const handlerRef = useRef(null);

    // 我们可以通过 ref 来保存对应的回调函数
    useLayoutEffect(() => {
        handlerRef.current = handler;
    });

    // useCallback 保证只在组件创建时构建一次该函数,之后都不会修改
    return useCallback((...args) => {
        // 直接取保存在 ref 中的函数调用,确保每次使用的函数都是同一个
        const fn = handlerRef.current;
        return fn(...args);
    }, []);
}

我们可以从代码中看到几个比较关键的部分:

  1. useRef 保存函数
    我们知道,ref 对象在组件的各次渲染中都保持一致,访问到的 current 字段都是最新的。useRef 会在每次渲染时返回同一个 ref 对象,并且当更新 current 值时并不会引起 re-render ,这是与 useState 不同的地方。所以 ref 保存函数是一个明智的选择。

  2. useLayoutEffect
    useLayoutEffectuseEffect 的区别在于执行的时期,前者是在 render 之前执行,而后者是在 render 之后执行,很显然我们最好在渲染之前设置 ref.current 的值。(其实这里使用 useEffect 也一样,但是 useLayoutEffect 更符合思路)。

  3. useCallback
    我们在 return 时需要返回一个函数用于子组件触发,因此依旧是需要对该函数进行 useCallback 包裹,依赖项为 [],从而保证之后父组件的重新渲染不会引起函数的重新创建。

效果

基于上面一系列关键部分,简易版 useEvent 就实现了。我们看看效果:

// 父组件
export function MyComponent(props) {
    const [count, setCount] = useState(0);

    // 使用 useEvent 包裹
    const click = useEvent(() => {
        setCount(count + 1)
    })

    return (
        <div>
            <div>count is: {count}</div>
            <Student click={click} />
        </div>
    )
}

// 子组件,PureComponent 纯组件
export class Student extends React.PureComponent {
    render() {
        return (
            <div>
                <div>{Math.random()}</div>
                <button onClick={this.props.click}>add</button>
            </div>
        )
    }
}

截屏2023-03-21 17 25 52

截屏2023-03-21 17 26 10

从上图的结果可以看出,我们点击 add 按钮,子组件并没有触发重新渲染(因为 random 随机数没改变,也可以自己在生命周期中打印)。

其他方法

在我上一篇 issue 中我提到可以使用函数返回的方法解决闭包陷阱的问题。那么我们就可以不使用 useEvent,直接修改一下父组件的原始代码为:

// 父组件
export function MyComponent(props) {
    const [count, setCount] = useState(0);

    const click = useCallback(() => {
        // 利用函数返回更新
        setCount(count => count + 1);
    }, [count])

    return (
        <div>
            <div>count is: {count}</div>
            <Student click={click} />
        </div>
    )
}

截屏2023-03-21 17 30 35

截屏2023-03-21 17 30 52

从上图的效果也可以看出能够解决上述问题。因此,两种方法都是可以实现的,见仁见智吧,根据需求使用。

总结

在这里我还是建议:在函数 Hook 中尽量避免使用直接赋值的方法来更新状态,这样很容易掉入闭包陷阱

参考:

facebook/react
What the useEvent React hook is

React基础-列表与 key

列表

JS 中我们经常会使用 Array.prototype.map() 的方法对数组内部的元素进行变换。实际上,在 JSX 中,我们也可以利用 map() 方法直接将数组中的每个元素变成 html 标签,然后将转换后的数组放入到 {} 中即可被读取。

const arr = [1, 2, 3];
const listItems = arr.map(item => <li>{item}</li>);
// ... 省略其他必要的 React 组件代码
return ( 
   <ul> {listItems} </ul>
)

上面的代码将会生成一个 1 到 5 的项目符号列表。

在实际的生产过程中,需求会复杂很多,我们可以通过数组迭代的方法生成一系列我们需要的元素或组件(一般来说都是同类型的),提高生产效率。

Key

既然也提到生产过程中连续生成的内容可能是复杂的组件,那么为了提高更新渲染的性能,我们可以给列表中的每个部分增加一个 key 属性,从而提高 diff 算法的执行效率。(什么是 diff 算法我会后续写一篇介绍)。

引用官方文档的话来说:key 帮助 React 识别哪些元素改变了,比如被添加或删除。因此你应当给数组中的每一个元素赋予一个确定的标识。

实际上, diff 算法多用于比较新旧 DOM 树中同一层级节点,如果我们给列表中的每个元素添加了 key (唯一标识符),那么 diff 算法就可以识别出新 DOM 树中哪些元素是旧 DOM 树已有的,如果发现了 key 值相同的新旧节点,就会执行移动操作(如果没有改变,就不移动),而不需要每次都删除旧节点,创建新节点。这无疑大大提高了React性能和渲染效率

那么 Key 应该怎么用,以及用在哪里呢?还是以上面的例子为例:

const arr = [{id: 1, name: 'jack'}, {id: 2, name: 'mike'}, {id: 1, name: 'john'}];
const listItems = arr.map(item => <li key={item.id}>{item.name}</li>);
// ... 省略其他必要的 React 组件代码
return ( 
   <ul> {listItems} </ul>
)

由上面的例子可知, key 是用在用户数组的元素身上,而且是每个元素的唯一标识符

数组元素中使用的 key 在其兄弟节点之间应该是独一无二的。然而,它们不需要是全局唯一的。当我们生成两个不同的数组时,我们可以使用相同的 key 值。

但是,不建议使用 index 作为 key。因为当数组改变了顺序之后,相同 index 对应的元素内容可能是不一样的。这样就会误导 diff 算法从而对某些节点不做更新。

总结

并不是所有的元素或组件都需要设置 key,对于非列表结构很难出现 DOM 经常变动的情况,逐层对比就已经满足新旧节点对比的需求了。然而列表中可能经常会出现插入删除添加 DOM 节点,此时 key 就能够提高性能了。

一个好的经验法则是:在 map() 方法中的元素需要设置 key 属性

React基础-状态提升/子组件修改父组件状态

状态提升

状态提升所对应的场景需求一般为:父组件内有多个子组件,也可以理解为有多个输入,而这些输入是同步的,一个子组件改变也会导致其他同级组件的改变。

对于以上问题,我们暂且不使用 ContextReact-Redux 等状态管理工具,因为 “杀鸡不用宰牛刀”。在不复杂,且不涉及全局或多层状态传递的情况下,我们可以通过 “状态提升” 的方法解决。用官方文档里的话来说:你应当依靠自上而下的数据流,而不是尝试在不同组件间同步 state

假设现在存在两个组件,<Rmb /><Dollar /> 两个子组件,它们能够满足我们实现人民币美元的换算。

这两个组件各有一个 <input> 可以供我们输入金额,当一个输入框里的金额改变,另一个输入框中的金额应当实时地换算为新的结果

所谓 “状态提升“ 就是把这两个组件的 state 属性 ”提升“ 到父组件的 state 中。简而言之就是:

  • 金额作为父组件的 state,再以 props 传递给两个子组件。同时 props 还要传递能够修改父组件 state 的方法给子组件。
  • 当子组件中的输入改变时,触发 props 中能够修改父组件 state 的方法
  • 该方法通过对应换算关系后,更新父组件的 state 属性
  • 父组件的 state 属性更新,意味着传给子组件的 porps 也将更新,从而子组件也将重新渲染。

用代码举个例子:

class Convert extends React.Component{
  constructor(props){
    super(props);
    // 父组件状态,之后作为 props 传递给子组件
    this.state = {
        rmb: '',
        dollar: ''
    }
  }
  // 修改父组件状态
  update = (res, type) => {
    const res = parseFloat(res);
    // 判断修改的是 rmb 还是 dollar
    const [rmb, dollar] = type === 'rmb' ? [res, res / 7] : [res * 7, res];
    this.setState({
        rmb: rmb,
        dollar: dollar
    })
  }
  render(){
    return(
        <div>
            {/* 把父组件的 state 和 修改方法作为 props 传给子组件 */}
           <Rmb res={this.state.rmb} toParent={this.update}/>
           <Dollar res={this.state.dollar} toParent={this.update} />
        </div>
    )
  }
}

上面代码的执行流程:
假设子组件 RmbDollar 都有一个 input 输入框。其 valueprops 中的 res 属性, onChange 方法绑定了 props 中的 toParent 方法

  • 无论哪个子组件输入框修改,都会触发父组件的 update 方法。
  • 父组件执行 update,修改自身的 state 属性。请求自身的重新渲染。
  • 父组件执行自身的 render 方法重新渲染 UI
  • 由于父组件的 state 更新,导致其传递给子组件的 props 属性,即 res 值改变。触发子组件的重新渲染。
  • 子组件根据新的 props 值重新渲染,触发自身的 render 方法,实现自己 input 框内的值也更新。

总结:

得益于每次的更新都经历相同的步骤,两个子组件的输入框内容才能始终保持同步。”状态提升“ 的方法不仅可以满足上述场景需求,也可以实现子组件对父组件的状态修改

React-shouldComponentUpdate 及其性能优化

shouldComponentUpdate 带来的性能优化

1. 简介

当一个组件的 propsstate 改变时,React 会构建一个新的虚拟 DOM 树,以此通过 diff 算法将新的虚拟 DOM 树和旧的虚拟 DOM 树进行对比。以此决定是否有必要更新真实的 DOM。当新旧虚拟 DOM 树中对应的节点不相同时,React 会更新该 DOM

如果我们知道组件在什么条件下应该重新渲染,那么就可以通过覆盖生命周期方法 shouldComponentUpdate 来进行提速。

该方法会在重新渲染前被触发如果 shouldComponentUpdate 返回 false,则不会进行 diff 判断,从而提高性能。

shouldComponentUpdate 默认实现总是返回 true,让 React 执行更新:

shouldComponentUpdate(nextProps, nextState){
    return true;
}

2. 使用

假设我们已知组件在什么条件下才应该重新渲染:当且仅当 props.namestate.score 改变时。那么我们就可以在 shouldComponentUpdate 中进行判断。

举个例子如下:

export class Student extends React.Component {
    constructor(props) {
        super(props);
        this.state = {
            age: 24,
            score: 60
        }
    }
    
    // 判断是否需要更新
    shouldComponentUpdate(nextProps, nextState) {
        if (this.props.name !== nextProps.name) {
            return true;
        }
        if (this.state.score !== nextState.score) {
            return true;
        }
        return false;
    }

    render() {
        return (
            <div>
                <p>{this.props.name}</p>
                <p>score: {this.state.score}</p>
                <p>age: {this.state.age}</p>
                <button onClick={() => {
                    this.setState(state => ({
                        score: state.score + 1
                    }))
                }}>addScore</button>
                <button onClick={() => {
                    this.setState(state => ({
                        age: state.age + 1
                    }))
                }}>addAge</button>
            </div>
        )
    }
}

上面代码在 shouldComponentUpdate 中进行了判断,如果返回 false 则不会进行后续的 diff 比较。比如:

  1. 点击 addScore 按钮:

截屏2023-03-04 10 11 41

  1. 点击 addAge 按钮:

截屏2023-03-04 10 12 10

由此可知,当我们判断 score 改变,shouldComponentUpdate 返回 true,后续会重新渲染;如果改变 age 属性, shouldComponentUpdate 返回 false,则不会重新渲染

3. PureComponent / React.memo

如果一个组件的渲染只依赖于 propsstate,我们把它称之为纯组件。那么,我们可以使用类似“浅比较”的模式来检查 propsstate 中所有的字段,以此来决定是否组件需要重新渲染

React 提供了 React.PureComponent,只需要让类组件继承它即可。而对于函数组件,只需要使用 React.memo 包裹即可

举个例子:

// 继承 PureComponent
export class Student extends React.PureComponent {
    constructor(props) {
        super(props);
        this.state = {
            age: 24,
            score: 60
        }
    }

    render() {
        return (
            <div>
                <p>{this.props.name}</p>
                <p>score: {this.state.score}</p>
                <p>age: {this.state.age}</p>
                <button onClick={() => {
                    this.setState(state => ({
                        score: state.score + 1
                    }))
                }}>addScore</button>
                <button onClick={() => {
                    this.setState(state => ({
                        age: state.age + 1
                    }))
                }}>addAge</button>
            </div>
        )
    }
}

上面代码对应的 Student 组件,只有在自己的 propsstate 属性改变,才会触发自身的重新渲染。即使它的父组件重新渲染了,但是如果它自身的 propsstate 没有改变时,它就不会重新渲染,即 shouldComponentUpdate 返回 false

(注意:这个例子和先前的例子并不等效。先前的例子中 age 属性改变也不会触发重新渲染,而当前的例子中只要 state 中任何属性值改变,都会重新渲染)

4. 当 PureComponent 遇上引用类型的数据

上面提到 PureComponent 会对新旧 stateprops 进行浅比较。如果当 PureComponent 遇上较为复杂的数据,如 state 中的属性包含数组对象等引用类型,会怎么样呢?看看下面的例子:

export class Student extends React.PureComponent {
    constructor(props) {
        super(props);
        this.state = {
            // 引用类型数据
            friends: ['jack', 'mike']
        }
    }

    addFriend = () => {
        // 下面中的 friends 和 this.state.friends 指向同一个数组对象
        const friends = this.state.friends;
        // 当 friends 被 push 时,this.state.friends 和它一样
        friends.push('dou');
        this.setState({
            friends: friends
        })
    }

    render() {
        return (
            <div>
                <p>{this.props.name}</p>
                <p>friends: {this.state.friends.join(',')}</p>
                <button onClick={this.addFriend}>addFriends</button>
            </div>
        )
    }
}

上面的代码执行结果:

截屏2023-03-04 10 27 28

即当点击按钮时并不会触发重新渲染。这是因为 addFriends 方法中,friends 数组和 this.state.friends 数组指向同一个数组(该数组地址不变,只是内容变了)。换而言之,它们是一样的,。在浅比较下就会被判断成没有变化,从而就不会触发渲染。


为了避免这种问题,我们可以使用 concat 重新生成一个数组,或者使用 ... 扩展运算符等方法来解决。核心的思路就是:让新的引用对象和旧的引用对象并不指向同一个

修改 addFriends() 方法,如:

// 使用 concat
addFriend = () => {
    this.setState(state => ({
        friends: state.friends.concat(['dou'])
    }))
}
// 使用 ...扩展运算符
addFriend = () => {
    this.setState(state => ({
        friends: [...state.friends, 'dou']
    }))
}

上面介绍的是对数组的处理方法。对于 object 对象,我们可以使用 Object.assign()... 扩展运算符

参考:

React 官方文档

浅谈 useReducer 方法和应用场景

浅谈 useReducer

众所周知, React Hooks 的出现赋予了函数组件 state 及保存和处理 state 的能力。常用的钩子有 useStateuseEffectuseRefuseContextuseReducer 等等。今天在这里聊一聊 useReducer 的作用和复杂场景下取代 useState 的能力

什么是 useReducer

什么是 useReducer 这个问题我已经在先前的一篇 React-Hooks 基础 中进行了相关介绍,如果不了解可以去看一看或者查阅官方文档,这里只做简单介绍和说明。

const [state, dispatch] = useReducer(reducer, initialState);

useReducer 方法接收两个参数:reducerinitialState

  • reducer 是一个纯函数,接收当前 state 和 action 对象,返回新的 state。
  • initialState 是 useReducer 维护的状态的初始值。

使用方法

举个例子,假设 initialState 维护的是一个教师信息,包含 name、age、gender、job 属性。reducer 包含两个参数,前者是当前维护的状态,后者是包含操作信息和数据信息的 action 对象。

  // 初始化状态
  const initialState = {
    name: 'jack',
    age: 50,
    gender: 'male',
    job: 'teacher',
  }
  // 创建 reducer 函数,唯一可以修改 state 的方法
  const reducer = (state, action) => {
    switch (action.type) {
      case 'addAge': {
        return {
          ...state,
          age: state.age + 1
        }
      }
      case 'minorAge': {
        return {
          ...state,
          age: state.age - 1
        }
      }
    }
  }

上面已经完成了 initialStatereducer 的配置,useReducer 将返回其维护的 state 的属性以及 dispatch 方法,其中 dispatch 方法需要传入一个 action 对象,它将自动调用 reducer 方法执行相应的方法。

  // 点击增加年龄
  const addAge = () => {
    // 传入对应的 type 触发 reducer 中对应的方法
    dispatch({ type: 'addAge' });
  }
  // 点击减少年龄
  const minorAge = () => {
    dispatch({ type: 'minorAge' });
  }

从上面的例子可以看出,给 dispatch 传入 action 对象(其实也只是个普通对象),该对象包括 type 属性。当然,也可以给 action 对象添加其他属性及其对应的值,并在 reducer 方法的 action 参数中进行相应的调用

因此,上述完整的例子代码如下:

import { useReducer } from 'react';

function App(props) {
  // 初始化状态
  const initialState = {
    name: 'jack',
    age: 50,
    gender: 'male',
    job: 'teacher',
  }
  // 创建 reducer 函数,唯一可以修改 state 的方法
  const reducer = (state, action) => {
    switch (action.type) {
      case 'addAge': {
        return {
          ...state,
          age: state.age + 1
        }
      }
      case 'minorAge': {
        return {
          ...state,
          age: state.age - 1
        }
      }
    }
  }

  // 使用 useReducer
  const [state, dispatch] = useReducer(reducer, initialState);

  // 点击增加年龄
  const addAge = () => {
    // 传入对应的 type 触发 reducer 中对应的方法
    dispatch({ type: 'addAge' });
  }
  // 点击减少年龄
  const minorAge = () => {
    dispatch({ type: 'minorAge' });
  }

  return (
    <>
      {Object.keys(state).map(k => <div>{`${k}: ${state[k]}`}</div>)}
      <button onClick={addAge}>addAge</button>
      <button onClick={minorAge}>minorAge</button>
    </>
  )
}

执行效果如下:

初始化,值为 initialState 中的值

截屏2023-04-03 10 47 27

点击 addAgeage 值 + 1

截屏2023-04-03 10 48 28

点击三次 minorAgeage 值 - 3

截屏2023-04-03 10 48 44

应用场景(和 useState 对比)

我们知道 useState 能够维护状态,从上面的例子也能看出 useReducer 也可以维护状态。我们试想,如果出现多个状态(多个 useState),并且基于复杂的逻辑进行 state 更新,就会造成更大的心智负担,甚至出现意料之外的错误。

不过 Reducers 并非没有缺点!以下是从不同角度比较它们的看法:

  • 代码体积: 通常,在使用 useState 时,一开始只需要编写少量代码。而 useReducer 必须提前编写 reducer 函数和需要调度的 actions。但是,当多个事件处理程序以相似的方式修改 state 时,useReducer 可以减少代码量。

  • 可读性: 当状态更新逻辑足够简单时,useState 的可读性还行。但是,一旦逻辑变得复杂起来,它们会使组件变得臃肿且难以阅读。在这种情况下,useReducer 允许你将状态更新逻辑与事件处理程序分离开来。

  • 可调试性: 当使用 useState 出现问题时, 你很难发现具体原因以及为什么。 而使用 useReducer 时, 你可以在 reducer 函数中通过打印日志的方式来观察每个状态的更新,以及为什么要更新(来自哪个 action)。 如果所有 action 都没问题,你就知道问题出在了 reducer 本身的逻辑中。 然而,与使用 useState 相比,你必须单步执行更多的代码。

  • 可测试性: reducer 是一个不依赖于组件的纯函数。这就意味着你可以单独对它进行测试。一般来说,我们最好是在真实环境中测试组件,但对于复杂的状态更新逻辑,针对特定的初始状态和 action,断言 reducer 返回的特定状态会很有帮助。

  • 个人偏好: 并不是所有人都喜欢用 reducer,没关系,这是个人偏好问题。你可以随时在 useStateuseReducer 之间切换,它们能做的事情是一样的!

如果你在修改某些组件状态时经常出现问题或者想给组件添加更多逻辑时,我们建议你还是使用 reducer。当然,你也不必整个项目都用 reducer,这是可以自由搭配的。你甚至可以在一个组件中同时使用 useState 和 useReducer。

参考:

React-合成事件

React 合成事件

浏览器的 DOM 事件,也就是我们常提到的 "监听器"。如点击鼠标悬停加载等。

对于原生的 DOM 事件,浏览器会把监听器绑定到对应的 DOM 上,然后创建一个事件对象。如果我们的页面中存在很多 DOM 事件,就需要腾出很多空间来存放对应的事件对象。这无疑是十分消耗内存的。

因此,React 并不把事件绑定到真实的 DOM 节点上,而是在 document 处创建一个 “事件池” (对象),用来保存所有的监听事件。

React 17 开始,移除了 “事件池” 的概念,把所有事件都绑定在 root。不过执行原理整体上不变,下面还是以 React 16 举例并介绍原理。

React 的合成事件仅在事件流的冒泡阶段触发(如果不了解浏览器的事件流可以去看JavaScript_Notes)。当事件发生并冒泡到 document 处时,则会在事件池中选择相应的事件处理程序执行。

1. 绑定合成事件

JSX 语法中,我们可以在标签中绑定 React 合成事件。以绑定按钮的点击事件为例:在原生的 <button> 标签中,绑定原生事件是 onclick,即全小写形式。而在 JSX 中,点击事件的属性为 onClick 驼峰形式,又如 input 标签的 onChange 属性。这些都代表 React 的合成事件。

<button onClick={this.Click}>点击按钮</button>

2. 在 React 中绑定原生事件

如果需要在 JSX绑定原生事件该如何操作呢?

由于原生事件需要绑定在真实的 DOM 上,所以一般是在 componentDidMount 生命周期中进行绑定,也可以使用ref 时进行绑定操作,在componentWillUnmount 阶段解除绑定。

举个例子:给一个按钮既绑定合成事件又绑定原生事件

class Test extends React.PureComponent {
    constructor(props) {
        super(props);
        this.ref = React.createRef(null);
    }

    // 原生事件的处理程序
    nativeEvent = (e) => {
        console.log('Native Event');
    }

    // 合成事件的处理程序
    syntheticEvent = (e) => {
        console.log('Synthetic Event');
    }

    // 添加原生事件
    componentDidMount(){
        this.ref.current.addEventListener('click', this.nativeEvent, false);
    }
        
    // 卸载原生事件
    componentWillUnmount(){
        this.ref.current.removeEventListener('click', this.nativeEvent, false);
    }


    render() {
        
        return (
            <button ref={this.ref} onClick={this.syntheticEvent}>Click Me!</button>
        )
    }
}

如果点击按钮,输出为:
截屏2023-03-06 15 03 18

3. 原生事件和合成事件的关联与混用

我们知道 click 是在冒泡阶段触发事件处理程序的。从上面例子能够看出,原生事件比和合成事件更早地触发事件处理

原因是:

  • 当真实的 DOM 触发事件时,此时 React构造合成事件对象,即 SyntheticEvent 对象。document 进行 dispatchEvent,找到触发事件的最深的一个节点。通过冒泡的形式一直从当前节点向上遍历到 document 处,或 获取该路径上对应节点的事件处理函数加入 eventQueue,最后按照顺序触发事件处理程序。

  • 然而,原生事件会在冒泡的过程中就触发对应的事件处理程序。

因此,原生事件会在合成事件之前触发。

再看一个复杂一点的例子:

class Test extends React.PureComponent {
    constructor(props) {
        super(props);
        this.childRef = React.createRef(null);
        this.parentRef = React.createRef(null);
    }

    // 原生事件的处理程序 parent
    nativeParentEvent = (e) => {
        console.log('Native Parent Event');
    }
        // 原生事件的处理程序 parent
        nativeChildEvent = (e) => {
            console.log('Native Child Event');
        }

    // 合成事件的处理程序 parent
    syntheticParentEvent = (e) => {
        console.log('Synthetic Parent Event');
    }
        // 合成事件的处理程序 parent
        syntheticChildEvent = (e) => {
            console.log('Synthetic Child Event');
        }

    // 添加原生事件
    componentDidMount() {
        this.childRef.current.addEventListener('click', this.nativeChildEvent, false);
        this.parentRef.current.addEventListener('click', this.nativeParentEvent, false);
    }

    // 卸载原生事件
    componentWillUnmount() {
        this.childRef.current.removeEventListener('click', this.nativeChildEvent, false);
        this.parentRef.current.removeEventListener('click', this.nativeParentEvent, false);
    }


    render() {
        return (
            <div ref={this.parentRef} onClick={this.syntheticParentEvent}>
                <button ref={this.childRef} onClick={this.syntheticChildEvent}>Click Me!</button>
            </div>
        )
    }
}

当点击 button 后,输出结果如下:

截屏2023-03-06 15 14 15

4. 阻止事件

在原生事件中,我们可以通过 event.stopPropagation() 方法来阻止事件继续冒泡传递

在上面的例子中,我们在 <div> 的原生事件中调用 stopPropagation 方法。结果会怎么样?

// 原生事件的处理程序 parent
nativeParentEvent = (e) => {
    console.log('Native Parent Event');
    e.stopPropagation();
}

结果如下:

截屏2023-03-06 15 21 00

由于事件冒泡过程中,在 <div> 处停止了继续传递,所以消息无法传递到 document,因此也就无法触发 React 的合成事件

React 17 之前,如果我们希望阻止合成事件,应该采用 event.preventDefault 方法。然而, React 17 帮我们让合成事件更加接近原生事件,我们可以在合成事件中调用 event.stopPropagation() 方法来阻止事件冒泡

5. 总结

上面讲解的 document 事件池概念都是在 React 17 之前,虽然在 17 之后已经让合成事件更加接近原生事件,但是整体原理依旧不变。

虽然上面讲了合成事件和原生事件的混用,但是不建议混用,容易造成一些意想不到的错误

合成事件相对于原生事件有不少的优点,如:

  • 抹平了浏览器间的兼容问题,不同浏览器支持的内容不一样,但合成事件是一个跨浏览器的原生事件包装器,便于跨浏览器开发

  • 对于原生的 DOM 事件,浏览器会把监听器绑定到对应的 DOM 上,然后创建一个事件对象。如果我们的页面中存在很多 DOM 事件,就需要腾出很多空间来存放对应的事件对象。这无疑是十分消耗内存的。但是,合成事件统一挂载在 documentroot)上,减少了内存消耗,并且可以统一管理

参考:

React 官方文档
由浅到深的 React 合成事件

从源码告诉你 React.Component 到底做了什么

从源码告诉你 React.Component 到底做了什么

当 Hooks 没出来之前,我们写 React 组件时就得使用类组件。然而每次写类组件的时候,都必须要继承 React.Component,那么我就有点好奇这个这个父类到底做了些什么?

话不多说,直接上源码:

源码路径:https://github.com/facebook/react/blob/main/packages/react/src/ReactBaseClasses.js

/**
 * Base class helpers for the updating state of a component.
 */
function Component(props, context, updater) {
  this.props = props;
  this.context = context;
  // If a component has string refs, we will assign a different object later.
  this.refs = emptyObject;
  // We initialize the default updater but the real one gets injected by the
  // renderer.
  this.updater = updater || ReactNoopUpdateQueue;
}

Component.prototype.setState = function (partialState, callback) {
  if (
    typeof partialState !== 'object' &&
    typeof partialState !== 'function' &&
    partialState != null
  ) {
    throw new Error(
      'setState(...): takes an object of state variables to update or a ' +
        'function which returns an object of state variables.',
    );
  }

  this.updater.enqueueSetState(this, partialState, callback, 'setState');
};

Component.prototype.forceUpdate = function (callback) {
  this.updater.enqueueForceUpdate(this, callback, 'forceUpdate');
};

把上面的源码进行一定的注解和简化,如下:

function Component(props, context, updater) {
  this.props = props;      // 绑定 props 属性
  this.context = context;  // 绑定 context 内容
  this.refs = emptyObject; // 绑定 ref 属性,初始值赋值为一个空对象
  this.updater = updater || ReactNoopUpdateQueue;  // 传入的 updater 对象
}
/* 在原型上绑定 setState 方法 */
Component.prototype.setState = function(partialState, callback) {
  this.updater.enqueueSetState(this, partialState, callback, 'setState');
}
/* 在原型上绑定 forceupdate 方法 */
Component.prototype.forceUpdate = function(callback) {
  this.updater.enqueueForceUpdate(this, callback, 'forceUpdate');
}

从上面的代码可以知道,Component 实际上会绑定或初始化一系列的属性,如 propsrefcontext 等信息。此外每个组件还会有一个 updater 对象。每次触发 setStateforceUpdate 方法时就会将该组件 updater 对象中对应的 setState 队列forUpdate`队列中存放的状态一并处理更新。


我们知道,在定义类组件时,其 constructor 构造函数中需要添加 super(props),这实际上就是在调用 Component 类的构造函数,从而初始化并设置对应的属性,如 propsrefcontextupdater 等信息。

Class MyComponent extends React.Component {
    constructor(props){
        super(props);
        // 其他代码
    }
}

如果我们不给 super() 方法传入 props 值,打印 this.props 时现实的就是 undefined。这是因为源码的 Component 构造函数中需要 Component(props, context, updater) 这三个参数,对于 props 和 context,如果没传入值,则会被初始化为 undefined

参考

React 源码

React-Refs 与 DOM

Refs

众所周知,React DOM 是虚拟节点,它实际上是一个对象,并且 current 属性指向真实的 DOM 节点。那该如何通过 React 来操纵 DOM 节点呢?

如何使用

Refs 提供了一种方式,允许我们访问 DOM 节点或在 render 方法中创建的 React 元素(只能是类组件,不能是函数组件,因为函数组件没有实例)。

// 类组件的方法
class MyComponent extends React.Component {
  constructor(props) {
    super(props);
    // 使用 React.createRef()
    this.ref = React.createRef();
  }
  render() {
    // 绑定到 DOM 节点上
    return <div ref={this.ref} />;
  }
}


// 函数组件的方法
function MyComponent(props){
    // 使用 useRef 钩子
    const ref = useRef();
    return(
        // 绑定到 DOM 节点上
        <div ref={ref}></div>
    )
}

基于此,我们就可以通过 this.ref(以类组件为例)来操纵 DOM 节点了。

使用场景

操纵 DOM 节点的场景主要包括:(参考官方文档)

  • 管理焦点,获取元素相关信息,文本选择或媒体播放等。
  • 触发强制动画。
  • 集成第三方 DOM 库。

以管理焦点为例:

function MyComponent(props){
    const ref = useRef();
    const focus = function(){
        // ref.current 指向 `DOM`,再使用原生 js 的 focus()
        ref.current.focus();
    }
    return(
        <div>
           <input ref={ref} />
           <button onClick={focus}>focus</button>
        </div>
        
    )
}

export default MyComponent;

上面代码的效果下,即 <input> 输入框会被 focus
image

给 class 组件添加 Ref

虽然不能给函数组件添加 Ref(因为函数组件没有实例),但是我们可以给 class 组件添加 Ref。对应的 current 属性将指向类组件的挂载实例

可以通过 Ref 对象来控制绑定的类组件实例的基本方法。详细看下面的例子:

// 父组件
export const RefInCustomComponent = () => {
    const caRef = useRef();
    // 加法:调用实例的 setState
    const add = () => {
        caRef.current.setState(state => ({
            count: state.count + 1
        }))
    }
    // 减法:调用实例的 setState
    const minor = () => {
        caRef.current.setState(state => ({
            count: state.count - 1
        }))
    }
    // 调用类实例的方法
    const changeName = () => {
        caRef.current.changeName();
    }
    return(
        <div>
            {/* 把 ref 绑定到类组件上 */}
            <CustomAvatar ref={caRef} />
            <button onClick={add}>add</button>
            <button onClick={minor}>minor</button>
            <button onClick={changeName}>changeName</button>
        </div>
        
    )
}

// 子组件
class CustomAvatar extends React.Component {
    constructor(props){
        super(props)
        this.state = {
            count: 0,
            name: 'jack'
        }
    }
    // 子组件的类方法
    changeName = () => {
        this.setState({
            name: 'dou'
        })
    }
    render() {
        return(
            <div>
                <p>count: {this.state.count}, name: {this.state.name}</p>
            </div>
        )
    }
}

效果:

  • 原始效果:
    image

  • 点击 add 按钮:
    image

  • 点击两下 minor 按钮:
    image

  • 点击 changeName 按钮
    image

React-Context

Context

React 提倡数据的流动是 ”自上而下“ 的,但是 porps 是逐层级传递数据,如果组件的深度较大,就需要一层一层地传下去,导致某些中间组件可能并不需要某些属性,但是为了提供给下面的组件,就必须存储着,这是极其繁琐的。

为了解决这种问题,Context 提供了一种在组件之间共享此类值的方式,而不必显式地通过组件树的逐层传递 props

Context 设计目的是为了共享那些对于一个组件树而言是 “全局” 的数据,例如页面主题当前用户 等等。

创建

我们可以通过 React.createContext() 方法来创建 Context 对象。它接受一个参数 defaultValue 初始值。

const MyContext = React.createContext(defaultValue);

包装组件树的根节点

当我们创建了一个 Context 对象之后,该对象会返回一个 Provider React 组件,该组件包含一个 value 属性,传递给下面需要的组件(我们称之为消费组件)。

class School extends React.Component{
    render(){
        return(
            <MyContext.Provider value={'jack'}>
                <Student />
            </MyContext.Provider>
        )
    }
}

消费组件取值

由上面代码,在 Student 组件(消费组件)中就可以使用 value 内的值。

那么 Student 该如何获取到这个值呢?我们可以在 Student 组件的 return 中设置 <MyContext.Consumer> 标签包裹我们需要的返回的内容。

这种方法需要一个函数作为子元素。这个函数接收当前的 Context 对象的 value 值,并返回一个 React 节点。这个 React 节点就是消费组件里返回的内容。

传递给函数的 value 值等价于组件树上方离这个 Context 最近的 Provider 提供的 value 值。如果没有对应的 Providervalue 参数等同于传递给 createContext()defaultValue

class Student extends React.Component{
    render(){
        return(
            <MyContext.Consumer>
                {(value) => (
                        <p>{value}</p>
                 )}
            </MyContext.Consumer>
        )
    }
}

当然,对于类组件我们也可以把 Context 对象挂载到类名的 contextType 属性上,在类中就可以直接调用 this.context 获取 value 的内容。

class Student extends React.Component{
    render(){
       // 通过 this.context 获取 value 值
        const value = this.context;
        return(
                    <p>{value}</p>
        )
    }
}
// 挂载到类的 contextType 属性上
Student.contextType = MyContext;

对于函数式组件,我们可以通过 useContext 钩子来获取 value 值。

function Student(props){
    // 通过 useCntext 获取 value 值
    const value = useContext(MyContext);
    return (
            <p>{value}</p>
    );
}

上述几种方法都可以在消费组件中获取到 value 值。

动态更改共享属性

上面都提到了如何在消费组件中获取到 value 值,那么如果我们需要更改 value 中的值,那该怎么办呢?

对于 Context 对象来说,Providervalue 发生变化时,它内部的所有消费组件都会重新渲染。那我们是不是可以通过给 value 赋值为一个对象,该对象包含属性值,也包含更改属性值的方法,再通过 value 里的方法来修改某个属性?

这种方法的逻辑是行得通的,但是最致命的一点就是给 value 赋值为一个对象,虽然修改了其内部的属性值,但是这个对象并没有变,所以并不能满足 value 发生变化这个条件。因此,页面不会重新渲染

但是,换一个思路,我们可以把 value 赋值为当前组件树根节点的 state 属性,在 state 中添加一个方法,可以 setState() 修改属性。从而触发根节点的重新渲染,并带来其他消费组件的更新。代码如下:

const MyContext = React.createContext();

export class School extends React.Component{
    constructor(props){
        super(props);
        // 该方法会触发 setState
        this.toggleStudent = (newStudent) => {
            this.setState({
                student: newStudent
            });
        };
        this.state = {
            student: 'dou',
            toggleStudent: this.toggleStudent
        }
    }

    render(){
        return(
            <MyContext.Provider value={this.state}>
                <Student />
            </MyContext.Provider>
        )
    }
}

// 消费组件
class Student extends React.Component{
    render(){
        return(
            <MyContext.Consumer>
                {({student, toggleStudent}) => (
                    <div>
                        <p> This student is: {student}</p>
                        <button onClick={() => {
                            const students = ['jack', 'mike', 'lucy', 'john'];
                            const newStudent = students[Math.floor(Math.random() * students.length)];
                            toggleStudent(newStudent);
                        }}>change</button>
                    </div>)}
            </MyContext.Consumer>
        )
    }
}

从源码告诉你 useState hook 在组件更新时做了什么

从源码告诉你 useState hook 在组件更新时做了什么

useState hook 是我们常用于维护状态的 hook。我们知道在函数组件中调用 setState 方法时,大概会有以下几个重点:

  • 重新执行整个函数组件,至于会不会重新渲染,这和传入的新 state 和旧 state 相不相同,如果相同就不重新渲染,反之重新渲染。
  • React 会对多个 setState 进行批量更新。(React 18 之前对于 setTimeout、setInterval、Promise 等方法是默认不批量更新的,这和上下文有关;但是 React 18 已经开始默认所有都批量更新了)
  • setState 中的 update 对象存在优先级,批量更新其实也是受优先级影响的(上下文会影响优先级)。

带着上面三个重点问题,阅读 useState 源码,基本上就能够弄清楚这三点是如何执行的了。

renderWithHooks

renderWithHooks 是所有函数式组件的触发函数,接下来一起看看:

源码地址:https://github.com/facebook/react/blob/main/packages/react-reconciler/src/ReactFiberHooks.js

export function renderWithHooks<Props, SecondArg>(
  current: Fiber | null,
  workInProgress: Fiber,
  Component: (p: Props, arg: SecondArg) => any,
  props: Props,
  secondArg: SecondArg,
  nextRenderLanes: Lanes,
): any {
  // 下一次渲染的优先级
  renderLanes = nextRenderLanes;
  // workInprogress 是即将调和渲染的 fiber 树
  // 一次组件的更新渲染过程是:
  // 1、从 current 复制一份作为 workInProgress
  // 2、对 workInProgress 进行调和,收集副作用
  // 3、把 workInProgress 树赋予给 current
  currentlyRenderingFiber = workInProgress;

  // workInProgress 是个全局变量,每次更新的内容可能不一样,因此执行前需要置空以下属性
  workInProgress.memoizedState = null;
  workInProgress.updateQueue = null;
  workInProgress.lanes = NoLanes;

  // 重点:根据当前函数组件是否是第一次渲染,决定 ReactCurrentDispatcher.current 是 mount 还是 update 的 hooks 对象
  ReactCurrentDispatcher.current =
    current === null || current.memoizedState === null
      ? HooksDispatcherOnMount
      : HooksDispatcherOnUpdate;
  
  // 调用 Component 方法执行函数组件
  let children = Component(props, secondArg);
  
  shouldDoubleInvokeUserFnsInHooksDEV = false;

  // 如果在渲染过程中有更新,则进行二次渲染,直到没有更新为止
  if (didScheduleRenderPhaseUpdateDuringThisPass) {
    children = renderWithHooksAgain(
      workInProgress,
      Component,
      props,
      secondArg,
    );
  }

  // 这边应该是 react 18 的对于开发环境的一个新特点,构建后就不会这样了
  // React 官方表示:如果是开发环境下,在第一次创建函数组件时,会进行两次渲染,以保证任务正确性。
  // ps:这也是为什么在 react 18 中,初始化一个函数组件时,在 useEffect(callback, [deps]) 的 callback 中如果执行 console.log,会打印两次的原因
  // 但在之后的更新中,就不会触发两次了。
  const shouldDoubleRenderDEV =
    __DEV__ &&
    debugRenderPhaseSideEffectsForStrictMode &&
    (workInProgress.mode & StrictLegacyMode) !== NoMode;

  shouldDoubleInvokeUserFnsInHooksDEV = shouldDoubleRenderDEV;

  // 在开发环境下会进行二次渲染
  if (shouldDoubleRenderDEV) {
    setIsStrictModeForDevtools(true);
    try {
      children = renderWithHooksAgain(
        workInProgress,
        Component,
        props,
        secondArg,
      );
    } finally {
      setIsStrictModeForDevtools(false);
    }
  }

  // hooks 渲染完后的操作,包括解决异常
  finishRenderingHooks(current, workInProgress, Component);

  // 返回函数组件
  return children;
}

从上面的源码可以了解到,renderWithHooks 的执行流程为:

  1. 在每次函数组件执行之前,先将 workInProgressmemoizedStateupdateQueue 属性清空,然后将当前新的 Hooks 信息挂载到这两个属性上。

  2. 通过判断 current 树是不是 null 或者 memorizedState 是不是 null 来判断是初始化HooksDispatcherOnMount),还是更新HooksDispatcherOnMount)。把对应的方法赋给 ReactCurrentDispatcher.current。这两种方法里面都包括了所有类型的 hooks 及其对应的调用方法。React 根据 current 的不同来判断对应的 Hooks,执行相应的方法

  3. Component(props, secondArg) 执行函数组件。执行函数组件会把里面的所有内容,包括 Hooks 都重新执行。

  4. 最后执行渲染后的操作 finishRenderingHooks,进行异常处理,并将一些属性置空用于下一次使用,如:currentHookworkInProgressHook 等。

下面就看一看不同 hooks 及其对应的方法...

HooksDispatcherOnMount / HooksDispatcherOnUpdate

HooksDispatcherOnMountHooksDispatcherOnUpdate 分别对应了 mount 阶段和 update 阶段各类型的 hook 及其对应的调用方法。

源码地址:https://github.com/facebook/react/blob/main/packages/react-reconciler/src/ReactFiberHooks.js

// hooks 初始化流程
const HooksDispatcherOnMount: Dispatcher = {
  readContext,

  use,
  useCallback: mountCallback,
  useContext: readContext,
  useEffect: mountEffect,
  useImperativeHandle: mountImperativeHandle,
  useLayoutEffect: mountLayoutEffect,
  useInsertionEffect: mountInsertionEffect,
  useMemo: mountMemo,
  useReducer: mountReducer,
  useRef: mountRef,
  useState: mountState, // useState 调用 mountState 方法。mountState 方法实际上就是 updateReducer 的套壳,真正使用的是 updateReducer 方法
  useDebugValue: mountDebugValue,
  useDeferredValue: mountDeferredValue,
  useTransition: mountTransition,
  useSyncExternalStore: mountSyncExternalStore,
  useId: mountId,
};

// hooks 更新流程,根据不同的 hook 类型,决定调用不同的方法
const HooksDispatcherOnUpdate: Dispatcher = {
  readContext,

  use,
  useCallback: updateCallback,
  useContext: readContext,
  useEffect: updateEffect,
  useImperativeHandle: updateImperativeHandle,
  useInsertionEffect: updateInsertionEffect,
  useLayoutEffect: updateLayoutEffect,
  useMemo: updateMemo,
  useReducer: updateReducer,
  useRef: updateRef,
  useState: updateState, // useState 调用 updateState 方法。updateState 方法实际上就是 updateReducer 的套壳,真正使用的是 updateReducer 方法
  useDebugValue: updateDebugValue,
  useDeferredValue: updateDeferredValue,
  useTransition: updateTransition,
  useSyncExternalStore: updateSyncExternalStore,
  useId: updateId,
};

由于我们比较关心 useState 方法,就定位到对应的方法:mountStateupdateState。初始化的内容比较简单,这里不做详细介绍,我们的重点放在 updateState 方法上。定位到该方法会发现,其内部调用了 updateReducerImpl 方法,这也是其核心内容,我们继续往下看代码...

updateReducerImpl

updateReducerImpl 方法是处理 useState hook 更新时的操作流程。我对该源码进行了解释说明,其源码如下:

源码地址:https://github.com/facebook/react/blob/main/packages/react-reconciler/src/ReactFiberHooks.js

// 处理 useState hook 的 setState 更新时的操作流程
function updateReducerImpl<S, A>(
  hook: Hook,
  current: Hook,
  reducer: (S, A) => S,
): [S, Dispatch<A>] {
  const queue = hook.queue;

  if (queue === null) {
    throw new Error(
      'Should have a queue. This is likely a bug in React. Please file an issue.',
    );
  }

  queue.lastRenderedReducer = reducer;

  let baseQueue = hook.baseQueue;

  const pendingQueue = queue.pending;
  // 如果待处理更新队列 pendingQueue 中有对象,就加入到基础更新队列 baseQueue 中
  if (pendingQueue !== null) {
    if (baseQueue !== null) {
      // 合并过程
      const baseFirst = baseQueue.next;
      const pendingFirst = pendingQueue.next;
      baseQueue.next = pendingFirst;
      pendingQueue.next = baseFirst;
    }
    if (__DEV__) {
      if (current.baseQueue !== baseQueue) {
        // Internal invariant that should never happen, but feasibly could in
        // the future if we implement resuming, or some form of that.
        console.error(
          'Internal error: Expected work-in-progress queue to be a clone. ' +
            'This is a bug in React.',
        );
      }
    }
    // baseQueue 被设置为 pendingQueue,也就是待更新的 update 对象队列
    current.baseQueue = baseQueue = pendingQueue;
    // 需要清空 pending 队列,这样本次执行 hook 的 pending 队列不会影响到下一次
    queue.pending = null;
  }

  // 如果基础更新队列中有 update 对象,说明需要执行更新,就按照优先级依次处理每一个 update 对象(下面会进行批量更新)
  if (baseQueue !== null) {
    const first = baseQueue.next;
    let newState = hook.baseState;

    let newBaseState = null;
    let newBaseQueueFirst = null;
    let newBaseQueueLast: Update<S, A> | null = null;
    let update = first;
    do {
      // An extra OffscreenLane bit is added to updates that were made to
      // a hidden tree, so that we can distinguish them from updates that were
      // already there when the tree was hidden.
      const updateLane = removeLanes(update.lane, OffscreenLane);
      const isHiddenUpdate = updateLane !== update.lane;

      // Check if this update was made while the tree was hidden. If so, then
      // it's not a "base" update and we should disregard the extra base lanes
      // that were added to renderLanes when we entered the Offscreen tree.
      const shouldSkipUpdate = isHiddenUpdate
        ? !isSubsetOfLanes(getWorkInProgressRootRenderLanes(), updateLane)
        : !isSubsetOfLanes(renderLanes, updateLane);

      // 当前任务的优先级不够,也就是说当前的 renderLanes 比该 update 对象的优先级高。需要先跳过,之后再处理。
      if (shouldSkipUpdate) {
        const clone: Update<S, A> = {
          lane: updateLane,
          revertLane: update.revertLane,
          action: update.action,
          hasEagerState: update.hasEagerState,
          eagerState: update.eagerState,
          next: (null: any),
        };
        // 如果在当前被跳过的 update 对象之前没有其他的 update 被跳过,该对象就是作为新的基础更新对象。
        if (newBaseQueueLast === null) {
          newBaseQueueFirst = newBaseQueueLast = clone;
          newBaseState = newState;
        } else {
          // 如果之前有更新被跳过,那么将这个更新对象添加到队列最后
          newBaseQueueLast = newBaseQueueLast.next = clone;
        }
        
        // 更新当前正在工作的 Fiber 节点(workInProgress)的优先级,标记这个更新对象的优先级由于不匹配当前的 renderLane,因此已经被跳过。
        // 在同文件的 renderWithHook() 方法中可以知道 currentlyRenderingFiber 对应了 workInProgress,表示当前正在工作的 fiber 树
        currentlyRenderingFiber.lanes = mergeLanes(
          currentlyRenderingFiber.lanes,
          updateLane,
        );
        markSkippedUpdateLanes(updateLane);
      } else { // 优先级足够
       
        // 优先级足够的时候理论上可以考虑对哪些 update 对象进行收集更新,但是此时还需要考虑一个 revertLane(还原的优先级),以保证组件状态的正确。
        // 这里我理解的也不是很清楚,但是对理解 useState 的 update 收集和更新过程不太影响。并且下面整个流程的**和上面判断 update 优先级和跳过的**是差不多的。
        // -----所以这一块不做深入地讨论。-----
        const revertLane = update.revertLane;
        if (!enableAsyncActions || revertLane === NoLane) {
          // This is not an optimistic update, and we're going to apply it now.
          // But, if there were earlier updates that were skipped, we need to
          // leave this update in the queue so it can be rebased later.
          if (newBaseQueueLast !== null) {
            const clone: Update<S, A> = {
              // This update is going to be committed so we never want uncommit
              // it. Using NoLane works because 0 is a subset of all bitmasks, so
              // this will never be skipped by the check above.
              lane: NoLane,
              revertLane: NoLane,
              action: update.action,
              hasEagerState: update.hasEagerState,
              eagerState: update.eagerState,
              next: (null: any),
            };
            newBaseQueueLast = newBaseQueueLast.next = clone;
          }
        } else {
          // This is an optimistic update. If the "revert" priority is
          // sufficient, don't apply the update. Otherwise, apply the update,
          // but leave it in the queue so it can be either reverted or
          // rebased in a subsequent render.
          if (isSubsetOfLanes(renderLanes, revertLane)) {
            // The transition that this optimistic update is associated with
            // has finished. Pretend the update doesn't exist by skipping
            // over it.
            update = update.next;
            continue;
          } else {
            const clone: Update<S, A> = {
              // Once we commit an optimistic update, we shouldn't uncommit it
              // until the transition it is associated with has finished
              // (represented by revertLane). Using NoLane here works because 0
              // is a subset of all bitmasks, so this will never be skipped by
              // the check above.
              lane: NoLane,
              // Reuse the same revertLane so we know when the transition
              // has finished.
              revertLane: update.revertLane,
              action: update.action,
              hasEagerState: update.hasEagerState,
              eagerState: update.eagerState,
              next: (null: any),
            };
            if (newBaseQueueLast === null) {
              newBaseQueueFirst = newBaseQueueLast = clone;
              newBaseState = newState;
            } else {
              newBaseQueueLast = newBaseQueueLast.next = clone;
            }
            // Update the remaining priority in the queue.
            // TODO: Don't need to accumulate this. Instead, we can remove
            // renderLanes from the original lanes.
            currentlyRenderingFiber.lanes = mergeLanes(
              currentlyRenderingFiber.lanes,
              revertLane,
            );
            markSkippedUpdateLanes(revertLane);
          }
        }

        // 该 update 对象的优先级足够,因此开始处理它的 action,收集新的 state 状态
        const action = update.action;
        if (shouldDoubleInvokeUserFnsInHooksDEV) {
          reducer(newState, action);
        }
        if (update.hasEagerState) {
          // If this update is a state update (not a reducer) and was processed eagerly,
          // we can use the eagerly computed state
          newState = ((update.eagerState: any): S);
        } else {
          newState = reducer(newState, action);
        }
      }
      // 循环 baseQueue(实际上就是 pendingQueue),处理该队列中的每个 update 对象,
      // 并把对应的 action 操作得到的结果更新到 newState 中(即收集新状态)
      update = update.next;
    } while (update !== null && update !== first);

    // 如果没有更新被跳过,说明本次 renderLane 对应的 update 对象批量更新结束时也意味着所有更新结束,可以设置 newBaseState 为新的 state 了
    if (newBaseQueueLast === null) {
      newBaseState = newState;
    } else {
      newBaseQueueLast.next = (newBaseQueueFirst: any);
    }

    // 循环收集完当前 renderLane 相同 lane 的 update 对象的状态,会得到一个 newState
    // 通过比较 newState 和之前的 state 是否相等,决定是否需要进行渲染。
    // markWorkInProgressReceivedUpdate() 方法对应了 ReactFiberBeginWork.js 文件,标记 fiber 节点调和渲染开始。
    if (!is(newState, hook.memoizedState)) {
      markWorkInProgressReceivedUpdate();
    }

    // 更新该组件的 hook 对象中的各属性
    hook.memoizedState = newState;
    hook.baseState = newBaseState;
    hook.baseQueue = newBaseQueueLast;

    queue.lastRenderedState = newState;
  }

  // 如果 Queue === null,说明没有 update 对象了,就把当前 hook(useState)的queue 优先级置为无。
  if (baseQueue === null) {
    queue.lanes = NoLanes;
  }

  // 因为函数组件重新渲染后,会重新执行函数,因此 useState 中应该返回为最新的 state,dispatch 还是我们之前传入的setState 方法,如 setName。
  const dispatch: Dispatch<A> = (queue.dispatch: any);
  return [hook.memoizedState, dispatch];
}

从上面的源码可以看到,updateReducerImpl 方法核心内容分为四个部分:

  1. 获取 Hook 对象中的更新队列、基础更新队列、基础状态、reduce 函数等信息。
  2. 如果更新队列中有待处理的更新对象,就将其加入到基础更新队列中。
  3. 按照优先级依次处理基础更新队列中的更新对象,计算新的状态。
  4. 判断新的状态和旧的状态是否相同,如果相同就不做重新渲染,反之需要重新渲染。
  5. 返回更新后的状态和 dispatch 方法。

总结

以上就是 useState hook 在更新时的详细流程。流程图如下:

回到最开始提出的三大问题,我们可以通过源码尝试解释:
截屏2023-07-07 15 24 45

  • 每次都会更新都会调用 Component 方法来重新执行整个函数组件。
  • 所有的 update 对象会通过自身的 lane 和 renderLane 进行计算(循环收集),高于 renderLane 的会一起批量更新,低于的则跳过,之后再根据低的 renderLane 进行批量更新,直到所有 update 对象都被处理。
  • 会不会重新渲染取决于合并后的 newState 与旧的 state 是否相同(浅比较),如果相同则不会重新渲染。

从源码告诉你 React 是如何抵御 XSS 攻击的

从源码告诉你 React 是如何抵御 XSS 攻击的

作为前端开发人员,XSS 攻击(如果对 XSS 不了解,先看一看这篇 issue)并不陌生。对于如何抵御 XSS 攻击,一般有以下三种方法:

  • 在服务端对 script 标签进行转义或过滤,再传回给浏览器,这样浏览器在 HTML 解析时就不会把 script 标签当作 JS 代码执行了。
  • 利用 http-only。当 Cookie 设置 http-only 后,会禁止 JavaScript 来访问 Cookie。
  • 充分利用 CSP。内容安全策略(CSP)是一个额外的安全层,会限制加载其他域下的资源文件、禁止向第三方提交数据。

虽然这篇 issue 的重点并不在于如何抵御 XSS 攻击,而是 React 框架是如何来抵御 XSS 攻击的,但是 React 使用的方法之一还是 转义

转义

React 在渲染 HTML 内容和渲染 DOM 节点时都会将 "'&<> 这几个字符进行自动转义,转义的方法为:

for (index = match.index; index < str.length; index++) {
    switch (str.charCodeAt(index)) {
      case 34: // "
        escape = '&quot;';
        break;
      case 38: // &
        escape = '&amp;';
        break;
      case 39: // '
        escape = '&#x27;';
        break;
      case 60: // <
        escape = '&lt;';
        break;
      case 62: // >
        escape = '&gt;';
        break;
      default:
        continue;
    }
  }

因此,一段恶意代码在被 React 渲染到浏览器前,对浏览器有特殊含义的字符都被转义了,恶意代码在渲染到 HTML 前都被转成了字符串,从而避免了 XSS 攻击。如下:

// 举例:一段恶意代码
<img src="wrong.jpg" onerror ="alert('你被我 xss 攻击啦!')"> 

// React 转义后输出到 html 中将变为:
&lt;img src=&quot;wrong.jpg&quot; onerror =&quot;alert(&#x27;你被我 xss 攻击啦!&#x27;)&quot;&gt; 

React.createElement 和 $$typeof 标识属性

了解 React 框架的都知道,React 通过 JSX 来编写的,JSX 实际上就是 React 的一个语法糖,Babel 会将 JSX 转为 React.createElement 语法。举个例子:

<div id="foo">bar</div>

对于上面这段 JSX 代码,Babel 会将其转译为:

React.createElement("div", {id: "foo"}, "bar");

React.createElement 的参数为:

  1. 元素类型
  2. 元素属性
  3. children(实际上这并不只是第三个参数,children 可能有很多个)

看一看 React.createElement 源码,了解一下它的内部做了些什么事:

React.createElement 源码链接:https://github.com/facebook/react/blob/main/packages/react/src/ReactElement.js

/**
 * https://github.com/reactjs/rfcs/pull/107
 * @param {*} type
 * @param {object} props
 * @param {string} key
 */

// 最新版现在已经不叫 createElement 了,简写为 ‘jsx’ 方法,包括我们写代码的时候也可以直接使用 jsx 来创建 React 组件。
export function jsx(type, config, maybeKey) {
  let propName;

  // Reserved names are extracted
  const props = {};


  //  createElement 会把 key 属性和 ref 属性单独处理。这也是为什么我们不能够在 props 属性中获取到 key 和 ref 的值
  let key = null;
  let ref = null;

  if (maybeKey !== undefined) {
    if (__DEV__) {
      checkKeyStringCoercion(maybeKey);
    }
    key = '' + maybeKey;
  }

  if (hasValidKey(config)) {
    if (__DEV__) {
      checkKeyStringCoercion(config.key);
    }
    key = '' + config.key;
  }

  if (hasValidRef(config)) {
    ref = config.ref;
  }

  // 下面是将把组件传入的 props 属性和 defaultProps 属性添加到新建的 props 对象中,从而实现合并
  for (propName in config) {
    if (
      hasOwnProperty.call(config, propName) &&
      !RESERVED_PROPS.hasOwnProperty(propName)
    ) {
      props[propName] = config[propName];
    }
  }

  if (type && type.defaultProps) {
    const defaultProps = type.defaultProps;
    for (propName in defaultProps) {
      if (props[propName] === undefined) {
        props[propName] = defaultProps[propName];
      }
    }
  }

  // 返回 ReactElement 元素,包含 type、key、ref、props 等属性。(current 不用太关注,它是指向处于构建过程中的组件的 owner,可以理解为用于记录临时变量。
  return ReactElement(
    type,
    key,
    ref,
    undefined,
    undefined,
    ReactCurrentOwner.current,
    props,
  );
}

上面就是 React.createElement 的源码,我并没有做删减,只是对每个部分添加了说明。上面的内容似乎并不能直接说明 React 能够抵御 XSS 攻击,这是因为核心中的核心在于 ReactElement ( ),该方法内部对 React 元素进行了进一步的增强。我们从源码来看一看 ReactElement ( ) 内部做了些什么:

ReactElement 源码也在同一个文件中:https://github.com/facebook/react/blob/main/packages/react/src/ReactElement.js

function ReactElement(type, key, ref, self, source, owner, props) {

  // 定义 React 元素,包含若干属性:
  const element = {

    // This tag allows us to uniquely identify this as a React Element
    // --> 官方也说了这个 tag 可以帮助我们判断这是不是一个 React 元素
    $$typeof: REACT_ELEMENT_TYPE,


    // Built-in properties that belong on the element.
    // --> 元素的一些自带属性
    type: type,
    key: key,
    ref: ref,
    props: props,

    // Record the component responsible for creating this element.
    // --> owner 不用了解太多,实际上就是一个负责产生该元素的组件
    _owner: owner,
  };

  // ...其他代码,主要是处理 __store、_self、__source 等属性。

  // 返回 element 元素
  return element;
}

我对 ReactElement 源码进行了一些精简和说明,源码中可以看到 $$typeof 这个属性能够帮助我们判断当前元素是不是 React 元素,这也是抵御 XSS 攻击的核心。首先,我们需要知道 REACT_ELEMENT_TYPE 是个什么玩意儿...

通过该属性定位到 ReactSymbols.js 文件:https://github.com/facebook/react/blob/main/packages/shared/ReactSymbols.js,来看看源码中的说明:

// The Symbol used to tag the ReactElement-like types.
// --> 标记 React 的元素类型的,并且是 Symbol 形式的。
export const REACT_ELEMENT_TYPE: symbol = Symbol.for('react.element');
export const REACT_PORTAL_TYPE: symbol = Symbol.for('react.portal');
export const REACT_FRAGMENT_TYPE: symbol = Symbol.for('react.fragment');
export const REACT_STRICT_MODE_TYPE: symbol = Symbol.for('react.strict_mode');
export const REACT_PROFILER_TYPE: symbol = Symbol.for('react.profiler');
export const REACT_PROVIDER_TYPE: symbol = Symbol.for('react.provider');
export const REACT_CONTEXT_TYPE: symbol = Symbol.for('react.context');
export const REACT_SERVER_CONTEXT_TYPE: symbol = Symbol.for(
  'react.server_context',
);
export const REACT_FORWARD_REF_TYPE: symbol = Symbol.for('react.forward_ref');
export const REACT_SUSPENSE_TYPE: symbol = Symbol.for('react.suspense');
export const REACT_SUSPENSE_LIST_TYPE: symbol = Symbol.for(
  'react.suspense_list',
);
export const REACT_MEMO_TYPE: symbol = Symbol.for('react.memo');
export const REACT_LAZY_TYPE: symbol = Symbol.for('react.lazy');
export const REACT_SCOPE_TYPE: symbol = Symbol.for('react.scope');
export const REACT_DEBUG_TRACING_MODE_TYPE: symbol = Symbol.for(
  'react.debug_trace_mode',
);
export const REACT_OFFSCREEN_TYPE: symbol = Symbol.for('react.offscreen');
export const REACT_LEGACY_HIDDEN_TYPE: symbol = Symbol.for(
  'react.legacy_hidden',
);
export const REACT_CACHE_TYPE: symbol = Symbol.for('react.cache');
export const REACT_TRACING_MARKER_TYPE: symbol = Symbol.for(
  'react.tracing_marker',
);
export const REACT_SERVER_CONTEXT_DEFAULT_VALUE_NOT_LOADED: symbol = Symbol.for(
  'react.default_value',
);

export const REACT_MEMO_CACHE_SENTINEL: symbol = Symbol.for(
  'react.memo_cache_sentinel',
);

const MAYBE_ITERATOR_SYMBOL = Symbol.iterator;
const FAUX_ITERATOR_SYMBOL = '@@iterator';

export function getIteratorFn(maybeIterable: ?any): ?() => ?Iterator<any> {
  if (maybeIterable === null || typeof maybeIterable !== 'object') {
    return null;
  }
  const maybeIterator =
    (MAYBE_ITERATOR_SYMBOL && maybeIterable[MAYBE_ITERATOR_SYMBOL]) ||
    maybeIterable[FAUX_ITERATOR_SYMBOL];
  if (typeof maybeIterator === 'function') {
    return maybeIterator;
  }
  return null;
}

上面源码并不需要看得太详细,我们只需要知道两点:

  • 标记了多种 React 元素类型
  • 是通过 Symbol 来标记的

知道这两点就够了,因为当使用 JSON.stringify() 时,以 Symbol 值作为键的属性会被完全忽略:

JSON.stringify({[Symbol("foo")]: "foo"}); // '{}'

因此,如果从服务端传递来具有攻击性的 JSON 数据想插入到前端页面,但在 Reconcile 阶段,React 会检查 element.$$typeof 是否合法,如果不合法的话就会直接报错

此外,使用 Symbol.for() 也还有其他好处: 因为 Symbol.for 注册的数据 在 iframeworker 等环境之间是全局共享的,所以可以在不同的应用程序之间传递受信任的元素。同样,如果页面上有多个 React 副本,它们仍然可以“同意”有效的 $$typeof 值。

总结

React 可以通过转义$$typeof 标记 来抵御 XSS 攻击。我们通过转义这一章节能够知道,如果是直接插入 HTML 代码,React 会对其进行转义,无法生成 DOM 元素插入进去。但是,在一些场景下,我们难免会需要直接往页面插入 html 代码,因此,React 为我们提供 dangerouslySetInnerHTML 属性来实现这个功能,但是使用这个功能则可能造成 XSS 漏洞。

React-diff 算法

diff 算法

我们知道 React基于 Vdom 的前端 UI 库,虚拟 DOM 是一个 JS 对象,通过渲染器把虚拟 DOM 渲染到真实 DOM 上。当 DOM 出现更新后,React 会把两棵虚拟 DOM 树进行对比,寻找出变化的部分进行更新,保留没有发生变化的部分,这就需要 diff 算法

从 React 16 开始,出现了 Fiber 架构,这是 React 的一次革命性的变更,我们需要把虚拟 DOM 转化为 Fiber 节点之后再进行 diff 算法寻找节点间的变化。但实际上**是不变的,只是操作的数据类型发生了改变而已。

原理

diff 算法主要是针对同一层级的同类型节点进行对比(对于不同层级的节点,只会提供新增、删除等操作)。当同类型节点们处于同一层级时,diff 提供了三种节点操作:新增删除移动。而 diff 算法优化性能的核心也就是 “ 移动” 。

什么是 “移动” ?举一个极端的例子,当前层级的元素没有更改任何内容,只是改变了顺序:

  <!-- 旧 dom  -->                 <!-- 新 dom  -->
  <ul>                            <ul>
    <li>我是 1 号</li>               <li>我是 3 号</li>
    <li>我是 2 号</li>               <li>我是 1 号</li>
    <li>我是 3 号</li>               <li>我是 2 号</li>
    <li>我是 4 号</li>               <li>我是 5 号</li>
    <li>我是 5 号</li>               <li>我是 4 号</li>
 </ul>                            </ul>

从上面的例子可以看出,<ul 列表中的选项们都没有修改内容,只是换了位置。如果把他们都先删除再重新创建,这很显然会影响性能的,它们只需要简单的移动就可以完成了。因此,diff 算法通过给每个同级元素添加 key 属性用于记录当前节点是否可以复用,然后把可复用的节点移动到新的位置即可,这就是 diff 算法的核心思路

单节点 diff

如果某个列表下只有一个元素,也就是所谓的单节点。那么 diff 算法的判断就很简单:

  • 如果是新增元素,直接新建对应的 fiber 对象。
  • 如果是新旧更新:如果节点的 keytype 都相同,则复用;反之则删除旧节点,新建新节点。
  • 如果是删除元素,直接删除即可。

多节点 diff

如果列表下存在多个节点,diff 算法则需要进行两轮遍历。第一次遍历寻找出公共序列,第二次遍历剩余序列探究是否还有节点可以复用

第一轮遍历

首先存在新旧的虚拟 DOM 树(Fiber),新的为命名为 newFiber,旧的命名为 oldFiber。为了更简单直观,以数组的形式表达这两棵树(实际上 Fiber 也是单链表结构,和数组很相似)。第一轮遍历步骤如下:

  1. i = 0 开始遍历,将 newFiber[0]oldFiber[0] 比较,判断节点是否可以复用。
  2. 如果可以复用,则 i++,继续比较 newFiber[i]oldFiber[i],依次往后对比。
  3. 如果不可以复用,主要分两种情况:
  • key 不同导致不可复用:立即跳出循环,结束第一轮遍历。
  • key 相同 type 不相同导致不可复用:将 oldFiber[i] 标记为删除,并继续遍历。
  1. 如果 newFiber 遍历完或者 oldFiber 遍历完,则第一轮遍历结束。

上面文字描述可能不太直观,我们举个例子描述:

  <!-- 旧 dom  -->                            <!-- 新 dom  -->
    <div key = '1' >我是 1 号</div>               <div key = '1' >我是 1 号</div>
    <div key = '2' >我是 2 号</div>               <div key = '2' >我是 2 号</div>
    <div key = '3' >我是 3 号</div>               <p key = '3' >我是 3 号</p>
    <div key = '4' >我是 4 号</div>               <div key = '5' >我是 5 号</div>
    <div key = '5' >我是 5 号</div>               <div key = '6' >我是 6 号</div>
    <div key = '6' >我是 6 号</div>               <div key = '4' >我是 4 号</div>

从上面例子进行 diff 的第一轮遍历就是:

  1. newFiber[0]oldFiber[0] 对比,key 都等于 1,type 都是 div,可以复用,继续遍历。
  2. newFiber[1]oldFiber[2] 对比,key 都等于 2,type 都是 div,可以复用,继续遍历。
  3. newFiber[2]oldFiber[2] 对比,key 都等于 3,前者的 type 是 div,后者是 p,不可复用,给 oldFiber[2] 打上 “删除” 的标签,继续遍历。
  4. newFiber[3]oldFiber[3] 对比,前者 key = 4,后者 key = 5,不相同,第一轮遍历结束。

根据上面第一轮遍历的结果,我们可以知道 key = 0 、1 的节点都是可以复用的,oldFiber[2] 需要删除,newFiber[2] 需要新增

第二轮遍历

当第一轮遍历完成后,一般会存在四种情况:

  • newFiberoldFiber 同时遍历完。这是最乐观的结果,因为什么都不用处理了。
  • newFiber 遍历完,oldFiber 还有剩余。给 oldFiber 剩余节点都打上 “删除” 标签。
  • newFiber 存在剩余,oldFiber 都遍历完。新增 newFiber 中剩余的节点即可。
  • newFiberoldFiber 都剩余。进入第二轮遍历。

最后一种情况是最常见的,下面也主要讲一讲针对这种情况,第二轮遍历如何开展:

  1. 声明 map 数据结构,把剩余的 oldFiber 节点都存入其中,以 key 为键,以节点为值,进行存储。
  2. 遍历 newFiber 剩余的节点.

在遍历 newFiber 剩余节点时,我们需要以相对位置作为参考依据,即判断节点的相对位置是否改变,如果改变则进行移动。此时需要引入两个记录变量:oldIndexlastPlacedIndex

  • oldIndex 是用来记录当前 newFiber 节点对应的 oldFiber 节点的索引位置。首先都是基于 newFiberoldFiber 剩余序列进行第二轮遍历,如果 newFiber[i] 可复用的节点为 oldFiber[j](key 相同,type 相同),则 oldIndex = j

  • lastPlacedIndex 是用来记录当前最后一个可复用的 oldFiber 节点所对应的索引位置lastPlacedIndex 会在第二轮遍历中,根据 oldIndex 的情况进行改变的,即记录前面匹配上的 oldFiber 节点们的索引最大值。

在第二轮遍历过程中,当 newFiber[i] 找到了可复用的 oldFiber[j] 节点时,oldIndex = j,此时还需要拿 oldIndexlastPlacedIndex 进行对比:

  • 如果 oldIndex < lastPlacedIndex,则说明当前 oldFiber[j] 节点需要向右移动。
  • 如果 oldIndex > lastPlacedIndex,则说明当前 oldFiber[j] 节点与前一个 newFiber 节点匹配上的可复用节点的相对顺序是依次从左往后排序,也说明当前 newFiber 节点和前一个 newFiber 节点的相对顺序也是如此。此后,更新lastPlacedIndex = oldIndex

还是拿上面的例子举例,这样看起来比较形象点。刚刚第一轮遍历完之后,剩余的序列如下:

  <!-- 旧 dom  -->                            <!-- 新 dom  -->
    <div key = '4' >我是 4 号</div>               <div key = '5' >我是 5 号</div>
    <div key = '5' >我是 5 号</div>               <div key = '6' >我是 6 号</div>
    <div key = '6' >我是 6 号</div>               <div key = '4' >我是 4 号</div>

此时构建一个 map(伪代码):

{
    4: <div key = '4' >我是 4 号</div>,
    5: <div key = '5' >我是 5 号</div>,
    6: <div key = '6' >我是 6 号</div> 
}
  1. 此时我们从 i = 0 开始遍历 newFiber 剩余序列,第一个就是 <div key = '5' >我是 5 号</div>
  2. 发现能够在 map 中找到对应的 oldFiber 节点,该节点在 oldFiber 剩余序列中的索引为 oldIndex = 1
  3. 此时 lastPlacedIndex = 0 < oldIndex,说明该节点和上一个节点在新旧数组中的相对顺序是相同的,不需要移动。因此, lastPlacedIndex = oldIndex =1
  4. 遍历下一个 newFiber[1],能够在 map 中找到对应的 oldFiber 节点,该节点在 oldFiber 剩余序列中的索引为 oldIndex = 2
  5. 此时 lastPlacedIndex = 1 < oldIndex,说明该节点和上一个节点在新旧数组中的相对顺序是相同的,不需要移动。因此, lastPlacedIndex = oldIndex =2
  6. 遍历下一个 newFiber[2],能够在 map 中找到对应的 oldFiber 节点,该节点在剩余序列中的索引为 oldIndex = 0
  7. 此时 lastPlacedIndex = 2 > oldIndex,说明**在 oldFiber 中,该节点的位置在此前遍历的节点之前,而在 newFiber 中,该节点在此前遍历的节点之后。因此,需要将该节点右移。
  8. newFiber 遍历完毕,第二轮遍历结束。

以上就是 diff 算法的全部流程。如有错误,欢迎指出~。

参考:

Render阶段中Fiber树的初始化与对比更新~

React-Hooks基础

什么是 Hooks ?

Hooks 的中文解释为 “钩子”,而 React Hooks 则是在 v16.8 版本发布的一种全新的API,可以说是颠覆了 React 一直以来的类组件的写法模式。

类组件

写过 React 的人都知道,我们刚入门所接触的 React 都是从类组件开始,如下:

class HelloWorld extends React.Component {
    render() {
        return <div>Hello World !</div>;
    }
}

从上面看,写组件类的时候好像不需要太多的代码量。但实际上,当我们的组件状态增多,业务逻辑变复杂时,组件类将会变得十分复杂(写项目的时候会有明显感觉)。

组件类便于我们理解组件内部逻辑,管理组件各种状态,但也存在一些缺点。Redux 的作者 Dan Abramov 总结了组件类的几个缺点:

  1. 复杂的组件难以拆分,也不便于测试。
  2. 业务逻辑分散在组建的各个方法中,容易出现重复逻辑和关联逻辑,造成代码冗余。
  3. 组件类引入了复杂的编程模式,比如 render、props 和高阶组件。

函数组件

函数组件顾名思义,就是用函数的形式来编写组件的内部逻辑和功能。但是,我们刚接触面向对象编程的时候就知道,类是对一系列变量和方法的封装,其每个对象实例具有自己的状态属性,而函数只是一个过程,实现某种功能或者返回某个值

React 之前也有函数组件,如下:

function HelloWorld(){
    return <div>Hello World !</div>
}

显然,这种函数组件虽然可以实现某种特定的功能,但是它不包含状态,也不支持生命周期方法,因此无法用于很多情景,更别说取代类了。

Hooks

为了解决函数组件的不足之处,React 提出了 Hooks 的概念,即通过 “钩子” 的形式把函数中需要的状态 ”钩“ 进来。因此,我们利用 React Hooks 可以实现完全不用“类”,写出一个全功能的组件

Hooks 常用钩子

React Hooks 中有几种常用的钩子函数,分别是:

1. useEffect()
2. useState()
3. useRef()
3. useContext()
4. useReducer()

下面,我们将分别介绍这五种常用钩子函数的作用。(至于其他钩子函数,可以自行百度或查看官网文档

useEffect():副作用钩子

useEffect() 顾名思义,“作用”钩子。这也是我们平时开发中最常用的钩子函数,往往需要实现某些“作用”但找不到对应钩子的时候,我们就可以用它。

那么 useEffect() 钩子的具体应用是什么呢? 简单来说,当我们希望组件加载或更新后实现一些其他功能的时候,我们就可以用它。组件每渲染一次,useEffect()函数就会自动触发一次,至于是加载时触发还是更新后触发,要由我们设定的参数决定。

useEffect() 使用方式如下所示:

import React, { useEffect } from 'react';

export default function HelloWorld(props) {
    // 关键代码
    useEffect(() => {
        document.title = 'Hello World !';
    }, []);
    return <h1>{document.title}</h1>;
}

其中,useEffect(() => {}, []) 中的第一个参数是一个函数,即我们所需要实现的一些其他功能;第二个参数是一个 Array,如果为空,则只在组件挂载时触发一下,如果有相关属性值,则每当属性值改变就会触发一次。如下所示:

export default function HelloWorld(props) {
    useEffect(() => {
        document.title = props.title;
    }, [props.title]);
    return <h1>{document.title}</h1>;
}

上面的代码中,我们在第二个参数中设置了 props.title,即只要父组件传递过来的 props.title 发生了变化,这个子组件的 document.title 就会随之改变,重新渲染。因此,如果我们不希望某个 useEffect() 每次渲染时都执行,我们可以在第二个参数 Array 中添加依赖项,只有依赖性发生变化时才会重新渲染

useState():状态钩子

useState() 也可以一眼看出大概是做什么用的,它就是给我们的函数组件添加和修改状态的钩子函数。因为函数组件没有状态,所以把需要的“状态”都存放在钩子里。

我们举一个例子,创建一个 Button 函数组件,并赋予其一个状态,即文本值,当触发点击操作时,文本状态值会改变。代码如下所示:

import React, { useState } from "react";

export default function Button(props)  {
    // 关键代码
    const  [buttonText, setButtonText] =  useState('Hello World !');

    handleClick = (event) => {
        // 修改状态
        return setButtonText('你好,世界!');
    }

    return  <button  onClick={handleClick}>{buttonText}</button>;
}

从上面的示例代码可以看出,useState() 的用法为 const [状态名, set状态名] = useState(初始值)。其中状态名不难理解,就是我们赋予状态的变量名称。

后面的 “set状态名” 实际上就是更新状态的函数的一个习惯性描述名称,你也可以写出其他的名称,比如 changeButtonText 等等。但是为了提高代码的可读性, React 开发者一致约定将函数名写成 set状态名 的形式。当我们需要修改状态时,只需要 set状态名(new state) 即可更新。

useRef():DOM交互钩子

看到 useRef() 我们应该不陌生,因为 React 在类组件中提供 createRef 方法帮助我们获取DOM节点。而在 React Hooks 中则提供了 useRef() 钩子实现相同作用。

这里我们就简单介绍一下 useRef()。众所周知,一个网页中包含了若干DOM节点,当我们需要对某个DOM节点进行交互/操作时,我们可以通过如下方式绑定DOM节点

import React, {useRef} from 'React';

export default function HelloWorld(props) {
    // 关键代码
    const myRef = useRef({});
    return <button ref={myRef}>Click Me !</button>;
}

绑定完成之后,我们就可以通过 myRef.current 来对该DOM节点进行相应的操作

useContext():共享状态钩子

新手入门 React 的时候知道组件间最简单的传递消息的方式是:子组件通过props 的方式获取父组件的信息

然而,当我们需要传递信息的组件跨级过多,容易导致传递信息方式过于繁琐复杂,甚至容易出现很多错误,以及调试困难等问题。因此,React 也为我们提供了 useContext() 钩子函数实现信息共享

原理与 props 大同小异

举个例子,我们希望组件 School 可以共享信息给 StudentTeacher

第一步使用 React Context API,在组件外部建立一个 Context。

export const SchoolContext = React.createContext({});

接着,我们利用 SchoolProvider 来封装组件 StudentTeacher

export default function School(props){
    return (
        // 核心代码
        <SchoolContext.Provider value={{studentName: 'douchen'}}>
            <div className='School'>
                <Student />
                <Teacher />
            </div>
        </SchoolContext.Provider>
    )
}

通过上面的代码,父组件 School 共享了 studentName 信息给 Student 和 Teacher 组件。那么 Student 和 Teacher 组件如何取到这个值呢?

import React, { useContext } from "react";
import { SchoolContext } from './School.js';

export default function Student(props){
    // 核心代码
    const { studentName } = useContext(SchoolContext);

    return (
        <div className="student">
            <p>This student is {studentName}</p>
        </div>
    );

}

由上面代码可知,我们可以import,然后通过 useContext(SchoolContext) 来获取我们需要的值。

!!!注意!!!:到这儿可能就有人要问了,这为啥不直接用 props 呢?显然在这里用不用 props 都差不多。然而,一旦 School 的层级更高,而不只是 StudentTeacher 的父级,可能是更高的祖先级时,利用 props 一级一级传下去就会很复杂。

此外,我们还可以用另外一种眼界来看待 useContext()。如果我们直接把 Provider 包在我们工程的最外层组件上,那就可以把最外层组件内的一些信息当作是全局信息,供所有子组件实用,那是不是很方便

useReducer():action 钩子

React 原本是不提供状态管理器的,更多的还是需要依赖外部库,如 Redux。但是,React Hooks 提供了 useReducer() 钩子函数实现状态管理。

什么是状态管理器呢?可以理解为这个组件它并不负责UI,而是一个逻辑组件,用于管理其他组件间的逻辑关系和状态管理

const [state, dispatch] = useReducer(reducer, initialState);

上面的状态管理器接受 reducer 函数和状态的初始值作为参数,返回一个数组。数组的第一个成员是状态的当前值,第二个成员是发送 action 的dispatch函数

举个例子,我们需要实现一个加减器,点击 + 按钮,计数加一;点击 - 按钮,计数减一。

首先,我们需要定义一个状态管理器 countReducer,分别讨论加减。

const countReducer = (state, action) => {
    switch(action){
        case 'add':
            return{
                ...state,
                count: state.count + 1
            }
        case 'sub':
            return{
                ...state,
                count: state.count - 1
            }
        default:
            return state
    }
}

接着,我们的计数器UI组件代码如下:

function Counter() {
    // 关键代码
    const [state, dispatch] = useReducer(countReducer, { count: 0 };

    return  (
        <div className="App">
            <p>Count: {state.count}</p>
            // 关键代码
            <button onClick={() => dispatch('add')}>+</button>
            <button onClick={() => dispatch('sub')}>-</button>
        </div>
    );
}

由上可知,useReducer() 可以帮助我们创建状态管理器,只负责组件中的状态和各种逻辑的管理

不过,useReducer() 相比于 Redux,没法提供中间件(Middleware)和时间旅行(Time travel),如果你需要这两个功能,还是要用 Redux

(个人还是推荐使用 Redux 来实现状态管理!)

参考资料:

React-HOC 高阶组件

高阶组件

高阶组件(HOC)是 React 中的一种复用组件逻辑的高级技巧。简而言之,高阶组件是以组件为参数,返回值为新组件的函数。

原理

高阶组件就是输入一个组件,返回一个新组件。当我们的应用场景中出现组件间存在相似的逻辑或功能时,我们可以通过高阶组件来增强这类组件,即HOC 中实现我们复用的逻辑包装组件,从而达到我们的需求。

const EnhancedComponent = withSubscription(WrappedComponent);  // withSubscription 为 高阶组件(函数)

为了区分高阶组件和普通组件,约定俗成的,高阶组件的方法一般以 with 命名开头用于区别。

使用

上面已经讲解了 HOC 的功能逻辑,如何创建 HOC 如下:

// HOC
const withSubscription = (WrappedComponent) => {
    return class extends React.Component{
        constructor(props){
            super(props);
            this.state = {
                // ... state 属性
            }
        }
        componentDidMount(){
            // ...
        }
        componentDidUpdate(){
            // ...
        }
        componentWillUnmount(){
            // ...
        }

        render(){
            return(
                // newProps 是其他新增属性
                <WrappedComponent {...this.props} {...newProps} />
            )
        }
    }
}

下面举个例子来详细说明如何使用 HOC

1. 场景:

定义一个简单并且好理解的场景:

  • 有若干个文本,每个文本后都具有一个 button 按钮,点击 button 后,会在数据库中随机选取一个人名,并在对应的文本中显示。

  • 其中,有一个受保护的文本,如果随机到某个指定人名,则会将其转化为 anonymous 达到 “匿名保护“。而另一个文本则不存在 “保护”,即随机到哪个人名就显示哪个。

可能这个例子过于简单,代码量也比较少,所以并不能直观地看出 HOC 能够帮助我们减少代码量和增加便捷。但是这个例子是通俗易懂的。

2. 分析:

  • 首先,我们需要找到组件间重复的功能和逻辑:如每个文本后都需要一个 button,都需要从数据库里随机查找数据。
  • 接着,把上面那些重复的逻辑和功能,在原组件中剔除,写入 HOC 中。
  • 最后,用 HOC 包裹需要这些逻辑和功能的组件即可。

HOC 方法:

export const withSubscription = (WrappedComponent) => {
    return class extends React.Component{
        constructor(props){
            super(props);
            this.users = ['jack', 'mike', 'dou', 'peter', 'lucy']; // 以数组模拟数据库
            this.state = {
                user: 'jack'
            }
        }
        // 随机获取用户
        changeUser = () => {
            const newUser = this.users[Math.floor(Math.random() * this.users.length)];
            console.log(newUser)
            this.setState({
                user: newUser
            })
        }

        render(){
            return(
                <div>
                    <WrappedComponent {...this.props} name={this.state.user} />
                    {/* 每个子组件都具备一个 changeUser 的按钮 */}
                    <button onClick={this.changeUser}>changeName</button>
                </div>
            )
        }
    }
}

具有保护机制的文本组件:

class ProtectUser extends React.Component {
    render() {
        return (
            // 如果 user 是 dou,则改为匿名
            <div>Protect userName: {(this.props.name === 'dou' ? 'anonymous' : this.props.name)}</div>
        )
    }
}
// 使用 HOC 包裹
export default withSubscription(ProtectUser);

不具有保护机制的文本组件:

class UnProtectUser extends React.Component {
    render() {
        return (
        <div>UnProtect userName: {this.props.name}</div>
        )
    }
}

// 使用 HOC 包裹
export default withSubscription(UnProtectUser);

父组件使用子组件(文本组件):

export class Texts extends Component {
  render() {
    return (
      <div>
        <ProtectUser />
        <UnProtectUser />
      </div>
    )
  }
}

结果演示:

  • 初始化:

截屏2023-03-01 10 39 37

  • 受保护文本在随机到 dou 的名字时,会变为 anonymous

截屏2023-03-01 10 40 17

  • 不受保护文本在随机到哪个名字就显示哪个:

截屏2023-03-01 10 40 38

3. 总结:

如果不使用 HOC 来包裹,则需要把 button,随机选名等内容都写到对应的文本组件中,这也就造成了代码的重复。

因此,HOC 高阶组件是一种很好的复用组件的方法。

当然,HOC 的存在是为了把一些重复的逻辑和功能剥离出来从而提供给需要的组件使用。但是,我们不应该在 HOC 中修改传入的组件,这样会操作一些意想不到的问题。

还是那句话,在 React 中,我们支持使用 组合 的方式来实现组件及组件间的联系。

React.cloneElement 用法

React.cloneElement

1. 场景

当我们需要把父组件 Parent 的属性或方法传递给若干子组件 Child 们使用时,我们通常的做法是在父组件的 render 中添加:

return(
    <>
        <Child id={1}  num1={this.state.num1}  num2={this.state.num2}  handle={this.handle} />
        <Child id={2}  num1={this.state.num1}  num2={this.state.num2}  handle={this.handle} />
        <Child id={3}  num1={this.state.num1}  num2={this.state.num2}  handle={this.handle} />
    </>
)

其中,this.state.num1this.state.num2this.handle 就是父组件属性,提供给子组件使用。在父组件 Parent 外层的组件中调用 <Parent /> 即可。

上面代码看着显然是有些冗余的,对此可以修改的一个方法就是:

return(
    // [1, 2, 3,...., n] 表示子组件的编号
    {[1, 2, 3].map(item => <Child id={item}  num1={this.state.num1}  num2={this.state.num2}  handle={this.handle} />)}
)

或者我们可以使用自定义 hooks 的方法来把共用的属性或方法封装起来,接着传递给子组件使用,这样更加方便。

此外,我们也可以使用共享属性和方法,使用 context 来实现,在 Parent 中添加 Provider,在 Child 中添加 Consumer

综上所述,实现以上的场景需求,有很多种方法,包括:直接传递自定义 hooks 封装context 共享等方法。

下面介绍一个通过 cloneElement 实现的方案。

cloneElement 简介

首先,React.cloneElement 接收三个参数:

cloneElement(element, props, ...children)

  • element: react 元素
  • props: 新增 props 属性
  • ...children: children 元素

其中,element 元素为我们需要操作的目标元素,我们操作传入的 element 元素,该元素会被添加 props 属性,以及修改其 children 内容。如果输入第三个参数,则目标元素 element 的子元素就会被输入的第三个参数所替代,反之,不变。

举个例子:

import { cloneElement } from 'react';

// ...
const clonedElement = cloneElement(
  <Row title={'Cabbage'}>
    Hello
  </Row>,
  { isHighlighted: true },
  'Goodbye'
);

console.log(clonedElement); // <Row title={'Cabbage'} isHighlighted={true}>Goodbye</Row>

从上面的例子可以看到:Row 组件被添加了 isHighlighted 属性,其子组件(原本是 Hello 文本元素)被修改为了 Goodbye 文本元素。

cloneElement 应用

现在回到我们一开始提到的场景,如何使用 cloneElement 方法来实现共享父组件的属性和方法呢?看代码:

// Parent 和 Child 组件
import React from 'react';
import { useState } from 'react';

// 父组件 Parent
export function Parent(props) {
  const { children } = props;
  const [count, setCount] = useState(1);

  const handleClick = () => {
    setCount(prev => prev + 1);
    console.log(count);
  }

  // 使用 React.Children.map 方法遍历 Parent 的 children,并使用 cloneElement 方法添加 props 参数
  const childrenWithProps = React.Children.map(children, child => React.cloneElement(child,
    {
      parentState: count,
      handleClick: handleClick
    }
  ));

  return (
    <div style={{ border: "1px solid blue" }}>
      <span>父容器:</span>
      {childrenWithProps}
    </div>
  )
}

// 子组件 Child
export function Child(props) {
  // 获取 props 属性
  const { id, parentState, handleClick } = props;
  // 设置样式和属性
  return (
    <div style={{ margin: "15px", border: "1px solid red" }}>
      子元素{id}
      <br />
      父组件属性count值: {parentState}
      <br />
      <span onClick={handleClick}
        style={{ display: "inline-block", padding: "3px 5px", color: "#ffffff", background: "green", borderRadius: "3px", cursor: "pointer" }}
      >click me</span>
    </div>
  )
}
// App 主组件
function App(props) {
  
  return (
    <Parent>
      <Child id={1} />
      <Child id={2} />
    </Parent>
  )
}

上述代码的效果如下:

截屏2023-05-31 16 57 30

当我们点击子元素 1 或子元素 2 的 click 按钮时,parent 的 count 属性值会增加。因此,子元素 1 和 2 的父元素 count 值都会 + 1:

比如点击 子元素 1 的 click:

截屏2023-05-31 16 59 11

因此,cloneElement 通过修改目标元素,并返回一个添加了新属性或修改了子元素的新元素供我们使用。

参考

React 官方文档 - cloneElement

React基础-元素渲染

原理:

众所周知,React 元素描述的是虚拟 DOM ,这也帮助它能够实现 DOM 节点的局部更新。简而言之就是,当有数据更新需要重新渲染页面时,React 为了避免整个页面所有 DOM 节点的更新(开销很大),虚拟 DOM 将找出需要更新的 DOM 进行重新渲染,而其他不需要更新的 DOM 节点则不需要动。也就是说只更新需要更新的地方

引用官方文档里的一句来说就是:与浏览器的 DOM 元素不同,React 元素是创建开销极小的普通对象。React DOM 会负责更新 DOM 来与 React 元素保持一致。

元素 是构成 React 的最小单位。因此,这里也以最简单的渲染过程作为示例,帮助理解 React 元素渲染原理。

将一个元素渲染为 DOM

假设页面中有一个 div 节点:

<div id='root'></div>

如果我们使用 React DOM 管理该 div 节点内部所有内容,那么需要把这个节点(称之为根节点)传入 ReactDOM.createRoot() 中,再把需要渲染的元素放入 render() 方法中即可。

const root = ReactDOM.createRoot(document.getElementById('root'));
const element = <h1>Hello, world</h1>;
root.render(element);

上面的代码将渲染出:Hello World

更新渲染

如果我们在上述的根节点 div 内部加入一个标签,其内容为当前的时间,随着现实世界的时间变动,其内容也随之变动。那么这就涉及到更新已渲染元素的内容。

我们知道 React 元素是不可变的。一旦被创建,你就无法更改它的子元素或者属性。用官方文档里的话来说就是:一个元素就像电影的单帧,它代表了某个特定时刻的 UI

因此,更新 UI 唯一的方式是:创建一个全新的元素,并将其传入 root.render()实现更新。举个例子:

// “根”节点
const root = ReactDOM.createRoot(
  document.getElementById('root')
);
// 更新渲染函数
function tick() {
  const element = (
    <div>
      <h1>Hello, world!</h1>
      <h2>It is {new Date().toLocaleTimeString()}.</h2>
    </div>
  );
  root.render(element);
}
// 定时器
setInterval(tick, 1000);

基于上述代码,可以实现本节刚开始提到的需求。

如果我们打开调试页面,监控一下 DOM 节点更新情况,我们就会发现:<h1> 标签并没有更新,而 <h2> 标签随时间改变而更新。这也就印证了本文最开始提到的 React 虚拟 DOM 的作用:它会将元素和它的子元素与它们之前的状态进行比较,并只会进行必要的更新来使 DOM 达到预期的状态

setState 更新问题

前言

setStateReact 里触发组件更新的方法之一,也频频会出现在我们的日常开发中。下面主要针对一些 setState 更新的问题谈一谈,核心内容包括两大方面:

  • 使用对象 or 函数来设置更新的值?
  • 批量更新 or 立即更新?

对象 or 函数

我们知道 setState 的方法结构是下面这样的:

setState(stateChange[, callback])

setState((state, props) => stateChange[, callback])

前者就是直接通过设置对象值来实现 stateChange,后者是通过函数返回来实现 stateChange。可能这么说并不够直观,举个形象点的例子,假设我们的 state 中有一个 count: 0 的属性。

// 直接设置对象
this.setState({
    count: this.state.count + 1
})

// 通过函数返回
this.setState((state) => ({
    count: state.count + 1
}))

这两种方法是都可以满足我们 + 1 的需求,在这种简单场景下也是不存在什么问题的。但是如果复杂一点,我们同时调用多次 setState,那么会出现什么情况呢?

// count 初始化为 0

componentDidMount(){    
    this.setState({
        count: this.state.count + 1
    })
    console.log('state1:', this.state.count);
    this.setState({
        count: this.state.count + 1
    })
    console.log('state2:', this.state.count);
    this.setState({
        count: this.state.count + 1
    })
    console.log('state3:', this.state.count);
}

render(){
    // 触发重新渲染就会输出一次
    console.log('render', this.state.count);
    return (
        <div>{组件}</div>
    )
}

上面的代码执行结果会是什么样呢?首先我们知道在 React 的生命周期和合成事件中,setSate批量更新的。也就意味着这三个 setState 会合并成一个更新,那么也就是说 render 部分的 console.log 只会打印一次,那对应的 this.state.count 输出又是多少呢?

截屏2023-03-19 17 22 46

结果是:1。那就有点奇怪了呀,我 + 1 三次,我可以认同你把三次 + 1 合在一起计算,但是为什么最后结果是只加了一个 1 呢?那我们再看看用函数返回的方法是什么样的结果。

// 用函数返回的方式实 stateChange
componentDidMount(){    
    this.setState(state => ({
        count: state.count + 1
    }))
    console.log('state1:', this.state.count);
    this.setState(state => ({
        count: state.count + 1
    }))
    console.log('state2:', this.state.count);
    this.setState(state => ({
        count: state.count + 1
    }))
    console.log('state3:', this.state.count);
}

render(){
    // 触发重新渲染就会输出一次
    console.log('render', this.state.count);
    return (
        <div>{组件}</div>
    )
}

执行结果为:
截屏2023-03-19 17 27 01
我们会发现使用函数回调的方法 + 1 三次,虽然也经过了批量更新,但是最后 render 出来的结果是 3。

那么为什么会出现这个问题呢?

首先,我们知道在生命周期方法中, React 会把 setState 进行批量更新,如果我们多次对某个 state 属性值进行操作,就会采用对该属性值操作的最后一次结果作为更新结果

由于批量更新了,所以这三次更新会合并为一次,而我们三次计算 this.state.count + 1 中的 this.state.count 是还没更新前的结果,也就是为 0,因此三次的操作实际上都可以转换为:

this.setState({
    count: 0 + 1
})
this.setState({
    count: 0 + 1
})
this.setState({
    count: 0 + 1
})

那么取最后一次对 count 操作的结果,也就是变为 1 嘛,所以最后 render 的结果也就是 1。

那么这个机制为什么在函数返回的方法上没起作用呢?

实际上起作用了,最主要的原因还是在于函数返回的方法传入的第一个参数是前一次 state,那么我们既然本次修改 state 需要上一次的 state,也就意味着必须先计算出上一次修改后的结果,然后再计算本次结果。所以,三次操作可以转换为:

this.setState({
    count: 0 + 1
})
this.setState({
    count: 1 + 1
})
this.setState({
    count: 2 + 1
})

我们再取最后一次对 count 操作的结果,也就是 3 嘛,所以最后 render 的结果也就是 3。

所以上述这个问题,我觉得最重要的点并不是批量更新,而是:本次操作使用的 state 值到底是哪一个


那换做是函数式组件呢?结果也都是一样的。简单举个例子:

// 直接设置
useEffect(() => {
    setCount(count + 1);
    console.log('state1:', count);
    setCount(count + 1);
    console.log('state2:', count);
    setCount(count + 1);
    console.log('state3:', count);
}, [])

// 触发重新渲染就会输出一次
console.log('render:', count);

截屏2023-03-19 17 50 29

// 使用函数返回
useEffect(() => {
    setCount(count => count + 1);
    console.log('state1:', count);
    setCount(count => count + 1);
    console.log('state2:', count);
    setCount(count => count + 1);
    console.log('state3:', count);
}, [])

// 触发重新渲染就会输出一次
console.log('render:', count); 

截屏2023-03-19 17 52 17

从上面的例子可以看出,使用 hooks 的方法来设置,和类是一样的。核心问题还是在:基于哪个值进行计算。所以在批量更新的基础上,我们一定要弄清楚到底是基于什么时候的 state 来计算的。

批量更新 or 立即更新

在我先前写过的一个 issue 中,提到了 setState 批量更新和立即更新的概念以及判断方法 (#19 )。这里不详细解释什么时候是批量更新,什么时候是立即更新了,有兴趣可以去看一下我的 issue,也可以去看一看源码。简单总结一下:

  • 在 React 可控的范围内,如合成事件、生命周期函数(钩子)中使用 setState,会执行批量更新。
  • 在 React 不可控的范围内,如原生事件(通过 addEventListener 绑定的)、setTimeoutsetIntervalPromise.then() 等方法中使用 setState,会立即更新。

举点例子吧:

componentDidMount(){    
    // 使用 setTimeout 包裹
    setTimeout(() => {
        this.setState({
            count: this.state.count + 1,
        })
        console.log('state1:', this.state.count);
        this.setState({
            count: this.state.count + 1
        })
        console.log('state2:', this.state.count);
        this.setState({
            count: this.state.count + 1
        })
        console.log('state3:', this.state.count);
    }, 0)
}

render(){
    // 触发重新渲染就会输出一次
    console.log('render:', this.state.count);
    return (
        // ...
    )
}

上面代码的执行结果如下:
截屏2023-03-19 18 03 46

我们可以从结果中看出,输出变成了 1 2 3。可能有一点让人感觉奇怪,这里面明明也是像上次一样调用的是 this.state.count + 1,而不是函数返回,为什么就可以正常输出 1 2 3 了呢。

原因在于:由于使用了 setTimeout 变为立即更新,每次执行 setState 都会触发一次更新。我们可以从输出的结果看到,先输出 render: xx 再输出 state: xx。所以,每次使用的 this.state.count 都是更新过后的 state 中的值。

整个更新流程可以简单描述为:

setState 更新 -> render -> 基于上一次 state 进行 setState 更新 -> render -> ...

那如果使用函数返回的方式呢?结果一样,毕竟函数返回的方法都是会拿到上一次修改的 state 来计算,这里就不举例子了。


如果使用 hooks 还和类组件一样吗?

// hooks 形式
useEffect(() => {
    setTimeout(() => {
        setCount(count + 1);
        console.log('state1:', count);
        setCount(count + 1);
        console.log('state2:', count);
        setCount(count + 1);
        console.log('state3:', count);
    }, 0)
}, [])

// 触发重新渲染就会输出一次
console.log('render:', count);

输出结果如下:

截屏2023-03-19 18 31 41

我们会惊奇地发现两个问题:

  • state123 后面输出的 count 一直都是 0。
  • render 莫名地少了一个。

对于第一个问题,其实很简单。因为我们的函数组件中创建 state 是通过 useState 来创建的,并且函数组件没有实例。我们每次触发函数组件更新,实际上操作流程是:把函数可执行的部分(比如一些副作用不会执行)从头到尾执行一遍。区别无非是每次的 state 值拿到的是最新的。但是,我们执行 setTimeout 使用的 count 值是触发 setTimeout 当时环境里的 count,可能因为我的语言功底导致这句话理解起来有点绕,用专业一点的术语来描述就是:形成闭包

所以我们输出的其实一直都是最开始环境里的 count 值, setTimeout 异步代码内部打印的 count 也就都是 0。这和类组件是有区别的

对于第二个问题,其实我也不确定具体原因。不过,我猜这和函数式组件的 setState 机制有关。因为我们知道函数式组件中 setState 内的值如果没有修改,是不会触发更新的;这和类组件的 this.setState 用一次就触发一次更新是不一样的(批量更新只是把多次更新合并,不代表用了 this.setState 不会更新),所以这里第三次没有触发重新渲染我猜也是这个原因,用于提高性能。

因此,我猜测当发现连续立即触发 setState两次,但值没有改变时,会合并后面的更新。为此,做一个小测试,如果我们把第三个 count + 1 改为 count + 2

useEffect(() => {
    setTimeout(() => {
        setCount(count + 1);
        console.log('state1:', count);
        setCount(count + 1);
        console.log('state2:', count);
        setCount(count + 2); // 修改增值,让 setState 中的值和上一次不一样
        console.log('state3:', count);
    }, 0)
}, [])

// 触发重新渲染就会输出一次
console.log('render:', count); 

截屏2023-03-19 18 39 17

首先所有的 count 依旧是刚开始执行 setTimeout 那个闭包里的 count 值,因此才会有 0 + 10 + 10 + 2 的结果。上面的结果似乎符合我的猜测。如果后面再加两个 setState(count + 2) 会发现又少了一次更新...可以试试看。

所以暂且认为是我猜测的这个原因吧...之后我也会继续研究。


上面使用 count + 1 直接设置的方法发现每次 render 后的值都是1,这是因为每次使用 count 都是最开始的那个值为 0 的。那我们改写一下,用函数返回的方法试一试:

// hooks 形式:使用函数返回
useEffect(() => {
    setTimeout(() => {
        setCount(count => count + 1);
        console.log('state1:', count);
        setCount(count => count + 1);
        console.log('state2:', count);
        setCount(count => count + 1);
        console.log('state3:', count);
    }, 0)
}, [])

// 触发重新渲染就会输出一次
console.log('render:', count); 

截屏2023-03-19 18 15 07

首先不用管 state123: 0 的问题,因为我们上面说了,打印的 count 都用的是执行 setTimeout 那个闭包里的值。但是不同点在于 打印 render: 后的值变为了 1、2、3。这个原因就不用多说了,因为依赖的值都是上一次修改后的 count,也是在这个基础上每次 + 1,而不是执行 setTimeout 那个闭包里的 count 值。

闭包引发的一些更新问题

首先举一个例子:

useEffect(() => {
// 定时器,每一秒 count + 1,触发一次渲染
    setInterval(() => {
        set(count + 1)
    }, 1000);
}, [])
// 触发重新渲染就会输出一次
console.log('render:', count);

上面代码输出的结果会是什么样的?看图:

截屏2023-03-19 19 37 44

render 后面输出的值一直都 1,并且只输出了两次。首先不用管输出了几次,这个可能和我上面提到的优化性能有关或者浏览器的原因。问题在于:为什么每次更新输出都是 1

实际上这个问题也很简单,也就是我上面提到的闭包问题。因为这个定时器是在 count = 0 的那个闭包环境下,所以定时器其实每次都在执行 0 + 1,所以就每次都会渲染为 1。

类似的,如果我们给一个按钮点击事件中加入 setTimeout,延迟设置为 3 秒,我们在 3 秒内疯狂点击 5 次,结果又是怎么样呢?

// 点击事件(React 合成事件)
const click = () => { // 我们在 3 秒内疯狂点击 5 次
    setTimeout(() => {
        setCount(count + 1);
    }, 3000)
}

结果如下:
截屏2023-03-19 19 41 56

首先还是不用管为什么只输出两次,理论上应该是输出五次,但是存在某种优化,所以问题下面不再重复。

我们会发现点击 5 次,输出的都是 render: 1,这是为什么呢?实际上也和上面的原因类似,**因为 setTimeout 点击需要 3 秒才能触发,在这 3 秒内狂点,无非是创建了多个 setTimeout,但是这些计时器中 count 的值还是在触发计时器时那个环境里的 count 值,也就是 0。所以会输出触发多次渲染但都是 1。


那么如何解决这些问题呢?

我们可以使用函数返回的方式来 setState,从而建立依赖,取到上一次修改的 countsetInterval 例子的更改方案如下:

useEffect(() => {
    // 利用函数返回
    setInterval(() => {
        setCount(count => count + 1);
    }, 1000);
}, [])

// 触发重新渲染就会输出一次
console.log('render:', count);

截屏2023-03-20 09 47 21

从上面结果可以看出,我们每秒输出的 count 值终于满足我们需要的 1、2、3、4...。因为函数返回的方式每次传入的参数是我们上一次修改的 count 状态值,因此每次计算都会基于上一次的值进行计算,而不是最开始那个闭包里的值。

同样的,如果在 setTimeout 中设置 3 秒延迟,并且以函数返回的形式来修改状态,是不是也可以解决我们上面狂点但是 count 只加 1 的问题?我们试试:

// 点击事件(React 合成事件)
const async = () => { // 在 3 秒内狂点 5 次 
    setTimeout(() => {
        setCount(count => count + 1);
    }, 3000)
}

截屏2023-03-20 09 53 57

从上面的结果可以看出,我们在 3 秒内狂点 5 次,结果是 1、2、3、4、5,满足我们的要求!

总结

setState 的更新涉及到我上面提到的两大类问题:

  • 直接设置值 or 使用函数返回
  • 批量更新 or 立即更新

但是,在 React Hooks 中,我们还可能会被闭包所影响,关键在于:使用的值是哪个闭包里的值

当能够把上述问题都给搞清楚的时候,使用 setState 就可以避免大多数问题了。

从源码告诉你 React 18 的各种优先级

从源码告诉你 React 18 的各种优先级

在介绍 React Scheduler 的 workloop 过程中(链接),我提到了在 React 的 concurrent 模式下,每一个更新任务都会有对应的优先级,从而构成最小堆再进行调度。

那么,React 中的 Scheduler 调度的优先级是什么呢?是我们常说的 Lane,还是另有其他?

凡是了解 React 源码的都知道,Concurrent 并发模式下, React 内部存在三种优先级机制:

  • Scheduler 优先级
  • Event 优先级
  • Lane 优先级

下面本文将介绍 React 中的优先级,和优先级之间的一个转化。

1、Lane

基于 16 位二进制编码的优先级,通过位运算可以更加快速。Lane 作用于 update 对象,一共有 31 种优先级,是最原始且最精细的优先级划分。具体如下:

源码地址:https://github.com/facebook/react/blob/v18.1.0/packages/react-reconciler/src/ReactFiberLane.new.js

https://github.com/facebook/react/blob/v18.1.0/packages/react-reconciler/src/ReactFiberLane.new.js

// 没有优先级
export const NoLanes: Lanes = /*                        */ 0b0000000000000000000000000000000;
export const NoLane: Lane = /*                          */ 0b0000000000000000000000000000000;

// 同步优先级(最高),一些离散事件,如点击、按键等
export const SyncLane: Lane = /*                        */ 0b0000000000000000000000000000001;

// 连续事件优先级,比如滚动、拖拽等
export const InputContinuousHydrationLane: Lane = /*    */ 0b0000000000000000000000000000010;
export const InputContinuousLane: Lane = /*             */ 0b0000000000000000000000000000100;

// 默认优先级
export const DefaultHydrationLane: Lane = /*            */ 0b0000000000000000000000000001000;
export const DefaultLane: Lane = /*                     */ 0b0000000000000000000000000010000;

// 可过渡更新的优先级
const TransitionHydrationLane: Lane = /*                */ 0b0000000000000000000000000100000;
const TransitionLanes: Lanes = /*                       */ 0b0000000001111111111111111000000;
const TransitionLane1: Lane = /*                        */ 0b0000000000000000000000001000000;
const TransitionLane2: Lane = /*                        */ 0b0000000000000000000000010000000;
const TransitionLane3: Lane = /*                        */ 0b0000000000000000000000100000000;
const TransitionLane4: Lane = /*                        */ 0b0000000000000000000001000000000;
const TransitionLane5: Lane = /*                        */ 0b0000000000000000000010000000000;
const TransitionLane6: Lane = /*                        */ 0b0000000000000000000100000000000;
const TransitionLane7: Lane = /*                        */ 0b0000000000000000001000000000000;
const TransitionLane8: Lane = /*                        */ 0b0000000000000000010000000000000;
const TransitionLane9: Lane = /*                        */ 0b0000000000000000100000000000000;
const TransitionLane10: Lane = /*                       */ 0b0000000000000001000000000000000;
const TransitionLane11: Lane = /*                       */ 0b0000000000000010000000000000000;
const TransitionLane12: Lane = /*                       */ 0b0000000000000100000000000000000;
const TransitionLane13: Lane = /*                       */ 0b0000000000001000000000000000000;
const TransitionLane14: Lane = /*                       */ 0b0000000000010000000000000000000;
const TransitionLane15: Lane = /*                       */ 0b0000000000100000000000000000000;
const TransitionLane16: Lane = /*                       */ 0b0000000001000000000000000000000;

// 更新失败重试的优先级
const RetryLanes: Lanes = /*                            */ 0b0000111110000000000000000000000;
const RetryLane1: Lane = /*                             */ 0b0000000010000000000000000000000;
const RetryLane2: Lane = /*                             */ 0b0000000100000000000000000000000;
const RetryLane3: Lane = /*                             */ 0b0000001000000000000000000000000;
const RetryLane4: Lane = /*                             */ 0b0000010000000000000000000000000;
const RetryLane5: Lane = /*                             */ 0b0000100000000000000000000000000;

export const SomeRetryLane: Lane = RetryLane1;


export const SelectiveHydrationLane: Lane = /*          */ 0b0001000000000000000000000000000;

// 不能idle的优先级集合
const NonIdleLanes: Lanes = /*                          */ 0b0001111111111111111111111111111;

// idle 优先级
export const IdleHydrationLane: Lane = /*               */ 0b0010000000000000000000000000000;
export const IdleLane: Lane = /*                        */ 0b0100000000000000000000000000000;

// 最低优先级,offScreen 离屏
export const OffscreenLane: Lane = /*                   */ 0b1000000000000000000000000000000;

2、Event 优先级

源码(事件分类):https://github.com/facebook/react/blob/v18.1.0/packages/react-dom/src/events/ReactDOMEventListener.js
源码(Lane 和 Event 优先级转化):https://github.com/facebook/react/blob/v18.1.0/packages/react-reconciler/src/ReactEventPriorities.new.js

React 在得到 Lane 优先级之后,会转换为 Event 优先级,即事件优先级,包括四种事件优先级:

// https://github.com/facebook/react/blob/v18.1.0/packages/react-reconciler/src/ReactEventPriorities.new.js

// Event 优先级分为四种,分别是:
// DiscreteEventPriority、ContinuousEventPriority、DefaultEventPriority、IdleEventPriority
// 四个事件优先级会被转换为一些特殊的 Lane 值:SyncLane、InputContinuousLane、DefaultLane、IdleLane

export const DiscreteEventPriority: EventPriority = SyncLane;      // 离散事件优先级,如 click、input、keydown 等
export const ContinuousEventPriority: EventPriority = InputContinuousLane; // 连续事件的优先级,如 scroll、drag 等
export const DefaultEventPriority: EventPriority = DefaultLane; // 默认优先级
export const IdleEventPriority: EventPriority = IdleLane; // 低优先级

React 也在 https://github.com/facebook/react/blob/v18.1.0/packages/react-dom/src/events/ReactDOMEventListener.js 文件中介绍了 DiscreteEventPriority、ContinuousEventPriority、DefaultEventPriority、IdleEventPriority 分别对应哪些事件

2.1、Lane 优先级转为 Event 优先级

update 对象的 Lane 优先级会根据被所在范围,转换为四种事件优先级之一,判断代码如下:

// https://github.com/facebook/react/blob/v18.1.0/packages/react-reconciler/src/ReactEventPriorities.new.js

// 一些优先级比较和输出方法
export function higherEventPriority(
  a: EventPriority,
  b: EventPriority,
): EventPriority {
  return a !== 0 && a < b ? a : b;
}

export function lowerEventPriority(
  a: EventPriority,
  b: EventPriority,
): EventPriority {
  return a === 0 || a > b ? a : b;
}

export function isHigherEventPriority(
  a: EventPriority,
  b: EventPriority,
): boolean {
  return a !== 0 && a < b;
}

// 把 lane 转为事件优先级,主要通过和几个特殊的 Lane (SyncLane,InputContinuousLane)进行大小比较
export function lanesToEventPriority(lanes: Lanes): EventPriority {
  // 获取最高优先级的 lane, 即 lanes 最右边
  const lane = getHighestPriorityLane(lanes);
  // 离散事件优先级(同步任务中,全都是 SyncLane,所以都是离散事件,只有并发模式才会使用其他的优先级)
  if (!isHigherEventPriority(DiscreteEventPriority, lane)) {
    return DiscreteEventPriority;
  }
  // 连续事件优先级(在并发模式下才会出现,
  if (!isHigherEventPriority(ContinuousEventPriority, lane)) {
    return ContinuousEventPriority;
  }
  // 如果不包含 idleWork,就是默认优先级(并发模式)
  if (includesNonIdleWork(lane)) {
    return DefaultEventPriority;
  }
  // 基于上面判断都没返回,则认为是最低的优先级(并发模式)
  return IdleEventPriority;
}

3、Scheduler 优先级

源码(优先级分类):https://github.com/facebook/react/blob/v18.1.0/packages/scheduler/src/SchedulerPriorities.js

得到 Event 优先级之后,会转换为 Scheduler 优先级,从而由 Scheduler 进行调度。Scheduler 优先级主要分为五种:

// https://github.com/facebook/react/blob/v18.1.0/packages/scheduler/src/SchedulerPriorities.js

// Scheduler 的优先级严格来说分为六种,但也会认为是五种,因为 NoPriority 表示没有优先级。
// ImmediatePriority、UserBlockingPriority、NormalPriority、LowPriority、IdlePriority 五种核心优先级
// 值越小,优先级越高

export type PriorityLevel = 0 | 1 | 2 | 3 | 4 | 5;

// TODO: Use symbols?
export const NoPriority = 0;           // 没有优先级
export const ImmediatePriority = 1;    // 立即执行的优先级,级别最高
export const UserBlockingPriority = 2; // 用户阻塞级别的优先级
export const NormalPriority = 3;       // 正常优先级
export const LowPriority = 4;          // 较低优先级
export const IdlePriority = 5;         // 最低优先级,任务可闲置

3.1、Event 优先级转为 Scheduler 优先级

那么 Event 优先级是如何转换为 Scheduler 优先级的呢?如下:

// https://github.com/facebook/react/blob/v18.1.0/packages/react-reconciler/src/ReactFiberWorkLoop.new.js
// function ensureRootIsScheduled(root: FiberRoot, currentTime: number) 函数下


let schedulerPriorityLevel;
// Event 优先级转为 Scheduler 优先级,会有一个一一对应的关系。
switch (lanesToEventPriority(nextLanes)) {
    case DiscreteEventPriority:
        schedulerPriorityLevel = ImmediateSchedulerPriority;
        break;
    case ContinuousEventPriority:
        schedulerPriorityLevel = UserBlockingSchedulerPriority;
        break;
    case DefaultEventPriority:
        schedulerPriorityLevel = NormalSchedulerPriority;
        break;
    case IdleEventPriority:
        schedulerPriorityLevel = IdleSchedulerPriority;
        break;
    default:
        schedulerPriorityLevel = NormalSchedulerPriority;
        break;
}

4、总结

从上面可知,React 18 的 Concurrent 模式下具备三种优先级,当我们产生 update 更新对象后,会经过以下几个流程:

  1. 给 update 对象设置 Lane 优先级
  2. 将 Lane 优先级转为 Event 优先级
  3. 将 Event 优先级转为 Scheduler 优先级
  4. Scheduler 根据优先级进行调度

5、参考

react 官方文档

React基础-生命周期那些事儿

✅ 生命周期那些事儿

其实很早就应该写这篇笔记了, 但是因为我过于 繁 (lan) 忙 (duo) , 直到现在才想起来写...

咱们可以先看下面的 React生命周期图 👇

React生命周期图

React的生命周期从广义上来看, 主要分为三大阶段, 即 挂载、渲染、卸载. 这里我们把React生命周期划分为两大类: 挂载卸载过程更新过程.


  • 挂载卸载过程

挂载时, 会先触发 componentWillMount 函数, 接着 render 渲染, 最后 componentDidMount 即渲染后执行的内容.

卸载时, 会触发 componentWillUnmount 函数, 以执行卸载组件前需要执行的内容, 如 删除计时器删除监听函数 等等.

  • 更新过程

React更新主要由两个原因触发: propsstate 改变.

shouldComponentUpdate 会先判断是否要触发更新. 如果是, 在 Update前会触发 componentWillUpdate 函数, 接着进行 render 渲染, 最后执行渲染后的内容 componentDidUpdate.


咱们下面可以看代码理解 👇:

class Author extends React.Component{
    // 生命周期 - 初始化
    constructor(props) {
        console.log("初始化阶段");
        // 初始化props
        super(props);
        // 初始化状态
        this.state = {
            name: 'douchen',
            age: 23
        }
    }
    // 生命周期 - 组件加载前
    componentWillMount() {
        console.log('组件加载前')
    }

    // 生命周期 - 组件加载后
    componentDidMount() {
        console.log('组件加载后')
    }

    // 调用方法
    updateUserInfo = () => {
        // 通过setState 更新state
        this.setState({
            name: 'superman',
            age: 24
        })
    }

    // 生命周期 - 数据是否更新
    shouldComponentUpdate() {
        console.log("数据是否需要更新")
        // 是否更新组件
        return true;
    }

    // 生命周期 - 数据将要更新
    componentWillUpdate() {
        console.log("数据将要更新")
    }

    // 生命周期 - 数据已经更新
    componentDidUpdate() {
        console.log("数据已经更新")
    }

    render() {
        console.log("组件加载或者数据更新")
        return (
            <div>
                <h1>my name is {this.state.name}</h1>
                <p>age: {this.state.age}</p>
                <button onClick={this.updateUserInfo}>更新</button>
            </div>
        )
    }
}

输出结果:

  • 初始化阶段
  • 组件加载前
  • 组件加载或者数据更新
  • 组件加载后
  • ---此时点击按钮---
  • 数据是否需要更新
  • 数据将要更新
  • 组件加载或者数据更新
  • 数据已经更新

参考文章:
🔗 React的生命周期
🔗 React生命周期

谈谈对 React 的理解

谈谈对 React 的理解

这篇文章并不是什么技术文,更多地偏向于一篇总结文。之所以这篇短文,是受面试官们的影响。我发现,或许 1 、2 面时面试官会停留于相关技术点、甚至技术细节的层面进行提问;然而,到了 3 面或者部分 2 面时,也就是所谓的 “主管面” 、“老板面” 时,被问的问题则更加深刻,更加宏观。

因此,受到面试官的影响,我觉得使用了这么久的 React,自己也确实应该总结一下对 React 的理解和认识。

我认为与其介绍 React,不如从 React 给我们带来了什么的角度进行讲解,想学习 React 及其相关细节,看官方文档来得更直接。

React 给我们带来了什么?

当我们不使用 React 构建应用程序,而是直接使用原生 JavaScript 构建应用程序时,我们需要负责管理应用程序的状态和界面,这可能会导致代码变得冗长和难以维护。而 React 通过引入组件化开发模式和声明式编程,解决了这些问题。此外,通过对使用过程的总结,我觉得 React 有以下的优势:

  1. 组件化开发模式:React将应用程序划分为多个小组件,每个组件都有自己的状态和行为(这并不局限于 React,Vue 也是如此)。这种组件化开发模式使得代码更易于维护、测试和重用。例如,一个购物车应用程序可以划分为多个小组件,如购物车图标组件、购物车列表组件、商品数量选择器组件等。

  2. 声明式编程:开发者只需关注界面应该呈现的状态,而不用关心如何操作 DOM。React 通过使用虚拟 DOM 来管理 DOM,只有在必要的情况下才更新 DOM,从而提高了性能。例如,在一个 Todo 应用程序中,开发者只需关注每个任务的完成状态,而不用关心如何更新 DOM。

  3. 高效性能:React采用虚拟 DOM,只有在必要的情况下才更新 DOM,从而提高了性能。此外,React还具有生命周期方法和状态管理机制,可以帮助开发者优化应用程序的性能。例如,在一个电子商务网站中,当用户添加商品到购物车时,React可以仅更新购物车组件的状态和 DOM,而不用重新渲染整个页面。

  4. Fiber 架构优化:在 React 16 中,Fiber 架构诞生。Fiber 作为一种单链表树的结构,每个组件都会被标识为一个 Fiber 节点,并且按照它们在 DOM 树中的顺序排列。每一个节点不仅有自己的 child 节点的指针,还有sibling 节点指针以及 return(父亲)节点的指针。这使得虚拟 DOM 更新时的递归操作可以被切片为多个小任务,并使用 requestIdleCallback 方法进行更新,从而减少浏览器渲染被阻塞的问题,大大提高用户的体验感。

  5. 社区支持和生态系统:React 拥有庞大的社区支持和生态系统,有许多优秀的第三方库和工具可以帮助开发者更好地使用React。例如,Redux是一个流行的状态管理库,React Router是一个用于管理路由的库。

综上所述,React 相比原生 JavaScript 的优势在于其组件化开发模式、声明式编程、高效性能和庞大的社区支持和生态系统。这些优势可以帮助开发者更轻松地构建高质量的应用程序。

setState 批量执行问题

setState 同步还是异步

经常会有人问 setState 是 “异步” 还是 “同步”,以及在使用的过程中如果 setStateconsole.log 一下 state,会发现并不是最新的结果。因此,基于上面的情形,我们会认为 setState 是 “异步” 的。当事实上呢?

JavaScript 语法层面角度来看,setState 就是一个普通的函数,自然就是同步代码。

但是,我们问同步和异步并不是代码层面的,而是调用该方法之后更新 DOM 是同步还是异步的。如果是同步,那么就应该返回最新的状态,反之,则是异步。

1. 调用环境

setState 更新 DOM 的同步和异步实际上是基于调用环境的。

如果我们去看 setState 的源码就会发现:

  • 首先,执行渲染的入口方法为 performSyncWorkOnRoot
  • setState 之后,会创建一个 update 对象加入到 Fiber 节点的等待队列中,等待调度渲染。
  • 此后,进入 scheduleUpdateOnFiber 方法中找到根 Fiber 节点,然后调用 performSyncWorkOnRoot 方法进行渲染。
  • 但是,在调用 performSyncWorkOnRoot 进行渲染之前,存在一个 executionContext 参数(执行上下文)进行判断,用于根据环境决定是否批量执行
  • 如果是当前环境下可以批量执行,则将状态合并后,调用 performSyncWorkOnRoot 方法进行渲染。反之,则立刻调用 performSyncWorkOnRoot 方法。

因此,是否批量执行也就决定了更新 DOM 是同步还是异步

  • React 可以控制的范围内调用,如生命周期函数合成事件的处理函数普通的执行函数等,React 会让状态更新变为批量执行,即把需要更新的状态合并到一起,再更新 DOM
  • JavaScript 控制的范围内调用,如 setTimeoutsetInterval原生的事件处理函数Ajax 回调函数等,React 中的 setState 被调用后会立刻更新 DOM

因此,同步和异步取决于更新是立即的还是批量的。而批量更新的好处就是能够先合并状态,再统一进行更新,这能够一定程度上提高性能。

2. 测试

批量执行:

class Test extends React.Component {
    constructor(props) {
        super(props);
        this.state = {
            count: 0
        }
    }

    componentDidMount(){
        this.setState({
            count: 1
        });
        console.log(this.state.count);
        this.setState({
            count: 2
        });
        console.log(this.state.count);
        this.setState({
            count: 3
        });
        console.log(this.state.count);
    }

    render() {
        console.log('render:', this.state.count);
        return (
            <p>{this.state.count}</p>
        )
    }
}

截屏2023-03-06 11 32 53

立即执行:

class Test extends React.Component {
    constructor(props) {
        super(props);
        this.state = {
            count: 0
        }
    }

    componentDidMount(){
        //  使用 setTimeout
        setTimeout(() => {
            this.setState({
                count: 1
            });
            console.log(this.state.count);
            this.setState({
                count: 2
            });
            console.log(this.state.count);
            this.setState({
                count: 3
            });
            console.log(this.state.count);
        })
    }

    render() {
        console.log('render:', this.state.count);
        return (
            <p>{this.state.count}</p>
        )
    }
}

截屏2023-03-06 11 35 19

总结:

是否批量执行决定了 setState 的 “异步” 和 “同步” 的更新差异

是否批量执行这个问题主要出现在 React 17 中,如果是使用 React 18 则无需担心,因为 React 18 已经全面使用批量执行了。

参考:

React 官方文档
React 的 setState 是同步还是异步

手写源码之 useState 中的 set 函数是如何保持不变的

手写源码之 use 中的 setState 函数是如何保持不变的

在 React 官方文档中说到:useState 中的 setState 是保持不变的,是不会随着函数组件的重新执行而重新创建

截屏2023-08-27 17 55 07

React 17:https://17.reactjs.org/docs/hooks-reference.html

实现方法

在 useState 方法中创建一个指针用来存储 setState 方法以保持每次组件更新时,都采用的是 mount 时创建的 setState 方法,而不是每一次都创建一个 setState 方法。如:

// 自己写一个 useState 的伪代码:
function useState(initialState){
  
  // 这句话表示:在当前函数组件的 Fiber 节点中的 alternate(双缓冲对象)中的 memorizedState 中找到对应的 hook。 
  const oldHook = currentHookFiber.alternate?.memorizedState?.[currentHookIndex];

  // 创建 hook 对象来存储一些必要的内容
  const hook = {
    state: oldHook ? oldHook.state : initialState,
    queue: [], // 修改状态的队列
    dispatch: oldHook ? oldHook.dispatch : null // 通过 dispatch 指针来存储 setState 方法,从而保证不会重新创建
  }

  // 修改 hook 对应存储的 state
  const actions = oldHook ? oldHook.queue : [];
  actions.forEach(action => {
    // 兼容使用 setState(state => state + 1) 函数形式和 setState(state + 1) 赋值的形式来修改 state
    hook.state = typeof action === 'function' ? action(hook.state) : action;
  })

  // !!!重点:
  // 通过保持对 setState 方法的引用,update 时直接赋值,只有 mount 时构建一次 setState 方法
  const setState = hook.dispatch ? hook.dispatch
    : (action) => {
      // ... 包括函数组件重新执行和 fiber workloop 等操作。
    }

  return [hook.state, setState];
}

上述代码中:由于通过 dispatch 指针来存储 setState 方法,因此在每一次 update 过程中只需要拿到 dispatch 即可进行复用,而不是每次都会创建一个新的 setState。

通过上面的伪代码,我们就实现了 React 官方文档中说的 setState 不变的特性。从实现原理来看,还是很基础的。因此,很多复杂操作都是由基本的原理堆叠出来的,学好基础还是很重要的。

React基础-事件及在类中的this问题

事件

所谓 “事件” 也就是我们常用的如 点击事件键盘事件等。

React 中的事件绑定

以点击事件为例。在 html 中,我们可以通过在 DOM 元素的 onclick 属性中赋予一个方法的字符串即可;在 JS 中我们也可以通过 addEventListener 来绑定事件。

<button onclick="count()" />

React 中也一样,不过略微不同。React 事件的命名采用小驼峰式(camelCase),而不是纯小写。并且添加到 DOM 中的形式是 jsx 特有的 {方法名} 的形式。

<button onClick={count} />

然而, addEventListenerJS 的方法,所以不受影响。

在类组件中绑定事件遇到的 this 指向问题

问题:

我们举一个计数器的例子,每点击一次,count 增加 1。

// 计数器
class Counter extends React.Component{
    constructor(props){
        super(props);
        this.state = {
            count: 0
        }
    }
    // 计数器
    add(){
        this.setState((state, props) =>{
            count: state.count + 1;
        })
    }
    // 渲染部分
    render() {
        return (
          <div>
            <h1>Count: {this.state.count}</h1>
            <button onClick={this.add}>add</button>
          </div>
        );
    }
}

上面的例子会不会实现我们预期的结果呢?结果是:不会

原因就在于<button>onClick 属性值为 add 函数,每点击一次,就会触发 onClick 方法。但是,我们知道函数名后面没有 (),说明它不会执行。如果把它赋给另外一个值,让另外一个值来执行(常见于回调或闭包)。遇到这种情形,我们会很容易想到一个问题:this 指向问题。举个例子:

var name = 'window';

let obj = {
    name: 'dou',
    sayName: function(){
        console.log(this.name);
    }
}

// this 绑定丢失
let sayName = obj.sayName;
sayName(); // 'window'

上面这种 隐式绑定的丢失问题 也同样会发生在上面那个的 <button> 上。因为我们知道在 JavaScript 中,类里面的方法是定义在类的原型上的,而非实例身上。因此,class 的方法默认不会绑定到实例的身上

因此,如果你忘记绑定 this.add 并把它传入了 onClick,当你调用这个函数的时候 this 的值为 undefined

解决:

解决方法有两种:

  • constructor 方法中给 this.add 绑定 this。从而把该方法的 this 绑定到对应实例身上。
    constructor(props){
        super(props);
        this.state = {
            count: 0
        }
        // 利用 bind 绑定
        this.count = this.count.bind(this);
    }
  • 在类方法中,使用 箭头函数。因为箭头函数本身是没有 this 的,它会继承自己被创建所处作用域的 this
    // 箭头函数表示
    count = () => {
        this.setState((state, props) =>{
            count: state.count + 1;
        })
    }

React基础-组件和props

组件

组件化React 的特点。我们可以把一个大的东西,细分为一系列小的零件,从而满足一些列需求,如复用等。

组件又可以分为函数组件类组件

function Welcome(props) {
  return <h1>Hello, {props.name}</h1>;
}

上面函数是一个 React 组件,因为它接收唯一带有数据的 props(代表属性)对象与并返回一个 React 元素。这类组件被称为“函数组件”。

class Welcome extends React.Component {
  render() {
    return <h1>Hello, {this.props.name}</h1>;
  }
}

这里的 class 方法继承了 React.Component,代表了 React类组件。这与上面的函数组件是等效的。

props

无论是上面的函数组件还是类组件,都具有 props 属性。而 props 属性可以理解为:当该组件被调用时,调用方传递给该组件的数据(类似于函数的形参)。

举个例子:

function Children(props) {
  return <h1>Hello, {props.name}</h1>;
}

function Father() {
  return (
      <Children name="Jack" />
  );
}

从上面的例子可以看出,当 Children 组件被父组件 Father 调用时,父组件传递给子组件的属性,就包含在 props 属性中。子组件可以根据需要,使用 props 中的内容。

需要注意一点:props 是只读的,并不能对其进行修改

组合组件

首先,在元素渲染中,我们提到了可以用 ReactDOM.createRoot() 方法来让 React 接管一个根节点下的所有内容。

那么,我们可以引申一下。如果有一个网页,它包含了一系列其他的节点,我们是不是就可以让 React DOM 直接从 <body> 级别就开始接管,这样一定是包含网页里的所有内容的吧。因此,我们可以直接在 HTML<body> 下定义一个 <div> 标签,<body> 有且仅有这一个标签

接着,我们再定义一个用于多次渲染的顶层组件 <App /> ,让 root.render() 渲染它即可。

最后,我们就可以直接在 <App /> 中调用组件,甚至可以组合多种组件,也可以一层一层地调用组件

举个例子如下:

// 顶层组件 App 调用 Main,Main 调用 Children

function App() {
  return ( 
      <Main />
  );
}

function Main(props){
  return (
    <div> // 可以组合多个组件
      <Children name = 'jack' age = {24}/>
      <Children name = 'mike' age = {25}/>
    </div>
  )
}

function Children(props){
  return(  // 从 props 中获取属性
    <h1>My name is {props.name}, age is {props.age}</h1>
  )
}

从上面的例子可以看出 React 的组件化特点:一层一层地细化为某个组件,并根据需要可以灵活地调用各组件

当然,如果不能直接看出组件化的效果,那么如果我们不组件化每个部分,最后的结果是什么样的呢?

function App(){
  return(
    <div>
      <h1>My name is jack, age is 24</h1>
      <h1>My name is mike, age is 25</h1>
    </div>
  )
}

上面两种代码呈现的效果是一样的,甚至我们觉得不组件化代码量更少。但是这只是因为我们的需求比较简单,如果复杂一些,它会暴露很致命的弱点:不够灵活,无法根据需求进行修改和复用

React-自定义 Hook

自定义 Hook

有时候我们会需要复用一些状态(业务)逻辑,常用的方法有 HOCrender props 模式,这两者我也在以往的 issue 中提过。但是这两种方法也容易引起一些问题,如:

  • 需要创建额外的组件
  • 当逻辑复杂时可能会造成组件的 “嵌套地狱”
  • ...

此时,我们可以通过 自定义Hook 在不增加额外组件的情况下实现一些状态逻辑的复用。

1. 定义

自定义 Hook 实际上就是一个函数,函数中可能使用了如 useStateuseEffect 等其他钩子来维护状态触发副作用实现某种逻辑或功能。我们约定俗成的以 use 为开头命名自定义钩子,告知他人这是一个自定义 Hook

Hook 是一种复用状态(业务)逻辑的方式,其内部通常会有 state 等属性。Hook 可以被多次使用,但是每个使用者的 state 是完全独立的,因为它不复用 state 本身

简而言之, Hook 的每次调用都有一个完全独立state。因此,可以在单个组件中多次调用同一个自定义 Hook

2. 举例使用

下面举几个例子,便于理解。

2.1 useMouse 钩子

useMouse 的主要功能是:实时记录鼠标移动时的鼠标位置

其执行步骤为:当鼠标移动时,会使用 setState 方法更新 state,保证 state 中的值是最新的鼠标位置返回给调用者

const useMouse = () => {
    const [position, setPosition] = useState({ x: 0, y: 0 });
    // 初始化,添加 listener
    useEffect(() => {
        // 鼠标移动的回调函数
        const mouseMove = (e) => {
            const { pageX, pageY } = e;
            setPosition({
                x: pageX,
                y: pageY
            })
        }
        window.addEventListener('mousemove', mouseMove);
        // 销毁时调用
        return () => window.removeEventListener('mousemove', mouseMove)
    }, [])
    return position;
}

2.2 useToggle 钩子

useToggle 的主要功能是:记录当前切换键的状态值,状态值非 0 即 1。

useToggle 实际上是我们日常开发中经常会用到的功能,如:页面主题 lightdark 切换按钮的 disabled 状态切换等等。

const useToggle = (initialValue) => {
    const [value, setValue] = useState(initialValue);
    /**
     * 使用 useCallback 包裹:因为该函数一直都是保持不变的。如果 toggle 方法被当作 props 属性传递给子组件,可以避免子组件的不必要渲染。
     * 如果不包裹,每次该 hook 内部使用 setState 时,都会重新创建 toggle 函数。如果传递给子组件,就会引发子组件的重复渲染
     */
    const toggle = useCallback(() => {
        setValue(v => !v);
    }, []);
    return [value, toggle];
}

3. 总结

在日常开发中,可以根据需求创建对应的 Hook,有助于灵活复用状态逻辑,减少代码量且便于后期维护

4. 参考资料:

React 官方文档

关于 hooks 中 setState() 的 “假更新” 问题

关于 hooks 中 setState() 的 “假更新” 问题

首先,我们需要知道:使用 React hooks 函数式组件时,如果使用 setState() 方法,并传入一个与上一刻的 state 相同的值,那么是不会执行重新渲染的。这是因为 React 对 hooks 做了性能优化,会对 setState 中传入的值和先前的值进行浅比较,如果通过isObject() 方法浅比较出来一样,则不会重新渲染,反之会。

下面就是正题,这是我在 React 官方仓库的某个 issue 中看到的问题,可以直接跳转链接查看。但如果该 issue 已被关闭,也可以从下面的简述和示例代码了解问题。

“假更新”问题

有两个 state,分别是:

  • const [a, setA] = useState(1);
  • const [b, setB] = useState(true);

有两个按钮,分别会触发两个 state 的 setState 方法。

setA(a + 1) 会触发一个重新渲染,但是 setB(b) 并不会(因为 React 对 hooks 的更新做出了优化)。但是如果点击 A 按钮触发了 setA(a + 1) 方法,导致一次重新渲染之后,再点击 B 按钮时,触发 setB(b) 方法能够导致一次重新渲染。此后再点击 B 按钮,setB(b) 方法就不会触发重新渲染。

因此,问题可以简单总结为:每次点一次按钮 A 后,点击 B 按钮就能够触发一次更新,但之后再点击 B 按钮就不会触发重新渲染。示例代码可以理解为:

import React, { useState } from "react";

export default function App() {
  const [a, setA] = useState(1);
  const [b, setB] = useState(true);

  // 每次组件执行的时候都会触发该 console.log
  console.log("render", a, b);

  return (
    <div className="App">
      <button
        onClick={() => {
          setA(a + 1);
        }}
      >
        click A
      </button>
      <button
        onClick={() => {
          setB(b);
        }}
      >
        click B
      </button>
    </div>
  );
}

问题解释

实际上,setB(b) 方法并不会触发更新,仅有 setA(a + 1) 会触发这一次更新。之所以会打印两次 "render, a, b",这是因为 react hooks 每调用一次 setState 都会把函数组件重新执行,因此就会出现两次 console.log。但是,这并不意味着更新了两次。我们知道 react 更新渲染的是 render 中的内容(函数组件对应了 return 中的内容),如果你在 return 中添加一个 Math.random() 的内容,就会发现,实际上 setB(b) 并不会触发第二次更新,只有 setA(a + 1) 会触发更新。

CodeSandBox 链接:https://codesandbox.io/s/update-issues-about-react-hooks-8dvqyk?file=/src/App.js

但是后来我发现,在上面 codesandbox 中的例子中,点击 clickB 按钮,会且仅会触发一次 consolg.log,之后无论点击多少次也不会触发。然而,每当点击完 clickA 按钮后,再点击 clickB 按钮,就又可以触发 console.log。这也就说明 react 在被 setA(a + 1) 导致重新渲染后,即使执行一个值并未改变的 setState(即 setB(b)),虽然这不会导致更新,但是 react 允许重新执行一次该函数组件,之后如果发现依旧没有改变就不会再次触发。上面是我的猜测内容,对此我还没有找到合理的解释,但是只要 setB(b) 不会触发额外的重复渲染,就不会造成过多的性能浪费,这是没问题的

在 debug 时发现本次更新中 fiber 节点的 lane === 1,这是因为当前是 Legacy 模式,所有的 update 对象的优先级就是 syncLane,其二进制为 0000...0001,对应的十进制就是 1。这个 update 的对象是在点击按钮调用 setA(a + 1) 方法生成的。

在 react 18中,如果不是在 Legacy 模式,而是 Concurrent 模式下,lane 就会根据上下文内容定义不同的优先级。

总结

因此,实际上只会触发一次实际更新,因为使用 setB(b) 方法时,Math.random() 并不会改变,也就意味着不会重新渲染(也可以用 useEffect 把依赖项改为 b 来测试)。但是也存在以下两个未解决的疑问:

  • 为什么 state 并没有改变,也会重新执行该函数组件?

  • 重新执行了该函数组件,但没有触发重新渲染,这是可以理解的。但是为什么每次通过 setA 重新渲染了组件最后,又可以通过 setB 重新执行该函数组件一次呢?

虽然上面的遗留问题尚未解决,但是可以证明的一点是:不会造成重复渲染,因此不会引起 fiber 节点的调和过程,以及 dom 节点的更新,性能不会得到太大的影响。但是无论如何,在函数式组件中,如果存在一些计算复杂、量大的内容,建议还是用 useMemo 包裹起来,以及使用 useCallbackPureComponentReact.memo 等方法解决一些不必要的更新

从源码告诉你 useEffect 中不同依赖项的更新规则

从源码告诉你 useEffect 中不同依赖项的更新规则

使用情景

useEffect是 React 开发中常用的副作用钩子,它接收两个参数:

  • 回调函数
  • 依赖项

在日常开发中,依赖项一般会设置为:空,空数组,有长度的数组,分别对应如下几种:

import { useState, useEffect } from 'react';

export default function App() {

  // 设置 state
  const [count, setCount] = useState(0);

  // 不填写第二个参数,默认为 undefined
  useEffect(() => {
    console.log('第二个参数为空');
  });

  // 第二个参数设置为空数组[]
  useEffect(() => {
    console.log('第二个参数为 []');
  }, []);

  // 第二个参数传入 count 依赖项
  useEffect(() => {
    console.log('第二个参数为包含 count 的数组');
  }, [count]);

  // 第二个参数传入 num 依赖项
  useEffect(() => {
    console.log('第二个参数为包含 num 的数组');
  }, [num]);

  return (
    <div className="App">
      <button onClick={() => {
        setCount(c => c + 1);
      }}>click count</button>

      <button onClick={() => {
        setNum(n => n + 1);
      }}>click</button>
    </div>
  );
}

示例代码地址:https://codesandbox.io/s/frosty-glade-fwhmhr?file=/src/App.js

初始化时,四个 useEffect 都会触发:

截屏2023-07-06 16 49 17

点击 click count 按钮时,会触发第二和第三个 useEffect:

截屏2023-07-06 16 49 53

点击 click num 按钮时,会触发第二和第四个 useEffect:

截屏2023-07-06 16 50 09

上面的实验效果和我们的预想的是一样的,useEffect 的第二个参数:

  • 如果没有设置,即 undefined,则会在每一次组件重新渲染时都会触发回调。
  • 如果是空数组 [],则会在组件第一个挂载时触发回调,之后不再触发。
  • 如果是有元素的数组,则只要有数组元素改变,就会执行回调;如果都没有改变,则不会执行。

那么这是为什么呢?要想了解根本原因,还是需要去阅读 useEffect 的源码。

useEffect hooks 执行挂载(mount)和更新(update)的源码在 ReactFiberHooks.js 中:
https://github.com/facebook/react/blob/main/packages/react-reconciler/src/ReactFiberHooks.js

源码解读

首先我们找到 mountEffectImplupdateEffectImpl 函数,它们分别对应了执行挂载和更新时执行的操作。

// useEffect 挂载时执行的方法
function mountEffectImpl(
  fiberFlags: Flags,
  hookFlags: HookFlags,
  create: () => (() => void) | void,
  deps: Array<mixed> | void | null,
): void {
  // 在组件初始化时,每一次 hooks 执行,都会调用 mountWorkInProgressHook()方法来
  const hook = mountWorkInProgressHook();
  // 初始化时是没有上一时刻的 deps,因此直接传入输入的依赖项。
  // 但是这一块很重要:如果没有传入依赖项(第二个参数),就是 undefined,但是 undefined 会被转为 null
  const nextDeps = deps === undefined ? null : deps;
  currentlyRenderingFiber.flags |= fiberFlags;
  // react hooks 的状态和属性都是存在自己的 memoizedState 属性中的(useState,useRef,useMemo等也是一样,只是存的数据和结构不一样而已)
  hook.memoizedState = pushEffect(
    HookHasEffect | hookFlags,
    create,
    createEffectInstance(),
    nextDeps,
  );
}

// 更新时 useEffect 的执行方法
function updateEffectImpl(
  fiberFlags: Flags,
  hookFlags: HookFlags,
  create: () => (() => void) | void,
  deps: Array<mixed> | void | null,
): void {
  const hook = updateWorkInProgressHook();
  // 如果没有传入依赖项(第二个参数),就是 undefined,但是 undefined 会被转为 null
  const nextDeps = deps === undefined ? null : deps;
  const effect: Effect = hook.memoizedState;
  const inst = effect.inst;

  if (currentHook !== null) {
    if (nextDeps !== null) {
      const prevEffect: Effect = currentHook.memoizedState;
      // 获取先前的依赖项的值
      const prevDeps = prevEffect.deps;
      // 判断 -> 决定是否执行回调
      // 如果 areHookInputImpl 返回 true,则不执行回调
      if (areHookInputsEqual(nextDeps, prevDeps)) {
        hook.memoizedState = pushEffect(hookFlags, create, inst, nextDeps);
        return;
      }
    }
  }

  // 如果 areHookInputImpl 返回 false,则说明输入有变化,就需要执行回调
  
  currentlyRenderingFiber.flags |= fiberFlags;

  hook.memoizedState = pushEffect(
    HookHasEffect | hookFlags,
    create,
    inst,
    nextDeps,
  );
}

mountEffectImplupdateEffectImpl 的源码我没有做删减,不过对一些核心部分添加了说明。仔细阅读了之后会发现,如果第二个参数不传入的话(undefined),react 会把它转为 null,之后再进行判断。

那判断是如何执行的呢?这就涉及到判断是否需要执行回调的方法:areHookInputsEqual() 方法,让我们看看这个方法内部是怎么做的:

areHookInputsEqual() 方法也在同一个文件。

// 判断依赖项是否改变,false 表示改变,true 表示没改变
function areHookInputsEqual(
  nextDeps: Array<mixed>,
  prevDeps: Array<mixed> | null,
): boolean {
  if (__DEV__) {
    if (ignorePreviousDependencies) {
      // Only true when this component is being hot reloaded.
      // 这部分和热更新有关,但这和 react 是无关的,而是看是否使用了热更新的工具。
      // 如果使用了热更新工具,则返回 false,即使依赖项没有改变,必须要执行回调
      return false;
    }
  }

  // 判断依赖项是不是 null,如果是就说明需要执行回调
  if (prevDeps === null) {
    if (__DEV__) {
      console.error(
        '%s received a final argument during this render, but not during ' +
          'the previous render. Even though the final argument is optional, ' +
          'its type cannot change between renders.',
        currentHookNameInDev,
      );
    }
    return false;
  }

  // 无关紧要...错误分析
  if (__DEV__) {
    // Don't bother comparing lengths in prod because these arrays should be
    // passed inline.
    if (nextDeps.length !== prevDeps.length) {
      console.error(
        'The final argument passed to %s changed size between renders. The ' +
          'order and size of this array must remain constant.\n\n' +
          'Previous: %s\n' +
          'Incoming: %s',
        currentHookNameInDev,
        `[${prevDeps.join(', ')}]`,
        `[${nextDeps.join(', ')}]`,
      );
    }
  }
  
  // 遍历依赖项数组,如果有一个元素改变,就返回 false,说明需要执行回调
  for (let i = 0; i < prevDeps.length && i < nextDeps.length; i++) {
    // $FlowFixMe[incompatible-use] found when upgrading Flow
    if (is(nextDeps[i], prevDeps[i])) {
      continue;
    }
    return false;
  }
  
  return true;
}

上面也是无删减版的 areHookInputsEqual 方法,我们可以发现,这个方法主要做了三件事:

  • 判断是否使用了热更新工具,根据 ignorePreviousDependencies 变量得到。
  • 判断依赖项是不是 null,undefined 也会被先转为 null。
  • 判断依赖项各元素有没有变化(但这一块比较是浅比较)。

如果没有使用热更新、依赖项也不是 null、依赖项也没有改变,那么就说明不需要执行回调,反之就需要执行回调。

总结

以上就是 useEffect 第二个参数的源码判断,从源码可以更清晰地了解到什么时候会触发回调,以及为什么会触发回调。

React-render props

render props

对于组件复用,我在 HOC 这篇 issue 中提到了高阶组件及其实现复用的方法。

此外,为实现组件复用,让组件更加灵活化,我们还可以使用 render props 的方法来实现。

原理:

render props 是指一种React 组件之间使用一个值为函数的 props 共享代码的简单技术。

具有 render props 的组件接受一个返回 React 元素的函数,并在该组件内部通过调用此函数来实现自己的渲染逻辑。

简而言之,就是给一个组件的 props 对象添加一个 render属性,该属性的值为一个返回 React 元素的函数,这个元素可以是简单的 DOM 元素,也可以是 React 中的复杂组件

export class Parent extends Component {
  render() {
    return (
      <div>
        {/* render 属性为返回一个 React 元素的函数 */}
         <Child render={(name) => <GrandChild name={name} />} />
      </div>
    )
  }
}

使用:

这里引用官方文档中的例子。

1. 场景:

当我们的鼠标移动时,在鼠标的位置添加一串文本显示当前位置或插入一个图片,随着鼠标的移动,这些内容也会跟着移动。

image

2. 代码实现:

鼠标附属内容的子组件 Text,包含说明文字和鼠标位置。

class Text extends React.Component {
    render() {
        const mouse = this.props.mouse;
        return (
            // 跟随鼠标位置
            <p style={{ position: 'absolute', left: mouse.x, top: mouse.y}}>当前鼠标位置:{mouse.x}, {mouse.y}</p>
        );
    }
}

获取鼠标位置的组件 Mouse,会在自身的 return 中调用 props.render 属性中的方法,从而渲染传入的附属内容

// 获取鼠标位置的组件
class Mouse extends React.Component {
    constructor(props) {
        super(props);
        this.handleMouseMove = this.handleMouseMove.bind(this);
        this.state = { x: 0, y: 0 };
    }

    handleMouseMove(event) {
        this.setState({
            x: event.clientX,
            y: event.clientY
        });
    }

    render() {
        return (
            <div style={{ height: '100vh' }} onMouseMove={this.handleMouseMove}>
                {/* 使用 render props 动态决定要渲染的内容,而不是给出一个 <Mouse> 渲染结果的静态表示 */}
                {this.props.render(this.state)}
            </div>
        );
    }
}

Mouse 的父组件,调用 Mouse 组件,并给 Mouse 组件传入 props.render 属性,属性值为需要渲染的附属组件

// Mouse 的父组件
class MouseTracker extends React.Component {
    render() {
        return (
            <div>
                render props 实现。这里的 Text 也可以是其他组件!
                <Mouse render={mouse => (
                    <Text mouse={mouse} />
                )} />
            </div>
        );
    }
}

3. 总结:

render props 可以实现组件的复用,比如在上面的例子中,我们也可以 Text 组件换位其他组件,如:

  • 插入小猫图片Cat组件:
class Cat extends React.Component {
    render() {
        const mouse = this.props.mouse;
        return (
            <img src='./cat.png' style={{ position: 'absolute', left: mouse.x, top: mouse.y }} />
        );
    }
}
  • 插入小狗图片Dog组件:
class Dog extends React.Component {
    render() {
        const mouse = this.props.mouse;
        return (
            <img src='./dog.png' style={{ position: 'absolute', left: mouse.x, top: mouse.y }} />
        );
    }
}

这也就说明,我们可以根据自己的需求,插入不同的组件,实现对 Mouse 组件的复用

React Fiber Tag 类型

React - Fiber - Tag 类型

我们知道 React 已经重构为了 Fiber 架构,每个组件都有对应的 fiber 节点,而 fiber 节点也有一个 Tag 属性用于表示当前组件是什么类型的,如:函数组件类组件Fragmentdom 节点文本 等等。下面从源码中抠出了所有 Fiber Tag 类型:

export const FunctionComponent = 0;       // 函数组件
export const ClassComponent = 1;          // 类组件
export const IndeterminateComponent = 2;  // 初始化的时候不知道是函数组件还是类组件 
export const HostRoot = 3;                // Root Fiber 可以理解为根元素,通过reactDom.render()产生的根元素
export const HostPortal = 4;              // 对应  ReactDOM.createPortal 产生的 Portal 
export const HostComponent = 5;           // dom 元素 比如 <div>
export const HostText = 6;                // 文本节点
export const Fragment = 7;                // 对应 <React.Fragment> 
export const Mode = 8;                    // 对应 <React.StrictMode>   
export const ContextConsumer = 9;         // 对应 <Context.Consumer>
export const ContextProvider = 10;        // 对应 <Context.Provider>
export const ForwardRef = 11;             // 对应 React.ForwardRef
export const Profiler = 12;               // 对应 <Profiler/ >
export const SuspenseComponent = 13;      // 对应 <Suspense>
export const MemoComponent = 14;          // 对应 React.memo 返回的组件

混合模式下如何给 children 添加额外 props 属性

混合模式下如何给 children 添加额外 props 属性

在先前的一个介绍 React.cloneElement 方法的 issue 中,提到这是一种为子组件添加额外的 props 属性的方法,我们可以通过以下方式来实现:

// 容器,会给其 children 都添加一个额外 props:parentProps
function Container(props) {
    // 给子组件的额外 props 属性
    const parentProps = {
        name: 'jack',
        job: 'student'
    }
    // 通过 cloneElement 方法为子组件添加额外 props 属性
    return props.children.map(item => React.cloneElement(item, {...parentProps}))
}

但是上述代码的前提条件是:Container 的 children 都是 React 组件。然而,我们在开发中也可能遇到一种混合模式的情况:

<Container>
  {/* react 组件 */}
  <Child />
  {/* 函数,返回 react 组件 */}
  {(extraProps) => <Child {...extraProps} name={'tom'}/>}
  {/* 文本 */}
  {'I am text'}
</Container>

对于上述情况,我们需要为前两个 React 组件添加额外的 props 属性,而过滤掉最后一个文本组件。

因此,问题就是标题所提到的:(混合模式)如果 children 元素有 react 组件,有函数返回组件,有其他内容(如文本),该如何为子组件添加额外 props 属性呢?

答:其实也很简单,无非是分情况,对 React 组件使用 React.cloneElement 方法,对函数则传参执行函数,对其他内容直接返回 null。代码如下:

function Container(props) {
  const parentProps = {
    name: 'jack',
    job: 'student'
  }
  
  return props.children.map(item => {
    if(React.isValidElement(item)){ // 使用 React.isValidElement 方法判断是不是是 react 组件
      return React.cloneElement(item, {...parentProps});
    }else if(typeof item === 'function'){ // 如果是函数(返回组件)
      return item(parentProps)
    }else{ // 如果都不是,返回 null
      return null;
    }
  })
}

执行结果:
截屏2023-06-15 23 43 26

从源码告诉你 React 的两个 workLoop 做了什么

React 的两个 workLoop

我们知道 React 的源码中有四个核心部分:

  • React
  • react-dom
  • react-reconciler
  • Scheduler

后面三者的关系如下图所示:

image

1. Scheduler workLoop

Scheduler 是 React 中的一个核心内容,即调度器,它负责了 React 中的任务调度,也就是我们常提到的如:“异步可中断更新”、“任务优先级” 等概念(在 Concurrent 模式下,legacy 模式还没有引入 Fiber,因此并不会中断更新)。

Scheduler 中有一个重要的概念是:workLoop(工作循环),它决定了 React 中如何调度任务。

源码地址:https://github.com/facebook/react/blob/17.0.2/packages/scheduler/src/Scheduler.js

/**
 * 
 * @param {boolean} hasTimeRemaining 是否还有剩余时间
 * @param {*} initialTime 该方法被调用时的时间
 * @returns 
 */
function workLoop(hasTimeRemaining, initialTime) {
  // 计算当前时间
  let currentTime = initialTime;
  // advanceTimers 方法会遍历一边 timerQueue(延时队列)中的任务,判断它们的 startTime 是否到了 currentTime,如果是则加入到 TaskQueue 中等待被执行。
  advanceTimers(currentTime);
  // 获取优先级最高的任务
  currentTask = peek(taskQueue);
  while (
    // 当前任务存在且调度器没有被暂停,不断执行 taskQueue 中的 task
    currentTask !== null &&
    !(enableSchedulerDebugging && isSchedulerPaused)
  ) {
    // 如果当前任务未过期且已经超过时间切片限制,则中断任务执行
    if (
      currentTask.expirationTime > currentTime &&
      (!hasTimeRemaining || shouldYieldToHost())
    ) {
      break;
    }
    // 拿到当前任务(优先级最高的)
    const callback = currentTask.callback;
    //当前任务为一个 function 才执行,否则无效
    if (typeof callback === 'function') {
      currentTask.callback = null;
      currentPriorityLevel = currentTask.priorityLevel;
      const didUserCallbackTimeout = currentTask.expirationTime <= currentTime;
      // 性能分析相关
      if (enableProfiling) {
        markTaskRun(currentTask, currentTime);
      }
      //开始执行任务
      const continuationCallback = callback(didUserCallbackTimeout);
      currentTime = getCurrentTime();
      //返回一个函数,说明当前任务没执行完。(因为之前时间片没时间了任务中断,就先返回一个函数,下次再从这个函数开始继续执行)
      if (typeof continuationCallback === 'function') {
        //将返回回来的函数作为新的任务函数重新赋值,继续执行
        currentTask.callback = continuationCallback;
        if (enableProfiling) {
          markTaskYield(currentTask, currentTime);
        }
        // 再次判断 timerQueue 中的任务是否有可以加入到 taskQueue 中的
        advanceTimers(currentTime);
        // 返回 true,说明停止 workLoop 的执行,意味着本次时间切片内的任务执行需要结束了
        return true;
      } else {
        //进入这里,说明 taskQueue 中当前优先级最高的任务已经执行完毕,此时对 taskQueue 小顶堆进行顺序调整,以便于 while 循环下次拿到优先级最高的任务
        if (enableProfiling) {
          markTaskCompleted(currentTask, currentTime);
          currentTask.isQueued = false;
        }
        if (currentTask === peek(taskQueue)) {
          pop(taskQueue);
        }
        // 再次判断 timerQueue 中的任务是否有可以加入到 taskQueue 中的
        advanceTimers(currentTime);
      }
    } else {
      pop(taskQueue);
    }
    currentTask = peek(taskQueue);
  }
  // currentTask 不为 null,说明 taskQueue 中还有任务没执行完毕,但是本次任务的执行时间已经超过调度器的时间切片了
  // 后续需要告知 performWorkUntilDeadline 方法,还有任务没有执行
  if (currentTask !== null) {
    return true;
  } else {
    const firstTimer = peek(timerQueue);
    if (firstTimer !== null) {
      requestHostTimeout(handleTimeout, firstTimer.startTime - currentTime);
    }
    return false;
  }
}

在 Scheduler 中有两个任务队列:taskQueuetimerQueue,简单地可以理解为:

  • taskQueue:用于存放需要直接执行的任务(高优先级任务),其数据结构是一个小顶堆,每次从堆顶取一个任务(它的过期时间是最短的,也就是优先级最高的)。
  • timerQueue:用于存放延时任务(低优先级任务),等时间到了就会被加入到 taskQueue 中。

既然如此,Scheduler 每次会从 taskQueue 中取一个当前优先级最高的任务来处理。(ps:每一次触发更新就是一个 Task)

2. Fiber 的 workLoop

每个任务执行时就会react-reconciler 来进行协调,具体操作包括:Fiber 自顶向下节点深度遍历,对有变化的节点进行记录和标注,再自底向上地收集副作用到 Fiber 根节点,每一个当前被操作的 Fiber 节点被称为 workInProgress,并且会执行 performUnitOfWork(其中包括 beginWorkcompleteWork 等方法)。

源代码:https://github.com/facebook/react/blob/main/packages/react-reconciler/src/ReactFiberWorkLoop.js

// concurrent 异步渲染模式
function workLoopConcurrent() {
    // 如果 workInProgress 不为空,并且执行时间没有超过时间片(超过 5ms 的话 shouldYield 会返回 true)
    while (workInProgress !== null && !shouldYield()) {
      // 执行当前 fiber 节点的 reconcile 任务
      performUnitOfWork(workInProgress);
    }
}

上面的内容其实就是 Fiber 的 workLoop,它专注于实现构造整个 Fiber 并收集副作用。

我们知道,一个 Task 的 reconciler 分别 render 和 commit 两个阶段。而 Fiber 的 workLoop 就对应了 render 阶段,也就是上面提到的对 Fiber 节点进行遍历和副作用收集的过程。

render 阶段是可中断的。因此,由于时间切片的限制,一个 Task 是可能会被中断的,该 Task (记为 A)会由全局指针 workInProgressRootworkInProgress 记录当前的 root node 和 fiber node 的状态,以等待下一次的空闲时间从被暂停的地方由 Fiber workLoop 继续执行

3. 两个 workLoop 互相协作

上面介绍了 Scheduler 和 Fiber 各自的 workLoop 过程,Scheduler 的 workLoop 会每次从 taskQueue 中取优先级最高的任务来执行,而 Fiber 每次协调时如果被中断,会存在全局指针来记录被中断的位置,以便恢复任务。

但是,在下一次的时间切片中,一般会出现两种情况:

1、如果没有出现优先级更高的任务

taskQueue 的堆顶就是上一次被中断的任务,因此 workInProgressRootworkInProgress 全局变量就不会改变,可以直接取出中断位置的信息,继续处理剩余的内容。

2、如果在这个过程中出现了比 A 优先级更高的任务 B

此时 B 就会在 taskQueue 的堆顶,此时就会重新初始化 workInProgressRootworkInProgress 全局变量,用于 B 任务的 Fiber 协调。这也就意味着会丢失 A 之前中断位置的信息,因此执行完 B 之后再执行 A 的话,就是从头重新开始。正如下图所示:

image

总结

因此,每次拿到新的时间片以后,Scheduler 的 workLoop 都会判断本次 Task 的优先级和上一次时间片到期中断的 Task 的优先级是否一样。如果一样,说明没有更高优先级的更新产生,可以继续上次未完成的协调;如果不一样,说明有更高优先级的更新进来,此时要清空之前 Fiber 协调的内容。等高优先级更新处理完成以后,再次从根节点开始处理低优先级更新。

参考

useAsyncRequest —自定义异步请求的 Hook

useAsyncRequest

useAsyncRequest 是一个自定义异步请求的 hook,它封装了基础的异步请求,只需要传入 url 即可发出请求并返回数据和请求状态。

之后可以进一步优化:可以传入请求方式、请求头等其他设置。

import { useState, useEffect } from 'react';

// 自定义 hooks
export const useAsyncRequest = (url) => {

    const [isOK, setIsOK] = useState(false);
    const [error, setError] = useState(null);
    const [result, setResult] = useState(null);

    useEffect(() => {
        // 异步请求方法,入参:key
        const requestData = async (url) => {
            try {
                // 请求数据,更复杂的请求需要自行定义,这里以 get 为例
                const response = await fetch(url);
                // 如果请求失败,则抛出错误
                if(!response.ok) {
                    throw new Error('request failed !');
                }
                // 设置成功状态
                setIsOK(true);
                // 返回请求到的数据
                const data = await response.json();
                setResult(data);
            }catch(e){
                // 捕获错误,并返回
                setError(e);
            }
        }
        // 执行请求
        requestData(url);
    }, [url])

    // 返回 hook 结果
    return { isOK, error, result }
}

React 18 - useTransition

React 18 - useTransition

众所周知,React 18 提出了 Concurrent 模式,为异步更新提供了可能。

在 React18 中,Concurrent 模式是否开启,需要根据触发更新的上下文来决定。

  • 如果更新是在 eventsetTimeoutnetwork request 的 callback 中触发,React 会采用 Legacy 模式。
  • 如果更新与 SuspenseuseTransitionOffScreen 相关,React 就会采用 Concurrent 模式。

在这篇文章中,我们就来聊一聊 useTransition 的原理和使用,其中会涉及到调度上下文优先级任务中断时间切片等概念,这也将有助于更深入地了解 Concurrent 模式下的异步更新。

useTransition 是 React18 提供的新的 API。使用 useTransition,更新的协调过程会采用可中断的 Concurrent 模式,能给我们带来用户体验上的提升。


ps :最近实在太忙...等有空一定更新...

React基础-组件组合实现代码复用

组合

React 中,我们很少(几乎不会)使用到继承方法来实现组件间代码的复用官方文档 也推荐我们使用组合而非继承来实现组件间的代码重用

包含关系

比如一些作为通用容器的组件,如 Sidebar(侧边栏)、Dialog(对话框)等,或者是我们自建的复杂组件(起容器作用),我们无法确定地知道其内部子组件的具体内容。那么我们可以通过一个特殊的 props.children 属性来将其子组件传递到渲染结果中。

// 容器组件(具有一定的样式)
function MyContainer(props) {
  return (
    <div  className='xxxx'>
      {props.children}  // props 的特殊属性(包含其内部节点),不需要父组件专门给它传递 children 属性
    </div>
  );
}

// 调用容器组件,并在其中添加其他子组件
function App() {
  return (
    <MyContainer>
      <h1>Welcome</h1>
      <p>HelloWorld</p>
      <canvas id="draw" width='200px' height='200px' style="background-color: palegoldenrod;"></canvas>
    </MyContainer>
  );
}

特例关系

我认为,实际在平时开发中,我们常常会把一些组件看作是其他组件的特殊实例,可以理解为一个个参数不同的特例。而这里提到的参数,就是我们传递的 props

// 容器组件
function MyContainer(props) {
  return (
    <div className='xxxx'>
      {props.children}  // props 的特殊属性(包含其内部节点),不需要父组件专门给它传递 children 属性
    </div>
  );
}

// 待复用的组件
function Template(props) {
  return (
    <MyContainer>
      <h1 className={props.h1Name}>Welcome ! </h1>
      <p className={props.pName}>{props.user}</p>
    </MyContainer>
  );
}

// 调用组件,即特殊实例
function User(props) {
  return (
    <Template user={props.name}  h1Name='xxxx' pName='xxx' />
  );
}

从上面的例子可以看出来,User 组件和 Template 组件实际上就是特例关系,特例也从传入的不同 props 属性体现出来。

我认为这也是 React 中组件复用的一种常见形式

在 React 中创建和使用 Redux 完整流程

在 React 中创建和使用 Redux 完整流程

今天重新阅读了 Redux 文档,受益匪浅。因此,也突发奇想地在这里结合官方文档的使用建议和实际开发过程中的常用方法来整体描述一下在 React 中创建和使用 Redux 的完整流程,以供后续复习。

准备工作:安装 Redux 核心库和工具包 npm install react-redux @reduxjs/toolkit --save

创建 store

第一步当然是创建 store,这也是 Redux 的状态大脑(统一管理部分),直接通过 configureStore 即可创建。

import { configureStore } from '@reduxjs/toolkit';

// redux store
export const store = configureStore({
  reducer: {
    // key - value 键值对形式注册切片
  }
});

切片 createSlice

如果是单页面项目,则不需要分开管理状态。但如果是多页面,并且各页面的状态和数据需要单独管理,那么我们就可以使用切片的功能来把 store 划分为多个切片,单独管理。

使用方法:

  1. 通过 createSlice 创建切片。
  2. 注册 nameinitialStatereducers 等属性。
  3. initialState 中存储的是初始化时的状态值,之后使用 action 和 reducer 修改时也会更新。
  4. reducer 中的形参 state 是一个代理对象,可以直接修改,作用会同步到该切片的状态中;action 是一个 Action 对象,其属性为 {type: 函数名, payload: 传入参数},所以传入的值可以在 action.payload 中取到。
  5. reducers 中可以有多个 reducer 函数。
import { createSlice } from '@reduxjs/toolkit';

const teacherSlice = createSlice({
  name: 'teacher',
  initialState: {
    name: 'jack',
    age: 45,
    gender: 'male'
  },
  reducers: {
    setName(state, action){
      state.name = action.payload.name;
      state.age = action.payload.age;
    },
    setAge(state, action){
      state.age = action.payload.age;
    }
  }
})

切片对象会自动生成 actions,其中存储的是切片自动生成的 action 创建器(函数)。上面的例子中,我们可以从 teacherSlice.actions 中获得 reducers 中注册的 reducer 方法。(但是这方法并不是一个简单的函数,而是一个 action 创建器性质的函数)

// 从切片对象的 actions 中导出函数
export const { 
  setName, 
  setAge
} = teacherReducer.actions;

此外,我们也可以把创建的切片对象给导出,后续可以讲切片对象注册到 store 中。但是导出时,我们需要导出的不是 teacherSlice,而是 teacherSlice.reducer

export default teacherSlice.reducer;

把切片注册到 store 中

在上一节中,已经注册并配置好切片对象并导出其 reducer 后,我们就可以把每个切片对象导出的 reducer 注册到 store 中。

import { configureStore } from '@reduxjs/toolkit';
// 假设创建了三个切片,每个切片的创建流程和上面一样
import teacherReducer from './slice/teacherReducerSlice'; // 因为是 export defult,所以取什么名字都一样
import studentReducer from './slice/studentReducerSlice';
import parentReducer from './slice/parentReducerSlice';

// 把切片对象注册到 redux store 中
export default configureStore({
  reducer: {
    teacher: teacherReducer,
    student: studentReducer,
    parent: parentReducer,
  }
});

完成以上操作后,就算是配置好了 Redux 的基本功能,后续就是在 React 中如何使用并修改数据了。

在 React 中使用

既然上面已经创建好了 store 之后,就需要使用 Provider 包裹某个祖先组件,并把 store 供给下面的 Consumer 组件使用。通常来说,为了管理全局状态,我们一般会用 Provider 包裹整个应用入口文件中的组件,如在 App 文件中包裹 Main

// react-redux
import store from './app/store';
import { Provider } from 'react-redux';

function App() {
  return (
    <Provider store={store}>
      <Main />
    </Provider>
  );
}

通过上述方法,在整个组件树中,我们都可以获取到 store 中的值。那么该如何使用呢?下面将分别介绍类组件和函数组件的使用方法。

类组件中使用

在类组件中使用 Redux,需要借助 react-redux 库中的 connect 方法(即高阶组件,也是一个柯里化后的方法)来增强子组件。我们需要在 connect 方法中传入两个参数,mapStateToPropsmapDispatchToProps 方法,这两个方法分别返回两个对象,分别获取 store 中某个切片对象的属性和对应 actions 对象暴露出来的方法,便于后续使用。

  • mapStateToProps 方法中,传入 state,指整个 storestate.teacher 则表示在 store 中注册键名为 teacher 的切片对象,可以回到上面去看一下 store 中注册的键值对。state.teacher.xxx 则表示该切片对象的 xxx 属性。

  • mapDispatchToProps 方法中,传入 dispatch 方法处理某个 action 对象,并调用 reducer 函数来更新属性。上面提到,切片对象 reducers 属性中的方法会被自动封装为 action 对象,暴露出来的方法也是从切片对象 actions 属性中而来,因此dispatch 方法传入一个 action 对象就可以调用对应的 reducer 方法来执行属性的更新
    (ps:dispatch 方法会触发重新渲染)

看文字可能比较难理解,我们根据上面的例子进行属性获取和属性修改:

// react-redux
import { connect } from 'react-redux';

// 导入切片对象的 actions 暴露出来的函数
import { setName, setAge } from './slice/teacherReducerSlice';

// 创建一个组件 Teacher
class Teacher extends React.Component {
  constructor(props){};
  // 由于 Teacher 是被高阶组件包裹着,所以应当使用 this.props 来获取对应属性和方法
  // 打印 name、age、gender
  sayName = () => {
    console.log(this.props.name);
  }
  sayAge = () => {
    console.log(this.props.age);
  }
  sayGender = () => {
    console.log(this.props.gender);
  }
  // 更改名字和年龄
  changeName = () => {
    this.props.setName('mike');
  }
  changeAge = () => {
    this.props.setAge(60);
  }
};

// 传入 mapStateToProps 和 mapDispatchToProps ,利用 connnet 高阶组件来增强 Teacher 组件
const mapStateToProps = (state) => {
  return {
    name: state.teacher.name,
    age: state.teacher.age,
    gender: state.teacher.gender,
  }
}
const mapDispatchToProps = (dispatch) => {
  return {
    setName: (payload) => dispatch(setName(payload)),
    setAge: (payload) => dispatch(setAge(payload)),    
  }
}
// 导出该组件
export default connect(mapStateToProps, mapDispatchToProps)(Teacher);

在上面的例子中,我们使用 this.props.属性名 就能够获取到对应切片对象中的属性,使用 this.props.方法名 就能修改对应切片对象中的属性。至于这里为什么要使用 this.props,因为 connect 实际上是一个经过柯里化的高阶组件,所以当用高阶组件包裹某个组件后,该组件获取高阶组件赋予它的值就属于 props 范畴。

函数组件中使用

在函数组件中,我们需要借助 useSelectoruseDispatch 钩子来实现上述的需求。

  • 使用 useSelector 可以实现 mapStateToProps 的功能,即获取到某个切片对象的属性。
  • 使用 useDispatch 可以实现 mapDispatchToProps 的功能,即修改某个切片对象的属性。

直接举例:

// react-redux 使用钩子
import { useSelector, useDispatch } from 'react-redux';

// 导入切片对象的 actions 暴露出来的函数
import { setName, setAge } from './slice/teacherReducerSlice';

// 创建一个组件 Teacher
export default Teacher(props){
  // 使用 useSelector 和 useDispatch 获取对应切片的属性,以及把方法转换为 action 对象
  const state = useSelector(state => state.teacher);
  const dispatch = useDispatch();

  // 打印 name、age、gender
  const sayName = () => {
    console.log(state.name);
  }
  const sayAge = () => {
    console.log(state.age);
  }
  const sayGender = () => {
    console.log(state.gender);
  }
  // 更改名字和年龄
  const changeName = () => {
    dispatch(setName('mike'));
  }
  const changeAge = () => {
    dispatch(setAge(60));
  }
};

从上面的例子可以看出,我们使用 useSelectoruseDispatch 更加方便地实现了 connect 高阶组件的功能。简单地概括就是:

  • 通过 useSelector 钩子获取到对应切片对象,之后直接调用该对象中的属性。
  • 通过 useDispatch 钩子传入 action 对象,从而使用切片对象 reducers 中对应的 reducer 函数修改属性值,并触发重新渲染。

参考

React基础-state与生命周期

state

在先前讲到的 元素渲染部分提到,我们可以通过 root.render() 来实现。除此以外,我们也可以通过 类组件的 state 属性函数组件的 useState() 钩子来控制组件的渲染。

为了方便理解,这里我们用类组件state 属性和 setState() 方法来控制元素的更新。

class Clock extends React.Component{
    constructor(props){
        super(props);
        this.state = {
            time: new Date().toLocaleTimeString()
        }
        this.timer = null;
    }
    // 更新时间
    tick(){
        this.setState({
            time: new Date().toLocaleTimeString()
        })
    }

    // 生命周期:挂载后执行
    componentDidMount(){
        this.timer = setInterval(() => this.tick(), 1000)
    }

    // 生命周期:卸载前执行
    componentWillUnmount(){
        clearInterval(this.timer);
    }

    // 渲染部分
    render() {
        return (
          <div>
            <h1>Hello, world!</h1>
            <h2>It is {this.state.time}.</h2>
          </div>
        );
    }
}
// React DOM 接管 'root‘
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<Clock />);

上面的例子就是 React 类组件的简单构建形式和一些基本方法

我们可以看到在 constructor 方法内存在一个 this.state 的属性,这就是用于存储能够引起组件更新渲染的属性。

这里采用的是官方文档中的时钟例子:每一秒更新一次渲染,从而更新页面显示的时间

我们把 time 属性放入在 this.state 属性中,之后并通过 this.setState() 的方法实现 time 属性的更新。那么 setState() 之后会怎么做呢?那就是更新 render() 部分的内容

计时器 setInterval 我们设置在了 ComponentDidMount 方法中,这实际上就是在 React 元素挂载后执行的一个方法。而当 Clock 组件不再使用,即将被卸载(销毁)时,就会执行 ComponentWillUnmount 方法,从而销毁计时器。

对于上面提到的 "挂载" 和 “卸载” 不太了解的,可以去了解一下 React 元素的生命周期,比如我之前写的一篇生命周期那些事儿

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.