TypescriptでVueコンポーネントを書くということ
以下のコンポーネントと同等のものを、typescriptで書くことを考えます。
const ToDoItem = Vue.extend({
name: "ToDoItem",
template: `
<div>
<input type="checkbox" v-model="done">
<span :style="style">{{ text }}</span>
</div>`,
props: {
title: { type: String, required: true },
note: { type: String },
},
data() {
return { done: false };
},
computed: {
style() {
return this.done ? { textDecoration: "line-through" } : {};
},
text() {
return this.title + (this.note ? " - " + this.note : "");
}
},
watch: {
done: function(value, oldValue) {
console.log(`check changed: ${this.title}, ${ value }`);
}
}
});
このコンポーネントは title
, note
, done
, style
, text
というプロパティを持つけれども、それはvueが動的に追加するものであってコンパイラがそれを知る術はないので、補完や型チェックも効かずtypescriptを使う嬉しさがありません。
vue-class-component
vue-class-component という準公式ライブラリがあって、これを使うと以下のような形でこの問題を軽減することができます。
import VueComponent from "vue-class-component";
@VueComponent<ToDoItem>({
template: `(略)`,
props: {
title: { type: String, required: true },
note: { type: String },
},
data() {
return { done: false };
},
watch: {
done: function(value, oldValue) { // ※1
// 以前は、コンパイラからするとここの this は functionのはずなので、this.title などは
// コンパイルエラーになった
// 今は、@VueComponentの型引数に指定した型をthisの型だと解釈するようになったので、
// 普通にプロパティアクセスできるようになった
console.log(`check changed: ${ this.title }, ${ value }`);
}
}
})
class ToDoItem extends Vue {
// props, dataのメンバをフィールドとして定義しておく必要がある
title: string;
note: string;
done: boolean;
// computedに指定していたものはproperty getterとして定義する
get style() {
return this.done ? { textDecoration: "line-through" } : {};
};
get text() {
return this.title + (this.note ? " - " + this.note : "");
};
});
これで、ToDoItem
が title
, note
, done
, style
, text
というプロパティを持つことを認識できるようになるので、補完や型チェックが効くようになります。
以前は、デコレータの引数として渡している箇所(※1のところなど)では、this.title
などのようにプロパティにアクセスすることはできなかったのですが、Vue 2.0 対応と同時に @VueComponent
に型引数を指定することでそれも普通にできるようになりました。
これで、かなりいい感じにはなるのですが、残る問題として、propsやdataの定義を、デコレータの引数に渡す部分とクラスのメンバとして定義する部分で2回書かなければいけないという話があります。
vueit
そこのところを解決するために、vue-class-componentを参考にしつつ vueit というライブラリを書きました。
vue-class-componentの機能を踏襲しつつ、 props
の定義についてはフィールドに付加したデコレータから組み立てられるようにしようというのがコンセプトです。
data
のメンバを2回書かないといけない点については特別なサポートはありませんが、interfaceを定義することによって手間を軽減しつつ
コンパイラに間違いを検出させることができます。
(これはvue-class-componentを使う場合でも有効なテクニックだと思います)
import { component, prop, watch } from "vueit";
// dataメンバを表すInterfaceを定義する
interface ToDoItemData {
done: boolean;
}
@component<ToDoItem>({
template: `(略)`,
data(): ToDoItemData { // 戻り値の型を明示的に指定することでコンパイラによるチェックが効く
return { done: false };
}
})
class ToDoItem extends Vue {
// dataのメンバについては、個々にフィールドとして定義する代わりに、$data というフィールドを定義する
// フィールドが増えてもあんしん
$data: ToDoItemData;
// propsのメンバについては、@propで修飾する
// type validation ({ type: String } の部分)については、typescript上の変数の型をもとに自動で設定する
// ※type validationの自動設定を有効にするには tscのコンパイルオプションに emitDecoratorMetadata の指定が必要
@prop.required title: string;
@prop note: string;
get style() {
// dataのメンバには $data 経由でアクセスする
return this.$data.done ? { textDecoration: "line-through" } : {};
};
get text() {
return this.title + (this.note ? " - " + this.note : "");
};
// @watchで修飾したメソッドはwatchに登録される
@watch("done")
onDoneChanged(value, oldValue) {
console.log(`check changed: ${ this.title }, ${ value }`);
}
});
Why not vue-typescript?
これととてもよく似たコンセプトのライブラリに vue-typescript というものがあります。
実際vue-typescriptを見つけたときはvueitを放棄してこっちに移行しようかと考えたのですが、以下のような理由からvueitに戻ってきました。
vue-typescriptは Vue 2.x に未対応であること
これは時間の問題だとは思います。
data の扱いに不自由な点があること
vue-typescriptの場合、data()
も自分で書く必要はなくて、例えば以下のようなコードを書けば { done: false }
を返すfunctionを自動的に生成してくれます。この点は非常に楽だし直観的です。
@VueComponent
class ToDoItem extends Vue {
done: boolean = false;
}
半面、data()
が返す内容はクラスを定義した時点で固定されてしまうため、例えば以下のように初期値をpropから与えるようなコンポーネントは作成できません。
Vue.extend({
props: ["initialValue"],
data() { return { value: this.initialValue }; }
})
vueit は data()
に関しては何もしないので、通常のコンポーネント定義と同様に手書きする手間はありますが、特別な制限はありません。
type validationの自動設定
props
を定義する時に以下のように型を指定すると、実行時に型チェックをさせることができます。
props: {
prop1: String,
prop2: { type: Boolean, required: true },
prop3: { type: Number, default: 0 }
}
tscのコンパイルオプションに emitDecoratorMetadata
が含まれている場合、vueitは変数の型に応じてこのtype validationをできるだけ自動で設定します。これは vue-typescript にはない機能です。
class MyComponent {
// propの型がstring, boolean, number, functionの場合は、それぞれString, Boolean, Number, Functionを
// type validationに設定します。
@prop prop1: string; // → prop1: String
@prop.requied prop2: boolean; // → prop2: { type: Boolean, required: true }
@prop.default(0) prop3: number; // → prop3: { type: Number, defult: 0 }
@prop prop4: () => any; // → prop4: Function
// 残念ながらunion型は今のところ検出できないので、必要に応じて明示しなければなりません
@prop({ type: [String, Number]}) prop5: string | number;
}
小ネタ
テンプレートのプリコンパイル
Webpackの場合、vue-template-compiler-loader をインストールして、その出力を compiledTemplate
に突っ込めばOK
/// webpack.conf.js 抜粋
loaders: {
{ test: /\.html$/, loader: "vue-template-compiler" }
}
@component({
compiledTemplate: require("./template.html"),
/* 略 */
})
class MyComponent extends Vue {
/* 略 */
}
Functional Component
Vue 2.0 では、dataを持たず、Hookも必要としないコンポーネントについては Functional Componentとして定義できるようになりました。
const ToDoItem = Vue.extends({
name: "ToDoItem",
functional: true,
props: ["text", "done"],
render(h, context) {
const { text, done } = context.props;
const style = { textDecoration: done ? "line-through" : "none" };
return h("div", { style }, [ text ]);
}
})
これを、以下のように書けるようにしてみました。 render()
の3番目以降の引数がpropsになります。
@functionalComponent
class ToDoItem extends Vue {
render(h, context, text: string, done: boolean) {
const style = { textDecoration: done ? "line-through" : "none" };
return h("div", { style }, [ text ]);
}
}
@prop
でpropsのオプションを指定することも。
@functionalComponent
class ToDoItem extends Vue {
render(h, context,
@prop.required text: string,
@prop.default(false) done: boolean) {
const style = { textDecoration: done ? "line-through" : "none" };
return h("div", { style }, [ text ]);
}
}
と言いつつ、インターフェイスに迷っているところがあるのでまだmasterにはマージしていませんが。
props を interface として定義する話
なにを使うにしろ使わないにしろ、propsをinterfaceとして定義しておきたくなることが多いんじゃないかと思います。
import { component, prop, watch } from "vueit";
// ToDoItemコンポーネントが持つpropsをinterfaceとして定義する
interface ToDoItemProps {
title: string;
note?: string;
}
@component<ToDoItem>({ ... })
class ToDoItem extends Vue implements ToDoItemProps {
// ToDoItemPropsをimplementすれば、ここでフィールド名などを間違えていないことが ** 部分的に ** 保障される
// 「部分的に」というのは、省略可能としてinterfaceに定義したメンバの方は間違えても検出されないから
@prop.required title: string;
@prop note: string;
get style() { ... };
get text() { ... };
});
例えば、このinterfaceを使って下のようなメソッドを用意しておけば、
function renderToDoItem(h, props: ToDoItemProps, options: Vue.VNodeData): Vue.VNode {
return h(ToDoItem, Object.assign({ props }, options));
}
親のrenderメソッドでToDoItemを出力するときに渡すpropsをコンパイラにチェックさせることができます。
function render(h, options: Vue.VNodeData): Vue.VNode {
return h("div", { staticClass: "todo-item" }, [
renderToDoItem(h, { text: "焼肉を焼きに行く" })
])
return h(ToDoItem, Object.assign({ props }, options));
}
ただ、そうすると結局propsの内容を2か所に書くことになるので、どうしたもんかなあという感じ。
それなら vue-class-componentでもいいんじゃないのという気もするし・・・
雑なまとめ
-
Typescriptでvueのコンポーネントを書くライブラリはたくさんあるけど、微妙にほしいものと違う気がするので自分で作った
-
Vue in Typescriptのベストプラクティス的なものについてはまだまだ考え中
-
意見とかもらえると喜びます