├── .babelrc ├── .gitignore ├── README.md ├── examples ├── button.js ├── counter.js ├── greeting.js ├── index.html ├── index.js ├── list-with-styles.js ├── lists.js ├── post.js └── title.js ├── package-lock.json ├── package.json └── src └── index.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["env"], 3 | "plugins": ["transform-react-jsx"] 4 | } 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | .cache/ 4 | lib/ -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # joltik 2 | 3 | A micro VDOM library for learning purposes 4 | 5 | ## Description 6 | 7 | I'm just learning how a VDOM (Virtual Document Object Model) library works by building one and documenting my learnings in medium. I don't pretend this to be nothing serious, so please don't use it in production. 8 | 9 | ## Installation 10 | 11 | ``` 12 | $ npm install joltik --save 13 | ``` 14 | 15 | ### Configuring JSX pragma 16 | 17 | By default, [Babel] transforms your JSX code into a call to `React.createElement`. You can customize this behaviour by adding a **jsx pragma** at the top of your files, with the following syntax: 18 | 19 | ```js 20 | /** @jsx j */ 21 | ``` 22 | 23 | Wher `j` here is joltik's replacement for `React.createElement`. You can read more about this behaviour in [@developit](https://github.com/developit)'s blog post: [WTF Is JSX](https://jasonformat.com/wtf-is-jsx/). 24 | 25 | ### Configuring Babel 26 | 27 | You will need to install Babel's [transform react jsx plugin](https://babeljs.io/docs/en/babel-plugin-transform-react-jsx) in order to support JSX syntax only, instead of the full preset you normally would use for React. 28 | 29 | Here's a sample of the bare minimum `.babelrc` config you will need: 30 | 31 | ```json 32 | { 33 | "presets": ["env"], 34 | "plugins": ["transform-react-jsx"] 35 | } 36 | ``` 37 | 38 | It is very convenient to replace your pragma everywhere by defoult, to avoid adding it as a comment at the top of your files. In order to do so, add the following config to the plugin: 39 | 40 | ``` 41 | { 42 | ... 43 | "plugins": [ 44 | ["@babel/plugin-transform-react-jsx", { 45 | "pragma": "j" 46 | }] 47 | ] 48 | } 49 | ``` 50 | 51 | ### Creating your first component 52 | 53 | The syntax is similar to a usual React component, with the only difference of importing `j` from joltik. 54 | 55 | ```jsx 56 | // HelloWorld.js 57 | import { j } from "joltik"; 58 | import "./styles.css"; 59 | 60 | export const HelloWorld = ({ text }) =>

{text}

; 61 | ``` 62 | 63 | To render an element, you would do: 64 | 65 | ```jsx 66 | // index.js 67 | import { j, createElement } from "joltik"; 68 | import { HelloWolrd } from "./HelloWorld"; 69 | 70 | document 71 | .getElementById("app") 72 | .appendChild(createElement()); 73 | ``` 74 | 75 | ## Demo 76 | 77 | You can see a working demo in [this codesandbox](https://codesandbox.io/s/93474k06xr) and also in the [examples folder](https://github.com/d4nidev/joltik/tree/master/examples). 78 | 79 | ## Why Joltik? 80 | 81 | ![joltik] 82 | 83 | [Joltik]() is the smallest Pokémon from all the current editions. 84 | 85 | ## References 86 | 87 | - [How to write your own Virtual DOM](https://medium.com/@deathmood/how-to-write-your-own-virtual-dom-ee74acc13060) 88 | - [WTF is JSX](https://jasonformat.com/wtf-is-jsx/) 89 | 90 | [joltik]: https://cdn.bulbagarden.net/upload/e/e7/Spr_5b_595.png 91 | -------------------------------------------------------------------------------- /examples/button.js: -------------------------------------------------------------------------------- 1 | /** @jsx j */ 2 | import { j } from "../src"; 3 | 4 | /** 5 | * A basic JSX component, non parameterized, with attributes 6 | */ 7 | const Button = ; 8 | 9 | function handleClick() { 10 | alert("joltik works!"); 11 | } 12 | 13 | export { Button }; 14 | -------------------------------------------------------------------------------- /examples/counter.js: -------------------------------------------------------------------------------- 1 | /** @jsx j */ 2 | import { j } from "../src"; 3 | 4 | export function Counter({ count = 0, onClick = () => {} } = {}) { 5 | const increment = () => { 6 | onClick(+1); 7 | }; 8 | 9 | const decrement = () => { 10 | onClick(-1); 11 | }; 12 | 13 | return ( 14 |
15 |

Count: {count}

16 | 17 | 18 |
19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /examples/greeting.js: -------------------------------------------------------------------------------- 1 | /** @jsx j */ 2 | import { j } from "../src"; 3 | 4 | /** 5 | * A functional component, receiving parameters as props 6 | * @param {string} props.name 7 | */ 8 | const Greeting = ({ name }) =>
Hello, {name}!
; 9 | 10 | export { Greeting }; 11 | -------------------------------------------------------------------------------- /examples/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 10 | 11 | 12 |
13 |

Counter

14 |
15 |
16 | 17 |
18 |

Static content

19 |
20 |
21 | 22 |
23 |

Diffing example

24 |
25 | 26 |
27 | 28 |
29 |

Props example

30 |
31 |
32 | 33 |
34 |

Functional component example

35 |
36 |
37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /examples/index.js: -------------------------------------------------------------------------------- 1 | /** @jsx j */ 2 | import { createElement, updateElement, j } from "../src"; 3 | import { Greeting } from "./greeting"; 4 | import { ListWithStyles } from "./list-with-styles"; 5 | import { List1, List2 } from "./lists"; 6 | import { Post } from "./post"; 7 | import { Counter } from "./counter"; 8 | 9 | // Counter example 10 | const counter = document.getElementById("counter"); 11 | let count = 0; 12 | let component = ; 13 | 14 | counter.appendChild(createElement(component)); 15 | 16 | function handleClick(value) { 17 | count = count + value; 18 | const update = ; 19 | updateElement(counter, update, component); 20 | component = update; 21 | } 22 | 23 | // Static example 24 | const st = document.getElementById("static"); 25 | st.appendChild(createElement(Post)); 26 | 27 | // Diffing example 28 | const diffing = document.getElementById("diffing"); 29 | diffing.appendChild(createElement(List1)); 30 | 31 | document.getElementById("reload").addEventListener("click", () => { 32 | updateElement(diffing, List2, List1); 33 | }); 34 | 35 | // Props example 36 | const props = document.getElementById("props"); 37 | props.appendChild(createElement(ListWithStyles)); 38 | 39 | // Functional component 40 | const functional = document.getElementById("functional"); 41 | functional.appendChild(createElement()); 42 | -------------------------------------------------------------------------------- /examples/list-with-styles.js: -------------------------------------------------------------------------------- 1 | /** @jsx j */ 2 | import { j } from "../src"; 3 | 4 | /** 5 | * A list component with different types of arguments (props) 6 | */ 7 | const ListWithStyles = ( 8 |
    9 |
  • item 1
  • 10 |
  • 11 | 12 | 13 |
  • 14 |
15 | ); 16 | 17 | export { ListWithStyles }; 18 | -------------------------------------------------------------------------------- /examples/lists.js: -------------------------------------------------------------------------------- 1 | /** @jsx j */ 2 | import { j } from "../src"; 3 | 4 | const List1 = ( 5 |
    6 |
  • non changing list item
  • 7 |
  • changing list item
  • 8 |
9 | ); 10 | 11 | const List2 = ( 12 |
    13 |
  • non changing list item
  • 14 |
  • changed by joltik diffing!
  • 15 |
16 | ); 17 | 18 | export { List1, List2 }; 19 | -------------------------------------------------------------------------------- /examples/post.js: -------------------------------------------------------------------------------- 1 | /** @jsx j */ 2 | import { j } from "../src"; 3 | import { Button } from "./button"; 4 | import { Title } from "./title"; 5 | 6 | /** 7 | * A complex JSX element, non parameterized, with attributes and nested children 8 | */ 9 | const Post = ( 10 |
11 | 12 | <p>Hello, world!</p> 13 | <Button /> 14 | </article> 15 | ); 16 | 17 | export { Post }; 18 | -------------------------------------------------------------------------------- /examples/title.js: -------------------------------------------------------------------------------- 1 | /** @jsx j */ 2 | import { j } from "../src"; 3 | 4 | /** 5 | * A basic JSX component, non parameterized, without attributes 6 | */ 7 | const Title = <h1>The title of the post</h1>; 8 | 9 | export { Title }; 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "joltik", 3 | "version": "0.0.6", 4 | "description": "A micro VDOM library for learning purposes", 5 | "main": "lib/index.js", 6 | "umd:main": "lib/index.umd.js", 7 | "module": "lib/index.mjs", 8 | "scripts": { 9 | "phoenix": "rm -rf node_modules package-lock.json && npm install", 10 | "test": "jest", 11 | "test:watch": "npm run test -- --watch", 12 | "build": "microbundle", 13 | "start": "parcel examples/index.html", 14 | "commit": "git-cz", 15 | "precommit": "lint-staged" 16 | }, 17 | "files": [ 18 | "lib" 19 | ], 20 | "repository": { 21 | "type": "git", 22 | "url": "https://github.com/d4nidev/joltik.git" 23 | }, 24 | "author": "Daniel de la Cruz <mail@danidev.es>", 25 | "license": "ISC", 26 | "devDependencies": { 27 | "babel-core": "6.26.3", 28 | "babel-plugin-transform-react-jsx": "6.24.1", 29 | "babel-preset-env": "1.7.0", 30 | "commitizen": "^4.0.3", 31 | "cz-conventional-changelog": "3.0.2", 32 | "husky": "3.0.5", 33 | "jest": "24.9.0", 34 | "lint-staged": "9.2.5", 35 | "microbundle": "0.11.0", 36 | "parcel-bundler": "1.12.3", 37 | "prettier": "1.18.2" 38 | }, 39 | "config": { 40 | "commitizen": { 41 | "path": "./node_modules/cz-conventional-changelog" 42 | } 43 | }, 44 | "lint-staged": { 45 | "*.{js,json,css,md}": [ 46 | "prettier --write", 47 | "git add" 48 | ] 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * joltik's JSX pragma 3 | * @param {*} type 4 | * @param {*} props 5 | * @param {...any} args 6 | */ 7 | export function j(type, props, ...args) { 8 | const children = args.length ? [].concat(...args) : null; 9 | return { 10 | type, 11 | props: props || {}, 12 | children 13 | }; 14 | } 15 | 16 | function isEventProp(name) { 17 | return /^on/.test(name); 18 | } 19 | 20 | function extractEventName(name) { 21 | return name.slice(2).toLowerCase(); 22 | } 23 | 24 | function setProp(node, name, value) { 25 | if (name === "className") { 26 | name = "class"; 27 | } else if (typeof value === "boolean") { 28 | setBooleanProp(node, name, value); 29 | } 30 | 31 | node.setAttribute(name, value); 32 | } 33 | 34 | function setBooleanProp(node, name, value) { 35 | if (value) { 36 | node.setAttribute(name, value); 37 | node[name] = true; 38 | } else { 39 | node[name] = false; 40 | } 41 | } 42 | 43 | function setAttributes(node, props = {}) { 44 | if (!props) { 45 | return; 46 | } 47 | Object.keys(props) 48 | .filter(prop => !isEventProp(prop)) 49 | .forEach(name => setProp(node, name, props[name])); 50 | } 51 | 52 | function addEventListeners(node, props = {}) { 53 | if (!props) { 54 | return; 55 | } 56 | Object.keys(props) 57 | .filter(isEventProp) 58 | .forEach(event => 59 | node.addEventListener(extractEventName(event), props[event]) 60 | ); 61 | } 62 | 63 | /** 64 | * Creates a DOM node. 65 | * @param {*} node 66 | */ 67 | export function createElement(node) { 68 | // Text nodes can be created stright away, and can't have children or attributes. 69 | if (typeof node === "string" || typeof node === "number") { 70 | return document.createTextNode(node); 71 | } 72 | 73 | // Object nodes are new tags, and it needs to be considered a new element. 74 | // the function uses recursion to parse this object. 75 | if (typeof node.type === "object") { 76 | return createElement(node.type); 77 | } 78 | 79 | // A functional component is parameterized. It just needs 80 | // to call the function with the props as the arguments. 81 | if (typeof node.type === "function") { 82 | return createElement(node.type(node.props)); 83 | } 84 | 85 | const element = document.createElement(node.type); 86 | 87 | // Sets the element attributes 88 | setAttributes(element, node.props); 89 | 90 | // Event listeners are threated independently 91 | addEventListeners(element, node.props); 92 | 93 | // Uses recursion to render its children, if any 94 | node.children && 95 | node.children 96 | .map(createElement) 97 | .forEach(child => element.appendChild(child)); 98 | 99 | return element; 100 | } 101 | 102 | /** 103 | * Updates a DOM node. 104 | * @param {*} parentNode 105 | * @param {*} newNode 106 | * @param {*} oldNode 107 | * @param {*} index 108 | */ 109 | export function updateElement(parentNode, newNode, oldNode, index = 0) { 110 | if (newNode && typeof newNode.type === "function") { 111 | newNode = newNode.type(newNode.props); 112 | } 113 | 114 | if (oldNode && typeof oldNode.type === "function") { 115 | oldNode = oldNode.type(oldNode.props); 116 | } 117 | 118 | // If the old node doesn't exist, it adds the new one to the parent. 119 | if (oldNode === undefined || oldNode === null) { 120 | parentNode.appendChild(createElement(newNode)); 121 | // If the new node doesn't exist, it removes it from the parent. 122 | } else if (newNode === undefined || newNode === null) { 123 | parentNode.removeChild(parentNode.childNodes[index]); 124 | // If the nodes have changed, it replaces the old one with its new version. 125 | } else if (nodesAreDifferent(newNode, oldNode)) { 126 | parentNode.replaceChild( 127 | createElement(newNode), 128 | parentNode.childNodes[index] 129 | ); 130 | } else if (newNode.type) { 131 | const newLength = newNode.children ? newNode.children.length : 0; 132 | const oldLength = oldNode.children ? oldNode.children.length : 0; 133 | 134 | // Recursively updates its children 135 | for (let i = 0; i < newLength || i < oldLength; i++) { 136 | updateElement( 137 | parentNode.childNodes[index], 138 | newNode.children[i], 139 | oldNode.children[i], 140 | i 141 | ); 142 | } 143 | } 144 | } 145 | 146 | /** 147 | * Compares two DOM nodes to decide if they are different. 148 | * @param {*} first 149 | * @param {*} second 150 | */ 151 | function nodesAreDifferent(first, second) { 152 | return ( 153 | typeof first !== typeof second || 154 | ((typeof first === "string" || typeof first === "number") && 155 | first !== second) || 156 | first.type !== second.type 157 | ); 158 | } 159 | --------------------------------------------------------------------------------