20201216のvue.jsに関する記事は14件です。

Nuxtで作るフォームの書き方 個人的まとめ (Vue v2)

こちらは Goodpatch Advent Calendar 2020 16日目の記事です。

前置き

プロジェクトで項目数が多めなフォームを作ったので、その知見を一度まとめておきたいと思い書いています。

Vue v3 がリリースされ、エコシステムも次々に対応していく中、v2 をまとめてどうすんだという感じですが、 まあ、まだ v3 を本番環境で使わないと思いますし、そこまで変わらないだろうということで。

あくまで、筆者が実際に開発していくなかで中でうまくいった形をまとめたものでして、こうすべきだ!という感じのものではありません。(っていうかもっとよいやり方があれば教えてください、本当に知りたいです、頼むよマジで)

内容については、既にさまざまな記事で書かれているものだと思いますが、何かの参考になれば幸いです。

前提

環境

Nuxt v2.14.x

データの送信方法

form の入力内容を送信する方法として、submitでページを更新して送信するパターンがありますが、本記事では APIサーバー に対してXHRで送信する形態を想定しています。

image.png

観点

この記事での「良さ」の基準は、コードの記述のしやすさ、読みやすさ、変更のしやさです。

(もし、それらのメリットを打ち消すぐらいパフォーマス等の観点でやばいことをしてたら教えてください)

フォームの書き方

フォーム例

全体構成を図示して説明していくのですが、具体的な例があったほうがわかりやすいので、以下のようなフォームを実現することを考えます。

image.png

全体の構成とデータフロー

先に、完成状態の構成とデータフローを示します。(APIの認証関連については省略)

全体構成図

image.png

以下、細かい説明をしていきます。

コンポーネントの構成

image.png

コンポーネントの構成については、Presentational and Container Component パターンを採用しています。
このパターンを使うことによって、責務が分離され、単体テストの実行やStorybookへの登録も容易になります。

パターンに従って、以下のような親子関係にしています。

<!-- ~/pages/users/_userCode/edit-profile.vue -->
<template>
  <div>
    <UserProfileFormContainer />
  </div>
</template>
<!-- ~/components/.../UserProfileFormContainer.vue -->
<template>
  <UserProfileForm :userProfile="userProfile" @update="update"/>
</template>
<!-- ~/components/.../UserProfileForm.vue -->
<template>
  <form>
    ...
  </form>
</template>

このぐらい単純な構造だと、Pageコンポーネントに直接フォームコンポーネントを置いて、Pageコンポーネントからデータを渡す、という方法もあると思います。

ただ、NuxtではPageコンポーネントにしか記述できない機能があるため、Containerの役割も持たせると肥大化してしまいます。そのため、このように3つのコンポーネントに分離することにしています。

入力中のデータ

image.png

Presentationalコンポーネントでは一般的にdataを極力持たせないのが良いといわれていますが、特別な要件が存在しない限り、フォームの入力中のデータは、フォームコンポーネント上の data に持つのが良いと思います。

というのも、仮に data を使わないとしたとき、残る候補はVuex Storeなのですが、以下のような理由で、メリットがあまりないからです。

  • 記述性の点で、v-modelを利用したいが、Storeを利用する場合、入力項目分のgetter/setterの定義が必要になる。
  • 入力中のデータなので他のページで使う用途は考えにくい。
  • リアルタイムプレビューなどが必要な場合も、プレビュー用のコンポーネントをフォーム内に配置すればよい。レンダリングされる場所は portal-vue などでいかようにもなる。
  • バリデーションルールやエラー文言などの情報を Store で扱うことになり、Storeの責務が増える。

私が開発するときは、図の通り、 inputというオブジェクトを作り、その中に入力データを集約するようにしています。

この input は、更新Actionに対してそのまま渡せるものになっていると、記述が簡単になります。

入力値の初期化

image.png

フォームを開いたとき、全て空欄の初期値のないフォームもあると思いますが、現在の設定値をフォームの初期値として表示したいことがほとんどだと思います。前項で示した通り、 input で入力データを扱いたいので、 APIから取得したデータをここにコピーします。

Storeから、Container、props経由で渡される userProfile オブジェクトは、StoreのState上のデータなので、直接編集はできません。なので、オブジェクトのshallow copyか、deep copyしたものを input に格納します。

input の初期化のタイミングに注意が必要です。親コンポーネントがどのタイミングで userProfile を更新するかは、コンポーネントは知りませんし、知っているべきではないので、userProfileがいつ更新されても input を更新できるようにしておく必要があります。

大きくわけて、コンポーネントが生成される前に渡されたパターンと、される前に渡されたパターンを考える必要があります。

その両方に対応できるのが watch です。 watch によって、userProfileがフォームコンポーネントの生成後に渡されたパターンをキャッチできます。そして watchimmediate オプションを trueにすることで、コンポーネントの create タイミングで初期化を実行できます。

<!-- ~/components/.../UserProfileForm.vue -->

<script>
// deep copyを実現するなんらかの関数
import { cloneDeep } from 'utils/common'

export default {
  props:{
  userProfile:{
      type:Object,
      default: undefined,
    }
  },
  data:{
    input: {}
  }
  watch: {
    // 元データが更新される度にinputを同期
    userProfile: {
      handler(val) {
        if (!val) {
          return
        }
        this.input = cloneDeep(val)
      },
      // この指定でcreateタイミングでもhandlerが呼ばれる
      immediate: true,
    },
  },
}
</script>

入力コンポーネント

フォームコンポーネントと入力コンポーネントは、 input の要素との v-model のみで繋げられるようになっていると記述が楽になります。

<input> などの要素や、外部ライブラリこの仕様に準拠していますが、カスタムコンポーネントもそのようになっていることが望ましいです。

TextBox は以下のように、 model を指定し、 input イベント時に入力値を返すことによって、 <input> と同様にふるまいます。

<!-- ~/components/.../UserProfileForm.vue -->
<template>
  <TextBox label="ユーザー名" v-model="input.userName" />
</template>
<!-- ~/components/.../TextBox.vue --> 
<template>
  <input
    class="text-box"
    v-bind="$attrs"
    :value="value"
    :class="{ error }"
    @keydown.enter.prevent
    v-on="listeners"
  />
</template>

<script>
import Vue from 'vue'
export default {
  inheritAttrs: false,
  model: {
    prop: 'value',
    event: 'input',
  },
  props: {
    value: {
      type: String,
      default: undefined,
    },
    error: {
      type: Boolean,
      default: false,
    },
  },
  computed: {
    listeners(): any {
      const vm = this
      return {
        ...this.$listeners,
        input(event) {
          return vm.$emit('input', event.target.value)
        },
      }
    },
  },
}

コンポーネントがv-modelに対応していなかったり、対応していても値の変換が必要になったりすると、フォームコンポーネントのやることが増えてしまいます。

変換処理をユーティリティ関数や、値を変換する機能をもった専用のTextBoxに逃がすなどして、フォームコンポーネント上の記述をすっきりさせると、項目追加やレイアウト変更に柔軟に対応することができます。

バリデーション

フォームにつきもののバリデーションについて。

素朴な書き方

今回の例のような簡単なフォームであれば、Vueのクックブックにあるように、if文を繰り返す方法もあります。

https://jp.vuejs.org/v2/cookbook/form-validation.html

checkForm()
  this.errors = []

  if(!input.icon){
    this.error.push("アイコンは必須です")
  } 

  if(!input.userName){
    this.error.push("ユーザー名は必須です")
  } else if(input.user.length > 100){
    this.error.push("ユーザー名は100文字以下です")
  }

  if(!input.prefecture){
    this.error.push("県名は必須です")
  } 

  if(!input.birthday){
    this.error.push("県名は必須です")
  } 

}

しかし、項目が多くなってきたり、くると、チェックする関数が肥大化し、読みにくくなってきます。

checkForm()
  this.errors = []

  if(!input.alpha){
    this.error.push('alphaは必須')
  }
  if(!input.beta){
    this.error.push('bataは必須')
  }
  .
  .
  .
  if(!input.theta){
    this.error.push('thetaは必須')
  } else if (/なんらかのパターン/.test(input.theta)) ({
    this.error.push('thetaは必須')
  }
  .
  .
  .
  .
  if(!input.omega){
    this.error.push('omegaは必須')
  }
}

手続的に書くと、確認しにくくバグを生みやすくなるので、宣言的にルールを記述できるようにするのが望ましいです。

ライブラリの利用

一例として、VeeValidate というライブラリを用いて記述してみます。

フォームコンポーネントに肥大化した関数を配置することなく、 template での宣言的な記述ですっきりと記述できます。

<!-- ~/components/.../UserProfileForm.vue -->
<template>
  <ValidationObserver slim>
    <form>
      <ValidationProvider name="ユーザー名" rules="required" v-slot="{ errors }">
        <TextBox label="ユーザー名" v-model="input.userName" />
        <ul>
          <li class="error" v-for="error in errors" v-text="error"/>
        <ul />
      </ValidationProvider>
      .
      .
      .
    </form>
  </ValidationObserver>
</template>
// 表示時のテキスト設定
extend('required', {
  ...required,
 // requiredに違反したとき、以下のエラーメッセージが表示される
 // {_field_} は ValidationProvider の name propの値が使用される。
  message: '{_field_}は必須です',
})

ページ遷移前の確認ダイアログ

フォームの入力中に、ページ遷移やタブを閉じるなどの入力内容が消えてしまう操作に対しては、ユーザーに確認を求める必要があります。

ユーザーの操作は大きく2つにわけられ、それぞれに対応が必要です。

リロード・別サイトへの移動・タブクローズ時に確認を出したいときには beforeunload イベントを利用します。(ブラウザの機能を使うため、ブラウザによって動作が異なることがあります)

<!-- ~/pages/users/_userCode/edit-profile.vue -->
<script>
export default {
  created () {
    window.addEventListener("beforeunload", this.onBeforeUnload)
  },
  destroyed () {
    window.removeEventListener("beforeunload", this.onBeforeUnload)
  },
  methods: {
    onBeforeUnload (e) {
       e.preventDefault()
       e.returnValue = ''
    },
  }
}
</script>

サイト内の別のページへ遷移時に確認を出したいときは、 Nuxtのページコンポーネントのみで使用できる Navigation Guard フックである beforeLeavePage を利用します。

<!-- ~/pages/users/_userCode/edit-profile.vue -->
<script>
export default {
  beforeRouteLeave (to, from, next) {
    const result = confirm("ページを離れると現在の編集内容が失われます。よろしいですか?")
    if (result) {
      next()
    } 
    else{
      next(false)
    }
}
</script>

フォームの入力内容が変更されたか検知する

前項のページ遷移前の確認ダイアログは、フォームの内容を変更していないのに毎回出てくると面倒だと感じることがあります。(開発していると特に)

フォームの入力内容を変更したときだけ、確認ダイアログを出したい場合どうすれば良いか。

先に結論のコードを貼ります。

<!-- ~/components/.../UserProfileForm.vue -->
<script>
// deep copyを実現するなんらかの関数
import { cloneDeep } from 'utils/common'

export default {
  props:{
    userProfile:{
      type:Object,
      default: undefined,
    }
  },
  data() {
    return {
      input: {},
      unwatchInput: undefined,
      dirty: false, // 入力内容を書き換えた場合 true になる
    }
  },
  watch: {
    // 元データが更新される度にinputを同期
    userProfile: {
      handler(val) {
        if (!val) {
          return
        }
        this.unwatchInput?.()
        this.input = cloneDeep(val)
        this.dirty = false  // inputが同期されたので dirtyフラグをクリア

        // inputが同期後に更新されたとき、一度だけdirtyをtrueにする
        this.unwatchInput = this.$watch(
          'input',
          () => {
            this.dirty = true
            this.unwatchInput?.()
          },
          { deep: true }
        )
      },
      immediate: true,
    },
    dirty(val) {
      this.$emit('update:dirty', val)
    },
  },
</script>

まず、フォームコンポーネントの data として入力内容が変化したかを現わす dirty を持たせ、dirtyの変更時には $emit('update:dirty') を実行するようにします。

dirtyがコンテナーコンポーネントから渡されている userProfileinput が同期しているかどうかを現わすようにするため、userProfile の更新時と、 input の更新をそれぞれ監視します。ただし、 input の更新監視は、初回のみでよいため、 $watch を使用し、最初に変更があったタイミングで unwatchInput を実行することによって、監視を停止しています。

あとは、 v-bind:dirty.sync を用いて、 dirty の値をページコンポーネントまで引き込み、ページ遷移時にフォームを表示するか否かの判定を行うようにすればOKです。

<!-- ~/pages/users/_userCode/edit-profile.vue -->
<script>
export default {
  created () {
    window.addEventListener("beforeunload", this.onBeforeUnload)
  },
  destroyed () {
    window.removeEventListener("beforeunload", this.onBeforeUnload)
  },
  beforeRouteLeave (to, from, next) {
    if (this.dirty) {
      const result = confirm("ページを離れると現在の編集内容が失われます。よろしいですか?")
      if(result)
      {
        next()
      } 
      else{
        next(false)
      }
    }
  }
  methods: {
    onBeforeUnload (e) {
       if(!this.dirty) return
       e.preventDefault()
       e.returnValue = ''
    },
  }
}
</script>

おわりに

入力フォームを作るときの、全体の構成から細かい設定まで、いくつかまとめてみました。
説明不足なところは書き足したり、別の記事を書いていきたいと思います。
ありがとうございました。

明日の Goodpatch Advent Calendar 2020@yahharo さんです。

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

Vue.jsでコンポーネント間でデータを受け渡す

こちらの記事はユアマイスターアドベントカレンダー2020の16日目の記事です
こんにちは! エンジニアインターンをしているツキヤマです。

はじめに

最近Vue.jsの勉強を始めてコンポーネントにおける親子間のデータの受け渡しについて学んだ時に結構複雑だったので備忘録として記事にまとめました。
今回は親コンポーネント(App.vue)と子コンポーネント(Hoge.vue)を使用していきたいと思います

親から子コンポーネントへのデータの受け渡し

親から子は簡単
親から子へのデータの受け渡しにはpropsを使用します。
propsは親コンポーネントから受け取りたいデータを配列で指定してあげます(今回はシンプルに数値だけを受け取りたいのでnumberだけです)

Hoge.vue
<template>
    <div>
        <p>{{number}}</p>
    </div>
</template>

<script>
export default {
    props: ['number'],
};
</script>

子コンポーネントで指定したprops(今回はnumber)を属性として定義してあげて値を指定すると子コンポーネントに指定された値(number="6")が渡されます

App.vue
<template>
    <div>
        <Hoge number="6"></Hoge>
    </div>
</template>

<script>
import Hoge from "./components/Hoge.vue"

export default {
    components: {
        Hoge,
    }
};
</script>

もちろんv-bindを使えば動的に扱うこともできます

App.vue
<template>
    <div>
        <Hoge v-bind:number="number"></Hoge> // dataで指定したnumber(10)が渡される
    </div>
</template>

<script>
import Hoge from "./components/Hoge.vue"

export default {
    data() {
        return {
            number: 10
        };
    },
    components: {
        Hoge,
    }
};
</script>

ちなみにpropsはバリデーションをかけることもできます

Hoge.vue
<script>
export default {
    props: {
        number: Number
    }
};
</script>

バリデーションを使用する場合propsは配列ではなくObject型で指定します
keyに親コンポーネントから受け取るデータ名
valueに型を指定できます
今回は受け取るnumberは数値なので型もNumberを指定しました
試しにnumberに文字列を指定するとコンソールで怒られました

console.log
vue.runtime.esm.js?2b0e:619 [Vue warn]: Invalid prop: type check failed for prop "number". Expected Number with value NaN, got String with value "hoge".

バリデーションには他にも

Hoge.vue
<script>
export default {
    props: {
        number: {
            type: Number, // 型の指定
            required: true, // 必ずこの属性が必要かどうか
            default: 10, // 属性が指定されなかった時のdefalt値
        }
    }
};
</script>

さらにObjectを指定して型以外のバリデーションも指定できます。
requireddefaultは共存できません

まとめ

propsはデータの受け取り口
属性の値がデータの送り口

子から親コンポーネントへのデータの受け渡し

子コンポーネントから親コンポーネントへのデータの受け渡しには$emitを使います。
結構難しいのでまずはサンプルコードから

App.vue
<template>
    <div>
        <Hoge v-bind:number="number" v-on:add-click="number = $event"></Hoge>
    </div>
</template>

<script>
import Hoge from "./components/Hoge.vue"

export default {
    data() {
        return {
            number: 10
        };
    },
    components: {
        Hoge,
    }
};
</script>
Hoge.vue
<template>
    <div>
        <p>{{number}}</p>
        <button v-on:click="increment">+1</button>
    </div>
</template>

<script>
export default {
    props: ['number'],
    methods: {
        increment() {
            this.$emit("add-click", this.number + 1);
        }
    }
};
</script>

ボタンを押したらpropsで親から受け取ったnumberが+1されるものを作りました

$emitとはなんなのか?

$emitはカスタムイベントを作るもの
第一引数にカスタムイベントの名前(なんでもいい)
第二引数に渡すデータを入れる(今回は親から受け取ったnumberを+1した値)
this.$emit("add-click", this.number + 1);

親コンポーネントでデータを受け取る

子コンポーネントの$emitで作ったカスタムイベントをv-onで発火させる
$eventには$emitの第二引数で指定しているデータが入っていて、そのデータをnumberに代入する
<Hoge v-bind:number="number" v-on:add-click="number = $event"></Hoge>

まとめ

$emitは「データを子から親に渡すもの」というよりかは子コンポーネントのタイミングで親コンポーネントのメソッドを発火させるもの、で発火した時についでにデータも送っているイメージ。難しい...

最後に

データの受け渡しは
親→子
親←子
の矢印の向きが変わるだけで全然違う。

Vue.jsは親から子にしかデータは渡せない。子から親にデータを渡しているというよりかは$emitでイベントを発火させて値を処理するのは親の仕事。

親が子供の世話をするはプログラムの世界でも一緒だなと実感しました

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

Hosting Vue.js site on AWS-S3 with Cloudflare

Hosting on S3 with Cloudflare

I wanted to see if you could create a serverless site with high availability and low cost without any major maintenance.

This is where the AWS S3 bucket came in. S3 buckets can do Static website hosting and are in general a cheap option for cloud storage, and have high availability. Not to mention, a S3 hosted website will basically not go down as long as the AWS servers are running.

However, one issue with S3 is that it does not do caching by itself and since you pay for each request this could potentially cause the monthly cost to spike if you get a lot of traffic.

In order to remedy this, I found that Cloudflare could be used with S3, which is a cloud solution that allows caching of website resources through the Cloudflare servers. If I stay within their free plan, I can use Cloudflare for free. Another option would have been AWS Cloudfront, but that solution is quite expensive.

So, Combining S3 with Cloudflare seemed like the most logical solution for my case. While S3 is limited to hosting HTML, CSS and Javascript. It seemed like a chance to try out some Vue.js to see how well it runs on S3.

The setup

After doing some research I found this setup to be the simplest to implement, while leaving some room for improvement.

The infrastructure

Before getting into the nitty-gritty, I thought I would go though the infrastructure first.
This is a simple overview of my setup and how I imagine the data flow.
Screen Shot 2020-12-16 at 15.53.40.png
1. Users access the domain which goes to the DNS server
2. The Domain ns records are set to point at the Cloudflare name servers.
3. Cloudflare accepts requests from the DNS and direct them to the target S3 bucket endpoint
4. The S3 buckets serves the resources to Cloudflare.
5. Cloudflare caches the resources and delivers them to the end user.

Requirements before starting

  • A domain name
  • AWS account
  • An hour or so of your time.
  • Coffee

Step 1: Create S3 bucket

  1. Go to your S3 page in the AWS console. Link: https://s3.console.aws.amazon.com/s3
  2. Press "Create bucket".
  3. Set the bucket name to your root domain name.
  4. Under Bucket settings uncheck "Block all public access".
  5. Make sure to check the following:

 ・Block public access to buckets and objects granted through new access control lists (ACLs)
 ・Block public access to buckets and objects granted through any access control lists (ACLs)
6. Add tags if you need it. (Good practice)
7. Create bucket.

Step 2: Configure S3 bucket

  1. After creating the bucket, open the bucket and go to Properties
  2. Press Edit under Static website hosting and enable it.
  3. Choose Host a static website.
  4. Under Index Document make sure it is set to index.html. This will be the main file for each directory.
  5. Add an Error Document if you want to. This will be the default error page it will redirect to.
  6. Save settings.

Step 3: Set S3 bucket permissions

  1. The bucket is currently set to be public, so you need to limit the access to it.
  2. Go to Permissions in your bucket.
  3. Under Bucket policy add a new policy and copy the following:

Bucket Policy Sample
qiita.rb
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "PublicReadGetObject",
            "Effect": "Allow",
            "Principal": "*",
            "Action": "s3:GetObject",
            "Resource": "arn:aws:s3:::example.com/*",
            "Condition": {
                "IpAddress": {
                    "aws:SourceIp": [
                        "2400:cb00::/32",
                        "2405:8100::/32",
                        "2405:b500::/32",
                        "2606:4700::/32",
                        "2803:f800::/32",
                        "2c0f:f248::/32",
                        "2a06:98c0::/29",
                        "103.21.244.0/22",
                        "103.22.200.0/22",
                        "103.31.4.0/22",
                        "104.16.0.0/12",
                        "108.162.192.0/18",
                        "131.0.72.0/22",
                        "141.101.64.0/18",
                        "162.158.0.0/15",
                        "172.64.0.0/13",
                        "173.245.48.0/20",
                        "188.114.96.0/20",
                        "190.93.240.0/20",
                        "197.234.240.0/22",
                        "198.41.128.0/17"
                    ]
                }
            }
        }
    ]
}


 ・Remember to replace "example.com" with your own bucket name.
4. This bucket policy will make it so only IP addresses from Cloudflare will be allowed.
5. Save and your are done.

Step 4: Upload Vue files (or static files)

I will not go too deep into this topic and just cover the part on what you need to upload.
1. Using Vue-cli build your vue project
2. Check the contents of your build. Usually ~/dist
3. Upload the contents from your build to your S3 bucket, make sure index.html is included and in the top directory.
4. That's it.

Step 5: Setup Cloudflare

Setting up Cloudflare for S3.
1. Create a Cloudflare account with a free plan.
Link: https://www.cloudflare.com/
2. Register your domain name on Cloudflare
3. Setup a CNAME for the root domain (and additional sub-domains)
・The NAME should be your root domain.
-if you have any sub-domains you register just the first part(eg. www.).
・The TARGET should be your S3 Bucket Endpoint.
・Press Save when done.
4. Next, Cloudflare requires you to set your domain NS records to point at two Cloudlfare NS records.
・This step might be different depending on your domain registrar.
・Cloudflare has a guide on how to setup the NS records on some popular domain sites here
・Remember to set the TTL to a low number on your NS record so you don't have to wait too long.
5. After the setup you can request Cloudflare to check if the domain setup has gone through.
6. If no issues occur Cloudflare will tell you it succeeded and you can now use your site.

STEP 6: Testing the site

Access your domain, it should now display your site, if all settings are correct.

If for any reason you get a 403 error, it is most likely caused by your S3 bucket permissions.
Any other issues might be caused by the DNS settings or the Cloudflare cache.

That is it, you now have an S3 hosted website.

Final thoughts

I think you can easily expand on this solution by integrating Lamda, Rest or gRPC to make a more dynamic site, while still keeping it some what serverless and low cost. I have yet tried this solution for an extended period of time, but I don't imagine that the monthly costs will go above 10~15$.

I do realize there are other VPC solutions that can achieve the same results as S3, which is something that deserves some exploring.

Overall I am satisfied with the end results, but I believe there is room for tweaking.

Resources

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

[Vue.js]ref属性を使って子コンポーネントインスタンスと子要素へのアクセスする

はじめに

refを初めて使用したので、備忘録として残します

ref属性とは

プロパティとイベントが存在するにも関わらず、ときどき JavaScript で直接子コンポーネントにアクセスする必要がある。
その場合に ref 属性を使うと、子コンポーネントにリファレンス ID を割り当てることができる

実装方法

今回はインプットフォームにフォーカスさせる

①インプットフォームにref属性を付与する

<input ref="input">

②親によって使用されるメソッドを定義して親コンポーネントに 内部の input 要素にフォーカスさせる

methods: {
  // 親からインプット要素をフォーカスするために使われる
  focus: function () {
    this.$refs.input.focus()
  }
}

これらの実装でfocusメソッドが動いた時にrefを付与したインプットフォームにフォーカスされる

参考

https://jp.vuejs.org/v2/guide/components-edge-cases.html#%E5%AD%90%E3%82%B3%E3%83%B3%E3%83%9D%E3%83%BC%E3%83%8D%E3%83%B3%E3%83%88%E3%82%A4%E3%83%B3%E3%82%B9%E3%82%BF%E3%83%B3%E3%82%B9%E3%81%A8%E5%AD%90%E8%A6%81%E7%B4%A0%E3%81%B8%E3%81%AE%E3%82%A2%E3%82%AF%E3%82%BB%E3%82%B9

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

Flask, Vue.js, OpenCV, Pytorchで画像認識アプリをHerokuにデプロイする

MNISTで学習したモデルを使って複数桁の数字を認識できないかと思った.ついでにWebアプリとしてデプロイもしてみた.
- コード
- アプリ

作業工程

  • 機械学習モデルを学習
  • 複数桁の数字を一桁の数字へ変換するモジュールを作成
  • Flask & Vue.jsのWebアプリを作成
  • Herokuへデプロイ

機械学習モデルを学習

PyTorch MNIST example - GitHubを参考にして学習.
学習に使用したコード→ ./server/modules/mnist.py

複数桁の数字を一桁の数字へ変換するモジュールを作成

コード→ ./server/modules/processes.py
メソッドについて説明する.

__init__()

コンストラクタではフロントから送信された1次元の画像データ(1channel)と画像のサイズ(width, height)を受け取り,(width, height, channel)へ変換する.
例) MNISTだったら (784, ) → (28, 28, 3)

_labeling()

OpenCVを使って,画像を2値化・ラベリング処理し,それぞれを正方形の画像データに変換する(_to_square()を使用).

_to_square()

MARGIN=5,つまりラベル付けされたピクセルから縦横方向に最低5ピクセル余白を取った正方形画像データに変換する.

divide_to_digit()

_labeling(),_to_squre()で変換したそれぞれの画像をbase64でエンコードする.(あとでフロントに渡すため)

Flask & Vue.jsのWebアプリを作成

フロンエンド

package.jsonは以下のようになっている.開発時はnpm run watchを実行すると便利.コンパイルされたファイルが./distに作成される.

"scripts": {
    "serve": "vue-cli-service serve",
    "build": "vue-cli-service build",
    "lint": "vue-cli-service lint",
    "watch": "vue-cli-service build --watch"  # 追加文
  }

サーバサイド

以下のパッケージをインストールする.
- Flask
- Flask-Cors
- gunicorn
- numpy
- opencv-python
- Pillow
- torch
- torchvision

開発時はpython server/app.pyを実行してサーバを立ち上げる.→ http://127.0.0.1:5000

Herokuへデプロイ

まず,Heroku CLIをインストールする.

その後,ログインしてHerokuへpushする.

heroku login
git add.
git commit -am "[update]"
git push heroku main

最後に

Herokuにデプロイする時に,OpenCVやPytorchが結構厄介だった.

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

Laravel + Vue.js の知識をアウトプットしていく

次の記事でLaravelをアウトプットしていますが、今回はLaravel + Vueでまとめていきます。
Laraveの知識をアウトプットして、資産化してます

準備

$ composer create-project laravel/laravel sampleproject --prefer-dist "7.*"

プロジェクトの作成を実施

もろもろの初期設定はこちらの記事を参考にしてください
Laraveの知識をアウトプットして、資産化してます

認証機能をインストール
$ composer require laravel/ui:^2.4 --dev
vueのインストール
$ php artisan ui vue --auth
$ npm install bootstrap-vue bootstrap
$ npm install && npm run dev

vue-routerのインストール

vue-routerのインストール
npm install --save vue-router

下記を追加する。

resources/app.js
import VueRouter from 'vue-router';

window.Vue = require("vue");
Vue.use(VueRouter);
const router = new VueRouter({
    mode: "history",
    routes: [
        {
            path: "/tasks",
            name: "task.list",
            component: TaskListComponent
        }
    ]
});

const app = new Vue({
    // (1) mountする要素。<div id="app">なので、#app
    el: "#app",
    router
});

app.blade.phpに<router-view>を追加します

app.blade.phpにrouter-viewを追加
<body>
    <div id="app">
        <router-view></router-view>
    </div>
    <!-- Scripts -->
    <script src="{{ mix('js/app.js') }}"></script>
</body>

urlをつける場合は公式ドキュメントのこちら

Vuexのインストール

stateで情報をやりとりするために利用する。
ドキュメントはこちら

$ npm install --save-dev vuex
npm run dev

vue-routeをimportする

Laravel Mixで読み込み

mixに変更
<!-- Styles -->
<link href="{{ mix('css/app.css') }}" rel="stylesheet">

<!-- Script -->
<script src="{{ mix('/js/app.js') }}" defer></script>

デバッグのインストール

composer require itsgoingd/clockwork

ddのように変数をデバッグ可能

clock(User::all());

流れ

  1. コンポーネントの作成
  2. app.jsにimport
  3. HTMLにコンポーメントを追加して表示。
  4. vue-routerでURLと画面を切り替え

コンポーネント(Component)の作成

components/HeaderComponent.vue
<template>
  <div class="container-fluid bg-dark mb-3">
    <div class="container">
      <nav class="navbar navbar-dark">
        <span class="navbar-brand mb-0 h1">Vue Laravel SPA</span>
        <div>
          <button class="btn btn-success">List</button>
          <button class="btn btn-success">ADD</button>
        </div>
      </nav>
    </div>
  </div>
</template>

<script>
export default {};
</script>

app.jsにimport

app.js
// import
import HeaderComponent from "./components/HeaderComponent";

// componentのタグ名を決める
Vue.component("header-component", HeaderComponent);

HTMLにコンポーメントを追加して表示。

example-componentの読み込み
<div id="app">
  <header-component></header-component>
  <router-view></router-view>
</div>

vue-routerでURLと画面を切り替え

直接componentを挿入する場合は、Vue.componentで挿入するが、ページ遷移ごとに挿入させるcomponentを切り替えたい場合は、Vue-routerで切り替えていきます。

Vue.use(VueRouter);
const router = new VueRouter({
    mode: "history",
    routes: [
        {
            path: "/tasks",
            name: "task.list",
            component: TaskListComponent
        },
        {
            path: "/tasks/:taskId",
            name: "task.show",
            component: TaskShowComponent,
            props: true
        },
        {
            path: "/tasks/create",
            name: "task.show",
            component: TaskShowComponent,
            props: true
        }
    ]
});

const app = new Vue({
    el: "#app",
    router
});

上記のVue-routerの記述で、pathごとのcomponentを定義している。
このcomponentは<router-view>に挿入される。それでpathごとに異なるcomponentが挿入されてページ遷移される。

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

CRUD機能の実装

ログイン機能の実装

Laravel 7.x Laravel Sanctum
参考になるQiita記事

sanctumインストール

コマンド
composer require laravel/sanctum

構成ファイルの公開を実施します

コマンド
php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"

マイグレーションの実行

コマンド
php artisan migrate

CreatePersonalAccessTokensTableが実行され、personal_access_tokensテーブルが追加されます。
また、config/sanctum.phpも追加されます

Kernel.phpにsanctumのミドルウェアを追加

SPAの認証として利用できるように、Kernel.phpファイルのapiミドルウェアにSanctumのミドルウェアを追記します。これでAPIに対するリクエストでセッション・クッキーによる自動認証が可能となります。

Http/Kernel.php
<?php

namespace App\Http;

use Illuminate\Foundation\Http\Kernel as HttpKernel;
// ↓追加
use Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful; 

class Kernel extends HttpKernel
{
    /**
     * The application's global HTTP middleware stack.
     *
     * These middleware are run during every request to your application.
     *
     * @var array
     */
    protected $middleware = [
        \App\Http\Middleware\TrustProxies::class,
        \App\Http\Middleware\CheckForMaintenanceMode::class,
        \Illuminate\Foundation\Http\Middleware\ValidatePostSize::class,
        \App\Http\Middleware\TrimStrings::class,
        \Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class,
    ];

    /**
     * The application's route middleware groups.
     *
     * @var array
     */
    protected $middlewareGroups = [
        'web' => [
            \App\Http\Middleware\EncryptCookies::class,
            \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
            \Illuminate\Session\Middleware\StartSession::class,
            // \Illuminate\Session\Middleware\AuthenticateSession::class,
            \Illuminate\View\Middleware\ShareErrorsFromSession::class,
            \App\Http\Middleware\VerifyCsrfToken::class,
            \Illuminate\Routing\Middleware\SubstituteBindings::class,
        ],

        'api' => [
            EnsureFrontendRequestsAreStateful::class, //追加
            'throttle:60,1',
            'bindings',
        ],
    ];
    ~~
}

コントローラーの作成

ログイン用のコントローラーを作成
php artisan make:controller LoginController

自前のログイン処理を作成するために、Auth::attemptを記述していく。

LoginController.php
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;

class LoginController extends Controller
{
    public function login(Request $request)
    {
        //validation
        $credentials = $request->validate([
            'email' => 'required|email',
            'password' => 'required'
        ]);

        //認証処理
        if (Auth::attempt($credentials)) {
            //認証に成功した場合
            return response()->json(['message' => 'Login successful'], 200);
        }

        //エラーメッセージの作成
        throw ValidationException::withMessages([
            'email' => ['The provided credentials are incorrect'],
        ]);
    }

    //logout処理を追加 
    public function logout()
    {
        //ログアウトの実行
        Auth::logout();
        //ログアウト成功したレスポンスをreturnする。
        return response()->json(['message' => 'Logged out'], 200);
    }
}

自前のエラー文を作成するために、use Illuminate\Validation\ValidationException;
$validator->errors()->add($key, $message)することで、自由にメッセージを追加することができます。
こちらの記事も参考になります
GitHubはこちら

throwについて

ValidationExceptionの記述
$validator = Validator::make([], []);
$validator->errors()->add('title', 'タイトルのエラーです。');
throw new ValidationException($validator);

// もしくは

throw ValidationException::withMessages(['title' => 'タイトルのエラーです。']);
例外処理の基本
<?php

try {
    // 例外が発生する可能性のあるコード
} catch (Exception $e) {
    // 例外が発生した場合に行う処理
}

?>

ルーティングの記述

APIでの認証を実施するため、api.phpでルーティングを記述する。

api.php
<?php

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;

Route::middleware('auth:sanctum')->get('/user', function (Request $request) {
    return $request->user();
});

Route::post('/login', 'LoginController@login');
Route::post('/logout', 'LoginController@logout');

その他

リダイレクト

this.$router.push({name: 'task.list'});でnameを指定してリダイレクトすることが可能

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

Vue3.0 + TypeScript + Mapbox GL JSの環境構築

はじめに

Vue.jsが好きです(告白)

最近は寝ても醒めてもVueの事ばかり考えてしまって、食事はごはんとおかずとお味噌汁くらいしか喉を通らず、夜も8時間くらいしか眠れません

そんな注目の的のVue、今年v3.0が正式リリースされ、そろそろ勉強しておこうと試した訳ですが、マップライブラリ周りの情報が少ないような気がしたので、本記事ではVue3.0 + TypeScript + Mapbox GL JSの環境構築方法をまとめます

Vue.js最高!

もくじ

Vue3.0プロジェクトの生成

まずCLIツールを更新しておきましょう

npm install -g @vue/cli

お好みのディレクトリで以下のコマンドで、Vueアプリケーションを対話形式で生成出来ます

vue create newapp

Vue3.0+TypeScript環境をつくるために、以下のとおり入力していきます

Vue CLI v4.5.9
? Please pick a preset: Manually select features
? Check the features needed for your project: 
 ◉ Choose Vue version
 ◉ Babel
❯◉ TypeScript
 ◯ Progressive Web App (PWA) Support
 ◯ Router
 ◯ Vuex
 ◯ CSS Pre-processors
 ◉ Linter / Formatter
 ◯ Unit Testing
 ◯ E2E Testing
? Choose a version of Vue.js that you want to start the project with 3.x (Preview)
? Use class-style component syntax? No
? Use Babel alongside TypeScript (required for modern mode, auto-detected polyfill
s, transpiling JSX)? Yes
? Pick a linter / formatter config: Basic? Pick additional lint features: (Press <space> to select, <a> to toggle all, <i> 
to invert selection)
? Where do you prefer placing config for Babel, ESLint, etc.? In dedicated confi
g files
? Save this as a preset for future projects? No

すると、上記の例では「newapp」というディレクトリが生成され、プロジェクトファイル一式が出力されます

以下のコマンドで

cd newapp
npm install
npm run serve

いつものウェルカムページが表示されます

スクリーンショット 2020-12-13 8.29.23.png

Mapbox GL JSの環境構築

インストール

いつものようにnpm install mapbox-glとすると、一悶着あった最新のv2.0がインストールされ、Mapbox APIトークンが必要になります(Mapboxのデータを使わなくても、マップの読み込みの都度APIアクセスが発生します)。

ということで、とりあえず従来と同じように使いたかったら

npm install mapbox-gl@v1.13.0

と、OSS時代の最終バージョンを指定しましょう

型定義も忘れずに…

npm install @types/mapbox-gl

地図コンポーネントの作成

デフォルト

HelloWorld.vue
<template>
  <div class="hello">
    <h1>{{ msg }}</h1>
    <p>
      For a guide and recipes on how to configure / customize this project,<br>
      check out the
      <a href="https://cli.vuejs.org" target="_blank" rel="noopener">vue-cli documentation</a>.
    </p>
    <h3>Installed CLI Plugins</h3>
    <ul>
      <li><a href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-babel" target="_blank" rel="noopener">babel</a></li>
      <li><a href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-typescript" target="_blank" rel="noopener">typescript</a></li>
      <li><a href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-eslint" target="_blank" rel="noopener">eslint</a></li>
    </ul>
    <h3>Essential Links</h3>
    <ul>
      <li><a href="https://vuejs.org" target="_blank" rel="noopener">Core Docs</a></li>
      <li><a href="https://forum.vuejs.org" target="_blank" rel="noopener">Forum</a></li>
      <li><a href="https://chat.vuejs.org" target="_blank" rel="noopener">Community Chat</a></li>
      <li><a href="https://twitter.com/vuejs" target="_blank" rel="noopener">Twitter</a></li>
      <li><a href="https://news.vuejs.org" target="_blank" rel="noopener">News</a></li>
    </ul>
    <h3>Ecosystem</h3>
    <ul>
      <li><a href="https://router.vuejs.org" target="_blank" rel="noopener">vue-router</a></li>
      <li><a href="https://vuex.vuejs.org" target="_blank" rel="noopener">vuex</a></li>
      <li><a href="https://github.com/vuejs/vue-devtools#vue-devtools" target="_blank" rel="noopener">vue-devtools</a></li>
      <li><a href="https://vue-loader.vuejs.org" target="_blank" rel="noopener">vue-loader</a></li>
      <li><a href="https://github.com/vuejs/awesome-vue" target="_blank" rel="noopener">awesome-vue</a></li>
    </ul>
  </div>
</template>

<script lang="ts">
import { defineComponent } from 'vue';

export default defineComponent({
  name: 'HelloWorld',
  props: {
    msg: String,
  },
});
</script>

<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
h3 {
  margin: 40px 0 0;
}
ul {
  list-style-type: none;
  padding: 0;
}
li {
  display: inline-block;
  margin: 0 10px;
}
a {
  color: #42b983;
}
</style>

Mapbox GL JS導入

HelloWorld.vue
<template>
    <div>
        {{ msg }}
        <div id="map" />
    </div>
</template>

<script lang="ts">
import mapboxgl from 'mapbox-gl';
import 'mapbox-gl/dist/mapbox-gl.css';

import { defineComponent, onMounted, reactive } from 'vue';
export default defineComponent({
    name: 'HelloWorld',
    props: {
        msg: String,
    },
    setup(props, context) {
        const mapstyle = reactive<mapboxgl.Style>({
            version: 8,
            sources: {
                OSM: {
                    type: 'raster',
                    tiles: ['http://tile.openstreetmap.org/{z}/{x}/{y}.png'],
                    tileSize: 256,
                    attribution:
                        '<a href="http://osm.org/copyright">© OpenStreetMap contributors</a>',
                },
            },
            layers: [
                {
                    id: 'OSM',
                    type: 'raster',
                    source: 'OSM',
                    minzoom: 0,
                    maxzoom: 18,
                },
            ],
        });
        onMounted(() => {
            const map = new mapboxgl.Map({
                container: 'map',
                style: mapstyle,
                center: [140.0, 38.2],
                zoom: 9,
                maxZoom: 18,
            });
        });
    },
});
</script>

<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
#map {
    height: 500px;
}
</style>

するとこうなる

スクリーンショット 2020-12-13 8.43.34.png

ここで、見慣れないsetup()という関数以下が、CompositionAPIなるものです。これまでVueを使っていたらすぐに使えそうな感触でした(ほんとはこの辺もまとめたかったのですが時間的なアレで割愛)。

VSCodeの設定

これで動くっちゃ動くんですが、もしかしたらマウスホバーしても変数定義は見られないし、コード補完も効いてないんじゃないでしょうか。TypeScriptの魅力の4割くらいはそこにあると思っているので、このままではいけません。

マウスホバーとコード補完

エクステンション「Vetur」が必要です。

スクリーンショット 2020-12-13 8.47.55.png

https://marketplace.visualstudio.com/items?itemName=octref.vetur

コードフォーマット

で、私の環境だとコード補完だとかはこれで解決したんですが、コードフォーマットが効かなくなりました。Veturのインストール直後からです。これは、VSCodeのフォーマットは「Prettier」に任せているからでした。Veturにもフォーマット機能が内蔵されていて競合するっぽいので、エクステンションの設定から

スクリーンショット 2020-12-13 8.50.26.png

Vetur > Format: Enableという項目のチェックを外したら解決しました。

おわりに

まだ実際にこの構成でアプリケーションを作ってはいませんが、ただでさえ最高なVue.jsにTypeScriptが加わる事でアホほど快適な開発環境になるのではないでしょうか。Mapbox GL JSはVue.jsとの相性も非常によく(個人の感想です)、さらにTypeScriptによる強力なコード補完で向かうところ敵なしです。

さぁみなさんもご一緒に

Vue.js最高!

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

【Vue版Gatsby】Gridosomeで自作ブログ作ってみた~プロジェクト作成からブログの表示まで~

パーソンリンクアドベントカレンダー19日の記事です!
自作ブログ作成に挑戦しました!

Gridsomeってなに?

GridsomeはVue.jsで書けるJAMStackフレームワーク
Vueで書けるJAMStackフレームワークとしては他にも、NuxtとかVuePressとかあります。
JAMStack公式ページに色々紹介されているのでそちらを御覧いただけるといいんじゃないかなって思います。
そもそもJAMStackがわからないっていう方はこちらのサイトをみていただけるといいんじゃないかなあと思います。

Gridsomeって何がいいの?

  • Vueでマークダウンでブログとかかける
  • Vueのプラグインが使える
  • GraphQLのちょっとした勉強にもなる
  • パフォーマンスの向上
  • より高いセキュリティ
  • 高速
  • JAMStackの公式ページに他にもメリットがたくさん書かれていますのでみてみてください

Gridsomeをはじめよう

GridsomeCLIのインストール

YARNの使用:yarn global add @gridsome/cli
NPMの使用:npm install --global @gridsome/cli

Gridsomeプロジェクトを作成します

gridsome create my-gridsome-site
  新しいプロジェクトを作成するには
cd my-gridsome-site 
  フォルダを開く
gridsome develop 
  ローカル開発サーバーを起動するには http://localhost:8080
  ハッピーコーディング??

インストールが完了してローカルホストにアクセスすると下記のような画面になっていたらひとまずOKです。
スクリーンショット 2020-12-08 10.58.23.png

しかし、このままではマークダウンで記事かけません。
そこでマークダウンで記事をかけるようにしてくれるプラグインをインストールします。

yarn add @gridsome/source-filesystem
npm install @gridsome/source-filesystem

yarn add @gridsome/transformer-remark
npm install @gridsome/transformer-remark

npm install @gridsome/remark-prismjs
yarn add @gridsome/remark-prismjs

インストールをしたらgridsome.config.jsmain.jsを編集しないといけません。

gridsome.config.js
module.exports = {
  siteName: 'Gridsome',
  templates: {
    Post: '/blog/:path'
  },
  plugins: [
    {
      use: '@gridsome/source-filesystem',
      options: {
        typeName: 'Post',
        path: 'content/**/*.md'
      }
    }
  ],
  transformers: {
    //Add markdown support to all file-system sources
    remark: {
      externalLinksTarget: '_blank',
      externalLinksRel: ['nofollow', 'noopener', 'noreferrer'],
      plugins: [
        '@gridsome/remark-prismjs'
      ]
    }
  },
}
main.js
import DefaultLayout from '~/layouts/Default.vue'
import 'prismjs/themes/prism.css'


export default function (Vue, { router, head, isClient }) {
  head.link.push({
    rel: 'stylesheet',
    href: 'https://fonts.googleapis.com/css?family=Stylish&display=swap'
  }),
  // Set default layout as a global component
  Vue.component('Layout', DefaultLayout)
}

2つのファイルを編集したらCtrl + C押して再起動してください。

各ファイルの紹介

ここまでで最低限ブログを書く準備はできました。お疲れさまでした。
次に各ファイルの使い方を説明したいと思います。

.
├── gridsome.config.js
├── gridsome.server.js
├── static/
├── content/
├── package-lock.json
├── package.json
├── src
│   ├── components
│   │   └── README.md
│   ├── favicon.png
│   ├── layouts
│   │   ├── Default.vue
│   │   └── README.md
│   ├── main.js
│   ├── pages
│   │   ├── About.vue
│   │   ├── Index.vue
│   │   └── README.md
│   └── templates
│       └── README.md
└── static
    └── README.md

こんな感じでgridsomeプロジェクトにいろんなファイルがあると思います。ご紹介します。

/src/components

おなじみのコンポーネントを作成するフォルダ
コンポーネントについては解説しないのでわからない方は調べてください

/src/layouts/Default.vue

ここでページのヘッダーやフッターを作成できます。

<template>
  <div class="layout">
    <header class="header">
      <strong>
        <g-link to="/">{{ $static.metadata.siteName }}</g-link>
      </strong>
      <nav class="nav">
        <g-link class="nav__link" to="/">Home</g-link>
        <g-link class="nav__link" to="/about/">About</g-link>
      </nav>
    </header>
    <slot/>
  </div>
</template>

このコンポーネントの<slot>の部分にページのコンテンツが挿入されます。

src/pages/**.vue

このディレクトリのファイル名がルーティングに使用されます。
例えばAbout.vueというファイルを作成したらlocalhost:8080/aboutにアクセスできるようになります。
これはブログのジャンル毎の記事に分けたい時とかに使えます。

src/pages/templates/*.vue

ここには、先程のsorce-filesystemを入れたときにgridsome.config.jsに記述したtypeNameと同じファイル名を作成する必要があります。
typeNamePostと記述したらPost.vueと命名する必要があります。
後にGraphQLからブログのデータを取得するためです。

ブログを作成する

次にブログを作成して画面に表示させるところまでをやってみます。
/content/my_blog/helloworld.mdを作成してください。

helloworld.md
---
title: HelloWorld!
description: FirstArticle
date: 2020-12-9
path: first
---

Lorem ipsum dolor sit amet, consectetur adipisicing elit. Pariatur excepturi labore tempore expedita, et iste tenetur suscipit explicabo! Dolores, aperiam non officia eos quod asperiores

こんな感じで書いてみました。適宜内容変えて書いてみてください。

ブログで書いた内容を取得する

http://localhost:8080/___explore

こちらを開いてみてください。GraphQLを試すことができます。
スクリーンショット 2020-12-14 10.28.51.png

こんな感じの画面が出てくるはずです。

先程試しに書いてみた記事の内容を取得してみます。

query {
  allPost {
    edges {
      node {
        title
        description
        date
        path
        content
      }
    }
  }
}

上記を先程の画面にコピペしてみてください。

スクリーンショット 2020-12-14 10.36.54.png

先程サンプルで書いたデータが返ってくるはずです。
この内容をsrc/pages/index.vueで取得してみたいと思います。
ファイルを下記のように編集してみてください。

/src/pages/index.vue
<template>
  <Layout>
    <hr>
    <div v-for="(article, index) in $page.allPost.edges" :key="index">
      <p>タイトル:{{article.node.title}}</p>
      <p>サブタイトル:{{article.node.description}}</p>
      <p>日付:{{article.node.date}}</p>
      記事の内容
      <p v-html="article.node.content"></p>
      <g-link :to="article.node.path">記事のリンクに移動</g-link>
    </div>
  </Layout>
</template>

<page-query>
query {
  metadata {
    siteName
  }
  allPost {
    edges {
      node {
        id
        title
        content
        description
        date
        path
      }
    }
  }
}
</page-query>

<script>
export default {
  metaInfo: {
    title: 'Hello, world!'
  }
}
</script>

</page-query>

スクリーンショット 2020-12-15 10.08.42.png

index.vueで記事のデータを全件取得してそれぞれ内容を表示させています
<g-link>というのはGridsomeが用意したGridsome内で使えるvue-routerのようなものです。基本これを使ってページ遷移をさせます。

しかし、現段階では記事でのデータを個別取得するように取得していないので記事のリンクに移動しても何も表示されていないかと思います。

記事の個別取得

記事の個別取得はGraphQLと紐付いたファイル名でないと出来ません。
gridsome.config.jssource-filesystemの箇所を見てみてください。

plugins: [
 {
    use: '@gridsome/source-filesystem',
    options: {
      typeName: 'Post',
      path: 'content/**/*.md'
    }
  }
],

ここのtypeNameと同じファイル名のものを/src/templates内に作成することで記事の個別のデータを取得することが出来ます。
自分の環境では、typeNamePostとあるのでPost.vueというファイルを作成します。

/src/templates/Post.vue
<template>
  <div>
    <Layout>
      <br>
      <h1>{{ $page.post.title }}</h1>
      <p v-html="$page.post.content"></p>
    </Layout>
  </div>
</template>

<script>
export default {

}
</script>

<page-query>
query Post  ($id: ID!) {
  post (id: $id) {
    title
    content
    date
    description
  }
}
</page-query>

作成したら上記のように編集してみてください。
編集したら先程のindexのページのリンクから飛んでみてください。

リンクから飛んだらURLを確認してみてください。
実際に書いてあるブログの記事のpathと同じになっていることを確認してください。

スクリーンショット 2020-12-15 10.21.48.png

実際に個別にデータが取得できていることが確認できました。
g-linkからページ遷移した後、ブログのIDが渡され、GraphQLで一致するブログのデータを取得しているんじゃないかなと予想しています。

以上が、ブログを作成して表示するまでの一連の流れとなります。
お疲れさまでした。

便利なプラグインの紹介

Vuetify

僕はCSSが嫌いです。極力書きたくありません。。
そんなときに便利なのがVuetifyとかBootstrapとかです。
今回自分はVuetifyを採用しました。

インストールとセットアップにあたってこちらの記事を参考にしましたので。こちらの記事を参考にインストールしてみてください。
本記事では長くなるので割愛させていただきます。スミマセン。

typescript

正直ページネーション機能を実装する時くらいしか使っていません。
公式ページにtypescriptを導入するためのプラグインの紹介がされているのでそこを参考にしました。

yarn add -D typescript ts-loader gridsome-plugin-typescript

npm install -D typescript ts-loader gridsome-plugin-typescript

続いてgridsome.config.jsを編集

/gridsome.config.js
module.exports = {
  plugins: [
    {
      use: 'gridsome-plugin-typescript',
    }
  ]
}

ルートディレクトリにtsconfig.jsonを作成します。

/tsconfig.json

{
    "compilerOptions": {
        "target": "es5",
        "module": "es2015",
        "moduleResolution": "node",
        "noImplicitReturns": true,
        "outDir": "./built/",
        "sourceMap": true,
        "strict": true
    },
    "include": [
        "./src/**/*"
    ]
}

続いてsrcディレクトリ内にvue-shims.d.tsを作成します。

/src/vue-shims.d.ts
declare module "*.vue" {
    import Vue from "vue";
    export default Vue;
}

あとは個々のvueファイル内のスクリプトタグにtsをつければ完了です
<script lang="ts">

netlifycms

これはnetlifyにブログをデプロイしたら使えるようになるやつです。
ブログデプロイ後サイトURL/adminにアクセスすると自分のブログ上でブログが書ける様になるすごいやつです。
これは後日、自作ブログデプロイ編(書く気が起きれば)でご紹介したいと思います。

最後に

今回JAMStackフレームワークに触ってみたくてNuxtかGridsomeかで迷った末、Gridsomeを選択しました。Nuxtよりも知名度低かったので。。
デプロイするまでにかなりハマってしまいましたが、いい勉強になったと思います。
自分が作成したブログをデプロイしてPageSpeed Insightsで計測したところ82点だったので100点目指して、いつかサイトを軽量化に挑戦してみようと思いました。

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

10秒で解決!VueRouterで遷移するときにスクロール位置が固定されてしまった時の話

今回は、VueRouterで別のページに遷移するときに、遷移する前のスクロール位置によって遷移先のページでトップの位置にならないという謎の事象が発生したので、その解決方法をご紹介します。

以下のコードをrouter/index.jsに追加するだけ。

const router = new VueRouter({
  mode: "history",
  base: process.env.BASE_URL,
  routes,
  //以下のコードを追加
  scrollBehavior(to, from, savedPosition) {
    if (savedPosition) {
      return savedPosition;
    } else {
      return { x: 0, y: 0 };
    }
  },
});

これを解決するのに、めちゃめちゃ時間がかかったので皆さんの助けになればなと思います。

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

warning Unexpected console statement no-console の無効化

はじめに

Javascriptでconsole.logを使用してデバッグをすると、eslintに引っかかりwarningエラーが出ることがある。その際の無効化の方法を備忘録をして残します。

エラー

warning  Unexpected console statement  no-console

対処方法

// eslint-disable-next-line no-console を追加する

test.js
test() {
  // eslint-disable-next-line no-console
  console.log('test')
}

参考

https://eslint.org/docs/rules/no-console

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

Laravel 6.x 非同期通信(Ajax) 【axios】 【Vue.js】 【Laravel-mix】 で簡易的なECサイトのカート(買い物かご)を作成

制作環境

Windows 10
Laravel : 6.18.35
Laravel/ui : 1.0
Laravel-mix : 5.0.1
Bootstrap : 4.0.0
Vue.js : 2.5.17
XAMPP
PHP : 7.4.3
Visual Studio Code

はじめに

この記事はプログラミングをはじめたばかりの素人が、できたことをメモするのに利用しています。
内容には誤りがあるかもしれません。

買い物かごを作成する際にうまくいかなかったので、改善策を見つけるのに作成した小規模プログラムです。
とりあえず完成した物を先に紹介し、後ほどうまくいかず試行錯誤した点を記載したいと思います。

機能実装が目的のため、デザイン(見た目)にはあまりこだわっていません。
また、記述も必要最低限にしています。
一部デザインの整形にBootstrapを使用しております。
Bootstrap、Vue.jsも含め、Laravel-mixを使用して記述してます。

作成要件

  • ユーザーの切り替えができる(ログイン機能を付けないので、手動でユーザーを切り替える仕様にします)。
  • 商品の情報を取得し、一覧表示する。
  • 商品の購入数を指定し、非同期通信でカートに追加できる。
  • カートに入っている商品点数を、非同期通信で表示する。
  • カートに追加する際はバリデーションを行い、エラーがあればメッセージを表示させる。
  • カートへの登録が完了したら、購入数は空に戻す。
  • ユーザーを切り替えるとカートに入っている商品点数も、非同期通信で変更される。

イメージ

メインページ
デフォルト.jpg

ユーザー選択でユーザーの切り替え
user.jpg

カート内の点数は()の中に表示させます
incart.jpg

購入数の指定ができます
post.jpg

カートに追加後は購入数を空に戻します
afterpost1.jpg

カートには点数が追加されます
afterpost2.jpg

ユーザーに合わせて表示されるカートの件数も変わります
userincart.jpg

バリデーションも実装します
validation.jpg

バリデーションでエラーがあればメッセージを表示します
errormsg.jpg

それでは、作成していきましょう。

マイグレーションファイルの作成

データベースへテーブルを作成したいと思います。
今回作成するのは、商品を管理するproducts_tableとカート用のcarts_tableです。

プロジェクトのディレクトリでターミナルを起動し、以下を実行してください。

php artisan make:migration create_products_table

続けて

php artisan make:migration create_carts_table

作成が完了したら、database>migrations内にある作成されたファイルを開き、以下のように記述します。

create_products_table
    public function up()
    {
        Schema::create('products', function (Blueprint $table) {
            $table->bigIncrements('id');
            $table->string('name')->comment('商品名');
            $table->string('price')->comment('価格');
            $table->timestamps();
        });
    }
create_carts_table
    public function up()
    {
        Schema::create('carts', function (Blueprint $table) {
            $table->bigIncrements('id');
            $table->unsignedTinyInteger('user_id')->comment('ユーザーID');
            $table->unsignedBigInteger('product_id')->comment('商品ID');
            $table->foreign('product_id')->references('id')->on('products')->onDelete('cascade');
            $table->string('quantity')->comment('購入数');
            $table->timestamps();
        });
    }

モデルの作成

次にモデルを作成していきます。
モデルはModelsフォルダの中に作成していきます。

ターミナルを起動し、以下を実行してください。

php artisan make:model Models/Product

続けて

php artisan make:model Models/Cart

作成されたファイルはapp>Modelsの中にあります。
各ファイルを以下のように記述してください。

Product.php
// リレーションのため追記
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class Product extends Model
{
    // 変更を許可するカラムを指定します
    protected $fillable = [
        'name', 'price'
    ];

    // リレーションのためのメソッドです
    // これでカートの情報がProduct側から取得できます
    public function cart()
    {
        // 紐付けるモデルを指定し返します
        return $this->belongsTo('App\Models\Cart');
    }
}
Cart.php
// リレーションのため追記
use Illuminate\Database\Eloquent\Relations\HasMany;

class Cart extends Model
{
    protected $fillable = [
        'user_id', 'product_id', 'quantity'
    ];

    public function product()
    {
        return $this->hasMany('App\Models\Product');
    }
}

ビューの作成

resources>views内に新しくproduct.blade.phpを作成し、以下のように記述します。
ちなみに、Vueテンプレートは一切使用していません。

product.blade.php
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <meta name="csrf-token" content="{{ csrf_token() }}">
    <link rel="stylesheet" href="{{ mix('css/app.css') }}">
    <title>商品一覧</title>

    <style>
        [v-cloak] {
            display: none;
        }
    </style>
</head>
<body>
    <div id="app" v-cloak>
        <div class="container">

            <div class="row mt-2">
                <label>ユーザー選択
                    <select name="user" id="user" v-model="user">
                        <option value="1">1</option>
                        <option value="2">2</option>
                        <option value="3">3</option>
                    </select></label>
                <p class="cart_text ml-auto">◆カートの中身(@{{ items }})</p>
            </div>

            <h1>商品一覧</h1>

                <p class="err_msg text-danger">@{{ errors.quantity }}</p>

            <div class="row justify-content-center">

                    @foreach ($productTable as $product)

                    <div class="product p-3 border border-success col-3">

                        <h3>商品名</h3>
                        <p>{{ $product->name }}</p>

                        <h3>価格</h3>
                        <p>{{ $product->price }}円</p>
                            <form id="form{{ $product->id }}">
                                @csrf
                                <label>購入数: <input type="text" name="quantity" size="2"></label>
                                <br>
                                <input type="hidden" name="product_id" value="{{ $product->id }}">
                                <input type="hidden" name="user_id" v-model="user">
                                <button type="button" @click="addCart({{ $product->id }})">カートに追加</button>
                            </form>

                    </div>

                    @endforeach

            </div>

        </div>
    </div>

    <script src="{{ mix('js/app.js') }}"></script>
</body>
</html>

ポイント

{{ $product->name }}

のように表示されているのはblade側の構文で

@{{ items }}

のように@が先頭についているのはVue側の構文です。

    <style>
        [v-cloak] {
            display: none;
        }
    </style>

ここで設定しているスタイルは、Vue側の構文を表示する際に一瞬{{ }}が表示されるのを防ぐためのものです。

フォームリクエストの作成

バリデーションはフォームリクエストで行うようにします。
ターミナルで以下を実行して下さい。

php artisan make:request CartRequest

app>Http>Requests内にファイルが作成されるので、開いて以下のように記述します。

CartRequest.php
class CartRequest extends FormRequest
{
    public function authorize()
    {
        // 今回認証は行わないのでtrueにします
        return true;
    }


    // 正規表現で正の整数だけパスするようにしてます
    public function rules()
    {
        return [
            'quantity' => 'regex:/^\d+$/'
        ];
    }


    public function messages()
    {
        return [
            'quantity.regex' => '購入数は正の整数を入力してください'
        ];
    }
}

ポイント

今回は非同期通信の中でフォームリクエストを使用しバリデーションを行いますが、通常のバリデーション処理と違い注意点があります。
通常であれば自動的に元のページへのリダイレクトレスポンスが作成され、エラーメッセージもフラッシュメッセージとしてセッションに保存されますが、非同期通信の場合はJSONが返されるだけで、レスポンスの作成は行われず、リダイレクトもしません。

コントローラの作成

ターミナルで以下を実行します。

php artisan make:controller ProductController

app>Http>Controllers内にファイルが作成されるので、開いて以下のように記述します。

ProductContoroller
// モデル利用のため追記
use App\Models\Product;
use App\Models\Cart;
// フォームリクエスト使用のため追記
use App\Http\Requests\CartRequest;

class ProductController extends Controller
{
    public function index()
    {
        // 商品情報を全て取得します
        $productTable = Product::all();
        // 取得した内容をビューに渡します
        return view('product', compact('productTable'));
    }

    // カートに商品を追加するメソッドです
    public function add_cart(CartRequest $request)
    {
        // フォームリクエストを通過したリクエストの値を全て$formに代入します
        $form = $request->all();
        // 不要な項目を削除します
        unset($form['_token']);

        // Cartモデルをインスタンス化(実体化)します
        $cartTable = new Cart;
        // 登録する値を各項目に一気に代入します
        $cartTable->fill($form);

        // Cartテーブルにデータを保存します
        $cartTable->save();

        // カートからユーザーIDが同じ物だけ抽出して数をカウントします
        $cart = $cartTable->where('user_id', $request->user_id)->count();
        // カウントした数を返します
        return $cart;
    }

    // カートの商品点数をカウントするメソッドです
    public function get_total(Request $request)
    {
        // カートからユーザーIDが同じ物だけ抽出して数をカウントして返します
        $cart = Cart::where('user_id', $request->user_id)->count();
        return $cart;
    }
}

ルーティングの作成

routes内のweb.phpを開いて以下のように記述します。

web.php
Route::get('/product', 'ProductController@index')->name('product');
Route::post('/ajax/product', 'ProductController@add_cart')->name('add_cart');
Route::get('/ajax/product', 'ProductController@get_total')->name('cart_total');

Vue.jsの作成

resources>js内のapp.jsを開き、下の方を以下のように記述します。

app.js
// Vueをインスタンス化(実体化)しappに代入します
const app = new Vue({
    // Vueを使用する範囲(仮想DOM)を指定します
    el: '#app',

    // 初期値で渡す値を設定します
    data() {
        return {
            // 現在選択されているユーザーです
            user: '',
            // カートの商品点数です
            items: '',
            // バリデーションのエラーメッセージです
            errors: {},
        }
    },
    methods: {
        // カートに商品を非同期通信で追加するメソッドです
        addCart(id) {
            // アクセス先のURLを作成しurlに代入します
            let url = '/ajax/product'
            // アクセス先に送信するデータをparamsに代入します
            let params = $('#form' + id).serialize()

            // thisが使えなくなるのでthatに代入し使えるようにします
            let that = this

            // エラーメッセージを初期化します
            that.errors = {}

            // axiosで非同期通信を開始します
            axios.post(url, params)
                // thenで通信成功時の処理を記載します
                // コントローラからの返り値がresに代入されます
                .then(res => {
                    // コントローラからの返り値(商品点数)をitemsに代入します
                    that.items = res.data
                    // 購入数の値を空に戻します
                    $('#form' + that.user)[0].reset()

                // catchで通信失敗又はバリデーションエラー時の処理を記載します
                // フォームリクエストからの返り値がerrorに代入されます
                }).catch(error => {
                    // ここで使用する変数errorsを定義します
                    var errors = {}

                    // for...in分でキーの数だけ処理を繰り返します
                    for (var key in error.response.data.errors) {
                        // errorsにキーと値を代入します
                        errors[key] = error.response.data.errors[key].join()
                    }
                    // errorsに抽出したエラーメッセージを代入します
                    that.errors = errors
                })
        },
    },
    // watchで値の変更の監視を行います
    watch: {
        // userの値が変更された(ユーザーを切り替えた)時の処理です
        user: function() {
            // アクセス先のURLを作成しurlに代入します
            let url = '/ajax/product?user_id=' + this.user

            // thisが使えなくなるのでthatに代入し使えるようにします
            let that = this

            // エラーメッセージを初期化します
            that.errors = {}

            // axiosで非同期通信を開始します
            axios.get(url)
                .then(res => {
                    // resで受け取ったコントローラの返り値(商品点数)をitemsに代入します
                    that.items = res.data
                })
        }
    }
})

ポイント

errors[key] = error.response.data.errors[key].join()

最後の.join()が抜けると、エラーメッセージの表示が

購入数は正の整数を入力してください

ではなく、

["購入数は正の整数を入力してください"]

と、余計なものが表示されます。

コンパイル

ここまで記述したら、最後にコンパイルを行います。
ターミナルで以下を実行してください。

npm run dev 又は npm run watch-poll

動作確認

/productにアクセスし、動作を確認してみてください。
要件が全て満たされていたら成功です。

作成時にハマった点

ユーザー切り替え時のカートの点数の取得

最初Vueには以下のように記述を書いていました。

    beforeUpdate() {
        let url = '/ajax/product?user_id=' + this.user

        let that = this

        axios.get(url)
            .then(res => {
                that.items = res.data
            })
    },

この記述だと、ユーザー変更時にカート内の商品点数を取得してはくれるのですが、同じ処理が2回行われてしまいます。
先ず、ユーザーが切り替わったことで変更とみなされ、処理が走ります(1回目)。
次に、itemsに値が入ることで変更とみなされ、処理が走ります(2回目)。

2回目の後に再度itemsに値が代入されますが、値が全く同じなので、変更とみなされず処理は走りません。

最終的にwatchを使用し、監視する項目を指定することでうまくいきました。

バリデーションのエラーメッセージの取得

これが一番ハマリました。
最初は@errorディレクティブを使用し、エラーメッセージを表示するようビューに記載していたのですが、非同期通信の場合リダイレクト処理は行われないので、通常フラッシュメッセージとしてセッションに保存されるエラーメッセージが受け取れませんでした。

改善策としてセッションに手動でエラーメッセージを保存しようと試みましたが、まず非同期通信の為ページが更新されないので、セッションに保存したところで反映されませんでした。
一部のDOMだけを更新させることも考えましたが、思う通りにできる記述方法を見つけることができませんでした。
また、色々名称等を試しましたが、セッションにどういうキーで、どうい形で、どんな値が保存されているのかわからず、@errorディレクティブを動かすことができませんでした。

最終的に@errorディレクティブの使用は諦めました。

Vueでのバリデーションのエラーメッセージの取得

最初エラーメッセージを表示させて際、ポイントで記載していますが、余計なものが表示されてしまいました。
["購入数は正の整数を入力してください"]
[""]が不要です。

console.logで受け取ったデータを確認したところ、["購入数は正の整数を入力してください"]この表示の他に、[0]で購入数は正の整数を入力してくださいという値があるのがわかりました。

そこで、以下のように記述を変更しうまくいきました。

errors[key] = error.response.data.errors[key][0]

うまくいきはしたのですが、どうしても[0]がの記述が気になりました。
今回バリデーションルールが1つしかないからいいものの、複数の場合に大変そうで、更に色々探して最終的に掲載しているjoin()を使うかたちにしました。

カートの件数のカウント

これも結構ハマリました。

以下うまくいかなかったコントローラの記述です。

$cart = $cartTable->find($request->user_id)->count();

カートの点数が常に1になります。
find()では1件の値しか取れていないようです。

$cart = $cartTable->find($request->user_id)->get()->count();

テーブルのレコード全件がカウントされてしまいます。
find()で抽出しても、get()がくると全部抽出されてしまいます。

最終的にwhereで条件を指定しうまくいきました。
個人的にはfind()でいけると思ったのですが・・・

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

Vue.js初心者がやりがちなコードの書き方

Vue.jsは学習のしやすいフレームワークであると思う。段階的に機能の導入ができるように設計されているため、必要以上の学習コストが発生しにくいということもあるが、特定の機能(例えば、算出プロパティ)を初めて学習する場合にそれを試すことが容易であることも大きい。

特定の機能を簡単に試せることは、メリットであると共に注意も必要であると思う。特に初学者は、実際のアプリケーションの中で

  • どのような状況でその機能を使えば良いのか

といった観点を把握しないままに先に進んでしまいがちで、状況に応じた適切な機能を選択できていないことが多々あるように思う。

この記事では、サンプルアプリのリファクタリングを通して、初心者がやりがちだと思うコードの書き方とその問題点を指摘していく。なお、リファクタリングは問題点に気づくための手段として行っているので、具体的なステップバイステップの安全なリファクタリング手順は記載していないことはご了承いただきたい。

目次

1. 当記事の対象読者
2. 動作環境
3. リファクタリングするサンプルアプリ
  3-1. サンプルアプリの要件
  3-2. リファクタリング前のコード
4. コードの問題点と改善方法
  4-1. 繰り返しを含むtemplate
  4-2. セットで更新しなければいけない状態
  4-3. 所属がおかしな状態
  4-4. 矛盾しうるプロパティ
  4-5. リファクタリング後のコード
5. まとめ
6. あとがき
7. 参考にしたサイト・書籍

1. 当記事の対象読者

当記事はVue.js初心者を対象とする。一通りの機能は触ったことがあり、とりあえず動作するものは作れるが、コンポーネントが上手く書けているか分からないといった場合には参考になる箇所があるかもしれない。

前提知識としてはシングルファイルコンポーネントが理解できれば良く、Vue RouterやVuexまでは踏み込まない。

また、初心者対象とはしたものの、ある程度Vue.jsの開発経験がある方にも読んでいただき、意見や間違いの指摘等をコメントいただけるとありがたい。

2. 動作環境

サンプルアプリはvue-cliでバージョン2.6.10のVue.jsアプリを作成、動作確認をおこなった。確認はしていないが3.0系で変更があった機能は使っていないので、3.0系でも問題なく動作すると思う。

3. リファクタリングするサンプルアプリ

今回、リファクタリングの対象とするサンプルアプリは下図のようなイメージだ。
sample_app_image
とある青果店のホームページの一角に表示するものと思って欲しい。

3-1. サンプルアプリの要件

この青果店ではりんごジュースを販売しているが、りんごジュース目当ての客が増えすぎたため、ホームページ上の抽選で当選した人にのみに販売することになった。要件は以下の通りだ。

  • りんごジュースに使うりんごは日替わりで、産地や品種、値段が異なる
  • Mサイズの料金を基本料金として、 Lサイズはその1.4倍の料金とする
  • 抽選はメールアドレスをフォームに入力して、その結果がそのメールアドレスに対して送信される

あまり現実的ではない設定は多々あるが、説明用と割り切っていただけると幸いだ。

3-2. リファクタリング前のコード

このアプリは基本的に以下3つコンポーネントで構成されている。

  • App...サンプルの全体
  • MyTextBox...メールアドレスの入力フォームで使用
  • MyButton...「抽選する」ボタンで使用

次にソースコードを示す。なるべく短くするよう努めたが、それでもやや長いため、現時点ではざっと確認する程度で構わない。

なお、CSSの記述に関しては今回の目的からは逸れること、そしてコードを短く保つために外した。ただし、スタイリングに使用したタグのclass属性は、タグの意味合いを理解する補助になるため意図的に残した。

App.vue(3.リファクタリング前)
<template>
  <div id="app">
    <h1>Ringo Juice</h1>
    <div class="today-info">
      <h4 class="today-info_title">本日のりんご</h4>
      <div class="today-info_list">
        <div class="today-info_item">
          <span>産地</span>
          {{ info.prefecture }}
        </div>
        <div class="today-info_item">
          <span>品種</span>
          {{ info.variety }}
        </div>
        <div class="today-info_item">
          <span>Mサイズ</span>
          {{ info.basePrice }}</div>
        <div class="today-info_item">
          <span>Lサイズ</span>
          {{ largeSizePrice }}</div>
      </div>
    </div>
    <p class="message">
      りんごジュースは1日100名様限定です。<br />当選者のみが購入可能です。<br />
      下記フォームにメールアドレスを入力してください。<br />抽選結果が届きます。
    </p>
    <div class="form">
      <MyTextBox ref="email" placeholder="メールアドレス" @send-text="sendEmail" />
      <MyButton isSmall @click="onClick">
        抽選する
      </MyButton>
    </div>
  </div>
</template>

<script>
import MyTextBox from '@/components/MyTextBox.vue'
import MyButton from '@/components/MyButton.vue'

export default {
  name: 'app',
  components: { MyTextBox, MyButton },
  data() {
    return {
      info: { prefecture: '', variety: '', basePrice: 0 },
      largeSizePrice: 0
    }
  },
  created() {
    this.info = this.getTodayInfo()
    this.largeSizePrice = this.info.basePrice * 1.4
  },
  methods: {
    getTodayInfo() {
      // 実際はWebAPIで情報を取得するが、話の単純化のため定数を返す
      return { prefecture: '青森', variety: 'ふじ', basePrice: 300 }
    },
    onClick() {
      this.$refs.email.sendText()
    },
    sendEmail(address) {
      // 実際はWebAPIでサーバにアドレスを送信するが、話の単純化のため警告を出すだけにしている
      alert(`${address}にメールを送信しました`)
    }
  }
}
</script>
MyTextBox.vue(3.リファクタリング前)
<template>
  <input v-model="text" :placeholder="placeholder" />
</template>

<script>
export default {
  props: {
    placeholder: { type: String, default: '' }
  },
  data() {
    return {
      text: ''
    }
  },
  methods: {
    sendText() {
      this.$emit('send-text', this.text)
    }
  }
}
</script>
MyButton.vue(3.リファクタリング前)
<template>
  <button :class="btnClass" @click="$emit('click')">
    <slot />
  </button>
</template>

<script>
export default {
  props: {
    isSmall: { type: Boolean },
    isLarge: { type: Boolean }
  },
  computed: {
    btnClass() {
      if (this.isSmall) return 'small'
      return 'large'
    }
  }
}
</script>

これでコードは以上だ。

正直小さなコンポーネントばかりなので、そこまで読むのは難しくないと思う。しかし、この中にも可読性を下げる要因は散りばめられており、もっと大きなコンポーネントの中では、そういった小さな要因が積み重なって扱いづらいコンポーネントになってしまう。

4. コードの問題点と改善方法

ここから上記アプリの問題点の指摘とその修正方法を記載していく。

4-1. 繰り返しを含むtemplate

まず、手始めにApp.vueの下記部分を見ていく。

App.vue(4-1.繰り返しを含むtemplate)
<template>
    <!-- ...略... -->
      <div class="today-info_list">
        <div class="today-info_item">
          <span>産地</span>
          {{ info.prefecture }}
        </div>
        <div class="today-info_item">
          <span>品種</span>
          {{ info.variety }}
        </div>
        <div class="today-info_item">
          <span>Mサイズ</span>
          {{ info.basePrice }}</div>
        <div class="today-info_item">
          <span>Lサイズ</span>
          {{ largeSizePrice }}</div>
      </div>
    <!-- ...略... -->
</template>

繰り返しは、Vue.jsに限らず一番目に着きやすいリファクタリングすべき箇所であることが多い。サンプルでは比較的単純な構造のたった4回の繰り返しだが、これがもっと大きくなってくるとコード量が増え、可読性が落ちる。また修正が必要な際は、同様の修正を複数箇所に適用しなければならず変更がしづらい。

しかし、もっと深刻な問題は「本当に繰り返しなのか」がこのコードを書いた本人以外には分かりづらいことである。何回か「繰り返し」という言葉を使用してきたが、実は一箇所だけ違う箇所がある。見つけられていなければ再度コードを見返して欲しい。答えは下の折り畳み内に記載した。


Q.繰り返しではない箇所はどこか(答えはクリックして開く)
A. 本当はそんなものはない。もし、時間をかけてじっくり探した人がいたら申し訳ないが、そういう人ほど次のリファクタリングの意図が理解できるだろう。

上記したような問題を解決するために、以下のファクタリングを適用する。

  • 繰り返している構造をコンポーネントとして抽出
  • 繰り返し部分にv-forを適用する

繰り返している構造をコンポーネントとして抽出

繰り返している構造を抽出したMyItemコンポーネントを作成する。

MyItem.vue(4-1.繰り返し構造をコンポーネントとして抽出)
<template>
  <div class="today-info_item">
    <span>{{ title }}</span>
    {{ value }}
  </div>
</template>

<script>
export default {
  props: {
    title: { type: String, required: true },
    value: { type: [String, Number], required: true }
  }
}
</script>

これを使用するとAppコンポーネントは次のようになる。

App.vue(4-1.MyItemを適用)
<template>
    <!-- ...略... -->
      <div class="today-info_list">
        <MyItem title="産地" :value="info.prefecture" />
        <MyItem title="品種" :value="info.variety" />
        <MyItem title="Mサイズ" :value="`${info.basePrice}円`" />
        <MyItem title="Lサイズ" :value="`${largeSizePrice}円`" />
      </div>
    <!-- ...略... -->
</template>

このようにしていれば繰り返しであることは格段に分かりやすくなる。

繰り返し部分にv-forを適用する

さて、繰り返している構造をコンポーネントとして抽出したことで、冗長な記述は大幅に減ったが、まだ検討の余地がある。シンプルにはなったが、MyItemタグを繰り返し記載している。v-forディレクティブを使用することでそのような記載をよりシンプルにすることが可能だ。

v-forをこの部分に適用するため、次のような算出プロパティを用意する

App.vue(4-1.繰り返し部分のデータを配列として扱う算出プロパティを用意)
<script>
// ...略...
export default {
  // ...略...
  computed: {
    infoItems() {
      return [
        { title: '産地', value: this.info.prefecture },
        { title: '品種', value: this.info.variety },
        { title: 'Mサイズ', value: `${this.info.basePrice}` },
        { title: 'Lサイズ', value: `${this.largeSizePrice}` }
      ]
    }
  },
  // ...略...
}
</script>

この算出プロパティを使用してtemplateを書き換えると次のようになる。

App.vue(4-1.繰り返し部分にv-forを適用)
<template>
    <!-- ...略... -->
      <div class="today-info_list">
        <MyItem v-for="item of infoItems" :title="item.title" :value="item.value" />
      </div>
    <!-- ...略... -->
</template>

説明のためにv-forを適用したが、今回のケースではここまでしてしまうと少しやりすぎかもしれない。しかし、繰り返している構造をコンポーネントにした場合でも、共通の属性(例えばクラス等)を指定しないといけない場合もあり、その場合はv-forを適用することでコードの記述量が減るので有用であると思う。

4-2. セットで更新しなければいけない状態

次に検討したいのはAppコンポーネントの次の部分である。

App.vue(4-2.余分な状態)
<script>
// ...略...
export default {
  // ...略...
  data() {
    return {
      info: { prefecture: '', variety: '', basePrice: 0 },
      largeSizePrice: 0
    }
  },
  created() {
    this.info = this.getTodayInfo()
    this.largeSizePrice = this.info.basePrice * 1.4
  },
  // ...略...
}
</script>

dataにはprefecturevarietybasePriceの3つのプロパティをもつオブジェクトinfolargeSizePriceが登録されている。

このうちMサイズの料金(基本料金)を表すinfobasePriceとLサイズの料金を表すlargeSizePriceの関係に注目したい。このアプリの要件には

  • Mサイズの料金を基本料金として、 Lサイズはその1.4倍の料金とする

というものがあった。これはMサイズの料金が決まれば、Lサイズの料金は自動的に決まるということであり、それぞれが独立した状態でないことを意味する。独立していない状態をdataとして登録してしまうと後々問題を引き起こす可能性がある。例えば、情報を30分に一回更新するといった要件が必要になったとする。次のようなイメージだ。

App.vue(4-2.30分に一回情報を更新する場合)
<script>
// ...略...
export default {
  // ...略...
  created() {
    this.info = this.getTodayInfo()
    this.largeSizePrice = this.info.basePrice * 1.4
    setInterval(() => {
      this.updateInfo()
    }, 30 * 60 * 1000)
  },
  methods: {
    // ...略...
    updateInfo() {
      this.info = this.getTodayInfo()
    }
  }
}
</script>

このコードの問題点はすぐに分かるだろう。updateInfo()の定義内でinfoを更新した際にlargeSizePriceがセットで更新されていない。そんな見落としはしないと思う人もいるかもしれないが、実際のアプリケーションの中では、エラー処理だったり、WebAPIから取得したデータの整形が行われていたりとupdateInfo()のコードが数十行のサイズになることもある。また他の箇所でもbasePriceが更新されるようなことがあれば、その度に忘れずlargeSizePriceを更新しなければならない。

上記したような問題を解決するために、以下のリファクタリングを適用する。

  • セットで更新しなければいけない状態は算出プロパティとして定義する

セットで更新しなければいけない状態は算出プロパティとして定義する

Vue.jsに慣れていない人は、算出プロパティを使えていないことが多々ある。この原因は憶測だが、算出プロパティがVue.js特有の機能であることや、そもそも算出プロパティを使わなくとも動くものは作れてしまうこと、あとは名前自体が何か計算が必要なときに使うものという誤解を招いていたりもしていると思う。サンプルアプリではまさに計算するために使っているが、状態に応じて決まるclassや、アイコンのパス指定などにも利用できる

さて、話が脱線してしまったが、修正自体はすごくシンプルだ。

App.vue(4-2.独立していない状態を算出プロパティとして定義)
<script>
// ...略...

export default {
  // ...略...
  data() {
    return {
      info: { prefecture: '', variety: '', basePrice: 0 }
    }
  },
  computed: {
    infoItems() {/* ...略... */},
    largeSizePrice() {
      return this.info.basePrice * 1.4
    }
  },
  created() {
    this.info = this.getTodayInfo()
  },
 // ...略...
}
</script>

これでbasePriceが更新されたときは自動的にlargeSizePriceが再計算される。

4-3. 所属がおかしな状態

次にMyButtonコンポーネントを検討する。上で記載したコードを再掲する。

MyTextBox.vue(4-3.最初に記載したものの再掲)
<template>
  <input v-model="text" :placeholder="placeholder" />
</template>

<script>
export default {
  props: {
    placeholder: { type: String, default: '' }
  },
  data() {
    return {
      text: ''
    }
  },
  methods: {
    sendText() {
      this.$emit('send-text', this.text)
    }
  }
}
</script>

このコンポーネントのメソッドsendText()は親コンポーネント(MyTextBoxを使う側、すなわちAppコンポーネント)で使用されることを想定して定義されている。

Appコンポーネントを見てみるとtemplateのMyTextBoxの呼び出し箇所でref属性が指定されており、onClickメソッド内でsendText()を呼び出していることが分かる。

App.vue(4-3.子コンポーネントのメソッド呼び出し)
<template>
   <!-- ...略... -->
      <MyTextBox ref="email" placeholder="メールアドレス" @send-text="sendEmail" />
      <MyButton isSmall @click="onClick">
        抽選する
      </MyButton>
   <!-- ...略... -->
</template>

<script>
// ...略...
export default {
  // ...略...
  methods: {
    getTodayInfo() {/* 省略 */},
    onClick() {
      this.$refs.email.sendText()
    },
    sendEmail(address) {/* 省略 */}
  }
}
</script>

このようなことをしてしまうと、コンポーネント同士は密結合になり、コンポーネントを超えた影響を考慮しなければならなくなってしまう。クラスのアクセス修飾子のように外部からのアクセスを制限できる仕組みがあれば良いが、恐らくVue.jsのコンポーネントにはそのような仕組みはないので、そもそも外部のコンポーネントの状態へのアクセスやメソッドの使用をしないように注意する他なさそうだ。

上記した問題を解決するために、以下のリファクタリングを適用する

  • 適切なコンポーネントに状態を移動

適切なコンポーネントに状態を移動

そもそも親コンポーネントで使用するメソッドであれば、親コンポーネント内で定義すれば良いのだが、そうなっていないのは入力フォームの状態をMyTextBox側で持ってしまっていることに原因がある。そこで、親コンポーネント側から入力フォームの状態を受け取るようにし、変更があれば親コンポーネントにイベント送信して通知するように修正する。

MyTextBox.vue(4-3.入力フォームの状態はプロパティとして受け取る)
<template>
  <input :value="text" :placeholder="placeholder" @input="onInput" />
</template>

<script>
export default {
  props: {
    text: { type: String, required: true },
    placeholder: { type: String, default: '' }
  },
  methods: {
    onInput(event) {
      this.$emit('input', event.target.value)
    }
  }
}
</script>

Appコンポーネントでは入力フォームの状態を定義し、MyTextBoxpropsとして渡し、変更イベントを受け取り次第状態を更新するようにする。

App.vue(4-3.入力フォームの状態を管理)
<template>
  <!-- ...略... -->
    <div class="form">
      <MyTextBox
        :text="address"
        placeholder="メールアドレス"
        @input="onInput"
      />
      <MyButton isSmall @click="onClick">
        抽選する
      </MyButton>
    </div>
  </div>
</template>

<script>
// ...略...
export default {
  name: 'app',
  components: {/* ...略... */},
  data() {
    return {
      info: {/* ...略... */},
      address: '' // <-- 入力フォーム用の状態を追加
    }
  },
  computed: {/* ...略... */},
  created() {/* ...略... */},
  methods: {
    getTodayInfo() {/* ...略... */},
    onClick() {
      alert(`${this.address}にメールを送信しました`) // <-- ボタンクリック時に子コンポーネントは介さないように修正
    },
    onInput(text) {
      this.address = text // <-- 入力フォームに変更があれば状態を変更
    }
  }
}
</script>

ところで、なぜこのアプリの実装者は最初にMyTextBoxの方に入力フォームの状態を定義したのだろうか。考えられる理由としてはAppコンポーネント側に状態を増やしたくなかったからということがあげられる。

今回の例では分かりづらいかもしれないが、複数のコンポーネント間でデータの共有をしようと思うとどうしても上位のコンポーネント側に状態が集まってしまいがちで、それを解決するために子側に状態を持たせてしまうことがある。しかし、コードの見かけ上状態が減ったように見えても、その状態が必要であることには変わりはない。コンポーネントの責務を分離するためにも、原則、状態は適切なコンポーネントに配置すべきだと思う。コンポーネントで使う状態が増えすぎてしまうような場合にはVuex等の状態管理ライブラリを導入するのも手だ。

さて、もっと簡潔な記載にするためMyTextBoxv-modelを適用したいと思うかもしれない。

App.vue(MyTextBoxにv-modelを適用)
<MyTextBox v-model="address" placeholder="メールアドレス" />

カスタムコンポーネントではv-modelを使用する場合、デフォルトではvalueをプロパティとして渡し、inputイベントによって値を更新するようになっているので、textをプロパティとして渡したい場合は下記のような修正が必要だ。

MyTextBox.vue(4-3.v-modelを使うための修正)
<template>
  <input :value="text" :placeholder="placeholder" @input="onInput" />
</template>

<script>
export default {
  model: { prop: 'text' }, // <-- これを追記
  props: {
    text: {/* ...略 ... */},
    placeholder: {/* ...略... */}
  },
  methods: {/* ...略... */}
}
</script>

4-4. 矛盾しうるプロパティ

最後に見ていくのはMyButtonコンポーネントのプロパティ部分だ。

MyButton.vue(4-4.矛盾しうるプロパティ)
<template>
  <!-- ...略... -->
</template>

<script>
export default {
  props: {
    isSmall: { type: Boolean },
    isLarge: { type: Boolean }
  },
  computed: {/* ...略 */}
}

指定できるのは、サイズ変更のための属性である。真偽値のプロパティは、コンポーネントを使う側では<MyButton isLarge>クリック</MyButton>のように値なしで属性を記載するだけで、:isLarge="true"と指定したと見なされるため、若干使い勝手が良いという側面がある。

一方で、<MyButton isSmall isLarge>クリック</MyButton>のように意味が矛盾するような指定もできてしまうので注意が必要だ。それ自体が直接問題を引き起こすようなことは少ないかもしれないが、デフォルト値の指定がしづらかったり、両方指定された場合の適用優先度を把握しなければならなかったりすることで、可読性が落ちる可能性がある。

上記問題を解決するために、以下のリファクタリングを適用する

  • 同種のプロパティをまとめる

同種のプロパティをまとめる

isSmallisLargeがともにサイズに関連するプロパティであったがために、矛盾が生じる指定ができてしまっていたので、これらをサイズを指定するためのプロパティにまとめてしまえば良い。

MyButton.vue(4-4.サイズ指定のプロパティをまとめる)
<template>
  <!-- ...略... -->
</template>

<script>
export default {
  props: {
    size: {
      type: String,
      default: 'small',
      validator: val => ['small', 'large'].includes(val)
    }
  },
  computed: {
    btnClass() {
      if (this.size === 'small') return 'small'
      return 'large'
    }
  }
}
</script>

validatorの記述は若干冗長だが、sizeというプロパティがどのような値を想定しているかのドキュメント代わりにもなるので、あった方が良いだろう。

参考程度に載せておくが、コンポーネントライブラリのVeutifyのv-btnコンポーネントは、まさにこのリファクタリングを適用する前のようなプロパティ指定でサイズ変更できるようになっている。

Vuetifyのボタンの例
<v-btn large>クリック</v-btn>

他のリファクタリングでも言えることだが、必ずしもこれが正解というものはなく、メリット・デメリットを考慮した上でどうすべきかという判断が必要であることは注意していただきたい。

4-5. リファクタリング後のコード

これで、適用したいリファクタリングは全て行った。改めてリファクタリング後のコードを記載すると次のようになる。

App.vue(4-5.リファクタリング後)
<template>
  <div id="app">
    <h1>Ringo Juice</h1>
    <div class="today-info">
      <h4 class="today-info_title">本日のりんご</h4>
      <div class="today-info_list">
        <MyItem v-for="item of infoItems" :title="item.title" :value="item.value" />
      </div>
    </div>
    <p class="message">
      りんごジュースは1日100名様限定です。<br />当選者のみが購入可能です。<br />
      下記フォームにメールアドレスを入力してください。<br />抽選結果が届きます。
    </p>
    <div class="form">
      <MyTextBox v-model="address" placeholder="メールアドレス" />
      <MyButton size="small" @click="onClick">
        抽選する
      </MyButton>
    </div>
  </div>
</template>

<script>
import MyTextBox from '@/components/MyTextBox.vue'
import MyButton from '@/components/MyButton.vue'
import MyItem from '@/components/MyItem.vue'

export default {
  name: 'app',
  components: { MyTextBox, MyButton, MyItem },
  data() {
    return {
      info: { prefecture: '', variety: '', basePrice: 0 },
      address: ''
    }
  },
  computed: {
    infoItems() {
      return [
        { title: '産地', value: this.info.prefecture },
        { title: '品種', value: this.info.variety },
        { title: 'Mサイズ', value: `${this.info.basePrice}` },
        { title: 'Lサイズ', value: `${this.largeSizePrice}` }
      ]
    },
    largeSizePrice() {
      return this.info.basePrice * 1.4
    }
  },
  created() {
    this.info = this.getTodayInfo()
  },
  methods: {
    getTodayInfo() {
      return { prefecture: '青森', variety: 'ふじ', basePrice: 300 }
    },
    onClick() {
      alert(`${this.address}にメールを送信しました`)
    }
  }
}
</script>
MyItem.vue(4-5.リファクタリング後)
<template>
  <div class="today-info_item">
    <span>{{ title }}</span>
    {{ value }}
  </div>
</template>

<script>
export default {
  props: {
    title: { type: String, required: true },
    value: { type: [String, Number], required: true }
  }
}
</script>
MyTextBox.vue(4-5.リファクタリング後)
<template>
  <input :value="text" :placeholder="placeholder" @input="onInput" />
</template>

<script>
export default {
  model: { prop: 'text' },
  props: {
    text: { type: String, required: true },
    placeholder: { type: String, default: '' }
  },
  methods: {
    onInput(event) {
      this.$emit('input', event.target.value)
    }
  }
}
</script>
MyButton.vue(4-5.リファクタリング後)
<template>
  <button :class="btnClass" @click="$emit('click')">
    <slot />
  </button>
</template>

<script>
export default {
  props: {
    size: {
      type: String,
      default: 'small',
      validator: val => ['small', 'large'].includes(val)
    }
  },
  computed: {
    btnClass() {
      if (this.size === 'small') return 'small'
      return 'large'
    }
  }
}
</script>

さて、リファクタリング前と比べていかがだろうか。正直、劇的な変化はないのでがっかりした方もいるかもしれない。しかし、今回紹介したような問題のある書き方を避け、避けられなくとも問題点を理解した上で、コンポーネントを書いていくことができれば、より大規模なアプリを開発する上で役に立つ場面があると信じている。

5. まとめ

今回見てきた、初心者がやりがちだと思うコードの書き方の問題点とその解決方法を改めてまとめる。

繰り返しを含むtemplate

  • 問題点
    • コード量が単純に増え可読性が落ちる
    • 同様の修正が複数箇所で必要になり、変更しづらい
    • そもそも繰り返しなのかが、書いた本人以外に分かりづらい
  • 解決方法
    • 繰り返しの構造をコンポーネントとして抽出する
    • 繰り返している箇所にv-forを適用する

セットで更新しなければいけない状態

  • 問題点
    • セットで更新しなければならないことを忘れてしまいがち
  • 解決方法
    • セットで更新しなければいけない状態は算出プロパティとして定義する

所属がおかしな状態

  • 問題点
    • コンポーネント同士が密結合になり、コンポーネント外の影響を考慮しなければならなくなる
  • 解決方法
    • 適切なコンポーネントに状態を移動する
    • (場合によっては、Vuex等状態管理ライブラリの使用を検討する)

矛盾しうるプロパティ

  • 問題点
    • デフォルト値の指定がしづらい
    • 矛盾する指定をしたときに、どちらが優先して適用されるか分かりづらい
  • 解決方法
    • 同種のプロパティをまとめる

6. あとがき

局所的に問題点の指摘やその解決方法は記載できたかと思うが、いかに扱いやすいコンポーネントになったかという説明ができなかったので、時間があれば補足したい。特にコンポーネントのテストはしやすくなっているはずで、そういった説明も加えられると良いかなと思う。

7. 参考にしたサイト・書籍

  • Vue.js
  • Vuetify
  • Element
  • Martin Fowler (2018) Refactoring: Improving the Design of Existing Code, 2nd Edition, Pearson Education Inc
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Nuxt.js】コマンドメモ

Nuxt.jsとFirebaseを使ってWebアプリ開発中。

よく忘れるのでメモ。

ローカル開発環境立ち上げ

npm run dev

プロジェクトをビルド

npm run build

Firebaseに反映

firebase deploy

久しぶりにデプロイするとエラーになった。

Error: Failed to get Firebase project PJ名. Please make sure the project exists and your account has permission to access it.

再ログインする必要があるらしい。

firebase logout
firebase login

終わり

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

vuetify-loader1.6にアップデートしてビルドできなくなった場合の対処法

LaravelMixを使ってビルドしている場合にvuetify-loaderをバージョンアップすると下記のようなエラーが出てビルドできなくなった。

Error: [VueLoaderPlugin Error] vue-loader 15 currently does not support vue rules with oneOf. at VueLoaderPlugin.apply

どうやらvue-loaderとvuetify-loaderの読み込み順があべこべになっているため発生しているようで、vuetify-loaderの読み込み方を変更する必要があるみたい。

Before

wepack.mix.js
mix.webpackConfig({
//~中略
  plugins: [
    new VuetifyLoaderPlugin(),
  ],
//~中略
})

After

https://github.com/vuetifyjs/vuetify-loader/issues/144#issuecomment-659308887

wepack.mix.js
mix.webpackConfig({
  plugins: [
    //ここで読み込まない
  ],
})
//ここで追って読み込む
mix.extend('vuetify', new class {
  webpackConfig (config) {
    config.plugins.push(new VuetifyLoaderPlugin())
  }
})
mix.vuetify()
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む