手写React useState,理解useState原理

70 阅读9分钟

一. 前言

在上一篇文章手写mini React,理解React渲染原理中实现了简单版本的useState,本篇文章会详细介绍useState的实现原理,提供一个更完善的useState版本。代码仓库

二. 实现useState

2.1 定义Hook对象原型

当每次调用useState方法时都会创建一个Hook对象,多个Hook对象之间会通过next指针进行索引,构建单链表数据结构

function Hook() {
  this.memoizedState = null // 记录state值
  this.next = null // 记录下一个hook
  this.queue = null // 收集更新state方法
}

2.2 定义函数组件方法调用装饰器

当每次调用函数组件方法(例如App Compoent Function)时会执行renderWithHooks方法,记录新FiberNode节点、旧FiberNode节点的useState hook链表节点,在调用useState方法时会用到

// 记录新FiberNode节点
let currentlyRenderingFiber = null
// 记录旧FiberNode节点的useState hook链表节点
let currentHook = null
// 记录新FiberNode节点useState hook链表节点
let workInProgressHook = null

/** 
 * @param {*} current 旧FiberNode节点
 * @param {*} workInProgress 新FiberNode节点
 * @param {*} Component 函数组件方法
 * @param {*} props 函数组件方法入参属性
*/
export function renderWithHooks(current, workInProgress, Component, props) {
  // 记录新FiberNode节点
  currentlyRenderingFiber = workInProgress
  if (current !== null) {
    // 记录旧FiberNode节点的useState hook链表
    currentHook = current.memoizedState
  }
  // 调用组件方法获取child ReactElement
  const children = Component(props)
  currentlyRenderingFiber = null
  currentHook = null
  workInProgressHook = null
  return children
}

2.3 首次调用useState方法

当首次执行函数组件方法,调用useState方法时会执行mountState方法逻辑。即创建Hook对象,记录state初始值,构建useState hook链表,返回初始值和触发更新渲染方法。

需要注意的是如果传入的初始值是function,会调用执行获取返回值作为初始state值。

function mountState(initialState) {
  // 如果传入的初始值是function,则调用执行获取返回值作为初始state值
  if (typeof initialState === 'function') {
    initialState = initialState()
  }
  const hook = new Hook()
  hook.memoizedState = initialState
  // 构建hook链表
  if (workInProgressHook === null) {
    currentlyRenderingFiber.memoizedState = workInProgressHook = hook
  } else {
    workInProgressHook = workInProgressHook.next = hook
  }
  // 触发更新渲染方法
  const dispatch = dispatchSetState.bind(null, currentlyRenderingFiber, hook)
  return [hook.memoizedState, dispatch]
}

2.4 定义触发更新dispatch方法

FiberNode节点的lanes属性值为NoLanes时,通过Object.is方法比较新旧state值是否相同,相同则不做任何处理,不相同则将lanes属性赋值为SyncLane,表示需要触发更新渲染,后续同步调用的dispatch方法时则不需要比对新旧节点属性值是否相同,可以优化性能。

dispatch方法会收集更新state的方法,赋值给useState hook节点的queue属性,在下次更新渲染时执行。当调用dispatch方法传入的actionfunction,可以通过入参获取上一个state值,执行返回新的state值,如果不为function,则直接作为新的state值。

需要注意的是触发更新渲染的逻辑是异步的,会将其作为微任务执行。具体实现参考2.7小节

// 获取FiberRootNode对象
function getRootForUpdatedFiber(fiber) {
  while (fiber.tag !== HostRoot) {
    fiber = fiber.return
  }
  return fiber.stateNode
}

function basicStateReducer(state, action) {
  return typeof action === 'function' ? action(state) : action
}

/**
 * @param {*} fiber FiberNode节点
 * @param {*} hook useState hook链表节点
 * @param {*} action 调用dispatch方法时传入的值
 */
function dispatchSetState(fiber, hook, action) {
  if (fiber.lanes === NoLanes) {
    // 获取旧state值
    const currentState = hook.memoizedState
    // 获取新state值
    const newState = basicStateReducer(currentState, action)
    // 如果state值相同则当前这次dispatch不需要触发更新
    if (Object.is(currentState, newState)) {
      return
    }
  }
  // 获取FiberRootNode对象
  const root = getRootForUpdatedFiber(fiber)
  // 收集更新state的方法,在创建新FiberNode节点时执行
  hook.queue.push((state) => basicStateReducer(state, action))
  root.pendingLanes = SyncLane
  fiber.lanes = SyncLane
  // 触发更新渲染
  ensureRootIsScheduled(root)
}

2.5 更新调用useState方法

当触发更新渲染,执行组件方法,再次调用useState方法时会执行updateReducer方法逻辑。可以发现updateReducer方法没有入参,说明更新调用useState方法传入的初始值是没有用的

// 触发更新再次调用函数组件处理逻辑
function updateReducer() {
  const hook = new Hook()
  // 执行更新state方法逻辑,获取新的state值
  hook.memoizedState = currentHook.queue.reduce(
    (state, action) => action(state),
    currentHook.memoizedState,
  )
  // 构建hook链表
  if (workInProgressHook === null) {
    currentlyRenderingFiber.memoizedState = workInProgressHook = hook
  } else {
    workInProgressHook = workInProgressHook.next = hook
  }
  currentHook = currentHook.next
  const dispatch = dispatchSetState.bind(null, currentlyRenderingFiber, hook)
  // 返回新的state值和dispatch
  return [hook.memoizedState, dispatch]
}

2.6 定义useState方法

如果新节点不存在旧FiberNode节点,说明是首次调用函数组件方法,则调用mountState方法,否则调用updateReducer方法

function useState(initialState) {
  const current = currentlyRenderingFiber.alternate
  if (current === null) {
    return mountState(initialState)
  } else {
    return updateReducer()
  }
}

2.7 实现简要版本scheduler

当我们调用useStatedispatch方法,会触发一次更新渲染,当同时调用多次dispatch方法时,不是每调用一次dispatch方法就触一次更新渲染,而是将多次调用dispatch更改state的逻辑保留到更新队列中,统一触发一次更新渲染,在下次渲染执行state更新队列的方法,更新state

通过didScheduleMicrotask变量判断是否需要添加一个触发更新渲染的微任务,queueMicrotask的作用是添加微任务事件,具体用法参考Using microtasks in JavaScript with queueMicrotask

// 是否添加一个更新渲染的微任务
let didScheduleMicrotask = false
// 记录FiberRootNode节点
let firstScheduledRoot = null

function processRootScheduleInMicrotask() {
  didScheduleMicrotask = false
  const root = firstScheduledRoot
  firstScheduledRoot = null
  // 调用更新渲染方法
  performWorkOnRoot(root, root.pendingLanes)
}

export function ensureRootIsScheduled(root) {
  if (didScheduleMicrotask) {
    return
  }
  didScheduleMicrotask = true
  firstScheduledRoot = root
  queueMicrotask(processRootScheduleInMicrotask)
}

三. useState使用准则

3.1 当连续调用dispatch方法修改state值时应该传入回调方法进行修改,而不是直接传入修改的值

例如下面这段代码的bad case,我们连续调用setCount方法,通过直接赋值的方式,那么由于这三次setCount使用的count值都是相同,都是1,那么每次setCount赋值结果都是 2,所以count值最终结果就是2,正确的方式应该是通过传入回调方式赋值,通过回调获取最新的count 值,然后+1再返回新的count

// bad
const [count, setCount] = useState(1);

function handleClick() {
  setCount(count + 1);
  setCount(count + 1);
  setCount(count + 1);
  // 最终结果count值为2
}

// good
const [count, setCount] = useState(1);

function handleClick() {
  setCount((state) => state + 1);
  setCount((state) => state + 1);
  setCount((state) => state + 1);
// 最终结果count值为4
}

3.2 当state是对象或数组时,修改state值不要直接修改原始对象或数组属性值,而是创建新对象和数组,基于此进行修改

例如下面这段代码的bad case,我们直接修改当前state的属性值,这种方式并不会触发重新渲染,因为react是通过Object.is的方式进行比对,不会判断属性值是否变更,正确方式应该是创建新的对象,修改新对象属性值

// bad
const [person, setPerson] = useState({ name: "jack", age: 10 });

function handleClick() {
  person.name = "lisi";
  setPerson(perosn);
}

// good
const [person, setPerson] = useState({ name: "jack", age: 10 });

function handleClick() {
  setPerson({
    ...person,
    name: "lisi",
  });
}

3.3 不要重复创建state初始值

例如下面这段代码中的bad case,我们调用getCount方法返回count初始值,这种方式会导致每次渲染都会执行一次getCount方法,但是没有意义的,因为count的初始值只有首次渲染时才会赋值,重新渲染不会再使用getCount返回结果进行赋值,所以最佳方式是传入getCount方法,react在首次渲染时会调用getCount方法获取返回值作为初始值进行赋值。

// bad
const [count, setCount] = useState(getCount());

// good
const [count, setCount] = useState(getCount);

四. 练习题

4.1 第一题

题目如下,定义一个count state,初始值是0,当点击一次button按钮,会调用handleClick方法。

  • 第一问: console.log输出结果是什么
  • 第二问: h1标签展示的count值是多少
function App() {
  const [count, setCount] = useState(0)
  
  const handleClick = () => {
    console.log(count)
    setCount(count + 1)
    console.log(count)
    setCount(count + 1)
    console.log(count)
    setTimeout(() => {
      console.log(count)
    }, 3000)
  }
  
  return (
    <div>
      <button onClick={handleClick}>click</button>
      <h1>{count}</h1>
    </div>
  )
}

第一问答案如下,console.log输出都是0

const handleClick = () => {
  console.log(count) // 0
  setCount(count + 1)
  console.log(count) // 0
  setCount(count + 1)
  console.log(count) // 0
  setTimeout(() => {
    console.log(count) // 0
  }, 3000)
}

首先我们知道每次调用函数方法如App Function都会生成函数上下文,会有自己的作用域,如count变量,handleClick方法都属于当前作用域里的属性,当调用setCount方法,会触发重新渲染,重新调用App Function方法生成新的函数上下文,会有新的作用域,在新的作用域里的count变量和handleClick方法和上一个函数上下文的作用域里的count变量和hanleClick方法是不一样的。回到第一问,因为当前作用域里的count值是0,所以console.log输出结果是0,包括setTimeout里的console.log输出结果也是0

第二问答案如下,h1标签最终展示结果为1

<h1>1</h1>

因为当前作用域的count值是0,所以两次setCount(count + 1)等价于setCount(0 + 1),也就是两次赋值结果都是1,所以count值最终结果是1

4.2 第二题

题目如下,定义一个count state,初始值是0,当点击一次button按钮,会调用handleClick方法,与上一题差异点在于调用setCount方法传入的是function

  • 第一问: console.log输出结果是什么
  • 第二问: h1标签展示的count值是多少
function App() {
  const [count, setCount] = useState(0)
  
  const handleClick = () => {
    console.log(count)
    setCount(s => s + 1)
    console.log(count)
    setCount(s => s + 1)
    console.log(count)
    setTimeout(() => {
      console.log(count)
    }, 3000)
  }
  
  return (
    <div>
      <button onClick={handleClick}>click</button>
      <h1>{count}</h1>
    </div>
  )
}

第一问答案与第一题一致,参考第一题答案解析,第二问答案如下,h1标签最终展示结果为2

<h1>2</h1>

当调用useState方法传入的参数是function时,可以通过入参拿到上一个state属性值时,function返回结果会作为state的新值,第一次调用setCount(s => s + 1),此时count值是0,所以等价于setCount(0 => 0 + 1)count值结果为1,第二次调用setCount(s => s + 1),此时count值是1,所以等价于setCount(1 => 1 + 1)count值结果为2,所以count值最终结果是2

4.3 第三题

定义一个person对象,点击一次button按钮,调用handleClick方法修改person.name属性值,问h1标签最终展示结果是什么

function App() {
  const [person, setPerson] = useState({ name: 'zhangsan' })

  const handleClick = () => {
    setPerson(s => {
        s.name = 'lisi'
        return s
    })
  }
  
  return (
    <div>
      <button onClick={handleClick}>click</button>
      <h1>{person.name}</h1>
    </div>
  )
}

答案如下,h1标签最终展示结果是zhangsan

<h1>zhangsan</h1>

可以发现展示结果并不是预期的lisi,回到handleClick方法执行逻辑,调用setPerson方法,通过入参获取person对象,直接修改person.name属性值,然后返回当前person对象,由于React是通过Object.is方法比对person对象是否发生变更,不会判断person对象的属性值是否发生变更,那么因为person对象都是同一个,所以不会触发重新渲染,所以h1标签最终展示结果还是zhangsan,正确方式应该返回新的person对象,即通过setPerson(s => { ...s, name: 'lisi' })方法修改person对象属性

五. 参考文档

5.1 React useState官方文档