React Hooks

在函数组件里天天 use 来 use 去,我也想自己整点


什么是 React Hooks

Hook 是 React 16.8 的新增特性。它可以让你在不编写 class 的情况下使用 state 以及其他的 React 特性

其实就是写类组件太麻烦了,函数组件有的功能又没有,所以就用 Hook 来实现所需的效果

平常见到的那些 useXXX 就是 Hook

为什么需要 Hook

可以参看官方给出的 Hook 简介 - 动机

简单说就是,写函数组件就是要用 Hook

常用 Hooks

一般有以下 Hook 是常用的

  1. useState
  2. useEffect / useLayoutEffect
  3. useContext
  4. useReducer
  5. useMemo / useCallback
  6. useRef

注意事项

在开始介绍 Hook 之前,需要先介绍一下注意事项

只在最顶层使用 Hook

react 要求在每次执行组件渲染的时候,都要执行相同的 Hook 序列,否则会认为代码出错

1
2
3
4
const [n, setN] = useState(0)
if(n % 2 === 0){
const [m, setM] = useState(0)
}

上述代码会触发 react 报错,因为每次执行时,有可能出现不一样的 Hook 序列

同样的,循环、嵌套函数也可能出现上述问题,请根据具体报错进行处理

只在 React 函数中调用 Hook

Hook 在别的函数中,是不起作用的,因为它只为函数组件服务

所以,Hook 的使用场景必须是以下两个条件之一

  1. 在 react 函数组件中调用 Hook
  2. 在自定义 Hook 中调用其它 Hook

关于自定义 Hook,下文中会有描述,可以通过目录快进

useState

类组件可以通过 this.state = {} 来声明私有变量,但是函数组件不行,所以就需要 useState,其作用实际上就是创建一个变量

通常使用如下形式创建

1
const [n, setN] = useState(0)

上述例子创建了一个变量,赋初始值为 0,并取得其 getter/setter API

useState 接受一个传入参数,表示要创建的变量的初始值,之后返回一个具有两个值的数组

第一个值是该变量的 getter,第二个则是对应的 setter

对于 getter,像普通变量一样使用即可,比如此时直接对 n 取值,就可以取到值 0

对于 setter,可以有两种调用方式

  1. 传入一个新值,此时会完全覆盖旧值

    当且仅当新值与旧值地址不同时,会触发 render

  2. 传入一个函数,取函数的返回值作为新值

    setter 会向这个函数传入一个参数,该参数的值是目标变量当前的最新值

当使用方式 1,直接传入一个新值时,需要注意以下两点

  1. 如果此时目标变量是一个对象且具有多个字段,则传入新对象时,不会同步旧有字段的值

    例如,此时对象是 {n: 0, m:1},通过 setter 设置新值为 {n: 1}

    则之后 getter 只能取到 {n: 1},字段 m 会丢失

  2. 不要修改旧有的值再传入

    例如,此时对象是 {n: 0},先直接通过 getter 执行 obj.n ++,再通过 setter 设置新值为 obj

    则因为新值和旧值的地址相等,即使内部值变化了,react 也还是认为这个变量并没有发生变化,所以不会重新触发 render

useEffect

useEffect 最大的作用就是监听

useEffect 要求传入两个参数,第一个参数是回调函数,第二个参数是一个数组,表示当数组中列出的对象变化后,执行回调函数

1
2
3
4
5
6
7
8
9
// 当 n 变化后,输出 'n changed'
useEffect(()=>console.log('n changed'),[n])

// 仅在函数组件初始化时,输出 'component start'
// 通常用于模拟生命周期钩子函数 componentDidMount
useEffect(()=>console.log('component start'),[])

// 不论 state 中任意变量变化了,都输出 'something changed'
useEffect(()=>console.log('something changed'))

注意,此处所有的回调函数,都会在页面重绘后才执行

useEffect 还有一个功能类似的函数,名为 useLayoutEffect

useLayoutEffect 会在 DOM diff 之后,页面重绘之前执行

但由于上述特点会浪费时间,阻碍用户看到新页面,所以一般不使用 useLayoutEffect,除非使用 useEffect 无法解决问题

useContext

提供组件上下文,让变量可以穿透组件,从父组件到达子组件

需要配合 React.createContext 使用

一个使用例如下

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
34
35
36
37
38
39
40
41
import React, { useState, useContext } from 'react';
import './App.css';

const themes = {
light: {
foreground: "#000000",
background: "#eeeeee"
},
dark: {
foreground: "#ffffff",
background: "#222222"
}
};

const ThemeContext = React.createContext(themes.light);

function App() {
const [value, setValue] = useState(themes.dark);
const change = () => {
setValue(themes.light)
};
return (
<ThemeContext.Provider value={value}>
<ThemedButton />
<div>
<button onClick={change}>change</button>
</div>
</ThemeContext.Provider>
);
}

function ThemedButton(props) {
const theme = useContext(ThemeContext);
return (
<button style={{ background: theme.background, color: theme.foreground }}>
I am styled by theme context!
</button>
);
}

export default App;

先声明 themes 作为父子组件通用的内容

然后通过 React.createContext 创建上下文,此处返回的变量可以任意命名

之后,在父组件中,通过 useState 创建一个响应式变量,用来存放当前提供给子组件的主题,并在模板中,使用一对 ThemeContext.Provider 标签,包裹住可以使用上下文的其它内容

此处允许子组件 ThemedButton 以及父组件中的一个 button 元素使用上下文

注意,不论你的上下文变量叫什么名字,此处的包裹标签都必须是 .Provider 形式

此时就可以在子组件中,通过 useContext 获取上下文,该 Hook 要求传入上下文变量作为参数,并返回此时父组件提供的具体内容,之后就可以在子组件的模板中使用了

useContext / createContext 组合通常用来提供局部的全局变量

之所以是全局变量,是因为其可以在父子组件之间通用

之所以又称为局部,是因为我们通常不希望有太多的变量污染全局空间,容易产生冲突,难以维护,所以最好不要放在主入口中

注意,上下文的修改不是响应式的,例如直接通过赋值语句修改上例中的 value 的话,并不会触发视图更新,所以上例采用了 useState 提供的响应式更新的方法

useReducer

是一种 useState 的替代方案,使用方法类似于 VUE 的 VUEX,都是一个存放数据的地方,加上预先声明的若干操作,之后通过 dispatch 提交操作

通常使用如下模样使用

1
const [state, dispatch] = useReducer(reducer, initialArg, init);

要求传入三个参数,按顺序分别是对数据的一组操作、数据初始值、初始化方法,其中第三个参数是可选的

该 Hook 的两个返回值,按顺序分别是数据的 getter,以及向对应数据提交操作的 dispatch 函数

基本用法

一个使用例如下

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
import React, { useReducer } from 'react';
import './App.css';

const initialState = { count: 0 };

function reducer(state, action) {
switch (action.type) {
case 'increment':
return { count: state.count + 1 };
case 'decrement':
return { count: state.count - 1 };
default:
throw new Error();
}
}

function App() {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<>
Count: {state.count}
<div>
<button onClick={() => dispatch({ type: 'decrement' })}>-</button>
<button onClick={() => dispatch({ type: 'increment' })}>+</button>
</div>
</>
);
}

export default App;

先声明要引入的初始值,一般命名为某种 state

然后以函数形式声明一组操作,该函数接受两个参数,按顺序分别是数据对象和 dispatch 提交上来的对象

按照约定,一般将操作类型放在 type 字段,将额外的操作数放在 payload 字段

在这组操作中,通过判断 action.type,来确定要执行的是哪种操作,然后返回一个新的对象,作为新的 state

需要注意的是,此处的返回值不会与旧的 state 自动合并,在使用中需要手动使用 spread 语法进行展开赋值

从上例的模板可以看出,对 state 的读操作,其写法与直接读源数据相同;对于 dispatch 函数,要求传入一个对象,该对象在操作函数中作为 action 出现

惰性初始化

也可以采用函数式声明,进行惰性初始化,这么做可以将用于计算 state 的逻辑提取到 reducer 外部,也为将来对重置 state 的 action 做处理提供了便利

一个惰性初始化的例子如下

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
34
35
36
37
38
39
40
import React, { useReducer } from 'react';
import './App.css';

const initialCount = 0;

function init(initialCount) {
return { count: initialCount };
}

function reducer(state, action) {
switch (action.type) {
case 'increment':
return { count: state.count + 1 };
case 'decrement':
return { count: state.count - 1 };
case 'reset':
return init(action.payload);
default:
throw new Error();
}
}

function App() {
const [state, dispatch] = useReducer(reducer, initialCount, init);
return (
<>
Count: {state.count}
<div>
<button
onClick={() => dispatch({ type: 'reset', payload: initialCount })}>
Reset
</button>
<button onClick={() => dispatch({ type: 'decrement' })}>-</button>
<button onClick={() => dispatch({ type: 'increment' })}>+</button>
</div>
</>
);
}

export default App;

显然,通过函数式初始化,可以对数据进行复杂的预处理,并且不会与 reducer 过耦合

但写法较为麻烦,实践中一般没什么必要,有需要的时候再重构吧

useMemo

常见的 useMemo 格式如下

1
const memoizedValue = useMemo(() => fn(a, b), [a, b]);

接受两个参数,返回一个缓存值

第一个参数是一个函数,该函数的传入参数为空,取该函数的返回值作为 useMemo 的返回值

第二个参数是一个监听数组,效果同 useState

可以通过该 Hook 回避多余的渲染

useCallback

一般我们执行一个函数的时候,都需要传入参数,但是用 useMemo 第一个参数必须为空,所以就会变成这样

1
const memoizedValue = useMemo(() => (a,b) => fn(a, b), [a, b]);

这是一个返回函数的函数,一看就很丑

所以有一个语法糖 useCallback,自带可选参数

格式如下

1
const memoizedValue = useCallback((a,b) => fn(a, b), [a, b]);

就是这样,与上面提到的 useMemo 的例子完全等效

React.memo

也可以通过 React.memo 函数,来缓存整个组件

其格式如下

1
2
3
4
5
6
7
8
9
10
11
function MyComponent(props) {
/* 使用 props 渲染 */
}
function areEqual(prevProps, nextProps) {
/*
如果把 nextProps 传入 render 方法的返回结果与
将 prevProps 传入 render 方法的返回结果一致则返回 true,
否则返回 false
*/
}
export default React.memo(MyComponent, areEqual);

要求传入两个参数,返回缓存的组件

第一个参数是函数组件

第二个参数可选,是一个判断函数,通过该函数进行对于新旧 props 的判断,返回 true / false 分别表示认为 props 变化或没有变化

当传递给 MyComponentprops 没有发生变化时,组件不会重新渲染

useRef

之前说过,react 的推荐思想之一就是对象不变性

但是这样的话,每次都要产生一个新对象,各种开销不得不考虑

于是就有了 useRef 这个 Hook,用来在组件中产生一个唯一的引用,使其在每次重新渲染的时候都保持同一个引用

一般格式如下

1
const refContainer = useRef(initialValue);

传入一个初始值,该初始值可以是对象

之后返回一个唯一的引用,通过该对象的 current 字段,取得我们传入的数据

一个用例如下

1
2
3
4
5
6
7
8
9
10
11
12
13
function TextInputWithFocusButton() {
const inputEl = useRef(null);
const onButtonClick = () => {
// `current` 指向已挂载到 DOM 上的文本输入元素
inputEl.current.focus();
};
return (
<>
<input ref={inputEl} type="text" />
<button onClick={onButtonClick}>Focus the input</button>
</>
);
}

通过创建一个初始值为空的引用,然后通过 ref 属性绑定到 DOM 元素上,之后该引用的 current 字段值就一直是该 DOM 元素

乍一看是不错,解决了重复产生多个对象的问题,但是又产生了另一个问题——当 ref 对象内容发生变化时,useRef不会通知你。变更 current 属性不会引发组件重新渲染

此时需要用户手动调用渲染函数,或采取如下的补救方法

补救方法

我们知道 useState 返回的 setter 可以刷新页面,那么我们就可以利用这个 Hook

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function App() {
const count = useRef(0);
const [_, fresh] = useState(null);
const onButtonClick = () => {
count.current++;
fresh(Math.random());
};
return (
<>
{count.current}
<button onClick={onButtonClick}>refresh</button>
</>
);
}

通过 useState 取得一个可以刷新页面的函数,之后每次在 count.current 更新时,传入保证不相同的随机数,就可以做到更新页面的效果了

子组件传递 ref

但是 ref 不能通过 props 传递,怎么办呢

可以通过 React.forwardRef 来包装一个子组件,并向下传递 ref

React.forwardRef 函数接受一个函数组件作为内部组件,并向该内部组件提供两个参数

第一个参数是正常的 props,第二个参数则是绑定在当前包装器组件上的 ref

一个用例如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function App() {
const inputEl = useRef(null);
const onButtonClick = () => {
// `current` 指向已挂载到 DOM 上的文本输入元素
inputEl.current.focus();
};
return (
<>
<MyInput ref={inputEl} type="text" />
<button onClick={onButtonClick}>Focus the input</button>
</>
);
}

const MyInput = React.forwardRef(function(props, ref){
return <input ref={ref} {...props} />;
});

不过类组件就没有这个问题,毕竟有 this 指针

useImperativeHandle

格式如下

1
useImperativeHandle(ref, createHandle, [deps])

将 ref 设置为 createHandle 的值,在依赖项变更的时候重新计算

其实就是起到一个设置 ref 的效果

但是修改 ref 有什么用呢?答案就是没什么用(

自定义 Hook

这个就简单了,先给个例子吧

1
2
3
4
5
6
7
8
9
10
11
12
13
// useUpdate.js
import { useEffect, useState } from 'react';

const useUpdate = (fn, dep) => {
const [flag, setFlag] = useState(false);

useEffect(() => {
if (flag) fn();
else setFlag(flag => true);
}, [fn, dep, flag]);
};

export default useUpdate;

通过 react 原生的 useStateuseEffect,来做到监听、回调与刷新

自定义 Hook 要求是必须命名为 useXxx 格式

除了本例的 useUpdate,也可以自定义一些别的对某些数据的操作,只要直接向外暴露读写接口即可

如果想要加强功能的话,甚至其余的 CURD 也可以一起写好暴露出去,2333

过时的闭包

英文说法为 stale closure

我们可以发现 react 的 useState 等功能,其实都是隐藏了真实目标,而向外暴露接口的形式,这就是一种闭包

但是大量使用闭包,也有显而易见的缺点,就是不利于内存的管理

所以 VUE 的作者尤雨溪认为这是一种”过时的闭包”

当然大家见仁见智吧,react 毕竟这么自由

但是!我去用 VUE 3.0 了,888888888888888


感谢阅读

--It's the end.Thanks for your read.--