URLをヒストリーとして保持しつつ、1ページで複数の検索を別々に走らせるために頑張った話

これは、[Wano Group Advent Calendar 2024](https://qiita.com/advent-calendar/2024/wano-group) の13日目の記事です。
12日目は、[@rom1000_onigiri](https://qiita.com/rom1000_onigiri)さんの、[企業の成長フェーズと並走するUIライブラリとの付き合い方](https://qiita.com/rom1000_onigiri/items/bb82016a7749a58218da)です。


# 概要

弊社では、カラオケやミュージックビデオの配信ができるサービス[Video Kicks](https://www.tunecore.co.jp/video-kicks-karaoke)を開発・運営しています。

今回は、1画面で複数の検索を別々に走らせることと、検索条件をヒストリーとして保持してブラウザバック時に検索条件を維持・復元するために頑張った話をします。

# 環境

```
"vue": "^3.2.31"
"vue-router": "^4.0.3"
```


# どんな画面？

[Video Kicks](https://www.tunecore.co.jp/video-kicks-karaoke)では、MVやカラオケ配信のために「リリース」というものを作成します。
マイページ上で今までに作成したリリースを検索することが出来ます。
詳細は後述しますが、この画面には検索ブロックが3つ存在し、それぞれが必要に応じて別々に検索APIを叩きます。

<img width=500 src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2475547/3b5eadb5-f1a5-74ad-05aa-d7db82ee978c.gif" />


# 必要要件

今回の開発では、以下の要件を実現する必要がありました。

- ブラウザバック・フォワード時に、以前入力した検索条件を維持する
- リリース一覧は、条件によって3つのブロック「更新可能リリース」「作成中」「リリース一覧」に分かれている
- ブロックごとに個別の検索条件を持つ
- キーワード検索のみ、3つ全てのブロックで絞り込みを実行する

# 実装方針

検索APIとの通信を最小限に抑えるため、検索条件を変更されたブロックのみ検索を走らせたいところです。

そこで以下のような方針で実装を進めました。
- クエリパラメータを付与し、それをvue-routerのヒストリーとして保存する
`/page/karaoke/release/list?key=テスト&status=20&renewPage=1&editingPage=17&releasePage=2&is_store_addition=false`
- クエリパラメータをvue側で監視し、条件に変更があったブロックのみ検索APIを叩く

各パラメータがどのブロックに影響するかは次の通りです。

## 全体

- key・・・キーワード検索

## 更新可能リリース

- renewPage・・・ページ番号

## 作成中

- editingPage・・・ページ番号

## リリース一覧

- releasePage・・・ページ番号
- status・・・リリースのステータスID
- is_store_addition・・・リリースの配信先が追加できるかどうか


以下に実装例を示します。ここでは説明に不要な部分は省略しています。

## 親コンポーネント

```List.vue
<template>
  <div class="label">キーワード検索</div>
  <div class="search-field">
    <div class="search-field__input">
      <vk-input
        @input="(v: string)=> (inputKeyword = v)"
        :value="inputKeyword"
        :placeholder="'キーワード検索'"
        style="width: 100%"
      ></vk-input>
    </div>
    <div class="search-field__btn">
      <vk-btn class="btn" @click="onClickSearchButton">検索</vk-btn>
    </div>
  </div>

  <spacer class="p-20" />

  <list-card
    :title="'更新可能リリース'"
    :keyword="searchKeyword"
    :page="renewPage"
    :limit="limit"
    :isShowSearchParams="false"
    :defaultKaraokeStatus="UIKaraokeStatus.CanRenewal"
    :storeAdditionOnly="false"
    @changePage="newPage => onChangePagination(newPage, 'renew')"
    @onError="store.error"
  />

  <spacer class="p-20" />

  <list-card
    :title="'作成中'"
    :keyword="searchKeyword"
    :page="editingPage"
    :limit="limit"
    :isShowSearchParams="false"
    :defaultKaraokeStatus="UIKaraokeStatus.Editing"
    :storeAdditionOnly="false"
    @changePage="newPage => onChangePagination(newPage, 'editing')"
    @onError="store.error"
  />

  <spacer class="p-20" />

  <list-card
    :title="'リリース一覧'"
    :keyword="searchKeyword"
    :page="releasePage"
    :limit="limit"
    :isShowSearchParams="true"
    :selectedKaraokeStatus="selectedKaraokeStatus"
    :defaultKaraokeStatus="UIKaraokeStatus.All"
    :storeAdditionOnly="storeAdditionOnly"
    @changePage="newPage => onChangePagination(newPage, 'release')"
    @changeAdditionOnly="newBool => onChangeAdditionOnly(newBool)"
    @changeStatus="onChangeKaraokeStatus"
    @onError="store.error"
  />
</template>

<script setup lang="ts">

// 明示的に「検索」を押されたときは、各セクション用のページを1に戻す
function onClickSearchButton() {
  searchKeyword.value = inputKeyword.value;
  renewPage.value = 1;
  editingPage.value = 1;
  releasePage.value = 1;
  addQueryParamsToUrl();
}

// ページの変更時に呼ばれる
function onChangePagination(
  page: number,
  type: "renew" | "editing" | "release"
) {
  if (type == "renew") renewPage.value = page;
  if (type == "editing") editingPage.value = page;
  if (type == "release") releasePage.value = page;
  addQueryParamsToUrl();
}

// リリース一覧 セクションの「ストア追加可能」の変更時に呼ばれる
function onChangeAdditionOnly(bool: boolean) {
  storeAdditionOnly.value = bool;
  addQueryParamsToUrl();
}

// リリース一覧 セクションの「配信ステータス」の変更時に呼ばれる
function onChangeKaraokeStatus(status: UIKaraokeStatus) {
  selectedKaraokeStatus.value = status;
  addQueryParamsToUrl();
}


// URLに検索キーワードを追加。検索状況をブラウザ履歴として保持するために使用
function addQueryParamsToUrl() {
  const url = `${KARAOKE_LIST_PATH}?key=${inputKeyword.value}&status=${selectedKaraokeStatus.value}&renewPage=${renewPage.value}&editingPage=${editingPage.value}&releasePage=${releasePage.value}&is_store_addition=${storeAdditionOnly.value}`;
  router.push(url);
}

// URLのクエリパラメータを取得して、検索フォームの各変数に反映
const setQueryParams = () => {
  const query = route.query;
  inputKeyword.value = (route.query.key as string) || ""; // 初期はURLパラメータからの復元文字がつかわれる
  searchKeyword.value = (route.query.key as string) || "";
  //selectedKaraokeStatusName.value = (route.query.status as string) || "全て";
  selectedKaraokeStatus.value =
    (Number(route.query.status) as UIKaraokeStatus) || UIKaraokeStatus.All;


  renewPage.value = Number(query.renewPage) || 1;
  editingPage.value = Number(query.editingPage) || 1;
  releasePage.value = Number(query.releasePage) || 1;
  storeAdditionOnly.value = Boolean(query.is_store_addition == "true");
};

// URLのクエリパラメータを監視して、変更があればsetQueryParamsを実行
watch(
  () => router.currentRoute.value.query,
  () => setQueryParams()
);


</script>
```

## 子コンポーネント

```ListCard.vue

<script setup lang="ts">

const props = defineProps<{
  title: string;
  page: number;
  limit: number;
  keyword: string;
  defaultKaraokeStatus: UIKaraokeStatus; // 初期のステータス絞り込み
  isShowSearchParams: boolean; // 配信ステータス・ストア追加可能のフォームを表示するか
  selectedKaraokeStatus?: UIKaraokeStatus;
  storeAdditionOnly?: boolean; // ストア追加可能のみ表示するか
}>();

// キーワード検索と、ブラウザバック時の再検索用
watch(
  [
    () => props.keyword,
    () => props.page,
    () => props.selectedKaraokeStatus,
    () => props.storeAdditionOnly
  ],
  (
    [newKey, newPage, newStatus, newStoreAdditionOnly],
    [oldKey, oldPage, oldStatus, oldStoreAdditionOnly]
  ) => {
    if (newKey != oldKey) {
      page.value = props.page;
      fetchKaraokes(page.value);
      return;
    }

    if (newStatus != oldStatus) {
      if (props.selectedKaraokeStatus) {
        page.value = props.page;
        fetchKaraokes(page.value);
      }
      return;
    }

    if (newPage != oldPage) {
      page.value = newPage;
      fetchKaraokes(newPage);
      return;
    }

    if (newStoreAdditionOnly != oldStoreAdditionOnly) {
      page.value = props.page;
      isAddOnly.value = props.storeAdditionOnly || false;
      fetchKaraokes(page.value);
      return;
    }
  }
);

</script>


```

処理の流れは次の通りです。
少し複雑になってしまいましたね、よりよい実装方法があればコメントいただけるとありがたいです...

キーワード検索は全てのブロック（ListCard.vue）にかかるため、親コンポーネント（List.vue）に配置してpropsで子に渡しています。

ページ数やステータスは、それぞれブロックごとに個別の値を持つため、子コンポーネント側で変更したものをemitで親に渡しています。

```mermaid

sequenceDiagram
    participant User as ユーザー
    participant List as List.vue（親）
    participant Card as ListCard.vue(子)

    User ->> List: 検索条件変更（キーワード）
    List ->> List: クエリパラメータの変更・ヒストリー保存
    List ->> Card: props
    Card ->> Card: propsの変更を監視・検索API叩く
    Card -->> User: 検索結果
    User ->> Card: 検索条件変更（ページャーやステータス）
    Card -->> List: emit
    List -->> List: クエリパラメータの変更・ヒストリー保存
    List ->> Card: props
    Card ->> Card: propsの変更を監視・検索API叩く
    Card -->> User: 検索結果

```

# 人材募集

弊社グループでは一緒に働くメンバーを募集中です、ご応募お待ちしています！

https://herp.careers/v1/wano