From 3c73774b38c458f56325f37b8243c23339fc8c5c Mon Sep 17 00:00:00 2001 From: Andrew Seier Date: Thu, 23 Mar 2023 09:08:40 -0700 Subject: [PATCH] Improve performance of injection phase. Just a few changes to improve the performance of going from a user-defined template function to a clonable template element. --- demo/performance/index.html | 6 ++ demo/performance/index.js | 129 +++++++++++++++++++++-------------- test/test-template-engine.js | 56 +++++++-------- x-element.js | 110 ++++++++++++++++------------- 4 files changed, 175 insertions(+), 126 deletions(-) diff --git a/demo/performance/index.html b/demo/performance/index.html index 06461d1..4b2c341 100644 --- a/demo/performance/index.html +++ b/demo/performance/index.html @@ -46,6 +46,12 @@ which are interpolated into a given template. This is the most common thing the engine needs to do.

+

+ Finally, a note on how the tests work — they are batched up and run within + animation frames to guard against any interference that might occur when + an animation frame is skipped due to the main thread being busy. This is + why the tests all take the same amount of time to complete. +

diff --git a/demo/performance/index.js b/demo/performance/index.js index 132d075..5863f10 100644 --- a/demo/performance/index.js +++ b/demo/performance/index.js @@ -32,66 +32,91 @@ const getValues = ({ attr, id, hidden, title, content1, content2 }) => { const values = [attr, id, hidden, title, content1, content2]; return values; }; +const tick = async () => { + await new Promise(resolve => requestAnimationFrame(resolve)); +}; -const run = async (output, constructor) => { +const run = async (output, constructor, ...tests) => { output.textContent = ''; const { render, html } = constructor.templateEngine; - const injectCount = 100000; - const initialCount = 1000000; - const updateCount = 1000000; + const slop = 1000; + const injectCount = 10000; + const initialCount = 100000; + const updateCount = 100000; - // Test inject performance. - await new Promise(resolve => setTimeout(resolve, 0)); - let injectSum = 0; - const injectProperties = { attr: '123', id: 'foo', hidden: false, title: 'test', content1: 'AAA', content2: 'BBB' }; - const injectContainer = document.createElement('div'); - for (let iii = 0; iii < injectCount; iii++) { - const strings = getStrings(); - const values = getValues(injectProperties); - const t0 = performance.now(); - render(injectContainer, html(strings, ...values)); - const t1 = performance.now(); - injectSum += t1 - t0; + if (tests.includes('inject')) { + // Test inject performance. + await tick(); + let injectSum = 0; + const injectProperties = { attr: '123', id: 'foo', hidden: false, title: 'test', content1: 'AAA', content2: 'BBB' }; + const injectContainer = document.createElement('div'); + for (let iii = 0; iii < injectCount + slop; iii++) { + const strings = getStrings(); + const values = getValues(injectProperties); + const t0 = performance.now(); + render(injectContainer, html(strings, ...values)); + const t1 = performance.now(); + if (iii >= slop) { + injectSum += t1 - t0; + } + if (iii % 100 === 0) { + await tick(); + } + } + const injectAverage = `${(injectSum / injectCount * 1000).toFixed(1).padStart(6)} µs`; + output.textContent += `${output.textContent ? '\n' : ''}inject: ${injectAverage} (tested ${injectCount.toLocaleString()} times)`; } - const injectAverage = `${(injectSum / injectCount * 1000).toFixed(1).padStart(6)} µs`; - output.textContent += `${output.textContent ? '\n' : ''}inject: ${injectAverage} (tested ${injectCount.toLocaleString()} times)`; - // Test initial performance. - await new Promise(resolve => setTimeout(resolve, 0)); - let initialSum = 0; - const initialStrings = getStrings(); - const initialProperties = { attr: '123', id: 'foo', hidden: false, title: 'test', content1: 'AAA', content2: 'BBB' }; - const initialValues = getValues(initialProperties); - const initialContainer = document.createElement('div'); - for (let iii = 0; iii < initialCount; iii++) { - const t0 = performance.now(); - render(initialContainer, html(initialStrings, ...initialValues)); - const t1 = performance.now(); - initialSum += t1 - t0; + if (tests.includes('initial')) { + // Test initial performance. + await new Promise(resolve => setTimeout(resolve, 0)); + let initialSum = 0; + const initialStrings = getStrings(); + const initialProperties = { attr: '123', id: 'foo', hidden: false, title: 'test', content1: 'AAA', content2: 'BBB' }; + const initialValues = getValues(initialProperties); + const initialContainer = document.createElement('div'); + for (let iii = 0; iii < initialCount + slop; iii++) { + const t0 = performance.now(); + render(initialContainer, html(initialStrings, ...initialValues)); + const t1 = performance.now(); + if (iii >= slop) { + initialSum += t1 - t0; + } + if (iii % 1000 === 0) { + await tick(); + } + } + const initialAverage = `${(initialSum / initialCount * 1000).toFixed(1).padStart(4)} µs`; + output.textContent += `${output.textContent ? '\n' : ''}initial: ${initialAverage} (tested ${initialCount.toLocaleString()} times)`; } - const initialAverage = `${(initialSum / initialCount * 1000).toFixed(1).padStart(4)} µs`; - output.textContent += `${output.textContent ? '\n' : ''}initial: ${initialAverage} (tested ${initialCount.toLocaleString()} times)`; - // Test update performance. - await new Promise(resolve => setTimeout(resolve, 0)); - let updateSum = 0; - const updateStrings = getStrings(); - const updateProperties = [ - { attr: '123', id: 'foo', hidden: false, title: 'test', content1: 'AAA', content2: 'BBB' }, - { attr: '456', id: 'bar', hidden: false, title: 'test', content1: 'ZZZ', content2: 'BBB' }, - ]; - const updateContainer = document.createElement('div'); - for (let iii = 0; iii < updateCount; iii++) { - const values = getValues(updateProperties[iii % 2]); - const t0 = performance.now(); - render(updateContainer, html(updateStrings, ...values)); - const t1 = performance.now(); - updateSum += t1 - t0; + if (tests.includes('update')) { + // Test update performance. + await new Promise(resolve => setTimeout(resolve, 0)); + let updateSum = 0; + const updateStrings = getStrings(); + const updateProperties = [ + { attr: '123', id: 'foo', hidden: false, title: 'test', content1: 'AAA', content2: 'BBB' }, + { attr: '456', id: 'bar', hidden: false, title: 'test', content1: 'ZZZ', content2: 'BBB' }, + ]; + const updateContainer = document.createElement('div'); + for (let iii = 0; iii < updateCount + slop; iii++) { + const values = getValues(updateProperties[iii % 2]); + const t0 = performance.now(); + render(updateContainer, html(updateStrings, ...values)); + const t1 = performance.now(); + if (iii >= slop) { + updateSum += t1 - t0; + } + if (iii % 1000 === 0) { + await tick(); + } + } + const updateAverage = `${(updateSum / updateCount * 1000).toFixed(1).padStart(5)} µs`; + output.textContent += `${output.textContent ? '\n' : ''}update: ${updateAverage} (tested ${updateCount.toLocaleString()} times)`; } - const updateAverage = `${(updateSum / updateCount * 1000).toFixed(1).padStart(5)} µs`; - output.textContent += `${output.textContent ? '\n' : ''}update: ${updateAverage} (tested ${updateCount.toLocaleString()} times)`; }; -await run(document.getElementById('default'), XElement); -await run(document.getElementById('lit-html'), LitHtmlElement); -await run(document.getElementById('uhtml'), UhtmlElement); +await run(document.getElementById('default'), XElement, 'inject', 'initial', 'update'); +await run(document.getElementById('lit-html'), LitHtmlElement, 'inject', 'initial', 'update'); +await run(document.getElementById('uhtml'), UhtmlElement, 'inject', 'initial', 'update'); diff --git a/test/test-template-engine.js b/test/test-template-engine.js index 7895806..d52da2b 100644 --- a/test/test-template-engine.js +++ b/test/test-template-engine.js @@ -808,8 +808,8 @@ describe('rendering errors', () => { }); describe('ifDefined', () => { - it('throws if used on a "boolean-attribute"', () => { - const expected = 'The ifDefined update must be used on an attribute, not on a boolean-attribute.'; + it('throws if used on a "boolean"', () => { + const expected = 'The ifDefined update must be used on an attribute, not on a boolean attribute.'; const getTemplate = ({ maybe }) => { return html`
`; }; @@ -862,8 +862,8 @@ describe('rendering errors', () => { container.remove(); }); - it('throws if used with "text-content"', () => { - const expected = 'The ifDefined update must be used on an attribute, not on text-content.'; + it('throws if used with "text"', () => { + const expected = 'The ifDefined update must be used on an attribute, not on text content.'; const getTemplate = ({ maybe }) => { return html``; }; @@ -882,8 +882,8 @@ describe('rendering errors', () => { }); describe('nullish', () => { - it('throws if used on a "boolean-attribute"', () => { - const expected = 'The nullish update must be used on an attribute, not on a boolean-attribute.'; + it('throws if used on a "boolean"', () => { + const expected = 'The nullish update must be used on an attribute, not on a boolean attribute.'; const getTemplate = ({ maybe }) => { return html`
`; }; @@ -936,8 +936,8 @@ describe('rendering errors', () => { container.remove(); }); - it('throws if used with "text-content"', () => { - const expected = 'The nullish update must be used on an attribute, not on text-content.'; + it('throws if used with "text"', () => { + const expected = 'The nullish update must be used on an attribute, not on text content.'; const getTemplate = ({ maybe }) => { return html``; }; @@ -974,8 +974,8 @@ describe('rendering errors', () => { container.remove(); }); - it('throws if used on a "boolean-attribute"', () => { - const expected = 'The live update must be used on a property, not on a boolean-attribute.'; + it('throws if used on a "boolean"', () => { + const expected = 'The live update must be used on a property, not on a boolean attribute.'; const getTemplate = ({ maybe }) => { return html`
`; }; @@ -1010,8 +1010,8 @@ describe('rendering errors', () => { container.remove(); }); - it('throws if used with "text-content"', () => { - const expected = 'The live update must be used on a property, not on text-content.'; + it('throws if used with "text"', () => { + const expected = 'The live update must be used on a property, not on text content.'; const getTemplate = ({ maybe }) => { return html``; }; @@ -1048,8 +1048,8 @@ describe('rendering errors', () => { container.remove(); }); - it('throws if used on a "boolean-attribute"', () => { - const expected = 'The unsafeHTML update must be used on content, not on a boolean-attribute.'; + it('throws if used on a "boolean"', () => { + const expected = 'The unsafeHTML update must be used on content, not on a boolean attribute.'; const getTemplate = ({ maybe }) => { return html`
`; }; @@ -1084,8 +1084,8 @@ describe('rendering errors', () => { container.remove(); }); - it('throws if used with "text-content"', () => { - const expected = 'The unsafeHTML update must be used on content, not on text-content.'; + it('throws if used with "text"', () => { + const expected = 'The unsafeHTML update must be used on content, not on text content.'; const getTemplate = ({ maybe }) => { return html``; }; @@ -1142,8 +1142,8 @@ describe('rendering errors', () => { container.remove(); }); - it('throws if used on a "boolean-attribute"', () => { - const expected = 'The unsafeSVG update must be used on content, not on a boolean-attribute.'; + it('throws if used on a "boolean"', () => { + const expected = 'The unsafeSVG update must be used on content, not on a boolean attribute.'; const getTemplate = ({ maybe }) => { return html`
`; }; @@ -1178,8 +1178,8 @@ describe('rendering errors', () => { container.remove(); }); - it('throws if used with "text-content"', () => { - const expected = 'The unsafeSVG update must be used on content, not on text-content.'; + it('throws if used with "text"', () => { + const expected = 'The unsafeSVG update must be used on content, not on text content.'; const getTemplate = ({ maybe }) => { return html``; }; @@ -1272,8 +1272,8 @@ describe('rendering errors', () => { container.remove(); }); - it('throws if used on a "boolean-attribute"', () => { - const expected = 'The map update must be used on content, not on a boolean-attribute.'; + it('throws if used on a "boolean"', () => { + const expected = 'The map update must be used on content, not on a boolean attribute.'; const getTemplate = ({ maybe }) => { return html`
`; }; @@ -1308,8 +1308,8 @@ describe('rendering errors', () => { container.remove(); }); - it('throws if used with "text-content"', () => { - const expected = 'The map update must be used on content, not on text-content.'; + it('throws if used with "text"', () => { + const expected = 'The map update must be used on content, not on text content.'; const getTemplate = ({ maybe }) => { return html``; }; @@ -1443,8 +1443,8 @@ describe('rendering errors', () => { container.remove(); }); - it('throws if used on a "boolean-attribute"', () => { - const expected = 'The repeat update must be used on content, not on a boolean-attribute.'; + it('throws if used on a "boolean"', () => { + const expected = 'The repeat update must be used on content, not on a boolean attribute.'; const getTemplate = ({ maybe }) => { return html`
`; }; @@ -1479,8 +1479,8 @@ describe('rendering errors', () => { container.remove(); }); - it('throws if used with "text-content"', () => { - const expected = 'The repeat update must be used on content, not on text-content.'; + it('throws if used with "text"', () => { + const expected = 'The repeat update must be used on content, not on text content.'; const getTemplate = ({ maybe }) => { return html``; }; diff --git a/x-element.js b/x-element.js index 1bde03e..1af8cad 100644 --- a/x-element.js +++ b/x-element.js @@ -1062,11 +1062,18 @@ class Template { static #templates = new WeakMap(); static #templateResults = new WeakMap(); static #updaters = new WeakMap(); + static #ATTRIBUTE_PREFIXES = { + attribute: 'x-element-attribute-', + boolean: 'x-element-boolean-', + property: 'x-element-property-', + }; + static #CONTENT_PREFIX = 'x-element-content-'; + static #CONTENT_REGEX = new RegExp(`${Template.#CONTENT_PREFIX}(\\d+)`); static #OPEN = /<[a-z][a-z0-9-]*(?=\s)/g; static #STEP = /\s+[a-z][a-z0-9-]*(?=[\s>])|\s+[a-z][a-zA-Z0-9-]*="[^"]*"/y; - static #CLOSE = />/g; static #ATTRIBUTE = /\s+(\??([a-z][a-zA-Z0-9-]*))="$/y; static #PROPERTY = /\s+\.([a-z][a-zA-Z0-9_]*)="$/y; + static #CLOSE = />/g; #type = null; #strings = null; @@ -1079,11 +1086,10 @@ class Template { inject(node, options) { if (!this.#analysis) { - let html = ''; + const htmlStrings = []; const state = { inside: false, index: 0 }; for (let iii = 0; iii < this.#strings.length; iii++) { - const string = this.#strings[iii]; - html += string; + let string = this.#strings[iii]; Template.#exhaustString(string, state); if (state.inside) { Template.#ATTRIBUTE.lastIndex = state.index; @@ -1092,10 +1098,10 @@ class Template { const name = attributeMatch[2]; if (attributeMatch[1].startsWith('?')) { // We found a match like this: html`
`. - html = html.slice(0, -3 - name.length) + `x-element-boolean-attribute-$${iii}="${name}`; + string = string.slice(0, -3 - name.length) + `${Template.#ATTRIBUTE_PREFIXES.boolean}${iii}="${name}`; } else { // We found a match like this: html`
`. - html = html.slice(0, -2 - name.length) + `x-element-attribute-$${iii}="${name}`; + string = string.slice(0, -2 - name.length) + `${Template.#ATTRIBUTE_PREFIXES.attribute}${iii}="${name}`; } state.index = 1; // Accounts for an expected quote character next. } else { @@ -1104,7 +1110,7 @@ class Template { if (propertyMatch) { // We found a match like this: html`
`. const name = propertyMatch[1]; - html = html.slice(0, -3 - name.length) + `x-element-property-$${iii}="${name}`; + string = string.slice(0, -3 - name.length) + `${Template.#ATTRIBUTE_PREFIXES.property}${iii}="${name}`; state.index = 1; // Accounts for an expected quote character next. } else { throw new Error(`Found invalid template string "${string}" at "${string.slice(state.index)}".`); @@ -1112,13 +1118,14 @@ class Template { } } else { // Assume it's a match like this: html`
${value}
`. - html += ``; + string += ``; state.index = 0; // No characters to account for. Reset to zero. } + htmlStrings[iii] = string; } - if (this.#type === 'svg') { - html = `${html}`; - } + const html = this.#type === 'svg' + ? `${htmlStrings.join('')}` + : htmlStrings.join(''); const element = document.createElement('template'); element.innerHTML = html; const blueprint = Template.#evaluate(element.content); // mutates element. @@ -1255,35 +1262,39 @@ class Template { path = path ?? []; const items = []; if (node.nodeType === Node.ELEMENT_NODE) { - for (const attribute of [...node.attributes]) { - const attributeMatch = attribute.name.match(/^x-element-attribute-\$(\d+)$/); - const booleanAttributeMatch = !attributeMatch ? attribute.name.match(/^x-element-boolean-attribute-\$(\d+)$/) : null; - const propertyMatch = !attributeMatch && !booleanAttributeMatch ? attribute.name.match(/^x-element-property-\$(\d+)$/) : null; - if (attributeMatch) { - node.removeAttribute(attributeMatch[0]); - items.push({ path, key: attributeMatch[1], type: 'attribute', name: attribute.value }); - } else if (booleanAttributeMatch) { - node.removeAttribute(booleanAttributeMatch[0]); - items.push({ path, key: booleanAttributeMatch[1], type: 'boolean-attribute', name: attribute.value }); - } else if (propertyMatch) { - node.removeAttribute(propertyMatch[0]); - items.push({ path, key: propertyMatch[1], type: 'property', name: attribute.value }); + const attributesToRemove = new Set(); + for (const attribute of node.attributes) { + const name = attribute.name; + const type = name.startsWith(Template.#ATTRIBUTE_PREFIXES.attribute) + ? 'attribute' + : name.startsWith(Template.#ATTRIBUTE_PREFIXES.boolean) + ? 'boolean' + : name.startsWith(Template.#ATTRIBUTE_PREFIXES.property) + ? 'property' + : null; + if (type) { + const prefix = Template.#ATTRIBUTE_PREFIXES[type]; + const key = name.slice(prefix.length); + const value = attribute.value; + items.push({ path, key, type, name: value }); + attributesToRemove.add(name); } } + for (const attribute of attributesToRemove) { + node.removeAttribute(attribute); + } // Special case to handle elements which only allow text content (no comments). - if (node.localName.match(/^plaintext|script|style|textarea|title$/)) { - const contentMatch = node.textContent.match(/x-element-content-\$(\d+)/); + const localName = node.localName; + if ((localName === 'style' || localName === 'script') && Template.#CONTENT_REGEX.exec(node.textContent)) { + throw new Error(`Interpolation of "${localName}" tags is not allowed.`); + } else if (localName === 'plaintext' || localName === 'textarea' || localName === 'title') { + const contentMatch = Template.#CONTENT_REGEX.exec(node.textContent); if (contentMatch) { - if (node.localName.match(/^plaintext|textarea|title$/)) { - node.textContent = ''; - items.push({ path, key: contentMatch[1], type: 'text-content' }); - } else { - throw new Error(`Interpolation of "${node.localName}" tags is not allowed.`); - } + items.push({ path, key: contentMatch[1], type: 'text' }); } } } else if (node.nodeType === Node.COMMENT_NODE) { - const contentMatch = node.textContent.match(/x-element-content-\$(\d+)/); + const contentMatch = Template.#CONTENT_REGEX.exec(node.textContent); if (contentMatch) { node.textContent = ''; const startNode = document.createComment(''); @@ -1313,7 +1324,7 @@ class Template { const node = find(item.path); switch (item.type) { case 'attribute': - case 'boolean-attribute': + case 'boolean': case 'property': { nextItems.push({ key: item.key, type: item.type, name: item.name, node }); break; @@ -1324,7 +1335,7 @@ class Template { nextItems.push(nextItem); break; } - case 'text-content': { + case 'text': { const nextItem = { key: item.key, type: item.type, node }; nextItems.push(nextItem); break; @@ -1345,10 +1356,10 @@ class Template { ? updater(type, lastValue, { node, name }) : Template.#attribute(type, values[key], lastValue, { node, name }); break; - case 'boolean-attribute': + case 'boolean': updater ? updater(type, lastValue, { node, name }) - : Template.#booleanAttribute(type, values[key], lastValue, { node, name }); + : Template.#boolean(type, values[key], lastValue, { node, name }); break; case 'property': updater @@ -1360,10 +1371,10 @@ class Template { ? updater(type, lastValue, { node, startNode }) : Template.#content(type, values[key], lastValue, { node, startNode }); break; - case 'text-content': + case 'text': updater ? updater(type, lastValue, { node }) - : Template.#textContent(type, values[key], lastValue, { node }); + : Template.#text(type, values[key], lastValue, { node }); break; } } @@ -1375,7 +1386,7 @@ class Template { } } - static #booleanAttribute(type, value, lastValue, { node, name }) { + static #boolean(type, value, lastValue, { node, name }) { if (value !== lastValue) { value ? node.setAttribute(name, '') @@ -1389,7 +1400,7 @@ class Template { } } - static #textContent(type, value, lastValue, { node }) { + static #text(type, value, lastValue, { node }) { if (value !== lastValue) { node.textContent = value; } @@ -1629,11 +1640,18 @@ class Template { } static #getTypeText(type) { - return type === 'attribute' - ? `an ${type}` - : type === 'boolean-attribute' || type === 'property' - ? `a ${type}` - : `${type}`; + switch (type) { + case 'attribute': + return 'an attribute'; + case 'boolean': + return 'a boolean attribute'; + case 'property': + return 'a property'; + case 'content': + return 'content'; + case 'text': + return 'text content'; + } } }