useState
昂贵的初始值
initialState
参数只会在组件的初始渲染中起作用,后续渲染时会被忽略。如果初始 state 需要通过复杂计算获得,则可以传入一个函数,在函数中计算并返回初始的 state,此函数只在初始渲染时被调用:
const [state, setState] = useState(() => {
const initialState = someExpensiveComputation(props);
return initialState;
});
如果我们不使用函数而使用如下形式,此时 useState
的初始值 someExpensiveComputation(props)
每次渲染都会执行,但除初始渲染外,后续的每次渲染计算得到的值均会被忽略,而造成了资源的浪费。
const [state, setState] = useState(someExpensiveComputation(props));
函数式更新
如果新的 state 需要通过使用先前的 state 计算得出,那么可以将函数传递给 setState
。该函数将接收先前的 state,并返回一个更新后的值。
例如下面这个例子,定时器每1秒,count加1。其中 useEffect
的依赖须增加 count 的依赖,否则就会存在闭包问题,count的值一直为 1。但加了 count 依赖后,每次 count 的更新,都会触发定时器的销毁与重新创建,这不是我们所需要的,这个时候我们可以使用函数式更新的方式。
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1); // 这个 effect 依赖于 `count` state
}, 1000);
return () => clearInterval(id);
}, [count]);
return <h1>{count}</h1>;
}
更换为函数式更新后,useEffect 则不需要任何依赖,因为 set 函数是稳定的,不会变化。
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount(count => count + 1);
}, 1000);
return () => clearInterval(id);
}, []); // 这里没有任何依赖
return <h1>{count}</h1>;
}
批量更新原则
React 内部遵循的是批量更新原则。
所谓异步批量是指在一次页面更新中如果涉及多次 state 修改时,会合并多次 state 修改的结果得到最终结果从而进行一次页面更新。
关于批量更新原则也仅仅在合成事件中通过开启 isBatchUpdating 状态才会开启批量更新,简单来说:
- 凡是
React
可以管控的地方,他就是异步批量更新。比如事件函数,生命周期函数中,组件内部同步代码。 - 凡是
React
不能管控的地方,就是同步批量更新。比如setTimeout
,setInterval
,原生DOM
事件中,包括Promise
中都是同步批量更新。
在 React 18 中通过 createRoot 中对外部事件处理程序进行批量处理,换句话说最新的 React 中关于 setTimeout、setInterval 等不能管控的地方都变为了批量更新。
声明 State 的一些原则
最小化状态
能用其他状态计算出来就不要单独声明状态。 一个 state 必须不能通过其它 state/props 直接计算出来,否则就不用定义 state。
const SomeComponent = (props) => {
const [source, setSource] = useState([
{type: 'done', value: 1},
{type: 'doing', value: 2},
])
const [doneSource, setDoneSource] = useState([]) // 可计算得出,不用定义
const [doingSource, setDoingSource] = useState([]) // 可计算得出,不用定义
useEffect(() => {
setDoingSource(source.filter(item => item.type === 'doing'))
setDoneSource(source.filter(item => item.type === 'done'))
}, [source])
return (
<div>
.....
</div>
)
}
上面的示例中,变量 doneSource
和 doingSource
可以通过变量 source
计算出来,那就不要定义 doneSource
和 doingSource
了!
保证数据源唯一
在项目中同一个数据,保证只存储在一个地方。
- 不要既存在 redux 中,又在组件中定义了一个 state 存储;
- 不要既存在父级组件中,又在当前组件中定义了一个 state 存储;
- 不要既存在 url query 中,又在组件中定义了一个 state 存储。
function SearchBox({ data }) {
const [searchKey, setSearchKey] = useState(getQuery('key'));
const handleSearchChange = e => {
const key = e.target.value;
setSearchKey(key);
history.push(`/movie-list?key=${key}`);
}
return (
<input
value={searchKey}
placeholder="Search..."
onChange={handleSearchChange}
/>
);
}
在上面的示例中,searchKey
存储在两个地方,既在 url query 上,又定义了一个 state。完全可以优化成下面这样:
function SearchBox({ data }) {
const searchKey = parse(localtion.search)?.key;
const handleSearchChange = e => {
const key = e.target.value;
history.push(`/movie-list?key=${key}`);
}
return (
<input
value={searchKey}
placeholder="Search..."
onChange={handleSearchChange}
/>
);
}
适当合并
const [firstName, setFirstName] = useState();
const [lastName, setLastName] = useState();
const [school, setSchool] = useState();
const [age, setAge] = useState();
const [address, setAddress] = useState();
const [weather, setWeather] = useState();
const [room, setRoom] = useState();
同样含义的变量可以合并成一个 state,代码可读性会提升很多:
const [userInfo, setUserInfo] = useState({
firstName,
lastName,
school,
age,
address
});
const [weather, setWeather] = useState();
const [room, setRoom] = useState();
当然这种方式我们在变更变量时,一定不要忘记带上老的字段,比如我们只想修改 firstName
:
setUserInfo(s=> ({
...s,
fristName,
}))
React Class 组件,state 是会自动合并的:
this.setState({
firstName
})
我们可以使用 ahooks 的 useSetState,其封装了类似的逻辑:
const [userInfo, setUserInfo] = useSetState({
firstName,
lastName,
school,
age,
address
});
// 自动合并
setUserInfo({
firstName
})
useRef 的使用
惰性初始值
有时我们想要避免重新创建 useRef()
的初始值。 举个例子,我们想确保 class 实例只被创建一次:
function Image(props) {
// ⚠️ IntersectionObserver 在每次渲染都会被创建
const ref = useRef(new IntersectionObserver(onIntersect));
// ...
}
useRef
不会 像 useState
那样接受一个特殊的函数重载。相反,我们编写你自己的函数来创建并将其设为惰性的:
function Image(props) {
const ref = useRef(null);
// ✅ IntersectionObserver 只会被惰性创建一次
function getObserver() {
if (ref.current === null) {
ref.current = new IntersectionObserver(onIntersect);
}
return ref.current;
}
// 当你需要时,调用 getObserver()
// ...
}
使用 ahooks 中的 useCreation
来实现 useRef 函数式初始值的功能:
const a = useRef(new Subject()); // 每次重渲染,都会执行实例化 Subject 的过程,即便这个实例立刻就被扔掉了
const b = useCreation(() => new Subject(), []); // 通过 factory 函数,可以避免性能隐患
避免 useCallback 的使用
function App(props) {
const method1 = () => {
// ...
}
const method2 = useCallback(() => {
// 这是一个和 method1 功能一样的方法
}, [props.a, props.b])
return (
<div>
<div onClick={method1}>button</div>
<div onClick={method2}>button</div>
</div>
)
}
上述代码,自然而然会觉得 method2
的性能要高一点,因为 method2
除非是依赖变了,不然不会重新生成,然而这是不正确的。
首先,每次执行函数,都重新生成一下它内部的变量这件事,开销是可以忽略不计的,这一点,官网的 Hooks FAQ 给出了我们相关的结论:
另外,useCallback
也一样每次都会生成新的函数(useCallbck
第一个参数对应的函数也需内存),只不过它生成了没有使用罢了。
一个典型的例子
我们拿官网的这个例子来举例:
function ProductPage({ productId }) {
const [product, setProduct] = useState(null);
async function fetchProduct() {
const response = await fetch('http://myapi/product/' + productId); // 使用了 productId prop
const json = await response.json();
setProduct(json);
}
useEffect(() => {
fetchProduct();
}, []); // 🔴 这样是无效的,因为 `fetchProduct` 使用了 `productId`
// ...
}
这里的 useEffect
依赖存在问题,自然而然我们会想到做如下修改:
function ProductPage({ productId }) {
const [product, setProduct] = useState(null);
const fetchProduct = useCallback(async () => {
const response = await fetch('http://myapi/product/' + productId);
const json = await response.json();
setProduct(json);
}, [productId])
useEffect(() => {
fetchProduct();
}, [fetchProduct]);
}
虽然这样能解决我们的问题,但这种方式 React 官网是不推荐的,最佳的做法是将函数移到 effect 内部。
若函数不能被移到 effect 内部,官网也提供了几点做法,但 useCallback 也是万不得已情况下才使用:
如果借助 ahooks 的 useMemoizedFn 也可以完美的解决该问题:
在某些场景中,我们需要使用 useCallback 来记住一个函数,但是在第二个参数 deps 变化时,会重新生成函数,导致函数地址变化。
const [state, setState] = useState(''); // 在 state 变化时,func 地址会变化 const func = useCallback(() => { console.log(state); }, [state]);
使用 useMemoizedFn,可以省略第二个参数 deps,同时保证函数地址永远不会变化。
const [state, setState] = useState(''); // func 地址永远不会变化 const func = useMemoizedFn(() => { console.log(state); });
useCallback 使用场景
假设我们有一个叫做 Counter
的子组件,初始化渲染的时候消耗非常大:
<ExpensiveCounter count={count} onClick={handleClick} />
如果我们不做任何优化,父组件有了任何更新,都会重新渲染 Counter
。为了避免每次渲染父组件的时候都重新渲染子组件,我们可以使用 React.memo
:
const ExpensiveCounter = React.memo(function Counter(props) {
...
})
使用 React.memo
包裹之后,Counter
组件只有在 props
发生变化的时候才会重新渲染,我们的 Counter
接受两个 props
:原始值 count
,函数 handleClick
。
如果父组件由于其他值的更改而发生了更新,父组件会重新渲染,由于 handleClick
是一个对象,每次渲染生成的 handleClick
都是新的。
这就会导致,尽管 Counter
被 React.memo
包裹了一层,但是还是会重新渲染,为了解决这个问题,我们就要这样写 handleClick
函数了:
const handleClick = useCallback(() => {
// 原来的 handleClick...
}, [])
这样,我们每次传递给 Counter
组件的 handleClick
都是同一个,我们的 Counter
组件只有在 count
发生变化的时候才会去渲染,这正是我们想要的,也就起到了很好的优化作用。
使用 useReducer + context 实现深层次的组件通信
const TodosDispatch = React.createContext(null);
function TodosApp() {
// 提示:`dispatch` 不会在重新渲染之间变化
const [todos, dispatch] = useReducer(todosReducer);
return (
<TodosDispatch.Provider value={dispatch}>
<DeepTree todos={todos} />
</TodosDispatch.Provider>
);
}
TodosApp
内部组件树里的任何子节点都可以使用 dispatch
函数来向上传递 actions 到 TodosApp
:
function DeepChild(props) {
// 如果我们想要执行一个 action,我们可以从 context 中获取 dispatch。
const dispatch = useContext(TodosDispatch);
function handleClick() {
dispatch({ type: 'add', text: 'hello' });
}
return (
<button onClick={handleClick}>Add todo</button>
);
}
总而言之,从维护的角度来这样看更加方便(不用不断转发回调),同时也避免了回调的问题。像这样向下传递 dispatch
是处理深度更新的推荐模式。
如果你选择使用 context 来向下传递 state,请使用两种不同的 context 类型传递 state 和 dispatch —— 由于 dispatch
context 永远不会变,因此读取它的组件不需要重新渲染,除非这些组件也需要用到应用程序的 state。
useLayoutEffect
useLayoutEffect 与 useEffect 使用方式是完全一致的,useLayoutEffect 的区别在于它会在所有的 DOM 变更之后同步调用 effect。
可以使用它来读取 DOM 布局并同步触发重渲染。在浏览器执行绘制之前, useLayoutEffect 内部的更新计划将被同步刷新。
通常对于一些通过 JS 计算的布局,如果你想减少 useEffect 带来的「页面抖动」,你可以考虑使用 useLayoutEffect 来代替它。
如何测量 DOM 节点?
获取 DOM 节点的位置或是大小的基本方式是使用 callback ref。每当 ref 被附加到一个另一个节点,React 就会调用 callback。
function MeasureExample() {
const [height, setHeight] = useState(0);
const measuredRef = useCallback(node => {
if (node !== null) {
setHeight(node.getBoundingClientRect().height);
}
}, []);
return (
<>
<h1 ref={measuredRef}>Hello, world</h1>
<h2>The above header is {Math.round(height)}px tall</h2>
</>
);
}
在这个案例中,我们没有选择使用 useRef
,因为当 ref 是一个对象时它并不会把当前 ref 的值的 变化 通知到我们。使用 callback ref 可以确保即便被测量的节点延迟显示,我们依然能够接收到相关的信息,以便更新测量结果。
在此示例中,当且仅当组件挂载和卸载时,callback ref 才会被调用。如果你希望在每次组件调整大小时都收到通知,则可能需要使用 ResizeObserver
或基于其构建的第三方 Hook。