Skip to content

Commit 9e9ae4d

Browse files
fix(perf): improve (#224)
1 parent 4e24645 commit 9e9ae4d

File tree

4 files changed

+147
-60
lines changed

4 files changed

+147
-60
lines changed

lib/Hook.js

Lines changed: 67 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -65,22 +65,45 @@ class Hook {
6565

6666
_tap(type, options, fn) {
6767
if (typeof options === "string") {
68-
options = {
69-
name: options
70-
};
71-
} else if (typeof options !== "object" || options === null) {
72-
throw new Error("Invalid tap options");
73-
}
74-
if (typeof options.name === "string") {
75-
options.name = options.name.trim();
76-
}
77-
if (typeof options.name !== "string" || options.name === "") {
78-
throw new Error("Missing name for tap");
79-
}
80-
if (typeof options.context !== "undefined") {
81-
deprecateContext();
68+
// Fast path: a string options ("name") is by far the most common
69+
// case. Build the final descriptor in a single allocation instead
70+
// of creating `{ name }` and then `Object.assign`ing it.
71+
const name = options.trim();
72+
if (name === "") {
73+
throw new Error("Missing name for tap");
74+
}
75+
options = { type, fn, name };
76+
} else {
77+
if (typeof options !== "object" || options === null) {
78+
throw new Error("Invalid tap options");
79+
}
80+
let { name } = options;
81+
if (typeof name === "string") {
82+
name = name.trim();
83+
}
84+
if (typeof name !== "string" || name === "") {
85+
throw new Error("Missing name for tap");
86+
}
87+
if (typeof options.context !== "undefined") {
88+
deprecateContext();
89+
}
90+
// Fast path: only `name` is set. Build the descriptor as a literal
91+
// so `_insert` and downstream consumers see the same hidden class
92+
// as the string-options path, avoiding a polymorphic call site.
93+
if (
94+
options.before === undefined &&
95+
options.stage === undefined &&
96+
options.context === undefined &&
97+
options.type === undefined &&
98+
options.fn === undefined
99+
) {
100+
options = { type, fn, name };
101+
} else {
102+
options.name = name;
103+
// Preserve previous precedence: user-provided keys win over the internal `type`/`fn`.
104+
options = Object.assign({ type, fn }, options);
105+
}
82106
}
83-
options = Object.assign({ type, fn }, options);
84107
options = this._runRegisterInterceptors(options);
85108
this._insert(options);
86109
}
@@ -98,7 +121,12 @@ class Hook {
98121
}
99122

100123
_runRegisterInterceptors(options) {
101-
for (const interceptor of this.interceptors) {
124+
const { interceptors } = this;
125+
const { length } = interceptors;
126+
// Common case: no interceptors.
127+
if (length === 0) return options;
128+
for (let i = 0; i < length; i++) {
129+
const interceptor = interceptors[i];
102130
if (interceptor.register) {
103131
const newOptions = interceptor.register(options);
104132
if (newOptions !== undefined) {
@@ -146,21 +174,36 @@ class Hook {
146174

147175
_insert(item) {
148176
this._resetCompilation();
177+
const { taps } = this;
178+
const stage = typeof item.stage === "number" ? item.stage : 0;
179+
180+
// Fast path: the overwhelmingly common `hook.tap("name", fn)` case
181+
// has no `before` and default stage 0. If the list is empty or the
182+
// last tap's stage is <= the new item's stage the item belongs at
183+
// the end - append in O(1), skipping the Set allocation and the
184+
// shift loop.
185+
if (!(typeof item.before === "string" || Array.isArray(item.before))) {
186+
const n = taps.length;
187+
if (n === 0 || (taps[n - 1].stage || 0) <= stage) {
188+
taps[n] = item;
189+
return;
190+
}
191+
}
192+
149193
let before;
194+
150195
if (typeof item.before === "string") {
151196
before = new Set([item.before]);
152197
} else if (Array.isArray(item.before)) {
153198
before = new Set(item.before);
154199
}
155-
let stage = 0;
156-
if (typeof item.stage === "number") {
157-
stage = item.stage;
158-
}
159-
let i = this.taps.length;
200+
201+
let i = taps.length;
202+
160203
while (i > 0) {
161204
i--;
162-
const tap = this.taps[i];
163-
this.taps[i + 1] = tap;
205+
const tap = taps[i];
206+
taps[i + 1] = tap;
164207
const xStage = tap.stage || 0;
165208
if (before) {
166209
if (before.has(tap.name)) {
@@ -177,7 +220,7 @@ class Hook {
177220
i++;
178221
break;
179222
}
180-
this.taps[i] = item;
223+
taps[i] = item;
181224
}
182225
}
183226

lib/HookCodeFactory.js

Lines changed: 59 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -77,20 +77,30 @@ class HookCodeFactory {
7777
}
7878

7979
setup(instance, options) {
80-
instance._x = options.taps.map((t) => t.fn);
80+
const { taps } = options;
81+
const { length } = taps;
82+
const fns = Array.from({ length });
83+
for (let i = 0; i < length; i++) {
84+
fns[i] = taps[i].fn;
85+
}
86+
instance._x = fns;
8187
}
8288

8389
/**
8490
* @param {{ type: "sync" | "promise" | "async", taps: Array<Tap>, interceptors: Array<Interceptor> }} options
8591
*/
8692
init(options) {
8793
this.options = options;
88-
this._args = [...options.args];
94+
// slice() avoids the iterator protocol overhead of [...arr].
95+
// eslint-disable-next-line unicorn/prefer-spread
96+
this._args = options.args.slice();
97+
this._joinedArgs = undefined;
8998
}
9099

91100
deinit() {
92101
this.options = undefined;
93102
this._args = undefined;
103+
this._joinedArgs = undefined;
94104
}
95105

96106
contentWithInterceptors(options) {
@@ -165,7 +175,10 @@ class HookCodeFactory {
165175
}
166176

167177
needContext() {
168-
for (const tap of this.options.taps) if (tap.context) return true;
178+
const { taps } = this.options;
179+
for (let i = 0; i < taps.length; i++) {
180+
if (taps[i].context) return true;
181+
}
169182
return false;
170183
}
171184

@@ -274,17 +287,30 @@ class HookCodeFactory {
274287
doneReturns,
275288
rethrowIfPossible
276289
}) {
277-
if (this.options.taps.length === 0) return onDone();
278-
const firstAsync = this.options.taps.findIndex((t) => t.type !== "sync");
290+
const { taps } = this.options;
291+
const tapsLength = taps.length;
292+
if (tapsLength === 0) return onDone();
293+
// Inlined findIndex to avoid the callback allocation.
294+
let firstAsync = -1;
295+
for (let i = 0; i < tapsLength; i++) {
296+
if (taps[i].type !== "sync") {
297+
firstAsync = i;
298+
break;
299+
}
300+
}
279301
const somethingReturns = resultReturns || doneReturns;
302+
// doneBreak doesn't depend on the loop variable - hoist to allocate once.
303+
const doneBreak = (skipDone) => {
304+
if (skipDone) return "";
305+
return onDone();
306+
};
280307
let code = "";
281308
let current = onDone;
282309
let unrollCounter = 0;
283-
for (let j = this.options.taps.length - 1; j >= 0; j--) {
310+
for (let j = tapsLength - 1; j >= 0; j--) {
284311
const i = j;
285312
const unroll =
286-
current !== onDone &&
287-
(this.options.taps[i].type !== "sync" || unrollCounter++ > 20);
313+
current !== onDone && (taps[i].type !== "sync" || unrollCounter++ > 20);
288314
if (unroll) {
289315
unrollCounter = 0;
290316
code += `function _next${i}() {\n`;
@@ -293,10 +319,6 @@ class HookCodeFactory {
293319
current = () => `${somethingReturns ? "return " : ""}_next${i}();\n`;
294320
}
295321
const done = current;
296-
const doneBreak = (skipDone) => {
297-
if (skipDone) return "";
298-
return onDone();
299-
};
300322
const content = this.callTap(i, {
301323
onError: (error) => onError(i, error, done, doneBreak),
302324
onResult:
@@ -370,31 +392,35 @@ class HookCodeFactory {
370392
rethrowIfPossible,
371393
onTap = (i, run) => run()
372394
}) {
373-
if (this.options.taps.length <= 1) {
395+
const { taps } = this.options;
396+
const tapsLength = taps.length;
397+
if (tapsLength <= 1) {
374398
return this.callTapsSeries({
375399
onError,
376400
onResult,
377401
onDone,
378402
rethrowIfPossible
379403
});
380404
}
405+
// done and doneBreak don't depend on the loop variable - hoist them
406+
// so they're allocated once per compile instead of once per tap.
407+
const done = () => {
408+
if (onDone) return "if(--_counter === 0) _done();\n";
409+
return "--_counter;";
410+
};
411+
const doneBreak = (skipDone) => {
412+
if (skipDone || !onDone) return "_counter = 0;\n";
413+
return "_counter = 0;\n_done();\n";
414+
};
381415
let code = "";
382416
code += "do {\n";
383-
code += `var _counter = ${this.options.taps.length};\n`;
417+
code += `var _counter = ${tapsLength};\n`;
384418
if (onDone) {
385419
code += "var _done = (function() {\n";
386420
code += onDone();
387421
code += "});\n";
388422
}
389-
for (let i = 0; i < this.options.taps.length; i++) {
390-
const done = () => {
391-
if (onDone) return "if(--_counter === 0) _done();\n";
392-
return "--_counter;";
393-
};
394-
const doneBreak = (skipDone) => {
395-
if (skipDone || !onDone) return "_counter = 0;\n";
396-
return "_counter = 0;\n_done();\n";
397-
};
423+
for (let i = 0; i < tapsLength; i++) {
398424
code += "if(_counter <= 0) break;\n";
399425
code += onTap(
400426
i,
@@ -428,13 +454,22 @@ class HookCodeFactory {
428454
}
429455

430456
args({ before, after } = {}) {
457+
// Hot during code generation (called once per tap + per interceptor).
458+
// Cache the common no-before/no-after result so we only join once.
459+
if (before === undefined && after === undefined) {
460+
let joined = this._joinedArgs;
461+
if (joined === undefined) {
462+
joined = this._args.length === 0 ? "" : this._args.join(", ");
463+
this._joinedArgs = joined;
464+
}
465+
return joined;
466+
}
431467
let allArgs = this._args;
432468
if (before) allArgs = [before, ...allArgs];
433469
if (after) allArgs = [...allArgs, after];
434470
if (allArgs.length === 0) {
435471
return "";
436472
}
437-
438473
return allArgs.join(", ");
439474
}
440475

lib/HookMap.js

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,11 @@ class HookMap {
2121
}
2222

2323
for(key) {
24-
const hook = this.get(key);
24+
// Hot path: inline the map lookup to skip the `this.get(key)`
25+
// indirection. This gets hit on every hook access in consumers
26+
// like webpack.
27+
const map = this._map;
28+
const hook = map.get(key);
2529
if (hook !== undefined) {
2630
return hook;
2731
}
@@ -30,7 +34,7 @@ class HookMap {
3034
for (let i = 0; i < interceptors.length; i++) {
3135
newHook = interceptors[i].factory(key, newHook);
3236
}
33-
this._map.set(key, newHook);
37+
map.set(key, newHook);
3438
return newHook;
3539
}
3640

lib/MultiHook.js

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,33 +11,38 @@ class MultiHook {
1111
}
1212

1313
tap(options, fn) {
14-
for (const hook of this.hooks) {
15-
hook.tap(options, fn);
14+
const { hooks } = this;
15+
for (let i = 0; i < hooks.length; i++) {
16+
hooks[i].tap(options, fn);
1617
}
1718
}
1819

1920
tapAsync(options, fn) {
20-
for (const hook of this.hooks) {
21-
hook.tapAsync(options, fn);
21+
const { hooks } = this;
22+
for (let i = 0; i < hooks.length; i++) {
23+
hooks[i].tapAsync(options, fn);
2224
}
2325
}
2426

2527
tapPromise(options, fn) {
26-
for (const hook of this.hooks) {
27-
hook.tapPromise(options, fn);
28+
const { hooks } = this;
29+
for (let i = 0; i < hooks.length; i++) {
30+
hooks[i].tapPromise(options, fn);
2831
}
2932
}
3033

3134
isUsed() {
32-
for (const hook of this.hooks) {
33-
if (hook.isUsed()) return true;
35+
const { hooks } = this;
36+
for (let i = 0; i < hooks.length; i++) {
37+
if (hooks[i].isUsed()) return true;
3438
}
3539
return false;
3640
}
3741

3842
intercept(interceptor) {
39-
for (const hook of this.hooks) {
40-
hook.intercept(interceptor);
43+
const { hooks } = this;
44+
for (let i = 0; i < hooks.length; i++) {
45+
hooks[i].intercept(interceptor);
4146
}
4247
}
4348

0 commit comments

Comments
 (0)