# Hooks的使用

Hook是React 16.8的新特性,它主要解决的问题是状态逻辑复用。

Hooks 逻辑上解决了纯函数无法持久化状态的“问题”,从而拓宽了纯函数组件的 适用范围。

以下是笔者在最近三个月使用React Hooks开发过程中的实践记录。(本文部分内容也参考了各个大牛观点,可详见参考链接)

update: 2019-12-11

# 【引言】Hooks组件、Class组件的渲染行为

# Hooks组件的渲染行为

Hooks组件每次render都是调用不同的渲染函数,所以每次都会拥有完全独立的函数作用域

后续的render每次都会生成全新且独立的props、state

alt

# Class组件的渲染行为

Class组件每次render都是调用同一个渲染函数。

后续的渲染只会改变this.props、this.state的值,而不是引用地址(唯一的this.props、this.state只在初始化时的构造函数中生成)

alt

# Hooks的使用规则

目前Hooks包括useCallback、useContext、useEffect、useImperativeHandle、useLayoutEffect、useMemo、useReducer、useRef、useState和useDebugValue。

  • 可以在函数组件、自定义Hooks中调用;
  • 不能在Class组件、循环、条件判断或者子函数中调用;为什么?

函数组件、class组件的区别

因为Hooks组件每次render都会拥有独立的作用域,所以在开发中有一些我们需要注意的

  • 函数组件内,变量/方法的声明位置
  • useState —— Hooks中的state状态
  • useRef —— 不变常量的声明方式
  • useEffect —— 副作用的声明方式
  • useCallback —— 缓存函数的声明方式
  • useMemo —— 缓存值(计算值)的声明方式

# 函数组件内的变量/方法的声明位置

应该减少在组件内部声明变量/方法

函数组件内的变量/方法在每次render时都会重新声明

# useState —— Hooks中的state状态

实现组件内部state状态(类似Class组件的state)。

一般来说,在函数退出后变量就就会”消失”,而 state 中的变量会被 React “保留”。React 会在 重复渲染时 记住它当前的值,并且提供最新的值给函数。

WARNING

# 以下引自 React官方文档📚

const [state, useState] = useState(initialState);
1

useState会返回一个state,以及更新state的函数(笔者注:以下统称setState

在初始渲染期间,返回的状态 (state) 与传入的第一个参数 (initialState) 值相同。

setState(newState)
1

在后续的重新渲染中,useState返回的state将始终是更新后、且最新的state

# setState的两种用法

由文档可知,我们可以通过调用setState来更新当前的state

// 1、直接设置state
setState(newState)

// 2、基于之前的state来更新state
setState(prevState => prevState + 1)
1
2
3
4
5

# setState的特点

  • 用于更新state。
    • 它接收一个新的state值,并将 组件的一次重新渲染 加入更新队列。
  • 方法的引用地址不变
    • 其引用地址不会在重新渲染时发生变化(即不必写入依赖项中)
  • 如果newStateprevState相同,React将跳过子组件的渲染和effect的执行
    • If you update a State Hook to the same value as the current state, React will bail out without rendering the children or firing effects. (React uses the Object.is comparison algorithm.
  • 和Class组件中的setState不同(?)
    • Class组件可以自动合并更新,但useState返回的setState只能覆盖原有值(可通过展开运算符来实现合并更新)
        setState(prevState => {
            return {...prevState, ...newState}
        })
    
    1
    2
    3

# useRef —— 不变常量的声明方式

如果我们需要一个对象,希望它 从一开始到之后的每次render 都是不变的。

“不变”指的是不会重新生成,可变其值,但不可变其址

这时候,useRef就派上用场了!

WARNING

# 以下引自 React官方文档📚

const refContainner = useRef(initialValue);
1

useRef会返回一个可变的ref对象(refContainner),其.current属性会被初始化为传入的参数(initialValue)。返回的ref对象在组件的整个生命周期内保持不变

# 项目中使用useRef的常见情况

  • 引用某个指定的dom实例时

# 例子1

访问DOM的主要方式。demo (opens new window)

1、无论该DOM节点如何改变,ref对象的.current属性都会被设置为相应的DOM节点。

2、.current就相当于直接的document.getElementById('myInput')

function App(props) {
    const inputRef = useRef(); // <-- 声明ref对象

    const onSubmit = () => {
        console.log(inputRef.current) 
    };
    
    return (
        <div>
            <input ref={inputRef} id="myInput" type="text" /> {/*  <-- ref对象的.current属性会被设置为相应的DOM节点 */}
            <button onClick={onSubmit}>提交</button>
        </div>
    )
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

# 例子2

// 1、有一个自定义Form表单:CustomizedForm
class CustomizedForm extends React.Component { ... }

// 2、经过 Form.create 包装后,组件EnhancedForm会自带 this.props.form 属性(该属性拥有各种对该form表单的各种操作方法)
const EnhancedForm =  Form.create()(CustomizedForm);

// 3、有一个App组件,调用了这个EnhancedForm表单组件...
function App(props) {
    let formRef = useRef();

    const onSubmit = () => {
        const { form } = formRef.current;
        form.validateFields((err, values) => {
            if (err) return;

            addData({ moduleType, [option.key]: formatParam(values) }).then(data => {
                formValueCache.current = {};
                setVisible(false);
                dispatch();
            });
        });
    };
    
    // 4、对被 Form.create 包装过的组件,可通过 wrappedComponentRef 这个属性拿到它的ref
    return <EnhancedForm wrappedComponentRef={(form) => (formRef.current = form)} />
}
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

这样之后,formRef.current指向的就是CustomizedForm表单的实例了。

  • 给自定义Hooks传入“被定义在依赖项的参数”时
function App(props) {
     // 使用useRef,返回一个稳定状态的引用值,避免死循环
    let ref = useRef({ resumeAll: true });
    // 此处useFetch是一个用于获取后端数据、且依赖于传入的请求参数的自定义Hooks(第二个参数表示接口请求参数)
    const { data = {}, isLoading } = useFetch(getResumeInfo, ref.current);
}
1
2
3
4
5
6

# useEffect —— 副作用的声明方式

虽然Hooks组件没有生命周期,但我们需要在某些指定时段执行一些事情。

useEffect可以实现 componentDidMountcomponentDidUpdatecomponentWillUnmount 这3种生命周期相近的逻辑。

WARNING

# 以下引自 React官方文档📚

useEffect(didUpdate);
1

React组件在 渲染阶段 不应该有任何副作用(如:改变DOM、添加订阅、设置定时器等)。因为在这里执行操作都太早了,还可能会产生bug并破坏UI的一致性。

若要进行一些副作用操作,可以使用useEffect渲染结束后 进行。

传给useEffect的函数叫作effect,它会保证:在浏览器完成本次布局与绘制后、且在下一轮新的渲染前 执行。

# effect的执行时机

effect会在每次render后都执行,但还有第二个条件:依赖项(denpendencies)

  • 如果 dependencies 不存在(为null),那么 callback 每次 render结束后 都会执行

  • 如果 dependencies 存在且为空数组([]),那么 callback 仅在 初次render结束后 会执行

  • 如果 dependencies 存在且不为空数组,只有当 每次 render结束后依赖项中的元素发生了变化, callback 才会执行

1、依赖项中应该包含:所有外部作用域中,会随时间变化的、并且在effect中有用到的变量

2、官方推荐通过eslint-plugin-react-hooks来自动绑定依赖。 eslint-plugin-react-hooks

3、如果清楚知道effect逻辑,且依赖项自动绑定不智能时,可以适当注释依赖项(通过eslint-disable-next-line)

# effect还有哪些特点?

下面有个例子,我们来初探effect的特点:

“首次渲染” 会先执行同步代码,随后从上往下依次执行effect

function App(props) {
    const [counter, setCounter] = useState(0); // 数量
    const [money, setMoney] = useState(0); // 总消费
    // const [integral, setIntegral] = useState(0); // 总积分

    useEffect(() => {
        console.log('我是第一个【依赖项为null】的effect');

        return () => console.log('我是第一个effect的清除函数');
    });

    useEffect(() => {
        console.log('我是第二个【依赖项为空数组】的effect');

        return () => console.log('我是第二个effect的清除函数');
    }, []);

    useEffect(() => {
        console.log('我是第三个【依赖项为counter】的effect');
        // setIntegral(counter * 100);

        return () => console.log('我是第三个effect的清除函数');
    }, [counter]);

    useEffect(() => {
        console.log('我是第四个【依赖项为counter且带有setState】的effect');
        setMoney(counter * 10);
        // setIntegral(counter * 200);

        return () => console.log('我是第四个effect的清除函数');
    }, [counter]);

    console.log('render渲染');

    return (
        <div className="App">
            <button onClick={() => setCounter(counter + 1)}>苹果+1</button>
            <div>总消费:{money}</div>
            {/* <div>总积分:{integral}</div> */}
        </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
34
35
36
37
38
39
40
41
42

输出结果如下:

// 首次渲染
render渲染
我是第一个【依赖项为null】的effect
我是第二个【依赖项为空数组】的effect
我是第三个【依赖项为counter】的effect
我是第四个【依赖项为counter且带有setState】的effect
1
2
3
4
5
6

“点击+1”后:

  • 1、先执行同步代码
  • 2、从上往下依次执行“依赖项发生变化了的”effect的清除函数
  • 3、再依次执行“依赖项发生变化了的”effect

输出结果如下:

render渲染
我是第一个effect的清除函数
我是第三个effect的清除函数
我是第四个effect的清除函数
我是第一个【依赖项为null】的effect
我是第三个【依赖项为counter】的effect
我是第四个【依赖项为counter且带有setState】的effect
render渲染 // <-- 因为第四个effect带有setState操作
我是第一个effect的清除函数
我是第一个【依赖项为null】的effect
1
2
3
4
5
6
7
8
9
10

React会在下次render中再判断各个effect“依赖项是否发生变化”,以此类推。

若将代码中的注释去掉,得到的也会是同样的打印输出。因为setState会在下次渲染前合并执行(?)

TIP

由以上代码,可知useEffect有以下特点:

  • React将按照effect的声明顺序依次调用组件中的每一个effect

  • React会在调用一个新的effect之前对前一个effect进行清理(若存在清理函数)

  • 各个effect会把副作用累积(?),在下次render时渲染。

# effect总结图

alt

# useCallback —— 缓存函数的声明方式

如果我们希望在Hook组件内定义函数,并不希望它因渲染而重新声明,而是能条件般地缓存下来。

“缓存”指的是当依赖项未发生改变时,useCallback会直接返回这个被缓存的函数(达到被赋值的变量的引用地址不变的效果)。依赖项“发生改变”是指改变了什么?

这时候,useCallback就派上用场了!

WARNING

# 以下引自 React官方文档📚

const memoizedCallback = useCallback(() => doSomething(a, b), [a, b]);
1

useCallback会返回一个memoized回调函数。

把内联回调函数、依赖项数组作为参数传入useCallback,它将返回这个回调函数的memoized版本,该回调函数仅在某个依赖项改变时才会更新。

# 例子

function Home(props) {
    const [counter, setCounter] = useState(0);

    // 使用useCallback来返回这个“缓存函数”
    const onClick = useCallback(() => {
        setCounter(props.count)
    }, [props.count])

    return (
        <div className="App">
            <button onClick={() => setCounter(counter + 1)}>苹果+1</button>
        </div>
    )
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

这样传入useCallback的回调函数就会被缓存下来,每次render后的onClick都是指向同一个引用;当props中count发生改变时才会重新声明这个回调函数,使得onClick方法指向新的引用。

# useMemo —— 缓存值(计算值)的声明方式

如果我们希望在Hooks组件内声明“计算值”(类似Vue.js的computed),并希望它只在依赖项改变时才重新计算,其它情况下保持“不变”。

“计算值”指的是当依赖项未发生改变时,useMemo直接返回上次的缓存值(以达到被赋值的变量的引用地址不变的效果)。相反,当依赖项发生改变时,能够重新计算新的值。

这时候,useMemo就派上用场了!

WARNING

# 以下引自 React官方文档📚

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

useMemo会返回一个memoized值。

把一个带有返回值的函数、依赖项数组作为参数传入useMemo,它仅会在某个依赖项改变时才重新计算memoized值。这种优化有助于避免在每次渲染时都进行高开销的计算。

# useMemo的执行时机

function Home(props) {
    const [counter, setCounter] = useState(0); // 数量
    const [like, setLike] = useState(0); // 点赞数

    // 根据“数量counter”来计算出“总消费money”
    const money = useMemo(() => {
        console.log('计算总消费'); // 为了打印useMemo执行时机
        return counter * 10;
    }, [counter]);

    // 根据“点赞数like”来计算出“总人气popularity
    const popularity = useMemo(() => {
        console.log('计算总人气'); // 为了打印useMemo执行时机
        return like * 0.1;
    }); // 此处没有传入依赖项,和上方作对比

    console.log('render渲染');

    return (
        <div className="App">
            <button onClick={() => setCounter(counter + 1)}>苹果+1</button>
            <div>总消费:{money}</div>

            <button onClick={() => setLike(like + 1)}>+1</button>
            <div>总人气:{popularity}</div>
        </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

上面的代码各情况的输出如下:

首次渲染的useMemo都会执行,且执行时机是在渲染过程中

// 首次渲染
计算总消费
计算总人气
render渲染
1
2
3
4

点击“苹果+1”后,因为在本次render过程中counter发生了变化而导致money重新计算,所以会打印“计算总消费”。又因为popularity的依赖项为null,表示依赖项发生了改变,所以会打印“计算总人气”。随后同步代码继续执行,输出“render渲染”:

// 点击“苹果+1”
计算总消费
计算总人气
render渲染
1
2
3
4

点击“赞+1”后,因为在本次render过程中counter并未发生变化,所以money不会重新计算。又因为popularity的依赖项为null,表示依赖项发生了改变,所以会打印“计算总人气”。随后同步代码继续执行,输出“render渲染”:

// 点击“赞+1”
计算总人气
render渲染
1
2
3

可见,useMemo具有以下特点:

TIP

  • 渲染过程中进行(相当于执行同步代码的顺序);
    • 所以不要在useMemo中传入的函数内部进行与渲染无关的操作(通常称之为“副作用”)
  • 若依赖项为nulluseMemo在每次渲染时都会计算新的值;
  • 若依赖项为([]),只会在初次渲染时重新计算;
  • 否则只会在依赖项发生改变时,会重新计算;依赖项“发生改变”是指改变了什么?

# 依赖项“发生改变”是指改变了什么?

由上面的知识可知,

1、useEffectuseCallbackuseMemo这类Hooks都有用到依赖项;

通过areHookInputsEqual方法比较前后两次依赖项

2、useState也只在两次state发生“变化”时才会触发组件重新渲染。

通过Object.is方法比较前后两次state

areHookInputsEqual是如何判断 依赖项发生了改变 呢?

import is from 'shared/objectIs';
function areHookInputsEqual(
    nextDeps: Array<mixed>, // 本次渲染时的依赖项
    prevDeps: Array<mixed> | null, // 上次渲染时的依赖项
) {
    // 注:返回true则表示:依赖项并未发生变化;

    // 1、若上次渲染时的依赖项为null,表明发生了变化
    if (prevDeps === null) {
        return false;
    }

    // 2、若两次渲染时的依赖项的长度不一样,表明发生了变化
    if (nextDeps.length !== prevDeps.length) {
        return false
    }
 
    // 3、依次对比两次渲染时的依赖项中的各项,只要存在一项在`is方法`检验时返回了false,表明发生了变化
    for (let i = 0; i < prevDeps.length && i < nextDeps.length; i++)   {
        if (is(nextDeps[i], prevDeps[i])) {
            continue;
        }
        return false;
    }
    // 4、以上都不符合,则表明未发生变化(依赖项为`[]`为这种情况)
        return true;
}
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

所以,当依赖项传递空数组([]),只在初次渲染时发送变化。 ReactFiberHooks源码 (opens new window)

其中is方法是ES6中Object.is的兼容性写法:

function is(x: any, y: any) {
  return (
    (x === y && (x !== 0 || 1 / x === 1 / y)) || (x !== x && y !== y) 
  );
}

export default (typeof Object.is === 'function' ? Object.is : is);
1
2
3
4
5
6
7

可知,Object.is 比较算法 (opens new window)属于浅比较即只要引用地址发生了变化,就表明发生了变化)。

# 依赖项检查插件:eslint-plugin-react-hooks

上面提到的:useEffectuseCallbackuseMemo都可以通过传入依赖项来达到条件渲染的效果。

React官方推荐启用eslint-plugin-react-hooks 中的 exhaustive-deps 规则。

此规则会在添加错误依赖时发出警告并给出修复建议。

1、npm安装:

yarn add eslint-plugin-react-hooks -D
1

2、ESLint配置:

// .eslintrc.js
module.exports = {
    // ...
    plugins: ['react-hooks'],
    rules: {
        // ...
        "react-hooks/rules-of-hooks": "error",
        "react-hooks/exhaustive-deps": "warn",
    }
}
1
2
3
4
5
6
7
8
9
10

# 参考链接

React Hooks工程实践总结 (opens new window)

React Hook (opens new window)

Object.is()——MDN (opens new window)

State Hook与Effect Hook解析 (opens new window)

更新时间: 11/21/2021, 2:45:24 AM