20191203のvue.jsに関する記事は23件です。

【Vue.js】アスペクト比を設定できるVueコンポーネントを作る

本記事は岩手県立大学 Advent Calendar 2019 4日目の記事です。

はじめに

Webページを作っているとアスペクト比を設定できる<img>タグが欲しいと思うことがありました。
これまではCSSの@media screenとか:beforeで頑張って実現していましたが、理解が難しいです。
その割に使いたい場面は結構あります(部品化したい)。
そのため、今回は「CSSを頑張らない」アスペクト比を設定できるVueコンポーネントを作っていきます。
(jQueryで実現する方法もあるみたいですが、私はjQueryを使ったことがないのでスルーします。)

CSSでアスペクト比を設定するときに困ること

width: 100%にしたいときの高さが定まらない

基本的に<img>の縦横サイズはwidth: 640px; height: 360pxという感じでpxで設定します。そうしないと好きなアスペクト比に設定できません。
それではwidth: 100%の時、実際の要素の横は何pxで、縦は何pxにするのでしょうか?
私も若かりし頃は、width: 100%; height: 56.25%とすればいけるんじゃね?と思っていましたが、それでは高さは0になってしまいます。

hoge.html
<div>
  <img src="..." style="width: 100%; height: 56.25%">
  <!-- 高さは0!! -->
</div>

これは、そもそも親の要素の高さが指定されていないからです。
したがって、動的にwidthとheightを〇〇pxで設定することが必要です。

コーディングのおことわり

  • Vue単一ファイルコンポーネントで記述します
  • TypeScriptで書きます
  • style部はStylus記法で書きます
  • vue-property-decoratorを使います
  • class styleでコンポーネントを定義します

実装したコード

VueAspectRatioImage.vue
<template>
<div class="v-aspect-ratio-img" :style="style">
  <img :src="imageData">
</div>
</template>
<script lang="ts">
import { Vue, Component, Prop } from 'vue-property-decorator'
@Component
export default class VueAspectRatioImage extends Vue {
  @Prop({ type: String, required: false, default: '128x128' })
  size!: string

  @Prop({ type: Number, required: false, default: 0 })
  aspectRatio!: number // height/width

  @Prop({ type: Boolean, required: false, default: false })
  fullWidth!: boolean

  @Prop({ type: String, required: false, default: null })
  refName!: string|null

  @Prop({ type: String, required: false, default: 'px' })
  unit!: string

  @Prop({ type: String, default: '', required: false })
  src!: string

  clientWidth: number|null = null

  mounted() {
    this.handleResize()
    // 画面サイズ変更イベント
    window.addEventListener('resize', this.handleResize)
  }
  beforeDestory() {
    window.removeEventListener('resize', this.handleResize)
  }

  handleResize() {
    if (this.refName) {
      const vc = this.$parent.$refs[this.refName] as any
      if (vc && Array.isArray(vc)) {
        try {
          vc[0].$el.style.minWidth = '0'
          this.clientWidth = vc[0].$el.clientWidth
        } catch {
          this.clientWidth = null
        }
      } else if (vc) {
        vc.$el.style.minWidth = '0'
        this.clientWidth = vc.$el.clientWidth
      }
    }
  }

  get imageData() {
    if (this.src.indexOf('http') === 0 || this.src.indexOf('data:image') === 0) {
      return this.src
    } else if (this.src) {
      return require('../assets/' + this.src)
    } else {
      return ''
    }
  }

  get style() {
    let width = '128'
    let height = '128'
    if (/\d+x\d+/.test(this.size)) {
      width = this.size.split('x')[0]
      height = this.size.split('x')[1]
    }

    if (this.fullWidth && this.refName && this.clientWidth) {
      width = String(this.clientWidth)
    }

    if (this.aspectRatio > 0) {
      height = String(this.aspectRatio * parseInt(width))
    }

    return {
      width: this.fullWidth ? '100%' : `${width}${this.unit}`,
      height: `${height}${this.unit}`,
      minWidth: `${width}${this.unit}`
    }
  }
}
</script>
<style lang="stylus" scoped>
.v-aspect-ratio-img
  img
    width 100%
    height 100%
    object-fit cover
</style>

コード解説

<templete>

VueAspectRatioImage.vue
<template>
<div class="v-aspect-ratio-img" :style="style">
  <img :src="imageData">
</div>
</template>

ポイント

  • <img>を囲む<div>に動的スタイルを適用すること

ここで<img>だけに動的スタイルを適用すると、このコンポーネント全体の横幅は常に100%になります。(divがblock要素だから)

<script>@Prop

  @Prop({ type: String, required: false, default: '128x128' })
  size!: string

  @Prop({ type: Number, required: false, default: 0 })
  aspectRatio!: number // height÷width

  @Prop({ type: Boolean, required: false, default: false })
  fullWidth!: boolean

  @Prop({ type: String, required: false, default: null })
  refName!: string|null

  @Prop({ type: String, required: false, default: 'px' })
  unit!: string

  @Prop({ type: String, default: '', required: false })
  src!: string

上からそれぞれ
size
縦横のサイズを決めます。
単位は他のPropで決めるので、ここでは256x256という感じで書きます。
(ここでは横 x 縦)

aspectRatio
アスペクト比を決めます。縦÷横の値。
横16:縦9ならば、9 ÷ 16 = 0.5625 です。

fullWidth
横いっぱいにするか決めます。
この場合でもアスペクト比は保たれます。

【重要】refName
このコンポーネントからみた親コンポーネントで設定するref属性名です。
これがないとwidth:100%の時の自身の横幅がわかりません。

unit
サイズの単位です。sizeで20x20、unitでremとすると、

width: 20rem;
height: 20rem;

とスタイリングされます。

src
画像URLを指定します。
コード中で<img :src="imageData">となっているのは、外部またはassets内のどちらからでも画像を参照できるように工夫するためです。

<script>mounted()beforeDestory()

  mounted() {
    this.handleResize()
    // 画面サイズ変更イベント
    window.addEventListener('resize', this.handleResize)
  }
  beforeDestory() {
    window.removeEventListener('resize', this.handleResize)
  }

ポイント

  • mounted()で画面サイズが変更されたときにhandleResize()が呼ばれるように設定する。
  • beforeDestoryでイベントリスナーを取り消す

<script>handleResize()

  handleResize() {
    if (this.refName) {
      const vc = this.$parent.$refs[this.refName] as any
      if (vc && Array.isArray(vc)) {
        try {
          vc[0].$el.style.minWidth = '0'
          this.clientWidth = vc[0].$el.clientWidth
        } catch {
          this.clientWidth = null
        }
      } else if (vc) {
        vc.$el.style.minWidth = '0'
        this.clientWidth = vc.$el.clientWidth
      }
    }
  }

ここではrefNameを使って、このコンポーネント自身のサイズを測定します。
正確には、width:100%の時の横幅を測定します。

3行目
const vc = this.$parent.$refs[this.refName] as any
ここで自身のVueComponentを取得します。(vcはその略)

7行目
this.clientWidth = vc[0].$el.clientWidth
ここで自身の横幅を測定します。

その他

if (vc && Array.isArray(vc)) {
...
} else if (vc) {

という条件分岐を書いているのは、場合によってはvcが配列になるからです。

<script>get imageData()

  get imageData() {
    if (this.src.indexOf('http') === 0 || this.src.indexOf('data:image') === 0) {
      return this.src
    } else if (this.src) {
      return require('../assets/' + this.src)
    } else {
      return ''
    }
  }

算出プロパティです。
ここでは、Propのsrcに外部URL(https://~)とassets内のファイル名のどちらが設定されても画像を参照できるようにしています。
ポイントはassets内のファイルを参照する時に、require(..)を使うことです。

<script>get style()

  get style() {
    let width = '128'
    let height = '128'
    if (/\d+x\d+/.test(this.size)) {
      width = this.size.split('x')[0]
      height = this.size.split('x')[1]
    }

    if (this.fullWidth && this.refName && this.clientWidth) {
      width = String(this.clientWidth)
    }

    if (this.aspectRatio > 0) {
      height = String(this.aspectRatio * parseInt(width))
    }

    return {
      width: this.fullWidth ? '100%' : `${width}${this.unit}`,
      height: `${height}${this.unit}`,
      minWidth: `${width}${this.unit}`
    }
  }

算出プロパティです。
ここでアスペクト比を保つように動的にスタイルしています。

4~7行目

    if (/\d+x\d+/.test(this.size)) {
      width = this.size.split('x')[0]
      height = this.size.split('x')[1]
    }

正規表現を使って、@Prop sizeの256x256などの文字列をパースします。

9~11行目

    if (this.fullWidth && this.refName && this.clientWidth) {
      width = String(this.clientWidth)
    }

@Prop fullWidthがtrueのときに、横いっぱいになるようにwidthを指定してます。
handleResize()で測定したclientWidthはwidthが100%の時の値です。

13~15行目

    if (this.aspectRatio > 0) {
      height = String(this.aspectRatio * parseInt(width))
    }

アスペクト比が設定された場合、heightを更新します。

17~21行目

    return {
      width: this.fullWidth ? '100%' : `${width}${this.unit}`,
      height: `${height}${this.unit}`,
      minWidth: `${width}${this.unit}`
    }

最終的にCSSの形のオブジェクトを返します。

このコンポーネントの利用方法

    <v-aspect-ratio-img
      src="background-sky.png"
      :fullWidth="true"
      ref="image3"
      ref-name="image3"
      :aspect-ratio="0.5625"
    />

実行イメージ

PC スマホ
localhost_8080_ (7).png localhost_8080_(iPhone 6_7_8).png

終わりに

Vueで好きな部品を作れるようになったので、コードを再利用することを前提に書いていけるのがいいですね。

今回のサンプル
https://github.com/Takahana/VueAspectRatioImage

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

1年間サーバーレスで運用したNuxt.js製サイトをTypescript移行した話とそこで学んだこと

概要・前置き

どうも、都内でフロントエンドエンジニアをしてます、かめぽんです。以前、Nuxt.js + Netlifyで爆速構築するサーバーレス開発入門という記事を書きまして、その記事と同じNuxt.js + Netlifyのシンプルな構成で作った、以下のサイトを1年間と3ヶ月ほど運用してきました。撮影からデザイン、インタラクションの実装からデプロイまで一気通貫でやってみました。

https://www.brightanddizain.com/スクリーンショット 2019-12-03 22.45.40.png

Lighthouseの評価もよくサイトがきっかけでお仕事もちょくちょくいただいたりと、おかげさまでここまでくることができました。(PWA対応もしてます)

スクリーンショット 2019-12-01 1.52.16.png

しかしながら、フロントエンド技術の流れが非常に早く陳腐化してきたことやサステナビリティを高めたいとは思いつつも課題が多かったのでこの機にnuxtのtypescript移行とそれに伴う環境基盤の構築をしました。そこで学んだことや大事なことなどをご紹介し、少しでも技術の移行やフロントエンド基盤構築や設計のお役に立てればと思います。
少々長いですが、最後まで読んでいただけると嬉しいです!

サイトの概要

以下、サイトの技術スタックです。動的な部分は少なく頻繁にメンテもするわけではないので、静的なコンテンツで管理しています。しかし、DXの向上を目指しつつメンテしやすいことが条件ではあり、かつコンポーネント指向開発は必須なためnuxt.jsを使用しています。

  • Nuxt.js(nuxt generate)
  • Netlify
  • Atomic Design
  • slack api

移行の目的

現状、SFCのVueコンポーネントでサイトを構成していますが、コンポーネントの管理があまりできておらず将来的にさらに陳腐化が進むことが懸念されます。
また、これからくるであろう技術を出来るだけ実践的に試せるプレイグラウンド的な立ち位置にしたく整理をしていきます。さらに、Typescriptがこれからデファクトスタンダートのような立ち位置になるのではないかと予想をふみjavascriptから脱却し、型安全による品質とサステナビリティ向上を目指します。

  • コンポーネント資産の管理と品質の向上
  • プレイグラウンド的立ち位置
  • 陳腐化による技術スタックの刷新

実際にやったこと・構成のポイント

ここからは実施したものやリファクタをした内容などを説明していきますが、実際に導入した施策はかなり多かったので、厳選して nuxt typescript移行をする際の重要な部分を記載します。また、アーキテクチャを考えるときに重要視しているStoreとService層について少し説明します。

  • typescript導入
  • Vuex Storeの型定義
  • StoreとSeriviceについて

typescript導入

まず、nuxtのtypescript移行についてですがこちらは 公式でも出ているtypescript.nuxtjs.orgに従って移行します。ここでやることとしては以下の通りです。

スクリーンショット 2019-12-03 22.42.24.png

  • 必要なモジュールの導入
  • config系の編集

必要なモジュールの導入

まずはじめにnuxt tsに移行するために、 @nuxt/types, @nuxt/typescript-build @nuxt/typescript-runtime を導入します。 加えて、ts-loader もインストールします。@nuxt/typescript-runtimeは任意ではありますがnuxt.configなどでtsを使う場合には必要なのでインストールします。なお@nuxt/typesは@nuxt/typescript-buildに含まれているので個別にインストールする必要はありません。

npm i -D @nuxt/typescript-build ts-loader

npm i @nuxt/typescript-runtimeはプロダクション環境でも必要になるためdependenciesでインストールします。

npm i @nuxt/typescript-runtime

config系の編集

最初にtsconfig.jsonの準備をしましょう。設定はお好みで良いですが以下に例を載せておきます。typesの欄に@nuxt/typesを追加しておきましょう。

package.json
{
  "compilerOptions": {
    "target": "es5",
    "module": "esnext",
    "moduleResolution": "node",
    "lib": [
      "esnext",
      "esnext.asynciterable",
      "dom"
    ],
    "typeRoots" : ["./type"],
    "allowSyntheticDefaultImports": true,
    "noImplicitAny": false,
    "esModuleInterop": true,
    "allowJs": true,
    "sourceMap": true,
    "strict": true,
    "noEmit": true,
    "rootDir": "./src/",
    "baseUrl": "./src/",
    "paths": {
      "@*": [
        "./*"
      ],
      "*": [
        "*"
      ]
    },
    "types": [
      "@types/node",
      "@nuxt/types"
    ]
  },
  "include": [
    "src/**/*.ts",
    "src/**/*.vue",
    "src/**/*.spec.ts",
    "src/**/*.spec.tsx",
    "src/**/*.test.ts",
    "src/**/*.test.tsx"
  ],
  "exclude": [
    "node_modules"
  ]
}

次に nuxt.configの設定ですが、先ほど @nuxt/typescript-runtimeを導入したので早速 nuxt.config.jsの拡張子を .tsに変えます。また、nuxtConfigに型をつけるので@nuxt/typesでtypeをつけます。

import { Configuration as NuxtConfiguration } from '@nuxt/types'

...

const nuxtConfig: NuxtConfiguration = {
  mode: 'universal',
  ...
  build: {
    extend(config: any, { isDev, isClient }) {
      const tsLoader = {
        loader: 'ts-loader',
        options: {
          appendTsSuffixTo: [/\.vue$/],
          context: __dirname,
          configFile: 'tsconfig.json'
        }
      }

      for (let rule of config.module.rules) {
        if (rule.loader === 'vue-loader') {
          rule.options.loaders = {
            ...rule.options.loaders,
            ts: tsLoader
          }
        }
      }
    }
  },
  buildModules: [
    [
      '@nuxt/typescript-build', // buildMudulesに@nuxt/typescript-buildを追加します。
      {
        typeCheck: true,
        ignoreNotFoundWarnings: true
      }
    ]
  ]
}
export default nuxtConfig

次に、npmコマンドからtsを動かせるようにするためにpackage.jsonのscriptsを編集します。今までは nuxt buildなどで動きましたがts環境では nuxt-tsを使用するのでnpm scripts内のコマンドを書き換えましょう。

package.json
{
    ...
  "scripts": {
    "dev": "nuxt-ts --spa",
    "build": "nuxt-ts build",
    "start": "nuxt-ts start",
    "generate": "nuxt-ts generate"
        ...
    }
    ...
}

ビルド周りの設定は以上なので、npm run devのコマンドを実行し http://localhost:3000/にアクセスしてサイトが表示されたら成功です。

Vuex Storeの型定義

次にStoreのtypescript対応になりますが、Storeの型定義に関しては@takepepeさんの ts-nuxtjs-expressを参考にさせていただいてます。圧倒的に感謝です!

NuxtのVuex Storeでやることとしては以下の通りになります。

  • typesの準備
  • Vuexの型の拡張
  • storeの型付け

最初にVuexにおけるtypesの準備をします。Storeはモジュールモードで以下のようなディレクトリ構成にします。

├─store/
  ├─ contact/
    ├─ index.ts  // contactのstoreモジュール本体
    └─ type.ts  // Storeの型定義ファイル

ここで、nuxtが型定義のファイルまでstore配下のファイルを自動的にstoreに認識してしまうため .nuxtignoreファイルを作り自動的にstoreに認識されないようにします。

.nuxtignore
store/**/type.ts

次にcontact storeの型定義ファイルを作ります。(記事に収めるるため実際のパラメータよりも少なくしています)
interfaceの名前が略称になってますが、それぞれ S(State)、 G(Getters)、 RG(RootGetters)、 M(Mutations)、 RM(RootMutations)、 A(Actions)、 RA(RootActions)の意味になります。

store/contact/type.ts
export interface S {
  name: string
  tel: string
  message: string
}

export interface G {
  name: string
  tel: string
  message: string
  isErrName: boolean
  isErrTel: boolean
  isErrMessage: boolean
}

export interface RG {
  'contact/name': G['name']
  'contact/tel': G['tel']
  'contact/message': G['message']
  'contact/isErrName': G['isErrName']
  'contact/isEreTel': G['isErrTel']
  'contact/isErrMeassage': G['isErrMessage']
}

export interface M {
  SET_NAME: string
  SET_TEL: string
  SET_MESSAGE: string
}

export interface RM {
  'contact/SET_NAME': M['SET_NAME']
  'contact/SET_TEL': M['SET_TEL']
  'contact/SET_MESSAGE': M['SET_MESSAGE']
}

export interface A {
  setName: string
  setTel: string
  setMessage: string
  resetContacts: void
  sendContacts: void
}

export interface RA {
  'contact/setName': A['setName']
  'contact/setTel': A['setTel']
  'contact/setMessage': A['setMessage']
  'contact/resetContacts': A['resetContacts']
  'contact/sendContacts': A['sendContacts']
}

次にVuexの型の拡張に入ります。以下のようにプロジェクトのルートに typesディレクトリを作成しVuexの型を拡張していきます。

├─types/
  ├─ vuex/
    ├─ index.d.ts  // プロジェクト全体に影響する実際に呼び出されるVuexの型定義
    ├─ root.ts  // プロジェクト固有のstoreで定義したtypesをVuexにつなぎこむ場所
    └─ type.ts  // Storeの型定義ファイル

index.d.tsでは主に共通的に使えるtype.tsとプロジェクト固有のルールを含んだroot.tsをインポートします。このプロジェクトでVuexを使う場合このファイルが読み込まれます。

types/vuex/index.d.ts
import './root'
import './type'

こちらはプロジェクト固有のルールを含んだVuexの型拡張になります。

types/vuex/root.ts
import 'vuex'
import * as Contact from '../../store/contact/type'
import * as View from '../../store/view/type'

declare module 'vuex' {
  type RootState = {
    contact: Contact.S
    viwe: View.S
  }
  type RootGetters = Contact.RG
  type RootMutations = Contact.RM
  type RootActions = Contact.RA
}

以下はVuexで共通的に使われる型拡張です。

types/vuex/type.ts
import 'vuex'

declare module 'vuex' {
  type Getters<S, G> = {
    [K in keyof G]: (
      state: S,
      getters: G,
      rootState: RootState,
      rootGetters: RootGetters
    ) => G[K]
  }

  type Mutations<S, M> = { [K in keyof M]: (state: S, payload: M[K]) => void }
  type ExCommit<M> = <T extends keyof M>(type: T, payload?: M[T]) => void
  type ExDispatch<A> = <T extends keyof A>(type: T, payload?: A[T]) => any
  type ExActionContext<S, A, G, M> = {
    commit: ExCommit<M>
    dispatch: ExDispatch<A>
    state: S
    getters: G
    rootState: RootState
    rootGetters: RootGetters
  }
  type Actions<S, A, G = {}, M = {}> = {
    [K in keyof A]: (ctx: ExActionContext<S, A, G, M>, payload: A[K]) => any
  }

  interface ExStore extends Store<RootState> {
    getters: RootGetters
    commit: ExCommit<RootMutations>
    dispatch: ExDispatch<RootActions>
  }
  type StoreContext = ExActionContext<
    RootState,
    RootActions,
    RootGetters,
    RootMutations
  >
}

最後にtsconfig.jsonのfilesにtypes/vuex/index.d.tsをfilesに記述します。基本的に独自で拡張した型ファイルがあれば、随時tsconfigに追加すようにしましょう。

tsconfig.json
"files": [
  "src/types/vuex/index.d.ts"
],

ここまで、Storeの型定義をしてきましたが、ここからは実際のStoreに型を付けていきます。先ほど配置した store/contact/index.tsに、定義した型を当てていきます。1行目のvuexのimportで、先ほどtypes/vuex/index.d.tsで拡張したGtters、Mutations、Actionsの型を取り込みます。2行目ではstore/contact/type.tsのinterface宣言した型を取り込んでいます。

GettersやActions内にあるserviceディレクトリから取り込んでいるのは、ビジネス要件を含んだ純関数になっています。

store/contact/index.ts
import { Getters, Mutations, Actions } from 'vuex'
import { S, G, M, A } from './type'

import {
  validName,
  validTel,
  validMessage
} from '../../service/validation'
import submitContact from '../../service/Contact'

export const state = (): S => ({
  name: '',
  tel: '',
  message: ''
})

export const getters: Getters<S, G> = {
  name: state => state.name,
  tel: state => state.tel,
  message: state => state.message,
  isErrName({ name }) {
    return validName(name)
  },
  isErrTel({ tel }) {
    return validTel(tel)
  },
  isErrMessage({ message }) {
    return validMessage(message)
  }
}

export const mutations: Mutations<S, M> = {
  SET_NAME(state, payload) {
    state.name = payload
  },
  SET_TEL(state, payload) {
    state.tel = payload
  },
  SET_MESSAGE(state, payload) {
    state.message = payload
  }
}


export const actions: Actions<S, A, G, M> = {
  setName({ commit }, payload) {
    commit('SET_NAME', payload)
  },
  setTel({ commit }, payload) {
    commit('SET_TEL', payload)
  },
  setMessage({ commit }, payload) {
    commit('SET_MESSAGE', payload)
  },
  resetContacts({ commit }) {
    commit('SET_NAME', '')
    commit('SET_TEL', '')
    commit('SET_EMAIL', '')
    commit('SET_COMPANY', '')
    commit('SET_MESSAGE', '')
  },
  sendContacts({ state }) {
    const { company, name, email, tel, message } = state

    return submitContact({
      name,
      tel,
      message
    })
  }
}

Storeの型定義と型付けに関しては以上で、あとはいつも通りVueコンポーネントでmapGettersやmapActionsでStoreの内容を取り込むだけで型安全なStoreを扱うことができます。モジュールモードになっているので、別のStoreを導入し型安全にしていきたい場合は同様の手順を踏んでいくと良いと思います。
ここで書いてあるtypescriptのコードはあくまで一例なのでプロジェクトにあった型定義を模索していければと思います。

StoreとService層ついて

NuxtでもVueのプロジェクトでもビジネスルールを含んだロジックの記述場所は悩みのタネの一つだと思います。コンポーネント側に寄せるのか、それともStoreに集約されるのか、両方使うならどれくらStoreに寄せるべきかなどなどあると思います。よく、 Storeの肥大化なんて言われるかと思います。とはいえ、コンポーネントにロジックが並んでいるのもViewとロジックが同居してしまい混乱の原因になり得ます。では、Storeの肥大化を防ぎながらどうやってビジネスルールを含んだロジックをVuexで管理するか、というところになります。

僕の意見としては、「ロジックの網羅性」「ビジネス固有の複雑さ」を役割分担することが大事だとおもってます。
ここでいうと、Storeがロジック(ユースケース)の網羅性を担保し、Service層でビジネス固有の複雑性を含むことです。

例えば先ほどのStoreの型をつけているところでいうと、Gettersを宣言しているisErrNameやisErrTelなどのreturn部分で、validNameやvalidTelなどがあります。これがServiece層の関数になっています。ここでは、Storeはこのロジックの中身を知りません。しかしながら、Storeは画面側で必要なプロパティやActionsをもつべきなので、このようにしています。

export const getters: Getters<S, G> = {
  name: state => state.name,
  tel: state => state.tel,
  message: state => state.message,
  isErrName({ name }) {
    return validName(name)  // ValidNameがService層
  },
  isErrTel({ tel }) {
    return validTel(tel)
  },
  isErrMessage({ message }) {
    return validMessage(message)
  }
}

こちらがそのService層の中身です。ここではあくまでとてもシンプルな例を出してますが、アプリケーション固有のロジックを以下のように値を受け取って出力するだけの純関数にすることで、簡単に使い回せますしVuexのコードを汚すことなく済みます。

type ValidType = (value: string) => boolean

export const validName: ValidType = value => {
  const isErr: boolean = value.length < 4 // 名前が4文字以内だと弾かれるというルール
  return isErr
}

StoreとService層とで分割することで、Vuexのことを気にせずこの関数の中身自体をスケールさせることも分割することも用意です。
また、純関数なので非常にテストがしやすく、Vuex以外の技術に移行しやすくその時のFWなどに依存しずらいというメリットもあります。(そもそもビジネスがFWの流行り廃りに左右されるのは本望ではないかと思います)

Service層が割とutils層みたいな部分と区別がつかないみたいな話もありますが、僕個人の意見としてはutils層は アプリケーション固有の情報を含まず共通して使えるものと認識しております。
例えば、axiosをラップした外部リソース取得用のモジュールなどです。それ自体は、ビジネスルールを含む訳ではないのでutils層におきます。ただそこから、特定のAPIを叩くための関数だったりプロジェクト固有の独自キーを用いたストレージへのアクセスとなるとそれを取り込んだ上でService層で定義します。

学んだこと

サーバーレス&generateサイトを1年間運用してみて

サーバーレス&静的サイトジェネレートということでNuxt + Netlifyで1年間サイトを運用してみて、非常に楽だったなという思いです。動的コンテンツが少なくちょっとしたお問い合わせフォームだけで複雑な実装の画面などはなかったので静的コンテンツの管理だけで済みました。
また、料金面でもドメイン取得料だけだったので総額の出費も3000円程度で済みました。Netlifyに限った話ではなく、Amazon S3にアップするだけでも良いですし、若干レガシー環境だとレンタルサーバーに書き出したファイルを設定するだけでサイト作成がほぼ完了したりします。またNuxtなので、静的ファイルも書き出せつつコンポーネント志向開発で出来るという強みは非常に大きかったです。

見えてきた課題とアーキテクチャを考える上で大事なこと

という主語がデカめのタイトルになってしまって申し訳ないですが、「配置すること」って結構重要だと思っていてディレクトリ構成やなんの技術をどう使うか、という話は後の運用に大きく影響し導入時に大部分が決まる、みたいなとこはあると思います。

実際に、typescript移行は影響範囲が全体でコンポーネントやStore全てに影響が出たので、最初からTSにすればよかったんや!!って思うことがありました。また、それによって必要なモジュールも増えるので決めるべき時にしっかり決めるべきだなと感じました。
その上で設計や技術選定で気をつけたいなと思ったことは「壊しやすいか」と「組織あるいはチームの構造はどんなか」です。

壊しやすさについて

壊しやすさは「依存度を下げる」みたいな意味合いも入っていて、ある特定の技術がなければ作れないあるいは作り直したほうが早いみたいな状況です。

いかに既存の資産を活かせる形をとれるかが重要になってくるんですが、先ほどのStoreとService層の話はまさにそれです。
Service層はビジネス固有のルールを含んでいますが、Vuexに対して依存しないようにしています。そうすることによって、最悪VueやVuexが使えない状態になったとしても、そのServiceの純関数群はそのまま他のFWでも扱うことが出来ます。(あくまで「壊しやすい形」のほんの一例です)

おそらく、フレームワークのライフサイクルよりも事業のライフサイクルの方がおそらく長い(事業による)かと思います。また、プロダクトやサービスに与える影響の大きさは、フレームワークではなくマーケットの動きだったりユーザーフィードバックの方が大きいはずなので、それに合わせていかに変化(壊しやすく)出来るかが重要、という感じです。

組織あるいはチームの構造はどんなか

こちらは、コンウェイの法則でもあるんですが

The structure of any system designed by an organization is isomorphic to the structure of the organization.

とあるので、出来上がるシステムの構造は設計する組織の構造に依存する、と解釈できます。
設計する時の組織構造は何か、どんなメンバーがいるのか、どんなチーム構成なのかという部分がシステムやアプリケーションに影響します。加えて、そのサービスやプロダクトが将来的にどういった方向に行きたいのか、どういった形態になりうるのか、未来の組織の様子を踏まえて設計することが必要です。つまり、未来のチームの姿と現在との差分を定義した上でアーキテクチャを考えると良さそうだなと、思ってます。

まとめ

  • Nuxt typescrptおすすめ!
  • 静的サイトジェネレート+ホスティングで運用コストを減らそう
  • 設計は「壊しやすいか」と「組織あるいはチームの構造はどんなか」を考慮する

ここまで読んでいただき誠に感謝です。
前章で組織構造大事やぞ、と言いつつ今回のNuxtのサイトは僕一人で作ったので、今回に関しては組織構造とかないですが技術に依存し過ぎずでもそれの旨味は味わいつつ、という風にやっていけそうな気はするのでこれからも運用していきます。以下、本記事のまとめになります。

今回実際に刷新したサイトのリポジトリをのせていますのでご参考にしていただければと思います。
https://github.com/isihigameKoudai/bright-and-dizain

参考

以下、参考リポジトリや資料になります。ありがとうございます!
- https://typescript.nuxtjs.org/
- https://github.com/takefumi-yoshii/ts-nuxtjs-express
- https://ja.wikipedia.org/wiki/%E3%83%A1%E3%83%AB%E3%83%B4%E3%82%A3%E3%83%B3%E3%83%BB%E3%82%B3%E3%83%B3%E3%82%A6%E3%82%A7%E3%82%A4

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【2019年12月版】Nuxt + TypeScript + ESLint + PrettierとVSCodeで環境構築する方法

環境

  • Windows 10 Home
  • Node v12.13.0
  • Yarn v1.7.0
  • Nuxt v2.10.2

対象

すでにNuxtの知識がある方、自分でnuxt.config.jsの書き換えができたり、ある程度ESlintなどの知識がある方を対象としております。
細かい所はかなり割愛しています

完成品

GitHubにて公開されています
type-script-nuxt

git clone https://github.com/devinoue/type-script-nuxt.git

Nuxtのインストール

通常版Nuxtのインストールします。

yarn create nuxt-app アプリ名

コマンドラインで色々と聞かれると思いますが、今回は以下のようにしています。

  • Eslintはここでは入れない。
  • Prettierは導入
  • 他は特になし

TypeScriptの導入

以下はほぼ公式通りです。

yarn add --dev @nuxt/typescript-build

設定ファイルのnuxt.config.jsのうち、buildModulesに書き加えます。

nuxt.config.js
export default {
  buildModules: [
    '@nuxt/typescript-build'
  ]
}

ルートディレクトリにtsconfig.jsonを作成してください。

以下のjsonは一行だけ公式と違います
"experimentalDecorators": true,
を加えているので、ご注意ください。

tsconfig.json
{
  "compilerOptions": {
    "target": "esnext",
    "module": "esnext",
    "experimentalDecorators": true,
    "moduleResolution": "node",
    "lib": [
      "esnext",
      "esnext.asynciterable",
      "dom"
    ],
    "esModuleInterop": true,
    "allowJs": true,
    "sourceMap": true,
    "strict": true,
    "noEmit": true,
    "baseUrl": ".",
    "paths": {
      "~/*": [
        "./*"
      ],
      "@/*": [
        "./*"
      ]
    },
    "types": [
      "@types/node",
      "@nuxt/types"
    ]
  },
  "exclude": [
    "node_modules"
  ]
}

Nuxt用クラススタイルデコレーター

クラススタイルで書きたいのでnuxt-property-decoratorをインストールします

yarn add nuxt-property-decorator

※vue-property-decoratorでもOKです

ここまでくればOKです。
pagesディレクトリのindex.vueを開いて、
<script> タグ以下をtsバージョンに書き換えてみましょう。

↓もともとのコード

<script>
import Logo from '~/components/Logo.vue'

export default {
  components: {
    Logo
  }
}
</script>

↓TypeScriptバージョン

<script lang="ts">
import { Component, Vue }
  from 'nuxt-property-decorator'
import Logo from '~/components/Logo.vue'
@Component({
  components: {
    Logo
  }
})
export default class Index extends Vue {}
</script>

これでyarn devを実行してみて、無事localhost:3000で見られたら成功です。

ESlintをインストールする

ローカルESlint、ローダー、EslintのNuxt用プラグインをインストールする

yarn add -D eslint eslint-loader eslint-plugin-nuxt

ESLint用NuxtTS設定ファイルをインストール

yarn add -D @nuxtjs/eslint-config-typescript

VSCodeを使っている人

自分はVSCodeを使っているのですが、保存時に自動的にlintが反映されるようにしています。
プラグインとしてeslintとveturあらかじめインストールしておいてください。
拡張機能をインストールするだけですので、詳細は割愛します。

nuxt.config.jsに自動反映用の設定を加えます。

nuxt.config.js
  build: {
    /*
     ** You can extend webpack config here
     */
    extend(config, ctx) {
      // Run ESLint on save
      if (ctx.isDev && ctx.isClient) {
        config.module.rules.push({
          enforce: 'pre',
          test: /\.(js|vue)$/,
          loader: 'eslint-loader',
          exclude: /(node_modules)/
        })
      }
    }
  },

.eslintrc.jsをルートディレクトリに作成し、
中身は以下のようにしています。

.eslintrc.js
module.exports = {
  root: true,
  env: {
      browser: true,
      node: true
  },
  extends: [
      '@nuxtjs/eslint-config-typescript',
      'plugin:nuxt/recommended',
      'plugin:prettier/recommended',
      'prettier',
      'prettier/vue'
  ],
  plugins: [
      'vue'
  ],
  rules: {
      'vue/html-closing-bracket-newline': 'off',
      'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'off',
      'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off',
  }
};

ESlintのルールは一例ですが、
"vue/html-closing-bracket-newline"この設定はオフにしておいた方がいいのではないかと思います。
Prettierがおかしな動きをする可能性があるので。

VSCodeのsetting.json

VSCodeのsetting.jsonを書き加えます。

setting.json
{
  "eslint.validate": [
    {
      "language": "vue",
      "autoFix": true
    },
    {
      "language": "javascript",
      "autoFix": true
    },
    {
      "language": "javascriptreact",
      "autoFix": true
    }
  ],
  "eslint.autoFixOnSave": true,
  "editor.formatOnSave": false,
  "vetur.validation.template": false
}

以上!

これでVScodeで動作させて問題なければ完了です。
お疲れさまでした?❗

エラー別解説

エラー1

This dependency was not found:                                           Module not found: Error: Can't resolve 'eslint-loader' 

ローダーをインストールしましょう。

yarn add -D eslint-loader

エラー2

Failed to load plugin 'nuxt' declared in '.eslintrc.js': Cannot find module 'eslint-plugin-nuxt'

プラグインをインストールしましょう

yarn add -D eslint-plugin-nuxt

エラー3

  2:7  error  Replace `·class="NuxtLogo"·width="245"·height="180"·viewBox="0·0·452·342"·xmlns="http://www.w3.org/2000/svg"` with `⏎····class="NuxtLogo"⏎····width="245"⏎····height="180"⏎····viewBox="0·0·452·342"⏎····xmlns="http://www.w3.org/2000/svg"⏎··`  prettier/prettier

~/components/Logo.vue
を開き自動修正を加えればエラーは消えます。

ファイルを保存するたびに異なる形で保存される

ファイルを保存するたびに異なるHTMLタグ整形で保存されることがあります。
自分の場合は"vue/html-closing-bracket-newline"をオフにしたら治りましたが、それでダメならPrettierのインストールは諦めましょう。

今回色々調べていましたが、Prettierのインストールに失敗して諦めている方も多い ようです。
自分の場合も諦めかけました?

終わりに

Nuxt TypeScriptのインストール方法は二転三転しています。
公式の対応次第ではあっという間に古びた情報になりえますので、ご注意ください。
最後に以下の記事に助けていただきました。あらためて深く感謝します。

Nuxt v2.9.2でTypeScript, eslint, Prettier環境構築 + VSCodeの設定
Nuxt.js + TypeScript + vue-class-component @IntelliJ IDEA

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

puppeteerとjestとvue

目次

  • ゴール
  • 経緯
  • 構成
  • 準備
  • さいごに

ゴール

vueでpuppeteerをつかってテスト実行してみる

経緯

特にないんですが、仕事で結合試験の効率化でpuppeteerを使うという話があって
しかもjestとも連携できるそうなので、これはvueとも連携できるんじゃないか
というのを思いついたわけです。

準備

どういう環境でやるのか
vue vuex vueRouter vuetify typescript

とりあえず、こんな感じでプロジェクト作成
vuecreate_puppeteer-ts.PNG

vue-plugin-jest-puppeteer

かなり試行錯誤しながら自力で設定する事ができなく途方にくれていたところで
都合の良いプラグインを発見。速攻でインストール
https://github.com/kaizendorks/vue-cli-plugin-jest-puppeteer

vue add jest-puppeteer

vuetifyを追加

jest-puppeteerの前にインストールすると、pluginsフォルダが消えてなくなったので
順序に気をつける(気のせいかもしれないです)

vue add vuetify

テスト実行

jest-puppeteerをvue add追加すると
package.jsonのscriptsに以下がついかされるので

"test:e2e": "jest --config=jest.e2e.config.js --runInBand",

実行するだけです

yarn test:e2e

ひとつ問題点がありまして
テストの中でyarn serveでローカルサーバーを起動するようになっているんですが
エラーになってしまいます。

解決策としては、いまのところ
あらかじめローカルサーバーを起動しておく以外に見つかっていません。

残課題

ローカルサーバーが起動していない状態で、実行ができるようにする。
githubのリポジトリのリンクを貼っておく

さいごに

使い所はありそうな感触はあったので、積極的に実務で使っていきたいと思います。

はじめてのQiitaの記事を書いてみて、アウトプットすることの大事さと難しさを実感しました。
ありがとうございました。
あと、時間ギリギリになってしまって、すいません。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Vuejs】watch immediate: trueはライフサイクルのどこで実行されるのか?

この記事は、Vue #2 Advent Calendar 2019 の2日目の記事です(4日目に飛び入り)。

前置き

Vue.jsにはVueインスタンス(コンポーネント)上のデータの変更を監視する watch というプロパティがあります。

さらに、 watch には immediate というオプションがあります。

watch は通常監視を始めて、データが変わった直後にコールバックが呼ばれますが、 immediate オプションを付与した watch は監視を始めた直後に一回コールバックが呼ばれます。

また、Vueにはインスタンスのライフサイクルに合わせて関数を実行する ライフサイクルフック という仕組みがあります。

そこで、 immediateオプション付きのwatchはライフサイクルにおけるどのタイミングで呼ばれるのか? という疑問が湧いたので調べてみました。

ライフサイクルフック

画像は Vue インスタンス — Vue.js より引用

TL;DR

  • watchのコードリーティングしてみた
  • 実行タイミングは beforeCreatecreated のあいだ
    • ドキュメントには記載無さそう?現状のコードではこのタイミングってぐらいなはず

ひとまず実行してみる

以下のコードで試してみたところ、immediate 付きの watchbeforeCreatecreated の間に実行されました。

https://codepen.io/sin-tanaka/pen/ExaxrZx

new Vue({
  el: '#app',
  data: function() {
    return {
      helloWorld: 'HelloWorld'
    };
  },
  beforeCreate: function() {
    console.log('call beforeCreate');
  },
  created: function() {
    console.log('call created');
  },
  mounted: function() {
    console.log('call mounted');
  },
  watch: {
    helloWorld: {
      handler: v => console.log('call watch', v),
      immediate: true
    }
  }
})
call beforeCreate
call watch HelloWorld
call created
call mounted

ライフサイクルの図で言うところの Init Injections & Reactivity でWatchを仕掛けているようです。

Vuejsのコードを読んでみる

改めてwatchのドキュメントを読んでみましたが、上記の動作を保証するような文言はなさそうでした。

そこで、2019.12.3時点でのdevブランチのコードを読んでみることにします

vuejs/vue: ? Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

new Vueは何をしているのか?

new Vue したときにどのようなコードを実行しているのかを追ってみます
package.jsonscriptsrollupruntime →…のように追っていくと new Vue の実態は以下のようでした。

https://github.com/vuejs/vue/blob/dev/src/core/instance/index.js

src/core/instance/index.js
import { initMixin } from './init'
import { stateMixin } from './state'
import { renderMixin } from './render'
import { eventsMixin } from './events'
import { lifecycleMixin } from './lifecycle'
import { warn } from '../util/index'

function Vue (options) {
  if (process.env.NODE_ENV !== 'production' &&
    !(this instanceof Vue)
  ) {
    warn('Vue is a constructor and should be called with the `new` keyword')
  }
  this._init(options)
}

initMixin(Vue)
stateMixin(Vue)
eventsMixin(Vue)
lifecycleMixin(Vue)
renderMixin(Vue)

export default Vue

function Vue new演算子付きで呼ばれたときのみ、 this._init(option) を実行しています。実質コンストラクタですね。

_init は Vueの中に定義されていないので、 initMixin stateMixin eventsMixin lifecycleMixin renderMixin 辺りで Vue.prototype._init を仕掛けているとみます。

initMixin

initMixin を読んでみると Vue.prototype._init = function (options?: Object) という記述がありました。この関数でコンストラクタに該当するコードを仕込んでいるようです。

src/core/instance/init.js
export function initMixin (Vue: Class<Component>) {
  Vue.prototype._init = function (options?: Object) {
    const vm: Component = this
    /**
    * 中略
    */
    vm._self = vm
    initLifecycle(vm)
    initEvents(vm)
    initRender(vm)
    callHook(vm, 'beforeCreate')
    initInjections(vm) // resolve injections before data/props
    initState(vm)
    initProvide(vm) // resolve provide after data/props
    callHook(vm, 'created')
    /**
    * 中略
    */
    if (vm.$options.el) {
      vm.$mount(vm.$options.el)
    }
  }
}

また、 callHook(vm, 'beforeCreate')callHook(vm, 'created') の関数呼び出しの行があります。これは名前の通り、ライフサイクルフックを実行している関数でした。

その間には initInjections(vm) initState(vm) initProvide(vm) という関数呼び出しがありました。
これはまさしくライフサイクルの図でいうところの Init Injections & Reactivity に該当する関数に見えます。

initState

initState 関数を見てみると initWatch 関数を実行している行がありました。

src/core/instance/state.js
export function initState (vm: Component) {
  vm._watchers = []
  const opts = vm.$options
  if (opts.props) initProps(vm, opts.props)
  if (opts.methods) initMethods(vm, opts.methods)
  if (opts.data) {
    initData(vm)
  } else {
    observe(vm._data = {}, true /* asRootData */)
  }
  if (opts.computed) initComputed(vm, opts.computed)
  if (opts.watch && opts.watch !== nativeWatch) {
    initWatch(vm, opts.watch)
  }
}

さらに initWatch 関数を追ってみます

src/core/instance/state.js
function initWatch (vm: Component, watch: Object) {
  for (const key in watch) {
    const handler = watch[key]
    if (Array.isArray(handler)) {
      for (let i = 0; i < handler.length; i++) {
        createWatcher(vm, key, handler[i])
      }
    } else {
      createWatcher(vm, key, handler)
    }
  }
}

まだ immediate の記述はナシ createWatcher を追います

src/core/instance/state.js
function createWatcher (
  vm: Component,
  expOrFn: string | Function,
  handler: any,
  options?: Object
) {
  if (isPlainObject(handler)) {
    options = handler
    handler = handler.handler
  }
  if (typeof handler === 'string') {
    handler = vm[handler]
  }
  return vm.$watch(expOrFn, handler, options)
}

vm.$watch を実行しているようなので、これも _init のように prototype に仕掛けている箇所を探してみます

vm.$watchはどこで仕掛けているのか?

index.js で実行している stateMixin で仕掛けているようでした

src/core/instance/state.js
export function stateMixin (Vue: Class<Component>) {
  /**
  * 中略
  */
  Vue.prototype.$watch = function (
    expOrFn: string | Function,
    cb: any,
    options?: Object
  ): Function {
    const vm: Component = this
    if (isPlainObject(cb)) {
      return createWatcher(vm, expOrFn, cb, options)
    }
    options = options || {}
    options.user = true
    const watcher = new Watcher(vm, expOrFn, cb, options)
    if (options.immediate) {
      try {
        cb.call(vm, watcher.value)
      } catch (error) {
        handleError(error, vm, `callback for immediate watcher "${watcher.expression}"`)
      }
    }
    return function unwatchFn () {
      watcher.teardown()
    }
  }
}

ここで watch のオプション immediate がtruthyであるとき cb.call(vm, watcher.value) を実行しているのが分かります。長かった・・・

よって、コードベースでも見ても immediate: truewatch の実行タイミングは beforeCreatecreated の間 であることが分かりました

おまけ

コードリーティングしてたら $watch が何やら unwatchFn なるfunctionを返しているのを発見しました。
名前のとおりですが、 $watch の戻り値をコールすると監視が解除されます。

https://codepen.io/sin-tanaka/pen/oNgNmGz

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

vue.jsとfirebaseを使って、パスワードマネージャを作った話

今回は、いつものGASと違って、firebaseを利用してパスワードマネージャのアプリケーションを作成しました。

きっかけ

きっかけは、自分が利用しているサービスのパスワードが同じパスワードで管理しているのに、こののままやったら危ないなー。と思ったのがきっかけです。

1passwordやトレンドマイクロ等がパスワードマネージャのアプリを提供しているのですが、せっかくなら、firebaseの勉強を兼ねて作ってみようと思いました。
完成している画面はこのようになっています。

白枠で塗りつぶしている部分は、アカウントIDを表示しています。

スクリーンショット 2019-12-03 16.20.12.png

イメージフロー

下記のようなイメージで今回は作成を行いました。

スクリーンショット 2019-12-03 16.23.41.png

①:firebaseのgoogle認証機能を利用してログイン処理を行う。

②:ログインしていきたアカウントがfirebaseのユーザデータベースに登録しているアカウントか?の確認を行う。

③:登録されていないアカウントの場合、ログイン画面にエラーメッセージを表示する。

④:登録されているアカウントの場合、パスワードデータからアクセストークンが一致するコレクションからデータを抽出し、一覧で表示する。

⑤:登録しているデータは編集削除が可能であり、また新規で登録も可能。

⑥:ログアウトを行うと、ログイン画面に移動する。

実際のデータ保持や、画面

ここからは、上記のイメージフローの実際の画面やデータベースの中身を説明したいと思います。

ログイン画面

ログイン画面はとてもシンプルになっています。「sign with google」のボタンをタップすると、googleアカウント選択ポップアップ画面が表示されます。
スクリーンショット 2019-12-03 16.33.04.png

firebaseのgoogle認証の使い方は以下の通りになります。(公式サイトに詳細に記載されていますので、詳しく知りたい方は、公式サイトを参考にすると良いと思います。)

//google認証を行うために必要
const provider = new firebase.auth.GoogleAuthProvider();

//google認証のポップアップ表示からのアカウント選択後の処理
firebase.auth().signInWithPopup(provider).then((result)=> {
    //[result]にgoogleの情報が入っている
    //ex. result.user.email => googleアカウント

    //認証に成功した場合の処理...

}).catch((error) => {      
    //認証に失敗した場合の処理...
});

認証後、firebaseのデータベースに作成したユーザデータに登録しているアカウントかの確認を行います。

もし、登録されていない場合は下記のようなメッセージが表示されてログイン画面から動かない状態です。

スクリーンショット 2019-12-03 16.54.51.png

スクリーンショット 2019-12-03 16.52.06.png

firebaseのデータベースの一覧データの取得方法は以下の通りとなります。

//firebaseconfigはfirebaseの設定、Firebase SDK snippetから取得してください。
const firebaseConfig = {
    apiKey: "XXXXXXXXXXXXXXXXXXXXXXXXXX",
    authDomain: "XXXXXXXXXXXXXXXXXXXXXXXXXX.firebaseapp.com",
    databaseURL: "https://XXXXXXXXXXXXXXXXXXXXXXXXXX.firebaseio.com",
    projectId: "XXXXXXXXXXXXXXXXXXXXXXXXXX",
    storageBucket: "XXXXXXXXXXXXXXXXXXXXXXXXXX.appspot.com",
    messagingSenderId: "XXXXXXXXXXXXXXXXXXXXXXXXXX",
    appId: "x:XXXXXXXXXXXXXXXXXXXXXXXXXX:xxx:XXXXXXXXXXXXXXXXXXXXXXXXXX",
    measurementId: "x-XXXXXXXXXXXXXXXXXXXXXXXXXX"
};

//firebaseのデータベースを使用する
const firebase_app = firebase.initializeApp(firebaseConfig);
const db = firebase.firestore(firebase_app);

//アクセスしたいコレクションを指定する。
const docRef = db.collection("colection");

//データベースにアクセスし、データ取得に成功した場合の処理
docRef.get().then((querySnapshot) => {
    querySnapshot.forEach((doc)=> {

        //[data].オブジェクトキーで保存しているデータの取得できます。
        let data = doc.data();

    })
    .catch((error) => {
        //データ取得時にエラーが発生した場合の処理

    })
});

データ一覧画面

登録しているアカウントからアクセスした場合、データ一覧の画面に移動します。
移動した際に、データをパスワードを保持しているデータにアクセスを行い、アクセストークンが同じコレクションの中に保持しているデータを一覧で表示します。

nosqlでこういうデータの持ち方が良いのかわからないのですが、今回はこのようにデータを保持しています。

スクリーンショット 2019-12-03 17.22.58.png

一覧画面は下記のようになります。(最初に紹介した画像と同じになります。)

スクリーンショット 2019-12-03 16.20.12.png

詳細を表示したいアカウントをタップすると詳細内容を表示します。

スクリーンショット 2019-12-03 17.29.27.png

ピンクのボタンをタップするとコピーを行うことができます。

主にパスワードをコピーして使用することが多いです。

スクリーンショット 2019-12-03 17.31.20.png

編集タブをオンにすると登録している内容の編集を行うことが可能です。

登録しているデータの更新の方法は以下の通りになります。

//更新したいドキュメントまでアクセスし、[set]で更新することができる。
db.collection("xxxxxx").doc(this.user.uid).collection("xxxxxx").doc(this.detail_data.doc_id).set({

    //キーと内容を全て記載する。更新したい内容だけ記載した場合、記載してない内容は消えるので注意です。
    /*
      キー : 値 
     ,キー : 値
     ,キー : 値 ...
     */

}).then(()=> {
    //データが更新した場合の処理
})
.catch((error)=> {
    //データ更新に失敗した場合の処理

});

データ登録

ヘッダーにある「+」ボタンからパスワードの新規登録が可能です。

「+」の左にあるボタンが一覧画面に移動、右側にあるボタンがログアウトボタンになります。

スクリーンショット 2019-12-03 17.40.31.png

入力する内容は入力欄に記載している通りとなります。

「icon」は今回は、fontawesomeのフリーアイコンを表示できるようにしています。

ただ、アイコンを使用するには、使用したい、classを検索して貼り付ける必要があるため少し面倒です。ここは改良する必要があるかなと考えています。

パスワードは「CREATEPASS」をタップすることで自動で生成してくれるようになっています。
パスワードの長さも大文字を含むのか、数字を含むのかも設定可能です。

スクリーンショット 2019-12-03 17.44.43.png

登録からデータを登録することができます。
ランダム生成の一意キーで登録する方法は以下の通りです。

//登録したい、コレクションまでアクセスを行い、[add]でランダムキーで登録することができます。
db.collection("xxxxxx").doc(this.user.uid).collection("xxxxxx").add({
    /*
      キー : 値 
     ,キー : 値
     ,キー : 値 ...
     */
})
.then((docRef)=> {
    //データを登録完了した場合の処理

})
.catch((error) => {
    //データ更新に失敗した場合の処理
});

個人的な課題

今回、パスワードをデータベースに保存するので、「crypto-js」を利用して暗号化を行おうと試みました。

しかし、実際に暗号化を行ってデータを新規登録を行おうとすると、ランダム生成されるキーがなぜか、毎回おんなじキーになってしまい、上手く利用することができずに課題になっているため、これは改善する必要があると考えています。

PWA化

GASのアプリケーションでは不可能だった、PWA化を今回実装を行いました。

こちらのサイトを参考にさせて頂き、実装を行いました。

最後に

意説明は以上となります。

firebaseのサービスは本当に便利だと感じ、もっとfirebaseFunctionsとかを学びたいと思いました。

まずは、このアプリケーションの課題を克服したいと思います。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

ゼロからVue.jsでビジュアルリグレッションテストするまでpart1/3

Part1 ここ
Part2 https://qiita.com/senku/items/20e21033edd512be1d4d
Part3 https://qiita.com/senku/items/08d547eda2c6ff818108

Vue.jsでビジュアルリグレッションテストをするためにいろいろやったナレッジを公開しまーす。

やりたいこと

  • Vue.jsのコンポーネントのビジュアルリグレッションテスト(画像回帰テスト)をしたい。
  • viewsレベルのテストもしたい。(Vuex/Vue Routerが絡む)
  • 多言語対応もしたい。(Vue I18nが絡む)
  • テストはなるべくローカルで完結したい。サーバ立てたりしたくない。

Summary

Storybook(ビジュアルの元ネタ) + storycap(画像生成) + reg-suit(画像比較&レポート) でやる。
Storyを作るために悪戦苦闘する

  • Vue.js開発環境の構築
  • StorybookでコンポーネントのStoryをつくろう
    • Vue I18nと戦う
    • Vue Routerはかんたん(ここまで)
    • Vuexはモックする
  • Storycapで画像を生成
  • reg-suitで比較&レポート

Vue.jsのセットアップ

ゼロからやりますと言ったものの、みんな流石にNode.jsは持ってるよね。

Vue CLI いれる

公式のインストールに従います。

$ npm install -g @vue/cli
()
+ @vue/cli@4.1.1
$ vue --version
@vue/cli 4.1.1

Vue CLI 4だ!

プロジェクトをつくる

公式のプロジェクトの作成に従う。はろーわーるど!

$ vue create hello-world

とりあえずデフォルトでいきましょう。babelとESLintだけ。

? Please pick a preset: (Use arrow keys)
❯ default (babel, eslint)

言われるがままにcdしてnpm run serveします。

$ cd hello-world
$ npm run serve

http://localhost:8080 ではろーわーるどできましたね。

Storybook導入

Storybookセットアップ

Storybook for Vueは見なかったことにして、Vue CLI plugin for Storybookの手順で入れます。Vue CLI Pluginとしてセットアップしないとvue.config.jsを参照してくれないので後で詰みます(1敗)。Vue CLIに逆らってはならぬ。

$ vue add storybook

Initial Frameworkには無言でEnterを押します。

インストール後、言われるがままにnpm run storybook:serveします。

$ npm run storybook:serve

自動でブラウザが起動して、http://localhost:6006/でStorybookが表示されますね。

かなり後で使うので、ブラウザが起動しないコマンドもここで作っておきましょう。--ciオプションが使えます。
オプションにはStorybookのCLI Optionがそのまま渡せるので覚えておくといつか役に立つかもしれません。

diff --git a/package.json b/package.json
index dfba14f..533efd0 100644
--- a/package.json
+++ b/package.json
@@ -7,7 +7,8 @@
     "build": "vue-cli-service build",
     "lint": "vue-cli-service lint",
     "storybook:build": "vue-cli-service storybook:build -c config/storybook",
-    "storybook:serve": "vue-cli-service storybook:serve -p 6006 -c config/storybook"
+    "storybook:serve": "vue-cli-service storybook:serve -p 6006 -c config/storybook",
+    "storybook:ci": "vue-cli-service storybook:serve -p 6006 -c config/storybook --ci"
   },
   "dependencies": {
     "core-js": "^3.4.3",

HelloWorldコンポーネントのStoryを書いてみる

Storyの書き方はいろいろありますが、とりあえずこんな感じで作ってみます。

src/stories/HelloWorld.stories.js
import { storiesOf } from "@storybook/vue";
import HelloWorld from "@/components/HelloWorld.vue";

storiesOf("HelloWorld", module)
  .add("test", () => {
  return {
    components: { HelloWorld },
    template: `
    <hello-world msg="from storybook" />
    `
  };
});

Storybookに表示されますね。msgで渡したfrom storybookもちゃんと表示されています。

image.png

ここから先しばらくは、Storybookと他のプラグインとの兼ね合いの話をします。

Vue I18nとのたたかい

Storybook で Vue-I18nを使う場合はちょっと細工が必要です。

まずは vue-i18nをセットアップします。公式のInstallation参照。
Vue CLI 3.x向けの手順ですがたぶん問題ありません。

$ vue add i18n

とりあえず全部デフォルトで。基本のロケールがenになります。

? The locale of project localization. en
? The fallback locale of project localization. en
? The directory where store localization messages of project. It's stored under `src` directory. locales
? Enable locale messages in Single file components ? No

src/main.jsからsrc/i18n.jsがロードされるようになりました。
localeのファイルがsrc/locales/en.jsonにできています。

src/locales/en.json
{
  "message": "hello i18n !!"
}

ちょっとsrc/components/HelloWorld.vueから読み込ませてみます。

src/components/HelloWorld.vue
diff --git a/src/components/HelloWorld.vue b/src/components/HelloWorld.vue
index 879051a..e77721a 100644
--- a/src/components/HelloWorld.vue
+++ b/src/components/HelloWorld.vue
@@ -1,6 +1,7 @@
 <template>
   <div class="hello">
     <h1>{{ msg }}</h1>
+    <p>{{ $t('message') }}</p>
     <p>
       For a guide and recipes on how to configure / customize this project,<br>
       check out the

npm run serve して http://localhost:8080/ を見てみます。
hello i18n !! が表示されてますね。

image.png

多言語の対応はここでは置いておいて、とりあえずenで話を進めます。

Vue I18n × Storybook

StorybookでHelloWorldのStoryを見てみましょう。npm run storybook:serveで再ロードさせます。

残念なことにエラーが出ます。Vue I18nがセットするプロパティの$tがないからですね。

image.png

Storybookの起動は src/main.js を経由していないため、別途コンポーネントにVueI18nを紐付けてやる必要があります。

やり方はいろいろあるんですが、とりあえずデコレータで解決しています。Vue.useはいらない。

src/stories/defaultDecorator.js
import i18n from "@/i18n";

export default function defaultDecorator() {
  return {
    template: "<div><story /></div>",
    i18n
  };
}
src/stories/HelloWorld.stories.js
diff --git a/src/stories/HelloWorld.stories.js b/src/stories/HelloWorld.stories.js
index 93b4b7e..d8e8605 100644
--- a/src/stories/HelloWorld.stories.js
+++ b/src/stories/HelloWorld.stories.js
@@ -1,7 +1,9 @@
 import { storiesOf } from "@storybook/vue";
+import defaultDecorator from "@/stories/defaultDecorator";
 import HelloWorld from "@/components/HelloWorld.vue";

 storiesOf("HelloWorld", module)
+  .addDecorator(defaultDecorator)
   .add("test", () => {
   return {
     components: { HelloWorld },

エラーが解消して、hello i18n !!が表示されます。

image.png

vue-i18n-loaderとのたたかい

Vue I18nには、SFCの中に定義を書けるvue-i18n-loaderがあります。これを使う場合は更にひと手間が必要です。

とりあえずvue invoke i18nで、プラグインの設定をvue-i18n-loaderが使えるように更新します。

$ vue invoke i18n
? The locale of project localization. en
? The fallback locale of project localization. en
? The directory where store localization messages of project. It's stored under `src` directory. locales
? Enable locale messages in Single file components ? Yes // ここだけy!

vue-i18n-loaderが追加され、vue.config.jsenableInSFCがtrueになり、src/components/HelloI18n.vueが作成されます。

App.vueから読めるようにしましょう。

src/App.vue
diff --git a/src/App.vue b/src/App.vue
index fcc5662..0660f5b 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -1,16 +1,19 @@
 <template>
   <div id="app">
     <img alt="Vue logo" src="./assets/logo.png">
+    <HelloI18n/>
     <HelloWorld msg="Welcome to Your Vue.js App"/>
   </div>
 </template>

 <script>
+import HelloI18n from './components/HelloI18n.vue'
 import HelloWorld from './components/HelloWorld.vue'

 export default {
   name: 'app',
   components: {
+    HelloI18n,
     HelloWorld
   }
 }

npm run serveを再実行すると、Hello i18n in SFC!が表示されます。(画像はテキスト選択しちゃった)

image.png

vue-i18n-loader × Storybook

src/components/HelloI18n.vueのStoryを作ります。

src/stories/HelloI18n.stories.js
import { storiesOf } from "@storybook/vue";
import defaultDecorator from "@/stories/defaultDecorator";
import HelloI18n from "@/components/HelloI18n.vue";

storiesOf("HelloI18n", module)
  .addDecorator(defaultDecorator)
  .add("test", () => {
  return {
    components: { HelloI18n },
    template: `
    <hello-i18n />
    `
  };
});

この時点ではStorybookは正しく動作しません。keyであるhelloがそのまま表示されてしまいます。

image.png

Storybookとvue-i18n-loaderの間には複雑な問題があります。

いろいろと見守ってはいるんですが、decoratorでthis.$root._i18nをセットするのが現状可能な解決策の一つです。(via. dlucian/vuejs-storybook-i18n #1)ただし強引な解決策なので、いつ動かなくなるかわかんないです。

src/stories/defaultDecorator.js
diff --git a/src/stories/defaultDecorator.js b/src/stories/defaultDecorator.js
index 4e5acdf..c50bf20 100644
--- a/src/stories/defaultDecorator.js
+++ b/src/stories/defaultDecorator.js
@@ -3,6 +3,9 @@ import i18n from "@/i18n";
 export default function defaultDecorator() {
   return {
     template: "<div><story /></div>",
-    i18n
+    i18n,
+    beforeCreate: function() {
+      this.$root._i18n = this.$i18n;
+    }
   };
 }

Hello i18n in SFC!が表示されるようになりました。

image.png

Vue Routerとのたたかい

Routerを入れ忘れていたので入れます。

$ vue add router

Use history mode for router? (Requires proper server setup for index fallback in production) には無言でEnter(Yes)を押していきます。

Vue Routerをインストールすると、src/App.vueからsrc/views/About.vuesrc/views/Home.vueへのリンクが生成されます。というかファイルごと上書きされます。さようならHelloI18n。

src/App.vueのStoryを書いてみます。

src/stories/App.stories.js
import { storiesOf } from "@storybook/vue";
import defaultDecorator from "@/stories/defaultDecorator";
import App from "@/App.vue";

storiesOf("App", module)
  .addDecorator(defaultDecorator)
  .add("test", () => {
  return {
    components: { App },
    template: `
    <app />
    `
  };
});

StorybookでこのStoryを表示するとエラーが起こるので、decoratorで解決します。
Storybookでページ遷移させる予定はないので、ルーティング情報は空にしておきます。すまんな。

src/stories/defaultDecorator.js
diff --git a/src/stories/defaultDecorator.js b/src/stories/defaultDecorator.js
index c50bf20..746aef7 100644
--- a/src/stories/defaultDecorator.js
+++ b/src/stories/defaultDecorator.js
@@ -1,8 +1,10 @@
+import Router from 'vue-router'
 import i18n from "@/i18n";

 export default function defaultDecorator() {
   return {
     template: "<div><story /></div>",
+    router: new Router({}),
     i18n,
     beforeCreate: function() {
       this.$root._i18n = this.$i18n;

config/storybook/config.jsVue.useしておく必要もあります。

config/storybook/config.js
diff --git a/config/storybook/config.js b/config/storybook/config.js
index fe20bab..d8dcd31 100644
--- a/config/storybook/config.js
+++ b/config/storybook/config.js
@@ -1,5 +1,9 @@
 /* eslint-disable import/no-extraneous-dependencies */
 import { configure } from '@storybook/vue'
+import Vue from 'vue'
+import Router from 'vue-router'
+
+Vue.use(Router)

 const req = require.context('../../src/stories', true, /.stories.js$/)

ここまでやれば、Vue Routerを使うコンポーネントのStoryが表示できるようになります。
リンクを押しても何も起こりません。

image.png

つづく

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Vue.jsでDBデータ一覧表示アニメーション

掲題の件をぐぐると表示するデータが決まっている一覧のアニメーションは出てくるのに
表示するデータが変わるアニメーションの例が出てこないのでメモ。

それなりにきれいに動く。
実際はchangeDataメソッドみたいなテストメソッドじゃなくて、DBデータをフェッチしてitemsに入れるように実装する。

参考:https://jp.vuejs.org/v2/guide/transitions.html#リストトランジション

可変一覧データ表示アニメーション例

ListAnimation.vue
<template>
    <transition-group :tag="tag" name="list-complete">
        <slot></slot>
    </transition-group>
</template>

<script>
export default {
    props:{
        tag: {
            type: String,
            default: 'ul',
        }
    }
}
</script>

<style lang="scss" scoped>
::v-deep {
// 直下のループItemだけアニメーション
    & > * {
        transition: all 0.5s;
    }
// 新しいデータをスライドイン
    .list-complete-enter {
        opacity: 0;
        transform: translateX(-30px);
    }
// 既存データは見えなくするだけ。
// absolute指定必須。
// absolute指定しないと既存データがその場所に残る。
    .list-complete-leave-active {
        opacity: 0;
        position: absolute;
        transition: none;
    }
}
</style>
  • 使い方
<template>
    <div>
        <button type="button" class="btn btn-primary" @click="changeData()"></button>
        <table class="table table-sm table-valign-middle">
            <thead>
                <tr>
                    <th>ID</th>
                    <th>名前</th>
                    <th>メールアドレス</th>
                </tr>
            </thead>
            <list-animation tag="tbody">
                <tr v-for="item in items" :key="item.id">
                    <td>{{ item.id }}</td>
                    <td>{{ item.name }}</td>
                    <td>{{ item.email }}</td>
                </tr>
            </list-animation>
        </table>
    </div>
</template>
<script>
import ListAnimation from '@/components/animations/ListAnimation.vue';
export default {
    data() {
        return {
            items: [],
            isChange: false,
        };
    },
    components: {
        ListAnimation,
    },
    methods: {
        changeData() {
            this.items = this.isChange ? [
                {id:1, name: 'test1', email: 'test1@test.com'},
                {id:2, name: 'test2', email: 'test2@test.com'},
                {id:3, name: 'test3', email: 'test3@test.com'},
                {id:4, name: 'test4', email: 'test4@test.com'},
            ] : [
                {id:5, name: 'test5', email: 'test5@test.com'},
                {id:6, name: 'test6', email: 'test6@test.com'},
                {id:7, name: 'test7', email: 'test7@test.com'},
                {id:8, name: 'test8', email: 'test8@test.com'},
            ];

            this.isChange = !this.isChange;
        },
    },
}
</script>

追記

JSFiddleとかで試すなら、trantision-groupの部分を以下のように変えないと動かない。
tableタグ内で使用できるタグの制限に先に引っかかってる様子。

<tbody name="list-complete" is="transition-group">
・・・
</tbody>

デモ

See the Pen GRggvoV by J-Yamada (@J-Yamada) on CodePen.

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Vue.js 備忘録1 (概念編)

はじめに

最近、Vueの案件をやっているのですが、如何せんudemyで勉強していたのが6~7月ぐらいだったので、基礎からスッポリ抜けてる!!

流石にまずいので、復習がてら、備忘録として何回かに分けてVueの基礎的な部分を記そうと思います。

Vueの特徴

・リアクティブプログラミング
データが更新されるごとに、再度画面を表示
・コンポーネントシステム
コンポーネントを使う。再利用可能
・テンプレート
htmlをベースとしたテンプレート構文が用意されている。

vue cliでプロジェクト作る

vue create ファイル名

Vueプロジェクトマネージャー

Vue cliが提供しているGUI。
プロジェクトを作ったり、すでにあるプロジェクトを読み込んだり出来る。
servebulidといったコマンドもプロジェクトマネージャー内で可能。

以下のコマンドで開く
vue ui

開発スタイル

フルスクラッチ

vue cliなどのツールを使わずに、全て自分で書く方法。
全て自分で書いているので、全体像を把握しやすい。
「便利なツールが自動的にやっといてくれる!」的な現象が起きないので、Vueのコンポーネントの仕組みなどを理解するまではこちら推奨。

プロジェクト

様々なフレームワークやライブラリを導入するようになるとプロジェクトの方が断然楽になる。
なので実際に開発するとなると圧倒的にこちらで行うことが多い!バージョンアップなども楽。ただファイル数が多いので最初は全体像が掴み難い。

参考文献

「Vue.js&Nuxt.js超入門」
著:掌田津耶乃

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Vue.js 備忘録1 (概念編)

背景

最近、Vueの案件をやってます!
以前はudemyで勉強してドラゴンスレイヤーっていう簡単なブラウザーゲーを作ってました。

しかし、それも7~8月に独学で勉強しただけなので、
いざ、案件に取りかかろうとしたら、基礎からスッポリ抜けている。
これは流石にまずいので、復習として、備忘録として何回かに分けてVueの基礎的な部分を記そうと思いました。

Vueの特徴

・リアクティブプログラミング
データが更新されるごとに、再度画面を表示
・コンポーネントシステム
コンポーネントを使う。再利用可能
・テンプレート
htmlをベースとしたテンプレート構文が用意されている。

vue cliでプロジェクト作る

vue create ファイル名

Vueプロジェクトマネージャー

Vue cliが提供しているGUI。
プロジェクトを作ったり、すでにあるプロジェクトを読み込んだり出来る。
servebulidといったコマンドもプロジェクトマネージャー内で可能。

以下のコマンドで開く
vue ui

開発スタイル

フルスクラッチ

vue cliなどのツールを使わずに、全て自分で書く方法。
全て自分で書いているので、全体像を把握しやすい。
「便利なツールが自動的にやっといてくれる!」的な現象が起きないので、Vueのコンポーネントの仕組みなどを理解するまではこちら推奨。

プロジェクト

様々なフレームワークやライブラリを導入するようになるとプロジェクトの方が断然楽になる。
なので実際に開発するとなると圧倒的にこちらで行うことが多い!バージョンアップなども楽。ただファイル数が多いので最初は全体像が掴み難い。

参考文献

「Vue.js&Nuxt.js超入門」
著:掌田津耶乃

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Vue.jsでYouTubeプレイヤーを埋め込む方法

はじめに

Vue.jsでYouTubeを埋め込んだので、その方法を簡単にまとめていきます。(めちゃくちゃ簡単にできます!)

vue-youtubeというすぐれものが存在し、開発初心者の私でも容易に実装できました!!
参考リンク

YouTubeの埋め込みのみの記事ですので、他の部分は記載していません。ご了承ください。

1.vue-youtubeをインストール

まずはvue-youtubeをインストールします。

npm install vue-youtube

2.YouTubeを埋め込む

もう最終ステップです!!
あとはyoutubeタグを入れるだけ!めっちゃ簡単じゃないですか!?

<template>内にはこちらを。

<youtube :video-id="videoId" />

<script>内にはこちらを。
videoIdにはYouTubeIDを入れてください。URLのv=以降の11個のところです!

export default {
  data() {
    return {
      videoId: 'fHuO3Xaje98'
    }
  }
}

最終的な全体コード

なんの装飾もない一番質素な状態ですが完成です!!

App.vue
<template>
  <div>
    <youtube :video-id="videoId" />
  </div>
</template>

<script>
import Vue from 'vue'
import VueYoutube from 'vue-youtube'

Vue.use(VueYoutube)

export default {
  data() {
    return {
      videoId: 'fHuO3Xaje98'
    }
  }
}
</script>

おまけ

youtubeタグ内にひと手間加えることで、プレイヤーのステータスをトリガーにイベントを発火することが出来るようです。

公式によると...

Events
The component triggers events to notify the parent component of changes in the player. For more information, see YouTube IFrame Player API.
スクリーンショット 2019-12-02 17.41.59.png

なんとこんなにあるんですね!!

せっかくなので再生した時にconsole.logに「再生中です」と出してみましょう〜

youtubeタグ内にはplayingを、<script>内にはmethodsを追加します。

App.vue
<template>
  <div>
    <youtube :video-id="videoId" @playing="playing" />
  </div>
</template>

<script>
import Vue from 'vue'
import VueYoutube from 'vue-youtube'

Vue.use(VueYoutube)

export default {
  data() {
    return {
      videoId: 'fHuO3Xaje98'
    }
  },
  methods: {
    playing() {
      console.log('再生中です')
    }
  }
}
</script>

動画を再生すると...
スクリーンショット 2019-12-03 12.18.20.png

やったー!大成功です!!:v_tone1:

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

GitHub Actions で Laravel + Vue 環境を自動デプロイ

GitHub Actions が正式リリースされましたね。
これで外部サービスを使用しなくても GitHub 単体で CI/CD などのワークフローを自動化できるようになりました。
image.png
https://github.com/features/actions

弊社は社内の GitLab でソースコード管理+ CI/CD していますが、ちょうど担当していた案件の納品先が GitHub ということもあり、Laravel + Vue アプリケーションをビルドし本番サーバにデプロイするところまでを実装してみました。

ゴール

特定のブランチに push すると自動で本番環境にデプロイする

流れ

ワークフローとして実行するタスクはこんな感じです。

  • 特定のブランチ(master)へのプッシュ(or プルリク) をトリガーにワークフローを起動
  • GitHub 上で docker コンテナを起動
    • 以下コンテナ上で
    • git clone (ソースコード取得)
    • composer install (PHP環境構築)
    • npm install (node環境構築)
    • npm run prod (フロントエンドビルド)
  • rsync でコンテナ上で構築したファイルを本番サーバへ転送

秘匿情報をいかにセキュアに管理するか

今回の案件ではフロントエンド(Vue.js)=>バックエンド(Laravel)間の API 通信の認証に Laravel Passport を使用していました。Laravel Passport では認証用のトークンをフロントエンドのコードに含めてビルドする必要があるため、トークンをコンテナ内に持ち込む必要がありますが、他にも DB のパスワードや、AWS のトークンなど、またビルドした成果物を rsync + ssh で本番サーバに同期する際の秘密鍵、などなど、知られてはいけない情報が多々あり、当然これら セキュアな情報を記載したコードをリポジトリにコミット出来ない問題 に直面します。

秘匿情報は Secrets に保存

これを解決するために、GitHub のリポジトリ設定画面には Secrets というメニューが追加されており、セキュアな情報に名前を付けて保存できます。これは GitHub Actions だけに使用する目的で追加された機能で、

Secrets are environment variables that are encrypted and only exposed to selected actions. Secrets are not passed to workflows that are triggered by a pull request from a fork of this repository.

とある通り、情報は暗号化され、選択されたアクションにのみ使用されれます。リポジトリのフォークで継承されることもなく、また、他のコントリビューターからも閲覧されることはありません。(そもそも登録した本人でさえ一度登録した内容は閲覧できません)

Secrets は、GitHub のリポジトリメニューの「Settings」からサイドメニューの「Secrets」より遷移し、「Add a new secret」をクリックして、Name と Value を登録します。

image.png

今回はここに、Laravel Passport の認証用トークン OAUTH_CLIENT_SECRET と rsync で使用する SSH の秘密鍵 SSH_PRIVATE_KEY を登録しました。これらの Name はワークフローの定義時に変数として使用することができるので後述の定義ファイル自体はセキュアな記述となります。

        MIX_OAUTH_CLIENT_SECRET: ${{ secrets.OAUTH_CLIENT_SECRET }}

ワークフローの作成

それではワークフローを作っていきましょう。リポジトリのタブメニューにある Actions をクリックすると予め用意されたワークフローが表示されます。これから作成するワークフローに近いものがあればそれをテンプレートとして使用しても良いですが、今回は使用しません。実は画面から作成しなくても、定義ファイルを直接リポジトリに追加しプッシュすればワークフローとして認識します。

定義ファイルは下記のパスになります。

.github/workflows/main.yml

拡張子からも分かる通り、GitHub Actions ではワークフローを YAML 形式で定義します。

name: 本番サーバへのデプロイ

on:
  push:
    branches:
      - master

jobs:

  deploy:

    runs-on: ubuntu-latest

    steps:
    - uses: actions/checkout@v1
    - name: バックエンドの準備
      run: composer install
    - name: フロントエンドのビルド
      run: npm install && npm run prod
      env:
        MIX_OAUTH_CLIENT_SECRET: ${{ secrets.OAUTH_CLIENT_SECRET }}
        APP_ENV: "production"
        APP_DEBUG: "false"
        APP_URL: "https://xxxxxx.test"
    - name: 秘密鍵のコピー
      run: echo "$SSH_PRIVATE_KEY" > id_rsa && chmod 600 id_rsa
      env:
        SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
    - name: ファイルをサーバに同期
      run: rsync -rlOtcv --delete --exclude-from=.rsyncignore -e "ssh -i id_rsa -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no" ./ deploy@xxxxxxx.test:/var/www/vhost/xxxxxxxx/

このファイルをpushするだけで指定したブランチに更新があった際にワークフローが開始されます。

ファイルの解説

簡単に定義ファイルの中を見ていきましょう。

name: (ワークフローの名称)

name: 本番サーバへのデプロイ

このワークフローの名前を定義します。GitHub のサイト上から見えるためわかりやすいものにしましょう。日本語も可能です。

on: (アクション起動条件)

on:
  push:
    branches:
      - master

トリガーとなる条件を記述します。ここでは master ブランチに push があった場合の条件を指定しています。

runs-on: (コンテナの指定)

    runs-on: ubuntu-latest

使用するコンテナを指定します。windows や macos も指定できるのでネイティブアプリのビルドなども可能。

steps: (アクションの定義)

    steps:

ここからワークフローをステップごとに定義します。

uses: (既存のアクションの使用)

    - uses: actions/checkout@v1

GitHub Actions は既に用意されたアクションや、GitHub Marketplace に公開されている自作アクションを部分的に使用することができます。ここでは actions/checkout と言うアクションを使用し、リポジトリから最新のソースを clone しています。

バックエンドの準備

    - name: バックエンドの準備
      run: composer install

このステップでは run アクションで composer install コマンドを実行しています。

フロントエンドのビルド

    - name: フロントエンドのビルド
      run: npm install && npm run prod
      env:
        MIX_OAUTH_CLIENT_SECRET: ${{ secrets.OAUTH_CLIENT_SECRET }}
        APP_ENV: "production"
        APP_DEBUG: "false"

ここではフロントエンドのビルドを実行しています。run アクションで npm installnpm run prod を実行し、Vue.js のソースをトランスパイルしています。
また、前述のトークンや環境ごとの依存情報などは、本来 .env に記述しますが、リポジトリには追加したくないため環境変数経由で Secrets の値を渡しています。

秘密鍵のコピー

Secrets に保存した SSH プライベートキーを環境変数経由でファイルに書き出しています。パーミッションも忘れずに。

    - name: 秘密鍵のコピー
      run: echo "$SSH_PRIVATE_KEY" > id_rsa && chmod 600 id_rsa
      env:
        SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}

ファイルをサーバに同期

あとは成果物をサーバに rsync+ssh でコピーするだけです。転送量を節約するため .rsyncignore に不要なファイルを定義し、いくつかのフォルダを除外しています。

    - name: ファイルをサーバに同期
      run: rsync -rlOtcv --delete --exclude-from=.rsyncignore -e "ssh -i id_rsa -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no" ./ deploy@xxxxxxx.test:/var/www/vhost/xxxxxxxx/
.rsyncignore
.env
.git
.github
node_modules/
storage/

いざ実行

ワークフローの実行は on で指定したタイミングでの自動起動なので、master ブランチに git push するだけです。
GitHub の Actions タブをクリックすると実行中のタスクをリアルタイムにチェックできます。
github-actions.gif

まとめ

社内の GitLab では、CI/CD をフル活用しておりユニットテストやLintチェックなどを常に自動化していますが、自社サーバに構築しているので環境を自前で構築する必要があったりスペックに悩まされてきましたが、GitHub Actions ではいとも簡単にできてしまいました。Marketplace での Action も充実してくると思うので今後さらに期待ができますね。非常に楽しみです。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Nuxt.jsでVue.jsのSSRのWebアプリケーションの制作についてまとめ

はじめに

この記事はLinkbal Advent Calendar 2019の3日目の記事です。
今年は弊社のサービスのリニュアルでVue.js初心者の僕はNuxt.jsでプロジェクト最初からリリースまで携わりました。
今回はその経験と学んだことについて紹介したいと思います。

TL;DR

この記事は僕の経験からいくつかメモや学んだことを共通したいものです。
一番伝えたいのはこの注意点です。

項目

事前に知っておくべきこと

Nuxt.jsはVue.jsに基づいたフレームワークなので、もちろん事前にVue.js公式ライブラリ(vue, vuexやvue-router)の基礎知識が必要です。その以外に、Vueと同様、Nuxt.jsもライブサイクルがありますので、
以下のNuxtのサーバーサイドレンダリング(SSR)ライブサイクルを知っておいたほうがいいと思います。

nuxt-lifecycle.png

参考: https://medium.com/@onlykiosk/the-complete-nuxt-guide-940751e1a6a5

  • nuxtServerInit このアクションはVuexのストアに定義されたら、リクエストが来たら、ルートを問わずにNuxt.jsはそれを呼び出します。これはユーザー認証やサーバーサイドから共通のデータの取得などのために、使われます。

僕の場合はnuxtServerInitにリクエストのUserAgentでデバイスの判定、ユーザー認証や全ページの共通のデータ取得の処理を入れました。

注意: Nuxtの公式のドキュメントにも書いてありますが、 このnuxtServerInitアクションはPromiseを返すかasync/awaitを使う必要です。

Note: Asynchronous nuxtServerInit actions must return a Promise or leverage async/await to allow the nuxt server to wait on them.

  • middleware: 上記の図に書いてあるNuxt.jsのデフォルトのmiddlewareの以外に、カスタマイズのmiddlewareを作り、使うことができます。
  • validate: ページのパラメーターやクエリーのバリデーション
  • asyncDatafetchはページレンダリングする(コンポーネントを初期化する)前に、非同期の処理でデータを取得するためのメソッドです。

簡単設定でカスタマイズ、拡張しやすい

Nuxt.jsのメインの設定ファイルnuxt.config.jsで簡単に色々な設定を変更、追加できます。例えば: ページのhead(metaやtitleなど)、環境変数やwebpackなど

Router

Nuxt.jspagesディレクトリにコンポーネントを定義したら、自動的にルーティングを生成してくれるってNuxt.jsの一つ特徴ですが、
実際のアプリケーションを作る時に、リダイレクトのルートや一つページ(コンポーネント)に対する複数ルートなど場合があるでしょうか?
その時に、Routerはカスタマイズが必要になりますね。

Nuxt.jsvue-routerを使っているので、vue-routerのフォーマットと以下のような簡単設定でカスタマイズできます。

また、全体ページに適用したいmiddlewarenuxt.config.jsにも定義できます。

// router/customRoutes.js
const CUSTOM_ROUTES = [
  // 既存のページの別ルート
  {
   name: 'custom-event-page',
   path: '/custom/',
   component: '@/pages/event.vue'
  },
  // リダイレクトのルート
  {
   path: '/source_page/:id?',
   redirect: {
    path: '/destination_page/:id?',
    query: { hoge: 'fuga'}
   }
  }
]
// nuxt.config.js
const customRoutes = require('./router/customRoutes')

module.exports = {
 router: {
    middleware: 'commonMiddleware', // middleware/commonMiddleware.js
    extendRoutes (routes) {
      customRoutes.forEach(route => {
        routes.push(route)
      })
    }
  },
}

Plugins

Nuxt.jsプラグインはVue.jsアプリケーションがインスタンス化される前に実装されます。
外部のパッケージやVueプラグインをNuxt.jsアプリケーションで使用したい場合、プラグインを使う必要です。

SSRをサポートしないVueプラグインがありますので、その時にクライアント側のみ使うようにする必要です。

// nuxt.config.js
  plugins: [
    { src: '~/plugins/no-ssr-plugin', ssr: false },
  ],

そして、Vueインスタンスに注入したい場合、プラグインを使う必要です。
僕の場合は、ページのasyncDataでエラーの時に、エラーのハンドリングと非同期の処理でデータを取得するため使いました。

// plugins/error-handler.js
export default ({ store }, inject) => {
 inject('error', async (errorCode) => {
    await errorHandler(store, errorCode)
    // エラーコードによりエラーのページにリダイレクトする
    error({ statusCode: errorCode })
  })
}
// pages/component.vue
asyncData ({ app, store, params }) {
   // エラーの場合
   await app.$error(errorCode)
}

Modules

Nuxt.jsのコア機能を簡単に拡張し、インテグレーションを加えるNuxt.jsの拡張機能です。
この拡張機能のおかげで、Nuxtコミュニティが広くなって、たくさん便利な機能がシェアされています。

次はいくつか便利なモジュールを紹介したいと思います。

便利なモジュール

Nuxtコミュニティでたくさん便利なモジュールを作ってくれて、シェアされています。
その中に使っていた2つNuxt.jsチームの公式モジュールを紹介したいと思います。

@nuxt/axios

簡単にAxiosNuxt.jsとを統合するモジュールです。

こちらはNuxt.jsで簡単にAxiosが使えるだけではなく、プラグインでaxiosinterceptorsを登録できます。

// plugins/axios

export default function ({ $axios, res, store }) {
  // リクエストの前処理
  $axios.onRequest(config => {
    config.withCredentials = true
    return config
  })

  // エラーをハンドリングする
  $axios.onError(error => {
    const code = parseInt(error.response && error.response.status)
    if (code === 500) {
      // handle error
    }
    if (process.env.NODE_ENV !== 'production') console.error(error)
  })

  // 返ってくる時の処理
  $axios.onResponse(response => {
    // SSRの時に、responseのヘッダーをチェックして、クライアント側に返すヘッダーをセットする
    if (response.headers && response.headers['xxxx'] && process.server) {
      res.setHeader('yyyyy', response.headers['yyyy'])
    }
  })
}

@nuxtjs/google-tag-manager

Nuxt.jsアプリケーションでGoogle Tag Manager(GTM)やGoogle Analytics(GA)が使えるようにするモジュールです。

Vue.jsのSSRのWebアプリケーションですが、初回のアクセスだけサーバーサイドレンダリングされて、その以降通常のVue.jsのアプリケーションになるので、GTMやGAタグのスクリプトをここままに使えないです。

このモジュールを使って、GTMやGA側で以下の設定を適切に変更すれば、問題なく使えます。

GTMやGA側の設定は詳しくないけど、主な変更点はイベントトリガーだと思います。
このモジュールのデフォルトのページビューイベントはnuxtRouteです。

注意点

この記事で一番伝えたいことはNuxt.jsの使用の時の注意点です。以下の2つあります。

Vuexストアのstateの値はfunctionでなければならない

Vue.jsアプリケーションでVuexストアのstate定義はObjectで普通じゃないでしょうか?Vuexの公式exampleもそうですし。

ただし、Nuxt.jsのSSRでそうするとで不要な共有状態が発生する可能性があります。この問題が発生すれば、サーバーサイド側から取ってくるデータを対象外(ユーザー)に送ってしまう可能性があります。

詳しくはこのNuxt.jsgithubのissueを見てください。

Regardless of the mode, your state value should always be a function to avoid unwanted shared state on the server side.

↑この注意点はNuxt.js公式ページにも記載してあります。

参考: https://nuxtjs.org/guide/vuex-store/

  • ストア定義の例

BAD

// store/state.js
export default {
  state_hoge: null,
  state_fuga: null
}
// store/index.js
import Vuex from 'vuex'
import state from './state'
import getters from './getters'
import mutations from './mutations'
import * as actions from './actions'

const createStore = () => {
  return new Vuex.Store({
    state,
    getters,
    mutations,
    actions
  })
}

export default createStore

GOOD

// store/state.js
export default () => ({
  state_hoge: null,
  state_fuga: null
})
// store/index.js
import Vuex from 'vuex'
import createState from './state'
import getters from './getters'
import mutations from './mutations'
import * as actions from './actions'

const createStore = () => {
  return new Vuex.Store({
    state: createState(),
    getters,
    mutations,
    actions
  })
}

export default createStore

asyncDataのデータが再利用されてしまう

詳しくはこのNuxt.jsgithubのissueを見て下さい。

簡単説明なら、以下のようです。

eventページのasyncData関数がこんな感じです。

asyncData ({ $axios }) {
   const res = await $axios.$get('/hoge_api_path')
   const data = { common: res.data.common }
   if (res.data.special_flg) {
     data.isSpecial = true
     data.specialData = res.data.special_data
   }
}
  1. まず、 events/123にアクセスしたら、asyncDataの返り値は以下の通りです。
{
  common: "hogehoge",
  isSpecial: true,
  specialData: "hogedata"
}
  1. 次、events/456にアクセスしたら、asyncDataの返値は以下の通りです。
{
  common: "fugafuga"
}

Nuxt.jsasyncDataのデータとVueコンポーネントに定義しているデータにマージして、Vueコンポーネントのデータになる仕組みで、再利用されてしまうので、events/456ページのデータは以下のようになってしまいます。

{
  common: "fugafuga",
  isSpecial: true,
  specialData: "hogedata"
}

ですから、asyncDataの返り値はifで分けず、絶対に同じフォーマットみたいでなければならない。

GOOD

asyncData ({ $axios }) {
   const res = await $axios.$get('/hoge_api_path')
   return {
     common: res.data.common,
     isSpecial: res.data.is_special,
     specialData: res.data.is_special ? res.data.special_data : null
   }
}

終わり

今回は自分のメモみたいでNuxt.jsでVue.jsのSSRのWebアプリケーションの制作について学んだことや得たことを紹介しました。
ご拝読いただきありがとうございます。

次回はNuxt.js/Vue.jsのWebアプリケーションのパフォーマンスの最適化について書く予定です。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

きたるべきvue-nextのコアを理解する

この記事は、Vue #2 Advent Calendar 2019の4日目。
@nagimaruxxxさんのフロントエンド開発をjQueryからVue.jsへ乗り換えたので比較してみるの次の記事です。

この記事でわかること

jQueryからVue.jsにはじめて移行したとき、「thisのなんちゃらを書き換えると動くんだー!」とか「computedって、依存する値が更新されたら自動で更新されてすごい!」というのが感想でした。

今回は、vue-nextという、いわば、「次世代のvue」でそういった「自動で更新されてすごい」がどのように実装されているか、を解説します。

普段Vue.jsを使っている人は、その裏の仕組みに感銘を受けるでしょう。日々の実装を少しだけ、いつもと違う視点で見れるようになると思います。
普段React.jsを使っている人はきっと、Vue.jsを使いたくなることでしょう。
皆さんもぜひリアクティブシステムの世界を少し味わってみてください。

目次

背景などを書いていたらかなり長くなってしまったため、「hooksとかvue-nextの話は聞いたことがある!俺は最低限の内容だけ読みたいんだ!」という方は「vue-nextのリアクティブシステム」まで飛ばすことをおすすめします。

そもそもvue-nextとは?

Vue.jsは2019年12月3日現在、v2.6.10がリリースされています。おそらく、世の中のVue.jsプログラムのほとんどは2系でしょう。
その一方、2018年の秋あたりから、Vue.jsはv3.xへのプラン1を予告していました。
各種カンファレンスだけでなく、公式のブログ2でも言及されており、「徐々に2系でも使えるようにしていく」と公言しています。

実際、vue-nextのリアクティブシステムのいくつかはvue-composition-apiを通じてvue 2系でも利用することができます。
リリースの時期は決まっていませんが、2019年の終わりから2020年の初めにかけてであろうと予想されています。(もしかしたら、このアドベントカレンダーの最中にリリースされるかもしれないですね...!)

背景

vue-nextに至るまでの動向をわかる範囲で書きます。
vue-nextが実装されるまでの経緯なので、ざっと流してもらっても構いません。

React hooksの登場

2018/10/25,26のReact Conf 2018で紹介された「React hooks」は、仮想DOM以来のフロントエンドでの大きな変化であるとみなされ、大きな注目を浴びました。3

React.jsフロントエンドの課題であった「型付けの困難さ」「ロジックの再利用の困難さ」を大幅に緩和し、フロントエンドの開発の様相を一変させたと言ってもいいでしょう。

RFCとしての登場

React hooksはそのままではVue.jsには移植できないものの、コンセプトや対処できる課題としてはVue.jsフロントエンドの課題と共通するものがあります。
とはいえ、そんなにすぐには対応なんてできません。
React界隈がhooksに湧いている中、Vue.jsではコアに特に大きな変更はなかったので、「あいつらいいなあ」と思ったのを覚えています。

状況が変わりだしたのは2019年の3月末です。
元々、v3.xでは今までの書き方を整備、発展させた「Class API」を提供する予定でした。
しかし、「型づけ大変だし、React hooksみたいなAPIがあればそんなの要らんくない?」ということでそのRFCは破棄されます。4今思うとなかなかすごい変更です。
Vue.jsでReact hooksに相当するRFCがここで登場します。5

実装の登場

2019年の4月時点では、vueのReact hooksに相当するものはRFCとしてはあるものの誰も使えないみたいな状況でした。
しかし、6月9日に「HooksライクなAPIのRFCを(ちゃんと)出したよ」というツイートがなされ、その上でVue.js 2系でも使える実装であるvue-function-apiが登場します。

実装が登場したことで一気にコミュニティーは活発になりました。6

そして、2019年の8月にVue.jsの本家に管理が移行7し、名前も「vue-composition-api」に変わり、現在に至ります。
現在、コミュニティーでVue.js 3ライクな書き方をしたい場合は、このvue-composition-apiを使うことになると思います。qiitaにも既にたくさんの記事がありますね。

vue-nextのアナウンス

さて、部分的に使えるようになり、あとは本体のバージョンアップを待つだけという状態になって、10/5に以下のツイートがなされます。

最初のアナウンスから1年の時を経てようやく、次世代のVue.jsを垣間見ることができるようになったわけです。

Vue.jsのcomposition-apiはReact hooksとどう違うのか?

「Vue版のHooks」とも言われる「vue-composition-api」(そしてvue-nextのシステム)ですが、先発にあたるReact hooksの長所を生かしつつ、短所を低減しています。

ここら辺はいろんな記事が書いているところでもあるので簡単に書きます。

React hooksはなぜ歓迎されたのか

Functional Componentの表現力向上

React hooksが登場するまで、React.jsのFunctional Componentはpropsしか受け取ることができませんでした。つまり、状態を持つことができなかったわけです。
Hooksが登場したことにより、今まではクラスベースのコンポーネントでしかできなかったこともFunctional Componentでできるようになりました。

型との相性

システムが大規模になればなるほど、型による静的な検査は効いてくるようになります。
関数の型をつけるのはクラス内でのそれよりも比較的簡単であるため、ライブラリの型定義などが改善され、より良い型づけがなされたプログラムを簡単に書けるようになりました。

ロジックの再利用性の向上

今まではライフサイクルごとの処理を再利用したい場合、React.jsの場合は基本的にHOC(Higher Order Component)しか手段がありませんでしたが、カスタムフックを作成することによりそれらを簡単に切り出すことができるようになりました。

React hooksの問題点

依存性を明示しなければいけない場合がある

useEffectがこの例にあたります。
公式にもありますが、useEffectはコンポーネントが更新されるたびに走るため、やや「走り過ぎ」なところがあります。8
それを間引き、useEffect内で参照した変数が更新されたタイミングでのみuseEffectを実行するためには、React hooksでは依存する物を明示的に示さなければいけません。これはややトリッキーです。

書ける場所の制限

当然といえば当然なのですが、ReactのHooksを使うことができ、その恩恵にあずかることができるのはReactのFunctional Component内のみです。

if,for,ネストした関数の中で使えない

Hooksを使う上で一番問題になるのはこのケースでしょう。フロントエンドにかかわらずこうした制御構造は大量に出てくるのにもかかわらず、Hooksはこれらの中では基本的には使うべきではないとされています。(繊細な注意を払えば使えなくはないですが、一歩間違えると変数の内容が「ズレる」という憂き目に遭います。)

これはReact hooksが、「呼ばれた順番でhookと値を紐づけている」という実装をしていることに基づく問題と考えられます。なかなか根が深いです。

vue-composition-apiの特徴

vue-composition-api並びにvue-nextでは、React hooksの長所を生かしつつ、上にあるような短所が現れないような実装になっています。

以下のコードを読んでみてください。(vue-composition-api公式のサンプルコードです。importは適宜省略しています。)

const state = reactive({
  count: 0
})

watch(() => {
  document.body.innerHTML = `count is ${state.count}`
})

state.countが更新されたらinnerHTMLを書き換える、という非常に単純なサンプルです。

このreactiveというところで、Vue.jsは「リアクティブな値」を作成しています。
そして、watchに関数を渡すことで、state.countが更新されたら実行されるという動作を宣言しています。

まず注意したいのは、このコードは別にコンポーネント内でなくても動くっちゃ動くというところです。コンポーネント内で宣言をしなければいけなかったReact hooksとは違い、リアクティブな値を生で扱うことができます。if文なども当然問題ありません。

また、特に依存性をwatchで宣言しなくても、「使ったものだけ監視する」という挙動をします。
これにより、開発者はよりロジックの実装に集中することができます。

もちろん、React hooksの長所である「ロジックの再利用」なども同じように実現できます。(vue-composition-apiの公式ではマウスの場所を追跡するというのを例にサンプルコードを解説を載せています。)

vue-composition-apiをReact hooksを異なるものにしているのは「リアクティブな値を生で扱うことができること」でしょう。

これにより、vue-nextでは非常にシンプルにフロントエンドを説明することができるようになります。
ドキュメントにもあるとおり、vueのレンダリングは究極的には「リアクティブな値をwatchしているだけ」と言えるようになるからです。9

(コードは例によって公式から引用しています。)

const state = reactive({
  count: 0
})

function increment() {
  state.count++
}

const renderContext = {
  state,
  increment
}

watch(() => {
  // hypothetical internal code, NOT actual API
  renderTemplate(
    `<button @click="increment">{{ state.count }}</button>`,
    renderContext
  )
})

vue-nextを読み解くには

vue-nextのリポジトリ自体はGithubにあるわけですが、見ればわかるとおりどこにも説明などのdocsがないので、そこらへんは自力で知る必要があります。

まず、この記事でもたびたび引用していたVue Composition API RFCのページを一通り眺め、使い方を知っておくとよいでしょう。というのも、表面的な振る舞いはここから大きく変わらないとされているからです。

事前知識を仕入れたところで、次に見るのはvue-nextのContributing Guideです。ここでは基本的な開発コマンドの説明やコントリビューションの規則に加えて、プロジェクト構成が書かれているからです。

今回の場合はリアクティブシステムを読むわけなので、「packages/reactivity」フォルダを見ればいいことがわかります。

最後に見るべきは「テストコード」です。テストコードには、ライブラリの作者が想定した使いかたについて、満足すべき挙動がずらっと書いてあります。使い方のドキュメントがない現状では、おそらくこのテストコードが一番のドキュメントです。(マイナーOSSあるある)

また、vue-nextのコードは全てTypeScriptで書かれているため、型情報も読み解く上で有用なヒントになります。

あとは関数の呼び出しを遡って行って、時に自分で実際にテストケースを書いて動かしたりしていきましょう。それではようやく本題です。

vue-nextのリアクティブシステム

そもそも「リアクティブ」とは?

これから「リアクティブな値」の実装をみていくわけですが、その前に「リアクティブ」って何?とならないように改めて述べておきます。

例えば以下のコードがあったとしましょう。

let a = 5
let b = a * 10
a = 7
console.log(b) // => 50

これを実行すると50と出力されます。2行目で「bにaの10倍を代入する」としたものの、aに7を代入したことでその関連性が崩れています。bはもはやaの10倍ではなくなってしまいました。
これを直してやるにはもう一回代入してやるしかありません。

b = a * 10
console.log(b) // => 70

呆れるくらい当たり前ですね。

今回の例は、非常に簡単なものでしたので、一文だけ付け足すことで「bはaの10倍なんだよ」とすることができます。
ですが、例えばbにあたるものが100個、200個あったらどうでしょう。

let a = 5
let b_000 = a * 21
let b_001 = Math.ceil(a / 2.71828)
// ...
let b_173 = Math.sin(a)
// ...
// コードのどこかのタイミングでaを更新する
a = 8

とても更新が大変そうですね。いくつか忘れてしまいそうです。
もちろん、関数としてこれらの再代入を記述し、毎回書くというアプローチをとることができます。

a = 8
makeSideEffectA(a)

ですが、変数ごとにこんなことをしていたら全然捗りませんし、何よりめんどくさいですよね。

ここでリアクティブシステムを入れてみましょう。

a = ref(5)
const b_000 = computed(() => a.value * 21)
const b_001 = computed(() => Math.ceil(a.value / 2.71828))
// ..
a.value = 10
console.log(b_000.value) // => 210

特に再代入などをしなくても、bの方に値が反映されました。
このように、一旦関係性、例えば「bはaの10倍だよ」とcomputedで定めておけば、そのあとはaを書き換えればbも自動で更新されます。
こうすれば、「bは代入したときはaの10倍だったけど、この行だと分からんなー」みたいなことがなくなり、「いや、いつでも10倍です」とわかるので、考えることが減りますね。代入忘れもおきません。

このように、ある変数を書き換えた時に、事前に定めた関係性を元に、他の変数が適切に更新されたり、事前に定めた動作が発動することを「リアクティブである」と言います。10

リアクティブシステムの登場人物たち

vue-next及びvue-composition-apiでは様々な関数が利用可能ですが、基幹である関数はかなり少数です。
依存性を辿っていくと、本質的には以下の2つしかないことがわかります。

  • reactive
    • 普通の値をリアクティブな値に変換する
  • effect
    • 値が変更された時の動作を記述する

他の利用可能な関数はほとんどこれをちょっと変更した程度で実装されているので、この2つをメインに追いつつ、重要な関数や変数を追っていきます。

普通の値からリアクティブな値を作り出す「refreactive

vue-next(vue-composition-api)ではリアクティブな値を作るための手段としてcomputed, readonly, ref, reactiveの4つがありますが、computedはリアクティブな値だけというよりは、リアクティブな値同士を組み合わせて作るという側面が強く、readonlyはほとんど実装がreactiveと同じため、実質的には後者の2つがメインです。
では順にみていきます。

プリミティブな値をリアクティブにする「ref

実装箇所:https://github.com/vuejs/vue-next/blob/dec444ef04e4a3aca72b965f497ae4c2f73df09c/packages/reactivity/src/ref.ts#L33

ここでの「プリミティブ」とは、「オブジェクト(typeofした時に'object'かつnullでない)でない」くらいの意味です。もしオブジェクトの場合、リアクティブな値を作る際に内部的に自動でreactiveが用いられます。
メインのコードとしては以下のような感じです。(適宜コメントを付与しています。)

export function ref(raw?: unknown) {
  // ... 略 ...
  const r = {
    _isRef: true,
    get value() {
      track(r, OperationTypes.GET, 'value')
      return raw
    },
    set value(newVal) {
      raw = convert(newVal)
      trigger(
        r,
        OperationTypes.SET,
        'value',
        __DEV__ ? { newValue: newVal } : void 0
      )
    }
  }
  return r
}

「プロパティーにgetとかsetがある?」となった人はJavaScriptのGetter/Setterについて調べておくとよいでしょう。
get value()(ゲッター)では、オブジェクトのvalueプロパティーを参照した時に実行される関数を記述しており、set value(newVal)(セッター)では逆に代入を行った時に実行される関数を記述しています。

先ほどの『そもそも「リアクティブ」とは?』でも述べたように、リアクティブな値は代入した時に他の変数を更新して欲しいので、triggerのところがそれをやっているのではないかという想像がつきます。
では、Getter内にあるtrackは何をしているかというと、例えばcomputed内でtrack関数が呼ばれた時に、「このcomputedはこの値に依存しているぞ!」という記録を行っています。具体的に中がどうなっているかはまた後で述べます。

プリミティブではない値をリアクティブにする「reactive

実装箇所:https://github.com/vuejs/vue-next/blob/dec444ef04e4a3aca72b965f497ae4c2f73df09c/packages/reactivity/src/reactive.ts#L52

export function reactive(target: object) {
  // if trying to observe a readonly proxy, return the readonly version.
  // 既にリアクティブなオブジェクトの一覧に登録されている場合、それを返す
  if (readonlyToRaw.has(target)) {
    return target
  }
  // target is explicitly marked as readonly by user
  if (readonlyValues.has(target)) {
    return readonly(target)
  }
  return createReactiveObject(
    // 元々の値
    target,
    // 元々の値から対応するリアクティブな値を引っ張り出せるWeakMap
    rawToReactive,
    // 上の逆の写像ができるWeakMap
    reactiveToRaw,
    // Proxyで使うハンドラー
    mutableHandlers,
    // 元々の値がSet,Mapなどの「コレクション」だった時に使うProxyのハンドラー
    mutableCollectionHandlers
  )
}

リアクティブな値にする値だけでなく、「値とリアクティブな値の対応が入ったWeakMap」を渡しているのは、同じオブジェクトから異なるリアクティブな値を作り出さないためと考えられます。
「あらゆるリアクティブな値への参照が入った変数」は一見メモリ的にやばそうですが、WeakMapなので、このページにあるとおり、格納しているオブジェクトを参照する手段が消滅した時に自動でガベージコレクションされるため大丈夫というわけです。

createReactiveObjectの中身は以下のようになります。

function createReactiveObject(
  target: unknown,
  toProxy: WeakMap<any, any>,
  toRaw: WeakMap<any, any>,
  baseHandlers: ProxyHandler<any>,
  collectionHandlers: ProxyHandler<any>
) {
  // ... 略 ...
  const handlers = collectionTypes.has(target.constructor)
    ? collectionHandlers
    : baseHandlers
  // リアクティブな値の作成
  observed = new Proxy(target, handlers)
  // ... 略 ...
  // 依存する変数、関数を記録する領域の確保
  if (!targetMap.has(target)) {
    targetMap.set(target, new Map())
  }
  return observed
}

ほとんどの行は「対応するリアクティブな値があるか」「もとの値とリアクティブな値をちゃんと紐づける」で消費されていますが、2つだけ違うものがあります。

まず一つは、Proxyによるリアクティブな値の作成です。見ての通り、vue-nextのリアクティビティーの核はProxyによるものです。MDNのProxyの解説を見るとわかりますが、Proxyはとても広範な操作に介入することができます。
これを用いて、各プロパティーの変更などを検出しています。
その際も、refの時と同じように、取得の時にはtrack。設定の時はtriggerが呼び出されます。

特に重要なのは、「array[1] = 5」や「object['存在しなかったキー'] = value」のような操作も検出できるようになったことです。
これにより、VueやReact初心者が詰まりがちな「代入でキーを追加する」といった動作でもリアクティビティーが保たれるようになります。11

もう一つは「依存している変数や関数の記憶領域」の作成です。
後でまたでてくると思いますが、vue-nextのリアクティブな値たちは、自分が使われた関数を記憶しています。そのための記憶領域です。

関係性と動作を宣言する「effect」

実装箇所:https://github.com/vuejs/vue-next/blob/dec444ef04e4a3aca72b965f497ae4c2f73df09c/packages/reactivity/src/effect.ts#L44

effectは関数を受け取り、関数内で参照したリアクティブな値が更新された時に関数を再度実行します。
実際のテストコードを見るとこんな感じです。

it('should be reactive', () => {
  const a = ref(1)
  let dummy
  effect(() => {
    dummy = a.value
  })
  expect(dummy).toBe(1)
  a.value = 2
  expect(dummy).toBe(2)
})

なかなか面白いことをしますね。effect内で参照したaを更新すると関数ももう一回実行され、dummyも更新されます。watchcomputedの裏側にはこんな奴が控えています。

少々長いのですが、まずはいったん関連する処理をピックアップします。

export function effect<T = any>(
  fn: () => T,
  options: ReactiveEffectOptions = EMPTY_OBJ
): ReactiveEffect<T> {
  // ... 略 ...
  const effect = createReactiveEffect(fn, options)
  // ... 略 ...
  return effect
}

// ... 中略 ...

function createReactiveEffect<T = any>(
  fn: () => T,
  options: ReactiveEffectOptions
): ReactiveEffect<T> {
  const effect = function reactiveEffect(...args: unknown[]): unknown {
    return run(effect, fn, args)
  } as ReactiveEffect
  // ... 略 ...
  return effect
}

function run(effect: ReactiveEffect, fn: Function, args: unknown[]): unknown {
  // ... 略 ...
  if (!effectStack.includes(effect)) {
    // ... 略 ...
    try {
      effectStack.push(effect)
      // 関数を実際に実行している箇所
      return fn(...args)
    } finally {
      effectStack.pop()
    }
  }
}

初見だと理解が難しい箇所です。ですが、creativeReactiveEffectrunに注目すると多少はマシになります。

creativeReactiveEffectでは、関数をラップしてReactiveEffectなる関数を作り出しています。
runではこれをeffectStackというスタック(配列)の一番上に積んで、渡された関数を実行し、またpopしています。

はっきりいってスタックを積んでから関数を実行だけしてまたpopしているだけで、これがどう「リアクティブ」につながるのかはここからだと全くわかりません。
強いていうなら、「関数が実行されている間は、対応するReactiveEffectが配列の一番上にいる」くらいな物です。

この意味を把握するためには、先ほどスルーした「track」と「trigger」に分け入る必要があります。

関係性と動作を記録する「track」、再生する「trigger

先ほど「trackでは『このcomputedはこの値に依存しているぞ!』という記録を行っている。」「triggerでは事前の関数や変数を元に値を再計算している」ということを述べましたが、それの詳細を書いていきます。

関係性を再構築する「trigger

実装箇所:https://github.com/vuejs/vue-next/blob/dec444ef04e4a3aca72b965f497ae4c2f73df09c/packages/reactivity/src/effect.ts#L148

export function trigger(
  target: object,
  type: OperationTypes,
  key?: unknown,
  extraInfo?: DebuggerEventExtraInfo
) {
  const depsMap = targetMap.get(target)
  // ... 略 ...
  const effects = new Set<ReactiveEffect>()
  const computedRunners = new Set<ReactiveEffect>()
  // ... 略 ...
  if (type === OperationTypes.CLEAR) {
    // collection being cleared, trigger all effects for target
    depsMap.forEach(dep => {
      addRunners(effects, computedRunners, dep)
    })
  } else {
    // schedule runs for SET | ADD | DELETE
    if (key !== void 0) {
      addRunners(effects, computedRunners, depsMap.get(key))
    }
    // ... 略 ...
  }
  const run = (effect: ReactiveEffect) => {
    scheduleRun(effect, target, type, key, extraInfo)
  }
  // Important: computed effects must be run first so that computed getters
  // can be invalidated before any normal effects that depend on them are run.
  computedRunners.forEach(run)
  effects.forEach(run)
}

typekeyごとの条件分岐を全部載せると冗長なので、一部だけ載せています。
このaddRunnersという部分で「こういう操作をされたんだけど、どんな関数を実行すべきかな?」というのを構築します。
例えば、コレクション(SetやMapの場合)がクリアされた場合(type === OperationTypes.CLEARの部分です)、それが使われた変数を全て再計算しないといけないため、関連する関数を全部拾い出しています。
depsMapには、「targetが計算に使われた関数」の情報が入っているので、それをいったん変数にまとめて、最後に一気に実行しています。

記憶を司る「track

export function track(target: object, type: OperationTypes, key?: unknown) {
  // ... 略 ...
  const effect = effectStack[effectStack.length - 1]
  // ... 略 ...
  let depsMap = targetMap.get(target)
  // ... 略 ...
  let dep = depsMap.get(key!)
  // ... 略 ...
  if (!dep.has(effect)) {
    dep.add(effect)
    effect.deps.push(dep)
    // ... 略 ...
  }
}

targetMapは「ある値が依存している関数や値の一覧」と先ほど書きました。
ここでは、そんなtargetMapになぜか「effectStackというスタックの一番上」を追加しています。これは一体どういうことでしょう。

ここで、先ほどの「関数が実行されている間は、対応するReactiveEffectがeffectStackの一番上にいる」という性質が生きてきます。

順を追うとこうなります。

  1. effectが関数fnを受け取る.fnの中ではリアクティブな値が参照されており、実行するとtrackが呼び出される。
  2. effectStackの一番上に「fnをラップしたReactiveEffect」が積まれる
  3. fnを実行する。
  4. fnの中ではリアクティブな値が参照されており、実行するとtrackが呼び出される。
  5. この間effectStackの一番上は「fnをラップしたReactiveEffect」なので、trackにより、targetMapに「fnをラップしたReactiveEffect」が入る。
  6. 実行が終わるとeffectStackはpopされる

そう、effectStackの正体はその名の示す通り、effectだけが関与する。vue-nextが保有するコールスタックだったのです!

effectの中でさらにcomputedを介してeffectが呼ばれた場合、そのスタックは積み上がっていきますし、これにより「リアクティブな値が、『自分が参照された時、今どんな関数が実行されているか』を知ることができる」ということです。

React hooksではこれを単純に呼ばれた順番で管理しているため、if文などによる「ズレ」が発生しますが、vue-nextではコールスタックを自前で持つことでこの問題を回避しています。

まとめ

  • vue-nextのリアクティブな性質はProxyを核に実装されている
  • リアクティブな値は「自分の値が使われた関数のリスト」を記憶している
  • vue-nextは自分でコールスタックを持つことで、上の記録をすることを可能にしている

vue-nextのリアクティブシステムの陥穽

vue-nextのリアクティブシステムは、React hooksの課題をいくつか解消しましたが、完璧ではありません。その最たる例として「非同期処理」を挙げます。

非同期処理

コールスタックを作っているところのコードを再掲します。

function run(effect: ReactiveEffect, fn: Function, args: unknown[]): unknown {
  // ... 略 ...
  if (!effectStack.includes(effect)) {
    // ... 略 ...
    try {
      effectStack.push(effect)
      // 関数を実際に実行している箇所
      return fn(...args)
    } finally {
      effectStack.pop()
    }
  }
}

冷静に考えるとこれはおかしくないですか?
例えば、複数のfnが同時に走ったり、なんてことがあったら「関数が実行されている間は、対応するReactiveEffectがeffectStackの一番上にいる」なんて前提はたやすく崩壊してしまいますよね?

しかしながら、普通JSは同期実行の場合、一時に関数を一つしか実行していない。という前提が成り立つため、これで何とかなります。例えば、Node.jsやブラウザの場合、イベントループでこうなっていることがわかります。12

一見安心なのですが、fnが非同期処理になった途端に、例えばasync functionになった瞬間におかしなことが起こります。

fnasync functionだった場合、fn(...args)の行は一瞬で実行され、Promiseが返ってしまうため13、その中身のtrackなどはとんちんかんなタイミングで実行されてしまうからです。

まあ幸いなことに、他の同期的なeffectが実行された場合、そちらがeffectStackの一番上に積まれるので、「参照した奴が更新されてるのに再計算されない!」は起こらないと考えられます。多少無駄な計算が走るくらいでしょう。

ただ、当の非同期処理がどのようになるかは細心の注意を払う必要があります。少なくともasync functionにするのはやめるべきですし、promise.thenの内部でリアクティブな値を使って計算するのはやめた方がいいでしょう。いづれの場合もリアクティブにはならないと考えられます。

現状一番マシな手段は、「返り値のPromiseの状態が更新されたら変化するリアクティブな値」を作ることです。14

最後に

長々と書いてしまいました。いかがでしたでしょうか。
vue-nextは「リアクティブな値を生で扱える」という非常に優れた性質を持っています。
関数型プログラミングなどとの相性もよく、これからの発展が非常に楽しみな技術ですね。
Vue 3系がリリースされてフロントエンドのデファクトになる日も遠くないかもしれません。
それでは、よきVue.jsライフを。

そして、友人であり、React.jsプログラマーでありながら確認・指摘をしてくださった@hikarinotomadoiにこの場を借りて感謝を申し上げます。


  1. 昨年のアドベントカレンダーの時点でかなり議論が進んでいるのがわかります。 

  2. ただし、この公式ブログの予告内容は日付を見てもわかるように少し古いところがあります。 

  3. 「Concurrent React」もこの時期に提案されているのですが、こちらの実装はHooksと比べるとかなりゆっくりと行われた印象です。当時の様子はこちら。 

  4. 「React hooksにインスパイアされてるけれど、うちらのやり方でやるよ」とも明記されています。https://github.com/vuejs/rfcs/pull/17#issuecomment-494242121 

  5. 議論を追っていくと、今のvue-nextやvue-composition-apiに相当するものがどう決まっていったのかが伺えてなかなか面白いです。 

  6. もちろんいろんな混乱もありました。「今までのコードは全部書き直し!?」をはじめとするデマも発生したりしています。https://dev.to/danielelkington/vue-s-darkest-day-3fgh 

  7. https://github.com/liximomo/vue-function-api/issues/14 

  8. https://ja.reactjs.org/docs/hooks-reference.html#conditionally-firing-an-effect 

  9. もちろん実際には中でより複雑に更新判定などを行ってます。あくまでも仮想的なコードです。 

  10. 完全に余談ですが、関数型プログラミングに慣れ親しんだ人ならこの書き方で「副作用」を連想するかもしれません。vue-nextの「リアクティブな値」は、「代入による副作用」を扱うことができ、実際にモナド則を満たす系を構成することができます。Vueのrefの命名がhaskellのSTRefモナドにちなんでいると考えるのは...流石に考えすぎでしょうか。 

  11. ProxyはIE11では使えないので、何らかの代替手段を提供はするようです。ただしパフォーマンス、機能ともに落ちるとのこと。これを機に脱IEが進むといいですね。同じような戦略をとっているMobxも似たことを言っているのでそうなりそうです。 

  12. https://developer.mozilla.org/ja/docs/Web/JavaScript/EventLoop 

  13. ここを勘違いしている人はそこそこいるようです。例えば全ての行を実行するのに3秒かかるasync functionがあったとしても、async functionの返り値であるPromiseが生成されるのは一瞬です。3秒かかるのはその返り値のPromiseが解決されるのにかかる時間です。awaitで実行が止まっているように見えるのは、このPromiseが解決するのを待っているからです。 

  14. しかしそういう値は今度はawaitで待てたりしないなど、Promiseの持つ性質を持たないという問題を抱えます。これを解決するコードは頻出かつ単純なので、これに特化したnpmパッケージを出す予定です。 

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Vue.jsで店舗の予約状況を表示してみた

この記事はVue Advent Calendar 2019の2日目の記事です。

とあるお店の予約システムを作った時の話で、管理画面側で予約状況をカレンダー風に表示したいという話があり、Vue.jsで作りました。

実際よりはだいぶ簡単にしてますが、完成イメージはこんな感じです。

img1.png

予約状況は、こんな感じのjsonで渡されるのでこれを上手くカレンダー風に表示できるようにします。

[
  {
    "id": 1,
    "name": "予約A",
    "startTime": "11:00",
    "endTime": "11:40"
  },
  {
    "id": 2,
    "name": "予約B",
    "startTime": "12:00",
    "endTime": "13:30"
  },
  {
    "id": 3,
    "name": "予約C",
    "startTime": "15:10",
    "endTime": "17:40"
  }      
]

ベースを作る

とりあえず、時間と線だけが表示されているベースを作っていきます。
実際の案件では店によって時間が違うという仕様でしたが、このデモでは10時から19時までという設定で作っていきます。

img2.png

index.html
<div id="app">
  <div class="main">
    <div class="cal">
      <div class="cal_row-label">
        <p class="cal_label" v-for="n in 10">{{ n + 9 }}</p>
      </div>
      <div class="cal_row-timeline">
        <div class="cal_block" v-for="n in 10"></div>
      </div>      
    </div>
  </div>
</div>
app.scss
* {
  box-sizing: border-box;
}

.main {
  position: relative;
  max-width: 300px;
  padding-right: 20px;
  background-color: #eee;
}

.cal {
  display: flex;
  padding-top: 20px;

  &_row-label {
    flex: 0 0 50px;
  }

  &_row-timeline {
    width: 100%;
  }

  &_label {
    height: 90px;
    transform: translateY(-12px);
    margin: 0;
    text-align: center; 
  }

  &_block {
    width: 100%;
    height: 90px;
    border-top: 1px solid #aaa;
  }
}
app.js
new Vue({
  el: '#app'
})

あとで計算で使いますが、このUIでは1時間の高さが90pxになっています。

予約データを表示

v-for で予約データを表示してみます。

index.html
<div class="reserve">
  <div
    v-for="reservation in reservations"
    class="reserve_item"
    :key="reservation.id">
      {{ reservation.name }}<br>
      {{ reservation.startTime }} 〜 {{ reservation.endTime }}
  </div>
</div>
app.scss
.reserve {
  position: absolute;
  top: 0;
  left: 60px;
  width: calc(100% - 90px);

  &_item {
    border: 1px solid #ddd;
    background-color: #fff;
    border-radius: 5px;
    padding: 10px;
  }
}

実際はAPIで取得してたのですが、このデモではVueインスタンスの data の中に入れてます。

app.js
new Vue({
  el: '#app',
  data: {
    reservations: [
      {
        "id": 1,
        "name": "予約A",
        "startTime": "11:00",
        "endTime": "11:40"
      },
      {
        "id": 2,
        "name": "予約B",
        "startTime": "12:00",
        "endTime": "13:30"
        },
      {
        "id": 3,
        "name": "予約C",
        "startTime": "15:10",
        "endTime": "17:40"
      }
    ]
  }
})

img3.png

データの表示ができました!
が、普通に縦に積まれただけなので位置や長さを計算していきます!

縦の位置の計算

予約の縦の位置がうまく開始時間の位置に配置されるように計算します。

index.html
<div
  v-for="reservation in reservations"
  class="reserve_item"
  :style="getPosition(reservation)"
  :key="reservation.id">
    {{ reservation.name }}<br>
    {{ reservation.startTime }} 〜 {{ reservation.endTime }}
</div>

getPosition() という位置を計算する関数を作って :style でバインディングします。

app.js
new Vue({
  el: '#app',
  data: {
    shopOpenHour: 10,
    calBlockHeight: 90,
    topMargin: 20,
    reservations: [
      // … 省略
    ]
  },

  methods: {
    getPosition(item) {
      let styles = {}

      // 開始時間の時と分を分割
      const startTime = item.startTime.split(':')      
      // 時間分の高さ計算
      let topPosition = (startTime[0] - this.shopOpenHour) * this.calBlockHeight
      // 分の高さを足す
      topPosition += startTime[1] * (this.calBlockHeight / 60)
      // 上部のマージン分の高さを足す
      topPosition += this.topMargin

      styles.top = topPosition + 'px'
      return styles      
    }
  }
})

うまいこと配置されました!!

img4.png

予約時間の長さを計算

さらに予約時間分の長さにするために getPosition() を改修して height を計算する処理も加えます。
終了時間と開始時間の差を計算して、1時間分の高さをかけることで長さの計算をしました。

app.js
getPosition(item) {
  let styles = {}

  // 開始時間の時と分を分割
  const startTime = item.startTime.split(':')
  // 時間分の高さ計算
  let topPosition = (startTime[0] - this.shopOpenHour) * this.calBlockHeight
  // 分の高さを足す
  topPosition += startTime[1] * (this.calBlockHeight / 60)
  // 上部のマージン分の高さを足す
  topPosition += this.topMargin

  // 終了時間の時と分を分割
  const endTime = item.endTime.split(':')
  // 終了時間と開始時間の差分を求める
  const endTimeLength = parseInt(endTime[0]) + parseFloat(endTime[1] / 60)
  const startTimeLength = parseInt(startTime[0]) + parseFloat(startTime[1] / 60)
  const itemHeight = (endTimeLength - startTimeLength) * this.calBlockHeight

  styles.top = topPosition + 'px'
  styles.height = itemHeight + 'px'

  return styles
}

完成

できました!スタイルのバインディングめちゃくちゃ便利!

See the Pen Vue Reservation Calendar by daichi (@kandai) on CodePen.

ちょっとしたTipsでしたが、誰かの参考になれば嬉しいです!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Vue.jsでいい感じのアニメーションを作りたい

この記事は クラウドワークス Advent Calendar 2019 の3日目の記事です。

昨日は、@minamijoyo さんによるtfupdateでTerraform本体/プロバイダ/モジュールのバージョンアップを自動化する でした!

はじめに

こんにちは、最近アルコールに負け続けている新卒エンジニアの @b0ntenmaru です。
Rubyの会社に入社して7ヶ月ほど経ったのですが、この7ヶ月間あまり Ruby は書かずに Vue.js を利用したフロントエンド開発ばかりしていました。

その中でも Vue.js でのアニメーションの実装は経験がなく、一から調べる機会があったので今回はそれについて書いていこうと思います。

基本的なこと

Vue.js はデフォルトでトランジション( transition )という機能を提供していて、それを利用することでいい感じにアニメーションを実装することができます。

まずここにボタンを押したら div.hoge を表示/非表示するだけの 単一ファイルコンポーネント があります。いい感じのアニメーションはありません。

<template>
  <div id="app">
    <button @click="show = !show">
      click!
    </button>

    <!--  アニメーションで表示/非表示が切り替わる時にふわっと表示させたい要素 -->
    <div class="hoge" v-if="show">
      hogehoge
    </div>
  </div>
</template>

<script>
export default {
  name: 'app',
  data() {
    return {
      show: false
    }
  }
}
</script>

See the Pen VwwJmBq by Date (@b0ntenmaru) on CodePen.

いい感じのアニメーションを追加する

アニメーションを動作させるためには該当の要素(ここでは div.hoge )を <transition> タグで囲み、トランジションクラスに対して CSS を指定します。トランジションクラスに関しては後述します。

<transition>
  <div class="hoge" v-if="show">
    hogehoge
  </div>
</transition>
<style>
/* 以下の v-enter, v-enter-to, v-enter-active がトランジションクラス */

/* 表示アニメーションをする前のスタイル */
.v-enter {
  opacity: 0;
}

/* 表示アニメーション後のスタイル */
.v-enter-to {
  opacity: 1;
}

/* 表示アニメーション動作中のスタイル */
.v-enter-active {
  transition: all 500ms;
}
</style>

これにより、要素の表示に動きを与えることができました。

See the Pen dyyxrJr by Date (@b0ntenmaru) on CodePen.

トランジションクラス

トランジションクラスは、 <transition> タグで要素を囲むことで使用できるようになるCSSのクラスです。
ちょうど上で登場した v-enter , v-enter-to , v-enter-active がトランジションクラスにあたります。

トランジションクラスには Enter と Leave の2つのフェーズが存在し、

  • <transition> タグで囲った要素を表示する時を Enter
  • <transition> タグで囲った要素を非表示にする時を Leave

と言います。(上のサンプルでは Enter のみ)

この2つのフェーズにはそれぞれ、アニメーションの動作前・動作中・動作後の3つ状態に対応した3つのクラスがあります。

クラス 要素の状態
v-enter 表示アニメーションの開始時のスタイル
v-enter-to 表示アニメーションの終了時のスタイル
v-enter-active 表示アニメーション中のスタイル
v-leave 非表示アニメーションの開始時のスタイル
v-leave-to 非表示アニメーションの終了時のスタイル
v-leave-active 非表示アニメーション中のスタイル

02.png

いい感じの非表示アニメーションを追加する

続いて非表示アニメーションを実装します。
Enter の時とやることは逆ですが、下記のように指定すると非表示アニメーションが動いてくれます。

/* 非表示アニメーション動作前のスタイル */
.v-leave {
  opacity: 1;
}

/* 非表示アニメーション動作後のスタイル */
.v-leave-to {
  opacity: 0;
}

/* 非表示アニメーション動作中のスタイル */
.v-leave-active {
  transition: all 500ms;
}

See the Pen yLyBLRP by Date (@b0ntenmaru) on CodePen.

このトランジションクラスが Vue でアニメーションを実装するための基本となります。

複数要素のトランジション

続いて、v-for など同時に描画された複数の要素にアニメーションを適応させるためのリストトランジションについて説明します。

下記の単一ファイルコンポーネントを例とします。
現状 hoge が一覧されており、ADD ボタン押下で新しい hoge が一覧に追加・ hoge 横の x ボタン押下で一覧から削除できる仕様となっています。

<template>
  <div id="app">
    <button @click="add">ADD</button>
    <div v-for="(todo, index) in todos" :key="todo.key">
      <span>{{ todo.value }}</span><input @click="remove(index)" type="button" value="x" />
    </div>
  </div>
</template>

<script>
export default {
  name: 'app',
  data() {
    return {
      todos: [
        {key: 0, value: 'hoge'},
        {key: 1, value: 'hoge'},
        {key: 2, value: 'hoge'},
      ],
      nextNum: 2
    }
  },

  methods: {
    add: function() {
      const value = 'hoge'
      const todo = {
        key: this.nextNum += 1,
        value
      }
      this.todos.push(todo)
    },
    remove: function(index) {
      this.todos.splice(index, 1)
    }
  }
}
</script>

See the Pen LYEPxyJ by Date (@b0ntenmaru) on CodePen.

いい感じのアニメーションを適応させる

hoge 追加時/削除時にアニメーションを適応させます。
やることとしては単一要素のトランジションと同じでですが、今回のような複数の要素にアニメーションを適応させる時は <transition-group> タグで囲い、フェーズ( Enter / Leave )ごとにスタイルを書いていきます。

<transition-group>
  <div v-for="(todo, index) in todos" :key="todo.key">
    <span>{{ todo.value }}</span><input @click="remove(index)" type="button" value="x" />
  </div>
</transition-group>
<style>
/* 表示・非表示アニメーション中 */
.v-enter-active, .v-leave-active {
  transition: all 500ms;
}

/* 表示アニメーション開始時 ・ 非表示アニメーション後 */
.v-enter, .v-leave-to {
  opacity: 0;
}
</style>

See the Pen VwYZPNo by Date (@b0ntenmaru) on CodePen.

一点自分がハマったポイントがあるのでこちらも是非読んでみてください。

これで複数要素のアニメーションが実装できました。
ですが削除ボタン押下後、要素が移動する時にアニメーションがなく、新しい位置に味気なく移動してしまっています。

要素移動のトランジション

<transition-group> は Enter / Leave だけでなく、要素の移動のためのトランジションクラスである v-move クラスを使うことで上のような味気ない移動にアニメーションを加えることができます。

やることとしては、簡単で以下の2点を追記します。

/* 要素が移動する時に700msで移動するように指定 */
.v-move {
  transition: all 700ms;
}

.v-leave-active {
  /* 移動のトランジションをさせる場合は非表示アニメーション中に position: absoluteを指定しないと正しく動作しない */
  position: absolute;
}

See the Pen jOENGvN by Date (@b0ntenmaru) on CodePen.

これで複数要素の表示/非表示/移動時のアニメーションが実装できました。

ちなみにこの v-move に関しては要素のあらゆる移動の時に適応されるので、複数要素をシャッフルさせる機能を追加してもいい感じに動いてくれます。

See the Pen yLyBxZQ by Date (@b0ntenmaru) on CodePen.

ハマったポイント

配列のインデックスをキーにv-bindしてはダメ

中の要素は、key 属性を持つことが 必須 です。

Vue の公式にも記述されているように、 <transition-group> タグ内部の v-for で指定された要素はそれぞれが key を持つことが必須ですが、ここに配列(ここでは todos )の index を渡してはいけません、アニメーションが正常に動作しなくなります。
下記はkeyに配列の index を渡した例です。
削除ボタンを押すと決まって最後の要素が削除されたように見えてしまっています。

<transition-group>
  <div v-for="(todo, index) in todos" :key="index">
    <span>{{ todo.value }}</span><input @click="remove(index)" type="button" value="x" />
  </div>
</transition-group>

See the Pen GRgKXaE by Date (@b0ntenmaru) on CodePen.

Vue.js の key はどの値に変更があったのかを追うために使われていて、 index を key に指定した場合、最後より前の要素を消すことによって index の値が更新され、 Vue がどの要素をアニメーションさせれば良いかわからなくなり、一番最後の要素が消えるような挙動となってしまうようです。なので key には配列の index ではない一意な値を渡してあげましょう。

終わりに

以上、「Vue.jsでいい感じのアニメーションを作りたい」でした。

アニメーションがいい感じかどうかはさておき、 Vue.js でも簡単にアニメーションを実装できることがお分りいただけたかと思います。
Vue.js のアニメーションには状態のトランジションなどもあるので是非いろいろ触ってみてください!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Rails+Vue.jsによるフォームの作例

私の体感では、Railsアプリケーションで開発にかかる時間の半分はテンプレートとJavaScriptで、その大半はフォームです。ややこしいテンプレートはRails側で頑張らずに、Vue.jsのようなJavaScriptのフレームワークに投げてしまう、という作り方を今後のスタイルとしたい。

このサンプルは、ここ1年半ほどRails上でVue.jsをいじくって考えた、現在のところのベターなパターンです。まだ研究中なところもあり、今後変更する可能性もあります。

サンプルプログラムはこちら。簡単なブログアプリケーションです。
https://github.com/kazubon/blog-rails6-vuejs

環境

  • Rails 5.2/6.0、Webpacker 4、Vue.js 2.6。
  • 非SPA、Turbolinksあり。
  • jQueryとBootstrapあり。

そこそこ大きな業務アプリケーションを想定(サンプルはブログですが)。Vue.jsではないJavaScriptを使っているなど、いろいろなスタイルのページが混じっているものとする。

ポイント

  • newとeditでは、2回リクエストを送る。1回目はふつうのHTMLで、ページの枠だけ受け取る。2回目はVueからAjaxでモデルのデータを受け取り、フォームの入力欄に反映する。
  • createとupdateは、Ajaxで呼ぶ。保存に成功したときはJavaScriptでリダイレクトし、失敗したらエラーメッセージを表示する。
  • 検索結果の表示ページでも、2回リクエストを送る。1回目はふつうのHTMLで、ページの枠だけ。2回目はVueからAjaxで検索のパラメータを送って記事リストを受け取り、一覧を表示する。

関連記事:

application.js

packs下のapplication.jsの書き方はいろいろ考えられますが、このサンプルではこんな感じです。HTML要素をid属性で探して、対応するVueアプリケーションをマウントします。

グローバル変数vuePropsは、Railsから直接データをVueのpropsに渡すのに使っています。

SessionForm(ログインフォーム、この記事では紹介なし)だけ.vueファイル内のテンプレートを使わずにRailsが出したHTMLをパースしていますが、これは比較研究したかっただけです。

app/javascript/packs/application.js
require("@rails/ujs").start();
require("turbolinks").start();

import 'core-js';
import Vue from 'vue';
import TurbolinksAdapter from 'vue-turbolinks'

import EntryIndex from '../entries/index';
import EntryForm from '../entries/form';
import EntryStar from '../entries/star';
import SessionForm from '../sessions/form';

Vue.use(TurbolinksAdapter);

document.addEventListener('turbolinks:load', () => {
  let apps = [
    { elem: '#entry-index', object: EntryIndex },
    { elem: '#entry-form', object: EntryForm },
    { elem: '#entry-star', object: EntryStar },
    { elem: '#session-form', object: SessionForm }
  ];

  let props = window.vueProps || {};
  apps.forEach((app) => {
    if($(app.elem).length) {
      if(app.object.render) { // テンプレートあり
        new Vue({ el: app.elem, render: h => h(app.object, { props }) });
      }
      else { // HTMLをテンプレートに
        new Vue(app.object).$mount(app.elem);
      }
    }
  });
});

newとedit

編集ページのアクションです。HTMLの枠とAjaxでのデータ送信を同じアクションにしていますが、Ajax用を分けてapi/entries_controller.rbのような別コントローラにすることも考えられます。

Entries::Formは形式的に置いているもので、このサンプルの編集ページでは使ってません。

app/controllers/entries_controller.rb
  def new
    @entry = Entry.new
    @form = Entries::Form.new(current_user, @entry)
    respond_to do |format|
      format.html
      format.json { render :edit }
    end
  end

  def edit
    @entry = current_user.entries.find(params[:id])
    @form = Entries::Form.new(current_user, @entry)
    respond_to :html, :json
  end

編集ページのHTML枠です。editではグローバル変数vuePropsでEntryモデルのidを渡しています。

app/views/entries/new.html.erb
<div id="entry-form"></div>
app/views/entries/edit.html.erb
<script>
var vueProps = <%= { entryId: @entry.try(:id) }.to_json.html_safe %>;
</script>
<div id="entry-form"></div>

編集ページのフォーム用のVueです。createdでAjaxを使ってEntryモデルのデータを取得し、フォームにセットします。

app/javascript/entries/form.vue
<template>
  <div>
    <form @submit="submit">
      <div v-if="alert" class="alert alert-danger">{{alert}}</div>
      <div class="form-group">
        <label for="entry-title">タイトル</label>
        <input type="text" v-model="entry.title" id="entry-title"
          class="form-control" required maxlength="255" pattern=".*[^\s]+.*">
      </div>
      <div class="form-group">
        <label for="entry-body">本文</label>
        <textarea v-model="entry.body" id="entry-body" cols="80" rows="15"
          class="form-control" required maxlength="40000">
        </textarea>
      </div>
      <div class="form-group">
        <label for="entry-tag0">タグ</label>
        <div>
          <input v-for="(tag, index) in entry.tags" :key="index" v-model="tag.name"
            class="form-control width-auto d-inline-block mr-2" style="width: 17%"
            maxlength="255" >
        </div>
      </div>
      <div class="form-group">
        <label for="entry-published_at">日時</label>
        <input type="text" v-model="entry.published_at" id="entry-published_at"
          class="form-control"
          pattern="\d{4}(-|\/)\d{2}(-|\/)\d{2} +\d{2}:\d{2}">
      </div>
      <div class="form-group mb-4">
        <input type="checkbox" v-model="entry.draft" id="entry-draft" value="1">
        <label for="entry-draft">下書き</label>
      </div>
      <div class="row">
        <div class="col">
          <button type="submit" class="btn btn-outline-primary">{{entryId ? '更新' : '作成'}}</button>
        </div>
        <div class="col text-right" v-if="entryId">
          <button type="button" class="btn btn-outline-danger" @click="destroy">削除</button>
        </div>
      </div>
    </form>
  </div>
</template>

<script>
export default {
  props: ['entryId'],
  data() {
    return {
      entry: {},
      alert: null
    };
  },
  created() {
    axios.get(this.path() + '.json').then((res) => {
      this.entry = res.data.entry;
      this.initTags();
    });
  },
  methods: {
    path() {
      return this.entryId ? `/entries/${this.entryId}/edit` : '/entries/new';
    },
    initTags() {
      let len = this.entry.tags.length;
      if(len < 5) {
        for(let i = 0; i < 5 - len; i++) {
          this.entry.tags.push({ name: '' });
        }
      }
    },
// 中略
  }
}
</script>

newとeditのアクションで、Vueに渡すjsonデータです。

app/views/entries/edit.jbuilder
json.entry do
  json.title @entry.title
  json.body @entry.body
  json.published_at (@entry.published_at || Time.zone.now).strftime('%Y-%m-%d %H:%M')
  json.draft @entry.draft
  json.tags do
    json.array! @entry.tags do |tag|
      json.name tag.name
    end
  end
end

createとupdate

新規作成と更新のアクションです。成功したときは、jsonでリダイレクト先のパスを返します。失敗したときは、ステータスコード422とメッセージを返します。

ここで使っているEntries::Formは、バリデーションとデータの保存を行うものです。そのうち別記事で紹介します。

app/controllers/entries_controller.rb
  def create
    @entry = Entry.new
    @form = Entries::Form.new(current_user, @entry, entry_params)
    if @form.save
      flash.notice = '記事を作成しました。'
      render json: { location: entry_path(@entry) }
    else
      render json: { alert: '記事を作成できませんでした。' },
        status: :unprocessable_entity
    end
  end

  def update
    @entry = current_user.entries.find(params[:id])
    @form = Entries::Form.new(current_user, @entry, entry_params)
    if @form.save
      flash.notice = '記事を更新しました。'
      render json: { location: entry_path(@entry) }
    else
      render json: { alert: '記事を更新できませんでした。' },
        status: :unprocessable_entity
    end
  end
# 中略
  def entry_params
    params.require(:entry).permit(
      :title, :body, :published_at, :draft, tags: [ :name ]
    )
  end

編集ページのフォーム用のVueで、フォームを送信するメソッドです。送信時には、HTTPヘッダにmeta属性からCSRF対策のトークンを入れます。

成功したときは指定のパスにリダイレクトします。失敗したときはアラート表示部にメッセージを入れます。

実際のアプリケーションでは、トークンを指定する部分はコードを共通化するべきでしょう。レスポンスの処理部分も共通化したほうがいいかも。

app/javascript/entries/form.vue
<script>
export default {
// 中略
  methods: {
// 中略
    submitPath() {
      return this.entryId ? `/entries/${this.entryId}` : '/entries';
    },
    submit(evt) {
      evt.preventDefault();
      if(!this.validate()) {
        return;
      }
      axios({
        method: this.entryId ? 'patch' : 'post',
        url: this.submitPath() + '.json',
        headers: {
          'X-CSRF-Token' : $('meta[name="csrf-token"]').attr('content')
        },
        data: { entry: this.entry }
      }).then((res) => {
        Turbolinks.visit(res.data.location);
      }).catch((error) => {
        if(error.response.status == 422) {
          this.alert = error.response.data.alert;
        }
        else {
          this.alert = `${error.response.status} ${error.response.statusText}`;
        }
        window.scrollTo(0, 0);
      });
    },
// 中略
  }
}
</script>

index

検索フォームと記事一覧を表示するindexアクションです。ここでもHTMLの枠とAjaxのデータを同じアクションにしています。

検索のパラメータには、まとめて扱えるようにキーqを付けています。これは、まあ私の好みです。

ここで使っているEntries::SearchFormは、パラメータを使って検索を行うものです。これもそのうち別記事で紹介します。

app/controllers/entries_controller.rb
  def index
    @user = User.active.find(params[:user_id]) if params[:user_id].present?
    @form = Entries::SearchForm.new(current_user, @user, search_params)
    respond_to :html, :json
  end
(中略)
  def search_params
    return {} unless params.has_key?(:q)
    params.require(:q).permit(:title, :tag, :offset, :sort)
  end

検索ページのHTML枠の一部です。グロバール変数vuePropsでVueにユーザーID(ユーザー別一覧の場合)と検索パラメータを渡します。

app/views/entries/new.html.erb
<script>
var vueProps = <%= { userId: @user.try(:id), query: params[:q] || {} }.to_json.html_safe %>;
</script>
<div id="entry-index"></div>

検索フォームと記事一覧用のVueです。フォーム(search_form.vue)と一覧(list.vue)を子コンポーネントに分けています。

app/javascript/entries/index.vue
<template>
  <div>
    <search-form :userId="userId" :query="query"></search-form>
    <list :userId="userId" :query="query"></list>
  </div>
</template>

<script>
import Form from './search_form';
import List from './list';

export default {
  props: ['userId', 'query'],
  components: { 'search-form': Form, 'list': List }
}
</script>

検索フォームのVueです。ここでは特に何もしてません。タグを入力したときに動的に候補を出すような機能を付けるときはここに追加するつもりです。

検索フォームの送信はブラウザーに任せてページ遷移します。

app/javascript/entries/search_form.vue
<template>
  <div>
    <form action="/entries" method="get" class="form-inline mb-4">
      <input type="text" name="q[title]" class="form-control mr-3 mb-2"
        v-model="query.title" placeholder="タイトル">
      <input type="text" name="q[tag]" class="form-control mr-3 mb-2"
        v-model="query.tag" placeholder="タグ">
      <input type="hidden" name="q[sort]" v-model="query.sort">
      <input type="hidden" name="user_id" v-model="userId">
      <button type="submit" class="btn btn-outline-primary mb-2">検索</button>
    </form>
  </div>
</template>

<script>
export default {
  props: ['userId', 'query']
}
</script>

検索結果の一覧を出すVueです。createdでAjax送信を行い、記事一覧のデータを配列entriesに入れます。「もっと読む」ボタンを押したときは、オフセットを増やして記事を取得します。

app/javascript/entries/list.vue
<template>
  <div>
    <div class="text-right mb-3">
      {{entriesCount}}件 | 
      <a :href="sortPath('date')" v-if="query.sort == 'stars'">日付順</a>
      <template v-else>日付順</template>
      | <a :href="sortPath('stars')" v-if="query.sort != 'stars'">いいね順</a>
      <template v-else>いいね順</template>
    </div>
    <div class="entries mb-4">
      <div v-for="entry in entries" :key="entry.id" class="entry">
        <div>
          <a :href="entry.path">
            <template v-if="entry.draft">(下書き) </template>
            {{entry.title}}
          </a>
        </div>
        <div class="text-right text-secondary">
          <a :href="entry.user_path">{{entry.user_name}}</a> |
          <a v-for="tag in entry.tags" :key="tag.id" class="mr-2"
              :href="tag.tag_path">{{tag.name}}</a> |
          {{entry.published_at}} |
          <span class="text-warning" v-if="entry.stars_count > 0">★{{entry.stars_count}}</span>
        </div>
      </div>
    </div>
    <div v-if="showMore">
      <button type="button" @click="moreClicked" class="btn btn-outline-secondary w-100">もっと見る</button>
    </div>
  </div>
</template>

<script>
import axios from 'axios';
import qs from 'qs';

export default {
  props: ['userId', 'query'],
  data: function () {
    return {
      entries: [],
      entriesCount: 0,
      offset: 0
    };
  },
  computed: {
    showMore() {
      return (this.entries.length < this.entriesCount);
    }
  },
  created () {
    this.getEntries();
  },
  methods: {
    getEntries() {
      let params = { q: { ...this.query, offset: this.offset }, user_id: this.userId };
      let path = '/entries.json?' + qs.stringify(params);
      axios.get(path).then((res) => {
        this.entries = this.entries.concat(res.data.entries);
        this.entriesCount = res.data.entries_count;
      });
    },
    moreClicked() {
      this.offset += 20;
      this.getEntries();
    },
    sortPath(key) {
      let params = { q: { ...this.query, sort: key }, user_id: this.userId };
      return '/entries?' + qs.stringify(params);
    }
  }
}
</script>

indexアクションで、検索結果を返すjsonです。Entries::SearchFormのメソッドを呼び出しています。

app/views/entries/index.jbuilder
json.entries do
  json.array! @form.entries do |entry|
    json.id entry.id
    json.title entry.title
    json.path entry_path(entry)
    json.user_name entry.user.name
    json.user_path user_entries_path(entry.user)
    json.draft entry.draft?
    json.published_at entry.published_at.try(:strftime, '%Y-%m-%d %H:%M')
    json.stars_count entry.stars_count
    json.tags do
      json.array! entry.tags do |tag|
        json.id tag.id
        json.name tag.name
        json.tag_path(
          @user ? user_entries_path(@user, q: { tag: tag.name }) :
            entries_path(q: { tag: tag.name })
        )
      end
    end
  end
end
json.entries_count @form.entries_count

Vue.jsと関係ない補足

  • createとupdateは、Ajaxで呼ぶほうが楽です。失敗時にrender :newなどでテンプレートを作り直す手間が省けるのは大きい。また、失敗ページでのブラウザーの進む/戻る/リロードの動作が自然になります。
  • 検索フォームを送信するときは、GETメソッドでページ遷移させて、URLを変化させます。POSTメソッドを使ったりAjaxを使ったりすると、ブラウザーのリロードで検索結果が消えたり、検索結果をブックマークできなくなったりします。
  • JavaScriptでフォームの送信を扱うときは、送信ボタンのclickイベントじゃなくて、form要素のsubmitイベントを処理すること。

検討課題

  • Vueのテンプレートは.vueファイル内に入れるべきか、Railsが出力したHTMLを使うべきか。既存のアプリケーションでは、テンプレートをすべて.vueファイルに移すのは現実的に無理なので、両方ありとします。
  • フォームのAjax送信にrails-ujs(remote: trueを付けてajax:successイベントを処理)を使うべきか。rails-ujsでもいいんだけど、慣れていない人に分かりにくいのでAxiosで統一したほうがよい。
  • RailsからVueに直接データを渡す方法(このサンプルのグローバル変数vueProps)については、まだ迷い中。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Vue.jsでいい感じのアニメーションを作りたい

この記事は クラウドワークス Advent Calendar 2019 の3日目の記事です。

昨日は、@minamijoyo さんによるtfupdateでTerraform本体/プロバイダ/モジュールのバージョンアップを自動化する でした!

はじめに

こんにちは、最近アルコールに負け続けている新卒エンジニアの @b0ntenmaru です。
Rubyの会社に入社して7ヶ月ほど経ったのですが、この7ヶ月間あまり Ruby は書かずに Vue.js を利用したフロントエンド開発ばかりしていました。

その中でも Vue.js でのアニメーションの実装は経験がなく、一から調べる機会があったので今回はそれについて書いていこうと思います。

基本的なこと

Vue.js はデフォルトでトランジション( transition )という機能を提供していて、それを利用することでいい感じにアニメーションを実装することができます。

まずここにボタンを押したら div.hoge を表示/非表示するだけの SFC があります。いい感じのアニメーションはありません。

<template>
  <div id="app">
    <button @click="show = !show">
      click!
    </button>

    <--  アニメーションで表示/非表示が切り替わる時にふわっと表示させたい要素 -->
    <div class="hoge" v-if="show">
      hogehoge
    </div>
  </div>
</template>

<script>
export default {
  name: 'app',
  data() {
    return {
      show: false
    }
  }
}
</script>

See the Pen VwwJmBq by Date (@b0ntenmaru) on CodePen.

いい感じのアニメーションを追加する

アニメーションを動作させるためには該当の要素(ここでは div.hoge )を <transition> タグで囲み、トランジションクラスに対して CSS を指定します。トランジションクラスに関しては後述します。

<transition>
  <div class="hoge" v-if="show">
    hogehoge
  </div>
</transition>
<style>
/* 以下の v-enter, v-enter-to, v-enter-active がトランジションクラス */

/* 表示アニメーションをする前のスタイル */
.v-enter {
  opacity: 0;
}

/* 表示アニメーション後のスタイル */
.v-enter-to {
  opacity: 1;
}

/* 表示アニメーション動作中のスタイル */
.v-enter-active {
  transition: all 500ms;
}
</style>

これにより、要素の表示に動きを与えることができました。

See the Pen dyyxrJr by Date (@b0ntenmaru) on CodePen.

トランジションクラス

トランジションクラスは、 transition タグで要素を囲むことで使用できるようになるCSSのクラスです。
ちょうど上で登場した v-enter , v-enter-to , v-enter-active がトランジションクラスにあたります。

トランジションクラスには Enter と Leave の2つのフェーズが存在し、

  • transition コンポーネントで囲った要素を表示する時を Enter
  • transition コンポーネントで囲った要素を非表示にする時を Leave

と言います。(上のサンプルでは Enter のみ)

この2つのフェーズにはそれぞれ、アニメーションの動作前・動作中・動作後の3つ状態に対応した3つのクラスがあります。

クラス 要素の状態
v-enter 表示アニメーションの開始時のスタイル
v-enter-to 表示アニメーションの終了時のスタイル
v-enter-active 表示アニメーション中のスタイル
v-leave 非表示アニメーションの開始時のスタイル
v-leave-to 非表示アニメーションの終了時のスタイル
v-leave-active 非表示アニメーション中のスタイル
02.png

いい感じの非表示アニメーションを追加する

続いて非表示アニメーションを実装します。
Enter の時とやることは逆ですが、下記のように指定すると非表示アニメーションが動いてくれます。

/* 非表示アニメーション動作前のスタイル */
.v-leave {
  opacity: 1;
}

/* 非表示アニメーション動作後のスタイル */
.v-leave-to {
  opacity: 0;
}

/* 非表示アニメーション動作中のスタイル */
.v-leave-active {
  transition: all 500ms;
}

See the Pen yLyBLRP by Date (@b0ntenmaru) on CodePen.

このトランジションクラスが Vue でアニメーションを実装するための基本となります。

複数要素のトランジション

続いて、v-for など同時に描画された複数の要素にアニメーションを適応させるためのリストトランジションについて説明します。

下記のSFCを例とします。
現状hogeが一覧されており、ADD ボタン押下で新しい hoge が一覧に追加・ hoge 横の x ボタン押下で一覧から削除できる仕様となっています。

<template>
  <div id="app">
    <button @click="add">ADD</button>
    <div v-for="(todo, index) in todos" :key="todo.key">
      <span>{{ todo.value }}</span><input @click="remove(index)" type="button" value="x" />
    </div>
  </div>
</template>

<script>
export default {
  name: 'app',
  data() {
    return {
      todos: [
        {key: 0, value: 'hoge'},
        {key: 1, value: 'hoge'},
        {key: 2, value: 'hoge'},
      ],
      nextNum: 2
    }
  },

  methods: {
    add: function() {
      const value = 'hoge'
      const todo = {
        key: this.nextNum += 1,
        value
      }
      this.todos.push(todo)
    },
    remove: function(index) {
      this.todos.splice(index, 1)
    }
  }
}
</script>

See the Pen LYEPxyJ by Date (@b0ntenmaru) on CodePen.

いい感じのアニメーションを適応させる

hoge 追加時/削除時にアニメーションを適応させます。
やることとしては単一要素のトランジションと同じでですが、今回のような複数の要素にアニメーションを適応させる時は <transition-gruop> タグで囲い、フェーズ( Enter / Leave )ごとにスタイルを書いていきます。

<transition-group>
  <div v-for="(todo, index) in todos" :key="todo.key">
    <span>{{ todo.value }}</span><input @click="remove(index)" type="button" value="x" />
  </div>
</transition-group>
<style>
/* 表示・非表示アニメーション中 */
.v-enter-active, .v-leave-active {
  transition: all 500ms;
}

/* 表示アニメーション開始時 ・ 非表示アニメーション後 */
.v-enter, .v-leave-to {
  opacity: 0;
}
</style>

See the Pen VwYZPNo by Date (@b0ntenmaru) on CodePen.

一点自分がハマったポイントがあるのでこちらも是非読んでみてください。

これで複数要素のアニメーションが実装できました。
ですが削除ボタン押下後、要素が移動する時にアニメーションがなく、新しい位置に味気なく移動してしまっています。

要素移動のトランジション

<transition-group> は Enter / Leave だけでなく、要素の移動のためのトランジションクラスである v-move クラスを使うことで上のような味気ない移動にアニメーションを加えることができます。

やることとしては、簡単で以下の2点を追記します。

/* 要素が移動する時に700msで移動するように指定 */
.v-move {
  transition: all 700ms;
}

.v-leave-active {
  /* 移動のトランジションをさせる場合は非表示アニメーション中に position: absoluteを指定しないと正しく動作しない */
  position: absolute;
}

See the Pen jOENGvN by Date (@b0ntenmaru) on CodePen.

これで複数要素の表示/非表示/移動時のアニメーションが実装できました。

ちなみにこの v-move に関しては要素のあらゆる移動の時に適応されるので、複数要素をシャッフルさせる機能を追加してもいい感じに動いてくれます。

See the Pen yLyBxZQ by Date (@b0ntenmaru) on CodePen.

ハマったポイント

配列のインデックスをキーにv-bindしてはダメ

中の要素は、key 属性を持つことが 必須 です。

と Vue の公式にも記述されているように、 transition-group タグ内部の v-for で指定された要素はそれぞれが key を持つことが必須ですが、ここに配列(ここでは todos )の index を渡してはいけません、アニメーションが正常に動作しなくなります。
下記はkeyに配列の index を渡した例です。
削除ボタンを押すと決まって最後の要素が削除されたように見えてしまっています。

<transition-group>
  <div v-for="(todo, index) in todos" :key="index">
    <span>{{ todo.value }}</span><input @click="remove(index)" type="button" value="x" />
  </div>
</transition-group>

See the Pen GRgKXaE by Date (@b0ntenmaru) on CodePen.

Vue.js の key はどの値に変更があったのかを追うために使われていて、 index を key に指定した場合、最後より前の要素を消すことによって index の値が更新され、 Vue がどの要素をアニメーションさせれば良いかわからなくなり、一番最後の要素が消えるような挙動となってしまうようです。なので key には配列の index ではない一意な値を渡してあげましょう。

終わりに

以上、「Vue.jsでいい感じのアニメーションを作りたい」でした。

アニメーションがいい感じかどうかはさておき、 Vue.js でも簡単にアニメーションを実装できることがお分りいただけたかと思います。
Vue.js のアニメーションには状態のトランジションなどもあるので是非いろいろ触ってみてください!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

vue.js+element-uiで複数選択(select)からフォーム(form)表示・非表示をコントロール

やりたいこと

グループ化の複数選択肢がある場合、ある選択(値)を選択すると、グループによって違うフォームが表示されます。

効果図

グループが二つあります。
スクリーンショット 2019-12-03 9.54.15.png
グループ1(select1~3)の中で任意の選択肢を選択すると、aaa+bbbのフォームが表示できます。
スクリーンショット 2019-12-03 9.54.22.png
グループ2(select4~5)の中で任意の選択肢を選択すると、ccc+dddのフォームが表示できます。
スクリーンショット 2019-12-03 9.54.33.png

コード分析

<el-select style="width:60%" v-model="value" placeholder="Select" @change='getValue'>
        <el-option-group v-for="group in options" :key="group.label" :label="group.label">
          <el-option v-for="item in group.options" :key="item.value" :label="item.label" :value="item.value">
          </el-option>
        </el-option-group>
      </el-select>

@change='getValue'でv-model="value"からバンディング・定義された複数選択の値をゲットします。

<el-form :rules="rules" :label-position="labelPosition" label-width="100px" ref="form" :model="form" v-if="isShow1">

      <el-form-item label="aaa">
        <el-input v-model="form.name1"></el-input>
      </el-form-item>
      <el-form-item label="bbb">
        <el-input v-model="form.name2"></el-input>
      </el-form-item>

    </el-form>

v-if="isShow1"でフォームの表示・非表示をコントロールします。

コード全文

select.vue
<style>

.el-row {
  margin-bottom: 20px;
  &:last-child {
    margin-bottom: 0;
  }
}

.el-textarea {
width:80%;
}


.el-input {
width:80%
}
</style>

<template>
<div>


  <el-card style="margin:0 auto;width:70%;">

    <el-row :label-position="labelPosition" label-width="100px" >
      <span好きな果物を選択してください  </span>
    </el-row>
    <el-row :label-position="labelPosition" label-width="100px" >
      <el-select style="width:60%" v-model="value" placeholder="Select" @change='getValue'>
        <el-option-group v-for="group in options" :key="group.label" :label="group.label">
          <el-option v-for="item in group.options" :key="item.value" :label="item.label" :value="item.value">
          </el-option>
        </el-option-group>
      </el-select>
    </el-row>
    <el-divider></el-divider>

    <el-form :rules="rules" :label-position="labelPosition" label-width="100px" ref="form" :model="form" v-if="isShow1">

      <el-form-item label="aaa">
        <el-input v-model="form.name1"></el-input>
      </el-form-item>
      <el-form-item label="bbb">
        <el-input v-model="form.name2"></el-input>
      </el-form-item>

    </el-form>

    <el-form :label-position="labelPosition" label-width="100px" ref="form" :model="form" v-if="isShow2">
      <el-form-item label="ccc">
        <el-input v-model="form.name3"></el-input>
      </el-form-item>

      <el-form-item label="ddd">
        <el-input type="textarea"></el-input>
      </el-form-item>

    </el-form>


  </el-card>
</div>
</template>

<script>
export default {
  data() {
    return {
      options: [{
        label: 'group1',
        options: [{
          value: 'select1',
          label: 'select1'
        }, {
          value: 'select2',
          label: 'select2'
        }, {
          value: 'select3',
          label: 'select3'
        }]
      }, {
        label: 'group2',
        options: [{
          value: 'select4',
          label: 'select4'
        }, {
          value: 'select5',
          label: 'select5'
        }]
      }],
      value: '',
      isShow1: false,
      isShow2: false,
      form: {
        name1: '',
        name2: '',
        name3: '',
        desc: ''
      }
    }
  },
  methods: {
    getValue: function() {
      console.log(this.value, '選択されました');
      if (this.value == "select4" || this.value == "select5") {
        this.isShow1 = false;
        this.isShow2 = true;
        console.log("isShow2 is show ");
      } else {
        this.isShow2 = false;
        this.isShow1 = true;
        console.log("isShow1 is show");
      }
    }
  }
}
</script>


  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Vue.jsはじめました

はじめに

こんにちは!ユアマイスターアドベントカレンダー3日目の投稿になります。
最近ようやくVue.jsを学ぶ決心がつき、ここ1ヶ月ほど合間をみて勉強してました。
本投稿では勉強内容をまとめ、アウトプットのきっかけになれば幸いです。

勉強方法

入門書と動画コンテンツを購入し、サンプルコードを書きながら徐々に慣れていきました。
ちなみに私は新技術を勉強するときにUdemyというサービスをよく利用しています。動画なのでわかりやすいし、セール期間中だと安く購入できるのでおすすめです。

ちなみに今回購入した本と動画がこちら↓

Vue.js入門 基礎から実践アプリケーション開発まで
https://gihyo.jp/book/2018/978-4-297-10091-9

Vue JS 入門決定版!jQuery を使わない Web 開発 - 導入からアプリケーション開発まで体系的に動画で学ぶ
https://www.udemy.com/course/learn-vuejs/

Vueの基本

Vueオブジェクト・Vueインスタンス

Vueを勉強する上で一番最初にやることはグローバル変数Vueのインスタンスを作成するところじゃないでしょうか?
Vueのインスタンスはいくつかのコンストラクタを持っていて、インスタンスをDOMとマウントすることでVueの基本的な機能を提供してくれます。(マウントとは既存のDOM要素をVueが生成するDOM要素に置き換えることです。)

オプション 内容
data UIの状態・データ
el Vueインスタンスをマウントする要素
filters データを文字列と整形する
methods イベントが発生した時などの振る舞い
computed データから派生して算出される値

例えば、el プロパティでマウント対象のDOMを指定し、Vueで操作することが可能になります。

image.png

sample.html
var vm = new Vue({
  el: '#app',
  data: {
    name: 'taro',
    age: 21
  },
  methods: greet: function () {
    alert('Hello');
  }
})

<div id="app">
  <p>
    名前: {{ name }}, 年齢: {{ age }}
  </p>

  <button @click="greet"></button>
</div>

基本文法

よく使った基本的な文法をいくつか紹介します。

変数表示

<div id="app">
  {{ text }}
</div>

var vm = new Vue({
  el: '#app',
  data: {
    text: 'hello'
  }
})

結果:hello

リストレンダリング v-for

<div id="app">
  <li v-for="item in items">
    {{ item.message }}
  </li>
</div>
var vm = new Vue({
  el: '#app',
  data: {
    items: [
      {
        message: 'first'
      },
      {
        message: 'second'
      },
    ]
  }
})

結果:
first
second

条件付きレンダリング v-if

<div id="app">
    <div v-if="first">first</div>
    <div v-if="second">second</div>
</div>
var vm = new Vue({
    el: '#app',
    data: {
        first: true,
        second: false,
    }
})

結果:first

データバインディング

VueはDOM要素とJsのデータを結びつける双方向なデータバインディングを提供しているので、対象のDOMに対してJsのデータをバインディングすることで何か変更があるたびにDOMも変更され、下記のように何か入力されたらテキストの値が変わります。

vue-sample.gif

サンプルコード

sample.html
<div id="app">
  <p>
    <input type="text" v-model="message">
  </p>
  <p>
    {{ message }}
  </p>
</div>

<script>
  var vm = new Vue({
    el: '#app',
    data: {
      message: ''
    }
  })
</script>

jQueryとの比較

jQueryで同じことを実現しようとするとデータバインディングの良さをよりイメージできると思います。
jQueryで実現する場合は値をセットし、inputの入力イベントをトリガーにイベントが発行されたタイミングで値取得とテキストへの値セットをしないといけません。もし、このようなパーツが複数存在するとき、毎回同じような処理を書かないといけません。

サンプル

sample.html
<input id="entry" type="text"/>
<p id="message"></p>

<script>
  $('#entry').keyup(function () {
    var val = $(this).val();
    $('#message').text(val);
  });
</script>

まとめ

このようにVueについて勉強したことを書かせて頂きました。
なんとか書き方は徐々に慣れてきたところですが、インスタンスが生成されてからの内部の動きやコンポネント設計などまだまだ勉強しないといけないところはたくさんあります。ただ、この機会にプロダクトにVue.jsを取り入れモダンな開発ができるようになればと思います。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Vue.jsでselectタグの初期値にオブジェクトを設定する際の注意

オブジェクト型(数値、文字列、論理値などプリミティブではない型)をoptionの値に設定

 以下のように、オブジェクト型を選択肢に設定して、そのデフォルト値を「v-model」で指定することができます。
 例の場合は、「selectedInfo」に「code=2」の値を設定しています。

sample.vue
<template>
 <select v-model="selectedInfo">
  <option v-for="info in infoList" :value="info">
   {{ info.label }}
  </option>
 </select>
</template>
<script>
export default {
 data() {
   return {
     selectedInfo: { code: '2', label: '2番' },
     infoList: [
       {code: '1', label: '1番'},
       {code: '2', label: '2番'},
       {code: '3', label: '3番'},
       {code: '4', label: '4番'},      
     ]
   }
 }
}
</script>

選択値(v-modelの値)は、プロパティや値が選択肢と完全に一致していないと選択されない

以下の例だと、remarkプロパティがないので選択されない。

sample.vue
<template>
 <select v-model="selectedInfo">
  <option v-for="info in infoList" :value="info">
   {{ info.label }}
  </option>
 </select>
</template>
<script>
export default {
 data() {
   return {
     selectedInfo: { code: '2', label: '2番' },
     infoList: [
       {code: '1', label: '1番', remark: '' },
       {code: '2', label: '2番', remark: '' },
       {code: '3', label: '3番', remark: '' },
       {code: '4', label: '4番', remark: '' },      
     ]
   }
 }
}
</script>

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

わいのNuxt.jsアプリケーションは遅かった。

この記事は 今年イチ!お勧めしたいテクニック by ゆめみ feat.やめ太郎 Advent Calendar 2019の3日目の記事です。
プログラミング初級者がNuxt.jsの高速化するのにストレージにキャッシュしてみたら、色々捗った事を書きます。
わいさん風を取り入れてみました。少しなまりが間違っている箇所があるかもしれませんが、ご容赦下さい。

Nuxt.jsでSSRしたら最速なのか?

ある日の転職したくて、ポートフォリオアプリを作っているワイ。

ワイ 「Nuxt.jsでサーバーサイドレンダリングが簡単に出来るみたいやな。」
ワイ 「サーバーサイドレンダリングで、アイソーモフィックでユニバーサルなシングルページアプリケーションで作ってみました、とか面接で言えば転職間違いなしやな」
ワイ 「ヘッドレスCMSが流行ってるみたいやな。Blogアプリとかいいやん。作って自分で勉強した事をアウトプット出来るしな。(本当はどうせ、作って満足して、アウトプット出来ないBlogアプリを量産するんやけどな。。)」
ワイ 「GraphCMSってのがあるやん、GraphQLでリクエスト
すればJsonでレスポンス返してくれるヘッドレスCMSやん。これ使えば、GraphQLは使えますって見栄張れるな。」
ワイ 「FirebaseのFunctionsでSSRしたらなんかかっこいいやん!私はサーバレスですって言えるやん。」
ワイ 「Bootstrap-vue使って、GraphqlのリクエストはAppoloClient使って、FontAwesome使って、GoogleFont使って・・・・・・よし出来た!」
ワイ 「なんでもNuxt.js用にライブラリ作ってくれてるもんで、便利やな!」

ワイ 「これでわいも爆速で最強のSSRやでって言えるな。よし、LightHouseしてオール100点ですっていうのをスクショ取ってQiitaで見せびらかしたろ」

first_lighthouse.png

ワイ 「全部緑色ちゃうやん!」
ワイ 「見せびらかせやんやん!」

LightHouseのパフォーマンス

上記のスクショでは、65点でしたが、凝ったWebアプリを作れば作るほどもっと悪いスコアが出る方も多いのではないでしょうか?(私はそうでした。)
パフォーマンス以外の部分は、LightHouseの評価するポイントを埋めていけば100点に近づいていきます。足し算の努力で対応出来ます。
しかし、パフォーマンスだけは、足し算だけではスコアが上がりません。この記事では、パフォーマンスを100点に近づけていく事をテーマに個人開発でポートフォリオを作っている方が、取り入れる時に参考になりそうな事を考えてみました。

まずLightHouseについて

Lighthouse(ライトハウス)は、もともとGoogleが提供しているChrome拡張機能でしたが、現在はChromeに標準搭載されて、デベロッパーツールのAuditsから使えるので、拡張機能はインストールしなくて大丈夫です。Goolgeが考えるWebサイト、Webアプリ
(モバイルも含む)が目指す先の道しるべとしてのLighthouse(灯台)という意味です。

スコアは変動する

LightHouseのスコアは、変動をできるだけ最小限に押さえられる様に作られているそうです。
しかし、インターネット回線の速度、実行するデバイスの違い、アクセスするサイト全てに手を加えるChrome拡張を入れていたり、異なるバージョンのLightHouseで計測する等様々な理由で変動します。出来るだけ同じ環境で、複数回計測し、平均点をあげて行けば良いのかなと思います。

LightHouseのバージョンによる違い。

スコアの算出方法としては、Googleが考えるベストプラクティスなので、googleが考えるユーザー知覚パフォーマンスに影響を与える度合いというもので、これから説明する、いくつかの指標に重み付けをして算出されています。
さらにこの重み付けは、LightHouseのバージョンが変われば変わってしまいます。
つまり一度、高スコアを出しても、Googleさんの考えが変わればスコアは変動するという事です。
なのでLightHouseのスコアを高くする事を目指すのがとにかく良いということでは無いという事は理解しておく必要があります。
参考URL:https://developers.google.com/web/fundamentals/performance/user-centric-performance-metrics

Perfomanceのスコアを算出する為にどんな指標があるのか?

詳しくは、https://developers.google.com/web/tools/lighthouse に書かれています。
この記事では、ざっくりとした説明にしますので、気になった部分は、こちらを確認してみて下さい。
スクリーンショット 2019-12-02 13.14.04.png

Chrome Dev Summit 2018の画像

スクリーンショット 2019-12-02 16.46.40.png
Paul Irish at Chrome Dev Summit 2018

画像をお借りして私のざっくりとした理解ですが、

  • FirstContentfulPaintがリクエストから、クライアントからアクションを起こせる状態になるまでの時間で、
  • その状態から一番時間がかかるタイミングで一番時間がかかるアクションを取った時のアプリからのレスポンスにかかる時間がMaxPotentialFirstInputDelayである。
  • Time to Interactviはページが完全にインタラクティブになるまでの時間です。

NuxtでSSRをすれば、Htmlをサーバ側で、生成してくるので、FirstContentfulPaintは早いが、早い分、MaxPotentialFirstInputDelayは大きく出るという事になると考えられます。MaxPotentialFirstInputDelayの計測値にGoogleが重きをおけばおくほどスコアは下がっていくというように考えられます。

ざっくりスコアを上げる為に主なできる事

  • クライアントのリクエストスタートからサーバのレスポンスを早くする。アーキテクチャの見直し。

  • なるべくCSS・JS・画像データを軽くして読み込みを早くする。

  • ソースの読み込み時にブロッキングが発生している所がないか確認、あればブロッキングしないように出来ないかやってみる。
    lazyloadとか。prefetchとかPrerenderとか。

今回のわい君が思いついたアプリ

スクリーンショット 2019-12-02 14.00.52.png

FirebaseFunctionsでSSRする時は要注意

FirebaseFunctionsをFirebaseHostingをドメインを結びつけて使用する際には現在、
リージョンはUSセントラルしか使えません!

つまり、クライアントが日本にあるとすると、USセントラルのFirebaseにリクエストを送ると、東京リージョンのGraphCMSにデータを取りに行き、USセントラルでから日本にレスポンスを返してきます。

世界1周旅行です。

GraphCMSのリージョンをUSにすればいいんじゃ無いかと思った方もいるかと思います。

しかし、CMSをUSに持っていっても、今度は、CMSで管理している画像を保存しているストレージがUSに行ってしまいます。

初期表示時にデータを入れてSSRをしたい時には、SSRを行うアプリケーションの核となる部分はクライアントに近くないと考えることが多くなると感じました。

世にあるCMSや、情報を発信してくれるAPIや、画像を圧縮してくれるAPIをアプリケーションに取り込む際は、リアルタイムにクライアントのレスポンスに応じて、APIサービスを使用するのでは無く、あらかじめストレージにキャッシュしておけるならしておくというような使い方をしなくちゃなと思いました。

CMSのデータをストレージにキャッシュさせるといい事いっぱい

スクリーンショット 2019-12-02 14.51.06.png

CMSで新しい記事を書いた時、または編集した時にフックしてFirebaseFunctionsをから、GraphCMSからデータを取り、ストレージに保存します。

すると、クライアントがBlogページへリクエストを送った時には、データはストレージに取りに行きます。先ほどのアメリカ日本を往復するデータ取得の処理は、クライアントがBlogを閲覧する時には走らなくなりました。

また、CMSへリクエストを投げる為に使っていた、ApolloClientもFunctionで、使うのでNuxtには必要なくなりました。少しデータが軽くなりました。

さらに勝手に気を利かせてGCPがCDNにキャッシュしてくれているみたい!

クライアントで欲しいデータを外部APIへ取りに行ったり、そのデータを加工したりするのは、FaaS,PaaS等にデプロイした自家製APIに任せて、使いたいデータの形式にしてストレージにキャッシュしておくのがいいなと思いました。

Blogアプリで考えられるのは、Markdown形式で書かれたコンテンツデータをHtmlにパースする事をストレージにキャッシュする前に処理出来ないか等が考えられると思います。まだ出来ていませんが、これは何を作る時でも色々考えられる事だと思いました。

pwa-moduleは入れる

Nuxt.jsは簡単にPWAを実現出来ます。簡単すぎて、serviceworkerについては別途学習しないと何も理解出来ないくらいです。この導入で、PWAモジュールに含まれるworkboxモジュールによってサービスワーカーが導入され、キャッシュが効くようになります。 Googleは現在、PWA推しのようです。

$ yarn add @nuxtjs/pwa
nuxt.config.js
modules: [
    '@nuxtjs/pwa
]

とにかく軽くする

$ nuxt build --analyze

しまくります。削減可能な部分を考えたり、重いライブラリを軽量ライブラリに置き換えたりします。

難しいことは考えずにとにかく軽くする方法を考えました。

FontAwesomeは使うアイコンだけ読み込むようにする。

FontAwesomeの導入方法は様々ありますが、いっぺんに全てのアイコンを読み込んでしまうものがあります。
よく使われているものでも、@fortawesome,nuxt-fontawesome,@nuxt/font-awesome,vue-fontawesome,vue-awesomeとか他にもあるかもしれません。
初級者向けの記事を参考にすると、出来るだけ簡単に単手順で全て取り込めて、どのページでも全てのフリーのアイコンを使えるようなものを採用することになってしまいます。

私は、軽量化するに当たって、@fortawesome/vue-fontawesomeを使ってプラグインで使うものんだけ読み込んでみました。これでも大きな軽量化になりました。使用するコンポーネントだけで読み込んだほうが良いのか、vue-fontawesome以外も使ってサイズの違いを確認してみます。

nuxt.config.js で プラグイン で読み込む

nuxt.config.js
css: [
       '@fortawesome/fontawesome-svg-core/styles.css'
     ],
plugins: [
    {
      src: '~plugins/font-awesome',
      ssr: false
    },
     ],

使用するものだけ読み込む

plugins/fontawesome.js
import Vue from 'vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { faCalendarAlt, faTags } from '@fortawesome/free-solid-svg-icons'

library.add(faCalendarAlt, faTags)

Vue.component('fa-icon', FontAwesomeIcon)

Vue.config.productionTip = false

コンポーネントライブラリは重い

今回、CSSフレームワークとして、使ったのはBootstrap-vueでした。BootStrap-vueは、コンポーネントのライブラリとなっています。CSSだけでなくJavascriptも内部で使っています。これが使ってない部分も全て取り込まれてしまいます。自分でJSを書く必要はありますが、ここをCSSのみのライブラリに変更出来れば軽量化出来ます。
スクリーンショット 2019-12-02 16.02.08.png

まだやりたい事は全部できてませんが、

まだ、bootstrap-vueは置き換えていません。
スクリーンショット 2019-12-03 0.11.36.png
さらにGraphCMSでは記事の内容を更新したり、新規投稿したりした時にHookして何かアクションを起こしたりする事は無料では出来ないことが発覚しました。とりあえずの間に合わせで、CMSを更新したら、ターミナルからrefreshコマンドを打てばリフレッシュ出来るようにしました。ポンコツ仕様ですが、自分でCMSを作りたいというモチベーションが出来たので無駄では無いと言い聞かせます。

結果、90点以上は平均して出るようになりました。

スクリーンショット 2019-10-04 17.07.18.png

ワイ「これで緑色にはなったな」
ワイ「データ駆動やからこそ、もらうデータのパターンが決まっていてキャッシュしやすいものはキャッシュ最強やな」
ワイ「それにしてもLightHouseを使って$ nuxt build --analyze叩いているうちに重いライブラリをみると敵に見えてきてしまうようになってしもたで。」

まとめ

ブログサイトやポートフォリオページなんかの更新がそれほどリアルタイム性が必要無いものは、ストレージにキャッシュする事でいい事満載だなと思いました。フロントエンドの役割が大きくなっていくにつれて、Firebaseにバックエンドは任せて、初級者はなんでもライブラリをフロントエンドに詰め込みまくってしまいがちです。
とりあえず、FirebaseStoreのデータを全部読み込んで、複雑なデータ処理もフロントエンドで行なったりしがちです。

私がその典型でした。

しかし、出来るだけサーバーサイドにデータ処理のロジックを回したり、外部APIをから手に入れるデータがある際は、キャッシュしたりする事でフロントエンドが軽量に出来るという視点を持たなくてはならないなと感じました。

今回の内容には直接関係無いかもしれませんが、最終的な成果物です。
ポートフォリオ的Blogアプリですが、作って満足して、内容は更新できていません。汗

最終的なBlogサイトのデモページ:https://nuxt-deploy-test-teshima.appspot.com/
コード:https://github.com/yujiteshima/nuxt_blog_LT

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

��わいのNuxt.jsアプリケーションは遅かった。

この記事は 今年イチ!お勧めしたいテクニック by ゆめみ feat.やめ太郎 Advent Calendar 2019の3日目の記事です。
プログラミング初級者がNuxt.jsの高速化するのにストレージにキャッシュしてみたら、色々捗った事を書きます。
わいさん風を取り入れてみました。少しなまりが間違っている箇所があるかもしれませんが、ご容赦下さい。

Nuxt.jsでSSRしたら最速なのか?

ある日の転職したくて、ポートフォリオアプリを作っているワイ。

ワイ 「Nuxt.jsでサーバーサイドレンダリングが簡単に出来るみたいやな。」
ワイ 「サーバーサイドレンダリングで、アイソーモフィックでユニバーサルなシングルページアプリケーションで作ってみました、とか面接で言えば転職間違いなしやな」
ワイ 「ヘッドレスCMSが流行ってるみたいやな。Blogアプリとかいいやん。作って自分で勉強した事をアウトプット出来るしな。(本当はどうせ、作って満足して、アウトプット出来ないBlogアプリを量産するんやけどな。。)」
ワイ 「GraphCMSってのがあるやん、GraphQLでリクエスト
すればJsonでレスポンス返してくれるヘッドレスCMSやん。これ使えば、GraphQLは使えますって見栄張れるな。」
ワイ 「FirebaseのFunctionsでSSRしたらなんかかっこいいやん!私はサーバレスですって言えるやん。」
ワイ 「Bootstrap-vue使って、GraphqlのリクエストはAppoloClient使って、FontAwesome使って、GoogleFont使って・・・・・・よし出来た!」
ワイ 「なんでもNuxt.js用にライブラリ作ってくれてるもんで、便利やな!」

ワイ 「これでわいも爆速で最強のSSRやでって言えるな。よし、LightHouseしてオール100点ですっていうのをスクショ取ってQiitaで見せびらかしたろ」

first_lighthouse.png

ワイ 「全部緑色ちゃうやん!」
ワイ 「見せびらかせやんやん!」

LightHouseのパフォーマンス

上記のスクショでは、65点でしたが、凝ったWebアプリを作れば作るほどもっと悪いスコアが出る方も多いのではないでしょうか?(私はそうでした。)
パフォーマンス以外の部分は、LightHouseの評価するポイントを埋めていけば100点に近づいていきます。足し算の努力で対応出来ます。
しかし、パフォーマンスだけは、足し算だけではスコアが上がりません。この記事では、パフォーマンスを100点に近づけていく事をテーマに個人開発でポートフォリオを作っている方が、取り入れる時に参考になりそうな事を考えてみました。

まずLightHouseについて

Lighthouse(ライトハウス)は、もともとGoogleが提供しているChrome拡張機能でしたが、現在はChromeに標準搭載されて、デベロッパーツールのAuditsから使えるので、拡張機能はインストールしなくて大丈夫です。Goolgeが考えるWebサイト、Webアプリ
(モバイルも含む)が目指す先の道しるべとしてのLighthouse(灯台)という意味です。

スコアは変動する

LightHouseのスコアは、変動をできるだけ最小限に押さえられる様に作られているそうです。
しかし、インターネット回線の速度、実行するデバイスの違い、アクセスするサイト全てに手を加えるChrome拡張を入れていたり、異なるバージョンのLightHouseで計測する等様々な理由で変動します。出来るだけ同じ環境で、複数回計測し、平均点をあげて行けば良いのかなと思います。

LightHouseのバージョンによる違い。

スコアの算出方法としては、Googleが考えるベストプラクティスなので、googleが考えるユーザー知覚パフォーマンスに影響を与える度合いというもので、これから説明する、いくつかの指標に重み付けをして算出されています。
さらにこの重み付けは、LightHouseのバージョンが変われば変わってしまいます。
つまり一度、高スコアを出しても、Googleさんの考えが変わればスコアは変動するという事です。
なのでLightHouseのスコアを高くする事を目指すのがとにかく良いということでは無いという事は理解しておく必要があります。
参考URL:https://developers.google.com/web/fundamentals/performance/user-centric-performance-metrics

Perfomanceのスコアを算出する為にどんな指標があるのか?

詳しくは、https://developers.google.com/web/tools/lighthouseに書かれています。
この記事では、ざっくりとした説明にしますので、気になった部分は、こちらを確認してみて下さい。
スクリーンショット 2019-12-02 13.14.04.png

Chrome Dev Summit 2018の画像

スクリーンショット 2019-12-02 16.46.40.png
Paul Irish at Chrome Dev Summit 2018

画像をお借りして私のざっくりとした理解ですが、

  • FirstContentfulPaintがリクエストから、クライアントからアクションを起こせる状態になるまでの時間で、

- その状態から一番時間がかかるタイミングで一番時間がかかるアクションを取った時のアプリからのレスポンスにかかる時間がMaxPotentialFirstInputDelayである。

  • Time to Interactviはページが完全にインタラクティブになるまでの時間です。

NuxtでSSRをすれば、Htmlをサーバ側で、生成してくるので、FirstContentfulPaintは早いが、早い分、MaxPotentialFirstInputDelayは大きく出るという事になると考えられます。MaxPotentialFirstInputDelayの計測値にGoogleが重きをおけばおくほどスコアは下がっていくというように考えられます。

ざっくりスコアを上げる為に主なできる事

  • クライアントのリクエストスタートからサーバのレスポンスを早くする。アーキテクチャの見直し。

  • なるべくCSS・JS・画像データを軽くして読み込みを早くする。

  • ソースの読み込み時にブロッキングが発生している所がないか確認、あればブロッキングしないように出来ないかやってみる。
    lazyloadとか。prefecheとかPrerenderとか。

今回のわい君が思いついたアプリ

スクリーンショット 2019-12-02 14.00.52.png

FirebaseFunctionsでSSRする時は要注意

FirebaseFunctionsをFirebaseHostingをドメインを結びつけて使用する際には現在、
リージョンはUSセントラルしか使えません!

つまり、クライアントが日本にあるとすると、USセントラルのFirebaseにリクエストを送ると、東京リージョンのGraphCMSにデータを取りに行き、USセントラルでから日本にレスポンスを返してきます。

世界1周旅行です。

GraphCMSのリージョンんをUSにすればいいんじゃ無いかと思った方もいるかと思います。

しかし、CMSをUSに持っていっても、今度は、CMSで管理している画像を保存しているストレージがUSに行ってしまいます。

初期表示時にデータを入れてSSRをしたい時には、SSRを行うアプリケーションの核となる部分はクライアントに近くないと考えることが多くなると感じました。

世にあるCMSや、情報を発信してくれるAPIや、画像を圧縮してくれるAPIをアプリケーションに取り込む際は、リアルタイムにクライアントのレスポンスに応じて、APIサービスを使用するのでは無く、あらかじめストレージにキャッシュしておけるならしておくというような使い方をしなくちゃなと思いました。

CMSのデータをストレージにキャッシュさせるといい事いっぱい

スクリーンショット 2019-12-02 14.51.06.png

CMSで新しい記事を書いた時、または編集した時にフックしてFirebaseFunctionsをから、GraphCMSからデータを取り、ストレージに保存します。

すると、クライアントがBlogページへリクエストを送った時には、データはストレージに取りに行きます。先ほどのアメリカ日本を往復するデータ取得の処理は、クライアントがBlogを閲覧する時には走らなくなりました。

また、CMSへリクエストを投げる為に使っていた、ApolloClientもFunctionで、使うのでNuxtには必要なくなりました。少しデータが軽くなりました。

さらに勝手に気を利かせてGCPがCDNにキャッシュしてくれているみたい!

クライアントで欲しいデータを外部APIへ取りに行ったり、そのデータを加工したりするのは、FaaS,PaaS等にデプロイした自家製APIに任せて、使いたいデータの形式にしてストレージにキャッシュしておくのがいいなと思いました。

Blogアプリで考えられるのは、Markdown形式で書かれたコンテンツデータをHtmlにパースする事をストレージにキャッシュする前に処理出来ないか等が考えられると思います。まだ出来ていませんが、これは何を作る時でも色々考えられる事だと思いました。

pwa-moduleは入れる

Nuxt.jsは簡単にPWAを実現出来ます。簡単すぎて、serviceworkerについては別途学習しないと何も理解出来ないくらいです。この導入で、PWAモジュールに含まれるworkboxモジュールによってサービスワーカーが導入され、キャッシュが効くようになります。 Googleは現在、PWA推しのようです。

$ yarn add @nuxtjs/pwa
nuxt.config.js
modules: [
    '@nuxtjs/pwa
]

とにかく軽くする

$ nuxt build --analyze

しまくります。削減可能な部分を考えたり、重いライブライを軽量ライブラリに置き換えたりします。

難しいことは考えずにとにかく軽くする方法を考えました。

FontAwesomeは使うアイコンだけ読み込むようにする。

FontAwesomeの導入方法は様々ありますが、いっぺんに全てのアイコンを読み込んでしまうものがあります。
よく使われているものでも、@fortawesome,nuxt-fontawesome,@nuxt/font-awesome,vue-fontawesome,vue-awesomeとか他にもあるかもしれません。
初級者向けの記事を参考にすると、出来るだけ簡単に単手順で全て取り込めて、どのページでも全てのフリーのアイコンを使えるようなものを採用することになってしまいます。

私は、軽量化するに当たって、@fortawesome/vue-fontawesomeを使ってプラグインで使うものんだけ読み込んでみました。これでも大きな軽量化になりました。使用するコンポーネントだけで読み込んだほうが良いのか、vue-fontawesome以外も使ってサイズの違いを確認してみます。

nuxt.config.js で プラグイン で読み込む

nuxt.config.js
css: [
       '@fortawesome/fontawesome-svg-core/styles.css'
     ],
plugins: [
    {
      src: '~plugins/font-awesome',
      ssr: false
    },
     ],

使用するものだけ読み込む

plugins/fontawesome.js
import Vue from 'vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { faCalendarAlt, faTags } from '@fortawesome/free-solid-svg-icons'

library.add(faCalendarAlt, faTags)

Vue.component('fa-icon', FontAwesomeIcon)

Vue.config.productionTip = false

コンポーネントライブラリは重い

今回、CSSフレームワークとして、使ったのはBootstrap-vueでした。BootStrap-vueは、コンポーネントのライブラリとなっています。CSSだけでなくJavascriptも内部で使っています。これが使ってない部分も全て取り込まれてしまいます。自分でJSを書く必要はありますが、ここをCSSのみのライブラリに変更出来れば軽量化出来ます。
スクリーンショット 2019-12-02 16.02.08.png

まだやりたい事は全部できてませんが、

まだ、bootstrap-vueは置き換えていません。
スクリーンショット 2019-12-03 0.11.36.png
さらにGraphCMSでは記事の内容を更新したり、新規投稿したりした時にHookして何かアクションを起こしたりする事は無料では出来ないことが発覚しました。とりあえずの間に合わせで、CMSを更新したら、ターミナルからrefreshコマンドを打てばリフレッシュ出来るようにしました。ポンコツ仕様ですが、自分でCMSを作りたいというモチベーションが出来たので無駄では無いと言い聞かせます。

結果、90点以上は平均して出るようになりました。

スクリーンショット 2019-10-04 17.07.18.png

ワイ「これで緑色にはなったな」
ワイ「データ駆動やからこそ、もらうデータのパターンが決まっていてキャッシュしやすいものはキャッシュ最強やな」
ワイ「それにしてもLightHouseを使って$ nuxt build --analyze叩いているうちに重いライブラリをみると敵に見えてきてしまうようになってしもたで。」

まとめ

ブログサイトやポートフォリオページなんかの更新がそれほどリアルタイム性が必要無いものは、ストレージにキャッシュする事でいい事満載だなと思いました。フロントエンドの役割が大きくなっていくにつれて、Firebaseにバックエンドは任せて、初級者はなんでもライブラリをフロントエンドに詰め込みまくってしまいがちです。
とりあえず、FirebaseStoreのデータを全部読み込んで、複雑なデータ処理もフロントエンドで行なったりしがちです。

私がその典型でした。

しかし、出来るだけサーバーサイドにデータ処理のロジックを回したり、外部APIをから手に入れるデータがある際は、キャッシュしたりする事でフロントエンドが軽量に出来るという視点を持たなくてはならないなと感じました。

今回の内容には直接関係無いかもしれませんが、最終的な成果物です。
ポートフォリオ的Blogアプリですが、作って満足して、内容は更新できていません。汗

最終的なBlogサイトのデモページ:https://nuxt-deploy-test-teshima.appspot.com/
コード:https://github.com/yujiteshima/nuxt_blog_LT

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む