├── .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 | 
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 |

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 |
--------------------------------------------------------------------------------