3行で
Componentに $props を生やすplugin(または型安全なVue Componentのはなし) の続きとして、
vue-typed-component というライブラリを作った。
fizz (1行余った)
これは何?
vue-class-component をラップして、props
や emit
にTypeScriptのコンパイラチェックが効くようにするためのライブラリ。
使い方
vue-class-component
と同じデコレータ(というかvue-class-component
のデコレータそのもの)と、コンポーネントの基底クラスとなる TypedComponent
, EvTypedComponent
などのクラスを公開している。
使い方は以下のサンプルから察してください。
import * as tc from "vue-typed-component";
interface ToDoProps { title: string, done: boolean }
interface ToDoEvents { stateChanged: boolean }
@tc.component<ToDoProps, ToDo>({
// ToDoPropsの定義に基づいてpropsの定義の過不足がコンパイル時にチェックされる
props: {
title: { type: String, required: true },
done: { type: Boolean, default: false }
},
template: "<div @click='clicked'><span :style='style'>{{ title }}</span>\\</div>"
})
class ToDo extends tc.EvTypedComponent<ToDoProps, ToDoEvents> {
// EvTypedComponentを継承することで、$props と $events という2つのインスタンスプロパティが生える
// $propsだけでいい場合は TypedComponent を使う
get style() {
// $props経由で各propにアクセスできる
if (this.$props.done) {
return { textDecoration: "line-through" };
}
else {
return {}
}
}
clicked() {
// $events経由でイベントをemitできる。$eventsには他に $on, $once, $off に対応する on, once, off
// メソッドもある。
// いずれも、ToDoEventsの定義に基づいてevent名と引数の型がコンパイル時にチェックされる
this.$events.emit("stateChanged", !this.$props.done);
}
}
$data
を持つコンポーネントの場合は StatefulTypedComponent
とか StatefulEvTypedComponent
を使う。
import * as tc from "vue-typed-component"
interface ToDoProps { title: string }
interface ToDoData { done: boolean }
@tc.component<ToDoProps, ToDo>({
props: {
title: { type: String, required: true }
},
template: "..."
})
class ToDo extends tc.StatefulTypedComponent<ToDoProps, ToDoData> {
// StatefulTypedComponent/StatefulEvTypedComponentはabstractクラスであり、
// data() の実装が強制される
data() {
return { done: false };
}
get style() {
// dataには $data 経由でアクセスできる。
// $data はもともとVueコンポーネントが持っているプロパティだが、Stateful~を使う
// ことでキャストなしでToDoData型として扱えるようになる
if (this.$data.done) {
return { textDecoration: "line-through" };
}
else {
return {};
}
}
clicked() {
this.$data.done = !this.$data.done;
}
}
ついでに Functional Componentの定義も
import * as tc from "vue-typed-component"
interface ToDoProps { id: string, title: string }
const ToDo = tc.functionalComponent<ToDoProps>(
/* name */ "ToDo",
/* props */ {
// ToDoPropsの定義に基づいてpropsの定義の過不足がコンパイル時にチェックされる
id: { type: String, required: true },
title: { type: String, required: true }
},
/* render */ (h, context) => {
// context.props の型がanyではなくToDoProps になる
const props = context.props;
return h("span", { attrs: { id: props.id } }, [ props.title ]);
}
);
できなくなること
$emit
ではイベントの引数を複数渡すことができるが、$events.emit
は仕組み上一つしか渡せない。
オブジェクトにまとめれば済む話ではある。
props
の定義で、 ["title", "done"]
のようにprop名だけの配列を指定することはできない。
何も制約を指定しない場合でも、 { title: {}, done: {} }
などのようにする必要がある。
所感
PropsやEvents, Dataなどをインターフェイスとして定義するのにめんどくささはあるのと、普通ならmixinで片付くところに継承ベースの仕組みを持ち込むことによる窮屈さはあるけど、コンパイラがいろいろチェックしてくれるありがたさには代えがたい。
Mapped Type
があるなら vue-class-component
+ α で十分なのでは!という気持ち。
問題
--strictNullChecks
と省略可能なprop
以下のようなコードを例にすると、 baz
には required
も default
も指定されていないので、実行時に undefined
になる可能性がある。
しかし、インターフェイスとしては $props.baz
は省略可能になっていないので、baz
を参照するコードを書いたときに strictNullChecks
のチェックをすり抜けてしまう。 1
かと言って、baz
を baz?
としてしまうと、props
の定義のところで baz
を書き忘れていてもエラーにならなくなってしまうので、それもあまりうれしくない。
interface Props {
foo: string,
bar: string,
baz: string
}
@tc.component<Props, MyComponent>({
props: {
foo: { type: String, required: true },
bar: { type: String, default: "bar" },
baz: { type: String }
},
template: "..."
}) class MyComponent ...
回避策として、baz?: string
ではなく baz: string | undefined
とするという手はある。
TODO
親コンポーネントのrenderメソッド内で、これで作ったコンポーネントをレンダリングする場合もProps, Eventsのインターフェイスが有効に使えるので、そのへんをよろしくやるようなhelperメソッドを追加する。
このへん(keyof で Vuex.Storeを型付けする)の話も合わせてVuexのサポートも統合する。
脚注
-
実際には、
required
なpropが指定されていない場合でも、コンソールに警告を出力するだけでそのまま動くので、foo
も実行時にundefined
になりうる。
そういう意味で、$props
の型をProps
ではなくPartial<Props>
にして、すべてのpropに対してstrictNullChecks
が適用されるようにする方が正確ではあるけど、それはそれでうっとうしくないかという気がして迷う。 ↩