├── .gitignore ├── 01 - state ├── 01 - manual dom manipulation.html └── 02 - state-based ui.html ├── 02 - dom diffing ├── 01 - what is dom diffing.html ├── 02 - converting a string to HTML.html ├── 03 - loop through each element.html ├── 04 - a rendering function.html └── render.js ├── 03 - reactivity ├── 01 - what is reactivity.html ├── 02 - setter functions.html ├── 03 - proxies.html ├── 04 - nested arrays and objects in proxies.html ├── 05 - creating proxies from proxies.html ├── 06 - detecting proxies.html ├── 07 - proxies and reactivity.html ├── render.js └── store.js ├── 04 - components ├── 01 - reactive component.html ├── 02 - node selectors.html ├── 03 - component methods │ ├── component.js │ └── index.html ├── 04 - lifecycle events │ ├── component.js │ └── index.html ├── render.js └── store.js ├── 05 - performance ├── 01 - batching renders.html ├── 02 - memoization and ids.html ├── 03 - virtual dom.html ├── 04 - why use a virtual dom.html ├── component.js ├── render.js └── store.js ├── 06 - project ├── 01 - project template.html ├── 02 - project completed.html ├── component.js ├── dogs.js ├── img │ ├── bandit.jpg │ ├── bert.jpg │ ├── brad.jpg │ ├── chewie.jpg │ ├── daphne.jpg │ ├── ethel.jpg │ ├── florence.jpg │ ├── grace.jpg │ ├── janice.jpg │ ├── joseph.jpg │ ├── kyle.jpg │ ├── reba.jpg │ ├── thor.jpg │ └── william.jpg ├── render.js └── store.js └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | # Node 2 | node_modules 3 | test/results 4 | test/coverage 5 | 6 | ## OS X 7 | .DS_Store 8 | ._* 9 | .Spotlight-V100 10 | .Trashes -------------------------------------------------------------------------------- /01 - state/01 - manual dom manipulation.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Manual DOM Manipulation 7 | 8 | 9 | 16 | 17 | 18 | 19 | 20 |

Manual DOM Manipulation

21 | 22 | 27 | 28 | 29 | 50 | 51 | -------------------------------------------------------------------------------- /01 - state/02 - state-based ui.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | State-Based UI 7 | 8 | 9 | 16 | 17 | 18 | 19 | 20 |

State-Based UI

21 | 22 |
23 | 24 | 25 | 67 | 68 | -------------------------------------------------------------------------------- /02 - dom diffing/01 - what is dom diffing.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | What is DOM diffing? 7 | 8 | 9 | 20 | 21 | 22 | 23 | 24 | 25 |

Wizards

26 | 31 | 32 | 33 |

Magical Folk

34 | 40 | 41 | 51 | 52 | -------------------------------------------------------------------------------- /02 - dom diffing/02 - converting a string to HTML.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Converting a string to HTML 7 | 8 | 9 | 20 | 21 | 22 | 23 | 24 |
25 | 26 | 27 | 77 | 78 | -------------------------------------------------------------------------------- /02 - dom diffing/03 - loop through each element.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Loop through each element 7 | 8 | 9 | 20 | 21 | 22 | 23 | 24 |
25 | 26 | 27 | 122 | 123 | -------------------------------------------------------------------------------- /02 - dom diffing/04 - a rendering function.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | A rendering function 7 | 8 | 9 | 20 | 21 | 22 | 23 | 24 |
25 | 26 | 27 | 28 | 75 | 76 | -------------------------------------------------------------------------------- /02 - dom diffing/render.js: -------------------------------------------------------------------------------- 1 | let render = (function () { 2 | 3 | // Form fields and attributes that can be modified by users 4 | // They also have implicit values that make it hard to know if they were changed by the user or developer 5 | let formFields = ['input', 'option', 'textarea']; 6 | let formAtts = ['value', 'checked', 'selected']; 7 | let formAttsNoVal = ['checked', 'selected']; 8 | 9 | /** 10 | * Convert a template string into HTML DOM nodes 11 | * @param {String} str The template string 12 | * @return {Node} The template HTML 13 | */ 14 | function stringToHTML (str) { 15 | 16 | // Create document 17 | let parser = new DOMParser(); 18 | let doc = parser.parseFromString(str, 'text/html'); 19 | 20 | // If there are items in the head, move them to the body 21 | if (doc.head && doc.head.childNodes.length) { 22 | Array.from(doc.head.childNodes).reverse().forEach(function (node) { 23 | doc.body.insertBefore(node, doc.body.firstChild); 24 | }); 25 | } 26 | 27 | return doc.body || document.createElement('body'); 28 | 29 | } 30 | 31 | /** 32 | * Check if an attribute string has a stringified falsy value 33 | * @param {String} str The string 34 | * @return {Boolean} If true, value is falsy (yea, I know, that's a little confusing) 35 | */ 36 | function isFalsy (str) { 37 | return ['false', 'null', 'undefined', '0', '-0', 'NaN', '0n', '-0n'].includes(str); 38 | } 39 | 40 | /** 41 | * Check if attribute should be skipped (sanitize properties) 42 | * @param {String} name The attribute name 43 | * @param {String} value The attribute value 44 | * @return {Boolean} If true, skip the attribute 45 | */ 46 | function skipAttribute (name, value) { 47 | let val = value.replace(/\s+/g, '').toLowerCase(); 48 | if (['src', 'href', 'xlink:href'].includes(name)) { 49 | if (val.includes('javascript:') || val.includes('data:text/html')) return true; 50 | } 51 | if (name.startsWith('on')) return true; 52 | } 53 | 54 | /** 55 | * Add an attribute to an element 56 | * @param {Node} elem The element 57 | * @param {String} att The attribute 58 | * @param {String} val The value 59 | */ 60 | function addAttribute (elem, att, val) { 61 | 62 | // Sanitize dangerous attributes 63 | if (skipAttribute(att, val)) return; 64 | 65 | // If it's a form attribute, set the property directly 66 | if (formAtts.includes(att)) { 67 | elem[att] = att === 'value' ? val : ' '; 68 | } 69 | 70 | // Update the attribute 71 | elem.setAttribute(att, val); 72 | 73 | 74 | } 75 | 76 | /** 77 | * Remove an attribute from an element 78 | * @param {Node} elem The element 79 | * @param {String} att The attribute 80 | */ 81 | function removeAttribute (elem, att) { 82 | 83 | // If it's a form attribute, remove the property directly 84 | if (formAtts.includes(att)) { 85 | elem[att] = ''; 86 | } 87 | 88 | // Remove the attribute 89 | elem.removeAttribute(att); 90 | 91 | } 92 | 93 | /** 94 | * Compare the existing node attributes to the template node attributes and make updates 95 | * @param {Node} template The new template 96 | * @param {Node} existing The existing DOM node 97 | */ 98 | function diffAttributes (template, existing) { 99 | 100 | // If the node is not an element, bail 101 | if (template.nodeType !== 1) return; 102 | 103 | // Get attributes for the template and existing DOM 104 | let templateAtts = template.attributes; 105 | let existingAtts = existing.attributes; 106 | 107 | // Add and update attributes from the template into the DOM 108 | for (let {name, value} of templateAtts) { 109 | 110 | // Skip user-editable form field attributes 111 | if (formAtts.includes(name) && formFields.includes(template.tagName.toLowerCase())) continue; 112 | 113 | // If its a no-value property and it's falsy remove it 114 | if (formAttsNoVal.includes(name) && isFalsy(value)) { 115 | removeAttribute(existing, name); 116 | continue; 117 | } 118 | 119 | // Otherwise, add the attribute 120 | addAttribute(existing, name, value); 121 | 122 | } 123 | 124 | // Remove attributes from the DOM that shouldn't be there 125 | for (let {name, value} of existingAtts) { 126 | 127 | // If the attribute exists in the template, skip it 128 | if (templateAtts[name]) continue; 129 | 130 | // Skip user-editable form field attributes 131 | if (formAtts.includes(name) && formFields.includes(existing.tagName.toLowerCase())) continue; 132 | 133 | // Otherwise, remove it 134 | removeAttribute(existing, name); 135 | 136 | } 137 | 138 | } 139 | 140 | /** 141 | * Add default attributes to a newly created element 142 | * @param {Node} elem The element 143 | */ 144 | function addDefaultAtts (elem) { 145 | 146 | // Only run on elements 147 | if (elem.nodeType !== 1) return; 148 | 149 | // Remove unsafe HTML attributes 150 | for (let {name, value} of elem.attributes) { 151 | 152 | // If the attribute should be skipped, remove it 153 | if (skipAttribute(name, value)) { 154 | removeAttribute(elem, name); 155 | continue; 156 | } 157 | 158 | // If it's a no-value attribute and its falsy, skip it 159 | if (formAttsNoVal.includes(name) && isFalsy(value)) continue; 160 | 161 | // Add the plain attribute 162 | addAttribute(elem, name, value); 163 | 164 | } 165 | 166 | // If there are child elems, recursively add defaults to them 167 | if (elem.childNodes) { 168 | for (let node of elem.childNodes) { 169 | addDefaultAtts(node); 170 | } 171 | } 172 | 173 | } 174 | 175 | /** 176 | * Get the content from a node 177 | * @param {Node} node The node 178 | * @return {String} The content 179 | */ 180 | function getNodeContent (node) { 181 | return node.childNodes && node.childNodes.length ? null : node.textContent; 182 | } 183 | 184 | /** 185 | * Check if two nodes are different 186 | * @param {Node} node1 The first node 187 | * @param {Node} node2 The second node 188 | * @return {Boolean} If true, they're not the same node 189 | */ 190 | function isDifferentNode (node1, node2) { 191 | return ( 192 | (typeof node1.nodeType === 'number' && node1.nodeType !== node2.nodeType) || 193 | (typeof node1.tagName === 'string' && node1.tagName !== node2.tagName) || 194 | (typeof node1.id === 'string' && node1.id !== node2.id) || 195 | (typeof node1.src === 'string' && node1.src !== node2.src) 196 | ); 197 | } 198 | 199 | /** 200 | * Check if the desired node is further ahead in the DOM existingNodes 201 | * @param {Node} node The node to look for 202 | * @param {NodeList} existingNodes The DOM existingNodes 203 | * @param {Integer} index The indexing index 204 | * @return {Integer} How many nodes ahead the target node is 205 | */ 206 | function aheadInTree (node, existingNodes, index) { 207 | return Array.from(existingNodes).slice(index + 1).find(function (branch) { 208 | return !isDifferentNode(node, branch); 209 | }); 210 | } 211 | 212 | /** 213 | * If there are extra elements in DOM, remove them 214 | * @param {Array} existingNodes The existing DOM 215 | * @param {Array} templateNodes The template 216 | */ 217 | function trimExtraNodes (existingNodes, templateNodes) { 218 | let extra = existingNodes.length - templateNodes.length; 219 | if (extra < 1) return; 220 | for (; extra > 0; extra--) { 221 | existingNodes[existingNodes.length - 1].remove(); 222 | } 223 | } 224 | 225 | /** 226 | * Remove scripts from HTML 227 | * @param {Node} elem The element to remove scripts from 228 | */ 229 | function removeScripts (elem) { 230 | let scripts = elem.querySelectorAll('script'); 231 | for (let script of scripts) { 232 | script.remove(); 233 | } 234 | } 235 | 236 | /** 237 | * Diff the existing DOM node versus the template 238 | * @param {Array} template The template HTML 239 | * @param {Node} existing The current DOM HTML 240 | */ 241 | function diff (template, existing) { 242 | 243 | // Get the nodes in the template and existing UI 244 | let templateNodes = template.childNodes; 245 | let existingNodes = existing.childNodes; 246 | 247 | // Don't inject scripts 248 | if (removeScripts(template)) return; 249 | 250 | // Loop through each node in the template and compare it to the matching element in the UI 251 | templateNodes.forEach(function (node, index) { 252 | 253 | // If element doesn't exist, create it 254 | if (!existingNodes[index]) { 255 | let clone = node.cloneNode(true); 256 | addDefaultAtts(clone); 257 | existing.append(clone); 258 | return; 259 | } 260 | 261 | // If there is, but it's not the same node type, insert the new node before the existing one 262 | if (isDifferentNode(node, existingNodes[index])) { 263 | 264 | // Check if node exists further in the tree 265 | let ahead = aheadInTree(node, existingNodes, index); 266 | 267 | // If not, insert the node before the current one 268 | if (!ahead) { 269 | let clone = node.cloneNode(true); 270 | addDefaultAtts(clone); 271 | existingNodes[index].before(clone); 272 | return; 273 | } 274 | 275 | // Otherwise, move it to the current spot 276 | existingNodes[index].before(ahead); 277 | 278 | } 279 | 280 | // If attributes are different, update them 281 | diffAttributes(node, existingNodes[index]); 282 | 283 | // Stop diffing if a native web component 284 | if (node.nodeName.includes('-')) return; 285 | 286 | // If content is different, update it 287 | let templateContent = getNodeContent(node); 288 | if (templateContent && templateContent !== getNodeContent(existingNodes[index])) { 289 | existingNodes[index].textContent = templateContent; 290 | } 291 | 292 | // If there shouldn't be child nodes but there are, remove them 293 | if (!node.childNodes.length && existingNodes[index].childNodes.length) { 294 | existingNodes[index].innerHTML = ''; 295 | return; 296 | } 297 | 298 | // If DOM is empty and shouldn't be, build it up 299 | // This uses a document fragment to minimize reflows 300 | if (!existingNodes[index].childNodes.length && node.childNodes.length) { 301 | let fragment = document.createDocumentFragment(); 302 | diff(node, fragment); 303 | existingNodes[index].appendChild(fragment); 304 | return; 305 | } 306 | 307 | // If there are nodes within it, recursively diff those 308 | if (node.childNodes.length) { 309 | diff(node, existingNodes[index]); 310 | } 311 | 312 | }); 313 | 314 | // If extra elements in DOM, remove them 315 | trimExtraNodes(existingNodes, templateNodes); 316 | 317 | } 318 | 319 | /** 320 | * Render a template into the UI 321 | * @param {Node|String} elem The element or selector to render the template into 322 | * @param {String} template The template to render 323 | */ 324 | function render (elem, template) { 325 | let node = elem === 'string' ? document.querySelector(elem) : elem; 326 | let html = stringToHTML(template); 327 | diff(html, node); 328 | } 329 | 330 | return render; 331 | 332 | })(); -------------------------------------------------------------------------------- /03 - reactivity/01 - what is reactivity.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | What is reactivity? 7 | 8 | 9 | 16 | 17 | 18 | 19 | 20 |

What is reactivity?

21 | 22 |
23 | 24 | 25 | 26 | 58 | 59 | -------------------------------------------------------------------------------- /03 - reactivity/02 - setter functions.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Setter Functions 7 | 8 | 9 | 16 | 17 | 18 | 19 | 20 |

Setter Functions

21 | 22 |
23 | 24 | 25 | 26 | 79 | 80 | -------------------------------------------------------------------------------- /03 - reactivity/03 - proxies.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Proxies 7 | 8 | 9 | 10 | 11 | 12 | 13 | 69 | 70 | -------------------------------------------------------------------------------- /03 - reactivity/04 - nested arrays and objects in proxies.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Nested arrays and objects in proxies 7 | 8 | 9 | 10 | 11 | 12 | 73 | 74 | -------------------------------------------------------------------------------- /03 - reactivity/05 - creating proxies from proxies.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Creating proxies from proxies 7 | 8 | 9 | 10 | 11 | 12 | 81 | 82 | -------------------------------------------------------------------------------- /03 - reactivity/06 - detecting proxies.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Detecting proxies 7 | 8 | 9 | 10 | 11 | 12 | 84 | 85 | -------------------------------------------------------------------------------- /03 - reactivity/07 - proxies and reactivity.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Proxies & Reactivity 7 | 8 | 9 | 16 | 17 | 18 | 19 | 20 |
21 | 22 | 23 | 24 | 25 | 74 | 75 | -------------------------------------------------------------------------------- /03 - reactivity/render.js: -------------------------------------------------------------------------------- 1 | let render = (function () { 2 | 3 | // Form fields and attributes that can be modified by users 4 | // They also have implicit values that make it hard to know if they were changed by the user or developer 5 | let formFields = ['input', 'option', 'textarea']; 6 | let formAtts = ['value', 'checked', 'selected']; 7 | let formAttsNoVal = ['checked', 'selected']; 8 | 9 | /** 10 | * Convert a template string into HTML DOM nodes 11 | * @param {String} str The template string 12 | * @return {Node} The template HTML 13 | */ 14 | function stringToHTML (str) { 15 | 16 | // Create document 17 | let parser = new DOMParser(); 18 | let doc = parser.parseFromString(str, 'text/html'); 19 | 20 | // If there are items in the head, move them to the body 21 | if (doc.head && doc.head.childNodes.length) { 22 | Array.from(doc.head.childNodes).reverse().forEach(function (node) { 23 | doc.body.insertBefore(node, doc.body.firstChild); 24 | }); 25 | } 26 | 27 | return doc.body || document.createElement('body'); 28 | 29 | } 30 | 31 | /** 32 | * Check if an attribute string has a stringified falsy value 33 | * @param {String} str The string 34 | * @return {Boolean} If true, value is falsy (yea, I know, that's a little confusing) 35 | */ 36 | function isFalsy (str) { 37 | return ['false', 'null', 'undefined', '0', '-0', 'NaN', '0n', '-0n'].includes(str); 38 | } 39 | 40 | /** 41 | * Check if attribute should be skipped (sanitize properties) 42 | * @param {String} name The attribute name 43 | * @param {String} value The attribute value 44 | * @return {Boolean} If true, skip the attribute 45 | */ 46 | function skipAttribute (name, value) { 47 | let val = value.replace(/\s+/g, '').toLowerCase(); 48 | if (['src', 'href', 'xlink:href'].includes(name)) { 49 | if (val.includes('javascript:') || val.includes('data:text/html')) return true; 50 | } 51 | if (name.startsWith('on')) return true; 52 | } 53 | 54 | /** 55 | * Add an attribute to an element 56 | * @param {Node} elem The element 57 | * @param {String} att The attribute 58 | * @param {String} val The value 59 | */ 60 | function addAttribute (elem, att, val) { 61 | 62 | // Sanitize dangerous attributes 63 | if (skipAttribute(att, val)) return; 64 | 65 | // If it's a form attribute, set the property directly 66 | if (formAtts.includes(att)) { 67 | elem[att] = att === 'value' ? val : ' '; 68 | } 69 | 70 | // Update the attribute 71 | elem.setAttribute(att, val); 72 | 73 | 74 | } 75 | 76 | /** 77 | * Remove an attribute from an element 78 | * @param {Node} elem The element 79 | * @param {String} att The attribute 80 | */ 81 | function removeAttribute (elem, att) { 82 | 83 | // If it's a form attribute, remove the property directly 84 | if (formAtts.includes(att)) { 85 | elem[att] = ''; 86 | } 87 | 88 | // Remove the attribute 89 | elem.removeAttribute(att); 90 | 91 | } 92 | 93 | /** 94 | * Compare the existing node attributes to the template node attributes and make updates 95 | * @param {Node} template The new template 96 | * @param {Node} existing The existing DOM node 97 | */ 98 | function diffAttributes (template, existing) { 99 | 100 | // If the node is not an element, bail 101 | if (template.nodeType !== 1) return; 102 | 103 | // Get attributes for the template and existing DOM 104 | let templateAtts = template.attributes; 105 | let existingAtts = existing.attributes; 106 | 107 | // Add and update attributes from the template into the DOM 108 | for (let {name, value} of templateAtts) { 109 | 110 | // Skip user-editable form field attributes 111 | if (formAtts.includes(name) && formFields.includes(template.tagName.toLowerCase())) continue; 112 | 113 | // If its a no-value property and it's falsy remove it 114 | if (formAttsNoVal.includes(name) && isFalsy(value)) { 115 | removeAttribute(existing, name); 116 | continue; 117 | } 118 | 119 | // Otherwise, add the attribute 120 | addAttribute(existing, name, value); 121 | 122 | } 123 | 124 | // Remove attributes from the DOM that shouldn't be there 125 | for (let {name, value} of existingAtts) { 126 | 127 | // If the attribute exists in the template, skip it 128 | if (templateAtts[name]) continue; 129 | 130 | // Skip user-editable form field attributes 131 | if (formAtts.includes(name) && formFields.includes(existing.tagName.toLowerCase())) continue; 132 | 133 | // Otherwise, remove it 134 | removeAttribute(existing, name); 135 | 136 | } 137 | 138 | } 139 | 140 | /** 141 | * Add default attributes to a newly created element 142 | * @param {Node} elem The element 143 | */ 144 | function addDefaultAtts (elem) { 145 | 146 | // Only run on elements 147 | if (elem.nodeType !== 1) return; 148 | 149 | // Remove unsafe HTML attributes 150 | for (let {name, value} of elem.attributes) { 151 | 152 | // If the attribute should be skipped, remove it 153 | if (skipAttribute(name, value)) { 154 | removeAttribute(elem, name); 155 | continue; 156 | } 157 | 158 | // If it's a no-value attribute and its falsy, skip it 159 | if (formAttsNoVal.includes(name) && isFalsy(value)) continue; 160 | 161 | // Add the plain attribute 162 | addAttribute(elem, name, value); 163 | 164 | } 165 | 166 | // If there are child elems, recursively add defaults to them 167 | if (elem.childNodes) { 168 | for (let node of elem.childNodes) { 169 | addDefaultAtts(node); 170 | } 171 | } 172 | 173 | } 174 | 175 | /** 176 | * Get the content from a node 177 | * @param {Node} node The node 178 | * @return {String} The content 179 | */ 180 | function getNodeContent (node) { 181 | return node.childNodes && node.childNodes.length ? null : node.textContent; 182 | } 183 | 184 | /** 185 | * Check if two nodes are different 186 | * @param {Node} node1 The first node 187 | * @param {Node} node2 The second node 188 | * @return {Boolean} If true, they're not the same node 189 | */ 190 | function isDifferentNode (node1, node2) { 191 | return ( 192 | (typeof node1.nodeType === 'number' && node1.nodeType !== node2.nodeType) || 193 | (typeof node1.tagName === 'string' && node1.tagName !== node2.tagName) || 194 | (typeof node1.id === 'string' && node1.id !== node2.id) || 195 | (typeof node1.src === 'string' && node1.src !== node2.src) 196 | ); 197 | } 198 | 199 | /** 200 | * Check if the desired node is further ahead in the DOM existingNodes 201 | * @param {Node} node The node to look for 202 | * @param {NodeList} existingNodes The DOM existingNodes 203 | * @param {Integer} index The indexing index 204 | * @return {Integer} How many nodes ahead the target node is 205 | */ 206 | function aheadInTree (node, existingNodes, index) { 207 | return Array.from(existingNodes).slice(index + 1).find(function (branch) { 208 | return !isDifferentNode(node, branch); 209 | }); 210 | } 211 | 212 | /** 213 | * If there are extra elements in DOM, remove them 214 | * @param {Array} existingNodes The existing DOM 215 | * @param {Array} templateNodes The template 216 | */ 217 | function trimExtraNodes (existingNodes, templateNodes) { 218 | let extra = existingNodes.length - templateNodes.length; 219 | if (extra < 1) return; 220 | for (; extra > 0; extra--) { 221 | existingNodes[existingNodes.length - 1].remove(); 222 | } 223 | } 224 | 225 | /** 226 | * Remove scripts from HTML 227 | * @param {Node} elem The element to remove scripts from 228 | */ 229 | function removeScripts (elem) { 230 | let scripts = elem.querySelectorAll('script'); 231 | for (let script of scripts) { 232 | script.remove(); 233 | } 234 | } 235 | 236 | /** 237 | * Diff the existing DOM node versus the template 238 | * @param {Array} template The template HTML 239 | * @param {Node} existing The current DOM HTML 240 | */ 241 | function diff (template, existing) { 242 | 243 | // Get the nodes in the template and existing UI 244 | let templateNodes = template.childNodes; 245 | let existingNodes = existing.childNodes; 246 | 247 | // Don't inject scripts 248 | if (removeScripts(template)) return; 249 | 250 | // Loop through each node in the template and compare it to the matching element in the UI 251 | templateNodes.forEach(function (node, index) { 252 | 253 | // If element doesn't exist, create it 254 | if (!existingNodes[index]) { 255 | let clone = node.cloneNode(true); 256 | addDefaultAtts(clone); 257 | existing.append(clone); 258 | return; 259 | } 260 | 261 | // If there is, but it's not the same node type, insert the new node before the existing one 262 | if (isDifferentNode(node, existingNodes[index])) { 263 | 264 | // Check if node exists further in the tree 265 | let ahead = aheadInTree(node, existingNodes, index); 266 | 267 | // If not, insert the node before the current one 268 | if (!ahead) { 269 | let clone = node.cloneNode(true); 270 | addDefaultAtts(clone); 271 | existingNodes[index].before(clone); 272 | return; 273 | } 274 | 275 | // Otherwise, move it to the current spot 276 | existingNodes[index].before(ahead); 277 | 278 | } 279 | 280 | // If attributes are different, update them 281 | diffAttributes(node, existingNodes[index]); 282 | 283 | // Stop diffing if a native web component 284 | if (node.nodeName.includes('-')) return; 285 | 286 | // If content is different, update it 287 | let templateContent = getNodeContent(node); 288 | if (templateContent && templateContent !== getNodeContent(existingNodes[index])) { 289 | existingNodes[index].textContent = templateContent; 290 | } 291 | 292 | // If there shouldn't be child nodes but there are, remove them 293 | if (!node.childNodes.length && existingNodes[index].childNodes.length) { 294 | existingNodes[index].innerHTML = ''; 295 | return; 296 | } 297 | 298 | // If DOM is empty and shouldn't be, build it up 299 | // This uses a document fragment to minimize reflows 300 | if (!existingNodes[index].childNodes.length && node.childNodes.length) { 301 | let fragment = document.createDocumentFragment(); 302 | diff(node, fragment); 303 | existingNodes[index].appendChild(fragment); 304 | return; 305 | } 306 | 307 | // If there are nodes within it, recursively diff those 308 | if (node.childNodes.length) { 309 | diff(node, existingNodes[index]); 310 | } 311 | 312 | }); 313 | 314 | // If extra elements in DOM, remove them 315 | trimExtraNodes(existingNodes, templateNodes); 316 | 317 | } 318 | 319 | /** 320 | * Render a template into the UI 321 | * @param {Node|String} elem The element or selector to render the template into 322 | * @param {String} template The template to render 323 | */ 324 | function render (elem, template) { 325 | let node = elem === 'string' ? document.querySelector(elem) : elem; 326 | let html = stringToHTML(template); 327 | diff(html, node); 328 | } 329 | 330 | return render; 331 | 332 | })(); -------------------------------------------------------------------------------- /03 - reactivity/store.js: -------------------------------------------------------------------------------- 1 | let store = (function () { 2 | 3 | /** 4 | * Emit a custom event 5 | * @param {*} data The store data 6 | */ 7 | function emit (data) { 8 | 9 | // Create a new event 10 | let event = new CustomEvent('store', { 11 | bubbles: true, 12 | cancelable: true, 13 | detail: data 14 | }); 15 | 16 | // Dispatch the event 17 | return document.dispatchEvent(event); 18 | 19 | } 20 | 21 | /** 22 | * Create a Proxy handler object 23 | * @param {Object} data The data object 24 | * @return {Object} The handler object 25 | */ 26 | function handler (data) { 27 | return { 28 | get (obj, prop) { 29 | if (prop === '_isProxy') return true; 30 | if (['[object Object]', '[object Array]'].includes(Object.prototype.toString.call(obj[prop])) && !obj[prop]._isProxy) { 31 | obj[prop] = new Proxy(obj[prop], handler(data)); 32 | } 33 | return obj[prop]; 34 | }, 35 | set (obj, prop, value) { 36 | if (obj[prop] === value) return true; 37 | obj[prop] = value; 38 | emit(data); 39 | return true; 40 | }, 41 | deleteProperty (obj, prop) { 42 | delete obj[prop]; 43 | emit(data); 44 | return true; 45 | } 46 | }; 47 | } 48 | 49 | /** 50 | * Create a new store 51 | * @param {Object} data The data object 52 | * @return {Proxy} The reactive proxy 53 | */ 54 | function store (data = {}) { 55 | data = ['[object Object]', '[object Array]'].includes(Object.prototype.toString.call(data)) ? data : {value: data}; 56 | return new Proxy(data, handler(data)); 57 | } 58 | 59 | return store; 60 | 61 | })(); -------------------------------------------------------------------------------- /04 - components/01 - reactive component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Reactive Components 7 | 8 | 9 | 16 | 17 | 18 | 19 | 20 |
21 | 22 | 23 | 24 | 25 | 85 | 86 | -------------------------------------------------------------------------------- /04 - components/02 - node selectors.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Node Selectors 7 | 8 | 9 | 16 | 17 | 18 | 19 | 20 |
21 | 22 | 23 | 24 | 25 | 91 | 92 | -------------------------------------------------------------------------------- /04 - components/03 - component methods/component.js: -------------------------------------------------------------------------------- 1 | let component = (function () { 2 | 3 | /** 4 | * Get an element in the DOM 5 | * @param {String|Node} elem The element or element selector 6 | * @return {Node} The DOM node 7 | */ 8 | function getElem (elem) { 9 | return typeof elem === 'string' ? document.querySelector(elem) : elem; 10 | } 11 | 12 | /** 13 | * Component Class 14 | */ 15 | class Component { 16 | 17 | /** 18 | * The constructor object 19 | * @param {Node|String} elem The element or selector to render the template into 20 | * @param {Function} html The template function to run when the data updates 21 | */ 22 | constructor (elem, html) { 23 | 24 | // Create instance properties 25 | this.elem = elem; 26 | this.template = html; 27 | this.handler = function (event) { 28 | render(getElem(elem), html()); 29 | }; 30 | 31 | // Init 32 | this.start(); 33 | 34 | } 35 | 36 | /** 37 | * Start reactive data rendering 38 | */ 39 | start () { 40 | render(getElem(this.elem), this.template()); 41 | document.addEventListener('store', this.handler); 42 | } 43 | 44 | /** 45 | * Stop reactive data rendering 46 | */ 47 | stop () { 48 | document.removeEventListener('store', this.handler); 49 | } 50 | 51 | /** 52 | * Render the UI 53 | */ 54 | render () { 55 | render(getElem(this.elem), this.template()); 56 | } 57 | 58 | } 59 | 60 | /** 61 | * Create a reactive component 62 | * @param {Node} elem The element to render into 63 | * @param {Function} html The template to render 64 | */ 65 | function component (elem, html) { 66 | return new Component(elem, html); 67 | } 68 | 69 | return component; 70 | 71 | })(); -------------------------------------------------------------------------------- /04 - components/03 - component methods/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Component Methods 7 | 8 | 9 | 16 | 17 | 18 | 19 | 20 |
21 | 22 | 23 | 24 | 25 | 26 | 65 | 66 | -------------------------------------------------------------------------------- /04 - components/04 - lifecycle events/component.js: -------------------------------------------------------------------------------- 1 | let component = (function () { 2 | 3 | /** 4 | * Get an element in the DOM 5 | * @param {String|Node} elem The element or element selector 6 | * @return {Node} The DOM node 7 | */ 8 | function getElem (elem) { 9 | return typeof elem === 'string' ? document.querySelector(elem) : elem; 10 | } 11 | 12 | /** 13 | * Emit a custom event 14 | * @param {String} type The event type 15 | * @param {Node} elem The element to emit the event on 16 | */ 17 | function emit (type, elem) { 18 | 19 | // Create a new event 20 | let event = new CustomEvent(type, { 21 | bubbles: true, 22 | cancelable: true 23 | }); 24 | 25 | // Dispatch the event 26 | return elem.dispatchEvent(event); 27 | 28 | } 29 | 30 | /** 31 | * Component Class 32 | */ 33 | class Component { 34 | 35 | /** 36 | * The constructor object 37 | * @param {Node|String} elem The element or selector to render the template into 38 | * @param {Function} html The template function to run when the data updates 39 | */ 40 | constructor (elem, html) { 41 | 42 | // Create instance properties 43 | let self = this; 44 | self.elem = elem; 45 | self.template = html; 46 | self.handler = function (event) { 47 | self.render(); 48 | }; 49 | 50 | // Init 51 | self.start(); 52 | 53 | } 54 | 55 | /** 56 | * Start reactive data rendering 57 | */ 58 | start () { 59 | this.render(); 60 | document.addEventListener('store', this.handler); 61 | emit('start', getElem(this.elem)); 62 | } 63 | 64 | /** 65 | * Stop reactive data rendering 66 | */ 67 | stop () { 68 | document.removeEventListener('store', this.handler); 69 | emit('stop', getElem(this.elem)); 70 | } 71 | 72 | /** 73 | * Render the UI 74 | */ 75 | render () { 76 | let elem = getElem(this.elem); 77 | render(elem, this.template()); 78 | emit('render', elem); 79 | } 80 | 81 | } 82 | 83 | /** 84 | * Create a reactive component 85 | * @param {Node} elem The element to render into 86 | * @param {Function} html The template to render 87 | */ 88 | function component (elem, html) { 89 | return new Component(elem, html); 90 | } 91 | 92 | return component; 93 | 94 | })(); -------------------------------------------------------------------------------- /04 - components/04 - lifecycle events/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Lifecycle Events 7 | 8 | 9 | 16 | 17 | 18 | 19 | 20 |
21 | 22 | 23 | 24 | 25 | 26 | 74 | 75 | -------------------------------------------------------------------------------- /04 - components/render.js: -------------------------------------------------------------------------------- 1 | let render = (function () { 2 | 3 | // Form fields and attributes that can be modified by users 4 | // They also have implicit values that make it hard to know if they were changed by the user or developer 5 | let formFields = ['input', 'option', 'textarea']; 6 | let formAtts = ['value', 'checked', 'selected']; 7 | let formAttsNoVal = ['checked', 'selected']; 8 | 9 | /** 10 | * Convert a template string into HTML DOM nodes 11 | * @param {String} str The template string 12 | * @return {Node} The template HTML 13 | */ 14 | function stringToHTML (str) { 15 | 16 | // Create document 17 | let parser = new DOMParser(); 18 | let doc = parser.parseFromString(str, 'text/html'); 19 | 20 | // If there are items in the head, move them to the body 21 | if (doc.head && doc.head.childNodes.length) { 22 | Array.from(doc.head.childNodes).reverse().forEach(function (node) { 23 | doc.body.insertBefore(node, doc.body.firstChild); 24 | }); 25 | } 26 | 27 | return doc.body || document.createElement('body'); 28 | 29 | } 30 | 31 | /** 32 | * Check if an attribute string has a stringified falsy value 33 | * @param {String} str The string 34 | * @return {Boolean} If true, value is falsy (yea, I know, that's a little confusing) 35 | */ 36 | function isFalsy (str) { 37 | return ['false', 'null', 'undefined', '0', '-0', 'NaN', '0n', '-0n'].includes(str); 38 | } 39 | 40 | /** 41 | * Check if attribute should be skipped (sanitize properties) 42 | * @param {String} name The attribute name 43 | * @param {String} value The attribute value 44 | * @return {Boolean} If true, skip the attribute 45 | */ 46 | function skipAttribute (name, value) { 47 | let val = value.replace(/\s+/g, '').toLowerCase(); 48 | if (['src', 'href', 'xlink:href'].includes(name)) { 49 | if (val.includes('javascript:') || val.includes('data:text/html')) return true; 50 | } 51 | if (name.startsWith('on')) return true; 52 | } 53 | 54 | /** 55 | * Add an attribute to an element 56 | * @param {Node} elem The element 57 | * @param {String} att The attribute 58 | * @param {String} val The value 59 | */ 60 | function addAttribute (elem, att, val) { 61 | 62 | // Sanitize dangerous attributes 63 | if (skipAttribute(att, val)) return; 64 | 65 | // If it's a form attribute, set the property directly 66 | if (formAtts.includes(att)) { 67 | elem[att] = att === 'value' ? val : ' '; 68 | } 69 | 70 | // Update the attribute 71 | elem.setAttribute(att, val); 72 | 73 | 74 | } 75 | 76 | /** 77 | * Remove an attribute from an element 78 | * @param {Node} elem The element 79 | * @param {String} att The attribute 80 | */ 81 | function removeAttribute (elem, att) { 82 | 83 | // If it's a form attribute, remove the property directly 84 | if (formAtts.includes(att)) { 85 | elem[att] = ''; 86 | } 87 | 88 | // Remove the attribute 89 | elem.removeAttribute(att); 90 | 91 | } 92 | 93 | /** 94 | * Compare the existing node attributes to the template node attributes and make updates 95 | * @param {Node} template The new template 96 | * @param {Node} existing The existing DOM node 97 | */ 98 | function diffAttributes (template, existing) { 99 | 100 | // If the node is not an element, bail 101 | if (template.nodeType !== 1) return; 102 | 103 | // Get attributes for the template and existing DOM 104 | let templateAtts = template.attributes; 105 | let existingAtts = existing.attributes; 106 | 107 | // Add and update attributes from the template into the DOM 108 | for (let {name, value} of templateAtts) { 109 | 110 | // Skip user-editable form field attributes 111 | if (formAtts.includes(name) && formFields.includes(template.tagName.toLowerCase())) continue; 112 | 113 | // If its a no-value property and it's falsy remove it 114 | if (formAttsNoVal.includes(name) && isFalsy(value)) { 115 | removeAttribute(existing, name); 116 | continue; 117 | } 118 | 119 | // Otherwise, add the attribute 120 | addAttribute(existing, name, value); 121 | 122 | } 123 | 124 | // Remove attributes from the DOM that shouldn't be there 125 | for (let {name, value} of existingAtts) { 126 | 127 | // If the attribute exists in the template, skip it 128 | if (templateAtts[name]) continue; 129 | 130 | // Skip user-editable form field attributes 131 | if (formAtts.includes(name) && formFields.includes(existing.tagName.toLowerCase())) continue; 132 | 133 | // Otherwise, remove it 134 | removeAttribute(existing, name); 135 | 136 | } 137 | 138 | } 139 | 140 | /** 141 | * Add default attributes to a newly created element 142 | * @param {Node} elem The element 143 | */ 144 | function addDefaultAtts (elem) { 145 | 146 | // Only run on elements 147 | if (elem.nodeType !== 1) return; 148 | 149 | // Remove unsafe HTML attributes 150 | for (let {name, value} of elem.attributes) { 151 | 152 | // If the attribute should be skipped, remove it 153 | if (skipAttribute(name, value)) { 154 | removeAttribute(elem, name); 155 | continue; 156 | } 157 | 158 | // If it's a no-value attribute and its falsy, skip it 159 | if (formAttsNoVal.includes(name) && isFalsy(value)) continue; 160 | 161 | // Add the plain attribute 162 | addAttribute(elem, name, value); 163 | 164 | } 165 | 166 | // If there are child elems, recursively add defaults to them 167 | if (elem.childNodes) { 168 | for (let node of elem.childNodes) { 169 | addDefaultAtts(node); 170 | } 171 | } 172 | 173 | } 174 | 175 | /** 176 | * Get the content from a node 177 | * @param {Node} node The node 178 | * @return {String} The content 179 | */ 180 | function getNodeContent (node) { 181 | return node.childNodes && node.childNodes.length ? null : node.textContent; 182 | } 183 | 184 | /** 185 | * Check if two nodes are different 186 | * @param {Node} node1 The first node 187 | * @param {Node} node2 The second node 188 | * @return {Boolean} If true, they're not the same node 189 | */ 190 | function isDifferentNode (node1, node2) { 191 | return ( 192 | (typeof node1.nodeType === 'number' && node1.nodeType !== node2.nodeType) || 193 | (typeof node1.tagName === 'string' && node1.tagName !== node2.tagName) || 194 | (typeof node1.id === 'string' && node1.id !== node2.id) || 195 | (typeof node1.src === 'string' && node1.src !== node2.src) 196 | ); 197 | } 198 | 199 | /** 200 | * Check if the desired node is further ahead in the DOM existingNodes 201 | * @param {Node} node The node to look for 202 | * @param {NodeList} existingNodes The DOM existingNodes 203 | * @param {Integer} index The indexing index 204 | * @return {Integer} How many nodes ahead the target node is 205 | */ 206 | function aheadInTree (node, existingNodes, index) { 207 | return Array.from(existingNodes).slice(index + 1).find(function (branch) { 208 | return !isDifferentNode(node, branch); 209 | }); 210 | } 211 | 212 | /** 213 | * If there are extra elements in DOM, remove them 214 | * @param {Array} existingNodes The existing DOM 215 | * @param {Array} templateNodes The template 216 | */ 217 | function trimExtraNodes (existingNodes, templateNodes) { 218 | let extra = existingNodes.length - templateNodes.length; 219 | if (extra < 1) return; 220 | for (; extra > 0; extra--) { 221 | existingNodes[existingNodes.length - 1].remove(); 222 | } 223 | } 224 | 225 | /** 226 | * Remove scripts from HTML 227 | * @param {Node} elem The element to remove scripts from 228 | */ 229 | function removeScripts (elem) { 230 | let scripts = elem.querySelectorAll('script'); 231 | for (let script of scripts) { 232 | script.remove(); 233 | } 234 | } 235 | 236 | /** 237 | * Diff the existing DOM node versus the template 238 | * @param {Array} template The template HTML 239 | * @param {Node} existing The current DOM HTML 240 | */ 241 | function diff (template, existing) { 242 | 243 | // Get the nodes in the template and existing UI 244 | let templateNodes = template.childNodes; 245 | let existingNodes = existing.childNodes; 246 | 247 | // Don't inject scripts 248 | if (removeScripts(template)) return; 249 | 250 | // Loop through each node in the template and compare it to the matching element in the UI 251 | templateNodes.forEach(function (node, index) { 252 | 253 | // If element doesn't exist, create it 254 | if (!existingNodes[index]) { 255 | let clone = node.cloneNode(true); 256 | addDefaultAtts(clone); 257 | existing.append(clone); 258 | return; 259 | } 260 | 261 | // If there is, but it's not the same node type, insert the new node before the existing one 262 | if (isDifferentNode(node, existingNodes[index])) { 263 | 264 | // Check if node exists further in the tree 265 | let ahead = aheadInTree(node, existingNodes, index); 266 | 267 | // If not, insert the node before the current one 268 | if (!ahead) { 269 | let clone = node.cloneNode(true); 270 | addDefaultAtts(clone); 271 | existingNodes[index].before(clone); 272 | return; 273 | } 274 | 275 | // Otherwise, move it to the current spot 276 | existingNodes[index].before(ahead); 277 | 278 | } 279 | 280 | // If attributes are different, update them 281 | diffAttributes(node, existingNodes[index]); 282 | 283 | // Stop diffing if a native web component 284 | if (node.nodeName.includes('-')) return; 285 | 286 | // If content is different, update it 287 | let templateContent = getNodeContent(node); 288 | if (templateContent && templateContent !== getNodeContent(existingNodes[index])) { 289 | existingNodes[index].textContent = templateContent; 290 | } 291 | 292 | // If there shouldn't be child nodes but there are, remove them 293 | if (!node.childNodes.length && existingNodes[index].childNodes.length) { 294 | existingNodes[index].innerHTML = ''; 295 | return; 296 | } 297 | 298 | // If DOM is empty and shouldn't be, build it up 299 | // This uses a document fragment to minimize reflows 300 | if (!existingNodes[index].childNodes.length && node.childNodes.length) { 301 | let fragment = document.createDocumentFragment(); 302 | diff(node, fragment); 303 | existingNodes[index].appendChild(fragment); 304 | return; 305 | } 306 | 307 | // If there are nodes within it, recursively diff those 308 | if (node.childNodes.length) { 309 | diff(node, existingNodes[index]); 310 | } 311 | 312 | }); 313 | 314 | // If extra elements in DOM, remove them 315 | trimExtraNodes(existingNodes, templateNodes); 316 | 317 | } 318 | 319 | /** 320 | * Render a template into the UI 321 | * @param {Node|String} elem The element or selector to render the template into 322 | * @param {String} template The template to render 323 | */ 324 | function render (elem, template) { 325 | let node = elem === 'string' ? document.querySelector(elem) : elem; 326 | let html = stringToHTML(template); 327 | diff(html, node); 328 | } 329 | 330 | return render; 331 | 332 | })(); -------------------------------------------------------------------------------- /04 - components/store.js: -------------------------------------------------------------------------------- 1 | let store = (function () { 2 | 3 | /** 4 | * Emit a custom event 5 | * @param {*} data The store data 6 | */ 7 | function emit (data) { 8 | 9 | // Create a new event 10 | let event = new CustomEvent('store', { 11 | bubbles: true, 12 | cancelable: true, 13 | detail: data 14 | }); 15 | 16 | // Dispatch the event 17 | return document.dispatchEvent(event); 18 | 19 | } 20 | 21 | /** 22 | * Create a Proxy handler object 23 | * @param {Object} data The data object 24 | * @return {Object} The handler object 25 | */ 26 | function handler (data) { 27 | return { 28 | get (obj, prop) { 29 | if (prop === '_isProxy') return true; 30 | if (['[object Object]', '[object Array]'].includes(Object.prototype.toString.call(obj[prop])) && !obj[prop]._isProxy) { 31 | obj[prop] = new Proxy(obj[prop], handler(data)); 32 | } 33 | return obj[prop]; 34 | }, 35 | set (obj, prop, value) { 36 | if (obj[prop] === value) return true; 37 | obj[prop] = value; 38 | emit(data); 39 | return true; 40 | }, 41 | deleteProperty (obj, prop) { 42 | delete obj[prop]; 43 | emit(data); 44 | return true; 45 | } 46 | }; 47 | } 48 | 49 | /** 50 | * Create a new store 51 | * @param {Object} data The data object 52 | * @return {Proxy} The reactive proxy 53 | */ 54 | function store (data = {}) { 55 | data = ['[object Object]', '[object Array]'].includes(Object.prototype.toString.call(data)) ? data : {value: data}; 56 | return new Proxy(data, handler(data)); 57 | } 58 | 59 | return store; 60 | 61 | })(); -------------------------------------------------------------------------------- /05 - performance/01 - batching renders.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Batching Renders 7 | 8 | 9 | 16 | 17 | 18 | 19 | 20 |
21 | 22 | 23 | 24 | 25 | 26 | 72 | 73 | -------------------------------------------------------------------------------- /05 - performance/02 - memoization and ids.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Memoization & IDs 7 | 8 | 9 | 16 | 17 | 18 | 19 | 20 |
21 | 22 | 23 | 24 | 25 | 26 | 64 | 65 | -------------------------------------------------------------------------------- /05 - performance/03 - virtual dom.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Virtual DOM 7 | 8 | 9 | 16 | 17 | 18 | 19 | 20 |

Wizards

21 | 26 | 27 | 28 | 69 | 70 | -------------------------------------------------------------------------------- /05 - performance/04 - why use a virtual dom.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Why use a virtual DOM? 7 | 8 | 9 | 16 | 17 | 18 | 19 | 20 | 21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 | Reply 35 |
36 |
37 | Retweet 38 |
39 |
40 | Favorite 41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 | 55 | -------------------------------------------------------------------------------- /05 - performance/component.js: -------------------------------------------------------------------------------- 1 | let component = (function () { 2 | 3 | /** 4 | * Get an element in the DOM 5 | * @param {String|Node} elem The element or element selector 6 | * @return {Node} The DOM node 7 | */ 8 | function getElem (elem) { 9 | return typeof elem === 'string' ? document.querySelector(elem) : elem; 10 | } 11 | 12 | /** 13 | * Emit a custom event 14 | * @param {String} type The event type 15 | * @param {Node} elem The element to emit the event on 16 | */ 17 | function emit (type, elem) { 18 | 19 | // Create a new event 20 | let event = new CustomEvent(type, { 21 | bubbles: true, 22 | cancelable: true 23 | }); 24 | 25 | // Dispatch the event 26 | return elem.dispatchEvent(event); 27 | 28 | } 29 | 30 | /** 31 | * Component Class 32 | */ 33 | class Component { 34 | 35 | /** 36 | * The constructor object 37 | * @param {Node|String} elem The element or selector to render the template into 38 | * @param {Function} html The template function to run when the data updates 39 | */ 40 | constructor (elem, html) { 41 | 42 | // Create instance properties 43 | let self = this; 44 | self.elem = elem; 45 | self.template = html; 46 | self.handler = function (event) { 47 | self.render(); 48 | }; 49 | self.debounce = null; 50 | 51 | // Init 52 | self.start(); 53 | 54 | } 55 | 56 | /** 57 | * Start reactive data rendering 58 | */ 59 | start () { 60 | this.render(); 61 | document.addEventListener('store', this.handler); 62 | emit('start', getElem(this.elem)); 63 | } 64 | 65 | /** 66 | * Stop reactive data rendering 67 | */ 68 | stop () { 69 | document.removeEventListener('store', this.handler); 70 | emit('stop', getElem(this.elem)); 71 | } 72 | 73 | /** 74 | * Render the UI 75 | */ 76 | render () { 77 | 78 | // Cache instance 79 | let self = this; 80 | 81 | // If there's a pending render, cancel it 82 | if (self.debounce) { 83 | window.cancelAnimationFrame(self.debounce); 84 | } 85 | 86 | // Setup the new render to run at the next animation frame 87 | self.debounce = window.requestAnimationFrame(function () { 88 | let elem = getElem(self.elem); 89 | render(elem, self.template()); 90 | emit('render', elem); 91 | }); 92 | 93 | } 94 | 95 | } 96 | 97 | /** 98 | * Create a reactive component 99 | * @param {Node} elem The element to render into 100 | * @param {Function} html The template to render 101 | */ 102 | function component (elem, html) { 103 | return new Component(elem, html); 104 | } 105 | 106 | return component; 107 | 108 | })(); -------------------------------------------------------------------------------- /05 - performance/render.js: -------------------------------------------------------------------------------- 1 | let render = (function () { 2 | 3 | // Form fields and attributes that can be modified by users 4 | // They also have implicit values that make it hard to know if they were changed by the user or developer 5 | let formFields = ['input', 'option', 'textarea']; 6 | let formAtts = ['value', 'checked', 'selected']; 7 | let formAttsNoVal = ['checked', 'selected']; 8 | 9 | /** 10 | * Convert a template string into HTML DOM nodes 11 | * @param {String} str The template string 12 | * @return {Node} The template HTML 13 | */ 14 | function stringToHTML (str) { 15 | 16 | // Create document 17 | let parser = new DOMParser(); 18 | let doc = parser.parseFromString(str, 'text/html'); 19 | 20 | // If there are items in the head, move them to the body 21 | if (doc.head && doc.head.childNodes.length) { 22 | Array.from(doc.head.childNodes).reverse().forEach(function (node) { 23 | doc.body.insertBefore(node, doc.body.firstChild); 24 | }); 25 | } 26 | 27 | return doc.body || document.createElement('body'); 28 | 29 | } 30 | 31 | /** 32 | * Check if an attribute string has a stringified falsy value 33 | * @param {String} str The string 34 | * @return {Boolean} If true, value is falsy (yea, I know, that's a little confusing) 35 | */ 36 | function isFalsy (str) { 37 | return ['false', 'null', 'undefined', '0', '-0', 'NaN', '0n', '-0n'].includes(str); 38 | } 39 | 40 | /** 41 | * Check if attribute should be skipped (sanitize properties) 42 | * @param {String} name The attribute name 43 | * @param {String} value The attribute value 44 | * @return {Boolean} If true, skip the attribute 45 | */ 46 | function skipAttribute (name, value) { 47 | let val = value.replace(/\s+/g, '').toLowerCase(); 48 | if (['src', 'href', 'xlink:href'].includes(name)) { 49 | if (val.includes('javascript:') || val.includes('data:text/html')) return true; 50 | } 51 | if (name.startsWith('on')) return true; 52 | } 53 | 54 | /** 55 | * Add an attribute to an element 56 | * @param {Node} elem The element 57 | * @param {String} att The attribute 58 | * @param {String} val The value 59 | */ 60 | function addAttribute (elem, att, val) { 61 | 62 | // Sanitize dangerous attributes 63 | if (skipAttribute(att, val)) return; 64 | 65 | // If it's a form attribute, set the property directly 66 | if (formAtts.includes(att)) { 67 | elem[att] = att === 'value' ? val : ' '; 68 | } 69 | 70 | // Update the attribute 71 | elem.setAttribute(att, val); 72 | 73 | 74 | } 75 | 76 | /** 77 | * Remove an attribute from an element 78 | * @param {Node} elem The element 79 | * @param {String} att The attribute 80 | */ 81 | function removeAttribute (elem, att) { 82 | 83 | // If it's a form attribute, remove the property directly 84 | if (formAtts.includes(att)) { 85 | elem[att] = ''; 86 | } 87 | 88 | // Remove the attribute 89 | elem.removeAttribute(att); 90 | 91 | } 92 | 93 | /** 94 | * Compare the existing node attributes to the template node attributes and make updates 95 | * @param {Node} template The new template 96 | * @param {Node} existing The existing DOM node 97 | */ 98 | function diffAttributes (template, existing) { 99 | 100 | // If the node is not an element, bail 101 | if (template.nodeType !== 1) return; 102 | 103 | // Get attributes for the template and existing DOM 104 | let templateAtts = template.attributes; 105 | let existingAtts = existing.attributes; 106 | 107 | // Add and update attributes from the template into the DOM 108 | for (let {name, value} of templateAtts) { 109 | 110 | // Skip user-editable form field attributes 111 | if (formAtts.includes(name) && formFields.includes(template.tagName.toLowerCase())) continue; 112 | 113 | // If its a no-value property and it's falsy remove it 114 | if (formAttsNoVal.includes(name) && isFalsy(value)) { 115 | removeAttribute(existing, name); 116 | continue; 117 | } 118 | 119 | // Otherwise, add the attribute 120 | addAttribute(existing, name, value); 121 | 122 | } 123 | 124 | // Remove attributes from the DOM that shouldn't be there 125 | for (let {name, value} of existingAtts) { 126 | 127 | // If the attribute exists in the template, skip it 128 | if (templateAtts[name]) continue; 129 | 130 | // Skip user-editable form field attributes 131 | if (formAtts.includes(name) && formFields.includes(existing.tagName.toLowerCase())) continue; 132 | 133 | // Otherwise, remove it 134 | removeAttribute(existing, name); 135 | 136 | } 137 | 138 | } 139 | 140 | /** 141 | * Add default attributes to a newly created element 142 | * @param {Node} elem The element 143 | */ 144 | function addDefaultAtts (elem) { 145 | 146 | // Only run on elements 147 | if (elem.nodeType !== 1) return; 148 | 149 | // Remove unsafe HTML attributes 150 | for (let {name, value} of elem.attributes) { 151 | 152 | // If the attribute should be skipped, remove it 153 | if (skipAttribute(name, value)) { 154 | removeAttribute(elem, name); 155 | continue; 156 | } 157 | 158 | // If it's a no-value attribute and its falsy, skip it 159 | if (formAttsNoVal.includes(name) && isFalsy(value)) continue; 160 | 161 | // Add the plain attribute 162 | addAttribute(elem, name, value); 163 | 164 | } 165 | 166 | // If there are child elems, recursively add defaults to them 167 | if (elem.childNodes) { 168 | for (let node of elem.childNodes) { 169 | addDefaultAtts(node); 170 | } 171 | } 172 | 173 | } 174 | 175 | /** 176 | * Get the content from a node 177 | * @param {Node} node The node 178 | * @return {String} The content 179 | */ 180 | function getNodeContent (node) { 181 | return node.childNodes && node.childNodes.length ? null : node.textContent; 182 | } 183 | 184 | /** 185 | * Check if two nodes are different 186 | * @param {Node} node1 The first node 187 | * @param {Node} node2 The second node 188 | * @return {Boolean} If true, they're not the same node 189 | */ 190 | function isDifferentNode (node1, node2) { 191 | return ( 192 | (typeof node1.nodeType === 'number' && node1.nodeType !== node2.nodeType) || 193 | (typeof node1.tagName === 'string' && node1.tagName !== node2.tagName) || 194 | (typeof node1.id === 'string' && node1.id !== node2.id) || 195 | (typeof node1.src === 'string' && node1.src !== node2.src) 196 | ); 197 | } 198 | 199 | /** 200 | * Check if the desired node is further ahead in the DOM existingNodes 201 | * @param {Node} node The node to look for 202 | * @param {NodeList} existingNodes The DOM existingNodes 203 | * @param {Integer} index The indexing index 204 | * @return {Integer} How many nodes ahead the target node is 205 | */ 206 | function aheadInTree (node, existingNodes, index) { 207 | return Array.from(existingNodes).slice(index + 1).find(function (branch) { 208 | return !isDifferentNode(node, branch); 209 | }); 210 | } 211 | 212 | /** 213 | * If there are extra elements in DOM, remove them 214 | * @param {Array} existingNodes The existing DOM 215 | * @param {Array} templateNodes The template 216 | */ 217 | function trimExtraNodes (existingNodes, templateNodes) { 218 | let extra = existingNodes.length - templateNodes.length; 219 | if (extra < 1) return; 220 | for (; extra > 0; extra--) { 221 | existingNodes[existingNodes.length - 1].remove(); 222 | } 223 | } 224 | 225 | /** 226 | * Remove scripts from HTML 227 | * @param {Node} elem The element to remove scripts from 228 | */ 229 | function removeScripts (elem) { 230 | let scripts = elem.querySelectorAll('script'); 231 | for (let script of scripts) { 232 | script.remove(); 233 | } 234 | } 235 | 236 | /** 237 | * Diff the existing DOM node versus the template 238 | * @param {Array} template The template HTML 239 | * @param {Node} existing The current DOM HTML 240 | */ 241 | function diff (template, existing) { 242 | 243 | // Get the nodes in the template and existing UI 244 | let templateNodes = template.childNodes; 245 | let existingNodes = existing.childNodes; 246 | 247 | // Don't inject scripts 248 | if (removeScripts(template)) return; 249 | 250 | // Loop through each node in the template and compare it to the matching element in the UI 251 | templateNodes.forEach(function (node, index) { 252 | 253 | // If element doesn't exist, create it 254 | if (!existingNodes[index]) { 255 | let clone = node.cloneNode(true); 256 | addDefaultAtts(clone); 257 | existing.append(clone); 258 | return; 259 | } 260 | 261 | // If there is, but it's not the same node type, insert the new node before the existing one 262 | if (isDifferentNode(node, existingNodes[index])) { 263 | 264 | // Check if node exists further in the tree 265 | let ahead = aheadInTree(node, existingNodes, index); 266 | 267 | // If not, insert the node before the current one 268 | if (!ahead) { 269 | let clone = node.cloneNode(true); 270 | addDefaultAtts(clone); 271 | existingNodes[index].before(clone); 272 | return; 273 | } 274 | 275 | // Otherwise, move it to the current spot 276 | existingNodes[index].before(ahead); 277 | 278 | } 279 | 280 | // If attributes are different, update them 281 | diffAttributes(node, existingNodes[index]); 282 | 283 | // Stop diffing if a native web component 284 | if (node.nodeName.includes('-')) return; 285 | 286 | // If content is different, update it 287 | let templateContent = getNodeContent(node); 288 | if (templateContent && templateContent !== getNodeContent(existingNodes[index])) { 289 | existingNodes[index].textContent = templateContent; 290 | } 291 | 292 | // If there shouldn't be child nodes but there are, remove them 293 | if (!node.childNodes.length && existingNodes[index].childNodes.length) { 294 | existingNodes[index].innerHTML = ''; 295 | return; 296 | } 297 | 298 | // If DOM is empty and shouldn't be, build it up 299 | // This uses a document fragment to minimize reflows 300 | if (!existingNodes[index].childNodes.length && node.childNodes.length) { 301 | let fragment = document.createDocumentFragment(); 302 | diff(node, fragment); 303 | existingNodes[index].appendChild(fragment); 304 | return; 305 | } 306 | 307 | // If there are nodes within it, recursively diff those 308 | if (node.childNodes.length) { 309 | diff(node, existingNodes[index]); 310 | } 311 | 312 | }); 313 | 314 | // If extra elements in DOM, remove them 315 | trimExtraNodes(existingNodes, templateNodes); 316 | 317 | } 318 | 319 | /** 320 | * Render a template into the UI 321 | * @param {Node|String} elem The element or selector to render the template into 322 | * @param {String} template The template to render 323 | */ 324 | function render (elem, template) { 325 | let node = elem === 'string' ? document.querySelector(elem) : elem; 326 | let html = stringToHTML(template); 327 | diff(html, node); 328 | } 329 | 330 | return render; 331 | 332 | })(); -------------------------------------------------------------------------------- /05 - performance/store.js: -------------------------------------------------------------------------------- 1 | let store = (function () { 2 | 3 | /** 4 | * Emit a custom event 5 | * @param {*} data The store data 6 | */ 7 | function emit (data) { 8 | 9 | // Create a new event 10 | let event = new CustomEvent('store', { 11 | bubbles: true, 12 | cancelable: true, 13 | detail: data 14 | }); 15 | 16 | // Dispatch the event 17 | return document.dispatchEvent(event); 18 | 19 | } 20 | 21 | /** 22 | * Create a Proxy handler object 23 | * @param {Object} data The data object 24 | * @return {Object} The handler object 25 | */ 26 | function handler (data) { 27 | return { 28 | get (obj, prop) { 29 | if (prop === '_isProxy') return true; 30 | if (['[object Object]', '[object Array]'].includes(Object.prototype.toString.call(obj[prop])) && !obj[prop]._isProxy) { 31 | obj[prop] = new Proxy(obj[prop], handler(data)); 32 | } 33 | return obj[prop]; 34 | }, 35 | set (obj, prop, value) { 36 | if (obj[prop] === value) return true; 37 | obj[prop] = value; 38 | emit(data); 39 | return true; 40 | }, 41 | deleteProperty (obj, prop) { 42 | delete obj[prop]; 43 | emit(data); 44 | return true; 45 | } 46 | }; 47 | } 48 | 49 | /** 50 | * Create a new store 51 | * @param {Object} data The data object 52 | * @return {Proxy} The reactive proxy 53 | */ 54 | function store (data = {}) { 55 | data = ['[object Object]', '[object Array]'].includes(Object.prototype.toString.call(data)) ? data : {value: data}; 56 | return new Proxy(data, handler(data)); 57 | } 58 | 59 | return store; 60 | 61 | })(); -------------------------------------------------------------------------------- /06 - project/01 - project template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Project Template 7 | 8 | 9 | 36 | 37 | 38 | 39 | 40 |

Awesome Dogs

41 | 42 |
43 |
44 | 45 | 51 |
52 | 53 |
54 | 55 | 61 |
62 | 63 |
64 | 65 | 70 |
71 |
72 | 73 | 74 |
75 | 76 | 77 | 78 | 79 | 80 | 81 | 84 | 85 | -------------------------------------------------------------------------------- /06 - project/02 - project completed.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Project Completed 7 | 8 | 9 | 44 | 45 | 46 | 47 | 48 |

Awesome Dogs

49 | 50 |
51 |
52 | 53 | 59 |
60 | 61 |
62 | 63 | 69 |
70 | 71 |
72 | 73 | 78 |
79 |
80 | 81 | 82 |
83 | 84 | 85 | 86 | 87 | 88 | 89 | 157 | 158 | 159 | -------------------------------------------------------------------------------- /06 - project/component.js: -------------------------------------------------------------------------------- 1 | let component = (function () { 2 | 3 | /** 4 | * Get an element in the DOM 5 | * @param {String|Node} elem The element or element selector 6 | * @return {Node} The DOM node 7 | */ 8 | function getElem (elem) { 9 | return typeof elem === 'string' ? document.querySelector(elem) : elem; 10 | } 11 | 12 | /** 13 | * Emit a custom event 14 | * @param {String} type The event type 15 | * @param {Node} elem The element to emit the event on 16 | */ 17 | function emit (type, elem) { 18 | 19 | // Create a new event 20 | let event = new CustomEvent(type, { 21 | bubbles: true, 22 | cancelable: true 23 | }); 24 | 25 | // Dispatch the event 26 | return elem.dispatchEvent(event); 27 | 28 | } 29 | 30 | /** 31 | * Component Class 32 | */ 33 | class Component { 34 | 35 | /** 36 | * The constructor object 37 | * @param {Node|String} elem The element or selector to render the template into 38 | * @param {Function} html The template function to run when the data updates 39 | */ 40 | constructor (elem, html) { 41 | 42 | // Create instance properties 43 | let self = this; 44 | self.elem = elem; 45 | self.template = html; 46 | self.handler = function (event) { 47 | self.render(); 48 | }; 49 | self.debounce = null; 50 | 51 | // Init 52 | self.start(); 53 | 54 | } 55 | 56 | /** 57 | * Start reactive data rendering 58 | */ 59 | start () { 60 | this.render(); 61 | document.addEventListener('store', this.handler); 62 | emit('start', getElem(this.elem)); 63 | } 64 | 65 | /** 66 | * Stop reactive data rendering 67 | */ 68 | stop () { 69 | document.removeEventListener('store', this.handler); 70 | emit('stop', getElem(this.elem)); 71 | } 72 | 73 | /** 74 | * Render the UI 75 | */ 76 | render () { 77 | 78 | // Cache instance 79 | let self = this; 80 | 81 | // If there's a pending render, cancel it 82 | if (self.debounce) { 83 | window.cancelAnimationFrame(self.debounce); 84 | } 85 | 86 | // Setup the new render to run at the next animation frame 87 | self.debounce = window.requestAnimationFrame(function () { 88 | let elem = getElem(self.elem); 89 | render(elem, self.template()); 90 | emit('render', elem); 91 | }); 92 | 93 | } 94 | 95 | } 96 | 97 | /** 98 | * Create a reactive component 99 | * @param {Node} elem The element to render into 100 | * @param {Function} html The template to render 101 | */ 102 | function component (elem, html) { 103 | return new Component(elem, html); 104 | } 105 | 106 | return component; 107 | 108 | })(); -------------------------------------------------------------------------------- /06 - project/dogs.js: -------------------------------------------------------------------------------- 1 | let dogData = [ 2 | { 3 | name: 'Bandit', 4 | age: 'Adult', 5 | size: 'Large', 6 | cats: true, 7 | img: 'bandit.jpg' 8 | }, 9 | { 10 | name: 'Bert', 11 | age: 'Adult', 12 | size: 'Small', 13 | cats: false, 14 | img: 'bert.jpg' 15 | }, 16 | { 17 | name: 'Brad', 18 | age: 'Puppy', 19 | size: 'Large', 20 | cats: false, 21 | img: 'brad.jpg' 22 | }, 23 | { 24 | name: 'Chewie', 25 | age: 'Senior', 26 | size: 'Small', 27 | cats: false, 28 | img: 'chewie.jpg' 29 | }, 30 | { 31 | name: 'Daphne', 32 | age: 'Adult', 33 | size: 'Large', 34 | cats: true, 35 | img: 'daphne.jpg' 36 | }, 37 | { 38 | name: 'Ethel', 39 | age: 'Puppy', 40 | size: 'Large', 41 | cats: true, 42 | img: 'ethel.jpg' 43 | }, 44 | { 45 | name: 'Florence', 46 | age: 'Adult', 47 | size: 'Small', 48 | cats: false, 49 | img: 'florence.jpg' 50 | }, 51 | { 52 | name: 'Grace', 53 | age: 'Puppy', 54 | size: 'Small', 55 | cats: false, 56 | img: 'grace.jpg' 57 | }, 58 | { 59 | name: 'Janice', 60 | age: 'Senior', 61 | size: 'Medium', 62 | cats: true, 63 | img: 'janice.jpg' 64 | }, 65 | { 66 | name: 'Joseph', 67 | age: 'Senior', 68 | size: 'Small', 69 | cats: true, 70 | img: 'joseph.jpg' 71 | }, 72 | { 73 | name: 'Kyle', 74 | age: 'Puppy', 75 | size: 'Medium', 76 | cats: true, 77 | img: 'kyle.jpg' 78 | }, 79 | { 80 | name: 'Reba', 81 | age: 'Senior', 82 | size: 'Large', 83 | cats: true, 84 | img: 'reba.jpg' 85 | }, 86 | { 87 | name: 'Thor', 88 | age: 'Puppy', 89 | size: 'Large', 90 | cats: true, 91 | img: 'thor.jpg' 92 | }, 93 | { 94 | name: 'William', 95 | age: 'Senior', 96 | size: 'Medium', 97 | cats: true, 98 | img: 'william.jpg' 99 | } 100 | ]; -------------------------------------------------------------------------------- /06 - project/img/bandit.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cferdinandi/state-based-ui-source-code/5856f4d885ed8f70d91bddc28d0cf2db1982e081/06 - project/img/bandit.jpg -------------------------------------------------------------------------------- /06 - project/img/bert.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cferdinandi/state-based-ui-source-code/5856f4d885ed8f70d91bddc28d0cf2db1982e081/06 - project/img/bert.jpg -------------------------------------------------------------------------------- /06 - project/img/brad.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cferdinandi/state-based-ui-source-code/5856f4d885ed8f70d91bddc28d0cf2db1982e081/06 - project/img/brad.jpg -------------------------------------------------------------------------------- /06 - project/img/chewie.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cferdinandi/state-based-ui-source-code/5856f4d885ed8f70d91bddc28d0cf2db1982e081/06 - project/img/chewie.jpg -------------------------------------------------------------------------------- /06 - project/img/daphne.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cferdinandi/state-based-ui-source-code/5856f4d885ed8f70d91bddc28d0cf2db1982e081/06 - project/img/daphne.jpg -------------------------------------------------------------------------------- /06 - project/img/ethel.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cferdinandi/state-based-ui-source-code/5856f4d885ed8f70d91bddc28d0cf2db1982e081/06 - project/img/ethel.jpg -------------------------------------------------------------------------------- /06 - project/img/florence.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cferdinandi/state-based-ui-source-code/5856f4d885ed8f70d91bddc28d0cf2db1982e081/06 - project/img/florence.jpg -------------------------------------------------------------------------------- /06 - project/img/grace.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cferdinandi/state-based-ui-source-code/5856f4d885ed8f70d91bddc28d0cf2db1982e081/06 - project/img/grace.jpg -------------------------------------------------------------------------------- /06 - project/img/janice.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cferdinandi/state-based-ui-source-code/5856f4d885ed8f70d91bddc28d0cf2db1982e081/06 - project/img/janice.jpg -------------------------------------------------------------------------------- /06 - project/img/joseph.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cferdinandi/state-based-ui-source-code/5856f4d885ed8f70d91bddc28d0cf2db1982e081/06 - project/img/joseph.jpg -------------------------------------------------------------------------------- /06 - project/img/kyle.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cferdinandi/state-based-ui-source-code/5856f4d885ed8f70d91bddc28d0cf2db1982e081/06 - project/img/kyle.jpg -------------------------------------------------------------------------------- /06 - project/img/reba.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cferdinandi/state-based-ui-source-code/5856f4d885ed8f70d91bddc28d0cf2db1982e081/06 - project/img/reba.jpg -------------------------------------------------------------------------------- /06 - project/img/thor.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cferdinandi/state-based-ui-source-code/5856f4d885ed8f70d91bddc28d0cf2db1982e081/06 - project/img/thor.jpg -------------------------------------------------------------------------------- /06 - project/img/william.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cferdinandi/state-based-ui-source-code/5856f4d885ed8f70d91bddc28d0cf2db1982e081/06 - project/img/william.jpg -------------------------------------------------------------------------------- /06 - project/render.js: -------------------------------------------------------------------------------- 1 | let render = (function () { 2 | 3 | // Form fields and attributes that can be modified by users 4 | // They also have implicit values that make it hard to know if they were changed by the user or developer 5 | let formFields = ['input', 'option', 'textarea']; 6 | let formAtts = ['value', 'checked', 'selected']; 7 | let formAttsNoVal = ['checked', 'selected']; 8 | 9 | /** 10 | * Convert a template string into HTML DOM nodes 11 | * @param {String} str The template string 12 | * @return {Node} The template HTML 13 | */ 14 | function stringToHTML (str) { 15 | 16 | // Create document 17 | let parser = new DOMParser(); 18 | let doc = parser.parseFromString(str, 'text/html'); 19 | 20 | // If there are items in the head, move them to the body 21 | if (doc.head && doc.head.childNodes.length) { 22 | Array.from(doc.head.childNodes).reverse().forEach(function (node) { 23 | doc.body.insertBefore(node, doc.body.firstChild); 24 | }); 25 | } 26 | 27 | return doc.body || document.createElement('body'); 28 | 29 | } 30 | 31 | /** 32 | * Check if an attribute string has a stringified falsy value 33 | * @param {String} str The string 34 | * @return {Boolean} If true, value is falsy (yea, I know, that's a little confusing) 35 | */ 36 | function isFalsy (str) { 37 | return ['false', 'null', 'undefined', '0', '-0', 'NaN', '0n', '-0n'].includes(str); 38 | } 39 | 40 | /** 41 | * Check if attribute should be skipped (sanitize properties) 42 | * @param {String} name The attribute name 43 | * @param {String} value The attribute value 44 | * @return {Boolean} If true, skip the attribute 45 | */ 46 | function skipAttribute (name, value) { 47 | let val = value.replace(/\s+/g, '').toLowerCase(); 48 | if (['src', 'href', 'xlink:href'].includes(name)) { 49 | if (val.includes('javascript:') || val.includes('data:text/html')) return true; 50 | } 51 | if (name.startsWith('on')) return true; 52 | } 53 | 54 | /** 55 | * Add an attribute to an element 56 | * @param {Node} elem The element 57 | * @param {String} att The attribute 58 | * @param {String} val The value 59 | */ 60 | function addAttribute (elem, att, val) { 61 | 62 | // Sanitize dangerous attributes 63 | if (skipAttribute(att, val)) return; 64 | 65 | // If it's a form attribute, set the property directly 66 | if (formAtts.includes(att)) { 67 | elem[att] = att === 'value' ? val : ' '; 68 | } 69 | 70 | // Update the attribute 71 | elem.setAttribute(att, val); 72 | 73 | 74 | } 75 | 76 | /** 77 | * Remove an attribute from an element 78 | * @param {Node} elem The element 79 | * @param {String} att The attribute 80 | */ 81 | function removeAttribute (elem, att) { 82 | 83 | // If it's a form attribute, remove the property directly 84 | if (formAtts.includes(att)) { 85 | elem[att] = ''; 86 | } 87 | 88 | // Remove the attribute 89 | elem.removeAttribute(att); 90 | 91 | } 92 | 93 | /** 94 | * Compare the existing node attributes to the template node attributes and make updates 95 | * @param {Node} template The new template 96 | * @param {Node} existing The existing DOM node 97 | */ 98 | function diffAttributes (template, existing) { 99 | 100 | // If the node is not an element, bail 101 | if (template.nodeType !== 1) return; 102 | 103 | // Get attributes for the template and existing DOM 104 | let templateAtts = template.attributes; 105 | let existingAtts = existing.attributes; 106 | 107 | // Add and update attributes from the template into the DOM 108 | for (let {name, value} of templateAtts) { 109 | 110 | // Skip user-editable form field attributes 111 | if (formAtts.includes(name) && formFields.includes(template.tagName.toLowerCase())) continue; 112 | 113 | // If its a no-value property and it's falsy remove it 114 | if (formAttsNoVal.includes(name) && isFalsy(value)) { 115 | removeAttribute(existing, name); 116 | continue; 117 | } 118 | 119 | // Otherwise, add the attribute 120 | addAttribute(existing, name, value); 121 | 122 | } 123 | 124 | // Remove attributes from the DOM that shouldn't be there 125 | for (let {name, value} of existingAtts) { 126 | 127 | // If the attribute exists in the template, skip it 128 | if (templateAtts[name]) continue; 129 | 130 | // Skip user-editable form field attributes 131 | if (formAtts.includes(name) && formFields.includes(existing.tagName.toLowerCase())) continue; 132 | 133 | // Otherwise, remove it 134 | removeAttribute(existing, name); 135 | 136 | } 137 | 138 | } 139 | 140 | /** 141 | * Add default attributes to a newly created element 142 | * @param {Node} elem The element 143 | */ 144 | function addDefaultAtts (elem) { 145 | 146 | // Only run on elements 147 | if (elem.nodeType !== 1) return; 148 | 149 | // Remove unsafe HTML attributes 150 | for (let {name, value} of elem.attributes) { 151 | 152 | // If the attribute should be skipped, remove it 153 | if (skipAttribute(name, value)) { 154 | removeAttribute(elem, name); 155 | continue; 156 | } 157 | 158 | // If it's a no-value attribute and its falsy, skip it 159 | if (formAttsNoVal.includes(name) && isFalsy(value)) continue; 160 | 161 | // Add the plain attribute 162 | addAttribute(elem, name, value); 163 | 164 | } 165 | 166 | // If there are child elems, recursively add defaults to them 167 | if (elem.childNodes) { 168 | for (let node of elem.childNodes) { 169 | addDefaultAtts(node); 170 | } 171 | } 172 | 173 | } 174 | 175 | /** 176 | * Get the content from a node 177 | * @param {Node} node The node 178 | * @return {String} The content 179 | */ 180 | function getNodeContent (node) { 181 | return node.childNodes && node.childNodes.length ? null : node.textContent; 182 | } 183 | 184 | /** 185 | * Check if two nodes are different 186 | * @param {Node} node1 The first node 187 | * @param {Node} node2 The second node 188 | * @return {Boolean} If true, they're not the same node 189 | */ 190 | function isDifferentNode (node1, node2) { 191 | return ( 192 | (typeof node1.nodeType === 'number' && node1.nodeType !== node2.nodeType) || 193 | (typeof node1.tagName === 'string' && node1.tagName !== node2.tagName) || 194 | (typeof node1.id === 'string' && node1.id !== node2.id) || 195 | (typeof node1.src === 'string' && node1.src !== node2.src) 196 | ); 197 | } 198 | 199 | /** 200 | * Check if the desired node is further ahead in the DOM existingNodes 201 | * @param {Node} node The node to look for 202 | * @param {NodeList} existingNodes The DOM existingNodes 203 | * @param {Integer} index The indexing index 204 | * @return {Integer} How many nodes ahead the target node is 205 | */ 206 | function aheadInTree (node, existingNodes, index) { 207 | return Array.from(existingNodes).slice(index + 1).find(function (branch) { 208 | return !isDifferentNode(node, branch); 209 | }); 210 | } 211 | 212 | /** 213 | * If there are extra elements in DOM, remove them 214 | * @param {Array} existingNodes The existing DOM 215 | * @param {Array} templateNodes The template 216 | */ 217 | function trimExtraNodes (existingNodes, templateNodes) { 218 | let extra = existingNodes.length - templateNodes.length; 219 | if (extra < 1) return; 220 | for (; extra > 0; extra--) { 221 | existingNodes[existingNodes.length - 1].remove(); 222 | } 223 | } 224 | 225 | /** 226 | * Remove scripts from HTML 227 | * @param {Node} elem The element to remove scripts from 228 | */ 229 | function removeScripts (elem) { 230 | let scripts = elem.querySelectorAll('script'); 231 | for (let script of scripts) { 232 | script.remove(); 233 | } 234 | } 235 | 236 | /** 237 | * Diff the existing DOM node versus the template 238 | * @param {Array} template The template HTML 239 | * @param {Node} existing The current DOM HTML 240 | */ 241 | function diff (template, existing) { 242 | 243 | // Get the nodes in the template and existing UI 244 | let templateNodes = template.childNodes; 245 | let existingNodes = existing.childNodes; 246 | 247 | // Don't inject scripts 248 | if (removeScripts(template)) return; 249 | 250 | // Loop through each node in the template and compare it to the matching element in the UI 251 | templateNodes.forEach(function (node, index) { 252 | 253 | // If element doesn't exist, create it 254 | if (!existingNodes[index]) { 255 | let clone = node.cloneNode(true); 256 | addDefaultAtts(clone); 257 | existing.append(clone); 258 | return; 259 | } 260 | 261 | // If there is, but it's not the same node type, insert the new node before the existing one 262 | if (isDifferentNode(node, existingNodes[index])) { 263 | 264 | // Check if node exists further in the tree 265 | let ahead = aheadInTree(node, existingNodes, index); 266 | 267 | // If not, insert the node before the current one 268 | if (!ahead) { 269 | let clone = node.cloneNode(true); 270 | addDefaultAtts(clone); 271 | existingNodes[index].before(clone); 272 | return; 273 | } 274 | 275 | // Otherwise, move it to the current spot 276 | existingNodes[index].before(ahead); 277 | 278 | } 279 | 280 | // If attributes are different, update them 281 | diffAttributes(node, existingNodes[index]); 282 | 283 | // Stop diffing if a native web component 284 | if (node.nodeName.includes('-')) return; 285 | 286 | // If content is different, update it 287 | let templateContent = getNodeContent(node); 288 | if (templateContent && templateContent !== getNodeContent(existingNodes[index])) { 289 | existingNodes[index].textContent = templateContent; 290 | } 291 | 292 | // If there shouldn't be child nodes but there are, remove them 293 | if (!node.childNodes.length && existingNodes[index].childNodes.length) { 294 | existingNodes[index].innerHTML = ''; 295 | return; 296 | } 297 | 298 | // If DOM is empty and shouldn't be, build it up 299 | // This uses a document fragment to minimize reflows 300 | if (!existingNodes[index].childNodes.length && node.childNodes.length) { 301 | let fragment = document.createDocumentFragment(); 302 | diff(node, fragment); 303 | existingNodes[index].appendChild(fragment); 304 | return; 305 | } 306 | 307 | // If there are nodes within it, recursively diff those 308 | if (node.childNodes.length) { 309 | diff(node, existingNodes[index]); 310 | } 311 | 312 | }); 313 | 314 | // If extra elements in DOM, remove them 315 | trimExtraNodes(existingNodes, templateNodes); 316 | 317 | } 318 | 319 | /** 320 | * Render a template into the UI 321 | * @param {Node|String} elem The element or selector to render the template into 322 | * @param {String} template The template to render 323 | */ 324 | function render (elem, template) { 325 | let node = elem === 'string' ? document.querySelector(elem) : elem; 326 | let html = stringToHTML(template); 327 | diff(html, node); 328 | } 329 | 330 | return render; 331 | 332 | })(); -------------------------------------------------------------------------------- /06 - project/store.js: -------------------------------------------------------------------------------- 1 | let store = (function () { 2 | 3 | /** 4 | * Emit a custom event 5 | * @param {*} data The store data 6 | */ 7 | function emit (data) { 8 | 9 | // Create a new event 10 | let event = new CustomEvent('store', { 11 | bubbles: true, 12 | cancelable: true, 13 | detail: data 14 | }); 15 | 16 | // Dispatch the event 17 | return document.dispatchEvent(event); 18 | 19 | } 20 | 21 | /** 22 | * Create a Proxy handler object 23 | * @param {Object} data The data object 24 | * @return {Object} The handler object 25 | */ 26 | function handler (data) { 27 | return { 28 | get (obj, prop) { 29 | if (prop === '_isProxy') return true; 30 | if (['[object Object]', '[object Array]'].includes(Object.prototype.toString.call(obj[prop])) && !obj[prop]._isProxy) { 31 | obj[prop] = new Proxy(obj[prop], handler(data)); 32 | } 33 | return obj[prop]; 34 | }, 35 | set (obj, prop, value) { 36 | if (obj[prop] === value) return true; 37 | obj[prop] = value; 38 | emit(data); 39 | return true; 40 | }, 41 | deleteProperty (obj, prop) { 42 | delete obj[prop]; 43 | emit(data); 44 | return true; 45 | } 46 | }; 47 | } 48 | 49 | /** 50 | * Create a new store 51 | * @param {Object} data The data object 52 | * @return {Proxy} The reactive proxy 53 | */ 54 | function store (data = {}) { 55 | data = ['[object Object]', '[object Array]'].includes(Object.prototype.toString.call(data)) ? data : {value: data}; 56 | return new Proxy(data, handler(data)); 57 | } 58 | 59 | return store; 60 | 61 | })(); -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # The State-Based UI Pocket Guide Source Code 2 | All of the source code for the [State-Based UI Pocket Guide](https://vanillajsguides.com). --------------------------------------------------------------------------------