LoginSignup
0
1

More than 1 year has passed since last update.

Vanilla JS・TSで関心事を分離した実装案(Singleton Components v1)

Last updated at Posted at 2022-06-08

(検討中につき随時添削していこうかと)

React チュートリアルを Singleton Components v1 で実装してみた
https://codesandbox.io/s/elated-heyrovsky-sxnz7q?file=/src/index.js
(Reactの方がクリーンなコードになりますね、さすがですね)

趣旨

コンポーネント指向ってだけなら、もしかして
アレ(React)もコレ(Web Components)も必要ないんじゃ、、、と。

こちら(v1)は vanilla 的な、個別にDOMを選択しては操作する実装になります。
v2はレンダリングをやり直すことで個別にDOMを取得する操作を省いています。
https://qiita.com/_tt/items/33dc1183b79660ca8873

サンプル

クリックしたらユーザダイアログを開く、×で閉じるの例。
親コンポーネントの関心はダイアログ、
子コンポーネントの関心はユーザ情報、として構成。
Typescript: https://codesandbox.io/s/brave-fire-kd2ut4?file=/src/index.ts
Javascript: https://codesandbox.io/s/falling-leaf-2riym3?file=/src/index.js
image.png

効能

・関心事をコンポーネント単位に分離できる
・スコープに悩まされない、使いたいインスタンスはどこでもクラス経由で取得でき、かつ局所化できる
・React / Vue などライブラリが引き起こす問題や、経験不足 / 情報不足で手間取らない(※)
・deprecated や破壊的変更がなく、ライブラリのバージョン違いによる学習コスト、情報収集コストが不要(※)
※なぜなら、自分で書く以外の、何か便利なコードが提供されるわけでは無いので
(私調べ)

この案のポイント

1. コンポーネントをシングルトンで実装する

コンポーネントが関数だと、状態(変数)は別の「何か」に持たせることになる。
なら、クラスにしてメンバ変数に持たせればいい。
ただ、そのクラスインスタンスはどこに置いておくのか。
親コンポーネントが保持すると、他でも使う場合は受け渡さないといけない。
もしくは、window.ComponentManager.get(id) / register(id, instance) みたいな「何か」を作るか。

この点、シングルトンで実装すれば
・コンポーネント定義も→ class ComponentA {}
・インスタンスも   → ComponentA.getInstance()
・状態も       → ComponentA.getInstance().get〇〇() / set〇〇(value)
コンポーネント内にまとめられ関心の分離が中途半端にならない。
すべて ComponentA であり、useState や ComponentManager みたいな別の「何か」に分散しない。

また、インスタンスも必要なところでどこからでもクラス経由で取得できるので
スコープに悩まされず、かつ局所化できる。

ComponentA.ts
import ComponentB from './ComponentB';
//・・・
  public init() {  // ComponentBのインスタンスを引数で受け取っておく必要がない
    // ここではComponentBのインスタンスは不要だし使えない
    somethingButton.addEventListener("click", () => {
      // ComponentBのインスタンスのスコープはこのリスナ関数内のみに局所化
      ComponentB.getInstance().somethingAction();
  });
}

※もしインスタンスが複数必要になる場合、
 getInstance(id) とするか、
 プロパティが複数になれば済む場合 getSomethingAll(): Something[] などとする。

2. コンポーネントの render() は static (issue: 2)

コンポーネントのインスタンス生成前にレンダリングできるため。
これによりコンストラクタでそのコンポーネントがレンダリングするHTMLElementを取得できる。
※static かどうか関係なく、render() では innerHTML に string を渡すため、
 ・関数を埋め込むことは困難
 ・変数もレンダリング時の値が埋め込まれるのみ、自動で再レンダリングされない
 →よって、レンダリングした HTMLElement をコンストラクタで取得し、
  init() などでリスナや値をセットする流れとする。・・・⓵
  再レンダリングが必要な要素は setter を用意し setter 内で書き換えを実装する・・・⓶

src/components/ComponentA.ts
export default class ComponentA extends Button {
  //・・・
  private static id = "a-button";
  public static render() {
    return /* html */`
      <button id=${ComponentA.id}>
        click me
      <button>
    `;
  }
  private constructor() {
    super(`#${ComponentA.id}`);
  }
  public init() {
    this.getElement().addEventListener(  //⓵
      'click',
      () => ComponentA.getInstance().setButtonLabel('clicked')
    );
  }
  public setButtonLabel(label: string) {  //⓶
    this.getElement().textContent = label;
  }
}

3. コンポーネントの初期化は init() で

コンストラクタが async にできないため init() を用意する。
また、子コンポーネントの init() は親コンポーネントの init() 内で実行する、
孫以下も同様、よってこのコールスタックを途切れさせないため必須とする。
※init() 使わずコンストラクタのみとすると、
 親コンポーネントのコンストラクタ内で ChildComponent.getInstance() となる。
 getInstance() はインスタンスを取得するという意味だが初期化のために実行している、
 メソッドに複数の意味を持たせるべきではないかなと。
(コンストラクタは HTMLElement 取得など、コンポーネントをコーディングしていく準備に使う)

4. HTMLElementラッパー層を用意する

下記実装案では src/elements/* が相当。
クリーンアーキテクチャを参考にすると、HTMLElements とコンポーネントの間に1層必要だろうと。
例えば input で汎用的な処理、button で汎用的な処理があった場合、ここに実装する。

準備

お好みで

example.sh
npm init -y
npm install -D parcel-bundler
# npm install typescript

# npx parcel index.html --open  # サーバ起動し開発
# npx parcel build index.html   # ビルド
example.dir
package.json
// tsconfig.json
index.html         // エントリポイント
src
┝ index.ts
┝ components/ButtonA.ts
┝ elements/Button.ts
┗ elements/Element.ts

innerHTML に渡す string のシンタックスハイライト
https://marketplace.visualstudio.com/items?itemName=Tobermory.es6-string-html

実装案

index.html
<body>
	<div id="app"></div>
	<script src="src/index.ts"></script>
</body>

↑ エントリポイント。JS・TSとの紐付けをする
 

src/index.ts
document.getElementById("app")!.innerHTML = /* html */`
  ${ButtonA.render()}
`;

(/* async */() => {
  ButtonA.getInstance().init();
  // await SomethingComponent.getInstance().init();
})();

↑ エントリポイントの延長線上
主にコンポーネントを用いてコーディング。
先にレンダリングし、後から初期化する。(ポイント2)
子コンポーネントの初期化は親コンポーネントの init() 内で行う。(ポイント3)
 

src/components/ButtonA.ts
// 特定の elements に1対1で紐づくコンポーネントなので継承する
// そうでない場合は継承せずメンバ変数で持つなど
export default class ButtonA extends Button {
  private static instance: ButtonA;
  public static getInstance() {
    if (!ButtonA.instance) ButtonA.instance = new ButtonA();
    return ButtonA.instance;
  }

  private static ids = {
    button: 'a-button'
  };
  public static render() {
    return /* html */`
      <!-- 子コンポーネントがある場合はその render() を実行する -->
      <button id="${ButtonA.ids.button}">
        submit
      </button>
    `;
  }

  // private foo;
  private constructor() {
    super(`#${ButtonA.ids.button}`);
    // this.foo = new Span(`#${ButtonA.ids.span}`);
  }
  public init() {
    // 子コンポーネントがある場合はその init() を実行する
    this.getElement()
      .addEventListener("click", (event: Event) => {/* something action */});
  }
  // public getFoo() { return this.foo; }
}

↑ コンポーネント
・シングルトン(複数必要な場合は getInstance(id) など)(ポイント1)
・render() は static(ポイント2, issue: 2)
・init() で初期化処理(ポイント3)
・子コンポーネントがある場合はこのコンポーネント内で render() と init() を実行する(ポイント3)
 

src/elements/Button.ts
export default class Button extends Element<HTMLButtonElement> {
  constructor(query?: string) {
    super(Button.name, query);
  }
}

↑ HTMLButtonElement のラッパーのようなもの。
Button における汎用的な処理はここにメソッドを実装する。(ポイント4)
基本、elements はコンポーネントで使われる。
 

src/elements/Element.ts
export default class Element<T extends HTMLElement> {
  private element: T;
  constructor(tagName: string, query?: string) {
    this.element = (query
      ? document.querySelector(query)
      : document.createElement(tagName.toLocaleLowerCase())) as T;
  }
  public getElement() {
    return this.element;
  }
}

↑ 各エレメントクラスの親クラス。
エレメント共通で実装したい処理はここにメソッドを追加する。
※一例としてここでは、コンストラクタにある通り
query が指定されていればその要素を取得し、指定されていなければ createElement としている
 

Javascript の場合

src/components/ButtonA.js
let instance;  // private static
const ids = {  // private static
  button: 'a-button'
};
class ButtonA extends Button {
  static render() {
    return /* html */`
      <button id="${ids.button}">
        submit
      </button>
    `;
  }
  static getInstance() {
    if (!instance) instance = new ButtonA();
    return instance;
  }
  constructor() {
    super(`#${ids.button}`);
  }

  init() {
    this.element  // getElement() は廃止、element を private にできないため
      .addEventListener("click", (event) => {/* something action */});
    return this;
  }
}

export const { getInstance, render } = UserDialog;  // コンストラクタを private にするためクラスを export しない

import も少し変更

index.js
import * as ButtonA from './components/ButtonA';

※ちなみに下記でもシングルトンになるようですが、
 render() 実行前にコンストラクタが実行されるため、render() のHTML要素を取得できず、
 今回は不採用

src/components/ButtonA.js
export default new ButtonA();

issue

  1. elements で querySelectorAll の取り扱い
  2. load時のみ render() なら static で成立するが、load後に値などの更新でも render() を実行する場合 static では使いづらい。
    インスタンスメソッドになればインスタンス変数やインスタンスメソッドの返値をそのまま埋め込める。
    懸念点:
    ・イベントリスナは init() でセットしてる、init() も合わせて再実行する?
    ・render() をインスタンスメソッドにするとコンストラクタでHTML要素を取得できない
    以下2案を検討

案1:
・innerHTML への代入を親コンポーネントが実行するので
 componentWillMount、componentDidMount 風なものが必要になるのかなと。
 であれば、placeholder を受け取り、自コンポーネントで innerHTML へ代入すれば、
 その後の処理も、componentDidMount 風なもの不要で書ける。

/src/components/Child2.ts
private input = {
  id: "fooId",
  value: "fooValue",
  element: null
};
private parent: HTMLElement | null = null;
public render(placeholder?: HTMLElement) {
  if (placeholder) this.parent = placeholder;
  // like componentWillMount
  this.input.element.removeEventListener('input', somethingListener);  // これが手間だなぁ

  // mount
  this.parent.innerHTML = /* html */ `
    <input
      id="#${this.input.id}"
      type="text"
      value="${this.input.value}" />
  `;

  // like componentDidMount
  this.input.element = document.querySelector(`#${this.input.id}`);
  this.input.element.addEventListener('input', somethingListener);
}

public setValue(value: string) {
  this.input.value = value;
  this.render();  // 再レンダリングで値を更新できる、イベントリスナの再設定も render() 内で行われる
}

ただ、この placeholder パターンだと
例えば下記の場合、Child2 の render() を実行すると Child1 の render() 分は消えることになる。

/src/components/Parent1.ts
private id = 'parent';
public render(placeholder?: HTMLElement) {
  //...
  placeholder.innerHTML = /* html */ `
    <div id="${this.id}"></div>
  `;
  const el = document.querySelector(`#${this.id}`);
  Child1.getInstance().render(el);
  Child2.getInstance().render(el);  // Child1 を上書きしてしまう
}

つまり、1 placeholder には 1コンポーネントのみとしなければならない。
ダイアログサンプルを書き直してみたが、やはりリスナの add / remove が入ると render() の見通しが悪くなる。
https://codesandbox.io/s/eager-mcclintock-nv9n5p?file=/src/components/UserDialog.ts
そもそも render ではない処理が入るのは不適切。
かつ、親コンポーネントが再度 render() を実行してしまうと
子コンポーネントが保持している placeholder は上書きされ無くなった要素となり動作しなくなるはず。

案2:
HTML属性のイベントハンドラを用いると render() のたびにリスナの add/remove が不要になる。
https://codesandbox.io/s/gallant-mccarthy-wkg6f5?file=/src/components/ComponentA.js
https://codesandbox.io/s/youthful-fire-kelbs7?file=/src/components/Square.js
宣言的UIと呼べるだろうか。問題は、
・window.components という「何か」に抵抗感がある
・子孫コンポーネントの render() はリフレッシュでは使えない、
 リフレッシュでは親コンポーネントの render() を使う、
 負荷の高い render() の場合はどのような表示になるか...
・親コンポーネントと子孫コンポーネントで render() が異なる実装になることに抵抗感がある
別記事にしました
https://qiita.com/_tt/items/33dc1183b79660ca8873

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1