diff --git a/lib/Hook.js b/lib/Hook.js index 6da14b2..560bb96 100644 --- a/lib/Hook.js +++ b/lib/Hook.js @@ -65,22 +65,45 @@ class Hook { _tap(type, options, fn) { if (typeof options === "string") { - options = { - name: options - }; - } else if (typeof options !== "object" || options === null) { - throw new Error("Invalid tap options"); - } - if (typeof options.name === "string") { - options.name = options.name.trim(); - } - if (typeof options.name !== "string" || options.name === "") { - throw new Error("Missing name for tap"); - } - if (typeof options.context !== "undefined") { - deprecateContext(); + // Fast path: a string options ("name") is by far the most common + // case. Build the final descriptor in a single allocation instead + // of creating `{ name }` and then `Object.assign`ing it. + const name = options.trim(); + if (name === "") { + throw new Error("Missing name for tap"); + } + options = { type, fn, name }; + } else { + if (typeof options !== "object" || options === null) { + throw new Error("Invalid tap options"); + } + let { name } = options; + if (typeof name === "string") { + name = name.trim(); + } + if (typeof name !== "string" || name === "") { + throw new Error("Missing name for tap"); + } + if (typeof options.context !== "undefined") { + deprecateContext(); + } + // Fast path: only `name` is set. Build the descriptor as a literal + // so `_insert` and downstream consumers see the same hidden class + // as the string-options path, avoiding a polymorphic call site. + if ( + options.before === undefined && + options.stage === undefined && + options.context === undefined && + options.type === undefined && + options.fn === undefined + ) { + options = { type, fn, name }; + } else { + options.name = name; + // Preserve previous precedence: user-provided keys win over the internal `type`/`fn`. + options = Object.assign({ type, fn }, options); + } } - options = Object.assign({ type, fn }, options); options = this._runRegisterInterceptors(options); this._insert(options); } @@ -98,7 +121,12 @@ class Hook { } _runRegisterInterceptors(options) { - for (const interceptor of this.interceptors) { + const { interceptors } = this; + const { length } = interceptors; + // Common case: no interceptors. + if (length === 0) return options; + for (let i = 0; i < length; i++) { + const interceptor = interceptors[i]; if (interceptor.register) { const newOptions = interceptor.register(options); if (newOptions !== undefined) { @@ -146,21 +174,36 @@ class Hook { _insert(item) { this._resetCompilation(); + const { taps } = this; + const stage = typeof item.stage === "number" ? item.stage : 0; + + // Fast path: the overwhelmingly common `hook.tap("name", fn)` case + // has no `before` and default stage 0. If the list is empty or the + // last tap's stage is <= the new item's stage the item belongs at + // the end - append in O(1), skipping the Set allocation and the + // shift loop. + if (!(typeof item.before === "string" || Array.isArray(item.before))) { + const n = taps.length; + if (n === 0 || (taps[n - 1].stage || 0) <= stage) { + taps[n] = item; + return; + } + } + let before; + if (typeof item.before === "string") { before = new Set([item.before]); } else if (Array.isArray(item.before)) { before = new Set(item.before); } - let stage = 0; - if (typeof item.stage === "number") { - stage = item.stage; - } - let i = this.taps.length; + + let i = taps.length; + while (i > 0) { i--; - const tap = this.taps[i]; - this.taps[i + 1] = tap; + const tap = taps[i]; + taps[i + 1] = tap; const xStage = tap.stage || 0; if (before) { if (before.has(tap.name)) { @@ -177,7 +220,7 @@ class Hook { i++; break; } - this.taps[i] = item; + taps[i] = item; } } diff --git a/lib/HookCodeFactory.js b/lib/HookCodeFactory.js index 67e4663..d3d5fa6 100644 --- a/lib/HookCodeFactory.js +++ b/lib/HookCodeFactory.js @@ -77,7 +77,13 @@ class HookCodeFactory { } setup(instance, options) { - instance._x = options.taps.map((t) => t.fn); + const { taps } = options; + const { length } = taps; + const fns = Array.from({ length }); + for (let i = 0; i < length; i++) { + fns[i] = taps[i].fn; + } + instance._x = fns; } /** @@ -85,12 +91,16 @@ class HookCodeFactory { */ init(options) { this.options = options; - this._args = [...options.args]; + // slice() avoids the iterator protocol overhead of [...arr]. + // eslint-disable-next-line unicorn/prefer-spread + this._args = options.args.slice(); + this._joinedArgs = undefined; } deinit() { this.options = undefined; this._args = undefined; + this._joinedArgs = undefined; } contentWithInterceptors(options) { @@ -165,7 +175,10 @@ class HookCodeFactory { } needContext() { - for (const tap of this.options.taps) if (tap.context) return true; + const { taps } = this.options; + for (let i = 0; i < taps.length; i++) { + if (taps[i].context) return true; + } return false; } @@ -274,17 +287,30 @@ class HookCodeFactory { doneReturns, rethrowIfPossible }) { - if (this.options.taps.length === 0) return onDone(); - const firstAsync = this.options.taps.findIndex((t) => t.type !== "sync"); + const { taps } = this.options; + const tapsLength = taps.length; + if (tapsLength === 0) return onDone(); + // Inlined findIndex to avoid the callback allocation. + let firstAsync = -1; + for (let i = 0; i < tapsLength; i++) { + if (taps[i].type !== "sync") { + firstAsync = i; + break; + } + } const somethingReturns = resultReturns || doneReturns; + // doneBreak doesn't depend on the loop variable - hoist to allocate once. + const doneBreak = (skipDone) => { + if (skipDone) return ""; + return onDone(); + }; let code = ""; let current = onDone; let unrollCounter = 0; - for (let j = this.options.taps.length - 1; j >= 0; j--) { + for (let j = tapsLength - 1; j >= 0; j--) { const i = j; const unroll = - current !== onDone && - (this.options.taps[i].type !== "sync" || unrollCounter++ > 20); + current !== onDone && (taps[i].type !== "sync" || unrollCounter++ > 20); if (unroll) { unrollCounter = 0; code += `function _next${i}() {\n`; @@ -293,10 +319,6 @@ class HookCodeFactory { current = () => `${somethingReturns ? "return " : ""}_next${i}();\n`; } const done = current; - const doneBreak = (skipDone) => { - if (skipDone) return ""; - return onDone(); - }; const content = this.callTap(i, { onError: (error) => onError(i, error, done, doneBreak), onResult: @@ -370,7 +392,9 @@ class HookCodeFactory { rethrowIfPossible, onTap = (i, run) => run() }) { - if (this.options.taps.length <= 1) { + const { taps } = this.options; + const tapsLength = taps.length; + if (tapsLength <= 1) { return this.callTapsSeries({ onError, onResult, @@ -378,23 +402,25 @@ class HookCodeFactory { rethrowIfPossible }); } + // done and doneBreak don't depend on the loop variable - hoist them + // so they're allocated once per compile instead of once per tap. + const done = () => { + if (onDone) return "if(--_counter === 0) _done();\n"; + return "--_counter;"; + }; + const doneBreak = (skipDone) => { + if (skipDone || !onDone) return "_counter = 0;\n"; + return "_counter = 0;\n_done();\n"; + }; let code = ""; code += "do {\n"; - code += `var _counter = ${this.options.taps.length};\n`; + code += `var _counter = ${tapsLength};\n`; if (onDone) { code += "var _done = (function() {\n"; code += onDone(); code += "});\n"; } - for (let i = 0; i < this.options.taps.length; i++) { - const done = () => { - if (onDone) return "if(--_counter === 0) _done();\n"; - return "--_counter;"; - }; - const doneBreak = (skipDone) => { - if (skipDone || !onDone) return "_counter = 0;\n"; - return "_counter = 0;\n_done();\n"; - }; + for (let i = 0; i < tapsLength; i++) { code += "if(_counter <= 0) break;\n"; code += onTap( i, @@ -428,13 +454,22 @@ class HookCodeFactory { } args({ before, after } = {}) { + // Hot during code generation (called once per tap + per interceptor). + // Cache the common no-before/no-after result so we only join once. + if (before === undefined && after === undefined) { + let joined = this._joinedArgs; + if (joined === undefined) { + joined = this._args.length === 0 ? "" : this._args.join(", "); + this._joinedArgs = joined; + } + return joined; + } let allArgs = this._args; if (before) allArgs = [before, ...allArgs]; if (after) allArgs = [...allArgs, after]; if (allArgs.length === 0) { return ""; } - return allArgs.join(", "); } diff --git a/lib/HookMap.js b/lib/HookMap.js index 8fdc5d6..ca91cd6 100644 --- a/lib/HookMap.js +++ b/lib/HookMap.js @@ -21,7 +21,11 @@ class HookMap { } for(key) { - const hook = this.get(key); + // Hot path: inline the map lookup to skip the `this.get(key)` + // indirection. This gets hit on every hook access in consumers + // like webpack. + const map = this._map; + const hook = map.get(key); if (hook !== undefined) { return hook; } @@ -30,7 +34,7 @@ class HookMap { for (let i = 0; i < interceptors.length; i++) { newHook = interceptors[i].factory(key, newHook); } - this._map.set(key, newHook); + map.set(key, newHook); return newHook; } diff --git a/lib/MultiHook.js b/lib/MultiHook.js index 8041264..900abbd 100644 --- a/lib/MultiHook.js +++ b/lib/MultiHook.js @@ -11,33 +11,38 @@ class MultiHook { } tap(options, fn) { - for (const hook of this.hooks) { - hook.tap(options, fn); + const { hooks } = this; + for (let i = 0; i < hooks.length; i++) { + hooks[i].tap(options, fn); } } tapAsync(options, fn) { - for (const hook of this.hooks) { - hook.tapAsync(options, fn); + const { hooks } = this; + for (let i = 0; i < hooks.length; i++) { + hooks[i].tapAsync(options, fn); } } tapPromise(options, fn) { - for (const hook of this.hooks) { - hook.tapPromise(options, fn); + const { hooks } = this; + for (let i = 0; i < hooks.length; i++) { + hooks[i].tapPromise(options, fn); } } isUsed() { - for (const hook of this.hooks) { - if (hook.isUsed()) return true; + const { hooks } = this; + for (let i = 0; i < hooks.length; i++) { + if (hooks[i].isUsed()) return true; } return false; } intercept(interceptor) { - for (const hook of this.hooks) { - hook.intercept(interceptor); + const { hooks } = this; + for (let i = 0; i < hooks.length; i++) { + hooks[i].intercept(interceptor); } }