模仿 big-react,使用 Rust 和 WebAssembly,从零实现 React v18 的核心功能。深入理解 React 源码的同时,还锻炼了 Rust 的技能,简直赢麻了!
代码地址:https://github.com/ParadeTo/big-react-wasm
本文对应 tag:v13
Based on big-react,I am going to implement React v18 core features from scratch using WASM and Rust.
Code Repository:https://github.com/ParadeTo/big-react-wasm
The tag related to this article:v13
我们知道,React 从 v17 开始使用 Lane 模型来替代之前的 Expiration Time 。为什么会有这种变化呢?我们先来看看之前的 Expiration Time 模型是怎么工作的。
We know that starting from v17, React has been using the Lane model to replace the previous Expiration Time model. Why was this change made? Let’s first take a look at how the former Expiration Time model worked.
每次触发更新,会产生一个 update
的数据结构,该结构上有个 expirationTime
的字段用来表示优先级。顾名思义,expirationTimes
是过期时间的意思,按照我们的惯例这个值越小表示越快过期,优先级应该越高。但它的值并不是简单的用当前时间加上一个常数得到的,而是经过了一系列的算法,最终的效果就是值越大优先级越高。一个 Fiber Tree 的 FiberNode
可能存在多个 update
。
Each time an update is triggered, an update
data structure is created, which has an expirationTime
field to represent its priority. As the name implies, expirationTimes
means the time after which the update is considered stale. According to our convention, the smaller the value, the sooner it expires, and the higher the priority should be. However, its value is not simply determined by adding a constant to the current time; it is the result of a series of algorithms, which ultimately mean that a larger value indicates a higher priority. A Fiber Tree’s FiberNode
may have multiple updates
.
每次 React 调度更新时,会在所有 FiberNode
节点的所有 update.expirationTime
中选择一个 expirationTime
作为本次更新的 renderExpirationTime
,如果 FiberNode
中的 update.expirationTime
< renderExpirationTime
,则该 update
会被跳过:
Every time React schedules an update, it selects an expirationTime
from all the update.expirationTimes
in all the FiberNode
nodes to be the renderExpirationTime
for this update. If the update.expirationTime
in a FiberNode
is less than the renderExpirationTime
, then that update
will be skipped:
1 | // https://github.com/facebook/react/blob/v16.10.0/packages/react-reconciler/src/ReactUpdateQueue.js#L516-L518 |
可以看到,使用 Expiration Time 这种模型时,如果想要判断当前更新任务是否包含在某个更新批次中,我们可以这样比较他们的优先级:
It can be seen that when using the Expiration Time model, if we want to determine whether a current update task is included in a certain batch of updates, we can compare their priorities like this:
1 | const isTaskIncludedInBatch = priorityOfTask >= priorityOfBatch |
这种方式的结果就是低优先级的任务不能单独拧出来处理。比如给定优先级 A > B > C,你不能在不处理 A 的情况下处理 B;同样,你不能在不同时处理 B 和 A 的情况下处理 C。
The result of this method is that tasks with lower priority cannot be processed individually. For example, given priorities A > B > C, you cannot process B without handling A; similarly, you cannot handle C without also handling B and A at the same time.
在 Suspense
出现之前,这样做是合理的。但是,当你引入了 IO 任务(即 Suspense),你可能会遇到一个情况,即一个高优先级的 IO 任务阻止了一个低优先级的 CPU 任务,但实际情况我们期望低优先级的 CPU 任务先完成。
Before the introduction of Suspense
, this was reasonable. However, when you introduce IO tasks (i.e., Suspense), you might encounter a situation where a high-priority IO task prevents a low-priority CPU task from executing, while in reality, we would prefer the low-priority CPU task to complete first.
考虑如下代码:
Consider the following code:
1 | const App = () => { |
其中:
Comp
中会发起异步请求(假设 2 秒后才返回),请求返回前会一直显示 loading- 每隔 1 秒将 count 加一
则看到的效果应该是这样:
Within this scenario:
Comp
initiates an asynchronous request (assuming it takes 2 seconds to return), and it will continuously show loading until the request returns.- The count is incremented by one every second.
The observed effect should be as follows:
1 | <div>loading...</div> |
但是如果按照 Expiration Time 模型的规则,由于 Suspense
对应的高优 IO 更新会阻止低优先级的 CPU 更新,所以看到的内容会是下面这样:
However, if we follow the rules of the Expiration Time model, since the high-priority IO update corresponding to Suspense
will block the low-priority CPU update, the content observed would be as follows:
1 | <div>loading...</div> |
Expiration Time 的另一个缺陷是在表示多个优先级组的方式上受到限制。使用 Set
的话在内存或计算上都不实际,因为要处理的计算非常普遍,所以需要尽可能快速并且使用尽可能少的内存。
作为妥协,我们通常会做的是维护一个优先级范围:
Another flaw of the Expiration Time model is its limitation in representing multiple groups of priorities. Using a Set
is impractical both in terms of memory and computation, because the calculations to be handled are very common, so they need to be as fast as possible and use as little memory as possible.
As a compromise, what we would typically do is maintain a range of priorities:
1 | const isTaskIncludedInBatch = |
如果是多个不连续的范围那代码写起来就比较繁琐了。
而换成 Lane 模型的话,计算起来就变得非常简单了:
If there are multiple discontinuous ranges, then the code becomes quite cumbersome to write.
However, with the Lane model, the calculation becomes very simple:
1 | const isTaskIncludedInBatch = (task & batchOfTasks) !== 0 |
简单介绍了下 Lane 模型,接下来看具体怎么实现。
这次的目标是实现 Batch Update,比如下面的例子,多次更新只会触发一次渲染:
Having briefly introduced the Lane model, let’s look at how it is implemented specifically.
The goal this time is to implement Batch Update, where, as in the following example, multiple updates will only trigger a single render:
1 | function App() { |
首先,我们定义好 Lane 以及相关的处理函数,目前只有两种 Lane:
Firstly, we define the Lanes and the related handling functions; currently, there are only two types of Lanes:
1 | use bitflags::bitflags; |
每当在 FiberNode
上触发更新时,会将更新的 Lane 冒泡到根节点 root
,并同 root.pendingLanes
进行合并:
Whenever an update is triggered on a FiberNode
, the update’s Lane is bubbled up to the root node root
and merged with root.pendingLanes
:
1 | pub fn mark_root_updated(&mut self, lane: Lane) { |
然后,根节点选出优先级最高的 Lane,并开启一轮渲染流程:
Subsequently, the root node selects the highest priority Lane and begins a round of the rendering process:
1 | fn ensure_root_is_scheduled(root: Rc<RefCell<FiberRootNode>>) { |
而这里并没有直接执行 perform_sync_work_on_root
,而是通过闭包把任务放到了一个队列中,然后在下一个宏任务或者微任务中一起处理:
Instead of directly executing perform_sync_work_on_root
, the task is placed into a queue through a closure, and then processed together in the next macro-task or micro-task:
1 | // packages/react-reconciler/src/sync_task_queue.rs |
schedule_microtask
我们按照 queueMicrotask -> Promise -> setTimeout
的顺序来选择合适的宏任务或者微任务 API。
In schedule_microtask
, we choose the appropriate macro-task or micro-task API in the order of queueMicrotask -> Promise -> setTimeout
.
执行 perform_sync_work_on_root
时,会按照 update_lane
的优先级来进行渲染,commit 完成后,会把 update_lane
从 pending_lanes
中剔除:
When executing perform_sync_work_on_root
, rendering is carried out according to the priority of update_lane
. After the commit is complete, update_lane
is removed from pending_lanes
:
1 | pub fn mark_root_finished(&mut self, lane: Lane) { |
这样,当下一个 perform_sync_work_on_root
再次执行时,由于得到的最高优先级为 NoLane
,就不会继续后面的流程了。
In this way, when perform_sync_work_on_root
is executed again, since the highest priority obtained is NoLane
, the subsequent process will not continue.
本次更新详见这里。
For more details on this update, see here.