/
framework.js
314 lines (271 loc) · 10.7 KB
/
framework.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
import { getFromMap, parseTemplate, toString } from './utils.js'
const parseCache = new WeakMap()
const domInstancesCache = new WeakMap()
const unkeyedSymbol = Symbol('un-keyed')
// for debugging
/* istanbul ignore else */
if (process.env.NODE_ENV !== 'production') {
window.parseCache = parseCache
window.domInstancesCache = domInstancesCache
}
// Not supported in Safari <=13
const hasReplaceChildren = 'replaceChildren' in Element.prototype
function replaceChildren (parentNode, newChildren) {
/* istanbul ignore else */
if (hasReplaceChildren) {
parentNode.replaceChildren(...newChildren)
} else { // minimal polyfill for Element.prototype.replaceChildren
parentNode.innerHTML = ''
parentNode.append(...newChildren)
}
}
function doChildrenNeedRerender (parentNode, newChildren) {
let oldChild = parentNode.firstChild
let oldChildrenCount = 0
// iterate using firstChild/nextSibling because browsers use a linked list under the hood
while (oldChild) {
const newChild = newChildren[oldChildrenCount]
// check if the old child and new child are the same
if (newChild !== oldChild) {
return true
}
oldChild = oldChild.nextSibling
oldChildrenCount++
}
/* istanbul ignore if */
if (process.env.NODE_ENV !== 'production' && oldChildrenCount !== parentNode.children.length) {
throw new Error('parentNode.children.length is different from oldChildrenCount, it should not be')
}
// if new children length is different from old, we must re-render
return oldChildrenCount !== newChildren.length
}
function patchChildren (newChildren, instanceBinding) {
const { targetNode } = instanceBinding
let { targetParentNode } = instanceBinding
let needsRerender = false
if (targetParentNode) { // already rendered once
needsRerender = doChildrenNeedRerender(targetParentNode, newChildren)
} else { // first render of list
needsRerender = true
instanceBinding.targetNode = undefined // placeholder comment not needed anymore, free memory
instanceBinding.targetParentNode = targetParentNode = targetNode.parentNode
}
// avoid re-rendering list if the dom nodes are exactly the same before and after
if (needsRerender) {
replaceChildren(targetParentNode, newChildren)
}
}
function patch (expressions, instanceBindings) {
for (const instanceBinding of instanceBindings) {
const {
targetNode,
currentExpression,
binding: {
expressionIndex,
attributeName,
attributeValuePre,
attributeValuePost
}
} = instanceBinding
const expression = expressions[expressionIndex]
if (currentExpression === expression) {
// no need to update, same as before
continue
}
instanceBinding.currentExpression = expression
if (attributeName) { // attribute replacement
targetNode.setAttribute(attributeName, attributeValuePre + toString(expression) + attributeValuePost)
} else { // text node / child element / children replacement
let newNode
if (Array.isArray(expression)) { // array of DOM elements produced by tag template literals
patchChildren(expression, instanceBinding)
} else if (expression instanceof Element) { // html tag template returning a DOM element
newNode = expression
/* istanbul ignore if */
if (process.env.NODE_ENV !== 'production' && newNode === targetNode) {
// it seems impossible for the framework to get into this state, may as well assert on it
// worst case scenario is we lose focus if we call replaceWith on the same node
throw new Error('the newNode and targetNode are the same, this should never happen')
}
targetNode.replaceWith(newNode)
} else { // primitive - string, number, etc
if (targetNode.nodeType === Node.TEXT_NODE) { // already transformed into a text node
// nodeValue is faster than textContent supposedly https://www.youtube.com/watch?v=LY6y3HbDVmg
targetNode.nodeValue = toString(expression)
} else { // replace comment or whatever was there before with a text node
newNode = document.createTextNode(toString(expression))
targetNode.replaceWith(newNode)
}
}
if (newNode) {
instanceBinding.targetNode = newNode
}
}
}
}
function parse (tokens) {
let htmlString = ''
let withinTag = false
let withinAttribute = false
let elementIndexCounter = -1 // depth-first traversal order
const elementsToBindings = new Map()
const elementIndexes = []
for (let i = 0, len = tokens.length; i < len; i++) {
const token = tokens[i]
htmlString += token
if (i === len - 1) {
break // no need to process characters - no more expressions to be found
}
for (let j = 0; j < token.length; j++) {
const char = token.charAt(j)
switch (char) {
case '<': {
const nextChar = token.charAt(j + 1)
/* istanbul ignore if */
if (process.env.NODE_ENV !== 'production' && !/[/a-z]/.test(nextChar)) {
// we don't need to support comments ('<!') because we always use html-minify-literals
// also we don't support '<' inside tags, e.g. '<div> 2 < 3 </div>'
throw new Error('framework currently only supports a < followed by / or a-z')
}
if (nextChar === '/') { // closing tag
// leaving an element
elementIndexes.pop()
} else { // not a closing tag
withinTag = true
elementIndexes.push(++elementIndexCounter)
}
break
}
case '>': {
withinTag = false
withinAttribute = false
break
}
case '=': {
/* istanbul ignore if */
if (process.env.NODE_ENV !== 'production' && !withinTag) {
// we don't currently support '=' anywhere but inside a tag, e.g.
// we don't support '<div>2 + 2 = 4</div>'
throw new Error('framework currently does not support = anywhere but inside a tag')
}
withinAttribute = true
break
}
}
}
const elementIndex = elementIndexes[elementIndexes.length - 1]
const bindings = getFromMap(elementsToBindings, elementIndex, () => [])
let attributeName
let attributeValuePre
let attributeValuePost
if (withinAttribute) {
// I never use single-quotes for attribute values in HTML, so just support double-quotes or no-quotes
const match = /(\S+)="?([^"=]*)$/.exec(token)
attributeName = match[1]
attributeValuePre = match[2]
attributeValuePost = /^[^">]*/.exec(tokens[i + 1])[0]
}
const binding = {
attributeName,
attributeValuePre,
attributeValuePost,
expressionIndex: i
}
/* istanbul ignore else */
if (process.env.NODE_ENV !== 'production') {
// remind myself that this object is supposed to be immutable
Object.freeze(binding)
}
bindings.push(binding)
// add a placeholder comment that we can find later
htmlString += (!withinTag && !withinAttribute) ? `<!--${bindings.length - 1}-->` : ''
}
const template = parseTemplate(htmlString)
return {
template,
elementsToBindings
}
}
function findPlaceholderComment (element, bindingId) {
// If we had a lot of placeholder comments to find, it would make more sense to build up a map once
// rather than search the DOM every time. But it turns out that we always only have one child,
// and it's the comment node, so searching every time is actually faster.
let childNode = element.firstChild
while (childNode) {
// Note that minify-html-literals has already removed all non-framework comments
// So we just need to look for comments that have exactly the bindingId as its text content
if (childNode.nodeType === Node.COMMENT_NODE && childNode.nodeValue === toString(bindingId)) {
return childNode
}
childNode = childNode.nextSibling
}
}
function traverseAndSetupBindings (dom, elementsToBindings) {
const instanceBindings = []
// traverse dom
const treeWalker = document.createTreeWalker(dom, NodeFilter.SHOW_ELEMENT)
let element = dom
let elementIndex = -1
do {
const bindings = elementsToBindings.get(++elementIndex)
if (bindings) {
for (let i = 0; i < bindings.length; i++) {
const binding = bindings[i]
const targetNode = binding.attributeName
? element // attribute binding, just use the element itself
: findPlaceholderComment(element, i) // not an attribute binding, so has a placeholder comment
/* istanbul ignore if */
if (process.env.NODE_ENV !== 'production' && !targetNode) {
throw new Error('targetNode should not be undefined')
}
const instanceBinding = {
binding,
targetNode,
targetParentNode: undefined,
currentExpression: undefined
}
/* istanbul ignore else */
if (process.env.NODE_ENV !== 'production') {
// remind myself that this object is supposed to be monomorphic (for better JS engine perf)
Object.seal(instanceBinding)
}
instanceBindings.push(instanceBinding)
}
}
} while ((element = treeWalker.nextNode()))
return instanceBindings
}
function parseHtml (tokens) {
// All templates and bound expressions are unique per tokens array
const { template, elementsToBindings } = getFromMap(parseCache, tokens, () => parse(tokens))
// When we parseHtml, we always return a fresh DOM instance ready to be updated
const dom = template.cloneNode(true).content.firstElementChild
const instanceBindings = traverseAndSetupBindings(dom, elementsToBindings)
return function updateDomInstance (expressions) {
patch(expressions, instanceBindings)
return dom
}
}
export function createFramework (state) {
const domInstances = getFromMap(domInstancesCache, state, () => new Map())
let domInstanceCacheKey = unkeyedSymbol
function html (tokens, ...expressions) {
// Each unique lexical usage of map() is considered unique due to the html`` tagged template call it makes,
// which has lexically unique tokens. The unkeyed symbol is just used for html`` usage outside of a map().
const domInstancesForTokens = getFromMap(domInstances, tokens, () => new Map())
const updateDomInstance = getFromMap(domInstancesForTokens, domInstanceCacheKey, () => parseHtml(tokens))
return updateDomInstance(expressions) // update with expressions
}
function map (array, callback, keyFunction) {
return array.map((item, index) => {
const originalCacheKey = domInstanceCacheKey
domInstanceCacheKey = keyFunction(item)
try {
return callback(item, index)
} finally {
domInstanceCacheKey = originalCacheKey
}
})
}
return { map, html }
}