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:v8
The previous article implemented support for the FunctionComponent
type, but it doesn't support Hooks yet. In this article, we'll use useState
as an example to explain how to implement it.
If you frequently use React, you may have wondered about this: useState
is imported from the react
library, but its actual implementation is in react-reconciler
. How is that achieved? Does React depend on react-reconciler
?
To understand this issue, let's analyze Big React.
First, let's take a look at the entry file for useState
:
// react/index.ts
import currentDispatcher, {
Dispatcher,
resolveDispatcher
} from './src/currentDispatcher';
export const useState = <State>(initialState: (() => State) | State) => {
const dispatcher = resolveDispatcher() as Dispatcher;
return dispatcher.useState<State>(initialState);
};
export const __SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED = {
currentDispatcher
};
// react/src/currentDispatcher.ts
...
const currentDispatcher: { current: null | Dispatcher } = {
current: null
};
export const resolveDispatcher = () => {
const dispatcher = currentDispatcher.current;
if (dispatcher === null) {
console.error('resolve dispatcher时dispatcher不存在');
}
return dispatcher;
};
export default currentDispatcher;
The code is straightforward. When useState
is executed, the core logic involves calling the useState
method on currentDispatcher.current
. It's evident that currentDispatcher.current
is initially set to null
. So, where is it assigned a value? The answer lies in renderWithHooks
:
// react-reconciler/src/fiberHooks.ts
export const renderWithHooks = (workInProgress: FiberNode) => {
...
currentDispatcher.current = HooksDispatcherOnMount
...
}
Moreover, the currentDispatcher
here is not directly imported from react
, but from the shared
library. And shared
ultimately imports __SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED
from react
, which contains the currentDispatcher
property:
// react-reconciler/src/fiberHooks.ts
import sharedInternals from 'shared/internals'
const {currentDispatcher} = sharedInternals
// shared/internals.ts
import * as React from 'react'
const internals = React.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED
export default internals
// react/index.ts
export const __SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED = {
currentDispatcher,
}
So, it forms a dependency relationship like this:
react-dom ---depend on--> react-reconciler ---depend on--> shared ---depend on--> react
During bundling, react
and shared
are bundled together into a react.js
file. When bundling react-dom
, react
needs to be specified as an external dependency. This means that the resulting react-dom.js
file won't include the code for react
but will treat it as an external dependency:
react + shared => react.js
react-dom + react-reconciler + shared => react-dom.js
This approach allows for easy replacement of the renderer. For example, if you want to implement react-noop
for testing purposes:
react-noop + react-reconciler + shared => react-noop.js
However, it's apparent that WASM builds don't support externals. So, what can be done? Upon reconsideration, it's realized that to meet the requirements mentioned above, two key points need to be addressed:
- React and renderer code should be bundled separately.
- The renderer should depend on React and be able to modify the values of variables in React at runtime.
We have already achieved the separation of bundling. Now, to implement the second point, which is modifying a variable's value in one WASM module from another WASM module, we refer to the documentation of wasm-bindgen
and discover that besides exporting functions from WASM for JavaScript usage, it's also possible to import functions from JavaScript for WASM to invoke:
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
extern "C" {
fn alert(s: &str);
}
#[wasm_bindgen]
pub fn greet(name: &str) {
// the alert is from JS
alert(&format!("Hello, {}!", name));
}
So, we can achieve the modification of a variable's value in one WASM module from another by using JavaScript as an intermediary. The specific approach is as follows:
We export an updateDispatcher
method from react
to JavaScript, which is used to update CURRENT_DISPATCHER.current
in react
.
fn derive_function_from_js_value(js_value: &JsValue, name: &str) -> Function {
Reflect::get(js_value, &name.into()).unwrap().dyn_into::<Function>().unwrap()
}
#[wasm_bindgen(js_name = updateDispatcher)]
pub unsafe fn update_dispatcher(args: &JsValue) {
let use_state = derive_function_from_js_value(args, "use_state");
CURRENT_DISPATCHER.current = Some(Box::new(Dispatcher::new(use_state)))
}
Then, we declare the import of this method in react-reconciler
(for simplicity, we omitted importing from shared
here):
#[wasm_bindgen]
extern "C" {
fn updateDispatcher(args: &JsValue);
}
During render_with_hooks
, the updateDispatcher
is called, passing an Object
that contains the use_state
property:
fn update_mount_hooks_to_dispatcher() {
let object = Object::new();
let closure = Closure::wrap(Box::new(mount_state) as Box<dyn Fn(&JsValue) -> Vec<JsValue>>);
let function = closure.as_ref().unchecked_ref::<Function>().clone();
closure.forget();
Reflect::set(&object, &"use_state".into(), &function).expect("TODO: panic set use_state");
updateDispatcher(&object.into());
}
Finally, we need to insert a piece of code at the top of the bundled react-dom/index_bg.js
file to import the updateDispatcher
method from react
:
import {updateDispatcher} from 'react'
Certainly, this step can be implemented using a script.
To summarize, the above process can be represented simply as:
The details of this update can be found here.
Let's test it by modifying the hello-world example:
import {useState} from 'react'
function App() {
const [name, setName] = useState(() => 'ayou')
setTimeout(() => {
setName('ayouayou')
}, 1000)
return (
<div>
<Comp>{name}</Comp>
</div>
)
}
function Comp({children}) {
return (
<span>
<i>{`Hello world, ${children}`}</i>
</span>
)
}
export default App
The result is shown below:
It looks strange, right? That's because we haven't fully implemented the update process yet.
So far, we have replicated the Big React v3 version. Please kindly give it a star!