- 投稿日:2019-12-03T23:53:05+09:00
【Vue.js】アスペクト比を設定できるVueコンポーネントを作る
本記事は岩手県立大学 Advent Calendar 2019 4日目の記事です。
はじめに
Webページを作っているとアスペクト比を設定できる
<img>
タグが欲しいと思うことがありました。
これまではCSSの@media screen
とか:before
で頑張って実現していましたが、理解が難しいです。
その割に使いたい場面は結構あります(部品化したい)。
そのため、今回は「CSSを頑張らない」アスペクト比を設定できるVueコンポーネントを作っていきます。
(jQueryで実現する方法もあるみたいですが、私はjQueryを使ったことがないのでスルーします。)CSSでアスペクト比を設定するときに困ること
width: 100%
にしたいときの高さが定まらない基本的に
<img>
の縦横サイズはwidth: 640px; height: 360px
という感じでpx
で設定します。そうしないと好きなアスペクト比に設定できません。
それではwidth: 100%
の時、実際の要素の横は何pxで、縦は何pxにするのでしょうか?
私も若かりし頃は、width: 100%; height: 56.25%
とすればいけるんじゃね?と思っていましたが、それでは高さは0になってしまいます。hoge.html<div> <img src="..." style="width: 100%; height: 56.25%"> <!-- 高さは0!! --> </div>これは、そもそも親の要素の高さが指定されていないからです。
したがって、動的にwidthとheightを〇〇pxで設定することが必要です。コーディングのおことわり
- Vue単一ファイルコンポーネントで記述します
- TypeScriptで書きます
- style部はStylus記法で書きます
- vue-property-decoratorを使います
- class styleでコンポーネントを定義します
実装したコード
VueAspectRatioImage.vue<template> <div class="v-aspect-ratio-img" :style="style"> <img :src="imageData"> </div> </template> <script lang="ts"> import { Vue, Component, Prop } from 'vue-property-decorator' @Component export default class VueAspectRatioImage extends Vue { @Prop({ type: String, required: false, default: '128x128' }) size!: string @Prop({ type: Number, required: false, default: 0 }) aspectRatio!: number // height/width @Prop({ type: Boolean, required: false, default: false }) fullWidth!: boolean @Prop({ type: String, required: false, default: null }) refName!: string|null @Prop({ type: String, required: false, default: 'px' }) unit!: string @Prop({ type: String, default: '', required: false }) src!: string clientWidth: number|null = null mounted() { this.handleResize() // 画面サイズ変更イベント window.addEventListener('resize', this.handleResize) } beforeDestory() { window.removeEventListener('resize', this.handleResize) } handleResize() { if (this.refName) { const vc = this.$parent.$refs[this.refName] as any if (vc && Array.isArray(vc)) { try { vc[0].$el.style.minWidth = '0' this.clientWidth = vc[0].$el.clientWidth } catch { this.clientWidth = null } } else if (vc) { vc.$el.style.minWidth = '0' this.clientWidth = vc.$el.clientWidth } } } get imageData() { if (this.src.indexOf('http') === 0 || this.src.indexOf('data:image') === 0) { return this.src } else if (this.src) { return require('../assets/' + this.src) } else { return '' } } get style() { let width = '128' let height = '128' if (/\d+x\d+/.test(this.size)) { width = this.size.split('x')[0] height = this.size.split('x')[1] } if (this.fullWidth && this.refName && this.clientWidth) { width = String(this.clientWidth) } if (this.aspectRatio > 0) { height = String(this.aspectRatio * parseInt(width)) } return { width: this.fullWidth ? '100%' : `${width}${this.unit}`, height: `${height}${this.unit}`, minWidth: `${width}${this.unit}` } } } </script> <style lang="stylus" scoped> .v-aspect-ratio-img img width 100% height 100% object-fit cover </style>コード解説
<templete>
部VueAspectRatioImage.vue<template> <div class="v-aspect-ratio-img" :style="style"> <img :src="imageData"> </div> </template>ポイント
<img>
を囲む<div>
に動的スタイルを適用することここで
<img>
だけに動的スタイルを適用すると、このコンポーネント全体の横幅は常に100%になります。(divがblock要素だから)
<script>
の@Prop
群@Prop({ type: String, required: false, default: '128x128' }) size!: string @Prop({ type: Number, required: false, default: 0 }) aspectRatio!: number // height÷width @Prop({ type: Boolean, required: false, default: false }) fullWidth!: boolean @Prop({ type: String, required: false, default: null }) refName!: string|null @Prop({ type: String, required: false, default: 'px' }) unit!: string @Prop({ type: String, default: '', required: false }) src!: string上からそれぞれ
size
縦横のサイズを決めます。
単位は他のPropで決めるので、ここでは256x256
という感じで書きます。
(ここでは横 x 縦)aspectRatio
アスペクト比を決めます。縦÷横の値。
横16:縦9ならば、9 ÷ 16 = 0.5625 です。fullWidth
横いっぱいにするか決めます。
この場合でもアスペクト比は保たれます。【重要】refName
このコンポーネントからみた親コンポーネントで設定するref属性名です。
これがないとwidth:100%
の時の自身の横幅がわかりません。unit
サイズの単位です。sizeで20x20
、unitでrem
とすると、width: 20rem; height: 20rem;とスタイリングされます。
src
画像URLを指定します。
コード中で<img :src="imageData">
となっているのは、外部またはassets内のどちらからでも画像を参照できるように工夫するためです。
<script>
のmounted()
とbeforeDestory()
mounted() { this.handleResize() // 画面サイズ変更イベント window.addEventListener('resize', this.handleResize) } beforeDestory() { window.removeEventListener('resize', this.handleResize) }ポイント
mounted()
で画面サイズが変更されたときにhandleResize()
が呼ばれるように設定する。- beforeDestoryでイベントリスナーを取り消す
<script>
のhandleResize()
handleResize() { if (this.refName) { const vc = this.$parent.$refs[this.refName] as any if (vc && Array.isArray(vc)) { try { vc[0].$el.style.minWidth = '0' this.clientWidth = vc[0].$el.clientWidth } catch { this.clientWidth = null } } else if (vc) { vc.$el.style.minWidth = '0' this.clientWidth = vc.$el.clientWidth } } }ここではrefNameを使って、このコンポーネント自身のサイズを測定します。
正確には、width:100%
の時の横幅を測定します。3行目
const vc = this.$parent.$refs[this.refName] as any
ここで自身のVueComponentを取得します。(vcはその略)7行目
this.clientWidth = vc[0].$el.clientWidth
ここで自身の横幅を測定します。その他
if (vc && Array.isArray(vc)) { ... } else if (vc) {という条件分岐を書いているのは、場合によっては
vc
が配列になるからです。
<script>
のget imageData()
get imageData() { if (this.src.indexOf('http') === 0 || this.src.indexOf('data:image') === 0) { return this.src } else if (this.src) { return require('../assets/' + this.src) } else { return '' } }算出プロパティです。
ここでは、Propのsrcに外部URL(https://~)とassets内のファイル名のどちらが設定されても画像を参照できるようにしています。
ポイントはassets内のファイルを参照する時に、require(..)
を使うことです。
<script>
のget style()
get style() { let width = '128' let height = '128' if (/\d+x\d+/.test(this.size)) { width = this.size.split('x')[0] height = this.size.split('x')[1] } if (this.fullWidth && this.refName && this.clientWidth) { width = String(this.clientWidth) } if (this.aspectRatio > 0) { height = String(this.aspectRatio * parseInt(width)) } return { width: this.fullWidth ? '100%' : `${width}${this.unit}`, height: `${height}${this.unit}`, minWidth: `${width}${this.unit}` } }算出プロパティです。
ここでアスペクト比を保つように動的にスタイルしています。4~7行目
if (/\d+x\d+/.test(this.size)) { width = this.size.split('x')[0] height = this.size.split('x')[1] }正規表現を使って、
@Prop size
の256x256などの文字列をパースします。9~11行目
if (this.fullWidth && this.refName && this.clientWidth) { width = String(this.clientWidth) }
@Prop fullWidth
がtrueのときに、横いっぱいになるようにwidthを指定してます。
handleResize()
で測定したclientWidth
はwidthが100%の時の値です。13~15行目
if (this.aspectRatio > 0) { height = String(this.aspectRatio * parseInt(width)) }アスペクト比が設定された場合、heightを更新します。
17~21行目
return { width: this.fullWidth ? '100%' : `${width}${this.unit}`, height: `${height}${this.unit}`, minWidth: `${width}${this.unit}` }最終的にCSSの形のオブジェクトを返します。
このコンポーネントの利用方法
<v-aspect-ratio-img src="background-sky.png" :fullWidth="true" ref="image3" ref-name="image3" :aspect-ratio="0.5625" />実行イメージ
PC スマホ 終わりに
Vueで好きな部品を作れるようになったので、コードを再利用することを前提に書いていけるのがいいですね。
- 投稿日:2019-12-03T23:21:11+09:00
1年間サーバーレスで運用したNuxt.js製サイトをTypescript移行した話とそこで学んだこと
概要・前置き
どうも、都内でフロントエンドエンジニアをしてます、かめぽんです。以前、Nuxt.js + Netlifyで爆速構築するサーバーレス開発入門という記事を書きまして、その記事と同じNuxt.js + Netlifyのシンプルな構成で作った、以下のサイトを1年間と3ヶ月ほど運用してきました。撮影からデザイン、インタラクションの実装からデプロイまで一気通貫でやってみました。
https://www.brightanddizain.com/
Lighthouseの評価もよくサイトがきっかけでお仕事もちょくちょくいただいたりと、おかげさまでここまでくることができました。(PWA対応もしてます)
しかしながら、フロントエンド技術の流れが非常に早く陳腐化してきたことやサステナビリティを高めたいとは思いつつも課題が多かったのでこの機にnuxtのtypescript移行とそれに伴う環境基盤の構築をしました。そこで学んだことや大事なことなどをご紹介し、少しでも技術の移行やフロントエンド基盤構築や設計のお役に立てればと思います。
少々長いですが、最後まで読んでいただけると嬉しいです!サイトの概要
以下、サイトの技術スタックです。動的な部分は少なく頻繁にメンテもするわけではないので、静的なコンテンツで管理しています。しかし、DXの向上を目指しつつメンテしやすいことが条件ではあり、かつコンポーネント指向開発は必須なためnuxt.jsを使用しています。
- Nuxt.js(nuxt generate)
- Netlify
- Atomic Design
- slack api
移行の目的
現状、SFCのVueコンポーネントでサイトを構成していますが、コンポーネントの管理があまりできておらず将来的にさらに陳腐化が進むことが懸念されます。
また、これからくるであろう技術を出来るだけ実践的に試せるプレイグラウンド的な立ち位置にしたく整理をしていきます。さらに、Typescriptがこれからデファクトスタンダートのような立ち位置になるのではないかと予想をふみjavascriptから脱却し、型安全による品質とサステナビリティ向上を目指します。
- コンポーネント資産の管理と品質の向上
- プレイグラウンド的立ち位置
- 陳腐化による技術スタックの刷新
実際にやったこと・構成のポイント
ここからは実施したものやリファクタをした内容などを説明していきますが、実際に導入した施策はかなり多かったので、厳選して nuxt typescript移行をする際の重要な部分を記載します。また、アーキテクチャを考えるときに重要視しているStoreとService層について少し説明します。
- typescript導入
- Vuex Storeの型定義
- StoreとSeriviceについて
typescript導入
まず、nuxtのtypescript移行についてですがこちらは 公式でも出ているtypescript.nuxtjs.orgに従って移行します。ここでやることとしては以下の通りです。
- 必要なモジュールの導入
- config系の編集
必要なモジュールの導入
まずはじめにnuxt tsに移行するために、
@nuxt/types,
@nuxt/typescript-build
@nuxt/typescript-runtime
を導入します。 加えて、ts-loader
もインストールします。@nuxt/typescript-runtime
は任意ではありますがnuxt.configなどでtsを使う場合には必要なのでインストールします。なお@nuxt/typesは@nuxt/typescript-buildに含まれているので個別にインストールする必要はありません。npm i -D @nuxt/typescript-build ts-loader
npm i @nuxt/typescript-runtime
はプロダクション環境でも必要になるためdependenciesでインストールします。npm i @nuxt/typescript-runtimeconfig系の編集
最初にtsconfig.jsonの準備をしましょう。設定はお好みで良いですが以下に例を載せておきます。typesの欄に
@nuxt/types
を追加しておきましょう。package.json{ "compilerOptions": { "target": "es5", "module": "esnext", "moduleResolution": "node", "lib": [ "esnext", "esnext.asynciterable", "dom" ], "typeRoots" : ["./type"], "allowSyntheticDefaultImports": true, "noImplicitAny": false, "esModuleInterop": true, "allowJs": true, "sourceMap": true, "strict": true, "noEmit": true, "rootDir": "./src/", "baseUrl": "./src/", "paths": { "@*": [ "./*" ], "*": [ "*" ] }, "types": [ "@types/node", "@nuxt/types" ] }, "include": [ "src/**/*.ts", "src/**/*.vue", "src/**/*.spec.ts", "src/**/*.spec.tsx", "src/**/*.test.ts", "src/**/*.test.tsx" ], "exclude": [ "node_modules" ] }次に nuxt.configの設定ですが、先ほど
@nuxt/typescript-runtime
を導入したので早速 nuxt.config.jsの拡張子を.ts
に変えます。また、nuxtConfigに型をつけるので@nuxt/typesでtypeをつけます。import { Configuration as NuxtConfiguration } from '@nuxt/types' ... const nuxtConfig: NuxtConfiguration = { mode: 'universal', ... build: { extend(config: any, { isDev, isClient }) { const tsLoader = { loader: 'ts-loader', options: { appendTsSuffixTo: [/\.vue$/], context: __dirname, configFile: 'tsconfig.json' } } for (let rule of config.module.rules) { if (rule.loader === 'vue-loader') { rule.options.loaders = { ...rule.options.loaders, ts: tsLoader } } } } }, buildModules: [ [ '@nuxt/typescript-build', // buildMudulesに@nuxt/typescript-buildを追加します。 { typeCheck: true, ignoreNotFoundWarnings: true } ] ] } export default nuxtConfig次に、npmコマンドからtsを動かせるようにするためにpackage.jsonのscriptsを編集します。今までは
nuxt build
などで動きましたがts環境ではnuxt-ts
を使用するのでnpm scripts内のコマンドを書き換えましょう。package.json{ ... "scripts": { "dev": "nuxt-ts --spa", "build": "nuxt-ts build", "start": "nuxt-ts start", "generate": "nuxt-ts generate" ... } ... }ビルド周りの設定は以上なので、
npm run dev
のコマンドを実行しhttp://localhost:3000/
にアクセスしてサイトが表示されたら成功です。Vuex Storeの型定義
次にStoreのtypescript対応になりますが、Storeの型定義に関しては@takepepeさんの ts-nuxtjs-expressを参考にさせていただいてます。圧倒的に感謝です!
NuxtのVuex Storeでやることとしては以下の通りになります。
- typesの準備
- Vuexの型の拡張
- storeの型付け
最初にVuexにおけるtypesの準備をします。Storeはモジュールモードで以下のようなディレクトリ構成にします。
├─store/ ├─ contact/ ├─ index.ts // contactのstoreモジュール本体 └─ type.ts // Storeの型定義ファイルここで、nuxtが型定義のファイルまでstore配下のファイルを自動的にstoreに認識してしまうため
.nuxtignore
ファイルを作り自動的にstoreに認識されないようにします。.nuxtignorestore/**/type.ts次にcontact storeの型定義ファイルを作ります。(記事に収めるるため実際のパラメータよりも少なくしています)
interfaceの名前が略称になってますが、それぞれ S(State)、 G(Getters)、 RG(RootGetters)、 M(Mutations)、 RM(RootMutations)、 A(Actions)、 RA(RootActions)の意味になります。store/contact/type.tsexport interface S { name: string tel: string message: string } export interface G { name: string tel: string message: string isErrName: boolean isErrTel: boolean isErrMessage: boolean } export interface RG { 'contact/name': G['name'] 'contact/tel': G['tel'] 'contact/message': G['message'] 'contact/isErrName': G['isErrName'] 'contact/isEreTel': G['isErrTel'] 'contact/isErrMeassage': G['isErrMessage'] } export interface M { SET_NAME: string SET_TEL: string SET_MESSAGE: string } export interface RM { 'contact/SET_NAME': M['SET_NAME'] 'contact/SET_TEL': M['SET_TEL'] 'contact/SET_MESSAGE': M['SET_MESSAGE'] } export interface A { setName: string setTel: string setMessage: string resetContacts: void sendContacts: void } export interface RA { 'contact/setName': A['setName'] 'contact/setTel': A['setTel'] 'contact/setMessage': A['setMessage'] 'contact/resetContacts': A['resetContacts'] 'contact/sendContacts': A['sendContacts'] }次にVuexの型の拡張に入ります。以下のようにプロジェクトのルートに
types
ディレクトリを作成しVuexの型を拡張していきます。├─types/ ├─ vuex/ ├─ index.d.ts // プロジェクト全体に影響する実際に呼び出されるVuexの型定義 ├─ root.ts // プロジェクト固有のstoreで定義したtypesをVuexにつなぎこむ場所 └─ type.ts // Storeの型定義ファイルindex.d.tsでは主に共通的に使えるtype.tsとプロジェクト固有のルールを含んだroot.tsをインポートします。このプロジェクトでVuexを使う場合このファイルが読み込まれます。
types/vuex/index.d.tsimport './root' import './type'こちらはプロジェクト固有のルールを含んだVuexの型拡張になります。
types/vuex/root.tsimport 'vuex' import * as Contact from '../../store/contact/type' import * as View from '../../store/view/type' declare module 'vuex' { type RootState = { contact: Contact.S viwe: View.S } type RootGetters = Contact.RG type RootMutations = Contact.RM type RootActions = Contact.RA }以下はVuexで共通的に使われる型拡張です。
types/vuex/type.tsimport 'vuex' declare module 'vuex' { type Getters<S, G> = { [K in keyof G]: ( state: S, getters: G, rootState: RootState, rootGetters: RootGetters ) => G[K] } type Mutations<S, M> = { [K in keyof M]: (state: S, payload: M[K]) => void } type ExCommit<M> = <T extends keyof M>(type: T, payload?: M[T]) => void type ExDispatch<A> = <T extends keyof A>(type: T, payload?: A[T]) => any type ExActionContext<S, A, G, M> = { commit: ExCommit<M> dispatch: ExDispatch<A> state: S getters: G rootState: RootState rootGetters: RootGetters } type Actions<S, A, G = {}, M = {}> = { [K in keyof A]: (ctx: ExActionContext<S, A, G, M>, payload: A[K]) => any } interface ExStore extends Store<RootState> { getters: RootGetters commit: ExCommit<RootMutations> dispatch: ExDispatch<RootActions> } type StoreContext = ExActionContext< RootState, RootActions, RootGetters, RootMutations > }最後にtsconfig.jsonのfilesにtypes/vuex/index.d.tsをfilesに記述します。基本的に独自で拡張した型ファイルがあれば、随時tsconfigに追加すようにしましょう。
tsconfig.json"files": [ "src/types/vuex/index.d.ts" ],ここまで、Storeの型定義をしてきましたが、ここからは実際のStoreに型を付けていきます。先ほど配置した
store/contact/index.ts
に、定義した型を当てていきます。1行目のvuexのimportで、先ほどtypes/vuex/index.d.tsで拡張したGtters、Mutations、Actionsの型を取り込みます。2行目ではstore/contact/type.ts
のinterface宣言した型を取り込んでいます。GettersやActions内にあるserviceディレクトリから取り込んでいるのは、ビジネス要件を含んだ純関数になっています。
store/contact/index.tsimport { Getters, Mutations, Actions } from 'vuex' import { S, G, M, A } from './type' import { validName, validTel, validMessage } from '../../service/validation' import submitContact from '../../service/Contact' export const state = (): S => ({ name: '', tel: '', message: '' }) export const getters: Getters<S, G> = { name: state => state.name, tel: state => state.tel, message: state => state.message, isErrName({ name }) { return validName(name) }, isErrTel({ tel }) { return validTel(tel) }, isErrMessage({ message }) { return validMessage(message) } } export const mutations: Mutations<S, M> = { SET_NAME(state, payload) { state.name = payload }, SET_TEL(state, payload) { state.tel = payload }, SET_MESSAGE(state, payload) { state.message = payload } } export const actions: Actions<S, A, G, M> = { setName({ commit }, payload) { commit('SET_NAME', payload) }, setTel({ commit }, payload) { commit('SET_TEL', payload) }, setMessage({ commit }, payload) { commit('SET_MESSAGE', payload) }, resetContacts({ commit }) { commit('SET_NAME', '') commit('SET_TEL', '') commit('SET_EMAIL', '') commit('SET_COMPANY', '') commit('SET_MESSAGE', '') }, sendContacts({ state }) { const { company, name, email, tel, message } = state return submitContact({ name, tel, message }) } }Storeの型定義と型付けに関しては以上で、あとはいつも通りVueコンポーネントでmapGettersやmapActionsでStoreの内容を取り込むだけで型安全なStoreを扱うことができます。モジュールモードになっているので、別のStoreを導入し型安全にしていきたい場合は同様の手順を踏んでいくと良いと思います。
ここで書いてあるtypescriptのコードはあくまで一例なのでプロジェクトにあった型定義を模索していければと思います。StoreとService層ついて
NuxtでもVueのプロジェクトでもビジネスルールを含んだロジックの記述場所は悩みのタネの一つだと思います。コンポーネント側に寄せるのか、それともStoreに集約されるのか、両方使うならどれくらStoreに寄せるべきかなどなどあると思います。よく、
Storeの肥大化
なんて言われるかと思います。とはいえ、コンポーネントにロジックが並んでいるのもViewとロジックが同居してしまい混乱の原因になり得ます。では、Storeの肥大化を防ぎながらどうやってビジネスルールを含んだロジックをVuexで管理するか、というところになります。僕の意見としては、「ロジックの網羅性」と「ビジネス固有の複雑さ」を役割分担することが大事だとおもってます。
ここでいうと、Storeがロジック(ユースケース)の網羅性を担保し、Service層でビジネス固有の複雑性を含むことです。例えば先ほどのStoreの型をつけているところでいうと、Gettersを宣言しているisErrNameやisErrTelなどのreturn部分で、validNameやvalidTelなどがあります。これがServiece層の関数になっています。ここでは、Storeはこのロジックの中身を知りません。しかしながら、Storeは画面側で必要なプロパティやActionsをもつべきなので、このようにしています。
export const getters: Getters<S, G> = { name: state => state.name, tel: state => state.tel, message: state => state.message, isErrName({ name }) { return validName(name) // ValidNameがService層 }, isErrTel({ tel }) { return validTel(tel) }, isErrMessage({ message }) { return validMessage(message) } }こちらがそのService層の中身です。ここではあくまでとてもシンプルな例を出してますが、アプリケーション固有のロジックを以下のように値を受け取って出力するだけの純関数にすることで、簡単に使い回せますしVuexのコードを汚すことなく済みます。
type ValidType = (value: string) => boolean export const validName: ValidType = value => { const isErr: boolean = value.length < 4 // 名前が4文字以内だと弾かれるというルール return isErr }StoreとService層とで分割することで、Vuexのことを気にせずこの関数の中身自体をスケールさせることも分割することも用意です。
また、純関数なので非常にテストがしやすく、Vuex以外の技術に移行しやすくその時のFWなどに依存しずらいというメリットもあります。(そもそもビジネスがFWの流行り廃りに左右されるのは本望ではないかと思います)Service層が割とutils層みたいな部分と区別がつかないみたいな話もありますが、僕個人の意見としてはutils層は
アプリケーション固有の情報を含まず共通して使えるもの
と認識しております。
例えば、axiosをラップした外部リソース取得用のモジュールなどです。それ自体は、ビジネスルールを含む訳ではないのでutils層におきます。ただそこから、特定のAPIを叩くための関数だったりプロジェクト固有の独自キーを用いたストレージへのアクセスとなるとそれを取り込んだ上でService層で定義します。学んだこと
サーバーレス&generateサイトを1年間運用してみて
サーバーレス&静的サイトジェネレートということでNuxt + Netlifyで1年間サイトを運用してみて、非常に楽だったなという思いです。動的コンテンツが少なくちょっとしたお問い合わせフォームだけで複雑な実装の画面などはなかったので静的コンテンツの管理だけで済みました。
また、料金面でもドメイン取得料だけだったので総額の出費も3000円程度で済みました。Netlifyに限った話ではなく、Amazon S3にアップするだけでも良いですし、若干レガシー環境だとレンタルサーバーに書き出したファイルを設定するだけでサイト作成がほぼ完了したりします。またNuxtなので、静的ファイルも書き出せつつコンポーネント志向開発で出来るという強みは非常に大きかったです。見えてきた課題とアーキテクチャを考える上で大事なこと
という主語がデカめのタイトルになってしまって申し訳ないですが、「配置すること」って結構重要だと思っていてディレクトリ構成やなんの技術をどう使うか、という話は後の運用に大きく影響し導入時に大部分が決まる、みたいなとこはあると思います。
実際に、typescript移行は影響範囲が全体でコンポーネントやStore全てに影響が出たので、最初からTSにすればよかったんや!!って思うことがありました。また、それによって必要なモジュールも増えるので決めるべき時にしっかり決めるべきだなと感じました。
その上で設計や技術選定で気をつけたいなと思ったことは「壊しやすいか」と「組織あるいはチームの構造はどんなか」です。壊しやすさについて
壊しやすさは「依存度を下げる」みたいな意味合いも入っていて、ある特定の技術がなければ作れないあるいは作り直したほうが早いみたいな状況です。
いかに既存の資産を活かせる形をとれるかが重要になってくるんですが、先ほどのStoreとService層の話はまさにそれです。
Service層はビジネス固有のルールを含んでいますが、Vuexに対して依存しないようにしています。そうすることによって、最悪VueやVuexが使えない状態になったとしても、そのServiceの純関数群はそのまま他のFWでも扱うことが出来ます。(あくまで「壊しやすい形」のほんの一例です)おそらく、フレームワークのライフサイクルよりも事業のライフサイクルの方がおそらく長い(事業による)かと思います。また、プロダクトやサービスに与える影響の大きさは、フレームワークではなくマーケットの動きだったりユーザーフィードバックの方が大きいはずなので、それに合わせていかに変化(壊しやすく)出来るかが重要、という感じです。
組織あるいはチームの構造はどんなか
こちらは、コンウェイの法則でもあるんですが
The structure of any system designed by an organization is isomorphic to the structure of the organization.とあるので、出来上がるシステムの構造は設計する組織の構造に依存する、と解釈できます。
設計する時の組織構造は何か、どんなメンバーがいるのか、どんなチーム構成なのかという部分がシステムやアプリケーションに影響します。加えて、そのサービスやプロダクトが将来的にどういった方向に行きたいのか、どういった形態になりうるのか、未来の組織の様子を踏まえて設計することが必要です。つまり、未来のチームの姿と現在との差分を定義した上でアーキテクチャを考えると良さそうだなと、思ってます。まとめ
- Nuxt typescrptおすすめ!
- 静的サイトジェネレート+ホスティングで運用コストを減らそう
- 設計は「壊しやすいか」と「組織あるいはチームの構造はどんなか」を考慮する
ここまで読んでいただき誠に感謝です。
前章で組織構造大事やぞ、と言いつつ今回のNuxtのサイトは僕一人で作ったので、今回に関しては組織構造とかないですが技術に依存し過ぎずでもそれの旨味は味わいつつ、という風にやっていけそうな気はするのでこれからも運用していきます。以下、本記事のまとめになります。今回実際に刷新したサイトのリポジトリをのせていますのでご参考にしていただければと思います。
https://github.com/isihigameKoudai/bright-and-dizain参考
以下、参考リポジトリや資料になります。ありがとうございます!
- https://typescript.nuxtjs.org/
- https://github.com/takefumi-yoshii/ts-nuxtjs-express
- https://ja.wikipedia.org/wiki/%E3%83%A1%E3%83%AB%E3%83%B4%E3%82%A3%E3%83%B3%E3%83%BB%E3%82%B3%E3%83%B3%E3%82%A6%E3%82%A7%E3%82%A4
- 投稿日:2019-12-03T23:04:28+09:00
【2019年12月版】Nuxt + TypeScript + ESLint + PrettierとVSCodeで環境構築する方法
環境
- Windows 10 Home
- Node v12.13.0
- Yarn v1.7.0
- Nuxt v2.10.2
対象
すでにNuxtの知識がある方、自分でnuxt.config.jsの書き換えができたり、ある程度ESlintなどの知識がある方を対象としております。
細かい所はかなり割愛しています完成品
GitHubにて公開されています
type-script-nuxtgit clone https://github.com/devinoue/type-script-nuxt.gitNuxtのインストール
通常版Nuxtのインストールします。
yarn create nuxt-app アプリ名コマンドラインで色々と聞かれると思いますが、今回は以下のようにしています。
- Eslintはここでは入れない。
- Prettierは導入
- 他は特になし
TypeScriptの導入
以下はほぼ公式通りです。
yarn add --dev @nuxt/typescript-build
設定ファイルのnuxt.config.jsのうち、buildModulesに書き加えます。
nuxt.config.jsexport default { buildModules: [ '@nuxt/typescript-build' ] }ルートディレクトリに
tsconfig.json
を作成してください。以下のjsonは一行だけ公式と違います
"experimentalDecorators": true,
を加えているので、ご注意ください。tsconfig.json{ "compilerOptions": { "target": "esnext", "module": "esnext", "experimentalDecorators": true, "moduleResolution": "node", "lib": [ "esnext", "esnext.asynciterable", "dom" ], "esModuleInterop": true, "allowJs": true, "sourceMap": true, "strict": true, "noEmit": true, "baseUrl": ".", "paths": { "~/*": [ "./*" ], "@/*": [ "./*" ] }, "types": [ "@types/node", "@nuxt/types" ] }, "exclude": [ "node_modules" ] }Nuxt用クラススタイルデコレーター
クラススタイルで書きたいのでnuxt-property-decoratorをインストールします
yarn add nuxt-property-decorator※vue-property-decoratorでもOKです
ここまでくればOKです。
pagesディレクトリのindex.vueを開いて、
<script>
タグ以下をtsバージョンに書き換えてみましょう。↓もともとのコード
<script> import Logo from '~/components/Logo.vue' export default { components: { Logo } } </script>↓TypeScriptバージョン
<script lang="ts"> import { Component, Vue } from 'nuxt-property-decorator' import Logo from '~/components/Logo.vue' @Component({ components: { Logo } }) export default class Index extends Vue {} </script>これで
yarn dev
を実行してみて、無事localhost:3000で見られたら成功です。ESlintをインストールする
ローカルESlint、ローダー、EslintのNuxt用プラグインをインストールする
yarn add -D eslint eslint-loader eslint-plugin-nuxt
ESLint用NuxtTS設定ファイルをインストール
yarn add -D @nuxtjs/eslint-config-typescript
VSCodeを使っている人
自分はVSCodeを使っているのですが、保存時に自動的にlintが反映されるようにしています。
プラグインとしてeslintとveturあらかじめインストールしておいてください。
拡張機能をインストールするだけですので、詳細は割愛します。nuxt.config.jsに自動反映用の設定を加えます。
nuxt.config.jsbuild: { /* ** You can extend webpack config here */ extend(config, ctx) { // Run ESLint on save if (ctx.isDev && ctx.isClient) { config.module.rules.push({ enforce: 'pre', test: /\.(js|vue)$/, loader: 'eslint-loader', exclude: /(node_modules)/ }) } } },
.eslintrc.js
をルートディレクトリに作成し、
中身は以下のようにしています。.eslintrc.jsmodule.exports = { root: true, env: { browser: true, node: true }, extends: [ '@nuxtjs/eslint-config-typescript', 'plugin:nuxt/recommended', 'plugin:prettier/recommended', 'prettier', 'prettier/vue' ], plugins: [ 'vue' ], rules: { 'vue/html-closing-bracket-newline': 'off', 'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'off', 'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off', } };ESlintのルールは一例ですが、
"vue/html-closing-bracket-newline"この設定はオフにしておいた方がいいのではないかと思います。
Prettierがおかしな動きをする可能性があるので。VSCodeのsetting.json
VSCodeのsetting.jsonを書き加えます。
setting.json{ "eslint.validate": [ { "language": "vue", "autoFix": true }, { "language": "javascript", "autoFix": true }, { "language": "javascriptreact", "autoFix": true } ], "eslint.autoFixOnSave": true, "editor.formatOnSave": false, "vetur.validation.template": false }以上!
これでVScodeで動作させて問題なければ完了です。
お疲れさまでした?❗エラー別解説
エラー1
This dependency was not found: Module not found: Error: Can't resolve 'eslint-loader'ローダーをインストールしましょう。
yarn add -D eslint-loaderエラー2
Failed to load plugin 'nuxt' declared in '.eslintrc.js': Cannot find module 'eslint-plugin-nuxt'プラグインをインストールしましょう
yarn add -D eslint-plugin-nuxtエラー3
2:7 error Replace `·class="NuxtLogo"·width="245"·height="180"·viewBox="0·0·452·342"·xmlns="http://www.w3.org/2000/svg"` with `⏎····class="NuxtLogo"⏎····width="245"⏎····height="180"⏎····viewBox="0·0·452·342"⏎····xmlns="http://www.w3.org/2000/svg"⏎··` prettier/prettier~/components/Logo.vue
を開き自動修正を加えればエラーは消えます。ファイルを保存するたびに異なる形で保存される
ファイルを保存するたびに異なるHTMLタグ整形で保存されることがあります。
自分の場合は"vue/html-closing-bracket-newline"をオフにしたら治りましたが、それでダメならPrettierのインストールは諦めましょう。今回色々調べていましたが、Prettierのインストールに失敗して諦めている方も多い ようです。
自分の場合も諦めかけました?終わりに
Nuxt TypeScriptのインストール方法は二転三転しています。
公式の対応次第ではあっという間に古びた情報になりえますので、ご注意ください。
最後に以下の記事に助けていただきました。あらためて深く感謝します。Nuxt v2.9.2でTypeScript, eslint, Prettier環境構築 + VSCodeの設定
Nuxt.js + TypeScript + vue-class-component @IntelliJ IDEA
- 投稿日:2019-12-03T20:03:37+09:00
puppeteerとjestとvue
目次
- ゴール
- 経緯
- 構成
- 準備
- さいごに
ゴール
vueでpuppeteerをつかってテスト実行してみる
経緯
特にないんですが、仕事で結合試験の効率化でpuppeteerを使うという話があって
しかもjestとも連携できるそうなので、これはvueとも連携できるんじゃないか
というのを思いついたわけです。準備
どういう環境でやるのか
vue
vuex
vueRouter
vuetify
typescript
vue-plugin-jest-puppeteer
かなり試行錯誤しながら自力で設定する事ができなく途方にくれていたところで
都合の良いプラグインを発見。速攻でインストール
https://github.com/kaizendorks/vue-cli-plugin-jest-puppeteervue add jest-puppeteer
vuetifyを追加
jest-puppeteerの前にインストールすると、pluginsフォルダが消えてなくなったので
順序に気をつける(気のせいかもしれないです)vue add vuetify
テスト実行
jest-puppeteerをvue add追加すると
package.jsonのscriptsに以下がついかされるので"test:e2e": "jest --config=jest.e2e.config.js --runInBand",実行するだけです
yarn test:e2eひとつ問題点がありまして
テストの中でyarn serveでローカルサーバーを起動するようになっているんですが
エラーになってしまいます。解決策としては、いまのところ
あらかじめローカルサーバーを起動しておく以外に見つかっていません。残課題
ローカルサーバーが起動していない状態で、実行ができるようにする。
githubのリポジトリのリンクを貼っておくさいごに
使い所はありそうな感触はあったので、積極的に実務で使っていきたいと思います。
はじめてのQiitaの記事を書いてみて、アウトプットすることの大事さと難しさを実感しました。
ありがとうございました。
あと、時間ギリギリになってしまって、すいません。
- 投稿日:2019-12-03T19:52:53+09:00
【Vuejs】watch immediate: trueはライフサイクルのどこで実行されるのか?
この記事は、Vue #2 Advent Calendar 2019 の2日目の記事です(4日目に飛び入り)。
前置き
Vue.jsにはVueインスタンス(コンポーネント)上のデータの変更を監視する watch というプロパティがあります。
さらに、
watch
にはimmediate
というオプションがあります。
watch
は通常監視を始めて、データが変わった直後にコールバックが呼ばれますが、immediate
オプションを付与したwatch
は監視を始めた直後に一回コールバックが呼ばれます。また、Vueにはインスタンスのライフサイクルに合わせて関数を実行する
ライフサイクルフック
という仕組みがあります。そこで、 immediateオプション付きのwatchはライフサイクルにおけるどのタイミングで呼ばれるのか? という疑問が湧いたので調べてみました。
画像は Vue インスタンス — Vue.js より引用
TL;DR
- watchのコードリーティングしてみた
- 実行タイミングは
beforeCreate
とcreated
のあいだ
- ドキュメントには記載無さそう?現状のコードではこのタイミングってぐらいなはず
ひとまず実行してみる
以下のコードで試してみたところ、
immediate
付きのwatch
はbeforeCreate
とcreated
の間に実行されました。https://codepen.io/sin-tanaka/pen/ExaxrZx
new Vue({ el: '#app', data: function() { return { helloWorld: 'HelloWorld' }; }, beforeCreate: function() { console.log('call beforeCreate'); }, created: function() { console.log('call created'); }, mounted: function() { console.log('call mounted'); }, watch: { helloWorld: { handler: v => console.log('call watch', v), immediate: true } } })call beforeCreate call watch HelloWorld call created call mountedライフサイクルの図で言うところの
Init Injections & Reactivity
でWatchを仕掛けているようです。Vuejsのコードを読んでみる
改めてwatchのドキュメントを読んでみましたが、上記の動作を保証するような文言はなさそうでした。
そこで、2019.12.3時点でのdevブランチのコードを読んでみることにします
new Vueは何をしているのか?
new Vue
したときにどのようなコードを実行しているのかを追ってみます
package.json
のscripts
→rollup
→runtime
→…のように追っていくとnew Vue
の実態は以下のようでした。https://github.com/vuejs/vue/blob/dev/src/core/instance/index.js
src/core/instance/index.jsimport { initMixin } from './init' import { stateMixin } from './state' import { renderMixin } from './render' import { eventsMixin } from './events' import { lifecycleMixin } from './lifecycle' import { warn } from '../util/index' function Vue (options) { if (process.env.NODE_ENV !== 'production' && !(this instanceof Vue) ) { warn('Vue is a constructor and should be called with the `new` keyword') } this._init(options) } initMixin(Vue) stateMixin(Vue) eventsMixin(Vue) lifecycleMixin(Vue) renderMixin(Vue) export default Vue
function Vue
new演算子付きで呼ばれたときのみ、this._init(option)
を実行しています。実質コンストラクタですね。
_init
は Vueの中に定義されていないので、initMixin
stateMixin
eventsMixin
lifecycleMixin
renderMixin
辺りでVue.prototype._init
を仕掛けているとみます。initMixin
initMixin
を読んでみるとVue.prototype._init = function (options?: Object)
という記述がありました。この関数でコンストラクタに該当するコードを仕込んでいるようです。src/core/instance/init.jsexport function initMixin (Vue: Class<Component>) { Vue.prototype._init = function (options?: Object) { const vm: Component = this /** * 中略 */ vm._self = vm initLifecycle(vm) initEvents(vm) initRender(vm) callHook(vm, 'beforeCreate') initInjections(vm) // resolve injections before data/props initState(vm) initProvide(vm) // resolve provide after data/props callHook(vm, 'created') /** * 中略 */ if (vm.$options.el) { vm.$mount(vm.$options.el) } } }また、
callHook(vm, 'beforeCreate')
とcallHook(vm, 'created')
の関数呼び出しの行があります。これは名前の通り、ライフサイクルフックを実行している関数でした。その間には
initInjections(vm)
initState(vm)
initProvide(vm)
という関数呼び出しがありました。
これはまさしくライフサイクルの図でいうところのInit Injections & Reactivity
に該当する関数に見えます。initState
initState
関数を見てみるとinitWatch
関数を実行している行がありました。src/core/instance/state.jsexport function initState (vm: Component) { vm._watchers = [] const opts = vm.$options if (opts.props) initProps(vm, opts.props) if (opts.methods) initMethods(vm, opts.methods) if (opts.data) { initData(vm) } else { observe(vm._data = {}, true /* asRootData */) } if (opts.computed) initComputed(vm, opts.computed) if (opts.watch && opts.watch !== nativeWatch) { initWatch(vm, opts.watch) } }さらに
initWatch
関数を追ってみますsrc/core/instance/state.jsfunction initWatch (vm: Component, watch: Object) { for (const key in watch) { const handler = watch[key] if (Array.isArray(handler)) { for (let i = 0; i < handler.length; i++) { createWatcher(vm, key, handler[i]) } } else { createWatcher(vm, key, handler) } } }まだ
immediate
の記述はナシcreateWatcher
を追いますsrc/core/instance/state.jsfunction createWatcher ( vm: Component, expOrFn: string | Function, handler: any, options?: Object ) { if (isPlainObject(handler)) { options = handler handler = handler.handler } if (typeof handler === 'string') { handler = vm[handler] } return vm.$watch(expOrFn, handler, options) }
vm.$watch
を実行しているようなので、これも_init
のようにprototype
に仕掛けている箇所を探してみますvm.$watchはどこで仕掛けているのか?
index.js
で実行しているstateMixin
で仕掛けているようでしたsrc/core/instance/state.jsexport function stateMixin (Vue: Class<Component>) { /** * 中略 */ Vue.prototype.$watch = function ( expOrFn: string | Function, cb: any, options?: Object ): Function { const vm: Component = this if (isPlainObject(cb)) { return createWatcher(vm, expOrFn, cb, options) } options = options || {} options.user = true const watcher = new Watcher(vm, expOrFn, cb, options) if (options.immediate) { try { cb.call(vm, watcher.value) } catch (error) { handleError(error, vm, `callback for immediate watcher "${watcher.expression}"`) } } return function unwatchFn () { watcher.teardown() } } }ここで
watch
のオプションimmediate
がtruthyであるときcb.call(vm, watcher.value)
を実行しているのが分かります。長かった・・・よって、コードベースでも見ても
immediate: true
のwatch
の実行タイミングはbeforeCreate
とcreated
の間 であることが分かりましたおまけ
コードリーティングしてたら
$watch
が何やらunwatchFn
なるfunctionを返しているのを発見しました。
名前のとおりですが、$watch
の戻り値をコールすると監視が解除されます。
- 投稿日:2019-12-03T18:36:07+09:00
vue.jsとfirebaseを使って、パスワードマネージャを作った話
今回は、いつものGASと違って、firebaseを利用してパスワードマネージャのアプリケーションを作成しました。
きっかけ
きっかけは、自分が利用しているサービスのパスワードが同じパスワードで管理しているのに、こののままやったら危ないなー。と思ったのがきっかけです。
1passwordやトレンドマイクロ等がパスワードマネージャのアプリを提供しているのですが、せっかくなら、firebaseの勉強を兼ねて作ってみようと思いました。
完成している画面はこのようになっています。白枠で塗りつぶしている部分は、アカウントIDを表示しています。
イメージフロー
下記のようなイメージで今回は作成を行いました。
①:firebaseのgoogle認証機能を利用してログイン処理を行う。
②:ログインしていきたアカウントがfirebaseのユーザデータベースに登録しているアカウントか?の確認を行う。
③:登録されていないアカウントの場合、ログイン画面にエラーメッセージを表示する。
④:登録されているアカウントの場合、パスワードデータからアクセストークンが一致するコレクションからデータを抽出し、一覧で表示する。
⑤:登録しているデータは編集削除が可能であり、また新規で登録も可能。
⑥:ログアウトを行うと、ログイン画面に移動する。実際のデータ保持や、画面
ここからは、上記のイメージフローの実際の画面やデータベースの中身を説明したいと思います。
ログイン画面
ログイン画面はとてもシンプルになっています。「sign with google」のボタンをタップすると、googleアカウント選択ポップアップ画面が表示されます。
firebaseのgoogle認証の使い方は以下の通りになります。(公式サイトに詳細に記載されていますので、詳しく知りたい方は、公式サイトを参考にすると良いと思います。)
//google認証を行うために必要 const provider = new firebase.auth.GoogleAuthProvider(); //google認証のポップアップ表示からのアカウント選択後の処理 firebase.auth().signInWithPopup(provider).then((result)=> { //[result]にgoogleの情報が入っている //ex. result.user.email => googleアカウント //認証に成功した場合の処理... }).catch((error) => { //認証に失敗した場合の処理... });認証後、firebaseのデータベースに作成したユーザデータに登録しているアカウントかの確認を行います。
もし、登録されていない場合は下記のようなメッセージが表示されてログイン画面から動かない状態です。firebaseのデータベースの一覧データの取得方法は以下の通りとなります。
//firebaseconfigはfirebaseの設定、Firebase SDK snippetから取得してください。 const firebaseConfig = { apiKey: "XXXXXXXXXXXXXXXXXXXXXXXXXX", authDomain: "XXXXXXXXXXXXXXXXXXXXXXXXXX.firebaseapp.com", databaseURL: "https://XXXXXXXXXXXXXXXXXXXXXXXXXX.firebaseio.com", projectId: "XXXXXXXXXXXXXXXXXXXXXXXXXX", storageBucket: "XXXXXXXXXXXXXXXXXXXXXXXXXX.appspot.com", messagingSenderId: "XXXXXXXXXXXXXXXXXXXXXXXXXX", appId: "x:XXXXXXXXXXXXXXXXXXXXXXXXXX:xxx:XXXXXXXXXXXXXXXXXXXXXXXXXX", measurementId: "x-XXXXXXXXXXXXXXXXXXXXXXXXXX" }; //firebaseのデータベースを使用する const firebase_app = firebase.initializeApp(firebaseConfig); const db = firebase.firestore(firebase_app); //アクセスしたいコレクションを指定する。 const docRef = db.collection("colection"); //データベースにアクセスし、データ取得に成功した場合の処理 docRef.get().then((querySnapshot) => { querySnapshot.forEach((doc)=> { //[data].オブジェクトキーで保存しているデータの取得できます。 let data = doc.data(); }) .catch((error) => { //データ取得時にエラーが発生した場合の処理 }) });データ一覧画面
登録しているアカウントからアクセスした場合、データ一覧の画面に移動します。
移動した際に、データをパスワードを保持しているデータにアクセスを行い、アクセストークンが同じコレクションの中に保持しているデータを一覧で表示します。nosqlでこういうデータの持ち方が良いのかわからないのですが、今回はこのようにデータを保持しています。
一覧画面は下記のようになります。(最初に紹介した画像と同じになります。)
詳細を表示したいアカウントをタップすると詳細内容を表示します。
ピンクのボタンをタップするとコピーを行うことができます。
主にパスワードをコピーして使用することが多いです。
編集タブをオンにすると登録している内容の編集を行うことが可能です。
登録しているデータの更新の方法は以下の通りになります。
//更新したいドキュメントまでアクセスし、[set]で更新することができる。 db.collection("xxxxxx").doc(this.user.uid).collection("xxxxxx").doc(this.detail_data.doc_id).set({ //キーと内容を全て記載する。更新したい内容だけ記載した場合、記載してない内容は消えるので注意です。 /* キー : 値 ,キー : 値 ,キー : 値 ... */ }).then(()=> { //データが更新した場合の処理 }) .catch((error)=> { //データ更新に失敗した場合の処理 });データ登録
ヘッダーにある「+」ボタンからパスワードの新規登録が可能です。
「+」の左にあるボタンが一覧画面に移動、右側にあるボタンがログアウトボタンになります。入力する内容は入力欄に記載している通りとなります。
「icon」は今回は、fontawesomeのフリーアイコンを表示できるようにしています。
ただ、アイコンを使用するには、使用したい、classを検索して貼り付ける必要があるため少し面倒です。ここは改良する必要があるかなと考えています。
パスワードは「CREATEPASS」をタップすることで自動で生成してくれるようになっています。
パスワードの長さも大文字を含むのか、数字を含むのかも設定可能です。登録からデータを登録することができます。
ランダム生成の一意キーで登録する方法は以下の通りです。//登録したい、コレクションまでアクセスを行い、[add]でランダムキーで登録することができます。 db.collection("xxxxxx").doc(this.user.uid).collection("xxxxxx").add({ /* キー : 値 ,キー : 値 ,キー : 値 ... */ }) .then((docRef)=> { //データを登録完了した場合の処理 }) .catch((error) => { //データ更新に失敗した場合の処理 });個人的な課題
今回、パスワードをデータベースに保存するので、「crypto-js」を利用して暗号化を行おうと試みました。
しかし、実際に暗号化を行ってデータを新規登録を行おうとすると、ランダム生成されるキーがなぜか、毎回おんなじキーになってしまい、上手く利用することができずに課題になっているため、これは改善する必要があると考えています。PWA化
GASのアプリケーションでは不可能だった、PWA化を今回実装を行いました。
こちらのサイトを参考にさせて頂き、実装を行いました。最後に
意説明は以上となります。
firebaseのサービスは本当に便利だと感じ、もっとfirebaseFunctionsとかを学びたいと思いました。
まずは、このアプリケーションの課題を克服したいと思います。
- 投稿日:2019-12-03T17:14:10+09:00
ゼロからVue.jsでビジュアルリグレッションテストするまでpart1/3
Part1 ここ
Part2 https://qiita.com/senku/items/20e21033edd512be1d4d
Part3 https://qiita.com/senku/items/08d547eda2c6ff818108Vue.jsでビジュアルリグレッションテストをするためにいろいろやったナレッジを公開しまーす。
やりたいこと
- Vue.jsのコンポーネントのビジュアルリグレッションテスト(画像回帰テスト)をしたい。
- viewsレベルのテストもしたい。(Vuex/Vue Routerが絡む)
- 多言語対応もしたい。(Vue I18nが絡む)
- テストはなるべくローカルで完結したい。サーバ立てたりしたくない。
Summary
Storybook(ビジュアルの元ネタ) + storycap(画像生成) + reg-suit(画像比較&レポート) でやる。
Storyを作るために悪戦苦闘する
- Vue.js開発環境の構築
- StorybookでコンポーネントのStoryをつくろう
- Vue I18nと戦う
- Vue Routerはかんたん(ここまで)
- Vuexはモックする
- Storycapで画像を生成
- reg-suitで比較&レポート
Vue.jsのセットアップ
ゼロからやりますと言ったものの、みんな流石にNode.jsは持ってるよね。
Vue CLI いれる
公式のインストールに従います。
$ npm install -g @vue/cli (略) + @vue/cli@4.1.1$ vue --version @vue/cli 4.1.1Vue CLI 4だ!
プロジェクトをつくる
公式のプロジェクトの作成に従う。はろーわーるど!
$ vue create hello-world
とりあえずデフォルトでいきましょう。babelとESLintだけ。
? Please pick a preset: (Use arrow keys) ❯ default (babel, eslint)言われるがままに
cd
してnpm run serve
します。$ cd hello-world $ npm run servehttp://localhost:8080 ではろーわーるどできましたね。
Storybook導入
Storybookセットアップ
Storybook for Vueは見なかったことにして、Vue CLI plugin for Storybookの手順で入れます。Vue CLI Pluginとしてセットアップしないと
vue.config.js
を参照してくれないので後で詰みます(1敗)。Vue CLIに逆らってはならぬ。$ vue add storybook
Initial Framework
には無言でEnterを押します。インストール後、言われるがままに
npm run storybook:serve
します。$ npm run storybook:serve
自動でブラウザが起動して、http://localhost:6006/でStorybookが表示されますね。
かなり後で使うので、ブラウザが起動しないコマンドもここで作っておきましょう。
--ci
オプションが使えます。
オプションにはStorybookのCLI Optionがそのまま渡せるので覚えておくといつか役に立つかもしれません。diff --git a/package.json b/package.json index dfba14f..533efd0 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "build": "vue-cli-service build", "lint": "vue-cli-service lint", "storybook:build": "vue-cli-service storybook:build -c config/storybook", - "storybook:serve": "vue-cli-service storybook:serve -p 6006 -c config/storybook" + "storybook:serve": "vue-cli-service storybook:serve -p 6006 -c config/storybook", + "storybook:ci": "vue-cli-service storybook:serve -p 6006 -c config/storybook --ci" }, "dependencies": { "core-js": "^3.4.3",HelloWorldコンポーネントのStoryを書いてみる
Storyの書き方はいろいろありますが、とりあえずこんな感じで作ってみます。
src/stories/HelloWorld.stories.jsimport { storiesOf } from "@storybook/vue"; import HelloWorld from "@/components/HelloWorld.vue"; storiesOf("HelloWorld", module) .add("test", () => { return { components: { HelloWorld }, template: ` <hello-world msg="from storybook" /> ` }; });Storybookに表示されますね。
msg
で渡したfrom storybook
もちゃんと表示されています。ここから先しばらくは、Storybookと他のプラグインとの兼ね合いの話をします。
Vue I18nとのたたかい
Storybook で Vue-I18nを使う場合はちょっと細工が必要です。
まずは vue-i18nをセットアップします。公式のInstallation参照。
Vue CLI 3.x向けの手順ですがたぶん問題ありません。$ vue add i18n
とりあえず全部デフォルトで。基本のロケールが
en
になります。? The locale of project localization. en ? The fallback locale of project localization. en ? The directory where store localization messages of project. It's stored under `src` directory. locales ? Enable locale messages in Single file components ? No
src/main.js
からsrc/i18n.js
がロードされるようになりました。
localeのファイルがsrc/locales/en.json
にできています。src/locales/en.json{ "message": "hello i18n !!" }ちょっと
src/components/HelloWorld.vue
から読み込ませてみます。src/components/HelloWorld.vuediff --git a/src/components/HelloWorld.vue b/src/components/HelloWorld.vue index 879051a..e77721a 100644 --- a/src/components/HelloWorld.vue +++ b/src/components/HelloWorld.vue @@ -1,6 +1,7 @@ <template> <div class="hello"> <h1>{{ msg }}</h1> + <p>{{ $t('message') }}</p> <p> For a guide and recipes on how to configure / customize this project,<br> check out the
npm run serve
して http://localhost:8080/ を見てみます。
hello i18n !!
が表示されてますね。多言語の対応はここでは置いておいて、とりあえず
en
で話を進めます。Vue I18n × Storybook
StorybookでHelloWorldのStoryを見てみましょう。
npm run storybook:serve
で再ロードさせます。残念なことにエラーが出ます。Vue I18nがセットするプロパティの
$t
がないからですね。Storybookの起動は
src/main.js
を経由していないため、別途コンポーネントにVueI18nを紐付けてやる必要があります。やり方はいろいろあるんですが、とりあえずデコレータで解決しています。
Vue.use
はいらない。src/stories/defaultDecorator.jsimport i18n from "@/i18n"; export default function defaultDecorator() { return { template: "<div><story /></div>", i18n }; }src/stories/HelloWorld.stories.jsdiff --git a/src/stories/HelloWorld.stories.js b/src/stories/HelloWorld.stories.js index 93b4b7e..d8e8605 100644 --- a/src/stories/HelloWorld.stories.js +++ b/src/stories/HelloWorld.stories.js @@ -1,7 +1,9 @@ import { storiesOf } from "@storybook/vue"; +import defaultDecorator from "@/stories/defaultDecorator"; import HelloWorld from "@/components/HelloWorld.vue"; storiesOf("HelloWorld", module) + .addDecorator(defaultDecorator) .add("test", () => { return { components: { HelloWorld },エラーが解消して、
hello i18n !!
が表示されます。vue-i18n-loaderとのたたかい
Vue I18nには、SFCの中に定義を書けるvue-i18n-loaderがあります。これを使う場合は更にひと手間が必要です。
とりあえず
vue invoke i18n
で、プラグインの設定をvue-i18n-loaderが使えるように更新します。$ vue invoke i18n
? The locale of project localization. en ? The fallback locale of project localization. en ? The directory where store localization messages of project. It's stored under `src` directory. locales ? Enable locale messages in Single file components ? Yes // ここだけy!
vue-i18n-loader
が追加され、vue.config.js
のenableInSFC
がtrueになり、src/components/HelloI18n.vue
が作成されます。
App.vue
から読めるようにしましょう。src/App.vuediff --git a/src/App.vue b/src/App.vue index fcc5662..0660f5b 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,16 +1,19 @@ <template> <div id="app"> <img alt="Vue logo" src="./assets/logo.png"> + <HelloI18n/> <HelloWorld msg="Welcome to Your Vue.js App"/> </div> </template> <script> +import HelloI18n from './components/HelloI18n.vue' import HelloWorld from './components/HelloWorld.vue' export default { name: 'app', components: { + HelloI18n, HelloWorld } }
npm run serve
を再実行すると、Hello i18n in SFC!
が表示されます。(画像はテキスト選択しちゃった)vue-i18n-loader × Storybook
src/components/HelloI18n.vue
のStoryを作ります。src/stories/HelloI18n.stories.jsimport { storiesOf } from "@storybook/vue"; import defaultDecorator from "@/stories/defaultDecorator"; import HelloI18n from "@/components/HelloI18n.vue"; storiesOf("HelloI18n", module) .addDecorator(defaultDecorator) .add("test", () => { return { components: { HelloI18n }, template: ` <hello-i18n /> ` }; });この時点ではStorybookは正しく動作しません。keyである
hello
がそのまま表示されてしまいます。Storybookとvue-i18n-loaderの間には複雑な問題があります。
いろいろと見守ってはいるんですが、decoratorで
this.$root._i18n
をセットするのが現状可能な解決策の一つです。(via. dlucian/vuejs-storybook-i18n #1)ただし強引な解決策なので、いつ動かなくなるかわかんないです。src/stories/defaultDecorator.jsdiff --git a/src/stories/defaultDecorator.js b/src/stories/defaultDecorator.js index 4e5acdf..c50bf20 100644 --- a/src/stories/defaultDecorator.js +++ b/src/stories/defaultDecorator.js @@ -3,6 +3,9 @@ import i18n from "@/i18n"; export default function defaultDecorator() { return { template: "<div><story /></div>", - i18n + i18n, + beforeCreate: function() { + this.$root._i18n = this.$i18n; + } }; }
Hello i18n in SFC!
が表示されるようになりました。Vue Routerとのたたかい
Routerを入れ忘れていたので入れます。
$ vue add router
Use history mode for router? (Requires proper server setup for index fallback in production)
には無言でEnter(Yes
)を押していきます。Vue Routerをインストールすると、
src/App.vue
からsrc/views/About.vue
とsrc/views/Home.vue
へのリンクが生成されます。というかファイルごと上書きされます。さようならHelloI18n。
src/App.vue
のStoryを書いてみます。src/stories/App.stories.jsimport { storiesOf } from "@storybook/vue"; import defaultDecorator from "@/stories/defaultDecorator"; import App from "@/App.vue"; storiesOf("App", module) .addDecorator(defaultDecorator) .add("test", () => { return { components: { App }, template: ` <app /> ` }; });StorybookでこのStoryを表示するとエラーが起こるので、decoratorで解決します。
Storybookでページ遷移させる予定はないので、ルーティング情報は空にしておきます。すまんな。src/stories/defaultDecorator.jsdiff --git a/src/stories/defaultDecorator.js b/src/stories/defaultDecorator.js index c50bf20..746aef7 100644 --- a/src/stories/defaultDecorator.js +++ b/src/stories/defaultDecorator.js @@ -1,8 +1,10 @@ +import Router from 'vue-router' import i18n from "@/i18n"; export default function defaultDecorator() { return { template: "<div><story /></div>", + router: new Router({}), i18n, beforeCreate: function() { this.$root._i18n = this.$i18n;
config/storybook/config.js
でVue.use
しておく必要もあります。config/storybook/config.jsdiff --git a/config/storybook/config.js b/config/storybook/config.js index fe20bab..d8dcd31 100644 --- a/config/storybook/config.js +++ b/config/storybook/config.js @@ -1,5 +1,9 @@ /* eslint-disable import/no-extraneous-dependencies */ import { configure } from '@storybook/vue' +import Vue from 'vue' +import Router from 'vue-router' + +Vue.use(Router) const req = require.context('../../src/stories', true, /.stories.js$/)ここまでやれば、Vue Routerを使うコンポーネントのStoryが表示できるようになります。
リンクを押しても何も起こりません。
- 投稿日:2019-12-03T16:34:55+09:00
Vue.jsでDBデータ一覧表示アニメーション
掲題の件をぐぐると表示するデータが決まっている一覧のアニメーションは出てくるのに
表示するデータが変わるアニメーションの例が出てこないのでメモ。それなりにきれいに動く。
実際はchangeDataメソッドみたいなテストメソッドじゃなくて、DBデータをフェッチしてitemsに入れるように実装する。参考:https://jp.vuejs.org/v2/guide/transitions.html#リストトランジション
可変一覧データ表示アニメーション例
ListAnimation.vue<template> <transition-group :tag="tag" name="list-complete"> <slot></slot> </transition-group> </template> <script> export default { props:{ tag: { type: String, default: 'ul', } } } </script> <style lang="scss" scoped> ::v-deep { // 直下のループItemだけアニメーション & > * { transition: all 0.5s; } // 新しいデータをスライドイン .list-complete-enter { opacity: 0; transform: translateX(-30px); } // 既存データは見えなくするだけ。 // absolute指定必須。 // absolute指定しないと既存データがその場所に残る。 .list-complete-leave-active { opacity: 0; position: absolute; transition: none; } } </style>
- 使い方
<template> <div> <button type="button" class="btn btn-primary" @click="changeData()"></button> <table class="table table-sm table-valign-middle"> <thead> <tr> <th>ID</th> <th>名前</th> <th>メールアドレス</th> </tr> </thead> <list-animation tag="tbody"> <tr v-for="item in items" :key="item.id"> <td>{{ item.id }}</td> <td>{{ item.name }}</td> <td>{{ item.email }}</td> </tr> </list-animation> </table> </div> </template> <script> import ListAnimation from '@/components/animations/ListAnimation.vue'; export default { data() { return { items: [], isChange: false, }; }, components: { ListAnimation, }, methods: { changeData() { this.items = this.isChange ? [ {id:1, name: 'test1', email: 'test1@test.com'}, {id:2, name: 'test2', email: 'test2@test.com'}, {id:3, name: 'test3', email: 'test3@test.com'}, {id:4, name: 'test4', email: 'test4@test.com'}, ] : [ {id:5, name: 'test5', email: 'test5@test.com'}, {id:6, name: 'test6', email: 'test6@test.com'}, {id:7, name: 'test7', email: 'test7@test.com'}, {id:8, name: 'test8', email: 'test8@test.com'}, ]; this.isChange = !this.isChange; }, }, } </script>追記
JSFiddleとかで試すなら、trantision-groupの部分を以下のように変えないと動かない。
tableタグ内で使用できるタグの制限に先に引っかかってる様子。<tbody name="list-complete" is="transition-group"> ・・・ </tbody>デモ
- 投稿日:2019-12-03T14:45:19+09:00
Vue.js 備忘録1 (概念編)
はじめに
最近、Vueの案件をやっているのですが、如何せんudemyで勉強していたのが6~7月ぐらいだったので、基礎からスッポリ抜けてる!!
流石にまずいので、復習がてら、備忘録として何回かに分けてVueの基礎的な部分を記そうと思います。
Vueの特徴
・リアクティブプログラミング
データが更新されるごとに、再度画面を表示
・コンポーネントシステム
コンポーネントを使う。再利用可能
・テンプレート
htmlをベースとしたテンプレート構文が用意されている。vue cliでプロジェクト作る
vue create ファイル名
Vueプロジェクトマネージャー
Vue cliが提供しているGUI。
プロジェクトを作ったり、すでにあるプロジェクトを読み込んだり出来る。
serve
やbulid
といったコマンドもプロジェクトマネージャー内で可能。以下のコマンドで開く
vue ui
開発スタイル
フルスクラッチ
vue cliなどのツールを使わずに、全て自分で書く方法。
全て自分で書いているので、全体像を把握しやすい。
「便利なツールが自動的にやっといてくれる!」的な現象が起きないので、Vueのコンポーネントの仕組みなどを理解するまではこちら推奨。プロジェクト
様々なフレームワークやライブラリを導入するようになるとプロジェクトの方が断然楽になる。
なので実際に開発するとなると圧倒的にこちらで行うことが多い!バージョンアップなども楽。ただファイル数が多いので最初は全体像が掴み難い。参考文献
「Vue.js&Nuxt.js超入門」
著:掌田津耶乃
- 投稿日:2019-12-03T14:45:19+09:00
Vue.js 備忘録1 (概念編)
背景
最近、Vueの案件をやってます!
以前はudemyで勉強してドラゴンスレイヤーっていう簡単なブラウザーゲーを作ってました。しかし、それも7~8月に独学で勉強しただけなので、
いざ、案件に取りかかろうとしたら、基礎からスッポリ抜けている。
これは流石にまずいので、復習として、備忘録として何回かに分けてVueの基礎的な部分を記そうと思いました。Vueの特徴
・リアクティブプログラミング
データが更新されるごとに、再度画面を表示
・コンポーネントシステム
コンポーネントを使う。再利用可能
・テンプレート
htmlをベースとしたテンプレート構文が用意されている。vue cliでプロジェクト作る
vue create ファイル名
Vueプロジェクトマネージャー
Vue cliが提供しているGUI。
プロジェクトを作ったり、すでにあるプロジェクトを読み込んだり出来る。
serve
やbulid
といったコマンドもプロジェクトマネージャー内で可能。以下のコマンドで開く
vue ui
開発スタイル
フルスクラッチ
vue cliなどのツールを使わずに、全て自分で書く方法。
全て自分で書いているので、全体像を把握しやすい。
「便利なツールが自動的にやっといてくれる!」的な現象が起きないので、Vueのコンポーネントの仕組みなどを理解するまではこちら推奨。プロジェクト
様々なフレームワークやライブラリを導入するようになるとプロジェクトの方が断然楽になる。
なので実際に開発するとなると圧倒的にこちらで行うことが多い!バージョンアップなども楽。ただファイル数が多いので最初は全体像が掴み難い。参考文献
「Vue.js&Nuxt.js超入門」
著:掌田津耶乃
- 投稿日:2019-12-03T14:37:48+09:00
Vue.jsでYouTubeプレイヤーを埋め込む方法
はじめに
Vue.jsでYouTubeを埋め込んだので、その方法を簡単にまとめていきます。(めちゃくちゃ簡単にできます!)
vue-youtubeというすぐれものが存在し、開発初心者の私でも容易に実装できました!!
参考リンクYouTubeの埋め込みのみの記事ですので、他の部分は記載していません。ご了承ください。
1.vue-youtubeをインストール
まずはvue-youtubeをインストールします。
npm install vue-youtube2.YouTubeを埋め込む
もう最終ステップです!!
あとはyoutubeタグを入れるだけ!めっちゃ簡単じゃないですか!?
<template>
内にはこちらを。<youtube :video-id="videoId" />
<script>
内にはこちらを。
videoIdにはYouTubeIDを入れてください。URLのv=以降の11個のところです!export default { data() { return { videoId: 'fHuO3Xaje98' } } }最終的な全体コード
なんの装飾もない一番質素な状態ですが完成です!!
App.vue<template> <div> <youtube :video-id="videoId" /> </div> </template> <script> import Vue from 'vue' import VueYoutube from 'vue-youtube' Vue.use(VueYoutube) export default { data() { return { videoId: 'fHuO3Xaje98' } } } </script>おまけ
youtubeタグ内にひと手間加えることで、プレイヤーのステータスをトリガーにイベントを発火することが出来るようです。
公式によると...
Events
The component triggers events to notify the parent component of changes in the player. For more information, see YouTube IFrame Player API.
なんとこんなにあるんですね!!
せっかくなので再生した時にconsole.logに「再生中です」と出してみましょう〜
youtubeタグ
内にはplayingを、<script>
内にはmethodsを追加します。App.vue<template> <div> <youtube :video-id="videoId" @playing="playing" /> </div> </template> <script> import Vue from 'vue' import VueYoutube from 'vue-youtube' Vue.use(VueYoutube) export default { data() { return { videoId: 'fHuO3Xaje98' } }, methods: { playing() { console.log('再生中です') } } } </script>やったー!大成功です!!
- 投稿日:2019-12-03T14:11:26+09:00
GitHub Actions で Laravel + Vue 環境を自動デプロイ
GitHub Actions が正式リリースされましたね。
これで外部サービスを使用しなくても GitHub 単体で CI/CD などのワークフローを自動化できるようになりました。
https://github.com/features/actions弊社は社内の GitLab でソースコード管理+ CI/CD していますが、ちょうど担当していた案件の納品先が GitHub ということもあり、Laravel + Vue アプリケーションをビルドし本番サーバにデプロイするところまでを実装してみました。
ゴール
特定のブランチに push すると自動で本番環境にデプロイする
流れ
ワークフローとして実行するタスクはこんな感じです。
- 特定のブランチ(master)へのプッシュ(or プルリク) をトリガーにワークフローを起動
- GitHub 上で docker コンテナを起動
- 以下コンテナ上で
- git clone (ソースコード取得)
- composer install (PHP環境構築)
- npm install (node環境構築)
- npm run prod (フロントエンドビルド)
- rsync でコンテナ上で構築したファイルを本番サーバへ転送
秘匿情報をいかにセキュアに管理するか
今回の案件ではフロントエンド(Vue.js)=>バックエンド(Laravel)間の API 通信の認証に Laravel Passport を使用していました。Laravel Passport では認証用のトークンをフロントエンドのコードに含めてビルドする必要があるため、トークンをコンテナ内に持ち込む必要がありますが、他にも DB のパスワードや、AWS のトークンなど、またビルドした成果物を rsync + ssh で本番サーバに同期する際の秘密鍵、などなど、知られてはいけない情報が多々あり、当然これら セキュアな情報を記載したコードをリポジトリにコミット出来ない問題 に直面します。
秘匿情報は Secrets に保存
これを解決するために、GitHub のリポジトリ設定画面には Secrets というメニューが追加されており、セキュアな情報に名前を付けて保存できます。これは GitHub Actions だけに使用する目的で追加された機能で、
Secrets are environment variables that are encrypted and only exposed to selected actions. Secrets are not passed to workflows that are triggered by a pull request from a fork of this repository.
とある通り、情報は暗号化され、選択されたアクションにのみ使用されれます。リポジトリのフォークで継承されることもなく、また、他のコントリビューターからも閲覧されることはありません。(そもそも登録した本人でさえ一度登録した内容は閲覧できません)
Secrets は、GitHub のリポジトリメニューの「Settings」からサイドメニューの「Secrets」より遷移し、「Add a new secret」をクリックして、Name と Value を登録します。
今回はここに、Laravel Passport の認証用トークン
OAUTH_CLIENT_SECRET
と rsync で使用する SSH の秘密鍵SSH_PRIVATE_KEY
を登録しました。これらの Name はワークフローの定義時に変数として使用することができるので後述の定義ファイル自体はセキュアな記述となります。MIX_OAUTH_CLIENT_SECRET: ${{ secrets.OAUTH_CLIENT_SECRET }}ワークフローの作成
それではワークフローを作っていきましょう。リポジトリのタブメニューにある Actions をクリックすると予め用意されたワークフローが表示されます。これから作成するワークフローに近いものがあればそれをテンプレートとして使用しても良いですが、今回は使用しません。実は画面から作成しなくても、定義ファイルを直接リポジトリに追加しプッシュすればワークフローとして認識します。
定義ファイルは下記のパスになります。
.github/workflows/main.yml
拡張子からも分かる通り、GitHub Actions ではワークフローを YAML 形式で定義します。
name: 本番サーバへのデプロイ on: push: branches: - master jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v1 - name: バックエンドの準備 run: composer install - name: フロントエンドのビルド run: npm install && npm run prod env: MIX_OAUTH_CLIENT_SECRET: ${{ secrets.OAUTH_CLIENT_SECRET }} APP_ENV: "production" APP_DEBUG: "false" APP_URL: "https://xxxxxx.test" - name: 秘密鍵のコピー run: echo "$SSH_PRIVATE_KEY" > id_rsa && chmod 600 id_rsa env: SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} - name: ファイルをサーバに同期 run: rsync -rlOtcv --delete --exclude-from=.rsyncignore -e "ssh -i id_rsa -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no" ./ deploy@xxxxxxx.test:/var/www/vhost/xxxxxxxx/このファイルをpushするだけで指定したブランチに更新があった際にワークフローが開始されます。
ファイルの解説
簡単に定義ファイルの中を見ていきましょう。
name: (ワークフローの名称)
name: 本番サーバへのデプロイこのワークフローの名前を定義します。GitHub のサイト上から見えるためわかりやすいものにしましょう。日本語も可能です。
on: (アクション起動条件)
on: push: branches: - masterトリガーとなる条件を記述します。ここでは master ブランチに push があった場合の条件を指定しています。
runs-on: (コンテナの指定)
runs-on: ubuntu-latest使用するコンテナを指定します。windows や macos も指定できるのでネイティブアプリのビルドなども可能。
steps: (アクションの定義)
steps:ここからワークフローをステップごとに定義します。
uses: (既存のアクションの使用)
- uses: actions/checkout@v1GitHub Actions は既に用意されたアクションや、GitHub Marketplace に公開されている自作アクションを部分的に使用することができます。ここでは actions/checkout と言うアクションを使用し、リポジトリから最新のソースを clone しています。
バックエンドの準備
- name: バックエンドの準備 run: composer installこのステップでは
run
アクションでcomposer install
コマンドを実行しています。フロントエンドのビルド
- name: フロントエンドのビルド run: npm install && npm run prod env: MIX_OAUTH_CLIENT_SECRET: ${{ secrets.OAUTH_CLIENT_SECRET }} APP_ENV: "production" APP_DEBUG: "false"ここではフロントエンドのビルドを実行しています。
run
アクションでnpm install
とnpm run prod
を実行し、Vue.js のソースをトランスパイルしています。
また、前述のトークンや環境ごとの依存情報などは、本来 .env に記述しますが、リポジトリには追加したくないため環境変数経由で Secrets の値を渡しています。秘密鍵のコピー
Secrets に保存した SSH プライベートキーを環境変数経由でファイルに書き出しています。パーミッションも忘れずに。
- name: 秘密鍵のコピー run: echo "$SSH_PRIVATE_KEY" > id_rsa && chmod 600 id_rsa env: SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}ファイルをサーバに同期
あとは成果物をサーバに rsync+ssh でコピーするだけです。転送量を節約するため .rsyncignore に不要なファイルを定義し、いくつかのフォルダを除外しています。
- name: ファイルをサーバに同期 run: rsync -rlOtcv --delete --exclude-from=.rsyncignore -e "ssh -i id_rsa -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no" ./ deploy@xxxxxxx.test:/var/www/vhost/xxxxxxxx/.rsyncignore
.env .git .github node_modules/ storage/いざ実行
ワークフローの実行は
on
で指定したタイミングでの自動起動なので、master ブランチに git push するだけです。
GitHub の Actions タブをクリックすると実行中のタスクをリアルタイムにチェックできます。
まとめ
社内の GitLab では、CI/CD をフル活用しておりユニットテストやLintチェックなどを常に自動化していますが、自社サーバに構築しているので環境を自前で構築する必要があったりスペックに悩まされてきましたが、GitHub Actions ではいとも簡単にできてしまいました。Marketplace での Action も充実してくると思うので今後さらに期待ができますね。非常に楽しみです。
- 投稿日:2019-12-03T12:54:19+09:00
Nuxt.jsでVue.jsのSSRのWebアプリケーションの制作についてまとめ
はじめに
この記事はLinkbal Advent Calendar 2019の3日目の記事です。
今年は弊社のサービスのリニュアルでVue.js
初心者の僕はNuxt.js
でプロジェクト最初からリリースまで携わりました。
今回はその経験と学んだことについて紹介したいと思います。TL;DR
この記事は僕の経験からいくつかメモや学んだことを共通したいものです。
一番伝えたいのはこの注意点です。項目
事前に知っておくべきこと
Nuxt.jsはVue.jsに基づいたフレームワークなので、もちろん事前にVue.js公式ライブラリ(vue, vuexやvue-router)の基礎知識が必要です。その以外に、Vueと同様、Nuxt.jsもライブサイクルがありますので、
以下のNuxtのサーバーサイドレンダリング(SSR)ライブサイクルを知っておいたほうがいいと思います。参考: https://medium.com/@onlykiosk/the-complete-nuxt-guide-940751e1a6a5
nuxtServerInit
このアクションはVuex
のストアに定義されたら、リクエストが来たら、ルートを問わずにNuxt.jsはそれを呼び出します。これはユーザー認証やサーバーサイドから共通のデータの取得などのために、使われます。僕の場合は
nuxtServerInit
にリクエストのUserAgentでデバイスの判定、ユーザー認証や全ページの共通のデータ取得の処理を入れました。注意: Nuxtの公式のドキュメントにも書いてありますが、 この
nuxtServerInit
アクションはPromiseを返すかasync/awaitを使う必要です。Note: Asynchronous nuxtServerInit actions must return a Promise or leverage async/await to allow the nuxt server to wait on them.
middleware
: 上記の図に書いてあるNuxt.jsのデフォルトのmiddlewareの以外に、カスタマイズのmiddlewareを作り、使うことができます。validate
: ページのパラメーターやクエリーのバリデーションasyncData
とfetch
はページレンダリングする(コンポーネントを初期化する)前に、非同期の処理でデータを取得するためのメソッドです。簡単設定でカスタマイズ、拡張しやすい
Nuxt.jsのメインの設定ファイル
nuxt.config.js
で簡単に色々な設定を変更、追加できます。例えば: ページのhead(metaやtitleなど)、環境変数やwebpackなどRouter
Nuxt.js
はpages
ディレクトリにコンポーネントを定義したら、自動的にルーティングを生成してくれるってNuxt.js
の一つ特徴ですが、
実際のアプリケーションを作る時に、リダイレクトのルートや一つページ(コンポーネント)に対する複数ルートなど場合があるでしょうか?
その時に、Routerはカスタマイズが必要になりますね。
Nuxt.js
はvue-router
を使っているので、vue-router
のフォーマットと以下のような簡単設定でカスタマイズできます。また、全体ページに適用したい
middleware
はnuxt.config.js
にも定義できます。// router/customRoutes.js const CUSTOM_ROUTES = [ // 既存のページの別ルート { name: 'custom-event-page', path: '/custom/', component: '@/pages/event.vue' }, // リダイレクトのルート { path: '/source_page/:id?', redirect: { path: '/destination_page/:id?', query: { hoge: 'fuga'} } } ]// nuxt.config.js const customRoutes = require('./router/customRoutes') module.exports = { router: { middleware: 'commonMiddleware', // middleware/commonMiddleware.js extendRoutes (routes) { customRoutes.forEach(route => { routes.push(route) }) } }, }Plugins
Nuxt.js
プラグインはVue.js
アプリケーションがインスタンス化される前に実装されます。
外部のパッケージやVueプラグインをNuxt.js
アプリケーションで使用したい場合、プラグインを使う必要です。SSRをサポートしないVueプラグインがありますので、その時にクライアント側のみ使うようにする必要です。
// nuxt.config.js plugins: [ { src: '~/plugins/no-ssr-plugin', ssr: false }, ],そして、Vueインスタンスに注入したい場合、プラグインを使う必要です。
僕の場合は、ページのasyncData
でエラーの時に、エラーのハンドリングと非同期の処理でデータを取得するため使いました。// plugins/error-handler.js export default ({ store }, inject) => { inject('error', async (errorCode) => { await errorHandler(store, errorCode) // エラーコードによりエラーのページにリダイレクトする error({ statusCode: errorCode }) }) }// pages/component.vue asyncData ({ app, store, params }) { // エラーの場合 await app.$error(errorCode) }Modules
Nuxt.js
のコア機能を簡単に拡張し、インテグレーションを加えるNuxt.js
の拡張機能です。
この拡張機能のおかげで、Nuxtコミュニティが広くなって、たくさん便利な機能がシェアされています。次はいくつか便利なモジュールを紹介したいと思います。
便利なモジュール
Nuxtコミュニティでたくさん便利なモジュールを作ってくれて、シェアされています。
その中に使っていた2つNuxt.js
チームの公式モジュールを紹介したいと思います。@nuxt/axios
簡単に
Axios
とNuxt.js
とを統合するモジュールです。こちらは
Nuxt.js
で簡単にAxios
が使えるだけではなく、プラグインでaxios
のinterceptors
を登録できます。// plugins/axios export default function ({ $axios, res, store }) { // リクエストの前処理 $axios.onRequest(config => { config.withCredentials = true return config }) // エラーをハンドリングする $axios.onError(error => { const code = parseInt(error.response && error.response.status) if (code === 500) { // handle error } if (process.env.NODE_ENV !== 'production') console.error(error) }) // 返ってくる時の処理 $axios.onResponse(response => { // SSRの時に、responseのヘッダーをチェックして、クライアント側に返すヘッダーをセットする if (response.headers && response.headers['xxxx'] && process.server) { res.setHeader('yyyyy', response.headers['yyyy']) } }) }@nuxtjs/google-tag-manager
Nuxt.js
アプリケーションでGoogle Tag Manager(GTM)やGoogle Analytics(GA)が使えるようにするモジュールです。
Vue.js
のSSRのWebアプリケーションですが、初回のアクセスだけサーバーサイドレンダリングされて、その以降通常のVue.js
のアプリケーションになるので、GTMやGAタグのスクリプトをここままに使えないです。このモジュールを使って、GTMやGA側で以下の設定を適切に変更すれば、問題なく使えます。
GTMやGA側の設定は詳しくないけど、主な変更点はイベントトリガーだと思います。
このモジュールのデフォルトのページビューイベントはnuxtRoute
です。注意点
この記事で一番伝えたいことは
Nuxt.js
の使用の時の注意点です。以下の2つあります。Vuexストアの
state
の値はfunction
でなければならない
Vue.js
アプリケーションでVuexストアのstate
定義はObjectで普通じゃないでしょうか?Vuex
の公式exampleもそうですし。ただし、
Nuxt.js
のSSRでそうするとで不要な共有状態が発生する可能性があります。この問題が発生すれば、サーバーサイド側から取ってくるデータを対象外(ユーザー)に送ってしまう可能性があります。詳しくはこの
Nuxt.js
のgithubのissueを見てください。Regardless of the mode, your state value should always be a function to avoid unwanted shared state on the server side.
↑この注意点はNuxt.js公式ページにも記載してあります。
参考: https://nuxtjs.org/guide/vuex-store/
- ストア定義の例
BAD
// store/state.js export default { state_hoge: null, state_fuga: null }// store/index.js import Vuex from 'vuex' import state from './state' import getters from './getters' import mutations from './mutations' import * as actions from './actions' const createStore = () => { return new Vuex.Store({ state, getters, mutations, actions }) } export default createStoreGOOD
// store/state.js export default () => ({ state_hoge: null, state_fuga: null })// store/index.js import Vuex from 'vuex' import createState from './state' import getters from './getters' import mutations from './mutations' import * as actions from './actions' const createStore = () => { return new Vuex.Store({ state: createState(), getters, mutations, actions }) } export default createStoreasyncDataのデータが再利用されてしまう
詳しくはこの
Nuxt.js
のgithubのissueを見て下さい。簡単説明なら、以下のようです。
event
ページのasyncData
関数がこんな感じです。asyncData ({ $axios }) { const res = await $axios.$get('/hoge_api_path') const data = { common: res.data.common } if (res.data.special_flg) { data.isSpecial = true data.specialData = res.data.special_data } }
- まず、
events/123
にアクセスしたら、asyncData
の返り値は以下の通りです。{ common: "hogehoge", isSpecial: true, specialData: "hogedata" }
- 次、
events/456
にアクセスしたら、asyncData
の返値は以下の通りです。{ common: "fugafuga" }
Nuxt.js
はasyncData
のデータとVueコンポーネントに定義しているデータにマージして、Vueコンポーネントのデータになる仕組みで、再利用されてしまうので、events/456
ページのデータは以下のようになってしまいます。{ common: "fugafuga", isSpecial: true, specialData: "hogedata" }ですから、
asyncData
の返り値はifで分けず、絶対に同じフォーマットみたいでなければならない。GOOD
asyncData ({ $axios }) { const res = await $axios.$get('/hoge_api_path') return { common: res.data.common, isSpecial: res.data.is_special, specialData: res.data.is_special ? res.data.special_data : null } }終わり
今回は自分のメモみたいで
Nuxt.js
でVue.jsのSSRのWebアプリケーションの制作について学んだことや得たことを紹介しました。
ご拝読いただきありがとうございます。次回は
Nuxt.js
/Vue.js
のWebアプリケーションのパフォーマンスの最適化について書く予定です。
- 投稿日:2019-12-03T12:39:21+09:00
きたるべきvue-nextのコアを理解する
この記事は、Vue #2 Advent Calendar 2019の4日目。
@nagimaruxxxさんのフロントエンド開発をjQueryからVue.jsへ乗り換えたので比較してみるの次の記事です。この記事でわかること
jQueryからVue.jsにはじめて移行したとき、「thisのなんちゃらを書き換えると動くんだー!」とか「computedって、依存する値が更新されたら自動で更新されてすごい!」というのが感想でした。
今回は、vue-nextという、いわば、「次世代のvue」でそういった「自動で更新されてすごい」がどのように実装されているか、を解説します。
普段Vue.jsを使っている人は、その裏の仕組みに感銘を受けるでしょう。日々の実装を少しだけ、いつもと違う視点で見れるようになると思います。
普段React.jsを使っている人はきっと、Vue.jsを使いたくなることでしょう。
皆さんもぜひリアクティブシステムの世界を少し味わってみてください。目次
- そもそもvue-nextとは
- 背景
- vuejsのcomposition-apiはreact-hooksとどう違うのか
- vue-nextを読み解くには
- vue-nextのリアクティブシステム
- vue-nextのリアクティブシステムの陥穽
- 最後に
背景などを書いていたらかなり長くなってしまったため、「hooksとかvue-nextの話は聞いたことがある!俺は最低限の内容だけ読みたいんだ!」という方は「vue-nextのリアクティブシステム」まで飛ばすことをおすすめします。
そもそもvue-nextとは?
Vue.jsは2019年12月3日現在、v2.6.10がリリースされています。おそらく、世の中のVue.jsプログラムのほとんどは2系でしょう。
その一方、2018年の秋あたりから、Vue.jsはv3.xへのプラン1を予告していました。
各種カンファレンスだけでなく、公式のブログ2でも言及されており、「徐々に2系でも使えるようにしていく」と公言しています。実際、vue-nextのリアクティブシステムのいくつかはvue-composition-apiを通じてvue 2系でも利用することができます。
リリースの時期は決まっていませんが、2019年の終わりから2020年の初めにかけてであろうと予想されています。(もしかしたら、このアドベントカレンダーの最中にリリースされるかもしれないですね...!)背景
vue-nextに至るまでの動向をわかる範囲で書きます。
vue-nextが実装されるまでの経緯なので、ざっと流してもらっても構いません。React hooksの登場
2018/10/25,26のReact Conf 2018で紹介された「React hooks」は、仮想DOM以来のフロントエンドでの大きな変化であるとみなされ、大きな注目を浴びました。3
React.jsフロントエンドの課題であった「型付けの困難さ」「ロジックの再利用の困難さ」を大幅に緩和し、フロントエンドの開発の様相を一変させたと言ってもいいでしょう。
RFCとしての登場
React hooksはそのままではVue.jsには移植できないものの、コンセプトや対処できる課題としてはVue.jsフロントエンドの課題と共通するものがあります。
とはいえ、そんなにすぐには対応なんてできません。
React界隈がhooksに湧いている中、Vue.jsではコアに特に大きな変更はなかったので、「あいつらいいなあ」と思ったのを覚えています。状況が変わりだしたのは2019年の3月末です。
元々、v3.xでは今までの書き方を整備、発展させた「Class API」を提供する予定でした。
しかし、「型づけ大変だし、React hooksみたいなAPIがあればそんなの要らんくない?」ということでそのRFCは破棄されます。4今思うとなかなかすごい変更です。
Vue.jsでReact hooksに相当するRFCがここで登場します。5実装の登場
2019年の4月時点では、vueのReact hooksに相当するものはRFCとしてはあるものの誰も使えないみたいな状況でした。
しかし、6月9日に「HooksライクなAPIのRFCを(ちゃんと)出したよ」というツイートがなされ、その上でVue.js 2系でも使える実装であるvue-function-apiが登場します。Published an RFC for function-based component API in Vue. Inspired by React hooks, but rooted in Vue’s reactivity system. Offering better logic composition and better TypeScript support. : https://t.co/WRBVrVQl7q
— Evan You (@youyuxi) 2019年6月9日実装が登場したことで一気にコミュニティーは活発になりました。6
そして、2019年の8月にVue.jsの本家に管理が移行7し、名前も「vue-composition-api」に変わり、現在に至ります。
現在、コミュニティーでVue.js 3ライクな書き方をしたい場合は、このvue-composition-apiを使うことになると思います。qiitaにも既にたくさんの記事がありますね。vue-nextのアナウンス
さて、部分的に使えるようになり、あとは本体のバージョンアップを待つだけという状態になって、10/5に以下のツイートがなされます。
Just open sourced Vue 3 source code live during my @vue_london remote talk. Will be tweeting/blogging about some interesting stuff we've been doing for the Vue 3 compiler next week. https://t.co/fgVA1OGgQp
— Evan You (@youyuxi) October 4, 2019最初のアナウンスから1年の時を経てようやく、次世代のVue.jsを垣間見ることができるようになったわけです。
Vue.jsのcomposition-apiはReact hooksとどう違うのか?
「Vue版のHooks」とも言われる「vue-composition-api」(そしてvue-nextのシステム)ですが、先発にあたるReact hooksの長所を生かしつつ、短所を低減しています。
ここら辺はいろんな記事が書いているところでもあるので簡単に書きます。
React hooksはなぜ歓迎されたのか
Functional Componentの表現力向上
React hooksが登場するまで、React.jsのFunctional Componentはpropsしか受け取ることができませんでした。つまり、状態を持つことができなかったわけです。
Hooksが登場したことにより、今まではクラスベースのコンポーネントでしかできなかったこともFunctional Componentでできるようになりました。型との相性
システムが大規模になればなるほど、型による静的な検査は効いてくるようになります。
関数の型をつけるのはクラス内でのそれよりも比較的簡単であるため、ライブラリの型定義などが改善され、より良い型づけがなされたプログラムを簡単に書けるようになりました。ロジックの再利用性の向上
今まではライフサイクルごとの処理を再利用したい場合、React.jsの場合は基本的にHOC(Higher Order Component)しか手段がありませんでしたが、カスタムフックを作成することによりそれらを簡単に切り出すことができるようになりました。
React hooksの問題点
依存性を明示しなければいけない場合がある
useEffectがこの例にあたります。
公式にもありますが、useEffectはコンポーネントが更新されるたびに走るため、やや「走り過ぎ」なところがあります。8
それを間引き、useEffect内で参照した変数が更新されたタイミングでのみuseEffectを実行するためには、React hooksでは依存する物を明示的に示さなければいけません。これはややトリッキーです。書ける場所の制限
当然といえば当然なのですが、ReactのHooksを使うことができ、その恩恵にあずかることができるのはReactのFunctional Component内のみです。
if,for,ネストした関数の中で使えない
Hooksを使う上で一番問題になるのはこのケースでしょう。フロントエンドにかかわらずこうした制御構造は大量に出てくるのにもかかわらず、Hooksはこれらの中では基本的には使うべきではないとされています。(繊細な注意を払えば使えなくはないですが、一歩間違えると変数の内容が「ズレる」という憂き目に遭います。)
これはReact hooksが、「呼ばれた順番でhookと値を紐づけている」という実装をしていることに基づく問題と考えられます。なかなか根が深いです。
vue-composition-apiの特徴
vue-composition-api並びにvue-nextでは、React hooksの長所を生かしつつ、上にあるような短所が現れないような実装になっています。
以下のコードを読んでみてください。(vue-composition-api公式のサンプルコードです。importは適宜省略しています。)
const state = reactive({ count: 0 }) watch(() => { document.body.innerHTML = `count is ${state.count}` })
state.count
が更新されたらinnerHTML
を書き換える、という非常に単純なサンプルです。この
reactive
というところで、Vue.jsは「リアクティブな値」を作成しています。
そして、watch
に関数を渡すことで、state.count
が更新されたら実行されるという動作を宣言しています。まず注意したいのは、このコードは別にコンポーネント内でなくても動くっちゃ動くというところです。コンポーネント内で宣言をしなければいけなかったReact hooksとは違い、リアクティブな値を生で扱うことができます。if文なども当然問題ありません。
また、特に依存性をwatchで宣言しなくても、「使ったものだけ監視する」という挙動をします。
これにより、開発者はよりロジックの実装に集中することができます。もちろん、React hooksの長所である「ロジックの再利用」なども同じように実現できます。(vue-composition-apiの公式ではマウスの場所を追跡するというのを例にサンプルコードを解説を載せています。)
vue-composition-apiをReact hooksを異なるものにしているのは「リアクティブな値を生で扱うことができること」でしょう。
これにより、vue-nextでは非常にシンプルにフロントエンドを説明することができるようになります。
ドキュメントにもあるとおり、vueのレンダリングは究極的には「リアクティブな値をwatchしているだけ」と言えるようになるからです。9(コードは例によって公式から引用しています。)
const state = reactive({ count: 0 }) function increment() { state.count++ } const renderContext = { state, increment } watch(() => { // hypothetical internal code, NOT actual API renderTemplate( `<button @click="increment">{{ state.count }}</button>`, renderContext ) })vue-nextを読み解くには
vue-nextのリポジトリ自体はGithubにあるわけですが、見ればわかるとおりどこにも説明などのdocsがないので、そこらへんは自力で知る必要があります。
まず、この記事でもたびたび引用していたVue Composition API RFCのページを一通り眺め、使い方を知っておくとよいでしょう。というのも、表面的な振る舞いはここから大きく変わらないとされているからです。
事前知識を仕入れたところで、次に見るのはvue-nextのContributing Guideです。ここでは基本的な開発コマンドの説明やコントリビューションの規則に加えて、プロジェクト構成が書かれているからです。
今回の場合はリアクティブシステムを読むわけなので、「packages/reactivity」フォルダを見ればいいことがわかります。
最後に見るべきは「テストコード」です。テストコードには、ライブラリの作者が想定した使いかたについて、満足すべき挙動がずらっと書いてあります。使い方のドキュメントがない現状では、おそらくこのテストコードが一番のドキュメントです。(マイナーOSSあるある)
また、vue-nextのコードは全てTypeScriptで書かれているため、型情報も読み解く上で有用なヒントになります。
あとは関数の呼び出しを遡って行って、時に自分で実際にテストケースを書いて動かしたりしていきましょう。それではようやく本題です。
vue-nextのリアクティブシステム
そもそも「リアクティブ」とは?
これから「リアクティブな値」の実装をみていくわけですが、その前に「リアクティブ」って何?とならないように改めて述べておきます。
例えば以下のコードがあったとしましょう。
let a = 5 let b = a * 10 a = 7 console.log(b) // => 50これを実行すると50と出力されます。2行目で「bにaの10倍を代入する」としたものの、aに7を代入したことでその関連性が崩れています。bはもはやaの10倍ではなくなってしまいました。
これを直してやるにはもう一回代入してやるしかありません。b = a * 10 console.log(b) // => 70呆れるくらい当たり前ですね。
今回の例は、非常に簡単なものでしたので、一文だけ付け足すことで「bはaの10倍なんだよ」とすることができます。
ですが、例えばbにあたるものが100個、200個あったらどうでしょう。let a = 5 let b_000 = a * 21 let b_001 = Math.ceil(a / 2.71828) // ... let b_173 = Math.sin(a) // ... // コードのどこかのタイミングでaを更新する a = 8とても更新が大変そうですね。いくつか忘れてしまいそうです。
もちろん、関数としてこれらの再代入を記述し、毎回書くというアプローチをとることができます。a = 8 makeSideEffectA(a)ですが、変数ごとにこんなことをしていたら全然捗りませんし、何よりめんどくさいですよね。
ここでリアクティブシステムを入れてみましょう。
a = ref(5) const b_000 = computed(() => a.value * 21) const b_001 = computed(() => Math.ceil(a.value / 2.71828)) // .. a.value = 10 console.log(b_000.value) // => 210特に再代入などをしなくても、bの方に値が反映されました。
このように、一旦関係性、例えば「bはaの10倍だよ」とcomputedで定めておけば、そのあとはaを書き換えればbも自動で更新されます。
こうすれば、「bは代入したときはaの10倍だったけど、この行だと分からんなー」みたいなことがなくなり、「いや、いつでも10倍です」とわかるので、考えることが減りますね。代入忘れもおきません。このように、ある変数を書き換えた時に、事前に定めた関係性を元に、他の変数が適切に更新されたり、事前に定めた動作が発動することを「リアクティブである」と言います。10
リアクティブシステムの登場人物たち
vue-next及びvue-composition-apiでは様々な関数が利用可能ですが、基幹である関数はかなり少数です。
依存性を辿っていくと、本質的には以下の2つしかないことがわかります。
- reactive
- 普通の値をリアクティブな値に変換する
- effect
- 値が変更された時の動作を記述する
他の利用可能な関数はほとんどこれをちょっと変更した程度で実装されているので、この2つをメインに追いつつ、重要な関数や変数を追っていきます。
普通の値からリアクティブな値を作り出す「
ref
、reactive
」vue-next(vue-composition-api)ではリアクティブな値を作るための手段として
computed
,readonly
,ref
,reactive
の4つがありますが、computed
はリアクティブな値だけというよりは、リアクティブな値同士を組み合わせて作るという側面が強く、readonly
はほとんど実装がreactive
と同じため、実質的には後者の2つがメインです。
では順にみていきます。プリミティブな値をリアクティブにする「
ref
」ここでの「プリミティブ」とは、「オブジェクト(typeofした時に'object'かつnullでない)でない」くらいの意味です。もしオブジェクトの場合、リアクティブな値を作る際に内部的に自動で
reactive
が用いられます。
メインのコードとしては以下のような感じです。(適宜コメントを付与しています。)export function ref(raw?: unknown) { // ... 略 ... const r = { _isRef: true, get value() { track(r, OperationTypes.GET, 'value') return raw }, set value(newVal) { raw = convert(newVal) trigger( r, OperationTypes.SET, 'value', __DEV__ ? { newValue: newVal } : void 0 ) } } return r }「プロパティーにgetとかsetがある?」となった人はJavaScriptのGetter/Setterについて調べておくとよいでしょう。
get value()
(ゲッター)では、オブジェクトのvalue
プロパティーを参照した時に実行される関数を記述しており、set value(newVal)
(セッター)では逆に代入を行った時に実行される関数を記述しています。先ほどの『そもそも「リアクティブ」とは?』でも述べたように、リアクティブな値は代入した時に他の変数を更新して欲しいので、
trigger
のところがそれをやっているのではないかという想像がつきます。
では、Getter内にあるtrack
は何をしているかというと、例えばcomputed
内でtrack
関数が呼ばれた時に、「このcomputed
はこの値に依存しているぞ!」という記録を行っています。具体的に中がどうなっているかはまた後で述べます。プリミティブではない値をリアクティブにする「
reactive
」export function reactive(target: object) { // if trying to observe a readonly proxy, return the readonly version. // 既にリアクティブなオブジェクトの一覧に登録されている場合、それを返す if (readonlyToRaw.has(target)) { return target } // target is explicitly marked as readonly by user if (readonlyValues.has(target)) { return readonly(target) } return createReactiveObject( // 元々の値 target, // 元々の値から対応するリアクティブな値を引っ張り出せるWeakMap rawToReactive, // 上の逆の写像ができるWeakMap reactiveToRaw, // Proxyで使うハンドラー mutableHandlers, // 元々の値がSet,Mapなどの「コレクション」だった時に使うProxyのハンドラー mutableCollectionHandlers ) }リアクティブな値にする値だけでなく、「値とリアクティブな値の対応が入ったWeakMap」を渡しているのは、同じオブジェクトから異なるリアクティブな値を作り出さないためと考えられます。
「あらゆるリアクティブな値への参照が入った変数」は一見メモリ的にやばそうですが、WeakMapなので、このページにあるとおり、格納しているオブジェクトを参照する手段が消滅した時に自動でガベージコレクションされるため大丈夫というわけです。createReactiveObjectの中身は以下のようになります。
function createReactiveObject( target: unknown, toProxy: WeakMap<any, any>, toRaw: WeakMap<any, any>, baseHandlers: ProxyHandler<any>, collectionHandlers: ProxyHandler<any> ) { // ... 略 ... const handlers = collectionTypes.has(target.constructor) ? collectionHandlers : baseHandlers // リアクティブな値の作成 observed = new Proxy(target, handlers) // ... 略 ... // 依存する変数、関数を記録する領域の確保 if (!targetMap.has(target)) { targetMap.set(target, new Map()) } return observed }ほとんどの行は「対応するリアクティブな値があるか」「もとの値とリアクティブな値をちゃんと紐づける」で消費されていますが、2つだけ違うものがあります。
まず一つは、
Proxy
によるリアクティブな値の作成です。見ての通り、vue-nextのリアクティビティーの核はProxyによるものです。MDNのProxyの解説を見るとわかりますが、Proxy
はとても広範な操作に介入することができます。
これを用いて、各プロパティーの変更などを検出しています。
その際も、ref
の時と同じように、取得の時にはtrack
。設定の時はtrigger
が呼び出されます。特に重要なのは、「
array[1] = 5
」や「object['存在しなかったキー'] = value
」のような操作も検出できるようになったことです。
これにより、VueやReact初心者が詰まりがちな「代入でキーを追加する」といった動作でもリアクティビティーが保たれるようになります。11もう一つは「依存している変数や関数の記憶領域」の作成です。
後でまたでてくると思いますが、vue-nextのリアクティブな値たちは、自分が使われた関数を記憶しています。そのための記憶領域です。関係性と動作を宣言する「effect」
effect
は関数を受け取り、関数内で参照したリアクティブな値が更新された時に関数を再度実行します。
実際のテストコードを見るとこんな感じです。it('should be reactive', () => { const a = ref(1) let dummy effect(() => { dummy = a.value }) expect(dummy).toBe(1) a.value = 2 expect(dummy).toBe(2) })なかなか面白いことをしますね。
effect
内で参照したa
を更新すると関数ももう一回実行され、dummy
も更新されます。watch
やcomputed
の裏側にはこんな奴が控えています。少々長いのですが、まずはいったん関連する処理をピックアップします。
export function effect<T = any>( fn: () => T, options: ReactiveEffectOptions = EMPTY_OBJ ): ReactiveEffect<T> { // ... 略 ... const effect = createReactiveEffect(fn, options) // ... 略 ... return effect } // ... 中略 ... function createReactiveEffect<T = any>( fn: () => T, options: ReactiveEffectOptions ): ReactiveEffect<T> { const effect = function reactiveEffect(...args: unknown[]): unknown { return run(effect, fn, args) } as ReactiveEffect // ... 略 ... return effect } function run(effect: ReactiveEffect, fn: Function, args: unknown[]): unknown { // ... 略 ... if (!effectStack.includes(effect)) { // ... 略 ... try { effectStack.push(effect) // 関数を実際に実行している箇所 return fn(...args) } finally { effectStack.pop() } } }初見だと理解が難しい箇所です。ですが、
creativeReactiveEffect
とrun
に注目すると多少はマシになります。
creativeReactiveEffect
では、関数をラップしてReactiveEffect
なる関数を作り出しています。
run
ではこれをeffectStack
というスタック(配列)の一番上に積んで、渡された関数を実行し、またpopしています。はっきりいってスタックを積んでから関数を実行だけしてまたpopしているだけで、これがどう「リアクティブ」につながるのかはここからだと全くわかりません。
強いていうなら、「関数が実行されている間は、対応するReactiveEffectが配列の一番上にいる」くらいな物です。この意味を把握するためには、先ほどスルーした「
track
」と「trigger
」に分け入る必要があります。関係性と動作を記録する「
track
」、再生する「trigger
」先ほど「
track
では『このcomputedはこの値に依存しているぞ!』という記録を行っている。」「trigger
では事前の関数や変数を元に値を再計算している」ということを述べましたが、それの詳細を書いていきます。関係性を再構築する「
trigger
」export function trigger( target: object, type: OperationTypes, key?: unknown, extraInfo?: DebuggerEventExtraInfo ) { const depsMap = targetMap.get(target) // ... 略 ... const effects = new Set<ReactiveEffect>() const computedRunners = new Set<ReactiveEffect>() // ... 略 ... if (type === OperationTypes.CLEAR) { // collection being cleared, trigger all effects for target depsMap.forEach(dep => { addRunners(effects, computedRunners, dep) }) } else { // schedule runs for SET | ADD | DELETE if (key !== void 0) { addRunners(effects, computedRunners, depsMap.get(key)) } // ... 略 ... } const run = (effect: ReactiveEffect) => { scheduleRun(effect, target, type, key, extraInfo) } // Important: computed effects must be run first so that computed getters // can be invalidated before any normal effects that depend on them are run. computedRunners.forEach(run) effects.forEach(run) }
type
やkey
ごとの条件分岐を全部載せると冗長なので、一部だけ載せています。
このaddRunners
という部分で「こういう操作をされたんだけど、どんな関数を実行すべきかな?」というのを構築します。
例えば、コレクション(SetやMapの場合)がクリアされた場合(type === OperationTypes.CLEAR
の部分です)、それが使われた変数を全て再計算しないといけないため、関連する関数を全部拾い出しています。
depsMap
には、「target
が計算に使われた関数」の情報が入っているので、それをいったん変数にまとめて、最後に一気に実行しています。記憶を司る「
track
」export function track(target: object, type: OperationTypes, key?: unknown) { // ... 略 ... const effect = effectStack[effectStack.length - 1] // ... 略 ... let depsMap = targetMap.get(target) // ... 略 ... let dep = depsMap.get(key!) // ... 略 ... if (!dep.has(effect)) { dep.add(effect) effect.deps.push(dep) // ... 略 ... } }
targetMap
は「ある値が依存している関数や値の一覧」と先ほど書きました。
ここでは、そんなtargetMap
になぜか「effectStack
というスタックの一番上」を追加しています。これは一体どういうことでしょう。ここで、先ほどの「関数が実行されている間は、対応するReactiveEffectが
effectStack
の一番上にいる」という性質が生きてきます。順を追うとこうなります。
effect
が関数fn
を受け取る.fn
の中ではリアクティブな値が参照されており、実行するとtrack
が呼び出される。effectStack
の一番上に「fn
をラップしたReactiveEffect
」が積まれるfn
を実行する。fn
の中ではリアクティブな値が参照されており、実行するとtrack
が呼び出される。- この間
effectStack
の一番上は「fn
をラップしたReactiveEffect
」なので、track
により、targetMap
に「fn
をラップしたReactiveEffect
」が入る。- 実行が終わると
effectStack
はpopされるそう、
effectStack
の正体はその名の示す通り、effect
だけが関与する。vue-nextが保有するコールスタックだったのです!
effect
の中でさらにcomputed
を介してeffect
が呼ばれた場合、そのスタックは積み上がっていきますし、これにより「リアクティブな値が、『自分が参照された時、今どんな関数が実行されているか』を知ることができる」ということです。React hooksではこれを単純に呼ばれた順番で管理しているため、if文などによる「ズレ」が発生しますが、vue-nextではコールスタックを自前で持つことでこの問題を回避しています。
まとめ
- vue-nextのリアクティブな性質は
Proxy
を核に実装されている- リアクティブな値は「自分の値が使われた関数のリスト」を記憶している
- vue-nextは自分でコールスタックを持つことで、上の記録をすることを可能にしている
vue-nextのリアクティブシステムの陥穽
vue-nextのリアクティブシステムは、React hooksの課題をいくつか解消しましたが、完璧ではありません。その最たる例として「非同期処理」を挙げます。
非同期処理
コールスタックを作っているところのコードを再掲します。
function run(effect: ReactiveEffect, fn: Function, args: unknown[]): unknown { // ... 略 ... if (!effectStack.includes(effect)) { // ... 略 ... try { effectStack.push(effect) // 関数を実際に実行している箇所 return fn(...args) } finally { effectStack.pop() } } }冷静に考えるとこれはおかしくないですか?
例えば、複数のfnが同時に走ったり、なんてことがあったら「関数が実行されている間は、対応するReactiveEffectがeffectStack
の一番上にいる」なんて前提はたやすく崩壊してしまいますよね?しかしながら、普通JSは同期実行の場合、一時に関数を一つしか実行していない。という前提が成り立つため、これで何とかなります。例えば、Node.jsやブラウザの場合、イベントループでこうなっていることがわかります。12
一見安心なのですが、
fn
が非同期処理になった途端に、例えばasync function
になった瞬間におかしなことが起こります。
fn
がasync function
だった場合、fn(...args)
の行は一瞬で実行され、Promise
が返ってしまうため13、その中身のtrack
などはとんちんかんなタイミングで実行されてしまうからです。まあ幸いなことに、他の同期的な
effect
が実行された場合、そちらがeffectStack
の一番上に積まれるので、「参照した奴が更新されてるのに再計算されない!」は起こらないと考えられます。多少無駄な計算が走るくらいでしょう。ただ、当の非同期処理がどのようになるかは細心の注意を払う必要があります。少なくとも
async function
にするのはやめるべきですし、promise.then
の内部でリアクティブな値を使って計算するのはやめた方がいいでしょう。いづれの場合もリアクティブにはならないと考えられます。現状一番マシな手段は、「返り値の
Promise
の状態が更新されたら変化するリアクティブな値」を作ることです。14最後に
長々と書いてしまいました。いかがでしたでしょうか。
vue-nextは「リアクティブな値を生で扱える」という非常に優れた性質を持っています。
関数型プログラミングなどとの相性もよく、これからの発展が非常に楽しみな技術ですね。
Vue 3系がリリースされてフロントエンドのデファクトになる日も遠くないかもしれません。
それでは、よきVue.jsライフを。そして、友人であり、React.jsプログラマーでありながら確認・指摘をしてくださった@hikarinotomadoiにこの場を借りて感謝を申し上げます。
昨年のアドベントカレンダーの時点でかなり議論が進んでいるのがわかります。 ↩
ただし、この公式ブログの予告内容は日付を見てもわかるように少し古いところがあります。 ↩
「Concurrent React」もこの時期に提案されているのですが、こちらの実装はHooksと比べるとかなりゆっくりと行われた印象です。当時の様子はこちら。 ↩
「React hooksにインスパイアされてるけれど、うちらのやり方でやるよ」とも明記されています。https://github.com/vuejs/rfcs/pull/17#issuecomment-494242121 ↩
議論を追っていくと、今のvue-nextやvue-composition-apiに相当するものがどう決まっていったのかが伺えてなかなか面白いです。 ↩
もちろんいろんな混乱もありました。「今までのコードは全部書き直し!?」をはじめとするデマも発生したりしています。https://dev.to/danielelkington/vue-s-darkest-day-3fgh ↩
https://ja.reactjs.org/docs/hooks-reference.html#conditionally-firing-an-effect ↩
もちろん実際には中でより複雑に更新判定などを行ってます。あくまでも仮想的なコードです。 ↩
完全に余談ですが、関数型プログラミングに慣れ親しんだ人ならこの書き方で「副作用」を連想するかもしれません。vue-nextの「リアクティブな値」は、「代入による副作用」を扱うことができ、実際にモナド則を満たす系を構成することができます。Vueのrefの命名がhaskellのSTRefモナドにちなんでいると考えるのは...流石に考えすぎでしょうか。 ↩
ProxyはIE11では使えないので、何らかの代替手段を提供はするようです。ただしパフォーマンス、機能ともに落ちるとのこと。これを機に脱IEが進むといいですね。同じような戦略をとっているMobxも似たことを言っているのでそうなりそうです。 ↩
https://developer.mozilla.org/ja/docs/Web/JavaScript/EventLoop ↩
ここを勘違いしている人はそこそこいるようです。例えば全ての行を実行するのに3秒かかる
async function
があったとしても、async function
の返り値であるPromise
が生成されるのは一瞬です。3秒かかるのはその返り値のPromise
が解決されるのにかかる時間です。await
で実行が止まっているように見えるのは、このPromise
が解決するのを待っているからです。 ↩しかしそういう値は今度は
await
で待てたりしないなど、Promise
の持つ性質を持たないという問題を抱えます。これを解決するコードは頻出かつ単純なので、これに特化したnpmパッケージを出す予定です。 ↩
- 投稿日:2019-12-03T11:59:44+09:00
Vue.jsで店舗の予約状況を表示してみた
この記事はVue Advent Calendar 2019の2日目の記事です。
とあるお店の予約システムを作った時の話で、管理画面側で予約状況をカレンダー風に表示したいという話があり、Vue.jsで作りました。
実際よりはだいぶ簡単にしてますが、完成イメージはこんな感じです。
予約状況は、こんな感じのjsonで渡されるのでこれを上手くカレンダー風に表示できるようにします。
[ { "id": 1, "name": "予約A", "startTime": "11:00", "endTime": "11:40" }, { "id": 2, "name": "予約B", "startTime": "12:00", "endTime": "13:30" }, { "id": 3, "name": "予約C", "startTime": "15:10", "endTime": "17:40" } ]ベースを作る
とりあえず、時間と線だけが表示されているベースを作っていきます。
実際の案件では店によって時間が違うという仕様でしたが、このデモでは10時から19時までという設定で作っていきます。index.html<div id="app"> <div class="main"> <div class="cal"> <div class="cal_row-label"> <p class="cal_label" v-for="n in 10">{{ n + 9 }}</p> </div> <div class="cal_row-timeline"> <div class="cal_block" v-for="n in 10"></div> </div> </div> </div> </div>app.scss* { box-sizing: border-box; } .main { position: relative; max-width: 300px; padding-right: 20px; background-color: #eee; } .cal { display: flex; padding-top: 20px; &_row-label { flex: 0 0 50px; } &_row-timeline { width: 100%; } &_label { height: 90px; transform: translateY(-12px); margin: 0; text-align: center; } &_block { width: 100%; height: 90px; border-top: 1px solid #aaa; } }app.jsnew Vue({ el: '#app' })あとで計算で使いますが、このUIでは1時間の高さが90pxになっています。
予約データを表示
v-for
で予約データを表示してみます。index.html<div class="reserve"> <div v-for="reservation in reservations" class="reserve_item" :key="reservation.id"> {{ reservation.name }}<br> {{ reservation.startTime }} 〜 {{ reservation.endTime }} </div> </div>app.scss.reserve { position: absolute; top: 0; left: 60px; width: calc(100% - 90px); &_item { border: 1px solid #ddd; background-color: #fff; border-radius: 5px; padding: 10px; } }実際はAPIで取得してたのですが、このデモではVueインスタンスの
data
の中に入れてます。app.jsnew Vue({ el: '#app', data: { reservations: [ { "id": 1, "name": "予約A", "startTime": "11:00", "endTime": "11:40" }, { "id": 2, "name": "予約B", "startTime": "12:00", "endTime": "13:30" }, { "id": 3, "name": "予約C", "startTime": "15:10", "endTime": "17:40" } ] } })データの表示ができました!
が、普通に縦に積まれただけなので位置や長さを計算していきます!縦の位置の計算
予約の縦の位置がうまく開始時間の位置に配置されるように計算します。
index.html<div v-for="reservation in reservations" class="reserve_item" :style="getPosition(reservation)" :key="reservation.id"> {{ reservation.name }}<br> {{ reservation.startTime }} 〜 {{ reservation.endTime }} </div>
getPosition()
という位置を計算する関数を作って:style
でバインディングします。app.jsnew Vue({ el: '#app', data: { shopOpenHour: 10, calBlockHeight: 90, topMargin: 20, reservations: [ // … 省略 ] }, methods: { getPosition(item) { let styles = {} // 開始時間の時と分を分割 const startTime = item.startTime.split(':') // 時間分の高さ計算 let topPosition = (startTime[0] - this.shopOpenHour) * this.calBlockHeight // 分の高さを足す topPosition += startTime[1] * (this.calBlockHeight / 60) // 上部のマージン分の高さを足す topPosition += this.topMargin styles.top = topPosition + 'px' return styles } } })うまいこと配置されました!!
予約時間の長さを計算
さらに予約時間分の長さにするために
getPosition()
を改修してheight
を計算する処理も加えます。
終了時間と開始時間の差を計算して、1時間分の高さをかけることで長さの計算をしました。app.jsgetPosition(item) { let styles = {} // 開始時間の時と分を分割 const startTime = item.startTime.split(':') // 時間分の高さ計算 let topPosition = (startTime[0] - this.shopOpenHour) * this.calBlockHeight // 分の高さを足す topPosition += startTime[1] * (this.calBlockHeight / 60) // 上部のマージン分の高さを足す topPosition += this.topMargin // 終了時間の時と分を分割 const endTime = item.endTime.split(':') // 終了時間と開始時間の差分を求める const endTimeLength = parseInt(endTime[0]) + parseFloat(endTime[1] / 60) const startTimeLength = parseInt(startTime[0]) + parseFloat(startTime[1] / 60) const itemHeight = (endTimeLength - startTimeLength) * this.calBlockHeight styles.top = topPosition + 'px' styles.height = itemHeight + 'px' return styles }完成
できました!スタイルのバインディングめちゃくちゃ便利!
See the Pen Vue Reservation Calendar by daichi (@kandai) on CodePen.
ちょっとしたTipsでしたが、誰かの参考になれば嬉しいです!
- 投稿日:2019-12-03T11:53:44+09:00
Vue.jsでいい感じのアニメーションを作りたい
この記事は クラウドワークス Advent Calendar 2019 の3日目の記事です。
昨日は、@minamijoyo さんによるtfupdateでTerraform本体/プロバイダ/モジュールのバージョンアップを自動化する でした!
はじめに
こんにちは、最近アルコールに負け続けている新卒エンジニアの @b0ntenmaru です。
Rubyの会社に入社して7ヶ月ほど経ったのですが、この7ヶ月間あまり Ruby は書かずに Vue.js を利用したフロントエンド開発ばかりしていました。その中でも Vue.js でのアニメーションの実装は経験がなく、一から調べる機会があったので今回はそれについて書いていこうと思います。
基本的なこと
Vue.js はデフォルトでトランジション(
transition
)という機能を提供していて、それを利用することでいい感じにアニメーションを実装することができます。まずここにボタンを押したら
div.hoge
を表示/非表示するだけの 単一ファイルコンポーネント があります。いい感じのアニメーションはありません。<template> <div id="app"> <button @click="show = !show"> click! </button> <!-- アニメーションで表示/非表示が切り替わる時にふわっと表示させたい要素 --> <div class="hoge" v-if="show"> hogehoge </div> </div> </template> <script> export default { name: 'app', data() { return { show: false } } } </script>See the Pen VwwJmBq by Date (@b0ntenmaru) on CodePen.
いい感じのアニメーションを追加する
アニメーションを動作させるためには該当の要素(ここでは
div.hoge
)を<transition>
タグで囲み、トランジションクラスに対して CSS を指定します。トランジションクラスに関しては後述します。<transition> <div class="hoge" v-if="show"> hogehoge </div> </transition><style> /* 以下の v-enter, v-enter-to, v-enter-active がトランジションクラス */ /* 表示アニメーションをする前のスタイル */ .v-enter { opacity: 0; } /* 表示アニメーション後のスタイル */ .v-enter-to { opacity: 1; } /* 表示アニメーション動作中のスタイル */ .v-enter-active { transition: all 500ms; } </style>これにより、要素の表示に動きを与えることができました。
See the Pen dyyxrJr by Date (@b0ntenmaru) on CodePen.
トランジションクラス
トランジションクラスは、
<transition>
タグで要素を囲むことで使用できるようになるCSSのクラスです。
ちょうど上で登場したv-enter
,v-enter-to
,v-enter-active
がトランジションクラスにあたります。トランジションクラスには Enter と Leave の2つのフェーズが存在し、
<transition>
タグで囲った要素を表示する時を Enter<transition>
タグで囲った要素を非表示にする時を Leaveと言います。(上のサンプルでは Enter のみ)
この2つのフェーズにはそれぞれ、アニメーションの動作前・動作中・動作後の3つ状態に対応した3つのクラスがあります。
クラス 要素の状態 v-enter 表示アニメーションの開始時のスタイル v-enter-to 表示アニメーションの終了時のスタイル v-enter-active 表示アニメーション中のスタイル v-leave 非表示アニメーションの開始時のスタイル v-leave-to 非表示アニメーションの終了時のスタイル v-leave-active 非表示アニメーション中のスタイル いい感じの非表示アニメーションを追加する
続いて非表示アニメーションを実装します。
Enter の時とやることは逆ですが、下記のように指定すると非表示アニメーションが動いてくれます。/* 非表示アニメーション動作前のスタイル */ .v-leave { opacity: 1; } /* 非表示アニメーション動作後のスタイル */ .v-leave-to { opacity: 0; } /* 非表示アニメーション動作中のスタイル */ .v-leave-active { transition: all 500ms; }See the Pen yLyBLRP by Date (@b0ntenmaru) on CodePen.
このトランジションクラスが Vue でアニメーションを実装するための基本となります。
複数要素のトランジション
続いて、
v-for
など同時に描画された複数の要素にアニメーションを適応させるためのリストトランジションについて説明します。下記の単一ファイルコンポーネントを例とします。
現状hoge
が一覧されており、ADD ボタン押下で新しいhoge
が一覧に追加・hoge
横の x ボタン押下で一覧から削除できる仕様となっています。<template> <div id="app"> <button @click="add">ADD</button> <div v-for="(todo, index) in todos" :key="todo.key"> <span>{{ todo.value }}</span><input @click="remove(index)" type="button" value="x" /> </div> </div> </template> <script> export default { name: 'app', data() { return { todos: [ {key: 0, value: 'hoge'}, {key: 1, value: 'hoge'}, {key: 2, value: 'hoge'}, ], nextNum: 2 } }, methods: { add: function() { const value = 'hoge' const todo = { key: this.nextNum += 1, value } this.todos.push(todo) }, remove: function(index) { this.todos.splice(index, 1) } } } </script>See the Pen LYEPxyJ by Date (@b0ntenmaru) on CodePen.
いい感じのアニメーションを適応させる
hoge
追加時/削除時にアニメーションを適応させます。
やることとしては単一要素のトランジションと同じでですが、今回のような複数の要素にアニメーションを適応させる時は<transition-group>
タグで囲い、フェーズ( Enter / Leave )ごとにスタイルを書いていきます。<transition-group> <div v-for="(todo, index) in todos" :key="todo.key"> <span>{{ todo.value }}</span><input @click="remove(index)" type="button" value="x" /> </div> </transition-group><style> /* 表示・非表示アニメーション中 */ .v-enter-active, .v-leave-active { transition: all 500ms; } /* 表示アニメーション開始時 ・ 非表示アニメーション後 */ .v-enter, .v-leave-to { opacity: 0; } </style>See the Pen VwYZPNo by Date (@b0ntenmaru) on CodePen.
一点自分がハマったポイントがあるのでこちらも是非読んでみてください。
これで複数要素のアニメーションが実装できました。
ですが削除ボタン押下後、要素が移動する時にアニメーションがなく、新しい位置に味気なく移動してしまっています。要素移動のトランジション
<transition-group>
は Enter / Leave だけでなく、要素の移動のためのトランジションクラスであるv-move
クラスを使うことで上のような味気ない移動にアニメーションを加えることができます。やることとしては、簡単で以下の2点を追記します。
/* 要素が移動する時に700msで移動するように指定 */ .v-move { transition: all 700ms; } .v-leave-active { /* 移動のトランジションをさせる場合は非表示アニメーション中に position: absoluteを指定しないと正しく動作しない */ position: absolute; }See the Pen jOENGvN by Date (@b0ntenmaru) on CodePen.
これで複数要素の表示/非表示/移動時のアニメーションが実装できました。
ちなみにこの
v-move
に関しては要素のあらゆる移動の時に適応されるので、複数要素をシャッフルさせる機能を追加してもいい感じに動いてくれます。See the Pen yLyBxZQ by Date (@b0ntenmaru) on CodePen.
ハマったポイント
配列のインデックスをキーにv-bindしてはダメ
中の要素は、key 属性を持つことが 必須 です。
と Vue の公式にも記述されているように、
<transition-group>
タグ内部の v-for で指定された要素はそれぞれが key を持つことが必須ですが、ここに配列(ここではtodos
)の index を渡してはいけません、アニメーションが正常に動作しなくなります。
下記はkeyに配列の index を渡した例です。
削除ボタンを押すと決まって最後の要素が削除されたように見えてしまっています。<transition-group> <div v-for="(todo, index) in todos" :key="index"> <span>{{ todo.value }}</span><input @click="remove(index)" type="button" value="x" /> </div> </transition-group>See the Pen GRgKXaE by Date (@b0ntenmaru) on CodePen.
Vue.js の key はどの値に変更があったのかを追うために使われていて、 index を key に指定した場合、最後より前の要素を消すことによって index の値が更新され、 Vue がどの要素をアニメーションさせれば良いかわからなくなり、一番最後の要素が消えるような挙動となってしまうようです。なので key には配列の index ではない一意な値を渡してあげましょう。
終わりに
以上、「Vue.jsでいい感じのアニメーションを作りたい」でした。
アニメーションがいい感じかどうかはさておき、 Vue.js でも簡単にアニメーションを実装できることがお分りいただけたかと思います。
Vue.js のアニメーションには状態のトランジションなどもあるので是非いろいろ触ってみてください!
- 投稿日:2019-12-03T11:33:04+09:00
Rails+Vue.jsによるフォームの作例
私の体感では、Railsアプリケーションで開発にかかる時間の半分はテンプレートとJavaScriptで、その大半はフォームです。ややこしいテンプレートはRails側で頑張らずに、Vue.jsのようなJavaScriptのフレームワークに投げてしまう、という作り方を今後のスタイルとしたい。
このサンプルは、ここ1年半ほどRails上でVue.jsをいじくって考えた、現在のところのベターなパターンです。まだ研究中なところもあり、今後変更する可能性もあります。
サンプルプログラムはこちら。簡単なブログアプリケーションです。
https://github.com/kazubon/blog-rails6-vuejs環境
- Rails 5.2/6.0、Webpacker 4、Vue.js 2.6。
- 非SPA、Turbolinksあり。
- jQueryとBootstrapあり。
そこそこ大きな業務アプリケーションを想定(サンプルはブログですが)。Vue.jsではないJavaScriptを使っているなど、いろいろなスタイルのページが混じっているものとする。
ポイント
- newとeditでは、2回リクエストを送る。1回目はふつうのHTMLで、ページの枠だけ受け取る。2回目はVueからAjaxでモデルのデータを受け取り、フォームの入力欄に反映する。
- createとupdateは、Ajaxで呼ぶ。保存に成功したときはJavaScriptでリダイレクトし、失敗したらエラーメッセージを表示する。
- 検索結果の表示ページでも、2回リクエストを送る。1回目はふつうのHTMLで、ページの枠だけ。2回目はVueからAjaxで検索のパラメータを送って記事リストを受け取り、一覧を表示する。
関連記事:
application.js
packs下のapplication.jsの書き方はいろいろ考えられますが、このサンプルではこんな感じです。HTML要素をid属性で探して、対応するVueアプリケーションをマウントします。
グローバル変数vuePropsは、Railsから直接データをVueのpropsに渡すのに使っています。
SessionForm(ログインフォーム、この記事では紹介なし)だけ.vueファイル内のテンプレートを使わずにRailsが出したHTMLをパースしていますが、これは比較研究したかっただけです。
app/javascript/packs/application.jsrequire("@rails/ujs").start(); require("turbolinks").start(); import 'core-js'; import Vue from 'vue'; import TurbolinksAdapter from 'vue-turbolinks' import EntryIndex from '../entries/index'; import EntryForm from '../entries/form'; import EntryStar from '../entries/star'; import SessionForm from '../sessions/form'; Vue.use(TurbolinksAdapter); document.addEventListener('turbolinks:load', () => { let apps = [ { elem: '#entry-index', object: EntryIndex }, { elem: '#entry-form', object: EntryForm }, { elem: '#entry-star', object: EntryStar }, { elem: '#session-form', object: SessionForm } ]; let props = window.vueProps || {}; apps.forEach((app) => { if($(app.elem).length) { if(app.object.render) { // テンプレートあり new Vue({ el: app.elem, render: h => h(app.object, { props }) }); } else { // HTMLをテンプレートに new Vue(app.object).$mount(app.elem); } } }); });newとedit
編集ページのアクションです。HTMLの枠とAjaxでのデータ送信を同じアクションにしていますが、Ajax用を分けて
api/entries_controller.rb
のような別コントローラにすることも考えられます。
Entries::Form
は形式的に置いているもので、このサンプルの編集ページでは使ってません。app/controllers/entries_controller.rbdef new @entry = Entry.new @form = Entries::Form.new(current_user, @entry) respond_to do |format| format.html format.json { render :edit } end end def edit @entry = current_user.entries.find(params[:id]) @form = Entries::Form.new(current_user, @entry) respond_to :html, :json end編集ページのHTML枠です。editではグローバル変数vuePropsでEntryモデルのidを渡しています。
app/views/entries/new.html.erb<div id="entry-form"></div>app/views/entries/edit.html.erb<script> var vueProps = <%= { entryId: @entry.try(:id) }.to_json.html_safe %>; </script> <div id="entry-form"></div>編集ページのフォーム用のVueです。createdでAjaxを使ってEntryモデルのデータを取得し、フォームにセットします。
app/javascript/entries/form.vue<template> <div> <form @submit="submit"> <div v-if="alert" class="alert alert-danger">{{alert}}</div> <div class="form-group"> <label for="entry-title">タイトル</label> <input type="text" v-model="entry.title" id="entry-title" class="form-control" required maxlength="255" pattern=".*[^\s]+.*"> </div> <div class="form-group"> <label for="entry-body">本文</label> <textarea v-model="entry.body" id="entry-body" cols="80" rows="15" class="form-control" required maxlength="40000"> </textarea> </div> <div class="form-group"> <label for="entry-tag0">タグ</label> <div> <input v-for="(tag, index) in entry.tags" :key="index" v-model="tag.name" class="form-control width-auto d-inline-block mr-2" style="width: 17%" maxlength="255" > </div> </div> <div class="form-group"> <label for="entry-published_at">日時</label> <input type="text" v-model="entry.published_at" id="entry-published_at" class="form-control" pattern="\d{4}(-|\/)\d{2}(-|\/)\d{2} +\d{2}:\d{2}"> </div> <div class="form-group mb-4"> <input type="checkbox" v-model="entry.draft" id="entry-draft" value="1"> <label for="entry-draft">下書き</label> </div> <div class="row"> <div class="col"> <button type="submit" class="btn btn-outline-primary">{{entryId ? '更新' : '作成'}}</button> </div> <div class="col text-right" v-if="entryId"> <button type="button" class="btn btn-outline-danger" @click="destroy">削除</button> </div> </div> </form> </div> </template> <script> export default { props: ['entryId'], data() { return { entry: {}, alert: null }; }, created() { axios.get(this.path() + '.json').then((res) => { this.entry = res.data.entry; this.initTags(); }); }, methods: { path() { return this.entryId ? `/entries/${this.entryId}/edit` : '/entries/new'; }, initTags() { let len = this.entry.tags.length; if(len < 5) { for(let i = 0; i < 5 - len; i++) { this.entry.tags.push({ name: '' }); } } }, // 中略 } } </script>newとeditのアクションで、Vueに渡すjsonデータです。
app/views/entries/edit.jbuilderjson.entry do json.title @entry.title json.body @entry.body json.published_at (@entry.published_at || Time.zone.now).strftime('%Y-%m-%d %H:%M') json.draft @entry.draft json.tags do json.array! @entry.tags do |tag| json.name tag.name end end endcreateとupdate
新規作成と更新のアクションです。成功したときは、jsonでリダイレクト先のパスを返します。失敗したときは、ステータスコード422とメッセージを返します。
ここで使っている
Entries::Form
は、バリデーションとデータの保存を行うものです。そのうち別記事で紹介します。app/controllers/entries_controller.rbdef create @entry = Entry.new @form = Entries::Form.new(current_user, @entry, entry_params) if @form.save flash.notice = '記事を作成しました。' render json: { location: entry_path(@entry) } else render json: { alert: '記事を作成できませんでした。' }, status: :unprocessable_entity end end def update @entry = current_user.entries.find(params[:id]) @form = Entries::Form.new(current_user, @entry, entry_params) if @form.save flash.notice = '記事を更新しました。' render json: { location: entry_path(@entry) } else render json: { alert: '記事を更新できませんでした。' }, status: :unprocessable_entity end end # 中略 def entry_params params.require(:entry).permit( :title, :body, :published_at, :draft, tags: [ :name ] ) end編集ページのフォーム用のVueで、フォームを送信するメソッドです。送信時には、HTTPヘッダにmeta属性からCSRF対策のトークンを入れます。
成功したときは指定のパスにリダイレクトします。失敗したときはアラート表示部にメッセージを入れます。
実際のアプリケーションでは、トークンを指定する部分はコードを共通化するべきでしょう。レスポンスの処理部分も共通化したほうがいいかも。
app/javascript/entries/form.vue<script> export default { // 中略 methods: { // 中略 submitPath() { return this.entryId ? `/entries/${this.entryId}` : '/entries'; }, submit(evt) { evt.preventDefault(); if(!this.validate()) { return; } axios({ method: this.entryId ? 'patch' : 'post', url: this.submitPath() + '.json', headers: { 'X-CSRF-Token' : $('meta[name="csrf-token"]').attr('content') }, data: { entry: this.entry } }).then((res) => { Turbolinks.visit(res.data.location); }).catch((error) => { if(error.response.status == 422) { this.alert = error.response.data.alert; } else { this.alert = `${error.response.status} ${error.response.statusText}`; } window.scrollTo(0, 0); }); }, // 中略 } } </script>index
検索フォームと記事一覧を表示するindexアクションです。ここでもHTMLの枠とAjaxのデータを同じアクションにしています。
検索のパラメータには、まとめて扱えるようにキー
q
を付けています。これは、まあ私の好みです。ここで使っている
Entries::SearchForm
は、パラメータを使って検索を行うものです。これもそのうち別記事で紹介します。app/controllers/entries_controller.rbdef index @user = User.active.find(params[:user_id]) if params[:user_id].present? @form = Entries::SearchForm.new(current_user, @user, search_params) respond_to :html, :json end (中略) def search_params return {} unless params.has_key?(:q) params.require(:q).permit(:title, :tag, :offset, :sort) end検索ページのHTML枠の一部です。グロバール変数vuePropsでVueにユーザーID(ユーザー別一覧の場合)と検索パラメータを渡します。
app/views/entries/new.html.erb<script> var vueProps = <%= { userId: @user.try(:id), query: params[:q] || {} }.to_json.html_safe %>; </script> <div id="entry-index"></div>検索フォームと記事一覧用のVueです。フォーム(search_form.vue)と一覧(list.vue)を子コンポーネントに分けています。
app/javascript/entries/index.vue<template> <div> <search-form :userId="userId" :query="query"></search-form> <list :userId="userId" :query="query"></list> </div> </template> <script> import Form from './search_form'; import List from './list'; export default { props: ['userId', 'query'], components: { 'search-form': Form, 'list': List } } </script>検索フォームのVueです。ここでは特に何もしてません。タグを入力したときに動的に候補を出すような機能を付けるときはここに追加するつもりです。
検索フォームの送信はブラウザーに任せてページ遷移します。
app/javascript/entries/search_form.vue<template> <div> <form action="/entries" method="get" class="form-inline mb-4"> <input type="text" name="q[title]" class="form-control mr-3 mb-2" v-model="query.title" placeholder="タイトル"> <input type="text" name="q[tag]" class="form-control mr-3 mb-2" v-model="query.tag" placeholder="タグ"> <input type="hidden" name="q[sort]" v-model="query.sort"> <input type="hidden" name="user_id" v-model="userId"> <button type="submit" class="btn btn-outline-primary mb-2">検索</button> </form> </div> </template> <script> export default { props: ['userId', 'query'] } </script>検索結果の一覧を出すVueです。createdでAjax送信を行い、記事一覧のデータを配列entriesに入れます。「もっと読む」ボタンを押したときは、オフセットを増やして記事を取得します。
app/javascript/entries/list.vue<template> <div> <div class="text-right mb-3"> {{entriesCount}}件 | <a :href="sortPath('date')" v-if="query.sort == 'stars'">日付順</a> <template v-else>日付順</template> | <a :href="sortPath('stars')" v-if="query.sort != 'stars'">いいね順</a> <template v-else>いいね順</template> </div> <div class="entries mb-4"> <div v-for="entry in entries" :key="entry.id" class="entry"> <div> <a :href="entry.path"> <template v-if="entry.draft">(下書き) </template> {{entry.title}} </a> </div> <div class="text-right text-secondary"> <a :href="entry.user_path">{{entry.user_name}}</a> | <a v-for="tag in entry.tags" :key="tag.id" class="mr-2" :href="tag.tag_path">{{tag.name}}</a> | {{entry.published_at}} | <span class="text-warning" v-if="entry.stars_count > 0">★{{entry.stars_count}}</span> </div> </div> </div> <div v-if="showMore"> <button type="button" @click="moreClicked" class="btn btn-outline-secondary w-100">もっと見る</button> </div> </div> </template> <script> import axios from 'axios'; import qs from 'qs'; export default { props: ['userId', 'query'], data: function () { return { entries: [], entriesCount: 0, offset: 0 }; }, computed: { showMore() { return (this.entries.length < this.entriesCount); } }, created () { this.getEntries(); }, methods: { getEntries() { let params = { q: { ...this.query, offset: this.offset }, user_id: this.userId }; let path = '/entries.json?' + qs.stringify(params); axios.get(path).then((res) => { this.entries = this.entries.concat(res.data.entries); this.entriesCount = res.data.entries_count; }); }, moreClicked() { this.offset += 20; this.getEntries(); }, sortPath(key) { let params = { q: { ...this.query, sort: key }, user_id: this.userId }; return '/entries?' + qs.stringify(params); } } } </script>indexアクションで、検索結果を返すjsonです。
Entries::SearchForm
のメソッドを呼び出しています。app/views/entries/index.jbuilderjson.entries do json.array! @form.entries do |entry| json.id entry.id json.title entry.title json.path entry_path(entry) json.user_name entry.user.name json.user_path user_entries_path(entry.user) json.draft entry.draft? json.published_at entry.published_at.try(:strftime, '%Y-%m-%d %H:%M') json.stars_count entry.stars_count json.tags do json.array! entry.tags do |tag| json.id tag.id json.name tag.name json.tag_path( @user ? user_entries_path(@user, q: { tag: tag.name }) : entries_path(q: { tag: tag.name }) ) end end end end json.entries_count @form.entries_countVue.jsと関係ない補足
- createとupdateは、Ajaxで呼ぶほうが楽です。失敗時に
render :new
などでテンプレートを作り直す手間が省けるのは大きい。また、失敗ページでのブラウザーの進む/戻る/リロードの動作が自然になります。- 検索フォームを送信するときは、GETメソッドでページ遷移させて、URLを変化させます。POSTメソッドを使ったりAjaxを使ったりすると、ブラウザーのリロードで検索結果が消えたり、検索結果をブックマークできなくなったりします。
- JavaScriptでフォームの送信を扱うときは、送信ボタンのclickイベントじゃなくて、form要素のsubmitイベントを処理すること。
検討課題
- Vueのテンプレートは.vueファイル内に入れるべきか、Railsが出力したHTMLを使うべきか。既存のアプリケーションでは、テンプレートをすべて.vueファイルに移すのは現実的に無理なので、両方ありとします。
- フォームのAjax送信にrails-ujs(
remote: true
を付けてajax:success
イベントを処理)を使うべきか。rails-ujsでもいいんだけど、慣れていない人に分かりにくいのでAxiosで統一したほうがよい。- RailsからVueに直接データを渡す方法(このサンプルのグローバル変数vueProps)については、まだ迷い中。
- 投稿日:2019-12-03T11:31:11+09:00
Vue.jsでいい感じのアニメーションを作りたい
この記事は クラウドワークス Advent Calendar 2019 の3日目の記事です。
昨日は、@minamijoyo さんによるtfupdateでTerraform本体/プロバイダ/モジュールのバージョンアップを自動化する でした!
はじめに
こんにちは、最近アルコールに負け続けている新卒エンジニアの @b0ntenmaru です。
Rubyの会社に入社して7ヶ月ほど経ったのですが、この7ヶ月間あまり Ruby は書かずに Vue.js を利用したフロントエンド開発ばかりしていました。その中でも Vue.js でのアニメーションの実装は経験がなく、一から調べる機会があったので今回はそれについて書いていこうと思います。
基本的なこと
Vue.js はデフォルトでトランジション(
transition
)という機能を提供していて、それを利用することでいい感じにアニメーションを実装することができます。まずここにボタンを押したら
div.hoge
を表示/非表示するだけの SFC があります。いい感じのアニメーションはありません。<template> <div id="app"> <button @click="show = !show"> click! </button> <-- アニメーションで表示/非表示が切り替わる時にふわっと表示させたい要素 --> <div class="hoge" v-if="show"> hogehoge </div> </div> </template> <script> export default { name: 'app', data() { return { show: false } } } </script>See the Pen VwwJmBq by Date (@b0ntenmaru) on CodePen.
いい感じのアニメーションを追加する
アニメーションを動作させるためには該当の要素(ここでは
div.hoge
)を<transition>
タグで囲み、トランジションクラスに対して CSS を指定します。トランジションクラスに関しては後述します。<transition> <div class="hoge" v-if="show"> hogehoge </div> </transition><style> /* 以下の v-enter, v-enter-to, v-enter-active がトランジションクラス */ /* 表示アニメーションをする前のスタイル */ .v-enter { opacity: 0; } /* 表示アニメーション後のスタイル */ .v-enter-to { opacity: 1; } /* 表示アニメーション動作中のスタイル */ .v-enter-active { transition: all 500ms; } </style>これにより、要素の表示に動きを与えることができました。
See the Pen dyyxrJr by Date (@b0ntenmaru) on CodePen.
トランジションクラス
トランジションクラスは、
transition
タグで要素を囲むことで使用できるようになるCSSのクラスです。
ちょうど上で登場したv-enter
,v-enter-to
,v-enter-active
がトランジションクラスにあたります。トランジションクラスには Enter と Leave の2つのフェーズが存在し、
transition
コンポーネントで囲った要素を表示する時を Entertransition
コンポーネントで囲った要素を非表示にする時を Leaveと言います。(上のサンプルでは Enter のみ)
この2つのフェーズにはそれぞれ、アニメーションの動作前・動作中・動作後の3つ状態に対応した3つのクラスがあります。
クラス 要素の状態 v-enter 表示アニメーションの開始時のスタイル v-enter-to 表示アニメーションの終了時のスタイル v-enter-active 表示アニメーション中のスタイル v-leave 非表示アニメーションの開始時のスタイル v-leave-to 非表示アニメーションの終了時のスタイル v-leave-active 非表示アニメーション中のスタイル いい感じの非表示アニメーションを追加する
続いて非表示アニメーションを実装します。
Enter の時とやることは逆ですが、下記のように指定すると非表示アニメーションが動いてくれます。/* 非表示アニメーション動作前のスタイル */ .v-leave { opacity: 1; } /* 非表示アニメーション動作後のスタイル */ .v-leave-to { opacity: 0; } /* 非表示アニメーション動作中のスタイル */ .v-leave-active { transition: all 500ms; }See the Pen yLyBLRP by Date (@b0ntenmaru) on CodePen.
このトランジションクラスが Vue でアニメーションを実装するための基本となります。
複数要素のトランジション
続いて、
v-for
など同時に描画された複数の要素にアニメーションを適応させるためのリストトランジションについて説明します。下記のSFCを例とします。
現状hoge
が一覧されており、ADD ボタン押下で新しいhoge
が一覧に追加・hoge
横の x ボタン押下で一覧から削除できる仕様となっています。<template> <div id="app"> <button @click="add">ADD</button> <div v-for="(todo, index) in todos" :key="todo.key"> <span>{{ todo.value }}</span><input @click="remove(index)" type="button" value="x" /> </div> </div> </template> <script> export default { name: 'app', data() { return { todos: [ {key: 0, value: 'hoge'}, {key: 1, value: 'hoge'}, {key: 2, value: 'hoge'}, ], nextNum: 2 } }, methods: { add: function() { const value = 'hoge' const todo = { key: this.nextNum += 1, value } this.todos.push(todo) }, remove: function(index) { this.todos.splice(index, 1) } } } </script>See the Pen LYEPxyJ by Date (@b0ntenmaru) on CodePen.
いい感じのアニメーションを適応させる
hoge
追加時/削除時にアニメーションを適応させます。
やることとしては単一要素のトランジションと同じでですが、今回のような複数の要素にアニメーションを適応させる時は<transition-gruop>
タグで囲い、フェーズ( Enter / Leave )ごとにスタイルを書いていきます。<transition-group> <div v-for="(todo, index) in todos" :key="todo.key"> <span>{{ todo.value }}</span><input @click="remove(index)" type="button" value="x" /> </div> </transition-group><style> /* 表示・非表示アニメーション中 */ .v-enter-active, .v-leave-active { transition: all 500ms; } /* 表示アニメーション開始時 ・ 非表示アニメーション後 */ .v-enter, .v-leave-to { opacity: 0; } </style>See the Pen VwYZPNo by Date (@b0ntenmaru) on CodePen.
一点自分がハマったポイントがあるのでこちらも是非読んでみてください。
これで複数要素のアニメーションが実装できました。
ですが削除ボタン押下後、要素が移動する時にアニメーションがなく、新しい位置に味気なく移動してしまっています。要素移動のトランジション
<transition-group>
は Enter / Leave だけでなく、要素の移動のためのトランジションクラスであるv-move
クラスを使うことで上のような味気ない移動にアニメーションを加えることができます。やることとしては、簡単で以下の2点を追記します。
/* 要素が移動する時に700msで移動するように指定 */ .v-move { transition: all 700ms; } .v-leave-active { /* 移動のトランジションをさせる場合は非表示アニメーション中に position: absoluteを指定しないと正しく動作しない */ position: absolute; }See the Pen jOENGvN by Date (@b0ntenmaru) on CodePen.
これで複数要素の表示/非表示/移動時のアニメーションが実装できました。
ちなみにこの
v-move
に関しては要素のあらゆる移動の時に適応されるので、複数要素をシャッフルさせる機能を追加してもいい感じに動いてくれます。See the Pen yLyBxZQ by Date (@b0ntenmaru) on CodePen.
ハマったポイント
配列のインデックスをキーにv-bindしてはダメ
中の要素は、key 属性を持つことが 必須 です。
と Vue の公式にも記述されているように、
transition-group
タグ内部の v-for で指定された要素はそれぞれが key を持つことが必須ですが、ここに配列(ここではtodos
)の index を渡してはいけません、アニメーションが正常に動作しなくなります。
下記はkeyに配列の index を渡した例です。
削除ボタンを押すと決まって最後の要素が削除されたように見えてしまっています。<transition-group> <div v-for="(todo, index) in todos" :key="index"> <span>{{ todo.value }}</span><input @click="remove(index)" type="button" value="x" /> </div> </transition-group>See the Pen GRgKXaE by Date (@b0ntenmaru) on CodePen.
Vue.js の key はどの値に変更があったのかを追うために使われていて、 index を key に指定した場合、最後より前の要素を消すことによって index の値が更新され、 Vue がどの要素をアニメーションさせれば良いかわからなくなり、一番最後の要素が消えるような挙動となってしまうようです。なので key には配列の index ではない一意な値を渡してあげましょう。
終わりに
以上、「Vue.jsでいい感じのアニメーションを作りたい」でした。
アニメーションがいい感じかどうかはさておき、 Vue.js でも簡単にアニメーションを実装できることがお分りいただけたかと思います。
Vue.js のアニメーションには状態のトランジションなどもあるので是非いろいろ触ってみてください!
- 投稿日:2019-12-03T10:08:57+09:00
vue.js+element-uiで複数選択(select)からフォーム(form)表示・非表示をコントロール
やりたいこと
グループ化の複数選択肢がある場合、ある選択(値)を選択すると、グループによって違うフォームが表示されます。
効果図
グループが二つあります。
グループ1(select1~3)の中で任意の選択肢を選択すると、aaa+bbbのフォームが表示できます。
グループ2(select4~5)の中で任意の選択肢を選択すると、ccc+dddのフォームが表示できます。
コード分析
<el-select style="width:60%" v-model="value" placeholder="Select" @change='getValue'> <el-option-group v-for="group in options" :key="group.label" :label="group.label"> <el-option v-for="item in group.options" :key="item.value" :label="item.label" :value="item.value"> </el-option> </el-option-group> </el-select>@change='getValue'でv-model="value"からバンディング・定義された複数選択の値をゲットします。
<el-form :rules="rules" :label-position="labelPosition" label-width="100px" ref="form" :model="form" v-if="isShow1"> <el-form-item label="aaa"> <el-input v-model="form.name1"></el-input> </el-form-item> <el-form-item label="bbb"> <el-input v-model="form.name2"></el-input> </el-form-item> </el-form>v-if="isShow1"でフォームの表示・非表示をコントロールします。
コード全文
select.vue<style> .el-row { margin-bottom: 20px; &:last-child { margin-bottom: 0; } } .el-textarea { width:80%; } .el-input { width:80% } </style> <template> <div> <el-card style="margin:0 auto;width:70%;"> <el-row :label-position="labelPosition" label-width="100px" > <span好きな果物を選択してください </span> </el-row> <el-row :label-position="labelPosition" label-width="100px" > <el-select style="width:60%" v-model="value" placeholder="Select" @change='getValue'> <el-option-group v-for="group in options" :key="group.label" :label="group.label"> <el-option v-for="item in group.options" :key="item.value" :label="item.label" :value="item.value"> </el-option> </el-option-group> </el-select> </el-row> <el-divider></el-divider> <el-form :rules="rules" :label-position="labelPosition" label-width="100px" ref="form" :model="form" v-if="isShow1"> <el-form-item label="aaa"> <el-input v-model="form.name1"></el-input> </el-form-item> <el-form-item label="bbb"> <el-input v-model="form.name2"></el-input> </el-form-item> </el-form> <el-form :label-position="labelPosition" label-width="100px" ref="form" :model="form" v-if="isShow2"> <el-form-item label="ccc"> <el-input v-model="form.name3"></el-input> </el-form-item> <el-form-item label="ddd"> <el-input type="textarea"></el-input> </el-form-item> </el-form> </el-card> </div> </template> <script> export default { data() { return { options: [{ label: 'group1', options: [{ value: 'select1', label: 'select1' }, { value: 'select2', label: 'select2' }, { value: 'select3', label: 'select3' }] }, { label: 'group2', options: [{ value: 'select4', label: 'select4' }, { value: 'select5', label: 'select5' }] }], value: '', isShow1: false, isShow2: false, form: { name1: '', name2: '', name3: '', desc: '' } } }, methods: { getValue: function() { console.log(this.value, '選択されました'); if (this.value == "select4" || this.value == "select5") { this.isShow1 = false; this.isShow2 = true; console.log("isShow2 is show "); } else { this.isShow2 = false; this.isShow1 = true; console.log("isShow1 is show"); } } } } </script>
- 投稿日:2019-12-03T08:59:49+09:00
Vue.jsはじめました
はじめに
こんにちは!ユアマイスターアドベントカレンダー3日目の投稿になります。
最近ようやくVue.jsを学ぶ決心がつき、ここ1ヶ月ほど合間をみて勉強してました。
本投稿では勉強内容をまとめ、アウトプットのきっかけになれば幸いです。勉強方法
入門書と動画コンテンツを購入し、サンプルコードを書きながら徐々に慣れていきました。
ちなみに私は新技術を勉強するときにUdemyというサービスをよく利用しています。動画なのでわかりやすいし、セール期間中だと安く購入できるのでおすすめです。ちなみに今回購入した本と動画がこちら↓
Vue.js入門 基礎から実践アプリケーション開発まで
https://gihyo.jp/book/2018/978-4-297-10091-9Vue JS 入門決定版!jQuery を使わない Web 開発 - 導入からアプリケーション開発まで体系的に動画で学ぶ
https://www.udemy.com/course/learn-vuejs/Vueの基本
Vueオブジェクト・Vueインスタンス
Vueを勉強する上で一番最初にやることはグローバル変数Vueのインスタンスを作成するところじゃないでしょうか?
Vueのインスタンスはいくつかのコンストラクタを持っていて、インスタンスをDOMとマウントすることでVueの基本的な機能を提供してくれます。(マウントとは既存のDOM要素をVueが生成するDOM要素に置き換えることです。)
オプション 内容 data UIの状態・データ el Vueインスタンスをマウントする要素 filters データを文字列と整形する methods イベントが発生した時などの振る舞い computed データから派生して算出される値 例えば、
el
プロパティでマウント対象のDOMを指定し、Vueで操作することが可能になります。sample.htmlvar vm = new Vue({ el: '#app', data: { name: 'taro', age: 21 }, methods: greet: function () { alert('Hello'); } }) <div id="app"> <p> 名前: {{ name }}, 年齢: {{ age }} </p> <button @click="greet"></button> </div>基本文法
よく使った基本的な文法をいくつか紹介します。
変数表示
<div id="app"> {{ text }} </div>var vm = new Vue({ el: '#app', data: { text: 'hello' } })結果:hello
リストレンダリング v-for
<div id="app"> <li v-for="item in items"> {{ item.message }} </li> </div>var vm = new Vue({ el: '#app', data: { items: [ { message: 'first' }, { message: 'second' }, ] } })結果:
first
second条件付きレンダリング v-if
<div id="app"> <div v-if="first">first</div> <div v-if="second">second</div> </div>var vm = new Vue({ el: '#app', data: { first: true, second: false, } })結果:first
データバインディング
VueはDOM要素とJsのデータを結びつける双方向なデータバインディングを提供しているので、対象のDOMに対してJsのデータをバインディングすることで何か変更があるたびにDOMも変更され、下記のように何か入力されたらテキストの値が変わります。
サンプルコード
sample.html<div id="app"> <p> <input type="text" v-model="message"> </p> <p> {{ message }} </p> </div> <script> var vm = new Vue({ el: '#app', data: { message: '' } }) </script>jQueryとの比較
jQueryで同じことを実現しようとするとデータバインディングの良さをよりイメージできると思います。
jQueryで実現する場合は値をセットし、inputの入力イベントをトリガーにイベントが発行されたタイミングで値取得とテキストへの値セットをしないといけません。もし、このようなパーツが複数存在するとき、毎回同じような処理を書かないといけません。サンプル
sample.html<input id="entry" type="text"/> <p id="message"></p> <script> $('#entry').keyup(function () { var val = $(this).val(); $('#message').text(val); }); </script>まとめ
このようにVueについて勉強したことを書かせて頂きました。
なんとか書き方は徐々に慣れてきたところですが、インスタンスが生成されてからの内部の動きやコンポネント設計などまだまだ勉強しないといけないところはたくさんあります。ただ、この機会にプロダクトにVue.jsを取り入れモダンな開発ができるようになればと思います。
- 投稿日:2019-12-03T06:04:25+09:00
Vue.jsでselectタグの初期値にオブジェクトを設定する際の注意
オブジェクト型(数値、文字列、論理値などプリミティブではない型)をoptionの値に設定
以下のように、オブジェクト型を選択肢に設定して、そのデフォルト値を「v-model」で指定することができます。
例の場合は、「selectedInfo」に「code=2」の値を設定しています。sample.vue<template> <select v-model="selectedInfo"> <option v-for="info in infoList" :value="info"> {{ info.label }} </option> </select> </template> <script> export default { data() { return { selectedInfo: { code: '2', label: '2番' }, infoList: [ {code: '1', label: '1番'}, {code: '2', label: '2番'}, {code: '3', label: '3番'}, {code: '4', label: '4番'}, ] } } } </script>選択値(v-modelの値)は、プロパティや値が選択肢と完全に一致していないと選択されない
以下の例だと、remarkプロパティがないので選択されない。
sample.vue<template> <select v-model="selectedInfo"> <option v-for="info in infoList" :value="info"> {{ info.label }} </option> </select> </template> <script> export default { data() { return { selectedInfo: { code: '2', label: '2番' }, infoList: [ {code: '1', label: '1番', remark: '' }, {code: '2', label: '2番', remark: '' }, {code: '3', label: '3番', remark: '' }, {code: '4', label: '4番', remark: '' }, ] } } } </script>
- 投稿日:2019-12-03T00:42:50+09:00
わいのNuxt.jsアプリケーションは遅かった。
この記事は 今年イチ!お勧めしたいテクニック by ゆめみ feat.やめ太郎 Advent Calendar 2019の3日目の記事です。
プログラミング初級者がNuxt.jsの高速化するのにストレージにキャッシュしてみたら、色々捗った事を書きます。
わいさん風を取り入れてみました。少しなまりが間違っている箇所があるかもしれませんが、ご容赦下さい。Nuxt.jsでSSRしたら最速なのか?
ある日の転職したくて、ポートフォリオアプリを作っているワイ。
ワイ 「Nuxt.jsでサーバーサイドレンダリングが簡単に出来るみたいやな。」
ワイ 「サーバーサイドレンダリングで、アイソーモフィックでユニバーサルなシングルページアプリケーションで作ってみました、とか面接で言えば転職間違いなしやな」
ワイ 「ヘッドレスCMSが流行ってるみたいやな。Blogアプリとかいいやん。作って自分で勉強した事をアウトプット出来るしな。(本当はどうせ、作って満足して、アウトプット出来ないBlogアプリを量産するんやけどな。。)」
ワイ 「GraphCMSってのがあるやん、GraphQLでリクエスト
すればJsonでレスポンス返してくれるヘッドレスCMSやん。これ使えば、GraphQLは使えますって見栄張れるな。」
ワイ 「FirebaseのFunctionsでSSRしたらなんかかっこいいやん!私はサーバレスですって言えるやん。」
ワイ 「Bootstrap-vue使って、GraphqlのリクエストはAppoloClient使って、FontAwesome使って、GoogleFont使って・・・・・・よし出来た!」
ワイ 「なんでもNuxt.js用にライブラリ作ってくれてるもんで、便利やな!」ワイ 「これでわいも爆速で最強のSSRやでって言えるな。よし、LightHouseしてオール100点ですっていうのをスクショ取ってQiitaで見せびらかしたろ」
ワイ 「全部緑色ちゃうやん!」
ワイ 「見せびらかせやんやん!」LightHouseのパフォーマンス
上記のスクショでは、65点でしたが、凝ったWebアプリを作れば作るほどもっと悪いスコアが出る方も多いのではないでしょうか?(私はそうでした。)
パフォーマンス以外の部分は、LightHouseの評価するポイントを埋めていけば100点に近づいていきます。足し算の努力で対応出来ます。
しかし、パフォーマンスだけは、足し算だけではスコアが上がりません。この記事では、パフォーマンスを100点に近づけていく事をテーマに個人開発でポートフォリオを作っている方が、取り入れる時に参考になりそうな事を考えてみました。まずLightHouseについて
Lighthouse(ライトハウス)は、もともとGoogleが提供しているChrome拡張機能でしたが、現在はChromeに標準搭載されて、デベロッパーツールのAuditsから使えるので、拡張機能はインストールしなくて大丈夫です。Goolgeが考えるWebサイト、Webアプリ
(モバイルも含む)が目指す先の道しるべとしてのLighthouse(灯台)という意味です。スコアは変動する
LightHouseのスコアは、変動をできるだけ最小限に押さえられる様に作られているそうです。
しかし、インターネット回線の速度、実行するデバイスの違い、アクセスするサイト全てに手を加えるChrome拡張を入れていたり、異なるバージョンのLightHouseで計測する等様々な理由で変動します。出来るだけ同じ環境で、複数回計測し、平均点をあげて行けば良いのかなと思います。LightHouseのバージョンによる違い。
スコアの算出方法としては、Googleが考えるベストプラクティスなので、googleが考えるユーザー知覚パフォーマンスに影響を与える度合いというもので、これから説明する、いくつかの指標に重み付けをして算出されています。
さらにこの重み付けは、LightHouseのバージョンが変われば変わってしまいます。
つまり一度、高スコアを出しても、Googleさんの考えが変わればスコアは変動するという事です。
なのでLightHouseのスコアを高くする事を目指すのがとにかく良いということでは無いという事は理解しておく必要があります。
参考URL:https://developers.google.com/web/fundamentals/performance/user-centric-performance-metricsPerfomanceのスコアを算出する為にどんな指標があるのか?
詳しくは、https://developers.google.com/web/tools/lighthouse に書かれています。
この記事では、ざっくりとした説明にしますので、気になった部分は、こちらを確認してみて下さい。
Chrome Dev Summit 2018の画像
Paul Irish at Chrome Dev Summit 2018画像をお借りして私のざっくりとした理解ですが、
- FirstContentfulPaintがリクエストから、クライアントからアクションを起こせる状態になるまでの時間で、
- その状態から一番時間がかかるタイミングで一番時間がかかるアクションを取った時のアプリからのレスポンスにかかる時間がMaxPotentialFirstInputDelayである。
- Time to Interactviはページが完全にインタラクティブになるまでの時間です。
NuxtでSSRをすれば、Htmlをサーバ側で、生成してくるので、FirstContentfulPaintは早いが、早い分、MaxPotentialFirstInputDelayは大きく出るという事になると考えられます。MaxPotentialFirstInputDelayの計測値にGoogleが重きをおけばおくほどスコアは下がっていくというように考えられます。
ざっくりスコアを上げる為に主なできる事
クライアントのリクエストスタートからサーバのレスポンスを早くする。アーキテクチャの見直し。
なるべくCSS・JS・画像データを軽くして読み込みを早くする。
ソースの読み込み時にブロッキングが発生している所がないか確認、あればブロッキングしないように出来ないかやってみる。
lazyloadとか。prefetchとかPrerenderとか。今回のわい君が思いついたアプリ
FirebaseFunctionsでSSRする時は要注意
FirebaseFunctionsをFirebaseHostingをドメインを結びつけて使用する際には現在、
リージョンはUSセントラルしか使えません!
つまり、クライアントが日本にあるとすると、USセントラルのFirebaseにリクエストを送ると、東京リージョンのGraphCMSにデータを取りに行き、USセントラルでから日本にレスポンスを返してきます。
世界1周旅行です。
GraphCMSのリージョンをUSにすればいいんじゃ無いかと思った方もいるかと思います。
しかし、CMSをUSに持っていっても、今度は、CMSで管理している画像を保存しているストレージがUSに行ってしまいます。
初期表示時にデータを入れてSSRをしたい時には、SSRを行うアプリケーションの核となる部分はクライアントに近くないと考えることが多くなると感じました。
世にあるCMSや、情報を発信してくれるAPIや、画像を圧縮してくれるAPIをアプリケーションに取り込む際は、リアルタイムにクライアントのレスポンスに応じて、APIサービスを使用するのでは無く、あらかじめストレージにキャッシュしておけるならしておくというような使い方をしなくちゃなと思いました。
CMSのデータをストレージにキャッシュさせるといい事いっぱい
CMSで新しい記事を書いた時、または編集した時にフックしてFirebaseFunctionsをから、GraphCMSからデータを取り、ストレージに保存します。
すると、クライアントがBlogページへリクエストを送った時には、データはストレージに取りに行きます。先ほどのアメリカ日本を往復するデータ取得の処理は、クライアントがBlogを閲覧する時には走らなくなりました。
また、CMSへリクエストを投げる為に使っていた、ApolloClientもFunctionで、使うのでNuxtには必要なくなりました。少しデータが軽くなりました。さらに勝手に気を利かせてGCPがCDNにキャッシュしてくれているみたい!
クライアントで欲しいデータを外部APIへ取りに行ったり、そのデータを加工したりするのは、FaaS,PaaS等にデプロイした自家製APIに任せて、使いたいデータの形式にしてストレージにキャッシュしておくのがいいなと思いました。
Blogアプリで考えられるのは、Markdown形式で書かれたコンテンツデータをHtmlにパースする事をストレージにキャッシュする前に処理出来ないか等が考えられると思います。まだ出来ていませんが、これは何を作る時でも色々考えられる事だと思いました。
pwa-module
は入れるNuxt.jsは簡単にPWAを実現出来ます。簡単すぎて、serviceworkerについては別途学習しないと何も理解出来ないくらいです。この導入で、PWAモジュールに含まれるworkboxモジュールによってサービスワーカーが導入され、キャッシュが効くようになります。 Googleは現在、PWA推しのようです。
$ yarn add @nuxtjs/pwa
nuxt.config.jsmodules: [ '@nuxtjs/pwa ]とにかく軽くする
$ nuxt build --analyzeしまくります。削減可能な部分を考えたり、重いライブラリを軽量ライブラリに置き換えたりします。
難しいことは考えずにとにかく軽くする方法を考えました。
FontAwesomeは使うアイコンだけ読み込むようにする。
FontAwesomeの導入方法は様々ありますが、いっぺんに全てのアイコンを読み込んでしまうものがあります。
よく使われているものでも、@fortawesome
,nuxt-fontawesome
,@nuxt/font-awesome
,vue-fontawesome
,vue-awesome
とか他にもあるかもしれません。
初級者向けの記事を参考にすると、出来るだけ簡単に単手順で全て取り込めて、どのページでも全てのフリーのアイコンを使えるようなものを採用することになってしまいます。私は、軽量化するに当たって、
@fortawesome/vue-fontawesome
を使ってプラグインで使うものんだけ読み込んでみました。これでも大きな軽量化になりました。使用するコンポーネントだけで読み込んだほうが良いのか、vue-fontawesome
以外も使ってサイズの違いを確認してみます。nuxt.config.js で プラグイン で読み込む
nuxt.config.jscss: [ '@fortawesome/fontawesome-svg-core/styles.css' ], plugins: [ { src: '~plugins/font-awesome', ssr: false }, ],使用するものだけ読み込む
plugins/fontawesome.jsimport Vue from 'vue' import { library } from '@fortawesome/fontawesome-svg-core' import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome' import { faCalendarAlt, faTags } from '@fortawesome/free-solid-svg-icons' library.add(faCalendarAlt, faTags) Vue.component('fa-icon', FontAwesomeIcon) Vue.config.productionTip = falseコンポーネントライブラリは重い
今回、CSSフレームワークとして、使ったのはBootstrap-vueでした。BootStrap-vueは、コンポーネントのライブラリとなっています。CSSだけでなくJavascriptも内部で使っています。これが使ってない部分も全て取り込まれてしまいます。自分でJSを書く必要はありますが、ここをCSSのみのライブラリに変更出来れば軽量化出来ます。
まだやりたい事は全部できてませんが、
まだ、bootstrap-vueは置き換えていません。
さらにGraphCMSでは記事の内容を更新したり、新規投稿したりした時にHookして何かアクションを起こしたりする事は無料では出来ないことが発覚しました。とりあえずの間に合わせで、CMSを更新したら、ターミナルからrefreshコマンドを打てばリフレッシュ出来るようにしました。ポンコツ仕様ですが、自分でCMSを作りたいというモチベーションが出来たので無駄では無いと言い聞かせます。結果、90点以上は平均して出るようになりました。
ワイ「これで緑色にはなったな」
ワイ「データ駆動やからこそ、もらうデータのパターンが決まっていてキャッシュしやすいものはキャッシュ最強やな」
ワイ「それにしてもLightHouseを使って$ nuxt build --analyze
叩いているうちに重いライブラリをみると敵に見えてきてしまうようになってしもたで。」まとめ
ブログサイトやポートフォリオページなんかの更新がそれほどリアルタイム性が必要無いものは、ストレージにキャッシュする事でいい事満載だなと思いました。フロントエンドの役割が大きくなっていくにつれて、Firebaseにバックエンドは任せて、初級者はなんでもライブラリをフロントエンドに詰め込みまくってしまいがちです。
とりあえず、FirebaseStoreのデータを全部読み込んで、複雑なデータ処理もフロントエンドで行なったりしがちです。私がその典型でした。
しかし、出来るだけサーバーサイドにデータ処理のロジックを回したり、外部APIをから手に入れるデータがある際は、キャッシュしたりする事でフロントエンドが軽量に出来るという視点を持たなくてはならないなと感じました。
今回の内容には直接関係無いかもしれませんが、最終的な成果物です。
ポートフォリオ的Blogアプリですが、作って満足して、内容は更新できていません。汗最終的なBlogサイトのデモページ:https://nuxt-deploy-test-teshima.appspot.com/
コード:https://github.com/yujiteshima/nuxt_blog_LT
- 投稿日:2019-12-03T00:42:50+09:00
��わいのNuxt.jsアプリケーションは遅かった。
この記事は 今年イチ!お勧めしたいテクニック by ゆめみ feat.やめ太郎 Advent Calendar 2019の3日目の記事です。
プログラミング初級者がNuxt.jsの高速化するのにストレージにキャッシュしてみたら、色々捗った事を書きます。
わいさん風を取り入れてみました。少しなまりが間違っている箇所があるかもしれませんが、ご容赦下さい。Nuxt.jsでSSRしたら最速なのか?
ある日の転職したくて、ポートフォリオアプリを作っているワイ。
ワイ 「Nuxt.jsでサーバーサイドレンダリングが簡単に出来るみたいやな。」
ワイ 「サーバーサイドレンダリングで、アイソーモフィックでユニバーサルなシングルページアプリケーションで作ってみました、とか面接で言えば転職間違いなしやな」
ワイ 「ヘッドレスCMSが流行ってるみたいやな。Blogアプリとかいいやん。作って自分で勉強した事をアウトプット出来るしな。(本当はどうせ、作って満足して、アウトプット出来ないBlogアプリを量産するんやけどな。。)」
ワイ 「GraphCMSってのがあるやん、GraphQLでリクエスト
すればJsonでレスポンス返してくれるヘッドレスCMSやん。これ使えば、GraphQLは使えますって見栄張れるな。」
ワイ 「FirebaseのFunctionsでSSRしたらなんかかっこいいやん!私はサーバレスですって言えるやん。」
ワイ 「Bootstrap-vue使って、GraphqlのリクエストはAppoloClient使って、FontAwesome使って、GoogleFont使って・・・・・・よし出来た!」
ワイ 「なんでもNuxt.js用にライブラリ作ってくれてるもんで、便利やな!」ワイ 「これでわいも爆速で最強のSSRやでって言えるな。よし、LightHouseしてオール100点ですっていうのをスクショ取ってQiitaで見せびらかしたろ」
ワイ 「全部緑色ちゃうやん!」
ワイ 「見せびらかせやんやん!」LightHouseのパフォーマンス
上記のスクショでは、65点でしたが、凝ったWebアプリを作れば作るほどもっと悪いスコアが出る方も多いのではないでしょうか?(私はそうでした。)
パフォーマンス以外の部分は、LightHouseの評価するポイントを埋めていけば100点に近づいていきます。足し算の努力で対応出来ます。
しかし、パフォーマンスだけは、足し算だけではスコアが上がりません。この記事では、パフォーマンスを100点に近づけていく事をテーマに個人開発でポートフォリオを作っている方が、取り入れる時に参考になりそうな事を考えてみました。まずLightHouseについて
Lighthouse(ライトハウス)は、もともとGoogleが提供しているChrome拡張機能でしたが、現在はChromeに標準搭載されて、デベロッパーツールのAuditsから使えるので、拡張機能はインストールしなくて大丈夫です。Goolgeが考えるWebサイト、Webアプリ
(モバイルも含む)が目指す先の道しるべとしてのLighthouse(灯台)という意味です。スコアは変動する
LightHouseのスコアは、変動をできるだけ最小限に押さえられる様に作られているそうです。
しかし、インターネット回線の速度、実行するデバイスの違い、アクセスするサイト全てに手を加えるChrome拡張を入れていたり、異なるバージョンのLightHouseで計測する等様々な理由で変動します。出来るだけ同じ環境で、複数回計測し、平均点をあげて行けば良いのかなと思います。LightHouseのバージョンによる違い。
スコアの算出方法としては、Googleが考えるベストプラクティスなので、googleが考えるユーザー知覚パフォーマンスに影響を与える度合いというもので、これから説明する、いくつかの指標に重み付けをして算出されています。
さらにこの重み付けは、LightHouseのバージョンが変われば変わってしまいます。
つまり一度、高スコアを出しても、Googleさんの考えが変わればスコアは変動するという事です。
なのでLightHouseのスコアを高くする事を目指すのがとにかく良いということでは無いという事は理解しておく必要があります。
参考URL:https://developers.google.com/web/fundamentals/performance/user-centric-performance-metricsPerfomanceのスコアを算出する為にどんな指標があるのか?
詳しくは、https://developers.google.com/web/tools/lighthouseに書かれています。
この記事では、ざっくりとした説明にしますので、気になった部分は、こちらを確認してみて下さい。
Chrome Dev Summit 2018の画像
Paul Irish at Chrome Dev Summit 2018画像をお借りして私のざっくりとした理解ですが、
- FirstContentfulPaintがリクエストから、クライアントからアクションを起こせる状態になるまでの時間で、
- その状態から一番時間がかかるタイミングで一番時間がかかるアクションを取った時のアプリからのレスポンスにかかる時間がMaxPotentialFirstInputDelayである。
- Time to Interactviはページが完全にインタラクティブになるまでの時間です。
NuxtでSSRをすれば、Htmlをサーバ側で、生成してくるので、FirstContentfulPaintは早いが、早い分、MaxPotentialFirstInputDelayは大きく出るという事になると考えられます。MaxPotentialFirstInputDelayの計測値にGoogleが重きをおけばおくほどスコアは下がっていくというように考えられます。
ざっくりスコアを上げる為に主なできる事
クライアントのリクエストスタートからサーバのレスポンスを早くする。アーキテクチャの見直し。
なるべくCSS・JS・画像データを軽くして読み込みを早くする。
ソースの読み込み時にブロッキングが発生している所がないか確認、あればブロッキングしないように出来ないかやってみる。
lazyloadとか。prefecheとかPrerenderとか。今回のわい君が思いついたアプリ
FirebaseFunctionsでSSRする時は要注意
FirebaseFunctionsをFirebaseHostingをドメインを結びつけて使用する際には現在、
リージョンはUSセントラルしか使えません!
つまり、クライアントが日本にあるとすると、USセントラルのFirebaseにリクエストを送ると、東京リージョンのGraphCMSにデータを取りに行き、USセントラルでから日本にレスポンスを返してきます。
世界1周旅行です。
GraphCMSのリージョンんをUSにすればいいんじゃ無いかと思った方もいるかと思います。
しかし、CMSをUSに持っていっても、今度は、CMSで管理している画像を保存しているストレージがUSに行ってしまいます。
初期表示時にデータを入れてSSRをしたい時には、SSRを行うアプリケーションの核となる部分はクライアントに近くないと考えることが多くなると感じました。
世にあるCMSや、情報を発信してくれるAPIや、画像を圧縮してくれるAPIをアプリケーションに取り込む際は、リアルタイムにクライアントのレスポンスに応じて、APIサービスを使用するのでは無く、あらかじめストレージにキャッシュしておけるならしておくというような使い方をしなくちゃなと思いました。
CMSのデータをストレージにキャッシュさせるといい事いっぱい
CMSで新しい記事を書いた時、または編集した時にフックしてFirebaseFunctionsをから、GraphCMSからデータを取り、ストレージに保存します。
すると、クライアントがBlogページへリクエストを送った時には、データはストレージに取りに行きます。先ほどのアメリカ日本を往復するデータ取得の処理は、クライアントがBlogを閲覧する時には走らなくなりました。
また、CMSへリクエストを投げる為に使っていた、ApolloClientもFunctionで、使うのでNuxtには必要なくなりました。少しデータが軽くなりました。さらに勝手に気を利かせてGCPがCDNにキャッシュしてくれているみたい!
クライアントで欲しいデータを外部APIへ取りに行ったり、そのデータを加工したりするのは、FaaS,PaaS等にデプロイした自家製APIに任せて、使いたいデータの形式にしてストレージにキャッシュしておくのがいいなと思いました。
Blogアプリで考えられるのは、Markdown形式で書かれたコンテンツデータをHtmlにパースする事をストレージにキャッシュする前に処理出来ないか等が考えられると思います。まだ出来ていませんが、これは何を作る時でも色々考えられる事だと思いました。
pwa-module
は入れるNuxt.jsは簡単にPWAを実現出来ます。簡単すぎて、serviceworkerについては別途学習しないと何も理解出来ないくらいです。この導入で、PWAモジュールに含まれるworkboxモジュールによってサービスワーカーが導入され、キャッシュが効くようになります。 Googleは現在、PWA推しのようです。
$ yarn add @nuxtjs/pwanuxt.config.jsmodules: [ '@nuxtjs/pwa ]とにかく軽くする
$ nuxt build --analyzeしまくります。削減可能な部分を考えたり、重いライブライを軽量ライブラリに置き換えたりします。
難しいことは考えずにとにかく軽くする方法を考えました。
FontAwesomeは使うアイコンだけ読み込むようにする。
FontAwesomeの導入方法は様々ありますが、いっぺんに全てのアイコンを読み込んでしまうものがあります。
よく使われているものでも、@fortawesome
,nuxt-fontawesome
,@nuxt/font-awesome
,vue-fontawesome
,vue-awesome
とか他にもあるかもしれません。
初級者向けの記事を参考にすると、出来るだけ簡単に単手順で全て取り込めて、どのページでも全てのフリーのアイコンを使えるようなものを採用することになってしまいます。私は、軽量化するに当たって、
@fortawesome/vue-fontawesome
を使ってプラグインで使うものんだけ読み込んでみました。これでも大きな軽量化になりました。使用するコンポーネントだけで読み込んだほうが良いのか、vue-fontawesome
以外も使ってサイズの違いを確認してみます。nuxt.config.js で プラグイン で読み込む
nuxt.config.jscss: [ '@fortawesome/fontawesome-svg-core/styles.css' ], plugins: [ { src: '~plugins/font-awesome', ssr: false }, ],使用するものだけ読み込む
plugins/fontawesome.jsimport Vue from 'vue' import { library } from '@fortawesome/fontawesome-svg-core' import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome' import { faCalendarAlt, faTags } from '@fortawesome/free-solid-svg-icons' library.add(faCalendarAlt, faTags) Vue.component('fa-icon', FontAwesomeIcon) Vue.config.productionTip = falseコンポーネントライブラリは重い
今回、CSSフレームワークとして、使ったのはBootstrap-vueでした。BootStrap-vueは、コンポーネントのライブラリとなっています。CSSだけでなくJavascriptも内部で使っています。これが使ってない部分も全て取り込まれてしまいます。自分でJSを書く必要はありますが、ここをCSSのみのライブラリに変更出来れば軽量化出来ます。
まだやりたい事は全部できてませんが、
まだ、bootstrap-vueは置き換えていません。
さらにGraphCMSでは記事の内容を更新したり、新規投稿したりした時にHookして何かアクションを起こしたりする事は無料では出来ないことが発覚しました。とりあえずの間に合わせで、CMSを更新したら、ターミナルからrefreshコマンドを打てばリフレッシュ出来るようにしました。ポンコツ仕様ですが、自分でCMSを作りたいというモチベーションが出来たので無駄では無いと言い聞かせます。結果、90点以上は平均して出るようになりました。
ワイ「これで緑色にはなったな」
ワイ「データ駆動やからこそ、もらうデータのパターンが決まっていてキャッシュしやすいものはキャッシュ最強やな」
ワイ「それにしてもLightHouseを使って$ nuxt build --analyze
叩いているうちに重いライブラリをみると敵に見えてきてしまうようになってしもたで。」まとめ
ブログサイトやポートフォリオページなんかの更新がそれほどリアルタイム性が必要無いものは、ストレージにキャッシュする事でいい事満載だなと思いました。フロントエンドの役割が大きくなっていくにつれて、Firebaseにバックエンドは任せて、初級者はなんでもライブラリをフロントエンドに詰め込みまくってしまいがちです。
とりあえず、FirebaseStoreのデータを全部読み込んで、複雑なデータ処理もフロントエンドで行なったりしがちです。私がその典型でした。
しかし、出来るだけサーバーサイドにデータ処理のロジックを回したり、外部APIをから手に入れるデータがある際は、キャッシュしたりする事でフロントエンドが軽量に出来るという視点を持たなくてはならないなと感じました。
今回の内容には直接関係無いかもしれませんが、最終的な成果物です。
ポートフォリオ的Blogアプリですが、作って満足して、内容は更新できていません。汗最終的なBlogサイトのデモページ:https://nuxt-deploy-test-teshima.appspot.com/
コード:https://github.com/yujiteshima/nuxt_blog_LT