JavaScript: 少し発展的なユーティリティ関数

JavaScript: 少し発展的なユーティリティ関数

April 21, 2023

良い記事を見かけたので自分なりの言葉で補足しながらまとめます。

Debounce #

元の英単語は「跳ね返り(バウンド)を抑える」という意味です。

具体的には以下の動きをします。

  1. 時間を指定して処理 A の実行予約をする。指定時間後に実行される。
  2. 指定時間を経過して処理 A が実行されるまでの間に、再び処理 A の実行予約が行われた場合、前回の予約を解除し、新たに予約を作成する。

こうすることで、処理 A が短時間に連続して実行された場合のパフォーマンス向上が期待できます。

/**
 * Debounce 関数
 */
function debounce(func, delay) {
  let timeout;
  return function () {
    const context = this;
    const args = arguments;
    clearTimeout(timeout);
    timeout = setTimeout(() => func.apply(context, args), delay);
  };
}

/**
 * 👇 使用例
 */

function updateLayout() {
  // 何かしらの処理
}

// Debounce 化した関数を作成
const debouncedUpdateLayout = debounce(updateLayout, 250);

// ウィンドウのリサイズに合わせて実行する処理
// Debounce 化されているので、連続実行してもウィンドウのリサイズ操作が止まったあとに1回だけ実行される
window.addEventListener('resize', debouncedUpdateLayout);

Throttle #

元の英単語は「流れを絞る、止める」という意味です。

Debounce と似ていますが微妙に異なります。Debounce は「実行予約されている処理」の数を抑制する働きをしましたが、Throttle は処理の「実行中の処理」の数を抑制します。

具体的には以下の動きをします。

  1. 処理 A を実行する。このとき抑制するため時間を指定する。
  2. 指定した時間が経過するまでの間に再び処理 A が実行されようとした場合、その処理は無視される。
/**
 * Throttle 関数
 */
function throttle(func, delay) {
  let wait = false;
  return (...args) => {
    if (wait) {
      return;
    }
    func(...args);
    wait = true;
    setTimeout(() => {
      wait = false;
    }, delay);
  };
}

/**
 * 👇 使用例
 */

function updateLayout() {
  // 何かしらの処理
}

// Throttle 化した関数を作成
const throttledUpdateLayout = throttle(updateLayout, 250);

// ウィンドウのスクロールに合わせて実行する処理
// Throttle 化されているので、連続実行しても指定時間が経過するまでは2回目以降の呼び出しは無視される
window.addEventListener('scroll', throttledUpdateLayout);

Once #

1回だけ実行することを約束するものです。

/**
 * Once 関数
 */
function once(func) {
  let ran = false;
  let result;
  return function () {
    if (ran) return result;
    result = func.apply(this, arguments);
    ran = true;
    return result;
  };
}

/**
 * 👇 使用例
 */

function requestSomeData() {
  // ネットワークにリクエストを送る処理
}

// Once 化した関数を作成
const sendRequestOnce = once(sendRequest);

// ボタンのクリックに合わせて実行する処理
// Once 化されているので、複数回してクリックされても1回しか実行されない
const button = document.querySelector('button');
button.addEventListener('click', sendRequestOnce);

Memoize #

キャッシュのように実行結果を保存しておき、以前に実行したことのあるものと同様の処理が実行されようとしたときは、再計算を行わずに、以前の処理済み結果を返すというものです。

/**
 * Memoize 関数
 */
function memoize(func) {
  const cache = new Map();
  return function () {
    const key = JSON.stringify(arguments);
    if (cache.has(key)) {
      return cache.get(key);
    }
    const result = func.apply(this, arguments);
    cache.set(key, result);
    return result;
  };
}

/**
 * 👇 使用例
 */

// 何か時間のかかる処理を行うもの(ここではフィボナッチ数列の算出)
function fibonacci(n) {
  if (n < 2) return 1;
  return fibonacci(n - 1) + fibonacci(n - 2);
}

// Memoize 化した関数を作成
const memoizedFibonacci = memoize(fibonacci);

// 実行する
console.time('total');
console.time('sub1');
const result1 = memoizedFibonacci(30);
console.timeEnd('sub1');
console.time('sub2');
const result2 = memoizedFibonacci(29);
console.timeEnd('sub2');
console.time('sub3');
const result3 = memoizedFibonacci(30);
console.timeEnd('sub3');
console.timeEnd('total');

// --- 結果 ---
// sub1: 18.521728515625 ms
// sub2: 7.33203125 ms
// sub3: 0.006103515625 ms 👈 Memoize 化された結果が使われているので速い
// total: 26.19091796875 ms

Curry #

関数をカリー化するものです。

// カリー化する前
calculate(100, 200, 300, 400, 500);

// カリー化した後
curriedCalculate(100)(200)(300)(400)(500);

カリー化前後の例が上記です。カリー化とは、複数の引数をとる関数を、1つの引数をとる関数の連続した呼び出しに置き換えることを意味します。

🚨 コードを元記事から少し変えています

/**
 * Curry 関数
 *
 * 補足:
 * - アリティは関数が取る引数の数を意味します
 * - func.length で func が受け取る引数の数が取得できます -> https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function/length
 */
function curry(func) {
  const arity = func.length;
  return function curried(...args) {
    if (args.length >= arity) return func(...args);
    return function (...moreArgs) {
      return curried(...args, ...moreArgs);
    };
  };
}

/**
 * 👇 使用例
 */

// 2地点の距離を計算する関数
function distance(x1, y1, x2, y2) {
  return Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2);
}

// Curry 化した関数を作成
const curriedDistance = curry(distance);

// 1地点の情報(0, 0)を確定させる
// カリー化した関数に引数の一部分を渡して、新たな関数を生成することを部分適用といいます。
const distanceFromOrigin = curriedDistance(0, 0);

// 適用済みの地点からの距離をそれぞれ求める
const d1 = distanceFromOrigin(1, 1);
const d2 = distanceFromOrigin(2, 2);

Partial #

部分適用をする関数です。先ほどの Curry 関数をシンプルに使えるようにしたもののようです。

(個人的は先ほどの Curry 関数だけあれば十分かと思います。)

/**
 * Partial 関数
 */
function partial(func, ...args) {
  return function partiallyApplied(...moreArgs) {
    return func(...args, ...moreArgs);
  };
}

/**
 * 👇 使用例
 */

// 何かしらの計算をする関数
function calculate(x, y, z) {
  return (x + y) * z;
}

// Partial 化した関数を作成
const multiply10By = partial(calculate, 8, 2);

// 部分適用した関数に残りの引数を渡して処理を実行する
const result = multiply10By(5);

さきほどの Curry 関数で実現するならば以下になります。

const multiply10By = curry(calculate)(8, 2);
const result = multiply10By(5);

Pipe #

関数を繋げる関数です。関数の実行結果を次の関数に渡し、順列に実行していきます。

UNIX の パイプ("|")と同じ働きです。ls -l | grep key | less

/**
 * Pipe 関数
 */
function pipe(...funcs) {
  return function piped(...args) {
    return funcs.reduce((result, func) => [func.call(this, ...result)], args)[0];
  };
}

/**
 * 👇 使用例
 */

function addPrefix(str) {
  return 'prefix-' + str;
}

function addSuffix(str) {
  return str + '-suffix';
}

function toUppercase(str) {
  return str.toUpperCase();
}

// Pipe 化した関数を作成
const decorated1 = pipe(addPrefix, addSuffix, toUppercase);
const decorated2 = pipe(toUppercase, addPrefix, addSuffix);

// 実行する
const result1 = decorated1('hello'); // PREFIX-HELLO-SUFFIX
const result2 = decorated2('hello'); // prefix-HELLO-suffix

Compose #

原理は Pipe 関数と同じです。ただし、reduce ではなく reduceRight メソッドを使用することで、関数は右側から実行されていきます。

/**
 * Compose 関数
 */
function compose(...funcs) {
  return function composed(...args) {
    return funcs.reduceRight((result, func) => [func.call(this, ...result)], args)[0];
  };
}

// Compose 化した関数を作成
const decorated1 = pipe(addPrefix, addSuffix, toUppercase);
const decorated2 = pipe(toUppercase, addPrefix, addSuffix);

// 実行する
const result1 = decorated1('hello'); // prefix-HELLO-suffix 👈 Pipe 関数と結果が異なります
const result2 = decorated2('hello'); // PREFIX-HELLO-SUFFIX 👈 Pipe 関数と結果が異なります

Pick #

対象のオブジェクトから指定したキーのみを抽出した新たなオブジェクトを生成する関数です。

/**
 * Pick 関数
 */
function pick(obj, keys) {
  return keys.reduce((acc, key) => {
    if (obj.hasOwnProperty(key)) {
      acc[key] = obj[key];
    }
    return acc;
  }, {});
}

/**
 * 👇 使用例
 */

const obj = {
  id: 1,
  name: 'Paul',
  password: '82ada72easd7',
  role: 'admin',
  website: 'https://www.paulsblog.dev',
};

// Pick されたオブジェクトを作成
const selected = pick(obj, ['name', 'website']);
console.log(selected); // { name: 'Paul', website: 'https://www.paulsblog.dev' }

Omit #

Pick の逆です。対象のオブジェクトから指定したキーを除外した新たなオブジェクトを生成する関数です。

/**
 * Omit 関数
 */
function omit(obj, keys) {
  return Object.keys(obj)
    .filter((key) => !keys.includes(key))
    .reduce((acc, key) => {
      acc[key] = obj[key];
      return acc;
    }, {});
}

/**
 * 👇 使用例
 */

const obj = {
  id: 1,
  name: 'Paul',
  job: 'Senior Engineer',
  twitter: 'https://www.twitter.com/paulknulst',
  website: 'https://www.paulsblog.dev',
};

// Omit されたオブジェクトを作成
const selected = omit(obj, ['id']);
console.log(selected); // {name: 'Paul', job: 'Senior Engineer', twitter: 'https://www.twitter.com/paulknulst', website: 'https://www.paulsblog.dev'}

Zip #

複数の配列を1つの配列にまとめる関数です。

/**
 * Zip 関数
 */
function zip(...arrays) {
  const maxLength = Math.max(...arrays.map((array) => array.length));
  return Array.from({ length: maxLength }).map((_, i) => {
    return Array.from({ length: arrays.length }, (_, j) => arrays[j][i]);
  });
}

/**
 * 👇 使用例
 */

// 座標を定義している配列
const xCoordinates = [1, 2, 3, 4];
const yCoordinates = [5, 6, 7, 8];
const zCoordinates = [3, 6, 1, 7];

// Zip された配列を作成
const points = zip(xCoordinates, yCoordinates, zCoordinates);
console.log(points); // [[1, 5, 3], [2, 6, 6], [3, 7, 1], [4, 8, 7]]