Implement React v18 from Scratch Using WASM and Rust - [8] Support Hooks

paradeto

ayou

Posted on April 26, 2024

Implement React v18 from Scratch Using WASM and Rust - [8] Support Hooks

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;
Enter fullscreen mode Exit fullscreen mode

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
  ...
}
Enter fullscreen mode Exit fullscreen mode

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,
}
Enter fullscreen mode Exit fullscreen mode

So, it forms a dependency relationship like this:

react-dom ---depend on--> react-reconciler ---depend on--> shared ---depend on--> react
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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));
}
Enter fullscreen mode Exit fullscreen mode

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)))
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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());
}
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

Certainly, this step can be implemented using a script.

To summarize, the above process can be represented simply as:

Image description

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
Enter fullscreen mode Exit fullscreen mode

The result is shown below:

Image description

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!

💖 💪 🙅 🚩
paradeto
ayou

Posted on April 26, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related