This tutorial is based on this tutorial, but with JSX, typescript and an easier approach to implement. You can checkout the notes and code on my GitHub repo.
This part we will render the vDOM to the actual DOM. In addition, we will also introduce fiber tree, which a core structure in React.
Rendering vDOM is, simple- too simple. You need to know the following web native APIs.
Woa, a bit too much, right? But all you need to do is mirroring the creation of vDOM to the actual DOM. Here is a simple example.
function render(vDom: VDomNode, parent: HTMLElement) { if (typeof vDom === 'string') { parent.appendChild(document.createTextNode(vDom)) } else if (vDom.kind === 'element') { const element = document.createElement(vDom.tag) for (const [key, value] of Object.entries(vDom.props ?? {})) { if (key === 'key') continue if (key.startsWith('on')) { element.addEventListener(key.slice(2).toLowerCase(), value as EventListener) } else { element.setAttribute(key, value as string) } } for (const child of vDom.children ?? []) { render(child, element) } parent.appendChild(element) } else { for (const child of vDom.children ?? []) { render(child, parent) } } }
We registered properties starting with on as event listeners, this is a common practice in React. Also, we ignored the key property, which is used for reconciliation, not for rendering.
Okay, so rendering done and this chapter ends...? No.
In real react, the rendering process is a bit more complicated. To be more specific, it will use requestIdleCallback, to make more urgent tasks to be done first, lowering its own priority.
Please note that requestIdleCallback is not supported on Safari, on both MacOS and iOS (Apple Engineers, please, why? At least they are working on it, at 2024). If you are on a Mac, use chrome, or replace it with a simple setTimeout. In real react, it uses scheduler to handle this, but the basic idea is the same.
To do so, we need to know the following web native APIs.
So we need to split our rendering in chunks, and use requestIdleCallback to handle it. A simple way would be to just render one node at a time. It is easy- but do not be eager to do so- or you'll waste a lot of time, since we also need other work to be done while rendering.
But we can have the following code as a basic framework for what we are going to do.
import { createDom, VDomNode } from "./v-dom" interface Fiber { parent: Fiber | null sibling: Fiber | null child: Fiber | null vDom: VDomNode, dom: HTMLElement | Text | null } let nextUnitOfWork: Fiber | null = null function workLoop(deadline: IdleDeadline) { let shouldYield = false while (nextUnitOfWork && !shouldYield) { nextUnitOfWork = performUnitOfWork( nextUnitOfWork ) shouldYield = deadline.timeRemaining()If you now fill // TODO with rendering vDOM, and return the next vDOM node to be rendered, you can have a simple idle time rendering. But don't be hasty- we need more work.
Fiber Tree
In the next chapter, we will implement reactivity, and the reconciliation is rather complicated- so we move some content into this part, that is the fiber tree.
Fiber tree is just a special data structure. When react handles changes, it does the following process.
- Something, may be a user, or initial rendering, triggers a change.
- React creates a new vDOM tree.
- React calculate the new fiber tree.
- React calculates the difference between the old fiber tree and the new fiber tree.
- React applies the difference to the actual DOM.
You can see, fiber tree is essential for React.
The fiber tree, a little bit different from traditional tree, has three types of relations between nodes.
For example, for the following DOM,
We can represent it as a tree.
div ├── p └── div ├── h1 └── h2
p is a child of the root div, but the secondary div is not a child of the root div, but a sibling of p. h1 and h2 are children of the secondary div.
When it comes to rendering, the order is mainly depth-first, but kind of different- so basically, it follows these rules. For each node, it goes through the following steps.
Now let's implement that. But first, we need to trigger the rendering process. It is simple- just set the nextUnitOfWork to the root of the fiber tree.
export function render(vDom: VDomNode, parent: HTMLElement) { nextUnitOfWork = { parent: null, sibling: null, child: null, vDom: vDom, dom: parent } }
After triggering the rendering, browser will call performUnitOfWork, this is where we, well, perform the work.
The first is that we need to create actual DOM elements. We can do this by creating a new DOM element, and append it to the parent DOM element.
function isString(value: VDomNode): value is string { return typeof value === 'string' } function isElement(value: VDomNode): value is VDomElement { return typeof value === 'object' } export function createDom(vDom: VDomNode): HTMLElement | Text | DocumentFragment { if (isString(vDom)) { return document.createTextNode(vDom) } else if (isElement(vDom)) { const element = document.createElement(vDom.tag === '' ? 'div' : vDom.tag) Object.entries(vDom.props ?? {}).forEach(([name, value]) => { if (value === undefined) return if (name === 'key') return if (name.startsWith('on') && value instanceof Function) { element.addEventListener(name.slice(2).toLowerCase(), value as EventListener) } else { element.setAttribute(name, value.toString()) } }) return element } else { throw new Error('Unexpected vDom type') } }
function performUnitOfWork(nextUnitOfWork: Fiber | null): Fiber | null { if(!nextUnitOfWork) { return null } if(!nextUnitOfWork.dom) { nextUnitOfWork.dom = createDom(nextUnitOfWork.vDom) } if(nextUnitOfWork.parent && nextUnitOfWork.parent.dom) { nextUnitOfWork.parent.dom.appendChild(nextUnitOfWork.dom) } // TODO throw new Error('Not implemented') }
This is the first part of the work. Now we need to construct the fiber branching out from the current one.
const fiber = nextUnitOfWork if (isElement(fiber.vDom)) { const elements = fiber.vDom.children ?? [] let index = 0 let prevSibling = null while (indexNow we have a fiber tree built for the current node. Now let's follow our rules to process the fiber tree.
if (fiber.child) { return fiber.child } let nextFiber: Fiber | null = fiber while (nextFiber) { if (nextFiber.sibling) { return nextFiber.sibling } nextFiber = nextFiber.parent } return nullNow we can render the vDOM, here it is. Please note that typescript is being stupid here since it can not tell the type of our virtual DOM, we need an ugly bypass here.
import { render } from "./runtime"; import { createElement, fragment, VDomNode } from "./v-dom"; function App() { returna
b
> } const app = document.getElementById('app') const vDom: VDomNode = App() as unknown as VDomNode render(vDom, app!)Now your vDOM is rendered to the actual DOM. Congratulations! You have done a great job. But we are not done yet.
Cumulative Commit
There will be a problem with the current implementation- if we have too many nodes that slows the whole process down, the user will see how the rendering is done. Of course, it won't leak commercial secrets or something, but it is not a good experience. We'd rather hide the dom creation behind the curtain, the submit it all at once.
The solution is simple- instead of directly committing to the document, we create an element without adding it to the document, and when we are done, we add it to the document. This is called cumulative commit.
let wip: Fiber | null = null let wipParent: HTMLElement | null = null export function render(vDom: VDomNode, parent: HTMLElement) { wip = { parent: null, sibling: null, child: null, vDom: vDom, dom: null, } wipParent = parent nextUnitOfWork = wip }Now, we remove the appendChild from performUnitOfWork, that is, the following part,
if(nextUnitOfWork.parent && nextUnitOfWork.parent.dom) { nextUnitOfWork.parent.dom.appendChild(nextUnitOfWork.dom) }Now if we finish all the work, we have all the fiber correctly built up with their DOM, but they are not added to the document. When such event dispatches, we call a commit function, which will add the DOM to the document.
function commit() { } function workLoop(deadline: IdleDeadline) { let shouldYield = false while (nextUnitOfWork && !shouldYield) { nextUnitOfWork = performUnitOfWork( nextUnitOfWork ) if(!nextUnitOfWork && wip) { commit() } shouldYield = deadline.timeRemaining()Now, the commit function is simple- just add all the children DOM recursively to the wip, then commit wip to the DOM.
function commit() { function commitChildren(fiber: Fiber | null) { if(!fiber) { return } if(fiber.dom && fiber.parent?.dom) { fiber.parent.dom.appendChild(fiber.dom) } commitChildren(fiber.child) commitChildren(fiber.sibling) } commitChildren(wip) wipParent?.appendChild(wip!.dom!) wip = null }You can test this out by adding a timeout to commitChildren function. previously, the rendering was done step by step, but now it is done all at once.
Nested Components
You may try nested functions- like the following,
import { render } from "./runtime"; import { createElement, fragment, VDomNode } from "./v-dom"; function App() { returna
b
> } function Wrapper() { returnc
} const app = document.getElementById('app') const vDom: VDomNode = Wrapper() as unknown as VDomNode render(vDom, app!)a
b
But it won't work, since when parsing the JSX, tag is just the label name. Sure, for native elements, it is just a string, but for components, it is a function. So in the process of converting JSX to vDOM, we need to check if the tag is a function, and if so, call it.
export function createElement(tag: string | Function, props: VDomAttributes, ...children: VDomNode[]): VDomElement { if (tag instanceof Function) { return tag(props, children) } return { kind: tag === '' ? 'fragment' : 'element', tag, children, props: props ?? {}, key: props?.key ?? undefined } }Now, props and children are required for each component. In real React, they added extra field to check- you can imagine, just by replacing functions with classes, so you have extra fields- then you provide new function to create objects, a typical factory pattern- but we take a lazy we here.
import { render } from "./runtime"; import { createElement, fragment, VDomAttributes, VDomNode } from "./v-dom"; type FuncComponent = (props: VDomAttributes, children: VDomNode[]) => JSX.Element const Wrapper: FuncComponent = (_: VDomAttributes, __: VDomNode[]) => { return} const app = document.getElementById('app') const vDom: VDomNode = Wrapper({}, []) as unknown as VDomNode console.log(vDom) render(vDom, app!)aa
>Please note that in the real React, the function component call is delayed to the fiber building stage. Nonetheless, we did so for convenience, and it doesn't really harm the purpose of this series.
Fragment
However, it's still not enough. Previously, we just treated fragment as div, which is not correct. But if you just replace that with a document fragment, it won't work. The reason for this is because fragments is a one-time container- which leads to a strange behaviour- like you cannot take real things out of it, and you can not nest them, and many strange things (really, why it just won't work simpler...). So, fuck, we need to dig this shit up.
So the solution is that, we do not create DOM for fragment- we find the correct parent to add the DOM.
We need,
export function isFragment(value: VDomNode): value is VDomElement { return isElement(value) && value.kind === 'fragment' }And change the rendering,
function commit() { function commitChildren(fiber: Fiber | null) { if(!fiber) { return } if(fiber.dom && fiber.parent?.dom) { fiber.parent?.dom?.appendChild(fiber.dom) } if(fiber.dom && fiber.parent && isFragment(fiber.parent.vDom)) { let parent = fiber.parent // find the first parent that is not a fragment while(parent && isFragment(parent.vDom)) { // the root element is guaranteed to not be a fragment has has a non-fragment parent parent = parent.parent! } parent.dom?.appendChild(fiber.dom!) } commitChildren(fiber.child) commitChildren(fiber.sibling) } commitChildren(wip) wipParent?.appendChild(wip!.dom!) wip = null }Now, the fragment is correctly handled.
Disclaimer: All resources provided are partly from the Internet. If there is any infringement of your copyright or other rights and interests, please explain the detailed reasons and provide proof of copyright or rights and interests and then send it to the email: [email protected] We will handle it for you as soon as possible.
Copyright© 2022 湘ICP备2022001581号-3