Skip to content

Commit

Permalink
Merge pull request #109 from aidenybai/efficient-keyed-diffing
Browse files Browse the repository at this point in the history
  • Loading branch information
aidenybai authored Aug 15, 2021
2 parents 1fc2c03 + 80f2f14 commit 6e2f2d2
Show file tree
Hide file tree
Showing 3 changed files with 184 additions and 39 deletions.
2 changes: 1 addition & 1 deletion scripts/dev.sh
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,4 @@ if [ ! -d dev ]; then
info "Couldn't find an the \`dev\` directory, creating one for you..."
fi

vite --host
vite
44 changes: 43 additions & 1 deletion src/__test__/patch.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { createElement } from '../createElement';
import { m, INSERT, UPDATE, DELETE } from '../m';
import { patch } from '../patch';
// import { patch } from '../patch';
import { VFlags, VNode, VProps } from '../structs';

const h = (tag: string, props?: VProps, ...children: VNode[]) =>
Expand Down Expand Up @@ -132,4 +131,47 @@ describe('.patch', () => {
);
expect(el.childNodes.length).toEqual(0);
});

it('should used keyed algorithm when flag is passed', () => {
const el = document.createElement('ul');
const list1 = ['foo', 'bar', 'baz'];
const newVNode1 = m(
'ul',
undefined,
list1.map((item) => m('li', { key: item }, [item])),
VFlags.ONLY_KEYED_CHILDREN,
);
patch(el, newVNode1, m('ul', undefined, undefined, VFlags.NO_CHILDREN));
expect(el).toEqual(createElement(newVNode1));

const list2 = ['foo', 'baz', 'bar'];
const newVNode2 = m(
'ul',
undefined,
list2.map((item) => m('li', { key: item }, [item])),
VFlags.ONLY_KEYED_CHILDREN,
);
patch(el, newVNode2, newVNode1);
expect(el).toEqual(createElement(newVNode2));

const list3 = ['foo0', 'foo', 'bar', 'baz', 'foo1', 'bar1', 'baz1'];
const newVNode3 = m(
'ul',
undefined,
list3.map((item) => m('li', { key: item }, [item])),
VFlags.ONLY_KEYED_CHILDREN,
);
patch(el, newVNode3, newVNode2);
expect(el).toEqual(createElement(newVNode3));

const list4 = list3.reverse();
const newVNode4 = m(
'ul',
undefined,
list4.map((item) => m('li', { key: item }, [item])),
VFlags.ONLY_KEYED_CHILDREN,
);
patch(el, newVNode4, newVNode3);
expect(el).toEqual(createElement(newVNode4));
});
});
177 changes: 140 additions & 37 deletions src/patch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,22 +16,39 @@ import {
* @param {VProps} newProps - New VNode props
* @returns {void}
*/
export const patchProps = (el: HTMLElement, oldProps: VProps, newProps: VProps): void => {
export const patchProps = (
el: HTMLElement,
oldProps: VProps,
newProps: VProps,
workQueue: (() => void)[],
): void => {
const skip = new Set<string>();

for (const oldPropName of Object.keys(oldProps)) {
const newPropValue = newProps[oldPropName];
if (newPropValue) {
el[oldPropName] = newPropValue;
const oldPropValue = oldProps[oldPropName];
if (newPropValue !== oldPropValue) {
if (typeof oldPropValue === 'function' && typeof newPropValue === 'function') {
if (oldPropValue.toString() !== newPropValue.toString()) {
workQueue.push(() => (el[oldPropName] = newPropValue));
}
} else {
workQueue.push(() => (el[oldPropName] = newPropValue));
}
}
skip.add(oldPropName);
} else {
el.removeAttribute(oldPropName);
delete el[oldPropName];
workQueue.push(() => {
el.removeAttribute(oldPropName);
delete el[oldPropName];
});
}
}

for (const newPropName of Object.keys(newProps)) {
if (!skip.has(newPropName)) {
el[newPropName] = newProps[newPropName];
workQueue.push(() => (el[newPropName] = newProps[newPropName]));
}
}
};
Expand All @@ -48,95 +65,175 @@ export const patchChildren = (
oldVNodeChildren: VNode[],
newVNodeChildren: VNode[],
keyed: boolean,
delta?: VDelta,
delta: VDelta | undefined,
workQueue: (() => void)[],
): void => {
if (!newVNodeChildren) {
el.textContent = '';
workQueue.push(() => (el.textContent = ''));
} else if (delta) {
for (let i = 0; i < delta.length; i++) {
const [deltaType, deltaPosition] = delta[i];
switch (deltaType) {
case VDeltaOperationTypes.INSERT: {
el.insertBefore(
createElement(newVNodeChildren[deltaPosition]),
el.childNodes[deltaPosition],
case VDeltaOperationTypes.INSERT:
workQueue.push(() =>
el.insertBefore(
createElement(newVNodeChildren[deltaPosition]),
el.childNodes[deltaPosition],
),
);
break;
}
case VDeltaOperationTypes.UPDATE: {
case VDeltaOperationTypes.UPDATE:
patch(
<HTMLElement | Text>el.childNodes[deltaPosition],
newVNodeChildren[deltaPosition],
oldVNodeChildren[deltaPosition],
workQueue,
);
break;
}
case VDeltaOperationTypes.DELETE: {
el.removeChild(el.childNodes[deltaPosition]);
case VDeltaOperationTypes.DELETE:
workQueue.push(() => el.removeChild(el.childNodes[deltaPosition]));
break;
}
}
} else if (keyed && oldVNodeChildren.length > 0) {
// Keyed reconciliation algorithm originally adapted from [Fre](https://github.com/yisar/fre)
let oldHead = 0;
let newHead = 0;
let oldTail = oldVNodeChildren.length - 1;
let newTail = newVNodeChildren.length - 1;

// Constrain tails to dirty vnodes: [X, A, B, C], [Y, A, B, C] -> [X], [Y]
while (oldHead <= oldTail && newHead <= newTail) {
if ((<VElement>oldVNodeChildren[oldTail]).key !== (<VElement>newVNodeChildren[newTail]).key) {
break;
}
oldTail--;
newTail--;
}

// Constrain heads to dirty vnodes: [A, B, C, X], [A, B, C, Y] -> [X], [Y]
while (oldHead <= oldTail && newHead <= newTail) {
if ((<VElement>oldVNodeChildren[oldHead]).key !== (<VElement>newVNodeChildren[newHead]).key) {
break;
}
oldHead++;
newHead++;
}

if (oldHead > oldTail) {
// There are no dirty old children: [], [X, Y, Z]
while (newHead <= newTail) {
const newHeadIndex = Number(newHead++);
const node = el.childNodes[newHeadIndex];
workQueue.push(() =>
el.insertBefore(createElement(newVNodeChildren[newHeadIndex], false), node),
);
}
} else if (newHead > newTail) {
// There are no dirty new children: [X, Y, Z], []
while (oldHead <= oldTail) {
const node = el.childNodes[oldHead++];
workQueue.push(() => el.removeChild(node));
}
} else {
const keyMap: Record<string, number> = {};
for (let i = oldHead; i <= oldTail; ++i) {
keyMap[(<VElement>oldVNodeChildren[i]).key!] = i;
}
while (newHead <= newTail) {
const newVNodeChild = <VElement>newVNodeChildren[newHead];
const oldVNodePosition = keyMap[newVNodeChild.key!];
const node = el.childNodes[oldVNodePosition];
const refNode = el.childNodes[newVNodeChildren.length + newHead++];

if (
oldVNodePosition !== undefined &&
newVNodeChild.key === (<VElement>oldVNodeChildren[oldVNodePosition]).key
) {
// Determine move for child that moved: [X, A, B, C] -> [A, B, C, X]
workQueue.push(() => el.insertBefore(node, refNode));
delete keyMap[newVNodeChild.key!];
} else {
// VNode doesn't exist yet: [] -> [X]
workQueue.push(() => el.insertBefore(createElement(newVNodeChild, false), refNode));
}
}
for (const oldVNodePosition of Object.values(keyMap)) {
// VNode wasn't found in new vnodes, so it's cleaned up: [X] -> []
const node = el.childNodes[oldVNodePosition];
workQueue.push(() => el.removeChild(node));
}
}
} else if (keyed) {
// TODO: Efficient diffing by keys with moves (#107)
} else {
if (oldVNodeChildren) {
// Interates backwards, so in case a childNode is destroyed, it will not shift the nodes
// and break accessing by index
for (let i = oldVNodeChildren.length - 1; i >= 0; --i) {
patch(<HTMLElement | Text>el.childNodes[i], newVNodeChildren[i], oldVNodeChildren[i]);
patch(
<HTMLElement | Text>el.childNodes[i],
newVNodeChildren[i],
oldVNodeChildren[i],
workQueue,
);
}
}
for (let i = oldVNodeChildren.length ?? 0; i < newVNodeChildren.length; ++i) {
el.appendChild(createElement(newVNodeChildren[i], false));
const node = createElement(newVNodeChildren[i], false);
workQueue.push(() => el.appendChild(node));
}
}
};

const replaceElementWithVNode = (el: HTMLElement | Text, newVNode: VNode): void => {
el.replaceWith(createElement(newVNode));
};

/**
* Diffs two VNodes and modifies the DOM node based on the necessary changes
* @param {HTMLElement|Text} el - Target element to be modified
* @param {VNode} newVNode - New VNode
* @param {VNode=} prevVNode - Previous VNode
* @returns {void}
*/
export const patch = (el: HTMLElement | Text, newVNode: VNode, prevVNode?: VNode): void => {
export const patch = (
el: HTMLElement | Text,
newVNode: VNode,
prevVNode?: VNode,
workQueue: (() => void)[] = [],
): void => {
if (!newVNode) {
el.remove();
workQueue.push(() => el.remove());
} else {
const oldVNode: VNode | undefined = prevVNode ?? el[OLD_VNODE_FIELD];
const hasString = typeof oldVNode === 'string' || typeof newVNode === 'string';

if (hasString && oldVNode !== newVNode) {
replaceElementWithVNode(el, newVNode);
workQueue.push(() => el.replaceWith(createElement(newVNode)));
} else if (!hasString) {
if (
(!(<VElement>oldVNode)?.key && !(<VElement>newVNode)?.key) ||
(<VElement>oldVNode)?.key !== (<VElement>newVNode)?.key
) {
if ((<VElement>oldVNode)?.tag !== (<VElement>newVNode)?.tag || el instanceof Text) {
replaceElementWithVNode(el, newVNode);
workQueue.push(() => el.replaceWith(createElement(newVNode)));
} else {
patchProps(el, (<VElement>oldVNode)?.props || {}, (<VElement>newVNode).props || {});
patchProps(
el,
(<VElement>oldVNode)?.props || {},
(<VElement>newVNode).props || {},
workQueue,
);

// Flags allow for greater optimizability by reducing condition branches.
// Generally, you should use a compiler to generate these flags, but
// hand-writing them is also possible
switch (<VFlags>(<VElement>newVNode).flag) {
case VFlags.NO_CHILDREN: {
el.textContent = '';
case VFlags.NO_CHILDREN:
workQueue.push(() => (el.textContent = ''));
break;
}
case VFlags.ONLY_TEXT_CHILDREN: {
case VFlags.ONLY_TEXT_CHILDREN:
// Joining is faster than setting textContent to an array
el.textContent = <string>(<VElement>newVNode).children!.join('');
workQueue.push(
() => (el.textContent = <string>(<VElement>newVNode).children!.join('')),
);
break;
}
default: {
default:
patchChildren(
el,
(<VElement>oldVNode)?.children || [],
Expand All @@ -145,14 +242,20 @@ export const patch = (el: HTMLElement | Text, newVNode: VNode, prevVNode?: VNode
// We need to pass delta here because this function does not have
// a reference to the actual vnode.
(<VElement>newVNode).delta,
workQueue,
);
break;
}
}
}
}
}
}

for (let i = 0; i < workQueue.length; i++) {
workQueue[i]();
// eslint-disable-next-line no-debugger
// debugger;
}

if (!prevVNode) el[OLD_VNODE_FIELD] = newVNode;
};

0 comments on commit 6e2f2d2

Please sign in to comment.