Intersection Observer APIを用いて、Web Componentsを遅延読み込み化する方法

はいさい、ちゅらデータ ぬ オースティン やいびーん！んな、がんじゅー やみ？

# 概要
Intersection Observerを使ってWeb Componentsで遅延読み込み(Lazy Loading)を導入する方法を紹介します。

## 背景
遅延読み込みは、すぐに不要なコードを後でダウンロードして読み込む技術です。例えば、アプリではGoogle Maps APIを使っているが、地図が出る画面を開くまで要らないので、その画面を開いた時に必要なJavaScriptを取ってくるようにするのが遅延読み込みです。UXをよくする常套手段です。Echarts、　Three.jsなどパッケージサイズが飛んでもなく大きいけど、ログインするまで使わないと言ったような時も、遅延読み込みが望ましいです。

React・Vueでは、すぐに読み込まれなくていいような、そのページを開くまで要らないコードを後で読み込むようにするための機能がたくさんあることを読者はご存知かと思います。

しかし、React依存症を断とうとしている筆者は、Web Componentsではどうやって遅延読み込みができるのか、明確ではなかったのです。

実は、Reactが存在する以前から、僕よりずっと賢くてJavaScriptを熟知している先輩たちがこの問題を解決していたのです。

それが、Intersection Observer APIでした。

Intersection Observer APIの本来の使い方の一つは、画像が見えてくるまで、画像を読み込まないようにするということした。

今回、Web ComponentをIntersection Observerで監視して、DOMに入った瞬間読み込むようにします。

## 以前の投稿をもとにしているのでよければこちらも併せてみてください。

https://qiita.com/tronicboy/items/e6d7a9b6c5130b1e0457

この記事でセットアップした開発環境がある前提で以下のコードを書いています。

# 解決法
### HTMLテンプレート
HTMLのテンプレートでは、まず画面の2倍ほどあるから\<div>を入れて、その下に、\<my-component>というWeb Componentを入れます。その後に、違う色の\<div>を入れて分かりやすくします。
```html:template.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">
  <!-- webpackがscript等を入れてくれます -->
  <title>Document</title>
</head>

<body>
  <div style="height: 200vh; width: 100%; background-color: lightcoral; margin-bottom: 1rem;"></div>
  <my-component></my-component>
  <div style="height: 200vh; width: 100%; background-color: lightblue; margin-top: 1rem;"></div>
</body>


</html>
```

### Intersection Observerで\<my-component>を監視する
次は、JavaScriptの方でこのエレメントを監視するようにIntersection Obvserverをセットアップします。
```typescript:src/index.ts
const observerCallback: IntersectionObserverCallback = (entries) => {
  entries.forEach((entry) => {
    const isIntersecting = entry.isIntersecting;
    const tagName = entry.target.tagName.toLowerCase(); // Chromeのデフォルトでは大文字で出てきます。
    console.log(tagName, isIntersecting);
  });
};

const observer = new IntersectionObserver(observerCallback, {
  root: document, // ユーザーのブラウザのView Portを境にする
  rootMargin: "0px", // ViewPortをそのまま境にする、マイナスだったら見える前に検知する
  threshold: 0, // ピクセルが一つでも見えればisIntersectingと判断する
});

const myComponent = document.querySelector("my-component")!;

observer.observe(myComponent);
```
ここで、エレメントのタグ名と、isIntersectingのboolean値も必要になってくるので、取っておきます。

正しく機能していることを確認するために、一度実験してみます。一発勝負！
![ezgif.com-gif-maker (1).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2344956/eaea8a74-b7bc-fe48-f024-24e279e82045.gif)
上等！（自画自賛）

## 見えた来た時に非同期でWeb Componentを読み込む
最初に\<my-component>が見えてきた時に読み込んでおきたいので、IntersectionObserverCallbackを以下のようにします。
```typescript:src/index.ts
// import MyComponent from "./my-component"; // 通常ならこのようなESモジュールのインポートをします。

const observerCallback: IntersectionObserverCallback = (entries) => {
  entries.forEach((entry) => {
    const isIntersecting = entry.isIntersecting;
    const tagName = entry.target.tagName.toLowerCase();
    if (!isIntersecting || customElements.get(tagName)) return; // すでに読み込んだWeb Componentであれば、読み込まないようにする
    import("./my-component").then((imports) => {
      const defaultImport = imports.default; // export defaultでしているもの（関数、定数、クラスでもOK）を指しています。
      customElements.define(tagName, defaultImport); // ここでブラウザにWeb Componentの本性を教える、この瞬間、constructorが実行され、DOMに入ります。
    });
  });
};

const observer = new IntersectionObserver(observerCallback, {
  root: document, // ユーザーのブラウザのView Portを境にする
  rootMargin: "0px", // ViewPortをそのまま境にする、マイナスだったら見える前に検知する
  threshold: 0, // ピクセルが一つでも見えればisIntersectingと判断する
});

const myComponent = document.querySelector("my-component")!;

observer.observe(myComponent);
```
画面に見えていない時は何もしないのと、すでに読み込んでいるのであれば、再度読み込むとエラーになるので、そういう時はすぐreturnしておきましょう。

### 結果
![ezgif.com-gif-maker (2).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2344956/61575f83-2ea7-b711-3ca2-8a928e54e86b.gif)
魔法のようですね。


ちなみに、前回の記事で使っていた\<my-component>ですが、中身はこちらです。
```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.childNodes.forEach((li) => li.removeEventListener("click", this.#handleLIClick));
    this.#ul.innerHTML = "";
    this.#listData.forEach((item) => {
      const li = document.createElement("li");
      li.textContent = item;
      li.addEventListener("click", this.#handleLIClick);
      this.#ul.append(li);
    });
    console.log("Data rendered to DOM");
  }

  #handleLIClick: EventListener = (event) => {
    console.log("Event inside Shadow DOM");
    const target = event.target;
    if (!(target instanceof HTMLElement)) return;
    const customEvent = new CustomEvent("tile-click", { bubbles: true, composed: true, detail: () => 48 });
    target.dispatchEvent(customEvent); // \<li>のエレメントもしくはその子エレメントから配信する
  };

  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);
      });
  }
}
```
Webpackのビルド成果物はこちらのように、\<my-component>の分が分かれていますね。
![スクリーンショット 2022-06-23 9.53.03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2344956/8d75b80c-765c-d553-a597-a7806371afcf.png)

実際、ChromeのDevToolsのNetworkタブで見てみると、
![ezgif.com-gif-maker (3).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2344956/bf3e6b45-fc26-183b-d279-438d90a1d707.gif)
このようにちゃんと遅延読み込みになっています。

# まとめ
Web Componentsとその部品が保有するパッケージを遅延読み込み化する方法を紹介しましたが、いかがでしょうか？
Reactのものとは、やり方が違いますが、見えていれば読み込む、という意味ではとても理にかなっている気がします。