Skip to main content

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

· 16 min read

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

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

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

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

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

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

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

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

const randomInt = (min: number, max: number) => Math.floor(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!