[キャッチアップ] Reactivity Transform
Vue 3 の Reactivity Transform は存在知ってたけど、まだまだ先の話だと思って試してもいなかったけど、eslint-plugin-vue へのルール追加もされてそろそろ実用フェーズな雰囲気がするので改めて試していこうと思う。
Vue は 3.2.39 を使うけど、この時点でもまだ非標準機能。
詳細は以下 RFC をウォッチすれば良いみたい。
Reactivity Transform
は Composition API のためのビルドタイムでコード変換を行う仕様。
Refs vs. Reactive Variables
-
refs
とreactive
どっち使ったらええんや問題がずっと残ってた -
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 を明示する必要もない
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 とかにならない)
Convert Existing Refs to Reactive Variables with $()
ref
に対して $()
を使っちゃっても特に問題ないよという話
function myCreateRef() {
return ref(0)
}
let count = $(myCreateRef())
Reactive Props Destructure
-
defineProps
を使ったprops
の定義は二つのつらみがある-
props
もreactive
なので分割代入しちゃうとリアクティブが切れるから、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)
})
}
}
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))
TypeScript 対応
Reactivity Transform
のマクロに関する型情報は公式から提供するし、TypeScript の標準ともバッティングしない。
つまり Vue SFC 以外の JS/TS ファイル内でもマクロを機能させることが出来る。 (Composition API を切り出したファイルってこと?)
トリプルスラッシュディレクティブで型を宣言
/// <reference types="vue/macros-global" />
Explicit Opt-in
Reactivity Transform
を使う場合は Vite の Vue プラグインまたは vue-loader の設定が必要。
ここまででドキュメントをざっと読んだので、一通り動かして試して見るフェーズに入る。
手元のサンドボックスプロジェクトは Vite 使ってるので、Vite 設定ファイルで Reactivity Transform を有効化する。
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
vue({
reactivityTransform: true
})
]
})
TypeScript も使ってるのでトリプルスラッシュディレクティブを追加
/// <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
}
動いた。
<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>
に推論されてる。
もうちょい応用例。オブジェクトに対する 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 が固定されちゃう。
useMousePosition
に切り出した場合もちゃんと動く。別に <script setup>
である必要もない。
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>
一々 .value
って書く必要がなくなるってのは便利だけど、結局リアクティビティを失う導線はあるし、あえて Ref のまま渡したい時に一工夫($$()
) 必要だしで、扱いづらさの点では変わってないように感じる。
慣れればどっちでも良いという気持ちもあるけど、 reactive
を禁止して ref
オンリーに出来ると考えればまぁありなのかな。
最初からこういうフレームワークですと言われれば納得感がある。
実際 Svelte を触ったときはこんな感じで感動したし。
ただ長年 Vue を使ってきた人が割り切れるかと言うと怪しいところ。