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

umfet_kngorlggfmgokzowwtsuu.png


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

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!


p-u9l27ynelxi92bcmdxhu76ma8.png

© Habrahabr.ru