文章翻译来自 Dan Abramov(React 库的核心团队成员之一) overreacted.io/a-complete-… 文章采用意译,融合了我自己的理解,欢迎大家留言讨论。先说总结:
- 每一次渲染都有自己的 prop\state\事件处理\effect\清理函数。在单次渲染的范围内,props 和 state 保持不变,也就是事件处理、effect 和清理函数拿到的 props 和 state 保持不变。使用 Ref 可以打破这一范式,让事件处理、effect 和清理函数拿到最新值。
- 先进行UI渲染,再执行 effect 的上一次的清理函数,最后执行 effect 函数。
- componentDidMount 和 useEffect(fn, []) 是不一样的,或者说 useEffect(fn, []) 和 componentDidMount 是不一样的。在 单次渲染范围内,useEffect(fn, []) 总是拿到最初的值,而不是最新的值。因为 this.state.count 则永远指向最新的值。
- effect 应该理解成同步,而不是按照生命周期去理解。如果按照 class 生命周期去理解 effect 会让我们更加迷糊,应该建立 effect 同步的 思维去思考如何使用 effect.
每一次的渲染都有自己的 Props and State
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>
Click me
</button>
</div>
);
}
为什么我们点击一次,页面的会显示 count 的次数呢?count 仅仅是一个数字,不是 数据绑定、watcher、proxy 或者其他什么语法。
// 第一次渲染,函数 Counter 第一次执行
function Counter() {
const count = 0; // Returned by useState()
// ...
<p>You clicked {count} times</p>
// ...
}
// 点击一次后,Counter 再次执行
function Counter() {
const count = 1; // Returned by useState()
// ...
<p>You clicked {count} times</p>
// ...
}
// 再点击一次后,Counter 再次执行
function Counter() {
const count = 2; // Returned by useState()
// ...
<p>You clicked {count} times</p>
// ...
}
也就是每一次函数 Counter 执行都能 “看见” 内部的 state count。尽管 count 可能在不同的渲染之间发生变化,但在对于每次渲染来说都是常量。
每一次的渲染都有自己的事件处理函数
function Counter() {
const [count, setCount] = useState(0);
function handleAlertClick() {
setTimeout(() => {
alert('You clicked on: ' + count);
}, 3000);
}
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>
Click me
</button>
<button onClick={handleAlertClick}>
Show alert
</button>
</div>
);
}
对于这个例子,当我们先点击按钮 Click me
,让 count 到增加5,然后点击按钮 Show alert
,之后继续点击增加 count 到 8, 3秒后 alert 的值是多少呢?
下意识会觉得弹出最新值8,其实不然,会弹出触发定时器 setTimeout 时的 count 的值,会弹出 5。
每次调用我们的函数时,count
值对于该特定调用是 恒定的。也就是——函数会被调用多次(每次渲染都会调用一次),但每次调用时,函数内部的 count
值都是恒定的,并且设定为特定的值(即该次渲染的状态值)。
下面的例子可以更好的理解 “函数每次调用,函数内部的值是恒定的”
function sayHi(person) {
setTimeout(() => {
alert('Hello, ' + person.name);
}, 3000);
}
let someone = {name: 'Dan'};
sayHi(someone);
someone = {name: 'Yuzhi'};
sayHi(someone);
someone = {name: 'Dominic'};
sayHi(someone);
会以此 alert Dan
、 Yuzhi
、 Dominic
。
注意: someone
每次是重新赋值了:someone = {name: 'Yuzhi'};
并不是修改 someone
的属性:someone.name = 'Yuzhi'
这个例子中,setTimeout
使用了外层函数 sayHi
的变量 person
,这正是 闭包 的概念。对于 setTimeout
函数来说,外部变量是 恒定的。
每一次渲染都有自己的副作用 effect
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
document.title = `You clicked ${count} times`;
});
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>
Click me
</button>
</div>
);
}
概念上,可以将 effect 想象成渲染结果的一部分。心智模型是这样的:跟事件处理一样 effect 函数属于每一次特定的渲染。
这段代码的执行逻辑如下:
// During first render
function Counter() {
// ...
useEffect(
// Effect function from first render
() => {
document.title = `You clicked ${0} times`;
}
);
// ...
}
// After a click, our function is called again
function Counter() {
// ...
useEffect(
// Effect function from second render
() => {
document.title = `You clicked ${1} times`;
}
);
// ...
}
// After another click, our function is called again
function Counter() {
// ...
useEffect(
// Effect function from third render
() => {
document.title = `You clicked ${2} times`;
}
);
// ..
}
渲染过程更细致的表述:
- React: state 现在是0,给我一下 UI
- 组件:
- 这是渲染结果:
<p>You clicked 0 times</p>
- 记得执行副作用 effect:
() => { document.title = 'You clicked 0 times' }
- 这是渲染结果:
- React:当然可以,浏览器,帮忙更新一下UI,我对 DOM 进行了一些修改。
- 浏览器:好的,我把这些渲染到屏幕上。
- React:OK,我现在要执行组件的副作用 effect 函数了。执行
() => { document.title = 'You clicked 0 times' }
接下来我们看下当点击按钮后会发生什么:
- 组件: React, 帮我把 state 设为 1
- React:给我一下 UI,现在 state 是 1
- 组件:
- 这是渲染结果:
<p>You clicked 1 times</p>
- 记得执行副作用 effect:
() => { document.title = 'You clicked 1 times' }
- 这是渲染结果:
- React:当然可以,浏览器,帮忙更新一下UI,我对 DOM 进行了一些修改。
- 浏览器:好的,我把这些渲染到屏幕上。
- React:OK,我现在要执行组件的副作用 effect 函数了。执行
() => { document.title = 'You clicked 1 times' }
每一次渲染有自己的一切
看下这段代码,并思考一下会输出什么呢。
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
setTimeout(() => {
console.log(`You clicked ${count} times`);
}, 3000);
});
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>
Click me
</button>
</div>
);
}
这是一个比较直观的例子,我们可以看到控制台输出一系列日志,每一个输出的日志都属于一个特定的渲染,每次渲染都有自己的 count
值,你可以自己试一下:codesandbox.io/s/lyx20m1ol
使用 class 实现,则是 codesandbox.io/s/kkymzwjqz…
componentDidUpdate() {
setTimeout(() => {
console.log(`You clicked ${this.state.count} times`);
}, 3000);
}
可以看到使用 class 实现,则会输出最新的 count
的值。
React 中的 Hooks 主要依赖闭包的特性,并且每次渲染都是函数执行。但是 class 方式修改 count,setTimeout
中 count 的引用会指向最新的 this.state.count
的值。我们可以利用 闭包的特性来解决这个问题:codesandbox.io/s/w7vjo0705…
componentDidUpdate() {
const count = this.state.count;
setTimeout(() => {
console.log(`You clicked ${count} times`);
}, 3000);
}
逆流而上(使用 Ref)
正因为上面所说的,每一次渲染所具有的 props 和 state 的值都是确定的,所以以下代码是等价的:
function Example(props) {
useEffect(() => {
setTimeout(() => {
console.log(props.counter);
}, 1000);
});
// ...
}
和
function Example(props) {
const counter = props.counter;
useEffect(() => {
setTimeout(() => {
console.log(counter);
}, 1000);
});
// ...
}
也就是不管在组件内部“早期”读取 props 还是 state,这都无关紧要。它们不会改变!在单次渲染的范围内,props 和 state 保持不变。(解构 props 会使这一点更加明显。)
如果从一个过去的渲染中从函数读取未来的 props 或 state 时(也就是拿到 state 的最新值),这是 逆流而上 的,打破了“每一次渲染都有自己的确定一切(保持不变)”的范式。我们可以使用 Ref
来实现,Ref
跟 React 本身在类组件中重新分配 this.state
的方式一样,可以随时修改 latestCount.current
,并获取到最新值:
function Example() {
const [count, setCount] = useState(0);
const latestCount = useRef(count);
useEffect(() => {
// Set the mutable latest value
latestCount.current = count;
setTimeout(() => {
// Read the mutable latest value
console.log(`You clicked ${latestCount.current} times`);
}, 3000);
});
清理函数(cleanup)怎么理解呢
正如文档中解释的那样,一些 effect 可能会返回一个清理函数。其目的是在某些情况下“撤销”effect,比如订阅。
考虑以下代码:
useEffect(() => {
ChatAPI.subscribeToFriendStatus(props.id, handleStatusChange);
return () => {
ChatAPI.unsubscribeFromFriendStatus(props.id, handleStatusChange);
};
});
假设在第一次渲染时,props 是 {id: 10}
,第二次渲染时是 {id: 20}
。你可能会认为是这样:
- React 清理
{id: 10}
的 effect。 - React 为
{id: 20}
渲染 UI。 - React 为
{id: 20}
运行 effect。
(事实并非如此。)
根据这种思维模型,你可能认为清理“看到”旧的 props,因为它在重新渲染之前运行,而新的 effect “看到”新的 props,因为它在重新渲染之后运行。这是直接从类生命周期中得出的思维模型,但在这里并不准确。为什么呢?
React 只会在让浏览器绘制后运行 effect。这样做会使你的应用程序更快,因为大多数 effect 不需要阻塞屏幕更新。Effect 的清理也是延迟的。上一个 effect 会在重新渲染带有新 props 后进行清理:
- React 为
{id: 20}
渲染 UI。 - 浏览器绘制。我们在屏幕上看到
{id: 20}
的 UI。 - React 清理
{id: 10}
的 effect。 - React 运行
{id: 20}
的 effect。
你可能会想:但是如果清理上一个 effect 是在 props 变成 {id: 20}
后运行,它怎么还能“看到”旧的 {id: 10}
props 呢?
我们之前讨论过这个问题... 🤔
是的,每一次渲染都有自己的一切。
Effect 的清理不会读取“最新”的 props,无论这意味着什么。它读取的是它定义时所属渲染的 props:
// 第一次渲染,props 是 {id: 10}
function Example() {
// ...
useEffect(
// 第一次渲染的 effect
() => {
ChatAPI.subscribeToFriendStatus(10, handleStatusChange);
// 第一次渲染的 effect 的清理
return () => {
ChatAPI.unsubscribeFromFriendStatus(10, handleStatusChange);
};
}
);
// ...
}
// 下一次渲染,props 是 {id: 20}
function Example() {
// ...
useEffect(
// 第二次渲染的 effect
() => {
ChatAPI.subscribeToFriendStatus(20, handleStatusChange);
// 第二次渲染的 effect 的清理
return () => {
ChatAPI.unsubscribeFromFriendStatus(20, handleStatusChange);
};
}
);
// ...
}
但无论如何,第一次渲染的 effect 清理所“看到”的 props 永远是 {id: 10}
。
这正是为什么 React 能在绘制之后处理 effects —— 并默认使你的应用程序更快。如果我们的代码需要它们,旧的 props 会一直存在。
同步,而非生命周期
React hook 的特点是初始渲染和更新是统一的,React 根据我们当前的 props 和 state 同步 DOM。渲染时没有“挂载”或“更新”的区别。我们应该以类似的方式看待 effect。useEffect
会根据我们的 props 和 state 来同步 React 之外的内容。