🐻

【フレームワーク不要!】モーダルを作って学ぶ、TypeScriptの具体的な使い方

2025/01/24に公開

Web サイト制作がメインのコーダー/エンジニアの皆さん、TypeScript を使っていますか?
TypeScript 導入の参考記事がアプリ開発向けのものばかりで、サイト制作の現場にはどうやって導入するんだ、?となる方に、記事を書いてみました。

この記事を書こうと思った理由

  • 入門のチュートリアルや、型について学んでみたけど、実際にどう使うの?という方に向けての知見共有のため
  • TypeScript は React,Vue 等と合わせて使う記事や検索結果が多く、サイト制作向けのものが少なかったため
  • Web サイト制作のコーダー/エンジニアが、TypeScript に入門するため

ゴール

  • TypeScript を使った DOM 操作や簡単な型定義を学べる
  • TypeScript の具体的な使い方を覚える
  • TypeScript のメリットを具体例を通して学ぶ 入門向け教材はUdemy の教材がわかりやすかったです。

対象者

  • Web サイト制作のコーダー/エンジニアで普段は素の JavaScript を使っているが、TypeScript も使ってみたい人
  • TypeScript を学んでみたけど、フレームワーク無しで具体的にどう使うか分からない。イメージが湧かない人
  • TypeScript はサイト制作には、いらないんじゃないか。と思っている人

JavaScript のクラスを使って解説します。
クラスが分からないよ、という方はこちらの記事で理解を深めてみてください!

TypeScript の導入

TypeScript を案件で使うには、開発環境の構築が必要です。
Vite を使って簡単に作成します!
Vite の公式チュートリアルより、
vs code でターミナルを開きnpm create vite@latestコマンドでプロジェクトを作成します。
今回はフレームワークは使用しないので、Vanilla を選択

続いて TypeScript を選択。

指示に従い、npm run devを実行
http://localhost:5173/にアクセス
この画面が表示されたら成功です!

まずは JavaScript で書いてみる

既存の記述は使わないので、不要なファイルは削除し
各ファイルを、下記のように書き換えます。

index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <link rel="stylesheet" href="/src/style.css" />
    <title>TypeScript modal</title>
  </head>
  <body>
    <div class="modal" data-modal>
      <button class="modal-open-button" type="button" data-open-button>オープン</button>

      <dialog class="content" data-modal-content="close">
        <button class="modal-close-button" type="button" data-close-button>×</button>
        <h1>Hello モーダルTypeScript</h1>
        <p>1 + 1 = <span data-modal-text-number></span></p>
      </dialog>
    </div>

    <!-- /.modal -->
    <script type="module" src="/src/main.js"></script>
  </body>
</html>

style.css

button {
  display: inline-block;
}

.content {
  background-color: #fff;
  border: 0;
  inset: 0;
  margin: auto;
  max-width: 800px;
  padding-top: 20px;
  position: fixed;
  transition-duration: 0.3s;
  transition-property: opacity, transform;
}

.modal-open-button {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
}

.modal-close-button {
  position: absolute;
  right: 0;
  top: 0;
}

[data-modal-content='close'] {
  opacity: 0;

  &::backdrop {
    opacity: 0;
  }
}

main.js

main.tsmain.js に変更し、記述を下記に変更します。

class Modal {
  constructor() {
    this.modals = document.querySelectorAll('[data-modal]');
    this.init();
  }

  init() {
    this.modals.forEach((modal) => {
      const content = modal.querySelector('[data-modal-content]');
      const openButton = modal.querySelector('[data-open-button]');
      const closeButton = modal.querySelector('[data-close-button]');

      openButton.addEventListener('click', () => {
        this.openModal(content);
      });

      closeButton.addEventListener('click', (event) => {
        this.closeModal(event, content, closeButton);
      });

      this.addCalcText(content);
    });
  }

  openModal = (content) => {
    content.showModal();
    // data-modal-contentをopenに変更(アニメーション実行)
    requestAnimationFrame(() => {
      content.setAttribute('data-modal-content', 'open');
    });
  };

  closeModal = (event, content, closeButton) => {
    if (event.target === content || event.currentTarget === closeButton) {
      content.addEventListener('transitionend', () => content.close(), { once: true });
      // data-modal-contentをcloseに変更 (アニメーション実行)
      content.setAttribute('data-modal-content', 'close');
    }
  };

  addCalcText = (content) => {
    const numberText = content.querySelector('[data-modal-text-number]');
    numberText.textContent = 2;
  };
}

new Modal();

モーダルが実装できました。

モーダルが問題なく動くか、まず JavaScript の状態で動作を確認します。

下記のように表示され、動作が確認できたら、準備完了です!
これから TypeScript に書き換えていきます。

TypeScript に書き換え

main.jsmain.ts に変更します。
下記のようにエラーが出るので、型を定義していきます。

要素の型定義

this.modalsquerySelectorAllを使用し[data-modal]属性を持つdiv 要素を取得するため、
modals: NodeListOf<HTMLDivElement>と型定義します。

class Modal {
  // 型定義を追加
  modals: NodeListOf<HTMLDivElement>;

  constructor() {
    this.modals = document.querySelectorAll('[data-modal]');
    this.init();
  }

contentopenButtoncloseButtonは、
型推論の状態だと、型Element | nullを返します。

HTML を確認すると、
content<dialog>要素、openButtoncloseButtonは、<button>要素のため
適切な型に修正します。
これで、contentopenButtoncloseButtonの型定義が完了し
取得した要素をより安全に使うことが可能になります。

// 各要素に型定義を追加
const content = modal.querySelector<HTMLDialogElement>('[data-modal-content]');
const openButton = modal.querySelector<HTMLButtonElement>('[data-open-button]');
const closeButton = modal.querySelector<HTMLButtonElement>('[data-close-button]');

要素の存在チェック

.tsファイルに変更した事で、null の可能性を示すエラーが発生します。
これは、querySelectorが取得する要素が存在しなかった場合に、null を返すためです。
要素の存在チェックを行い、取得した要素が、存在した場合にのみ動作を実行する記述を追加します。

this.modals.forEach((modal) => {
  const content = modal.querySelector<HTMLDialogElement>('[data-modal-content]');
  const openButton = modal.querySelector<HTMLButtonElement>('[data-open-button]');
  const closeButton = modal.querySelector<HTMLButtonElement>('[data-close-button]');

  // 要素の存在チェックを追加
  if (!openButton || !closeButton || !content) return;

  openButton.addEventListener('click', () => {
    this.openModal(content);
  });

メソッドの引数に型定義

まだ、メソッドの引数にエラーがあるので、型定義を追加します。

各メソッドの引数に型を定義します。
引数に型を定義することで、openModal メソッドが想定していない型を受け取ることを防ぎます。

// メソッドの引数に型定義を追加
openModal = (content: HTMLDialogElement) => {
  content.showModal();

  requestAnimationFrame(() => {
    content.setAttribute('data-modal-content', 'open');
  });
};

closeModal = (event: MouseEvent, content: HTMLDialogElement, closeButton: HTMLButtonElement) => {
  if (event.target === content || event.currentTarget === closeButton) {
    content.addEventListener('transitionend', () => content.close(), { once: true });

    content.setAttribute('data-modal-content', 'close');
  }
};

addCalcText = (content: HTMLDialogElement) => {
  const numberText = content.querySelector('[data-modal-text-number]');
  if (!numberText) return;
  numberText.textContent = 2;
};

型の修正

.jsファイルの時は、なかったエラーが発生しました。

textContent は string 型として定義されるため、
number 型の値を代入しようとすると、型エラーが発生します。
今回は 1 + 1 = 2 というテキストを表示させたいだけ(number 型である必要が無い)なので
numberText.textContent = 2;を下記のように修正します。

addCalcText = (content: HTMLDialogElement) => {
  const numberText = content.querySelector<HTMLSpanElement>('[data-modal-text-number]');
  if (!numberText) return;
  // 2→'2'とし、number型をstring型に変更
  numberText.textContent = '2';
};

仕上げ

全てのエラーが無くなりました。
html ファイルのsrc="/src/main.js"src="/src/main.ts"に変更し、
問題なく動作するかを確認します。

動作は確認できますが、TypeScript の恩恵をより受けるため、下記の修正を加えて完了です。

  • private 修飾子の追加により、クラス内でのみ使用可能なプロパティに変更
    • クラス外からプロパティやメソッドを誤って使用することを防げます。これにより安全性が向上します。
  • void 型の追加で、戻り値がないことを明示

TypeScript への書き換えが完了です。

class Modal {
  // private修飾子の追加
  private modals: NodeListOf<HTMLDivElement>;

  constructor() {
    this.modals = document.querySelectorAll('[data-modal]');
    this.init();
  }

  // private修飾子の追加
  private init() {
    this.modals.forEach((modal) => {
      const content = modal.querySelector<HTMLDialogElement>('[data-modal-content]');
      const openButton = modal.querySelector<HTMLButtonElement>('[data-open-button]');
      const closeButton = modal.querySelector<HTMLButtonElement>('[data-close-button]');

      if (!openButton || !closeButton || !content) return;

      openButton.addEventListener('click', () => {
        this.openModal(content);
      });

      closeButton.addEventListener('click', (event) => {
        this.closeModal(event, content, closeButton);
      });

      this.addCalcText(content);
    });
  }

  // private修飾子の追加とvoid型の追加
  private openModal = (content: HTMLDialogElement): void => {
    content.showModal();

    requestAnimationFrame(() => {
      content.setAttribute('data-modal-content', 'open');
    });
  };

  // private修飾子の追加とvoid型の追加
  private closeModal = (event: MouseEvent, content: HTMLDialogElement, closeButton: HTMLButtonElement): void => {
    if (event.target === content || event.currentTarget === closeButton) {
      content.addEventListener('transitionend', () => content.close(), { once: true });

      content.setAttribute('data-modal-content', 'close');
    }
  };

  // private修飾子の追加とvoid型の追加
  private addCalcText = (content: HTMLDialogElement): void => {
    const numberText = content.querySelector('[data-modal-text-number]');
    if (!numberText) return;
    numberText.textContent = '2';
  };
}

new Modal();

最後に

このくらいのプログラムだと、TypeScript のメリットよりも
導入ハードルや、知識インプットの方が手間に感じる場合もあるかと思います。
しかし、記述量が増えたりプログラムが複雑になる事で、メリットを感じる事が多くなるように思います。

このプログラムにアニメーションの追加、アクセシビリティに配慮する等を試し、実務で使えるモーダルにすることで
TypeScript の理解がさらに深まります。

エラーが出て、また怒られた。。。となる時もありますが
逆にありがとうという気持ちで修正に取り組んでいます!

ts ライフをより楽しめるように、引き続き頑張ります。

GitHubで編集を提案

Discussion