分两部分,第一部分官方hook,第二部分为各种库和自己写的自定义hooks,这个文章会持续更新。
官方Hook
官方Hook这部分不记录各Hook的基本使用方式,重点是记录一些特性。大部分内容来源于React官网。
useEffect(useLayoutEffect)
React的函数组件应该是是纯函数,在函数组件主体内(这里指在 React 渲染阶段)改变 DOM、添加订阅、设置定时器、记录日志以及执行其他包含副作用的操作都是不被建议的,因为这可能会产生莫名其妙的 bug 并破坏 UI 的一致性。这些副作用任务一般交给useEffect来做。
effect执行时机
关于执行时机我一般都习惯和类组件的生命周期对比,所以先来深入的思考一下componentDidMount 到底是什么时候调用的。
componentDidMount默认为同步函数,会在组件挂载后(插入 DOM 树中)立即调用。要注意,这里不是渲染后调用,是在插入DOM树之后调用的,这是有区别的,不要把组件的生命周期看作是DOM的生命周期。如下图浏览器内核运行流程
可以看到,浏览器内核在把HTML解析为DOM 树,又把CSS解析为CSS规则树之后,会结合进行布局操作,形成渲染树,然后才会去绘制页面。componentDidMount就是在生成DOM树之后,绘制之前这个区间内调用的。
这就代表着我们可以在这里来读取 DOM 布局并同步触发重渲染,比如可以在 componentDidMount() 里直接调用 setState()。它将触发额外渲染,此渲染会发生在浏览器更新屏幕之前。如此保证了即使在 render() 两次调用的情况下,用户也不会看到中间状态。这种情况的调用步骤为:render=>componentDidMount=>render=>componentDidUpdate。
接下来回到effect执行时机。
与 componentDidMount、componentDidUpdate 不同的是,在更改作用于DOM并让浏览器绘制屏幕后才会去调用useEffect的effect。尽管如此,也一定会在任何新的渲染之前执行,并且React会在组件更新前刷新上一轮渲染的effect。
如果某些effect期望在浏览器绘制之前调用,比如dom变更,使用useLayoutEffect,在浏览器执行绘制之前,useLayoutEffect 内部的更新计划将被同步刷新。调用阶段和componentDidMount、componentDidUpdate 一样,所以不多说了。
下面按步骤细说:
- React在render阶段(diff,render函数)之后,进入pre-commit阶段,类组件触发
getSnapshotBeforeUpdate生命周期,函数组件会在调度useEffect的effect和effect的清除函数。注意是调度,不是执行,只是加入一个队列中。 - commit阶段,React将更新的内容挂载到DOM树,挂载完成以后,类组件会同步执行
componentDidMount或componentDidUpdate,函数组件同步执行useLayoutEffect的effect,在这之前会执行useLayoutEffect上一个effect的清除函数。 - 由于js线程和浏览器渲染线程是互斥的,只要js还在运行,即使内存中的真实 DOM 已经变化,浏览器也不会立刻渲染到屏幕。
- 当js运行完成后,浏览器渲染。React只用一次重绘的代价,就把所有需要更新的 DOM 节点全部更新完成。
- 渲染完成后运行
useEffect的effect,在这之前会执行useEffect上一次effect返回的清除函数。
清除effect
useEffect的返回函数就是清除函数,为防止内存泄漏,清除函数会在组件卸载前执行。另外如果组件多次渲染,则在执行下一个 effect 之前,上一个 effect 就已被清除,需要注意的是,和effect一样,清除函数也是在浏览器绘制屏幕后被延迟调用。例如第一次渲染id为10,第二次渲染id为20:
- React渲染id为20的UI
- 浏览器绘制,我们在屏幕上看到id为20的UI
- React清除id为10的effect
- React运行id为20的effect
effect条件执行
useEffect 的依赖项,分三种
- 没有,只要组件更新(包括第一次挂载)就调用。
- 数组,数组内的依赖项改变时,effect重新创建并调用。
- 空数组,仅在挂载和卸载时调用,不属于特殊情况,仍然遵循数组的运行方式,只是代表不需要响应任何值的改变。
典型问题
为什么effect不能是异步函数
async函数是使用async关键字声明的函数。 async函数是AsyncFunction构造函数的实例, 并且其中允许使用await关键字。async和await关键字让我们可以用一种更简洁的方式写出基于Promise的异步行为,而无需刻意地链式调用promise。
async函数会返回一个AsyncFunction对象,使用隐式Promise返回它的结果。简单来说就是一定会返回一个promise对象。但是useEffect不应该返回任何内容或者只能返回清理函数。所以当我们把effect写成async函数时会收到警告⚠️。
但我们仍然可以调用异步函数,所以我们换种思路:
useEffect(() => {
(async function fun(){
await ...
})()
}, []);
useEffect(() => {
async function fun(){
await ...
};
fun()
}, []);
在依赖列表中省略函数是否安全?
react.docschina.org/docs/hooks-…
useEffect可能带来闭包问题,怎么办?
useEffect使用了js闭包机制
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1); // 这个 effect 依赖于 `count` state
}, 1000);
return () => clearInterval(id);
}, []); // 🔴 Bug: `count` 没有被指定为依赖
return <h1>{count}</h1>;
}
// 第二个例子
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setTimeout(() => {
console.log(count)
}, 3000);
return () => {
clearTimeout(timer);
}
}, [])
return (
<button onClick={() => setCount(c => c + 1)}>
click
</button>
)
}
传入空的依赖数组,意味着该 hook 只在组件挂载时运行一次,并非重新渲染时。但如此会有问题,在定时器的回调中,count 的值不会发生变化。因为当 effect 执行时,会创建一个闭包,并将 count 的值被保存在该闭包当中,且初值为 0。每隔一秒,回调就会执行 setCount(0 + 1),因此,count 永远不会超过 1。第二个例子类似,不论在3秒内点多少次按钮,也输出0。
要解决该问题有两种办法:
-
给setCount传入一个函数, setCount(count => count+1),具体原因查看笔记《深入理解setState》
-
使用ref引用count,通过ref.current拿到的值永远是最新的。
const [count, setCount] = useState(0); // 通过 ref 来记忆最新的 count const countRef = useRef(count); countRef.current = count; useEffect(() => { const timer = setTimeout(() => { console.log(countRef.current) }, 1000); return () => { clearTimeout(timer); } }, [])
事实上在一般代码里不会存在上面所说的这个问题,在异步调用(或称延迟调用)时,才一定会存在,比如setTimeout、setInterval、Promise.then、useEffect卸载回调函数。也很好理解,非异步代码在组件渲染后立刻执行,闭包里保存的都是最新的值,异步代码什么时候执行,谁知道这期间组件状态发生了什么变化。这时一般都可以通过上面所说的ref方法解决。
useState
这里说四点useState的注意项,以下所说的setState为useState返回值里的setState。
-
异步的问题,先说结论,和类组件一样,setState在React周期内是异步的(这个词在笔记《深入理解setState》里解释过),其他地方是同步的。看一个例子
export default function FunCpn() { const [count, setCount] = useState(0); useLayoutEffect(() => { console.log("object"); }); const handleClick = () => { // Promise.resolve(1).then(() => { setCount(1); console.log(count); setCount(2); console.log(count); // }); }; return ( <div> <button onClick={handleClick}>ssssssss</button> </div> ); }上面的点击函数触发时,
useLayoutEffect只触发一次,两次输出count也都是0,而且输出顺序是0 0 object符合预期。但是当加入Promise回调时,上面说过类组件的话就会变成同步调用,但在这里输出的count都是0,感觉是异步的,但useLayoutEffect却被调用了两次,而且输出顺序为object 0 object 0,又说明是同步的。其实这里确实是同步的,count输出0是因为闭包。 -
在组件后续的更新渲染中,
useState并不是不会执行,只是返回的第一个值将始终是更新后最新的state,并且React 会确保setState函数的标识是稳定的,不会在重新渲染时发生变化。这就是为什么可以安全地从hook的依赖列表中省略 setState。 -
setState也可以接受一个函数,这个函数只有一个参数,不会接收props,也不会自动合并更新对象,并且不支持state更新之后的回调函数。
setState(prevState => { return {...prevState, ...updatedValues}; }); -
useState接收的参数只会在组件的初始渲染中起作用,后续渲染时会被忽略。如果初始 state 需要通过复杂计算获得,则可以传入一个函数,在函数中计算并返回初始的state,此函数只在初始渲染时被调用:const [state, setState] = useState(() => { const initialState = someExpensiveComputation(props); return initialState; }); const [state, setState] = useState(new Subject()) // 每次重渲染,都会执行实例化Subject的过程,即便这个实例没用,会被扔掉(重渲染会返回最新的state) const [state, setState] = useState(() => new Subject()) // 传递函数,可以避免性能隐患
以上内容是和类组件的this.setState()做对比的形式记录,可以看另一篇笔记《深入理解setState》
典型问题
没给useState传this, React怎么知道useState对应的是哪个组件?
zh-hans.reactjs.org/docs/hooks-…
一个组件都多个useState,React怎么知道哪个state对应哪个 useState?
靠的是 useState调用的顺序, useState的调用顺序在每次重新渲染时都是相同的。
useContext
接收一个 context 对象(React.createContext 的返回值)并返回该 context 的当前值。
当组件上层最近的 <MyContext.Provider> 更新时,该hook会触发重渲染,并使用最新传递给 MyContext provider的contextvalue 值。即使祖先使用 React.memo或 shouldComponentUpdate,也会在组件本身使用 useContext 时重新渲染。
useMemo
返回一个memoized(已缓存)值,一般用作性能优化的手段,和React.memo配套使用效果最佳。
注意:传入 useMemo 的函数会在渲染期间执行,也就是早于useEffect。
useCallback
返回一个memoized(已缓存)回调函数。一般用作性能优化的手段,和React.memo配套使用效果最佳。
useCallback(fn, deps) 相当于 useMemo(() => fn, deps)
useRef
useRef 返回一个可变的 ref 对象,其 .current 属性被初始化为传入的参数。
ref 对象在组件的整个生命周期内保持不变,useRef 会在每次渲染时返回同一个 ref 对象。
当 ref 对象内容发生变化时,useRef 并不会通知你。变更 .current 属性不会引发组件重新渲染。
当ref用作于访问DOM时,是什么时候赋值的呢?和React更新DOM同时,在render()之后,componentDidMount或componentDidUpdate之前。
useReducer
const [state, dispatch] = useReducer(reducer, initialArg, init);
//reducer
(state, action) => newState
和useState的setState一样,不会随着重新渲染发生改变,所以可以不写入依赖项中。
简化版本:
function useReducer(reducer, initialState) {
const [state, setState] = useState(initialState);
function dispatch(action) {
const nextState = reducer(state, action);
setState(nextState);
}
return [state, dispatch];
}
可以惰性创建初始值。需要将 init 函数作为 useReducer 的第三个参数传入,这样初始state将被设置为 init(initialArg)。这么做可以将用于计算state的逻辑提取到reducer外部,这也为将来对重置state的action做处理提供了便利。
function init(initialCount) {
return {count: initialCount};
}
function reducer(state, action) {
switch (action.type) {
case 'increment':
return {count: state.count + 1};
case 'reset':
return init(action.payload);
default:
throw new Error();
}
}
function Counter({initialCount}) {
const [state, dispatch] = useReducer(reducer, initialCount, init);
return (
<>
Count: {state.count}
<button onClick={() => dispatch({type: 'reset', payload: initialCount})}>
Reset
</button>
<button onClick={() => dispatch({type: 'increment'})}>+</button>
</>
);
}
useImperativeHandle
useImperativeHandle(ref, createHandle, [deps])
useImperativeHandle 应当与forwardRef一起使用
function FancyInput(props, ref) {
const inputRef = useRef();
useImperativeHandle(ref, () => ({
focus: () => {
inputRef.current.focus();
}
}));
return <input ref={inputRef} ... />;
}
FancyInput = forwardRef(FancyInput);
第三方Hook
useLatest 返回当前最新值
来自ahooks
源代码:
function useLatest<T>(value: T) {
const ref = useRef(value);
ref.current = value;
return ref;
}
本hook利用两个特性:1. useRef 每次渲染时返回同一个 ref 对象;2. ref对象内容发生变化不会触发渲染。
本hook逻辑很简单,但这是一个非常重要的思路,ahooks里很多hook都有用到该思路,因此记录下来,它是解决闭包问题很好的方式。
使用:
import React, { useState, useEffect } from 'react';
import { useLatest } from 'ahooks';
export default () => {
const [count, setCount] = useState(0);
const latestCountRef = useLatest(count);
useEffect(() => {
const interval = setInterval(() => {
setCount(latestCountRef.current + 1);
}, 1000);
return () => clearInterval(interval);
}, []);
return (
<>
<p>count: {count}</p>
</>
);
};
usePrevious 获取上一轮props或state
来自react官网
源代码:
function usePrevious(value) {
const ref = useRef();
useEffect(() => {
ref.current = value;
});
return ref.current;
}
注意:这里应该返回ref.current,不应该返回ref,返回ref.current代表返回的是当前ref存的值,返回ref代表返回ref这个可变值对象。如果返回了ref,虽然DOM上仍然显示的是上一轮的值,但在某些地方,比如点击函数里使用ref,拿到的就是已经更新过(本轮)的值了。
该hook思路来源于下面代码,但这段代码存在一个问题,就是上面注意点所说的。
function Counter() {
const [count, setCount] = useState(0);
const prevCountRef = useRef();
useEffect(() => {
prevCountRef.current = count;
});
const prevCount = prevCountRef.current;
return <h1>Now: {count}, before: {prevCount}</h1>;
}
使用:
function Counter() {
const [count, setCount] = useState(0);
const calculation = count + 100;
const prevCalculation = usePrevious(calculation);
// ...
useSetState 合并改变state
来自ahooks
源代码:
const useSetState = (initialState) => {
const [state, setState] = useState(initialState);
const setMergeState = useCallback((patch) => {
setState((prevState) => ({ ...prevState, ...(typeof patch === 'function' ? patch(prevState) : patch) }));
}, []);
return [state, setMergeState];
};
class的setState改变state时会使用Object.assign进行合并,useState不行,该hook模拟setState的行为,用法和useState一致。
import React from 'react';
import { useSetState } from 'ahooks';
export default () => {
const [state, setState] = useSetState({
hello: '',
count: 0,
});
return (
<div>
{/*增加属性可以只写要增加的属性*/}
<button type="button" onClick={() => setState({ foo: 'bar' })}>
set foo
</button>
{/*改变count可以只写count*/}
<button type="button" onClick={() => setState((prev) => ({ count: prev.count + 1 }))}>
count + 1
</button>
</div>
);
};
useToggle 在两个状态值间切换
来自ahooks
源代码:
function useToggle<D, R>(defaultValue: D = false as unknown as D, reverseValue?: R) {
const [state, setState] = useState<D | R>(defaultValue);
const actions = useMemo(() => {
const reverseValueOrigin = (reverseValue === undefined ? !defaultValue : reverseValue) as D | R;
const toggle = () => setState((s) => (s === defaultValue ? reverseValueOrigin : defaultValue));
const set = (value: D | R) => setState(value);
const setLeft = () => setState(defaultValue);
const setRight = () => setState(reverseValueOrigin);
return {
toggle,
set,
setLeft,
setRight,
};
// useToggle ignore value change
// }, [defaultValue, reverseValue]);
}, []);
return [state, actions];
}
ahooks还有一个useBoolean,个人感觉没必要,与此hook类似只是两个状态值为true\false,况且直接用useState切换boolean也很方便。
使用:
const [state, { toggle, set, setLeft, setRight }] = useToggle('Hello', 'World');
useMemoizedFn 持久化函数
来自ahooks
源代码:
type noop = (...args: any[]) => any;
function useMemoizedFn<T extends noop>(fn: T) {
const fnRef = useRef<T>(fn);
// why not write `fnRef.current = fn`?
// https://github.com/alibaba/hooks/issues/728
fnRef.current = useMemo(() => fn, [fn]);
const memoizedFn = useRef<T>();
if (!memoizedFn.current) {
memoizedFn.current = function (...args) {
// eslint-disable-next-line @typescript-eslint/no-invalid-this
return fnRef.current.apply(this, args);
} as T;
}
return memoizedFn.current;
}
讲个故事:
子组件和父组件说:你不要总让传给我的函数变化,函数一变我就得重新渲染一遍,太麻烦了。
父组件说:函数不变你就不能用了呀(拿到的旧的state)。
子组件说:我不管,你自己想办法。
父组件绞尽脑汁:好吧,我写一个useMemoizedFn,他返回的函数一直都是不变的。
子组件说:那我能拿到新的state吗?
useMemoizedFn说:能。
子组件说:怎么做到的?
useMemoizedFn说:我里面调用的函数是变的,但我自己不变。相当于我穿了件衣服,衣服不变,但衣服里面的人会变,你能看到的只是这件衣服,所以对你来说是不变的。
子组件说:哦,我懂了。
一般使用useCallback来优化函数,仅在依赖项改变时重新生成函数,但优化的不够彻底。该hook利用ref不变的性质使函数在整个生命周期内都不变。
const [state, setState] = useState('');
// 函数使用了state,为了调用时拿到最新的state,就需要加入依赖项
// 在state变化时,func地址会变化
const func = useCallback(() => {
console.log(state);
}, [state]);
// func地址永远不会变化,依然可以拿到最新的state
const func = useMemoizedFn(() => {
console.log(state);
});
useCreation 创建缓存值
来自ahooks
源代码:
import type { DependencyList } from 'react';
import { useRef } from 'react';
import depsAreSame from '../utils/depsAreSame';
export default function useCreation<T>(factory: () => T, deps: DependencyList) {
const { current } = useRef({
deps,
obj: undefined as undefined | T,
initialized: false,
});
if (current.initialized === false || !depsAreSame(current.deps, deps)) {
current.deps = deps;
current.obj = factory();
current.initialized = true;
}
return current.obj as T;
}
export default function depsAreSame(oldDeps: DependencyList, deps: DependencyList): boolean {
if (oldDeps === deps) return true;
for (let i = 0; i < oldDeps.length; i++) {
if (!Object.is(oldDeps[i], deps[i])) return false;
}
return true;
}
该hook是useMemo和useRef的替代品。
-
useMemo的问题:引用React文档**你可以把
useMemo作为性能优化的手段,但不要把它当成语义上的保证。**将来,React 可能会选择“遗忘”以前的一些 memoized 值,并在下次渲染时重新计算它们,比如为离屏组件释放内存。先编写在没有useMemo的情况下也可以执行的代码 —— 之后再在你的代码中添加useMemo,以达到优化性能的目的。 -
useRef的问题:和上面提到的useState传入默认值的问题(第4点)一样,如果初始值需要较昂贵的操作,会带来一点性能问题。
useCreation可以保证不会被意外的重新计算,也可以用来创建一些常量,创建常量时和useRef类似,但在创建比较复杂的常量时更省性能。
使用:
const foo = useCreation(() => new Foo(), []);
useEventEmitter 创建事件通知实例
来自ahooks
源代码:
type Subscription<T> = (val: T) => void;
export class EventEmitter<T> {
private subscriptions = new Set<Subscription<T>>();
emit = (val: T) => {
for (const subscription of this.subscriptions) {
subscription(val);
}
};
useSubscription = (callback: Subscription<T>) => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const callbackRef = useRef<Subscription<T>>();
callbackRef.current = callback;
// eslint-disable-next-line react-hooks/rules-of-hooks
useEffect(() => {
function subscription(val: T) {
if (callbackRef.current) {
callbackRef.current(val);
}
}
this.subscriptions.add(subscription);
return () => {
this.subscriptions.delete(subscription);
};
}, []);
};
}
export default function useEventEmitter<T = void>() {
const ref = useRef<EventEmitter<T>>();
if (!ref.current) {
ref.current = new EventEmitter();
}
return ref.current;
}
这里的EventEmitter本质上是一个观察者模式的具体实现。主要是用来方便组件间进行事件通知。通过 props 或者 Context ,可以将EventEmitter实例共享给其他组件。然后在其他组件中,可以调用 EventEmitter 的 emit 方法,发送一个事件,或是调用 useSubscription 方法,订阅事件。
使用useEffect和useRef,可以保证
EventEmitter实例只会创建一次。useSubscription会在组件创建时自动注册订阅,并在组件销毁时自动取消订阅。
注意点:
- 不要在useRef里直接写入
EventEmitter实例或callback,原因上文提到过。 - 父子组件没必要使用该方式,父通知子可以用foreardRef,子通知父可以用回调函数。
使用:
export default function Form() {
const event$ = useEventEmitter();
function handleClick() {
event$.emit("父组件发出事件");
}
return (
<>
<h1>
<button onClick={handleClick}>发事件</button>
<Level1 event$={event$} />
</h1>
</>
);
}
function Level1(props) {
return (
<div style={{ display: "flex" }}>
<Level21 {...props} />
<Level22 {...props} />
</div>
);
}
function Level21(props) {
const [level1, setLevel1] = useState();
props.event$.useSubscription(val => {
setLevel1(`${val} ${new Date()}`);
});
return <h1>{level1}</h1>;
}
function Level22(props) {
const [level1, setLevel1] = useState();
props.event$.useSubscription(val => {
setLevel1(`${val} ${new Date()}`);
});
return <h1>{level1}</h1>;
}
useRouteState 缓存前端路由state
源代码
/**
* @param {object} props,组件的props
* @param {string} cacheKey
* @return {*}
*/
export const usePropsState = (cacheKey: string, props: any): any => {
const recvState = useMemo<any>(() => {
const location = props.location;
if (location.state) { // 判断当前有参数
const d = JSON.stringify(location.state);
sessionStorage.setItem(cacheKey, d); // 存入到sessionStorage中
return location.state;
} else {
const state = sessionStorage.getItem(cacheKey)
return state ? JSON.parse(state) : null; // 当state没有参数时,取sessionStorage中的参数
}
}, [cacheKey, props.location.state]);
return recvState;
};
// 使用:
const data = usePropsState('data', props);
在前端路由领域,路由栈中每个路由记录都可以持久化序列化存储一个state,例如调用history.pushState就可以往路由栈里添加记录并且可以传入state。
history库中有两种history对象
- browserHistory:push方法在底层会使用history.pushState,所以该对象存储的state也是持久化的。
- hashHistory:考虑兼容性问题,截止第4版本的history库在hashHistory上没有使用pushState的底层接口。因此不建议在hashHistory中使用state,虽然可通过location对象传递state,但是其作为页面级别的state,不具备持久化state的能力。这时,仅能从hashHistory.location.state中读取到对象{some:'state'},而不能从window.history.state中读取到。例如在浏览器中执行一次后退再前进的操作,由于window.history.state没有存储状态,这时读取hashHistory.location.state,读取到的值将为空。而browserHistory可以再次读取到state值,所以需要注意,hashHistory.location.state在导航过程中并不能如browserHistory一样其state值能得到再现。由于pushState等HTML5接口已经被广泛使用,在history库未来的第5版本中将使用pushState来模拟hashHistory,因此持久化的state设置会得到支持。此时,hashHistory设置的state也可从window.history.state中读取到。
在上面的背景下,我们在使用hashHistory时可以利用storage来存储state。该hook有两个作用:
- 封装获取state过程,减少重复代码。
- 解决hashHistory对象里state没有持久化的问题。