[Perf] Composer: Avoid duplicated calculations for various instances of useEmojiTooltip

This commit is contained in:
Alexander Zinchuk 2021-12-10 18:33:20 +01:00
parent 6baa636ef7
commit 49c0794789
4 changed files with 113 additions and 76 deletions

View File

@ -22,8 +22,8 @@ declare namespace React {
type AnyLiteral = Record<string, any>;
type AnyClass = new (...args: any[]) => any;
type AnyFunction = (...args: any) => any;
type AnyToVoidFunction = (...args: any) => void;
type AnyFunction = (...args: any[]) => any;
type AnyToVoidFunction = (...args: any[]) => void;
type NoneToVoidFunction = () => void;
type EmojiCategory = {

View File

@ -1,5 +1,5 @@
import {
useCallback, useEffect, useMemo, useState,
useCallback, useEffect, useState,
} from '../../../../lib/teact/teact';
import { EDITABLE_INPUT_ID } from '../../../../config';
@ -12,8 +12,16 @@ import focusEditableElement from '../../../../util/focusEditableElement';
import {
buildCollectionByKey, flatten, mapValues, pickTruthy, unique,
} from '../../../../util/iteratees';
import memoized from '../../../../util/memoized';
import useFlag from '../../../../hooks/useFlag';
interface Library {
keywords: string[];
byKeyword: Record<string, Emoji[]>;
names: string[];
byName: Record<string, Emoji[]>;
}
let emojiDataPromise: Promise<EmojiModule>;
let emojiRawData: EmojiRawData;
let emojiData: EmojiData;
@ -22,6 +30,10 @@ let RE_EMOJI_SEARCH: RegExp;
const EMOJIS_LIMIT = 36;
const FILTER_MIN_LENGTH = 2;
const prepareRecentEmojisMemo = memoized(prepareRecentEmojis);
const prepareLibraryMemo = memoized(prepareLibrary);
const searchInLibraryMemo = memoized(searchInLibrary);
try {
RE_EMOJI_SEARCH = new RegExp('(^|\\s):[-+_:\\p{L}\\p{N}]*$', 'gui');
} catch (e) {
@ -40,27 +52,10 @@ export default function useEmojiTooltip(
isDisabled = false,
) {
const [isOpen, markIsOpen, unmarkIsOpen] = useFlag();
const [byId, setById] = useState<Record<string, Emoji> | undefined>();
const [keywords, setKeywords] = useState<string[]>();
const [byKeyword, setByKeyword] = useState<Record<string, Emoji[]>>({});
const [names, setNames] = useState<string[]>();
const [byName, setByName] = useState<Record<string, Emoji[]>>({});
const [shouldForceInsertEmoji, setShouldForceInsertEmoji] = useState(false);
const [filteredEmojis, setFilteredEmojis] = useState<Emoji[]>(MEMO_EMPTY_ARRAY);
const recentEmojis = useMemo(
() => {
if (!byId || !recentEmojiIds.length) {
return [];
}
return Object.values(pickTruthy(byId, recentEmojiIds));
},
[byId, recentEmojiIds],
);
// Initialize data on first render.
useEffect(() => {
if (isDisabled) return;
@ -77,44 +72,7 @@ export default function useEmojiTooltip(
}, [isDisabled]);
useEffect(() => {
if (!byId || isDisabled) {
return;
}
const emojis = Object.values(byId);
const byNative = buildCollectionByKey(emojis, 'native');
const baseEmojisByKeyword = baseEmojiKeywords
? mapValues(baseEmojiKeywords, (natives) => {
return Object.values(pickTruthy(byNative, natives));
})
: {};
const emojisByKeyword = emojiKeywords
? mapValues(emojiKeywords, (natives) => {
return Object.values(pickTruthy(byNative, natives));
})
: {};
setByKeyword({ ...baseEmojisByKeyword, ...emojisByKeyword });
setKeywords([...Object.keys(baseEmojisByKeyword), ...Object.keys(emojisByKeyword)]);
const emojisByName = emojis.reduce((result, emoji) => {
emoji.names.forEach((name) => {
if (!result[name]) {
result[name] = [];
}
result[name].push(emoji);
});
return result;
}, {} as Record<string, Emoji[]>);
setByName(emojisByName);
setNames(Object.keys(emojisByName));
}, [isDisabled, baseEmojiKeywords, byId, emojiKeywords]);
useEffect(() => {
if (!isAllowed || !html || !byId || !keywords || !keywords.length) {
if (!isAllowed || !html || !byId || isDisabled) {
unmarkIsOpen();
return;
}
@ -128,34 +86,28 @@ export default function useEmojiTooltip(
const forceSend = code.length > 2 && code.endsWith(':');
const filter = code.substr(1, forceSend ? code.length - 2 : undefined);
let matched: Emoji[] = [];
let matched: Emoji[] = MEMO_EMPTY_ARRAY;
setShouldForceInsertEmoji(forceSend);
if (!filter) {
matched = recentEmojis;
matched = prepareRecentEmojisMemo(byId, recentEmojiIds, EMOJIS_LIMIT);
} else if (filter.length >= FILTER_MIN_LENGTH) {
const matchedKeywords = keywords.filter((keyword) => keyword.startsWith(filter)).sort();
matched = matched.concat(flatten(Object.values(pickTruthy(byKeyword, matchedKeywords))));
// Also search by names, which is useful for non-English languages
const matchedNames = names.filter((name) => name.startsWith(filter));
matched = matched.concat(flatten(Object.values(pickTruthy(byName, matchedNames))));
matched = unique(matched);
const library = prepareLibraryMemo(byId, baseEmojiKeywords, emojiKeywords);
matched = searchInLibraryMemo(library, filter, EMOJIS_LIMIT);
}
if (matched.length) {
if (!forceSend) {
markIsOpen();
}
setFilteredEmojis(matched.slice(0, EMOJIS_LIMIT));
setFilteredEmojis(matched);
} else {
unmarkIsOpen();
}
}, [
byId, byKeyword, keywords, byName, names, html, isAllowed, markIsOpen,
recentEmojis, unmarkIsOpen, setShouldForceInsertEmoji,
byId, html, isAllowed, markIsOpen, recentEmojiIds, unmarkIsOpen, setShouldForceInsertEmoji,
isDisabled, baseEmojiKeywords, emojiKeywords,
]);
const insertEmoji = useCallback((textEmoji: string, isForce?: boolean) => {
@ -201,3 +153,74 @@ async function ensureEmojiData() {
return emojiDataPromise;
}
function prepareRecentEmojis(byId: Record<string, Emoji>, recentEmojiIds: string[], limit: number) {
if (!byId || !recentEmojiIds.length) {
return MEMO_EMPTY_ARRAY;
}
return Object.values(pickTruthy(byId, recentEmojiIds)).slice(0, limit);
}
function prepareLibrary(
byId: Record<string, Emoji>,
baseEmojiKeywords?: Record<string, string[]>,
emojiKeywords?: Record<string, string[]>,
): Library {
const emojis = Object.values(byId);
const byNative = buildCollectionByKey<Emoji>(emojis, 'native');
const baseEmojisByKeyword = baseEmojiKeywords
? mapValues(baseEmojiKeywords, (natives) => {
return Object.values(pickTruthy(byNative, natives));
})
: {};
const emojisByKeyword = emojiKeywords
? mapValues(emojiKeywords, (natives) => {
return Object.values(pickTruthy(byNative, natives));
})
: {};
const byKeyword = { ...baseEmojisByKeyword, ...emojisByKeyword };
const keywords = ([] as string[]).concat(Object.keys(baseEmojisByKeyword), Object.keys(emojisByKeyword));
const byName = emojis.reduce((result, emoji) => {
emoji.names.forEach((name) => {
if (!result[name]) {
result[name] = [];
}
result[name].push(emoji);
});
return result;
}, {} as Record<string, Emoji[]>);
const names = Object.keys(byName);
return {
byKeyword,
keywords,
byName,
names,
};
}
function searchInLibrary(library: Library, filter: string, limit: number) {
const {
byKeyword, keywords, byName, names,
} = library;
let matched: Emoji[] = MEMO_EMPTY_ARRAY;
const matchedKeywords = keywords.filter((keyword) => keyword.startsWith(filter)).sort();
matched = matched.concat(flatten(Object.values(pickTruthy(byKeyword!, matchedKeywords))));
// Also search by names, which is useful for non-English languages
const matchedNames = names.filter((name) => name.startsWith(filter));
matched = matched.concat(flatten(Object.values(pickTruthy(byName, matchedNames))));
matched = unique(matched);
return matched.slice(0, limit);
}

19
src/util/memoized.ts Normal file
View File

@ -0,0 +1,19 @@
import { areSortedArraysEqual } from './iteratees';
const cache = new WeakMap<AnyFunction, {
lastArgs: any[];
lastResult: any;
}>();
export default function memoized<T extends AnyFunction>(fn: T) {
return (...args: Parameters<T>): ReturnType<T> => {
const cached = cache.get(fn);
if (cached && areSortedArraysEqual(cached.lastArgs, args)) {
return cached.lastResult;
}
const result = fn(...args);
cache.set(fn, { lastArgs: args, lastResult: result });
return result;
};
}

View File

@ -16,14 +16,12 @@ export function debounce<F extends AnyToVoidFunction>(
clearTimeout(waitingTimeout);
waitingTimeout = undefined;
} else if (shouldRunFirst) {
// @ts-ignore
fn(...args);
}
// eslint-disable-next-line no-restricted-globals
waitingTimeout = self.setTimeout(() => {
if (shouldRunLast) {
// @ts-ignore
fn(...args);
}
@ -48,7 +46,6 @@ export function throttle<F extends AnyToVoidFunction>(
if (!interval) {
if (shouldRunFirst) {
isPending = false;
// @ts-ignore
fn(...args);
}
@ -62,7 +59,6 @@ export function throttle<F extends AnyToVoidFunction>(
}
isPending = false;
// @ts-ignore
fn(...args);
}, ms);
}
@ -97,7 +93,6 @@ export function throttleWith<F extends AnyToVoidFunction>(schedulerFn: Scheduler
schedulerFn(() => {
waiting = false;
// @ts-ignore
fn(...args);
});
}