TypeScript + Vue.jsでのフロントエンド開発

最近webアプリ(RailsのviewでVue.jsを使ってる感じの部分)をフロントエンドだけ切り離してリライトする業務をやった。

他の業務と並行で進め、実装からリリースまでの期間は大体営業日換算で2週間ちょいくらい。

バックエンドのAPIなどはすでに開発済みのものを使えたので純粋にフロントエンドのみ。

途中、既存の動いてるアプリへ機能追加などされていたので都度追従していく作業もやった。

そもそもリライトすることを決めたのは元のアプリが歴史的な経緯などあり、トランスパイルできる環境に無く、加えてAndroid4.2以降のwebブラウザに対応する必要があったため。素手でES5を書き続けるのは規模が大きくなっていくとだいぶ辛いしここらでちゃんと整えましょうということで重い腰をあげたという感じ。

フロントエンドライブラリとしてはVue.jsに元々は慣れてたこともあり、Vue.jsを利用。この夏にリリースされる予定のVue 3系からはクラスベースのコンポーネントとTypeScriptサポートが予定されているので慣れる意味も含めてTypeScriptも利用することにした。

またNuxtも検討したがSSRは要らないしNuxtのファイル名ベースのルーティングが使えないことが初めからわかってたのでVue CLI3を使う。

以降、TypeScript + Vue.jsでどう開発を進めていけばいいのかなど試行錯誤した結果をまとまって書き留めておく。

環境構築

とりあえず環境構築。Vue CLI3を叩く。

$ yarn global add @vue/cli
$ vue create vue-ts-project

対話形式で色々聞かれるが今回はこんな感じ。

Vue CLI v3.5.1
  ? Please pick a preset: Manually select features
  ? Check the features needed for your project: Babel, TS, Router, Vuex, CSS Pre-processors, Linter, Unit
  ? Use class-style component syntax? Yes
  ? Use Babel alongside TypeScript for auto-detected polyfills? Yes
  ? Use history mode for router? (Requires proper server setup for index fallback in production) Yes
  ? Pick a CSS pre-processor (PostCSS, Autoprefixer and CSS Modules are supported by default): Sass/SCSS (with dart-sass)
  ? Pick a linter / formatter config: Prettier
  ? Pick additional lint features: (Press <space> to select, <a> to toggle all, <i> to invert selection)Lint on save
  ? Pick a unit testing solution: Jest
  ? Where do you prefer placing config for Babel, PostCSS, ESLint, etc.? In dedicated config files
  ? Save this as a preset for future projects? No

Pick a linter / formatter configではESLint + Prettierを選択した。TypeScript on ESLint の未来にも書かれているが今後はESLintに統一される流れっぽいので。

ESLintの設定

インストール。

yarn add -D eslint @typescript-eslint/eslint-plugin prettier eslint-config-prettier eslint-plugin-prettier

.eslintrs.jsはこんな感じ。plugin:vue/recommendedも入れてたんだけど、VSCodeのVeturを使ってるとうまく機能しなくて外してしまった。

module.exports = {
  parser:  'vue-eslint-parser',
  parserOptions: {
    'parser': '@typescript-eslint/parser',
  },
  extends:  [
    'plugin:@typescript-eslint/recommended',
    'prettier/@typescript-eslint',
    'plugin:prettier/recommended',
  ],
  rules: {
    'prettier/prettier': [
      'error',
      {
        'singleQuote': true,
        'semi': false
      }
    ],
    'indent': [
      "error",
      2
    ],
    'interface-name': false,
    'ordered-imports': false,
    'object-literal-sort-keys': false,
    'no-consecutive-blank-lines': false
  }
}

構成

アプリのディレクトリ構成

ディレクトリ構成はこんな感じ。もちろん実際はコンポーネントもstoreももっと巨大だけど見やすさのために省きまくってる。

.
├── App.vue
├── main.ts
├── router.ts
├── shims-tsx.d.ts
├── shims-vue.d.ts
├── @types
│   └── vue.d.ts
├── api
│   └── home.ts
├── components
│   ├── Loading.vue
│   └── home
│       └── Header.vue
├── constants
│   └── url.ts
├── directives
│   └── swipe.ts
├── models
│   ├── item.ts
│   └── user.ts
├── plugins
│   └── global.ts
├── stores
│   ├── index.ts
│   └── modules
│       └── user.ts
├── utils
│   ├── error.ts
│   └── logger.ts
└── views
    ├── home
    │   └── Index.vue
    └── profile
        └── Index.vue

shims-tsx.d.ts/shims-vue.d.ts

TypeScriptプロジェクトに特有のファイル。

.vueファイルをTypeScriptで使えるようにする。

main.ts

new Vue をするエントリーポイント。globalで設定しておきたい処理などをする。

App.vue

コンポーネントのルート。グローバルで使うCSSの定義とかコンポーネントで最初にやっておきたい初期化処理とかはここでやる。

views

viewsには各ページのTOPの親コンポーネントを置く。

他の子コンポーネントとVuexのactionの橋渡しとなるようなコントローラ役っぽいことをやってもらう。

components

ボタンや画像やヘッダーやフッターなどの大小の違いはあれどパーツとなるコンポーネントを置く。

極力viewからpropでデータを渡してもらってそれを表示するだけの役割にしたい(実際はそんなに上手くいかない...)。

stores

Vuexを中心にアプリのstate(状態)とそれを変更する(mutation)とviewとやり取りするメソッド(action)を定義する場所。

大体のアプリのロジックはここに詰まっていく。

mutationはprivateメソッドとして定義し、publicなのはactionとget propertyだけにする。

models

APIのレスポンスの型定義とか諸々のstateをinterfaceとして定義して置いておく。

api

APIの定義。返り値は全てPromiseで返すようにする。vuexのactionから呼び出される。

@types

型定義のないライブラリなどを使った時にdeclare moduleなどで独自の型定義を追加して設置する場所。

その他

pluginsとかutilとかは好みなので適当に必要に応じて作る。

開発にあたって意識すること

vue-property-decorator/vue-class-componentに慣れる

クラスコンポーネント形式(vue-class-component)でVueを書いたことがない人もいると思うが、TypeScriptで型を適切に推測できるようにするにはクラスコンポーネントで書く必要があるので今後の為に慣れておくと良い。

Use class-style component syntaxでYesを選ぶとvue-property-decoratorが使えるようになっている。これはvue-class-componentをデコレータを使って書けるようにするやつで慣れると便利。

その辺はこの記事がわかりやすいので合わせて読んでおくと良い。

その記事から書き方を抜粋したのがこれ。Angular感。

import { Component, Prop, Emit, Watch, Vue } from 'vue-property-decorator';
 
@Component({
  /** filters */
  filters: {
    convertUpperCase(value: string): string | null {
      if (!value) {
        return null;
      }
      return value.toUpperCase();
    },
  },
})
export default class HelloVue extends Vue {
  /** props */
  @Prop() val!: string;
 
  /** data */
  value: string = this.val;
  inputValue: string = '';
 
  /** emit */
  @Emit('handle-click')
  clickButton(val: string): void {}
 
  /** watch */
  @Watch('value')
  onValueChange(newValue: string, oldValue: string): void {
    console.log(`watch: ${newValue}, ${oldValue}`);
  }
 
  /** computed */
  get isDisabled(): boolean {
    return this.inputValue === '';
  }
 
  /** lifecycle hook */
  mounted(): void {
    console.log('mounted');
  }
 
  /** methods */
  handleInput($event: Event): void {
    this.inputValue = (($event.target as any) as HTMLInputElement).value;
  }
  handleClick(): void {
    if (this.inputValue === '') {
      return;
    }
    this.value = this.inputValue;
    this.inputValue = '';
    this.clickButton(this.value);
  }
}

Vuexとvuex-module-decorators

Vuexを制したものはVueでのフロントエンド開発の半分以上を制したと言っても過言ではない。それくらい大事なのでちゃんと方針を決めて臨む必要がある。

今回はドメイン駆動Vuexで複雑さに立ち向かうに習ってVuexを作っていくことにした。

とはいえ複雑なことは特になくて、下記の画像のように、

  • Action: UIから呼ばれる/APIを呼ぶ/mutationを呼ぶ
  • mutation: stateを更新する
  • getter: stateをごにょごにょしてUIに返す

という役割をそれぞれ担うように気をつけるだけ。

f:id:razokulover:20190417161101p:plain
ドメイン駆動Vuexで複雑さに立ち向かう https://medium.com/studist-dev/ddd-vuex-c47055f6c1ba

また、今回のプロジェクトではvuex-module-decoratorsを使った。

これはVuexをデコレータを使って書けるようになるライブラリ。Vuex + TypeScriptで発生しがちな型がイマイチ効かなかったりみたいなことが完全に発生しなくなる。安全!

こんな感じで書く。

import { Module, VuexModule, Mutation, Action } from 'vuex-module-decorators'

export interface ConterStateInterface {
  count: number
}

@Module({ dynamic: true, store, name: "counter", namespaced: true })
class Counter extends VuexModule implements ConterStateInterface {
  public count: number = 0

  @Mutation
  private INCREMENT(delta: number): void {
    this.count += delta
  }
  @Mutation
  private DECREMENT(delta: number): void {
    this.count -= delta
  }
  @Action()
  public incr(): void {
    return this.INCREMENT(1)
  }
  @Action()
  public decr(): void {
    return this.DECREMENT(1)
  }
}
export const counterModule = getModule(Counter);

従来のcommit("INCREMENT", "any")みたいな呼び出しだと引数になんでも渡せてしまうが、this.INCREMENT('hoge')みたいな呼び出しができるようになるのでnumber型以外の時にエラーを吐くようになる。

下記を読むともっと詳しい使い方などがわかる。

その他Tipsや注意とか

public/privateや返り値の型

極力つけるようにする。コードを見た時に一目で役割が理解しやすいし、IDEの補完も詳細に効きやすくなるので。

アプリ全体の初期化

最初にやりたい初期化はpluginにしてmain.tsで読み込むと便利。

例えば、以下のようなdocument.querySelectorをラップするメソッドをVue.prototypeにグローバル定義したい場合は、これをmain.tsで読み込ませる。

import _Vue from 'vue'

export default {
  install(Vue: typeof _Vue) {
    Vue.prototype.$Elm = (selector: string) => {
      const elms = document.querySelectorAll<HTMLElement>(selector)
      return elms.length === 1 ? elms[0] : elms
    }
  }
}
import GlobalInitializer from '@/plugins/global'
.
.
Vue.use(GlobalInitializer)

最初にやりたい処理が少なければmain.tsに直接書いてもいいけど、たくさんある場合はpluginとして外だしした方がmain.tsが汚れなくて良さそう。

directive

Vue.directive('sample-hook', {...}) みたいに定義してるときに {...} のコード量が多いと見通しが悪くなる。

そういう時は第2引数のObjectの部分だけ切り出してexport defaultしておいて、利用する際にcomponent側で都度importしてdirective定義する感じが良いっぽい

環境変数

process.envで読み込める環境変数VUE_APP_がPrefixについたものだけ

vuex-module-decoratorsを使う上での注意

  • public propertyは必ず初期値が必要なので、public userName!: string みたいな定義だとダメで、public userName: string = '' みたいにする
  • moduleを使うときはnamenamespaceの設定が必須。 namaspace: true name: 'user' みたいな感じで設定。片方しか設定してなかったとしてもエラーとかは吐いてくれないので注意。

ドキュメントとソースコードを読んで知ってればどっちも当たり前なんだけど、ハマると時間を浪費してしまい大変。

vue-routerの変化をWatchする

こんな感じでいける。

@Watch('$route', { immediate: true, deep: true })
onRouteChange() {}

lazyload

画像をたくさん扱う時はlazyloadしたいと思う。vueで一番人気なライブラリといえばvue-lazyloadだけど、これは複数画像を画面いっぱいで扱う時に画像が二重にロードされたりすることがあってちょっと使いづらい。

そこでIntersection Observer APIを使った軽量なlozadというライブラリを使って自作の画像コンポーネントを作って対応した。

Intersection Observer APIは簡単にいうと、ある要素が画面内に100%入った時に何かcallbackを実行するという処理を簡単にできるようにする機能。

現状だとSafariではPC/Mobile共に利用ないが、Chrome/Firefox/Edgeなどでは概ね利用できる。polyfillも用意されてるので合わせて使うと良い。

下記の例ではLazyImageコンポーネントl-srcl-styleという感じでpropを指定できるようにしている。

<template>
  <img
    :data-src="dataSrc"
    :style="dataStyle"
  />
</template>

<script lang="ts">
import { Component, Vue, Prop } from 'vue-property-decorator'
import lozad from 'lozad'

@Component({})
export default class LazyImage extends Vue {
  @Prop() lSrc!: string
  @Prop() lStyle!: string

  get dataSrc(): string {
    return this.lSrc
  }

  get dataStyle(): string {
    return this.lStyle
  }

  mounted() {
    const observer = lozad(this.$el)
    observer.observe()
  }
}
</script>

<style lang="scss" scoped></style>
<LazyImage :l-src="hoge.jpg" :l-style="width: 100px; height: 80px"></LazyImage>

まとめ

TypeScript + Vue.jsでフロントエンド開発をした際に利用した技術やtipsについて書いた。

開発全体を通じて時間を使ったのは、

あたりでTypeScriptを使ったクラスコンポーネントの書き方自体はそこまで苦労しなかった。

最初TypeScriptでVue.jsアプリを書くのは大変という勝手な先入観を持っていたが、クラスコンポーネントやvuex-module-decoratorsを使って開発した結果それほど不満なく開発できたように思う。

Vue 3に向けてTypeScriptサポートが強化されるなど今後さらにTypeScript + Vueの利用が増えていくはずなので早めに慣れておくと良さそう。

アプリに関してはまだunitテストが甘かったりリファクタが必要そうな場所は沢山あるので今後改善していく。

何かある人はブコメ@razokuloverまでどうぞ。

[asin:4297100916:detail]