Skip to content

Commit

Permalink
perf: optimize hot paths and reduce overhead for low-end devices (#368)
Browse files Browse the repository at this point in the history
* perf: compare values ​​directly instead of includes

* perf: compare first character via access index instead of startsWith

* perf: use Set + has instead of an array with includes

* perf(shared): implement split once for fixKeyCase and resolveMetaKeyType

* feat(shared): introduce thenable helper

* perf(shared): use thenable in normalise for reduce async/await functions

* perf(dom): use promise chain instead of async function in debouncedRenderDOMHead

* chore(dom): remove async modifier for debouncedRenderDOMHead

* perf(shared): remove unnecessary array spread in tagDedupeKey

* refactor: undefined is allowed for spread object

* perf(schema-org): avoid new array allocation in dedupeMerge

* perf(dom): check empty class or style in foreach instead of allocation of filter

* perf(dom): implement split once for style in trackCtx

* perf(dom): convert tag name to lowercase once in renderDOMHead

* refactor: compare with undefined without typeof in safe places

* test(shared): add benchmark for processTemplateParams

* perf(shared): move sub to top module in templateParams

* perf(shared): avoid unnecessary operations in processTemplateParams

* perf(shared): use single replacer in processTemplateParams

* refactor(dom): move condition to else in renderDOMHead

* perf(shared): combine filter in normaliseStyleClassProps

* perf(shared): optimize normaliseEntryTags for handle tag promises

* refactor: use arrow function instead of regular anonymous

* perf(shared): cache allowed meta properties for tagDedupeKey

* perf(shared): use concurrency chain instead of Promise.all in normaliseEntryTags

* perf(unhead): allocate once third party dedupe keys in dedupe plugin

* refactor(shared): remove second parameter in tagDedupeKey

* fix(shared): normalise should handle consistently

* perf(shared): reduce overhead from object.entries and map in normaliseEntryTags

* perf(shared): promise should be edge case in normaliseProps

* chore(shared): remove export from nestedNormaliseProps

* perf(shared): promise should be edge case in normaliseEntryTags

* fix(shared): switch condition in for i in nestedNormaliseEntryTags

* chore(shared): rename resolvedTags to tagPromises in nestedNormaliseEntryTags

* perf(shared): reduce overhead by using thenable in normaliseProps

* perf(shared): promise should be edge case in normaliseTag

* perf(shared): use for of instead of forEach in normaliseTag

* perf(unhead): move common props to top of module in dedupe plugin

* perf(unhead): use for in instead of object values in dedupe plugin

* perf(unhead): use for of instead of forEach in dedupe plugin

* perf(unhead): use for in instead of object entries in eventHandler plugin

is a double acceleration of the plugin

* perf(unhead): use slice & substring instead of replace in eventHandler

* perf(unhead): move filter into loop body in sort plugin

* perf(unhead): use substring instead of replace in sort plugin

* perf(unhead): swap the loops in the sorting plugin

* perf(shared): simplify complexity in tagWeight

* perf(unhead): combine sorts in sort plugin

* perf(unhead): reduce complexity to search in templateParams plugin

* perf(unhead): move filter into loop body in templateParams plugin

* perf(unhead): find and remove once templateParams tag in templateParams plugin

* perf(unhead): improve contentAttrs handle in templateParams plugin

* perf(unhead): speed up payload plugin using one loop

* perf(unhead): compute props keys after static in dedupe plugin

* perf(unhead): use splice instead of delete index + filter in titleTemplate plugin

* perf(unhead): first check hasProps in dedupe plugin

* perf(unhead): handle classes & styles without loop in dedupe plugin

* perf(unhead): use object with null prototype in dedupe plugin

* perf(unhead): use for of loop instead of map for patch entry

* perf(dom): use for in loop for props instead of Object.entries in renderDOMHead

* perf(dom): use for in loop for handle _eventHandlers in renderDOMHead

* perf(dom): clear side effects in for in loop in renderDOMHead

* perf(dom): replace loop with a direct property check for textContent and innerHTML

* perf(dom): remove cast to array HTMLCollection in renderDOMHead

* perf(unhead): speed up hashTag using for in loop & early returns

* perf(vue): reduce overhead from resolveUnrefHeadInput

* perf(dom): use set to store taken dedupe keys

* perf(ssr): use string concatenation in propsToString

* perf(ssr): add space before attrs in propsToString

* perf(ssr): use string concatenation in ssrRenderTags

* perf(ssr): use object.assign instead of spread operator

* perf(shared): use for of instead of array map

* perf(shared): check the static string first in fixKeyCase

* perf(shared): use for in & for of loops in meta

* perf(schema-org): use for in loop in resolveNodeId

* perf(schema-org): use for in loop in stripEmptyProperties

* perf(unhead): delete tag._duped if exists in dedupe plugin

* perf(unhead): remove loop for check third party dedupe keys in dedupe plugin

* perf(shared): reduce operations in eventHandler plugin
  • Loading branch information
negezor authored Jul 20, 2024
1 parent dcefefd commit 9325dfc
Show file tree
Hide file tree
Showing 47 changed files with 693 additions and 346 deletions.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@
"export:sizes": "pnpm -r --parallel --filter=./packages/** run export:sizes",
"bump": "bumpp package.json packages/*/package.json --commit --push --tag",
"release": "pnpm build && pnpm bump && pnpm -r publish --no-git-checks",
"lint": "eslint . --fix"
"lint": "eslint . --fix",
"benchmark": "vitest bench"
},
"devDependencies": {
"@antfu/eslint-config": "^2.21.2",
Expand Down
2 changes: 1 addition & 1 deletion packages/addons/src/plugins/inferSeoMetaPlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export interface InferSeoMetaPluginOptions {
const inputKey = entry.resolvedInput ? 'resolvedInput' : 'input'
const input = entry[inputKey]
const weight = (typeof input.titleTemplate === 'object' ? input.titleTemplate?.tagPriority : false) || entry.tagPriority || 100
if (typeof input.titleTemplate !== 'undefined' && weight <= lastWeight) {
if (input.titleTemplate !== undefined && weight <= lastWeight) {
titleTemplate = input.titleTemplate
lastWeight = weight
}
Expand Down
2 changes: 1 addition & 1 deletion packages/addons/src/unplugin/TreeshakeServerComposables.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export interface TreeshakeServerComposablesOptions extends BaseTransformerTypes
}

export const TreeshakeServerComposables = createUnplugin<TreeshakeServerComposablesOptions, false>((options: TreeshakeServerComposablesOptions = {}) => {
options.enabled = typeof options.enabled !== 'undefined' ? options.enabled : true
options.enabled = options.enabled !== undefined ? options.enabled : true

return {
name: 'unhead:remove-server-composables',
Expand Down
4 changes: 2 additions & 2 deletions packages/addons/src/unplugin/vite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export type { UnpluginOptions }

export default (options: UnpluginOptions = {}): Plugin[] => {
return [
TreeshakeServerComposables.vite({ filter: options.filter, sourcemap: options.sourcemap, ...options.treeshake || {} }),
UseSeoMetaTransform.vite({ filter: options.filter, sourcemap: options.sourcemap, ...options.transformSeoMeta || {} }),
TreeshakeServerComposables.vite({ filter: options.filter, sourcemap: options.sourcemap, ...options.treeshake }),
UseSeoMetaTransform.vite({ filter: options.filter, sourcemap: options.sourcemap, ...options.transformSeoMeta }),
]
}
12 changes: 7 additions & 5 deletions packages/dom/src/debounced.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@ export interface DebouncedRenderDomHeadOptions extends RenderDomHeadOptions {
/**
* Queue a debounced update of the DOM head.
*/
export async function debouncedRenderDOMHead<T extends Unhead<any>>(head: T, options: DebouncedRenderDomHeadOptions = {}) {
export function debouncedRenderDOMHead<T extends Unhead<any>>(head: T, options: DebouncedRenderDomHeadOptions = {}) {
const fn = options.delayFn || (fn => setTimeout(fn, 10))
return head._domUpdatePromise = head._domUpdatePromise || new Promise<void>(resolve => fn(async () => {
await renderDOMHead(head, options)
delete head._domUpdatePromise
resolve()
return head._domUpdatePromise = head._domUpdatePromise || new Promise<void>(resolve => fn(() => {
return renderDOMHead(head, options)
.then(() => {
delete head._domUpdatePromise
resolve()
})
}))
}
2 changes: 1 addition & 1 deletion packages/dom/src/domPlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export interface DomPluginOptions extends RenderDomHeadOptions {
return {
mode: 'client',
hooks: {
'entries:updated': function (head) {
'entries:updated': (head) => {
// async load the renderDOMHead function
debouncedRenderDOMHead(head, options)
},
Expand Down
96 changes: 68 additions & 28 deletions packages/dom/src/renderDOMHead.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export async function renderDOMHead<T extends Unhead<any>>(head: T, options: Ren
const tags = (await head.resolveTags())
.map(tag => <DomRenderTagContext> {
tag,
id: HasElementTags.includes(tag.tag) ? hashTag(tag) : tag.tag,
id: HasElementTags.has(tag.tag) ? hashTag(tag) : tag.tag,
shouldRender: true,
})
let state = head._dom as DomState
Expand All @@ -40,32 +40,43 @@ export async function renderDOMHead<T extends Unhead<any>>(head: T, options: Ren
state = {
elMap: { htmlAttrs: dom.documentElement, bodyAttrs: dom.body },
} as any as DomState

const takenDedupeKeys = new Set()

for (const key of ['body', 'head']) {
const children = dom[key as 'head' | 'body']?.children
const tags: HeadTag[] = []
for (const c of [...children].filter(c => HasElementTags.includes(c.tagName.toLowerCase()))) {
for (const c of children) {
const tag = c.tagName.toLowerCase() as HeadTag['tag']
if (!HasElementTags.has(tag)) {
continue
}
const t: HeadTag = {
tag: c.tagName.toLowerCase() as HeadTag['tag'],
tag,
props: await normaliseProps(
c.getAttributeNames()
.reduce((props, name) => ({ ...props, [name]: c.getAttribute(name) }), {}),
),
innerHTML: c.innerHTML,
}
// we need to account for the fact that duplicate tags may exist as some are supported, increment the dedupe key
const dedupeKey = tagDedupeKey(t)
let d = dedupeKey
let i = 1
let d = tagDedupeKey(t)
while (d && tags.find(t => t._d === d))
d = `${d}:${i++}`
t._d = d || undefined
while (d && takenDedupeKeys.has(d))
d = `${dedupeKey}:${i++}`
if (d) {
t._d = d
takenDedupeKeys.add(d)
}
tags.push(t)
state.elMap[c.getAttribute('data-hid') || hashTag(t)] = c
}
}
}

// presume all side effects are stale, we mark them as not stale if they're re-introduced
state.pendingSideEffects = { ...state.sideEffects || {} }
state.pendingSideEffects = { ...state.sideEffects }
state.sideEffects = {}

function track(id: string, scope: string, fn: () => void) {
Expand All @@ -78,52 +89,77 @@ export async function renderDOMHead<T extends Unhead<any>>(head: T, options: Ren
const isAttrTag = tag.tag.endsWith('Attrs')
state.elMap[id] = $el
if (!isAttrTag) {
;['textContent', 'innerHTML'].forEach((k) => {
// @ts-expect-error unkeyed
tag[k] && tag[k] !== $el[k] && ($el[k] = tag[k])
})
if (tag.textContent && tag.textContent !== $el.textContent) {
$el.textContent = tag.textContent
}
if (tag.innerHTML && tag.innerHTML !== $el.innerHTML) {
$el.innerHTML = tag.innerHTML
}
track(id, 'el', () => {
// the element may have been removed by a duplicate tag or something out of our control
state.elMap[id]?.remove()
delete state.elMap[id]
})
}
// we need to attach event listeners as they can have side effects such as onload
for (const [k, value] of Object.entries(tag._eventHandlers || {})) {
if ($el.getAttribute(`data-${k}`) !== '') {
// avoid overriding
(tag!.tag === 'bodyAttrs' ? dom!.defaultView! : $el).addEventListener(k.replace('on', ''), value.bind($el))
$el.setAttribute(`data-${k}`, '')
if (tag._eventHandlers) {
// we need to attach event listeners as they can have side effects such as onload
for (const k in tag._eventHandlers) {
if (!Object.prototype.hasOwnProperty.call(tag._eventHandlers, k)) {
continue
}

if ($el.getAttribute(`data-${k}`) !== '') {
// avoid overriding
(tag!.tag === 'bodyAttrs' ? dom!.defaultView! : $el).addEventListener(
// onload -> load
k.substring(2),
tag._eventHandlers[k].bind($el),
)
$el.setAttribute(`data-${k}`, '')
}
}
}
Object.entries(tag.props).forEach(([k, value]) => {
for (const k in tag.props) {
if (!Object.prototype.hasOwnProperty.call(tag.props, k)) {
continue
}
const value = tag.props[k]

const ck = `attr:${k}`
// class attributes have their own side effects to allow for merging
if (k === 'class') {
if (!value) {
continue
}
// if the user is providing an empty string, then it's removing the class
// the side effect clean up should remove it
for (const c of (value || '').split(' ').filter(Boolean)) {
for (const c of value.split(' ')) {
// always clear side effects
isAttrTag && track(id, `${ck}:${c}`, () => $el.classList.remove(c))
!$el.classList.contains(c) && $el.classList.add(c)
}
}
else if (k === 'style') {
if (!value) {
continue
}
// style attributes have their own side effects to allow for merging
for (const c of (value || '').split(';').filter(Boolean)) {
const [k, ...v] = c.split(':').map(s => s.trim())
for (const c of value.split(';')) {
const propIndex = c.indexOf(':')
const k = c.substring(0, propIndex).trim()
const v = c.substring(propIndex + 1).trim()
track(id, `${ck}:${k}`, () => {
($el as any as ElementCSSInlineStyle).style.removeProperty(k)
})
;($el as any as ElementCSSInlineStyle).style.setProperty(k, v.join(':'))
;($el as any as ElementCSSInlineStyle).style.setProperty(k, v)
}
}
else {
// attribute values get set directly
$el.getAttribute(k) !== value && $el.setAttribute(k, (value as string | boolean) === true ? '' : String(value))
isAttrTag && track(id, ck, () => $el.removeAttribute(k))
}
})
}
}

const pending: DomRenderTagContext[] = []
Expand All @@ -144,11 +180,13 @@ export async function renderDOMHead<T extends Unhead<any>>(head: T, options: Ren
continue
}
ctx.$el = ctx.$el || state.elMap[id]
if (ctx.$el)
if (ctx.$el) {
trackCtx(ctx)
else
}
else if (HasElementTags.has(tag.tag)) {
// tag does not exist, we need to render it (if it's an element tag)
HasElementTags.includes(tag.tag) && pending.push(ctx)
pending.push(ctx)
}
}
// 3. render tags which require a dom element to be created or requires scanning DOM to determine duplicate
for (const ctx of pending) {
Expand All @@ -168,7 +206,9 @@ export async function renderDOMHead<T extends Unhead<any>>(head: T, options: Ren
frag.bodyClose && dom.body.appendChild(frag.bodyClose)

// clear all side effects still pending
Object.values(state.pendingSideEffects).forEach(fn => fn())
for (const k in state.pendingSideEffects) {
state.pendingSideEffects[k]()
}
head._dom = state
head.dirty = false
await head.hooks.callHook('dom:rendered', { renders: tags })
Expand Down
17 changes: 12 additions & 5 deletions packages/schema-org/src/core/resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,11 +118,18 @@ export function resolveNodeId<T extends Thing>(node: T, ctx: SchemaOrgGraph, res
alias = type.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase()
}
const hashNodeData: Record<string, any> = {}
Object.entries(node).forEach(([key, val]) => {
// remove runtime private fields
if (!key.startsWith('_'))
hashNodeData[key] = val
})
for (const key in node) {
if (key[0] === '_') {
continue
}

if (!Object.prototype.hasOwnProperty.call(node, key)) {
continue
}

hashNodeData[key] = node[key]
}

node['@id'] = prefixId(ctx.meta[prefix], `#/schema/${alias}/${node['@id'] || hashCode(JSON.stringify(hashNodeData))}`)
return node
}
Expand Down
2 changes: 1 addition & 1 deletion packages/schema-org/src/core/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export function normaliseNodes(nodes: SchemaOrgNode[]) {
const nodeKey = resolveAsGraphKey(n['@id'] || hash(n)) as Id
const groupedKeys = groupBy(Object.keys(n), (key) => {
const val = n[key]
if (key.startsWith('_'))
if (key[0] === '_')
return 'ignored'
if (Array.isArray(val) || typeof val === 'object')
return 'relations'
Expand Down
6 changes: 3 additions & 3 deletions packages/schema-org/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,10 @@ export function SchemaOrgUnheadPlugin(config: MetaInput, meta: () => Partial<Met
return defineHeadPlugin(head => ({
key: 'schema-org',
hooks: {
'entries:resolve': function () {
'entries:resolve': () => {
graph = createSchemaOrgGraph()
},
'tag:normalise': async function ({ tag }) {
'tag:normalise': async ({ tag }) => {
if (tag.key === 'schema-org-graph') {
// this is a bit expensive, load in seperate chunk
const { loadResolver } = await import('./resolver')
Expand Down Expand Up @@ -75,7 +75,7 @@ export function SchemaOrgUnheadPlugin(config: MetaInput, meta: () => Partial<Met
delete tag.props.schemaOrg
}
},
'tags:resolve': async function (ctx) {
'tags:resolve': async (ctx) => {
// find the schema.org node, should be a single instance
for (const tag of ctx.tags) {
if (tag.tag === 'script' && tag.key === 'schema-org-graph') {
Expand Down
22 changes: 12 additions & 10 deletions packages/schema-org/src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,20 +75,17 @@ export function asArray(input: any) {
}

export function dedupeMerge<T extends Thing>(node: T, field: keyof T, value: any) {
const dedupeMerge: any[] = []
const input = asArray(node[field])
dedupeMerge.push(...input)
const data = new Set(dedupeMerge)
const data = new Set(asArray(node[field]))
data.add(value)
// @ts-expect-error untyped key
node[field] = [...data.values()].filter(Boolean)
node[field] = [...data].filter(Boolean)
}

export function prefixId(url: string, id: Id | string) {
// already prefixed
if (hasProtocol(id))
return id as Id
if (!id.startsWith('#'))
if (id[0] !== '#')
id = `#${id}`
return withBase(id, url) as Id
}
Expand Down Expand Up @@ -117,7 +114,7 @@ export function resolveDefaultType(node: Thing, defaultType: Arrayable<string>)

export function resolveWithBase(base: string, urlOrPath: string) {
// can't apply base if there's a protocol
if (!urlOrPath || hasProtocol(urlOrPath) || (!urlOrPath.startsWith('/') && !urlOrPath.startsWith('#')))
if (!urlOrPath || hasProtocol(urlOrPath) || ((urlOrPath[0] !== '/') && (urlOrPath[0] !== '#')))
return urlOrPath
return withBase(urlOrPath, base)
}
Expand All @@ -132,16 +129,21 @@ export function resolveAsGraphKey(key?: Id | string) {
* Removes attributes which have a null or undefined value
*/
export function stripEmptyProperties(obj: any) {
Object.keys(obj).forEach((k) => {
for (const k in obj) {
if (!Object.prototype.hasOwnProperty.call(obj, k)) {
continue
}

if (obj[k] && typeof obj[k] === 'object') {
// avoid walking vue reactivity
if (obj[k].__v_isReadonly || obj[k].__v_isRef)
return
stripEmptyProperties(obj[k])
return
}
if (obj[k] === '' || obj[k] === null || typeof obj[k] === 'undefined')
if (obj[k] === '' || obj[k] === null || obj[k] === undefined)
delete obj[k]
})
}

return obj
}
2 changes: 1 addition & 1 deletion packages/schema-org/src/vue/runtime/components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ function ignoreKey(s: string) {
if (s.startsWith('aria-') || s.startsWith('data-'))
return false

return ['class', 'style'].includes(s)
return s === 'class' || s === 'style'
}

export function defineSchemaOrgComponent(name: string, defineFn: (input: any) => any): ReturnType<typeof defineComponent> {
Expand Down
Loading

0 comments on commit 9325dfc

Please sign in to comment.