要約
<sctipt>
const arr = $state([]);
</sctipt>
{#each data as item, i}
<div bind:this={arr[i]}></div>
{/each}
をやめて
<sctipt>
const map = new Map(); // or new SvelteMap()
</sctipt>
{#each data as item, i}
<div bind:this={
() => map.get(i),
(div) => {
if (div != null) {
map.set(i, div);
} else {
map.delete(i);
}
}
}></div>
{/each}
にしたほうが幸せになります。
その理由を知りたい方は、さらに記事を読み進めてください。
bind:this
Svelteのbind:this
とは、テンプレート内の要素やSvelteコンポーネントの参照オブジェクトを取得できる機能です。
具体的な使い方はここに書くより公式ドキュメント
を見たほうが早いでしょう。
{#each} で bind:this を使う
Svelteコンポーネントを実装しているとよく以下のようなケースに出くわします。
{#each data as item}
<div bind:this={このdivをbindしたい!}></div>
{/each}
あなたならどうするでしょうか。
{#each}
でないケースなら、普通は変数を一つ用意してそこにbindすると思います。
しかし {#each}
の中の要素 (コンポーネント) だとそうは行きません。
なぜなら data
の数によって <div>
要素が増えたり減ったりしてしまうからです。
そして、そこでよく思いつくのが配列の要素にbindするです。
{#each data as item, i}
<div bind:this={arr[i]}></div>
{/each}
こう書くだけで、arr
の i 番目の要素に i 番目の div が代入されます。
非常に直感的ですね。
しかし、この書き方には大きな落とし穴があります。
要素が消えた後の落とし穴
先ほどのコードで data
の length が減ったケースを想像してみましょう。
仮に data.length
が 5 から 2 になったとします。
すると、それまで存在していた要素、i でいうと 2 <= i < 5
の範囲がDOMから消えます。ではbind先はどうなるでしょうか。
正解は null
になる、です。
この挙動は公式サイトにも書かれていないのであまり知られていないかもしれません。
変数単体なら値が null
になるだけなのであまり影響がないのですが、問題は配列のケースです。
arr = [(0番目のdiv), (1番目のdiv), (2番目のdiv), (3番目のdiv), (4番目のdiv)]
だったのが
arr = [(0番目のdiv), (1番目のdiv), null, null, null]
になります。
この現象の何が問題なのかというと、data.length
と arr.length
が一致しないのです。
普通はdata.length
が減ったんだから当然 arr.length
も減るだろ、と思ってしまうのですが、bind
はあくまでbind先に値を代入するだけですので、消えた要素のスロットには null
が残ってしまいます。これは気持ち悪いですし、バグの原因にもなりえます。
{#each} の中で {#if} を使ったときの落とし穴
もっと問題なのが {#if}
を使った時の挙動です。
先ほどのケースで data.lengh = 10
としましょう。
その上で次のような実装を考えます。
{#each data as item, i}
{#if i - Math.floor(i / 2) * 2 === 0}
<div bind:this={arr[i]}></div>
{/if}
{/each}
言葉で説明すると、 i が 2 で割り切れるときだけdivを表示する実装です。
先ほど「bind
はあくまでbind先に値を代入するだけ」と書いたので、ここでピンときた人もいるかと思います。
arr
の値はこのとき下のようになるのです。
arr = [(0番目のdiv), , (2番目のdiv), , (4番目のdiv), , (6番目のdiv), , (8番目のdiv)]
いわゆる Empty Slot というやつが入ってますね…。
JavaScriptでは禁忌とされているやつです。
Empty Slotがどう危険なのかは調べたらわかると思いますのでここでは割愛します。
で、この状態で更に data.length = 5
にしたときのことを想像してみてください。
arr = [(0番目のdiv), , (2番目のdiv), , null, , null, , null]
もうぐちゃぐちゃです。
arr[7]
からarr[9]
に至っては、もうすごいことになってます。
-
arr[7]
→ (Empty Slot) -
arr[8]
→ null -
arr[9]
→ 配列範囲外
同じ「存在しない要素」なのに、3つもの状態に分かれてしまっています。
{#if}
が {#each}
の中にあるとすんげーカオスになってしまうことが分かったかと思います。
で、Mapを使う
前置きが長くなりましたが、ここで Map
の登場です。
(リアクティブがいい!という人は SvelteMap
に読み替えてください)
Map
をbindすると、Arrayのbindに存在した問題がすべて解決するのです。
Function bindings
Mapのbindの話をする前に、Function bindingsというAPIの説明をする必要があります。
Function bindingsは Svelte 5.9 で追加された新しいbindのAPIです。
それまでbindは変数の値への代入しかできなかったですが、Function bindingsによってgetter/setterでbindできるようになりました。
使い方は例によって上の公式サイトを読めばわかるかと思います。
MapをFunction bindしてみる
今、我々がやりたいのは、
map.get(i)
が i番目の要素になる。要素が存在しないならmap.has(i)
がfalse
になる
です。
実際に実装してみましょう。
{#each data as item, i}
<div bind:this={
() => map.get(i),
(div) => {
if (div != null) {
map.set(i, div);
} else {
map.delete(i);
}
}
}></div>
{/each}
コードとしてはちょっと長くなりますが、これだけです。関数を見たらどういう意味かは大体わかるかと思います。
要素が消えた後の挙動
Arrayのケースと同様に、data.length
が5から2になったとします。
Arrayではnullが残ってしまっていましたが、上の実装ではどうなるでしょうか。
divが消えるとsetterが呼ばれます。
(div) => {
if (div != null) {
map.set(i, div);
} else {
map.delete(i);
}
}
div は null ですので、map.delete(i)
が呼ばれますね!
なのでmapは最終的に
Map([0, (0番目のdiv)], [1, (1番目のdiv)])
という値になります。
直観的ですし、何よりdata.length === map.size
なのが良いですね。
{#each} の中で {#if} を使ったときの挙動
Arrayのときと同じく data.lengh = 10
としましょう。
もう皆さんお分かりかと思うので結果だけ載せますが、
{#each data as item, i}
{#if i - Math.floor(i / 2) * 2 === 0}
<div bind:this={
() => map.get(i),
(div) => {
if (div != null) {
map.set(i, div);
} else {
map.delete(i);
}
}
}></div>
{/if}
{/each}
と書くと map の値は
Map([0, (0番目のdiv)], [2, (2番目のdiv)], [4, (4番目のdiv)], [6, (6番目のdiv)], [8, (8番目のdiv)])
になります。
Empty Slotなど存在しなかった…!
おわりに
Function bidingsが追加されたのが執筆時点で約半年前なので、そもそもSvelteでこんな書き方ができること自体がまだあまり知られてないかと思います。
ただArrayにbindするといろいろバグの原因になりやすいので、みなさんMapを使っていきましょう!ということが周知できれば幸いです。
(なによりここ数か月でIteratorのメソッドが増えてMapが使いやすくなったのもある)