72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
84 |
85 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 | })();
--------------------------------------------------------------------------------
/03 - reactivity/06 - detecting proxies.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Detecting proxies
7 |
8 |
9 |
10 |
11 |
12 |
84 |
85 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 | })();
--------------------------------------------------------------------------------
/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 | })();
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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/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/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/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 | })();
--------------------------------------------------------------------------------