状态的更新
之前我们已经看懂了 React 的事件委托机制,那么不如在一次点击事件中尝试修改组件的状态来更新我们的页面。
首先康康 setState 是如何工作的,我们知道 this.setState 是 React.Component 类中的方法:
/**
* @description 更新组件 state
* @param { object | Function } partialState 下个阶段的状态
* @param { ?Function } callback 更新完毕之后的回调
*/
Component.prototype.setState = function (partialState, callback) {
if (!(
isObject(partialState) ||
isFunction(partialState) ||
isNull
)) {
console.warn('setState 的第一个参数应为对象、函数或 null')
return
}
this.updater.enqueueSetState(this, partialState, callback, 'setState')
}
看起来核心步骤就是触发挂载在实例上的一个 updater 对象。默认的, updater 会是一个展位的空对象,虽然实现了 enqueueSetState 等方法,但是这些方法内部都是空的。
// 我们初始化这个默认的 update,真正的 updater 会被 renderer 注入
this.updater = updater || ReactNoopUpdateQueue
export const ReactNoopUpdateQueue = {
/**
* 检查组件是否已经挂载
*/
isMounted: function (publishInstance) {
// 初始化 ing 的组件就别挂载不挂载了
return false
},
/**
* 强制更新
*/
enqueueForceUpdate: function (publishInstance, callback, callerName) {
console.warn('enqueueForceUpdate', publishInstance)
},
/**
* 直接替换整个 state,通常用这个或者 setState 来更新状态
*/
enqueueReplaceState: function (
publishInstance,
completeState,
callback,
callerName
) {
console.warn('enqueueReplaceState', publishInstance)
},
/**
* 修改部分 state
*/
enqueueSetState: function (
publishInstance,
partialState,
callback,
callerName
) {
console.warn('enqueueSetState', publishInstance)
}
}
还记得我们在 render 的过程中,是通过执行 Component.render() 来获得一个类组件的实例,当 React 得到了这个实例之后,就会将实例的 updater 替换成真正的 classComponentUpdater :
function adoptClassInstance (
workInProgress: Fiber,
instance: any
): void {
instance.updater = classComponentUpdate
...
}
刚刚我们触发了这个对象中的 enqueueSetState 函数,那么可以看看实现:
const classComponentUpdate = {
isMounted,
/**
* 触发组件状态的更新
* @param inst ReactElement
* @param payload any
* @param callback 更新结束之后的回调
*/
enqueueSetState(
inst: ReactElement,
payload: any,
callback?: Function
) {
// ReactElement -> fiber
const fiber = getInstance(inst)
// 当前时间
const currentTime = requestCurrentTime()
// 获取当前 suspense config
const suspenseConfig = requestCurrentSuspenseConfig()
// 计算当前 fiber 节点的任务过期时间
const expirationTime = computeExpirationForFiber(
currentTime,
fiber,
suspenseConfig
)
// 创建一个 update 实例
const update = createUpdate(expirationTime, suspenseConfig)
update.payload = payload
// 将 update 装载到 fiber 的 queue 中
enqueueUpdate(fiber, update)
// 安排任务
ScheduleWork(fiber, expirationTime)
},
...
}
显然,这个函数的作用就是获得类组件对应的 fiber ,更新它在任务调度器中的过期时间(领导给了新工作,自然要定新的 Deadline ) ,然后就是创建一个新的 update 任务装载到 fiber 的任务队列中。最后通过 ScheduleWork (告诉任务调度器来任务了,赶紧干活) 要求从这个 fiber 开始调和,至于调和和更新的步骤我们在第一次渲染中已经有了大致的了解。
顺带提一提 Hooks 中的 useState 。网络上有挺多讲解 hook 实现的文章已经讲得很全面了,我们只需要搞清楚以下几点问题。
Q1. 函数组件不像类组件一样拥有实例,数据存储在哪里
A1. 任何以 ReactElement 为粒度的组件都需要围绕 fiber ,数据存储在 fiber.memorizedState 上
Q2. useState 的实现
A2. 如果你听过了 useState 那么你就应该听过 useReducer ,如果听过 reducer 就应该知道 redux。首先,useState 的本质就是 useReducer 的语法糖。我们都知道构建一个状态库需要一个 reducer ,useState 就是当 reducer 函数为 a => a
时的特殊情况。
function basicStateReducer<S>(state: S, action: BasicStateAction<S>): S {
return typeof action === 'function' ? action(state) : action
}
function updateState<S>(
initialState: (() => S) | S
): [ S, Dispatch<BasicStateAction<S>> ] {
return updateReducer<S, (() => S) | S, any>(basicStateReducer, initialState)
}
Q3. 为什么 Hooks 的顺序和个数不允许改变
A3. 每次执行 Hooks 函数需要取出上一次渲染时数据的最终状态,因为结构是链表而不是一个 Map,所以这些最终状态也会是有序的,所以如果个数和次序改变会导致数据的错乱。
时间调度机制
虽然今年过期时间 expirationTime 机制已经被淘汰了,但是不管是航道模型还是过期时间,本质上都是任务优先级的不同体现形式。
在探究运行机制之前我们需要知道一个问题就是,为什么时间片的性能会优于同步计算的性能。此处借用司徒正美老师 文章 中的例子。
实验 1,通过 for 循环一次性向 document 中插入 1000 个节点
function randomHexColor(){
return "#" + ("0000"+ (Math.random() * 0x1000000 << 0).toString(16)).substr(-6);
}
setTimeout(function() {
var k = 0;
var root = document.getElementById("root");
for(var i = 0; i < 10000; i++){
k += new Date - 0 ;
var el = document.createElement("div");
el.innerHTML = k;
root.appendChild(el);
el.style.cssText = background:${randomHexColor()};height:40px ;
}
}, 1000);
实验 2,进行 10 次 setTimeout 分批次操作,每次插入 100 个节点
function randomHexColor() {
return "#" + ("0000" + (Math.random() * 0x1000000 << 0).toString(16)).substr(-6);
}
var root = document.getElementById("root");
setTimeout(function () {
function loop(n) {
var k = 0;
console.log(n);
for (var i = 0; i < 100; i++) {
k += new Date - 0;
var el = document.createElement("div");
el.innerHTML = k;
root.appendChild(el);
el.style.cssText = background:${randomHexColor()};height:40px ;
}
if (n) {
setTimeout(function () {
loop(n - 1);
}, 40);
}
}
loop(100);
}, 1000);
相同的结果,第一个实验花费了 1000 ms,而第二个实验仅仅花费了 31.5 ms。
这和 V8 引擎的底层原理有关,我们都知道浏览器是单线程,一次性需要做到 GUI 描绘,事件处理,JS 执行等多个操作时,V8 引擎会优先对代码进行执行,而不会对执行速度进行优化。如果我们稍微给浏览器一些时间,浏览器就能够进行 JIT ,也叫热代码优化。
简单来说, JS 是一种解释型语言,每次执行都需要被编译成字节码才能被运行。但是如果某个函数被多次执行,且参数类型和参数个数始终保持不变。那么这段代码会被识别为 热代码 ,遵循着“万物皆可空间换时间”的原则,这段代码的字节码会被缓存,下次再次运行的时候就会直接被运行而不需要进行耗时的解释操作。也就是 解释器 + 编译器 的模式。
做个比喻来说,我们工作不能一直蛮干,必须要给自己一些时间进行反思和总结,否则工作速度和效率始终是线性的,人也不会有进步。
还记得在 WorkLoop 函数中,每次处理完一个 fiber 都会跳出循环执行一次 shouldYield 函数进行判断,是否应该将执行权交还给浏览器处理用户时间或是渲染。看看这个 shouldYield 函数的代码:
// 当前是否应该阻塞 react 的工作
function shouldYield (): boolean {
// 获取当前的时间点
const currentTime = getCurrentTime()
// 检查任务队列中是否有任务需要执行
advanceTimers(currentTime)
// 取出任务队列中任务优先级最高的任务
const firstTask = peek(taskQueue)
// 以下两种情况需要 yield
// 1. 当前任务队列中存在任务,且第一个任务的开始时间还没到,且过期时间小于当前任务
// 2. 处于固定的浏览器渲染时间区间
return (
(
currentTask !== null &&
firstTask !== null &&
(firstTask as any).startTime <= currentTime &&
(firstTask as any).expirationTime < currentTask.expirationTime
)
// 当前处于时间片的阻塞区间
|| shouldYieldToHost()
)
}
决定一个任务当前是否应该被执行有两个因素。
- 这个任务是否非执行不可,正所谓一切的不论是不是先问为什么都是耍流氓。如果到期时间还没到,为什么不先把线程空出来留给可能的高优先级任务呢。
- 如果多个任务都非执行不可,那么任务的优先级是否是当前队列中最高的。
如果一个任务的过期时间已经到了必须执行,那么这个任务就应该处于 待执行队列 taskQueue 中。相反这个任务的过期时间还没到,就可以先放在 延迟列表 中。每一帧结束的时候都会执行 advanceTimer 函数,将一些延迟列表中到期的任务取出,插入待执行队列。
可能是出于最佳实践考虑,待执行队列是一个小根堆结构,而延迟队列是一个有序链表。
回想一下 React 的任务调度要求,当一个新的优先级更高的任务产生,需要能够打断之前的工作并插队。也就是说,React 需要维持一个始终有序的数组数据结构。因此,React 自实现了一个小根堆,但是这个小根堆无需像堆排序的结果一样整体有序,只需要保证每次进行 push 和 pop 操作之后,优先级最高的任务能够到达堆顶。
所以 shouldYield 返回 true 的一个关键条件就是,当前 taskQueue 堆中的堆顶任务的过期时间已经到了,那么就应该暂停工作交出线程使用权。
那么待执行的任务是如何被执行的呢。这里我们需要先了解 MessageChannel 的概念。Message
Channel 的实例会拥有两个端口,其中第一个端口为发送信息的端口,第二个端口为接收信息的端口。当接收到信息就可以执行指定的回调函数。
const channel = new MessageChannel()
// 发送端
const port = channel.port2
// 接收端
channel.port1.onmessage = performWorkUntilDeadline // 在一定时间内尽可能的处理任务
每当待执行任务队列中有任务的时候,就会通过 Channel 的发送端发送一个空的 message ,当接收端异步地接收到这个信号的时候,就会在一个时间片内尽可能地执行任务。
// 记录任一时间片的结束时刻
let deadline = 0
// 单位时间切片长度
let yieldInterval = 5
// 执行任务直到用尽当前时间片空闲时间
function performWorkUntilDeadline () {
if (scheduledHostCallback !== null) {
// 如果有计划任务,那么需要执行
// 当前时间
const currentTime = getCurrentTime()
// 在每个时间片之后阻塞(5ms)
// deadline 为这一次时间片的结束时间
deadline = currentTime + yieldInterval
// 既然能执行这个函数,就代表着还有时间剩余
const hasTimeRemaining = true
try {
// 将当前阻塞的任务计划执行
const hasMoreWork = scheduledHostCallback(
hasTimeRemaining,
currentTime
)
if (!hasMoreWork) {
// 如果没有任务了,清空数据
isMessageLoopRunning = false
scheduledHostCallback = null
} else {
// 如果还有任务,在当前时间片的结尾发送一个 message event
// 接收端接收到的时候就将进入下一个时间片
port.postMessage(null)
}
} catch (error) {
port.postMessage(null)
throw(error)
}
} else {
// 压根没有任务,不执行
isMessageLoopRunning = false
}
}
我们在之前说过,阻塞 WorkLoop 的条件有两个,第一个是任务队列的第一个任务还没到时间,第二个条件就是 shouldYieldToHost 返回 true,也就是处于时间片期间。
// 此时是否是【时间片阻塞】区间
export function shouldYieldToHost () {
return getCurrentTime() >= deadline
}
总结一下,时间调度机制其实就是 fiber 遍历任务 WorkLoop 和调度器中的任务队列争夺线程使用权的过程。不过区别是前者完全是同步的过程,只会在每个 while 的间隙去询问 调度器 :我是否可以继续执行下去。而在调度器拿到线程使用权的每个时间片中,都会尽可能的处理任务队列中的任务。
传统武术讲究点到为止,以上内容,就是这次 React 原理的全部。在文章中我并没有放出大量的代码,只是放出了一些片段用来佐证我对于源码的一些看法和观点,文中的流程只是一个循序思考的过程,如果需要查看更多细节还是应该从源码入手。
当然文中的很多观点带有主观色彩,并不一定就正确,同时我也不认为网络上的其他文章的说法就和 React 被设计时的初衷完全一致,甚至 React 源码中的很多写法也未必完美。不管阅读什么代码,我们都不要神话它,而是应该辩证的去看待它。总的来说,功过 91 开。
前端世界并不需要第二个 React ,我们学习的意义并不是为了证明我们对这个框架有多么了解。而是通过窥探这些顶级工程师的实现思路,去完善我们自己的逻辑体系,从而成为一个更加严谨的人。
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论