beichensky / Blog

经常写博客的地方,会时常记录一些学习笔记、技术博客或者踩坑历程。
181 stars 12 forks source link

React Hooks 用法详解 #5

Open beichensky opened 3 years ago

beichensky commented 3 years ago

本文对 16.8 版本之后 React 发布的新特性 Hooks 进行了详细讲解,并对一些常用的 Hooks 进行代码演示,希望可以对需要的朋友提供些帮助。

前言

本文已收录在 Github: https://github.com/beichensky/Blog 中,欢迎 Star!

一、Hooks 简介

HooksReact v16.7.0-alpha 中加入的新特性。它可以让你在 class 以外使用 state 和其他 React 特性。 本文就是演示各种 Hooks API 的使用方式,对于内部的原理这里就不做详细说明。


二、Hooks 初体验

Foo.js

import React, { useState  } from 'react';

function Foo() {
    // 声明一个名为“count”的新状态变量
    const [count, setCount] = useState(0);

    return (
        <div>
            <p>You clicked {count} times</p>
            <button onClick={() => setCount(count + 1)}>
                Click me
            </button>
        </div>
    );
}

export default Foo;

useState 就是一个 Hook,可以在我们不使用 class 组件的情况下,拥有自身的 state,并且可以通过修改 state 来控制 UI 的展示。


三、常用的两个 Hooks

1、useState

语法

const [state, setState] = useState(initialState)

与在类中使用 setState 的异同点:

使用对比

之前想要使用组件内部的状态,必须使用 class 组件,例如:

Foo.js

import React, { Component } from 'react';

export default class Foo extends Component {
    constructor(props) {
        super(props);
        this.state = {
            count: 0
        };
    }

    render() {
        return (
            <div>
            <p>You clicked {this.state.count} times</p>
            <button onClick={() => this.setState({ count: this.state.count + 1 })}>
                Click me
            </button>
            </div>
        );
    }
}

而现在,我们使用函数式组件也可以实现一样的功能了。也就意味着函数式组件内部也可以使用 state 了。

Foo.js

import React, { useState } from 'react';

function Foo() {
    // 声明一个名为“count”的新状态变量
    const [count, setCount] = useState(0);

    return (
        <div>
            <p>You clicked {count} times</p>
            <button onClick={() => setCount(count + 1)}>
                Click me
            </button>
        </div>
    );
}

export default Foo;

优化

创建初始状态是比较昂贵的,所以我们可以在使用 useState API 时,传入一个函数,就可以避免重新创建忽略的初始状态。

普通的方式:

// 直接传入一个值,在每次 render 时都会执行 createRows 函数获取返回值
const [rows, setRows] = useState(createRows(props.count));

优化后的方式(推荐):

// createRows 只会被执行一次
const [rows, setRows] = useState(() => createRows(props.count));

2、useEffect

之前很多具有副作用的操作,例如网络请求,修改 UI 等,一般都是在 class 组件的 componentDidMount 或者 componentDidUpdate 等生命周期中进行操作。而在函数组件中是没有这些生命周期的概念的,只能 return 想要渲染的元素。 但是现在,在函数组件中也有执行副作用操作的地方了,就是使用 useEffect 函数。

语法

useEffect(() => { doSomething });

两个参数:

虽然传递 [] 更接近熟悉的 componentDidMountcomponentWillUnmount 执行规则,但我们建议不要将它作为一种习惯,因为它经常会导致错误。

使用对比

假如此时我们有一个需求,让 documenttitleFoo 组件中的 count 次数保持一致。

使用 类组件:

Foo.js

import React, { Component } from 'react';

export default class Foo extends Component {
    constructor(props) {
        super(props);
        this.state = {
            count: 0
        };
    }

    componentDidMount() {
        document.title = `You clicked ${ this.state.count } times`;
    }

    componentDidUpdate() {
        document.title = `You clicked ${ this.state.count } times`;
    }

    render() {
        return (
            <div>
            <p>You clicked {this.state.count} times</p>
            <button onClick={() => this.setState({ count: this.state.count + 1 })}>
                Click me
            </button>
            </div>
        );
    }
}

而现在在函数组件中也可以进行副作用操作了。

Foo.js

import React, { useState, useEffect } from 'react';

function Foo() {
    // 声明一个名为“count”的新状态变量
    const [count, setCount] = useState(0);

    // 类似于 componentDidMount 和 componentDidUpdate:
    useEffect(() => {
        // 使用浏览器API更新文档标题
        document.title = `You clicked ${count} times`;
    });

    return (
        <div>
            <p>You clicked {count} times</p>
            <button onClick={() => setCount(count + 1)}>
                Click me
            </button>
        </div>
    );
}

export default Foo;

不仅如此,我们可以使用 useEffect 执行多个副作用(可以使用一个 useEffect 执行多个副作用,也可以分开执行)

useEffect(() => {
    // 使用浏览器API更新文档标题
    document.title = `You clicked ${count} times`;
});

const handleClick = () => {
    console.log('鼠标点击');
}

useEffect(() => {
    // 给 window 绑定点击事件
    window.addEventListener('click', handleClick);
});

现在看来功能差不多了。但是在使用类组件时,我们一般会在 componentWillMount 生命周期中进行移除注册的事件等操作。那么在函数组件中又该如何操作呢?

useEffect(() => {
    // 使用浏览器API更新文档标题
    document.title = `You clicked ${count} times`;
});

const handleClick = () => {
    console.log('鼠标点击');
}

useEffect(() => {
    // 给 window 绑定点击事件
    window.addEventListener('click', handleClick);

    return () => {
        // 给 window 移除点击事件
        window.removeEventListener('click', handleClick);
    }
});

可以看到,我们传入的第一个参数,可以 return 一个函数出去,在组件被销毁时,会自动执行这个函数

优化 useEffect

上面我们一直使用的都是 useEffect 中的第一个参数,传入了一个函数。那么 useEffect 的第二个参数呢?

useEffect 的第二个参数是一个数组,里面放入在 useEffect 使用到的 state 值,可以用作优化,只有当数组中 state 值发生变化时,才会执行这个 useEffect

useEffect(() => {
    // 使用浏览器API更新文档标题
    document.title = `You clicked ${count} times`;
}, [ count ]);

Tip:如果想模拟 class 组件的行为,只在 componetDidMount 时执行副作用,在 componentDidUpdate 时不执行,那么 useEffect 的第二个参数传一个 [] 即可。(但是不建议这么做,可能会由于疏漏出现错误)


四、其他 Hooks API

1、useContext

语法

const value = useContext(MyContext);

接受上下文对象(从中 React.createContext 返回的值)并返回该上下文的当前上下文值。当前上下文值由树中调用组件上方 value 最近的 prop 确定 <MyContext.Provider>

useContext(MyContext) 则相当于 static contextType = MyContext 在类中,或者 <MyContext.Consumer>

用法

App.js 文件中创建一个 context,并将 context 传递给 Foo 子组件

App.js

import React, { createContext } from 'react';
import Foo from './Foo';

import './App.css';

export const ThemeContext = createContext(null);

export default () => {

    return (
        <ThemeContext.Provider value="light">
            <Foo />
        </ThemeContext.Provider>
    )
}

Foo 组件中,使用 useContext API 可以获取到传入的 context

Foo.js

import React, { useContext } from 'react';

import { ThemeContext } from './App';

export default () => {

    const context = useContext(ThemeContext);

    return (
        <div>Foo 组件:当前 theme 是:{ context }</div>   
    )
}

注意事项

useContext 必须是上下文对象本身的参数:

useContext(MyContext) 只允许您阅读上下文并订阅其更改。您仍然需要 在树中使用以上内容来为此上下文提供值。

2、useReducer

语法

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

useState 的替代方案。 接受类型为 (state, action) => newState 的reducer,并返回与 dispatch 方法配对的当前状态。

当你涉及多个子值的复杂 state(状态) 逻辑时,useReducer 通常优于 useState

用法

Foo.js

import React, { useReducer } from 'react';

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();
    }
}

export default () => {

    // 使用 useReducer 函数创建状态 state 以及更新状态的 dispatch 函数
    const [state, dispatch] = useReducer(reducer, initialState);
    return (
        <>
            Count: {state.count}
            <br />
            <button onClick={() => dispatch({type: 'increment'})}>+</button>
            <button onClick={() => dispatch({type: 'decrement'})}>-</button>
        </>
    );
}

优化:延迟初始化

还可以惰性地创建初始状态。为此,你可以将init函数作为第三个参数传递。初始状态将设置为 init(initialArg)

它允许你提取用于计算 reducer 外部的初始状态的逻辑。这对于稍后重置状态以响应操作也很方便:

Foo.js

import React, { useReducer } from 'react';

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();
    }
}

export default ({initialCount = 0}) => {

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

}

与 useState 的区别

针对第二点区别,我们可以演示一下: 在上面 useState 用法的例子中,我们新增一个 button

useState 中的 Foo.js

import React, { useState } from 'react';

function Foo() {
    // 声明一个名为“count”的新状态变量
    const [count, setCount] = useState(0);

    return (
        <div>
            <p>You clicked {count} times</p>
            <button onClick={() => setCount(count + 1)}>
                Click me
            </button>
            <button onClick={() => {
                setCount(count + 1);
                setCount(count + 1);
            }}>
                测试能否连加两次
            </button>
        </div>
    );
}

export default Foo;

点击 测试能否连加两次 按钮,会发现,点击一次, count 还是只增加了 1,由此可见,useState 确实是 异步 更新数据;

在上面 useReducer 用法的例子中,我们新增一个 buttonuseReducer 中的 Foo.js

import React, { useReducer } from 'react';

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();
    }
}

export default () => {

    // 使用 useReducer 函数创建状态 state 以及更新状态的 dispatch 函数
    const [state, dispatch] = useReducer(reducer, initialState);
    return (
        <>
            Count: {state.count}
            <br />
            <button onClick={() => dispatch({type: 'increment'})}>+</button>
            <button onClick={() => dispatch({type: 'decrement'})}>-</button>
            <button onClick={() => {
                dispatch({type: 'increment'});
                dispatch({type: 'increment'});
            }}>
                测试能否连加两次
            </button>
        </>
    );
}

点击 测试能否连加两次 按钮,会发现,点击一次, count 增加了 2,由此可见,每次 dispatch 一个 action 就会更新一次数据,useReducer 确实是 同步 更新数据;

3、useCallback

语法

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

返回值 memoizedCallback 是一个 memoized 回调。传递内联回调和一系列依赖项。useCallback 将返回一个回忆的memoized版本,该版本仅在其中一个依赖项发生更改时才会更改。

当将回调传递给依赖于引用相等性的优化子组件以防止不必要的渲染(例如 shouldComponentUpdate)时,这非常有用。

配合子组件使用 PureComponentmemo 时,可以减少子组件不必要的渲染次数

用法

4、useMemo

语法

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

返回一个memoized值。 传递“创建”函数和依赖项数组。useMemo 只会在其中一个依赖项发生更改时重新计算 memoized 值。此优化有助于避免在每个渲染上进行昂贵的计算。

useMemo在渲染过程中传递的函数会运行。不要做那些在渲染时通常不会做的事情。例如,副作用属于 useEffect,而不是 useMemo。

用法

例子:

Foo.js

import React from 'react';

export default ({ text }) => {

    console.log('Foo:', 'render');
    return <div>Foo 组件:{ text }</div>

}

Bar.js

import React from 'react';

export default ({ text }) => {

    console.log('Bar:', 'render');
    return <div>Bar 组件:{ text }</div>

}

App.js

import React, { useState } from 'react';
import Foo from './Foo';
import Bar from './Bar';

export default () => {

    const [a, setA] = useState('foo');
    const [b, setB] = useState('bar');

    return (
        <div>
            <Foo text={ a } />
            <Bar text={ b } />
            <br />
            <button onClick={ () => setA('修改后的 Foo') }>修改传给 Foo 的属性</button>
            &nbsp;&nbsp;&nbsp;&nbsp;
            <button onClick={ () => setB('修改后的 Bar') }>修改传给 Bar 的属性</button>
        </div>
    )
}

此时我们点击上面任意一个按钮,都会看到控制台打印了两条输出, A 和 B 组件都会被重新渲染。

现在我们使用 useMemo 进行优化

App.js

import React, { useState, useMemo } from 'react';
import Foo from './Foo';
import Bar from './Bar';

import './App.css';

export default () => {

    const [a, setA] = useState('foo');
    const [b, setB] = useState('bar');

+    const foo = useMemo(() => <Foo text={ a } />, [a]);
+    const bar = useMemo(() => <Bar text={ b } />, [b]);

    return (
        <div>
+            {/* <Foo text={ a } />
+            <Bar text={ b } /> */}
+            { foo }
+            { bar }
            <br />
            <button onClick={ () => setA('修改后的 Foo') }>修改传给 Foo 的属性</button>
            &nbsp;&nbsp;&nbsp;&nbsp;
            <button onClick={ () => setB('修改后的 Bar') }>修改传给 Bar 的属性</button>
        </div>
    )
}

此时我们点击不同的按钮,控制台都只会打印一条输出,改变 a 或者 b,A 和 B 组件都只有一个会重新渲染。

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

5、useRef

语法

const refContainer = useRef(initialValue);

useRef 返回一个可变的 ref 对象,其 .current 属性被初始化为传递的参数(initialValue)。返回的对象将存留在整个组件的生命周期中。

注意:useRef() 比 ref 属性更有用。与在类中使用 instance(实例) 字段的方式类似,它可以 方便地保留任何可变值。

注意,内容更改时useRef 不会通知您。变异.current属性不会导致重新渲染。如果要在 React 将引用附加或分离到DOM节点时运行某些代码,则可能需要使用回调引用。

用法

下面这个例子中展示了可以在 useRef() 生成的 refcurrent 中存入元素、字符串

Example.js

import React, { useRef, useState, useEffect } from 'react'; 

export default () => {

    // 使用 useRef 创建 inputEl 
    const inputEl = useRef(null);

    const [text, updateText] = useState('');

    // 使用 useRef 创建 textRef 
    const textRef = useRef();

    useEffect(() => {
        // 将 text 值存入 textRef.current 中
        textRef.current = text;
        console.log('textRef.current:', textRef.current);
    });

    const onButtonClick = () => {
        // `current` points to the mounted text input element
        inputEl.current.value = "Hello, useRef";
    };

    return (
        <>
            {/* 保存 input 的 ref 到 inputEl */}
            <input ref={ inputEl } type="text" />
            <button onClick={ onButtonClick }>在 input 上展示文字</button>
            <br />
            <br />
            <input value={text} onChange={e => updateText(e.target.value)} />
        </>
    );

}

点击 在 input 上展示文字 按钮,就可以看到第一个 input 上出现 Hello, useRef;在第二个 input 中输入内容,可以看到控制台打印出对应的内容。

6、useLayoutEffect

语法

useLayoutEffect(() => { doSomething });

useEffect Hooks 类似,都是执行副作用操作。但是它是在所有 DOM 更新完成后触发。可以用来执行一些与布局相关的副作用,比如获取 DOM 元素宽高,窗口滚动距离等等。

进行副作用操作时尽量优先选择 useEffect,以免阻止视觉更新。与 DOM 无关的副作用操作请使用 useEffect

用法

用法与 useEffect 类似。但会在 useEffect 之前执行

Foo.js

import React, { useRef, useState, useLayoutEffect } from 'react'; 

export default () => {

    const divRef = useRef(null);

    const [height, setHeight] = useState(100);

    useLayoutEffect(() => {
        // DOM 更新完成后打印出 div 的高度
        console.log('useLayoutEffect: ', divRef.current.clientHeight);
    })

    return <>
        <div ref={ divRef } style={{ background: 'red', height: height }}>Hello</div>
        <button onClick={ () => setHeight(height + 50) }>改变 div 高度</button>
    </>

}

7、useImperativeHandle

在函数组件中,没有组件实例,所以无法像类组件中,通过绑定子组件的实例调用子组件中的状态或者方法。

那么在函数组件中,如何在父组件调用子组件的状态或者方法呢?答案就是使用 useImperativeHandle

语法

useImperativeHandle(ref, createHandle, [deps])

用法

注意:

Foo.js

import React, { useState, useImperativeHandle, useCallback } from 'react';

const Foo = ({ actionRef }) => {
    const [value, setValue] = useState('');

    /**
     * 随机修改 value 值的函数
     */
    const randomValue = useCallback(() => {
        setValue(Math.round(Math.random() * 100) + '');
    }, []);

    /**
     * 提交函数
     */
    const submit = useCallback(() => {
        if (value) {
            alert(`提交成功,用户名为:${value}`);
        } else {
            alert('请输入用户名!');
        }
    }, [value]);

    useImperativeHandle(
        actionRef,
        () => {
            return {
                randomValue,
                submit,
            };
        },
        [randomValue, submit]
    );

    /* !! 返回多个属性要按照上面这种写法,不能像下面这样使用多个 useImperativeHandle
      useImperativeHandle(actionRef, () => {
          return {
              submit,
          }
      }, [submit])

      useImperativeHandle(actionRef, () => {
          return {
              randomValue
          }
      }, [randomValue])
  */

    return (
        <div className="box">
            <h2>函数组件</h2>
            <section>
                <label>用户名:</label>
                <input
                    value={value}
                    placeholder="请输入用户名"
                    onChange={e => setValue(e.target.value)}
                />
            </section>
            <br />
        </div>
    );
};

export default Foo;

App.js

import React, { useRef } from 'react';
import Foo from './Foo'

const App = () => {
    const childRef = useRef();

    return (
        <div>
            <Foo actionRef={childRef} />
            <button onClick={() => childRef.current.submit()}>调用子组件的提交函数</button>
            <br />
            <br />
            <button onClick={() => childRef.current.randomValue()}>
                随机修改子组件的 input 值
            </button>
        </div>
    );
};

五、尝试编写自定义 Hooks

这里我们就仿照官方的 useReducer 做一个自定义的 Hooks

1、编写自定义 useReducer

src 目录下新建一个 useReducer.js 文件:

useReducer.js

import React, { useState } from 'react';

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

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

    return [state, dispatch];
}

Tip: Hooks 不仅可以在函数组件中使用,也可以在别的 Hooks 中进行使用。

2、使用自定义 useReducer

好了,自定义 useReducer 编写完成了,下面我们看一下能不能正常使用呢?

改写 Foo 组件

Example.js

import React from 'react';

// 从自定义 useReducer 中引入
import useReducer from './useReducer';

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();
    }
}

export default () => {

    // 使用 useReducer 函数创建状态 state 以及更新状态的 dispatch 函数
    const [state, dispatch] = useReducer(reducer, initialState);
    return (
        <>
            Count: {state.count}
            <br />
            <button onClick={() => dispatch({type: 'increment'})}>+</button>
            <button onClick={() => dispatch({type: 'decrement'})}>-</button>
        </>
    );
}

五、Hooks 使用及编写规范


六、使用 React 提供的 ESLint 插件

根据上一段所写,在 React 中使用 Hooks 需要遵循一些特定规则。但是在代码的编写过程中,可能会忽略掉这些使用规则,从而导致出现一些不可控的错误。这种情况下,我们就可以使用 React 提供的 ESLint 插件:eslint-plugin-react-hooks。下面我们就看看如何使用吧。

安装 ESLint 插件

$ npm install eslint-plugin-react-hooks --save

在 .eslintrc 中使用插件

// Your ESLint configuration
// "react-hooks/rules-of-hooks": "error", // Checks rules of Hooks
// "react-hooks/exhaustive-deps": "warn" // Checks effect dependencies
{
  "plugins": [
    "react-hooks"
  ],
  "rules": {
    "react-hooks/rules-of-hooks": "error",
    "react-hooks/exhaustive-deps": "warn"
  }
}

七、参考文档

React 官网

React Hooks FAQ

写在后面

如果有写的不对或不严谨的地方,欢迎大家能提出宝贵的意见,十分感谢。

如果喜欢或者有所帮助,欢迎 Star,对作者也是一种鼓励和支持。