Reactive Props Destructure を支える技術
みなさんこんにちは, ubugeeei です.
最近技術発信が全くできていないな〜お前それでも Vue Team Member かよ,と思いつつ,せっかく Vue 3.5 がリリースされたのでそれに関連した機能の記事でも書こうかと思います.
Vue 3.5.0 がリリースされました
先日,Vue 3.5.0 (Tengen Toppa Gurren Lagann) がリリースされました.
このリリースでは,Reactivity System の最適化や,新しいコンポーザブルである useTemplateRef
,useId
,Custom Elements の改善など様々な変更が入りました.
詳しくは上記の公式ブログや,同 publication のまとめ記事 などを参照してください.
今回のトピック
Vue 3.5 で Reactive Props Destructure という機能が安定版となりました.
今回はこの Reactive Props Destructure について,機能のおさらいや経緯,実装,そしてそれらを支援する技術について解説します.
Reactive Props Destructure ってなんだっけ? (おさらい)
Reactive Props Destructure は defineProps で定義された props を Destructure (分割代入) した際にリアクティビティを維持する機能です.
これにより,いくつかの DX 改善を期待することができます.
特に,デフォルト値の設定に関して withDefault
を使用せずに簡潔に書けるようになることは大きな進歩です.
const { count = 0, msg = "hello" } = defineProps<{
count?: number;
message?: string;
}>();
// count の値が変更された場合もちゃんと trigger される
const double = computed(() => count * 2);
以前までの書き方
const props = withDefaults(
defineProps<{
count?: number;
msg?: string;
}>(),
{
count: 0,
msg: "hello",
}
);
const double = computed(() => props.count * 2);
Reactive Props Destructure の実装の経緯
RFC について
Reactive Props Destructure は RFC として始まりました.
とは言っても,起票者は Vue.js の作者である Evan You 氏で,元はというと以前 Evan 氏が起票していた Reactivity Transform という別の RFC が由来になっているものです.
Reactivity Transform は Reactivity に関する DX 向上を図るためのコンパイラ実装で,例としては以下のような機能が挙げられます.(一部紹介)
-
$ref
による.value
の省略 -
$
による既存のリアクティブ変数の変換 - props destructure
-
$$
による境界を超えるリアクティビティの維持
そうです.props destructure はこの中の一つで,Reactivity Transform の一部として提案されました.
Reactivity Transform の廃止
Reactivity Transform は experimental として実装が進められていましたが,最終的には廃止されることになりました.
廃止になった理由は同 RFC の以下のコメントにまとまっています.
廃止理由を簡単に要約すると,
-
.value
が省略されるとリアクティブな変数とそうでない変数の区別がつきにくい - 異なるメンタルモデル間のコンテキストシフトのコストを生む
- ref で動作することを期待する外部関数は結局あるので,そこで精神的な負担を増やすことになる
と言ったものです.
この Reactivity Transform は Vue Macros というライブラリで引き続き利用可能 になっていますが,vuejs/core からは 3.3 非推奨化,3.4 では完全に削除されました.
Reactive Props Destructure としての RFC
そんなこんなでも元々は Reactivity Transform の一部として提案されていた Reactive Props Destructure ですが,後に独立した RFC として 2023/4 に提案されることになりました.
This was part of the Reactivity Transform proposal and now split into a separate proposal of its own.
RFC にもあるように,モチベーションは主に 2 点です.
- デフォルト値とエイリアスのための簡潔な構文
- template での暗黙的な props アクセスとの一貫性
詳細は後で書きますが,概ね以下のようにコンパイルされることが RFC にも記載されています.
Compilation Rules
The compilation logic is straightforward - the above example is compiled like the following:
Input
const { count } = defineProps(["count"]); watchEffect(() => { console.log(count); });
Output
const __props = defineProps(["count"]); watchEffect(() => { console.log(__props.count); });
ユーザーが書くソースコードとしては destructuring されたプロパティを扱うことになりますが,コンパイラはこれらの変数を追跡して従来通りの props オブジェクトから辿る形でアクセスするコードに変換し,リアクティビティを維持します.
そして,この機能における欠点も 同コメント に記載されています.
以下にいくつかまとめると,
- destructuring された props を誤って関数に渡してしまい,リアクティビティが失われる可能性がある
- props であることが明示的でない (他の変数と区別がつかなくなる)
- コンパイラマジックによる初学者の混乱
と言った感じで詳しくは RFC を参照して欲しいですが,大きく上記のような欠点が挙げられており,この欠点に対する向き合い方 も同時に記載されています.
向き合い方に関してはざっくり「別に,今までもそうだったけどたいして問題か?」と言った感じです.
Reactive Props Destructure はどのように動作するか
RFC に書いてあるものは一部なので,もう少し細かく実際の動作を覗いてみましょう.
実装方法については後で詳しく触れますが,まず前提として Reactive Props Destructure は コンパイラの実装 です.
コンパイラがなんなのか,という話については 同 publication のこちらの記事 を是非参照してください.
ついては「動作を見る」と言いまいしたが,正確には「どのようにコンパイルされるか見る」ということになります.
基本動作 (Reactive Props Destructure 以前)
まずは,Props Destructure を利用しない場合の基本的な動作です.
せっかくなので,定義した props を template 内で利用することも一緒に見てみます.
Input
<script setup lang="ts">
import { computed } from "vue";
const props = defineProps<{ count: number }>();
const double = computed(() => props.count * 2);
</script>
<template>
<div>{{ props.count }}{{ count }}{{ double }}</div>
</template>
Output
/* Analyzed bindings: {
"computed": "setup-const",
"props": "setup-reactive-const",
"double": "setup-ref",
"count": "props"
} */
import { defineComponent as _defineComponent } from "vue";
import { computed } from "vue";
const __sfc__ = /*#__PURE__*/ _defineComponent({
__name: "App",
props: {
count: { type: Number, required: true },
},
setup(__props, { expose: __expose }) {
__expose();
const props = __props;
const double = computed(() => props.count * 2);
const __returned__ = { props, double };
Object.defineProperty(__returned__, "__isScriptSetup", {
enumerable: false,
value: true,
});
return __returned__;
},
});
import {
toDisplayString as _toDisplayString,
openBlock as _openBlock,
createElementBlock as _createElementBlock,
} from "vue";
function render(_ctx, _cache, $props, $setup, $data, $options) {
return (
_openBlock(),
_createElementBlock(
"div",
null,
_toDisplayString($setup.props.count) +
_toDisplayString($props.count) +
_toDisplayString($setup.double),
1 /* TEXT */
)
);
}
__sfc__.render = render;
__sfc__.__file = "src/App.vue";
export default __sfc__;
props オブジェクトの名前
まず,setup 関数の第一引数として受け取る props は __props
という名前に固定されていて,
ユーザー定義の props オブジェクト名はこれをバインドしていることがわかります.
const props = defineProps();
と書いた場合には,
const props = __props;
になりますし,
const mySuperAwesomeProps = defineProps();
と書いた場合には,
const mySuperAwesomeProps = __props;
になります.
props の定義
Vue.js では props の定義はコンポーネントの props オプションで行われます.
今回で言うと,
const __sfc__ = /*#__PURE__*/ _defineComponent({
props: {
count: { type: Number, required: true },
},
});
の部分です.
Input の defineProps<{ count: number }>()
の部分がコンパイラによって props: { count: { type: Number, required: true } }
に変換されていることがわかります.
もしここで,defineProps<{ count?: string }>()
と書いた場合は,props: { count: { type: String, required: false } }
になります.
また,ここで注意したい点は,Vue.js のコンパイラは型情報ではなく,optional parameter の記述を見て required を決定するため,defineProps<{ count: string | undefined }>()
と書いても required
は false
になりません.
参照元
続いては template に関してです.
今回の Input の例では
<template>
<div>{{ props.count }}{{ count }}{{ double }}</div>
</template>
となっていますが,props, count, double はそれぞれどのように参照されているか見てみましょう.
Vue.js では props として定義された変数は template でそのまま参照することができます.(like {{ count }}
)
もちろん,script setup 内で props オブジェクトとして名前をつけたものも参照できます. (like {{ props.count }}
)
コンパイル結果をみてみると,上部に解析結果があります.
/* Analyzed bindings: {
"computed": "setup-const",
"props": "setup-reactive-const",
"double": "setup-ref",
"count": "props"
} */
computed
も解析されてしまっているのは置いておいて,props
, double
, count
に注目してみてください.
それぞれ,setup-reactive-const
, setup-ref
, props
というバインディングがついています.
このメタデータを元に template 内での変数の参照を解決していることがわかります.
実際にどう解決されているかというと,
function render(_ctx, _cache, $props, $setup, $data, $options) {
return (
_openBlock(),
_createElementBlock(
"div",
null,
_toDisplayString($setup.props.count) +
_toDisplayString($props.count) +
_toDisplayString($setup.double),
1 /* TEXT */
)
);
}
の部分です.
template 内に登場する識別子のバインディング情報が setup-xxx
の場合には $setup
から,props の場合には $props
から参照されているようにコンパイルします.
Reactive Props Destructure の基本動作
さて,ここから今回の本題である Reactive Props Destructure の動作についてみていきます.
まずはシンプルな例から見ていきましょう.
Input
<script setup lang="ts">
import { computed } from "vue";
const { count } = defineProps<{ count: number }>();
const double = computed(() => count * 2);
</script>
<template>
<div>{{ count }}{{ double }}</div>
</template>
Output
/* Analyzed bindings: {
"computed": "setup-const",
"double": "setup-ref",
"count": "props"
} */
import { defineComponent as _defineComponent } from "vue";
import { computed } from "vue";
const __sfc__ = /*#__PURE__*/ _defineComponent({
__name: "App",
props: {
count: { type: Number, required: true },
},
setup(__props, { expose: __expose }) {
__expose();
const double = computed(() => __props.count * 2);
const __returned__ = { double };
Object.defineProperty(__returned__, "__isScriptSetup", {
enumerable: false,
value: true,
});
return __returned__;
},
});
import {
toDisplayString as _toDisplayString,
openBlock as _openBlock,
createElementBlock as _createElementBlock,
} from "vue";
function render(_ctx, _cache, $props, $setup, $data, $options) {
return (
_openBlock(),
_createElementBlock(
"div",
null,
_toDisplayString($props.count) + _toDisplayString($setup.double),
1 /* TEXT */
)
);
}
__sfc__.render = render;
__sfc__.__file = "src/App.vue";
export default __sfc__;
Destructuring してしまっているので,前回のテンプレートから {{ props.count }}
は削除してしまいました (props と言う名前の変数は存在しないため).
まず注目したいのはコンパイル結果の
const double = computed(() => __props.count * 2);
の部分です.こちらは RFC にも記載がありました.
実際にユーザーが書いたコードは const double = computed(() => count * 2);
ですが,メタデータによって count
が props
であることはわかっているので,__props.count
としてコンパイルされています.
この挙動は template 内で count を動作させる際とほとんど同様です.
RFC にもあった
template での暗黙的な props アクセスとの一貫性
の部分も伺えます.
スコープの制御もきちんとできているようで,
import { computed } from "vue";
const { count } = defineProps<{ count: number }>();
const double = computed(() => count * 2);
{
const count = 1;
console.log(count);
}
と書いた場合には,
const double = computed(() => __props.count * 2);
{
const count = 1;
console.log(count);
}
と言うコードが生成されます.
デフォルト値の設定
続いてデフォルト値の設定です.出力コードはみなさんすでに予想できていると思います.
Input
<script setup lang="ts">
import { computed } from "vue";
const { count = 0 } = defineProps<{ count?: number }>();
const double = computed(() => count * 2);
</script>
<template>
<div>{{ count }}{{ double }}</div>
</template>
Output
/* Analyzed bindings: {
"computed": "setup-const",
"double": "setup-ref",
"count": "props"
} */
import { defineComponent as _defineComponent } from "vue";
import { computed } from "vue";
const __sfc__ = /*#__PURE__*/ _defineComponent({
__name: "App",
props: {
count: { type: Number, required: false, default: 0 },
},
setup(__props, { expose: __expose }) {
__expose();
const double = computed(() => __props.count * 2);
const __returned__ = { double };
Object.defineProperty(__returned__, "__isScriptSetup", {
enumerable: false,
value: true,
});
return __returned__;
},
});
import {
toDisplayString as _toDisplayString,
openBlock as _openBlock,
createElementBlock as _createElementBlock,
} from "vue";
function render(_ctx, _cache, $props, $setup, $data, $options) {
return (
_openBlock(),
_createElementBlock(
"div",
null,
_toDisplayString($props.count) + _toDisplayString($setup.double),
1 /* TEXT */
)
);
}
__sfc__.render = render;
__sfc__.__file = "src/App.vue";
export default __sfc__;
count: { type: Number, required: true, default: 0 },
を観測することができました.
props のエイリアス
Reactive Props Destructure では通常の JavaScript の分割代入のように,変数名にエイリアスを与えることができます.
こちらも,どのようなコンパイル結果になるか除いてみましょう.
Input
<script setup lang="ts">
import { computed } from "vue";
const { count: renamedPropsCount } = defineProps<{ count: number }>();
const double = computed(() => renamedPropsCount * 2);
</script>
<template>
<div>{{ count }}{{ renamedPropsCount }}{{ double }}</div>
</template>
Output
/* Analyzed bindings: {
"renamedPropsCount": "props-aliased",
"__propsAliases": {
"renamedPropsCount": "count"
},
"computed": "setup-const",
"double": "setup-ref",
"count": "props"
} */
import { defineComponent as _defineComponent } from "vue";
import { computed } from "vue";
const __sfc__ = /*#__PURE__*/ _defineComponent({
__name: "App",
props: {
count: { type: Number, required: true },
},
setup(__props, { expose: __expose }) {
__expose();
const double = computed(() => __props.count * 2);
const __returned__ = { double };
Object.defineProperty(__returned__, "__isScriptSetup", {
enumerable: false,
value: true,
});
return __returned__;
},
});
import {
toDisplayString as _toDisplayString,
openBlock as _openBlock,
createElementBlock as _createElementBlock,
} from "vue";
function render(_ctx, _cache, $props, $setup, $data, $options) {
return (
_openBlock(),
_createElementBlock(
"div",
null,
_toDisplayString($props.count) +
_toDisplayString($props["count"]) +
_toDisplayString($setup.double),
1 /* TEXT */
)
);
}
__sfc__.render = render;
__sfc__.__file = "src/App.vue";
export default __sfc__;
まず,注目したいところは上部の解析結果です.
/* Analyzed bindings: {
"renamedPropsCount": "props-aliased",
"__propsAliases": {
"renamedPropsCount": "count"
},
"computed": "setup-const",
"double": "setup-ref",
"count": "props"
} */
となっており,エイリアスの情報が追加されています.
__propsAliases
にエイリアスと元の変数名の対応が記載されており,renamedPropsCount
も props-alised
という解析結果になっています.
この情報を元にコンパイラは,renamedPropsCount
を __props.count
としてコンパイルしています.
const double = computed(() => __props.count * 2);
_toDisplayString($props.count) +
_toDisplayString($props["count"]) +
_toDisplayString($setup.double);
Reactive Props Destructure はどのように実装されているか
さて,ここまでで Reactive Props Destructure についてユーザーが記述したコードがどのようなランタイムコードに変換されるかをみてきました.
それでは,これらが実際にどのような実装によって実現されているのか,vuejs/core のソースコードを読みながら追っていきます.
前程知識
まずは vuejs/core と言うリポジトリのおさらいですが,これが Vue.js (v3.0 以降) の実装で,packages 配下にいくつかのパッケージがあります.
今回見ていくのは compiler-sfc
と言うパッケージです.ここに Single File Component のコンパイラが実装されています.
多くの場合はバンドラ等のツールのプラグインやローダー (別のライブラリ) に呼び出されて使われます.
(例えば,vite だと vite-plugin-vue)
そして,この実装には,compile
と言う一つの大きな関数があるわけではなく,Single File Component を全体をパースする parse
と言う関数と,各ブロックをコンパイルするための,
compileScript
, compileTemplate
, compileStyle
と言う関数があります.
parse
の結果は SFCDescriptor
という SFC の情報を構造化したオブジェクトで,各ブロックのコンパイル関数はこの SFCDescriptor
(もしくは SFCDescriptor から得た情報) を引数に取ります.
script の解析やコンパイル
今回は script に関するコンパイラをメインで読んでいくことになるので,compileScript
周辺を読んでいきます.
注目したいファイル/ディレクトリは packages/compiler-sfc/src/compileScript.ts
と packages/compiler-sfc/src/script
です.
script/
の配下にそれぞれの処理が分割されていて,compileScript の方でそれらが呼び出されています.
今回は主に,以下の 3 つの処理を追ってみます.
まずは呼び出し元である compileScript.ts
から見ていきます.
compileScript という大きな関数があります.
この関数は SFCDescriptor を受け取り,コードをコンパイルします.
Descriptor から <script>
と <script setup>
の情報を取り出し,それぞれの処理を行います.
メタ情報の解析
順序は行ったり来たりするのですが,まずはメタ情報の解析から見てみます.
先ほどのコードの続きを見ていくと,bindings の情報を保持するオブジェクトが見当たります.
そして,いくつかの binding 情報を最終的には ctx.bindingMetadata
に格納しています.
この ctx.bindingMetadata
には後ほど読む analyzeScriptBindings
の結果も格納されているようで,全体として binding のメタ情報は全てこのオブジェクトに集約されていることが予測できます.
この関数の結果としても使われているため,間違いないでしょう.
試しにこの ctx.bindingMetadata を出力しつつ以下のような SFC をコンパイルしてみると,
<script setup lang="ts">
import { computed } from "vue";
const { count: renamedProps = 0 } = defineProps<{ count?: number }>();
const double = computed(() => renamedProps * 2);
</script>
<template>
<div>{{ renamedProps }}{{ count }}{{ double }}</div>
</template>
以下のような出力を得ることが出来ました.
{
renamedProps: 'props-aliased',
__propsAliases: { renamedProps: 'count' },
computed: 'setup-const',
double: 'setup-ref',
count: 'props'
}
また,binding のタイプには以下のものが列挙されていることも前程知識として覚えておくと良いでしょう.
それでは実際に ctx.bindingMetadata がどのように生成されているか見ていきましょう.
大枠を見てみると,compileScript は 1.1 ~ 11 のステップで処理を行っています.
※ 一部省略
.
.
.
この中から bindingMetadata に関わる部分を見ていきます.
さらにいうと,今回は defineProps や Reactive Props Destructure に関わる部分をのみを見ていきます.
<script>
1.1 walk import declarations of
script タグの import の解析です.
import 文ももちろん識別子のバインディングがあるので,解析して bindingMetadata に追加しています.
ここで気をつけたいのは,これらは <script>
の処理であり,<script setup>
は別の処理になっている点です.
AST を操作し,import 文を探します.
見つけたら registerUserImport
という関数で情報を登録します.
この時点では bindingMetadata
ではなく,ctx.userImports
というオブジェクトに登録しています.
この ctx.userImports
は後ほど bindingMetadata
に統合されます.
<script setup>
1.2 walk import declarations of
先ほどの処理の <script setup>
版です.
bindingMetadata
を生成する処理は同じですが,通常の script と違う点は import 文を hoist したりしている点です. (setup 関数の中には書けないので)
<script>
body
2.1 process normal
こちらはかなり長いですが,注目するべきところは後半に呼び出されている walkDeclaration
です.
walkDeclaration
少し前後しますが,walkDeclaration についてみてみましょう.(実際には 2.1 に関連しない部分もありますが,これから何度も出てくるので)
変数や関数,クラスの宣言があった場合にはそれらを元に bindingMetadata に情報を追加しています.
定義された変数が const
であるかどうか,初期値が ref
, computed
, shallowRef
, customRef
, toRef
の呼び出しであるかどうかなど,細かく判定います.
さらにここでポイントなのは,初期値が defineProps
, defineEmits
, withDefaults
などのコンパイラマクロの呼び出してあるかどうかも判定している点です.
この段階ではまだ BindingTypes.PROS
としてはマークされていないようです.
(defineProps だった場合には BindingTypes.SETUP_REACTIVE_CONST
になる)
<script setup>
body
2.2 process
続いて <script setup>
の処理です.
早速 processDefineProps
や processDefineEmits
, processDefineOptions
といったなんとも香ばしい関数が並んでいます.
変数宣言についてハンドリングしている部分を見てみましょう.
この辺りで defineProps
や defineEmits
が処理されていることがわかります.
今回は defineProps
に絞って読んでいきます.
ついては processDefineProps
という関数を読んでいきます.
defineProps を読む
processDefineProps
は script/defineProps.ts に実装されています.
初っ端ですが,node
が defineProps
の呼び出しでなければ processWithDefaults
に飛ばします.
processWithDefaults を見てみましょう.
こちらも初っ端で withDefault
の呼び出しであるかどうかを判定し,そうでなければ false を返して終了です.
何も処理は行われません.
それ以外の場合では withDefault の呼び出しをハンドルし,第一引数として渡される想定がされている defineProps を processDefineProps にかけます.
それでは processDefineProps に戻ります.
defineProps
には 2 つの API があります.
第 1 引数に Runtime Props の定義を渡す形式と,ジェネリクスに型を記載する形式の 2 つです.
まずは前者のオブジェクトを ctx に保存しておきます.
そしてそれらが存在する場合は key をとりだして bindingMetadata に BindingTypes.PROPS
として登録します.
続いて前者との重複チェックも行いつつ,後者の API の場合の定義 (型引数) も ctx に保存しておきます.
続いて,declId
がオブジェクトパターンの場合,Props Destructure として processPropsDestructure
を呼び出します.
processPropsDestructure
は script/definePropsDestructure.ts に実装されています.
processPropsDestructure
を見てみましょう.
processPropsDestructure
は,props の定義を解析する処理で,それ以外の部分で登場する props の参照書き換えの処理 (e.g. count
を __props.count
に書き換える) は行いません.
declId.properties
を読み進め,デフォルト値がある場合はそれを props の定義に追加していく処理がメインです.
参照書き換えの処理はまた後ほど読み進めます.今はここまででおしまいです.
processDefineProps
に戻ります.
戻ります,と言ってもこの後は特に何もしないので,processDefineProps
はここでおしまいです.
後は,ここで完成した ctx.propsRuntimeDecl
または ctx.propsTypeDecl
を元にコードを生成するだけです.
生成処理はここには書いていないので,また後で見ていきます.
<script setup>
body の続き
2.2 process compileScript に戻ってきました.
defineProps や defineEmits の処理が終わった後は,2-1 の時と同じように変数や関数,クラスの宣言を探して walkDeclaration
していきます.
walkDeclaration
は以前説明した通りです.
また,script setup
ではこれらに加え,以下のようないくつかの特別なハンドリングが必要です.
今回のメインテーマである Reactive Props Destructure には関係ないので,詳しくは説明しません.
- top-level await の場合に非同期コンポーネントとしてマークする
https://github.com/vuejs/core/blob/6402b984087dd48f1a11f444a225d4ac6b2b7b9e/packages/compiler-sfc/src/compileScript.ts#L628-L649 - export 文があった場合にエラーにする
https://github.com/vuejs/core/blob/6402b984087dd48f1a11f444a225d4ac6b2b7b9e/packages/compiler-sfc/src/compileScript.ts#L656-L667
以上で 2 の処理はおしまいです.
3 props destructure transform
続いて 3 です.ここが今回の肝です.
実行している関数名からも分かる通り,いよいよ Reactive Props Destructure の真髄と言ってもいいでしょう.
propsDestructure を読む
さて,早速読んでいきましょう.
この transformDestructuredProps
という関数は script/definePropsDestructure.ts に実装されています.
本記事でも,
スコープの制御もきちんとできているようで,
と触れた通り,スコープの管理が必要です.
まずはスコープ情報を詰め込む場所です.
スコープのスタックを操作する関数も見当たります.
前半部分には他にもローカル関数が用意されていますが,一旦読み飛ばして必要になったら随時読んでいきます.
メインの処理はこれ以下になります.
ctx から <script setup>
の AST を取得し,そのツリーを walk していきます.
estree-walker というライブラリが提供している walk
という関数を使いながら AST を walk して最終的な成果物を作るっていくのですが,
まずはこの walk を始めるまえに walkScope
という関数を一度実行して現在のスコープ (ルート) にあるバインディング情報を登録していきます.
node.body
(Statement[]
) を見て回り,識別子を生成しうる箇所を探しながら登録していきます.
具体的には変数,関数,クラスの宣言です.
これにより,currentScope として設定されているスコープに対してバインディング情報が登録されます.
この walkScope
関数は,walk
関数の中で呼び出されています.
それでは,ここからは実際に walk
関数で行っていることを見ていきましょう.
leave hook では大したことをやっていないので,先にこっちだけ把握しておきましょう.
重要な部分は,AST Node の type が /Function(?:Expression|Declaration)$|Method$/
にマッチする時または BlockStatement である時に popScope
している点です.
ここだけ押さえておけば問題ないでしょう.
それでは,メインの enter hook の方です.
function scopes
/Function(?:Expression|Declaration)$|Method$/
にマッチする場合です.
pushScope
しつつ,引数も walk して binding を登録しておきます.
catch param
続いて catch のパラメータです.
こちらはなんとも見落としがちですが,
try {
} catch (e) {}
の e
の部分です.これも忘れず登録しておきます.
non-function block scopes
続いて block scopes です.
function 以外のものが相当するようなので,if や for, while, try などに渡される BlockStatement などは全て対象です.
こちらは body の statement を見て周り,変数宣言や関数宣言などを見つけて binding を登録していきます.
identifier
ここまででバインディングの登録は終わっていて,最後に Identifier に入った時にバインディング情報をもとに id を書き換えます.
count
などが __props.count
に変換されるのはまさにこの時です.
scope の値を見て,local 変数でなかった場合は rewriteId を実行します.
rewriteId では,単純な識別子の書き換え (e.g. x --> __props.x
) に加え, オブジェクトのショートハンドなども処理しています.(e.g. { prop } -> { prop: __props.prop }
)
以上で Reactive Props Destructure の 識別子の書き換え に関する処理は終わりです.
ここまでである程度 Reactive Props Destructure に関する処理を理解できたのではないでしょうか.
後は,これまでに得た情報をもとにコードを生成するだけです.
後少し,その前に bindingMetadata の登録周りでやることがあるので続いてはそちらをみて行きましょう.
6. analyze binding metadata
もう何をやったか覚えてない方もいるかもしれませんが,ようやく戻ってきました.
これまで,compileScript の 1-1, 1-2, 2-1, 2-2, 3 と進んで,3 については Reactive Props Destructure の処理を見ました.
続きです.
4, 5 は一旦読み飛ばして,6 の binding metadata の解析に進みます.
まずは scriptAst
(setup ではない通常の script の AST) に対して analyzeScriptBindings
を実行します.
analyzeScriptBindings
は script/analyzeScriptBindings.ts に実装されています.
ここで何が行われているかというと,export default
を探して Options API からのバインディングを解析しています.
例えば,props
オプションなどです.
export default
されたオブジェクトに props
という key が存在している場合は BindingTypes.PROPS
として登録します.
他にも,inject
であったり,computed
, method
, setup
, data
などを解析して bindingMetadata に登録しています.
さて,analyzeScriptBindings
を抜けて 6 の処理の続きを見てみましょう.
続きは簡単で,これまでに収集した ctx.userImports
や scriptBindings
, setupBindings
等を ctx.bindingMetadata
に統合しています.
ここまでで bindingMetadata は完成です! お疲れ様でした!
残りはコード生成を見ていきましょう!
8. finalize setup() argument signature
生成する setup 関数のコードのシグネチャを決めています.
第 1 引数として受け取る props が __props
になるのもここの仕業です.
10. finalize default export
ここで最終的に出力するコードを組み立てています.
特に今回注目したいのは以下の部分です.
ここで,props の定義コードを生成し, runtimeOptions
(コンポーネントのオプションとして出力されるコードが格納される変数) に追加しています.
genRuntimeProps
をみてみましょう.
この関数は script/defineProps.ts に定義されています.
ランタイムオブジェクトとして渡されている場合はそれをそのまま文字列にして結果とします.
Destructure されている場合はそれに加え,デフォルト値を取り出し,mergeDefaults
という関数で実行時 (ランタイム) マージするようなコードを出力します.
続いては型引数で props が定義された場合です.
extractRuntimeProps
という関数で型情報を元にランタイムオブジェクト (を表す文字列) を生成します.
resolveRuntimePropsFromType という関数で props の定義データに変換していることがわかります.
やっていることは意外にも単純で,最終的には key 名,type, required の情報を持ったオブジェクトを生成しています.
required に関しては,本記事でも,
optional parameter の記述を見て required を決定するため
のように触れた通り,型情報から required を決定しています.
これらの定義データをどのように作っているのか軽くみていきましょう.
まずは resolveTypeElements という関数で型情報を解決しています.
Vue.js は 3.3.0 から defineProps などのマクロで import された type 定義を使えるようになりました.
ついては,単純に型引数に渡された型のリテラルを解析するだけではなく,外部ファイルから import された型定義等も解決しなくてはなりません.
その辺りをゴタゴタやっているのがこの辺りの処理です.
解決には tsconfig で設定された alias 等も必要なため,typescript をロードして,ts の api を使用して tsconfig を読み取ったりしています.
そうして解決された型情報を元に Props の定義に指定される type を推論します.
その関数が inferRuntimeType
です.
こちらもまた複雑ですが,型 node の type を元に推論します.
これらの推論方法については詳しくは触れませんが,地道に分岐を書いて,要所要所で resolveTypeReference
したり,再帰的に inferRuntimeType
したりして頑張ります.
さて,これらの処理によって型他引数に与えられた型情報からランタイムの Props 定義オブジェクトの生成に必要な情報を得ることができるようになりました.
残りは生成です.
ここまで戻ってきました.
後は props の定義を一つ一つループで回して genRuntimePropFromType
という関数でコードを生成するだけす.
Template での識別子の扱い
かなり長かったですが,ここまでで Reactive Props Destructure と defineProps の実装を追うことができました.
同時に, bindingMetadata にバインディングの情報が登録できているので,compileTemplate の方ではその情報を参照することで,
template で識別子を扱う際に正しい参照元のコードを出力することができます.
今回は Reactive Props Destructure について理解することが目的なので,template のコンパイラについては詳しくは触れませんが,
一応該当の部分はこの辺りのコードになります.
template のコンパイラは transformer というインタフェースを持っており,任意のトランスフォーム処理を実行することができます.
中でも,これは transformExpression
というトランスフォーマで,template に登場する式に対する変換を行なっています.
特に,CallExpression や MemberExpression,IdentifierExpression に関しては正しい参照元を辿ってコードを書き換える必要があります.
その処理が上記の rewriteIdentifier です.
登録された BindingTypes を元に正しい prefix を付与したりします.
e.g. {{ renamedCount }}
-> {{ $props['renamedCount'] }}
実装方法についてまとめ
ここまでで bindingMetadata の解析,defineProps, withDefault のハンドリング,destructuring された props へ参照の追跡,bindingMetadata に基づいた template のコンパイルについて見てきました.
これで一通りの処理に目を通したことになります.
お疲れ様でした.
しかし今回のタイトルは「Props Destructure の実装方法!」ではありません.
「Reactive Props Destructure を支える技術」です.
コンパイラはもちろんその一つですが,もう一つ大事なものがあります.
それが「言語ツールによる支援」です.
言語ツールの支援について
RFC に中でも,Props Destructure の欠点として以下のようなものが挙げられていました.
- destructuring された props を誤って関数に渡してしまい,リアクティビティが失われる可能性がある
- props であることが明示的でない (他の変数と区別がつかなくなる)
- コンパイラマジックによる初学者の混乱
これらに関しては,「まぁ以前からそうだし,特に大きな問題にはならない」という総評ではありましたが,
そもそもこの Reactive Props Destructure は DX を改善する目的で作られたものです.
以前からそうだったとはいえ,改善できる部分があるなら改善したほうが良いです.
そこで,Vue Team は言語ツールによる支援によってこれらを解決する道を選びました.
具体的には,props であることがわかりやすいように inlay hints を実装します.
こちらはリリースブログでも言及があります.
For those who prefer to better distinguish destructured props from normal variables, @vue/language-tools 2.1 has shipped an opt-in setting to enable inlay hints for them:
これにより,デフォルト値やエイリアスなどの簡潔な props 定義の恩恵を受けつつ,props の明示性を保つ試みです.
こちらは vuejs/language-tools に plugin として実装されています.
ここで,destructuring された props を探索するための findDestructuredProps
という関数を覗いてみると,先ほど compiler-sfc で見た transformDestructuredProps
のような実装を観測することができます.
transformDestructuredProps
と同じように,スコープとバインディングを解析し,識別子を検索しているようです.
総じて,どのように Reactive Props Destructure と向き合うのが良さそうか
かなり長い記事になってしまいましたが,今回は Vue 3.5.0 で安定版となった Reactive Props Destructure について,経緯や実装方法,言語ツールによる支援について見てきました.
この Reactive Props Destructure は RFC での議論を見ても分かる通り,賛否両論で,特に欠点について気にしている人も少なくないようでした.
実は,Vue.js Team にはチームメンバーのみが閲覧/書き込みできる internal な discussion board があり,そこではチームメンバーのみでの議論が行われることがあります.
Reactive Props Destructure はそこでも多くの議論が重ねられていました.
個人的には総じて,Props Destructure はとても良いものだと感じています.
そして,それを「良い」と評価するにはいくつかのポイントがあると思っているので以下にそれをまとめてこの記事の締めとしたいと思います.
- Vue.js は「コンパイラと言語ツールを通じて DX を向上させる」という方針を持っていること
これは一貫した考えがあると思います.ここについては以前の僕のスライドをぜひ見てみてください.
What is Vue.js? Hmm… It’s just a language lol - 既存の API とのちょうど良い落とし所がどこか考えること
そもそも Vue.js で props を定義するランタイムコードは props オプションただ一つです.
こちらに関しては 同 publication のコンパイラマクロに関する記事 をぜひ見てください. - 利点と欠点を理解し,欠点との向き合い方について考えてみること
これは言わずもがなだと思います.
RFC にモチベーションと欠点,そしてその欠点に対する向き合い方が書かれているのでまずはそれを読みましょう.
https://github.com/vuejs/rfcs/discussions/502#discussion-5140019
「Motivation」の部分と「Drawbacks」の部分です.
また,欠点に関してはここに書かれてあることに加え,「言語ツールによる支援」があることも念頭においてみましょう. - 仕組みについて理解してみること
これは必須ではありませんが,どのように実装されているのかを知ることで考え方や勘所を掴むことができます.
ここに関してはぜひこの記事を参考にしてもらえると嬉しいです.
一度仕組みを理解し,日頃コーディングするときは瞬間的にそのことを忘れ,表面に現れるインタフェースに集中し,少し迷った時に仕組みを思い出してみる.
みたいなスイッチをすることができればかなり強いと思います.
おしまい
Discussion