TypeScript: разбираем исходный код Radash

от автора

Привет, друзья!

Radash — это современная альтернатива Lodash, библиотека, предоставляющая набор часто используемых утилит (вспомогательных функций), реализованных на TypeScript. В данной статье мы вместе с вами разберем исходный код нескольких наиболее интересных утилит.

Репозиторий с кодом библиотеки находится здесь.

Обратите внимание: я позволил себе немного модифицировать отдельные утилиты для повышения читаемости и сокращения шаблонного кода. Также в нескольких местах пришлось поправить типы.

Для тех, кому интересно, вот большая коллекция сниппетов JavaScript.

Начнем с чего-нибудь попроще.

Генерации и извлечение произвольных значений

Функция для генерации произвольного целого числа в заданном диапазоне

const randomInt = (min: number, max: number) => ~~(Math.random() * (max - min + 1) + min);

Пример использования:

const randomInt = randomInt(1, 10); console.log(randomInt); // 6

Классика.

Функция для извлечения произвольного элемента из массива

const draw = <T>(arr: T[]): T | null => {   // длина массива   const len = arr.length;   // если массив является пустым   if (len === 0) {     return null;   }   // генерируем произвольное целое число от первого до последнего индекса массива   const i = random(0, len - 1);   // возвращаем произвольный элемент   return arr[i]; };

Пример использования:

const arr = [1, 2, 3, 4, 5]; const randomItem = draw(arr); console.log(randomItem); // 4

Если требуется возвращать только уникальные элементы, можно мутировать исходный массив следующим образом:

const draw = <T>(arr: T[], mutate?: boolean): T | null => {   const len = arr.length;   if (len === 0) {     return null;   }   const i = random(0, len - 1);   // метод `splice` мутирует исходный массив и возвращает массив извлеченных элементов   return mutate ? arr.splice(i, 1)[0] : arr[i]; };

Пример использования:

const arr = [1, 2, 3, 4, 5]; const randomItems = []; while (arr.length) {   const randomItem = draw(arr, true);   randomItems.push(randomItem); } // получается своего рода перемешивание элементов исходного массива console.log(randomItems); // [2, 5, 1, 4, 3]

Это приводит нас к следующей функции.

Функция для перемешивания элементов массива

export const shuffle = <T>(arr: T[]): T[] => {   return arr     // преобразуем исходный массив в массив объектов со свойствами `random` и `value`     .map((a) => ({ random: Math.random(), value: a }))     // сортируем массив по полю `random`     .sort((a, b) => a.random - b.random)     // возвращаем оригинальные значения     .map((a) => a.value); };

Пример использования:

const arr = [1, 2, 3, 4, 5]; const randomItems = shuffle(arr); console.log(randomItems); // [4, 2, 5, 1, 3]

Простейшая, но не очень правильная реализация такой функции выглядит следующим образом:

const shuffle = <T>(arr: T[]): T[] =>   arr.slice().sort(() => Math.random() - 0.5);

Более правильный вариант — Тасование Фишера-Йетса:

const shuffle = <T>(arr: T[]): T[] => {   let len = arr.length;   while (len) {     const i = ~~(Math.random() * len--);     [arr[len], arr[i]] = [arr[i], arr[len]];   }   return arr; };

Функция для генерации произвольной строки заданной длины

export const uid = (length: number, symbols: string = "") => {   // символы, используемые для генерации строки   const chars =     "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" + symbols;   // результат   let _uid = "";   for (let i = 1; i <= length; i++) {     // извлекаем случайный символ     const i = random(0, chars.length - 1);     // и добавляем его к результату     _uid += characters[i];   }   // возвращаем результат   return _uid; };

Пример использования:

const randomStr = uid(10); console.log(randomStr); // xQZc1hzSqa

Простейшая реализация такой функции выглядит следующим образом:

// 10-11 символов // преобразуем число в строку и удаляем первые 2 символа - `0.` const uid = () => Math.random().toString(36).slice(2);

Обратите внимание: если значения, генерируемые такими функциями, планируется использовать в качестве идентификаторов DOM-элементов, то следует помнить, что id элемента не может начинаться с числа. Возможно, это как-то связано с тем, что такие элементы становятся свойствами глобального объекта window. Для решения данной задачи достаточно заменить первое число в строке на какую-нибудь букву, например, x:

// заменяем первое число буквой `x` const uid = () => Math.random().toString(36).slice(2).replace(/\d/, "x");

Двигаемся дальше.

Работа с массивами и объектами

Функция-генератор для формирования диапазона целых чисел

function* range(   // начало диапазона   start: number,   //  конец диапазона   end: number,   // шаг   step: number = 1 ): Generator<number> {   for (let i = start; i <= end; i += step) {     yield i;     // останавливаем генератор, если текущее значение плюс шаг больше конца диапазона     if (i + step > end) break;   } }

Пример использования:

const numsRange = range(1, 10, 2); console.log(numsRange.next().value);  // 1 console.log(numsRange.next().value);  // 3 console.log(...numsRange);            // 5 7 9

Функция для генерации массива с диапазоном целых чисел

// функция возвращает массив из генератора const list = (start: number, end: number, step: number = 1): number[] =>   Array.from(range(start, end, step));

Пример использования:

const arrWithNumsRange = list(1, 10, 2); console.log(arrWithNumsRange); // [1, 3, 5, 7, 9]

Для генерации массива с числами двойной точности можно воспользоваться следующей функцией:

const list = (   start: number = 0,   stop: number = 1,   step: number = 0.1,   // точность округления   precision: number = 1 ) =>   Array.from({ length: (stop - start) / step + 1 }, (_, i) =>     // метод `toFixed` возвращает строку     // конвертируем ее в число     Number((start + i * step).toFixed(precision))   );

Пример использования:

const arrWithNumsRange = list(); console.log(arrWithNumsRange); // [0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1]

Функция для разделение массива на части

const chunk = <T>(arr: T[], size: number = 2): T[][] => {   // определяем количество частей посредством деления длины массива   // на указанный размер с округлением в большую сторону   const chunks = Math.ceil(arr.length / size);   // создаем новый массив с длиной, равной количеству частей   // перебираем его элементы и возвращаем копии частей исходного массива указанного размера   return Array.from({ length: chunks }, (_, i) =>     arr.slice(i * size, i * size + size)   ); };

Пример использования:

const arr = [1, 2, 3, 4, 5]; const chunked = chunk(arr, 2); console.log(chunked); // [ [1, 2], [3, 4], [5] ]

Функция для преобразования массива в объект

const objectify = <T, Key extends string | number | symbol, Value = T>(   arr: T[],   // геттер ключей   getKey: (i: T) => Key,   // геттер значений, по умолчанию возвращающий элемент массива - объект   getValue: (i: T) => Value = (i) => i as unknown as Value ): Record<Key, Value> =>   arr.reduce(     // возвращается объект     (acc, item) => ({       ...acc,       // динамическое свойство       [getKey(item)]: getValue(item),     }),     {} as Record<Key, Value>   );

Пример использования:

const usersArr = [   { name: "Alice", age: 23 },   { name: "Bob", age: 32 }, ]; // геттер ключей const usersObj = objectify(usersArr, (u) => u.name); console.log(usersObj); /*   {     Alice: { name: 'Alice', age: 23 },     Bob: { name: 'Bob', age: 32 }   }  */  // геттеры ключей и значений const usersObj2 = objectify(   usersArr,   (u) => u.name,   (u) => u.age ); console.log(usersObj2); // { Alice: 23, Bob: 32 }

Функция для преобразования объекта в массив

const listify = <TVal, TKey extends string | number | symbol, KRes>(   obj: Record<TKey, TVal>,   // функция-преобразователь   toItem: (key: TKey, val: TVal) => KRes ) => {   const entries = Object.entries(obj);    if (entries.length === 0) return [];    return entries.reduce((acc, entry) => {     return [...acc, toItem(entry[0] as TKey, entry[1] as TVal)];   }, [] as KRes[]); };

Пример использования:

const usersObj = {   alice: {     age: 23,   },   bob: {     age: 32,   }, }; // `key` - ключ/имя пользователя в нижнем регистре // `val` - объект пользователя: `{ age: number }` const usersArr = listify(usersObj, (key, val) => ({   // разворачиваем объект   ...val,   // "капитализируем" имя   name: key[0].toUpperCase() + key.slice(1), })); console.log(usersArr); /*   [     { age: 23, name: "Alice" },     { age: 32, name: "Bob" }   ] */

Теперь кое-что более интересное.

Работа с функциями

Частичное применение функции

const partial =   // (функция, основные параметры)   (fn: Function, ...args: any[]) =>   // (дополнительные параметры)   (...rest: any[]) =>     fn(...args, ...rest);

Пример использования:

// скидка const discount = 0.1; // функция для получение цены со скидкой, равной 10% стоимости товара const getPriceWithDiscount = partial(   // функция для получения цены со скидкой   (d: number, p: number) => p - p * d,   // скидка   discount ); // цена const price = 100; // цена со скидкой const priceWithDiscount = getPriceWithDiscount(price); console.log(priceWithDiscount); // 90

Функция для проксирования свойств объекта

Данная функция позволяет выполнять определенные операции при доступе к свойству объекта (реализовано с помощью объекта Proxy):

export const proxied = <T, K>(   // обработчик, вызываемый при доступе к свойству   handler: (prop: T) => K ): Record<string, K> =>   new Proxy(     {},     {       get: (_, prop: any) => handler(prop),     }   );

Пример использования:

const person = {   firstName: "Harry",   lastName: "Heman", };  const proxiedPerson = proxied((prop: keyof typeof person) =>   // переводим значение свойства в верхний регистр   person[prop].toUpperCase() );  console.log(proxiedPerson.firstName); // HARRY

Функция мемоизации

Данная функция позволяет мемоизировать (memoize) результаты вызова другой функции:

// тип функции, передаваемой в качестве параметра функции мемоизации type Func<TArgs = any, KReturn = any | void> = (...args: TArgs[]) => KReturn; // тип кеша - объект со свойствами `exp` и `value` type Cache<T> = Record<string, { exp: number; value: T }>;  // функция кеширования const memoize = <T>(   // кеш - объект   cache: Cache<T>,   // кешируемая функция   fn: Func<any, T>,   // геттер ключа для доступа к кешу   keyFunc: Func<string> | null,   // время жизни кеша - срок, в течение которого кеш считается валидным   ttl: number ) => {   return function callWithMemo(...args: any): T {     // ключ для доступа к кешу     const key = keyFunc ? keyFunc(...args) : JSON.stringify({ args });     // имеется ли значения в кеше?     const existing = cache[key];     // если имеется     if (existing !== undefined) {       // и время жизни кеша не истекло       if (existing.exp > new Date().getTime()) {         // возвращаем значение         return existing.value;       }     }     // вычисляем значение     const result = fn(...args);     // записываем его в кеш     cache[key] = {       exp: new Date().getTime() + ttl,       value: result,     };     // возвращаем значение     return result;   }; };  const memo = <TFunc extends Function>(   // кешируемая функция   fn: TFunc,   // настройки   {     // геттер ключа для доступа к кешу     key = null,     // время жизни кеша     ttl = 300,   }: {     key?: Func<any, string> | null;     ttl?: number;   } = {} ) => memoize({}, fn as any, key, ttl) as any as TFunc;

Пример использования:

const factorial = (n: number): number => (n <= 1 ? 1 : n * factorial(n - 1)); const memoizedFactorial = memo(factorial);  console.time("t1"); // первый вызов мемоизированной функции - значение вычисляется memoizedFactorial(150); console.timeEnd("t1"); // 0.10...  console.time("t2"); // второй вызов мемоизированной функции с тем же аргументом - значение доставляется из кеша memoizedFactorial(150); console.timeEnd("t2"); // 0.01...

Дебаунсинг и троттлинг

Простыми словами: дебаунсинг (debouncing) — это когда функция выполняется один раз по истечении указанного времени с момента последнего вызова, независимо от количества ее вызовов, а троттлинг (throttling) — это когда в течение определенного времени функция выполняется только один раз, несмотря на количество ее вызовов (обычно функция выполняется в начале указанного периода).

Начнем с дебаунсинга:

// функция принимает коллбек, вызываемый по истечении указанного времени, // и задержку в мс export const debounce = (fn: Function, ms: number) => {   let timer: any = null;   const debounced = (...args: any[]) => {     // очищаем таймер при каждом вызове функции     clearTimeout(timer);     timer = setTimeout(() => {       // выполняем коллбек       fn(...args);       // очищаем таймер       clearTimeout(timer);     }, ms);   };   return debounced; };

Пример использования:

<p id="par">0</p> <button id="btn">click</button>

// получаем ссылку на параграф const par = document.getElementById("par"); // счетчик let clicks = 0; // обработчик клика const onClick = () => {   // увеличиваем значение счетчика   clicks += 1;   // выводим значение счетчика в качестве текста параграфа   (par as HTMLParagraphElement).textContent = clicks.toString(); }; // дебаунсинг const debouncedOnClick = debounce(onClick, 1000); // получаем ссылку на кнопку const btn = document.getElementById("btn"); // регистрируем обработчик (btn as HTMLButtonElement).onclick = throttledOnClick;

Сколько бы раз мы не нажали кнопку, значение счетчика увеличится только на 1 по истечении 1 сек с момента последнего нажатия. Как правило, дебаунсинг применяется в отношении обработчиков таких событий, как scroll и mousemove (или touchmove).

Троттлинг:

// функция принимает коллбек, вызываемый один раз в течение указанного  времени, // и интервал в мс export const throttle = (fn: Function, ms: number) => {   // индикатор готовности   let ready = true;   const throttled = (...args: any[]) => {     // если индикатор готовности имеет значение `false`     if (!ready) return;     // выполняем коллбек     fn(...args);     // обновляем индикатор     ready = false;     const timer = setTimeout(() => {       // обновляем индикатор по истечении указанного времени       ready = true;       // очищаем таймер       clearTimeout(timer);     }, ms);   };   return throttled; };

Пример использования:

// перепишем последний пример const throttledOnClick = throttle(onClick, 1000);  const btn = document.getElementById("btn");  (btn as HTMLButtonElement).onclick = throttledOnClick;

Теперь сколько бы раз мы не нажимали кнопку, значение счетчика будет увеличиваться на 1 не чаще одного раза в сек. Троттлинг может применяться в отношении обработчиков таких событий, как keydown или mousedown.

Напоследок самое интересное.

Работа с асинхронными функциями

Функция для выполнения асинхронной функции

// тип аргументов type ArgumentsType<T> = T extends (...args: infer U) => any ? U : never; // тип результата выполнения промиса type UnwrapPromisify<T> = T extends Promise<infer U> ? U : T;  // функция возвращает [ ошибка, результат ] // ошибка и результат могут иметь значение `null` export const tryit = <TFunction extends (...args: any) => any>(   fn: TFunction ) => {   return async (     ...args: ArgumentsType<TFunction>   ): Promise<[Error | null, UnwrapPromisify<ReturnType<TFunction>> | null]> => {     try {       return [null, await fn(...(args as any))];     } catch (err) {       return [err as any, null];     }   }; };

Пример использования:

const getUsers = tryit(() =>   fetch("https://jsonplaceholder.typicode.com/users?_limit=2").then((r) =>     r.json()   ) ); const [error, users] = await getUsers(); console.log(error); // null console.log(users); // [ [user], [user] ]  // ошибка в урле const getUsers2 = tryit(() =>   fetch("https://jsonplaceholder.typicod.com/users?_limit=2").then((r) =>     r.json()   ) ); const [error2, users2] = await getUsers2(); console.log(error2?.message); // Failed to fetch console.log(users2); // null

Функция для повторного выполнения асинхронной операции

Данная функция позволяет предпринимать несколько попыток выполнения асинхронной операции:

export const retry = async <TResponse>(   // выполняемая операция - промис   fn: (exit: (err: any) => void) => Promise<TResponse>,   options: {     // количество попыток     times?: number;     // задержка между попытками     delay?: number | null;     // экспоненциальная задержка     backoff?: (count: number) => number;   } ): Promise<TResponse | void> => {   // по умолчанию предпринимается 3 попытки   const times = options?.times ?? 3;   const delay = options?.delay;   const backoff = options?.backoff ?? null;    for (const i of list(1, times)) {     const [err, result] = (await tryit(fn)((err: any) => {       throw { _exited: err };     })) as [any, TResponse];     // если ошибки нет, возвращаем результат     if (!err) return result;     // если возникла ошибка, выбрасываем ее     if (err._exited) throw err._exited;     // если количество попыток исчерпано, выбрасываем исключение     if (i === times) throw err;     // задержка между попытками     if (delay) await sleep(delay);     // экспоненциальная задержка     if (backoff) await sleep(backoff(i));   } };

Пример использования:

// ошибка в урле const getUsers = () =>   fetch("https://jsonplaceholder.typicod.com/users?_limit=2").then((r) =>     r.json()   );  await retry(getUsers, { delay: 1000 }); // после 3 попыток с задержкой в 1 сек выбрасывается исключение `Uncaught TypeError: Failed to fetch`

Функция для одновременного выполнения нескольких асинхронных операций

Данная функция позволяет выполнять несколько асинхронных операций за один раз (реализовано с помощью Promise.all()):

// тип результата выполнения асинхронной операции type WorkItemResult<K> = {   index: number;   result: K;   error: any; }; // класс кастомной ошибки class AggregateError extends Error {   errors: Error[];   constructor(errors: Error[]) {     super();     this.errors = errors;   } }  // вспомогательная функция сортировки const sort = <T>(   arr: T[],   getter: (item: T) => number,   desc = false ) => {   if (!arr) return [];   const asc = (a: T, b: T) => getter(a) - getter(b);   const dsc = (a: T, b: T) => getter(b) - getter(a);   return arr.slice().sort(desc === true ? dsc : asc); }; // вспомогательная функция разделения массива пополам // в зависимости от логического значения, возвращаемого переданной функцией `condition` const fork = <T>(   arr: T[],   condition: (item: T) => boolean ): [T[], T[]] => {   if (!arr) return [[], []];   return arr.reduce(     (acc, item) => {       const [a, b] = acc;       if (condition(item)) {         return [[...a, item], b];       } else {         return [a, [...b, item]];       }     },     [[], []] as [T[], T[]]   ); };  // основная функция const parallel = async <T, K>(   // количество одновременно выполняемых асинхронных операций   limit: number,   // массив параметров для операции   arr: T[],   // операция   fn: (item: T) => Promise<K> ): Promise<K[]> => {   // преобразуем массив параметров в массив объектов   const work = arr.map((item, index) => ({     index,     item,   }));   // обрабатываем этот массив   const processor = async (res: (value: WorkItemResult<K>[]) => void) => {     // массив результатов     const results: WorkItemResult<K>[] = [];      while (true) {       // берем последний элемент массива - метод `pop` мутирует исходный массив       const next = work.pop();        // если элементы кончились, возвращаем результат       if (!next) return res(results);        // выполняем операцию, получаем результаты       const [error, result] = await tryit(fn)(next.item);        // помещаем результаты в массив       results.push({         error,         result: result as K,         index: next.index,       });     }   };    // создаем очередь   const queues = list(1, limit).map(() => new Promise(processor));    // ждем завершения всех операций   const itemResults = (await Promise.all(queues)) as WorkItemResult<K>[][];    // сортируем массив результатов по индексам   // и делим его по наличию ошибок   const [errors, results] = fork(     sort(itemResults.flat(), (r) => r.index),     (x) => !!x.error   );    // если имеются ошибки   if (errors.length > 0) {     // выбрасываем кастомное исключение     throw new AggregateError(errors.map((error) => error.error));   }    // иначе возвращаем массив результатов   return results.map((r) => r.result); };

Пример использования:

// массив путей const urls = [   "https://jsonplaceholder.typicode.com/users/1",   "https://jsonplaceholder.typicode.com/posts/1",   "https://jsonplaceholder.typicode.com/todos/1", ]; // функция для отправки запроса по указанному урлу const fetcher = (url: string) => fetch(url).then((r) => r.json()); // данные const data = await parallel(3, urls, async (url) => await fetcher(url)); console.log(data); // [ [user], [post], [todo] ]  const urls2 = [   "https://jsonplaceholder.typicode.com/users/1",   // ошибка в урле   "https://jsonplaceholder.typicod.com/posts/1",   "https://jsonplaceholder.typicode.com/todos/1", ]; const [err, data2] = await tryit(parallel)(   3,   urls2,   // не хватает типа   async (url) => await fetcher(url as string) ); console.log(data2); // null // не хватает типа console.log((err as AggregateError).errors); // [TypeError: Failed to fetch...] console.log((err as AggregateError).errors[0].message); // Failed to fetch

Пожалуй, это все, чем я хотел поделиться с вами в этой статье. Мы рассмотрели примерно половину утилит, предоставляемых Radash, остальные функции показались мне не такими интересными. Надеюсь, вы нашли для себя что-то полезное и не зря потратили время.

Благодарю за внимание и happy coding!



ссылка на оригинал статьи https://habr.com/ru/company/timeweb/blog/686824/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *