Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve performance by reducing array slices and RegExp recreation #2128

Merged
merged 2 commits into from
Jan 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
31 changes: 22 additions & 9 deletions src/EventEmitter.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,21 @@
class EventEmitter {
constructor() {
// This is an Object containing Maps:
//
// { [event: string]: Map<listener: function, numTimesAdded: number> }
//
// We use a Map for O(1) insertion/deletion and because it can have functions as keys.
//
// We keep track of numTimesAdded (the number of times it was added) because if you attach the same listener twice,
// we should actually call it twice for each emitted event.
this.observers = {};
}

on(events, listener) {
events.split(' ').forEach((event) => {
this.observers[event] = this.observers[event] || [];
this.observers[event].push(listener);
if (!this.observers[event]) this.observers[event] = new Map();
const numListeners = this.observers[event].get(listener) || 0;
this.observers[event].set(listener, numListeners + 1);
});
return this;
}
Expand All @@ -18,21 +27,25 @@ class EventEmitter {
return;
}

this.observers[event] = this.observers[event].filter((l) => l !== listener);
this.observers[event].delete(listener);
}

emit(event, ...args) {
if (this.observers[event]) {
const cloned = [].concat(this.observers[event]);
cloned.forEach((observer) => {
observer(...args);
const cloned = Array.from(this.observers[event].entries());
cloned.forEach(([observer, numTimesAdded]) => {
for (let i = 0; i < numTimesAdded; i++) {
observer(...args);
}
});
}

if (this.observers['*']) {
const cloned = [].concat(this.observers['*']);
cloned.forEach((observer) => {
observer.apply(observer, [event, ...args]);
const cloned = Array.from(this.observers['*'].entries());
cloned.forEach(([observer, numTimesAdded]) => {
for (let i = 0; i < numTimesAdded; i++) {
observer.apply(observer, [event, ...args]);
}
});
}
}
Expand Down
24 changes: 16 additions & 8 deletions src/Interpolator.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,15 +70,23 @@ class Interpolator {
}

resetRegExp() {
// the regexp
const regexpStr = `${this.prefix}(.+?)${this.suffix}`;
this.regexp = new RegExp(regexpStr, 'g');

const regexpUnescapeStr = `${this.prefix}${this.unescapePrefix}(.+?)${this.unescapeSuffix}${this.suffix}`;
this.regexpUnescape = new RegExp(regexpUnescapeStr, 'g');
const getOrResetRegExp = (existingRegExp, pattern) => {
if (existingRegExp && existingRegExp.source === pattern) {
existingRegExp.lastIndex = 0;
return existingRegExp;
}
return new RegExp(pattern, 'g');
};

const nestingRegexpStr = `${this.nestingPrefix}(.+?)${this.nestingSuffix}`;
this.nestingRegexp = new RegExp(nestingRegexpStr, 'g');
this.regexp = getOrResetRegExp(this.regexp, `${this.prefix}(.+?)${this.suffix}`);
this.regexpUnescape = getOrResetRegExp(
this.regexpUnescape,
`${this.prefix}${this.unescapePrefix}(.+?)${this.unescapeSuffix}${this.suffix}`,
);
this.nestingRegexp = getOrResetRegExp(
this.nestingRegexp,
`${this.nestingPrefix}(.+?)${this.nestingSuffix}`,
);
}

interpolate(str, data, lng, options) {
Expand Down
17 changes: 12 additions & 5 deletions src/ResourceStore.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,20 @@ class ResourceStore extends EventEmitter {
? options.ignoreJSONStructure
: this.options.ignoreJSONStructure;

let path = [lng, ns];
if (key && typeof key !== 'string') path = path.concat(key);
if (key && typeof key === 'string')
path = path.concat(keySeparator ? key.split(keySeparator) : key);

let path;
if (lng.indexOf('.') > -1) {
path = lng.split('.');
} else {
path = [lng, ns];
if (key) {
if (Array.isArray(key)) {
path.push(...key);
} else if (typeof key === 'string' && keySeparator) {
path.push(...key.split(keySeparator));
} else {
path.push(key);
}
}
}

const result = utils.getPath(this.data, path);
Expand Down
99 changes: 72 additions & 27 deletions src/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,33 +26,40 @@ export function copy(a, s, t) {
});
}

// We extract out the RegExp definition to improve performance with React Native Android, which has poor RegExp
// initialization performance
const lastOfPathSeparatorRegExp = /###/g;

function getLastOfPath(object, path, Empty) {
function cleanKey(key) {
return key && key.indexOf('###') > -1 ? key.replace(/###/g, '.') : key;
return key && key.indexOf('###') > -1 ? key.replace(lastOfPathSeparatorRegExp, '.') : key;
}

function canNotTraverseDeeper() {
return !object || typeof object === 'string';
}

const stack = typeof path !== 'string' ? [].concat(path) : path.split('.');
while (stack.length > 1) {
const stack = typeof path !== 'string' ? path : path.split('.');
let stackIndex = 0;
// iterate through the stack, but leave the last item
while (stackIndex < stack.length - 1) {
if (canNotTraverseDeeper()) return {};

const key = cleanKey(stack.shift());
const key = cleanKey(stack[stackIndex]);
if (!object[key] && Empty) object[key] = new Empty();
// prevent prototype pollution
if (Object.prototype.hasOwnProperty.call(object, key)) {
object = object[key];
} else {
object = {};
}
++stackIndex;
}

if (canNotTraverseDeeper()) return {};
return {
obj: object,
k: cleanKey(stack.shift()),
k: cleanKey(stack[stackIndex]),
};
}

Expand Down Expand Up @@ -134,15 +141,50 @@ export function escape(data) {
return data;
}

/**
* This is a reusable regular expression cache class. Given a certain maximum number of regular expressions we're
* allowed to store in the cache, it provides a way to avoid recreating regular expression objects over and over.
* When it needs to evict something, it evicts the oldest one.
*/
class RegExpCache {
constructor(capacity) {
this.capacity = capacity;
this.regExpMap = new Map();
// Since our capacity tends to be fairly small, `.shift()` will be fairly quick despite being O(n). We just use a
// normal array to keep it simple.
this.regExpQueue = [];
}

getRegExp(pattern) {
const regExpFromCache = this.regExpMap.get(pattern);
if (regExpFromCache !== undefined) {
return regExpFromCache;
}
const regExpNew = new RegExp(pattern);
if (this.regExpQueue.length === this.capacity) {
this.regExpMap.delete(this.regExpQueue.shift());
}
this.regExpMap.set(pattern, regExpNew);
this.regExpQueue.push(pattern);
return regExpNew;
}
}

const chars = [' ', ',', '?', '!', ';'];
// We cache RegExps to improve performance with React Native Android, which has poor RegExp initialization performance.
// Capacity of 20 should be plenty, as nsSeparator/keySeparator don't tend to vary much across calls.
const looksLikeObjectPathRegExpCache = new RegExpCache(20);

export function looksLikeObjectPath(key, nsSeparator, keySeparator) {
nsSeparator = nsSeparator || '';
keySeparator = keySeparator || '';
const possibleChars = chars.filter(
(c) => nsSeparator.indexOf(c) < 0 && keySeparator.indexOf(c) < 0,
);
if (possibleChars.length === 0) return true;
const r = new RegExp(`(${possibleChars.map((c) => (c === '?' ? '\\?' : c)).join('|')})`);
const r = looksLikeObjectPathRegExpCache.getRegExp(
`(${possibleChars.map((c) => (c === '?' ? '\\?' : c)).join('|')})`,
);
let matched = !r.test(key);
if (!matched) {
const ki = key.indexOf(keySeparator);
Expand All @@ -153,36 +195,39 @@ export function looksLikeObjectPath(key, nsSeparator, keySeparator) {
return matched;
}

/**
* Given
*
* 1. a top level object obj, and
* 2. a path to a deeply nested string or object within it
*
* Find and return that deeply nested string or object. The caveat is that the keys of objects within the nesting chain
* may contain period characters. Therefore, we need to DFS and explore all possible keys at each step until we find the
* deeply nested string or object.
*/
export function deepFind(obj, path, keySeparator = '.') {
if (!obj) return undefined;
if (obj[path]) return obj[path];
const paths = path.split(keySeparator);
const tokens = path.split(keySeparator);
let current = obj;
for (let i = 0; i < paths.length; ++i) {
if (!current) return undefined;
if (typeof current[paths[i]] === 'string' && i + 1 < paths.length) {
for (let i = 0; i < tokens.length; ) {
if (!current || typeof current !== 'object') {
return undefined;
}
if (current[paths[i]] === undefined) {
let j = 2;
let p = paths.slice(i, i + j).join(keySeparator);
let mix = current[p];
while (mix === undefined && paths.length > i + j) {
j++;
p = paths.slice(i, i + j).join(keySeparator);
mix = current[p];
let next;
let nextPath = '';
for (let j = i; j < tokens.length; ++j) {
if (j !== i) {
nextPath += keySeparator;
}
if (mix === undefined) return undefined;
if (mix === null) return null;
if (path.endsWith(p)) {
if (typeof mix === 'string') return mix;
if (p && typeof mix[p] === 'string') return mix[p];
nextPath += tokens[j];
next = current[nextPath];
if (next !== undefined) {
i += j - i + 1;
break;
}
const joinedPath = paths.slice(i + j).join(keySeparator);
if (joinedPath) return deepFind(mix, joinedPath, keySeparator);
return undefined;
}
current = current[paths[i]];
current = next;
}
return current;
}
Expand Down
13 changes: 13 additions & 0 deletions test/runtime/eventEmitter.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,19 @@ describe('i18next', () => {
expect(disabledHandler).not.toHaveBeenCalled();
});

it('should emit twice if a handler was attached twice', () => {
const calls = [];
const listener = (payload) => {
calls.push(payload);
};

emitter.on('events', listener);
emitter.on('events', listener);
emitter.emit('events', 1);

expect(calls).toEqual([1, 1]);
});

it('it should emit wildcard', () => {
expect.assertions(2);

Expand Down
26 changes: 26 additions & 0 deletions test/runtime/utils.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,30 @@ describe('utils', () => {
expect(res).toEqual({ some: 'thing' });
});
});

describe('#deepFind', () => {
it('finds value for a basic path', () => {
const obj = { a: { b: { c: 1 } } };
const value = utils.deepFind(obj, 'a.b.c');
expect(value).toEqual(1);
});

it('finds no value for a non-existent path', () => {
const obj = { a: { b: { c: 1 } } };
const value = utils.deepFind(obj, 'a.b.d');
expect(value).toEqual(undefined);
});

it('finds value for a key that has a dot', () => {
const obj = { a: { 'b.b': { c: 1 } } };
const value = utils.deepFind(obj, 'a.b.b.c');
expect(value).toEqual(1);
});

it('finds value for an array index', () => {
const obj = { a: [{ c: 1 }] };
const value = utils.deepFind(obj, 'a.0.c');
expect(value).toEqual(1);
});
});
});