├── .babelrc ├── .gitignore ├── README.md ├── favicon.ico ├── index.html ├── index.jsx ├── package-lock.json ├── package.json ├── renderer └── tiny-dom.js ├── screenshot.png ├── utils ├── css.js └── debug-methods.js └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "env", 4 | "stage-0", 5 | "react" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Custom 2 | 3 | dist 4 | node_modules 5 | 6 | # Created by https://www.gitignore.io/api/macos,visualstudiocode 7 | 8 | ### macOS ### 9 | *.DS_Store 10 | .AppleDouble 11 | .LSOverride 12 | 13 | # Icon must end with two \r 14 | Icon 15 | 16 | # Thumbnails 17 | ._* 18 | 19 | # Files that might appear in the root of a volume 20 | .DocumentRevisions-V100 21 | .fseventsd 22 | .Spotlight-V100 23 | .TemporaryItems 24 | .Trashes 25 | .VolumeIcon.icns 26 | .com.apple.timemachine.donotpresent 27 | 28 | # Directories potentially created on remote AFP share 29 | .AppleDB 30 | .AppleDesktop 31 | Network Trash Folder 32 | Temporary Items 33 | .apdisk 34 | 35 | ### VisualStudioCode ### 36 | .vscode/* 37 | !.vscode/settings.json 38 | !.vscode/tasks.json 39 | !.vscode/launch.json 40 | !.vscode/extensions.json 41 | .history 42 | 43 | # End of https://www.gitignore.io/api/macos,visualstudiocode 44 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-tiny-dom 2 | 3 | `react-tiny-dom` is a minimal implementation of [react-dom](https://reactjs.org/docs/react-dom.html) as custom renderer using React 16 official Renderer API. 4 | 5 | The purpose of this project is to show the meaning of each method of the `ReconcilerConfig` passed to [react-reconciler](https://github.com/facebook/react/tree/master/packages/react-reconciler), by using a practical yet familiar environment: the browser DOM. 6 | 7 | ![react-tiny-dom](screenshot.png) 8 | 9 | ## What's supported 10 | 11 | - Nested React components 12 | - `setState` updates 13 | - Text nodes 14 | - HTML Attributes 15 | - Event listeners 16 | - `className` prop 17 | - `style` prop 18 | 19 | ## What's not supported yet, but I plan to 20 | 21 | The following features of `react-dom` are not supported yet but I'll probably add them: 22 | 23 | - Web Components 24 | 25 | Any other feature which doesn't help explaining the `Renderer API`, like `dangerouslySetInnerHTML`, won't be supported on purpose, to keep the source code minimal and focused on simplicity. 26 | 27 | ## Installation 28 | 29 | ``` 30 | npm install 31 | npm start # Runs the example using react-tiny-dom 32 | ``` 33 | 34 | ## FAQ 35 | 36 | ### How can I customize the methods logs in the console? 37 | 38 | By default the demo logs most method calls of the Renderer, but you can pass a list of method names to exclude in the second parameter of `debugMethods`, when passing the `ReconcilerConfig` to `Reconciler`. 39 | 40 | ```js 41 | const TinyDOMRenderer = Reconciler( 42 | debugMethods(hostConfig, ['now', 'getChildHostContext', 'shouldSetTextContent']) 43 | ); 44 | ``` 45 | 46 | Obviously passing `hostConfig` directly to `Reconciler` will completely disable any method log. 47 | -------------------------------------------------------------------------------- /favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jiayihu/react-tiny-dom/8bc2a23d5fc01288c83c20b535ff6fc248cb39f6/favicon.ico -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | ReactTinyDOM 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { ReactTinyDOM } from './renderer/tiny-dom'; 3 | 4 | function Card(props) { 5 | return ( 6 |
7 | React tiny DOM 13 |
14 |

{props.title}

15 |

{props.children}

16 | 19 |
20 |
21 | ); 22 | } 23 | 24 | class HelloWorld extends React.Component { 25 | state = { 26 | counter: 0, 27 | }; 28 | 29 | handleClick = () => { 30 | console.clear(); // Clear the console to show only new method calls 31 | this.setState({ 32 | counter: this.state.counter + 1, 33 | }); 34 | } 35 | 36 | render() { 37 | const isEven = this.state.counter % 2 === 0; 38 | const styles = { 39 | color: isEven ? '#673AB7' : '#F44336', 40 | }; 41 | 42 | return ( 43 |
44 | 45 |

A minimal implementation of react-dom using react-reconciler APIs

46 |

Counter: {this.state.counter}

47 |

48 | 51 |

52 |
53 |
54 | ); 55 | } 56 | } 57 | 58 | ReactTinyDOM.render(, document.querySelector('.root')); 59 | 60 | /** 61 | * A more basic application to see and understand better the Renderer method calls in the console. 62 | */ 63 | 64 | // class HelloWorld extends React.Component { 65 | // constructor() { 66 | // super(); 67 | // this.state = { 68 | // value: 0, 69 | // }; 70 | 71 | // this.handleClick = this.handleClick.bind(this); 72 | // } 73 | 74 | // handleClick() { 75 | // this.setState({ 76 | // value: this.state.value + 1, 77 | // }); 78 | // } 79 | 80 | // render() { 81 | // const styles = { 82 | // backgroundColor: this.state.value % 2 === 0 ? 'red' : 'green', 83 | // }; 84 | 85 | // return ( 86 | //
87 | //

react-tiny-dom

88 | //

Counter: {this.state.value}

89 | //

90 | // 93 | //

94 | //
95 | // ); 96 | // } 97 | // } 98 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-tiny-dom", 3 | "version": "0.0.1", 4 | "description": "A minimal implementation of react-dom to learn custom renderers", 5 | "main": "index.js", 6 | "scripts": { 7 | "build": "webpack -p", 8 | "copy": "cp index.html dist/index.html && cp favicon.ico dist/favicon.ico && mkdir dist/dist && cp dist/main.js dist/dist/main.js", 9 | "deploy": "rimraf dist && npm run build && npm run copy && gh-pages -d dist", 10 | "start": "webpack-dev-server" 11 | }, 12 | "author": "Jiayi Hu ", 13 | "license": "MIT", 14 | "dependencies": { 15 | "deep-diff": "^0.3.8", 16 | "fbjs": "^1.0.0", 17 | "react": "^16.6.1", 18 | "react-reconciler": "^0.18.0" 19 | }, 20 | "devDependencies": { 21 | "babel-cli": "^6.26.0", 22 | "babel-core": "^6.26.0", 23 | "babel-loader": "^7.1.2", 24 | "babel-preset-env": "^1.6.1", 25 | "babel-preset-react": "^6.24.1", 26 | "babel-preset-stage-0": "^6.24.1", 27 | "gh-pages": "^1.1.0", 28 | "rimraf": "^2.6.2", 29 | "webpack": "^3.8.1", 30 | "webpack-dev-server": "^2.9.4" 31 | }, 32 | "keywords": [ 33 | "react", 34 | "react-dom", 35 | "fiber", 36 | "renderer", 37 | "reconciler" 38 | ] 39 | } 40 | -------------------------------------------------------------------------------- /renderer/tiny-dom.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Reconciler from 'react-reconciler'; 3 | import emptyObject from 'fbjs/lib/emptyObject'; 4 | import { isUnitlessNumber } from '../utils/css'; 5 | import { debugMethods } from '../utils/debug-methods'; 6 | 7 | function setStyles(domElement, styles) { 8 | Object.keys(styles).forEach(name => { 9 | const rawValue = styles[name]; 10 | const isEmpty = rawValue === null || typeof rawValue === 'boolean' || rawValue === ''; 11 | 12 | // Unset the style to its default values using an empty string 13 | if (isEmpty) domElement.style[name] = ''; 14 | else { 15 | const value = 16 | typeof rawValue === 'number' && !isUnitlessProperty(name) ? `${rawValue}px` : rawValue; 17 | 18 | domElement.style[name] = value; 19 | } 20 | }); 21 | } 22 | 23 | function shallowDiff(oldObj, newObj) { 24 | // Return a diff between the new and the old object 25 | const uniqueProps = new Set([...Object.keys(oldObj), ...Object.keys(newObj)]); 26 | const changedProps = Array.from(uniqueProps).filter( 27 | propName => oldObj[propName] !== newObj[propName] 28 | ); 29 | 30 | return changedProps; 31 | } 32 | 33 | function isUppercase(letter) { 34 | return /[A-Z]/.test(letter); 35 | } 36 | 37 | function isEventName(propName) { 38 | return propName.startsWith('on') && window.hasOwnProperty(propName.toLowerCase()); 39 | } 40 | 41 | const hostConfig = { 42 | // appendChild for direct children 43 | appendInitialChild(parentInstance, child) { 44 | parentInstance.appendChild(child); 45 | }, 46 | 47 | // Create the DOMElement, but attributes are set in `finalizeInitialChildren` 48 | createInstance(type, props, rootContainerInstance, hostContext, internalInstanceHandle) { 49 | return document.createElement(type); 50 | }, 51 | 52 | createTextInstance(text, rootContainerInstance, internalInstanceHandle) { 53 | // A TextNode instance is returned because literal strings cannot change their value later on update 54 | return document.createTextNode(text); 55 | }, 56 | 57 | // Actually set the attributes and text content to the domElement and check if 58 | // it needs focus, which will be eventually set in `commitMount` 59 | finalizeInitialChildren(domElement, type, props) { 60 | // Set the prop to the domElement 61 | Object.keys(props).forEach(propName => { 62 | const propValue = props[propName]; 63 | 64 | if (propName === 'style') { 65 | setStyles(domElement, propValue); 66 | } else if (propName === 'children') { 67 | // Set the textContent only for literal string or number children, whereas 68 | // nodes will be appended in `appendChild` 69 | if (typeof propValue === 'string' || typeof propValue === 'number') { 70 | domElement.textContent = propValue; 71 | } 72 | } else if (propName === 'className') { 73 | domElement.setAttribute('class', propValue); 74 | } else if (isEventName(propName)) { 75 | const eventName = propName.toLowerCase().replace('on', ''); 76 | domElement.addEventListener(eventName, propValue); 77 | } else { 78 | domElement.setAttribute(propName, propValue); 79 | } 80 | }); 81 | 82 | // Check if needs focus 83 | switch (type) { 84 | case 'button': 85 | case 'input': 86 | case 'select': 87 | case 'textarea': 88 | return !!props.autoFocus; 89 | } 90 | 91 | return false; 92 | }, 93 | 94 | // Useful only for testing 95 | getPublicInstance(inst) { 96 | return inst; 97 | }, 98 | 99 | // Commit hooks, useful mainly for react-dom syntethic events 100 | prepareForCommit() {}, 101 | resetAfterCommit() {}, 102 | 103 | // Calculate the updatePayload 104 | prepareUpdate(domElement, type, oldProps, newProps) { 105 | // Return a diff between the new and the old props 106 | return shallowDiff(oldProps, newProps); 107 | }, 108 | 109 | getRootHostContext(rootInstance) { 110 | return emptyObject; 111 | }, 112 | getChildHostContext(parentHostContext, type) { 113 | return emptyObject; 114 | }, 115 | 116 | shouldSetTextContent(type, props) { 117 | return ( 118 | type === 'textarea' || 119 | typeof props.children === 'string' || 120 | typeof props.children === 'number' 121 | ); 122 | }, 123 | 124 | now: () => { 125 | // noop 126 | }, 127 | 128 | supportsMutation: true, 129 | 130 | useSyncScheduling: true, 131 | 132 | appendChild(parentInstance, child) { 133 | parentInstance.appendChild(child); 134 | }, 135 | 136 | // appendChild to root container 137 | appendChildToContainer(parentInstance, child) { 138 | parentInstance.appendChild(child); 139 | }, 140 | 141 | removeChild(parentInstance, child) { 142 | parentInstance.removeChild(child); 143 | }, 144 | 145 | removeChildFromContainer(parentInstance, child) { 146 | parentInstance.removeChild(child); 147 | }, 148 | 149 | insertBefore(parentInstance, child, beforeChild) { 150 | parentInstance.insertBefore(child, beforeChild); 151 | }, 152 | 153 | insertInContainerBefore(parentInstance, child, beforeChild) { 154 | parentInstance.insertBefore(child, beforeChild); 155 | }, 156 | 157 | commitUpdate(domElement, updatePayload, type, oldProps, newProps, internalInstanceHandle) { 158 | updatePayload.forEach(propName => { 159 | // children changes is done by the other methods like `commitTextUpdate` 160 | if (propName === 'children') { 161 | const propValue = newProps[propName]; 162 | if (typeof propValue === 'string' || typeof propValue === 'number') { 163 | domElement.textContent = propValue; 164 | } 165 | return; 166 | } 167 | 168 | if (propName === 'style') { 169 | // Return a diff between the new and the old styles 170 | const styleDiffs = shallowDiff(oldProps.style, newProps.style); 171 | const finalStyles = styleDiffs.reduce((acc, styleName) => { 172 | // Style marked to be unset 173 | if (!newProps.style[styleName]) acc[styleName] = ''; 174 | else acc[styleName] = newProps.style[styleName]; 175 | 176 | return acc; 177 | }, {}); 178 | 179 | setStyles(domElement, finalStyles); 180 | } else if (newProps[propName] || typeof newProps[propName] === 'number') { 181 | if (isEventName(propName)) { 182 | const eventName = propName.toLowerCase().replace('on', ''); 183 | domElement.removeEventListener(eventName, oldProps[propName]); 184 | domElement.addEventListener(eventName, newProps[propName]); 185 | } else { 186 | domElement.setAttribute(propName, newProps[propName]); 187 | } 188 | } else { 189 | if (isEventName(propName)) { 190 | const eventName = propName.toLowerCase().replace('on', ''); 191 | domElement.removeEventListener(eventName, oldProps[propName]); 192 | } else { 193 | domElement.removeAttribute(propName); 194 | } 195 | } 196 | }); 197 | }, 198 | 199 | commitMount(domElement, type, newProps, internalInstanceHandle) { 200 | domElement.focus(); 201 | }, 202 | 203 | commitTextUpdate(textInstance, oldText, newText) { 204 | textInstance.nodeValue = newText; 205 | }, 206 | 207 | resetTextContent(domElement) { 208 | domElement.textContent = ''; 209 | }, 210 | }; 211 | 212 | const TinyDOMRenderer = Reconciler( 213 | debugMethods(hostConfig, ['now', 'getChildHostContext', 'shouldSetTextContent']) 214 | ); 215 | 216 | export const ReactTinyDOM = { 217 | render(element, domContainer, callback) { 218 | let root = domContainer._reactRootContainer; 219 | 220 | if (!root) { 221 | // Remove all children of the domContainer 222 | let rootSibling; 223 | while ((rootSibling = domContainer.lastChild)) { 224 | domContainer.removeChild(rootSibling); 225 | } 226 | 227 | const newRoot = TinyDOMRenderer.createContainer(domContainer); 228 | root = domContainer._reactRootContainer = newRoot; 229 | } 230 | 231 | return TinyDOMRenderer.updateContainer(element, root, null, callback); 232 | }, 233 | }; 234 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jiayihu/react-tiny-dom/8bc2a23d5fc01288c83c20b535ff6fc248cb39f6/screenshot.png -------------------------------------------------------------------------------- /utils/css.js: -------------------------------------------------------------------------------- 1 | /** 2 | * CSS properties which accept numbers but are not in units of "px". 3 | * 4 | * @NOTE: Taken from React source code 5 | * @url{https://github.com/facebook/react/blob/37e4329bc81def4695211d6e3795a654ef4d84f5/packages/react-dom/src/shared/CSSProperty.js} 6 | */ 7 | const unitlessCSSProperties = { 8 | animationIterationCount: true, 9 | borderImageOutset: true, 10 | borderImageSlice: true, 11 | borderImageWidth: true, 12 | boxFlex: true, 13 | boxFlexGroup: true, 14 | boxOrdinalGroup: true, 15 | columnCount: true, 16 | columns: true, 17 | flex: true, 18 | flexGrow: true, 19 | flexPositive: true, 20 | flexShrink: true, 21 | flexNegative: true, 22 | flexOrder: true, 23 | gridRow: true, 24 | gridRowEnd: true, 25 | gridRowSpan: true, 26 | gridRowStart: true, 27 | gridColumn: true, 28 | gridColumnEnd: true, 29 | gridColumnSpan: true, 30 | gridColumnStart: true, 31 | fontWeight: true, 32 | lineClamp: true, 33 | lineHeight: true, 34 | opacity: true, 35 | order: true, 36 | orphans: true, 37 | tabSize: true, 38 | widows: true, 39 | zIndex: true, 40 | zoom: true, 41 | 42 | // SVG-related properties 43 | fillOpacity: true, 44 | floodOpacity: true, 45 | stopOpacity: true, 46 | strokeDasharray: true, 47 | strokeDashoffset: true, 48 | strokeMiterlimit: true, 49 | strokeOpacity: true, 50 | strokeWidth: true, 51 | }; 52 | 53 | export function isUnitlessProperty(styleName) { 54 | return !!unitlessCSSProperties[styleName]; 55 | } 56 | -------------------------------------------------------------------------------- /utils/debug-methods.js: -------------------------------------------------------------------------------- 1 | export function debugMethods(obj, excludes) { 2 | return new Proxy(obj, { 3 | get: function(target, name, receiver) { 4 | if (typeof target[name] === 'function' && !excludes.includes(name)) { 5 | return function(...args) { 6 | const methodName = name; 7 | console.group(methodName); 8 | console.log(...args); 9 | console.groupEnd(); 10 | return target[name](...args); 11 | }; 12 | } else if (target[name] !== null && typeof target[name] === 'object') { 13 | return debugMethods(target[name], excludes); 14 | } else { 15 | return Reflect.get(target, name, receiver); 16 | } 17 | }, 18 | }); 19 | } 20 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const webpack = require('webpack'); 3 | 4 | const root = { 5 | src: path.join(__dirname, 'index.jsx'), 6 | dest: path.join(__dirname), 7 | }; 8 | 9 | module.exports = { 10 | devServer: { 11 | historyApiFallback: true, 12 | noInfo: false, 13 | port: 3000, 14 | }, 15 | devtool: 'eval', 16 | entry: { 17 | main: root.src, 18 | }, 19 | output: { 20 | path: root.dest, 21 | filename: 'dist/main.js', 22 | }, 23 | resolve: { 24 | extensions: ['.js', '.jsx'], 25 | }, 26 | module: { 27 | rules: [ 28 | { 29 | test: /\.jsx?$/, 30 | use: [ 31 | { 32 | loader: 'babel-loader', 33 | options: { 34 | cacheDirectory: true, 35 | }, 36 | }, 37 | ], 38 | include: root.src, 39 | }, 40 | ], 41 | }, 42 | plugins: [], 43 | }; 44 | --------------------------------------------------------------------------------