6
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

vue-class-componentをもっと型安全に使いたい話

Last updated at Posted at 2016-12-20

3行で

Componentに $props を生やすplugin(または型安全なVue Componentのはなし) の続きとして、
vue-typed-component というライブラリを作った。
fizz (1行余った)

これは何?

vue-class-component をラップして、propsemit に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 には requireddefault も指定されていないので、実行時に undefined になる可能性がある。
しかし、インターフェイスとしては $props.baz は省略可能になっていないので、baz を参照するコードを書いたときに strictNullChecks のチェックをすり抜けてしまう。 1

かと言って、bazbaz? としてしまうと、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のサポートも統合する。

脚注

  1. 実際には、required なpropが指定されていない場合でも、コンソールに警告を出力するだけでそのまま動くので、foo も実行時に undefined になりうる。
    そういう意味で、 $props の型を Props ではなく Partial<Props> にして、すべてのpropに対して strictNullChecks が適用されるようにする方が正確ではあるけど、それはそれでうっとうしくないかという気がして迷う。

6
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?