手写 React Hooks,助你掌握实现原理(全程无废话)

lxf2023-06-28 12:30:01

前言

本文适合有一定 react 基础,且学习过 hooks 的同学,通过手写实现 hooks,加深其实现原理

useState

语法回顾

先来回顾下 useState 的语法:

const [state, setState] = useState(initValue)

useState 接受一个初始值作为参数 initValue, 可以直接传值,也可以传入一个函数,并返回初始值,即:useState(() => initValue)

useState 返回一个数组,数组中第一个元素是我们需要使用的 state,第二个元素是用来更改 state 的回调函数 setState(newValue)

手写实现

实现思路:

  1. 因为我们会多次使用 useState 创建多个 state,所以为了区分谁是谁,创建一个全局 lastStates 数组来存放对应的 state
  2. 同时,为了方便后续更新 state,创建一个全局的索引 lastIndex 找到当前 state 在 lastStates 中的位置。
  3. 每次调用 useState 时,存值,返回数组。
  4. 调用 setState 时,通过 lastStates[lastIndex] 修改当前 state,并 Rerender。

代码如下:

let lastIndex = 0;   //记录当前下标
let LastStates = []; //记录 states

function useState(initValue) {
    //1、记录当前 state 的下标(这里使用了闭包的原理)
    const currentIndex = lastIndex;
    
    //2、记录初始值
    lastStates[currentIndex] = lastStates[currentIndex] ?? initValue;
    
    //3、setState 回调修改 state
    function setState(newValue) {
        lastStates[currentIndex] = newValue
        //修改后,重新render
        render()
    }
    
    //4、返回数组
    return [lastStatas[lastIndex++], setState]
}

注意:为什么最后返回的是 lastStates[lastIndex++], 而不是lastStates[currentIndex]?

因为在一个组件中,我们会多次调用 useState 创建不同的 state, 每一次 lastIndex++ 后,下一次再调用 useState 时,通过 currentIndex = lastIndex 记录最新的下标,通过闭包就能保证 setState 时状态更改是正确的。

useCallback

语法回顾

const memoizedCallback = useCallback(() => {
    doSomething()
},dependenices)

useCallback 接受一个 回调函数 和 依赖项数组作为参数,返回一个 记忆值函数,当依赖项发生改变的时候,对记忆值函数进行更新。

通过 useCallback 包裹的函数,可以避免非必要性的重新渲染,达到性能优化的目的。比如父组件传一个 函数props 给子组件,而父组件 state 发生改变时,子组件也会跟着 render,这时可以用该 hook 进行优化。

手写实现

实现思路:

  1. 创建全局的两个变量: lastCallback、lastDependencies,用于保存传入的回调函数和依赖项
  2. 执行 useCallback 时,判断是否传入了依赖项 dependencies
  • 若传入,则对比 dependencies 和 lastDependencies 中的每一个依赖项是否改变,若改变,则记录最新的 callback 和 依赖项,若没改变,返回记忆值函数 lastCallback
  • 若没传入,可以理解为第一次调用 useCallback,记录 callback 和 依赖项,返回 lastCallback

代码如下:

let lastCallback;
let lastDependencies;

function useCallback(callback, dependencies) {
    //1、判断是否传入了依赖项
    if (dependencies) {
        const isChange = !dependencies.every((item, index) => {
            return item === lastDependencies[index]
        })
        //如果依赖项改变
        if (isChange) {
            lastCallback = callback; //更新记忆值函数
            lastDependencies = dependencies;  //记录最新的依赖项
        }
    } else {
        //没传入依赖项,可以当成是第一次调用 useCallback
        lastCallback = callback;
        lastDependencies = dependencies;
    }
    
    //返回记忆值函数
    return lastCallback
}

useMemo

语法回顾

useMemo 和 useCallback 大同小异,只不过前者缓存结果,后者缓存函数。

const memoizedData= useMemo(() => {
    return result
},dependenices)

useMemo 接受一个 回调函数 和 依赖项数组作为参数,返回一个 记忆值结果,当依赖项发生改变的时候,重新计算并返回结果。

useMemo 底层原理

useMemo 会记录上一次执行计算的结果,并把它绑定在函数组件对应的 fiber 对象上,只要组件不销毁,缓存值就一直存在,但是 deps 中如果有一项改变,就会重新计算结果,将其作为新的值记录到 fiber 对象上。

手写实现

useMemo 和 useCallback 类似,直接上代码:

let lastResult;
let lastDependencies;

function useCallback(callback, dependencies) {
    //1、判断是否传入了依赖项
    if (dependencies) {
        const isChange = !dependencies.every((item, index) => {
            return item === lastDependencies[index]
        })
        //如果依赖项改变
        if (isChange) {
            lastResult = callback(); //更新记忆值
            lastDependencies = dependencies;  //记录最新的依赖项
        }
    } else {
        //没传入依赖项,可以当成是第一次调用 useMemo
        lastResult = callback();
        lastDependencies = dependencies;
    }
    
    //返回记忆值函数
    return lastResult
}

useReducer

语法回顾

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

useReducer接收两个参数:

第一个参数:reducer函数,第二个参数:初始化的state。 返回值为最新的state和dispatch函数(dispatch 用来触发reducer函数,计算对应的state)。

按照官方的说法:对于复杂的state操作逻辑,嵌套的state的对象,推荐使用useReducer。

我们可以先来看一个例子

// 官方 useReducer Demo
// 第一个参数:应用的初始化
const initialState = {count: 0};

// 第二个参数:state的reducer处理函数
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() {
    // 返回值:最新的state 和 dispatch函数
    const [state, dispatch] = useReducer(reducer, initialState);
    return (
        <>
            // useReducer会根据dispatch的action,返回最终的state,并触发rerender
            Count: {state.count}
            // dispatch 用来接收一个 action参数「reducer中的action」,用来触发reducer函数,更新最新的状态
            <button onClick={() => dispatch({type: 'increment'})}>+</button>
            <button onClick={() => dispatch({type: 'decrement'})}>-</button>
        </>
    );
}

手写实现

实现思路:

  1. 与 useState 类似,创建全局的两个变量:
    • lastIndex:当前 state 的下标。
    • lastStates:存储 state 的数组。
  2. 执行 useReducer 时,记录初始值。
  3. 定义 dispatch(reducer, action) 函数。
  4. 返回数组 [当前的 state, dispatch]。

代码如下:

let lastIndex = 0;
let lastStates = [];

function useReducer(reducer, initState) {
    //1、记录当前 state 下标
    const currentIndex = lastIndex;
    //2、记录初始值
    lastStates[currentIndex] = lastStates[currentIndex] ?? initState;
    
    //3、定义 dispatch
    function dispatch(action) {
        // reducer 处理 state,返回处理后的结果
        lastStates[currentIndex] = reducer(lastStates[currentIndex], action);
        // 重新渲染
        render();
    }
    
    return [lastStates[lastIndex++], dispatch]
}

useContext

语法回顾

const MyContext = React.createContext()

createContext 能够创建一个 React 的 上下文(context),然后订阅了这个上下文的组件中,可以拿到上下文中提供的数据或者其他信息。

如果要使用创建的上下文,需要通过 Context.Provider 最外层包装组件,并且需要显示的通过 <MyContext.Provider value={{xx:xx}}> 的方式传入 value,指定 context 要对外暴露的信息。

子组件在匹配过程中只会匹配最新的 Provider,也就是说如果有下面三个组件:ContextA.Provider->A->ContexB.Provider->B->C, 如果 ContextA 和 ContextB 提供了相同的方法,则 C 组件只会选择 ContextB 提供的方法。

通过 React.createContext 创建出来的上下文,在子组件中可以通过 useContext 这个 Hook 获取 Provider 提供的内容,即:

const {funcName} = useContext(MyContext);

从上面代码可以发现,useContext 需要将 MyContext 这个 Context 实例传入,不是字符串,就是实例本身。

这种用法会存在一个比较尴尬的地方,父子组件不在一个目录中,如何共享 MyContext 这个 Context 实例呢?

一般这种情况下,我会通过 Context Manager 统一管理上下文的实例,然后通过 export 将实例导出,在子组件中在将实例 import 进来。

下面我们看看代码,使用起来非常简单

import React, { useState, useContext } from 'react';
import ReactDOM from 'react-dom';
let AppContext = React.createContext()
function Counter() {
    let { state, setState } = useContext(AppContext)
    return (
        <>
            Count: {state.count}

            <button onClick={() => setState({ number: state.number + 1 })}>+</button>
        </>
    );
}
function App() {
    let [state, setState] = useState({ number: 0 })
    return (
        <AppContext.Provider value={{ state, setState }}>
            <div>
                <h1>{state.number}</h1>
                <Counter></Counter>
            </div>
        </AppContext.Provider>
    )
}
function render() {
    ReactDOM.render(
        <App />,
        document.getElementById('root')
    );
}
render()

要是用过vue的同学,会发现,这个机制有点类似vue 中提供的provide和inject

手写实现

原理非常简单,由于createContext,Provider 不是ReactHook的内容, 所以这里只需要实现 useContext,如代码所示,只需要一行代码

import React, { useState } from 'react';
import ReactDOM from 'react-dom';
let AppContext = React.createContext()

// useContext 实现
function useContext(context){
    return context._currentValue
}

function Counter() {
    let { state, setState } = useContext(AppContext)
    return (
        <>
            <button onClick={() => setState({ number: state.number + 1 })}>+</button>
        </>
    );
}
function App() {
    let [state, setState] = useState({ number: 0 })
    return (
        <AppContext.Provider value={{ state, setState }}>
            <div>
                <h1>{state.number}</h1>
                <Counter></Counter>
            </div>
        </AppContext.Provider>
    )
}
function render() {
    ReactDOM.render(
        <App />,
        document.getElementById('root')
    );
}
render()

useEffect

语法回顾

useEffect(() => {
    doSomething()
    
    return () => {
        //清除副作用,如取消事件订阅、清除定时器
    }
}, dependencies);

useEffect 可以理解为函数组件的生命周期函数,看作类组件 componentDidMoun、componentDidUpdate 和 componentWillUnmount 这三个生命周期的组合。

  • 当 dependencies 为 [] 时,useEffect 相当于 componentDidMount,只会在组件初次挂在完成后执行,后续改变 state 时,此时 useEffect 不会再次执行。

  • 当 dependencies 不传时,useEffect 相当于 componentDidUpdate, 会在组件初次渲染以及 state 更新时执行

  • 当 useEffect 返回一个清除函数(cleanHandler)时,React 将会在执行清除操作时调用它,比如组件销毁前。

  • 当 dependencies 不是空数组时,只有当依赖发生改变时, useEffect才会重新执行。

实现思路

useEffect 的思路和 useMemo、useCallback类似,都会去判断依赖是否发生改变,并记录新依赖,区别是 useEffect 没有返回值,而 useMemo、useCallback 有返回值。

代码如下:

import React, { useState} from 'react';
import ReactDOM from 'react-dom';

let lastEffectDependencies  //记录依赖
function useEffect(callback,dependencies){
    //是否已存在依赖,若存在,判断依赖是否改变
    if(lastEffectDependencies){
        let changed = !dependencies.every((item,index)=>{
            return item === lastEffectDependencies[index]
        })
        //若依赖改变,重新执行 callback,记录新的依赖
        if(changed){
            setTimeout(callback())
            lastEffectDependencies = dependencies
        }
    }else{ 
        //若不存在,则调用 callback,记录依赖
        setTimeout(callback())
        lastEffectDependencies = dependencies
    }
}


function App() {
    let [number, setNumber] = useState(0)
    useEffect(()=>{
        console.log(number);
    },[number])
    return (
        <div>
            <h1>{number}</h1>
            <button onClick={() => setNumber(number+1)}>+</button>
        </div>
    )
}
function render() {
    ReactDOM.render(
        <App />,
        document.getElementById('root')
    );
}
render()

注意:为什么执行 callback 时要加一个 setTimeout ?

因为实际上 callback 是在 浏览器渲染结束后执行 的。所以我们要加 setTimeout。

useLayoutEffect

useEffect 和 useLayoutEffect 两个 hook 十分类似。

原理

这两个hook基本相同,只是调用时机不同

而官方也明确说明:请全部使用useEffect,除非遇到bug或者不可解决的问题,再考虑使用useLayoutEffect

上面说到 useEffect 的调用时机是 浏览器渲染结束后执行 的,而 useLayoutEffect 是在 DOM构建完成,浏览器渲染前执行 的。

所以这里需要把宏任务setTimeout改成微任务

import React, { useState} from 'react';
import ReactDOM from 'react-dom';


let lastEffectDependencies  //记录依赖
function useLayouyEffect(callback,dependencies){
    //是否已存在依赖,若存在,判断依赖是否改变
    if(lastEffectDependencies){
        let changed = !dependencies.every((item,index)=>{
            return item === lastEffectDependencies[index]
        })
        //若依赖改变,重新执行 callback,记录新的依赖
        if(changed){
            Promise.resolve().then(callback())  //微任务模拟
            lastEffectDependencies = dependencies
        }
    }else{ 
        //若不存在,则调用 callback,记录依赖
        Promise.resolve().then(callback())  //微任务模拟
        lastEffectDependencies = dependencies
    }
}


function App() {
    let [number, setNumber] = useState(0)
    useLayouyEffect(()=>{
        console.log(number);
    },[number])
    return (

        <div>
            <h1>{number}</h1>
            <button onClick={() => setNumber(number+1)}>+</button>
        </div>
    )
}
function render() {
    ReactDOM.render(
        <App />,
        document.getElementById('root')
    );
}
render()

结语

以上内容如有错误,欢迎留言指出,一起进步