皆さん、こんにちは。LINEでフロントエンド開発を担当しているUIT1室のシュウと申します。
今回、年に一度の企画「LINEのお年玉」キャンペーンにて JavaScript の部分を担当させていただきました。LINEのお年玉は多くのトラフィックが流れる大規模かつ短期間の企画となります。
技術的な挑戦をするためのプロジェクトとしてもちょうど良いサイズ感であったため、今回多くの挑戦を行いましたので、連載形式で紹介していければと思います。
初回である今回は、Vue.js と TypeScript を併用した開発についてです。
なぜ TypeScript を使うのか?
これまで LINE のプロジェクトでは、JavaScript をメインの言語としてフロントエンド開発を行ってきました。ですが、時代の流れもあり、現在では新規プロジェクトの多くが TypeScript を採用しています。
月並みですが、今回 Vue + TypeScript を採用した理由としては以下となります。
- LINE の UIT では Vue.js が最も多く使われている(参考: LINE Front-End Tooling Survey 2019)
- JavaScript にはない静的型を持ち、安全な開発が期待できる
- VSCode との補完に優位性がある
- Vue 2.5+ では TypeScript と十分に連携できる
これらを踏まえて、今回は Vue.extend スタイルで開発を進めました。
なぜデコレータを使わないのか?
Vue + TypeScript では vue-class-component や vue-property-decorator による開発がポピュラーですが、今回 Vue.extend を採用したのには理由があります。
- Class API が Vue 3 の RFC にリジェクトされた
- 既存の JavaScript コードと併用がしやすい
- プロジェクトメンバーの好み
一番大きいのは、 Class API のリジェクトです。
現在 Alpha が出ている Vue 3.0 を考えると、クラスは今後主流ではなくなることを考えてこのような形をとりました。
Tips: Vue 2.4 以前の記法との比較
Vue + TypeScript を昔挑戦したことがある人ほど、相性について悪い印象を持っているのではないでしょうか。
実際に私も過去に挑戦し、 Vue+TypeScriptに挑戦する記事を書いていたこともあります。しかし当時は、this の型がうまく予測できず、ComponentOptions としてしか export できないなどの問題が有りました。この状態では、現実的に開発は厳しいというのが伝わるかと思います。
現在(Vue 2.5+)ではこのような問題は解決されており、 this の型推論および型検査が正常に動作します。
Vue.js + TypeScript での開発で行ったこと
今回は新規プロジェクトなのでセットアップは Vue CLI にて行いました。
環境構築で特に問題となった点はありませんので、実際のコーディングについて説明します。
Vue での書き方
Vue.extendを利用します。
素直に Vue Component を記述する時、 Vue.extend を利用することが多いかと思います。ですが、Vue + TypeScript において、インスタンスプロパティへとアクセスする際は、このままでは問題が生じます。
export default {
methods: {
setMargin() {
this.$refs.svg as SVGElement.style.margin = '-1px'; // Property '$refs' does not exist on type '{ methods: { setMargin(): void; }; }'.
},
}
}
これは、 TypeScript がこのコードが Vue インスタンスと認識していないためです。そのため、以下のように Vue.extend とします。
import Vue from 'vue'
export default Vue.extend({
methods: {
setMargin() {
this.$refs.svg as SVGElement.style.margin = '-1px'; // Property '$refs' does not exist on type '{ methods: { setMargin(): void; }; }'.
},
}
})
更にこれだけでは、ビルド時に .vue ファイルが見つからない問題が発生する可能性があります。
1 ERROR
• main.ts
[ts]Cannot find module './App'. (4 17)
そうした場合は、shims-vue.d.ts のようなファイルを用意し、以下のように配置します。
declare module "*.vue" {
import Vue from "vue";
export default Vue;
}
これで正しく実装することができるようになります。
不足している型定義を as で補う
$refs の使い方
Vue.js で実際の DOM 要素への参照を持つ $refs は、TypeScript によって事前に型を特定することができません。
$refs の内容は、実行時によって移り変わることが理由となります。実際に、Vue デフォルトでは readonly $refs: { [key: string]: Vue | Element | Vue[ ] | Element[ ] }; として定義されています。
ですが、Element 要素を持っているため、任意の HTML や Vue コンポーネントへと型キャストを行うことは可能です。今回は以下のように記述しています。
import Vue from "vue";
import Popup from "./Popup.vue";
export default Vue.extend({
name: "YetAnotherPopup",
methods: {
open() {
(this.$refs.popup as InstanceType).open();
},
close() {
return (this.$refs.popup as InstanceType).close();
},
},
components: {
Popup
}
});
ここではコンポーネントとを指定するために InstanceType を利用しました。これで適切にコードが記述できます。
mixin の使い方
mixin については、mixins のマージの際にコンフリクトすることが理由となり、正しい定義を行うことができません。mixin の場合も、手動での Type Assertion によって解決できます。
import { adIdMixin } from "@/mixins/adIdMixin";
export default Vue.extend({
mixins: [adIdMixin],
methods: {
click() {
(this as InstanceType).sendTracking('purchase','click');
},
});
Vuex ストアの型定義
最後に、Vuex のストアの型定義について紹介します。
Vuex の構築
まずは state を export します。
export const state = { loading: false, valueA: null as string | null, valueB: null as ({ numberValue: number; stringValue: string; })[ ] | null };
次に Action および Mutation も記述した上で export します。
import { ActionTree, Commit } from "vuex";
import * as API from "@/utils/api";
import { state } from "./state";
// namespaced store なら、ここで rootState を導入することで rootState の型が提示される
import { rootState } from "@/store/types";
export default {
// we didn't use rootState here
getInfo({ commit, state, _rootState }) {
state.loading = true;
return API.getInfo()
.then(res => commit('setState', res.data))
.finally(() => {
state.loading = false;
});
},
// ActionTree<S, R> // S represent state, R represent rootState
// namespaced store なら、ここで rootState を導入することで rootState の型が提示される
// もしそうでなければ、もう一度 typeofstate 入力すること、だって rootState == state
} as ActionTree;
typeof を使うことで TypeScript に自分が型を推測させて、 state を初期化する時二回プロパティを書くのは要らない。
this.$store で as で Type Assertion
this.$store はデフォルトで State<any> の定義を持っています。こちらも同じく実行時の文脈に依存するため、予め型の予測をすることはできません。今回は as による Type Assertion で解決しました。
この場合、実装には 3 つの方法があります。
①:必要な文だけ逐次 import して型付けする
import Vue from 'vue';
import { state as childState } from "@/store/childStore/state";
// import store from "@/store";
export default Vue.extend({
computed: {
isLoading() {
return (this.$store.childState as typeof childState).loading;
}
},
});
②:ストア全体に対して RootState を型付けする
import Vue from 'vue';
import rootStore from "@/store";
export default Vue.extend({
computed: {
isLoading() {
return (this.$store as typeof rootStore).childState.loading;
// or as Store
// Store should be imported from vuex declartion
}
},
});
③:直接 store を import して、 this.$store を使わずに解決する。
import Vue from 'vue';
import rootStore from "@/store";
export default Vue.extend({
methods: {
getLoading() {
return rootStore.childState.loading;
}
},
});
今回は主に ① を利用して実装を行いました。
Tips: mapState と mapGetters について
残念ですが、mapState や mapGetters は正しく推論されません。
以下のように記述した場合でも、 this.valueA があることだけが伝わりますが、中身は any となります。
import Vue from "vue";
import { mapState } from "vuex";
export default Vue.extend({
computed: {
...mapState("childState", ["valueA"]),
},
methods: {
getNumber() {
this.valueA; // any
}
}
});
template 内で利用するデータだけに留めることを推奨します。
おわりに
「LINEのお年玉」では、このようにして Vue + TypeScript を利用して開発を進めてみました。こうしてみると、デコレータを利用せずとも、おおよその機能が利用できることがわかります。
今後のプロジェクトでも、 Vue 3.0 の様子を見つつ、当面はこのような形で Vue + TypeScript を利用することが増えそうです。
次の記事では、Content Loader の活用を始めとした、こまごまとした UX の向上施策を紹介したいと思います。
PR
LINE株式会社では、 Vue.js や TypeScript に詳しいエンジニアを募集しています。ご興味のある方はぜひ以下からご応募ください。