跳到主要内容

Hook

信息

一个组件从创建到销毁,中间会经历无数次重新渲染(渲染指调用函数生成虚拟DOM,通过Diff操作真实DOM),多次函数调用自然意味着会存在多个函数作用域。

同时组件创建的时候会有一块关联的线性存储单元(本质上是链表存储memorizedState),销毁的时候会清空。那么每次执行渲染函数的时候,根据Hook在函数中出现的位置,这个Hook都有权访问(读与写)线性存储单元中对应位置中的数据,这也是为什么我们强制要求每次执行函数时Hook出现的顺序都必须一致。所以从本质上来说,多次函数调用时生成的作用域都共享着同一块数据单元。

useState

组件的内部状态。

如下述例子中,count变量实际上是通过读取线性存储单元中对应位置(本例为位置0)得到的,调用setCount的时候实际上就是在对应位置写入新的值,并且会触发组件渲染(因此第二次组件渲染的时候count能够拿到新的值)

实时编辑器
结果
Loading...

setState的参数也可以是一个函数

setState(count => count + 1)

useEffect

当依赖的某个状态改变时执行副作用。

这意味着当执行useEffect时,我们需要有能力判断当前依赖数组的值是否和上一次作用域的该值发生了变化,看起来我们是在比较两个函数作用域的值,实际上我们只需要在第一次函数调用的时候把这个值记录在线性存储单元中,这样第二次函数调用的时候就可以取值进行比较了。

实时编辑器
结果
Loading...

我们经常会使用useEffect来设置定时器、事件订阅,为避免内存泄漏或其他影响,我们需要在useEffect的回调中返回一个函数用来清除副作用,该函数会在组件重新渲染或销毁前调用。

useEffect(() => {
const timer = setTimeout(() => {
// doSomething
}, 1000);
// 清除副作用
return () => {
clearTimeout(timer)
}
})

useRef

useRef的作用类似useState,也是在线性存储单元中记录数据,但useState中数据的改变会引发重新渲染,如果我们又希望不同函数作用域可以引用到同一个值,又不希望这个值的更新触发渲染,这个时候就可以使用useRef

实时编辑器
结果
Loading...

除了用来记录值,useRef还可以用来记录DOM节点的引用

function Test() {
const ref = useRef<HTMLDivElement>(null!) // ref: RefObject<HTMLDivElement>

return <div ref={ref} onClick={() => console.log(ref.current.innerText)}>点击</div>
}
备注

值得一提的是,根据useRef传值方式不同,其返回值的类型也不一样。

interface MutableRefObject<T> {
current: T;
}

interface RefObject<T> {
readonly current: T | null;
}

useMemo

根据依赖的变量缓存计算的结果,很明显我们需要在线性存储空间中记录依赖的值和被缓存的结果。

const revertMsg = useMemo(() => msg.split('').reverse().join(''), [msg])

useCallback

根据依赖的变量缓存函数,很明显我们需要在线性存储空间中记录依赖的值和被缓存的函数。

对于这样的一段代码,父组件将匿名函数(或普通函数)作为props传递给子组件。当父组件重新渲染,则会生成一个全新的匿名函数(地址不同)作为props传递给子组件,因此会触发子组件的重新渲染

function Father () {
const [count, setCount] = useState(0)
return (
<PureChildComponent onClick={() => { setCount(1) }}/>
)
}

通过使用useCallback缓存该函数,因为地址相同所以可以避免不必要的组件渲染。

function Father () {
const [count, setCount] = useState(0)
const cb = useCallback(() => {
setCount(count => count + 1)
}, [])
return (
<PureChildComponent onClick={cb}/>
)
}

理论上我们也可以使用useMemo实现该功能,只是useCallback更加语义化

function Father () {
const [count, setCount] = useState(0)
const cb = useMemo(() => {
return () => setCount(count => count + 1)
}, [])
return (
<PureChildComponent onClick={cb}/>
)
}
备注

useCallback(fn, deps) 相当于 useMemo(() => fn, deps)

useContext

通过使用useContext,我们能够在组件内部获取到外层<MyContext.Provider value={value}>传递下来的值,免去了一层一层传props的烦恼。

警告

但需要注意的是,一旦我们组件使用了useContext(),那么一旦Provider传递的value地址发生了改变,就会触发我们组件的重新渲染。

const MyContext = createContext(null);

function App() {
const [count, setCount] = useState(0);

return (
// 当count改变,第二次渲染时value是个地址不同的新对象,导致使用`useContext`的组件也会渲染
<MyContext.Provider value={{
name: 'aka',
age: 20
}}>
<button onClick={() => setCount(count => count + 1)}></button>
<Child />
</MyContext.Provider>
);
}

const Child = memo(() => {
useContext(MyContext);
console.log('render');
return <div>hello</div>;
});
信息

实际上这是设计成这样的,不过这样可能会导致我们使用的时候渲染太频繁,目前我们也有一些方法避免重复的渲染

useReducer

功能和useState一样,只是修改数据的形式不同

function Todos() {
const [todos, dispatch] = useReducer(todosReducer, []);

function handleAddClick(text) {
dispatch({ type: 'add', text });
}

// ...
}

function todosReducer(state, action) {
switch (action.type) {
case 'add':
return [...state, {
text: action.text,
completed: false
}];
// ... other actions ...
default:
return state;
}
}

useReducer的简单实现版本如下

function useReducer(reducer, initialState) {
const [state, setState] = useState(initialState);

function dispatch(action) {
const nextState = reducer(state, action);
setState(nextState);
}

return [state, dispatch];
}

useImperativeHandle

备注

useImperativeHandle必须与forwordRef搭配使用

// forwardRef + useImperativeHandle
function FancyInput(props, ref) {
const inputRef = useRef();
useImperativeHandle(ref, () => ({
focus: () => {
inputRef.current.focus();
}
}));
return <input ref={inputRef} />;
}
FancyInput = forwardRef(FancyInput);

自定义Hook

简单来说只要一个函数内部用到了Hook就可以叫做自定义Hook,只不过一般会约定函数名前缀为use

我们只需要保证对于一个组件多次调用时内部Hook(eg: useStateuseEffect)出现的次序保持一致即可,所谓的自定义Hook其实起到的只是一种组合的功能,用于将相关的逻辑抽离到一个函数内部。

Hook闭包陷阱

在本章的开头我已经解释了,组件多次渲染意味着多次创建作用域。虽然我们在最新的作用域发现值已经是最新的,但一个旧的作用域中调用的函数自然只能拿到旧作用域中的旧值。

例子1 🌰

实时编辑器
结果
Loading...

本例我们在第一个函数作用域的count值为0,同时我们设置了一个定时器;当我们点击按钮的时候会把组件的Fiber Node内的memorizedState中对应位置的状态设置为10,并触发组件的第二次渲染,在第二个函数作用域的时候count值为10。

然而当三秒后第一个作用域的函数执行,输出了第一个作用域的count值,结果为0。

通用解决办法
useEffect(() => {
const timer = setTimeout(() => {
console.log(count)
}, 3000)

return () => clearTimeout(timer)
}, [count])

例子2 🌰

实时编辑器
结果
Loading...

本例类似,在第一个函数作用域的count为0,同时我们把useCallback的参数回调缓存进memorizedState当中;当组件第二次渲染的时候,useCallback因为依赖没有发生变化,把缓存的第一个函数作用域中的回调函数的地址传给了第二个函数作用域的fn,因此点击输出按钮的时候相当于调用了第一个函数作用域的回调,自然输出的是第一个函数作用域的count值,结果为0。

通用解决办法
const fn = useCallback(() => {
console.log(count)
}, [count])

这样当依赖的值发生变化时,我们将生成一个新的函数并缓存进memorizedState

useCallback有两点好处,一是减少不必要的函数创建开销,二是地址的不变使得作为props传递给子组件时也可以减少不必要的渲染(第二点对性能的影响更大)。但对于上述解决方案,一旦count变化非常频繁,我们的函数还是得不断重新创建,我们可能希望能避免这种情况。

实际上我们可以使用useRef来实现想要的效果,其原理是在最新的函数作用域中把count的值赋值给ref.current,因此在旧的作用域中可以通过ref.current取到该值。

实时编辑器
结果
Loading...

而在ahooks也提供了一个usePersistFn提供给我们使用。

export type noop = (...args: any[]) => any;

function usePersistFn<T extends noop>(fn: T) {
const ref = useRef<any>(() => {
throw new Error('Cannot call function while rendering.');
});

ref.current = fn;

const persistFn = useCallback(((...args) => ref.current(...args)) as T, [ref]);

return persistFn;
}

// 使用
const [count, setCount] = useState(0);
const showCountPersistFn = usePersistFn(() => {
alert(`Current count is ${count}`);
});

本质是refcurrent不断被赋予新的函数fn,所以可以拿到新的函数作用域下的值。

参考链接:

React Hook原理

React useEffect的陷阱