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:v19
In React, there are two optimization strategies called "bailout" and "eagerState." Bailout refers to skipping the current FiberNode or its descendant nodes, or both, during the reconciliation process when certain conditions are met. On the other hand, eagerState allows skipping the update directly when updating the state if certain conditions are met. Below, we will provide an example to illustrate these strategies (React version 18.2.0):
function Child({num}) {
console.log('Child render')
return <div>Child {num}</div>
}
function Parent() {
console.log('Parent render')
const [num, setNum] = useState(1)
return (
<div onClick={() => setNum(2)}>
Parent {num}
<Child num={num} />
</div>
)
}
export default function App() {
console.log('App render')
return (
<div>
App
<Parent />
</div>
)
}
- First render, console output:
App render
Parent render
Child render
- First click, console output:
Parent render
Child render
- Second click, console output:
Parent render
- Third click, no console output.
Let's analyze this briefly:
During the first render, all components are printed, which is expected.
During the first click, the App component doesn't have any prop changes and doesn't trigger an update task, so it's reasonable that "App render" is not printed. The Parent component triggers an update (1->2), so "Parent render" is printed, which is expected. The Child component has prop changes, so "Child render" is printed, which is also expected.
During the second click, although the Parent component triggers an update, the state remains the same. It seems that "Parent render" shouldn't be printed, which is the first question. Also, since the Parent component is re-executed, it implies that the ReactElements under the Parent component are also recreated, so the new and old props of the Child component should be different. Why isn't "Child render" printed? This is the second question.
During the third click, although the Parent component triggers an update, the state remains the same. "Parent render" shouldn't be printed, and "Child render" shouldn't be printed either, which is reasonable.
Regarding the first question, it actually reflects that React's optimization is not thorough enough. For more details, please refer to this article (article in Chinese).
Regarding the second question, it will be explained below.
Next, let's briefly introduce how to implement these two optimizations. The specific changes can be found here.
bailout
Before we proceed with the implementation, let's think about when a FiberNode can skip the reconciliation process. Upon careful consideration, it should meet the following conditions:
- The
props
andtype
of the node haven't changed. - The update priority of the node is lower than the current update priority.
- There is no use of
Context
. - The developer hasn't used
shouldComponentUpdate
orReact.memo
to skip updates.
Since our "big react wasm" implementation hasn't included the last two conditions, we will only discuss the first two conditions for now.
We need to add the bailout logic at the beginning of the begin_work
function. The code is as follows:
...
unsafe {
DID_RECEIVE_UPDATE = false;
};
let current = work_in_progress.borrow().alternate.clone();
if current.is_some() {
let current = current.unwrap();
let old_props = current.borrow().memoized_props.clone();
let old_type = current.borrow()._type.clone();
let new_props = work_in_progress.borrow().pending_props.clone();
let new_type = work_in_progress.borrow()._type.clone();
// Condition 1
if !Object::is(&old_props, &new_props) || !Object::is(&old_type, &new_type) {
unsafe { DID_RECEIVE_UPDATE = true }
} else {
// Condition 2
let has_scheduled_update_or_context =
check_scheduled_update_or_context(current.clone(), render_lane.clone());
if !has_scheduled_update_or_context {
unsafe { DID_RECEIVE_UPDATE = false }
return Ok(bailout_on_already_finished_work(
work_in_progress,
render_lane,
));
}
}
}
work_in_progress.borrow_mut().lanes = Lane::NoLane;
...
fn check_scheduled_update_or_context(current: Rc<RefCell<FiberNode>>, render_lane: Lane) -> bool {
let update_lanes = current.borrow().lanes.clone();
if include_some_lanes(update_lanes, render_lane) {
return true;
}
// TODO Context
false
}
And when this FiberNode hits the bailout strategy, if none of the child nodes meet the update priority for this update, the entire subtree rooted at the current FiberNode can also be skipped.
fn bailout_on_already_finished_work(
wip: Rc<RefCell<FiberNode>>,
render_lane: Lane,
) -> Option<Rc<RefCell<FiberNode>>> {
if !include_some_lanes(wip.borrow().child_lanes.clone(), render_lane) {
if is_dev() {
log!("bailout the whole subtree {:?}", wip);
}
return None;
}
if is_dev() {
log!("bailout current fiber {:?}", wip);
}
clone_child_fiblers(wip.clone());
wip.borrow().child.clone()
}
The child_lanes
here represent the combined lanes
of all the descendant nodes of a FiberNode, as shown below:
When a node triggers an update, it bubbles up to update the child_lanes
of its ancestors.
As mentioned earlier, the bailout strategy has three scenarios: skipping the current FiberNode, skipping the current FiberNode and its descendants, and skipping only the descendants. We have already discussed the first two scenarios, so when does the third scenario occur? The answer lies in the update_function_component
:
fn update_function_component(
work_in_progress: Rc<RefCell<FiberNode>>,
render_lane: Lane,
) -> Result<Option<Rc<RefCell<FiberNode>>>, JsValue> {
let next_children = render_with_hooks(work_in_progress.clone(), render_lane.clone())?;
let current = work_in_progress.borrow().alternate.clone();
if current.is_some() && unsafe { !DID_RECEIVE_UPDATE } {
bailout_hook(work_in_progress.clone(), render_lane.clone());
return Ok(bailout_on_already_finished_work(
work_in_progress,
render_lane,
));
}
reconcile_children(work_in_progress.clone(), Some(next_children));
Ok(work_in_progress.clone().borrow().child.clone())
}
Here, the component code corresponding to the current FiberNode is executed once. Only if it detects a difference between the previous and current values of a state, it sets DID_RECEIVE_UPDATE
to true
:
// filber_hooks.rs
...
if !Object::is(&ms_value, &ps_value) {
mark_wip_received_update();
}
...
// begin_work.rs
static mut DID_RECEIVE_UPDATE: bool = false;
pub fn mark_wip_received_update() {
unsafe { DID_RECEIVE_UPDATE = true };
}
If the values are the same, it can proceed to the logic of bailout_on_already_finished_work
.
This explains the second question. Although the Parent component was unexpectedly re-rendered, this additional safeguard prevents the impact from spreading further.
eagerState
The implementation of eagerState is relatively straightforward. When dispatch_set_state
is called, if both the WIP and Current have a priority of NoLane and the state values before and after the update are equal, the update can be skipped directly:
fn dispatch_set_state(
fiber: Rc<RefCell<FiberNode>>,
update_queue: Rc<RefCell<UpdateQueue>>,
action: &JsValue,
) {
let lane = request_update_lane();
let mut update = create_update(action.clone(), lane.clone());
let current = { fiber.borrow().alternate.clone() };
if fiber.borrow().lanes == Lane::NoLane
&& (current.is_none() || current.unwrap().borrow().lanes == Lane::NoLane)
{
let current_state = update_queue.borrow().last_rendered_state.clone();
if current_state.is_none() {
panic!("current state is none")
}
let current_state = current_state.unwrap();
let eager_state = basic_state_reducer(¤t_state, &action);
// if not ok, the update will be handled in render phase, means the error will be handled in render phase
if eager_state.is_ok() {
let eager_state = eager_state.unwrap();
update.has_eager_state = true;
update.eager_state = Some(eager_state.clone());
if Object::is(¤t_state, &eager_state) {
enqueue_update(update_queue.clone(), update, fiber.clone(), Lane::NoLane);
if is_dev() {
log!("Hit eager state")
}
return;
}
}
}
...
}
This article concludes that the reason React optimization is not thorough is because there are two FiberNode trees in React. When a click occurs, only the "update flags" on one tree are cleared, so an additional execution is needed to ensure that the "update flags" on both trees are cleared. Therefore, if an additional line of code is added here, it can achieve thorough optimization.
pub fn begin_work(
work_in_progress: Rc<RefCell<FiberNode>>,
render_lane: Lane,
) -> Result<Option<Rc<RefCell<FiberNode>>>, JsValue> {
...
work_in_progress.borrow_mut().lanes = Lane::NoLane;
+ if current.is_some() {
+ let current = current.clone().unwrap();
+ current.borrow_mut().lanes = Lane::NoLane;
+ }
...
}
Please kindly give me a star!