Vuetify3 VDataTableのソート機能の実装 カスタマイズ編
はじめに
前回は、VDataTableのソート機能の基本的な実装方法について解説しました。
今回は前回の記事で説明することができなかったソート機能のカスタマイズ方法について、解説していきます!前回のソースコードの問題点
カスタマイズするにも、まずベースは必要ですので、まずは前回紹介したソート機能付きのVDataTableを用意します。
前回紹介したたソート機能付きのVDataTableのソースコードは以下のとおりです。
<script lang="ts">
type SortItem = {
key: string;
order?: 'asc' | 'desc';
};
type Header = {
readonly title?: string;
readonly align?: "center" | "end" | "start";
readonly sortable: boolean;
readonly key?: string;
};
export default {
data () {
const sortBy: readonly SortItem[] = [{ key: 'calories', order: 'asc' }];
const headers: readonly Header[] = [
{
title: 'Dessert (100g serving)',
align: 'start',
sortable: false,
key: 'name',
},
{
title: 'Calories',
sortable: true,
key: 'calories'
},
{
title: 'Fat (g)',
sortable: true,
key: 'fat'
},
{
title: 'Carbs (g)',
sortable: true,
key: 'carbs'
},
{
title: 'Protein (g)',
sortable: true,
key: 'protein'
},
{
title: 'Iron (%)',
sortable: true,
key: 'iron'
},
];
const desserts = [
{
name: 'Frozen Yogurt',
calories: 200,
fat: 6.0,
carbs: 24,
protein: 4.0,
iron: '1%',
},
{
name: 'Ice cream sandwich',
calories: 200,
fat: 9.0,
carbs: 37,
protein: 4.3,
iron: '1%',
},
{
name: 'Eclair',
calories: 300,
fat: 16.0,
carbs: 23,
protein: 6.0,
iron: '7%',
},
{
name: 'Cupcake',
calories: 300,
fat: 3.7,
carbs: 67,
protein: 4.3,
iron: '8%',
},
{
name: 'Gingerbread',
calories: 400,
fat: 16.0,
carbs: 49,
protein: 3.9,
iron: '16%',
},
{
name: 'Jelly bean',
calories: 400,
fat: 0.0,
carbs: 94,
protein: 0.0,
iron: '0%',
},
{
name: 'Lollipop',
calories: 400,
fat: 0.2,
carbs: 98,
protein: 0,
iron: '2%',
},
{
name: 'Honeycomb',
calories: 400,
fat: 3.2,
carbs: 87,
protein: 6.5,
iron: '45%',
},
{
name: 'Donut',
calories: 500,
fat: 25.0,
carbs: 51,
protein: 4.9,
iron: '22%',
},
{
name: 'KitKat',
calories: 500,
fat: 26.0,
carbs: 65,
protein: 7,
iron: '6%',
},
];
return {
sortBy: sortBy,
headers: headers,
desserts: desserts,
};
},
}
</script>
<template>
<v-app>
<v-sheet
class="pa-2"
width="894"
>
<v-data-table
v-model:sort-by="sortBy"
:headers="headers"
:items="desserts"
></v-data-table>
</v-sheet>
</v-app>
</template>
各列でソート可能とするかは、headersオブジェクトのsortableキーで制御します。
この例でいえばDessertの列以外はソート可能となっています。
では、さっそく実際に動かしてみましょう。
………どうでしょう。
ヘッダーのIronをクリックして、Ironのデータを昇順にソートしたとき、こう思ったはずです。
あれ…順番おかしい…
デフォルトのソートの中身
なぜこのような順番になるのでしょうか?
勘のいい方なら、他のCaloriesなどのデータとは違い、Ironのデータには%が付いているので、数値ではなく、文字列での比較になっているのではないかと気づくかと思います。
文字列として比較した場合、先頭の文字から文字コードの大小を比較します。そのため、16%と2%では一文字目の1(文字コードU+0031)と2(文字コードU+0032)で比較して、この時点で16%の方が小さいと判定されているわけです。
でも本当にそうなのでしょうか?
文字列として比較されているというのは、あくまでも動作からの推測です。
さっそくIron列のソートのカスタマイズをしてもよいのですが、VuetifyはGitHubでソースコードを見ることができますので、まずはVDataTableのデフォルトのソートが本当にそのような仕様になっているのかをソースコードで確認してみましょう。
以下が現時点(2024年3月12日)のデフォルトのソートです。
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
})
}
このうち、カスタムソートの部分を除外すると以下のようになります。
export function sortItems<T extends Record<string, any>> (
items: T[],
sortByItems: readonly SortItem[],
locale: string,
): 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)
// ①降順(desc)の場合は比較対象のデータ(sortA、sortB)を入れ替える
if (sortOrder === 'desc') {
[sortA, sortB] = [sortB, sortA]
}
// ②比較対象のデータ(sortA、sortB)がDateのインスタンスなら、経過ミリ秒数の値を比較して返す
// Dates should be compared numerically
if (sortA instanceof Date && sortB instanceof Date) {
return sortA.getTime() - sortB.getTime()
}
// ③比較対象のデータ(sortA、sortB)をどちらも小文字に変換する
[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
})
}
だいぶ読みやすくなりましたので、このコードを読んでみましょう。
設定や準備部分を除く、データの比較処理の部分は、以下のようになっていると読みとれます。
①降順(desc)の場合は比較対象のデータ(sortA、sortB)を入れ替える
②比較対象のデータ(sortA、sortB)がDateのインスタンスなら、経過ミリ秒数の値を比較して返す
③比較対象のデータ(sortA、sortB)をどちらも小文字に変換する
④比較対象のデータが同じなら並び替えをしない
⑤比較対象のデータのどちらかが空かどうかで判定して結果を返す
(空の方が小さい扱い)
⑥比較対象のデータを数値化した値で比較して結果を返す
(数値化できない文字列の場合は比較しない)
⑦比較対象のデータを文字列として比較して結果を返す
以上より、⑥で数値として比較ができない場合、⑦で文字列として比較されるということが、ソースコードから読み取ることができます。
今回の実装例でIron列の%付きの値が文字列でのソートとなったのも、これで納得がいきます。
※ここで、Intl.Collatorについての説明を書こうとしたら、今回の本筋から外れていってしまったので、Intl.Collatorの説明については省略…
ソートのカスタマイズ
ここまででデフォルトのソートのおおよその動作は理解できたかと思います。
やはり、デフォルトのソートでIron列のデータを、%を除いた数値部分で比較した並び順になるようにソートすることはできないようです。
というわけで、ここからが本番です!ソートをカスタマイズしてみましょう。
VDataTableでソートのカスタマイズをしたい場合には、VDataTableのcustom-key-sortプロパティを利用するか、VDataTableのheadersに代入している配列のオブジェクトにsortキーを付加します。
※v3.5.0以降はsortRawキーも利用できますが、このケースでは利用するメリットがないため、別の記事で説明しようかと思います。
それぞれ実装したソースコードは以下のようになります。
custom-key-sortを利用する場合
<script lang="ts">
type SortItem = {
key: string;
order?: 'asc' | 'desc';
};
type Header = {
readonly title?: string;
readonly align?: "center" | "end" | "start";
readonly sortable: boolean;
readonly key?: string;
};
export default {
data () {
const sortBy: readonly SortItem[] = [{ key: 'calories', order: 'asc' }];
const headers: readonly Header[] = [
{
title: 'Dessert (100g serving)',
align: 'start',
sortable: false,
key: 'name',
},
{
title: 'Calories',
sortable: true,
key: 'calories'
},
{
title: 'Fat (g)',
sortable: true,
key: 'fat'
},
{
title: 'Carbs (g)',
sortable: true,
key: 'carbs'
},
{
title: 'Protein (g)',
sortable: true,
key: 'protein'
},
{
title: 'Iron (%)',
sortable: true,
key: 'iron'
},
];
const desserts = [
{
name: 'Frozen Yogurt',
calories: 200,
fat: 6.0,
carbs: 24,
protein: 4.0,
iron: '1%',
},
{
name: 'Ice cream sandwich',
calories: 200,
fat: 9.0,
carbs: 37,
protein: 4.3,
iron: '1%',
},
{
name: 'Eclair',
calories: 300,
fat: 16.0,
carbs: 23,
protein: 6.0,
iron: '7%',
},
{
name: 'Cupcake',
calories: 300,
fat: 3.7,
carbs: 67,
protein: 4.3,
iron: '8%',
},
{
name: 'Gingerbread',
calories: 400,
fat: 16.0,
carbs: 49,
protein: 3.9,
iron: '16%',
},
{
name: 'Jelly bean',
calories: 400,
fat: 0.0,
carbs: 94,
protein: 0.0,
iron: '0%',
},
{
name: 'Lollipop',
calories: 400,
fat: 0.2,
carbs: 98,
protein: 0,
iron: '2%',
},
{
name: 'Honeycomb',
calories: 400,
fat: 3.2,
carbs: 87,
protein: 6.5,
iron: '45%',
},
{
name: 'Donut',
calories: 500,
fat: 25.0,
carbs: 51,
protein: 4.9,
iron: '22%',
},
{
name: 'KitKat',
calories: 500,
fat: 26.0,
carbs: 65,
protein: 7,
iron: '6%',
},
];
return {
sortBy: sortBy,
headers: headers,
desserts: desserts,
};
},
}
</script>
<template>
<v-app>
<v-sheet
class="pa-2"
width="894"
>
<v-data-table
v-model:sort-by="sortBy"
:headers="headers"
:items="desserts"
:custom-key-sort="{iron: (a, b) => {
a = a.replace(/[^0-9]/g, '');
b = b.replace(/[^0-9]/g, '');
return a - b;
}}"
></v-data-table>
</v-sheet>
</v-app>
</template>
headersに代入している配列のオブジェクトにsortキーを付加する場合
<script lang="ts">
type SortItem = {
key: string;
order?: 'asc' | 'desc';
};
type Header = {
readonly title?: string;
readonly align?: "center" | "end" | "start";
readonly sortable: boolean;
readonly key?: string;
readonly sort?: (a: any, b: any) => number;
};
export default {
data () {
const sortBy: readonly SortItem[] = [{ key: 'calories', order: 'asc' }];
const headers: readonly Header[] = [
{
title: 'Dessert (100g serving)',
align: 'start',
sortable: false,
key: 'name',
},
{
title: 'Calories',
sortable: true,
key: 'calories'
},
{
title: 'Fat (g)',
sortable: true,
key: 'fat'
},
{
title: 'Carbs (g)',
sortable: true,
key: 'carbs'
},
{
title: 'Protein (g)',
sortable: true,
key: 'protein'
},
{
title: 'Iron (%)',
sortable: true,
key: 'iron',
sort: (a, b) => {
a = a.replace(/[^0-9]/g, '');
b = b.replace(/[^0-9]/g, '');
return a - b;
}
},
];
const desserts = [
{
name: 'Frozen Yogurt',
calories: 200,
fat: 6.0,
carbs: 24,
protein: 4.0,
iron: '1%',
},
{
name: 'Ice cream sandwich',
calories: 200,
fat: 9.0,
carbs: 37,
protein: 4.3,
iron: '1%',
},
{
name: 'Eclair',
calories: 300,
fat: 16.0,
carbs: 23,
protein: 6.0,
iron: '7%',
},
{
name: 'Cupcake',
calories: 300,
fat: 3.7,
carbs: 67,
protein: 4.3,
iron: '8%',
},
{
name: 'Gingerbread',
calories: 400,
fat: 16.0,
carbs: 49,
protein: 3.9,
iron: '16%',
},
{
name: 'Jelly bean',
calories: 400,
fat: 0.0,
carbs: 94,
protein: 0.0,
iron: '0%',
},
{
name: 'Lollipop',
calories: 400,
fat: 0.2,
carbs: 98,
protein: 0,
iron: '2%',
},
{
name: 'Honeycomb',
calories: 400,
fat: 3.2,
carbs: 87,
protein: 6.5,
iron: '45%',
},
{
name: 'Donut',
calories: 500,
fat: 25.0,
carbs: 51,
protein: 4.9,
iron: '22%',
},
{
name: 'KitKat',
calories: 500,
fat: 26.0,
carbs: 65,
protein: 7,
iron: '6%',
},
];
return {
sortBy: sortBy,
headers: headers,
desserts: desserts,
};
},
}
</script>
<template>
<v-app>
<v-sheet
class="pa-2"
width="894"
>
<v-data-table
v-model:sort-by="sortBy"
:headers="headers"
:items="desserts"
></v-data-table>
</v-sheet>
</v-app>
</template>
今回はcustom-key-sort、headersのsortキーに以下の比較関数をコールバック関数として代入しています。
(a, b) => {
a = a.replace(/[^0-9]/g, '');
b = b.replace(/[^0-9]/g, '');
return a - b;
}
「replace(/[^0-9]/g, '')」で正規表現を用いて、0~9以外の文字があった場合は空文字に置換しています。"16%"であれば、"%"が空文字に置換されますので、"16"となります。
また、JavaScriptの仕様上、数字の足し算は文字列の結合「"43"+"21"は"4321"」になりますが、引き算の場合は、JavaScriptの暗黙的な型変換で文字列が数値に変換され、「"43"-"21"は22」のようになるので、「return a - b;」で比較結果として数値を返すことができます。
今回はデータに%が付いているケースでしたが、金額でカンマが三桁ごとについているようなケース(999,999,999)のデータでも、この比較関数で対応できます。
では、実際に動かしてみましょう。
%を除いた数値部分で比較した並び順になっていることが確認できました!
より汎用的なソートにカスタマイズ
これで完了としてもよいのですが、もう少し考えてみましょう。
今回の例では、Iron列のデータが全て%付きの値でしたが、もし空文字が入ってきたらどうなるのでしょう?
Iron列のデータに空文字を入れてみましょう。
<script lang="ts">
type SortItem = {
key: string;
order?: 'asc' | 'desc';
};
type Header = {
readonly title?: string;
readonly align?: "center" | "end" | "start";
readonly sortable: boolean;
readonly key?: string;
readonly sort?: (a: any, b: any) => number;
};
export default {
data () {
const sortBy: readonly SortItem[] = [{ key: 'calories', order: 'asc' }];
const headers: readonly Header[] = [
{
title: 'Dessert (100g serving)',
align: 'start',
sortable: false,
key: 'name',
},
{
title: 'Calories',
sortable: true,
key: 'calories'
},
{
title: 'Fat (g)',
sortable: true,
key: 'fat'
},
{
title: 'Carbs (g)',
sortable: true,
key: 'carbs'
},
{
title: 'Protein (g)',
sortable: true,
key: 'protein'
},
{
title: 'Iron (%)',
sortable: true,
key: 'iron',
sort: (a, b) => {
a = a.replace(/[^0-9]/g, '');
b = b.replace(/[^0-9]/g, '');
return a - b;
}
},
];
const desserts = [
{
name: 'Frozen Yogurt',
calories: 200,
fat: 6.0,
carbs: 24,
protein: 4.0,
iron: '1%',
},
{
name: 'Ice cream sandwich',
calories: 200,
fat: 9.0,
carbs: 37,
protein: 4.3,
iron: '1%',
},
{
name: 'Eclair',
calories: 300,
fat: 16.0,
carbs: 23,
protein: 6.0,
iron: '7%',
},
{
name: 'Cupcake',
calories: 300,
fat: 3.7,
carbs: 67,
protein: 4.3,
iron: '8%',
},
{
name: 'Gingerbread',
calories: 400,
fat: 16.0,
carbs: 49,
protein: 3.9,
iron: '16%',
},
{
name: 'Jelly bean',
calories: 400,
fat: 0.0,
carbs: 94,
protein: 0.0,
iron: '0%',
},
{
name: 'Lollipop',
calories: 400,
fat: 0.2,
carbs: 98,
protein: 0,
iron: '2%',
},
{
name: 'Honeycomb',
calories: 400,
fat: 3.2,
carbs: 87,
protein: 6.5,
iron: '45%',
},
{
name: 'Donut',
calories: 500,
fat: 25.0,
carbs: 51,
protein: 4.9,
iron: '22%',
},
{
name: 'KitKat',
calories: 500,
fat: 26.0,
carbs: 65,
protein: 7,
iron: '',
},
];
return {
sortBy: sortBy,
headers: headers,
desserts: desserts,
};
},
}
</script>
<template>
<v-app>
<v-sheet
class="pa-2"
width="894"
>
<v-data-table
v-model:sort-by="sortBy"
:headers="headers"
:items="desserts"
></v-data-table>
</v-sheet>
</v-app>
</template>
Iron列の最後のデータを空文字にしてみました。
これを先ほどの比較関数で並び替えると以下のようになります。
0%と1%の間に空文字が入ってしまいました…
これは引き算のとき、空文字がJavaScriptの暗黙的な型変換で0になるためで、先ほどの比較関数では0%も空文字も同じ値という扱いになっているからです。
(例として、「1-""は1」、「""-""は0」になります。)
せっかくデフォルトのソートの内容も確認していますので、デフォルトのソートを参考に以下のように、もう少し汎用的なソートに変えてみましょう。
<script lang="ts">
const stringCollator = new Intl.Collator("en", { sensitivity: 'accent', usage: 'sort' });
const isEmpty = (val: any) => {
return val === null || val === undefined || (typeof val === 'string' && val.trim() === '')
};
const sortNumbers = (sortA: any, sortB: any) => {
[sortA, sortB] = [sortA, sortB].map(s => s != null ? s.toString().toLocaleLowerCase() : s);
if (sortA === sortB) return 0;
if (isEmpty(sortA) && isEmpty(sortB)) return 0;
if (isEmpty(sortA)) return -1;
if (isEmpty(sortB)) return 1;
sortA = sortA.replace(/[^0-9]/g, '');
sortB = sortB.replace(/[^0-9]/g, '');
if (!isNaN(sortA) && !isNaN(sortB)) return Number(sortA) - Number(sortB);
return stringCollator.compare(sortA, sortB);
};
type SortItem = {
key: string;
order?: 'asc' | 'desc';
};
type Header = {
readonly title?: string;
readonly align?: "center" | "end" | "start";
readonly sortable: boolean;
readonly key?: string;
readonly sort?: (a: any, b: any) => number;
};
export default {
data () {
const sortBy: readonly SortItem[] = [{ key: 'calories', order: 'asc' }];
const headers: readonly Header[] = [
{
title: 'Dessert (100g serving)',
align: 'start',
sortable: false,
key: 'name',
},
{
title: 'Calories',
sortable: true,
key: 'calories'
},
{
title: 'Fat (g)',
sortable: true,
key: 'fat'
},
{
title: 'Carbs (g)',
sortable: true,
key: 'carbs'
},
{
title: 'Protein (g)',
sortable: true,
key: 'protein'
},
{
title: 'Iron (%)',
sortable: true,
key: 'iron',
sort: sortNumbers
},
];
const desserts = [
{
name: 'Frozen Yogurt',
calories: 200,
fat: 6.0,
carbs: 24,
protein: 4.0,
iron: '1%',
},
{
name: 'Ice cream sandwich',
calories: 200,
fat: 9.0,
carbs: 37,
protein: 4.3,
iron: '1%',
},
{
name: 'Eclair',
calories: 300,
fat: 16.0,
carbs: 23,
protein: 6.0,
iron: '7%',
},
{
name: 'Cupcake',
calories: 300,
fat: 3.7,
carbs: 67,
protein: 4.3,
iron: '8%',
},
{
name: 'Gingerbread',
calories: 400,
fat: 16.0,
carbs: 49,
protein: 3.9,
iron: '16%',
},
{
name: 'Jelly bean',
calories: 400,
fat: 0.0,
carbs: 94,
protein: 0.0,
iron: '0%',
},
{
name: 'Lollipop',
calories: 400,
fat: 0.2,
carbs: 98,
protein: 0,
iron: '2%',
},
{
name: 'Honeycomb',
calories: 400,
fat: 3.2,
carbs: 87,
protein: 6.5,
iron: '45%',
},
{
name: 'Donut',
calories: 500,
fat: 25.0,
carbs: 51,
protein: 4.9,
iron: '22%',
},
{
name: 'KitKat',
calories: 500,
fat: 26.0,
carbs: 65,
protein: 7,
iron: '',
},
];
return {
sortBy: sortBy,
headers: headers,
desserts: desserts,
};
},
}
</script>
<template>
<v-app>
<v-sheet
class="pa-2"
width="894"
>
<v-data-table
v-model:sort-by="sortBy"
:headers="headers"
:items="desserts"
></v-data-table>
</v-sheet>
</v-app>
</template>
sortNumbersという比較関数を作成してみました。
デフォルトの比較関数に先ほどの正規表現による処理を差し込んだような比較関数にしています。
これで空文字があっても、うまく動作するはずです。
もう一度動かしてみましょう。
今度はIron列を昇順で並べたとき、空文字が一番上にくるようになしました!
ただし、紹介しておいてなんですが…この比較関数の方が先に紹介したものよりも優れているということでもありません。
このソートの処理自体は特に遅くはないかと思いますが、汎用性と処理速度はトレードオフになる場合もありますので、想定されるデータに合わせた比較関数を用意することが肝要です。
まとめ
以上が前回説明できなかったソート機能のカスタマイズ方法になります。
ただ、今回はIntl.CollatorやsortRawキーの説明は省略してしまいました…
なので、Intl.CollatorやsortRawキーについては別の記事で説明できればと思います以下の記事に記載しました。
今回の最終的なソースコードは以下に上げていますので、参考としてください。
参考文献
この記事は以下の情報を参考にして執筆しました。
Discussion