写给一年前自己的React Hooks释疑
前言
React Hooks自发布以来褒贬不一,支持者认为它提升了代码的简洁性与复用性,反对者则认为它带来的心智负担过于沉重。然而不可否认的是,如今Hooks已成为React体系的核心概念之一,它已深深扎根于React生态系统之中,并将持续生长发展。
这篇文章会从useState、useCallback、useEffect三个基础Hook入手,适时提供示例,介绍React Hooks某些限制的由来,纠正新人常有的误解,希望能为初学者带来一些帮助。
useState
理解的关键:状态快照
首先我们要明确一点,在一次渲染过程中,无论是在JSX中还是在事件回调里,通过useState
获取到的状态值总是固定不变的。这是因为useState
总是返回当前渲染时的状态快照。
举个例子,下面的代码中点击按钮不会使计数器自增:
function Counter() {
const [count, setCount] = useState(0);
const handleClick = () => {
count++;
console.log(count); // 输出的总是修改前的值
};
return (
<div>
Count: {count}
<button onClick={handleClick}>+</button>
</div>
);
}
原因就在于,count
只是一个常量,它是当前渲染时状态的一个快照。**在React中,我们是不能直接修改状态的,必须通过状态更新函数来触发新的渲染。**这里应该使用setCount(count + 1)
。
同理,如果一个状态在某次渲染中被多次引用,无论是在JSX还是在回调函数中,得到的值都是一样的:
function Counter() {
const [count, setCount] = useState(0);
const handleClick = () => {
console.log(count);
setCount(count + 1);
};
return (
<div>
Count: {count}
<button onClick={handleClick}>{count}</button>
</div>
);
}
点击按钮,handleClick
中和JSX中的count
输出是一样的。在同一次渲染中,一个状态的值是恒定不变的。
然而,由于状态值被"锁定",在异步回调中访问状态时,可能会遇到闭包问题:
function Counter() {
const [count, setCount] = useState(0);
const handleClick = () => {
setTimeout(() => {
setCount(count + 1);
}, 1000);
};
return (
<div>
Count: {count}
<button onClick={handleClick}>+</button>
</div>
);
}
连续点击按钮几次,会发现计数器只增加了1。这是因为setTimeout
中的回调捕获了点击时count
的值。**在事件处理函数中,异步回调捕获的总是当次渲染时状态的快照。**要解决这个问题,可以使用函数式更新:
setCount(prevCount => prevCount + 1);
这样,更新函数在执行时会获取到最新的状态值。
理解状态快照的概念,对于正确使用useState
至关重要。它能让你避免许多常见的陷阱,写出可预测的代码。
异步的状态更新
在上面的例子中,我们已经看到,setCount
并不会立即改变count
的值,而是触发一次新的渲染。事实上,React中所有的状态更新都是异步的。
当我们调用状态更新函数时,React并不会马上修改状态,而是把这次更新放入一个队列中。在一次事件处理或生命周期函数中,无论我们调用多少次更新函数,React都会把它们打包成一次重渲染。
function Counter() {
const [count, setCount] = useState(0);
const handleClick = () => {
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
};
return (
<div>
Count: {count}
<button onClick={handleClick}>+3</button>
</div>
);
}
点击按钮,计数器只会增加1,而不是3。这是因为这三次setCount
调用使用的count
值都是当前渲染时的值0。React会把这三次更新打包,相当于setCount(0 + 1)
。
正因为状态更新是异步的,所以我们不能在更新后立即获取到最新的状态值:
function handleClick() {
setCount(count + 1);
console.log(count); // 输出的是更新前的值
}
如果要在状态更新后执行某些操作,可以使用useEffect
:
useEffect(() => {
console.log(count); // 输出更新后的值
}, [count]);
有些同学可能会问,既然状态更新是异步的,那我们能不能用await
来等待更新完成呢?很遗憾,答案是不能。**状态更新函数本身并不返回Promise,所以是不能被await
的。**这是因为,React并不想让一次更新"阻塞"其他更新,相反,它希望尽可能批量处理更新,提高性能。
理解状态更新的异步性,对于避免依赖过时状态、正确处理复杂的状态逻辑非常重要。
何时会触发重渲染
在类组件中,我们通过调用this.setState
来触发组件的重渲染。而在函数组件中,状态更新函数(如setCount
)是触发重渲染的唯一方式。
每次调用状态更新函数,React都会重新执行组件函数,得到最新的JSX。如果新的JSX与上一次渲染的不同,React就会更新DOM。
function Counter() {
const [count, setCount] = useState(0);
console.log('render');
return (
<div>
Count: {count}
<button onClick={() => setCount(count + 1)}>+</button>
<button onClick={() => setCount(count)}>refresh</button>
</div>
);
}
在这个例子中,点击第一个按钮会让计数器自增并触发重渲染,而点击第二个按钮虽然调用了setCount
,但并没有改变状态的值,所以并不会触发重渲染。
这里需要注意的是,**即使两次渲染返回的JSX是一模一样的,只要状态发生了改变,就一定会触发重渲染。**React并没有在渲染前比较新旧JSX的智能优化。这是出于两点考虑:
- 在渲染过程中,组件可能有副作用,如发起网络请求、操作DOM等,即使最终返回的JSX没变,这些副作用也应该被执行。
- JSX的比对本身就有一定开销,在现代浏览器下,大多数情况下这个开销可以忽略不计。为了一点点性能而引入复杂的比对逻辑,得不偿失。
当然,如果你真的遇到了因为重复渲染导致的性能问题,React也提供了一些优化手段,如useMemo
、useCallback
等。但在优化之前,我们更应该关注的是,渲染本身是否必要,是否可以通过拆分组件、简化状态来避免不必要的渲染。
当我们写JSX时,我们在写什么
JSX是React的核心特性之一,它让我们能够用一种类似HTML的语法来描述UI。但JSX并不是什么魔法,它最终会被编译成普通的JavaScript。
当我们写下这样一段JSX:
<div>
<h1>Hello, world!</h1>
<p>Welcome to my app.</p>
</div>
React会将其编译为:
React.createElement(
"div",
null,
React.createElement("h1", null, "Hello, world!"),
React.createElement("p", null, "Welcome to my app.")
);
React.createElement
会根据传入的参数,创建一个描述UI的JavaScript对象,我们称之为"React元素"。所以,JSX本质上只是创建React元素的一种语法糖。
这也解释了为什么我们可以在JSX中使用变量和表达式:
const name = 'Josh Perez';
const element = <h1>Hello, {name}</h1>;
因为在编译后,{name}
会成为React.createElement
的一个参数:
const name = 'Josh Perez';
const element = React.createElement("h1", null, "Hello, ", name);
同样,当我们在JSX中渲染另一个组件时:
function Welcome(props) {
return <h1>Hello, {props.name}</h1>;
}
const element = <Welcome name="Sara" />;
编译后,<Welcome name="Sara" />
也会变成一个React.createElement
调用:
const element = React.createElement(Welcome, {name: "Sara"});
但这里有一点不同,**在JSX中,如果一个元素的类型是一个函数或类,React会调用这个函数或类来渲染元素,而不是直接创建一个DOM节点。**这就是React组件的基本原理。
理解JSX的编译过程对于深入理解React非常有帮助。它让我们知道JSX并没有什么神奇之处,它最终还是要转化为React API的调用。同时它也提醒我们,在JSX中使用过多的逻辑和表达式会影响可读性,适度地抽取组件和变量是一个好习惯。
useCallback
不会自动带来性能优化
有人可能认为只要使用了useCallback就一定能优化性能,但事实并非如此。useCallback的实质是逐个比较依赖数组中的元素,如果依赖项没有发生变化,就返回上一次缓存的函数引用;否则,就返回一个新的函数引用。
当一个使用了useCallback的函数作为props传递给子组件时,如果子组件使用了React.memo()进行了优化,那么只有在useCallback返回的函数引用发生变化时,子组件才会重新渲染。这样可以避免不必要的渲染,减少渲染计算的成本。
举个例子:
const ParentComponent = () => {
const [count, setCount] = useState(0);
const handleClick = useCallback(() => {
setCount(count + 1);
}, [count]);
return (
<div>
<p>Count: {count}</p>
<ChildComponent onClick={handleClick} />
</div>
);
};
const ChildComponent = React.memo(({ onClick }) => {
return <button onClick={onClick}>Increment</button>;
});
在这个例子中,如果不使用useCallback,每次ParentComponent渲染时,都会创建一个新的handleClick函数,导致memo失效,ChildComponent也跟着重新渲染。而使用了useCallback后,只有当count发生变化时,handleClick函数才会更新,避免了不必要的重新渲染。
但是,如果useCallback的依赖数组频繁变化,或者缓存的函数本身比较复杂,或者自组件没有使用memo,反而可能会导致性能下降。因此在使用useCallback时,要根据实际情况权衡利弊。
除了useCallback之外,useMemo和useEffect也可以用来减少不必要的计算和操作。useMemo可以缓存计算结果,useEffect可以避免每次渲染都执行副作用。
const ParentComponent = () => {
const [count, setCount] = useState(0);
const expensiveValue = useMemo(() => {
// 一些复杂的计算
return calculateValue(count);
}, [count]);
useEffect(() => {
// 一些副作用操作,比如订阅事件
const subscription = subscribe();
return () => {
// 清理副作用,比如取消订阅
unsubscribe(subscription);
};
}, []);
// ...
};
难以去掉的依赖数组
在使用useCallback时,我们必须正确地声明依赖数组,以确保函数中使用的所有变量都包含在依赖数组中。否则,可能会导致函数引用的变量不是最新的值,产生bug。
为了解决这个问题,社区中有一些常用的方案,比如使用useRef和useEffect(或useLayoutEffect)来实现useMemoizedFn,以便在函数内部访问最新的props和state。
const useMemoizedFn = (fn) => {
const ref = useRef(fn);
useLayoutEffect(() => {
ref.current = fn;
});
return useCallback((...args) => ref.current(...args), []);
};
但这种方案也有一些不足之处,比如会带来额外的复杂度,而且在某些场景下可能会有性能问题。
React官方也意识到了这个问题,并提出了一个新的RFC(Request for Comments):useEvent。useEvent可以在不需要手动声明依赖数组的情况下,始终访问到最新的props和state。
const handleClick = useEvent((e) => {
// 这里可以安全地访问到最新的props和state
console.log(e.target);
});
如果这个RFC最终被采纳,相信会极大地简化useCallback的使用。
useEffect
不是"副作用标记"
与初学者可能有的理解不同,useEffect
并不是用来给某些操作"打标记",声明它们是副作用。事实上,useEffect
中的操作会在每次渲染后才执行。React会在渲染后根据effect的定义,将其操作排队,然后依次执行。
举个例子,如果我们想在组件渲染后,根据某个状态值决定是否发送网络请求,可以这样写:
function Example() {
const [count, setCount] = useState(0);
useEffect(() => {
if (count > 0) {
// 在渲染后根据count值决定是否发请求
fetchSomething(count);
}
}, [count]);
// ...
}
可以看到,我们在useEffect
中根据count
的值决定是否发请求。这个判断逻辑会在每次渲染后执行。React并不会因为我们把它放在了useEffect
中,就在渲染过程中替我们做这个判断。相反,useEffect
就像一个"待办清单",React会在渲染后的某个时机,逐个执行清单上的任务。
所以,useEffect
的作用并非标记操作的性质,而是声明在满足某些条件时,需要在渲染后执行哪些操作。它让我们能够把关注点分离,在渲染过程中专注于产生UI,而将那些与渲染不直接相关的操作放到渲染后去执行。这种关注点分离,提高了组件的可读性和可维护性。
空依赖数组不是特例
在useEffect
中,我们通过指定依赖数组来控制effect的执行时机。当依赖数组中的值发生变化时,React会重新执行effect。有些初学者可能会把空依赖数组[]
看作一种特殊情况,认为它会让effect只在挂载时执行一次。但事实并非如此。
React在每次渲染后,都会逐个比较依赖数组中的值是否发生变化。对于空依赖数组来说,由于它不依赖任何变量,因此在每次渲染后比较时,总是会得到相同的结果。这就像在问"0等于0吗?",答案永远是"是"。因此,使用空依赖数组的effect,只会在组件挂载后执行一次。
举个例子:
function Example() {
useEffect(() => {
console.log('只在挂载后执行一次');
}, []); // 空依赖数组
useEffect(() => {
console.log('在每次渲染后都会执行');
}); // 没有依赖数组
// ...
}
在这个例子中,第一个effect使用了空依赖数组[]
,因此它只会在组件挂载后执行一次。而第二个effect没有指定依赖数组,这意味着它会在每次渲染后都执行。
所以,空依赖数组并不是一种特殊的语法,它只是依赖数组这一通用机制的一个常见用例。理解了这一点,我们就不应该把[]
看得太过特殊,而应该用同样的思维方式去理解所有的依赖数组。
依赖数组与闭包问题
在使用useEffect
时,我们经常会遇到闭包问题。这主要发生在effect中使用了函数组件中定义的变量,但没有将其正确地加入依赖数组的情况下。
举个例子,假设我们要在组件挂载时注册一个事件监听器,并在组件卸载时移除它:
function Example() {
const [count, setCount] = useState(0);
useEffect(() => {
const handleClick = () => {
console.log(`You clicked ${count} times`);
};
document.addEventListener('click', handleClick);
return () => {
document.removeEventListener('click', handleClick);
};
}, []); // 错误的依赖数组
// ...
}
在这个例子中,我们在effect中使用了count
变量,但没有将其加入依赖数组。这会导致一个问题:监听器中的count
值永远是0,因为它捕获了组件第一次渲染时的count
值。
正确的做法是将count
加入依赖数组:
useEffect(() => {
const handleClick = () => {
console.log(`You clicked ${count} times`);
};
document.addEventListener('click', handleClick);
return () => {
document.removeEventListener('click', handleClick);
};
}, [count]); // 正确的依赖数组
所以,在使用useEffect
时,我们需要仔细分析effect中使用的变量,正确地设置依赖数组。有时,我们可能需要调整代码结构,以避免不必要的重复执行,同时确保始终可以访问到最新的变量值。
简单对比 useEffect 与 Vue 的 watch
对于有Vue经验的开发者来说,React的useEffect
可能会让人感到有些困惑。在Vue中,我们通常使用watch
来观察数据的变化并执行一些操作。而在React中,useEffect
似乎承担了类似的职责。但它们还是有一些重要的区别。
在Vue中,watch
是一种明确的数据依赖声明。我们告诉Vue,当某些数据变化时,需要执行某些操作。这种方式非常直观,但有时也会导致过度使用watch
的问题。
而在React中,useEffect
并不是专门用来观察数据变化的。它更像是一个通用的"副作用处理器",用于处理那些不直接影响渲染结果的操作,如订阅事件、发送网络请求等。React倾向于更加显式(explicit)的编程风格,鼓励我们将状态变化的处理逻辑放在事件处理函数中,而不是散布在各处的useEffect
中。
举个例子,假设我们有一个表单,在用户输入时需要实时保存草稿。在Vue中,我们可能会这样写:
<template>
<input v-model="draft" />
</template>
<script>
export default {
data() {
return {
draft: '',
};
},
watch: {
draft(newValue) {
saveDraft(newValue);
},
},
};
</script>
而在React中,更地道的写法是这样的:
function Form() {
const [draft, setDraft] = useState('');
const handleChange = (e) => {
const newDraft = e.target.value;
setDraft(newDraft);
saveDraft(newDraft);
};
return <input value={draft} onChange={handleChange} />;
}
在React的版本中,我们将保存草稿的逻辑直接放在了事件处理函数handleChange
中,而不是使用useEffect
来观察draft
的变化。这种方式更加直观,也更容易理解数据流动。
当然,这并不意味着useEffect
就是不好的。在许多场景下,useEffect
仍然是最佳的工具,比如订阅外部事件、管理计时器等。但在处理用户交互和数据流动时,我们应该优先考虑更加显式和直接的方式,而不是过度依赖useEffect
。
总的来说,useEffect
与Vue的watch
有相似之处,但也有重要区别。在React中,我们应该更加谨慎和有目的地使用useEffect
,而不是将其视为watch
的替代品。
总结
总的来说,Hooks是React的一大利器,但也不是银弹。过度使用Hooks、滥用依赖数组、错误地管理副作用,都可能给应用带来不必要的复杂度和性能问题。作为开发者,我们应该努力吃透Hooks的本质,根据场景合理使用,并始终关注应用的可维护性和性能。
希望这篇文章能带来一些启发,让我们在使用Hooks时能多些思考,少些迷茫。同时也欢迎大家在评论区分享理解和经验,让我们共同进步。
转载自:https://juejin.cn/post/7352322041850675226