Skip to content

Commit

Permalink
Improve performance by reducing array slices and RegExp recreation
Browse files Browse the repository at this point in the history
  • Loading branch information
hsource committed Jan 28, 2024
1 parent cf6b91f commit 7850e56
Show file tree
Hide file tree
Showing 6 changed files with 160 additions and 51 deletions.
32 changes: 21 additions & 11 deletions src/EventEmitter.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
class EventEmitter {
constructor() {
// This is a nested 2-level Map:
// { [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,22 +26,24 @@ 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);
});
for (const [observer, numTimesAdded] of this.observers[event].entries()) {
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]);
});
for (const [observer, numTimesAdded] of this.observers['*'].entries()) {
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);
});
});
});

0 comments on commit 7850e56

Please sign in to comment.