feat: allow to modify rendered tree
This commit is contained in:
parent
36cb4e667d
commit
1dc675661a
3 changed files with 220 additions and 60 deletions
250
src/lib/YarJS.ts
250
src/lib/YarJS.ts
|
|
@ -1,10 +1,16 @@
|
||||||
import {
|
import {
|
||||||
|
YarEffectTag,
|
||||||
YarElement,
|
YarElement,
|
||||||
YarFiber,
|
YarFiber,
|
||||||
YarHTMLTagName,
|
YarHTMLTagName,
|
||||||
YarProps,
|
YarProps,
|
||||||
} from "./YarJs.interfaces";
|
} from "./YarJs.interfaces";
|
||||||
|
|
||||||
|
let FIBER_TO_PROCESS: YarFiber | null | undefined = null;
|
||||||
|
let WIP_ROOT: YarFiber | null | undefined = null;
|
||||||
|
let CURRENT_ROOT: YarFiber | null | undefined = null;
|
||||||
|
let FIBERS_TO_DELETE: YarFiber[] = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This function converts Text into Dom Text Elements
|
* This function converts Text into Dom Text Elements
|
||||||
*/
|
*/
|
||||||
|
|
@ -37,82 +43,138 @@ export function createElement(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dynamically generate a Dom node depending on it's type
|
||||||
|
* @param fiber - The Fiber Tree element to generate a DOM node
|
||||||
|
* @returns a new DOM Node
|
||||||
|
*/
|
||||||
function generateDomNode(fiber: YarFiber) {
|
function generateDomNode(fiber: YarFiber) {
|
||||||
const dom =
|
const dom =
|
||||||
fiber.type === "TEXT"
|
fiber.type === "TEXT"
|
||||||
? document.createTextNode("")
|
? document.createTextNode("")
|
||||||
: document.createElement(fiber.type);
|
: document.createElement(fiber.type);
|
||||||
|
|
||||||
Object.keys(fiber.props)
|
updateDom(dom, {}, fiber.props);
|
||||||
.filter((key) => key !== "children")
|
|
||||||
.forEach((key) => {
|
|
||||||
// @ts-expect-error: I cannot figure it out how to properly type the dom props
|
|
||||||
dom[key] = fiber.props[key];
|
|
||||||
});
|
|
||||||
|
|
||||||
return dom;
|
return dom;
|
||||||
}
|
}
|
||||||
|
|
||||||
let nextUnitOfWork: YarFiber | null | undefined = null;
|
/**
|
||||||
let wipRoot: YarFiber | null | undefined = null;
|
* Apply a set of props to the DOM element without affecting it's placement
|
||||||
|
* @param dom - Element to update
|
||||||
|
* @param prevProps - Previos set of params
|
||||||
|
* @param nextProps - New set of params
|
||||||
|
*/
|
||||||
|
function updateDom(
|
||||||
|
dom: HTMLElement | Text,
|
||||||
|
prevProps: Record<string, unknown>,
|
||||||
|
nextProps: Record<string, unknown>,
|
||||||
|
) {
|
||||||
|
const isEvent = (key: string) => key.startsWith("on");
|
||||||
|
const isProperty = (key: string) => key !== "children" && !isEvent(key);
|
||||||
|
const isNew =
|
||||||
|
(prev: Record<string, unknown>, next: Record<string, unknown>) =>
|
||||||
|
(key: string) =>
|
||||||
|
prev[key] !== next[key];
|
||||||
|
const isGone =
|
||||||
|
(_prev: Record<string, unknown>, next: Record<string, unknown>) =>
|
||||||
|
(key: string) =>
|
||||||
|
!(key in next);
|
||||||
|
|
||||||
|
// Remove old properties
|
||||||
|
Object.keys(prevProps)
|
||||||
|
.filter(isProperty)
|
||||||
|
.filter(isGone(prevProps, nextProps))
|
||||||
|
.forEach((name) => {
|
||||||
|
// @ts-expect-error: I cannot figure it out how to properly type the dom props
|
||||||
|
dom[name] = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set new or changed properties
|
||||||
|
Object.keys(nextProps)
|
||||||
|
.filter(isProperty)
|
||||||
|
.filter(isNew(prevProps, nextProps))
|
||||||
|
.forEach((name) => {
|
||||||
|
// @ts-expect-error: I cannot figure it out how to properly type the dom props
|
||||||
|
dom[name] = nextProps[name];
|
||||||
|
});
|
||||||
|
|
||||||
|
//Remove old or changed event listeners
|
||||||
|
Object.keys(prevProps)
|
||||||
|
.filter(isEvent)
|
||||||
|
.filter((key) => !(key in nextProps) || isNew(prevProps, nextProps)(key))
|
||||||
|
.forEach((name) => {
|
||||||
|
const eventType = name.toLowerCase().substring(2);
|
||||||
|
dom.removeEventListener(
|
||||||
|
eventType,
|
||||||
|
prevProps[name] as EventListenerOrEventListenerObject,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add event listeners
|
||||||
|
Object.keys(nextProps)
|
||||||
|
.filter(isEvent)
|
||||||
|
.filter(isNew(prevProps, nextProps))
|
||||||
|
.forEach((name) => {
|
||||||
|
const eventType = name.toLowerCase().substring(2);
|
||||||
|
dom.addEventListener(
|
||||||
|
eventType,
|
||||||
|
nextProps[name] as EventListenerOrEventListenerObject,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Actually commit the processed Fiber to the real DOM
|
||||||
|
* @param fiber - The Fiber Element to process
|
||||||
|
*/
|
||||||
function commitWork(fiber: YarFiber | null) {
|
function commitWork(fiber: YarFiber | null) {
|
||||||
if (!fiber) return;
|
if (!fiber) return;
|
||||||
|
|
||||||
const domParent = fiber.parent!.dom!;
|
const domParent = fiber.parent!.dom!;
|
||||||
domParent.appendChild(fiber.dom!);
|
|
||||||
|
switch (fiber.effectTag) {
|
||||||
|
case YarEffectTag.Placement:
|
||||||
|
domParent.appendChild(fiber.dom!);
|
||||||
|
break;
|
||||||
|
case YarEffectTag.Deletion:
|
||||||
|
domParent.removeChild(fiber.dom!);
|
||||||
|
break;
|
||||||
|
case YarEffectTag.Update:
|
||||||
|
updateDom(fiber.dom!, fiber.alternate!.props, fiber.props);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
commitWork(fiber.child);
|
commitWork(fiber.child);
|
||||||
commitWork(fiber.sibling);
|
commitWork(fiber.sibling);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Commit the work to be proccesed into the real DOM from it's root */
|
||||||
function commitRoot() {
|
function commitRoot() {
|
||||||
if (!wipRoot) return;
|
if (!WIP_ROOT) return;
|
||||||
|
|
||||||
commitWork(wipRoot.child);
|
FIBERS_TO_DELETE.forEach(commitWork);
|
||||||
wipRoot = null;
|
commitWork(WIP_ROOT.child);
|
||||||
|
CURRENT_ROOT = WIP_ROOT;
|
||||||
|
WIP_ROOT = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process the Fiber Tree, this is a representation of the DOM
|
||||||
|
* Since this process could be interrupted before the whole
|
||||||
|
* tree is processed, we just do the representation and computation here,
|
||||||
|
* then in "commitWork" we add the representation to the actual DOM
|
||||||
|
*
|
||||||
|
* @param fiber - The Fiber to process
|
||||||
|
*/
|
||||||
function processFiber(fiber: YarFiber): YarFiber | undefined {
|
function processFiber(fiber: YarFiber): YarFiber | undefined {
|
||||||
// Process the Fiber Tree, this is a representation of the DOM
|
|
||||||
// Since this this process could be interrupted before the whole
|
|
||||||
// tree is processed, we just do the representation and computation here,
|
|
||||||
// then in "commitWork" we add the representation to the actual DOM
|
|
||||||
|
|
||||||
if (!fiber.dom) {
|
if (!fiber.dom) {
|
||||||
// Create the actual dom element
|
// Create the actual dom element
|
||||||
fiber.dom = generateDomNode(fiber);
|
fiber.dom = generateDomNode(fiber);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a new fiber for each child
|
// Create a new fiber for each child
|
||||||
const elements = fiber.props.children;
|
reconcileChildren(fiber, fiber.props.children);
|
||||||
let index = 0;
|
|
||||||
let prevSibling: YarFiber | null = null;
|
|
||||||
|
|
||||||
while (index < elements.length) {
|
|
||||||
const element = elements[index];
|
|
||||||
|
|
||||||
const newFiber = {
|
|
||||||
parent: fiber,
|
|
||||||
type: element.type,
|
|
||||||
props: element.props,
|
|
||||||
child: null,
|
|
||||||
sibling: null,
|
|
||||||
dom: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Each Fiber only holds a reference to it's first child (in the Fiber Tree representation),
|
|
||||||
// so if is the first new Fiber we add it as a child to the parent,
|
|
||||||
// if is not, we add as a sibling of the last child
|
|
||||||
// Nonetheless, each fiber has a reference to it's parent
|
|
||||||
if (index === 0) {
|
|
||||||
fiber.child = newFiber;
|
|
||||||
} else if (prevSibling !== null) {
|
|
||||||
prevSibling.sibling = newFiber;
|
|
||||||
}
|
|
||||||
|
|
||||||
prevSibling = newFiber;
|
|
||||||
index++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Search for the new fiber that needs to be processed
|
// Search for the new fiber that needs to be processed
|
||||||
if (fiber.child) {
|
if (fiber.child) {
|
||||||
|
|
@ -135,17 +197,94 @@ function processFiber(fiber: YarFiber): YarFiber | undefined {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* Process the children of the Fiber node, deciding for each child
|
||||||
|
* if it needs to be updated, added or deleted based on the last render tree.
|
||||||
|
* @param fiber - Fiber element to process
|
||||||
|
* @param elements - Children to add
|
||||||
|
*/
|
||||||
|
function reconcileChildren(fiber: YarFiber, elements: YarElement[]) {
|
||||||
|
let index = 0;
|
||||||
|
let prevSibling: YarFiber | null = null;
|
||||||
|
let oldFiber = fiber.alternate?.child; // old element rendered
|
||||||
|
let newFiber: YarFiber | null = null;
|
||||||
|
|
||||||
|
while (index < elements.length || oldFiber) {
|
||||||
|
const element = elements[index]; // new element to render
|
||||||
|
|
||||||
|
// NOTE: Here React also uses keys, that makes a better reconciliation.
|
||||||
|
// For example, it detects when children change places in the element array.
|
||||||
|
|
||||||
|
const isSameType = oldFiber?.type === element.type;
|
||||||
|
|
||||||
|
if (isSameType) {
|
||||||
|
// update the node
|
||||||
|
newFiber = {
|
||||||
|
parent: fiber,
|
||||||
|
type: oldFiber!.type,
|
||||||
|
props: element.props,
|
||||||
|
alternate: oldFiber,
|
||||||
|
dom: oldFiber!.dom,
|
||||||
|
child: null,
|
||||||
|
sibling: null,
|
||||||
|
effectTag: YarEffectTag.Update,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isSameType && element !== null && element !== undefined) {
|
||||||
|
// add new node
|
||||||
|
newFiber = {
|
||||||
|
parent: fiber,
|
||||||
|
type: element.type,
|
||||||
|
props: element.props,
|
||||||
|
child: null,
|
||||||
|
sibling: null,
|
||||||
|
dom: null,
|
||||||
|
alternate: null,
|
||||||
|
effectTag: YarEffectTag.Placement,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isSameType && oldFiber !== null && oldFiber !== undefined) {
|
||||||
|
// Delete old node
|
||||||
|
oldFiber!.effectTag = YarEffectTag.Deletion;
|
||||||
|
FIBERS_TO_DELETE.push(oldFiber!);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oldFiber) {
|
||||||
|
oldFiber = oldFiber.sibling;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each Fiber only holds a reference to it's first child (in the Fiber Tree representation),
|
||||||
|
// so if is the first new Fiber we add it as a child to the parent,
|
||||||
|
// if is not, we add as a sibling of the last child
|
||||||
|
// Nonetheless, each fiber has a reference to it's parent
|
||||||
|
if (index === 0) {
|
||||||
|
fiber.child = newFiber;
|
||||||
|
} else if (prevSibling !== null) {
|
||||||
|
prevSibling.sibling = newFiber;
|
||||||
|
}
|
||||||
|
|
||||||
|
prevSibling = newFiber;
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start processing the fiber tree in the background while it has work to do.
|
||||||
|
*
|
||||||
* This function should be passed on requestIdleCallback
|
* This function should be passed on requestIdleCallback
|
||||||
* so the browser can process it while idle
|
* so the browser can process it while idle.
|
||||||
|
*
|
||||||
|
* @param deadline - IdleDeadline procided by the browser
|
||||||
*/
|
*/
|
||||||
function processFiberTree(deadline: IdleDeadline) {
|
function processFiberTree(deadline: IdleDeadline) {
|
||||||
let shouldYield = false;
|
let shouldYield = false;
|
||||||
while (nextUnitOfWork && !shouldYield) {
|
while (FIBER_TO_PROCESS && !shouldYield) {
|
||||||
nextUnitOfWork = processFiber(nextUnitOfWork);
|
FIBER_TO_PROCESS = processFiber(FIBER_TO_PROCESS);
|
||||||
shouldYield = deadline.timeRemaining() < 1;
|
shouldYield = deadline.timeRemaining() < 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!nextUnitOfWork && wipRoot) {
|
if (!FIBER_TO_PROCESS && WIP_ROOT) {
|
||||||
commitRoot();
|
commitRoot();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -155,24 +294,29 @@ function processFiberTree(deadline: IdleDeadline) {
|
||||||
requestIdleCallback(processFiberTree);
|
requestIdleCallback(processFiberTree);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add a new element to render into the DOM
|
* Render an element into a container.
|
||||||
|
* @param element - New element to render
|
||||||
|
* @param container - The parent DOM to add the element into
|
||||||
*/
|
*/
|
||||||
export function render(
|
export function render(
|
||||||
element: React.JSX.Element,
|
element: React.JSX.Element,
|
||||||
container: HTMLElement,
|
container: HTMLElement,
|
||||||
): void {
|
): void {
|
||||||
wipRoot = {
|
WIP_ROOT = {
|
||||||
type: "",
|
type: "",
|
||||||
dom: container,
|
dom: container,
|
||||||
parent: null,
|
parent: null,
|
||||||
child: null,
|
child: null,
|
||||||
sibling: null,
|
sibling: null,
|
||||||
|
alternate: CURRENT_ROOT,
|
||||||
|
effectTag: null,
|
||||||
props: {
|
props: {
|
||||||
children: [element],
|
children: [element],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
nextUnitOfWork = wipRoot;
|
FIBERS_TO_DELETE = [];
|
||||||
|
FIBER_TO_PROCESS = WIP_ROOT;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Fragment() {}
|
export function Fragment() {}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,11 @@
|
||||||
export type YarHTMLTagName = keyof React.JSX.IntrinsicElements | string;
|
export type YarHTMLTagName = keyof React.JSX.IntrinsicElements | string;
|
||||||
|
|
||||||
|
export enum YarEffectTag {
|
||||||
|
Update,
|
||||||
|
Placement,
|
||||||
|
Deletion,
|
||||||
|
}
|
||||||
|
|
||||||
export type YarProps = {
|
export type YarProps = {
|
||||||
children: YarElement[];
|
children: YarElement[];
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
|
|
@ -15,4 +21,6 @@ export interface YarFiber extends YarElement {
|
||||||
dom: null | HTMLElement | Text;
|
dom: null | HTMLElement | Text;
|
||||||
child: null | YarFiber;
|
child: null | YarFiber;
|
||||||
sibling: null | YarFiber;
|
sibling: null | YarFiber;
|
||||||
|
alternate?: null | YarFiber;
|
||||||
|
effectTag: null | YarEffectTag;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
22
src/main.tsx
22
src/main.tsx
|
|
@ -3,11 +3,19 @@ import { render } from "./lib/YarJS";
|
||||||
|
|
||||||
const root = document.querySelector<HTMLDivElement>("#app")!;
|
const root = document.querySelector<HTMLDivElement>("#app")!;
|
||||||
|
|
||||||
const element = (
|
const rerender = (value: string) => {
|
||||||
<div id="foo">
|
const onChange = (e: React.FormEvent<HTMLInputElement>) => {
|
||||||
<a>bar</a>
|
rerender(e.currentTarget.value);
|
||||||
<b />
|
};
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
render(element, root);
|
const element = (
|
||||||
|
<div id="foo">
|
||||||
|
<input type="text" onInput={onChange} value={value} />
|
||||||
|
<h2>Hello {value}</h2>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
render(element, root);
|
||||||
|
};
|
||||||
|
|
||||||
|
rerender("foo");
|
||||||
|
|
|
||||||
Reference in a new issue