シャッフル主任の進捗報告

興味のあるものを作ります。進捗を不定期にご報告します。

そうだ、教祖になろう。出エジプト記 第4章8節 クライアントサイドのE2Eテストを実装する

知らず知らず歩いてきた細く長いこの道


すっかり休み気分も抜けた頃ですが、あけましておめでとうございます。
お正月は異教の宗教施設に行ってきました。
やはり、朝の寺社仏閣は心が洗われます。
2020年も新たな気持ちで本ブログに取り組んでいきたいと思います。

f:id:chief-shuffle:20200104095645j:plain

振り返れば遥か遠く 故郷が見える


昨年の第4章7節 クライアントサイドのユニットテストをカバレッジ100%にするでJestでVue.jsのユニットテストを実装しました。
Jestはモジュール単位のテストですが今回はEnd to End Test、つまりブラウザからリクエストを送ったのをシミュレートして、返ってきたレスポンスをアサーションするテストを実装しようと思います。
使うのはNightwatchパッケージです。
まあこれが新年一発目のブログが遅れた理由です。
もちろん、正月ボケもその一因ですが。

Nightwatchは疑似的なサーバから受け取ったレスポンスをブラウザにレンダリングさせます。
Cloud9の実体はEC2インスタンスなのでAmazon Linuxになるわけですが、
GUI関連のライブラリが入っておらず、まずChromeのインストールがひと苦労でした。
試行錯誤をいちいち書いていると長くなりますし、細かいことは忘れてしまったので、要点だけ書き留めます。

まずyarn test:e2eを起動すると「何言ってんだい!うちにゃぁ、Chromeなんてないよ!嫌ならGoogleさんちの子になんな!」と怒られてしまいます。

Error: An error occurred while retrieving a new session: "unknown error: cannot find Chrome binary"

f:id:chief-shuffle:20200115190503j:plain

ああ川の流れのように


まずはChromeを入れるとこからです。
UIを伴わないヘッドレスChromeを導入します。
参考にさせていただだきました。

qiita.com

必要な依存パッケージを全部入れます。

$ sudo yum install -y cups-libs dbus-glib libXrandr libXcursor libXinerama cairo cairo-gobject pango ipa-gothic-fonts ipa-mincho-fonts
$ sudo rpm -ivh --nodeps http://mirror.centos.org/centos/7/os/x86_64/Packages/atk-2.28.1-1.el7.x86_64.rpm
$ sudo rpm -ivh --nodeps http://mirror.centos.org/centos/7/os/x86_64/Packages/at-spi2-atk-2.26.2-1.el7.x86_64.rpm
$ sudo rpm -ivh --nodeps http://mirror.centos.org/centos/7/os/x86_64/Packages/at-spi2-core-2.28.0-1.el7.x86_64.rpm
$ sudo rpm -ivh --nodeps http://dl.fedoraproject.org/pub/archive/fedora/linux/releases/20/Fedora/x86_64/os/Packages/g/GConf2-3.2.6-7.fc20.x86_64.rpm
$ sudo rpm -ivh --nodeps http://dl.fedoraproject.org/pub/archive/fedora/linux/releases/20/Fedora/x86_64/os/Packages/l/libXScrnSaver-1.2.2-6.fc20.x86_64.rpm
$ sudo rpm -ivh --nodeps http://dl.fedoraproject.org/pub/archive/fedora/linux/releases/20/Fedora/x86_64/os/Packages/l/libxkbcommon-0.3.1-1.fc20.x86_64.rpm
$ sudo rpm -ivh --nodeps http://dl.fedoraproject.org/pub/archive/fedora/linux/releases/20/Fedora/x86_64/os/Packages/l/libwayland-client-1.2.0-3.fc20.x86_64.rpm
$ sudo rpm -ivh --nodeps http://dl.fedoraproject.org/pub/archive/fedora/linux/releases/20/Fedora/x86_64/os/Packages/l/libwayland-cursor-1.2.0-3.fc20.x86_64.rpm
$ sudo rpm -ivh --nodeps http://dl.fedoraproject.org/pub/archive/fedora/linux/releases/20/Fedora/x86_64/os/Packages/g/gtk3-3.10.4-1.fc20.x86_64.rpm
$ sudo rpm -ivh --nodeps http://dl.fedoraproject.org/pub/archive/fedora/linux/releases/16/Fedora/x86_64/os/Packages/gdk-pixbuf2-2.24.0-1.fc16.x86_64.rpm

yumリポジトリGoogleリポジトリを登録。

$ sudo touch /etc/yum.repos.d/google-chrome.repo
$ echo -e "[google-chrome]\nname=google-chrome\nbaseurl=http://dl.google.com/linux/chrome/rpm/stable/\$basearch\nenabled=1\ngpgcheck=1\ngpgkey=https://dl-ssl.google.com/linux/linux_signing_key.pub" | sudo tee -a /etc/yum.repos.d/google-chrome.repo
$ cat /etc/yum.repos.d/google-chrome.repo
[google-chrome]
name=google-chrome
baseurl=http://dl.google.com/linux/chrome/rpm/stable/$basearch
enabled=1
gpgcheck=1
gpgkey=https://dl-ssl.google.com/linux/linux_signing_key.pub

Chromeの安定版をインストールします。

$ sudo yumdownloader google-chrome-stable
Loaded plugins: priorities, update-motd, upgrade-helper
amzn-main                                                                                                                                                 | 2.1 kB  00:00:00     
amzn-updates                                                                                                                                              | 2.5 kB  00:00:00     
google-chrome                                                                                                                                             | 1.3 kB  00:00:00     
4668 packages excluded due to repository priority protections
google-chrome-stable-79.0.3945.117-1.x86_64.rpm                                                                                                           |  59 MB  00:00:01     
$ ls -l google-chrome-stable-79.0.3945.117-1.x86_64.rpm
-rw-r--r-- 1 root root 62377282 Jan  6 23:36 google-chrome-stable-79.0.3945.117-1.x86_64.rpm
$ sudo rpm -i --nodeps google-chrome-stable-79.0.3945.117-1.x86_64.rpm
Error: Could not find xdg-icon-resource
warning: %post(google-chrome-stable-79.0.3945.117-1.x86_64) scriptlet failed, exit status 1

xdg-icon-resourceが見つからないという警告が出ますが、ヘッドレスChromeなので構いません。
インストールされたバージョンを確認します。

$ /opt/google/chrome/chrome --version
/opt/google/chrome/chrome: /lib64/libdbus-1.so.3: no version information available (required by /usr/lib64/libatk-bridge-2.0.so.0)
/opt/google/chrome/chrome: /lib64/libdbus-1.so.3: no version information available (required by /usr/lib64/libatspi.so.0)
Google Chrome 79.0.3945.117 unknown

なにやら警告が2行出ていますが、バージョンが出ているのでとりあえずよし。

Googleトップページのスクショを撮ります。

/opt/google/chrome/chrome --headless --disable-gpu --window-size=1024,768 --hide-scrollbars --screenshot='google.png' https://www.google.co.jp/

f:id:chief-shuffle:20200115192449j:plain

たくさんメッセージが出ますが、Written to file google.png.が出てればOK。
PNGファイルを開いてみると、HTTPレスポンスが画像化されています。

f:id:chief-shuffle:20200115192530j:plain

とめどなく空が黄昏に染まるだけ


これで文句ないだろう!とyarn test:e2eを起動すると
「おっとっと、どうやらChromeが起動しなかったみたいだねぇ(ニヤニヤ)」と嫌らしい笑みを浮かべています。
ああ、憎たらしい!
神様、なぜ新年からこのような奴と餅を吸わねばならんのでしょう!

Error: An error occurred while retrieving a new session: "unknown error: Chrome failed to start: exited abnormally"

f:id:chief-shuffle:20200115201939j:plain

しかし、エラーメッセージにヒントがありました。
あぁ、神は我を見放したもうたりはしなかった。

 ERROR  Error: Command failed: /home/ec2-user/environment/test/node_modules/nightwatch/bin/nightwatch --config /home/ec2-user/environment/test/node_modules/@vue/cli-plugin-e2e-nightwatch/nightwatch.config.js --env chrome

/home/ec2-user/environment/test/node_modules/@vue/cli-plugin-e2e-nightwatch/nightwatch.config.jsを見てみると、環境変数からヘッドレスChromeを使うかどうか判断しているようです。

const startHeadless = process.env.VUE_NIGHTWATCH_HEADLESS === '1'if (startHeadless) {
  chromeArgs.push('headless')
  geckoArgs.push('--headless')
}

この環境変数を設定してみます。

$ export VUE_NIGHTWATCH_HEADLESS=1
$ yarn test:e2e

f:id:chief-shuffle:20200115202759j:plain

やっと実行できました!
一発目からこれでは今年のブログライフの先が思いやられますが、これを~/.bashrcに設定しておけば安心です。

ちなみにテストソースは以下のような感じです。
3秒以内にbodyが現れ、メッセージが一致していることを確認しています。

module.exports = {
  'default e2e tests': browser => {
    browser
      .init()
      .waitForElementVisible('#app')
      .assert.elementPresent('div#life')
      .waitForElementVisible('body', 3000)
      .assert.elementPresent('div#life')
      .assert.containsText(
        'div#life ul',
        'あなたはアジアの小国の王様に生まれ変わりました。'
      )
      .end()
  },
}

もっと画面にボタンなどが増えてくれば活躍してくれそうです。
では、本年もご愛顧を賜りますようお願い申し上げます。

そうだ、教祖になろう。出エジプト記 第4章7節 クライアントサイドのユニットテストをカバレッジ100%にする

寝乱れて 隠れ宿


f:id:chief-shuffle:20191231102652j:plain

現在、年末年始休みで東北に来ております。
近年は暖冬で雪が少ないものの、川とランデブーする単線の列車は旅情がありますね。

九十九折り 浄蓮の滝


第4章6節 クライアントサイドを自動テストするでJestを導入し、ユニットテストを実施しました。
カバレッジ100%になってなかったので、続きをやっていきます。
別に100%にしないといけないわけじゃないのですが、最初なんで今後のサンプルということで。

まずは、src/store/index.jsをテストするtests/unit/store/index.spec.jsです。

import store from '@/store'
import * as types from '@/store/mutation-types'
const state = store.state

describe('store/index.js', () => {
  beforeEach(() => {
    jest.spyOn(console, 'log')
    jest.spyOn(console, 'error')
  })

  afterEach(() => {
    console.log.mockRestore()
    console.error.mockRestore()
  })

  it('正:初期状態', () => {
    expect(state.list).toEqual([])
  })

  it('正:list格納', () => {
    let list = [
      'あなたはアジアの小国の王子に生まれ変わりました。',
      'アジアの小国の王子は今から980年前に生まれました。',
      '民に敬われた王の嫡男として生き',
      '50歳で死にました。',
    ]

    store.commit(types.STORE_LIST, {list: list})
    expect(state.list).toEqual(list)
  })
})

STORE_LISTをコミットして与えたリストと同じであることをアサーションしています。
console.log()console.error()spyOnして想定外の出力がないことを確認します。

続いて、src/main.jsをテストするtests/unit/main.spec.jsです。

import axios from 'axios'

jest.mock('axios')

describe('main.js', () => {
  beforeEach(() => {
    jest.spyOn(console, 'log')
    jest.spyOn(console, 'error')
    axios.get.mockResolvedValue({})
  })

  afterEach(() => {
    console.log.mockRestore()
    console.error.mockRestore()
  })

  it('正:初期状態', () => {
    /* eslint-disable no-unused-vars */
    var main = require('@/main')
    expect(console.log).not.toHaveBeenCalled()
    expect(console.error).toHaveBeenCalledTimes(1)
  })
})

同じようにconsole.log()spyOn
Ajax処理が走らないようにaxios.getモックします。

ま、今のプログラムはLife.vue以外ほとんど何もしていないのでこんなもんです。

テストを実行します。
めでたくカバレッジ100%になりました。
あーすっきり。

f:id:chief-shuffle:20191231103759j:plain

あなたと越えたい 天城越え


もうすぐ年越しですね。
みなさまの令和二年が素晴らしい年になりますように。

そうだ、教祖になろう。出エジプト記 第4章6節 クライアントサイドを自動テストする

Deine Zauber binden wieder,(汝が魔力は再び結び合わせる)


第3章8節 サーバサイドを自動テストするではPythonで書いたLambda処理のユニットテストを作りました。

今回は第4章5節 Veu.jsでクライアントサイドを実装するで実装したVue.jsのコードを自動テストしていきたいと思います。

Vue.jsのユニットテストはJestかMocha+Chaiって感じらしいのですが、よりお手軽なJestを使います。

qiita.com

vue createがJestをインストールしてくれてtests/unit/sample.spec.jsというテストモジュールができています。
テスト対象のHelloWorld.vueはすでに消してしまいましたが、試しにテストを起動してみます。

$ yarn test:unit

f:id:chief-shuffle:20191228171718j:plain

はい、「んなもんねえよ!」と怒られました。

これを利用してテストモジュールを作ってみます。
名前をtests/unit/Life.spec.jsに変更。
とりあえずで書いたのがこちら。

import axios from 'axios'
import Vue from 'vue'
import Life from '@/components/Life.vue'
import {shallowMount} from '@vue/test-utils'

jest.mock('axios')

describe('Life.vue', () => {

  beforeEach(() => {
    let response = [
      'あなたはアジアの小国の王様に生まれ変わりました。',
      'アジアの小国の王様は今から1000年前に生まれました。',
      '安定した治世で民に敬われながら生き',
      '40歳で死にました。',
    ]
    axios.get.mockResolvedValue(response)
  })

  it('正:疎通', () => {
    let wrapper = shallowMount(Life, {})
    expect(wrapper.find('div#life').exists()).toBe(true)
    expect(wrapper.find('div#life ul').exists()).toBe(false)
  })
})

axiosをJestのモックオブジェクトに差し替えてaxios.getで任意の値を受信するようにしてます。
it()がテストメソッドですね。
shallowMountでダミーマウントしたら、生成されたHTMLにタグが存在するかexport().to~()アサーションしてます。
beforeEach()は各テストメソッド実行前に呼ばれるやつです。

これを実行すると、

f:id:chief-shuffle:20191228172415j:plain

成功で通りました。
ちなみに、存在しないHTMLタグをアサーションすると、

f:id:chief-shuffle:20191228172747j:plain

「そんなのないよー。ぷんぷん。」と優しく怒ってくれます。

実行時になにやら警告が出ています。

(node:5064) UnhandledPromiseRejectionWarning: TypeError: Cannot read property 'commit' of undefined
(node:5064) UnhandledPromiseRejectionWarning: Unhandled promise rejection. This error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch(). (rejection id: 1)
(node:5064) [DEP0018] DeprecationWarning: Unhandled promise rejections are deprecated. In the future, promise rejections that are not handled will terminate the Node.js process with a non-zero exit code.
 PASS  tests/unit/Life.spec.js

Veux系の警告のよう。
そういえば、まだデータストアをモック化してませんでした。

import axios from 'axios'
import Vuex from 'vuex'
import Life from '@/components/Life.vue'
import {shallowMount, createLocalVue} from '@vue/test-utils'

const localVue = createLocalVue()
localVue.use(Vuex)
jest.mock('axios')

describe('Life.vue', () => {
  let store
  let storeList

  beforeEach(() => {
    let response = [
      'あなたはアジアの小国の王様に生まれ変わりました。',
      'アジアの小国の王様は今から1000年前に生まれました。',
      '安定した治世で民に敬われながら生き',
      '40歳で死にました。',
    ]
    axios.get.mockResolvedValue(response)

    storeList = jest.fn()
    store = new Vuex.Store({
      state: {
        list: [],
      },
      mutations: {
        STORE_LIST: storeList 
      },
    })
  })

  it('正:疎通', () => {
    const wrapper = shallowMount(Life, {
      store,
      localVue
    })
    expect(storeList).not.toHaveBeenCalled()
    expect(wrapper.find('div#life').exists()).toBe(true)
    expect(wrapper.find('div#life ul').exists()).toBe(false)
  })
})

データストアを丸々モック化しました。

f:id:chief-shuffle:20191228174609j:plain

警告が消えました。

Was die Mode streng geteilt;(時流が強く切り離したものを)


ところで、現在のアサーションは画面表示直後を前提にしています。
mounted()で呼ばれるaxiosAjax処理を実行する前ですね。
<ul>タグもなければMutationも呼ばれてません。

    expect(storeList).toHaveBeenCalledTimes(0)
    expect(wrapper.find('div#life ul').exists()).toBe(false)

Ajax処理が実行されたあとの表示もアサーションしましょう。

Vueの$nextTick()を使います。
HTMLのDOMが変化するまでまってくれます。

import axios from 'axios'
import Vuex from 'vuex'
import Life from '@/components/Life.vue'
import {shallowMount, createLocalVue} from '@vue/test-utils'

const localVue = createLocalVue()
localVue.use(Vuex)
jest.mock('axios')

describe('Life.vue', () => {
  let store
  let storeList
  let wrapper
  let list

  beforeEach(() => {
    list = [
      'あなたはアジアの小国の王子に生まれ変わりました。',
      'アジアの小国の王子は今から980年前に生まれました。',
      '民に敬われた王の嫡男として生き',
      '50歳で死にました。',
    ]
    axios.get.mockResolvedValue({data: list})

    storeList = jest.fn((state, payload) => {
      state.list = payload.list
    })
    store = new Vuex.Store({
      state: {
        list: [],
      },
      mutations: {
        STORE_LIST: storeList,
      },
    })
  })

  it('正:mounted', done => {
    wrapper = shallowMount(Life, {
      store,
      localVue,
    })
    expect(storeList).not.toHaveBeenCalled()
    expect(wrapper.find('div#life').exists()).toBe(true)
    expect(wrapper.find('div#life ul').exists()).toBe(false)

    wrapper.vm.$nextTick(() => {
      expect(storeList).toHaveBeenCalledTimes(1)
      expect(wrapper.find('div#life ul').exists()).toBe(true)
      for (let i = 0; i < list.length; i++) {
        expect(
          wrapper
            .findAll('div#life ul li')
            .at(i)
            .text()
        ).toEqual(list[i])
      }
      done()
    })
  })

mounted()がデータストアのlistにAjax通信の結果を格納したのちは<ul>タグが現れます。
<ul>配下の<li>のテキストを1行ずつアサーションしています。

f:id:chief-shuffle:20191230232847j:plain

Alle Menschen werden Brüder,(すべての人々は兄弟となる)


トランジションアサーションしたいのですが一度にやるとテストケースが長すぎるので、afterEnter()afterLeave()を別々にテストします。
2つテストケースを足しました。

  it('正:afterEnter', done => {
    let mounted = jest.spyOn(Life, 'mounted').mockImplementation(() => {})
    wrapper = shallowMount(Life, {
      data() {
        return {
          show: true,
        }
      },
      store,
      localVue,
    })

    wrapper.vm.afterEnter()
    setTimeout(() => {
      expect(storeList).not.toHaveBeenCalled()
      expect(wrapper.find('div#life ul').exists()).toBe(false)
      done()
    }, 3000)
  })

  it('正:afterLeave', done => {
    let mounted = jest.spyOn(Life, 'mounted').mockImplementation(() => {})
    wrapper = shallowMount(Life, {
      store,
      localVue,
    })

    wrapper.vm.afterLeave()
    setTimeout(() => {
      expect(storeList).toHaveBeenCalledTimes(1)
      expect(wrapper.find('div#life ul').exists()).toBe(true)
      for (let i = 0; i < list.length; i++) {
        expect(
          wrapper
            .findAll('div#life ul li')
            .at(i)
            .text()
        ).toEqual(list[i])
      }
      done()
    }, 2000)
  })

いずれもjest.spyOnmounted()をモックで抑止しています。
正:afterEnterではshowtrueから始めてafterEnter()を呼んだあと、一定時間待ってから<ul>タグがないことアサーションしています。
正:afterLeaveではshowfalseから始めてafterEnter()を呼んだあと、一定時間まってから<ul>タグがあるをアサーションしています。
実行すると、カバレッジ100%になりました。

f:id:chief-shuffle:20191230233300j:plain

ちなみにさっきからカバレッジを取得できてるのは--coverageオプションをpackage.jsonに入れたからです。

…
  "scripts": {
    …
    "test:unit": "vue-cli-service test:unit --coverage",
    …
  },
…

よくみると、実行結果の1か所が赤くなっています。

PASS tests/unit/Life.spec.js (5.866s)

「いいけど、時間かかりすぎ-」ということです。
5秒以上だと怒られるようです。
実際にsetTimeoutの遅延時間分だけ待っているので当然です。
ので、テスト用にsetTimeoutの遅延時間をいじれるようにします。

テスト対象のsrc/components/Life.vueで、遅延時間をdataに入れます。

<template>
  <div id="life">
    <transition name="fade" @after-enter="afterEnter" @after-leave="afterLeave">
      <ul v-if="show">
        <li v-for="msg in list" :key="msg">
          {{ msg }}
        </li>
      </ul>
    </transition>
  </div>
</template>

<script>
import axios from 'axios'
import {mapState} from 'vuex'
import * as types from '@/store/mutation-types'

const AFTER_ENTER_DELAY = 3000
const AFTER_LEAVE_DELAY = 2000

export default {
  name: 'Life',
  data() {
    return {
      show: false,
      delayAfterEnter: AFTER_ENTER_DELAY,
      delayAfterLeave: AFTER_LEAVE_DELAY,
    }
  },
  computed: {
    ...mapState(['list']),
  },
  mounted() {
    this.rebirth()
  },
  methods: {
    rebirth() {
      axios.get('/api/rebirth').then(response => {
        this.$store.commit(types.STORE_LIST, {
          list: response.data,
        })
        this.show = true
      })
    },
    afterEnter() {
      var _this = this
      setTimeout(() => (_this.show = false), this.delayAfterEnter)
    },
    afterLeave() {
      var _this = this
      setTimeout(() => _this.rebirth(), this.delayAfterLeave)
    },
  },
}
</script>

<style lang="stylus">
#life
  padding 10%

li
  list-style-type none

.fade-enter-active, .fade-leave-active
  transition all .8s ease

.fade-enter, .fade-leave-to
  opacity 0
</style>

ほんで、テストモジュールでdataの値を差し替えつつ、自分の遅延時間も減らします。

  it('正:afterEnter', done => {
    const AFTER_ENTER_DELAY = 30
    let mounted = jest.spyOn(Life, 'mounted').mockImplementation(() => {})
    wrapper = shallowMount(Life, {
      data() {
        return {
          show: true,
          delayAfterEnter: AFTER_ENTER_DELAY,
        }
      },
      store,
      localVue,
    })

    wrapper.vm.afterEnter()
    setTimeout(() => {
      expect(storeList).not.toHaveBeenCalled()
      expect(wrapper.find('div#life ul').exists()).toBe(false)
      done()
    }, AFTER_ENTER_DELAY)
  })

  it('正:afterLeave', done => {
    const AFTER_LEAVE_DELAY = 20
    let mounted = jest.spyOn(Life, 'mounted').mockImplementation(() => {})
    wrapper = shallowMount(Life, {
      data() {
        return {
          delayAfterLeave: AFTER_LEAVE_DELAY,
        }
      },
      store,
      localVue,
    })

    wrapper.vm.afterLeave()
    setTimeout(() => {
      expect(storeList).toHaveBeenCalledTimes(1)
      expect(wrapper.find('div#life ul').exists()).toBe(true)
      for (let i = 0; i < list.length; i++) {
        expect(
          wrapper
            .findAll('div#life ul li')
            .at(i)
            .text()
        ).toEqual(list[i])
      }
      done()
    }, AFTER_LEAVE_DELAY)
  })

0.1秒程度に縮まりました。

f:id:chief-shuffle:20191230234728j:plain

Wo dein sanfter Flügel weilt.(汝の柔らかな翼が留まる所で)


そういえば、カバレッジがコンソールにしか出ていませんでした。
HTMLにも出力してみます。
package.jsonに以下を追加します。

  "jest": {
    …
    "collectCoverage": true,
    "collectCoverageFrom": ["src/**/*.{js,vue}"],
    "coverageReporters": ["text", "html"],
    "coverageDirectory": "<rootDir>/tests/unit/coverage"
  }

実行すると、対象ソース全体に対してのカバレッジが表示されます。
tests/unit/coverage/index.htmlをプレビューすると、フォルダ/ファイルごとのカバレッジ詳細を確認できます。

f:id:chief-shuffle:20191230235213j:plain

いやー、やっぱりユニットテストは苦行ですね。
jest.spyOn()の利便性を思い出すまで3日掛かりました。
今年中にカバレッジを100%にしたいんだけど、あと1日でできるんだろうか。
無理だった場合は次回が来年になります。
みなさま良いお年を。

そうだ、教祖になろう。出エジプト記 第4章5節 Veu.jsでクライアントサイドを実装する

Freude, schöner Götterfunken,(歓喜よ、美しき光よ)


仕事納めも済ませ、すっかり年末感が強くなりました。

www.youtube.com

第4章4節 ESLint設定を整えるでリント環境を整えたので、今回は歓喜のうちにクライアントサイドをコーディングしていきます。

軽くコンポーネントを整理しました。
AboutとFaqは後々やるとして、メインコンテンツを実装していきます。

f:id:chief-shuffle:20191227063057p:plain

ソースツリーはこちら。
デフォルトのソースはさっぱり消しました。
今年の汚れは今年のうちに。

$ tree src
src
├── App.vue
├── components
│   └── Life.vue
├── main.js
├── router
│   └── index.js
├── store
│   └── index.js
└── views
    └── Rebirth.vue
$ tree public
public
├── api
│   └── rebirth
├── favicon.ico
└── index.html

Tochter aus Elysium,(楽園エリジウムの娘よ)


まず、サーバサイドがないのでダミーのレスポンスを作ります。
public/api/rebirthです。

[
  "あなたはアジアの小国の王様に生まれ変わりました。",
  "アジアの小国の王様は今から1000年前に生まれました。",
  "安定した治世で民に敬われながら生き",
  "40歳で死にました。"
]

これを読んで表示するsrc/components/Life.vueです。
複数行をv-forで繰り返し表示しています。

<template>
  <div id="life">
    <ul>
      <li v-for="msg in list" :key="msg">
        {{ msg }}
      </li>
    </ul>
  </div>
</template>

<script>
import axios from 'axios'

export default {
  name: 'Life',
  data() {
    return {
      list: [],
    }
  },
  mounted() {
    axios.get('/api/rebirth').then(response => (this.list = response.data))
  },
}
</script>

<style lang="stylus">
#life
  padding 10%

li
  list-style-type none
</style>

その外側のビューsrc/views/Rebirth.vueです。

<template>
  <div class="rebirth">
    <Life />
  </div>
</template>

<script>
import Life from '@/components/Life.vue'

export default {
  name: 'Rebirth',
  components: {
    Life,
  },
}
</script>

src/router/index.jsはさっぱり他のリンクを削除。

import Vue from 'vue'
import VueRouter from 'vue-router'

Vue.use(VueRouter)

const routes = [
  {
    path: '/',
    name: 'rebirth',
    component: () => import('@/views/Rebirth.vue'),
  },
]

const router = new VueRouter({
  mode: 'history',
  base: process.env.BASE_URL,
  routes,
})

export default router

src/App.vueで統合。

<template>
  <div id="app">
    <router-view />
  </div>
</template>

<style lang="stylus">
#app
  font-family 'Avenir', Helvetica, Arial, sans-serif
  -webkit-font-smoothing antialiased
  -moz-osx-font-smoothing grayscale
  text-align center
  color #2c3e50
  margin-top 60px
</style>

他は変えていません。
プレビューしてみます。

f:id:chief-shuffle:20191227065305j:plain

表示されました。

Wir betreten feuertrunken,(我らは炎に酔って踏み入る)


これでもいいのですが、データが一つのコンポーネントに閉じられているので、他コンポーネントで使うときに不便です。
クライアントサイドのデータストアを一元管理して各コンポーネントで利用できるようにしたいと思います。
Vuexで実現します。

f:id:chief-shuffle:20191227065343p:plain

Vuexでは最低限、StateとMutationを定義します。
Stateはデータです。
コンポーネントdataと同じと思っていいでしょう。
動的に<template>配下に反映されます。
Mutationはデータの変更処理です。
変更処理をVuexモジュール内に閉じ込めてカプセル化します。

まず、Mutationの種別を定義します。

新規にsrc/store/mutation-types.jsを作成します。

export const STORE_LIST = 'STORE_LIST'

次にsrc/store/index.jsで実際のStateとMutationを定義します。
stateは単純なリストであるlistのみ。
mutationは変数を受けてstate.listに代入する関数がひとつ。

import Vue from 'vue'
import Vuex from 'vuex'
import * as types from '@/store/mutation-types'

Vue.use(Vuex)

export default new Vuex.Store({
  state: {
    list: [],
  },
  mutations: {
    [types.STORE_LIST](state, payload) {
      state.list = payload.list
    },
  },
  actions: {},
  modules: {},
})

最後にデータストアを参照するsrc/components/Life.vueです。 直にdataで定義していたlistを、データストアのlistからmapStateで取得するよう変更しています。
また、レスポンス受信時の処理はthis.$store.commitでMutationを起動しています。

<template>
  <div id="life">
    <ul>
      <li v-for="msg in list" :key="msg">
        {{ msg }}
      </li>
    </ul>
  </div>
</template>

<script>
import axios from 'axios'
import {mapState} from 'vuex'
import * as types from '@/store/mutation-types'

export default {
  name: 'Life',
  computed: {
    ...mapState(['list']),
  },
  mounted() {
    axios.get('/api/rebirth').then(response => {
      this.$store.commit(types.STORE_LIST, {
        list: response.data,
      })
    })
  },
}
</script>

<style lang="stylus">
#life
  padding 10%

li
  list-style-type none
</style>

これでさきほどと同じ結果が得られるのですが、試しに他のコンポーネントでもデータストアを参照してみます。
src/views/Rebirth.vuelistの件数を表示してみましょう。

<template>
  <div class="rebirth">
    <Life />
    line: {{ list.length }}
  </div>
</template>

<script>
import {mapState} from 'vuex'
import Life from '@/components/Life.vue'

export default {
  name: 'Rebirth',
  components: {
    Life,
  },
  computed: {
    ...mapState(['list']),
  },
}
</script>

Vuexをインポートして、mapStatelistを取得し、list.lengthで件数を表示しています。
プレビューすると複数コンポーネントlistを参照できていることを確認できます。

f:id:chief-shuffle:20191227193644j:plain

Himmlische, dein Heiligtum!(天なるもの、汝の聖所へ)


これでは一度読み込んで終わりなので、メッセージがゆっくり現れて消えるようアニメーションをつけたいと思います。
Vue.jsのトランジションを使います。
トランジションでは現れるアニメーションをEnterで、消えるアニメーションをLeaveで定義します。
アニメーション開始時点のEnter/Leaveから、終了時点のtoに至る過程がアニメーションを行う期間activeです。

https://jp.vuejs.org/images/transition.png

試しにスライドで現れるEnterだけ定義してみます。
<ul>全体の表示を制御するshowをMutationコミット後にtrueにします。 アニメーション開始時点は透明度100%で右に10pxずれた状態、0.8秒で正規の透明度と位置に遷移します。

<template>
  <div id="life">
    <transition name="slide-fade">
      <ul v-if="show">
        <li v-for="msg in list" :key="msg">
          {{ msg }}
        </li>
      </ul>
    </transition>
  </div>
</template>

<script>
import axios from 'axios'
import {mapState} from 'vuex'
import * as types from '@/store/mutation-types'

export default {
  name: 'Life',
  data() {
    return {
      show: false,
    }
  },
  computed: {
    ...mapState(['list']),
  },
  mounted() {
    axios.get('/api/rebirth').then(response => {
      this.$store.commit(types.STORE_LIST, {
        list: response.data,
      })
      this.show = true
    })
  },
}
</script>

<style lang="stylus">
#life
  padding 10%

li
  list-style-type none

.slide-fade-enter-active
  transition all .8s ease

.slide-fade-enter
  transform translateX(10px)
  opacity 0
</style>

次にEnterのアニメーションが終わってから一定時間経過後にLeaveのアニメーションを開始したいと思います。
<transition>タグに@after-enter属性をつけました。
Enterのアニメーションが終わったら、methodsに追加したafterEnterを呼び出されてshowfalseにするとLeaveアニメーションが始まります。
同じくLeaveのアニメーションが終わったら、methodsrebirthに移動したAjax処理を呼び出します。
これで延々と表示・非表示を繰り返すようになりました。
transform translateX(10px)は消しました。

<template>
  <div id="life">
    <transition
      name="fade"
      @after-enter="afterEnter"
      @after-leave="afterLeave"
    >
      <ul v-if="show">
        <li v-for="msg in list" :key="msg">
          {{ msg }}
        </li>
      </ul>
    </transition>
  </div>
</template>

<script>
import axios from 'axios'
import {mapState} from 'vuex'
import * as types from '@/store/mutation-types'

export default {
  name: 'Life',
  data() {
    return {
      show: false,
    }
  },
  computed: {
    ...mapState(['list']),
  },
  mounted() {
    this.rebirth()
  },
  methods: {
    rebirth() {
      axios.get('/api/rebirth').then(response => {
        this.$store.commit(types.STORE_LIST, {
          list: response.data,
        })
        this.show = true
      })
    },
    afterEnter() {
      var _this = this
      setTimeout(() => (_this.show = false), 3000)
    },
    afterLeave() {
      var _this = this
      setTimeout(() => _this.rebirth(), 2000)
    },
  },
}
</script>

<style lang="stylus">
#life
  padding 10%

li
  list-style-type none

.fade-enter-active, .fade-leave-active
  transition all .8s ease

.fade-enter, .fade-leave-to
  opacity 0
</style>

基本の動きはできました。
まだ画像や凝ったアニメーションはありませんが、やり出すと時間ばかりかかるので、一旦この辺で。

次回はクライアントサイドの自動テストを考えたいと思います。

そうだ、教祖になろう。出エジプト記 第4章4節 ESLint設定を整える

我々の街と塔を作ろう。塔の先が天に届くほどの。


これまで第4章1節 Cloud9にVue.js開発環境を導入するなどでトランスパイラに触れました。
Vue.jsのトランスパイラは「Babel」です。
BabelはJavaScriptトランスパイラで、本家サイトでは「次世代JavaScriptコンパイラ」と説明されています。

github.com

"babble"と発音するらしいです。

見よ、その塔はシナルの地、アッシリアとバビロンの中間にあった。


第4章3節 Vue.jsでAjax通信するで出たWarningを解消するのに、このBabelが関係してきます。

Vue.jsプロジェクトをトランスパイルする前にESLintというリンタを使います。
リンタとは、プログラムソースがコーディング規約に則しているかチェックするツールです。
vue create プロジェクト名でVue CLIを導入したときにESLint + Prettierを導入しました。
Prettierはコードフォーマッターです。
登場人物が多いですね。

Prettierはコーディング規約に沿うようにコードを整形してくれます。
ESLintもオプションでコードを整形できますが、Prettierの方が簡単により見やすくしてくれるといったところです。

とりあえずPrettierのことはおいといて、ESLintを使ってWarningが出ないようにコードを修正していきます。
参考にさせていただいたサイトです。

qiita.com

.vueファイルでESLintが利くようにeslint-config-vueパッケージを導入します。
参考サイトにある他のパッケージはvue createで入っているようです。

$ cd clientside/
$ yarn add -D eslint-config-vue
$ ./node_modules/.bin/eslint src/**/*.js src/**/*.vue 

デフォルト設定だとこんな感じの結果になります。

f:id:chief-shuffle:20191223191031j:plain

設定をカスタマイズするため、プロジェクト直下に'.eslintrc.js`というファイルを作ります。
Vueの推奨設定を読み込みます。

module.exports = {
  "extends": ["vue", "plugin:vue/recommended"],
  "env": {
    "browser": true
  },
  "rules": {}
}

これで走らせると80個ほどエラーと警告が出ます。

f:id:chief-shuffle:20191223191421j:plain

f:id:chief-shuffle:20191223191410j:plain

1つずつエラーを確認しながら修正したいので、今出たエラーと警告の右側に表示されているルール名を全部”rules”"off"で追加します。

module.exports = {
  "extends": ["vue", "plugin:vue/recommended"],
  "env": {
    "browser": true
  },
  "rules": {
    "indent": "off",
    "semi": "off",
    "space-in-parens": "off",
    "quotes": "off",
    "vue/html-closing-bracket-newline": "off",
    "vue/html-indent": "off",
    "vue/html-self-closing": "off",
    "vue/max-attributes-per-line": "off",
    "vue/name-property-casing": "off",
    "vue/require-default-prop": "off",
  }
}

一回走らせます。

f:id:chief-shuffle:20191223191722j:plain

まだ1つ出てます。
これは”rules”では消せないのでbabel-eslintで抑止します。
babel-eslintはESLintのためのBabel Parserのラッパーです。
Babel ParserはBabelの中にあるJavaScriptパーサーで、昔はBabylonと呼ばれていたそうです。
いよいよ混乱してきましたね。

"parserOptions"を追加してもう一回走らせます。

module.exports = {"parserOptions": {
    "parser": "babel-eslint",
  },
  …
}

babel-eslintはデフォルトで"allowImportExportEverywhere": falseになっており、Importに対するエラーが消えます。
これで一旦エラーがない状態になりました。

その為に、この街はバベルと名付けられた。


では、ルールを1つずつ修正していきたいと思います。
デフォルト以外にしたい設定は"off"の部分を修正したい設定値に変えていきます。

インデントは2にしてみます。

    "indent": "indent": ["error", 2],

リントします。

$ ./node_modules/.bin/eslint src/**/*.js src/**/*.vue

f:id:chief-shuffle:20191223192731j:plain

17か所エラーになってますね。
--fixオプションをつけて修正します。

$ ./node_modules/.bin/eslint --fix src/**/*.js src/**/*.vue

これが、

...
const routes = [{
    path: "/",
    name: "home",
    component: Home
  },
...

こうなりました。

...
const routes = [{
  path: "/",
  name: "home",
  component: Home
},
...

続いて、セミコロンをなしにします。

    "semi": ["error", "never"],
import Vue from "vue";
import VueRouter from "vue-router";
import Home from "../views/Home.vue";

import Vue from "vue"
import VueRouter from "vue-router"
import Home from "../views/Home.vue"

どんどんいきます。
好き嫌いが分かれるでしょうが、複数行の場合の末尾のカンマを必須にします。

    "comma-dangle": ["error", "always-multiline"],
export default new Vuex.Store({
  state: {},
  mutations: {},
  actions: {},
  modules: {}
})

export default new Vuex.Store({
  state: {},
  mutations: {},
  actions: {},
  modules: {},
})

()の内側のスペースなしはデフォルトなのでこの行は消します。

    "space-in-parens": "off",
  component: () =>
    import ( /* webpackChunkName: "about" */ "../views/About.vue"),

  component: () =>
      import (/* webpackChunkName: "about" */ "../views/About.vue"),

クォーテーションはダブルクォーテーション。

    "quotes": ["error", "double"],

これはエラーなし。シングルクォーテーションはなかったようです。

ここから.vueファイルのHTML部分の規則です。
HTMLの>位置はデフォルトなので削除。

    "vue/html-closing-bracket-newline": "off",
      <a href="https://cli.vuejs.org" target="_blank" rel="noopener"
        >vue-cli documentation</a
      >.

      <a href="https://cli.vuejs.org" target="_blank" rel="noopener">vue-cli documentation</a>.

HTML属性位置はデフォルトなので削除。

    "vue/max-attributes-per-line": "off",
      <a href="https://cli.vuejs.org" target="_blank" rel="noopener">vue-cli documentation</a>.

      <a
        href="https://cli.vuejs.org"
        target="_blank"
        rel="noopener"
        >vue-cli documentation</a>.

HTMLのインデントもデフォルトで削除。

    "vue/html-indent": "off",
      <li>
        <a
          href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-babel"
          target="_blank"
          rel="noopener"
          >babel</a>
      </li>

↓ 微妙!(>が2つ上がってる)

      <li>
        <a
          href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-babel"
          target="_blank"
          rel="noopener"
        >babel</a>
      </li>

HTMLタグの閉じタグ許可。

    "vue/html-self-closing": ["error", {
      "html": { "normal": "always", "void": "always", "component": "always" },
    }],

これもエラーなし。

プロパティ名はデフォルト(キャメルケース)なので削除。

    "vue/name-property-casing": "off",
  name: "ajax",

  name: "Ajax",

プロパティのrequired属性必須はデフォルトなので削除。

    "vue/require-default-prop": "off",
  props: {
    msg: String
  }

あれ、これは--fixで修正されないようです。
手で修正。

    msg: {
      type: String,
      required: true
    }

最終的に.eslintrc.jsはこうです。

module.exports = {
  "extends": ["vue", "plugin:vue/recommended"],
  "env": {
    "browser": true
  },
  "parserOptions": {
    "parser": "babel-eslint",
  },
  "rules": {
    "comma-dangle": ["error", "always"],
    "indent": ["error", 2],
    "semi": ["error", "never"],
    "quotes": ["error", "double"],
    "vue/html-self-closing": ["error", {
      "html": { "normal": "always", "void": "always", "component": "always" },
    }],
  }
}

主がそこで、全地の言葉を乱し、そこから人を全地に散らされたからである。


手間はかかりましたが一応設定が整いましたね。
では改めてsrc/router/index.jsをいじって保存すると、

f:id:chief-shuffle:20191223203729j:plain

おっと、設定と異なるインデントで自動フォーマットされてエラーが大量発生しますね。
あれ、これ涙?

Cloud9のJSBeutify設定とESLintの設定が合っていないからっぽいです。
ので、保存時にESLintを使ってフォーマットしてくれるように変更します。

PreferenceのProject Setting > JavaScript Support > Custom Code Formatterの欄に以下のコマンドを設定します。
保存したファイルのディレクトリに下りてって、eslint --fixを実行するコマンドです。
npmのパッケージ実行コマンドであるnpxで起動しています。
うまく動いてないときのトレースのためにホームディレクトリにログファイルを吐いてます。

cd `dirname "$file"`;npx eslint --fix `basename "$file"` &> ~/eslint.log

これでsrc/router/index.jsを保存しなおすと、エラーがなくなりました。
npxを起動するのが遅いのか、若干タイムラグがあるのが気になります。

Cloud9にJavaScriptと認識されてるファイルでしかフォーマッタのトリガがかからないので、.vueファイルのエディタの右下にあるファイル種別を「JavaScript」に変更します。

これで保存時に.vueファイルもフォーマットされますが、JavaScriptファイルには存在しない<script>のあたりでParsing errorが発生します。
ので、Hints&Warning > Ignore Messages Matchingの欄にParsing errorと入力して無視します。
これやっちゃうと例えば<script>が抜けたりしてもコーディング時点では分からないのですが、ビルド時点で気づけるのでまあよしとしましょう。

ついでに、Mark missing semicolonsを外してエディタの横に出ている「i」マークが表示されないようにします。

見よ、人の子らはシナルの地に自分たちの都市と塔を建てようというそのふとどきなはかりごとのゆえに邪悪になった。


なんとかESLintの設定を整えてきたのですが、はっきり言って結構手間です。
Prettierだともちょっと楽に設定できるようで、今後のためにPrettier設定をESLintに読ませる形に全面見直したいと思います。
つまり、今の設定はほぼ全部壊します。

大変参考になりました。

qiita.com

修正した.eslintrc.jsがこれ。
ESLint、Prettier、Vueの推奨を利用。

module.exports = {
  parser: 'vue-eslint-parser',
  parserOptions: {
    parser: 'babel-eslint',
    sourceType: 'module',
  },
  env: {
    browser: true,
    node: true,
  },
  extends: [
    'eslint:recommended',
    'plugin:prettier/recommended',
    'plugin:vue/recommended',
    'prettier/vue',
  ],
  rules: {},
}

新規作成したPrettierの設定である.prettierrc.jsがこれ。
シングルクォーテーションにしました。

module.exports = {
  printWidth: 80,
  tabWidth: 2,
  singleQuote: true,
  semi: false,
  trailingComma: 'es5',
  bracketSpacing: false,
}

上記の2ファイルも整形できるよう、PreferenceのCustom Code Formatterに--ignore-patternオプションを追加しました。
デフォルトだと対象外のファイルもリントしてくれます。

cd `dirname "$file"`;npx eslint --fix `basename "$file"` --ignore-pattern '!.*.js' &> ~/eslint.log

苦労しましたが、これでやっとESLint設定が整いました。

次回はHTMLをスッキリ書けるPugを入れたかったので、またフォーマッタ設定を変えないといけないんかいとビクビクでしたが、色々サポートが利かなくなるし、特段コーディング量も減らないし、あまりメリットがなさそうなのでやめておこうと思います。

qiita.com

2019/12/27追記

Pugをちょっと入れてみましたが、eslint --fixが利かないようで断念しました。残念。

yarn add -D pug pug-plain-loader eslint-plugin-pug
  plugins: ['pug'],
<template>
  <div class="home">
    <img alt="Vue logo" src="../assets/logo.png" />
    <HelloWorld msg="Welcome to Your Vue.js App" />
  </div>
</template>

<template lang="pug">
  div.home
    img(alt="Vue logo" src="../assets/logo.png"
    HelloWorld(msg="Welcome to Your Vue.js App")
</template>

そうだ、教祖になろう。出エジプト記 第4章3節 Vue.jsでAjax通信する

Аксиос!(適任!)


第2章2節 CloudFrontでAPI Gatewayを同一ドメイン化するjQueryを利用してサーバとAjax通信しました。
Vue.jsではaxiosというライブラリを使います。
Wikipediaによると

アクシオスは元々はギリシャ語で「価値が有る」「ふさわしい」「値する」の意。新約聖書にも複数個所に使われている。正教会では新たに聖職者が叙聖される時に使われる事から「適任」という訳が当てられることもある。

ということです。
このaxiosがそっから来てるのかはわかりませんが。

www.youtube.com

動きを確認してみましょう。
axiosをインストールします。

$ yarn install -D axios

サーバサイド処理の代わりに静的ファイルを置いてみます。
publicの下にtest.jsonを作ります。

{
    "key1": "value1",
    "key2": "value2"
}

src/views/Home.vueをコピーしてsrc/views/Ajax.vueを作ります。
データのinfo<div>タグにバインドしています。
画面が読み込まれたときにtest.jsonを取得するようにしてみます。

<template>
  <div>
    {{ info }}
  </div>
</template>

<script>
import axios from "axios"

export default {
  name: "ajax",
  data () {
    return {
      info: null
    }
  },
  mounted () {
    axios
      .get("/test.json")
      .then(response => (this.info = response))
  }
}
</script>

src/router/index.jsAjax.vueの設定を追加します。

  {
    path: "/ajax",
    name: "ajax",
    component: () =>
      import ("../views/Ajax.vue")
  }

最後に src/App.vueにリンクを追加します。

      <router-link to="/ajax">Ajax</router-link>

画面を確認してみます。
Ajax」リンクをクリックすると、

f:id:chief-shuffle:20191222152618j:plain

レスポンスのステータス200のヘッダとボディが<div>タグの中に表示されました。

f:id:chief-shuffle:20191222153505j:plain

動いたには動いたんですが、ビルドの際にWarningが出てしまいます。
次回はこれを何とかしたいと思います。

そうだ、教祖になろう。出エジプト記 第4章2節 Vue.jsを思い出す

いつのことだか 思い出してごらん


第4章1節 Cloud9にVue.js開発環境を導入するでは

vue create プロジェクト名

でVue.jsを導入しました。
色々入ってます。
Vue.jsに触ったのは2年前でどういう構造になっていたのか忘れてしまいましたので、おさらいしてみましょう。

ディレクトリ構成はこんな感じです。

f:id:chief-shuffle:20191222101117j:plain

あんなこと こんなこと あったでしょう


1ファイルずつ見ていきましょう。

  • public/index.html

public配下はトランスパイルされない、そのまま静的コンテンツになるソースです。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width,initial-scale=1.0">
    <link rel="icon" href="<%= BASE_URL %>favicon.ico">
    <title>clientside</title>
  </head>
  <body>
    <noscript>
      <strong>We're sorry but clientside doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
    </noscript>
    <div id="app"></div>
    <!-- built files will be auto injected -->
  </body>
</html>

<div id="app"></div>の部分にビルドされたJavaScriptがバインドされます。

  • src/main.js
import Vue from "vue";
import App from "./App.vue";
import router from "./router";
import store from "./store";

Vue.config.productionTip = false;

new Vue({
  router,
  store,
  render: h => h(App)
}).$mount("#app");

index.html#appApp.uveをバインドするメインのJavaScriptです。
Vue RouterのrouterとVuexのstoreを読み込んでいます。

  • src/App.vue
<template>
  <div id="app">
    <div id="nav">
      <router-link to="/">Canaan</router-link> |
      <router-link to="/about">Egipt</router-link>
    </div>
    <router-view />
  </div>
</template>

<style lang="stylus">
#app
  font-family 'Avenir', Helvetica, Arial, sans-serif
  -webkit-font-smoothing antialiased
  -moz-osx-font-smoothing grayscale
  text-align center
  color #2c3e50
  margin-top 60px
</style>

.vueファイルの構成です。
<template>でHTML、<script>JavaScript<style>CSSを定義します。
このファイルがJavaScriptを使ってないんで<script>はありません。

<template>router-linkはVue Routerのパスです。
Vue Routerは、SPAでサーバとの通信なしで画面を切り替える仕組みです。
ビルドすると<a>タグになります。
選択したパスに対応するコンポーネント<router-view />に表示されます。
パスやコンポーネントsrc/router/index.jsで定義されます。

<style>はStylusで定義されます。
CSSメタ言語のStylusはトランスパイスされてCSSになります。
直接CSSを書くときの{};が不要で、変数を使えたりもします。

  • src/router/index.js
import Vue from "vue";
import VueRouter from "vue-router";
import Home from "../views/Home.vue";

Vue.use(VueRouter);

const routes = [
  {
    path: "/",
    name: "home",
    component: Home
  },
  {
    path: "/about",
    name: "about",
    // route level code-splitting
    // this generates a separate chunk (about.[hash].js) for this route
    // which is lazy-loaded when the route is visited.
    component: () =>
      import(/* webpackChunkName: "about" */ "../views/About.vue")
  }
];

const router = new VueRouter({
  mode: "history",
  base: process.env.BASE_URL,
  routes
});

export default router;

Vue Routerのパスとコンポーネントの対応を定義します。
"/"に対してはHome"/about"に対しては"../views/About.vue"を動的にインポートしています。
SPAでは画面を切り替えないのでブラウザの履歴機能をシミュレートするためにのmode: "history"を指定しています。
スライドショー作るだけなら要らなかったかな。
まあ使わなかったら消しましょう。

  • src/store/index.js
import Vue from "vue";
import Vuex from "vuex";

Vue.use(Vuex);

export default new Vuex.Store({
  state: {},
  mutations: {},
  actions: {},
  modules: {}
});

VuexのStateやActionを定義します。
本家によると「 Vuex は Vue.js アプリケーションのための 状態管理パターン + ライブラリです。」

vuex.vuejs.org

SPAだとJavaScriptでデータを保持していろんなトリガで画面に反映するので、ばらばらに実装するとすぐにコードがスパゲッティになります。
ので、一元的なデータストアから画面に直接反映させる仕組みです。 ちょっとよくわからないので、実装するときに詳しく見ていきましょう。

  • src/view/Home.vue
<template>
  <div class="home">
    <img alt="Vue logo" src="../assets/logo.png" />
    <HelloWorld msg="Welcome to Your Vue.js App" />
  </div>
</template>

<script>
// @ is an alias to /src
import HelloWorld from "@/components/HelloWorld.vue";

export default {
  name: "home",
  components: {
    HelloWorld
  }
};
</script>

Vue Routerで"/"が選択されたときに読み込まれるコンポーネントです。
コンポーネントHelloWorldをインポートして<HelloWorld>タグとして配置しています。
引数としてmsgを渡してます。

  • src/components/HelloWorld.vue
<template>
  <div class="hello">
    <h1>{{ msg }}</h1>
(略)
  </div>
</template>

<script>
export default {
  name: "HelloWorld",
  props: {
    msg: String
  }
};
</script>

<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped lang="stylus">
(略)
</style>

引数のmsgを受け取って<h1>タグに埋め込んでます。

大体の構造は思い出した気がしますので、次回から実装に移っていきたいと思います。