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 = (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 = (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 = (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 = (arr: T[]): T[] =>
arr.slice().sort(() => Math.random() - 0.5);
Более правильный вариант — Тасование Фишера-Йетса:
const shuffle = (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 {
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 = (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 = (
arr: T[],
// геттер ключей
getKey: (i: T) => Key,
// геттер значений, по умолчанию возвращающий элемент массива - объект
getValue: (i: T) => Value = (i) => i as unknown as Value
): Record =>
arr.reduce(
// возвращается объект
(acc, item) => ({
...acc,
// динамическое свойство
[getKey(item)]: getValue(item),
}),
{} as Record
);
Пример использования:
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 = (
obj: Record,
// функция-преобразователь
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 = (
// обработчик, вызываемый при доступе к свойству
handler: (prop: T) => K
): Record =>
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 = (...args: TArgs[]) => KReturn;
// тип кеша - объект со свойствами `exp` и `value`
type Cache = Record;
// функция кеширования
const memoize = (
// кеш - объект
cache: Cache,
// кешируемая функция
fn: Func,
// геттер ключа для доступа к кешу
keyFunc: Func | 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 = (
// кешируемая функция
fn: TFunc,
// настройки
{
// геттер ключа для доступа к кешу
key = null,
// время жизни кеша
ttl = 300,
}: {
key?: Func | 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;
};
Пример использования:
0
// получаем ссылку на параграф
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 extends (...args: infer U) => any ? U : never;
// тип результата выполнения промиса
type UnwrapPromisify = T extends Promise ? U : T;
// функция возвращает [ ошибка, результат ]
// ошибка и результат могут иметь значение `null`
export const tryit = any>(
fn: TFunction
) => {
return async (
...args: ArgumentsType
): Promise<[Error | null, UnwrapPromisify> | 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 (
// выполняемая операция - промис
fn: (exit: (err: any) => void) => Promise,
options: {
// количество попыток
times?: number;
// задержка между попытками
delay?: number | null;
// экспоненциальная задержка
backoff?: (count: number) => number;
}
): Promise => {
// по умолчанию предпринимается 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 = {
index: number;
result: K;
error: any;
};
// класс кастомной ошибки
class AggregateError extends Error {
errors: Error[];
constructor(errors: Error[]) {
super();
this.errors = errors;
}
}
// вспомогательная функция сортировки
const sort = (
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 = (
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 (
// количество одновременно выполняемых асинхронных операций
limit: number,
// массив параметров для операции
arr: T[],
// операция
fn: (item: T) => Promise
): Promise => {
// преобразуем массив параметров в массив объектов
const work = arr.map((item, index) => ({
index,
item,
}));
// обрабатываем этот массив
const processor = async (res: (value: WorkItemResult[]) => void) => {
// массив результатов
const results: WorkItemResult[] = [];
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[][];
// сортируем массив результатов по индексам
// и делим его по наличию ошибок
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!