Closed17

[キャッチアップ] Reactivity Transform

shingo.sasakishingo.sasaki

Reactivity Transform は Composition API のためのビルドタイムでコード変換を行う仕様。

shingo.sasakishingo.sasaki

Refs vs. Reactive Variables

  • refsreactive どっち使ったらええんや問題がずっと残ってた
  • reactive はオブジェクトを分割代入しちゃうとリアクティブが失われる問題あるけど、 ref.value でのアクセス忘れがち
  • Reactivity Transform はコンパイルタイムでそういう問題を解決するよ
<script setup>
let count = $ref(0)

console.log(count)

function increment() {
  count++
}
</script>

<template>
  <button @click="increment">{{ count }}</button>
</template>
  • $ref() はコンパイルタイムのマクロで、ランタイムで動くメソッドではない
  • let count = $ref(0) における count は、コンパイルタイムでリアクティブな変数に書き換えられてランタイムで動作する
  • 変換後のランタイムコードは以下になるイメージ
import { ref } from 'vue'

let count = ref(0)

console.log(count.value)

function increment() {
  count.value++
}
  • 同様に各既存API に、 $ プレフィックスの付いたマクロが提供される
    • $ref
    • $computed
    • $shallowRef
    • $customRef
    • $toRef
  • これらのマクロは vue/macros からインポートできるけど、コンパイルタイムで暗黙に参照されるから、 import を明示する必要もない
shingo.sasakishingo.sasaki

Destructuring with $()

reactive オブジェクトを分割代入しちゃうとリアクティブが剥がれちゃうけど、それを阻止するためのマクロとして $() が用意されてる

const { x, y } = useMouse()

console.log(x, y)

だと x, y はリアクティブじゃなくなるけど

const { x, y } = $(useMouse())

console.log(x, y)

ならリアクティブを保持できる。

具体的には以下のように toRef を使用して個別に ref にしつつ、 .value が必要な箇所では挿入するコードに変換される

const __temp = useMouse(),
  x = toRef(__temp, 'x'),
  y = toRef(__temp, 'y')

console.log(x.value, y.value)

$() はリアクティブオブジェクトでも通常のオブジェクトでも使用することができ、どちらにしてもリアクティブ変数になる (二重ref とかにならない)

shingo.sasakishingo.sasaki

Convert Existing Refs to Reactive Variables with $()

ref に対して $() を使っちゃっても特に問題ないよという話

function myCreateRef() {
  return ref(0)
}

let count = $(myCreateRef())
shingo.sasakishingo.sasaki

Reactive Props Destructure

  • defineProps を使った props の定義は二つのつらみがある
    • propsreactive なので分割代入しちゃうとリアクティブが切れるから、 props.x の形でのアクセスが必要
    • TS の型定義のみの props 宣言をしてしまうと、デフォルト値の設定が困難
      • widthDefaults を使えばどうにかなるけど、キーバリューの定義が冗長になる

Reactivity Transform の世界では、 defineProps を使うだけで分割代入ができて、かつデフォルト値の設定もここでできちゃう。

<script setup lang="ts">
  interface Props {
    msg: string
    count?: number
    foo?: string
  }

  const {
    msg,
    // default value just works
    count = 1,
    // local aliasing also just works
    // here we are aliasing `props.foo` to `bar`
    foo: bar
  } = defineProps<Props>()

  watchEffect(() => {
    // will log whenever the props change
    console.log(msg, count, bar)
  })
</script>

こう変換される。もうなんでもありだな。

export default {
  props: {
    msg: { type: String, required: true },
    count: { type: Number, default: 1 },
    foo: String
  },
  setup(props) {
    watchEffect(() => {
      console.log(props.msg, props.count, props.foo)
    })
  }
}
shingo.sasakishingo.sasaki

Retaining Reactivity Across Function Boundaries

Reactive Transform のおかげで、 ref を一々 .value で参照する必要がなくなったけど、それはそれで面倒な問題が残り続けてるよ。

一つは ref を関数で渡した場合。

function trackChange(x: Ref<number>) {
  watch(x, (x) => {
    console.log('x changed!')
  })
}

let count = $ref(0)
trackChange(count) // doesn't work!

trackChange に対して Ref を渡したつもりが、この場合は trackChange(count.value) にコンパイルされたから型が合わないよ。

この場合 $$() を使うことで変換せずにそのまま Ref を渡せるようになるよ。

trackChange($$(count))

2つ目の問題は Ref を直接関数から return する際に、リアクティブが失われる問題がある。

function useMouse() {
  let x = $ref(0)
  let y = $ref(0)

  // listen to mousemove...

  // doesn't work!
  return {
    x,
    y
  }
}

これは

return {
  x: x.value,
  y: y.value
}

に変換されてしまうからである。

これも同様に $$() を使って、コンパイルを抑制することで解決できる。

return $$({
  x,
  y
})

$$() は分割代入された props に対しても適用可能。これはまぁ納得感ある。

const { count } = defineProps<{ count: number }>()

passAsRef($$(count))
shingo.sasakishingo.sasaki

TypeScript 対応

Reactivity Transform のマクロに関する型情報は公式から提供するし、TypeScript の標準ともバッティングしない。

つまり Vue SFC 以外の JS/TS ファイル内でもマクロを機能させることが出来る。 (Composition API を切り出したファイルってこと?)

トリプルスラッシュディレクティブで型を宣言

/// <reference types="vue/macros-global" />
shingo.sasakishingo.sasaki

Explicit Opt-in

Reactivity Transform を使う場合は Vite の Vue プラグインまたは vue-loader の設定が必要。

shingo.sasakishingo.sasaki

ここまででドキュメントをざっと読んだので、一通り動かして試して見るフェーズに入る。

shingo.sasakishingo.sasaki

手元のサンドボックスプロジェクトは Vite 使ってるので、Vite 設定ファイルで Reactivity Transform を有効化する。

vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [
    vue({
      reactivityTransform: true
    })
  ]
})

TypeScript も使ってるのでトリプルスラッシュディレクティブを追加

src/vite-env.d.ts
/// <reference types="vite/client" />
/// <reference types="vue/macros-global" />

declare module '*.vue' {
  import type { DefineComponent } from 'vue'
  const component: DefineComponent<{}, {}, any>
  export default component
}

これでこの辺の型が入ってくるのね。

import {
  $ as _$,
  $$ as _$$,
  $ref as _$ref,
  $shallowRef as _$shallowRef,
  $computed as _$computed,
  $customRef as _$customRef,
  $toRef as _$toRef
} from './macros'

declare global {
  const $: typeof _$
  const $$: typeof _$$
  const $ref: typeof _$ref
  const $shallowRef: typeof _$shallowRef
  const $computed: typeof _$computed
  const $customRef: typeof _$customRef
  const $toRef: typeof _$toRef
}
shingo.sasakishingo.sasaki

動いた。

<template>
  <h1>{{ count }}</h1>
  <button @click="increment">+</button>
  <button @click="decrement">-</button>
</template>

<script setup lang="ts">
let count = $ref(0)

const increment = () => {
  count++
}

const decrement = () => {
  count--
}
</script>

count の型は ReactiveVariable<number> に推論されてる。

shingo.sasakishingo.sasaki

もうちょい応用例。オブジェクトに対する Ref から分割代入した x, y をリアクティブに描画するパターン。

<template>
  <h1>Position</h1>
  <p>x: {{ x }}</p>
  <p>y: {{ y }}</p>
</template>

<script setup lang="ts">
const mousePosition = $ref({ x: 0, y: 0 })
const { x, y } = $(mousePosition)

document.body.addEventListener('mousemove', e => {
  mousePosition.x = e.clientX
  mousePosition.y = e.clientY
})
</script>

$(mousePosition) にしないと分割代入の時点でリアクティブが切れるので、初回描画時の初期値(0) で x,y が固定されちゃう。

shingo.sasakishingo.sasaki

useMousePosition に切り出した場合もちゃんと動く。別に <script setup> である必要もない。

src/hooks/useMousePosition.ts
export const useMousePosition = () => {
  const position = $ref({ x: 0, y: 0 })
  document.body.addEventListener('mousemove', e => {
    position.x = e.clientX
    position.y = e.clientY
  })

  return position
}
<template>
  <h1>Position</h1>
  <p>x: {{ x }}</p>
  <p>y: {{ y }}</p>
</template>

<script setup lang="ts">
import { useMousePosition } from './hooks/useMousePosition'

const { x, y } = $(useMousePosition())
</script>
shingo.sasakishingo.sasaki

一々 .value って書く必要がなくなるってのは便利だけど、結局リアクティビティを失う導線はあるし、あえて Ref のまま渡したい時に一工夫($$()) 必要だしで、扱いづらさの点では変わってないように感じる。

慣れればどっちでも良いという気持ちもあるけど、 reactive を禁止して ref オンリーに出来ると考えればまぁありなのかな。

shingo.sasakishingo.sasaki

最初からこういうフレームワークですと言われれば納得感がある。
実際 Svelte を触ったときはこんな感じで感動したし。

ただ長年 Vue を使ってきた人が割り切れるかと言うと怪しいところ。

このスクラップは2022/09/17にクローズされました