• Stars
    star
    429
  • Rank 101,271 (Top 2 %)
  • Language
    TypeScript
  • License
    MIT License
  • Created about 3 years ago
  • Updated over 1 year ago

Reviews

There are no reviews yet. Be the first to send feedback to the community and the maintainers!

Repository Details

Build your own mini Million.js

💯 Hundred Code Size NPM Version

Hundred is intended to be a toy block virtual DOM based off of Million.js, and is a proof-of-concept and a learning resource more than a tool you should actually use in production. This implementation is similarly based off of blockdom.

How do I make a block virtual DOM?

Let's go through a tutorial on how to create Hundred!

Recommended prerequisites:

Step 1: Create a h function

The h function allows us to create virtual nodes. It takes in a tag name, an object of attributes, and an array of children. It returns a virtual DOM node.

// Helper function to create virtual dom nodes
// e.g. h('div', { id: 'foo' }, 'hello') => <div id="foo">hello</div>
export const h = (
  type: string,
  props: Props = {},
  ...children: VNode[]
): VElement => ({
  type,
  props,
  children,
});

console.log(h('div', { id: 'foo' }, 'hello'));
// gives us { type: 'div', props: { id: 'foo' }, children: ['hello'] }

console.log(h('div', { id: 'foo' }, h('span', null, 'hello')));
// gives us { type: 'div', props: { id: 'foo' }, children: [{ type: 'span', props: null, children: ['hello'] }] }

Essentially, the virtual nodes are just plain JavaScript objects that represent the DOM nodes we want to create.

Step 2: Create a block function

Let's assume that the user will provide some function fn that takes in some props and returns a virtual node. Basically, the props represent the data, and the virtual node represents the view (establishes a one-way data flow).

export const block = (fn: (props: Props) => VNode) => {
  // ...
};

One thing about block virtual DOM is that we can create a "mapping." Essentially, we need to figure out which props correspond to which virtual nodes. We can do this by passing a "getter" Proxy that will return a Hole (temporary placeholder for a future value) when we access a property.

// Represents a property access on `props`
// this.key is used to identify the property
// Imagine an instance of Hole as a placeholder for a value
class Hole {
  key: string;
  constructor(key: string) {
    this.key = key;
  }
}

export const block = (fn: (props: Props) => VNode) => {
  // by using a proxy, we can intercept ANY property access on
  // the object and return a Hole instance instead.
  // e.g. props.any_prop => new Hole('any_prop')
  const proxy = new Proxy(
    {},
    {
      get(_, prop: string) {
        return new Hole(prop);
      },
    }
  );
  // we pass the proxy to the function, so that it can
  // replace property accesses with Hole placeholders
  const vnode = fn(proxy);

  // allows us to see instances of Hole inside the virtual node tree!
  console.log(vnode);

  // ...
};

Step 3: Implementing a render function

Our barebones layout is effectively done, but now we need to implement static analysis to deal with those Hole placeholders. We can do this by creating a render function that takes in a virtual node and returns a real DOM node.

Let's start by just creating the base function that turns virtual nodes into real DOM nodes:

// Converts a virtual dom node into a real dom node.
// It also tracks the edits that need to be made to the dom
export const render = (
  // represents a virtual dom node, built w/ `h` function
  vnode: VNode
): HTMLElement | Text => {
  if (typeof vnode === 'string') return document.createTextNode(vnode);

  const el = document.createElement(vnode.type);

  if (vnode.props) {
    for (const name in vnode.props) {
      const value = vnode.props[name];
      el[name] = value;
    }
  }

  for (let i = 0; i < vnode.children?.length; i++) {
    const child = vnode.children[i];
    el.appendChild(render(child));
  }

  return el;
};

console.log(render(h('div', { id: 'foo' }, 'hello')));
// gives us <div id="foo">hello</div>

Now, we need to add the static analysis part. We can do this by adding two new parameters: edits and path. edits is an array of Edit, which represents our "mapping." Each edit has data where the relevant DOM node is within the tree (via path), the key used to access props (via hole), the property name that we need to update (via name) if it is an attribute edit, and the index of the child (via child) if it is a child edit.

// Converts a virtual dom node into a real dom node.
// It also tracks the edits that need to be made to the dom
export const render = (
  // represents a virtual dom node, built w/ `h` function
  vnode: VNode,
  // represents a list of edits to be made to the dom,
  // processed by identifying `Hole` placeholder values
  // in attributes and children.
  //    NOTE: this is a mutable array, and we assume the user
  //    passes in an empty array and uses that as a reference
  //    for the edits.
  edits: Edit[] = [],
  // Path is used to keep track of where we are in the tree
  // as we traverse it.
  // e.g. [0, 1, 2] would mean:
  //    el1 = 1st child of el
  //    el2 = 2nd child of el1
  //    el3 = 3rd child of el2
  path: number[] = []
): HTMLElement | Text => {
  if (typeof vnode === 'string') return document.createTextNode(vnode);

  const el = document.createElement(vnode.type);

  if (vnode.props) {
    for (const name in vnode.props) {
      const value = vnode.props[name];
      if (value instanceof Hole) {
        edits.push({
          type: 'attribute',
          path, // the path we need to traverse to get to the element
          attribute: name, // to set the value during mount/patch
          hole: value.key, // to get the value from props during mount/patch
        });
        continue;
      }
      el[name] = value;
    }
  }

  for (let i = 0; i < vnode.children?.length; i++) {
    const child = vnode.children[i];
    if (child instanceof Hole) {
      edits.push({
        type: 'child',
        path, // the path we need to traverse to get to the parent element
        index: i, // index represents the position of the child in the parent used to insert/update the child during mount/patch
        hole: child.key, // to get the value from props during mount/patch
      });
      continue;
    }
    // we respread the path to avoid mutating the original array
    el.appendChild(render(child, edits, [...path, i]));
  }

  return el;
};

Step 4: Implementing a mount and patch function for blocks

Now that we have a render function that can handle Hole placeholders, we can implement a mount function that takes in a virtual node and mounts it to the DOM. We can also implement a patch function that takes in a new virtual node and patches the DOM with the new changes.

There are some notable differences between mount and patch:

Within mount, we will create a copy of the DOM node that render produces. This is because we want to keep the original DOM node around so that we can use it to patch the DOM later. Also, we need to track element references for each Edit so that we can use them to patch the DOM later.

Within patch, we will use the original DOM node that we created in mount to patch the DOM. This is different because mount will insert or create new nodes, while patch will only update existing nodes.

// block is a factory function that returns a function that
// can be used to create a block. Imagine it as a live instance
// you can use to patch it against instances of itself.
export const block = (fn: (props: Props) => VNode) => {
  // by using a proxy, we can intercept ANY property access on
  // the object and return a Hole instance instead.
  // e.g. props.any_prop => new Hole('any_prop')
  const proxy = new Proxy(
    {},
    {
      get(_, prop: string) {
        return new Hole(prop);
      },
    }
  );
  // we pass the proxy to the function, so that it can
  // replace property accesses with Hole placeholders
  const vnode = fn(proxy);

  // edits is a mutable array, so we pass it by reference
  const edits: Edit[] = [];
  // by rendering the vnode, we also populate the edits array
  // by parsing the vnode for Hole placeholders
  const root = render(vnode, edits);

  // factory function to create instances of this block
  return (props: Props): Block => {
    // elements stores the element references for each edit
    // during mount, which can be used during patch later
    const elements = new Array(edits.length);

    // mount puts the element for the block on some parent element
    const mount = (parent: HTMLElement) => {
      // cloneNode saves memory by not reconstrcuting the dom tree
      const el = root.cloneNode(true);
      // we assume our rendering scope is just one block
      parent.textContent = '';
      parent.appendChild(el);

      for (let i = 0; i < edits.length; i++) {
        const edit = edits[i];
        // walk the tree to find the element / hole
        let thisEl = el;
        // If path = [1, 2, 3]
        // thisEl = el.childNodes[1].childNodes[2].childNodes[3]
        for (let i = 0; i < edit.path.length; i++) {
          thisEl = thisEl.childNodes[edit.path[i]];
        }

        // make sure we save the element reference
        elements[i] = thisEl;

        // this time, we can get the value from props
        const value = props[edit.hole];

        if (edit.type === 'attribute') {
          thisEl[edit.attribute] = value;
        } else if (edit.type === 'child') {
          const textNode = document.createTextNode(value);
          thisEl.insertBefore(textNode, thisEl.childNodes[edit.index]);
        }
      }
    };

    // patch updates the element references with new values
    const patch = (newBlock: Block) => {
      for (let i = 0; i < edits.length; i++) {
        const edit = edits[i];
        const value = props[edit.hole];
        const newValue = newBlock.props[edit.hole];

        // dirty check
        if (value === newValue) continue;

        const thisEl = elements[i];

        if (edit.type === 'attribute') {
          thisEl[edit.attribute] = newValue;
        } else if (edit.type === 'child') {
          thisEl.childNodes[edit.index].textContent = newValue;
        }
      }
    };

    return { mount, patch, props, edits };
  };
};

This is great, but it's not really a virtual DOM–it only allows us to create one block and patch it against itself. Oftentimes, we want to construct these blocks into trees.

So, let's add a special case for block values in props.

// block is a factory function that returns a function that
// can be used to create a block. Imagine it as a live instance
// you can use to patch it against instances of itself.
export const block = (fn: (props: Props) => VNode) => {
  // by using a proxy, we can intercept ANY property access on
  // the object and return a Hole instance instead.
  // e.g. props.any_prop => new Hole('any_prop')
  const proxy = new Proxy(
    {},
    {
      get(_, prop: string) {
        return new Hole(prop);
      },
    }
  );
  // we pass the proxy to the function, so that it can
  // replace property accesses with Hole placeholders
  const vnode = fn(proxy);

  // edits is a mutable array, so we pass it by reference
  const edits: Edit[] = [];
  // by rendering the vnode, we also populate the edits array
  // by parsing the vnode for Hole placeholders
  const root = render(vnode, edits);

  // factory function to create instances of this block
  return (props: Props): Block => {
    // elements stores the element references for each edit
    // during mount, which can be used during patch later
    const elements = new Array(edits.length);

    // mount puts the element for the block on some parent element
    const mount = (parent: HTMLElement) => {
      // cloneNode saves memory by not reconstrcuting the dom tree
      const el = root.cloneNode(true);
      // we assume our rendering scope is just one block
      parent.textContent = '';
      parent.appendChild(el);

      for (let i = 0; i < edits.length; i++) {
        const edit = edits[i];
        // walk the tree to find the element / hole
        let thisEl = el;
        // If path = [1, 2, 3]
        // thisEl = el.childNodes[1].childNodes[2].childNodes[3]
        for (let i = 0; i < edit.path.length; i++) {
          thisEl = thisEl.childNodes[edit.path[i]];
        }

        // make sure we save the element reference
        elements[i] = thisEl;

        // this time, we can get the value from props
        const value = props[edit.hole];

        if (edit.type === 'attribute') {
          thisEl[edit.attribute] = value;
        } else if (edit.type === 'child') {
          // handle nested blocks if the value is a block
          if (value.mount && typeof value.mount === 'function') {
            value.mount(thisEl);
            continue;
          }

          const textNode = document.createTextNode(value);
          thisEl.insertBefore(textNode, thisEl.childNodes[edit.index]);
        }
      }
    };

    // patch updates the element references with new values
    const patch = (newBlock: Block) => {
      for (let i = 0; i < edits.length; i++) {
        const edit = edits[i];
        const value = props[edit.hole];
        const newValue = newBlock.props[edit.hole];

        // dirty check
        if (value === newValue) continue;

        const thisEl = elements[i];

        if (edit.type === 'attribute') {
          thisEl[edit.attribute] = newValue;
        } else if (edit.type === 'child') {
          // handle nested blocks if the value is a block
          if (value.patch && typeof value.patch === 'function') {
            // patch cooresponding child blocks
            value.patch(newBlock.edits[i].hole);
            continue;
          }
          thisEl.childNodes[edit.index].textContent = newValue;
        }
      }
    };

    return { mount, patch, props, edits };
  };
};

If you want to view the full source code, check out src/index.ts.

Install Hundred

Inside your project directory, run the following command:

npm install hundred

Usage

import { h, block } from 'hundred';

const Button = block(({ number }) => {
  return h('button', null, number);
});

const button = Button({ number: 0 });

button.mount(document.getElementById('root'));

setInterval(() => {
  button.patch(Button({ number: Math.random() }));
}, 100);

License

hundred is MIT-licensed open-source software by Aiden Bai.

More Repositories

1

pattycake

Zero-runtime pattern matching
TypeScript
806
star
2

lucia

🙋‍♀️ 3kb library for tiny web apps
TypeScript
738
star
3

million-react

⚛️ Vite starter for Million.js
CSS
424
star
4

reaict

Optimize React with AI
TypeScript
133
star
5

hacky

⚙️ Crank.js with tagged templates
TypeScript
45
star
6

grumpy

🔑 Painless key-value storage (deprecated)
TypeScript
36
star
7

snip

✌️ The simple, no-bs link shortener
TypeScript
35
star
8

dababy

:trollface: Data binding so simple even DaBaby could do it!
TypeScript
29
star
9

vhs

old school website vibes
JavaScript
22
star
10

million-demo

Million vs. React demo
JavaScript
19
star
11

million-site

🦁 Million.js site built with Nextra
JavaScript
15
star
12

site

⌨️ Personal website
JavaScript
13
star
13

million-tanstack-virtual

JavaScript
12
star
14

site-mini

Minimal revamp of my personal site
CSS
12
star
15

vite-plugin-million

⚡ Million.js' compiler powered by Vite.js
TypeScript
11
star
16

babel-preset-million

❤️‍🔥 Transforms JSX to Million virtual nodes
JavaScript
10
star
17

mini

Extremely minimalistic website starter
TypeScript
9
star
18

docscan

👓 Scans documents and returns strings
Python
8
star
19

is-monday

⏰ Checks if today is Monday
JavaScript
8
star
20

kbowl-client

🧠 Conduct oral knowledge bowl online!
TypeScript
8
star
21

website

🌐 Source code of personal website.
HTML
8
star
22

aidenybai

:shipit: My GitHub readme
Shell
8
star
23

million-dev-setup

Million dev setup
TypeScript
8
star
24

build-your-own-virtual-dom

BYO slides for presenting at conferences
Vue
7
star
25

revenge.ev3

💪 Skyridge's Sumo bot program in ev3.
7
star
26

schzoom

Zoom scheduler dashboard for students written using Remake
JavaScript
7
star
27

tba

💙 Wrapper for bluealliance.com
JavaScript
7
star
28

skywarder

📘 A simple and fast RESTful API for interacting with the Skyward grading system.
JavaScript
7
star
29

alastor

😈🤘 Hellish-fast asynchronous HTTP client for NodeJS
TypeScript
7
star
30

lucia-starter

📦 The official bundler boilerplate for Lucia
JavaScript
6
star
31

www

⌨️ Personal website
TypeScript
6
star
32

i.zephyr

The static-file hosting service that'll work like a breeze.
TypeScript
6
star
33

hastebin

⚡ Module for creating hastebins
TypeScript
6
star
34

weebify

🤡 Frictionless text weebifier (AP CSA project w/@sarahberah)
JavaScript
6
star
35

amogus.church

sus
HTML
6
star
36

base

🎨 My base javascript project template.
JavaScript
6
star
37

screacth

what if we made react in scratch (blockly)
6
star
38

camas

Camas Codes website
CSS
5
star
39

me

Minimalistic personal webpage
EJS
5
star
40

virtual-dom-workshop

JavaScript
5
star
41

kbowl-server

Websocket IO for kbowl
JavaScript
5
star
42

million-compiler

JSX compiler for Million (proof of concept)
JavaScript
5
star
43

fuck.zephyr

take selfies by throwing up your middle finger
JavaScript
5
star
44

aidenybai.github.io-old

🕸️Personal website
JavaScript
5
star
45

million-delta-react-test

dom vs. million-react vs. million-react delta
JavaScript
5
star
46

kbowl

🧠 App for oral knowledge bowl during COVID times
JavaScript
5
star
47

arewesamayet

Are we sama yet?
JavaScript
5
star
48

million-conference-slides

"Optimizing React for Performance with Million.js" conference slides for Million.js
JavaScript
5
star
49

magnet-project

🧲 CHS Magnet Project API
JavaScript
5
star
50

handsfree-toolkit

A REPL with a composable Handsfree.js toolkit for developing webcam AI reverse workshops
JavaScript
4
star
51

stonks

Stock data viewer example w/ Million.js vs React
JavaScript
4
star
52

leetcode

🧩 My leetcode solutions
4
star
53

fury

😡 Web-based FRC scouting application
JavaScript
4
star
54

apcsp

💻 Repository to store all my AP Computer Science Principles projects (2021-22)
TypeScript
4
star
55

calculus-adventure-project

Among Us Calc Adventure Project Web Interface
JavaScript
4
star
56

jumpstart

⚡ A simple Vite + Hacky boilerplate
TypeScript
4
star
57

folders

imagine website but folders
JavaScript
4
star
58

fiber-explorer

JavaScript
4
star
59

chinese-typer

🧧 Typer project for Chinese class.
JavaScript
4
star
60

sanic

💬 Imagine messaging but ultrasonic
JavaScript
4
star
61

sheesh

🤩 Vite + Lucia + Tailwind starter that'll make you go SHEESH
TypeScript
4
star
62

boomer

👴 Unopinionated monolithic webapp boilerplate
EJS
3
star
63

skyward-client

💙📘 The better web client for Skyward
JavaScript
3
star
64

xu-ellen.github.io

🌐 Minimalistic personal website for Ellen Xu
HTML
3
star
65

lucia-demo

Lucia demos for ISEF
HTML
3
star
66

discordrp

CSS
3
star
67

albio

🛰 Astronomy package infrastructure for JavaScript
JavaScript
3
star
68

mst

🧐 MST Magnet Research Journal
TypeScript
3
star
69

Quarters3

🧠 Online FRC 2471 game for small brain moments.
CSS
3
star
70

rap-god

the real slim shady
3
star
71

million-starter

💡 Opinionated Million starter project
3
star
72

tion

Experimental tracking pixel for GitHub repositories
JavaScript
3
star
73

brotherblocker

super basic script to fake-block websites
JavaScript
3
star
74

apcsa-java-env

☕ Environment to use during AP test
Java
3
star
75

skyridge-science.github.io

:atom: We're a passionate group of students competing in Science Olympiad for Skyridge Middle School.
CSS
3
star
76

petite-react

3
star
77

boilerplate

simple, no-bullshit fullstack web boilerplate
TypeScript
3
star
78

mental-health-app

MST Freshman Mental Health App (Not by @aidenybai, repo for hosting)
HTML
2
star
79

lucia-kumiko-starter

Starter project for a lucia/kumiko project
HTML
2
star
80

physics-final-project

TypeScript
2
star
81

roaster

2
star
82

nn

neural net in javascript
2
star
83

yrs

yrs website
HTML
2
star
84

remake-playground

My own playground with messing with remake
JavaScript
2
star
85

mst-database

🧐 MST Magnet Research Journal (NEW)
JavaScript
2
star
86

deopt-web

Generate v8 logs for browser JavaScript
2
star
87

remake-cache-test

HTML
2
star
88

io

Socket.io server for personal use
JavaScript
2
star
89

nextjs-dev-server-temp

JavaScript
2
star
90

lucia-slide-deck

slide deck for symposium webinar
JavaScript
2
star
91

whapnomore

EJS
2
star
92

touch-grass

Touch grass, outerneter
JavaScript
1
star
93

lablab

1
star
94

million-lint-sc-repro

TypeScript
1
star
95

garden

aiden and belly garden!
HTML
1
star
96

capybara-army

1
star
97

swc-visitor-api

TypeScript
1
star
98

million-sierpinski-triangle-demo

Million.js implementation of Sierpinski Triangle React Fiber Demo
1
star
99

jacky

Directly use HTML inside JavaScript
1
star
100

semaphore

HTML
1
star