mini-react 第六天:实现useState

93 阅读5分钟

I. 实现useState


在前一天的课程中,我们是通过调用React.update()函数返回的update函数来触发重新渲染的。今天的课程,我们会把它替换成React.js中的useState 写法。

1. 使用useState语句

按照React.js的写法使用useState.

import React from "./core/React.js"
function Foo() {
  const [count, setCount] = React.useState(10)
  function handleClick() {
    setCount(pre => pre + 2)
  }
  return (
    <div>
      <h1>Foo : {count}</h1>
      <button onClick={handleClick}>click</button>
    </div>
  )
}
function App() {
  return (
    <div>
      <h1>App</h1>
      <Foo></Foo>
    </div>
  )
}

export default App

2. 补齐useState定义

  • useState function 以数组的形式返回statesetState
  • setState包含之前的react.update 操作

为了简单,我们先支持useState()参数是一个函数的情况setState(pre => pre + 2)


// function update() {
//   // 函数组件在运行此HOC的时候顺便记录当前fiber节点wipFiber
//   let currentFiber = wipFiber;
//   return () => {
//     wipRoot = {
//       ...currentFiber,
//       alternate: currentFiber,
//     };
//     nextWorkOfUnit = wipRoot;
//   }
// }

function useState(initial) {
  let currentFiber = wipFiber;

  const stateHook = {
    state: initial,
  }

  const setState = (action) => {
    stateHook.state = action(stateHook.state)
    console.log(stateHook.state);
    wipRoot = {
      ...currentFiber,
      alternate: currentFiber,
    };
    nextWorkOfUnit = wipRoot;
  }

  return [stateHook.state, setState];
}

【问题】console.log打印更新的state值,确实是12,但是一直都是12不再往上增加了。而且页面上也没显示更新后的数值。

image.png

【分析】每一次setState 导致的state更新,要保存到fiber节点上:

  • 这里只是调用了action来得到更新的数值,但是这个数值并没有存储下来供下一次使用。每次都是读的initial值。我们需要添加一个值来记录它,并保存在fiber上。
  let currentFiber = wipFiber;
  // 取出上一次渲染时的state
  let oldHook = currentFiber.alternate?.stateHook;

  // 如果有上一次的值就用上一次的值,没有的话就用初始值
  const stateHook = {
    state: oldHook ? oldHook.state : initial,
  }

  // 将拿到的值存储在fiber上
  currentFiber.stateHook = stateHook;

useState-action.gif

state初始化以及更新流程图:

image.png

3. 扩展到支持多个state:

  • stateHooks数组存储一个节点中的多个useState;用stateHookIndex 来区分同节点内的不同的useState
//全局变量
let stateHooks;
let stateHookIndex;
  • 对于每个fiber节点,进行初始化
function updateFunctionComponent(fiber) {

  stateHooks = [];
  stateHookIndex = 0;
  
  wipFiber = fiber;
  // 如果是函数组件,不直接为其append dom
  const children = [fiber.type(fiber.props)]
  reconcileChildren(fiber, children)
}
  • 按index取值,更新hooks数组;每调用一次useState, index++。每一次运行函数组件函数,都会运行useState。从而const stateHook被更新,setState() 中调用的stateHook也被更新,返回的state也更新。
function useState(initial) {
  let currentFiber = wipFiber;
  // 根据index找到对应的stateHook
  let oldHook = currentFiber.alternate?.stateHooks[stateHookIndex];

  // 存储本次的state供setState使用
  const stateHook = {
    state: oldHook ? oldHook.state : initial,
  }
  stateHooks.push(stateHook);
  stateHookIndex++;
  
   //刷新hooks数组
  currentFiber.stateHooks = stateHooks;

  const setState = (action) => {
    stateHook.state = action(stateHook.state);
    wipRoot = {
      ...currentFiber,
      alternate: currentFiber,
    };
    nextWorkOfUnit = wipRoot;
  }

  return [stateHook.state, setState];
}

💡启发

这也就解释了为什么useState 只能在组件里直接调用,不能在if里使用,这样会导致index混乱。

II. 批量执行action


实现

【问题】有时,用户的一个行为会触发多次render:当setState 被一个操作多次触发的时候

<button onClick={() => {
    setNumber(n => n + 1);
    setNumber(n => n + 1);
    setNumber(n => n + 1);
}}>+3</button>

【解决】批量执行state更新,然后再渲染fiber节点,减少不必要的渲染。由于这个优化是针对每一个state,所以队列也添加到每个stateHook上。


let stateHooks;
let stateHookIndex;
function useState(initial) {
  let currentFiber = wipFiber;
  // 根据index找到对应的stateHook
  let oldHook = currentFiber.alternate?.stateHooks[stateHookIndex];

  // 存储本次的state供setState使用
  const stateHook = {
    state: oldHook ? oldHook.state : initial,
    queue: oldHook ? oldHook.queue : [],
  }
  // 调用action
  stateHook.queue.forEach(action => {
    stateHook.state = action(stateHook.state)
  })
  stateHook.queue = [];

  stateHooks.push(stateHook);
  stateHookIndex++;
  currentFiber.stateHooks = stateHooks;

  const setState = (action) => {
    stateHook.queue.push(typeof action === "function" ? action : () => action)
    // stateHook.state = action(stateHook.state)

    wipRoot = {
      ...currentFiber,
      alternate: currentFiber,
    };
    nextWorkOfUnit = wipRoot;
  }

  return [stateHook.state, setState];
}

支持传值而非action函数

将值转化为一个返回该值得箭头函数() => action

完整语句:typeof action === "fuction" ? action : () => action;

代码分析

【问】上面的代码中,action入队之后,nextWorkOfUnit会被更新,看起来fiber节点也会入队。这是如何避免多次渲染造成的浪费的?

【答】问题中的预设并不一定成立:每次调用setState,nextWorkOfUnit会被更新:nextWorkOfUnit = wipRoot,但是workloop 需要等待浏览器空闲时间来执行。一种情况是,workloop还没有等到可以执行的时间,nextWorkOfUnit就又被第二次、第三次的setState刷新了。当workloop等到时间来执行的时候,实际上最后一次的nextWorkOfUnit = wipRoot已经完成了,这时候进行的渲染实际上就是基于最新的action queue的了。

【佐证】 添加log打印可以印证上面的回答

  const setState = (action) => {
    stateHook.queue.push(typeof action === "function" ? action : () => action)
    wipRoot = {
      ...currentFiber,
      alternate: currentFiber,
    };
    nextWorkOfUnit = wipRoot;

    console.log("setState nextWorkOfUnit", nextWorkOfUnit);
  }
  
function workLoop(deadline) {
  let shouldYield = false;
  while (!shouldYield && nextWorkOfUnit) {
  
    console.log("get nextWorkOfUnit", nextWorkOfUnit);
    nextWorkOfUnit = performWorkOfUnit(nextWorkOfUnit);
  ...

image.png

联想、对比

对比之前在setState中直接执行action得到结果的做法,批量执行会把action存入alternate。然后触发渲染,进而执行useState。此时再批量执行action。

image.png

  • 第一次执行useState是进行初始化并提供setState函数
  • 调用了setState函数后会将action入队以及将fiber节点入队
  • fiber节点的入队会带来渲染函数组件,执行函数组件函数就会再度执行useState 进而运行队列里的所有action

【问】加入queue执行的机制中,怎么保证最后一个批次的action queue也被执行?

【答】如上图所示,action的入队这个行为定义在setState函数中。当setState函数被调用,首先执行的是更新之前的alternate中的action queue。然后再渲染和批量执行,这样很顺利的就得到了本次需要计算出来的state值。

III. 减少不必要的更新

检测state值是否变了,没变的话就不重新渲染

  const setState = (action) => {
    // 处理值一样的情况
    const eagerState = typeof action === "function" ? action(stateHook.state) : action
    if (eagerState === stateHook.state) return

    stateHook.queue.push(typeof action === "function" ? action : () => action)
    // stateHook.state = action(stateHook.state)

    wipRoot = {
      ...currentFiber,
      alternate: currentFiber,
    }

    nextWorkOfUnit = wipRoot
  }

华丽的分割线

至此,本天的课程其实已经结束了,但是我的思考没有停止:

【疑问】如果每次都执行action,那action入队就没必要了吧?都已经得到执行结果了没必要再执行一遍

【试验】不使用action queue,直接设置stateHook.state = action(stateHook.state)。添加console log看看。

  const setState = (action) => {
    const eagerState = typeof action === "function" ? action(stateHook.state) : action
    if (eagerState === stateHook.state) return
    //stateHook.queue.push(typeof action === "function" ? action : () => action)
    stateHook.state = eagerState
    wipRoot = {
      ...currentFiber,
      alternate: currentFiber,
    }
    nextWorkOfUnit = wipRoot
    
    console.log("eagerState", eagerState, "set nextWorkOfUnit",  nextWorkOfUnit)
  }

function workLoop(deadline) {
  let shouldYield = false;
  while (!shouldYield && nextWorkOfUnit) {
    console.log("get nextWorkOfUnit", nextWorkOfUnit)
    nextWorkOfUnit = performWorkOfUnit(nextWorkOfUnit);
    ...

【结果】会发现即使不使用queue,多次的setState 调用也并不会直接触发多次的渲染。这还是因为setState的间隔较短,并未分不到不同的浏览器空闲时间内。可见本次课程中的queue方式优化并不严谨。

image.png