- 投稿日:2020-12-16T23:59:32+09:00
Nuxtで作るフォームの書き方 個人的まとめ (Vue v2)
こちらは Goodpatch Advent Calendar 2020 16日目の記事です。
前置き
プロジェクトで項目数が多めなフォームを作ったので、その知見を一度まとめておきたいと思い書いています。
Vue v3 がリリースされ、エコシステムも次々に対応していく中、v2 をまとめてどうすんだという感じですが、 まあ、まだ v3 を本番環境で使わないと思いますし、そこまで変わらないだろうということで。
あくまで、筆者が実際に開発していくなかで中でうまくいった形をまとめたものでして、こうすべきだ!という感じのものではありません。(っていうかもっとよいやり方があれば教えてください、本当に知りたいです、頼むよマジで)
内容については、既にさまざまな記事で書かれているものだと思いますが、何かの参考になれば幸いです。
前提
環境
Nuxt v2.14.x
データの送信方法
form の入力内容を送信する方法として、submitでページを更新して送信するパターンがありますが、本記事では APIサーバー に対してXHRで送信する形態を想定しています。
観点
この記事での「良さ」の基準は、コードの記述のしやすさ、読みやすさ、変更のしやさです。
(もし、それらのメリットを打ち消すぐらいパフォーマス等の観点でやばいことをしてたら教えてください)
フォームの書き方
フォーム例
全体構成を図示して説明していくのですが、具体的な例があったほうがわかりやすいので、以下のようなフォームを実現することを考えます。
全体の構成とデータフロー
先に、完成状態の構成とデータフローを示します。(APIの認証関連については省略)
全体構成図
以下、細かい説明をしていきます。
コンポーネントの構成
コンポーネントの構成については、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つのコンポーネントに分離することにしています。
入力中のデータ
Presentationalコンポーネントでは一般的にdataを極力持たせないのが良いといわれていますが、特別な要件が存在しない限り、フォームの入力中のデータは、フォームコンポーネント上の
data
に持つのが良いと思います。というのも、仮に
data
を使わないとしたとき、残る候補はVuex Storeなのですが、以下のような理由で、メリットがあまりないからです。
- 記述性の点で、v-modelを利用したいが、Storeを利用する場合、入力項目分のgetter/setterの定義が必要になる。
- 入力中のデータなので他のページで使う用途は考えにくい。
- リアルタイムプレビューなどが必要な場合も、プレビュー用のコンポーネントをフォーム内に配置すればよい。レンダリングされる場所は portal-vue などでいかようにもなる。
- バリデーションルールやエラー文言などの情報を Store で扱うことになり、Storeの責務が増える。
私が開発するときは、図の通り、
input
というオブジェクトを作り、その中に入力データを集約するようにしています。この
input
は、更新Actionに対してそのまま渡せるものになっていると、記述が簡単になります。入力値の初期化
フォームを開いたとき、全て空欄の初期値のないフォームもあると思いますが、現在の設定値をフォームの初期値として表示したいことがほとんどだと思います。前項で示した通り、
input
で入力データを扱いたいので、 APIから取得したデータをここにコピーします。Storeから、Container、props経由で渡される
userProfile
オブジェクトは、StoreのState上のデータなので、直接編集はできません。なので、オブジェクトのshallow copyか、deep copyしたものをinput
に格納します。
input
の初期化のタイミングに注意が必要です。親コンポーネントがどのタイミングでuserProfile
を更新するかは、コンポーネントは知りませんし、知っているべきではないので、userProfileがいつ更新されてもinput
を更新できるようにしておく必要があります。大きくわけて、コンポーネントが生成される前に渡されたパターンと、される前に渡されたパターンを考える必要があります。
その両方に対応できるのが
watch
です。watch
によって、userProfileがフォームコンポーネントの生成後に渡されたパターンをキャッチできます。そしてwatch
のimmediate
オプションを 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がコンテナーコンポーネントから渡されている
userProfile
とinput
が同期しているかどうかを現わすようにするため、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 さんです。
- 投稿日:2020-12-16T23:21:25+09:00
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.logvue.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
を指定して型以外のバリデーションも指定できます。
※required
とdefault
は共存できませんまとめ
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
でイベントを発火させて値を処理するのは親の仕事。親が子供の世話をするはプログラムの世界でも一緒だなと実感しました
- 投稿日:2020-12-16T21:05:14+09:00
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.
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
- Go to your S3 page in the AWS console. Link: https://s3.console.aws.amazon.com/s3
- Press "Create bucket".
- Set the bucket name to your root domain name.
- Under Bucket settings uncheck "Block all public access".
- 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
- After creating the bucket, open the bucket and go to Properties
- Press Edit under Static website hosting and enable it.
- Choose Host a static website.
- Under Index Document make sure it is set to index.html. This will be the main file for each directory.
- Add an Error Document if you want to. This will be the default error page it will redirect to.
- Save settings.
Step 3: Set S3 bucket permissions
- The bucket is currently set to be public, so you need to limit the access to it.
- Go to Permissions in your bucket.
- 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
- 投稿日:2020-12-16T16:53:54+09:00
[Vue.js]ref属性を使って子コンポーネントインスタンスと子要素へのアクセスする
はじめに
refを初めて使用したので、備忘録として残します
ref属性とは
プロパティとイベントが存在するにも関わらず、ときどき JavaScript で直接子コンポーネントにアクセスする必要がある。
その場合に ref 属性を使うと、子コンポーネントにリファレンス ID を割り当てることができる実装方法
今回はインプットフォームにフォーカスさせる
①インプットフォームにref属性を付与する
<input ref="input">②親によって使用されるメソッドを定義して親コンポーネントに 内部の input 要素にフォーカスさせる
methods: { // 親からインプット要素をフォーカスするために使われる focus: function () { this.$refs.input.focus() } }これらの実装でfocusメソッドが動いた時にrefを付与したインプットフォームにフォーカスされる
参考
- 投稿日:2020-12-16T15:51:47+09:00
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:5000Herokuへデプロイ
まず,Heroku CLIをインストールする.
その後,ログインしてHerokuへpushする.
heroku login git add. git commit -am "[update]" git push heroku main
最後に
Herokuにデプロイする時に,OpenCVやPytorchが結構厄介だった.
- 投稿日:2020-12-16T14:46:07+09:00
Laravel + Vue.js の知識をアウトプットしていく
次の記事でLaravelをアウトプットしていますが、今回はLaravel + Vueでまとめていきます。
Laraveの知識をアウトプットして、資産化してます準備
$ composer create-project laravel/laravel sampleproject --prefer-dist "7.*"プロジェクトの作成を実施
もろもろの初期設定はこちらの記事を参考にしてください
Laraveの知識をアウトプットして、資産化してます認証機能をインストール$ composer require laravel/ui:^2.4 --devvueのインストール$ php artisan ui vue --auth $ npm install bootstrap-vue bootstrap $ npm install && npm run devvue-routerのインストール
vue-routerのインストールnpm install --save vue-router下記を追加する。
resources/app.jsimport 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 vuexnpm run devvue-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/clockworkddのように変数をデバッグ可能
clock(User::all());流れ
- コンポーネントの作成
- app.jsにimport
- HTMLにコンポーメントを追加して表示。
- 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を指定してリダイレクトすることが可能
- 投稿日:2020-12-16T12:09:17+09:00
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 newappVue3.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いつものウェルカムページが表示されます
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>するとこうなる
ここで、見慣れないsetup()という関数以下が、CompositionAPIなるものです。これまでVueを使っていたらすぐに使えそうな感触でした(ほんとはこの辺もまとめたかったのですが時間的なアレで割愛)。
VSCodeの設定
これで動くっちゃ動くんですが、もしかしたらマウスホバーしても変数定義は見られないし、コード補完も効いてないんじゃないでしょうか。TypeScriptの魅力の4割くらいはそこにあると思っているので、このままではいけません。
マウスホバーとコード補完
エクステンション「Vetur」が必要です。
https://marketplace.visualstudio.com/items?itemName=octref.vetur
コードフォーマット
で、私の環境だとコード補完だとかはこれで解決したんですが、コードフォーマットが効かなくなりました。Veturのインストール直後からです。これは、VSCodeのフォーマットは「Prettier」に任せているからでした。Veturにもフォーマット機能が内蔵されていて競合するっぽいので、エクステンションの設定から
Vetur > Format: Enable
という項目のチェックを外したら解決しました。おわりに
まだ実際にこの構成でアプリケーションを作ってはいませんが、ただでさえ最高なVue.jsにTypeScriptが加わる事でアホほど快適な開発環境になるのではないでしょうか。Mapbox GL JSはVue.jsとの相性も非常によく(個人の感想です)、さらにTypeScriptによる強力なコード補完で向かうところ敵なしです。
さぁみなさんもご一緒に
Vue.js最高!
- 投稿日:2020-12-16T11:01:54+09:00
【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/cliGridsomeプロジェクトを作成します
gridsome create my-gridsome-site 新しいプロジェクトを作成するには cd my-gridsome-site フォルダを開く gridsome develop ローカル開発サーバーを起動するには http://localhost:8080 ハッピーコーディング??インストールが完了してローカルホストにアクセスすると下記のような画面になっていたらひとまずOKです。
しかし、このままではマークダウンで記事かけません。
そこでマークダウンで記事をかけるようにしてくれるプラグインをインストールします。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.js
とmain.js
を編集しないといけません。gridsome.config.jsmodule.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.jsimport 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
と同じファイル名を作成する必要があります。
typeName
にPost
と記述したら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を試すことができます。
こんな感じの画面が出てくるはずです。
先程試しに書いてみた記事の内容を取得してみます。
query { allPost { edges { node { title description date path content } } } }上記を先程の画面にコピペしてみてください。
先程サンプルで書いたデータが返ってくるはずです。
この内容を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>index.vueで記事のデータを全件取得してそれぞれ内容を表示させています
<g-link>
というのはGridsomeが用意したGridsome内で使えるvue-router
のようなものです。基本これを使ってページ遷移をさせます。しかし、現段階では記事でのデータを個別取得するように取得していないので記事のリンクに移動しても何も表示されていないかと思います。
記事の個別取得
記事の個別取得はGraphQLと紐付いたファイル名でないと出来ません。
gridsome.config.js
のsource-filesystem
の箇所を見てみてください。plugins: [ { use: '@gridsome/source-filesystem', options: { typeName: 'Post', path: 'content/**/*.md' } } ],ここの
typeName
と同じファイル名のものを/src/templates
内に作成することで記事の個別のデータを取得することが出来ます。
自分の環境では、typeName
がPost
とあるので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
と同じになっていることを確認してください。実際に個別にデータが取得できていることが確認できました。
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.jsmodule.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.tsdeclare 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点目指して、いつかサイトを軽量化に挑戦してみようと思いました。
- 投稿日:2020-12-16T10:39:29+09:00
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 }; } }, });これを解決するのに、めちゃめちゃ時間がかかったので皆さんの助けになればなと思います。
- 投稿日:2020-12-16T09:40:07+09:00
warning Unexpected console statement no-console の無効化
- 投稿日:2020-12-16T06:40:33+09:00
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を使用して記述してます。作成要件
- ユーザーの切り替えができる(ログイン機能を付けないので、手動でユーザーを切り替える仕様にします)。
- 商品の情報を取得し、一覧表示する。
- 商品の購入数を指定し、非同期通信でカートに追加できる。
- カートに入っている商品点数を、非同期通信で表示する。
- カートに追加する際はバリデーションを行い、エラーがあればメッセージを表示させる。
- カートへの登録が完了したら、購入数は空に戻す。
- ユーザーを切り替えるとカートに入っている商品点数も、非同期通信で変更される。
イメージ
それでは、作成していきましょう。
マイグレーションファイルの作成
データベースへテーブルを作成したいと思います。
今回作成するのは、商品を管理するproducts_table
とカート用のcarts_table
です。プロジェクトのディレクトリでターミナルを起動し、以下を実行してください。
php artisan make:migration create_products_table続けて
php artisan make:migration create_carts_table作成が完了したら、
database>migrations
内にある作成されたファイルを開き、以下のように記述します。create_products_tablepublic function up() { Schema::create('products', function (Blueprint $table) { $table->bigIncrements('id'); $table->string('name')->comment('商品名'); $table->string('price')->comment('価格'); $table->timestamps(); }); }create_carts_tablepublic 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.phpclass 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.phpRoute::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()
でいけると思ったのですが・・・
- 投稿日:2020-12-16T03:38:27+09:00
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. リファクタリングするサンプルアプリ
今回、リファクタリングの対象とするサンプルアプリは下図のようなイメージだ。
とある青果店のホームページの一角に表示するものと思って欲しい。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
にはprefecture
、variety
、basePrice
の3つのプロパティをもつオブジェクトinfo
とlargeSizePrice
が登録されている。このうちMサイズの料金(基本料金)を表す
info
のbasePrice
と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
コンポーネントでは入力フォームの状態を定義し、MyTextBox
にprops
として渡し、変更イベントを受け取り次第状態を更新するようにする。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
等の状態管理ライブラリを導入するのも手だ。さて、もっと簡潔な記載にするため
MyTextBox
にv-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>
のように意味が矛盾するような指定もできてしまうので注意が必要だ。それ自体が直接問題を引き起こすようなことは少ないかもしれないが、デフォルト値の指定がしづらかったり、両方指定された場合の適用優先度を把握しなければならなかったりすることで、可読性が落ちる可能性がある。上記問題を解決するために、以下のリファクタリングを適用する
- 同種のプロパティをまとめる
同種のプロパティをまとめる
isSmall
とisLarge
がともにサイズに関連するプロパティであったがために、矛盾が生じる指定ができてしまっていたので、これらをサイズを指定するためのプロパティにまとめてしまえば良い。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. 参考にしたサイト・書籍
- 投稿日:2020-12-16T00:53:12+09:00
【Nuxt.js】コマンドメモ
Nuxt.jsとFirebaseを使ってWebアプリ開発中。
よく忘れるのでメモ。
ローカル開発環境立ち上げ
npm run devプロジェクトをビルド
npm run buildFirebaseに反映
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終わり
- 投稿日:2020-12-16T00:30:24+09:00
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.jsmix.webpackConfig({ //~中略 plugins: [ new VuetifyLoaderPlugin(), ], //~中略 })After
https://github.com/vuetifyjs/vuetify-loader/issues/144#issuecomment-659308887
wepack.mix.jsmix.webpackConfig({ plugins: [ //ここで読み込まない ], }) //ここで追って読み込む mix.extend('vuetify', new class { webpackConfig (config) { config.plugins.push(new VuetifyLoaderPlugin()) } }) mix.vuetify()