(笔记-新鲜又营养)React Hooks 15 min

1,401 阅读16分钟

React hooks

对于reacthooks的学习可以从三个方面展开:

  • react hooks介绍
  • react hooks使用
  • react hooks封装
  • react hooks原理

react hooks 的本质作用是为了对 react 的函数式组件进行增强的,让函数式组件具有了存储状态的功能,同时具备了处理副作用(指发送网络请求或者进行 DOM 操作)的能力。或者说,让函数式组件拥有了部分类组件的部分功能

hook 相比于类组件的优势

  • 缺少逻辑复用的机制,只能通过 HOC 等复杂代码实现相同的逻辑复用,这会导致代码嵌套层数深导致代码臃肿,难以调试。
  • 类组件难以维护,体现在将一组相干的业务逻辑拆分到不同的生命周期函数中去,这也造成了在同一个生命周期函数中维护了多个不相干的业务逻辑代码。 -- 而在函数式组件中引入的 useEffect 钩子函数则完美的解决了这个问题
  • 类组件中还具有特殊的 this 的指向问题,而保证 this 的指向问题需要花费更多的代码。这同时会造成代码更加复杂,难以维护。

以上三点,其实就是使用 react hooks 的原因所在了。(复用难度大、业务代码不聚合、特殊的 this 问题)

react hooks定义以及常见的hooks

react hooks 本质上是一堆钩子函数,通过这些钩子函数完成了对函数式组件的增强,内置的钩子有:

  • useState // 使用闭包完成对状态的保存
  • useEffects
  • useReducer
  • useRef
  • useCallback
  • useContext
  • useMemo

1. useState

这段代码是一个 React 组件的实现,使用了 React Hooks 中的 useState. 以下是代码的内容:

import React, { useState } from 'react';

function App () {
  const [count, setCount] = useState(0);
  return (
    <div>
      <span>{count}</span>
      <button onClick={() => setCount(count + 1)}>+1</button>
    </div>
  );
}

此代码定义了一个名为 App 的函数组件,该组件内部有一个状态 count,它是通过 useState 钩子初始化为0的。组件返回一个 div 包含一个显示计数的 span 和一个按钮。当按钮被点击时,按钮的 onClick 事件处理函数会调用 setCount 以将 count 的值增加 1。

useState的使用特点:

  • 接受唯一的任意类型的参数作为初始值
  • 返回值为数组,数组的第二个元素以set开头
  • 此方法可以被调用多次
  • 参数可以是一个函数,函数的执行结果会被作为初始值,并且此函数【只会执行一次】,这个特点很容易被忽略掉!在初始值为动态值的时候非常好用!

针对第四点,有一个代码举例:

  • 不好的实践
const propsCount = props.count || 0;
const [ count, setCount ] = useState(propsCount);
  • 好的实践
const [ count, setCount ] = useState(()=> (props.count || 0));

第一种写法会造成每次重新渲染的时候导致第一句会无意义的执行。

关于useState的使用,还有两点注意:

  • 设置状态值方法的参数可以是一个值也可以是一个函数
  • 设置状态值方法的方法本身是异步的

举个例子,下面的两种做法都是可以的:

setCount(count+1);

setCount(count=>count+1); // 形参表示更新之前的值

关于上面的第二点的验证可以使用改变document.title的方式:

document.title = count

改成同步的可以写成:

setCount(count=>(doucment.title = count+1, count+1))

使用 useState 和使用 setState 的时候都是可以传递一个函数进去的,他们两个在这一点上是高度统一的。

2. useReducer

useReducer 钩子函数提供了另外一种让函数式组件保存状态方式。那么 useReducer 相对于 useState 的优点在于什么呢?使用 useReducer 可以对同一个数据进行多个既定 Type 类型的操作。

import React, { useReducer } from 'react';

function App (){
    function reducer(state, action){
        switch(action.type){
            case 'increment':
                return state + 1;
            break;
            case 'decrement':
                return state - 1;
            break;
        }
    }
    
    const [count, dispatch] = useReducer(reducer, 0);
    
    return (
        <div>
            <button onClick={()=>dispatch({type:'decrement'})}>-1</button>
            <span>{count}</span>
            <button onClick={()=>dispatch({type:'increment'})}>+1</button>
        </div>
    )
}

export default App;

这不就是一个大号的 useState 吗?其优势可能在于可以处理 复杂数据结构,而正是由于处理对象是复杂结构体,因此才需要一定的设计模式和规范,包括将其设计成无状态的。

3. useContext -- 跨组件层级获取数据的时候简化获取数据的代码

即外层组件中的数据不必通过透传的方式逐层传递到深层的子组件中去,子组件通过其他渠道也能够获取外层组件中的数据。

import react, {createContext} from 'react';
const countContext = createContext();
function App () {
    return <countContext.Provider value={100} ><Foo /></countContext.Provider>
}

function Foo () {
    return <countContext.Consumer>{value=><div>{value}</div>}</countContext.Consumer>
}

或者,不想使用 Consumer 组件 + 函数 这种形式的话,可以从 useContext 这个狗子中取值:

function Foo () {
    const value = useContext(countContext);
    return <div>{value}</div>
}

上述例子的代码组织方式不够清晰,实际过程中,我们通过初始化 countContext 然后将 Provider 和 Consumer 暴露出去,也可以将 useContext(countContext) 封装成 () => useContext(countContext) 暴露出去,在使用 Provider 的时候向其中注入初始 value.

4. useEffect -- 让函数式组件具有处理副作用的功能,类似于生命周期函数

    1. useEffect 执行时机

可以把 useEffect 看做 componentDidMount, componentDidUpdatecomponentWillUnmount 这三个函数的组合。即,两头 mount 中间 update.

useEffect(() => {})           // => componentDidMount, componentDidUpdate
useEffect(() => {}, [])       // => componentDidMount
useEffect(() => {}, [**])       // => componentDidMount Conditional
useEffect(() => () => {})     // => componentWillUnmount 一个钩子完成分散的两个生命周期函数中的逻辑

举一个简单的例子:

import React, { useEffect } from "react";

function App() {
  function onScroll() {
    console.log('页面发生滚动了');
  }

  useEffect(() => {
    window.addEventListener('scroll', onScroll);
    return () => {
      window.removeEventListener('scroll', onScroll);
    };
  }, []);

  return <div>App works</div>;
}

export default App;

一个隐晦的点:

const [count, setCount] = useState(0);

useEffect(() => {
  const timerId = setInterval(() => {
    setCount(() => count + 1);
  }, 1000);

  return () => {
    clearInterval(timerId);
  };
}, []);

上述代码无法完成累加效果,原因在于:

  • useEffect的依赖数组[]是空的,所以这个useEffect只会在组件的挂载时运行一次。这意味着,计时器设置的时候,count状态的引用值将始终是初次渲染时的状态,即0。结果是,setCount(() => count + 1);这行代码每次执行时,都是将01,而不是累加。
  • 实际上上述代码在编辑器中就是会提醒报错的。

因此需要修改为下面的形式:

const [count, setCount] = useState(0);

useEffect(() => {
  const timerId = setInterval(() => {
    console.log('我真的执行了');
    setCount(count + 1);
  }, 1000);

  return () => {
    clearInterval(timerId);
  };
}, [count]); // 不推荐

或者,

useEffect(() => {
  const timerId = setInterval(() => {
    setCount(prevCount => prevCount + 1);
  }, 1000);

  return () => {
    clearInterval(timerId);
  };
}, []);

总结一下:不是 setInterval 不执行,而是获取了缓存值,导致累加的计算逻辑错误。两种解决方案都是提供了能够获取正确的 count 当前值,所以才解决了问题。

tip: 手动卸载组件:ReactDOM.unmountComponentAtNode(document.getElementById('root')) tip: 使用 document.title 的方式调试组件是不是真的更新了,这种方式比使用 console.log 要好得多。 tip: setState 如果选择函数作为入参,那么通过参数可以获取当前值,这在某些场景下是非常重要和方便的

5. useEffect 和异步函数 -- 很重要!!

需要注意的一点就是 useEffect 的入参函数的返回值只能是一个 function,因此不可以将此入参函数变成异步函数,也就是说,下面的写法是错误的!

useEffect(async () => {}, [])

正确的做法应该为:

useEffect(()=>{
    (async () => {
        await axios.get()
    })()
},[])

6. useMemo -- 计算属性

机制为:监听某个数据是否发生了变化,如果发生了变化就根据变化之后的值重新计算新值,这有利于避免昂贵的重复计算,类似于 vue 的 computed。

import {useMemo} form 'react';

const result = useMemo(()=>{
    let _a;
    // compute result basing with result
    return _a;
}, [count])

7. memo 方法 -- 注意它和 useMemo 没有什么关系

机制:性能优化,如果本组件中的数据没有发生变化,就会阻止其更新,类似于类组件中的 PureComponentshouldComponentUpdate

其基本的形式可以为:

import React, { memo } from 'react';

function Counter () {
    return <div></div>;
}

export default memo(Counter);

通过一个场景说明 memo 的作用:

假设Counter组件作为了App组件的子组件,那么如果export default出去的是Counter而不是memo(Counter),那么随着App组件的刷新,Counter组件会无条件的刷新;但是App刷新,Counter就要刷新这个事实虽然是React组件更新的机制但是很多情况下是不必要的,特别是Counter组件中的数据并没有发生更新的时候,因此,子啊Counter组件导出的时候在其前面加上memo()这个壳就可以实现App刷新的时候Counter不会更新,这样就可以提高一些性能了。

多说一句,此时Counter组件的刷新可以从两个途径实现:1. Counter内部状态改变,比如说其内部的setState的调用; 2. 即调用的时候的props数据发生了变化也会导致Counter的刷新。

总结一下,memo防止的实际上是“被动刷新”,而不是数据驱动的刷新。

8. useCallback -- 缓存函数,重新渲染的时候能够获取相同的函数实例

这里必须要澄清一下,为什么需要保证相同的函数示例。实际上,在 js 中,创建一个函数的消耗是非常小的,基本上可以忽略不计。所以使用 useCallback 保证组件在渲染前后其中的函数实例的一致性并不是使用 useCallback 的考量。

真正需要用到 useCallback 的地方在于:如果父组件中创建的函数示例 cb 需要传递给子组件,那么对于子组件来说,从 props 对象中接受的此属性将会引起子组件的重新渲染。即便是子组件使用memo包裹也是没有用的,这不是 memo 所解决的被动渲染的问题,而是传递到子组件的入参发生变化(一定会)引起子组件的刷新

出于这样的考量,在将父组件中的函数传递给子组件的时候,使用 useCallback 保证此传递函数不会每次都随着父组件的更新而重新序列化,可以在很大程度上保证子组件避免没有必要的重新渲染。

import React, { useCallback } from 'react';

function Counter() {
  const [count, setCount] = useState(0);
  const resetCount = useCallback(() => setCount(0), [setCount]);
  
  return <div>
    <span>{count}</span>
    <button onClick={() => setCount(count + 1)}>+1</button>
    <Test resetCount={resetCount}/>
  </div>
}

useCallback 的延申。当我们将一个不是频繁变动属性值的 obj 传递给子组件的时候,考虑下面两种方式进行改进:1. 使用 useCallback 包裹原对象的工厂函数;2. 使用 useRef 并手动管理属性值的更新。

9. useRef -- 用来操作 DOM 的利器或保存数据 -- 在组件重新渲染的时候保持一部分数据

Attention

  • 用来操作 DOM 的利器(推荐方式)或保存数据(定时器 id)

useRef 保存的数据和 useState 保存的数据的机制是不同的。使用 useState 保存的数据在发生变化的时候会引起组件的重新渲染,而使用 useRef 保存的数据在发生变化的时候不会引起变化。这一点也可以反过来理解,使用 useRef 保存的数据是跨组件渲染的,也就是说组件的渲染不会引起 useRef 中的数据。

那么 useRef 通常存储的都是一些什么样的数据呢?一般来说通过 useRef 保存一些辅助数据是比较合适的。比如说用来保存定时器的返回值就非常的合适。

不使用 useRef 的时候无法完成清除定时器的任务:

function App() {
  const [count, setCount] = useState(0);
  let timerId = null;

  useEffect(() => {
    timerId = setInterval(() => {
      setCount(count => count + 1);
    }, 1000)
  }, [])

  const stopCount = () => {
    clearInterval(timerId)
  }

  return <div>
    {count}
    <button onClick={stopCount}>停止</button>
  </div>;
}

export default App;

使用 useRef 之后就能够成功的清除定时器了:

function App() {
  const [count, setCount] = useState(0);
  let timerId = React.useRef();

  useEffect(() => {
    timerId.current = setInterval(() => {
      setCount(count => count + 1);
    }, 1000)
  }, [])

  const stopCount = () => {
    clearInterval(timerId.current)
  }

  return <div>
    {count}
    <button onClick={stopCount}>停止</button>
  </div>;
}

export default App;

自定义 hooks -- 结合业务逻辑和现有 hook

为什么需要自定义hooks?

  • 使用 hook 的形式封装和共享逻辑是标准模式

自定义 hooks 的本质

  • 其本质就是自定义的逻辑和内置hooks的有机结合

形式上的要求

  • 和自定义 hooks 相同,自定义的 hooks 也要求以 use 开头

步骤

    1. 完成业务需求
    1. 抽取公共部分到公共hooks库然后引入

1. 封装一个获取文章数据的hook

step1: 常规写法

import axios from 'axios';

function App() {
  const [post, setPost] = useState({});
  useEffect(() => {
    axios.get('https://jsonplaceholder.typicode.com/posts/1')
      .then(response => setPost(response.data));
  }, [])

  return <div>
    <div>{post.title}</div>
    <div>{post.body}</div>
  </div>;
}

export default App;

step2: 封装 hook

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

function useGetPost() {
  const [post, setPost] = useState({});
  useEffect(() => {
    axios.get('https://jsonplaceholder.typicode.com/posts/1')
      .then(response => setPost(response.data));
  }, []);

  return [post, setPost];
}

function App() {
  const [post, setPost] = useGetPost();

  return <div>
    <div>{post.title}</div>
    <div>{post.body}</div>
  </div>;
}

export default App;

2. 封装一个表单提交的 hook

import React, { useState } from 'react';

function useUpdateInput(initialValue) {
  const [value, setValue] = useState(initialValue);
  
  const onChange = event => setValue(event.target.value);
  
  return {
    value,
    onChange
  };
}

function App() {
  const usernameInput = useUpdateInput('');
  const passwordInput = useUpdateInput('');
  
  const submitForm = event => {
    event.preventDefault();
    console.log(usernameInput.value);
    console.log(passwordInput.value);
  };
  
  return (
    <form onSubmit={submitForm}>
      <input type="text" name="username" {...usernameInput} />
      <input type="password" name="password" {...passwordInput} />
      <input type="submit" />
    </form>
  );
}

export default App;

虽然封装的思路很巧妙,但是不得不说在很多情况下都是忘了 event.preventDefault() 的。

Attention

  • 获取网络请求
  • 封装表单元素操作

至少现在有两个封装 hook 的思路了。

路由相关的hooks -- 这是薄弱点,一定注意

所谓的路由 hooks ,其实指的就是react-router-dom中提供的一些hooks:useHistory useLocation useRouterMatch useParams, 一共是四个。

  • useHistory
  • useLocation
  • useRouterMatch
  • useParams
  • 这些信息本来都是可以通过 this.props 中拿到的,但是使用这些钩子条理会更加的清晰。

路由导航的设置:

import React from "react";
import { Link, Route } from "react-router-dom";
import Home from "./pages/Home";
import List from "./pages/List";

function App() {
  return (
    <>
      <div>
        <Link to="/home/zhangsan">首页</Link>
        <Link to="/list">列表页</Link>
      </div>
      <div>
        <Route path="/home/:name" component={Home} />
        <Route path="/list" component={List} />
      </div>
    </>
  );
}

export default App;

在路由子组件中使用这四个钩子函数:

import React from "react";
import { useHistory, useLocation, useRouteMatch, useParams } from "react-router-dom";

export default function Home(props) {
  console.log(props);
  console.log(useHistory());
  console.log(useLocation());
  console.log(useRouteMatch());
  console.log(useParams());
  
  return <div>Home Works</div>;
}

补充 forwardRef 和 useImperativeHandle

useImperativeHandle 的作用就是在 forwardRef 包裹的组件的 ref 上挂载方法的。

import React, { useRef, useImperativeHandle, forwardRef } from 'react';

const FancyInput = forwardRef((props, ref) => {
  const inputRef = useRef();
  useImperativeHandle(ref, () => ({
    focus: () => {
      inputRef.current.focus();
    }
  }));

  return <input ref={inputRef} />;
});

function Parent() {
  const inputRef = useRef();
  
  return (
    <>
      <FancyInput ref={inputRef} />
      <button onClick={() => inputRef.current.focus()}>
        聚焦到输入框
      </button>
    </>
  );
}

hooks的原理

1. 实现useState钩子函数的原理

如果要手写 setState 那么就需要写从其使用方法入手,然后逐步的实现其共呢个,每一种功能的实现都可以看成是一个节点,所有节点都完成之后就完成了整个手写过程。

其原理大概可以表示成为:

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

let state = []; // 用来保存状态的数组
let setters = []; // 用来保存设置状态函数的数组
let stateIndex = 0; // 表示当前状态索引的变量

function createSetter(index) {
  return function(newState) {
    state[index] = newState;
    render();
  };
}

function useState(initialState) {
  state[stateIndex] = state[stateIndex] ? state[stateIndex] : initialState;
  setters.push(createSetter(stateIndex));
  let setter = setters[stateIndex];
  let value = state[stateIndex];
  stateIndex++; // 遍历到下一个状态
  return [value, setter];
}

function render() {
  stateIndex = 0; // 重置索引,这样可以保证在重新渲染时从第一个状态开始
  ReactDOM.render(<App />, document.getElementById('root')); // 渲染组件
}

// App组件示例,可以在这里使用我们自定义的useState钩子
function App() {
  // 使用自定义的useState钩子
  const [count, setCount] = useState(0);
  const [text, setText] = useState('hello');

  return (
    <div>
      <button onClick={() => setCount(count + 1)}>Count: {count}</button>
      <input value={text} onChange={e => setText(e.target.value)} />
    </div>
  );
}

export default App;

下面是详细的构建过程:出入参相同 -> state 保持 -> 能够多次调用

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


// step 1 -- 基本结构及重新渲染
{
    const useState = initialState => {
        let state = initialState;

        function setState (newState) {
            state = newState;
            render();
        }
        return [state, setState];
    }

    function render () {
        ReactDOM.render(<App />, document.getElementById('root'));
    }
}

// step 2 -- state 保持(通过闭包)
{
    let state = null;
    const useState = initialState => {
        state ??= initialState;

        function setState (newState) {
            state = newState;
            render();
        }
        return [state, setState];
    }

    function render () {
        ReactDOM.render(<App />, document.getElementById('root'));
    }
}

// step 3 -- 多次调用(state 串台问题 - 一个变量存储多个状态问题)
// 误区:无法确定一个组件中会使用多少次 useState 所有不能手动声明,我们采用动态数组的方式
{
    let states = [];
    let setters = [];
    let stateIndex = 0;

    function useState (initialState) {
        states[stateIndex] ??= initialState;
        setters.push(setterFactory(stateIndex));
        const state = states[stateIndex];
        const setState = setters[stateIndex];
        stateIndex++;

        return [state, setState];
    }

    /**
     * 通过闭包保存当前序列号
     * @param {*} index 
     * @returns 
     */
    function setterFactory (index) { // 这里的闭包也很亮眼
        return function (newState) {
            states[index] = newState;
            render();
        }
    }

    function render () {
        stateIndex = 0; // 这里的归 0 极其重要
        ReactDOM.render(<App />, document.getElementById('root'));
    }
}

其实现过程就是三个变量、三个函数。

2. 实现useEffect钩子函数的原理

useEffect的作用原理大概可以通过下面的代码简要说明:

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

let prevDepsAry = []; // 存放依赖项数组的上一次值
let effectIndex = 0; // 当前副作用索引

function useEffect(callback, depsAry) {
  // 检查callback是否为函数
  if (Object.prototype.toString.call(callback) !== '[object Function]') {
    throw new Error('useEffect的第一个参数必须是一个函数');
  }

  // 如果没有依赖项数组,则每次渲染都调用callback
  if (typeof depsAry === 'undefined') {
    callback();
  } else {
    // 检查depsAry是否为数组
    if (Object.prototype.toString.call(depsAry) !== '[object Array]') {
      throw new Error('useEffect的第二个参数必须是数组');
    }

    // 获取上一次的依赖项数组值
    let prevDeps = prevDepsAry[effectIndex];

    // 判断依赖项数组是否发生了变化
    let hasChanged = prevDeps ? !depsAry.every((dep, index) => dep === prevDeps[index]) : true;

    // 如果依赖项发生变化,调用callback
    if (hasChanged) {
      callback();
    }

    // 存储当前的依赖项数组值,供下一次渲染时使用
    prevDepsAry[effectIndex] = depsAry;
  }

  // 增加索引,以供下一个副作用使用
  effectIndex++;
}

function render() {
  // 渲染函数开始时,重置副作用索引
  effectIndex = 0;
  // 渲染应用
  ReactDOM.render(<App />, document.getElementById('root'));
}

// App组件示例,这里可以使用自定义的useState和useEffect
function App() {
  // 试验性地模拟一些hooks
  // ...

  useEffect(() => {
    console.log('副作用函数执行了');
    // 这里可以添加一些副作用逻辑,例如API请求,订阅事件等

    // 有依赖项的情况下,只有在依赖项发生变化时才执行
  }, [/* 依赖项数组 */]);

  return (
    // 组件内容
    <div></div>
  );
}

render(); // 首次渲染

比较简单,不要想太多了。下面是详细过程:

// step 1 -- 形式上处理出入参数
{
    let prevDepsAry = [];

    function useEffect(callback, depsAry) {
        if(Object.prototype.toString.call(callback) !== '[object Function]')
            throw new Error('第一个参数必须是函数');

        if( typeof depsAry === 'undefined') {
            callback();
        } else {
            if(Object.prototype.toString.call(depsAry) !== '[object Array]')
                throw new Error('第二个参数必须是数组');
            let hasChanged = depsAry.every((dep, index) => {
                return dep === prevDepsAry[index];
            }) === false;

            if (hasChanged) {
                callback();
            }

            prevDepsAry = depsAry;
        }
    }
}

// step 2 -- 处理多次调用
// 解决方案和 setState 的实现是非常相同的,使用 二维数组 和 index 闭包即可。
{
    let prevDepsAry = [];
    let effectIndex = 0;

    function useEffect(callback, depsAry) {
        if(Object.prototype.toString.call(callback) !== '[object Function]')
            throw new Error('第一个参数必须是函数');

        if( typeof depsAry === 'undefined') {
            callback();
        } else {
            if(Object.prototype.toString.call(depsAry) !== '[object Array]')
                throw new Error('第二个参数必须是数组');

            let prevDeps = prevDepsAry[effectIndex];

            // 如果是第一次执行则 hasChanged 必为 true
            let hasChanged = prevDeps ? depsAry.every((dep, index) => {
                return dep === prevDeps[index];
            }) === false : true;

            if (hasChanged) {
                callback();
            }

            prevDepsAry[effectIndex] = depsAry;
        }

        effectIndex++;
    }
}

3. 实现useReducer钩子函数的原理 -- 不是 redux 但是借用了其思想

useReducer 钩子函数本质上实际是对 useState 的 setState 部分的增强

function useReducer = (reducer, initialValue) => {
    const [state, setState] = useState(initialValue);
    return [state, action => setState(reducer(state, action))]; // action => setState(action) --> action => setState(reducer(state, action))
}

总之一句话就是劫持 useState 并加强之。注意入参多了一个,步骤多了一个。多想多练,一定能掌握。

总结

useState 和 useEffect 钩子函数的实现原理本质上是在合适的时机调用 ReactDOM.render 这个函数进行更新。而 useReducer 钩子函数的本质是对 useState 钩子函数的增强。