現在プライベートで作っているWEBサービスのフロントエンドを作るにあたって、Vue.js+TypeScriptを試してみることにした。
基本的には公式サイトを参照させていただいた。

作るもの

メモを編集する画面を作成する。

  • 初期表示
    • メモ表示欄とEDITボタンが表示されている
    • メモ表示欄にはメモ取得APIで取得したメモの内容を表示する
    • APIで取得する内容はこんな感じ。{ id: 123, body: "これはサンプルです"}
  • EDITボタンクリック
    • 表示欄+EDITボタンを非表示にし、編集欄(textarea)+SAVEボタンを表示する
  • SAVEボタンクリック
    • 編集欄+SAVEボタンを非表示にし、表示欄+EDITボタンを表示する
    • メモ保存APIでメモを保存する

プロジェクト作成

npmのインストール

以前記事を書いたのでこちらを参照のこと。
node.jsおよびnpmのTips

vue-cliのインストール

npm install -g @vue/cli
プロジェクトを作成

以下のコマンドで対話型で作成するプロジェクトの内容を決めていく。

vue create frontend

以下の設定で作成した。

? Please pick a preset: Manually select features
? Check the features needed for your project: Babel, TS, Linter
? Use class-style component syntax? Yes
? Use Babel alongside TypeScript for auto-detected polyfills? Yes
? Pick a linter / formatter config: Standard
? Pick additional lint features: (Press <space> to select, <a> to toggle all, <i
> to invert selection)Lint on save
? Where do you prefer placing config for Babel, PostCSS, ESLint, etc.? In dedica
ted config files
? Save this as a preset for future projects? (y/N) N

アプリ起動

サーバを起動する。

cd frontend
npm run serve

サイトにアクセスすると、とりあえずサンプル画面が表示される。
http://localhost:8081/

自動生成された設定ファイル群の説明

.browserslistrc

異なるフロントエンドツールにおいて、ターゲットブラウザやNode.jsのバージョンをシェアするための設定を記載するもの。
下記設定は、ブラウザの利用シェアが1%より大きく、かつ、直近2バージョンをターゲットとして指定している。
詳細はこちらを参照のこと。

> 1%
last 2 versions

.editorconfig

EditorConfigは異なるエディタやIDEで一貫したコーディングスタイルを維持するための設定を記載するもの。
ファイル保存時にフォーマットされるようにするには以下の作業をする必要がある。

  • Editor Config for VS Code拡張をインストール
  • Code -> Preference -> Settings -> Text Editor -> Formatting -> Format on Saveをチェック
    • これをやると、保存時にindentもやってくれるが、"->'変換や行末に;追加などいらんことをするようになってしまう
    • こちらの記事のようにすれば回避できる模様
    • ただ、lintの定義が増えてしまうので今回自分は Format on Save は行わないことにした

下記設定は、特定の拡張子において、インデントのスタイルや行末のスペース、最終行の改行などの扱いを設定している。
詳細はこちらを参照のこと。

[*.{js,jsx,ts,tsx,vue}]
indent_style = space
indent_size = 2
trim_trailing_whitespace = true
insert_final_newline = true

.eslintrc.js

EsLintは、JavaScriptを実行せずに静的にJavaScriptコードの問題を発見するためツール。
EsLintを実行する方法はいくつかある。

  • npm run serveを実行する。サーバが立ち上がりファイルが保存されるたびにeslintが走る。
  • npm run lintを実行する。
  • VS CodeのESlint拡張をインストールする(デフォルトでSettingsのEslint: Enableになっている。)

下記設定の意味はコード上にコメントで記載。詳細はこちらを参照のこと。

module.exports = {
  root: true, // このフォルダがrootだという指定。つまり親フォルダの設定ファイルを探しに行かない
  env: {
    node: true // 事前定義されているグローバル変数をNode.jsグローバル変数にする。`browser`なども指定可能。
  },
  'extends': [ // vue用の拡張。基本これに従っておけばいいのだと思う。
    'plugin:vue/essential',
    '@vue/standard',
    '@vue/typescript'
  ],
  rules: { 
    'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'off', // productionの場合はerrorレベルで出力。それ以外は普通に出力
    'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off'
  },
  parserOptions: {
    parser: '@typescript-eslint/parser' // typescript用のparserを指定
  }
}

.gitignore

git管理対象外のファイルを指定してくれている。

babel.config.js

BabelはJavaScriptのコンパイラ。古いブラウザでES2015以降の新しいバージョンのJavaScriptを動かせるよう、後方互換性のあるバージョンのJavaScriptへコンパイルしてくれる。
ここではvue用のプリセットが設定されている。詳細はこちらを参照のこと。

module.exports = {
  presets: [
    '@vue/app'
  ]
}

package.json

npmでインストールする対象のJavaScriptパッケージとタスクの設定。
詳細はこちらを参照のこと。

{
  "name": "frontend",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "serve": "vue-cli-service serve", // アプリを起動するタスク
    "build": "vue-cli-service build", // production用にjavascriptをコンパイルするタスク
    "lint": "vue-cli-service lint" // lintを実行するタスク
  },
  "dependencies": {
    "core-js": "^2.6.5",
    "vue": "^2.6.10",
    (省略)
  },
  "devDependencies": {
    "@vue/cli-plugin-babel": "^3.7.0",
    (省略)
  }
}

postcss.config.js

PostCSSはJSプラグインでスタイルを変換するためのツール。変数はmixinをサポートしており、将来のCSSシンタックスでcssを記述できるらしい。
今回はVuetifyを利用するので、独自にスタイルを定義しない方針。
詳細はこちらを参照のこと。

module.exports = {
  plugins: {
    autoprefixer: {}
  }
}

tsconfig.json

TypeScriptに関する設定。詳細はこちらを参照のこと。

{
  "compilerOptions": {
    "target": "esnext",
    "module": "esnext",
    "strict": true, // strictモード
    "jsx": "preserve",
    "importHelpers": true,
    "moduleResolution": "node",
    "experimentalDecorators": true,
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true,
    "sourceMap": true, // mapファイルを出力する
    "baseUrl": ".",
    "types": [
      "webpack-env"
    ],
    "paths": {
      "@/*": [
        "src/*" // ランタイムにおいて、"@/hoge" -> "./src/hoge"としてパスを解決する
      ]
    },
    "lib": [ // コンパイルに含めるライブラリのリスト
      "esnext",
      "dom",
      "dom.iterable",
      "scripthost"
    ]
  },
  "include": [ // コンパイル対象のファイル
    "src/**/*.ts",
    "src/**/*.tsx",
    "src/**/*.vue",
    "tests/**/*.ts",
    "tests/**/*.tsx"
  ],
  "exclude": [ // 対象外にするフォルダ
    "node_modules"
  ]
}

入れておいた方がいいVS Codeの拡張

  • VueVetur拡張をインストールする
  • Editor Config for VS Code拡張をインストールする

実装Tips

やりたいこと別にTipsを追記していく方式にしようと思う。

Vueインスタンスに外部からパラメータを渡す

こちらを参考に、main.tsにてComponentOptionsの定義をすれば良い。
下記サンプルは、urlというパラメータをオプションとして追加している。?:で定義しないとエラーになるので注意。

import Vue from 'vue'
import App from './App.vue'

Vue.config.productionTip = false

// ここでComponentOptionsにurlというオプションを追加している
declare module 'vue/types/options' {
  interface ComponentOptions<V extends Vue> {
    url?: string
  }
}

// urlというオプションに値を渡している。
new Vue({
  url: 'http://rinoguchi.com/hoge/fuga',
  render: h => h(App)
}).$mount('#app')

これにより、Vueインスタンスからいつでもoptionの値を取得できるようになる。

export default class App extends Vue {
  private url?: string = this.$root.$options.url
}

子コンポーネントから親コンポーネントの処理を呼び出す

今回は、Loginコンポーネントでログインが完了したら、親のAppコンポーネントのloggedInプロパティをtrueにするというケースを説明する。

まずは、子コンポーネントのLoginコンポーネントにて、@Emitを使ってsucceededイベントを定義する。
下記サンプルでは、loginボタンをクリックしたら、ログイン処理が実行され、処理が終わったらthis.succeeded()を実行している。

<template>
  <div>
    <button @click=login>login</button>
  </div>
</template>

<script lang="ts">
@Component
export default class Login extends Vue {
  @Emit() private succeeded () {}
  login () {
    // login処理が実装してある
    (省略)
    // login処理が終わったらsucceededを実行する
    this.succeeded()
  }
}
</script>

次に、親コンポーネントのAppコンポーネントにて、Loginコンポーネントの@succeededイベントに対してloginSucceeded()を紐づける。
これによりLoginコンポーネントにてsucceededイベントが発火されたら、親コンポーネント側のloginSucceeded()が実行されることになる。

<template>
  <div id="app">
    <template v-if=loggedIn>
      <Memo>
    </template>
    <template v-else>
      <Login @succeeded=loginSucceeded />
    </template>
  </div>
</template>

<script lang="ts">
export default class App extends Vue {
  private loggedIn: boolean = false
  loginSucceeded (): void {
    this.loggedIn = true
    private url?: string = this.$root.$options.url
  }
}
</script>

axiosを使ってAJAX通信を行う

axiosを使ってAPIを呼び出す。
今回は、mountedにてメモを取得するAPIを呼び出し、その結果を画面に描画するようにしている。

まずは、依存関係を追加する。

npm install axios --save
  • templateでは、v-ifを使ってAPIでメモを取得するまではloading...を表示し、取得したらmemoを描画している。
  • scriptでは、@Propで親コンポーネントから渡ってきたmemoIdをプロパティとして定義している。
  • ライフサイクルフックのmountedにてAPIを呼び出している。
    • 200以外が返ってきたらalertを表示してアーリーリターンする。
    • 200の場合は、responseデータのmemoをプロパティのmemoに設定している
    • finallythis.loading = falseを設定し、メモが表示される。
<template>
  <div>
    <template v-if=loading>
      <pre>loading...</pre>
    </template>
    <template v-else>
      <pre>{{ memo }}</pre> 
    </template>  
  </div>
</template>

<script lang="ts">
export default class Memo extends Vue {
  @Prop() private memoId!: string
  private loading: boolean = false
  private memo?: string

  mounted (): void = {
    axios
      .get(`http://rinoguchi.com/memos`, { params: { id: this.memoId } })
      .then(response => {
        if (response.status !== 200) {
          alert('api call error')
          return
        }
        this.body = response.data.body
      })
      .finally(() => {
        this.loading = false
      })
  }
}

typescriptを使っているのでModelクラスを定義してみる

今回は、idbodyの二つのプロパティを持つMemoModelを定義してみようと思う。
いくつかの定義パターンが考えられるので、解説しようと思う。

1. プロパティがundefinedでもOKな場合

class MemoModel {
  id?: number
  body?: string
}

const memo = new MemoModel()
console.log(memo.body) // -> undefined
memo.body = 'xyz'
console.log(memo.body) // -> 'xyz'

2. プロパティがundefindedだと困るけど、初期値を設定できる場合

class MemoModel {
  id: number = -1
  body: string = 'default'
}

const memo = new MemoModel()
console.log(memo.body) // -> `default`
memo.body = 'xyz'
console.log(memo.body) // -> 'xyz'

3. プロパティがundefindedだと困るけど、初期値を設定できない場合

class MemoModel {
  id: number = -1
  body: string = 'default'
  constructor(theId: number, theBody: string) {
    this.id = theId
    this.body = theBody
  }
}

const memo = new MemoModel(123, 'xyz')
console.log(memo.body) // -> `xyz`

今回は、プロパティがundefindedだと困るけど、初期値は設定しても問題なかったので、2.のパターンで実装した。
もちろん、Modelクラスは別ファイルで定義して、importして使っても良い。

<template>
  <div>{{ memo.body }}</div>
</template>

<script lang="ts">
class MemoModel {
  id: number = -1
  body: string = 'default'
}

@Component
export default class Memo extends Vue {
  private memo: MemoModel = new MemoModel()

  mounted (): void {
    axios
      .get(`http://rinoguchi.com/memos/latest`)
      .then(response => {
        if (response.status !== 200) {
          alert('api call error')
          return
        }
        this.memo.id = response.data.id
        this.memo.body = response.data.body
      })
      .finally(() => {
        this.loading = false
      })
  }
}

環境変数を読み込む

今回はvue-cliを使っているので、こちらを参考にした。

以下の記述がほぼ全てを表していると思う。

.env # loaded in all cases
.env.local # loaded in all cases, ignored by git
.env.[mode] # only loaded in specified mode
.env.[mode].local # only loaded in specified mode, ignored by git

今回は、developmemtモードで動かしている際の環境変数を定義した。
変数名はVUE_APPで始まっている必要があるので要注意!!

APIのベースURLは、.env.developmentにて管理することにした。

VUE_APP_BASE_URL=https://rinoguchi.com/

Google OAuth認証のクライアントIDは、センシティブな情報なのでGITにcommitしたくない。そのため、.env.development.localにて管理することにした。

VUE_APP_GOOGLE_CLIENT_ID=***********.apps.googleusercontent.com

あとは、process.envを使ってどこからでも利用できる。

console.log(process.env.VUE_APP_BASE_URL)
console.log(process.env.VUE_APP_GOOGLE_CLIENT_ID)