React的hook✅
为什么hook必须在组件内的顶层声明?
这是为了确保每次组件渲染时,Hooks 的调用顺序保持一致。React利用 hook 的调用顺序来跟踪各个 hook 的状态。每当一个函数组件被渲染时,所有的 hook 调用都是按照从上到下的顺序依次执行的。React 内部会维护一个状态列表,这个列表中的每个状态项都对应一个 hook 的调用,包括 useState、useEffect 等。当你调用 useState(initialValue) 时,React 会在内部为这个状态分配一个索引。该索引基于 hook 调用的顺序。例如,第一次调用 useState 时,它会在状态列表的第一个位置存储状态,第二次调用会在第二个位置存储,以此类推。
考虑下面这个demo:
const Component = () => {
const [count, setCount] = useState(0);
const handleOfClick = () => setCount(count + 1);
return <button onClick={setCount}>{count}</button>
}
Q:既然每次视图的更新都会重新执行整个函数,那必然会执行到const [count, setCount] = useState(0)
这句代码。如果我在上一次更新中把count加到10,为什么在新的渲染周期中,React能记住这个10而不是传给useState的0呢?
A:当组件重新渲染时,React 会根据组件的调用顺序再次按顺序调用对应的 hook。这样,React 可以确保它能够始终访问到正确的状态。例如,当第二次渲染时,React 知道第一个 useState 是哪个状态,因为它在第一次渲染时已经分配了这个状态的索引,这个索引是靠hook调用的顺序产生的索引来追踪的。 所以如果在条件语句、循环或嵌套函数中调用 hook,可能会导致调用顺序的变化,从而产生不可预知的状态。
useImperativeHandle
useImperativeHandle通常是和forwardRef配合使用的,用来把子组件中的属性或者方法暴露给父组件,在进行组件的封装或者组件间的通信的时候常会使用。如下:
// 封装一个可拖拽的组件
const DragComponent = forwardRef(
(
props: {
children: React.ReactNode, // 求求你不要挂一个很复杂的组件进来🙏
// other config...
},
ref // ref是必须的
) => {
// 复位
const resetPosition = () => {
// todo: 可以在父组件中调用,让这个可拖拽的组件在父组件中回到第一次渲染的位置
}
useImperativeHandle(ref, () => {
resetPosition // 显式声明
})
return (
<div>{props.children}</div>
)
}
)
// 之后在某一个页面中使用它
const Page = () => {
const dragRef = useRef(null)
const handleOfClick = () => {
dragRef.current?.reset();
}
return (
<div>
<DragComponent ref={dragRef}/>
<button onClick={handleOfClick}>复位</button>
</div>
)
}
useCallback
useCallBack用来缓存一个函数的引用,它常常配合memo使用以提高渲染的性能。
const Component = memo((
{ count, setCount }: { count: number; setCount: () => void }
) => {
console.log("CountComponeng render");
return (
<div>
<button onClick={() => setCount()}>CountComponeng: {count}</button>
</div>
);
})
function App() {
console.log("App render");
const [parentCount, setParentCount] = useState(0);
const [childCount, setChildCount] = useState(0);
const addChildCount = useCallback(() => {
setChildCount(childCount + 1);
}, [childCount]);
// 这样也行
// const addChildCount = useCallback(() => {
// setChildCount((count) => count + 1);
// }, []);
return (
<div id="app">
<h1>Hello Vite + React!</h1>
<button onClick={() => setParentCount(parentCount + 1)}>
parentCount: {parentCount}
</button>
<CountComponeng setCount={setChildCount} count={childCount} />
</div>
);
}
export default App;
useReducer
类似redux的更规范的写法,我用的还不多😢,权当记录(该说不说,确实优雅):
import React, { useReducer } from "react";
// 定义初始状态
const initialState = { count: 0 };
// 定义 reducer 函数
const reducer = (state, action) => {
switch (action.type) {
case "increment":
return { count: state.count + 1 };
case "decrement":
return { count: state.count - 1 };
default:
return state;
}
};
const Counter = () => {
// 使用 useReducer
const [state, dispatch] = useReducer(reducer, initialState);
return (
<div>
<p>Count: {state.count}</p>
<button onClick={() => dispatch({ type: "increment" })}>Increment</button>
<button onClick={() => dispatch({ type: "decrement" })}>Decrement</button>
</div>
);
};
export default Counter;
setState的函数写法和变量写法
我有这样的代码:const [count, setCount] = useState(0)
,在普通的情况下setCount(count + 1)
和setCount((count) => count + 1)
都能实现count加1并更新视图的操作。
但是,考虑下面这个demo:
const handleOfClick = () => {
setCount(count+1)
setCount(count+1)
setCount(count+1)
}
每一次点击,count最终都只能加1而不能加3,这个React官网介绍的很清楚这里不多说。但是如果把上面的setCount(count+1)
换成setCount((count) => count + 1)
,确实能实现点击一次就+3并更新视图的功能,因为这种函数的写法保证了在进行状态更新时,能够获取到最新的状态值,特别是在状态更新依赖于之前的状态值时,可以避免因为异步执行导致的潜在问题。
始终记住setState是异步的,而且不是没setState一次就更新一次视图的(涉及到React为了优化渲染性能而使用的批量更新策略)。
再考虑一个更普遍的场景:
const Page = () => {
const [count, setCount] = useState(1);
useEffect(() => {
const scrollableContainer = document.getElementById("scrollable-container");
scrollableContainer?.addEventListener("scroll", () => {
setCount(count + 1)
});
}, [])
return <div id="scroll-container">{count}</div>
}
你会发现,任凭你滚动的再快,count只会加到2,然后就一直不变了。因为此处的useEffect只会执行一次,当你使用 addEventListener 直接绑定事件时,你得到的是一个闭包。在这个闭包中,count 的值是在事件绑定时捕获的(1)。
但是把setCount(count+1)
换成setCount((count) => count + 1)
就能每次滚动的时候都加1,因为它会接受当前状态作为参数,这样每次更新都会基于最新的状态进行计算,从而避免因为闭包问题导致的状态不正确的问题。