# Hook 简介
Hook (opens new window) 是 React 16.8 的新增特性。它可以让我们在不编写 class 的情况下使用 state 以及其他的 React 特性。
Hook 并没有带来破坏性改动。
完全可选的。
100% 向后兼容的。
现在可用。
没有计划从 React 中移除 class。
Hook 不会影响对 React 概念的理解。
# 为什么要使用 Hook
很多人觉得 React 比 vue 难学的原因,主要有以下几点:
生命周期难以理解,很难熟练使用。
高阶组件理解起来也不容易。
React 的许多优秀解决方案都在社区,并且都是英文的。
Redux 概念非常多,难以理解。
而 Hook 能降低我们学习 React 的学习成本,主要体现在以下几点:
上手更容易,编写的代码更简洁。
有了 Hook,生命周期和高阶组件可以不用学,redux 也不再是必需品。
开发体验好,可以让函数组件维护内部的状态。
最大的好处:简化了逻辑复用。之前在类组件中想复用代码一般是通过使用高阶组件来实现的,但是这种方式的代码难理解,不直观,而且还会增加额外的组件节点。而使用 Hook 会简洁和直观很多,并且不会产生额外的组件节点。
有助于关注分离。Hook 能够让针对同一个业务逻辑的代码尽可能聚合在一块儿。这是过去在类组件中很难做到的。因为在类组件中,我们不得不把同一个业务逻辑的代码分散在类组件的不同生命周期的方法中。
# Hook 使用规则
只在最顶层使用 Hook,不要在循环、条件或嵌套函数中调用 Hook。
只在 React 函数(函数组件或自定义 Hook)中调用 Hook,不要在普通的 JavaScript 函数中调用 Hook。
补充
如果一定要在类组件中使用 Hook,也是有方法的。那就是利用高阶组件的模式,将 Hook 封装成高阶组件,从而让类组件使用。
比如:我们已经定义了监听窗口大小变化的一个 Hook:useWindowSize。将其转换为高阶组件如下:
import React from "react";
import { useWindowSize } from "../hooks/useWindowSize";
export const withWindowSize = (Comp) => {
return (props) => {
const windowSize = useWindowSize();
return <Comp windowSize={windowSize} {...props} />;
};
};
2
3
4
5
6
7
8
9
然后使用这个高阶组件:
import React from "react";
import { withWindowSize } from "./withWindowSize";
class MyComp {
render() {
const { windowSize } = this.props;
// ...
}
}
// 通过 withWindowSize 高阶组件给 MyComp 添加 windowSize 属性
export default withWindowSize(MyComp);
2
3
4
5
6
7
8
9
10
11
12
# Hook 学习建议
首先精通三个基础 Hooks,也就是 useState、useEffect 和 useContext。然后在此基础上:
掌握 useRef 的一般用法;
当需要优化性能,减少不必要的渲染时,学习掌握 useMemo 和 useCallback;
当需要在大中型 React 项目中处理复杂 state 时,学习掌握 useReducer;
当需要封装组件,对外提供命令式接口时,学习掌握 useRef 加 useImperativeHandle;
当页面上用户操作直接相关的紧急更新(Urgent Updates,如输入文字、点击、拖拽等),受到异步渲染拖累而产生卡顿,需要优化时,学习掌握 useDeferredValue 和 useTransition。
# Hook 总览
# Hook API
# useState (opens new window)
可以接收任意类型的参数,返回一个 state,以及更新 state 的函数。
在组件每次渲染时都会重新执行。
底层也是使用了闭包的原理。首次渲染时 state 获取的是初始值,再次渲染时 state 获取的就是闭包中的缓存值了。
允许使用多次。
注意
很多人会把 state 当变量用,很容易把过多的数据放到 state 里。我们在使用 state 的时候要遵循以下两个原则:
保证状态最小化。某些数据如果能从已有的 state 中计算得到,那么我们就应该始终在用的时候去计算,而不要把计算的结果存到某个 state 中。这样的话,才能简化我们的状态处理逻辑。
避免中间状态,确保唯一数据源。在开发的时候,我们要找到正确的状态来源,并直接使用这个来源,避免中间状态。
import React, { useState } from "react";
function HookComponent() {
// useState 在组件每次渲染时都会重新执行
// useState 底层也是使用了闭包的原理
// 首次渲染时 count 获取的是初始值,再次渲染时 count 获取的就是闭包中的缓存值了
// useState 允许使用多次
// let [count, setCount] = useState(0);
// let [count, setCount] = useState({ a: 1 });
const num = 2;
let [count, setCount] = useState(() => {
// 接收函数进行逻辑运算
return 10 * num;
});
return (
<div>
{/*<p>{count.a}-{count.b}</p>*/}
<p>{count}</p>
<button onClick={handleBtnClick}>加一</button>
</div>
);
function handleBtnClick() {
// setCount 接收的参数可以是任意类型,会覆盖掉原来 count 的值
// 也是异步的
// setCount(++count);
// 不想被覆盖的话可以使用 es6 的扩展运算符
// setCount({...count, b: 2})
setCount((count) => {
return ++count;
});
}
}
export default HookComponent;
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
# useEffect (opens new window)
接收一个函数,要么返回一个清除副作用的函数,要么不返回。
用来处理副作用,比如定时器、ajax 请求等。跟生命周期函数 componentDidMount 用途相同,在 DOM 渲染完成后执行。
useEffect 的副作用回调和清除函数,它们都在提交阶段执行,且大多情况都是异步执行的。这些函数被加入 callback 更新队列。在真实 DOM 绘制之后,下一次 React 渲染之前,这个队列里的 callback 会依次执行,所以这时候 callback 能拿到真实 DOM。
useEffect 中返回的回调函数,不只是会在组件销毁时执行,而且是每次 Effect 重新执行之前都会执行,用于清理上一次 Effect 的执行结果。
传给 useEffect 的函数会延迟调用,如果需要同步执行,就得使用 useLayoutEffect。
第二个参数让副作用变得可控。
在以上代码的基础上,使用 useEffect 让 count 等一秒后加一。
import React, { useEffect } from "react"; // DOM 渲染完成后执行 useEffect(() => { setTimeout(() => { setCount((count) => { return ++count; }); }, 1000); });
1
2
3
4
5
6
7
8
9
10但是这么写了之后会发现一个问题,count 会一直在加一,这是因为在副作用(setTimeout)执行过程中,修改了 count,组件的状态发生了改变,就会引发重新渲染,重新渲染又会触发 useEffect 重新执行,从而陷入了无限循环。
解决这个问题的方法是给 useEffect 传入第二个参数,一个空数组,这样就只会执行一次。
传入第二个参数就相当于告诉 React,useEffect 不依赖 state 或者 props 的变化,只依赖传入的第二个参数的变化。
import React, { useEffect } from "react"; // DOM 渲染完成后执行 useEffect(() => { setTimeout(() => { setCount((count) => { return ++count; }); }, 1000); }, []);
1
2
3
4
5
6
7
8
9
10使用第二个参数可以让副作用变得可控,比如当点击按钮的时候,改变第二个参数里的值,触发 useEffect 的执行。
let [refresh, setRefresh] = useState(0); // DOM 渲染完成后执行 useEffect(() => { setTimeout(() => { setCount((count) => { return ++count; }); }, 1000); }, [refresh]); return <button onClick={() => setRefresh(!refresh)}>刷新</button>;
1
2
3
4
5
6
7
8
9
10
11
12每次点击刷新按钮的时候,count 的值就会在一秒后再次加一。
如何清除副作用?
在生命周期函数中,清除副作用一般是在 componentWillUnmount 里完成的。
在 useEffect 中是通过返回一个清除副作用的函数来完成的。
// DOM 渲染完成后执行 useEffect(() => { setTimeout(() => { setCount((count) => { return ++count; }); }, 1000); console.log("副作用函数"); function clear() { // 清除副作用的工作放在这里 // 执行时机有两个 // 1. 组件卸载前 // 2. 下一次 useEffect 触发前 console.log("清除副作用函数"); } return clear; }, [refresh]);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17每次点击刷新按钮都会先打印“清除副作用函数”,再打印“副作用函数”。
# useMemo (opens new window)
把函数和依赖项数组作为参数传入 useMemo,它仅会在某个依赖项改变时才重新计算 memoized 值。这种优化有助于避免在每次渲染时都进行高开销的计算。
缓存计算结果,返回函数运行的结果,是一个值。
两大好处:避免重复计算和避免子组件重复渲染。
# useCallback (opens new window)
把内联回调函数及依赖项数组作为参数传入 useCallback,它将返回该回调函数的 memoized 版本,该回调函数仅在某个依赖项改变时才会更新。
缓存回调函数,返回一个函数。
useCallback 的功能其实是可以用 useMemo 来实现的。
const myUseCallback = useMemo(() => {
// 返回一个函数作为缓存结果
return () => {
// 在这里进行事件处理
};
}, [dep1, dep2]);
2
3
4
5
6
这两个 Hook 可以用来实现计算缓存和记忆函数,但是不能盲目使用。
const memorized1 = useCallback(() => {
return count;
}, [num]);
const memorized2 = useMemo(() => {
return count;
}, [num]);
console.log("useCallback: " + memorized1());
console.log("useMemo: " + memorized2);
console.log("count: " + count);
2
3
4
5
6
7
8
9
10
用了 useMemo 和 useCallback 之后,count 的值会被缓存下来,只有当 num 的值发生改变时,count 的值才会更新,否则就一直会是初始值。
注意
两个 Hook 的第二个参数依赖项数组一定要传,否则不会进行缓存。
# useRef (opens new window)
返回一个可变的 ref 对象,其 current 属性被初始化为传入的参数。
存储跨渲染的数据。可以把 useRef 看作是在函数组件之外创建的一个容器空间。在这个容器上,我们可以通过唯一的 current 属设置一个值,从而在函数组件的多次渲染之间共享这个值。
比如:假设我们要做一个计时器组件,这个组件有开始和暂停两个功能。很显然,我们需要用 window.setInterval 来提供计时功能;而为了能够暂停,我们就需要在某个地方保存这个 window.setInterval 返回的计数器的引用,确保在点击暂停按钮的同时,也能用 window.clearInterval 停止计时器。那么,这个保存计数器引用的最合适的地方,就是 useRef,因为它可以存储跨渲染的数据。
import React, { useState, useCallback, useRef } from "react"; export default function Timer() { // 定义 time state 用于保存计时的累积时间 const [time, setTime] = useState(0); // 定义 timer 这样一个容器用于在跨组件渲染之间保存一个变量 const timer = useRef(null); // 开始计时的事件处理函数 const handleStart = useCallback(() => { // 使用 current 属性设置 ref 的值 timer.current = windoe.setInterval(() => { setTime((time) => time + 1); }, 100); }, []); // 暂停计时的事件处理函数 const handlePause = useCallback(() => { // 使用 clearInterval 来停止计时 window.clearInterval(timer.current); timer.current = null; }, []); return ( <div> {time / 10}seconds. <br /> <button onClick={handleStart}>Start</button> <button onClick={handlePause}>Pause</button> </div> ); }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32跟 React.createRef() 的作用一样,用来访问 DOM 节点。放在 useEffect 中去操作 DOM 节点。
import React, { useRef } from "react"; function HookComponent() { const inputRef = useRef(null); useEffect(() => { inputRef.current.focus(); }, []); return ( <div> <label htmlFor="input">输入框:</label> <input type="text" id="input" ref={inputRef}></input> </div> ); }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16页面渲染完成后 input 框会聚焦,点击 label 标签也会聚焦 input 框。
useRef 中值变化不会触发重新渲染,useState 中则是会触发渲染。
# useContext (opens new window)
接收一个 context 对象(React.createContext 的返回值)并返回该 context 的当前值。
全局状态管理,主要用于跨层次,或者同层组件之间的数据共享。
context 和 useContext 配合使用能够更好的解决组件之间状态共享的问题。
// 顶层组件 ContextProvider.js import React, { createContext, useState } from "react"; export const context = createContext({}); export function ContextProvider({ children }) { let [count, setCount] = useState(10); const countVal = { count, setCount, add: () => setCount(count + 1), reduce: () => setCount(count - 1) }; // context 对象中提供了一个自带的 Provider 组件 return <context.Provider value={countVal}>{children}</context.Provider>; }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16// 子组件 SubCount.js import React, { useContext } from "react"; import { context, ContextProvider } from "./ContextProvider"; function SubCount() { const { count = 0, add, reduce } = useContext(context); return ( <div> <p>{count}</p> <button onClick={add}>加</button> <button onClick={reduce}>减</button> </div> ); } export default () => ( <ContextProvider> <SubCount /> </ContextProvider> );
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21如果有多个顶层组件向同一个组件传数据,那么只需要嵌套使用就行了。
// 顶层组件 ContextProvider2.js import React, { createContext, useState } from "react"; export const context2 = createContext({}); export function ContextProvider2({ children }) { let [val, setVal] = useState(10); const value = { val, setVal }; // context2 对象中提供了一个自带的 Provider 组件 return <context2.Provider value={value}>{children}</context2.Provider>; }
1
2
3
4
5
6
7
8
9
10
11
12
13
14// 子组件 SubCount.js import React, { useContext } from "react"; import { context, ContextProvider } from "./ContextProvider"; import { context2, ContextProvider2 } from "./ContextProvider2"; function SubCount() { const { count = 0, add, reduce } = useContext(context); const { val } = useContext(context2); return ( <div> <p> {count}--{val} </p> <button onClick={add}>加</button> <button onClick={reduce}>减</button> </div> ); } export default () => ( <ContextProvider2> <ContextProvider> <SubCount /> </ContextProvider> </ContextProvider2> );
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
补充
Context 看上去就是一个全局的数据,为什么要设计这样一个复杂的机制,而不是直接用一个全局的变量去保存数据呢?
答案其实很简单,就是为了能够进行数据的绑定。当这个 Context 的数据发生变化时,使用这个数据的组件就能够自动刷新。但如果没有 Context,而是使用一个简单的全局变量,就很难去实现了。
# useReducer (opens new window)
useState 的替代方案。
接收一个形如 (state, action) => newState 的 reducer,并返回当前的 state 以及与其配套的 dispatch 方法。
const [state, dispatch] = useReducer(reducer, initialArg, init);
1跟 redux 里的 reducer 用法类似。
import React, { useReducer } from "react"; // 第一个参数,定义 reducer const reducer = (state, action) => { switch (action.type) { case "add": return { ...state, count: state.count + 1 }; case "reduce": return { ...state, count: state.count - 1 }; default: return state; } }; // 第二个参数,指定默认值 const initialState = { count: 10 }; // 第三个参数,是一个函数,会将第二个参数当作它的参数执行。此参数可选 const init = (initialState) => { // 对初始值进行一些逻辑处理 return { count: initialState.count + 2 }; }; export default function ReducerComponent() { const [state, dispatch] = useReducer(reducer, initialState, init); return ( <div> <p>{state.count}</p> <button onClick={() => dispatch({ type: "add" })}>加</button> <button onClick={() => dispatch({ type: "reduce" })}>减</button> </div> ); }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33可以看到,用 useReducer 来实现计数器的功能要比 useState 复杂,因此,对于一些简单的功能,能用 useState 实现就用它实现。使用 useReducer 时需要评估好当前场景是否适用。
# 自定义 Hook
通过自定义 Hook (opens new window),我们可以将一些相同的逻辑代码提取到函数中来复用。
自定义 Hook 是一个函数,其名称必须以 “use” 开头,并且函数内部一定调用了其他的 Hook,可以是内置的 Hook,也可以是其它自定义的 Hook。
自定义 Hook 是一种重用状态逻辑的机制,所以每次使用自定义 Hook 时,其中的所有 state 和副作用都是完全隔离的。
比如实现一个书籍列表组件,会从服务器端读取特定类别下的书籍列表数据。数据是分页返回的,当还有下一页时,用户可以点击 “读取更多” 按钮,加载下一页数据拼到当前列表尾部。
其中,分页读取书籍列表这部分逻辑,我们就可以抽取成自定义 hook。
import React, { useEffect, useState } from "react";
const useFetchBooks = (categoryId, apiUrl = "/api/books") => {
const [books, setBooks] = useState([]);
const [totalPages, setTotalPages] = useState(1);
const [currentPage, setCurrentPage] = useState(1);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const fetchBooks = async () => {
const url = `${apiUrl}?category=${categoryId}&page=${currentPage}`;
const res = await fetch(url);
const { items, totalPages } = await res.json();
setBooks((books) => books.concat(items));
setTotalPages(totalPages);
setIsLoading(false);
};
setIsLoading(true);
fetchBooks();
}, [categoryId, currentPage]);
const hasNextPage = currentPage < totalPages;
const onNextPage = () => {
setCurrentPage((current) => current + 1);
};
return { books, isLoading, hasNextPage, onNextPage };
};
const BookList = ({ categoryId }) => {
const { books, isLoading, hasNextPage, onNextPage } = useFetchBooks;
return (
<div>
<ul>
{books.map((book) => (
<li key={book.id}>{book.title}</li>
))}
{isLoading && <li>loading...</li>}
</ul>
<button onClick={onNextPage} disabled={!hasNextPage}>
读取更多
</button>
</div>
);
};
export default BookList;
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
# Hook 返回值类型的优缺点
注意这里 useFetchBooks 的返回值是一个对象,而基础 Hooks 之一的 useState,它的返回值却是一个具有两个成员的数组。这两种返回值类型,各有什么好处呢?
useState 返回只有两个值的数组,优点:解构命名方便,缺点:要按顺序。
自定义 hook 返回对象,优点:不需要按顺序也可以解构,缺点:解构如果需要重命名相对麻烦。
React 团队建议返回两个以上值的自定义 Hook 应该使用对象而不是数组。
# 使用 render props 模式重用 UI 逻辑
render props 就是把一个 render 函数作为属性传递给某个组件,由这个组件去执行这个函数从而 render 实际的内容。它是 react 开发中一个最重要的设计模式,因为它解决了 UI 逻辑的重用问题,不仅适用于 Class 组件,在函数组件的场景下也不可或缺。
在 Class 组件时期,render props 和 HOC(高阶组件)两种模式可以说是进行逻辑重用的两把利器,但是实际上,HOC 的所有场景几乎都可以用 render props 来实现。
在函数组件中,Hooks 有一个局限,那就是只能用作数据逻辑的重用,而一旦涉及 UI 表现逻辑的重用,就有些力不从心了,而这正是 render props 擅长的地方。所以,即使有了 Hooks,我们也要掌握 render props 这个设计模式的用法。
比如使用 render props 实现计数器功能。
import { useState, useCallback } from "react";
function CounterRenderProps({ children }) {
const [count, setCount] = useState(0);
const increment = useCallback(() => {
setCount(count + 1);
}, [count]);
const decrement = useCallback(() => {
setCount(count - 1);
}, [count]);
return children({ count, increment, decrement });
}
export default function CounterRenderPropsExample() {
return (
<CounterRenderProps>
{({ count, increment, decrement }) => {
return (
<div className="exp-10-counter-render-props">
<button onClick={decrement}>-</button>
<span>{count}</span>
<button onClick={increment}>+</button>
</div>
);
}}
</CounterRenderProps>
);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
这个例子演示了纯数据逻辑的重用,也就是重用的业务逻辑自己不产生任何 UI。那么在这种场景下,其实用 Hooks 是更方便的。
import { useState, useCallback } from "react";
function useCounter() {
// 定义 count 这个 state 用于保存当前数值
const [count, setCount] = useState(0);
// 实现加 1 的操作
const increment = useCallback(() => setCount(count + 1), [count]);
// 实现减 1 的操作
const decrement = useCallback(() => setCount(count - 1), [count]);
// 重置计数器
const reset = useCallback(() => setCount(0), []);
// 将业务逻辑的操作 export 出去供调用者使用
return { count, increment, decrement, reset };
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
这也是为什么我们经常说 Hooks 能够替代 render props 这个设计模式。但是,需要注意的是,Hooks 仅能替代纯数据逻辑的 render props。如果有 UI 展示的逻辑需要重用,那么我们还是必须借助于 render props 的逻辑,这就是我们必须要掌握 render props 这种设计模式的原因。
比如下面这个例子。假设我们需要显示一个列表,如果超过一定数量,则把多余的部分折叠起来,通过一个弹出框去显示。而这样的列表类型可能会有多种,比如有两种这样的列表,
一个只显示用户名,这在一些社交软件的界面上很常见,只显示几个点赞的用户,多余的用一个数字来表示,鼠标移上去则跳转或者显示完整列表。
另外一个是表格,但是也只显示前面 5 个,多余的折叠到 “更多...” 里面。鼠标移上去再用表格形式显示出剩余列表。
对于这一类场景,功能相同的部分是:数据超过一定数量时,显示一个 “更多...” 的文字;鼠标移上去则弹出一个框,用于显示其它的数据。功能不同的部分是:每一个列表项如何渲染,是在使用的时候决定的。
对于这一类具有 UI 逻辑重用需求的场景,我们就无法通过 Hooks 实现,而是需要通过 render props 这个设计模式。具体运行效果可以看这里 (opens new window)。
import { Popover } from "antd";
import data from "./data";
function ListWithMore({ renderItem, data = [], max }) {
const elements = data.map((item, index) => renderItem(item, index, data));
const show = elements.slice(0, max);
const hide = elements.slice(max);
return (
<span className="exp-10-list-with-more">
{show}
{hide.length > 0 && (
<Popover content={<div style={{ maxWidth: 500 }}>{hide}</div>}>
<span className="more-items-wrapper">
and{" "}
<span className="more-items-trigger"> {hide.length} more...</span>
</span>
</Popover>
)}
</span>
);
}
export default () => {
return (
<div className="exp-10-list-with-more">
<h1>User Names</h1>
<div className="user-names">
Liked by:{" "}
<ListWithMore
renderItem={(user) => {
return <span className="user-name">{user.name}</span>;
}}
data={data}
max={3}
/>
</div>
<br />
<br />
<h1>User List</h1>
<div className="user-list">
<div className="user-list-row user-list-row-head">
<span className="user-name-cell">Name</span>
<span>City</span>
<span>Job Title</span>
</div>
<ListWithMore
renderItem={(user) => {
return (
<div className="user-list-row">
<span className="user-name-cell">{user.name}</span>
<span>{user.city}</span>
<span>{user.job}</span>
</div>
);
}}
data={data}
max={5}
/>
</div>
</div>
);
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
# Hook 工具库
下面几个库基本上涵盖了常见的 react hooks 实现,非常适合平常工作和学习。