前言
大家好,我是麦西。
近来发现 React 官方文档更新了。
仔细想来,学习使用 React 这么久还没有好好拜读过官方文档。于是认真读写了一遍官方教程。这里把学到的一些知识记录下来,分享给大家。
纯函数组件
React 官方推荐全面拥抱 hooks。这也就意味着,类组件已经是过去式了。这一点从官方文档也可以看出,新的官方文档已经不再介绍和使用类组件了。
部分 JavaScript 函数是存粹的,这类函数被称为纯函数。
纯函数通常具有以下特征:
- 只负责自己的工作。它不会更改函数调用前就存在的对象或变量。
- 输入相同,则输出相同。给定相同的输入,纯函数总是返回相同的结果。
可简单的理解为,函数的执行不依赖且不改变外界。纯函数的优点是没有副作用,可移植性好。在 A 项目能够用,B 项目想要使用直接拿过来就好了。可以通过下面这几个例子感受下纯函数的概念:
// 纯函数
function add(a, b) {
return a + b;
}
// 非纯函数,函数执行依赖外界变量all
let all = 100;
function plus(a) {
return all + a;
}
// 非纯函数,函数执行改变了外界变量obj
let obj = {};
function fun(a) {
obj.a = a;
return a;
}
// 非纯函数,函数的执行依赖外界getCount()
async function(a, b) {
const c = await getCount(); // 副作用
return a + b +c;
}
// addConst是否是纯函数存在争议,我更倾向于它是
const data = 100;
function addConst(a) {
return a + data;
}
最后一个例子,addConst
依赖于 data
, 但 data
是常量。这种情况存在争议,有人认为是,也有人认为不是。我更倾向于addConst
是纯函数。
官方建议建议我们使用纯函数来编写组件。非纯函数编写的组件可能会存在副作用,造成意料之外的影响。下面是一个非纯函数组件的例子:
let guest = 0;
function Cup() {
guest = guest + 1;
return <h2>Tea cup for guest #{guest}</h2>;
}
export default function TeaSet() {
return (
<>
<Cup />
<Cup />
<Cup />
</>
);
}
页面显示的结果是:
Tea cup for guest #1
Tea cup for guest #2
Tea cup for guest #3
在上面这个例子中,Cup
是非纯函数组件,它依赖于外界 guest
变量。由于多个 Cup
组件依赖的是同一个变量guest
。当我们每次使用组件的时候,都会修改guest
,这就会导致每次使用组件都会产生不同的结果。
因此,为了避免出现意想不到的结果,我们最好使用纯函数编写组件。
渲染和提交
在 React 应用中一次屏幕更新都会发生以下三个步骤:
1. 触发
也就说触发一次渲染。有两种原因会导致组件渲染:
-
组件的初次渲染: 当应用启动时,会触发初次渲染。也就是
render
方法的执行。
import Image from './Image.js';
import { createRoot } from 'react-dom/client';
const root = createRoot(document.getElementById('root'));
root.render(<Image />); // 初次渲染
- 组件或者其祖先的状态发生了改变
一旦组件被初始渲染后,我们可以通过 set函数
更新组件状态来触发之后的渲染。
2. 渲染
在我们触发渲染后,React 会调用组件来确定要在屏幕上显示的内容。渲染中
即 React 在调用你的组件函数。
-
在进行初次渲染时, React 会调用根组件。
-
对于后续的渲染, React 会调用 内部状态更新 触发了渲染 的函数组件。
3. 提交
在渲染(调用)您的组件之后,React 将会修改 DOM。
-
对于初次渲染, React 会使用
appendChild()
DOM API 将其创建的所有 DOM 节点放在屏幕上。 -
对于再次渲染, React 将应用最少的必要操作(在渲染时计算),以使得 DOM 与最新的渲染输出相互匹配。
React 仅在渲染之间存在差异时才会更改 DOM 节点。 如果渲染结果与上次一样,那么 React 将不会修改 DOM。
在渲染完成并且 React 更新 DOM 之后,浏览器就会重新绘制屏幕。
useState
使用 state 需要注意以下几点:
-
当一个组件需要在多次渲染记住某些信息时,使用 state 变量。
-
调用 Hook 时,包括 useState,仅在组件或者另一个 Hook 的顶层作用域调用。
-
state 是隔离且私有的。也就是说,将一个组件调用两次,他们内部的 state 不会互相影响。
1. state 如同一张快照
当 React 重新渲染一个组件时:
- React 会再次调用你的函数
- 你的函数会返回新的 JSX
- React 会更新界面来匹配你返回的 JSX
作为一个组件的记忆,state 不同于在你的函数返回之后就会消失的普通变量。state 实际上“活”在 React 本身中——就像被摆在一个架子上!——位于你的函数之外。当 React 调用你的组件时,它会为特定的那一次渲染提供一张 state 快照。你的组件会在其 JSX 中返回一张包含一整套新的 props 和事件处理函数的 UI 快照 ,其中所有的值都是 根据那一次渲染中 state 的值 被计算出来的!
import { useState } from 'react';
export default function Counter() {
const [number, setNumber] = useState(0);
return (
<>
<h1>{number}</h1>
<button
onClick={() => {
setNumber(number + 1);
setNumber(number + 1);
setNumber(number + 1);
}}>
+3
</button>
</>
);
}
以下是这个按钮的点击事件处理函数通知 React 要做的事情:
-
setNumber(number + 1)
:number
是 0 所以setNumber(0 + 1)
。
React 准备在下一次渲染时将number
更改为 1。 -
setNumber(number + 1)
:number
是 0 所以setNumber(0 + 1)
。
React 准备在下一次渲染时将number
更改为 1。 -
setNumber(number + 1)
:number
是 0 所以setNumber(0 + 1)
。
React 准备在下一次渲染时将number
更改为 1。
尽管你调用了三次 setNumber(number + 1)
,但在这次渲染的 事件处理函数中 number
会一直是 0,所以你会三次将 state 设置成 1。这就是为什么在你的事件处理函数执行完以后,React 重新渲染的组件中的 number
等于 1 而不是 3。
为了更好理解,我们看下面这个例子:
import { useState } from 'react';
export default function Counter() {
const [number, setNumber] = useState(0);
return (
<>
<h1>{number}</h1>
<button
onClick={() => {
setNumber(number + 5);
setTimeout(() => {
alert(number);
}, 3000);
}}>
+5
</button>
</>
);
}
点击+5 后,弹出的数字是 0,而不是 5. 点击按钮后的操作:
setNumber(0+5)
js setTimeout(() => { alert(0) })
2. 将 state 加入队列
React 会对 state 更新进行批处理。在上面的示例中,连续调用了三次setNumber(number + 1)
并不能得到我们想要的结果。
React 会等到事件处理函数中的所有代码都运行完毕再处理你的 state 更新。
我们可以通过更新函数来在下次渲染之前多次更新同一个 state。比如:
import { useState } from 'react';
export default function Counter() {
const [number, setNumber] = useState(0);
return (
<>
<h1>{number}</h1>
<button
onClick={() => {
setNumber((n) => n + 1);
setNumber((n) => n + 1);
setNumber((n) => n + 1);
}}>
+3
</button>
</>
);
}
下面是 React 在执行事件处理函数时处理这几行代码的过程:
-
setNumber(n => n + 1)
:n => n + 1
是一个函数。React 将它加入队列。 -
setNumber(n => n + 1)
:n => n + 1
是一个函数。React 将它加入队列。 -
setNumber(n => n + 1)
:n => n + 1
是一个函数。React 将它加入队列。
当你在下次渲染期间调用 useState 时,React 会遍历队列。之前的 number state 的值是 0,所以这就是 React 作为参数 n 传递给第一个更新函数的值。然后 React 会获取你上一个更新函数的返回值,并将其作为 n 传递给下一个更新函数,以此类推:
更新队列 | n | 返回值 |
---|---|---|
n => n + 1 | 0 | 0 + 1 = 1 |
n => n + 1 | 1 | 1 + 1 = 2 |
n => n + 1 | 2 | 2 + 1 = 3 |
看看下面这个例子:
import { useState } from 'react';
export default function Counter() {
const [number, setNumber] = useState(0);
return (
<>
<h1>{number}</h1>
<button
onClick={() => {
setNumber(number + 5);
setNumber((n) => n + 1);
setNumber(42);
}}>
增加数字
</button>
</>
);
}
以下是 React 在执行事件处理函数时处理这几行代码的过程:
-
setNumber(number + 5)
:number
为 0,所以setNumber(0 + 5)
。React 将 “替换为 5” 添加到其队列中。 -
setNumber(n => n + 1)
:n => n + 1
是一个更新函数。React 将该函数添加到其队列中。 -
setNumber(42)
:React 将 “替换为 42” 添加到其队列中。
在下一次渲染期间,React 会遍历 state 队列:
更新队列 | n | 返回值 |
---|---|---|
替换为 5 | 0 | 5 |
n => n + 1 | 5 | 5 + 1 = 6 |
替换为 42 | 6 | 42 |
可以这样来理解状态队列的更新:
function getFinalState(baseState, queue) {
let finalState = baseState;
queue.forEach((update) => {
finalState = typeof update === 'function' ? update(finalState) : update;
});
return finalState;
}
其中 baseState 是初始状态,queue 是状态更新队列,包括数据和更新函数。
3. set 函数一定会触发更新吗?
看下面这个例子:
export default function Counter() {
const [number, setNumber] = useState(0);
const [person, setPerson] = useState({ name: 'jack' });
console.log('渲染');
return (
<>
<button
onClick={() => {
setNumber(number);
}}>
增加数字
</button>
<h1>{number}</h1>
<button
onClick={() => {
person.age = 18;
setPerson(person);
}}>
修改对象
</button>
<h1>{JSON.stringify(person)}</h1>
</>
);
}
组件的更新意味着组件函数的重新执行。对于上面这个例子,无论是点击 增加数字
还是 改变对象
都没有打印 渲染
。
set 函数触发更新的条件:
- 值类型,state 的值改变
- 引用类型,state 的引用改变
对于上面的例子:
- number 是值类型。点击增加数字,值没有改变,不会触发更新。
- person 是引用类型。点击修改对象,虽然 person 对象的值虽然变化了,但是引用地址没有变化,因此也不会触发更新。
4. 构建 state 的原则
- 合并关联的 state
有时候我们可能会不确定使用单个 state 还是多个 state 变量。
const [x, setX] = useState(0);
const [y, setY] = useState(0);
或
const [position, setPosition] = useState({ x: 0, y: 0 });
从技术上讲,我们可以使用其中任何一种方法。但是,如果某两个 state 变量总是一起变化,则将它们统一成一个 state 变量可能更好。这样你就不会忘记让它们始终保持同步。
- 避免矛盾的 state
import { useState } from 'react';
export default function Send() {
const [isSending, setIsSending] = useState(false);
const [isSent, setIsSent] = useState(false);
return (
<>
<button
onClick={() => {
setIsSent(false);
setIsSending(true);
setTimeout(() => {
setIsSending(false);
setIsSent(true);
}, 2000);
}}>
发送
</button>
{isSending && <h1>正在发送...</h1>}
{isSent && <h1>发送完成</h1>}
</>
);
}
尽管这段代码是有效的,但也会让一些 state “极难处理”。例如,如果你忘记同时调用 setIsSent
和 setIsSending
,则可能会出现 二者 同时为 true 的情况。
可以用一个 status 变量来代替它们。代码如下:
import { useState } from 'react';
export default function Send() {
const [status, setStatus] = useState('init');
return (
<>
<button
onClick={() => {
setStatus('sending');
setTimeout(() => {
setStatus('sent');
}, 2000);
}}>
发送
</button>
{status === 'sending' && <h1>正在发送...</h1>}
{status === 'sent' && <h1>发送完成</h1>}
</>
);
}
- 避免冗余的 state
import { useState } from 'react';
export default function Name() {
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [fullName, setFullName] = useState('');
return (
<>
<span>First Name</span>
<input
value={firstName}
onChange={(e) => {
setFirstName(e.target.value);
setFullName(e.target.value + ' ' + lastName);
}}
/>
<span>Last Name</span>
<input
value={lastName}
onChange={(e) => {
setLastName(e.target.value);
setFullName(firstName + ' ' + e.target.value);
}}
/>
<h1>{fullName}</h1>
</>
);
}
能够看出,fullName 是冗余的 state。我们可以直接:
const fullName = firstName + ' ' + lastName;
无需再把 fullName 存放到 state 中。
- 避免重复的 state
有时候,在我们存储的 state 中,可能有两个 state 有重合的部分。这时候我们就要考虑是不是有重复的问题了。
具体例子见这里
5. 保存和重置 state
前面我们说过,组件内部的 state 是互相隔离的。一个组件 state 的改变不会影响另外一个。然而,我们看下面这个例子:
import { useState } from 'react';
export default function App() {
const [isFancy, setIsFancy] = useState(false);
return (
<div>
{isFancy ? <Counter isFancy={true} /> : <Counter isFancy={false} />}
<label>
<input
type='checkbox'
checked={isFancy}
onChange={(e) => {
setIsFancy(e.target.checked);
}}
/>
使用好看的样式
</label>
</div>
);
}
function Counter({ isFancy }) {
const [score, setScore] = useState(0);
const [hover, setHover] = useState(false);
let className = 'counter';
if (hover) {
className += ' hover';
}
if (isFancy) {
className += ' fancy';
}
return (
<div className={className} onPointerEnter={() => setHover(true)} onPointerLeave={() => setHover(false)}>
<h1>{score}</h1>
<button onClick={() => setScore(score + 1)}>加一</button>
</div>
);
}
当我们修改了 Counter
组件 的 state 后,点击 checkbox
切换到另一个 Counter
,旧 Counter
的 state 并没有变为 0,而是保留了下来。如下图:
在 React 中,相同位置的相同组件会使得 state 保留下来。那么怎么才能让上述例子的 state 重置呢?
有两种方法:
1. 将组件渲染在不同的位置
{
isFancy ? (
<Counter isFancy={true} />
) : (
<div>
<Counter isFancy={false} />
</div>
);
}
2. 使用 key 来标识组件
{
isFancy ? <Counter isFancy={true} key={fancyTrue} /> : <Counter isFancy={false} key={fancyFalse} />;
}
效果如下:
useRef
基本用法
提起 useRef
,很多人都会把它跟 DOM 联系起来。其实 useRef 不止可以用来存储 DOM 元素。它的定义是:
如果希望组件记住某些信息,但又不想让这些信息触发新的渲染,可以使用 useRef。
比如下面这个例子:
import React, { useState, useEffect } from 'react';
let timer = null;
function Counter() {
const [count, setCount] = useState(0);
const onStart = () => {
timer = setInterval(() => {
setCount((count) => count + 1);
}, 1000);
};
const onStop = () => {
clearInterval(timer);
};
useEffect(() => {
return () => {
clearInterval(timer);
};
}, []);
return (
<>
<h1>{count}</h1>
<button onClick={onStart}>开始</button>
<button onClick={onStop}>停止</button>
</>
);
}
这个例子里,我们写了一个 Counter
组件。点击开始按钮 count
每秒增加 1,点击停止按钮 count
停止增加。
看上去,这个组件好像很 OK。
但是如果在一个页面使用 Counter 组件两次,就会发现,第一个定时器停止不了。
export default function App() {
return (
<div className='App'>
<Counter />
<Counter />
</div>
);
}
如下图:
这是因为两个组件公用同一个 timer
变量,第二个组件修改 timer
后,导致第一个组件中 clearInterval
处理的是第二个组件的 timer
。 因此第一个组件无法停止定时增加。
官网推荐我们使用纯函数编写组件也是基于此。
估计有人会说,我可以把 timer
变量放到 Counter
内部。组件内部的变量是互相隔离的, 这样就不会把第一个 Counter 组件的 timer
给覆盖了。
放到内部有两种情况:
1. 直接使用变量。 当组件更新的时候,组件函数重新执行,会导致 timer
重新创建,因此并不能清除之前的 timer
。
2. 使用 state。 使用 state 可以解决问题,但是会导致不必要的渲染。每次 timer
变化都会导致组件重新渲染。
其实对于这种定时器清理的问题,我们可以使用 useRef。useRef 创建一个变量,变量里有一个 current 属性。
const timeRef = useRef(null);
比如上面这段代码,会创建一个变量 timeRef, 它的结构类似 { current: null }
。
使用 ref 修改上述例子中的代码:
function Counter() {
const [count, setCount] = useState(0);
const timeRef = useRef(null);
const onStart = () => {
timeRef.current = setInterval(() => {
setCount((count) => count + 1);
}, 1000);
};
const onStop = () => {
clearInterval(timeRef.current);
};
useEffect(() => {
return () => {
clearInterval(timeRef.current);
};
}, []);
return (
<>
<h1>{count}</h1>
<button onClick={onStart}>开始</button>
<button onClick={onStop}>停止</button>
</>
);
}
运行试试,完美解决了之前的问题。
可以这样理解,ref 跟 state 的区别是,ref 不会导致组件重新渲染。
使用 ref 操作 DOM
我们来看一个 ref 操作 DOM 的例子:
import { useRef } from 'react';
export default function Form() {
const inputRef = useRef(null);
function handleClick() {
inputRef.current.focus();
}
return (
<>
<input ref={inputRef} />
<button onClick={handleClick}>聚焦输入框</button>
</>
);
}
效果:点击聚焦输入框按钮,输入框将会聚焦。
这段代码主要做了以下事情:
- 使用
useRef
Hook 声明inputRef
。 -
<input ref={inputRef}>
告诉 React 将这个 input 的 DOM 节点放入inputRef.current
。 - 在
handleClick
函数中,从inputRef.current
读取 input DOM 节点并调用它的focus()
。 - 给按钮添加点击事件
handleClick
forwardRef
我们可以使用 ref 属性配合 useRef 直接调用 DOM。那么可不可以给组件添加 ref 调用组件的 DOM 呢?让我们来试一下:
import { useRef } from 'react';
function MyInput(props) {
return <input {...props} />;
}
export default function MyForm() {
const inputRef = useRef(null);
function handleClick() {
inputRef.current.focus();
}
return (
<>
<MyInput ref={inputRef} />
<button onClick={handleClick}>聚焦输入框</button>
</>
);
}
我们给 MyInput
组件加上了 ref
,可是当我们点击 聚焦输入框按钮,则会报错:Cannot read properties of null (reading 'focus')
也就是说 inputRef.current
是 null
。我们并不能拿到组件的 DOM 元素。
默认情况下,React 不允许组件访问其他组件的 DOM 节点。这是因为 ref 是应急方案,应当谨慎使用。如果组件想要暴露自己的的 DOM,则需要使用forwardRef
来包装,并把 ref 转发给自己的子元素。 比如这样:
import { forwardRef, useRef } from 'react';
const MyInput = forwardRef((props, ref) => {
return <input {...props} ref={ref} />;
});
export default function Form() {
const inputRef = useRef(null);
function handleClick() {
inputRef.current.focus();
}
return (
<>
<MyInput ref={inputRef} />
<button onClick={handleClick}>聚焦输入框</button>
</>
);
}
它是这样工作的:
-
<MyInput ref={inputRef} />
告诉 React 将对应的 DOM 节点放入inputRef.current
中。但是,这取决于MyInput
组件是否允许这种行为, 默认情况下是不允许的。 -
MyInput
组件是使用 forwardRef 声明的。 这让从上面接收的inputRef
作为第二个参数 ref 传入组件,第一个参数是 props 。 -
MyInput
组件将自己接收到的 ref 传递给它内部的<input>
。
这样就通过 forwardRef
向父组件暴露了子组件的 DOM 节点。
useEffect
useEffect 是使用频率仅低于 useState 的 hook。很多人把 useEffect 当做监听器来使用。这是不太妥当的。
useEffect 是用来处理由渲染本身而不是点击事件引起的副作用。
基本用法
useEffect(setup, dependencies?)
-
setup
处理逻辑,是一个函数。可以返回一个清理函数 -
dependencies
是依赖项,当依赖项变化会执行, 会执行setup
函数
值得注意的几个问题
1. useEffect 的执行时机
- useEffect 在组件挂载完成后,也就是说 DOM 更新完毕后,按照定义的顺序执行。
比如这个例子:
import React, { useState, useEffect } from 'react';
export default function App() {
const [number, setNumber] = useState(0);
useEffect(() => {
console.log('-- 空依赖1,useEffect执行 --');
}, []);
useEffect(() => {
console.log('-- 非空依赖,useEffect执行 --', number);
}, [number]);
useEffect(() => {
console.log('-- 空依赖2,useEffect执行 --');
}, []);
console.log('渲染');
return (
<>
<h1>{number}</h1>
</>
);
}
结果打印为:
渲染
-- 空依赖1,useEffect执行 --
-- 非空依赖,useEffect执行 -- 0
-- 空依赖2,useEffect执行 --
执行顺序依次为:
所有 DOM 更新完毕 => 空依赖 1 useEffect 执行 => 非空依赖 useEffect 执行 => 空依赖 2 useEffect 执行
- useEffect 的清理函数在组件卸载期间调用或者下次运行之前调用。 比如下面这个例子:
import React, { useState, useEffect } from 'react';
function Title() {
const [title, setTitle] = useState('这里是标题');
useEffect(() => {
console.log('空依赖,useEffect执行');
return () => console.log('空依赖,useEffect清理函数执行');
}, []);
useEffect(() => {
console.log('非空依赖,useEffect执行');
return () => console.log('非空依赖,useEffect清理函数执行');
}, [title]);
return <h1 onClick={() => setTitle((title) => `${title}1`)}>{title}</h1>;
}
export default function App() {
const [titleVisible, setTitleVisible] = useState(true);
return (
<>
{titleVisible && <Title />}
<button onClick={() => setTitleVisible(!titleVisible)}>{`${titleVisible ? '隐藏' : '显示'}标题`}</button>
</>
);
}
由前面我们知道,组件挂载完成后才会按照顺序执行 useEffect, 因此打印结果是:
空依赖,useEffect执行
非空依赖,useEffect执行
然后点击标题,会打印:
非空依赖,useEffect清理函数执行
非空依赖,useEffect执行
最后,我们点击 隐藏标题 按钮,会打印:
空依赖,useEffect清理函数执行
非空依赖,useEffect清理函数执行
也就是说,空依赖的 useEffect 只会在组件挂载完成后执行,清理函数只会在组件卸载后执行
非空依赖的 useEffect 则有两种情况:
- 组件挂载完成后执行,清理函数在组件卸载后执行
- 依赖发生变化时执行,清理函数会在依赖发生变化,useEffect 内的逻辑执行前调用
2. 依赖项
-
依赖项为空,则只会在组件挂载完成后执行一次。当组件再次更新时候,不会执行。
-
如果 React 的所有依赖项都具有与上次渲染期间相同的值,则 React 将跳过 Effect
-
您不能“选择”您的依赖项。它们由 Effect 中的代码决定。
-
依赖项需要是能够触发组件更新的变量,比如 state 或者 props
不需要 effect 的情况
effect 是 React 的应急方案。它允许我们能够与一些外部系统同步,比如 ajax 请求和浏览器 DOM。如果不涉及外部系统,则不需要 effect。删除不必要的 effect 可以使代码更容易理解,运行速度更快并且不容易出错。
下面是几种常见的不需要 effect 的情况:
- 如果您可以在渲染期间计算某些东西,则不需要 Effect。
- 要缓存昂贵的计算,请添加 useMemo 而不是 useEffect.
- 要重置整个组件树的状态,请将不同的传递 key 给它。
- 要重置特定位的状态以响应属性更改,请在渲染期间设置它。
- 因为显示组件而运行的代码应该在 Effects 中,其余的应该在事件中。
- 如果您需要更新多个组件的状态,最好在单个事件期间执行。
- 每当您尝试同步不同组件中的状态变量时,请考虑提升状态。
- 您可以使用 Effects 获取数据,但您需要实施清理以避免竞争条件。
具体例子可以参考官网https://react.docschina.org/learn/you-might-not-need-an-effect#caching-expensive-calculations
我的理解就是,尽可能少用 useEffect,除非不用不行的情况。
useLayoutEffect
useLayoutEffect 跟 useEffect 唯一的不同就是二者的执行时机不同。
前面说过,对于一次更新有三个阶段:触发、渲染(render) 和 提交(commit)。
render 阶段主要是组件函数执行,jsx 转化为 Fiber 等工作。
commit 阶段主要是把更改反映到浏览器上,类似 document.appendChild()
之类的操作。
useEffect 在 commit 阶段完成后执行。
useLayoutEffect 在 commit 阶段之前执行。
由于 commit 阶段主要是页面更新的操作,因此useLayoutEffect
会阻塞页面更新。
比如这个例子:
import { useState, useEffect, useLayoutEffect } from 'react';
export default function App() {
const [text, setText] = useState('11111');
useEffect(() => {
console.log('useEffect');
let i = 0;
while (i < 100000000) {
i++;
}
setText('00000');
}, []);
// useLayoutEffect(() => {
// console.log("useLayoutEffect");
// let i = 0;
// while (i < 100000000) {
// i++;
// }
// setText("00000");
// }, []);
return <h1>{text}</h1>;
}
使用 useEffect
页面会有明显的从 11111
变成 00000
的过程。使用 useLayoutEffect
则不会。
让我们梳理下执行流程:
useEffect: render => commit(反映到页面上) => useEffect => render => commit(反映到页面上)
useLayoutEffect: render => useLayoutEffect => render => commit(反映到页面上)
useLayoutEffect
执行后发现 state 更新,就不再把 11111
反映到页面上了,直接再次执行 react 渲染。因此我们没有看到从 11111
闪烁成 00000
的过程。
自定义 hook
自定义 hook 是一个函数,它允许我们在组件之间共享逻辑。
在使用自定义 hook 之前,我们代码复用的最小单元是组件。使用自定义 hook 之后,我们可以方便地复用组件里的逻辑。
基本使用
编写自定义 hook 需要遵循以下规则:
-
命名必须是 use 后跟大写字母,比如
useLogin
,useForceUpdate
-
自定义 hook 中至少要使用一个其他 hook
比如我们写一个强制刷新的 hook:
import { useState } from 'react';
function useForceUpdate() {
const [, setForceState] = useState({});
return () => setForceState({});
}
在组件里使用:
export default function App() {
const forceUpdate = useForceUpdate();
console.log('render');
return <button onClick={forceUpdate}>强制刷新</button>;
}
当我们点击 强制刷新
按钮的时候,会打印 render。也就是App
组件重新渲染了。
何时使用
就个人理解,我觉得有两种情况比较适合使用自定义 hook:
-
有经常复用的组件逻辑时
-
使用自定义 hook 后能够让代码逻辑,数据流向更清晰
memo
在 React 中,父组件的重新渲染会导致子组件的重新渲染。memo 允许我们在 props 不变的情况下避免渲染子组件。
语法
memo(Component, arePropsEqual?)
:包装一个组件,并获得改组件的缓存版本。
Component
: 要包装的组件。
arePropsEqual(prevProps, nextProps)
: 接收两个参数,前一次的 props 和后一次的 props。返回值是一个布尔类型,true
表示新旧 props 相等,false
表示两次 props 不相等。
下面用一个例子感受它的用法。
缓存子组件的例子
import React, { useState, memo } from 'react';
function Hello({ text }) {
console.log('子组件重新渲染');
return <h1>{`hello ${text}!`}</h1>;
}
export default function App() {
const [count, setCount] = useState(0);
const [text, setText] = useState('');
const onAddCount = () => {
setCount((count) => count + 1);
};
const onChangeText = () => {
setText('world');
};
return (
<>
<span>{count}</span>
<button onClick={onAddCount}>+1</button>
<Hello text={text} />
<button onClick={onChangeText}>改变子组件文本</button>
</>
);
}
当我们点击 +1
按钮时,会打印 子组件重新渲染
。也就是说当我们的父组件更新的时候,子组件也会相应更新。
但是如果我们用 memo 来包裹子组件,代码如下:
import React, { useState, memo } from 'react';
const Hello = memo(function Hello() {
console.log('子组件重新渲染');
return <h1>Hello, world!</h1>;
});
// ...
当我们点击 +1
按钮时, 子组件重新渲染
不会再打印。也就说我们通过 memo
实现了子组件的缓存。
需要注意的是,当上下文或者子组件内部状态变化的时,依然会触发更新。 memo 缓存组件只是针对 props 不发生改变的情况。
prop 是对象、数组或函数的情况
当传递给子组件的 prop 是对象、数组或函数时,由于它们是引用类型,父组件重新渲染会导致它们被重新定义。也就是说,props 发生了变化。这种情况下,依然会触发子组件更新。
比如下面这个例子:
import React, { useState, memo } from 'react';
const List = memo(function List({ list }) {
console.log('子组件重新渲染');
return (
<>
{list.map((item) => (
<div key={item.id}>{item.content}</div>
))}
</>
);
});
export default function App() {
const [title, setTitle] = useState('父组件');
const [todoList, setTodoList] = useState([
{ id: 1, content: '吃饭', isDone: true },
{ id: 2, content: '睡觉', isDone: false },
{ id: 3, content: '洗澡', isDone: true },
{ id: 4, content: '刷牙', isDone: false },
{ id: 5, content: '刷抖音', isDone: false }
]);
const changeTitle = () => {
setTitle('父组件' + Math.random().toFixed(2));
};
const list = todoList.filter((item) => item.isDone);
return (
<>
<h1 onClick={changeTitle}>{title}</h1>
<List list={list} />
</>
);
}
点击父组件,依然会触发子组件渲染。这是由于每次父组件渲染都会重新定义一个变量 list
, 两次的 list
不是同一个引用。
这种情况要怎么处理才能避免子组件渲染呢?有两种办法:
1. 使用比较函数
我们可以给 memo 添加第二个参数arePropsEqual
:
// ...
(prevProps, nextProps) => {
return (
prevProps.list.length === nextProps.list.length &&
prevProps.list.every((item) => {
let allOk = true;
for (let key in item) {
if (prevProps[key] !== nextProps[key]) {
allOk = false;
}
}
return allOk;
})
);
};
// ...
这样,当修改 title 时,list 的内容没有变化,并不会触发子组件更新。
个人建议,尽可能避免使用比较函数。主要出于两个考虑:一来别人需要阅读你的比较函数来确定你的组件更新规则;二来我们重写比较函数就意味着每次父组件更新都会执行比较函数。如果比较函数比较复杂且耗时,那么使用比较函数就不再是好的选择了。
2. 使用 useCallback 或者 useMemo 来缓存引用类型
useCallback 用来缓存一个函数。在这个例子里,使用 useMemo 比较合适。
修改 list 的定义,代码如下:
// ...
// 使用useMemo缓存list, 这样title改变不会再触发子组件渲染
const list = useMemo(() => todoList.filter((item) => item.isDone), [todoList]);
// ...
这样,由于我们缓存了 list
, 当修改 title
时,list
仍为同一个 list
,并不会触发子组件更新。
useMemo
useMemo 允许我们缓存一个计算结果。当再次渲染的时候,返回上一次的结果而不是重新计算。
语法
const cachedValue = useMemo(calculateValue, dependencies)
-
calculateValue
: 缓存的计算结果。 当它是一个函数时,会缓存这个函数的返回值。 -
dependencies
: 依赖项。当依赖项变化时,重新计算结果。
使用场景
- 防止组件重新渲染
比如前面的例子:
当 prop 是对象、数组或函数的情况,这时候可以使用 useMemo 配合 memo 缓存组件。
- 避免昂贵的计算
比如下面这个例子
import React, { useState, useMemo } from 'react';
export default function App() {
const [count, setCount] = useState(0);
// 模拟复杂的运算,需要两秒钟
const getResult = async () => {
await new Promise((resolve) => {
setTimeout(() => resolve(), 2000);
});
return 2;
};
const onAddCount = async () => {
const result = await getResult();
setCount((count) => count + result);
};
return (
<>
<span>{count}</span>
<button onClick={onAddCount}>+随机数</button>
</>
);
}
getResult
是一个耗时的计算,需要两秒钟。这就会导致我们每次点击按钮,都要等待两秒才能响应。如果我们使用 useMemo 缓存结果,那么只有第一次需要等待两秒,后面都会快速响应。
import React, { useState, useMemo } from 'react';
export default function App() {
const [count, setCount] = useState(0);
// 使用useMemo缓存复杂的计算结果
const getResult = useMemo(async () => {
await new Promise((resolve) => {
setTimeout(() => resolve(), 2000);
});
return 2;
}, []);
const onAddCount = async () => {
// 使用useMemo直接缓存计算结果,getResult是结果不是函数
const result = await getResult;
setCount((count) => count + result);
};
return (
<>
<span>{count}</span>
<button onClick={onAddCount}>+随机数</button>
</>
);
}
使用 useMemo 前的效果:
使用useMemo前使用 useMemo 后的效果:
使用useMemo后useCallback
useMemo 允许我们缓存一个函数。当再次渲染的时候,返回上一次的函数而不是重新定义。
语法
const cachedFn = useCallback(fn, dependencies)
-
fn
: 缓存的函数。 -
dependencies
: 依赖项。当依赖项变化时,重新计算结果。
使用场景
- 防止组件重新渲染
当我们传给子组件的属性有函数的时候,比如下面这个例子:
import React, { useState, memo, useMemo, useCallback } from 'react';
const Hello = memo(function Hello({ text, onClick }) {
console.log('子组件重新渲染');
return <h1 onClick={onClick}>{`hello ${text}!`}</h1>;
});
export default function App() {
const [count, setCount] = useState(0);
const [text, setText] = useState('');
const onAddCount = () => {
setCount((count) => count + 1);
};
const onChangeText = () => {
setText('world');
};
return (
<>
<span>{count}</span>
<button onClick={onAddCount}>+1</button>
<Hello text={text} onClick={onChangeText} />
</>
);
}
当我们点击 count 会造成子组件的渲染,这是因为 onChangeText
是引用类型,每次父组件渲染,它都被重新定义。这导致了每次 props 都发生了变化。我们可以使用 useCallback
来缓存 onChangeText
:
// 使用useCallback来缓存onChangeText
const onChangeText = useCallback(() => {
setText('world');
}, []);
使用 useMemo 也可以实现相同的结果,只不过需要再多包一层函数:
// 使用useMemo缓存onChangeText
const onChangeText = useMemo(() => {
return () => {
setText('world');
};
}, []);
- 优化自定义 hook
如果您正在编写自定义 Hook,建议将它返回的任何函数包装到 useCallback:
function useRouter() {
const { dispatch } = useContext(RouterStateContext);
const navigate = useCallback(
(url) => {
dispatch({ type: 'navigate', url });
},
[dispatch]
);
const goBack = useCallback(() => {
dispatch({ type: 'back' });
}, [dispatch]);
return {
navigate,
goBack
};
}
这确保了 Hook 的使用者可以在需要时优化他们自己的代码。
争议
有人认为应当给所有的函数包上 useCallback, 我并不认同。主要是出于以下两个考虑:
- 使用 useCallback 后代码可读性变差
- 创建一个函数的性能消耗几乎可以忽略不计,不应作为优化点
最后
官方文档内容较多,这里只整理个人认为比较常用的知识点。想要查漏补缺的小伙伴可以去看官网
网友评论