webpackでTypeScriptとWeb Components APIを使ったプロジェクトを簡単にセットアップする方法

# 概要

本記事は、JavaScriptをプロダクション用に最適化するwebpackと一緒に、ブラウザのネイティブAPIである　Web Component APIを簡単に導入する方法を紹介するものです。

コードの内容のみ見たい方は、3章目からお読みください！

## 目次
1. Web Components APIとは
1. Web ComponentsをVanillaで使うメリット
1. webpackのセットアップ
1. 試しにWeb Componentを一つ書いてみる
1. Web Componentでのステート管理について


# Web Components APIとは

Web Components APIは、HTMLとCSSとJavaScriptをWebページのエレメントで統一して管理するための技術です。

React・Vue・Angularの部品と同じ概念です。一つのページの機能に関わるコードをまとめて、独立したモージュルとして成り立つようにするものです。

ただ、上記のフレームワークとの大きな違いがあります。それは、Web Components APIはフレームワークではないことです。ブラウザの標準機能なので、フレームワーク・第三者パッケージを使う必要はない。

このAPIは、数年前から本格的に開発され、2年前のMicrosoft Edgeのサポート追加を最後に、全てのモダンなブラウザでフルサポートされています。

<strong>Web Componentsは、ただのVanilla JavaScriptであること</strong>、これが主なポイントです。

実際、全ての部品をWeb Component APIで書いている大手の会社も多く存在します。特に熱烈に取り組んでいるのは、Googleです。ご存知の方もいらっしゃると思いますが、Youtubeのプレーヤー等は、Web Componentで書かれています。

## Web Componentsを使うメリット

上記でも触れましたが、Web Componentsはフレームワークを使わない、ブラウザに入っている標準機能なので、以下の大きいメリットがあります。
1. フレームワークのように、大量のコードをユーザーにダウンロードしてもらう必要はない。
1. Web Componentを定義すれば、どこでもただのHTMLタグとして既存のWebページに導入できる
1. フレームワークを使っているプロジェクトでも使用できる
1. React、Vueなど、フレームワークが違っても同じ部品が使える
1. 第三者npmパッケージを使う必要はないので、セキュリティ上の配慮は不要。
1. ブラウザ機能なので、サポートは確実かつ充実。また、10年後に消えるようなことはないと自信を持って言える。
1. Vanilla JavaScriptなので、使うことで、チームのJavaScriptのスキルが上がる

最後の4番のメリットはもう少し説明したいのですが、フレームワークは基本的に賞味期限つきのものだと筆者は考えております。Reactはここ数年後は必ず残ると思いますが、Reactの書き方も変わるだろうし、Reactのコードを書くのと、ただのVanilla JavaScriptを書くことは別のスキルが要求されていると思います。
しかし、Web Componentで開発をすると、Vanilla JavaScriptのスキルが磨かれ、チームの全般的なWeb開発能力が向上して今後フレームワークが変わっても残る技術を獲得するのだと思います。
あくまでも、筆者の意見です。

# webpackプロジェクトのセットアップ

まずは、パッケージマネジャーでpackage.jsonを作成しておきます。今回は、<a href="https://yarnpkg.com/getting-started/install">yarn</a>を使います。任意のダイレクトリに入って以下のコードを実行します。
```
yarn init
```

次、webpackに必要なパッケージをインストールします。
```
yarn add -D webpack webpack-cli babel-loader @babel/core @babel/preset-env 
@babel/preset-typescript typescript css-loader to-string-loader html-loader html-webpack-plugin
```

結果としてこのようなpackage.jsonになります。

```json:package.json
{
  "name": "webpack-wc",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT",
  "devDependencies": {
    "@babel/core": "^7.18.2",
    "@babel/preset-env": "^7.18.2",
    "@babel/preset-typescript": "^7.17.12",
    "babel-loader": "^8.2.5",
    "css-loader": "^6.7.1",
    "html-loader": "^3.1.0",
    "html-webpack-plugin": "^5.5.0",
    "to-string-loader": "^1.2.0",
    "typescript": "^4.7.3",
    "webpack": "^5.73.0",
    "webpack-cli": "^4.9.2"
  }
}




```

|パーケージ名 | 使う理由 |
| --- | --- |
| webpack | ...ね？ |
| webpack-cli | npx webpackなど、ターミナルで実行できるように | 
| babel-loader | TypeScriptをコンパイルできるように、webpackがbabelを使って純粋なJavaScriptに翻訳してくれる | 
| @babel/core | babelのコア |
| @babel/preset-env | babelの基本的な設定を含めたもの |
| @babel/preset-typescript | babelのTypeScript用の基本的な設定
| typescript | （任意）型が使えるように |
| css-loader | .css拡張子のファイルをstringとしてES Modulesでインポートできるように |
| to-string-loader | css-loaderが返すObjectの.toStringメソッドを自動的に実行してくれるもの | 
| html-loader | .html拡張子のファイルをstringとしてES Modulesでインポートできるように |
| html-webpack-plugin | (任意)webpackがバンドル化した成果物を自動で\<script>タグに入れてくれた.htmlファイルを出力するため |

### webpackの設定ファイルを作成する

package.jsonと同じダイレクトリにwebpack.config.jsという名前のファイルを作ります。その中に以下のコードを入れます。

```javascript:webpack.config.js
const path = require("path");
const HtmlWebpackPlugin = require("html-webpack-plugin");

module.exports = {
  entry: "./src/index.ts",
  mode: "production",
  module: {
    rules: [
      {
        test: /\.ts$/,
        exclude: /node_modules/,
        use: {
          loader: "babel-loader",
          options: {
            presets: ["@babel/preset-env", "@babel/preset-typescript"],
          },
        },
      },
      {
        test: /\.js$/,
        exclude: /node_modules/,
        use: {
          loader: "babel-loader",
          options: {
            presets: ["@babel/preset-env"],
          },
        },
      },
      {
        test: /\.css$/i,
        use: ["to-string-loader", "css-loader"],
      },
      {
        test: /\.html$/i,
        loader: "html-loader",
      },
    ],
  },
  resolve: {
    extensions: [".ts", ".js"],
  },
  output: {
    filename: "[name].[fullhash].js",
    path: path.resolve(__dirname, "dist"),
    clean: true,
  },
  plugins: [
    new HtmlWebpackPlugin({
      template: path.resolve(__dirname, "src", "index.html"),// これは任意です。入らなければpluginsごと削除すればよい
    }),
  ],
};
```

TypeScriptで.cssと.html拡張子のファイルをES Moduleとして使うので、下記のように型を定義しておきます。
ちなみに、.d.ts拡張子のファイルはTypeScriptが自動で読み込んでくれるのです。設定、インポートなどは不要です。
```typescript:src/modules.d.ts
declare module "*.html" {
  const content: string;
  export default content;
}

declare module "*.css" {
  const content: string;
  export default content;
}
```

次に、src/index.ts、index.css、およびindex.htmlを作ります。.cssと.htmlはwebpackの設定を確かめるように作ります。

```typescript:src/index.ts
import css from "./index.css";
import html from "./index.html";

console.log("Hello World!", css, html);

```

```css:src/index.css
* {
  box-sizing: border-box;
}

h1 {
  color: black;
  font-size: 1.5rem;
}
```

```html:src/index.html
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>

<body>
  <h1>Hello World! From Template</h1>
</body>

</html>
```

これで一回正しく設定されているかを試してみましょう！

```
npx webpack
```

良さそうな結果！
![スクリーンショット 2022-06-13 10.16.44.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2344956/49273628-afc6-9f1f-5616-d1e0349020e0.png)

ブラウザで開いてみると、期待通りに動いています！
![スクリーンショット 2022-06-13 12.28.52.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2344956/9a6e4c35-a2a8-0d09-7f57-700e588cd954.png)

# Web Componentを一つ書いてみる
ここからは、Shadow DOMを持ったWeb Componentを書いてみてwebpackでバンドル化してみます。

### Shadow DOMとは
Shadow DOMをご存知の方は以下の説明は不要です。

Shadow DOMは、Web Componentに、独立したDOMを付与する機能です。

あるWeb ComponentにShadow DOMを追加し、そこにHTMLとCSSを入れると、それが他のDOMのエレメントと無関係になり、CSSも完全にリセットした、そのWeb Component専用のものになります。
Shadow DOM内でLight DOMとIDが被っても大丈夫ですし、Shadow DOMで指定したCSSがLight DOMで反映されない。

詳しくはこちらの<a href="https://developer.mozilla.org/ja/docs/Web/Web_Components/Using_shadow_DOM">MDN記事をご覧ください。</a>

まずは、my-componentというフォルダを作成し、そこに、index.ts, styles.css, template.htmlのファイルを入れます。

![スクリーンショット 2022-06-13 12.36.30.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2344956/8c9dbd6c-30cd-e55f-5a7b-f8d46b4da992.png)

そして、index.tsで以下のようなコードを書き、Shadow DOMをセットアップします。

```src/my-component/index.ts
import template from "./template.html";
import styles from "./styles.css";

export default class MyComponent extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: "open" });
    if (!this.shadowRoot) throw Error("Browser does not support Shadow DOM");
    this.shadowRoot.innerHTML = template;
    const styleElement = document.createElement("style");
    styleElement.innerHTML = styles;
    this.shadowRoot.append(styleElement);
  }
}
```
解説します。

|　コード　|　理由　|
| --- | --- |
| export default class MyComponent extends HTMLElement | Web Componentは全てHTMLElementというクラスを元にしています。これは、Web Componentが実際のHTMLエレメントになるからです。 |
| this.attachShadow({ mode: "open" }); | Web ComponentのエレメントにShadow DOMを付与する。Modeは基本的にopenにする。closedにすることもできるが、使う場面はないと考えてもいい。 <a href="https://developer.mozilla.org/ja/docs/Web/API/ShadowRoot/mode">詳しくはこちら</a> |
| this.shadowRoot.innerHTML = template; | インポートしたHTMLファイルを、webpackがstringにしてくれますので、Web ComponentのshadowRootにそのまま入れます。shadowRootはShadow DOMの一番下の段階を指します。 |
| const styleElement = document.createElement("style"); styleElement.innerHTML = styles; this.shadowRoot.append(styleElement); | Shadow DOMに\<style>\</style>のタグを入れています。このstyleタグの中にはstyles.cssのstringデータが入ります。

```html:src/my-component/template.html
<ul>
  <li>Item 1</li>
  <li>Item 2</li>
</ul>
```

CSSは以下のようにします。
:host {}は、Web Component自身のエレメントに対するCSSになります。
```css:src/my-component/styles.css
:host {
  --primary-color: white;
  --secondary-color: rgb(218, 218, 218);
  --highlight-color: rgb(0, 140, 255);
  --highlight-hover: rgb(26, 152, 255);
  --danger-color: rgb(255, 94, 94);
  --warning-color: rgb(255, 250, 211);

  width: 90%;
  max-width: 500px;
  margin: 1rem auto;
  background-color: var(--primary-color);
  border: 1px solid var(--primary-color);
  border-radius: 8px;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.168);
  padding: 1rem;
  display: flex;
  flex-direction: column;
}

ul {
  list-style-type: none;
  padding: 0;
  margin: 0;
  display: flex;
  flex-direction: column;
}

li {
  display: flex;
  flex-direction: row;
  justify-content: center;
  align-items: center;
  border: 1px var(--secondary-color) solid;
  background: var(--secondary-color);
  border-radius: 8px;
  color: black;
  padding: 1rem;
  margin-bottom: 0.5rem;
  text-align: center;
  font-size: 1.25rem;
}

 li:last-child {
  margin-bottom: 0;
 }

```

この部品を、ブラウザに登録する必要があります。そのコードは以下のようにします。
```typescript:src/index.ts
import MyComponent from "./my-component";

customElements.define("my-component", MyComponent);

const body = document.querySelector("body")!; // DOMに入れていきましょうね。

const myComponent = document.createElement("my-component"); // 普通のエレメントです！
body.append(myComponent);

```

ビルドしてみると、このようになります。

![スクリーンショット 2022-06-13 13.07.33.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2344956/9086c207-2c87-34f2-8ed7-2456c1d0cc8d.png)

# Web Componentでのステート管理について
読者は、ブラウザにVanillaでこのような機能が入っているのに、なぜReact・Vueのようなフレームワークを使うのか、疑問に思うのかもしれません。

実際、Web Components APIはそれらのフレームワークが解決していた大きな問題（部品ごとにコードを管理すること）を解決してくれています。

ここで、よく聞かれるのは、「ステート管理をWeb Componentでどうしたらいいのか？」という質問です。

答えは、<strong>JavaScriptを使って管理します。</strong>。

実は、JavaScriptのClassにはgetter, setterの機能があり、それで、DOMの必要な変更もあわせて変数が変わった時に実行できるようになっています。

以下、簡単な非同期のロジックをMyComponentに追加してみます。

APIから情報を取得し、その情報をリストに落とす。

```typescript:src/my-component/index.ts
import template from "./template.html";
import styles from "./styles.css";

export default class MyComponent extends HTMLElement {
  #listData: string[] = [];
  #ul: HTMLUListElement;

  constructor() {
    console.log("Element Created.");
    super();
    this.attachShadow({ mode: "open" });
    if (!this.shadowRoot) throw Error("Browser does not support Shadow DOM");
    this.shadowRoot.innerHTML = template;
    const styleElement = document.createElement("style");
    styleElement.innerHTML = styles;
    this.shadowRoot.append(styleElement);
    this.#ul = this.shadowRoot.querySelector("ul")!;
  }

  get listData(): string[] {
    return this.#listData;
  }
  set listData(value: string[]) {
    this.#listData = value;
    this.#ul.innerHTML = "";
    this.#listData.forEach((item) => {
      const li = document.createElement("li");
      li.textContent = item;
      this.#ul.append(li);
    });
    console.log("Data rendered to DOM");
  }

  connectedCallback() {
    console.log("Element Added to DOM");
    fetch("https://jsonplaceholder.typicode.com/posts?_limit=10")
      .then((result) => result.json())
      .then((data) => {
        console.log("Data Retrieved.");
        this.listData = data.map((post) => post.title);
      });
  }
}
```

connectedCallbackはWeb Componentの一生（Life Cycle）でブラウザが自動で実行してくれる関数の一つです。

上記のように、getterとsetterで部品のデータを管理すると、データ（変数）が変わった時に、必要な処理がなされるように作れます。

結果：

![スクリーンショット 2022-06-13 13.24.53.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2344956/d12023c0-19b9-f40a-f4c2-6b57d3616917.png)

また、必要であれば、Reduxのような中央ステートライブラリも使えます。Web ComponentはあくまでもVanilla JSなので、使い方次第です。

筆者は、Reduxのような中央ステートは特別な理由がない限り、使わない方が、品質の高いコードが書けると思っているのでお勧めしません。

# まとめ

以上、WebpackとWeb Componentsを100% Vanillaで作る方法を紹介しました。

もちろん、template.htmlとstyles.cssを分けずに、そのまま innerHtmlのところにstringとして渡すことができます！ただ、VS Codeのintellisense、開発環境の利便性を考えると、分けるメリットがあるかと思うので、そのやり方を紹介してみました。

また、今回はTypeScriptを使いましたが、Web Components APIはTypeScript機能ではないので、もちろんJavaScriptだけでも大丈夫です。


# 追記：CSSをtemplate.htmlに入れるのもあり
css-loaderなど、styles.cssを別ファイルに分けるためにwebpackの設定がやや複雑になるので、htmlテンプレートの中に\<style>\</style>タグを入れて、そこにCSSを入れることももちろん可能です。

ただ、template.htmlがやや大きくなりますので、ご参考までに。