フレームワークなしで Ajax してみた

## モチベーション

- わかりやすさ
    - 普段は、Python や シェルスクリプトで書き捨てスクリプトを作っているが、わかりやすい GUI がほしくなる時がある
- 書き捨てのスクリプト用途にストレージを使いたくない
    - React などのフレームワークで作ってみると、プロジェクトごとにドデカい`node_modules`やもろもろのディレクトリができてしまう
- ファイルは少ない方が良い
    - 欲を言えば 1 ファイル、300 行くらいで収めたい

この記事では簡単なアプリケーション作るためならば Vanilla な JavaScript でも十分であることを示す。

## デモページ

- [Met_GUI_Vanilla_js](https://rhc826.github.io/Met_GUI_Vanilla_js/)
    - イメージが湧きやすいように記事のコードをデプロイしておいた
        - 自分でも使っているので、そのうちアップデートを入れる。あくまでイメージを掴むためのもの

## メトロポリタン美術館の画像検索アプリケーション

例としてメトロポリタン美術館の API を使った画像検索アプリケーションを使う。
- [The Metropolitan Museum of Art Collection API](https://metmuseum.github.io/)

かいつまんで説明すると、この API には
- 検索語から該当する作品のリストを返す API
- ID から作品の情報を返す API
がある。

このアプリケーションでやりたいことは
- 単語で作品を検索する
- 探した作品にアクセスする
の２つだ。

つまり、次の２つの操作が可能であればアプリケーションを作ることができる。
1. 非同期通信で API を叩く操作
    1. 単語から該当する ID を返す API 
    1. ID から作品の情報を返す API
1. DOM 操作
    1. ノード を追加する操作と削除する操作
    1. 追加する ノード を作る操作

Vanilla な JavaScript には `fetch`, `querySelector`, `appendChild`, `createElement` があるので、満足な機能を持っていることがわかる。

それでは JavaScript を乗せるシンプルな html を作る。

```index.html
<!DOCTYPE html>
<html lang="ja">
    <head>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>フレームワークなしで Ajax してみる</title>
        <link rel="stylesheet" href="https://fonts.xz.style/serve/inter.css" />
        <link
            rel="stylesheet"
            href="https://cdn.jsdelivr.net/npm/@exampledev/new.css@1.1.2/new.min.css"
        />
    </head>

    <body>
        <h1>メトロポリタン美術館 API 操作画面</h1>
        <section id="image"></section>
        <search>
            <form>
                <section class="上段">
                    <label for="input_label">単語検索:</label>
                    <input id="input_label" />
                    <button id="run_drop" type="button">RUN!!!</button>
                </section>
                <section class="下段">
                    <label for="drop_select">ドロップリスト検索 :</label>
                    <select name="works" id="drop_select"></select>
                    <button type="button" id="run_fetch">RUN!!</button>
                    <button type="button" id="run_peke" onclick="remove_peke()">
                        ❌をすべて消す
                    </button>
                </section>
            </form>
        </search>
        <script defer src="index.js"></script>
    </body>
</html>
```

この html は２つのテキストボックスと３つのボタンを持っている。
ボタンはそれぞれに機能が割り振られていて、

- 単語で検索し、検索結果の上から 10 個までの作品に画像付きリンクを張る(画像がない作品はリンクの代わりに❌を表示する)
- ドロップダウンリストに詰められた検索結果のリストから画像つきリンクを指名して張る
- ❌をすべて消す

```index.js
//@ts-check

/**
 * id の作品への画像つきリンクを作成する
 * @param {string | null} id
 */
async function append_anchor(id) {
    if (!id) {
        console.error("NO DATA!");
        return;
    }
    const obj = await fetch_content(
        `https://collectionapi.metmuseum.org/public/collection/v1/objects/${id}`
    );
    if ("total" in obj) {
        console.error("想定しないオブジェクトが発見されました");
        return;
    }
    const Element = createAnchorTag(obj);
    document.querySelector("section#image")?.appendChild(Element);
    return obj;
}
/**
 * ドロップダウンリストの要素を作る関数
 * @param {string} id
 * @returns {void}
 */
function append_option(id) {
    const opt = document.createElement("option");
    opt.textContent = id;
    opt.className = "drop_box_item";
    document.querySelector("#drop_select")?.appendChild(opt);
}

/**
 * API を叩く関数。
 * @param {string} path
 * @returns {Promise<{ objectIDs: number[]; total: number; } | { primaryImageSmall: string; objectID: number; title: string; }>} - APIから取得したオブジェクト
 */
async function fetch_content(path) {
    const content = await fetch(path).then((response) => response.json());
    console.log("fetch_content:", content);
    return content;
}

/**
 * 指定されたURLから画像埋め込み要素を生成します。
 * URLが指定されていない場合、代わりにエラーメッセージが含まれたパラグラフ要素が生成されます。
 * @param {string} url - 画像のURL
 * @returns {HTMLImageElement|HTMLParagraphElement} - 画像埋め込み要素またはパラグラフ要素
 */
function createImgTag(url) {
    if (!url) {
        const paragraph = document.createElement("p");
        paragraph.textContent = "❌";
        return paragraph;
    }
    const imgTag = document.createElement("img");
    imgTag.src = url;
    return imgTag;
}

/**
 * 指定されたオブジェクトから画像埋め込み要素を生成します。
 * URLが設定されていない場合、代わりにエラーメッセージが含まれたパラグラフ要素が生成されます。
 * @param {{primaryImageSmall: string, objectID: number, title: string}} Met_obj - JSON
 * @returns {HTMLAnchorElement|HTMLParagraphElement} - 画像埋め込み要素またはパラグラフ要素
 */
function createAnchorTag(Met_obj) {
    if (!Met_obj.primaryImageSmall) {
        // Click すると消える Error パラグラフ
        const paragraph = document.createElement("p");
        paragraph.textContent = "❌";
        paragraph.className = "Error";
        paragraph.addEventListener("click", () => {
            paragraph.remove();
        });
        return paragraph;
    }
    const imgTag = document.createElement("img");
    const anchorTag = document.createElement("a");
    anchorTag.appendChild(imgTag);
    anchorTag.href = `https://www.metmuseum.org/art/collection/search/${Met_obj.objectID}`;
    imgTag.src = Met_obj.primaryImageSmall;
    imgTag.alt = Met_obj.title;
    return anchorTag;
}

/**
 * ❌ を一度に消す関数
 */
function remove_peke() {
    document.querySelectorAll("p.Error").forEach((res) => res.remove());
}

// ID検索のボタン
document
    .querySelector("button#run_fetch")
    ?.addEventListener("click", async () => {
        /** @type {HTMLSelectElement | null} */
        const word = document.querySelector("#drop_select");
        if (!word) {
            return;
        }
        await append_anchor(word.value);
    });

// ワード検索のボタン
document
    .querySelector("button#run_drop")
    ?.addEventListener("click", async () => {
        // このイベントでやること
        // - objectIDs を得る
        // - ドロップダウンリストの中身を詰める
        // - リンクを表示する
        /** @type {HTMLInputElement | null} */
        const word = document.querySelector("input#input_label");
        if (!word?.value) {
            console.error("NO DATA!");
            return;
        }

        const obj = await fetch_content(
            `https://collectionapi.metmuseum.org/public/collection/v1/search?q=${word?.value}`
        );

        // ここからドロップダウンリストの中身を詰める
        if ("title" in obj) {
            return;
        }

        obj.objectIDs.forEach((id) => {
            append_option(String(id));
        });

        // ここからリンクを張る作業
        // 負荷をかけないために API を叩く回数と間隔を制限する
        const max = obj.objectIDs.length > 10 ? 10 : obj.objectIDs.length;
        const delay = 1000;
        for (let index = 0; index < max; index++) {
            // index > obj.objectIDs.length のとき undefined を返してエラーを吐かないことに注意
            if (index > obj.objectIDs.length) {
                break;
            }
            const objectID = obj.objectIDs[index];

            await append_anchor(String(objectID));
            await new Promise((s) => setTimeout(s, delay));
        }
    });
```

## 振り返り

以前に TypeScript + React で記事のアプリケーションと同じ用途のものを学習のために作ったことがある。

- [Met_GUI](https://rhc826.github.io/Met_GUI/)

このときは、

- 簡単なスクリプトのつもりなのに、いくつもファイルを作るのは嫌だ
- 気がつくと useState が増殖している……

という感想だった。

今回 JavaScript + JSDoc で作り直してみて

- シングルファイルで簡単なアプリケーションならできるのがわかった
- Classless CSS をシンプルな html に当てるだけで、見れる GUI になる
- JavaScript + JSDoc で型の恩恵を受けられる
    - どっちみち裏で動いているのは tsc だが、ポン置きで動くのが便利 
- インクリメンタルな動作が必要なら React を入れた方が楽そうだ

と思った。

今後の課題として

- JavaScript でインクリメンタルな動作を書く方法を調べる

## Link

そうは言ってもフレームワークは便利なので使えるのなら使いたい、参考にフレームワークを小さく使う記事を紹介しておく。

- [ssig33: &quot;最近最小限のReactアプリを作るのに使ってるもの についてとりあえずメモしとく。 ](https://pub.ssig33.com/o/48a0d03324884f5d886667b7197a978e)
- [雑にReactアプリを作りたい時に使ってるもの](https://zenn.dev/razokulover/articles/7653ef0336db77)

Classless CSS についてはここにまとまっている
- [dbohdan/classless-css: A list of classless CSS themes/frameworks with screenshots](https://github.com/dbohdan/classless-css?tab=readme-ov-file)