是非、Vue.js
The English version of this article is available here.
ここ4ヶ月、仕事でVue.jsを大きなプロジェクトに使っていました。その間、いくつかのVueの欠点につまずき、この記事でまとめてみました。
記事内のコードは仕事の開発環境と同様です。現時点では、その環境は次の通りです。
- Vue.js (2.6.10)
vue-property-decorator
(8.3.0)vuex
(3.1.1)
- Typescript (3.6.4)
Table of Contents
動機
現在、東京のユニークビジョンというソフトウェア会社に勤めています。週に一回、エンジニアで集まり、技術勉強会を行っています。この記事は元々、その勉強会のためでした。
動機はもう1つあります。ユニークビジョンの全プロジェクトがVueを使っているわけではありません。新規プロジェクトにVueを使おうという方針もありません。フレームワークの選択過程に少しでも役立てるよう、という目標を心がけながら書いてみました。
Vueとは?
Vueとは、再使用可能な「コンポーネント」を作ることができるフレームワークのことです。コンポーネントを、ある「もの」のHTMLとJavaScriptだと定義しておきましょう。
もちろん、コンポーネント的なWeb開発のため、Vueは不可欠ではありません。Vueの工夫は、それを容易にするシンタックスシュガーいわゆる糖衣構文です。
Vueのコンポーネントの例として、次のカラーピッカーを見てください。
<template>
<div class="color-picker">
<div
v-for="(color, index) in colors"
:key="index"
class="item"
:class="color == value ? 'selected' : ''"
:style="{ background: color; }"
@click="$emit('input', color)"
/>
</div>
</template>
import Vue from 'vue';
import { Component, Prop } from 'vue-class-component';
@Component
export default class ColorPicker extends Vue {
@Prop({ required: true })
colors!: string[];
@Prop()
value?: string;
}
Vueの構文に慣れると、コンポーネントの定義は非常に短くなってきます。コンポーネントを使うのも同様です。カラーピッカーの例の実装は次のようです。
<template>
<div id="app">
<color-picker v-model="color" :colors="colors" />
<div>
<span>Color: {{ color }}</span>
</div>
</div>
</template>
import Vue from 'vue';
import { Component } from 'vue-property-decorator';
import ColorPicker from '@/components/ColorPicker.vue';
const RED = '#ff0000';
const GREEN = '#00ff00';
const BLUE = '#0000ff';
@Component({
components: {
ColorPicker,
},
})
class App extends Vue {
color: string = GREEN;
get colors() {
return [RED, GREEN, BLUE];
}
}
要するに、Vueのシンタックスシュガーを活かせば、割と複雑なコンポーネントでも簡潔に定義したり使用したりできるようになります。
そして、コードが短くなるにつれ、不具合も少なくなってきます。
ですが、犠牲になるものもあります。
問題:テンプレートの型安全
Vueのコンポーネントには、<template>
というHTMLが入ります。コンポーネントはそのHTMLを通して、子コンポーネントに「プロパティー」と呼ばれる引数を渡します。
残念ながら、子コンポーネントはプロパティーの型を定義しても、TypeScriptに確認されません。
例えば、カラーピッカーは color
という引数を文字列の配列として定義しています。
class ColorPicker extends Vue {
@Prop({ required: true })
colors!: string[];
}
でも、間違って文字列ではない配列を渡そうと…
<template>
<div>
<!-- 色って、数字でもあるんだろっ? -->
<color-picker
:colors="[
0xff0000,
0x00ff00,
0x0000ff
]"
/>
</div>
</template>
最初のエラーは、実行時です。おまけに、Vueのそれぞれのネイティブなコンポーネント(例えば <input>
)は自動的に引数を文字列に交換してしまうから、謎の不具合として数日間後も発生しかねません。
私たちのプロジェクトは、最新鋭の型安全ツールを使おうとしています。でも、<template>
にはそれは不可能です。いくら頑張っても、完全になりません。
完全な型安全がプロジェクトの条件の一つであれば、Vueはまだお勧めできません。
問題:グローバル変数
Vueのテンプレートはコンポーネントのデータとして宣言されたものしか使えません。例えば、カラーピッカーのアプリは、グローバルとして宣言された色を、テンプレートで使えるように、ローカルのゲッターに入れておいています。
const RED = '#ff0000';
const GREEN = '#00ff00';
const BLUE = '#0000ff';
@Component()
class App extends Vue {
get colors() {
return [RED, GREEN, BLUE];
}
}
結果として、テンプレートには colors
という変数が使えるようになりました。
<template>
<div class="color-picker">
<!-- 大丈夫:`colors` をローカルで宣言した。 -->
<div v-for="color in colors" />
</div>
</template>
ただし、コンスタントを直接使ってみると、未定義だと叱られます。
<template>
<div class="color-picker">
<!-- エラー:未定義、らしい。-->
<div v-for="color in [RED, GREEN, BLUE]" />
</div>
</template>
テンプレート内での lodash
のようなユーティリティーライブラリーの使用は特に不便です。まず、コンポーネントに定義するしかありません。
(でも、テンプレート内の型安全がないことと組み合わせれば、あまりテンプレート内の処理をしたくなくなります。)
私たちのプロジェクトには、データーベースの様々な値を定義する kbn
というコンスタントがあります。ほとんど全部のテンプレートに使われているため、モンキーパッチで各コンポーネントに入れています。
import { VueConstructor } from 'vue';
import { KbnConstants } from '@/kbn';
export default {
install(vue: VueConstructor) {
vue.prototype.$kbn = KbnConstants;
}
};
完全な解決はできません。TypeScriptを型宣言で納得させる必要があります。
import Vue from 'vue';
import { KbnConstants } from '@/kbn';
declare module 'vue/types/vue' {
interface Vue {
$kbn: typeof KbnConstants;
}
}
このプロセスは、少しずつオーバーヘッドを増やすばかりか、全くスケールしません。しかも、いつローカルとして定義するのか、いつモンキーパッチにするのか、判断は極めて難しいです。
Vueを使うと、絶対にグローバル変数や関数などをテンプレートに渡すという問題に直面します。知っている限り、綺麗な解決方法はありません。
問題:Vuexの窮地
変数をテンプレートで直接使えないことで、色々なライブラリーが使いにくくなると思います。
例として、Vuexというステート管理ライブラリーを上げましょう。Vuexとは、アプリのステートとそのステートを操る関数を「ストア」というオブジェクトとして定義し、コンポーネントはそのストアを使ってもらうライブラリーのことです。
便利な抽象です。他のフレームワークも似ているライブラリーを使っています。
でも、Vueの場合、このライブラリーは驚くほど未完全です。例えば、カラーピッカーに戻って、色の配列に enabled
という簡単なプロパティーを付けて見ましょう。
そのデータはVuexのストアであれば、次のように見えます。
const store = new Vuex.Store({
state: {
colors: [
{ color: '#ff0000', enabled: true },
{ color: '#00ff00', enabled: false },
{ color: '#0000ff', enabled: true },
]
},
getters: {
enabledColors: (state) =>
state.colors.filter((color) => color.enabled),
}
});
とすると、どのコンポーネントでも同じ関数などが使えるようになります。しかし、Vuexのドキュメンテーションを見たら、コンポーネントで使うためには、次のような定義が必要です。
import store from '@/store';
@Component({
computed: {
enabledColors: store.getters.enabledColors,
}
})
class App extends Vue {
}
そうです。毎回、再宣言する必要があります。
私たちは、静的変数として定義し、全コンポーネントに入れたことでこの問題を「解決」しました。便利になりましたが、同時に静的なのでモックを入れてテストするのは非常に難しくなってしまいました。
UIの自動テストがプロジェクトの条件であれば、窮地です。綺麗なコードとVuexのどちらかを、選択させられます。
それは嫌だったら、Vueを使わない方が良いかもしれません。
問題:一貫しない反応
カラーピッカーに戻ってみると、色を選択するとUIがすぐに更新されると確認できます。Vueは、UIに使われる変数が変わると、自動的にUIを更新してくれると主張しています。
残念ながら、その主張は結構条件付きです。
コンポーネントには、二種類のデータがあります。先ほど紹介した「プロパティー」は親コンポーネントからもらった引数です。残っているのは一般的な「データ」で、コンポーネントのローカルステートを保つ変数のことです。
なぜか、undefined
として初期化されたプロパティーは更新を起こしますが、データは起こしません。
@Component
class App extends Vue {
// 更新を起こす。
@Prop()
property: string | undefined;
// 更新を起こさない。
data: string | undefined;
}
そして、配列を使ってみると、メソッドの一部のみが更新を起こします…
@Component
class App extends Vue {
items: string[];
// 更新を起こす。
update(index: number, item: string) {
this.items.splice(index, 1, item);
}
// 更新を起こさない。
update(index: number, item: string) {
this.items[index] = item;
}
}
さらに、Vueはオブジェクトのプロパティーの追加と削除に気付けないらしいです。つまり、コンポーネントが作られた当時に存在していたプロパティーのみが更新を起こします。
interface Content {
messages: string[];
count: number;
}
@Component
class App extends Vue {
content: Partial<Content> = {
count: 0,
};
updateMessages(messages: string[]) {
// 更新を起こす。
this.content.count = messages.length;
// 更新を起こさない。
this.content.messages = messages;
}
}
ちなみに、正式的な答えは、content
という変数を毎回、完全に上書きすることです。
Vueのドキュメンテーションは上記の問題を「現代のJavaScriptの制限」に責任を追わせています。
こういう合理性のないルールを覚える気がなければ、Vueを使わないでください。
結論
Vueのテンプレートとコンポーネントの構文を学習する時、初めは簡潔に使えるように見えるかもしれません。しかし、プロジェクトが進むと、いずれ制御が失われます。そして、型安全、自動テスト、とDRY原則などはなおさら重視されてきます。
あくまで個人意見ですが、Vueはそういう高品質を求めるプロジェクトにはふさわしいとは思いません。
しかし、Vueの特性を習うのを引き換えに、速くデザインを練り直すことができる選択肢を探しているのなら、是非、Vue.js。