Skip to content

Commit b2e8d80

Browse files
committed
Improve performance
1 parent 5ec53f9 commit b2e8d80

5 files changed

Lines changed: 211 additions & 46 deletions

File tree

base.js

Lines changed: 59 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -24,15 +24,12 @@ function encoderForArrayFormat(options) {
2424
}
2525

2626
if (value === null) {
27-
return [
28-
...result, [encode(key, options), '[', index, ']'].join(''),
29-
];
27+
result.push([encode(key, options), '[', index, ']'].join(''));
28+
return result;
3029
}
3130

32-
return [
33-
...result,
34-
[encode(key, options), '[', encode(index, options), ']=', encode(value, options)].join(''),
35-
];
31+
result.push([encode(key, options), '[', encode(index, options), ']=', encode(value, options)].join(''));
32+
return result;
3633
};
3734
}
3835

@@ -47,16 +44,12 @@ function encoderForArrayFormat(options) {
4744
}
4845

4946
if (value === null) {
50-
return [
51-
...result,
52-
[encode(key, options), '[]'].join(''),
53-
];
47+
result.push([encode(key, options), '[]'].join(''));
48+
return result;
5449
}
5550

56-
return [
57-
...result,
58-
[encode(key, options), '[]=', encode(value, options)].join(''),
59-
];
51+
result.push([encode(key, options), '[]=', encode(value, options)].join(''));
52+
return result;
6053
};
6154
}
6255

@@ -71,16 +64,12 @@ function encoderForArrayFormat(options) {
7164
}
7265

7366
if (value === null) {
74-
return [
75-
...result,
76-
[encode(key, options), ':list='].join(''),
77-
];
67+
result.push([encode(key, options), ':list='].join(''));
68+
return result;
7869
}
7970

80-
return [
81-
...result,
82-
[encode(key, options), ':list=', encode(value, options)].join(''),
83-
];
71+
result.push([encode(key, options), ':list=', encode(value, options)].join(''));
72+
return result;
8473
};
8574
}
8675

@@ -104,10 +93,12 @@ function encoderForArrayFormat(options) {
10493
value = value === null ? '' : value;
10594

10695
if (result.length === 0) {
107-
return [[encode(key, options), keyValueSeparator, encode(value, options)].join('')];
96+
result.push([encode(key, options), keyValueSeparator, encode(value, options)].join(''));
97+
return result;
10898
}
10999

110-
return [[result, encode(value, options)].join(options.arrayFormatSeparator)];
100+
result.push(encode(value, options));
101+
return result;
111102
};
112103
}
113104

@@ -122,16 +113,12 @@ function encoderForArrayFormat(options) {
122113
}
123114

124115
if (value === null) {
125-
return [
126-
...result,
127-
encode(key, options),
128-
];
116+
result.push(encode(key, options));
117+
return result;
129118
}
130119

131-
return [
132-
...result,
133-
[encode(key, options), '=', encode(value, options)].join(''),
134-
];
120+
result.push([encode(key, options), '=', encode(value, options)].join(''));
121+
return result;
135122
};
136123
}
137124
}
@@ -175,7 +162,7 @@ function parserForArrayFormat(options) {
175162
return;
176163
}
177164

178-
accumulator[key] = [...accumulator[key], value];
165+
accumulator[key].push(value);
179166
};
180167
}
181168

@@ -194,7 +181,7 @@ function parserForArrayFormat(options) {
194181
return;
195182
}
196183

197-
accumulator[key] = [...accumulator[key], value];
184+
accumulator[key].push(value);
198185
};
199186
}
200187

@@ -226,7 +213,9 @@ function parserForArrayFormat(options) {
226213
return;
227214
}
228215

229-
accumulator[key] = [...accumulator[key], ...arrayValue];
216+
for (const item of arrayValue) {
217+
accumulator[key].push(item);
218+
}
230219
};
231220
}
232221

@@ -237,7 +226,12 @@ function parserForArrayFormat(options) {
237226
return;
238227
}
239228

240-
accumulator[key] = [...[accumulator[key]].flat(), value];
229+
if (Array.isArray(accumulator[key])) {
230+
accumulator[key].push(value);
231+
return;
232+
}
233+
234+
accumulator[key] = [accumulator[key], value];
241235
};
242236
}
243237
}
@@ -298,6 +292,12 @@ function getHash(url) {
298292
return hash;
299293
}
300294

295+
function getUrlWithoutQuery(url) {
296+
// Avoid `split('?')` so query-heavy URLs don't allocate large arrays.
297+
const queryStart = url.indexOf('?');
298+
return queryStart === -1 ? url : url.slice(0, queryStart);
299+
}
300+
301301
function parseValue(value, options, type) {
302302
if (type === 'string' && typeof value === 'string') {
303303
return value;
@@ -381,11 +381,21 @@ export function parse(query, options) {
381381
return returnValue;
382382
}
383383

384-
for (const parameter of query.split('&')) {
385-
if (parameter === '') {
384+
// Avoid `split('&')` so separator-heavy inputs don't allocate large arrays of empty strings.
385+
let parameterStart = 0;
386+
387+
for (let index = 0; index <= query.length; index++) {
388+
if (index < query.length && query[index] !== '&') {
386389
continue;
387390
}
388391

392+
if (index === parameterStart) {
393+
parameterStart = index + 1;
394+
continue;
395+
}
396+
397+
const parameter = query.slice(parameterStart, index);
398+
parameterStart = index + 1;
389399
const parameter_ = options.decode ? parameter.replaceAll('+', ' ') : parameter;
390400

391401
let [key, value] = splitOnFirst(parameter_, '=');
@@ -498,9 +508,9 @@ export function stringify(object, options) {
498508
).filter(item => item !== undefined);
499509
}
500510

501-
return processedArray
502-
.reduce(formatter(key), [])
503-
.join('&');
511+
const result = processedArray.reduce(formatter(key), []);
512+
const arrayFormatSeparator = ['comma', 'separator', 'bracket-separator'].includes(options.arrayFormat) ? options.arrayFormatSeparator : '&';
513+
return result.join(arrayFormatSeparator);
504514
}
505515

506516
return encode(key, options) + '=' + encode(value, options);
@@ -520,7 +530,7 @@ export function parseUrl(url, options) {
520530
}
521531

522532
return {
523-
url: url_?.split('?')?.[0] ?? '',
533+
url: getUrlWithoutQuery(url_ ?? ''),
524534
query: parse(extract(url), options),
525535
...(options && options.parseFragmentIdentifier && hash ? {fragmentIdentifier: decode(hash, options)} : {}),
526536
};
@@ -534,7 +544,7 @@ export function stringifyUrl(object, options) {
534544
...options,
535545
};
536546

537-
const url = removeHash(object.url).split('?')[0] || '';
547+
const url = getUrlWithoutQuery(removeHash(object.url)) || '';
538548
const queryFromUrl = extract(object.url);
539549

540550
const query = {
@@ -572,7 +582,10 @@ export function pick(input, filter, options) {
572582
}
573583

574584
export function exclude(input, filter, options) {
575-
const exclusionFilter = Array.isArray(filter) ? key => !filter.includes(key) : (key, value) => !filter(key, value);
585+
if (Array.isArray(filter)) {
586+
const filterSet = new Set(filter);
587+
return pick(input, key => !filterSet.has(key), options);
588+
}
576589

577-
return pick(input, exclusionFilter, options);
590+
return pick(input, (key, value) => !filter(key, value), options);
578591
}

test/exclude.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,25 @@ test('handles empty filter array', t => {
2424
t.is(queryString.exclude('http://example.com/?a=1&b=2&c=3', []), 'http://example.com/?a=1&b=2&c=3');
2525
});
2626

27+
test('handles large exclusion arrays without quadratic slowdown', t => {
28+
const count = 20_000;
29+
const query = Array.from({length: count}, (_, index) => `a${index}=1`).join('&');
30+
const url = `https://example.com/?${query}`;
31+
const filter = Array.from({length: count}, (_, index) => `b${index}`);
32+
const filterSet = new Set(filter);
33+
34+
const predicateStartTime = performance.now();
35+
queryString.exclude(url, key => filterSet.has(key));
36+
const predicateElapsedTime = performance.now() - predicateStartTime;
37+
38+
const arrayStartTime = performance.now();
39+
const result = queryString.exclude(url, filter);
40+
const arrayElapsedTime = performance.now() - arrayStartTime;
41+
42+
t.true(result.startsWith('https://example.com/?a0=1&a1=1'));
43+
t.true(arrayElapsedTime < (predicateElapsedTime * 5) + 50, `Expected exclusion array filtering to stay near the Set predicate baseline. Array: ${arrayElapsedTime}ms. Predicate: ${predicateElapsedTime}ms.`);
44+
});
45+
2746
test('handles excluding non-existent parameters', t => {
2847
t.is(queryString.exclude('http://example.com/?a=1&b=2', ['c', 'd']), 'http://example.com/?a=1&b=2');
2948
});

test/parse-url.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,17 @@ test('handles strings with query string that contain =', t => {
1919
t.deepEqual(queryString.parseUrl('https://foo.bar?foo=bar=&foo=baz='), {url: 'https://foo.bar', query: {foo: ['bar=', 'baz=']}});
2020
});
2121

22+
test('handles query-heavy URLs without splitting the whole URL', t => {
23+
const count = 10_000_000;
24+
const url = `https://foo.bar?${'?'.repeat(count)}`;
25+
const startTime = performance.now();
26+
const parsed = queryString.parseUrl(url, {sort: false});
27+
const elapsedTime = performance.now() - startTime;
28+
29+
t.is(parsed.url, 'https://foo.bar');
30+
t.true(elapsedTime < 120, `Expected URL parsing to avoid splitting the whole URL. Took ${elapsedTime}ms.`);
31+
});
32+
2233
test('handles strings with fragment identifier', t => {
2334
t.deepEqual(queryString.parseUrl('https://foo.bar?top=foo#bar', {parseFragmentIdentifier: true}), {url: 'https://foo.bar', query: {top: 'foo'}, fragmentIdentifier: 'bar'});
2435
t.deepEqual(queryString.parseUrl('https://foo.bar?foo=bar&foo=baz#top', {parseFragmentIdentifier: true}), {url: 'https://foo.bar', query: {foo: ['bar', 'baz']}, fragmentIdentifier: 'top'});

test/parse.js

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,79 @@ test('handle multiple of the same key', t => {
9494
t.deepEqual(queryString.parse('foo=bar&foo=baz'), {foo: ['bar', 'baz']});
9595
});
9696

97+
test('handles many duplicate keys without quadratic slowdown', t => {
98+
const count = 20_000;
99+
const duplicateQuery = Array.from({length: count}, () => 'a=1').join('&');
100+
const uniqueQuery = Array.from({length: count}, (_, index) => `a${index}=1`).join('&');
101+
102+
const uniqueStartTime = performance.now();
103+
queryString.parse(uniqueQuery);
104+
const uniqueElapsedTime = performance.now() - uniqueStartTime;
105+
106+
const duplicateStartTime = performance.now();
107+
const parsed = queryString.parse(duplicateQuery);
108+
const duplicateElapsedTime = performance.now() - duplicateStartTime;
109+
110+
t.is(parsed.a.length, count);
111+
t.true(parsed.a.every(value => value === '1'));
112+
t.true(duplicateElapsedTime < (uniqueElapsedTime * 20) + 100, `Expected duplicate-key parsing to stay near the unique-key baseline. Duplicate: ${duplicateElapsedTime}ms. Unique: ${uniqueElapsedTime}ms.`);
113+
});
114+
115+
test('handles many explicit array items without quadratic slowdown', t => {
116+
const count = 20_000;
117+
const cases = [
118+
{
119+
arrayFormat: 'bracket',
120+
duplicateQuery: Array.from({length: count}, () => 'a[]=1').join('&'),
121+
uniqueQuery: Array.from({length: count}, (_, index) => `a${index}[]=1`).join('&'),
122+
},
123+
{
124+
arrayFormat: 'colon-list-separator',
125+
duplicateQuery: Array.from({length: count}, () => 'a:list=1').join('&'),
126+
uniqueQuery: Array.from({length: count}, (_, index) => `a${index}:list=1`).join('&'),
127+
},
128+
{
129+
arrayFormat: 'bracket-separator',
130+
duplicateQuery: Array.from({length: count}, () => 'a[]=1').join('&'),
131+
uniqueQuery: Array.from({length: count}, (_, index) => `a${index}[]=1`).join('&'),
132+
},
133+
];
134+
135+
for (const {arrayFormat, duplicateQuery, uniqueQuery} of cases) {
136+
const uniqueStartTime = performance.now();
137+
queryString.parse(uniqueQuery, {arrayFormat});
138+
const uniqueElapsedTime = performance.now() - uniqueStartTime;
139+
140+
const duplicateStartTime = performance.now();
141+
const parsed = queryString.parse(duplicateQuery, {arrayFormat});
142+
const duplicateElapsedTime = performance.now() - duplicateStartTime;
143+
144+
t.is(parsed.a.length, count);
145+
t.true(parsed.a.every(value => value === '1'));
146+
t.true(duplicateElapsedTime < (uniqueElapsedTime * 10) + 100, `Expected ${arrayFormat} parsing to stay near the unique-key baseline. Duplicate: ${duplicateElapsedTime}ms. Unique: ${uniqueElapsedTime}ms.`);
147+
}
148+
});
149+
150+
test('handles large bracket-separator chunks without argument spread overflow', t => {
151+
const count = 200_000;
152+
const query = `a[]=0&a[]=${Array.from({length: count}, () => '1').join(',')}`;
153+
const parsed = queryString.parse(query, {arrayFormat: 'bracket-separator'});
154+
155+
t.is(parsed.a.length, count + 1);
156+
t.is(parsed.a[0], '0');
157+
t.is(parsed.a.at(-1), '1');
158+
});
159+
160+
test('handles many empty parameters without allocating split results', t => {
161+
const query = '&'.repeat(5_000_000);
162+
const startTime = performance.now();
163+
const parsed = queryString.parse(query, {sort: false});
164+
const elapsedTime = performance.now() - startTime;
165+
166+
t.deepEqual(parsed, {});
167+
t.true(elapsedTime < 50, `Expected empty parameter parsing to avoid split allocation. Took ${elapsedTime}ms.`);
168+
});
169+
97170
test('handle multiple values and preserve appearence order', t => {
98171
t.deepEqual(queryString.parse('a=value&a='), {a: ['value', '']});
99172
t.deepEqual(queryString.parse('a=&a=value'), {a: ['', 'value']});

0 commit comments

Comments
 (0)