🐉

Vuetify3 VDataTableのソート機能の実装 日本語仕様編

はじめに

前回は、VDataTableのソート機能のカスタマイズ方法について解説しました。
https://zenn.dev/iandcinc/articles/4737a3e08bcc6d
今回は前回の記事で説明することができなかったIntl.CollatorやsortRawキーの説明も交えて、VDataTableのソート機能を日本語仕様とする方法について、解説していきます。

ベースとなるサンプルの作成

まずはベースとなるサンプルをつくりましょう。
Vuetifyの公式ページには多くのVDataTableの実装例が紹介されていますが、データに「漢字」などの入った実装例はありませので、今回は、私の方でベースとなるサンプルを用意しました。
サンプルとしては、皆さんになじみのあるデータがよいかと思いましたので、皆様ご存知、あの尼子十勇士をデータとして使用しました!

App.vue
<script lang="ts">
  type Header = {
    readonly title?: string;
    readonly align?: "center" | "end" | "start";
    readonly width?: string | number | undefined;
    readonly sortable: boolean;
    readonly key?: string;
    readonly sort?: (a: any, b: any) => number;
  };
  export default {
    data () {
      const headers: readonly Header[] = [
          {
            title: 'No.',
            align: 'center',
            width: 45,
            sortable: false,
            key: 'number',
          },
          {
            title: '武将名',
            align: 'start',
            sortable: true,
            key: 'name',
          },
          {
            title: '元の名',
            sortable: false,
            key: 'originalName'
          },
          {
            title: '人物の信憑性',
            sortable: true,
            key: 'exist'
          },
        ];
      const members = [
          {
            number: 1,
            name: '山中鹿之助',
            ruby: 'ヤマナカシカノスケ',
            originalName: '',
            exist: '実在する',
          },
          {
            number: 2,
            name: '大谷古猪之助',
            ruby: 'オオタニコイノスケ',
            originalName: '大谷猪之助',
            exist: '',
          },
          {
            number: 3,
            name: '早川鮎之助',
            ruby: 'ハヤカワアユノスケ',
            originalName: '吉田七助',
            exist: '',
          },
          {
            number: 4,
            name: '横道兵庫之助',
            ruby: 'ヨコミチヒョウゴノスケ',
            originalName: '不明',
            exist: '実在する',
          },
          {
            number: 5,
            name: '寺本生死之助',
            ruby: 'テラモトセイシノスケ',
            originalName: '寺元半四郎',
            exist: '実在の可能性がある',
          },
          {
            number: 6,
            name: '皐月早苗之助',
            ruby: 'サツキサナエノスケ',
            originalName: '不明',
            exist: '実在の可能性がある',
          },
          {
            number: 7,
            name: '高橋渡之助',
            ruby: 'タカハシワタリノスケ',
            originalName: '不明',
            exist: '',
          },
          {
            number: 8,
            name: '秋宅庵之助',
            ruby: 'アキヤケイオリノスケ',
            originalName: '秋宅甚助',
            exist: '実在する',
          },
          {
            number: 9,
            name: '薮中茨之助',
            ruby: 'ヤブナカイバラノスケ',
            originalName: '藪中卯之助',
            exist: '実在の可能性がある',
          },
          {
            number: 10,
            name: '荒波碇之助',
            ruby: 'アラナミイカリノスケ',
            originalName: '徳蔵',
            exist: '',
          },
        ];
      return {
        headers: headers,
        members: members,
      };
    },
  }
</script>

<template>
  <v-app>
    <v-sheet
      class="pa-2"
      width="700"
    >
      <v-data-table
        :headers="headers"
        :items="members"
      ></v-data-table>
    </v-sheet>
  </v-app>
</template>

既知かと思いますが、念のため…
尼子十勇士は「願わくば、我に七難八苦を与えたまえ」で有名なドMの人「山陰の麒麟児」と呼ばれた山中鹿之助を中心とした尼子氏の復興に勤めたとされる10人の勇士です。
それはそれとして、さっそく実際に動かしてみましょう。
VDataTableソート機能日本語化ベース
ヘッダーの武将名をクリックして、武将名を昇順で並び替えてみました。
あれ…並びがおかしい…

Intl.Collatorのlocaleで日本語仕様に変更

漢字の読み順でもないですし、画数順でもないようです。
前回も紹介したVDataTableのデフォルトのソートをもう一度確認してみましょう。
以下が現時点(2024年3月19日)のデフォルトのソートです。

sort.ts
export function sortItems<T extends Record<string, any>> (
  items: T[],
  sortByItems: readonly SortItem[],
  locale: string,
  customSorters?: Record<string, DataTableCompareFunction>,
  customRawSorters?: Record<string, DataTableCompareFunction>,
): T[] {
  const stringCollator = new Intl.Collator(locale, { sensitivity: 'accent', usage: 'sort' })

  return [...items].sort((a, b) => {
    for (let i = 0; i < sortByItems.length; i++) {
      const sortKey = sortByItems[i].key
      const sortOrder = sortByItems[i].order ?? 'asc'

      if (sortOrder === false) continue

      let sortA = getObjectValueByPath(a.raw, sortKey)
      let sortB = getObjectValueByPath(b.raw, sortKey)
      let sortARaw = a.raw
      let sortBRaw = b.raw

      if (sortOrder === 'desc') {
        [sortA, sortB] = [sortB, sortA]
        ;[sortARaw, sortBRaw] = [sortBRaw, sortARaw]
      }

      if (customRawSorters?.[sortKey]) {
        const customResult = customRawSorters[sortKey](sortARaw, sortBRaw)

        if (!customResult) continue

        return customResult
      }

      if (customSorters?.[sortKey]) {
        const customResult = customSorters[sortKey](sortA, sortB)

        if (!customResult) continue

        return customResult
      }

      // Dates should be compared numerically
      if (sortA instanceof Date && sortB instanceof Date) {
        return sortA.getTime() - sortB.getTime()
      }

      [sortA, sortB] = [sortA, sortB].map(s => s != null ? s.toString().toLocaleLowerCase() : s)

      if (sortA !== sortB) {
        if (isEmpty(sortA) && isEmpty(sortB)) return 0
        if (isEmpty(sortA)) return -1
        if (isEmpty(sortB)) return 1
        if (!isNaN(sortA) && !isNaN(sortB)) return Number(sortA) - Number(sortB)
        return stringCollator.compare(sortA, sortB)
      }
    }

    return 0
  })
}

色々な処理がされていますが、漢字の並べ替えに関係があるのは、以下の二カ所ですので、ここに注目してみましょう。

  const stringCollator = new Intl.Collator(locale, { sensitivity: 'accent', usage: 'sort' })
        return stringCollator.compare(sortA, sortB)

Intl.Collator()コンストラクターは、言語を考慮した文字列の比較を可能にする Intl.Collatorオブジェクトを生成することができるコンストラクターです。
Intl.Collator()コンストラクターの第一引数localeはVuetifyのデフォルトで'en'(英語)、第二引数optionsは{ sensitivity: 'accent', usage: 'sort' }になっています。
localeは考慮する言語で、このサンプルでは'en'(英語)なので、漢字は考慮されていません。
このため、漢字の並び順は読み順でも画数順でもなく、文字コード順となっています。
「大、寺、山、早、横、皐、秋、荒、藪、高」を文字コードで表すと「U+5927、U+5BFA、U+5C71、U+65E9、U+6A6B、U+7690、U+79CB、U+8352、U+85EA、U+9AD8」ですので、並びに違和感はありますが、文字コードによって、正しく昇順で並べ替えられてはいます。
ここで、勘のいい方なら第一引数のlocaleを'ja'(日本語)に替えることができるのではないかと考えるかと思います。
実際、考慮する言語はVLocaleProviderのlocaleで指定できます。
ちょっと試してみましょう。

App.vue
<script lang="ts">
  type Header = {
    readonly title?: string;
    readonly align?: "center" | "end" | "start";
    readonly width?: string | number | undefined;
    readonly sortable: boolean;
    readonly key?: string;
    readonly sort?: (a: any, b: any) => number;
  };
  export default {
    data () {
      const headers: readonly Header[] = [
          {
            title: 'No.',
            align: 'center',
            width: 45,
            sortable: false,
            key: 'number',
          },
          {
            title: '武将名',
            align: 'start',
            sortable: true,
            key: 'name',
          },
          {
            title: '元の名',
            sortable: false,
            key: 'originalName'
          },
          {
            title: '人物の信憑性',
            sortable: true,
            key: 'exist'
          },
        ];
      const members = [
          {
            number: 1,
            name: '山中鹿之助',
            ruby: 'ヤマナカシカノスケ',
            originalName: '',
            exist: '実在する',
          },
          {
            number: 2,
            name: '大谷古猪之助',
            ruby: 'オオタニコイノスケ',
            originalName: '大谷猪之助',
            exist: '',
          },
          {
            number: 3,
            name: '早川鮎之助',
            ruby: 'ハヤカワアユノスケ',
            originalName: '吉田七助',
            exist: '',
          },
          {
            number: 4,
            name: '横道兵庫之助',
            ruby: 'ヨコミチヒョウゴノスケ',
            originalName: '不明',
            exist: '実在する',
          },
          {
            number: 5,
            name: '寺本生死之助',
            ruby: 'テラモトセイシノスケ',
            originalName: '寺元半四郎',
            exist: '実在の可能性がある',
          },
          {
            number: 6,
            name: '皐月早苗之助',
            ruby: 'サツキサナエノスケ',
            originalName: '不明',
            exist: '実在の可能性がある',
          },
          {
            number: 7,
            name: '高橋渡之助',
            ruby: 'タカハシワタリノスケ',
            originalName: '不明',
            exist: '',
          },
          {
            number: 8,
            name: '秋宅庵之助',
            ruby: 'アキヤケイオリノスケ',
            originalName: '秋宅甚助',
            exist: '実在する',
          },
          {
            number: 9,
            name: '薮中茨之助',
            ruby: 'ヤブナカイバラノスケ',
            originalName: '藪中卯之助',
            exist: '実在の可能性がある',
          },
          {
            number: 10,
            name: '荒波碇之助',
            ruby: 'アラナミイカリノスケ',
            originalName: '徳蔵',
            exist: '',
          },
        ];
      return {
        headers: headers,
        members: members,
      };
    },
  }
</script>

<template>
  <v-app>
    <v-sheet
      class="pa-2"
      width="700"
    >
      <v-locale-provider locale="ja">
        <v-data-table
          :headers="headers"
          :items="members"
        ></v-data-table>
      </v-locale-provider>
    </v-sheet>
  </v-app>
</template>

VLocaleProviderのlocaleを'ja'(日本語)にしてみました。
これでどのようになるか、実際に動かしてみましょう。
VDataTableソート機能日本語化ロケール変更
再度、ヘッダーの武将名をクリックして、武将名を昇順で並び替えてみました。
あれ……微妙だな………
ここで注意していただきたい点があります。
Intl.Collatorは、考慮する言語を日本語とした場合、漢字については読みに従います。
読みに従うので、[一(いち), 二(に), 三(さん)]の昇順での並びは[一(いち), 三(さん), 二(に)]になりますし、また、読みといっても漢字によっては複数あるので「鈴木」の「鈴」が「レイ」という読みで扱われるなど、私達が考える「正しい読み」とは異なる場合があります。
Intl.Collatorの仕様として、音読みが優先されるため、今回のサンプルでは「秋宅庵之助」が一番上にきてほしいところですが、「秋」が「あき」ではなく「シュウ」として扱われているので、一番上にこないようです。
さて、どうしよう………

sortRawでソートをカスタマイズ

ここで前回紹介できなかったsortRawキーの出番です!
Vuetifyのv3.5.0以降はsortRawキーが利用できます。
sortでは、特定の列のデータしか参照できませんでしたが、sortRawを使用すると、すべての値にアクセスできるようになります。
ここまで、データとして用意したmembersという配列内のオブジェクトにruby(フリガナ)というキーがあるにも関わらず、このキーは使用していませんでした。
sortRawを使用するとテーブルに表示していないrubyも参照できるので、ここで活用してみましょう。
(最初から、どうせ、これ(ruby)を使うのだろうと思った方も多いかもしれません…)
sortRawでrubyを参照する場合は以下のようにします。

App.vue
<script lang="ts">
  const stringCollator = new Intl.Collator("ja", { sensitivity: 'accent', usage: 'sort' });
  const isEmpty = (val: any) => {
    return val === null || val === undefined || (typeof val === 'string' && val.trim() === '')
  };
  type Member = {
    number: number;
    name: string;
    ruby: string;
    originalName: string;
    exist: string;
  };
  const sortName = (sortA: Member, sortB: Member) => {
    const [rubyA, rubyB] = [sortA.ruby, sortB.ruby].map(s => s != null ? s.toString().toLocaleLowerCase() : s);
    if (rubyA === rubyB) return 0;
    if (isEmpty(rubyA) && isEmpty(rubyB)) return 0;
    if (isEmpty(rubyA)) return -1;
    if (isEmpty(rubyB)) return 1;
    return stringCollator.compare(rubyA, rubyB);
  };
  type Header = {
    readonly title?: string;
    readonly align?: "center" | "end" | "start";
    readonly width?: string | number | undefined;
    readonly sortable: boolean;
    readonly key?: string;
    readonly sortRaw?: (a: any, b: any) => number;
  };
  export default {
    data () {
      const headers: readonly Header[] = [
          {
            title: 'No.',
            align: 'center',
            width: 45,
            sortable: false,
            key: 'number',
          },
          {
            title: '武将名',
            align: 'start',
            sortable: true,
            key: 'name',
            sortRaw: sortName,
          },
          {
            title: '元の名',
            sortable: false,
            key: 'originalName'
          },
          {
            title: '人物の信憑性',
            sortable: true,
            key: 'exist'
          },
        ];
      const members: Member[] = [
          {
            number: 1,
            name: '山中鹿之助',
            ruby: 'ヤマナカシカノスケ',
            originalName: '',
            exist: '実在する',
          },
          {
            number: 2,
            name: '大谷古猪之助',
            ruby: 'オオタニコイノスケ',
            originalName: '大谷猪之助',
            exist: '',
          },
          {
            number: 3,
            name: '早川鮎之助',
            ruby: 'ハヤカワアユノスケ',
            originalName: '吉田七助',
            exist: '',
          },
          {
            number: 4,
            name: '横道兵庫之助',
            ruby: 'ヨコミチヒョウゴノスケ',
            originalName: '不明',
            exist: '実在する',
          },
          {
            number: 5,
            name: '寺本生死之助',
            ruby: 'テラモトセイシノスケ',
            originalName: '寺元半四郎',
            exist: '実在の可能性がある',
          },
          {
            number: 6,
            name: '皐月早苗之助',
            ruby: 'サツキサナエノスケ',
            originalName: '不明',
            exist: '実在の可能性がある',
          },
          {
            number: 7,
            name: '高橋渡之助',
            ruby: 'タカハシワタリノスケ',
            originalName: '不明',
            exist: '',
          },
          {
            number: 8,
            name: '秋宅庵之助',
            ruby: 'アキヤケイオリノスケ',
            originalName: '秋宅甚助',
            exist: '実在する',
          },
          {
            number: 9,
            name: '薮中茨之助',
            ruby: 'ヤブナカイバラノスケ',
            originalName: '藪中卯之助',
            exist: '実在の可能性がある',
          },
          {
            number: 10,
            name: '荒波碇之助',
            ruby: 'アラナミイカリノスケ',
            originalName: '徳蔵',
            exist: '',
          },
        ];
      return {
        headers: headers,
        members: members,
      };
    },
  }
</script>

<template>
  <v-app>
    <v-sheet
      class="pa-2"
      width="700"
    >
      <v-data-table
        :headers="headers"
        :items="members"
      ></v-data-table>
    </v-sheet>
  </v-app>
</template>

rubyで比較を行うsortName関数を作成して、sortRawで使用するようにしています。
では、動かしてみましょう。
VDataTableソート機能日本語化完成
またまた、ヘッダーの武将名をクリックして、武将名を昇順で並び替えてみました。
今度はフリガナ順で、武将名を並べることができました!

まとめ

以上がVDataTableのソートを日本語仕様とする方法と前回説明できなかったIntl.CollatorやsortRowキーの説明になります。
今回の最終的なソースコードは以下に上げていますので、参考としてください。
https://github.com/y-kashima-iandc/vuetify-datatable-with-customsort-ja

参考文献

この記事は以下の情報を参考にして執筆しました。

株式会社アイアンドシー Tech Blog

Discussion