阅读官方文档的学习心得 以及 记录写项目时遇到的一些关于React的问题及其解决方案, 以便后续查看...
🔗 查看跳转 👉 Issues
🔗 React 源码
Just record some learning notes about React...
先前在 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
节点 input
的 ref
,并在必要时访问,就像其直接使用 input
一样。
此外,子组件 Children
也可以通过 ref.current
来获取到 input
节点,并在必要时使用它。
按照人类能感知到的最低限度每秒 60 帧的频率划分时间片,那么每个时间片就是 16 ms。如果浏览器的刷新频率大于 60 次每秒,那么我们能够感受到当前页面是流畅的;反之,如果浏览器的刷新频率小于 60 次每秒,我们就会感受到页面的卡顿。
对于一个完整的帧而言,浏览器会执行哪些操作呢?如下图所示:
浏览器的处理流程按顺序是:
input event
事件:处理输入事件(如 click/ input/ scroll 等),让用户得到最早的反馈。Timers
定时器:处理 JS 定时器,需要检查定时器是否到时间,并执行对应的回调。Begin Frame
开始帧:即每一帧的事件,包括 window.resize
、scroll
等。requestAnimationFrame
,即在每次绘制之前,会执行 requestAnimationFrame
回调。Layout
操作:包括计算布局和更新布局,即这个元素的样式是怎样的,它应该在页面如何展示。Paint
操作:得到 DOM 树中每个节点的尺寸与位置等信息,然后针对每个元素进行内容填充。requestIdleCallback
里注册的任务。我们知道,布局操作和绘制操作是交给 GUI
来渲染实现的,然而 JS 引擎线程和 GUI 渲染线程是互斥的,即一个执行,另一个只能等待。因此,如果在某个 JS 任务中执行时间很长(超过 16 ms),就会阻塞 GUI 线程渲染页面,从而出现卡顿。
React 为什么需要 Fiber
? 我们知道 React 的更新渲染是通过对比新旧虚拟 DOM 树实现的:深度遍历虚拟 DOM 树的每个节点,找出需要变动的节点,然后同步更新他们。(React 称这个过程为 reconcilation
协调过程)。
但是,这个遍历过程是采用递归实现的,我们知道递归会导致执行栈越来越深,占用大量栈内存;而且一旦触发就不能中断,如果中断了就无法返回中断位置继续执行。因此,reconcilation
过程执行后就会一直占用浏览器资源,这也将导致用户触发的事件得不到相应,从而出现卡顿现象。
Fiber
是一种执行单元,也是一种数据结构(链表结构)。每个执行单元都包含了对应 DOM 节点的属性信息和更新信息。
每次执行完一个执行单元,React 就会检查现在还剩多少时间,如果有时间则继续执行下一个单元,如果没有时间则将控制权还给浏览器。React Fiber 与浏览器的核心交互流程如下:
简而言之,先前的 React 更新渲染是需要递归便利整个虚拟 DOM 树,这是一个大型任务;而使用了 Fiber 架构就可以把这个大型任务给划分为一系列小的任务单元。如果浏览器执行完任务之后还存在空闲时间,就会执行这些小任务。虽然小任务执行过程是不可中断的(执行了就必须执行结束),但是碎片化的处理能够大大减少占用浏览器资源,避免阻塞,而不是像之前一样执行一个大任务从而导致浏览器无法响应。
Fiber 是一种采用链表实现的数据结构,在 React 16 后,每一个虚拟 DOM 节点都会对应一个 Fiber 对象。该对象内包含 child
(第一个子节点)、sibling
(最近的兄弟节点)、return
(父节点)等属性。
从代码角度而言,fiber 对象就是对虚拟 DOM 节点的一个描述,它包含了该节点在虚拟 DOM 树中的上下文关系。
requestIdleCallback
是实现 Fiber 的基础 API(但实际上 Fiber 使用的是 MessageChannl 实现生成宏任务的,因为。requestIdleCallback 的兼容性较差)。浏览器为开发者提供了 requestIdleCallback 方法,该方法能使开发者在主事件循环上执行后台和低优先级的工作,而不影响延迟关键事件,如动画和输入响应。
requestIdleCallback 方法的第一个参数是一个回调函数。如果正常帧任务完成后没超过 16ms,说明有多余的空闲时间,此时就会执行 requestIdleCallback 里注册的任务。
具体的执行流程如下:
当浏览器执行完当前任务后,如果没有剩余时间了,或者已经没有下一个可执行的任务了,React 会归还控制权给浏览器。在下一帧时,同样使用 requestIdleCallback 去申请下一个时间片。具体的流程如下图:
此时可能冒出一个疑问:如果一直没有空闲时间那是不是就一直不执行我们注册的任务了?
我们可以在 requestIdlCallback 方法的第二个参数中配置 timeout
属性。该参数属性是用于定义超时时间的,如果到了超时时间了,浏览器必须立即执行。使用方法如下:
window.requestIdleCallback(callback, { timeout: 1000 })。
如果是因为 timeout 时间到了必须执行回调函数,那么用户就有可能会感受到页面的卡顿了,因为一帧的时间必然是超过了 16ms。
此外,window.requestIdleCallback(callback)
的 callback 中会接收到默认参数 deadline ,其中包含了以下两个属性:
一帧页面渲染并非只能对应一个 requestIdleCallback 注册的回调,实际上 requestIdleCallback 注册的回调可以在所有帧渲染时被执行。因此,如果前面的帧没有空闲时间,并且每个注册回调的 timeout 并没有达到,这些注册回调可能就会堆积起来,直到某一帧存在空闲时间(或 timeout 超时),就会被执行。相反,如果当前帧的空闲时间足够,那么也可以依次执行多个注册回调,直到没有剩余时间。如果执行某次回调后没有剩余时间或者已经超出剩余时间了,那么就必须将控制权返还给浏览器,同时发起下一次的时间片请求,再下一段空闲时间继续执行剩余的回调函数。
注意:如果执行某个注册回调时超过了当前帧的剩余时间,则会一直卡在这里执行,直到该任务执行完毕。如果代码存在死循环,则浏览器会卡死。
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 。
上面已经多次提到了 Fiber 的结构是链表结构,Fiber tree 实际上就是一个单链表树结构。同样,Fiber tree 的状态更新也是由链表结构实现的,每个 fiber 节点保存自己的状态更新单元,通过更新队列的形式组织成一个链表结构,统一更新状态。下面是模拟 Fiber tree 更新的示意图:
如上图所示,每一个单元包含了 payload
(数据)和 nextUpdate
(指向下一个单元的指针)。因此,我们模拟地定义 fiber 单元结构如下:
class Update {
constructor(payload, nextUpdate) {
this.payload = payload // payload 数据
this.nextUpdate = nextUpdate // 指向下一个节点的指针
}
}
上面已经定义了模拟 fiber 对象的单元结构。接下来需要定义一个队列,把每个单元串联起来。
其中定义了两个指针:
作用是指向更新的第一个单元和最后一个单元。
还加入了 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:实例化一个队列,向其中加入很多节点,再更新这个队列:
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 }
我们知道 React 的生命周期可以分为 render
/ reconciliation
(协调)和 commit
(提交)两个阶段。在 render 阶段中,我们需要找出所有节点的变更;而在 commit 阶段中,需要执行所有变更。
render / reconciliation 阶段的生命周期钩子:
commit 阶段的生命周期钩子:
这两个阶段存在最本质的差异在于:能否中断。
因此,render 阶段是可中断的,而 commit 阶段一直都是无法中断的,只要提交了就必须执行完,
在 render 阶段中,会找出所有节点的变更,如节点新增、删除、属性变更等,这些变更 React 统称为副作用(effect)。
此阶段会构建一棵虚拟 DOM 树以及对应的 Fiber tree,以虚拟 DOM 节点为单元对任务进行拆分,即一个虚拟 DOM 节点对应一个任务(fiber),最后产出的结果是 effect list
,从中可以知道哪些节点更新、哪些节点增加、哪些节点删除了。
已知在 render 阶段,React 会生成一颗新的虚拟 DOM 树及其对应的 Fiber tree,虚拟 DOM 上每一个节点都对应了一个 fiber 对象,该对象内部包括 child
、sibling
、return
等属性。我们可以通过这些属性,进行深度遍历,收集该节点下的每一个 child 的变更(effect),然后汇总传递(effect list)到该节点处。了解二叉树深度遍历的都知道,这种自下而上的需求得用后序遍历来实现。
具体执行流程如下(以二叉树为例,N 叉树逻辑一样):
我们进行深度遍历的目标就是为了获得节点的 effect list
,这也就和我们在 3.2 节中提到的队列 baseState 属性类似。只不过对于整棵树来说,我们希望能够获得根节点的 effect list,来收集整个 Fiber tree 上的副作用。
在深度遍历的过程中,我们收集所有节点的变更产出 effect list。注意其中只包含了需要变更的节点,没有变更的节点不存在 effect,不需要收集。通过每个节点更新结束时向上归并 effect list 来收集任务结果,最后根节点的 effect list 里就记录了包括了所有需要变更的结果。
具体步骤如下:
commit 阶段需要将上阶段计算出来的需要处理的副作用一次性执行,此阶段不能暂停,否则会出现 UI 更新不连续的现象。此阶段需要根据 effect list,将所有更新都 commit 到DOM树上。
React-Fiber 通过使用单链表树结构,巧妙地将 查找新旧虚拟 DOM 树节点变更 切分为一系列小任务,允许 React 可以不断地向浏览器提出 requestIdlCallback 请求,注册这些小任务。而不需要一次性递归到底占用大量的浏览器资源,给用户带来不好的使用体验。
详细的代码(或源码分析)可以参考本文的参考资料
首先我们先看一下官方文档中对 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
可以有利于我们判断历史和当前的内联回调函数的相等性,从而避免渲染不要渲染的子组件。
我们知道,在 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);
上述代码的实验结果如下:
- 点击 Button1,只有 Button1 后面的随机数改变。
- 点击 Button2,Button1 和 Button2 后面的随机数均改变。
详细实验结果如下所示:
1、初始化结果:
2、点击 Button1 后,Button1 后面的随机数改变。
3、点击 Button2 后,Button1 和 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
的说明:
Pass a “create” function and an array of dependencies. useMemo will only recompute the memoized value when one of the dependencies has changed.
简单来说,useMemo
的参数包括一个创建函数和依赖项。创建函数会需要返回一个值,只有在依赖项发生改变的时候,才会重新调用此函数,返回一个新的值。
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
来包裹。这样能够避免每当父组件重新渲染时,再一次触发大量且复杂的计算,直接调用缓存即可。
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、初始化结果:
2、点击 changeName
修改 name
状态。
3、点击 changeAge
修改 age
状态。
从上面的结果可以看出:
React.memo
包裹了 Student
组件,它只会在传入的 props
改变或内部 state
改变才会重新渲染。user1
使用了 useMemo
,即只有当依赖项 name
属性改变时,user1
的值才会改变,对应的 Student
子组件才会重新渲染。user2
没有使用 useMemo
,只要父组件 MyComponent
重新渲染,对应的 user2
对象也会重新创建。由于旧的 user2
和新的并不能判等,因此对应的子组件 Student
也会重新渲染。这也就通过 useMemo
实现避免子组件的不必要渲染。
如果没有提供依赖项数组,useMemo
在每次渲染时都会计算新的值,这和 useEffect
没有提供依赖项数组是同样的处理情况。
肯定会有人问这个 useMemo
和 useEffect
有什么关系,看起来挺像的。记住,传入 useMemo
的函数会在渲染期间执行。请不要在这个函数内部执行不应该在渲染期间内执行的操作,诸如副作用这类的操作属于 useEffect
的适用范畴,而不是 useMemo
。
此外,useMemo
可以通过返回函数,能够起到和 useCallback
同样的效果。
useCallback
可以封装函数,以保存内联函数的不变性,即缓存函数。可以结合 React.memo
或 shouldComponentUpdate
等功能,让子组件避免一些不必要的渲染。
useMemo
缓存的是函数返回值,它既可以优化子组件的不必要渲染,也可以存储当前组件的一些复杂逻辑计算环节。
React 官方文档
一篇文章带你理解 React 中最“臭名昭著”的 useMemo 和 useCallback
详解 React useCallback & useMemo
先看一看 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
如此好用,并且可以帮我们避免开发里的一些不必要问题。因此,在这里开一篇 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 的方法就是触发 action
,action
是一个用于描述已发生事件的普通对象。
这样确保了视图和网络请求都不能直接修改 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
对象(实际开发中可以通过 connect
或 useDispatch
钩子将我们的输入包装为 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
是纯函数,这也有利于保证不存在副作用,避免了一些意外的数据修改问题。
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
的源码,了解一下他更新判断的机制。
我们知道可以使用 shouldComponentUpdate
或 PureComponent
或 React.memo
来让组件避免不必要的更新。shouldComponentUpdate
是可以自己设置判断条件,而后面两个是会对组件的新旧 props
和 state
进行浅比较,从而避免不必要的更新。
我们也知道 useCallback
和 useMemo
常常需要结合上面的三种方法一起使用。当我们将一个函数作为 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);
}, []);
}
我们可以从代码中看到几个比较关键的部分:
useRef
保存函数。
我们知道,ref
对象在组件的各次渲染中都保持一致,访问到的 current
字段都是最新的。useRef 会在每次渲染时返回同一个 ref
对象,并且当更新 current
值时并不会引起 re-render
,这是与 useState
不同的地方。所以 ref
保存函数是一个明智的选择。
useLayoutEffect
useLayoutEffect
和 useEffect
的区别在于执行的时期,前者是在 render
之前执行,而后者是在 render
之后执行,很显然我们最好在渲染之前设置 ref.current
的值。(其实这里使用 useEffect
也一样,但是 useLayoutEffect
更符合思路)。
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>
)
}
}
从上图的结果可以看出,我们点击 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>
)
}
从上图的效果也可以看出能够解决上述问题。因此,两种方法都是可以实现的,见仁见智吧,根据需求使用。
在这里我还是建议:在函数 Hook
中尽量避免使用直接赋值的方法来更新状态,这样很容易掉入闭包陷阱。
在 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
属性,从而提高 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 属性。
状态提升所对应的场景需求一般为:父组件内有多个子组件,也可以理解为有多个输入,而这些输入是同步的,一个子组件改变也会导致其他同级组件的改变。
对于以上问题,我们暂且不使用 Context
或 React-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>
)
}
}
上面代码的执行流程:
假设子组件 Rmb
和 Dollar
都有一个 input
输入框。其 value
为 props
中的 res
属性, onChange
方法绑定了 props
中的 toParent
方法。
update
方法。update
,修改自身的 state
属性。请求自身的重新渲染。render
方法重新渲染 UI
。state
更新,导致其传递给子组件的 props
属性,即 res
值改变。触发子组件的重新渲染。props
值重新渲染,触发自身的 render
方法,实现自己 input
框内的值也更新。得益于每次的更新都经历相同的步骤,两个子组件的输入框内容才能始终保持同步。”状态提升“ 的方法不仅可以满足上述场景需求,也可以实现子组件对父组件的状态修改。
当一个组件的 props
或 state
改变时,React
会构建一个新的虚拟 DOM
树,以此通过 diff
算法将新的虚拟 DOM
树和旧的虚拟 DOM
树进行对比。以此决定是否有必要更新真实的 DOM
。当新旧虚拟 DOM
树中对应的节点不相同时,React
会更新该 DOM
。
如果我们知道组件在什么条件下应该重新渲染,那么就可以通过覆盖生命周期方法 shouldComponentUpdate
来进行提速。
该方法会在重新渲染前被触发,如果 shouldComponentUpdate
返回 false
,则不会进行 diff
判断,从而提高性能。
shouldComponentUpdate
默认实现总是返回 true
,让 React
执行更新:
shouldComponentUpdate(nextProps, nextState){
return true;
}
假设我们已知组件在什么条件下才应该重新渲染:当且仅当 props.name
和 state.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
比较。比如:
addScore
按钮:addAge
按钮:由此可知,当我们判断 score
改变,shouldComponentUpdate
返回 true
,后续会重新渲染;如果改变 age
属性, shouldComponentUpdate
返回 false
,则不会重新渲染。
如果一个组件的渲染只依赖于 props
和 state
,我们把它称之为纯组件。那么,我们可以使用类似“浅比较”的模式来检查 props
和 state
中所有的字段,以此来决定是否组件需要重新渲染。
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
组件,只有在自己的 props
和 state
属性改变,才会触发自身的重新渲染。即使它的父组件重新渲染了,但是如果它自身的 props
和 state
没有改变时,它就不会重新渲染,即 shouldComponentUpdate
返回 false
。
(注意:这个例子和先前的例子并不等效。先前的例子中 age
属性改变也不会触发重新渲染,而当前的例子中只要 state
中任何属性值改变,都会重新渲染)
上面提到 PureComponent
会对新旧 state
和 props
进行浅比较。如果当 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>
)
}
}
上面的代码执行结果:
即当点击按钮时并不会触发重新渲染。这是因为 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()
或 ...
扩展运算符。
useReducer
众所周知, React Hooks
的出现赋予了函数组件 state
及保存和处理 state
的能力。常用的钩子有 useState
、useEffect
、useRef
、useContext
、useReducer
等等。今天在这里聊一聊 useReducer
的作用和复杂场景下取代 useState
的能力。
什么是 useReducer
这个问题我已经在先前的一篇 React-Hooks 基础 中进行了相关介绍,如果不了解可以去看一看或者查阅官方文档,这里只做简单介绍和说明。
const [state, dispatch] = useReducer(reducer, initialState);
useReducer
方法接收两个参数:reducer
和 initialState
。
举个例子,假设 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
}
}
}
}
上面已经完成了 initialState
和 reducer
的配置,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
中的值
点击 addAge
,age
值 + 1
点击三次 minorAge
,age
值 - 3
我们知道 useState
能够维护状态,从上面的例子也能看出 useReducer
也可以维护状态。我们试想,如果出现多个状态(多个 useState
),并且基于复杂的逻辑进行 state 更新,就会造成更大的心智负担,甚至出现意料之外的错误。
不过 Reducers 并非没有缺点!以下是从不同角度比较它们的看法:
代码体积: 通常,在使用 useState
时,一开始只需要编写少量代码。而 useReducer
必须提前编写 reducer 函数和需要调度的 actions
。但是,当多个事件处理程序以相似的方式修改 state 时,useReducer
可以减少代码量。
可读性: 当状态更新逻辑足够简单时,useState
的可读性还行。但是,一旦逻辑变得复杂起来,它们会使组件变得臃肿且难以阅读。在这种情况下,useReducer
允许你将状态更新逻辑与事件处理程序分离开来。
可调试性: 当使用 useState
出现问题时, 你很难发现具体原因以及为什么。 而使用 useReducer
时, 你可以在 reducer 函数中通过打印日志的方式来观察每个状态的更新,以及为什么要更新(来自哪个 action)。 如果所有 action 都没问题,你就知道问题出在了 reducer 本身的逻辑中。 然而,与使用 useState 相比,你必须单步执行更多的代码。
可测试性: reducer 是一个不依赖于组件的纯函数。这就意味着你可以单独对它进行测试。一般来说,我们最好是在真实环境中测试组件,但对于复杂的状态更新逻辑,针对特定的初始状态和 action,断言 reducer 返回的特定状态会很有帮助。
个人偏好: 并不是所有人都喜欢用 reducer,没关系,这是个人偏好问题。你可以随时在 useState
和 useReducer
之间切换,它们能做的事情是一样的!
如果你在修改某些组件状态时经常出现问题或者想给组件添加更多逻辑时,我们建议你还是使用 reducer。当然,你也不必整个项目都用 reducer,这是可以自由搭配的。你甚至可以在一个组件中同时使用 useState 和 useReducer。
浏览器的 DOM
事件,也就是我们常提到的 "监听器"。如点击、鼠标悬停、加载等。
对于原生的 DOM
事件,浏览器会把监听器绑定到对应的 DOM
上,然后创建一个事件对象。如果我们的页面中存在很多 DOM
事件,就需要腾出很多空间来存放对应的事件对象。这无疑是十分消耗内存的。
因此,React
并不把事件绑定到真实的 DOM
节点上,而是在 document
处创建一个 “事件池” (对象),用来保存所有的监听事件。
React 17
开始,移除了 “事件池” 的概念,把所有事件都绑定在root
上。不过执行原理整体上不变,下面还是以React 16
举例并介绍原理。
React
的合成事件仅在事件流的冒泡阶段触发(如果不了解浏览器的事件流可以去看JavaScript_Notes)。当事件发生并冒泡到 document
处时,则会在事件池中选择相应的事件处理程序执行。
在 JSX
语法中,我们可以在标签中绑定 React
合成事件。以绑定按钮的点击事件为例:在原生的 <button>
标签中,绑定原生事件是 onclick
,即全小写形式。而在 JSX
中,点击事件的属性为 onClick
驼峰形式,又如 input
标签的 onChange
属性。这些都代表 React
的合成事件。
<button onClick={this.Click}>点击按钮</button>
如果需要在 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>
)
}
}
我们知道 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
后,输出结果如下:
在原生事件中,我们可以通过 event.stopPropagation()
方法来阻止事件继续冒泡传递。
在上面的例子中,我们在 <div>
的原生事件中调用 stopPropagation
方法。结果会怎么样?
// 原生事件的处理程序 parent
nativeParentEvent = (e) => {
console.log('Native Parent Event');
e.stopPropagation();
}
结果如下:
由于事件冒泡过程中,在 <div>
处停止了继续传递,所以消息无法传递到 document
处,因此也就无法触发 React
的合成事件。
在 React 17
之前,如果我们希望阻止合成事件,应该采用 event.preventDefault
方法。然而, React 17
帮我们让合成事件更加接近原生事件,我们可以在合成事件中调用 event.stopPropagation()
方法来阻止事件冒泡。
上面讲解的 document
事件池概念都是在 React 17
之前,虽然在 17 之后已经让合成事件更加接近原生事件,但是整体原理依旧不变。
虽然上面讲了合成事件和原生事件的混用,但是不建议混用,容易造成一些意想不到的错误。
合成事件相对于原生事件有不少的优点,如:
抹平了浏览器间的兼容问题,不同浏览器支持的内容不一样,但合成事件是一个跨浏览器的原生事件包装器,便于跨浏览器开发。
对于原生的 DOM
事件,浏览器会把监听器绑定到对应的 DOM
上,然后创建一个事件对象。如果我们的页面中存在很多 DOM
事件,就需要腾出很多空间来存放对应的事件对象。这无疑是十分消耗内存的。但是,合成事件统一挂载在 document
(root
)上,减少了内存消耗,并且可以统一管理。
当 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 实际上会绑定或初始化一系列的属性,如 props
、ref
、context
等信息。此外每个组件还会有一个 updater
对象。每次触发 setState
或 forceUpdate
方法时就会将该组件 updater 对象中对应的 setState 队列或 forUpdate`队列中存放的状态一并处理更新。
我们知道,在定义类组件时,其 constructor
构造函数中需要添加 super(props)
,这实际上就是在调用 Component
类的构造函数,从而初始化并设置对应的属性,如 props
、ref
、context
、updater
等信息。
Class MyComponent extends React.Component {
constructor(props){
super(props);
// 其他代码
}
}
如果我们不给 super()
方法传入 props
值,打印 this.props
时现实的就是 undefined
。这是因为源码的 Component
构造函数中需要 Component(props, context, updater)
这三个参数,对于 props 和 context,如果没传入值,则会被初始化为 undefined。
众所周知,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
节点的场景主要包括:(参考官方文档)
以管理焦点为例:
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
:
虽然不能给函数组件添加 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>
)
}
}
效果:
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
值。如果没有对应的 Provider
,value
参数等同于传递给 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
对象来说,当 Provider
的 value
发生变化时,它内部的所有消费组件都会重新渲染。那我们是不是可以通过给 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 是我们常用于维护状态的 hook。我们知道在函数组件中调用 setState 方法时,大概会有以下几个重点:
带着上面三个重点问题,阅读 useState 源码,基本上就能够弄清楚这三点是如何执行的了。
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
的执行流程为:
在每次函数组件执行之前,先将 workInProgress
的 memoizedState
和 updateQueue
属性清空,然后将当前新的 Hooks 信息挂载到这两个属性上。
通过判断 current 树是不是 null 或者 memorizedState
是不是 null 来判断是初始化(HooksDispatcherOnMount
),还是更新(HooksDispatcherOnMount
)。把对应的方法赋给 ReactCurrentDispatcher.current
。这两种方法里面都包括了所有类型的 hooks 及其对应的调用方法。React 根据 current 的不同来判断对应的 Hooks,执行相应的方法。
Component(props, secondArg)
执行函数组件。执行函数组件会把里面的所有内容,包括 Hooks 都重新执行。
最后执行渲染后的操作 finishRenderingHooks
,进行异常处理,并将一些属性置空用于下一次使用,如:currentHook
、workInProgressHook
等。
下面就看一看不同 hooks 及其对应的方法...
HooksDispatcherOnMount
和 HooksDispatcherOnUpdate
分别对应了 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 方法,就定位到对应的方法:mountState
和 updateState
。初始化的内容比较简单,这里不做详细介绍,我们的重点放在 updateState
方法上。定位到该方法会发现,其内部调用了 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
方法核心内容分为四个部分:
以上就是 useState hook 在更新时的详细流程。流程图如下:
作为前端开发人员,XSS 攻击(如果对 XSS 不了解,先看一看这篇 issue)并不陌生。对于如何抵御 XSS 攻击,一般有以下三种方法:
虽然这篇 issue 的重点并不在于如何抵御 XSS 攻击,而是 React 框架是如何来抵御 XSS 攻击的,但是 React 使用的方法之一还是 转义。
React 在渲染 HTML 内容和渲染 DOM 节点时都会将 "'&<>
这几个字符进行自动转义,转义的方法为:
for (index = match.index; index < str.length; index++) {
switch (str.charCodeAt(index)) {
case 34: // "
escape = '"';
break;
case 38: // &
escape = '&';
break;
case 39: // '
escape = ''';
break;
case 60: // <
escape = '<';
break;
case 62: // >
escape = '>';
break;
default:
continue;
}
}
因此,一段恶意代码在被 React 渲染到浏览器前,对浏览器有特殊含义的字符都被转义了,恶意代码在渲染到 HTML 前都被转成了字符串,从而避免了 XSS 攻击。如下:
// 举例:一段恶意代码
<img src="wrong.jpg" onerror ="alert('你被我 xss 攻击啦!')">
// React 转义后输出到 html 中将变为:
<img src="wrong.jpg" onerror ="alert('你被我 xss 攻击啦!')">
了解 React 框架的都知道,React 通过 JSX 来编写的,JSX 实际上就是 React 的一个语法糖,Babel 会将 JSX 转为 React.createElement
语法。举个例子:
<div id="foo">bar</div>
对于上面这段 JSX 代码,Babel 会将其转译为:
React.createElement("div", {id: "foo"}, "bar");
React.createElement
的参数为:
看一看 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;
}
上面源码并不需要看得太详细,我们只需要知道两点:
Symbol
来标记的知道这两点就够了,因为当使用 JSON.stringify()
时,以 Symbol 值作为键的属性会被完全忽略:
JSON.stringify({[Symbol("foo")]: "foo"}); // '{}'
因此,如果从服务端传递来具有攻击性的 JSON 数据想插入到前端页面,但在 Reconcile 阶段,React 会检查 element.$$typeof
是否合法,如果不合法的话就会直接报错。
此外,使用 Symbol.for()
也还有其他好处: 因为 Symbol.for 注册的数据 在 iframe
和 worker
等环境之间是全局共享的,所以可以在不同的应用程序之间传递受信任的元素。同样,如果页面上有多个 React 副本,它们仍然可以“同意”有效的 $$typeof
值。
React 可以通过转义和 $$typeof 标记
来抵御 XSS 攻击。我们通过转义这一章节能够知道,如果是直接插入 HTML 代码,React 会对其进行转义,无法生成 DOM 元素插入进去。但是,在一些场景下,我们难免会需要直接往页面插入 html 代码,因此,React 为我们提供 dangerouslySetInnerHTML
属性来实现这个功能,但是使用这个功能则可能造成 XSS 漏洞。
我们知道 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 算法的判断就很简单:
fiber
对象。key
和 type
都相同,则复用;反之则删除旧节点,新建新节点。如果列表下存在多个节点,diff
算法则需要进行两轮遍历。第一次遍历寻找出公共序列,第二次遍历剩余序列探究是否还有节点可以复用。
首先存在新旧的虚拟 DOM 树(Fiber),新的为命名为 newFiber
,旧的命名为 oldFiber
。为了更简单直观,以数组的形式表达这两棵树(实际上 Fiber 也是单链表结构,和数组很相似)。第一轮遍历步骤如下:
i = 0
开始遍历,将 newFiber[0]
和 oldFiber[0]
比较,判断节点是否可以复用。i++
,继续比较 newFiber[i]
和 oldFiber[i]
,依次往后对比。key
不同导致不可复用:立即跳出循环,结束第一轮遍历。key
相同 type
不相同导致不可复用:将 oldFiber[i]
标记为删除,并继续遍历。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 的第一轮遍历就是:
newFiber[0]
和 oldFiber[0]
对比,key 都等于 1,type 都是 div
,可以复用,继续遍历。newFiber[1]
和 oldFiber[2]
对比,key 都等于 2,type 都是 div
,可以复用,继续遍历。newFiber[2]
和 oldFiber[2]
对比,key 都等于 3,前者的 type 是 div
,后者是 p
,不可复用,给 oldFiber[2]
打上 “删除” 的标签,继续遍历。newFiber[3]
和 oldFiber[3]
对比,前者 key = 4,后者 key = 5,不相同,第一轮遍历结束。根据上面第一轮遍历的结果,我们可以知道 key = 0 、1 的节点都是可以复用的,oldFiber[2]
需要删除,newFiber[2]
需要新增。
当第一轮遍历完成后,一般会存在四种情况:
newFiber
和 oldFiber
同时遍历完。这是最乐观的结果,因为什么都不用处理了。newFiber
遍历完,oldFiber
还有剩余。给 oldFiber
剩余节点都打上 “删除” 标签。newFiber
存在剩余,oldFiber
都遍历完。新增 newFiber
中剩余的节点即可。newFiber
和 oldFiber
都剩余。进入第二轮遍历。最后一种情况是最常见的,下面也主要讲一讲针对这种情况,第二轮遍历如何开展:
map
数据结构,把剩余的 oldFiber
节点都存入其中,以 key
为键,以节点为值,进行存储。newFiber
剩余的节点.在遍历 newFiber
剩余节点时,我们需要以相对位置作为参考依据,即判断节点的相对位置是否改变,如果改变则进行移动。此时需要引入两个记录变量:oldIndex
和 lastPlacedIndex
。
oldIndex
是用来记录当前 newFiber
节点对应的 oldFiber
节点的索引位置。首先都是基于 newFiber
和 oldFiber
剩余序列进行第二轮遍历,如果 newFiber[i]
可复用的节点为 oldFiber[j]
(key 相同,type 相同),则 oldIndex = j
。
lastPlacedIndex
是用来记录当前最后一个可复用的 oldFiber
节点所对应的索引位置。lastPlacedIndex
会在第二轮遍历中,根据 oldIndex
的情况进行改变的,即记录前面匹配上的 oldFiber
节点们的索引最大值。
在第二轮遍历过程中,当 newFiber[i]
找到了可复用的 oldFiber[j]
节点时,oldIndex = j
,此时还需要拿 oldIndex
和 lastPlacedIndex
进行对比:
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>
}
i = 0
开始遍历 newFiber
剩余序列,第一个就是 <div key = '5' >我是 5 号</div>
。map
中找到对应的 oldFiber
节点,该节点在 oldFiber
剩余序列中的索引为 oldIndex = 1
。lastPlacedIndex = 0
< oldIndex
,说明该节点和上一个节点在新旧数组中的相对顺序是相同的,不需要移动。因此, lastPlacedIndex = oldIndex =1
。newFiber[1]
,能够在 map
中找到对应的 oldFiber
节点,该节点在 oldFiber
剩余序列中的索引为 oldIndex = 2
。lastPlacedIndex = 1
< oldIndex
,说明该节点和上一个节点在新旧数组中的相对顺序是相同的,不需要移动。因此, lastPlacedIndex = oldIndex =2
。newFiber[2]
,能够在 map
中找到对应的 oldFiber
节点,该节点在剩余序列中的索引为 oldIndex = 0
。lastPlacedIndex = 2
> oldIndex
,说明**在 oldFiber
中,该节点的位置在此前遍历的节点之前,而在 newFiber
中,该节点在此前遍历的节点之后。因此,需要将该节点右移。newFiber
遍历完毕,第二轮遍历结束。以上就是 diff
算法的全部流程。如有错误,欢迎指出~。
Hooks
的中文解释为 “钩子”,而 React Hooks
则是在 v16.8 版本发布的一种全新的API,可以说是颠覆了 React 一直以来的类组件的写法模式。
写过 React 的人都知道,我们刚入门所接触的 React 都是从类组件开始,如下:
class HelloWorld extends React.Component {
render() {
return <div>Hello World !</div>;
}
}
从上面看,写组件类的时候好像不需要太多的代码量。但实际上,当我们的组件状态增多,业务逻辑变复杂时,组件类将会变得十分复杂(写项目的时候会有明显感觉)。
组件类便于我们理解组件内部逻辑,管理组件各种状态,但也存在一些缺点。Redux 的作者 Dan Abramov 总结了组件类的几个缺点:
- 复杂的组件难以拆分,也不便于测试。
- 业务逻辑分散在组建的各个方法中,容易出现重复逻辑和关联逻辑,造成代码冗余。
- 组件类引入了复杂的编程模式,比如 render、props 和高阶组件。
函数组件顾名思义,就是用函数的形式来编写组件的内部逻辑和功能。但是,我们刚接触面向对象编程的时候就知道,类是对一系列变量和方法的封装,其每个对象实例具有自己的状态属性,而函数只是一个过程,实现某种功能或者返回某个值。
React 之前也有函数组件,如下:
function HelloWorld(){
return <div>Hello World !</div>
}
显然,这种函数组件虽然可以实现某种特定的功能,但是它不包含状态,也不支持生命周期方法,因此无法用于很多情景,更别说取代类了。
为了解决函数组件的不足之处,React 提出了 Hooks
的概念,即通过 “钩子” 的形式把函数中需要的状态 ”钩“ 进来。因此,我们利用 React Hooks
可以实现完全不用“类”,写出一个全功能的组件。
React Hooks 中有几种常用的钩子函数,分别是:
1. useEffect()
2. useState()
3. useRef()
3. useContext()
4. useReducer()
下面,我们将分别介绍这五种常用钩子函数的作用。(至于其他钩子函数,可以自行百度或查看官网文档)
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()
也可以一眼看出大概是做什么用的,它就是给我们的函数组件添加和修改状态的钩子函数。因为函数组件没有状态,所以把需要的“状态”都存放在钩子里。
我们举一个例子,创建一个 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()
我们应该不陌生,因为 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节点进行相应的操作。
新手入门 React 的时候知道组件间最简单的传递消息的方式是:子组件通过props
的方式获取父组件的信息。
然而,当我们需要传递信息的组件跨级过多,容易导致传递信息方式过于繁琐复杂,甚至容易出现很多错误,以及调试困难等问题。因此,React 也为我们提供了 useContext()
钩子函数实现信息共享。
(原理与 props 大同小异)
举个例子,我们希望组件 School
可以共享信息给 Student
和 Teacher
。
第一步使用 React Context API,在组件外部建立一个 Context。
export const SchoolContext = React.createContext({});
接着,我们利用 School
的 Provider
来封装组件 Student
和 Teacher
。
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
的层级更高,而不只是 Student
和 Teacher
的父级,可能是更高的祖先级时,利用 props 一级一级传下去就会很复杂。
此外,我们还可以用另外一种眼界来看待 useContext()
。如果我们直接把 Provider
包在我们工程的最外层组件上,那就可以把最外层组件内的一些信息当作是全局信息,供所有子组件实用,那是不是很方便?
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 来实现状态管理!)
高阶组件(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
。
定义一个简单并且好理解的场景:
有若干个文本,每个文本后都具有一个 button
按钮,点击 button
后,会在数据库中随机选取一个人名,并在对应的文本中显示。
其中,有一个受保护的文本,如果随机到某个指定人名,则会将其转化为 anonymous
达到 “匿名保护“。而另一个文本则不存在 “保护”,即随机到哪个人名就显示哪个。
可能这个例子过于简单,代码量也比较少,所以并不能直观地看出 HOC
能够帮助我们减少代码量和增加便捷。但是这个例子是通俗易懂的。
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>
)
}
}
结果演示:
dou
的名字时,会变为 anonymous
:如果不使用 HOC
来包裹,则需要把 button
,随机选名等内容都写到对应的文本组件中,这也就造成了代码的重复。
因此,HOC
高阶组件是一种很好的复用组件的方法。
当然,HOC
的存在是为了把一些重复的逻辑和功能剥离出来从而提供给需要的组件使用。但是,我们不应该在 HOC
中修改传入的组件,这样会操作一些意想不到的问题。
还是那句话,在 React
中,我们支持使用 组合 的方式来实现组件及组件间的联系。
当我们需要把父组件 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.num1
、this.state.num2
和 this.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
实现的方案。
首先,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
方法来实现共享父组件的属性和方法呢?看代码:
// 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>
)
}
上述代码的效果如下:
当我们点击子元素 1 或子元素 2 的 click
按钮时,parent 的 count 属性值会增加。因此,子元素 1 和 2 的父元素 count 值都会 + 1:
比如点击 子元素 1 的 click:
因此,cloneElement
通过修改目标元素,并返回一个添加了新属性或修改了子元素的新元素供我们使用。
众所周知,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
是 React
里触发组件更新的方法之一,也频频会出现在我们的日常开发中。下面主要针对一些 setState
更新的问题谈一谈,核心内容包括两大方面:
我们知道 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
输出又是多少呢?
结果是: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>
)
}
执行结果为:
我们会发现使用函数回调的方法 + 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);
// 使用函数返回
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);
从上面的例子可以看出,使用 hooks
的方法来设置,和类是一样的。核心问题还是在:基于哪个值进行计算。所以在批量更新的基础上,我们一定要弄清楚到底是基于什么时候的 state
来计算的。
在我先前写过的一个 issue 中,提到了 setState
批量更新和立即更新的概念以及判断方法 (#19 )。这里不详细解释什么时候是批量更新,什么时候是立即更新了,有兴趣可以去看一下我的 issue,也可以去看一看源码。简单总结一下:
setState
,会执行批量更新。addEventListener
绑定的)、setTimeout
、setInterval
、Promise.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 (
// ...
)
}
我们可以从结果中看出,输出变成了 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);
输出结果如下:
我们会惊奇地发现两个问题:
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);
首先所有的 count
依旧是刚开始执行 setTimeout
那个闭包里的 count
值,因此才会有 0 + 1
、0 + 1
、0 + 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);
首先不用管 state123: 0
的问题,因为我们上面说了,打印的 count
都用的是执行 setTimeout
那个闭包里的值。但是不同点在于 打印 render:
后的值变为了 1、2、3。这个原因就不用多说了,因为依赖的值都是上一次修改后的 count
,也是在这个基础上每次 + 1,而不是执行 setTimeout
那个闭包里的 count
值。
首先举一个例子:
useEffect(() => {
// 定时器,每一秒 count + 1,触发一次渲染
setInterval(() => {
set(count + 1)
}, 1000);
}, [])
// 触发重新渲染就会输出一次
console.log('render:', count);
上面代码输出的结果会是什么样的?看图:
render
后面输出的值一直都 1,并且只输出了两次。首先不用管输出了几次,这个可能和我上面提到的优化性能有关或者浏览器的原因。问题在于:为什么每次更新输出都是 1?
实际上这个问题也很简单,也就是我上面提到的闭包问题。因为这个定时器是在 count = 0
的那个闭包环境下,所以定时器其实每次都在执行 0 + 1
,所以就每次都会渲染为 1。
类似的,如果我们给一个按钮点击事件中加入 setTimeout
,延迟设置为 3 秒,我们在 3 秒内疯狂点击 5 次,结果又是怎么样呢?
// 点击事件(React 合成事件)
const click = () => { // 我们在 3 秒内疯狂点击 5 次
setTimeout(() => {
setCount(count + 1);
}, 3000)
}
首先还是不用管为什么只输出两次,理论上应该是输出五次,但是存在某种优化,所以问题下面不再重复。
我们会发现点击 5 次,输出的都是 render: 1
,这是为什么呢?实际上也和上面的原因类似,**因为 setTimeout
点击需要 3 秒才能触发,在这 3 秒内狂点,无非是创建了多个 setTimeout
,但是这些计时器中 count
的值还是在触发计时器时那个环境里的 count
值,也就是 0。所以会输出触发多次渲染但都是 1。
那么如何解决这些问题呢?
我们可以使用函数返回的方式来 setState
,从而建立依赖,取到上一次修改的 count
值。 setInterval
例子的更改方案如下:
useEffect(() => {
// 利用函数返回
setInterval(() => {
setCount(count => count + 1);
}, 1000);
}, [])
// 触发重新渲染就会输出一次
console.log('render:', count);
从上面结果可以看出,我们每秒输出的 count
值终于满足我们需要的 1、2、3、4...。因为函数返回的方式每次传入的参数是我们上一次修改的 count
状态值,因此每次计算都会基于上一次的值进行计算,而不是最开始那个闭包里的值。
同样的,如果在 setTimeout
中设置 3 秒延迟,并且以函数返回的形式来修改状态,是不是也可以解决我们上面狂点但是 count
只加 1 的问题?我们试试:
// 点击事件(React 合成事件)
const async = () => { // 在 3 秒内狂点 5 次
setTimeout(() => {
setCount(count => count + 1);
}, 3000)
}
从上面的结果可以看出,我们在 3 秒内狂点 5 次,结果是 1、2、3、4、5,满足我们的要求!
setState
的更新涉及到我上面提到的两大类问题:
但是,在 React Hooks
中,我们还可能会被闭包所影响,关键在于:使用的值是哪个闭包里的值。
当能够把上述问题都给搞清楚的时候,使用 setState
就可以避免大多数问题了。
在介绍 React Scheduler 的 workloop 过程中(链接),我提到了在 React 的 concurrent 模式下,每一个更新任务都会有对应的优先级,从而构成最小堆再进行调度。
那么,React 中的 Scheduler 调度的优先级是什么呢?是我们常说的 Lane
,还是另有其他?
凡是了解 React 源码的都知道,Concurrent 并发模式下, React 内部存在三种优先级机制:
下面本文将介绍 React 中的优先级,和优先级之间的一个转化。
基于 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;
源码(事件分类):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 分别对应哪些事件。
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;
}
源码(优先级分类):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; // 最低优先级,任务可闲置
那么 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;
}
从上面可知,React 18 的 Concurrent 模式下具备三种优先级,当我们产生 update 更新对象后,会经过以下几个流程:
其实很早就应该写这篇笔记了, 但是因为我过于 繁 (lan) 忙 (duo) , 直到现在才想起来写...
咱们可以先看下面的 React生命周期图
👇
React
的生命周期从广义上来看, 主要分为三大阶段, 即 挂载、渲染、卸载. 这里我们把React生命周期
划分为两大类: 挂载卸载过程 和 更新过程.
挂载时, 会先触发 componentWillMount
函数, 接着 render
渲染, 最后 componentDidMount
即渲染后执行的内容.
卸载时, 会触发 componentWillUnmount
函数, 以执行卸载组件前需要执行的内容, 如 删除计时器、删除监听函数 等等.
React
更新主要由两个原因触发: props
或 state
改变.
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生命周期
这篇文章并不是什么技术文,更多地偏向于一篇总结文。之所以这篇短文,是受面试官们的影响。我发现,或许 1 、2 面时面试官会停留于相关技术点、甚至技术细节的层面进行提问;然而,到了 3 面或者部分 2 面时,也就是所谓的 “主管面” 、“老板面” 时,被问的问题则更加深刻,更加宏观。
因此,受到面试官的影响,我觉得使用了这么久的 React,自己也确实应该总结一下对 React 的理解和认识。
我认为与其介绍 React,不如从 React 给我们带来了什么的角度进行讲解,想学习 React 及其相关细节,看官方文档来得更直接。
当我们不使用 React 构建应用程序,而是直接使用原生 JavaScript 构建应用程序时,我们需要负责管理应用程序的状态和界面,这可能会导致代码变得冗长和难以维护。而 React 通过引入组件化开发模式和声明式编程,解决了这些问题。此外,通过对使用过程的总结,我觉得 React 有以下的优势:
组件化开发模式:React将应用程序划分为多个小组件,每个组件都有自己的状态和行为(这并不局限于 React,Vue 也是如此)。这种组件化开发模式使得代码更易于维护、测试和重用。例如,一个购物车应用程序可以划分为多个小组件,如购物车图标组件、购物车列表组件、商品数量选择器组件等。
声明式编程:开发者只需关注界面应该呈现的状态,而不用关心如何操作 DOM。React 通过使用虚拟 DOM 来管理 DOM,只有在必要的情况下才更新 DOM,从而提高了性能。例如,在一个 Todo 应用程序中,开发者只需关注每个任务的完成状态,而不用关心如何更新 DOM。
高效性能:React采用虚拟 DOM,只有在必要的情况下才更新 DOM,从而提高了性能。此外,React还具有生命周期方法和状态管理机制,可以帮助开发者优化应用程序的性能。例如,在一个电子商务网站中,当用户添加商品到购物车时,React可以仅更新购物车组件的状态和 DOM,而不用重新渲染整个页面。
Fiber 架构优化:在 React 16 中,Fiber 架构诞生。Fiber 作为一种单链表树的结构,每个组件都会被标识为一个 Fiber 节点,并且按照它们在 DOM 树中的顺序排列。每一个节点不仅有自己的 child 节点的指针,还有sibling 节点指针以及 return(父亲)节点的指针。这使得虚拟 DOM 更新时的递归操作可以被切片为多个小任务,并使用 requestIdleCallback 方法进行更新,从而减少浏览器渲染被阻塞的问题,大大提高用户的体验感。
社区支持和生态系统:React 拥有庞大的社区支持和生态系统,有许多优秀的第三方库和工具可以帮助开发者更好地使用React。例如,Redux是一个流行的状态管理库,React Router是一个用于管理路由的库。
综上所述,React 相比原生 JavaScript 的优势在于其组件化开发模式、声明式编程、高效性能和庞大的社区支持和生态系统。这些优势可以帮助开发者更轻松地构建高质量的应用程序。
经常会有人问 setState
是 “异步” 还是 “同步”,以及在使用的过程中如果 setState
后 console.log
一下 state
,会发现并不是最新的结果。因此,基于上面的情形,我们会认为 setState
是 “异步” 的。当事实上呢?
从 JavaScript
语法层面角度来看,setState
就是一个普通的函数,自然就是同步代码。
但是,我们问同步和异步并不是代码层面的,而是调用该方法之后更新 DOM
是同步还是异步的。如果是同步,那么就应该返回最新的状态,反之,则是异步。
setState
更新 DOM
的同步和异步实际上是基于调用环境的。
如果我们去看 setState
的源码就会发现:
performSyncWorkOnRoot
。setState
之后,会创建一个 update
对象加入到 Fiber
节点的等待队列中,等待调度渲染。scheduleUpdateOnFiber
方法中找到根 Fiber
节点,然后调用 performSyncWorkOnRoot
方法进行渲染。performSyncWorkOnRoot
进行渲染之前,存在一个 executionContext
参数(执行上下文)进行判断,用于根据环境决定是否批量执行。performSyncWorkOnRoot
方法进行渲染。反之,则立刻调用 performSyncWorkOnRoot
方法。因此,是否批量执行也就决定了更新 DOM
是同步还是异步。
React
可以控制的范围内调用,如生命周期函数、合成事件的处理函数、普通的执行函数等,React
会让状态更新变为批量执行,即把需要更新的状态合并到一起,再更新 DOM
。JavaScript
控制的范围内调用,如 setTimeout
、setInterval
、原生的事件处理函数、Ajax
回调函数等,React
中的 setState
被调用后会立刻更新 DOM
。因此,同步和异步取决于更新是立即的还是批量的。而批量更新的好处就是能够先合并状态,再统一进行更新,这能够一定程度上提高性能。
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>
)
}
}
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>
)
}
}
是否批量执行决定了 setState
的 “异步” 和 “同步” 的更新差异。
是否批量执行这个问题主要出现在 React 17
中,如果是使用 React 18
则无需担心,因为 React 18
已经全面使用批量执行了。
在 React 官方文档中说到:useState 中的 setState 是保持不变的,是不会随着函数组件的重新执行而重新创建。
在 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 不变的特性。从实现原理来看,还是很基础的。因此,很多复杂操作都是由基本的原理堆叠出来的,学好基础还是很重要的。
所谓 “事件” 也就是我们常用的如 点击事件、键盘事件等。
以点击事件为例。在 html
中,我们可以通过在 DOM
元素的 onclick
属性中赋予一个方法的字符串即可;在 JS
中我们也可以通过 addEventListener
来绑定事件。
<button onclick="count()" />
React
中也一样,不过略微不同。React
事件的命名采用小驼峰式(camelCase),而不是纯小写。并且添加到 DOM
中的形式是 jsx
特有的 {方法名}
的形式。
<button onClick={count} />
然而, addEventListener
是 JS
的方法,所以不受影响。
我们举一个计数器的例子,每点击一次,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
的特点。我们可以把一个大的东西,细分为一系列小的零件,从而满足一些列需求,如复用等。
组件又可以分为函数组件和类组件:
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
属性可以理解为:当该组件被调用时,调用方传递给该组件的数据(类似于函数的形参)。
举个例子:
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>
)
}
上面两种代码呈现的效果是一样的,甚至我们觉得不组件化代码量更少。但是这只是因为我们的需求比较简单,如果复杂一些,它会暴露很致命的弱点:不够灵活,无法根据需求进行修改和复用。
有时候我们会需要复用一些状态(业务)逻辑,常用的方法有 HOC
和 render props
模式,这两者我也在以往的 issue
中提过。但是这两种方法也容易引起一些问题,如:
此时,我们可以通过 自定义Hook
在不增加额外组件的情况下实现一些状态逻辑的复用。
自定义 Hook
实际上就是一个函数,函数中可能使用了如 useState
、useEffect
等其他钩子来维护状态和触发副作用以实现某种逻辑或功能。我们约定俗成的以 use
为开头命名自定义钩子,告知他人这是一个自定义 Hook
。
Hook
是一种复用状态(业务)逻辑的方式,其内部通常会有 state
等属性。Hook
可以被多次使用,但是每个使用者的 state
是完全独立的,因为它不复用 state
本身。
简而言之, Hook
的每次调用都有一个完全独立的 state
。因此,可以在单个组件中多次调用同一个自定义 Hook
。
下面举几个例子,便于理解。
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;
}
useToggle
的主要功能是:记录当前切换键的状态值,状态值非 0 即 1。
useToggle
实际上是我们日常开发中经常会用到的功能,如:页面主题 light
和 dark
切换、按钮的 disabled
状态切换等等。
const useToggle = (initialValue) => {
const [value, setValue] = useState(initialValue);
/**
* 使用 useCallback 包裹:因为该函数一直都是保持不变的。如果 toggle 方法被当作 props 属性传递给子组件,可以避免子组件的不必要渲染。
* 如果不包裹,每次该 hook 内部使用 setState 时,都会重新创建 toggle 函数。如果传递给子组件,就会引发子组件的重复渲染
*/
const toggle = useCallback(() => {
setValue(v => !v);
}, []);
return [value, toggle];
}
在日常开发中,可以根据需求创建对应的 Hook
,有助于灵活复用状态逻辑,减少代码量且便于后期维护。
首先,我们需要知道:使用 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
包裹起来,以及使用 useCallback
、PureComponent
、React.memo
等方法解决一些不必要的更新。
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 都会触发:
点击 click count 按钮时,会触发第二和第三个 useEffect:
点击 click num 按钮时,会触发第二和第四个 useEffect:
上面的实验效果和我们的预想的是一样的,useEffect 的第二个参数:
undefined
,则会在每一次组件重新渲染时都会触发回调。[]
,则会在组件第一个挂载时触发回调,之后不再触发。那么这是为什么呢?要想了解根本原因,还是需要去阅读 useEffect 的源码。
useEffect hooks 执行挂载(mount)和更新(update)的源码在 ReactFiberHooks.js 中:
https://github.com/facebook/react/blob/main/packages/react-reconciler/src/ReactFiberHooks.js
首先我们找到 mountEffectImpl
和 updateEffectImpl
函数,它们分别对应了执行挂载和更新时执行的操作。
// 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,
);
}
mountEffectImpl
和 updateEffectImpl
的源码我没有做删减,不过对一些核心部分添加了说明。仔细阅读了之后会发现,如果第二个参数不传入的话(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、依赖项也没有改变,那么就说明不需要执行回调,反之就需要执行回调。
以上就是 useEffect 第二个参数的源码判断,从源码可以更清晰地了解到什么时候会触发回调,以及为什么会触发回调。
对于组件复用,我在 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>
)
}
}
这里引用官方文档中的例子。
当我们的鼠标移动时,在鼠标的位置添加一串文本显示当前位置或插入一个图片,随着鼠标的移动,这些内容也会跟着移动。
鼠标附属内容的子组件 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>
);
}
}
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
架构,每个组件都有对应的 fiber 节点,而 fiber 节点也有一个 Tag
属性用于表示当前组件是什么类型的,如:函数组件、类组件、Fragment
、dom
节点、文本 等等。下面从源码中抠出了所有 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 返回的组件
在先前的一个介绍 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;
}
})
}
我们知道 React 的源码中有四个核心部分:
后面三者的关系如下图所示:
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 中有两个任务队列:taskQueue
和 timerQueue
,简单地可以理解为:
既然如此,Scheduler 每次会从 taskQueue 中取一个当前优先级最高的任务来处理。(ps:每一次触发更新就是一个 Task)
每个任务执行时就会由 react-reconciler
来进行协调,具体操作包括:Fiber 自顶向下节点深度遍历,对有变化的节点进行记录和标注,再自底向上地收集副作用到 Fiber 根节点,每一个当前被操作的 Fiber 节点被称为 workInProgress
,并且会执行 performUnitOfWork
(其中包括 beginWork
和 completeWork
等方法)。
源代码: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)会由全局指针 workInProgressRoot
和 workInProgress
记录当前的 root node 和 fiber node 的状态,以等待下一次的空闲时间从被暂停的地方由 Fiber workLoop 继续执行。
上面介绍了 Scheduler 和 Fiber 各自的 workLoop 过程,Scheduler 的 workLoop 会每次从 taskQueue 中取优先级最高的任务来执行,而 Fiber 每次协调时如果被中断,会存在全局指针来记录被中断的位置,以便恢复任务。
但是,在下一次的时间切片中,一般会出现两种情况:
1、如果没有出现优先级更高的任务
taskQueue 的堆顶就是上一次被中断的任务,因此
workInProgressRoot
和workInProgress
全局变量就不会改变,可以直接取出中断位置的信息,继续处理剩余的内容。
2、如果在这个过程中出现了比 A 优先级更高的任务 B
此时 B 就会在 taskQueue 的堆顶,此时就会重新初始化
workInProgressRoot
和workInProgress
全局变量,用于 B 任务的 Fiber 协调。这也就意味着会丢失 A 之前中断位置的信息,因此执行完 B 之后再执行 A 的话,就是从头重新开始。正如下图所示:
总结:
因此,每次拿到新的时间片以后,Scheduler 的 workLoop 都会判断本次 Task 的优先级和上一次时间片到期中断的 Task 的优先级是否一样。如果一样,说明没有更高优先级的更新产生,可以继续上次未完成的协调;如果不一样,说明有更高优先级的更新进来,此时要清空之前 Fiber 协调的内容。等高优先级更新处理完成以后,再次从根节点开始处理低优先级更新。
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 提出了 Concurrent 模式,为异步更新提供了可能。
在 React18 中,Concurrent 模式是否开启,需要根据触发更新的上下文来决定。
event
、setTimeout
、network request
的 callback 中触发,React 会采用 Legacy 模式。Suspense
、useTransition
、OffScreen
相关,React 就会采用 Concurrent 模式。在这篇文章中,我们就来聊一聊 useTransition
的原理和使用,其中会涉及到调度、上下文、优先级、任务中断、时间切片等概念,这也将有助于更深入地了解 Concurrent 模式下的异步更新。
useTransition 是 React18 提供的新的 API。使用 useTransition,更新的协调过程会采用可中断的 Concurrent 模式,能给我们带来用户体验上的提升。
ps :最近实在太忙...等有空一定更新...
在 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
中组件复用的一种常见形式。
今天重新阅读了 Redux 文档,受益匪浅。因此,也突发奇想地在这里结合官方文档的使用建议和实际开发过程中的常用方法来整体描述一下在 React 中创建和使用 Redux 的完整流程,以供后续复习。
准备工作:安装 Redux 核心库和工具包 npm install react-redux @reduxjs/toolkit --save
第一步当然是创建 store
,这也是 Redux 的状态大脑(统一管理部分),直接通过 configureStore
即可创建。
import { configureStore } from '@reduxjs/toolkit';
// redux store
export const store = configureStore({
reducer: {
// key - value 键值对形式注册切片
}
});
如果是单页面项目,则不需要分开管理状态。但如果是多页面,并且各页面的状态和数据需要单独管理,那么我们就可以使用切片的功能来把 store
划分为多个切片,单独管理。
使用方法:
createSlice
创建切片。name
、initialState
、reducers
等属性。initialState
中存储的是初始化时的状态值,之后使用 action 和 reducer 修改时也会更新。reducer
中的形参 state
是一个代理对象,可以直接修改,作用会同步到该切片的状态中;action
是一个 Action 对象,其属性为 {type: 函数名, payload: 传入参数}
,所以传入的值可以在 action.payload
中取到。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;
在上一节中,已经注册并配置好切片对象并导出其 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 中如何使用并修改数据了。
既然上面已经创建好了 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
方法中传入两个参数,mapStateToProps
和 mapDispatchToProps
方法,这两个方法分别返回两个对象,分别获取 store
中某个切片对象的属性和对应 actions
对象暴露出来的方法,便于后续使用。
在 mapStateToProps
方法中,传入 state
,指整个 store
。state.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
范畴。
在函数组件中,我们需要借助 useSelector
和 useDispatch
钩子来实现上述的需求。
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));
}
};
从上面的例子可以看出,我们使用 useSelector
和 useDispatch
更加方便地实现了 connect
高阶组件的功能。简单地概括就是:
useSelector
钩子获取到对应切片对象,之后直接调用该对象中的属性。useDispatch
钩子传入 action
对象,从而使用切片对象 reducers
中对应的 reducer
函数修改属性值,并触发重新渲染。在先前讲到的 元素渲染部分提到,我们可以通过 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
元素的生命周期,比如我之前写的一篇生命周期那些事儿。
A declarative, efficient, and flexible JavaScript library for building user interfaces.
🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.
TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
An Open Source Machine Learning Framework for Everyone
The Web framework for perfectionists with deadlines.
A PHP framework for web artisans
Bring data to life with SVG, Canvas and HTML. 📊📈🎉
JavaScript (JS) is a lightweight interpreted programming language with first-class functions.
Some thing interesting about web. New door for the world.
A server is a program made to process requests and deliver data to clients.
Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.
Some thing interesting about visualization, use data art
Some thing interesting about game, make everyone happy.
We are working to build community through open source technology. NB: members must have two-factor auth.
Open source projects and samples from Microsoft.
Google ❤️ Open Source for everyone.
Alibaba Open Source for everyone
Data-Driven Documents codes.
China tencent open source team.