React Hooks 全解

Hooks 的由来

Hooks的出现是为了解决 React 长久以来存在的一些问题:

  • 带组件状态的逻辑很难重用

为了解决这个问题,需要引入render propshigher-order components这样的设计模式,如react-redux提供的connect方法。这种方案不够直观,而且需要改变组件的层级结构,极端情况下会有多个wrapper嵌套调用的情况。

Hooks可以在不改变组件层级关系的前提下,方便的重用带状态的逻辑。

  • 复杂组件难于理解

大量的业务逻辑需要放在componentDidMountcomponentDidUpdate等生命周期函数中,而且往往一个生命周期函数中会包含多个不相关的业务逻辑,如日志记录和数据请求会同时放在componentDidMount中。另一方面,相关的业务逻辑也有可能会放在不同的生命周期函数中,如组件挂载的时候订阅事件,卸载的时候取消订阅,就需要同时在componentDidMountcomponentWillUnmount中写相关逻辑。

Hooks可以封装相关联的业务逻辑,让代码结构更加清晰。

  • 难于理解的 Class 组件

JS 中的this关键字让不少人吃过苦头,它的取值与其它面向对象语言都不一样,是在运行时决定的。为了解决这一痛点,才会有箭头函数的this绑定特性。另外 React 中还有Class ComponentFunction Component的概念,什么时候应该用什么组件也是一件纠结的事情。代码优化方面,对Class Component进行预编译和压缩会比普通函数困难得多,而且还容易出问题。

Hooks可以在不引入 Class 的前提下,使用 React 的各种特性。

什么是 Hooks

Hooks are functions that let you “hook into” React state and lifecycle features from function components

上面是官方解释。从中可以看出 Hooks 是函数,有多个种类,每个 Hook 都为Function Component提供使用 React 状态和生命周期特性的通道。Hooks 不能在Class Component中使用。

React 提供了一些预定义好的 Hooks 供我们使用,下面我们来详细了解一下。

常用hooks

image.png

image.png

State Hook

先来看一个传统的Class Component:

class Example extends React.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 Hook 来改写会是这个样子:

import React, { useState } from 'react';

function Example() {
  // 定义一个 State 变量,变量值可以通过 setCount 来改变
  const [count, setCount] = useState(0);

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

可以看到useState的入参只有一个,就是 state 的初始值。这个初始值可以是一个数字、字符串或对象,甚至可以是一个函数。当入参是一个函数的时候,这个函数只会在这个组件初始渲染的时候执行:

const [state, setState] = useState(() => {
  const initialState = someExpensiveComputation(props);
  return initialState;
});
复制代码

useState的返回值是一个数组,数组的第一个元素是 state 当前的值,第二个元素是改变 state 的方法。这两个变量的命名不需要遵守什么约定,可以自由发挥。要注意的是如果 state 是一个对象,setState 的时候不会像Class Component的 setState 那样自动合并对象。要达到这种效果,可以这么做:

setState(prevState => {
  // Object.assign 也可以
  return {...prevState, ...updatedValues};
});
复制代码

从上面的代码可以看出,setState 的参数除了数字、字符串或对象,还可以是函数。当需要根据之前的状态来计算出当前状态值的时候,就需要传入函数了,这跟Class Component的 setState 有点像。

另外一个跟Class Component的 setState 很像的一点是,当新传入的值跟之前的值一样时(使用Object.is比较),不会触发更新。

  • 注1: 如果想要性能优化,比如初始时内部逻辑比较复杂,将useState初始值改成用函数返回的形式,这样只会在初始时解析一次,不会每次渲染重新解析
  • 注2: setState尽量也写成函数形式,写成对象形式有一些小小的缺陷
  • 注3: 如果state是一个对象,不能局部更新setState,因为setState不会帮我们合并属性
  • 注4: setState地址要变,如果setState地址不变,React会认为数据没有变化
  • 注5:
//你敢这么写,他就敢把age给你删掉
const [user,setUser] = useState({name:'Frank', age: 18})
const onClick = ()=>{
   setUser({
     name: 'Jack'
   })
}
//想要连续两次调用,一次加2,用函数写法,不能用对象写法
const [n, setN] = useState(0)
const onClick = ()=>{
  // setN(n+1)
  // setN(n+1) // 你会发现 n 不能加 2
  setN(i=>i+1)
  setN(i=>i+1)
}
复制代码

Effect Hook

解释这个 Hook 之前先理解下什么是副作用。网络请求、订阅某个模块或者 DOM 操作都是副作用的例子,Effect Hook 是专门用来处理副作用的。正常情况下,在Function Component的函数体中,是不建议写副作用代码的,否则容易出 bug。

下面的Class Component例子中,副作用代码写在了componentDidMountcomponentDidUpdate中:

class Example extends React.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>
    );
  }
}
复制代码

可以看到componentDidMountcomponentDidUpdate中的代码是一样的。而使用 Effect Hook 来改写就不会有这个问题:

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

function Example() {
  const [count, setCount] = useState(0);
      //这样写表示组件挂载和每次更新时调用
  useEffect(() => {
    document.title = `You clicked ${count} times`;
  });

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

useEffect会在每次 DOM 渲染后执行,不会阻塞页面渲染。它同时具备componentDidMountcomponentDidUpdatecomponentWillUnmount三个生命周期函数的执行时机。 image.png 此外还有一些副作用需要组件卸载的时候做一些额外的清理工作的,例如订阅某个功能:

class FriendStatus extends React.Component {
  constructor(props) {
    super(props);
    this.state = { isOnline: null };
    this.handleStatusChange = this.handleStatusChange.bind(this);
  }

  componentDidMount() {
    ChatAPI.subscribeToFriendStatus(this.props.friend.id, this.handleStatusChange);
  }

  componentWillUnmount() {
    ChatAPI.unsubscribeFromFriendStatus(this.props.friend.id,this.handleStatusChange);
  }

  handleStatusChange(status) {
    this.setState({
      isOnline: status.isOnline
    });
  }

  render() {
    if (this.state.isOnline === null) {
      return 'Loading...';
    }
    return this.state.isOnline ? 'Online' : 'Offline';
  }
}
复制代码

componentDidMount订阅后,需要在componentWillUnmount取消订阅。使用 Effect Hook 来改写会是这个样子:

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

function FriendStatus(props) {
  const [isOnline, setIsOnline] = useState(null);

  useEffect(() => {
    function handleStatusChange(status) {
      setIsOnline(status.isOnline);
    }

    ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
    
    // 返回一个函数来进行额外的清理工作:
    return function cleanup() {
      ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
    };
  });

  if (isOnline === null) {
    return 'Loading...';
  }
  return isOnline ? 'Online' : 'Offline';
}
复制代码

useEffect的返回值是一个函数的时候,React 会在下一次执行这个副作用之前执行一遍清理工作,整个组件的生命周期流程可以这么理解:

组件挂载 --> 执行副作用 --> 组件更新 --> 执行清理函数 --> 执行副作用 --> 组件更新 --> 执行清理函数 --> 组件卸载

上文提到useEffect会在每次渲染后执行,但有的情况下我们希望只有在 state 或 props 改变的情况下才执行。如果是Class Component,我们会这么做:

componentDidUpdate(prevProps, prevState) {
  if (prevState.count !== this.state.count) {
    document.title = `You clicked ${this.state.count} times`;
  }
}
复制代码

使用 Hook 的时候,我们只需要传入第二个参数:

useEffect(() => {
  document.title = `You clicked ${count} times`;
}, [count]); // 只有在 count 改变的时候才执行 Effect
复制代码

第二个参数是一个数组,可以传多个值,一般会将 Effect 用到的所有 props 和 state 都传进去。

当副作用只需要在组件挂载的时候和卸载的时候执行,第二个参数可以传一个空数组[],实现的效果有点类似componentDidMountcomponentWillUnmount的组合。

useLayoutEffect

useLayoutEffect的用法跟useEffect的用法是完全一样的,都可以执行副作用和清理操作。它们之间唯一的区别就是执行的时机。

useEffect不会阻塞浏览器的绘制任务,它在浏览器渲染完成,页面更新后才会执行。

useLayoutEffectcomponentDidMountcomponentDidUpdate的执行时机一样,会阻塞页面的渲染。如果在里面执行耗时任务的话,页面就会卡顿。

在绝大多数情况下,useEffectHook 是更好的选择。唯一例外的就是需要根据新的 UI 来进行 DOM 操作的场景。useLayoutEffect会保证在页面渲染前执行,也就是说页面渲染出来的是最终的效果。如果使用useEffect,页面很可能因为渲染了 2 次而出现抖动。

  • 注1:多个useEffect按序执行,但useLayoutEffect总会比useEffect先执行
  • 注2: useEffect会在页面渲染完成之后再执行,如下代码就会发生抖动,从0瞬间变为1000,但如果用useLayoutEffect,会在页面还未渲染时就执行,直接将1000渲染到页面上
import React, { useState, useEffect } from "react";

import ReactDOM from "react-dom";

import "./styles.css";

const BlinkyRender = () => {

const [value, setValue] = useState(0);

useEffect(() => {
      //页面挂载完成之后执行,依赖value,value变化重新执行
     document.querySelector('#x').innerText = `value: 1000`

}, [value]);

return (

     <div id="x" onClick={() => setValue(0)}>value: {value}</div>

);

};

ReactDOM.render(

   <BlinkyRender />,

   document.querySelector("#root")

);
复制代码

useContext

useContext可以很方便的去订阅 context 的改变,并在合适的时候重新渲染组件。我们先来熟悉下标准的 context API 用法:

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

class App extends React.Component {
  render() {
    return (
      <ThemeContext.Provider value="dark">
        <Toolbar />
      </ThemeContext.Provider>
    );
  }
}

// 中间层组件
function Toolbar(props) {
  return (
    <div>
      <ThemedButton />
    </div>
  );
}

class ThemedButton extends React.Component {
  // 通过定义静态属性 contextType 来订阅
  static contextType = ThemeContext;
  render() {
    return <Button theme={this.context} />;
  }
}
复制代码

除了定义静态属性的方式,还有另外一种针对Function Component的订阅方式:

function ThemedButton() {
    // 通过定义 Consumer 来订阅
    return (
        <ThemeContext.Consumer>
          {value => <Button theme={value} />}
        </ThemeContext.Consumer>
    );
}
复制代码

使用useContext来订阅,代码会是这个样子,没有额外的层级和奇怪的模式:

function ThemedButton() {
  const value = useContext(ThemeContext);
  return <Button theme={value} />;
}
复制代码

在需要订阅多个 context 的时候,就更能体现出useContext的优势。传统的实现方式:

function HeaderBar() {
  return (
    <CurrentUser.Consumer>
      {user =>
        <Notifications.Consumer>
          {notifications =>
            <header>
              Welcome back, {user.name}!
              You have {notifications.length} notifications.
            </header>
          }
      }
    </CurrentUser.Consumer>
  );
}
复制代码

useContext的实现方式更加简洁直观:

function HeaderBar() {
  const user = useContext(CurrentUser);
  const notifications = useContext(Notifications);

  return (
    <header>
      Welcome back, {user.name}!
      You have {notifications.length} notifications.
    </header>
  );
}
复制代码

实例:

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

const ThemeContext = React.createContext(themes.light);
  //themes.light初始默认值
function App() {
  return (
    <ThemeContext.Provider value={themes.dark}>
      <Toolbar />
    </ThemeContext.Provider>
  );
}

function Toolbar(props) {
  return (
    <div>
      <ThemedButton />
    </div>
  );
}
//接收传递过来的themes.dark
function ThemedButton() {
const theme = useContext(ThemeContext); 
return (   
  <button style={{ background: theme.background, color: theme.foreground }}>   
        I am styled by theme context!    
  </button>  );
}
复制代码

useReducer

useReducer的用法跟 Redux 非常相似,当 state 的计算逻辑比较复杂又或者需要根据以前的值来计算时,使用这个 Hook 比useState会更好。下面是一个例子:

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 Counter() {
  const [state, dispatch] = useReducer(reducer, initialState);
  return (
    <>
      Count: {state.count}
      <button onClick={() => dispatch({type: 'decrement'})}>-</button>
      <button onClick={() => dispatch({type: 'increment'})}>+</button>
    </>
  );
}
复制代码

useCallback / useMemo / React.memo

React.memo

    function App() {
        const [n, setN] = React.useState(0);
        const [m, setM] = React.useState(0);
        const onClick = () => {
        setN(n + 1);

    };
    return (
        <div className="App">
            <div>
                 <button onClick={onClick}>update n {n}</button>
            </div>
//点击button,状态n每次更新,Child组件也会被跟着更新,即使他所依赖的状态m没有变化
            {/* <Child data={m}/> */}
//点击button,状态n每次更新,Child2组件不会跟着更新,因为他所依赖的状态m没有变化            
            <Child2 data={m}/>
        </div>
     );
    }
    function Child(props) {
        console.log("child 执行了");
        console.log('假设这里有大量代码')
        return <div>child: {props.data}</div>;
    }
    //将Child组件用React.memo包裹,就可以实现组件依赖的状态不变,组件就不会更新
    const Child2 = React.memo(Child);
    const rootElement = document.getElementById("root");
    ReactDOM.render(<App />, rootElement);
复制代码

//注意,这玩意有个bug,给子组件添加了监听函数之后一秒破功,因为父组件变化导致app函数重新渲染,导致生成新的onClickChild,虽然功能一样,但地址不一样

import React from "react";
import ReactDOM from "react-dom";

function App() {
    const [n, setN] = React.useState(0);
    const [m, setM] = React.useState(0);
    const onClick = () => {
        setN(n + 1);
    };
    const onClickChild = () => {
        console.log(m);
    };
    return (
        <div className="App">
            <div>
                <button onClick={onClick}>update n {n}</button>
            </div>
            <Child2 data={m} onClick={onClickChild} />
            {/* Child2 居然又执行了 */}
        </div>
    );
}
function Child(props) {
    console.log("child 执行了");
    console.log("假设这里有大量代码");
    return <div onClick={props.onClick}>child: {props.data}</div>;

}
const Child2 = React.memo(Child);
const rootElement = document.getElementById("root");

ReactDOM.render(<App />, rootElement)
复制代码

而 React 给出的方案是useCallback Hook。在依赖不变的情况下,它会返回相同的引用,避免子组件进行无意义的重复渲染:

const memoizedCallback = useCallback( 
    () => { doSomething(a, b); }, [a, b], 
 );
复制代码
`useCallback(fn, deps)` 相当于 `useMemo(() => fn, deps)`复制代码

useCallback缓存的是方法的引用,而useMemo缓存的则是方法的返回值。使用场景是减少不必要的子组件渲染:

const memoizedValue = useMemo(() => () => { doSomething(a, b); x}, [a, b]);
复制代码
import React, { useMemo } from "react";
import ReactDOM from "react-dom";
import "./styles.css";

function App() {
    const [n, setN] = React.useState(0);
    const [m, setM] = React.useState(0);
    const onClick = () => {
        setN(n + 1);
    };
    const onClick2 = () => {
        setM(m + 1);
    };
    //useMemo实现函数的重用,接收一个函数,函数的返回值就是你要缓存的东西
    const onClickChild = useMemo(() => {
        return () => {
            console.log("on click child, m: " + m);
        };
    }, [m]); // 这里呃 [m] 改成 [n] 就会打印出旧的 m
    return (
        <div className="App">
            <div>
                <button onClick={onClick}>update n {n}</button>
                <button onClick={onClick2}>update m {m}</button>
            </div>
            <Child2 data={m} onClick={onClickChild} />
        </div>
    );
}

function Child(props) {
    console.log("child 执行了");
    console.log("假设这里有大量代码");
    return <div onClick={e => props.onClick(e.target)}>child: {props.data}</div>;
}

const Child2 = React.memo(Child);

const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);
复制代码
  • useCallBack 针对可能重新创建的函数进行优化,使得函数被缓存,React.memo 认定两次地址是相同就可以避免子组件冗余的更新。

  • useMemo 针对不必要的计算进行优化,避免了当前组件中一些的冗余计算操作。

image.png

useRef

useRef类似于React.createRef.

使用类组件

class App extends React.Component {
  refInput = React.createRef();
  componentDidMount() {
    this.refInput.current && this.refInput.current.focus();
  }
  render() {
    return <input ref={this.refInput} />;
  }
}
复制代码

使用函数组件

function App() {
  const refInput = React.useRef(null);
  React.useEffect(() => {
    refInput.current && refInput.current.focus();
  }, []);

  return <input ref={refInput} />;
}
复制代码

自定义 Hooks

还记得我们上一篇提到的 React 存在的问题吗?其中一点是:

带组件状态的逻辑很难重用

通过自定义 Hooks 就能解决这一难题。

继续以上面文章中订阅朋友状态的例子:

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

function FriendStatus(props) {
  const [isOnline, setIsOnline] = useState(null);

  useEffect(() => {
    function handleStatusChange(status) {
      setIsOnline(status.isOnline);
    }

    ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
    return () => {
      ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
    };
  });

  if (isOnline === null) {
    return 'Loading...';
  }
  return isOnline ? 'Online' : 'Offline';
}
复制代码

假设现在我有另一个组件有类似的逻辑,当朋友上线的时候展示为绿色。简单的复制粘贴虽然可以实现需求,但太不优雅:

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

function FriendListItem(props) {
  const [isOnline, setIsOnline] = useState(null);

  useEffect(() => {
    function handleStatusChange(status) {
      setIsOnline(status.isOnline);
    }

    ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
    return () => {
      ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
    };
  });

  return (
    <li style={{ color: isOnline ? 'green' : 'black' }}>
      {props.friend.name}
    </li>
  );
}
复制代码

这时我们就可以自定义一个 Hook 来封装订阅的逻辑:

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

function useFriendStatus(friendID) {
  const [isOnline, setIsOnline] = useState(null);

  useEffect(() => {
    function handleStatusChange(status) {
      setIsOnline(status.isOnline);
    }

    ChatAPI.subscribeToFriendStatus(friendID, handleStatusChange);
    return () => {
      ChatAPI.unsubscribeFromFriendStatus(friendID, handleStatusChange);
    };
  });

  return isOnline;
}
复制代码

自定义 Hook 的命名有讲究,必须以use开头,在里面可以调用其它的 Hook。入参和返回值都可以根据需要自定义,没有特殊的约定。使用也像普通的函数调用一样,Hook 里面其它的 Hook(如useEffect)会自动在合适的时候调用:

function FriendStatus(props) {
  const isOnline = useFriendStatus(props.friend.id);

  if (isOnline === null) {
    return 'Loading...';
  }
  return isOnline ? 'Online' : 'Offline';
}

function FriendListItem(props) {
  const isOnline = useFriendStatus(props.friend.id);

  return (
    <li style={{ color: isOnline ? 'green' : 'black' }}>
      {props.friend.name}
    </li>
  );
}
复制代码

自定义 Hook 其实就是一个普通的函数定义,以use开头来命名也只是为了方便静态代码检测,不以它开头也完全不影响使用。在此不得不佩服 React 团队的巧妙设计。

Hooks 使用规则

使用 Hooks 的时候必须遵守 2 条规则:

  • 只能在代码的第一层调用 Hooks,不能在循环、条件分支或者嵌套函数中调用 Hooks。
  • 只能在Function Component或者自定义 Hook 中调用 Hooks,不能在普通的 JS 函数中调用。

Hooks 的设计极度依赖其定义时候的顺序,如果在后序的 render 中 Hooks 的调用顺序发生变化,就会出现不可预知的问题。上面 2 条规则都是为了保证 Hooks 调用顺序的稳定性。为了贯彻这 2 条规则,React 提供一个 ESLint plugin 来做静态代码检测:eslint-plugin-react-hooks

Guess you like

Origin juejin.im/post/7032695751998177287