动手实现(一):实现 简易 useState

177 阅读6分钟

实现目标

以下是一个useState的简单使用demo。两个按钮点击触发setNumber函数,修改number值。然后组件render,重新计算number的新值,用于展示。

export const PageDemo = () => {
  const [number, setNumber] = useState(0)

  return (
    <div>
      <div>{number}</div>
      <button onClick={() => setNumber(number + 1)}>+</button>
      <button onClick={() => setNumber(number - 1)}>-</button>
    </div>
  )
}

代数效应

在实现我们自己的useState之前,我们需要了解一个思想 —— 代数效应

函数中的副作用

假设,我们有一个获取用户存款的函数getUserMoney,还有一个获取多个用户存款的函数getTotalMoney。代码如下:

    // 同步
    function getUserMoney(name) {
      const picNum = doSomeSyncThing();
      return picNum;
    }

    function getTotalMoney(useList) {
      return useList.reduce((totalMoney, user) => {
          const money = getUserMoney(user) ?? 0;
          return totalMoney + money
      }, 0)
    }

    const total = getTotalMoney(['ZS', 'LS'])

    // 异步
    async function getUserMoney(name) {
      const picNum = await doSomeAsyncThing();
      return picNum;
    }

    async function getTotalMoney(user1, user2) {
      return useList.reduce((totalMoney, user) => {
          const money = await getUserMoney(user) ?? 0;
          return totalMoney + moeny
      }, 0)
    }

之前的代码都是同步的情况下获取用户的存款。突然有一天需求变更了,需要变成调用后台接口来获取用户的存款。那么我们的getUserMoney函数需要变成异步的,需要给getUserMoneygetTotalMoney裹上async/await。但是异步是会传染的,相应的getTotalMoney函数也会需要使用async/await

这样我们的代码就被迫全面修改了,那我们有办法避免这种修改吗。怎样我们才能在需要异步获取用户存款的时候,还能继续写同步的代码呢。

这里我们就需要引入代数效应这个思想。

代数效应的伪代码示例

    function getNumber(name) {
      const picNum = perform name;
      return picNum;
    }

    function getTotalNumber(user1, user2) {
      return useList.reduce((totalMoney, user) => {
          const money = getUserMoney(user) ?? 0;
          return totalMoney + money
      }, 0)
    }

    try {
      getTotalNumber('kaSong', 'xiaoMing');
    } handle (who) {
      switch (who) {
        case 'kaSong':
          resume with 230;
        case 'xiaoMing':
          resume with 122;
        default:
          resume with 0;
      }
    }

我们假设有一个resume、handle、perform语法。以下步骤简单的解释代数效应伪代码的执行流程:

  1. 代码执行到getNumber函数中,遇上了perform会像try/catch一样跳出当前的函数调用栈
  2. 找到最近的handle模块,执行handle模块中的代码。
  3. handle中代码执行完毕之后,会跳转回到perform跳出的位置,继续执行后续的代码。

注意:perform handle resume 是我们虚拟出的伪代码,并不是如今js中存在的语法

实现属于自己的 mini useState

简单分析

  1. useState接受一个初始值,作为state的初始值
  2. useState返回一个数组,数组的第一项是state的当前值,第二项是一个记录修改state动作的函数
  3. 记录修改state动作的函数执行后,需要重新render组件
  4. hook函数允许多个使用,但是他们之间是彼此独立互不影响的,如下图:

实现代码

App组件

App是我们模拟的react组件,我使用三个函数代替生产的html元素的点击和显示效果。

  1. showState输出state到控制台代替显示number
  2. increase调用代替点击新增按钮
  3. decrease调用代替点击减少按钮
    // 简单模拟jsx模块
    const App = () => {
      const [number, setNumber] = useMiniState(0)
      console.log('isMount ->', isMount)
      console.log('number ->', number)

      return {
        showState: () => {
          console.log('number ->', number)
        },
        increase: () => {
          setNumber(state => state + 1)
        },
        decrease: () => {
          setNumber(number - 1)
        }
      }
    }

虚拟节点

在打包时候,react会去解析jsx语法,通过createElement函数生成一个个的虚拟节点。这里我们不深入去实现虚拟dom的实现过程,暂时使用一个全局变量代替createElement生成的虚拟dom

let fiber = {
  node: App,
  memorizedState: null,
}

调度函数

我们需要实现一个简单的调度函数,重新执行App函数计算useMiniState的返回值,重新生成showState、increase、decrease三个函数。

  1. 我们需要把当前的工作hook指针指向当前App节点里面的第一个hook函数
  2. 重新执行App函数,即App组件的render
  3. 如果是第一次挂载节点,需要把挂载节点标识置为false
  4. 返回App函数的结果
let isMount = true
let workInProgressHook = null

const schedule = () => {
  workInProgressHook = fiber.memorizedState
  const node = fiber.node()
  if (isMount) {
    isMount = false
  }
  return node
}

useMiniState

接下来我们需要去实现useMiniState函数,我们之前分析过useState实现的四个点,这里一步一步来实现。首先是hook用来存放数据的对象。

hook函数存放数据的对象

首先我们需要建立一个对象来装载当前hook的一些参数。

  1. useMiniState函数的当前state需要被记录,所以我们给这个对象添加一个属性memorizedStateValue
  2. 但是当我们在一个组件中多次使用useMiniState函数创建多个状态的时候,我们该如何获取下一个useMiniStatestate值呢?答案当然是链表,于是我们把多个hook函数的数据存在一条链表中。给hook对象添加一个next属性,指向下一个hook。下图是我们现在暂时得到的hook对象:

记录hook动作的函数

我们需要写一个记录修改state动作的函数,给这个函数取名dispatchAction

  • 这个函数需要记录下对state修改的每次动作,为后续的计算state新值提供基础。很自然我们有了dispatchAction函数的第一个参数,修改state的动作,即action
  • 那么我们这些action需要存放在什么地方呢?没错就是我们刚刚新建的hook对象,我们给hook对象再添加一个属性queue,现在hook对象如下图所示。这里有人肯定会问了,为什么queue是一个对象,里面的value才是一个数组。这是因为我们需要把queue传递到dispatchAction函数中,再dispatchAction中修改queue。所以我们需要一个引用传递。

  • 现在我们的dispatchAction函数有了接受参数了,queueaction。我们只需要把action添加在queue队尾。然后重新触发调度函数就行,于是我们得到了如下图所示的dispatchAction函数

计算state的新值

目前我们只能在hook对象中得到旧值,即memorizedStateValue。我们还需要通过hook对象中记录的queue来计算state的新值。

queue中记录的action有两种

  1. action是一个基础值
  2. action是一个函数,接收旧的state,来计算新的state

所以我们需要对action进行一个判断,如果是函数执行,如果是基础值就直接记录下来。等到queue都遍历好了,我们state的新值也就得到了。接下来我们只需要返回新的statedispatchAction函数就可以了

以下是useMiniState的完整代码:

const dispatchAction = (queue, action) => {
  queue.value.push(action)

  window.app = schedule()
}

const useMiniState = (initState) => {
  let hook

  if (isMount) {
    hook = {
      memorizedStateValue: initState,
      next: null,
      queue: {
        value: []
      }
    }
    if (!fiber.memorizedState) {
      fiber.memorizedState = hook
    } else {
      workInProgressHook.next = hook
    }
    workInProgressHook = hook
  } else {
    hook = workInProgressHook
    workInProgressHook = workInProgressHook.next
  }

  let oldStateValue = hook.memorizedStateValue
  if (hook.queue.value?.length > 0) {
    for (const action of hook.queue.value) {
      if(typeof action === 'function'){
        oldStateValue = action(oldStateValue)
      } else {
        oldStateValue = action
      }
    }
    hook.queue.value = []
    hook.memorizedStateValue = oldStateValue
  }

  return [hook.memorizedStateValue, dispatchAction.bind(null, hook.queue)]
}

感谢

最后要感谢一下《React技术揭秘》的作者卡哥,这本书对我的react源码学习起到了很大帮助。 《React技术揭秘》:react.iamkasong.com/#%E5%AF%BC%…

文中如有错误或不严谨的地方,请给予指正,十分感谢。