useEffect (() => {
document.title = `你点击了 ${count} 次`
这里的 effect 在每次渲染后都会执行,因为我们没有指定 deps。
总结一下,useEffect 的两个参数:
effect 函数:执行副作用操作的函数。
deps 数组 (可选):effect 函数的依赖项数组。
如果指定了 deps,那么只有 deps 中的值变化时,effect 才会重新执行。
如果传入 []
作为 deps,则 effect 只会在第一次渲染时执行。
如果不指定 deps,则 effect 将在每次渲染后执行。
理解 effect 函数和 deps 数组是使用 useEffect 的关键。effect 负责执行具体的副作用操作,而 deps 控制 effect 的执行时机。
会执行两次的 useEffect
在开发环境下,useEffect 第二个参数设置为空数组时,组件渲染时会执行两次。这是因为 React 在开发模式下会执行额外的检查,以检测 Trumpkin 警告并给出更好的错误信息。当你指定 []
作为 deps 时,这意味着 effect 没有任何依赖项,所以它应该只在组件挂载时执行一次。但是,在第一次渲染时,React 无法确定 deps 是否会在将来的渲染中发生变化。所以它会在初始渲染时执行一次 effect,然后在 “调用阶段” 再执行一次,以确保如果 deps 发生变化,effect 也会再次执行。如果在 “调用阶段” 重新渲染时 deps 仍然为 []
,那么 React 会更新内部状态,记录该 effect 确实没有依赖项,并在将来的渲染中跳过 “调用阶段” 重新执行的步骤。
这就是在开发环境下 effect 会执行两次的原因。这种行为只在开发模式下发生,在生产模式下 effect 只会执行一次。目的是为了提高开发体验,给出更清晰的错误提示。如果 effect 的 deps 发生变化但没有再次执行,React 可以明确地给出警告。而在生产模式下,这样的检查是不必要的,所以 effect 只会执行一次以减少性能开销。
总结一下:当你在开发环境下使用 useEffect 并指定 []
作为依赖项时,effect 函数会在初始渲染时执行两次。这是因为 React 会在 “调用阶段” 再次执行 effect,以检查依赖项是否发生变化,给出更清晰的警告信息。如果 deps 仍然为 []
,那么 React 会更新状态并在将来跳过 “调用阶段” 的重新执行。这种行为只在开发模式下发生,生产模式下 effect 只会执行一次。
什么是 Trumpkin 警告?
Trumpkin 警告是 useEffect Hook 的一种错误警告。它会在开发环境下出现,用来表示 effect 函数中使用的某个状态或 props 在依赖项 deps 中遗漏。
function Counter () {
const [count, setCount] = useState (0);
useEffect (() => {
document.title = `You clicked ${count} times`;
}); // 没有指定 deps
这里,effect 函数使用了 count state,但我们没有将它添加到 deps 中。所以 React 会在开发环境下给出 Trumpkin 警告: React Hook useEffect has a missing dependency: 'count'. Either include it or remove the dependency array.
这是为了提示我们 count 状态发生变化时,effect 函数并不会重新执行,这很可能是个 bug。要修复这个警告,我们有两种选择:
添加 count 到 deps:
useEffect (() => {
document.title = `You clicked ${count} times`;
}, [count]);
如果 effect 不依赖任何值,传入空数组 []
:
useEffect (() => {
document.title = `You clicked ${count} times`;
}, []);
为什么说此时可能是个 bug?当你不指定 useEffect 的第二个参数 (deps) 时,effect 回调函数会在每次渲染后执行。但是,这并不意味着 effect 中使用的所有状态和 props 都会在 effect 重新执行时更新。 effect 执行时所使用的变量会被创建出一个闭包,它会捕获 effect 创建时那一刻变量的快照。所以,如果 effect 使用了某个状态,但没将其添加到依赖项 deps 中,当那个状态更新时,effect 中仍然会使用旧的值。 这很可能导致 bug。
function Counter () {
const [count, setCount] = useState (0);
useEffect (() => {
document.title = `You clicked ${count} times`; // 使用了 count 但没有指定为依赖
这里,effect 中使用了 count 状态,但是我们没有将它添加到 deps 中。在第一次渲染时,count 为 0,所以 document.title 会被设置为 "You clicked 0 times"。如果我们随后将 count 更新为 1, 你可能会期望 document.title 也变为 "You clicked 1 times"。但是,当 effect 被执行时,它会捕获 count 的 “旧值” 0。所以 document.title 实际上仍然会是 "You clicked 0 times"。 count 的更新并没有触发 effect 的重新执行。
这就是 Trumpkin 警告出现的原因,React 会检测到 effect 中使用了某个状态,但没有在依赖项 deps 中指定它,这很有可能导致 bug。 所以 Trumpkin 警告的目的是在开发环境下检测这样的错误,并给出清晰的提示以修复它们。
了解 React 中的 “调用阶段”
React 在初次渲染后会再次执行 useEffect Hook 的调用,以校验是否有依赖项被遗漏从而产生 Trumpkin 警告。在上例中,React 在第一次渲染时会执行一次 effect,然后在 “调用阶段” 再次执行 effect。这时,它会检测到 count 状态被使用但未在 deps 中指定,所以会产生 Trumpkin 警告。如果 deps 指定为 []
,在 “调用阶段” 的重新执行中它会检测到 deps 没有变化,所以会更新内部状态并在将来的渲染中跳过这个额外步骤(调用阶段)。
useEffect 的实现
effect 函数会创建一个闭包,捕获函数内部使用的所有状态和 props 的值。这是因为 Javascript 中的函数会隐式创建闭包。当 effect 第一次执行时,它会读取函数内使用的所有状态和 props,并将其值保存到闭包中。
举个例子:
function Counter () {
const [count, setCount] = useState (0);
useEffect (() => {
const foo = count; // 读取 count 并存入闭包
document.title = `You clicked ${foo} times`;
这里,foo 变量是定义在 effect 函数内部的。当 effect 第一次执行时,它会读取 count 的当前值 0,并将其保存到 foo 中。foo 变量及其所捕获的 0 值都被保存在 effect 的闭包中。即使后续我们将 count 更新为 1,当 effect 重新执行时,它仍然会在闭包中找到 foo 变量,其值为 0。所以 document.title 不会更新。除非我们指定 [count]
作为 effect 的依赖项:
useEffect (() => {
const foo = count;
document.title = `You clicked ${foo} times`;
}, [count]);
现在,每当 count 更新时,effect 会重新执行。它会再次读取 count 的最新值,并将其保存到闭包的 foo 中:
第一次执行:count 为 0,foo 被设置为 0
count 更新为 1:effect 重新执行,读取 count 为 1,将其保存到 foo 中,覆盖之前的值
以此类推...
要实现这个效果,有两个关键点:
Javascript 函数会隐式创建闭包,用来存储函数内定义的变量和其值。
effect 会在第一次执行时读取所有使用的状态和 props 的值,并将其保存到闭包中。除非 deps 发生变化,否则 effect 在重新执行时会使用闭包中的 “旧值”。
这就是 effect 如何通过闭包捕获变量值的实现机制。理解这一点,以及如何通过依赖项 deps 避免使用 “旧值” 导致的 bug,是使用 useEffect 的关键。
在 useEffect 中指定了 deps 依赖项时,它会在 deps 中的任何值变化时重新运行 effect 函数。这时,它会重新读取最新的值,而不是使用闭包中的 “旧值”。这是通过在 effect 函数内部重新声明状态和 props 的值来实现的。每当 effect 重新运行时,它会捕获那一刻的最新值,然后替换闭包中的 “旧值”。
举个例子:
function Counter () {
const [count, setCount] = useState (0);
useEffect (() => {
const foo = count; // 重新读取 count 的最新值
document.title = `You clicked ${foo} times`;
}, [count]); // 指定 count 作为依赖项
在第一次执行时,foo 被设置为 count 的初始值 0。当我们更新 count 为 1 时,effect 会重新运行,因为我们指定了 [count]
作为依赖项。这时,effect 会再次读取 count,现在其值为 1。它会将 1 赋值给 foo,覆盖闭包中的 “旧值” 0。所以每当 effect 重新运行时,它都会重新读取状态和 props 的最新值,并更新闭包中的值。这确保了在 effect 函数中,我们总是使用的是最新的,而不是旧的闭包值。在 React 源码中,这是通过在 effect 重新运行时调用 create 子函数来实现的:
function useEffect (create, deps) {
//...
function recompute () {
const newValue = create (); // 重新运行子函数,读取最新值
storedValue.current = newValue; // 更新闭包中的值
if (depsChanged) recompute (); // 如果 deps 变化,重新计算
每当依赖项 deps 变化时,React 会调用 recompute 函数来重新运行 create 子函数。create 会读取最新的状态和 props 值,并将新值保存到存储变量 storedValue 中,覆盖之前的值。所以,通过在 effect 重新运行时重新读取值并更新存储变量,React 确保你总是在 effect 函数中使用最新的 props 和状态,而不是闭包捕获的 “旧值”。这就是 useEffect 在指定了 deps 依赖项时如何避免使用闭包中的 “旧值” 的实现机制。
每当 deps 变化,它会重新运行 effect 并读取最新的值,更新存储在闭包中的值。当你不指定 useEffect 的依赖项 deps 时,effect 函数会在每次渲染后运行。这时,effect 在重新运行时会继续使用闭包中的 “旧值”,而不是读取最新的状态和 props 值。这是因为没有指定依赖关系,所以 React 认为 effect 不依赖于任何值的变化。在源码中,这是通过不调用 recompute 函数来实现的。recompute 函数负责在依赖项变化时重新运行 effect 并更新闭包值。所以简单来说,当你不指定 deps 时,effect 在重新运行时什么也不会做 —— 它会继续使用之前闭包中的值。
举个例子:
function Counter () {
const [count, setCount] = useState (0);
useEffect (() => {
const foo = count;
document.title = `You clicked ${foo} times`;
在第一次渲染时,foo 被设置为 count 的初始值 0。当我们更新 count 为 1 时,effect 会重新运行,但这时它不会重新读取 count。它会继续使用闭包中存储的 foo,其值仍为 0。
所以 document.title 不会更新,它将保持 "You clicked 0 times"。这是因为我们没有指定 deps 数组,所以 React 认为 effect 不依赖任何值。每次渲染后重新运行 effect 仅仅是为了刷新副作用。它并不会读取最新的 props 或状态值。在源码中,effect 的重新运行如下所示:
function useEffect (create, deps) {
//...
if (didRender) {
// 重新运行 effect, 但不会重新计算值
create ();
if (depsChanged) recompute ();
所以当你不指定 deps 时,didRender 值会在每次渲染后变为 true,从而重新运行 effect。但是,由于 depsChanged 总是 false,所以 recompute 函数不会被调用。effect 在重新运行时只会调用 create 函数,但不会重新读取值或更新闭包中的存储值。所以它会继续使用闭包中的 “旧值”,而不是最新的 props 和状态。这就是当不指定依赖项 deps 时,useEffect 作用在重新运行时如何继续使用闭包中的 “旧值” 而非最新值的实现机制。
在 useEffect 源码中,“旧值” 是通过 useRef hook 保存的。useRef 返回一个可变的 ref 对象,其 .current 属性被用于存储任何值,这个值在组件的整个生命周期中持续存在。所以,useEffect 使用 useRef 来保存第一次执行时读取的 props 和状态的值,这些就是所谓的 “旧值”。
useEffect 的简化实现如下:
function useEffect (create, deps) {
const storedValue = useRef (null); // 使用 useRef 保存旧值
function recompute () {
const newValue = create (); // 重新运行,读取最新值
storedValue.current = newValue; // 更新旧值
if (didRender) {
create (); // 重新运行,使用旧值 storedValue.current
if (depsChanged) recompute (); // 如果 deps 变化,重新计算新值
在初始渲染时,会运行 create 函数,读取一些值并将其赋值给 storedValue。这些就是 “旧值”。
如果没有指定依赖项,didRender 将在每次渲染后变为 true,重新运行 create 函数,但这时仍使用存储在 storedValue 中的 “旧值”。
如果指定了依赖项 deps,且 deps 发生变化,recompute 函数会重新运行 create,读取最新的值,并将其更新到 storedValue 中,覆盖 “旧值”。
如果依赖项 deps 没有变化,什么也不会发生 ——storedValue 中的 “旧值” 会继续被使用。
所以,useRef hook 被用来在 effect 的多次执行之间保存 props 和状态的 “旧值”。每当依赖关系无变化时,这些 “旧值” 会继续被使用。通过指定依赖项,你可以确保在值变化时重新运行 effect, 并使用最新的 props 和状态值更新存储的 “旧值”。这就是 useEffect 源码中 “旧值” 如何被保存及使用的实现机制。理解它对于掌握 useEffect 的工作原理非常重要。
create 函数可以读取 storedValue 的值,因为:
storedValue 是在 effect 函数内声明的。
create 函数也是在 effect 函数内定义的,所以它可以访问 effect 作用域中的变量,包括 storedValue。
这是闭包的结果,create 函数会捕获 surrounding scope 的变量,使其值得以在多次调用之间保持。
举个例子:
function useEffect (create, deps) {
const storedValue = useRef (null);
function effect () {
const create = () => {
console.log (storedValue.current); // 可以访问 storedValue
//...
这里,create 函数被定义在 effect 函数内部。所以它可以访问 effect 作用域中的变量,包括 storedValue。当 create 函数在后续调用中运行时,它会继续使用创建时捕获的 storedValue 变量。这是闭包的结果 —— 即使 effect 函数完成执行,create 函数所捕获的变量也会被保留在内存中,供后续调用使用。
总结一下:
create 和 storedValue 都是在 effect 内声明的,所以 create 可以访问 storedValue。
create 函数捕获了 surrounding scope 的变量,使得这些变量在函数调用之间保持其值。这就是闭包。
所以,每当 create 被调用时,它都可以访问之前声明的 storedValue,并读取其当前值。
这就是 create 如何可以在多次调用之间共享并访问同一个 storedValue 的机制。
这一点对理解 useEffect 的工作原理很重要。 effect 中声明的变量和函数都会被捕获在闭包中,并在多次 effect 执行之间共享。理解了这一点,useEffect 中 “旧值” 的保存和读取机制也就很清楚了。
在 useEffect 源码中,effect 函数会在以下情况被调用:
在组件初始渲染时。此时它会执行 effect,读取 props 和状态的值,并将其存储以供后续执行使用。
如果你指定了依赖项 deps,且 deps 中的任何值发生变化时。这时它会重新执行 effect,读取最新的 props 和状态值,并更新存储的 “旧值”。
如果你不指定依赖项 deps,则在每次渲染后都会调用 effect。这时它会继续使用存储的 “旧值”。
大致的 useEffect 实现如下:
function useEffect (create, deps) {
const effect = () => {
const newValue = create (); // 读取最新值
storedValue.current = newValue; // 更新旧值
if (!deps) {
didRender = true; // 没有依赖项,每次渲染后运行
if (didRender) effect (); // 运行 effect
if (deps && depsChanged) {
effect (); // 如果有依赖项且变化了,运行 effect
根据是否指定了依赖项 deps 及其是否发生变化,effect 会在以下情况被调用:
第一次渲染。此时会调用 effect,将 create 函数读取的值存储为 “旧值”。
如果指定了 deps 但未变化,什么也不会发生。继续使用存储的 “旧值”。
如果指定了 deps 且其发生变化,会调用 effect,通过 create 函数读取最新的值,并更新存储的 “旧值”。
如果不指定 deps,didRender 会在每次渲染后变为 true,从而调用 effect。但这时会继续使用存储的 “旧值”。
effect 函数的调用与是否指定依赖项 deps 及 deps 是否发生变化直接相关。理解 effect 根据这些条件的不同调用方式,是理解 useEffect 的关键。useEffect 会在合适的时机调用 effect,以执行必要的副作用操作,同时确保你在 effect 中总是使用最新的 props 和状态值。这就是 useEffect 的强大之处。
useEffect 大致实现
function useEffect (create, deps) {
const effect = () => {
const newValue = create (); // Re-run create and get new value
storedValue.current = newValue; // Update stored value
const storedValue = useRef (null);
const [depsChanged, setDepsChanged] = useState (false);
if (didRenderRef.current && deps === undefined) {
throw new Error ('Must either specify deps or no deps');
const prevDeps = useRef (deps);
const didRenderRef = useRef (false);
if (depsChanged || !prevDeps.current) {
prevDeps.current = deps; // Update prevDeps ref
didRenderRef.current = true;
useLayoutEffect (() => {
if (didRenderRef.current && !depsChanged && prevDeps.current !== deps) {
setDepsChanged (true); // Trigger re-run of effect
// Call the effect
useLayoutEffect (() => {
effect ();
// Re-run effect if deps change
useLayoutEffect (() => {
if (depsChanged && didRenderRef.current) {
effect ();
didRenderRef.current = false;
setDepsChanged (false);
}, deps);
// Always re-run on mount
useLayoutEffect (effect, []);
useMemo
useMemo 用于优化组件的渲染性能。它会在依赖项变化时重新计算 memoized 值,并且只在依赖项变化时重新渲染组件。你应该在以下情况使用 useMemo:
昂贵的计算:如果你有一个复杂的计算,它应该只在某些依赖项变化时重新运行,那么 useMemo 非常有用。它会记住最后计算的值,并仅在依赖项变化时重新计算。
避免不必要的渲染:如果你有一个组件,它在重新渲染时执行昂贵的 DOM 操作,那么你应该通过 useMemo 来优化它,使其只在依赖项变化时重新渲染。
依赖项变化时才重新计算值:如果你想基于 props 的某些值来计算一些数据,并且你只想在依赖 props 值变化时重新计算该数据。
在 React 函数组件中,每当组件重新渲染时,其函数体都会被执行。这意味着任何计算的数据或渲染的元素都会重新计算和重新创建。这通常没什么问题,但如果计算或渲染代价高昂,它可能会造成性能问题。
举个例子:
const expensiveComputation = (a, b) => {
// 做一些昂贵的计算...
return result;
function MyComponent () {
const [a, setA] = useState (1);
const [b, setB] = useState (1);
const result = expensiveComputation (a, b);
//...
在这里,每当组件重新渲染时,expensiveComputation 都会被调用,即使 a 和 b 没有变化。使用 useMemo 可以解决这个问题:
function MyComponent () {
const [a, setA] = useState (1);
const [b, setB] = useState (1);
const result = useMemo (() => expensiveComputation (a, b), [a, b]);
//...
现在,result 只会在 a 或 b 变化时重新计算。所以,useMemo 的主要目的就是为了避免 React 函数组件不必要的重复计算,提高组件的性能。
useMemo 的实现
useMemo 的实现比较简单,它基本上是 useEffect 的一个特例。
function useMemo (nextCreate, deps) {
currentlyRenderingMemo++;
const create = useRef (nextCreate);
const depsRef = useRef (deps);
function recompute () {
currentlyRenderingMemo++;
const memoizedValue = create.current ();
memoized.current = memoizedValue;
currentlyRenderingMemo--;
if (deps.current !== deps) {
deps.current = deps;
recompute ();
const memoized = useRef (null);
if (currentlyRenderingMemo === 0) {
recompute ();
return memoized.current;
它做了以下几件事:
当前渲染的 useMemo 数量加 1。这是为了避免在嵌套的 useMemo 调用中重复运行 effects。
用 useRef 创建对 create 函数和 deps 数组的引用。
定义 recompute 函数来调用 create 函数并更新 memoized 值。
如果 deps 变化了,调用 recompute 来重新计算 memoized 值。
如果这是第一个 useMemo 调用,调用 recompute 来计算初始 memoized 值。
返回 memoized 值。
在组件卸载时,自动清空 refs,相当于运行过清理函数。
所以本质上,它只在依赖项变化时重新运行 create 函数,并记住最后的值,这与 useEffect 有些相似。但 useMemo 专注于记忆化值,而不产生任何副作用。这就是 React 中 useMemo 的简单实现原理。它通过跟踪依赖项和缓存上次计算的值来优化组件渲染性能。
useCallback
useCallback 与 useMemo 类似,它也是用于优化性能的。但是它用于记忆化函数,而不是值。useCallback 会返回一个 memoized 回调函数,它可以确保函数身份在多次渲染之间保持不变,仅在某个依赖项变化时才会更新,这可以用于避免在每次渲染时都创建新的函数实例。所以,当你有一个会在多次渲染之间保持不变的函数时,使用 useCallback 是一个很好的优化手段。
举个例子,当你有一个函数作为事件处理程序时,它通常在创建后就不会改变。但是,如果你直接在渲染方法中定义这个函数,它会在每次渲染时重新创建。
function MyComponent () {
const [count, setCount] = useState (1);
function handleClick () {
setCount (c => c + 1);
return <button onClick={handleClick}>Increment</button>
这里,handleClick 函数在每次渲染时都会重新定义。虽然它的逻辑在多次渲染之间没有变化。
使用 useCallback 优化这段代码:
function MyComponent () {
const [count, setCount] = useState (1);
const handleClick = useCallback (() => {
setCount (c => c + 1);
}, []); // 依赖项 [] 表示仅在第一次渲染时创建
return <button onClick={handleClick}>Increment</button>
现在,handleClick 只会在第一次渲染时创建。在随后的渲染中,它都指向同一个函数实例。这可以避免在每次渲染时创建新的事件处理程序,从而优化组件的性能。
总结一下: useCallback 的主要作用是:
记忆化函数实例,避免在每次渲染时创建新的函数。
当函数作为 props 传递给子组件时,可以让子组件避免不必要的重新渲染。
与 useMemo 类似,你应该在依赖项变化时才更新回调函数。否则,它就没有意义了。总之,useCallback 主要用于性能优化,通过记忆化函数实例来避免不必要的重新创建和重新渲染。
useCallback 的实现
useCallback 的实现也比较简单。它基本上就是用 useMemo 来记忆化一个函数。
function useCallback (callback, deps) {
return useMemo (() => callback, deps);
它直接调用 useMemo,传入 callback 函数和 deps 数组。
所以,useCallback 的工作原理是:
在第一次渲染时,调用 callback 函数并记住结果。
在后续渲染中,如果 deps 没有变化,直接返回上次记住的函数。
如果 deps 变化了,再次调用 callback 并记住新结果。
在组件卸载时,自动清理 useMemo 的副作用。
所以本质上,它就是把函数当作 useMemo 的创建函数来调用,并根据依赖项决定是否需要重新创建函数实例。这与事件处理程序的例子非常吻合。
举个具体例子:
function useCallback (callback, [a, b]) {
return useMemo (() => {
callback () // 只在第一次渲染时调用
}, [a, b]) // 如果 a 或 b 变化时重新调用 callback
那么第一次渲染时会立即调用 callback,并记住结果。如果后续 a 或 b 变化,callback 会再次被调用,并更新记忆的值。如果 a 和 b 保持不变,直接返回上次记住的函数实例。这就是 useCallback 的简单实现原理。它通过将函数实例记忆化来确保函数身份在多次渲染之间保持一致,从而优化性能。综上,useCallback 的实现是基于 useMemo 的。它利用 useMemo 的记忆化特性来记忆化函数,以此来提高组件渲染性能。