0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Svelte で PWA (台本ビューア) <第11回>

Last updated at Posted at 2024-08-10

PWA で作った台本ビューアSvelte で作り直すことにしたので、やったことをメモしていきます。

<第11回> Svelte 5 対応

Svelte 5 はまだ正式リリースされていませんが (※執筆時点)、リリースに備えて Runes 等に対応することにしました。
Runes ではリアクティブな変数の書き方が変わります。
また Runes 以外にも Svelte 5 で追加された書き方があるので、今のうちから慣れておこうという目論見です。

この記事では私の「台本ビューア」で対応したことだけ解説します。
解説する範囲は狭いですが、実際に対応したコードを見ながらの説明となります。

Runes の概要については こちら に、Svelte 5 の新しい API については こちら に情報があるので参考にしてみてください。

今回の概要

Svelte 5 での新しい書き方のうち 以下のものについて、実際のコードを見ながら説明します。

今回使うソースコードは GitHub の以下のコミットにあります。

Svelte 5 のリリース時には仕様が変わっているかも知れないことに ご注意ください。

Runes

Runes は一言でいうと、今まで変数をリアクティブにするためにしてきた特殊な書き方を、関数の形に統一するものです。

どういう関数があるかは Svelte 5 の API の Runes に関するページ に一覧があります。
ここではそのうち、$state(), $derived(), $effect(), $props(), $bindable() に対応した例を紹介します。

$state()

$state() は、今まで単に let hoge = 'fuga' 等としていた リアクティブな 変数の宣言を置き換えます。
これにより、他のリアクティブでない (リアクティブにする必要がない) 変数と区別がつくようになります。

たとえば src/App.svelte ではいくつかのパネル (子コンポーネント) の開閉の動作にリアクティブな変数を使っています。
そのうちのひとつ、Toc (目次) コンポーネントの開閉について見てみます。

※ src/App.svelte 全体の変更点は こちら

変更前

src/App.svelte (抜粋 - 変更前)
<script lang="ts">
  /* 中略 */
  let toc: Toc
  let tocIsOpen = false
  /* 中略 */
</script>

{#if psc && tocIsOpen}
  <Toc
    bind:this="{toc}"
    bind:psc
    on:close="{() => { tocIsOpen = false }}"
    ...
  />
{/if}

変更後

src/App.svelte (抜粋 - 変更後)
<svelte:options runes={true} />
<script lang="ts">
  /* 中略 */
  let toc: Toc | undefined = $state()
  let tocIsOpen = $state(false)
  /* 中略 */
</script>

{#if psc && tocIsOpen}
  <Toc
    bind:this={toc}
    psc={psc}
    onClose={() => tocIsOpen = false}
    ...
  />
{/if}

解説

Toc は目次を表示するためのコンポーネントです。
{#if} を使って、tocIsOpentrue の時だけ表示 (というか存在) させています。
つまり、tocIsOpen がリアクティブであることにより、tocIsOpen値を変えるだけで 目次を出したり消したりできます。

また、Toc のインスタンスを bind している変数 toc もリアクティブにしています。
理由は (上記のコードでは省略されていますが)、リアクティブにするとモーダル時の制御や Android の back ボタンの処理がしやすいからです。

これら2つの変数はリアクティブなので、変更後のコードでは $state() で初期化している、というのが変更点になります。
$state() については以上です。

変更前は bind:this="{toc}" だったところを、変更後はダブルクォートをやめて bind:this={toc} と書いています。
理由は、ダブルクォートをつける書き方だと Svelte の将来のバージョンで文字列として扱われるようになるからです。
元々ダブルクォートをつけていた理由は、そうしないと Qiita でシンタックスハイライトが効かなかったからなのですが、今はダブルクォートを付けなくても (言語を svelte にすれば) 効くようになりました。

$derived()

$derived()$state() に似ていますが、引数に (初期値ではなく) を渡して リアクティブに評価した結果 を返します。
これにより、今まで $: (リアクティブ宣言) を使っていた箇所のいくつかを置き換えることができます。

src/App.svelte で isModal という変数を更新する宣言があるので見てみます。

変更前

src/App.svelte (抜粋 - 変更前)
<script lang="ts">
  /* 中略 */
  $: isModal = tocIsOpen || menuIsOpen || dataIsOpen || aboutIsOpen
  /* 中略 */
</script>

<Viewer
  ...
  bind:inert="{isModal}"
/>

この例では、isModal は その後に続く4つの変数に 連動して 更新されます。
具体的には、4つの変数の少なくとも1個が true になれば isModal も true になり、Viewer コンポーネントの inert 属性が true になる仕組みです。

変更後

src/App.svelte (抜粋 - 変更後)
<svelte:options runes={true} />
<script lang="ts">
  /* 中略 */
  let isModal = $derived(tocIsOpen || menuIsOpen || dataIsOpen || aboutIsOpen)
  /* 中略 */
</script>

<!-- 略 -->

解説

変更後は、let isModal と宣言することで 変数であることが分かりやすくなりました。
また、右辺に書いていた式を $derived() に渡すようにしてリアクティブであることも分かりやすくなりました。
$: でやっていたことをただ置き換える訳でなく細かな違いがあるので、気になる方は 公式の解説 をチェックしてください。

$derived() に式ではなく関数を渡したい状況では、$derived.by() を使うことができます。

$effect()

$effect()$derived.by() に似ていますが、関数を使ってリアクティブな値を返すのでなく、関数の中で 変数を更新する等の副作用 を処理します。

上で $derived() した isModal が 他のリアクティブ宣言の中で呼ばれていたので、そちらを見てみます。

変更前

src/App.svelte (抜粋 - 変更前)
<script lang="ts">
  /* 中略 */
  // モーダル状態が変わった時の処理
  $: if (viewer) {
    if (isModal) {
      lockScroll()
    } else {
      unlockScroll()
    }
  }
  /* 中略 */
</script>

ここで viewer は子コンポーネントのインスタンスです。
つまり そのインスタンスが存在する時に isModaltrue になったら lockScroll() を実行して、false になったら unlockScroll() を実行するようにしています。

変更後

src/App.svelte (抜粋 - 変更後)
<svelte:options runes={true} />
<script lang="ts">
  /* 中略 */
  // モーダル状態が変わった時の処理
  $effect(() => {
    if (viewer) {
      if (isModal) {
        lockScroll()
      } else {
        unlockScroll()
      }
    }
  })
  /* 中略 */
</script>

解説

変更はシンプルで、単に $: で書いていたものを無名関数にして $effect() に渡しています。
この関数自体は変数の更新はしていませんが、他の関数を呼んでいるので そっちで変数が更新されていることを考慮して $derived.by() ではなく $effect() を使っています。

他にも、$effect() を使うべき以下の理由があります。

  1. $derived.by() はリアクティブな値を返すことが目的なので、用途が違う。
  2. $effect() は DOM が更新されるタイミングでしか実行されないので、無駄に実行を繰り返さない。

$props()

$props() は、親コンポーネントから受け取ったプロパティ値をまとめて返します。
コンポーネントプロパティは、今までは export let xx として 「外から代入可能な変数」 のように考えていましたが、Runes では $props() (引数のように) 値を受け取る形 になります。

ただし、この受け取った値がリアクティブになっている場合があります。
その場合、親が値を更新すれば、子コンポーネント側でプロパティ値を受け取った変数がリアクティブに更新されます。

src/components/Toc.svelte で 親コンポーネント (src/App.svelte) からプロパティ値を受け取る例を見てみます。

変更前

src/components/Toc.svelte (抜粋 - 変更前)
<script lang="ts">
  import { createEventDispatcher, onMount } from 'svelte'
  /* 中略 */
  const dispatch = createEventDispatcher()
  
  // コンポーネントプロパティ
  export let psc: PSc         // 台本データ
  export let current: number  // 現在地に対応する見出し
  
  /* 中略 */
  export function close() {
    /* 中略 */
      dispatch('close')
    /* 中略 */
  }

  /* 中略 */
</script>

変更前はコンポーネントプロパティを2個 持っています。
また、'close'dispatch() しているので 親は on:close プロパティでハンドラを指定しています。

このコードでは 'close' という同じ名前を 2か所で違う意味で使っていました。
ひとつは close() 関数で、export して外から実行できるようにしていました。
もうひとつは 'close' というカスタムイベント名で、これを dispatch() することで親の on:close ハンドラを実行していました。
それでちょっと紛らわしかったのですが、このうち 'close' イベントの方は今回の変更で変えることになりました。

変更後

src/components/Toc.svelte (抜粋 - 変更後)
<svelte:options runes={true} />
<script lang="ts">
  import { onMount } from 'svelte'
  /* 中略 */
  // const dispatch = createEventDispatcher()  // 削除
  
  // コンポーネントプロパティ
  type Props = {
    psc: PSc;           // 台本データ
    current: number;    // 現在地に対応する見出し
    onClose: Function;  // 親が目次を閉じるハンドラ
    onGoTo: Function;   // 親が見出し行にスクロールするハンドラ
  }
  const { psc, current, onClose, onGoTo }: Props = $props()
  
  /* 中略 */
  export function close() {
    /* 中略 */
      onClose()
    /* 中略 */
  }

  /* 中略 */
</script>

解説

親からプロパティ値を受け取っているのは以下の行です。

  const { psc, current, onClose, onGoTo }: Props = $props()

やっていることは JavaScript の 分割代入 です。
TypeScript を使っているので type Props で各プロパティの型を宣言しています。

カスタムイベントのハンドラも $props() で受け取れるので、今までの ディスパッチャを使ったやり方 は不要になります。
ここでは onClose という名前で、このコンポーネントを閉じる時に親が実行するイベントハンドラを受け取っています。

イベントハンドラについては、後ほど改めて説明します。

$bindable()

$bindable() を使うと、$props() から値を受け取った変数から、親へと 値を逆流させる ことができます。
つまり、親も子も同じ1個の値を 参照したり更新したりしている状態になります。

ファイルを選択する UI のコンポーネント (src/components/UI/FileSelect.svelte) でこれを使っているので見てみましょう。

変更前

src/components/UI/FileSelect.svelte (抜粋 - 変更前)
<script lang="ts">
  // コンポーネントプロパティ
  export let files: FileList | null  // input タグで選択されたファイルの配列
  export let accept=''               // input タグの accept 属性 ('.json' など)

  /* 中略 */
</script>

<div>
  <label>
    <input type="file" bind:files accept="{accept}" />{filename}
  </label>
</div>

このコンポーネントでは <input> 要素の files 属性に同名の変数を bind して、それを export して親と共有しています。
親の方では以下のように このコンポーネントを使っているはずです。

<FileSelect bind:files accept=".json" />

これにより、ファイルが選択されるたびにイベントを送出して親の方でハンドラを実行しなくても、親は必要な時に選択されたファイルを参照することができます。

変更後

src/components/UI/FileSelect.svelte (抜粋 - 変更後)
<svelte:options runes={true} />
<script lang="ts">
  // コンポーネントプロパティ
  type Props = {
    files: FileList | null;  // input タグで選択されたファイルの配列
    accept: string;          // input タグの accept 属性 ('.json' など)
  }
  let { files = $bindable(), accept }: Props = $props()

  /* 中略 */
</script>

<!-- 略 -->

解説

まず、先ほどと同様に コンポーネントプロパティを $props() を使った書き方にします。
この時、親の方で変数をバインドしている (bind: files 等としている) プロパティについては、子で 受け取る側の変数の初期値を $bindable() します。

「初期値」は本来は値が来なかった時に使われるものなので、この書き方に若干の違和感を感じますが、宣言のしかたとしては $state() と同様と言えます。
どこかでコンパイラに bindable であることを教えないといけないので こうなっているのだと思います。

バインド可能なプロパティ (上記の files) のための変数は let で宣言する必要があります。
上の例では まとめて let にしていますが、他の変数 (上記の accept) は const にしたい場合もあると思います。
いろいろやってみた結果、以下のように書いたら できました。

let { files = $bindable(), ...rest }: Props = $props()
const { accept } = rest

:warning: 順番を逆にして filesrest から受け取るようにすると、$bindable()$props() を受けていないと言うエラーになります。

イベントハンドラ

$props() のところで、親からカスタムイベントのハンドラを受け取るようにしました。
dispatch() を使っていた時は 「親に向けてイベントメッセージを送信する」 という感じだったのに対して、「受け取ったハンドラをただ実行する」 という感じの書き方が できるようになりました。

// 変更前
dispatch('close')  // 'close' というイベントを送信

// 変更後
onClose()          // 'onClose' というハンドラを実行
                   // 'close' という関数があるので名前を変えている

親の方も、以下のように書き方が変わります (差が分かるように コードを簡略化しています)。

<!-- 変更前 -->
<Toc on:close={() => { tocIsOpen = false }} />

<!-- 変更後 -->
<Toc onClose={() => { tocIsOpen = false }} />

引数を取る場合

イベントハンドラが 引数を取る場合の書き方も すっきりしました。
Toc (目次) コンポーネントで見出しを選択した時の、台本をその見出しまでスクロールさせる時のカスタムイベントを見てみます。

// 変更前
dispatch('goTo', { index })

// 変更後
onGoTo(index)

index がハンドラに渡したい値です。
変更前はイベントのプロパティとして渡していましたが、普通に 関数に引数を渡すのと同じ になりました。

変更前の dispatch() の第2引数は { index: index } という連想配列の糖衣構文です。
これが ハンドラが受け取るイベントの detail プロパティに入ります。

親のコードも以下のように分かりやすくなりました (差が分かるように コードを簡略化しています)。

<!-- 変更前 -->
<Toc on:goTo={(e) => { goToHeadline(e.detail.index) }} />

<!-- 変更後 -->
<Toc onGoTo={goToHeadline} />

store を Runes で置き換え

複数の .svelte ファイル間で値を受け渡したい場合 (特に それらが親子関係にない場合)、これまでは store を使っていました。
これを この記事の最初に説明した $state() で置き換えることができます。

Runes の紹介記事でも 説明されています が、要は $state() のリアクティビティは export import にくっついて来る、という事です。

ファイル名について

この後の具体例で ファイル名が以下のようになっています。

このようにファイル名を変えた理由は2つあります。

  1. store ではなく グローバルな値を保持するオブジェクト を使うようにしたから。
  2. Runes を使うために拡張子を .svelte.ts にする必要があるから。

具体例

「台本ビューア」では (PWA なので) Service Worker を更新する関数を store で受け渡していました。

:point_right_tone2:<第3回> の「更新用関数を保持するストア」 参照。

変更前

src/lib/store.ts
import type { Writable } from 'svelte/store'
import { writable } from 'svelte/store'

/** アプリを更新する関数を保持するストア */
export const appUpdateFunc: Writable<Function | null> = writable(null)

このように store を使う場合、import する側では appUpdateFunc.set(xx) で値 (この場合は関数) をセットして、$appUpdateFunc で参照します。

変更後

src/lib/g.svelte.ts
class Global {
  // アプリを更新するための関数を保持するプロパティ
  appUpdateFunc: Function | undefined = $state()
}
export const g = new Global()

オブジェクトのプロパティを $state() で初期化することによってリアクティブにしています。
あとは import する側で g.appUpdateFunc を読み書きするだけです。

Snippets

最後に Snippets です。
これは簡単にいうと HTML タグを使い回せるようにテンプレート化する機能です。

今まで <slot> を使っていたところを、これで置き換えることができます。
特に {#each ...} の中で <slot> を使う時はパラメタの渡し方が分かりにくかったので、Snippets で置き換えると良いです。

DB に保存 した台本の一覧を表示するコンポーネント (src/components/DataList.svelte) で これを使うようにしたので見てみます。

変更前

src/components/DataList.svelte (抜粋 - 変更前)
<!-- 前略 -->
    <DndList
      items="{items}"
      let:item="{scIndexItem}"
      let:cellId
    >
      <DataCell
        item="{scIndexItem}"
        cellId="{cellId}"
      />
    </DndList>
<!-- 後略 -->

<DataCell> という 別で定義しているコンポーネントが、DndList<slot> に入ります。

src/components/UI/DndList.svelte (抜粋 - 変更前)
<script lang="ts">
  export let items
</script>

<div class="scroll-box" bind:this="{scrollBox}">
  <div bind:this="{cellsRow}">
    {#each items as item (item.id)}
      <slot item="{item}" cellId="cell{item.id}"></slot>
    {/each}
  </div>
</div>

<slot> に渡すパラメタに {#each ...} で回している変数 (item) を使えますが、これらのパラメタは親の方で let:xx と書いて、リアクティブに逆流してくる局所変数 のように定義する必要がありました。

:pencil: item というパラメタは内容が分かるように変数名を scIndexItem に変えて受け取っています。

変更後

src/components/DataList.svelte (抜粋 - 変更後)
<!-- 前略 -->
    <DndList
      items={items}
    >
      {#snippet cell(item: DndCellItem)}
        <DataCell
          item={item}
          cellId="cell{item.id}"
        />
      {/snippet}
    </DndList>
<!-- 後略 -->
src/components/UI/DndList.svelte (抜粋 - 変更後)
<svelte:options runes={true} />
<script lang="ts">
  const { items, cell } = $props()  // 受け取るプロパティに cell を追加
</script>

<div class="scroll-box" bind:this={scrollBox}>
  <div bind:this={cellsRow}>
    {#each items as item (item.id)}
      {@render cell(item)}
    {/each}
  </div>
</div>

解説

コンポーネント (<DndList>) の子要素として Snippets を宣言すると、自動的にプロパティになります。
DndList 側では他のプロパティと同様に受け取って、{@render ...} で引数を与えて実体化します。

変更前はややこしかった <DataCell> へのプロパティの渡し方も、Snippets が引数を持っていることにより 明解になりました。

Snippets の使い方はこれで合っているのですが、なぜか DndList の動作が おかしくなった ので、もしかしたら Snippets を使うのをやめるかも知れません。 修正しました (⇒ コミット)。
ここでの例はあくまで Snippets の使い方の参考用とお考え下さい。

参考資料

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?