ライブラリなしのJavaScriptで作る、関数ファクトリを使ったCounterのサンプル


ライブラリなしの JavaScript や jQuery を使う場合、どのように設計すれば開発、保守、テストが書きやすいかの問題にわたしは良くぶつかります。React や Vue、Angular といったライブラリを使うならある程度はそのライブラリの流儀に従えば良く、活発に開発のベストプラクティスが議論されているため情報を得やすいです。その反面、ライブラリなしの JavaScript だと途端に正解が分からなくなります。

色々な人が携わった年代ものの JavaScript ファイルは、public な関数や変数ばかりな実装になっていることも多いです。機能追加やこれからの保守を考えれば極力、不必要に public な要素は増やしたくありませんし、すでにあるコードも少しずつリファクタリングはしたいもの。

そこで最近取り入れているのが古くからあるファクトリ関数や module pattern です。closure を使って関数内に変数や関数を実装し、その関数のオブジェクトを作ることで内部の関数や変数にアクセスします。

class ではない理由

private な変数や関数を使うには class を使う方法もあります。ライブラリなしの JavaScript や jQuery 使用時に、class の導入に踏み切れなかった理由は下記です

  • class をすんなり受け入れてもらえるか
  • this

1 つ目については自身のいる環境によると思います。周囲のメンタルや学習コスト面で、class よりは function の方が受け入れやすいのではと考えました。this は長年 JavaScript とともに語られるくらいには分かりにくい機能です。避けられるなら避けた方が良く、closure なら this を使う必要がありません。

以上が class ではなく function 中心での実装を考えたいと思った理由です。もちろん、どう考えても class の方が良い状況なら class にしますが、そうでないなら function の方がシンプルだと思います。また、JavaScript に理解あるフロントエンドエンジニアだけが参加する開発であっても、自分的には class よりシンプルに書けているように感じるため、現状は function 押しです。React や Vue も hooks 導入で関数よりになっています。

基本の index.html

まず index.html に counter のボタンを追加します。JavaScript も普通に読み込みます。

<button id="counter">0</button>
<script src="./index.js"></script>

Counter function の作成

クリックしたら表示された数が増えるシンプルな Counter を作成していきます。独立した Counter の Object を作成するために Closure という機能を使います。関数内に関数や変数を定義することで、class のように private な関数、変数を持つことができます。

public に実行できる関数は Counter 関数内で return して、生成したオブジェクトから使用できるようにします。

// index.js

const Counter = () => {
  let count = 0;
  const counterDOM = document.querySelector("#counter");

  const increment = () => {
    count++
  }
  const render = () => {
    counterDOM.textContent = count;
  }
  return {
    init() {
      counterDOM.addEventListener('click', () => {
        increment();
        render();
      })
    }
  }
}

// 初期化してイベント付与
document.addEventListener('DOMContentLoaded', () => {
  const counter = Counter();
  counter.init();
})

ざっくりと Counter を作成しました。Counter 関数内で return している init 関数は counter オブジェクトで使用することができます。一方、increment や render を直接 counter オブジェクトからは使うことができません。また、count 変数にもアクセスできません。

const counter = Counter();
counter.init(); // ok
counter.increment(); // ng

今のところ Counter の function 内で querySelector を使い、外部の DOM を取得しています。この部分はあとで修正してます。

+ボタンを作る

カウンター自身をクリックするのではなく、counter とは別に+ボタンを作成してそれをクリックしたら数が増えるようにします。

index.html に button を追加します。

<div id="counter">0</div>
<button id="increment">+</button>
<script src="./index.js"></script>

index.js に increment button を追加します。

//index.js

const Counter = () => {
  let count = 0;
  const counterDOM = document.querySelector("#counter");
  const incrementBtn = document.querySelector("#increment");

  const increment = () => {
    count++;
  };
  const render = () => {
    counterDOM.textContent = count;
  };
  return {
    init() {
      counterDOM.addEventListener("click", () => {
        increment();
        render();
      });
    }
  };
};

document.addEventListener("DOMContentLoaded", () => {
  const counter = Counter();
  counter.init();
});

カウンター 1 つならこれで良いようにも思いますが、外部 HTML への依存は少ない方が使いやすいです。index.html を変更します。

<!-- index.html -->
<div id="counter-container">
  <div id="counter">0</div>
</div>
<script src="./index.js"></script>

html からボタンを削除して counter を div で囲いました。次に javaScript で+ボタンを作成して DOM に付与します。

const Counter = () => {
  let count = 0;
  const counterContainerElement = document.querySelector("#counter-container");
  const counterDOM = document.querySelector("#counter");

  const increment = () => {
    count++;
  };
  const render = () => {
    counterDOM.textContent = count;
  };
  const createIncrementBtn = () => {
    const button = document.createElement("button");
    button.textContent = "+";
    button.onclick = () => {
      increment();
      render();
    };
    return button;
  };
  return {
    init() {
      const incrementBtn = createIncrementBtn();
      counterContainerElement.appendChild(incrementBtn);
    }
  };
};

// 省略

どこまで JavaScript 内で DOM を作るかはケースバイケースですが、デザインの変更が少ない、あるいは同じものを何度も使いまわすなら JavaScript 内で完結した方が使いやすいです。

counter も作ろう!

counter の DOM も JavaScript 内で生成するために関数を追加して、render 関数も修正します。

// 省略
let counter;

const createCounter = () => {
  counter = document.createElement("div");
  counter.textContent = count;
  return counter;
};

const render = () => {
  counter.textContent = count;
};

return {
  init() {
    const counter = createCounter();
    const incrementBtn = createIncrementBtn();
    counterContainer.appendChild(counter);
    counterContainer.appendChild(incrementBtn);
  }
};

これで HTML 側には counter もボタンも必要なくなり、counter を表示する場所さえあれば良くなりました。

Counter を表示する DOM を変数で受け取る

Counter function 内での querySelector は残り 1 つとなりました。これを外部から受け取るように修正します。

const Counter = element => {
  const counterContainerElement = element;

  // 省略
};

document.addEventListener("DOMContentLoaded", () => {
  const counterEl = document.querySelector("#counter-container");
  const counter = Counter(counterEl);
  counter.init();
});

これで任意のタグに Counter を表示できます。

現状のソース全文

ひとまず完成です。

// index.js
const Counter = element => {
  const counterContainerElement = element;
  let count = 0;
  let counter;

  const createCounter = () => {
    counter = document.createElement("div");
    counter.textContent = count;
    return counter;
  };
  const increment = () => {
    count++;
  };
  const render = () => {
    counter.textContent = count;
  };
  const createIncrementBtn = () => {
    const button = document.createElement("button");
    button.textContent = "+";
    button.onclick = () => {
      increment();
      render();
    };
    return button;
  };
  return {
    init() {
      const counter = createCounter();
      const incrementBtn = createIncrementBtn();
      counterContainerElement.appendChild(counter);
      counterContainerElement.appendChild(incrementBtn);
    }
  };
};

document.addEventListener("DOMContentLoaded", () => {
  const counterEl = document.querySelector("#counter-container");
  const counter = Counter(counterEl);
  counter.init();
});

必要な function を return する

同じ Counter を複数ヶ所で使いたい場合には、上記のように作成すると使うのは簡単です。全部入りなので使い方もシンプルです。

const counterEl1 = //省略
const counterEl2 = //省略
const counterEl3 = //省略
Counter(counterEl1).init();
Counter(counterEl2).init();
Counter(counterEl3).init();

同じ構成で count は別々の Counter が 3 つできます。しかし、全部入りな分、デザインの変更にはとても弱いです。下手に Counter function 内で class 付与したりいろいろやろうとすると if 文が増えて乱雑になりがち。

//index.js
const Counter = element => {
  // 省略

  return {
    init() {},
    increment() {},
    decrement() {},
    reset() {}
  };
};

そこで、必要な funciotn を return して使う方法もあります。すでに HTML でタグが組み終わっていたり、デザインと機能は別々に処理したい時などはこちらの方法が無難です。また、テストしやすいです。

const counter = Counter(el);
counter.init();

const incrementBtn = ...;
const decrementBtn = ...;
const resetBtn = ...;
incrementBtn.addEventListener('click', () => {
  counter.increment();
})
decrementBtn.addEventListener('click', () => {
  counter.decrement();
}
resetBtn.addEventListener('click', () => {
  counter.reset();
})

こんな感じです。Closure を使えば、Class を使わなくても Counter に関する機能をまとめ、public に出す必要がないものを内部に閉じ込められ、this とも疎遠になれます。とても便利!

closure の気になるところとしては、パフォーマンス面で問題提起されがちということです。ただ、わたしの試した限りでは closure を使うことでメモリリークを起こしたり、class と比較してあからさまに遅いといった状況にはまだ遭遇していません。問題に出会えたら原因と改良点について追記します。

Counter のテスト

reset や上限機能をつけた Counter とそのテストも書いたので紹介します。codesandbox でも公開しています。

counter demo

// test
import { Counter } from "../index";
import { getByTestId } from "@testing-library/dom";
import "@testing-library/jest-dom/extend-expect";

function getCounter() {
  const div = document.createElement("div");
  const counter = Counter(div);
  counter.init();
  const counterCount = getByTestId(div, "counter");
  return { counter, counterCount };
}

test("Counter Init", () => {
  const { counterCount } = getCounter();
  expect(counterCount).toHaveTextContent(0);
});

test("Counter Increment", () => {
  const { counter, counterCount } = getCounter();
  counter.increment();
  expect(counterCount).toHaveTextContent(1);
});

ボタンなどを JavaScritp で生成したバージョンのテストは後日。

CounterList でたくさんの Counter を作る

Counter を複数作成し、それらのカウントの合計を表示する場合。それぞれの counter で count 数を返す関数を定義して足し合わせるのも手ですが、total を増加させる関数を引数で渡す方法もあります。

const CounterList = element => {
  let total = 0;
  let counter = [];
  const counterNum = 10;

  const incrementTotalNum = () => {
    total++;
  };

  return {
    init() {
      for (var i = 0; i < counterNum; i++) {
        counter.push(Counter(element, incrementTotalNum));
        counter[i].init();
      }
    },
    getTotalCount() {
      return total;
    }
  };
};

Counter の方は受け取った関数を実行するようにします。

const Counter = (element, incrementTotalNum) => {
  const increment = () => {
    incrementTotalNum();
    count++;
  };
  // 省略
};

親の関数から子のオブジェクトを操作する方法は、色々な機能や DOM の更新部分が絡み合っている状態の解消の取っ掛かりにしやすく、影響範囲を小さくできます。

親と子のオブジェクト

1 つの JavaScript ファイルにページ単位で上から下に処理がどんどん書き足されているケースがあります。機能毎の分離が曖昧で、後々コードを追いかけるのが難しく、なにか変更があるたびに新しい行数が同じ階層に増えていきがち。CounterList と Counter のように closure を用いて機能をまとめると、後々ファイルにも分割しやすくなるためおすすめです。

<div id="container">
  <div class="section-1"></div>
  <div class="section-2"></div>
  <div class="section-3"></div>
</div>

このような構造があって、section-1、2、3 のそれぞれで色々な操作、Ajax でデータ取得や書き換えや計算があり、とにかく年々積み重なった処理が多いとします。

const Section1 = () => {}
const Section2 = () => {}
const Section3 = () => {}


const Container = () => {
  return init() {
   const section1 = Section1();
   const section2 = Section2();
   const section3 = Section3();
  }
}

機能での分割が難しい時は、DOM の構造を参考に大雑把なブロック単位のオブジェクトにしてみます。Section1 は closure で内部に必要な機能や変数を持つため、既存のコードとバッティングすることもなく共存が可能です。ブロック毎にオブジェクトを作成することでそれぞれの機能が見えてくるようになり、機能の重複部分が理解できたら、オブジェクトをコード単位から機能単位に作り変えて、少しずつ既存の処理と置き換えていくのが良いと思います。

もちろん、全ての機能を closure 使ってオブジェクトでアクセスする必要はなく、JavaScript の使用量が少ないならこのような改善を無理に行う必要はないです。ただ、現時点で新しい機能追加や、保守で何かしら問題が出ているなら、一度小さく機能毎に module としてまとめてしまう方法は、既存のコードと共存しながら修正を進められるのでおすすめです。

参考

クロージャ-MDN

Classes, Complexity, and Functional Programming