一. 前言
在上一篇文章手写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
方法传入的action
为function
,可以通过入参获取上一个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
当我们调用useState
的dispatch
方法,会触发一次更新渲染,当同时调用多次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
对象属性