- 投稿日:2019-12-13T23:47:13+09:00
Vue.js で Pnotify を mixin で登録して使ってみた
概要
アラートやメッセージの表示用のライブラリ Pnotify をVue.jsで利用したときの設定のメモ。
メッセージ表示などはどの画面からも使うような処理だったので、mixin を使ってどの画面からも呼び出せるように定義しています。
環境
- Vue.js 2.6.10
- Pnotify 4.0.0
ライブラリ導入
以下のコマンドでインストールできる
npm install --save pnotifymixin を使った設定
まずは呼び出したい共通メソッドを作成。
notifyNotice
メソッドを呼び出して、通知メッセージが表示されるようにしたい。pnotify-custom.jsimport PNotify from "../node_modules/pnotify/dist/umd/PNotify.js"; import PNotifyButtons from "../node_modules/pnotify/dist/umd/PNotifyButtons.js"; export default new class { constructor() { this.defaultOptions = { delay: 2000, modules: { Buttons: { sticker: false } } }; } notifyNotice(title, text) { title ? this._notify(title, text) : this._notifyNoTitle(text); } _notify(title, text, type = "notice") { PNotify.alert({ type, title, text, delay: this.defaultOptions.delay, modules: this.defaultOptions.modules }); } _notifyNoTitle(text, type = "notice") { PNotify.alert({ type, text, delay: this.defaultOptions.delay, modules: this.defaultOptions.modules }); } }();mixinで読み込ませるファイル
common.jsimport notify from "./pnotify-custom.js"; export default { methods: { notifyNotice(title, text) { notify.notifyNotice(title, text); } } };Vueのインスタンス宣言箇所
mixinで先ほどのcommon.js
を読み込んでいるmain.jsimport Vue from 'vue' import App from './App.vue' import "../node_modules/pnotify/dist/PNotifyBrightTheme.css"; import common from "./common.js"; Vue.mixin(common); new Vue({ render: h => h(App), }).$mount('#app')呼び出し方の例
this.notifyNotice([タイトル],[メッセージ])
で呼び出せるようになっているAPP.vue<template> <div id="app"> <h1>Pnotify</h1> <button @click="onNotify">タイトルなし</button> <button @click="onNotifyTitle">タイトルあり</button> </div> </template> <script> export default { name: "app", methods: { onNotify() { this.notifyNotice(undefined, "テスト[タイトルなし]"); }, onNotifyTitle() { this.notifyNotice("タイトル", "テスト[タイトルあり]"); } } }; </script> <style> </style>まとめ
Pnotifyは簡単にリッチなメッセージダイアログを使うことができるのでお勧めのライブラリです。
こういったメッセージの表示だけでなく、共通で使える処理の定義をまとめて登録できるので、
mixinをうまく使えると開発がかなりはかどると思う。
- 投稿日:2019-12-13T23:04:25+09:00
複数のNuxt.jsを用いたプロジェクトに携わってきて感じたこと
みなさんこんにちは@y_temp4です。
自分は現在フリーランスのエンジニアとして主にフロントエンドの開発を行っており、これまでに複数の Vue/Nuxt の案件に携わってきました。
というわけで今回は、いくつかの Nuxt プロジェクトに関わってきて得た知見についてシェアしていければなと思います!
想定読者
この記事は以下のような方を想定読者として書いています。
- Nuxt を導入すべきか迷っている/新規プロジェクトで Nuxt の利用を検討している
- Nuxt の構成で迷っている
- Nuxt の利用について興味がある
Nuxt でプロジェクトを初める条件
まずはじめに Nuxt のプロジェクトを初める条件ですが、基本的に Nuxt はフロントエンドのフレームワークであり、バックエンドと疎結合な作りになっていることが望ましいです。いわゆる SPA を作る感じですね。
ですので、新規にプロジェクトを立ち上げる際は、バックエンドは完全に API サーバーとしてフロントエンドと分離させるような設計にすべきでしょう。
例えば、自分が初めて Nuxt のコードを書いた際はバックエンド API が AWS の Lambda + API Gateway でした。
参考:【ALIS のシステム】サーバサイドアーキテクチャ:その1 〜ALIS サーバサイド構成〜 | ALIS
余談:なぜ SPA で開発するのか
本記事のメインの話題とは話が逸れるので詳しくは書きませんが、SPA で開発する必要性・メリデメに関しては、以下のスライドが参考になるかと思います。
私たちはなぜ SPA で開発するのか / Why you choose SPA - Speaker Deck
Nuxt プロジェクトの構成で意識すべきこと
Nuxt のプロジェクトをいくつか見てきた感想として、自分が一番思うのは「Nuxt の仕組みに沿った形で作ったほうが良い」ということです。まぁフレームワークを使う上で、フレームワークの仕組みに乗っかったほうがいいのは当然かも知れませんが、フロントエンドの構成はよりバックエンドよりもおざなりになりがちかなと感じています。
例えば Nuxt にはモジュールという概念があり、これを用いることによってある程度品質の担保された機能を実装できます。
また、Nuxt のプロジェクトに元から用意されたディレクトリ構造くらいは守るようにしましょう。
Nuxt は変化の速度が早いフレームワークであるので、独自に実装した箇所がバージョンアップの追従の足枷になるケースがよくあります。ですので、Nuxt の機能を逸脱したことをやろうとする場合、それにそこまでする価値があるか・既存の仕組みで実現できないか、よく検討した方が良いと感じています。
TypeScript の導入
Nuxt における TypeScript の導入は、基本的に以下の公式ドキュメントに沿えば OK です。
しかし、それ以外の細かい点で TypeScript 導入を行う際につまずいたポイント等があるので、今回はそれについて列記します。
Nuxt をプログラムで使う際の設定
「Nuxt.js をプログラムで使う」にあるように、通常の
nuxt-ts
コマンドではなく Express などから Nuxt を動かしているケースでは、@nuxt/typescript-build
の関係でうまく動作しないことがありました。おそらくすぐに改善されるかとは思いますが、この記事の執筆時点での解決方法を記述しておきます。
import tsModule from '@nuxt/typescript-build' ... if (config.dev) { await nuxt.moduleContainer.addModule(tsModule) const builder = new Builder(nuxt) await builder.build() } else { await nuxt.ready() } ...メインの部分以外はよくあるコードと同じなので省略します。このコードはtypescript-build/test/module.test.tsを参考に書きました。
Lint について
見落としがちですが、Nuxt での eslint の設定は
@nuxtjs/eslint-config
、もしくは TS を使う際は@nuxtjs/eslint-config-typescript
を使うと、.eslintrc
をスッキリとした形で記述できます。.eslintrc.jsmodule.exports = { extends: ['@nuxtjs/eslint-config-typescript'] }ただ、2019 年 11 月に TypeScript の 3.7 が出ましたが、このパッケージのアップデートは TS3.7 への対応が少し遅く、ちょっとだけやきもきしたのを覚えています。
今後 TS 等の追従に伴い
@nuxtjs/eslint-config-typescript
のアップデートが待てない方は、中の実装まで一応確認しておいたほうがいいかもしれません。さいごに
Nuxt のプロジェクトに関わってきて思ったのは、やはり Nuxt は比較的簡単にはじめられる反面、フロントエンドのよくある規則等を把握しておかないとすぐに破綻するリスクもある、ということです。
今後、Vue に関しては Vue のバージョン 3 にてComposition APIが導入されこれが使われるようになると、おそらくより設計力が求められるようになるのではないか、と感じています。
Nuxt のプロジェクトでも Composition API を考慮した実装が追加されていくでしょうから、この辺は意識しておく必要があります。
個人的に Composition API は今ある Vue の問題(主に型周り)を解決しうる素晴らしい仕組みかと思いますが、規約等をきちんと決めて開発を始めないと既存のコードよりも実装のつらみが出てくることが予想されます。
参考:Vue Composition API のコラムっぽいもの集 - mya-ake com
繰り返しになりますが、今後 Nuxt の開発をする方はこの辺の流れの変化にも対応できるように意識しつつ開発を進めていくことをオススメします!
さいごに、この記事が参考になった方はぜひいいねしていただけますと幸いです!
最後まで読んでいただき、ありがとうございました ?
- 投稿日:2019-12-13T22:39:52+09:00
Vuetifyで作成したフォーム画面の入力時にvue-shortkeyのイベントを抑える
概要
Vuetifyをつかってフォーム画面を作成していた時、
他に設定されていたショートカットキーが邪魔をしてきたので
ショートカットキーを無効にするコードを追加したときのメモです。状況
・Vuetifyを利用して画面機能を作っている。
・vue-shortkeyプラグインを利用してショートカットキーを設定している発生した問題
Vuetifyをつかってフォーム画面を作りテストしていた時、
ほかの方が作成された共通部分で「←」「→」キーがショートカットキーとして設定されていたため
フォーム項目の入力時に予期しないイベントが発生してしまい
とても使い物になりませんでした。ショートカットキーに関して「vue-shortkey」プラグインを利用していたため
「vue-shortkey」のドキュメントを見たところ適当な翻訳ショートカットキーを無効にするためには、 要素に「v-shortkey.avoid」タグを追加すればよいとあったので、早速タグを追加してみました。
しかし全く機能しません。相変わらずショートカットキーが邪魔をしてきます。
問題の原因
調査したところ、「v-shortkey.avoid」タグは
無効にしたい要素本体に設定しないといけないということがわかりました。私の場合、vuetifyを使ってフォーム画面を作成しています。
バリバリvuetifyのformコントロールを使っております。
もちろんテキストフィールドも<v-text-field>をつかっております。<v-text-field>内に「v-shortkey.avoid」タグを記述すればよいのでは?と思い
そのままその通り実行しました。ブラウザで画面を開き、開発者ツールでDOMを確認したとき
その考えは浅はかであったことに気づかされます。当たり前ですが、<v-text-field>というタグをDOMに直すと
いくつかのDIVタグに囲まれた奥深い場所にinputがあります。すなわち<v-text-field>に「v-shortkey.avoid」タグを設定しても
有効にしてほしいinput部分に掛からず、
inputを取り囲むDIVに対してかかってしまっています。これは困りました。
しかしドキュメントはシンプルに解決策へとつながる道を指示しています。
解決策
「vue-shortkey」のドキュメントを再度読み直したところ
「v-shortkey.avoid」タグを要素に直接設定する以外にも
プラグインを呼び出す箇所で、オプションにprevent項目を設定しクラスもしくは要素を記述することで
ショートカットキーを無効にできるとありました。簡単な設定記述例を引用します。
ドキュメントから引用Vue.use('vue-shortkey', { prevent: ['.my-class-name'] })プラグインを呼び出している箇所でこのように記載して、
あとは<v-text-field>にもクラスを追加するだけだと一瞬錯覚するでしょう。ですが、思い出してください。
<v-text-field>というのはDIVに囲まれた奥深い場所にinputがあるということを。
すなわち、ここで定義したクラスをそのまま<v-text-field>に記述しても、
有効にさせたいinputには届かないのです。なので以下のようにコッソリとinputを添えてあげましょう。
input要素を指定Vue.use('vue-shortkey', { prevent: ['.my-class-name input'] })こうすることで特定のクラス下にあるinputに対して、ショートカットキーが無効になります。
プラグインの呼び出し箇所を上記のように変更し
あとは<v-text-field>にもクラスを追加するだけで完成です。結果
フォーム画面の項目入力時に他のショートカットキーが呼び出されないようになりました。
- 投稿日:2019-12-13T21:24:52+09:00
Vue.jsで使える日付選択ライブラリ(vuejs-datepicker)
概要
Vue.js で開発しているときに、日付選択のライブラリで使いやすいのがないかなと
探していた時に出会ったライブラリ。環境
- Vue.js 2.6.10
- vuejs-datepicker 1.6.2
ライブラリ導入
以下のコマンドでインストールできる
npm install vuejs-datepicker --save使い方はこんな感じ
<template> <div id="app"> <h1>vuejs-datepicker</h1> <vuejs-datepicker :language="ja"></vuejs-datepicker> </div> </template> <script> import vuejsDatepicker from "vuejs-datepicker"; import { en, ja } from "vuejs-datepicker/dist/locale"; export default { name: "app", components: { vuejsDatepicker }, data() { return { en, ja }; } }; </script> <style> </style>表示言語はpropsの指定で変更することができる(上記の例は日本語版を利用)
まとめ
Vue.js に特化した日付選択ライブラリの紹介でした。
探しているときに思ったのが、こういったライブラリはやっぱり jQuery を使っているライブラリが多いなと感じました。
- 投稿日:2019-12-13T21:02:17+09:00
Vue-multiselect デフォルトラベルを共通で変更する方法
概要
Vue.jsで開発するときによく使わせていただいている Vue-multiselect というセレクトボックスのライブラリがあるのですが、
デフォルトのままで使うと
のように、プレースホルダーなどが英語で標準的に設定されてしまっている。
propsnに値を設定すると変えられるので問題はないのですが、複数箇所で使うとその設定を全部にするのが面倒。そして時々忘れてしまう。。。
そこで、ライブラリの表示などを初期設定したコンポーネントを作ればいいと思い、
実際に実装したときのメモ。環境
- Vue.js 2.6.10
- Vue-multiselect 2.1.4
ライブラリ導入
以下のコマンドでインストールできる
npm install vue-multiselect --save使い方はこんな感じ
<template> <div id="app"> <h1>Vue-multiselect</h1> <multiselect v-model="value" :options="options"></multiselect> </div> </template> <script> import Multiselect from "vue-multiselect"; import "vue-multiselect/dist/vue-multiselect.min.css"; export default { name: "app", components: { Multiselect }, data() { return { value: undefined, options: ["data1", "data2", "data3"] }; } }; </script> <style> </style>このままだと、最初の画像のようにプレースホルダーやらが英語のまま出てきてしまう。
Propsで変更できるのだが、使うところ全部で設定するのは面倒。。。ラベル設定共通化(コンポーネント化)
以下のようなコンポーネントを作って、そのコンポーネントをアプリ内で使うようにすればよい。
customSelect.vue<template> <div> <multiselect :options="options" v-model="selected" :multiple="multiple" :disabled="disabled" :placeholder="_settings.placeholder" :selectLabel="_settings.selectLabel" :deselectLabel="_settings.deselectLabel" :selectedLabel="_settings.selectedLabel" @select="select" @remove="remove" > <template slot="noResult"> <span>{{ _settings.noDataLabel }}</span> </template> <template slot="noOptions"> <span>{{ _settings.noDataLabel }}</span> </template> </multiselect> </div> </template> <script> // Library import import multiselect from "vue-multiselect"; import "vue-multiselect/dist/vue-multiselect.min.css"; const defaultSettings = { placeholder: "", selectLabel: "", deselectLabel: "", selectedLabel: "", noDataLabel: "対象データがありません" }; export default { props: { value: { type: null }, options: { type: Array, default: () => [] }, multiple: { type: Boolean, default: false }, disabled: { type: Boolean, default: false }, settings: { type: Object, default: undefined } }, components: { multiselect }, data() { return { _settings: undefined }; }, created() { this._settings = Object.assign({}, defaultSettings, this.settings); }, computed: { selected: { get() { return this.value; }, set(val) { this.$emit("input", val); } } }, methods: { select(selectedOption, id) { this.$emit("select", selectedOption, id); }, remove(selectedOption, id) { this.$emit("remove", selectedOption, id); } } }; </script> <style> </style>上記のコンポーネントを呼び出すようにすると、
プレースホルダーのラベルなどがコンポーネントの設定になっている。このようにしておくと、propsで設定を渡すことで、
呼び出す箇所によってプレースホルダーのラベルを変えられたりなどもできる。まとめ
部品をコンポーネント化して使うっていうのは、Vue.jsの実装でわりと基本的なことかもしれないが、
必ず使う場面が出てくると思うので、覚えておいて損はないと思う。あと、このライブラリ( Vue-multiselect )は使いやすく機能も豊富なので、おすすめです。
- 投稿日:2019-12-13T20:59:14+09:00
vue+akitaでのステート設計パターン
Akita とは
AkitaはDatoramaというセールスフォースに買収されたマーケティング会社に開発されてる。
Rxjsで作られた、Angular,React,Vueなどでのステート管理ライブラリ。
※ちなみにchrome extensionのredux-devtoolで状態を追える。
詳しいコンセプトはこちら2つのStore
Store
Storeはストアの状態を管理する単一なObjectなどを入れるところ。基本的にはデータなどは入れるべきじゃない。
EntityStore
EntityStoreはデータなどを入れるとCollectionとして管理してくれる。
Storeへのアクセス
基本的には以下の流れ。
- ComponentからServiceを介してStoreを更新
- ComponentからQueryでStoreを取得
normalizr+reduxのようなステート設計
ここではredux+normalizr+immutable?のようなentityとidを分けて保管して、表示するidだけを他で管理するようなUser一覧を作る。
- Normalizrを使用したReduxの実装パターン細かいところはGithubに上がってるので、ソースコード読んでほしい。
1.UserListコンポーネントは表示する情報を管理するStoreを持つ。
2. APIの結果となるUser情報を管理するEntityStoreを持つ。
APIで情報を取得する度にここに蓄えて、上記のStoreも更新する。
3.Storeをwatchして、画面に表示するUserListを更新する。こうすることでEntityStoreはフロントエンドでのキャッシュにもなるし、データ構造自体も理解しやすくなるかもしれない。。。と思った。でも、もう少しStoreとEntityStoreは疎結合にすべきか
![]()
Github
- 投稿日:2019-12-13T18:37:23+09:00
Vue.jsで引数をうまく渡せず詰まった時の話〜引数分割束縛〜
やりたかったことと詰まった背景
Vue.jsでフロントエンドの実装。一覧表示の画面から1つのアイテムをクリックすると、その詳細ページに飛べるように実装したい。
詳細画面のアイテムの情報自体はサーパーサイドからJSON形式で送られてくる。その送られてきた情報が載っているURL+クリックしたアイテムのidを詳細画面のURLにしたい。
上記の理由より、URLとクリックされたアイテムのidとを引数として関数に持たせようとしたが、ただカッコの中に引数を渡すだけではうまくいかなかった。
調べて出てきた下記の、引数分割束縛という書き方で実装しようとした。
関数(引数が2つ以上の時の書き方)
//波カッコに入れる。 event({第一引数, 第二引数, 第三引数, 第四引数})引数分割束縛。
引数を波括弧で囲って使用する。以下今週一番躓いたところ。
// view/detail.vue methods: { ...mapActions([ 'get_detail_ajax' ]) }, mounted() { this.get_detail_ajax(url, this.id).then(()=>{ console.log(url,this.id) //←ここではちゃんと値が取れてる。 this.detail = this.$store.detail; }); }// store/index.js actions: { get_detail_ajax(context,{ url, id }) { console.log(url, id); //両方ともundefinedに。 return axios.get(URL_BASE + url + id, { headers: { "Content-Type": "application/json", "Authorization": 'Bearer ' + token }, responseType: 'json', }) .then((res) => { Vue.set(this, 'detail', res.data); }) }, }contextを抜くかもしくは引数に波括弧を使わなければちゃんと値を取得できたが、contextがないといらない情報もたくさん渡ってきてしまうし、個別ページなのでidもURLに含めたい。
(var url="http://〇〇..."と変数を宣言するときに、:idをどうにか含めようとしましたがエラーに。式展開してもダメでした...)苦肉の解決策
無理やりくっつけて引数を減らしました。
// view/detail.vue methods: { ...mapActions([ 'get_detail_ajax' ]) }, mounted() { var id = this.id; url = url + id //再代入してURLにidを含ませる。 this.get_detail_ajax(url).then(()=>{ this.detail = this.$store.detail; }); }// store/index.js get_detail_ajax(context, url) { return axios.get(URL_BASE + url, { headers: { "Content-Type": "application/json", "Authorization": 'Bearer ' + token }, responseType: 'json', }) .then((res) => { Vue.set(this, 'detail', res.data); }) },なぜ波括弧を使うと取得できないのか、また判明すれば追記いたします。
追記
親切な方がコメント欄で丁寧に教えてくださったのでメモ。
引数を引数分割束縛で持たせる場合、同じオブジェクト内のものしか持たせることができない。
// view側 var hoge = "第一引数" var huga = "第二引数" var moge = "第三引数" hogeHugaMoge (hoge,huga,moge)とある場合、同じオブジェクトではないので
// store側 hogeHugaMoge ({hoge,huga,moge}){ console.log(hoge,huga,moge); //エラーとなる }上記のようには使用できない。
なので下記のように1つのオブジェクトにまとめて入れて...
// view側 var hogehugamoge = { hoge: "第一引数", huga: "第二引数", moge: "第三引数" } hogeHugaMoge(hogehugamoge)// store側 hogeHugaMoge ({hoge,huga,moge}){ console.log(hoge,huga,moge); //第一引数、第二引数、第三引数がちゃんと渡ってくる! }成功!
Vue.jsのactionでは指定できる引数は1つ迄のようです。
上記の方法でちゃんと実装できました!コメントいただき本当にありがとうございました!
- 投稿日:2019-12-13T18:31:25+09:00
フロントVue.js、サーバーサイドLaravelの実装方法
概要
1つのアプリを、フロントはVue.js、サーバーサイドはLaravelで作成し、Vue.js側でサーバーサイドをAPIとして引っ張ってきて使用したい。
axios
store/index.jsimport Vue from 'vue' import Vuex from 'vuex' import axios from 'axios' Vue.use(Vuex) const URL_BASE = '全ページ共通のホスト部分URL'; var token = "生成されたトークン"; //新しいVuexを生成 export default new Vuex.Store({ state: { list: [] }, actions: { //このcontextがないと、urlが上手く渡って来ないことがある。 get_ajax(context, url) { //変数urlはviewで定義する return axios.get(URL_BASE + url, { //このheadersは、認証が必要なページの時に記載する。 headers: { "Content-Type": "application/json", "Authorization": 'Bearer ' + token }, responseType: 'json', }) //resに送られてきたデータが入っている。 .then((res) => { //下記の記載で、ここの配列listにres.dataをsetしている。 Vue.set(this, 'list', res.data); }); }, } });views/index.js//ここら辺は今回関係ないとこ <template> <div> <Template/> </div> </template> <script> //これは今回関係なし。 import Template from "@/common/Template.vue" //vuexのmapActionsが使えるようになる。 import { mapActions } from 'vuex' var url = 'このサーバーサイドで実装した、このviewページのurl'; export default { data() { return { list: [] } }, //ここら辺も今回関係なし components: { Template }, //mapActionsにstoreで定義したget_ajaxが使えるように記載する。 methods: { ...mapActions([ 'get_ajax' ]), }, //asyncとawaitで非同期通信となる。 async mounted() { await this.get_ajax(url).then(()=>{ //ここの記載でdataの中の配列listにstoreで定義されたlistを代入している。 this.list = this.$store.list; }); } } </script>
- 投稿日:2019-12-13T18:24:10+09:00
[Vue.js] v-modelのinputの処理を間引く
v-modelでの入力する際のinput処理を間引く
input時に動く処理が多くて重くなってしまったので、間引けないか考えた時のメモ
※class styleで記述しているので注意setterが動く際にlodashのdebounceを使って間引くようにする
<template> <textarea v-model="text" /> </template> <script lang="ts"> import _ from 'lodash' import { Component, Vue } from 'vue-property-decorator' @Component export default class DebounceTextArea extends Vue { public _text: string = "" public get text(): string { return this._text } // debounceの返り値がFunctionなのでmethodとして定義しておく public debounceTextSetter = _.debounce((text: string) => { this._text = text }, 1000) public set text(text: string) { // debounceで入力が終わってから値が設定されるようにする this.debounceTextSetter(text) } } </script>
- 投稿日:2019-12-13T15:28:55+09:00
二重中括弧 {{ }} 内でできること ❏Vue.js❏
開発環境はJSFiddleで!
https://qiita.com/ITmanbow/items/9ae48d37aa5b847f1b3b① data内のプロパティを直接書き込んで表示させることができる
html<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script> <div id="app"> <p>{{ message }}</p> </div>javascriptnew Vue({ el: "#app", data: { message: "hello world!" } })【出力結果】
hello world!
② javascript式を書くことができる
html<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script> <div id="app"> <p>{{ message }}</p> <p>{{ number + 5 }}</p> </div>javascriptnew Vue({ el: "#app", data: { message: "hello world!" number: 3 } })【出力結果】
hello world!
8
③ 三項演算子を書くことができる
html<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script> <div id="app"> <p>{{ message }}</p> <p>{{ number + 5 }}</p> <p>{{ ok ? "YES" : "NO" }}</p> </div>javascriptnew Vue({ el: "#app", data: { message: "hello world!" number: 3 ok: true } })【出力結果】
hello world!
8
YES
④ メソッドを書くことができる
html<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script> <div id="app"> <p>{{ message }}</p> <p>{{ number + 5 }}</p> <p>{{ ok ? "YES" : "NO" }}</p> <p>{{ sayHi() }}</p> </div>javascriptnew Vue({ el: "#app", data: { message: "hello world!" number: 3 ok: true }, methods: { sayHi: function() { return "Hi"; } } })【出力結果】
hello world!
8
YES
Hi
ではまた!
- 投稿日:2019-12-13T14:50:23+09:00
create-nuxt-app で利用できる UI フレームワークを比較する
この記事は Nuxt.js Advent Calendar 2019 の 13日目の記事です。
皆さんは普段、Nuxt.js で開発するときに UI フレームワークを使っていますか?
create-nuxt-app をするときに、気になっている UI フレームワークはあれど、使ってみたことがないものが大半だったので、今回は UI フレームワークを使ってアドベントカレンダーを作ってみました。UI フレームワーク一覧
公式サイトの「好きな UI フレームワークを選択します」という項目で紹介されています。
- BootstrapVue
- Vuetify.js
- Bulma
- Tailwind CSS
- Element UI
- Ant Design Vue
- Buefy
- iView
- Tachyons
ドキュメントを見ると全部で 9つあります(2019/12/13 現在)
比較ポイント
なるべく個人の主観が入らないように、比較ポイントを明記しておきます。
GitHub Star 数
![]()
スターの数が多いほど、古くからあり、多くの人に利用されているということなので、一基準として載せておきます(2019/12/13 現在の数字です)。
成果物
![]()
各 UI フレームワークで作ったアドベントカレンダーです。
GitHub のリポジトリを Netlify でホスティングしています。ちなみにカレンダーは全てレスポンシブ対応していません。
レスポンシブにする工数がなかったのと、フレームワークによってはレスポンシブ対応していないため、コンポーネントや要素ごと表示・非表示で切り替えることになり、実装の参考にならなさそうだと思ったからです。
デスクトップサイズの画面でご覧ください。公式ドキュメントの読みやすさ
![]()
これが読みにくいと開発速度に関わってくるため、公式ドキュメントの読みやすさ(情報の調べやすさ)も評価に入れています。
カレンダーを作ったときに調べやすかった点・調べにくかった点など。所感
![]()
カレンダーを作ってみての所感。個人の感想です。
比較してみた。
BootstrapVue
Vue.js で使える Bootstrap フレームワーク。
プラグインやカスタム UI コンポーネントが用意されています。GitHub Star
![]()
10,536 Star
「CSS フレームワークといえば Bootstrap」と言われるくらい有名で、昔からあるフレームワークなので、BootstrapVue もスター数が多いです。
成果物
![]()
- https://advent-calendar-2019-bootstrap.netlify.com/
- https://github.com/ryamakuchi/advent-calendar-2019-bootstrap
いかにも Bootstrap なデザインのカレンダーが出来上がりました。
公式ドキュメントの読みやすさ
![]()
作るときは、Bootstrap のドキュメントもあわせて読みます。
UI コンポーネントを使う場合は BootstrapVue のドキュメントを読むのですが、CSS クラスを指定して使いたい場合は Bootstrap のドキュメントに情報が書いてあるので、そちらを読みます。ドキュメント自体の読みやすさは普通です。
所感
![]()
作っているときに、「カレンダーは 7カラムなのでレイアウトグリッドシステムだとカラムがきれいに整わない...
」となりました。
テーブルで作ることも検討してみたのですが、BootstrapVue の場合はテーブルよりもレイアウトグリッドシステムのほうがビジュアルデザイン的にカレンダーっぽくなるなと思ったので、レイアウトグリッドシステムで組んでカラムの CSS を上書きしています。
作り終えてビジュアルデザインを見たときに、やはり「Bootstrap 感」は出ているな、と感じました。
ビジュアルデザインだけで言うと、もう少しモダンな UI フレームワークがあるので、ビジュアルデザインを重視するようなアプリケーションでない場合(管理画面など)に使うと良さそうです。Bootstrap に慣れている人が Vue.js でアプリケーションを作るときとかに最適かなと思います。
Vuetify.js
Vuetify はマテリアルデザインの UI フレームワークです。
マテリアルデザインのためモダンな UI と相性が良く、プレミアムテーマなどもあり、多くのコンポーネントが用意されています。また、他フレームワークとの比較がある Why Vuetify? がとても参考になります。
GitHub Star
![]()
22,887 Star
BootstrapVue よりもスター数が多いです。多くの企業でも採用例があるみたいなので、有名なのですね。
成果物
![]()
- https://advent-calendar-2019-vuetify.netlify.com/
- https://github.com/ryamakuchi/advent-calendar-2019-vuetify
なんと、Vuetify は Calendar component が用意されていました!!!(これはすごい)
早速使ってみて、Google カレンダーのような UI のアドベントカレンダーが出来上がりました。
少々画面の描画に時間がかかっている気がしますが、Calendar component の API を見るに色々な使い方ができそうでワクワクします。公式ドキュメントの読みやすさ
![]()
読みやすいです。使いたいコンポーネントがあるかをヘッダーの Search からも左側のナビゲーションからも検索でき、API の情報も分かりやすく書いてあるので、公式サイトで調べるだけで完結します。
所感
![]()
「マテリアルデザインで Vue.js アプリケーションを作りたい」というときには、もう一強なんじゃないでしょうか。
日本語で紹介されている記事も多く、コンポーネントや API も豊富なので、機能の多いアプリケーションであっても作りやすいかなと思いました。Bulma
Bulma は 100% レスポンシブでモダンな CSS フレームワークです。
CSS オンリーのため、UI コンポーネントは提供しておらず、インタラクションなども自分で組んでいける柔軟なフレームワークです。GitHub Star
![]()
37,723 Star
Bulma は純粋な CSS フレームワークなので、Vue だけでなく React や Ruby on Rails などでも利用できる、人気のフレームワークです。
成果物
![]()
- https://advent-calendar-2019-bulma.netlify.com/
- https://github.com/ryamakuchi/advent-calendar-2019-bulma
Table で組んでいます。
CSS を組むのはとても簡単に分かりやすくできたのですが、これを作るときもぱっと見で理解しづらいロジックを組んでいたりと、可読性はあまり良くありません
ご了承ください。
公式ドキュメントの読みやすさ
![]()
Bulma の公式ドキュメントは少しクセがあるため、読みづらいです。
ページの階層が 4階層あったりと(Table のドキュメントなど)、どこにどんな情報があるか分からないうちは情報にたどり着くまでが大変です。また、一番読みづらい理由が、ドキュメント全体に対する検索機能が用意されていないことです。
そのため、Bulma で何かを調べたいときは「Bulma Table」というような単語でググって出てきた公式ドキュメントで情報にたどり着くほうが早いです。所感
![]()
Bulma のビジュアルデザインも「イマドキ」な感じで好きです。
個人的には Nuxt というよりも、モノリシックな Rails で何かを開発するときに、Bootstrap を使うんだったらビジュアルデザインの良い Bulma をオススメするよ、というような立ち位置にいます。Tailwind
Tailwind は、カスタムデザインをすばやく構築するための、ユーティリティファーストな CSS フレームワークです。
Why Tailwind? に書いてあることを抜粋すると、
Most CSS frameworks do too much.
他の CSS フレームワークはあまりにも多くのことを行います。Tailwind は、設計されたコンポーネントはありますが、低レベルのユーティリティクラスが用意されているため、HTML を離れることなくカスタムデザインを構築できます。
GitHub Star
![]()
17,559 Star
20k はいかずとも、BootstrapVue よりもスター数が多く、有名であることが分かります。
成果物
![]()
- https://advent-calendar-2019-tailwind.netlify.com/
- https://github.com/ryamakuchi/advent-calendar-2019-tailwind
よく Tailwind の話題が巷で上がってくると思うのですが、確かに CSS のクラスを指定するだけなので柔軟性がとても高く、開発しやすいです。
tailwind.css は SPA 屋がもつ「なんで JavaScript Framework 世界でコンポーネント書いてんのに CSS in JS したり対応するクラス名を制定してそこに CSS を 1:1 で書かないといかんねん」みたいな感情をほぼ完璧に解消している
— Takuma Hanatani (@potato4d) January 20, 2019公式ドキュメントの読みやすさ
![]()
ヘッダーに大きめの Search があり、検索しやすいです。
右側のナビゲーションからも調べられますが、項目が多いので、主に CSS のプロパティ名で Search から検索するほうが楽に情報にたどり着けます。所感
![]()
Tailwind を使うと、UI コンポーネントを使っているときに大変な「UI コンポーネントの API の使い方を知るためにドキュメントを読む」というコストがほぼなくなり、コーダーとしてはとても楽ちんに開発することができます。
その代わり、Tailwind は class の指定がとても長くなり、セマンティックではないというところが気になっています。
普通の CSS を書くときは、.semantic {}
に詳細な CSS プロパティを指定してるため HTML が読みやすいのですが、 Tailwind は class 一つ一つがプロパティと対応しているため、ぱっと見でどんな意図の CSS がついているのかが分かりづらくなっています。そういえば、CSS Advent Calendar 2019 - Qiita の、21日目の記事がちょうど Tailwind の記事なので、楽しみです
Element UI
Element UI はデスクトップ UI ライブラリです。(正式名称は Element ですが、 Element UI と呼ばれているようです)
デスクトップ UI のため、レスポンシブには対応していません。
GitHub Star
![]()
42,882 Star
今のところ Star 数が一番多いです。
成果物
![]()
- https://advent-calendar-2019-element-ui.netlify.com/
- https://github.com/ryamakuchi/advent-calendar-2019-element-ui
Table コンポーネントを使って作成しています。今回はあまり時間がなかった(1週間前から 9カレンダー作り始めた)ため、ちょっと読みづらいコードになってしまっています。。
computed を使ったり、子コンポーネントを使ったりして設計を良くすればもう少し読みやすくなるかと思います。今回はメンテナンスする気がないので可読性を無視した実装になっています公式ドキュメントの読みやすさ
![]()
とても読みやすいです。最初に Guide に目を通すと設計思想が見えてきます。
Component では Search 検索とナビゲーション検索ができ、SPA でページ遷移が速いです。所感
![]()
個人的には、この色素の薄いビジュアルデザインがとても気に入っています。
カレンダーでは使われていませんが、ドキュメントにある各 UI コンポーネントのインタラクションが良いなと感じました。イマドキな感じのデザインで、かつ Form 機能などがある Web サイトにぴったりだと思います。
ちなみにデスクトップ UI のため、レスポンシブには対応していないと書きましたが、Element UI では
.el-foo
のような形で要素に class がつくので、class を上書きや追加してレスポンシブ化することも可能です。
モバイルファーストではないやり方ですが、カスタマイズしてレスポンシブ対応することも不可能ではないと思いました。Ant Design Vue
Vue.js で使える Ant Design フレームワーク。
Ant Design は Angular や React でも使うことができ、きちんと考え抜かれた設計思想と設計リソースがあるのが特徴です。GitHub Star
![]()
8,897 Star
Ant Design 自体が比較的新しいため、Star 数は 10k 超えていません。
成果物
![]()
- https://advent-calendar-2019-ant-design.netlify.com/
- https://github.com/ryamakuchi/advent-calendar-2019-ant-design
今回は試しに card コンポーネントで作ってみました。
(テーブルで作っても良かったのですが、ちょっと変わったことがしてみたかったのでした)
シンプルで色素の薄いカレンダーが出来上がりました。
公式ドキュメントの読みやすさ
![]()
こちらも Element UI と同じく、とても読みやすいです。
Search 検索とナビゲーション検索ができ、右カラムには現在読んでいる地点の Index が表示されています。また、Ant Design 自体の公式ドキュメント の Guidelines がとても興味深く、デザインを勉強するときの参考になりそうだなと思いました。
所感
![]()
作ってみた感想は、Element UI ととても似ているなと思いました。
用意されているコンポーネントの種類や、管理画面向きのデザインでレスポンシブ対応がされていないところなどもよく似ています。ビジュアルデザインとしては Element UI よりも余白が広めに設計されていて、よりシンプルで洗練されている印象です。
カレンダーだけでは伝わりづらいのですが、Card コンポーネントを Element と比較するとビジュアルデザインの違いが分かりやすいかと思います。Buefy
Vue.js で使える Bulma フレームワークです。
CSS フレームワークの Bulma に、UI コンポーネントを足したようなものが Buefy で、軽量なフレームワークです。GitHub Star
![]()
6,710 Star
スターは少なめです。
成果物
![]()
- https://advent-calendar-2019-buefy.netlify.com/
- https://github.com/ryamakuchi/advent-calendar-2019-buefy
Bulma で作ったアドベントカレンダーと比べると、ほとんど色くらいしか違いません。
(ちなみに primary color はデフォルトで設定されているものを使っています)Table コンポーネントで組んでおり、こちらも UI コンポーネントに props でデータを渡していたりと、ロジックがちょっと分かりづらくなっています
このため、実装にかかった時間でいうと Bulma よりも時間がかかりました。公式ドキュメントの読みやすさ
![]()
Buefy のドキュメントは Bulma と比べると読みやすいです。
ただ、Bulma と同様に検索機能がないため、何か UI コンポーネントを探したいというときはナビゲーションカラムから探すのが早いです。
とはいえ Element UI や Ant Design Vue と比べると、若干情報を探しにくく感じました。所感
![]()
Vue.js で Bulma ライクなビジュアルデザインにしたいときに、Buefy が候補に挙がってくるかと思います。
Bulma を使ってコンポーネントを自作するよりも、Buefy が用意しているコンポーネントを使いたいというときに利用すると良さそうです。iView
Vue.js で使える高品質な UI ツールキットです。
GitHub Star
![]()
22,875 Star
BootstrapVue や よりも Star が多く、Vuetify とほぼ同じくらいのスター数です。
正直日本ではあまり聞いたことがないフレームワークだったのですが、スター数から人気のフレームワークだということが分かりました。成果物
![]()
- https://advent-calendar-2019-iview.netlify.com/
- https://github.com/ryamakuchi/advent-calendar-2019-iview
iView は初めて使ってみたのですが、正直あまり Element UI との差が分かりませんでした...
(少し文字の大きさが小さいかな?くらい)最初 Table コンポーネントで実装を試みたのですが、少し複雑で子コンポーネントに切り出して実装する必要がありそうだったので、時間がなかったので諦めて Card コンポーネントで実装しました。
公式ドキュメントの読みやすさ
![]()
普通に読みやすく、全体検索とナビゲーションからの検索ができます。
ドキュメントのほうも Element UI や Ant Design Vue と似たようなビジュアルです。所感
![]()
技術選定の際に気にすることとして、「作りたいアプリケーションに備える(もしくは今後備えそうな)機能」をまず考え、それから UI フレームワークを選定するかと思います。
Element UI、Ant Design Vue、iView はそれぞれ似ているのですが、微妙に使いたいコンポーネントの API や提供しているコンポーネントが違ってきます。
そのため選定する前に使いたいコンポーネントのドキュメントを読んでみてから比較するのが良いと思います。
参考記事:管理画面向けのVue.jsのUIフレームワーク、iViewについて - JX通信社エンジニアブログ
Tachyons
Tachyons は UI コンポーネントを用意したフレームワークではなく、CSS ツールキットです。
できるだけ軽量な CSS で、高速に読み込みレスポンシブなページを作成できます。ちなみに Tachyon とは、超光速で動くと仮定されている粒子のことで、速さを意識した CSS ツールキットなのかなと思いました。
タキオン - WikipediaGitHub Star
![]()
9,404 Star
同じくスタイルだけを提供する CSS フレームワークの Tailwind CSS と比べるとマイナーで、スター数も 10k 超えていません。
(ちなみに Qiita には一応 TACHYONS タグはありました)
成果物
![]()
- https://advent-calendar-2019-tachyons.netlify.com/
- https://github.com/ryamakuchi/advent-calendar-2019-tachyons
「あれっ、意外と使いやすい!すぐできた
」って所感でした。
UI フレームワークの複雑なドキュメントを読まずに、自分で柔軟に作っていく感じです。Tailwind CSS に近いイメージで、CSS を指定して使います。
HTML 要素としては div や article で組みました。ただし、こちらも カラムを 7等分にすることができなかったので、そこは自分で CSS を書きました。
.calendar-item { width: calc(100% / 7); }公式ドキュメントの読みやすさ
![]()
Table of Styles(各クラスのリストと対応するスタイルの一覧)ページが、びっくりするほど読みやすかったです。
このページだけでいうと、紹介してきた UI フレームワークの中で一番ラクに調べることができました。
調べるときのワークフローとして、何かを「検索」すると思いますが、Tachyons ではこのページを開いて、⌘ + F
でページ内検索して調べます。
大抵レイアウトを組むときは CSS プロパティを調べたりしますが、例えばtransition
とかlink
とかで検索すると.link
というクラスが出てきて、やりたいことに近いクラスが一発で分かります。所感
![]()
初めて使ってみたのですが、意外と好きだなという印象です。
シンプルに CSS のクラスを指定するだけでやりたいことが実現できるので、柔軟性の高いフレームワークです。
TACHYONS - Gallery というページに Tachyons で作られたサイトの一覧があるので、様々なサイトを手軽に作れる良いフレームワークなんだな、と思いました。使ったことがなかったけれど、使ってみて案外良かったフレームワークを知れた良い機会でした
Framevuerk(かくれキャラ)
あれ?公式サイトには載っていないけれど、
create-nuxt-app
するともうひとりいる?Framevuerk です。
ちょっと今回は時間が足りなかったのでカレンダーは実装していませんが、頭の片隅にとどめておきます。興味のある人はカレンダーでも実装してみてはいかがでしょうか?(編集リクエストお待ちしています!)
まとめ
create-nuxt-app
から利用できる UI フレームワークは、主に 2種類に大別できそうです。
- UI コンポーネントを提供するフレームワーク
- Element UI、Buefy など多数
- CSS のクラスを提供するフレームワーク
- Tailwind CSS、Tachyons、Bulma
それぞれのメリットデメリットとして、
- UI フレームワーク
- メリット
- オールインワンに UI コンポーネントを提供しているため、使いたい UI とドンピシャなものが見つかれば、それに沿って作るとだいぶ工数を削減できる
- デメリット
- 通常の UI コンポーネントの使い方からは少し逸れた使い方をしたいときに、ソースを読んで、実装を上書きする必要がある
- CSS フレームワーク
- メリット
- 複雑な UI だったとしても柔軟にコーディングすることができる
- デメリット
- アニメーションや状態変化など、コンポーネントのロジック部分を自前で実装する必要がある
という点が挙げられます。
また、今回の発表の元ネタであるおいちゃんさん( @inouetakuya )の LT はとても参考になりました。
create-nuxt-app で選べる現代の UI フレームワーク / Modern UI Frameworks - Speaker Deck
こちらでも取り上げられていたように、一部のスタイル / コンポーネントのみ利用可にできるフレームワークもいくつかあるので、気軽にフレームワークを使ってみるのも面白いかなと思いました。
以上、長々とありがとうございました!よいクリスマスをお過ごしください!
![]()
- 投稿日:2019-12-13T14:14:49+09:00
Vue.jsでのDryなエラーハンドリングと排他処理
株式会社SKILLでエンジニアをしている田坂です。
今回は弊社で開発している職歴BANKという職歴サービスで
使用しているVuejsの実装の一部を紹介したいと思います。1. 概要
- 既存の例外ハンドリング手法をまとめてProsConsを言及
- 例外ハンドリングをDryかつ、既存コードのロジックの変更をできるだけ抑えて実現した
- ついでにUI操作における排他処理機能を実装した
1.1 アプリケーションのシチュエーション
左の様な画面でユーザがフォーム送信などの操作をした時に、エラー画面に遷移させずその画面のままで
何が起きて何をすべきかを例えば右の画面の様にユーザに伝えたい状況について考えていきます。なぜ画面そのままか → 画面が変わってしまうと、フォームの内容が消えるので、UXが悪くなりこれを回避するためです。
コードでいうと以下の様なactionを呼んで、
Store.create(this.form)
で例外が発生するとします。export default class Form extends Vue { async createSummary() { await Store.create(this.form) } }上の状況では、例えば以下の様な例外は想定できると思います
- バリデーションなどの個別機能に関するエラーが返ってくる
- セッションエラーなどのアプリケーションにおける根幹のステート変更やユーザからの追加アクションが必要になる。その場合もう一度ログインしてもらう必要があります
Vueコンポーネント側でハンドリングしなかった場合
Vueコンポーネント側でcatchしなかった場合、Vuejsが元々持ってる
handleError
が呼ばれて以下のデフォルトエラー画面が表示されます。
コンソールのログ
vendors.app.js:35776 error TypeError: "a" is not a constructor ....(省略) (anonymous) @ vendors.app.js:35776 push../.nuxt/client.js.vue__WEBPACK_IMPORTED_MODULE_17__.default.config.errorHandler @ app.js:563 globalHandleError @ commons.app.js:12305 handleError @ commons.app.js:12274 (anonymous) @ commons.app.js:12291 Promise.catch (async) invokeWithErrorHandling @ commons.app.js:12291 invoker @ commons.app.js:12614 original._wrapper @ commons.app.js:173372. 解決法
ここで検討する 解決法一覧
- シンプルにtry-catch
- then-catchのメソッドチェーン
- window.onerror
- errorCaptured
- Vue.config.errorHandler
- 私たちが使用している解決法
前提として、ケースバイケースだったりするので一概にこれがいいってのはないです。
自分の環境にマッチしたソリューションを選択してください。2.1 解決法1 シンプルにtry-catch
コード例
export default class Form extends Vue { async createSummary() { try { await Store.create(this.form) } catch(e) { if(e.errorCode === 'session_expired') { // storeからセッション情報クリアしてログイン要求したい } else if(e.errorCode === 'illegal_argument') { // 入力値異常 } else if(e.errorCode === 'network_error') { // 通信環境がいいとこで再度実行してくださいなど表示したい } } } }Pros
誰しも理解できて直感的だと思います
Cons
Vueコンポーネント毎に同じ様なハンドリングを書かないといけなく。結構面倒。
個別機能に関するハンドリングだけならまだいいが、セッション系のハンドリングなどはメンテナンスする必要が出たら全域で改修が必要になってnot good.例外をハンドリングしようとしているのがこのactionでは
Store.create(this.form)
だけなのでまだこの量に収まっていますが
API呼び出しを複数発行していてかつハンドリング方法が違う場合はもっとハンドリングをふやす必要があります。2.2 解決法2 then-catchのメソッドチェーン
コード例
// 成功時呼ばれる関数 function onFulfilled(data) { console.log(data); } function onRejected(err) { console.log(err); } readFileAsync(module.filename) .then(onFulfilled, onRejected);引用(https://qiita.com/koki_cheese/items/c559da338a3d307c9d88)
この手法だと例外スロー時にonRejectedが呼ばれてそこで好きな処理を書くことができます。
Pros
Promiseでメソッドチェーンを多用してるプロジェクトには相性が良さそう
Cons
Prosの逆で、 Promiseをあまり定義してない状況からこの手法で実装すると変更すべき箇所が多く大変そう
2.3 解決法3 window.onerror
Browser側で実行されるjsの例外を拾ってくれます。
なのでServer側で実行される例外は拾ってくれません。
Vuejs使ってるのであれば、同等の挙動をする解決法5 Vue.config.errorHandler
を選択するのがいいかと思います。2.4 解決法4 errorCaptured
コード例
export default class Form extends Vue { async createSummary() { await Store.create(this.form) } errorCaptured(e, vm, info) { if(e.errorCode === 'session_expired') { // storeからセッション情報クリアしてログイン要求したい } else if(e.errorCode === 'illegal_argument') { // 入力値異常 } else if(e.errorCode === 'network_error') { // 通信環境がいいとこで再度実行してくださいなど表示したい } return false // ここで例外の伝搬をとめる } }
errorCaptured
を使うとVueComponent側で例外をハンドリングを共通処理として定義することができます。
falseを返せば伝搬はとまりますが、返さないと親コンポーネントに例外が伝搬します。ここで詳しく書かれてます。
Vue.jsのエラーハンドリングについて調べた件(前編)Prop
コンポーネント単位やコンポーネントツリー単位でハンドリング設計をしたい場合に有効な手段だと思います。
Cons
一つ一つtry-catchしてハンドリングするよりかは、ある程度まとまった単位でハンドリングできるので少しDryになりますが
どの機能や種類の例外をどこでハンドリングするかなどの全体の設計がむずかしそうですし、コンポーネントツリー単位でハンドリングする
実装にするとメンテナンスコストが高そう2.5 解決法5 Vue.config.errorHandler
コード例
Vue.config.errorHandler = function (e, vm, info) { if(e.errorCode === 'session_expired') { // storeからセッション情報クリアしてログイン要求したい } else if(e.errorCode === 'illegal_argument') { // 入力値異常 } else if(e.errorCode === 'network_error') { // 通信環境がいいとこで再度実行してくださいなど表示したい } return false }これはどのコンポーネントで例外が発生してもここでcatchするというものです。
https://jp.vuejs.org/v2/api/#errorHandler
false
を返すとここで例外が握り潰され、それ以外の場合はconsole.err
でエラーがコンソールに出力されます。Pros
個人的に他の手法と比べて一番シンプルに実装しやすいのかなと思います。
Cons
基本的にこれで事足りるのですが、SSRの時に少し困る場合があります。
SSR側で例外が起きた場合error
を呼ぶことができないため、エラーが起きたVueComponent
を表示せずエラー画面を表示するということはできません(間違ってたら教えてください)2.6 解決法6 私たちが使用しているソリューション
結論
decoratorを活用します。
decoratorでactionを囲って汎用的な例外をキャッチしたり汎用エラーエラーメッセージをUIに反映させます。以下はdecoratorの定義
descriptor(action)をtry-catchで囲んであげます。descriptorのはじめに、mutex Lockされてるかチェックして
Lockされていたら実行しない。Lockされてなければ、Lockしてaction実行終了後にLockを解除しています。export function AsyncRescuable(): (target: any, name: string, descriptor: PropertyDescriptor) => void { return function(target: any, name: string, descriptor: PropertyDescriptor) { const delegate = descriptor.value descriptor.value = async function() { // *1 例外をキャッチするためにtry-catchで囲む try { return await delegate.apply(this, arguments) } catch (e) { // *2 例外ハンドリングする await handleException(target, name, e, arguments) } } return descriptor } }セッション系の例外ハンドリングやブラウザレンダリングの時はエラーモーダルを表示するなどの処理はここでします。
async function handleException(target, name, e, args) { const message = getErrorMessage(e) if (hasSessionError(e.graphQLErrors)) { await AuthStore.logout() if (name === 'asyncData') { args[0].error(message) } else { ErrorModalStore.openErrorModal(message) } } else if (name === 'asyncData') { // SSRとその他で出し分ける必要がある。 args[0].error(message) } else { ErrorModalStore.openErrorModal(message) } }使うところ
export default class Form extends Vue { // *1 これをつけることで例外が起きたら任意のハンドリングを挟める @AsyncRescuable() async createSummary() { await Store.create(this.form) } }このdecoratorではあくまでも汎用的な例外処理しかしないので
機能毎で固有の例外処理は別途ハンドリング処理を実装する必要があります。
しかし、上での書いた通り、汎用的なセッションエラーやエラーメッセージをモーダルで表示したりするのはこの実装で対応できます。3. 排他処理
UIからユーザがボタンをクリックするとき二重クリックや一つのアクションをしている時に別のアクションを阻止させたい場合の解決法方の一つです。
上のdecorator実装を応用すると排他処理が割と簡単にかけます。
interface RescueableOptions { mutex: 'none' | 'lock' loading: boolean } export default function Rescuable( options: RescueableOptions = { mutex: 'none', loading: false } ): (target: any, name: string, descriptor: PropertyDescriptor) => void { return function(target: any, name: string, descriptor: PropertyDescriptor) { const delegate = descriptor.value descriptor.value = function() { if (options.mutex === 'lock') { if (MutexStore.isProcessing) return MutexStore.beginProcessing(options.loading) } try { const result = delegate.apply(this, arguments) MutexStore.endProcessing(options.loading) return result } catch (e) { MutexStore.endProcessing(true) handleException(target, name, e, arguments) } } return descriptor } } export function AsyncRescuable( options: RescueableOptions = { mutex: 'none', loading: false } ): (target: any, name: string, descriptor: PropertyDescriptor) => void { return function(target: any, name: string, descriptor: PropertyDescriptor) { const delegate = descriptor.value descriptor.value = async function() { if (options.mutex === 'lock') { if (MutexStore.isProcessing) { return } MutexStore.beginProcessing(options.loading) } try { const result = await delegate.apply(this, arguments) MutexStore.endProcessing(options.loading) return result } catch (e) { MutexStore.endProcessing(true) await handleException(target, name, e, arguments) } } return descriptor } }以下はdecoratorを使用する実装
decoratorの引数にoptionを渡します。export default class Form extends Vue { // mutex: lockで他の操作を無視 // loading: true でローディングアイコンだす @AsyncRescuable({ mutex: 'lock', loading: true }) async createSummary() { await Store.create(this.form) } }
createSummary
が終わるまでユーザは@AsyncRescuable({ mutex: 'lock'})
がついているactionは呼べない様に
することができます。4. まとめ
今回はいくつかの例外ハンドリングに関して考察し、decoratorでのハンドリングに提案をしました。
プロジェクトごとに適切なパターンは違うと思うのでフィットしそうなソリューションを採用してください。
また、この手法もまだまだ発展途上なのでインプットいただけると嬉しいです!5. おまけ
エラー画面をカスタマイズ
layouts/error.vue
を定義している場合デフォルトのエラー画面をカスタマイズすることができます
vuex-decoratorを使う場合
@Action
に{ rawError: true }
を渡さないとラップされた例外が返ってくるので注意@Action({ rawError: true }) public async fetch() { // hogehoge }6. 参考文献
Vue.jsのエラーハンドリングについて調べた件(前編)
今更だけどPromise入門
ユーザのブラウザで起きた JavaScript のエラーを収集する
Vue.config.errorHandlerはどこで発生したエラーをキャプチャできるのか
- 投稿日:2019-12-13T05:46:34+09:00
ポケモン剣盾の素早さ比較ツールをNuxt.jsで作成してNetlifyで公開するまで
GameWith AdventCalendar 2019 の記事です。
この内容はGameWithのコンテンツとは関係なく、個人的な趣味で作ったものです。はじめに
ポケモントレーナーの いのす(@inosy22)です!
ポケットモンスター ソード・シールド のランク戦を日々楽しんでいます。日頃から、ポケモンを育成する際に、色々な企業や個人の皆様が作っていただいているサイトを利用させていただいています。
そんな中、ポケモンにおいて重要な要素である 素早さ に対しては、自分にとって使いやすいと思えるツールが見当たらなかったので自作して公開しました!ポケモン剣盾の素早さ比較ツールを公開しました!
— いのす? (@inosy22_) December 9, 2019
『対戦中に即座に素早さを比較したい』
『仮想敵に対しての細かい素早さ調整をしたい』
という場合に使うことを想定して作ったのでよかったら使ってみてください!https://t.co/NioRCXgQTR↓実際に作成して公開したものです
https://pokemon-tools.netlify.com/speed-checker/
(↑ミミッキュはピカチュウ相手だと性格補正かかってないと素早さ負けちゃうことを確認しているgif)
こちらを作るにあたっての、企画 -> 実装 -> 公開 までの道のりを記載していきたいと思います。
前提
利用技術
コスト(お金も時間も)を控えめにするために以下の技術を採用しています。
Vuetify/Netlifyについては知識がなかったので勉強しながら、
Nuxt.jsは少し構築経験がありましたが、Vueに対しての知識は薄かったので、そこらへんも勉強しながらでした。この状態で、企画->実装->公開 の流れまでで、12時間程度でできたと思います。
ポケモンのドメイン知識
こちらについての詳細は他のサイトをご覧いただけるといいと思いますが、最低限本記事に関連しそうなものをざっくり説明をします。
種族値
: ポケモン種類ごとに定められているベースとなる能力値個体値
: ポケモン1匹ごとに与えられる能力値
- (例外を除いて、最高値のポケモンを使うことが前提)
努力値
: ポケモントレーナーが各種能力値を強化できる値
- ★素早さ調整において努力値の調整は重要
性格補正
: ポケモンの性格によって上下する倍率 (1.1倍/1倍/0.9倍)
- ★素早さ調整において性格補正の選択は重要
実数値
: レベル/種族値/努力値/個体値/性格補正から計算される値
- (ポケモンのステータスを見るとこの結果が表示されています)
ランク補正
: 「すばやさ が ぐーんと あがった」などの際の補正
- ★素早さ調整においてランク補正前提の調整をすることもある
その他補正
: 持ち物/状態異常/場の状態/特性 などでも変化します
- ★素早さ調整においてこれらの補正を前提に調整することもある
★マークがついている部分は今回の素早さ比較ツールで操作可能な値です。
(ポケモンはLv.50、個体値は さいこう のみの前提で進めます)
企画
主な利用想定ユーザーは 自分 です!(笑)
素早さ計算するだけのツールはいろいろあるのですが、
比較しながら細かい調整をするツールがほしいという欲望がありました。加えて、ポケモン剣盾から参入した新規ユーザーが、対戦中に素早さがわからないという話を、周りの人たち/実況動画/Twitter等で見かけました。
それを踏まえると、以下の方針が生まれました。
- 対戦中に即座に素早さ比較をしやすい
- 仮想敵に対して細かい素早さ調整がしやすい
これに従った、具体的な実装内容としては、以下の通りです。
- 相手のポケモンと自分のポケモンの2つの計算を行って比較する
- ポケモンを選ぶ際はインクリメンタルサーチで快適に
- デフォルト値は最速育成状態にして、その他の補正系の値はなし
- 努力値や補正値系はポチポチしてるだけで調整できる
- 条件を変更した際の結果をリアルタイムで見ることができる(リアクティブ!)
実装
全ての実装方法を細かく解説はしきれないので、今回の特徴的な実装の部分にフォーカスして取り上げます!
細かい内容が気になる場合は、こちらのリポジトリを見ていただければと思います。
https://github.com/inosy22/pokemon-tools今回は綺麗な実装というよりは素早く作り上げることを重視したので、コードは結構汚めです...。
最低限同じロジックを2度書かないようにする程度の調整はしてあります。種族値データを用意する
GameWithのAdventCalendarなので、こちらの記事を参考にさせていただきます!
【ポケモン剣盾】全ポケモン種族値ランキング【ポケモンソードシールド】 - GameWith※記事内容の商用利用、サーバーに負荷をかけることは利用規約違反になるのでやめましょう!
今回は商用利用でもなく、記事のコンテンツというよりは、ポケモンの普遍的なデータのみを抽出するだけです。
また、サーバーに負荷をかけずに取得します。Webページで先ほどのページを開いて、GoogleChromeのConsoleからJavascriptでDOMの中身を抽出することで、
ポケモン名をキーにして、バリューをポケモンの情報を持つオブジェクト(現状は素早さ種族値(s)のみ)を生成します。var pokeJson = {} $(".pokemonSS_table_scroll tr[data-col1]").each((key, val) => { pokeJson[$(val).attr('data-col1')] = { s: $(val).attr('data-col7') } }) console.log(JSON.stringify(pokeJson))Nuxt.jsプロジェクト構築
yarn create nuxt-app
コマンドを用いてプロジェクトを作成しました。create-nuxt-app v2.12.0 ✨ Generating Nuxt.js project in pokemon-speed-checker ? Project name pokemon-speed-checker ? Project description Pokemon speed checker for sword-shield ? Author name inosy22 ? Choose the package manager Yarn ? Choose UI framework Vuetify.js ? Choose custom server framework None (Recommended) ? Choose Nuxt.js modules (Press <space> to select, <a> to toggle all, <i> to invert selection) ? Choose linting tools ESLint, Prettier ? Choose test framework Jest ? Choose rendering mode Single Page App ? Choose development tools jsconfig.json (Recommended for VS Code)注目すべきところは、
Vuetify
を入れたことぐらいです。SPA(SinglePageApp) か SSR(Universal)にするかを選択しますが、今回は最終的に静的ファイルに落とし込む
nuxt generate
コマンドを利用するためどちらでもいいのかなと思います。CompositionAPIについて
また、今回利用しているVue.jsのバージョンは2系ですが、近い将来出るはずのVue.js3系のバージョンで標準装備されている
CompositionAPI
を勉強がてら利用してみます。これを利用することにより、ClassベースのVueComponentではなく、Functionベースのコンポーネント作成が可能です。
Vue2系には標準装備されていないので、提供されているパッケージを追加します。
yarn add @vue/composition-api参考:CompositionAPI紹介
https://vue-composition-api-rfc.netlify.com/#api-introduction個人的には、CompositionAPIはまだRFCではあるものの、かなり使い勝手良かったです。
直感的に書くことができて、SFCのtemplateで利用できる変数は、CompositionAPIのCreateComponentでreturnしたオブジェクトに宣言したものだけというのが非常にシンプルでわかりやすい!実数値計算ロジック
コード: https://github.com/inosy22/pokemon-tools/tree/master/src/lib/pokemon
こちらは、ES6のクラスベースでオブジェクト指向っぽく作成。
時短のためTypeScriptにしなかったので、privateプロパティっぽくしてますがなってないのはご愛嬌...。
Nuxt.jsの部分とはあまり関係なく、ポケモンのドメインロジックなので詳細説明は省きます。ページの作成
コード: https://github.com/inosy22/pokemon-tools/blob/master/src/pages/speed-checker.vue
Nuxt.jsは
pages
ディレクトリにVueファイルを作成すると、その通りにルーティングされるので、ファイルを作成するだけです。
最初はこのファイルに全てベタ書きしていましたが、冗長な部分があったので、VueComponentの分割を行いました。全体のレイアウトなどは、Vuetifyのグリッドシステムを使うことで、簡単にレスポンシブデザインを実現します。
全てのパーツは、VuetifyのForm系のコンポーネントなどを利用して、
v-model でVueComponentのstateを紐付けることで、リアクティブな処理を実現しています。Vueコンポーネント設計
コード: https://github.com/inosy22/pokemon-tools/tree/master/src/components/speed_checker
冗長な機能と見た目を持つものは以下の2種類があったので、ここだけ別Componentに分離しています。
- CalculatorCard(赤枠)
- PlusMinusButton(青枠)
ラジオボタンとチェックボックスの部分については、シンプルなVuetifyのコンポーネントを使っているだけなので、説明を省略します。
ポケモンの選択
インクリメンタルサーチで簡単にポケモンを選べるようにします。
これは、VuetifyのComboBoxコンポーネントを用いることで簡単に作成できます。実際の利用例
<v-combobox v-model="state.pokemonName" // Componentのstateと紐付け :items="pokemonsForSearch" // 検索候補の文字列の配列を渡す :error="compute.speed.value === '???'" // エラー条件(やばい判定方法なのを今回は見逃してくださいw) @click:clear="clearText" // 消すボタンが押された時に実行される関数 clear-icon="mdi-close-circle" // 消すボタンのアイコンを決める clearable // ×ボタンで消せるようにする autocomplete="off" // ブラウザのオートコンプリートは消す label="ポケモン" // 表示名 dense // デザイン調整 />今回は、ひらがなでも検索に引っかかるように無理矢理ひらがなも表記するようにしていますが、itemsにオブジェクトの配列を渡して、
text/value
を入れ、フィルター関数などを自前で作ったものを設定すれば、うまくできそうな気がします。
しかし、今回は時間の関係で断念。努力値の入力
努力値は 0 から 252 までの値を取りますが、実数値への影響は4の倍数ごとに発生します。
また、Lv.50の場合は、0 と 8n+4 (0 <= n <= 31, nは整数) の値以外は使うことがありません。つまり、これもComboBoxコンポーネントで補助してあげることで、入力がしやすくなります。
また、努力値は一番細かく調整したい箇所なので、細かい調整をリアクティブに確認しながら行いやすいように、プラスマイナスボタンもつけることにします。プラスマイナスボタンについては、ボタンの有効無効フラグとクリック時に発火する関数を受け取るだけのコンポーネントになっており、同じ見た目で色々な加算減算処理に対応できるようにしています。
アイコンは Iconコンポーネント を利用していて、MaterialDesignIconsから自由に使うことができます。
PlusMinusButton.vue<template> <div> <v-icon @click="props.onClickMinusButton()" large color="rgba(255, 255, 255, 0.7)" class="pt-1" style="font-size: 32px" > {{ props.enableMinusButton ? 'mdi-minus-box' : 'mdi-minus-box-outline' }} </v-icon> <v-icon @click="props.onClickPlusButton()" color="rgba(255, 255, 255, 0.7)" large class="pt-1" style="font-size: 32px" > {{ props.enablePlusButton ? 'mdi-plus-box' : 'mdi-plus-box-outline' }} </v-icon> </div> </template> <script> import { createComponent } from '@vue/composition-api' export default createComponent({ props: { enableMinusButton: { type: Boolean, default: false }, enablePlusButton: { type: Boolean, default: false }, onClickMinusButton: { type: Function, default: () => {} }, onClickPlusButton: { type: Function, default: () => {} } }, setup(props, ctx) { return { props } } }) </script>補正ランクの選択
補正ランクは、 -6 から +6 までの値のみなので、セレクトボックスを使うことにします。
しかし、セレクトボックスを使うと、1ランクずつ変更して徐々に確かめたい場合に面倒なので、こちらにもプラスマイナスボタンを利用します。素早さ比較結果の表示
親コンポーネントであるページで比較結果を表示します。
自分と相手のそれぞれのCalculatorCard
で計算された実数値は、
それぞれのコンポーネントが管理しているので、
親コンポーネントに通知する必要があります。そのため、親コンポーネントから、親のstateに計算結果を連携するコールバック関数を
CalculatorCard
にpropsとして渡してあげます。
CalculatorCard
はその関数を、実数値の計算が終わった時に実行します。propsで渡すコールバック関数の宣言const calculatedOwnSpeed = (speed) => { state.ownSpeed = speed } const calculatedOpponentSpeed = (speed) => { state.opponentSpeed = speed }CalculatorCardにコールバック関数を渡す<v-flex xs12 sm6 class="card-container"> <CalculatorCard :title="`自分のポケモン`" :calculated-speed="calculatedOwnSpeed" /> </v-flex> <v-flex xs12 sm6 class="card-container"> <CalculatorCard :title="`相手のポケモン`" :calculated-speed="calculatedOpponentSpeed" /> </v-flex>
state.ownSpeed
とstate.opponentSpeed
に依存した算出プロパティを作成してあげれば、子コンポーネントの計算結果も親コンポーネントの比較結果にリアクティブに処理されます。算出プロパティconst compute = { result: computed(() => { const result = { text: '計測不能', color: 'white', percentage: 0, competition: 0 } if ( isNaN(state.ownSpeed) || state.ownSpeed === null || isNaN(state.opponentSpeed) || state.opponentSpeed === null ) { return result } const ownSpeed = Number(state.ownSpeed) const opponentSpeed = Number(state.opponentSpeed) if (ownSpeed > opponentSpeed) { result.text = '速い!' result.color = '#880000' } else if (opponentSpeed > ownSpeed) { result.text = '遅い...' result.color = 'white' } else { result.text = '同速' result.color = 'black' } result.percentage = Math.round( (ownSpeed / (ownSpeed + opponentSpeed)) * 100 ) result.competition = Math.floor((ownSpeed / opponentSpeed) * 100) / 100 return result }) }結果の表示部分は、ProgressLinerコンポーネントを利用しています。
Vueの Slot を使うことで、プログレスバーの中に文字を入れることができるので、結果をそこに記載しています。結果表示プログレスバー<!-- スマホの場合だけ、固定フッターにする --> <v-footer :fixed="$vuetify.breakpoint.xs" width="100%"> <v-flex xs12 sm12> <v-progress-linear :value="compute.result.value.percentage" color="amber" height="40" reactive > <v-row algin="center" justify="space-between"> <v-col style="text-align: left; margin-left: 10px; font-size: 1.2rem; color: black;" > <strong>{{ state.ownSpeed }}</strong> </v-col> <v-col style="text-align: center;" cols="6"> <strong :style=" `color: ${compute.result.value.color}; font-size: 1.2rem;` " > {{ compute.result.value.text }} ({{ compute.result.value.competition }}倍) </strong> </v-col> <v-col style="text-align: right; margin-right: 10px; font-size: 1.2rem;" > <strong>{{ state.opponentSpeed }}</strong> </v-col> </v-row> </v-progress-linear> </v-flex> </v-footer>スマホの場合は
CalculatorCard
が縦に並ぶようにしているため、
結果表示を単純に最下部にしてしまうと、ポチポチ値を変更した際にリアクティブに結果を見ることができなくなってしまいます。そのため、スマホサイズの場合だけ、結果表示は固定フッターにするという形を取りました。
OGPの設定
SEO対策やSNSなどでのシェアのためのOGP設定を行います。
nuxt.config.js
のheadプロパティ を使うことで一括で全ページに入れることができます。nuxt.config.jshead: { titleTemplate: '%s', title: 'ポケモン剣盾素早さ比較ツール', meta: [ { charset: 'utf-8' }, { name: 'viewport', content: 'width=device-width, initial-scale=1' }, { name: 'keywords', content: 'ポケモンソードシールド,ポケモン剣盾,ソード,シールド,SW,SH,剣盾,けんたて,ポケモン,ぽけもん,素早さ,素早さ比較,素早さチェッカー' }, { hid: 'description', name: 'description', content: '速攻計算!ポケモン剣盾(ポケモンソードシールド)素早さ比較ツール by @inosy22' }, { hid: 'twitter:card', name: 'twitter:card', content: 'summary' }, { hid: 'twitter:site', name: 'twitter:site', content: '@inosy22' }, { hid: 'og:type', property: 'og:type', content: 'website' }, { hid: 'og:title', property: 'og:title', content: 'ポケモン剣盾素早さ比較ツール' }, { hid: 'og:url', property: 'og:url', content: 'https://pokemon-tools.netlify.com' }, { hid: 'og:description', property: 'og:description', content: '速攻計算!ポケモン剣盾用素早さ比較ツール by @inosy22' }, { hid: 'og:image', property: 'og:image', content: 'https://pokemon-tools.netlify.com/img/speed-ball.png' }, { hid: 'og:site_name', name: 'og:site_name', content: 'ポケモン素早さ比較 for 剣盾' } ], link: [{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }] }※ ページごとに設定する場合は別の方法を使う必要があります
※ SSR(univarsal)や静的ジェネレートであればページごとに設定できますが、SPAモードは全ページ共通以外設定不可です公開
netlifyを利用することで、Githubのmasterブランチに変更にフックしてデプロイが可能です。
https://www.netlify.com/Netlifyの利用
Nuxt.jsの公式ドキュメントのFAQにやり方が書いてあります。
https://ja.nuxtjs.org/faq/netlify-deployment/今回は、静的に作られたサイトで問題ないので、
BuildCommandにはnpm run generate
を指定し、PublishDirectoryにdist
を入力します。Domainの変更
Domain Management
からChangeSiteName
を開くことで、
****.netlify.com
のサブドメイン部分を自由に変更することができます。GoogleAnaliticsをNetifyで利用する
こちらの記事を参考にさせていただきました!
https://protoout.studio/posts/netlify-snippet-ga
Build & Deploy
のPost processing
の設定で、<head>
タグにGoogleAnaliticsのタグを挿入されるようにします。さいごに
Nuxt.js はアプリケーション構築を簡単に行うことができてかなり便利です!
また、CompositionAPI を使うことでシンプルな記述ができたのも実装速度向上につながりました!
一番時間がかかったのはUI周りでしたが、Vuetify のおかげでデザイン知識が無いの自分が、コンポーネント単位の細かいデザインを考えなくて良いため、かなりの時間節約になりました!
Netlify については設定が10分ぐらいで終わり、CDのことも全く考えなくて良い、さらには無料なのは最高ですね!
加えて、Nuxt.js と Netlify はかなり相性がいいように感じました!趣味での簡単なアプリケーション作成であれば、Nuxt.js + Vuetify + Netlify の構成、手早く作るのに非常にオススメです!
ポケモンのランク戦やっている方は、ぜひこちらの素早さ比較ツール使ってみてください!
そして余裕があればissueやプルリクもお待ちしております!
- 投稿日:2019-12-13T05:43:11+09:00
Vuexストアでするべきこと、しないべきことを考える
この記事は、Yumemi.vue #4でのLTした内容を基に、改めて文書として再整理したものになります。
LTスライドはこちら。表記注
「Vuexを使う」と言う時、実際に私達がアプリケーションから利用するものは、厳密にはVuexそのものではなくVuexによって生成されるデータストアオブジェクトです。
「Vuexというライブラリそのもの」と、「Vuexによって生成されるデータストアオブジェクト」を区別するため、この記事では
- Vuex = ライブラリ
- Vuexストア = Vuexによって生成されるデータストアオブジェクト
という表記分けを用います。
対象
この記事では
- Vue.jsを使っている人
- Vue.jsを知っている人
- Vue.jsを使おうとしている人
のいずれかに当てはまる人、その中でも
- Vuexを使っている人
- Vuexを知っている人
- Vuexを使おうとしている人
のいずれかに当てはまる人を対象として想定しています。即ち、Vue.jsとVuexの両方について、少なくとも関心を持っている人を想定しています。
また、この記事に以下の内容は含まれません。
- javascriptのコード解説
- Vue.jsのコード解説
- Vuexのコード解説
概要
Vuexは、グローバルデータを一元管理するための管理パターンを提供してくれる強力なツールです。
一方、その強力さ故に、Vuexの本来の目的を逸脱した使い方も散見されるように思います。この記事では、Vuexを使って何をするべきか、Vuexを使って何をしないべきかを考えます。
Vuexストアの責務を考える
Vuexが私達に提供してくれるものは、機能的には
- グローバルかつ単一のデータストア
- 上記データストアへの限定的なアクセス方法
という2つの性質を持ったストアオブジェクト = Vuexストアの生成です。
さて、実際にVuexストアをアプリケーションで利用するに辺り、Vuexストアが持つべき責務の範囲はどの程度なのでしょうか。換言すれば、Vuexストアが責務を負うべきもの、責務を負うべきでないもの1は、一体何なのでしょうか。まずはそれを考えてみます。
何のためにVuexを使うのか
そもそもとして、Vuexはどのような問題を解決するためのライブラリなのでしょうか。
ライブラリもまたソフトウェアである以上、第一にまず解決したい問題が有り、その問題を解決するために何らかの手段が考案され、その実装としてライブラリが有るはずです。解決したい問題が存在しないソフトウェアというものは、架空のものか、さもなければタイピング練習の副産物として作られたものでしょう。
Vuexもまた実際に利用されているライブラリである以上、何らかの問題解決を目的としたものであるはずです。
ではその目的とはなんぞやという問について、この記事では
- コンポーネントを超えて存在する情報を管理するため
- 上記のストアオブジェクトへの更新を安全にするため
が解であると考えます。
コンポーネントを超えて存在する情報を管理する
Vueは元々、コンポーネントという単位でのデータ管理方法を提供してくれます。しかしながら、システムの要件によっては、特定のコンポーネントに限定されず、かつ、ページを跨いでもなお同一性が求められるような情報も、時として現れるでしょう。
そのような問題を解決する一つの手段として、コンポーネントの単位を超え、かつどの場面でアクセスしても同一の実体であるようなデータストアを用意すること、即ち「グローバルかつ単一のデータストア」を用意し、それを各所から利用することが考えられます。
Vuexは、「コンポーネントを超えて共有される情報」を管理するための手段として、まさにこの「グローバルかつ単一のデータストア」を提供してくれます。ストアオブジェクトへの更新を安全にする
単純に「グローバルかつ単一のデータストア」が欲しいだけであれば、
export default { hoge: { items: [/*...*/] }, fuga: { /**/ } }のようなオブジェクトを一つ定義しておけば事足ります。
しかしながら、単なるグローバルオブジェクトではあらゆる場面から自由にアクセス可能であるのみならず、自由な更新すらも可能となってしまいます。仮に更新用のメソッドや関数を定義したとしても、プログラマーがそれらを一切無視してデータを直接更新することは妨げません。そのようなストアを安心して利用することは、およそ難しいでしょう。
Vuexは奔放な情報更新を許さず、action → mutation → state → getter
という一方向でのデータ更新を強制するストアオブジェクトを提供します。ストアオブジェクト内のデータを更新する場合にはaction
あるいはmutation
という予め定義された手続きを経由せねばならず、state
を直接更新することは禁じられています2。こうしたVuexの提供する単方向データフローという(Fluxアーキテクチャを基とした)構造により、私達は無秩序なストア更新を心配する必要が無くなります。
Vuexストアの責務
上記から、Vuexを使うそもそもの目的は
- コンポーネントを超えて共有される情報を管理すること
- その実現方法として、グローバルなストアオブジェクトを使う
- そうしたストアオブジェクトへの更新を安全にすること
- そのために単方向のデータフローを強制する
と整理できるでしょう。
更に一文にまとめるならば、コンポーネントを超えて共有される情報へ、安全にアクセスする方法を提供することがVuexのそもそもの目的である、という形となると思います。これがVuexの目的であるならば、そのような目的の下に生成されたVuexストアの責務は、コンポーネントを超えて共有される情報へ、安全にアクセスする方法を提供することであると言って良いでしょう。
Vuexストアのアンチパターンを考える
前節で、Vuexストアの責務をコンポーネントを超えて共有される情報へ、安全にアクセスする方法を提供することと定義しました。これによって、「Vuexストアでするべきこと」は定義されたと考えて良いでしょう3。
この定義を出発点に、以降では「Vuexストアでしないべきこと」を考えてみます。何が「Vuexストアのアンチパターン」か
ここでは、Vuexストアの責務を逸脱した方法全てをアンチパターンと見做すこととします。
些か過激に聞こえますが、Vuexストアに対しては必要なものだと思っています。以下、その理由を説明します。SRP/SDP
ここで土台としている考え方は
の2つです。SRP・SDPそのものについての詳細な解説は他記事を参照していただくこととして、それぞれの内容をざっくり書くと
- SRP
- 一つのモジュール7に複数の責務を持たせると、様々な理由で変更されるようになり、予期しない変更が発生しやすくなる
- したがって、一つのモジュールに複数の責務を持たせてはならない
- SDP
- 不安定なものに依存すると、変更の影響が激しくなる
- したがって、より安定したものに依存せよ
というものです。
Vuexストアの責務を逸脱した方法 = アンチパターン
「Vuexストアの責務を逸脱した方法」でVuexストアを使うということは、即ちVuexストアに新たな責務を追加するということです。それにより、Vuexストアは複数の変更を抱えることになり、Vuexストアは"本来の責務"と"新しい責務"の両方に常に対応することを強いられます。どちらかの責務に変更が発生すればVuexストアも変化することが求められ、しかもその変更がもう一方に波及してはいけません。
このような取り扱い方は、Vuexストアを不安定にし、場合によってはVuexストアの改善すらも拒みます8。何らかの理由によって、あえてSRP違反を承知で作るという場面ももしかするとどこかで有り得るのかも知れません9。しかし、そうした事を考える前に、Vuexストアはコンポーネントを超えて共有される情報を扱うグローバルストアであるということを確認する必要があります。
通常であれば、フロントのデータや振る舞いはコンポーネントに閉じるため、そのスコープと影響範囲もまた、コンポーネントツの内部に閉じます。一方でVuexストアは、上記のような性質を持つ関係上、フロントアプリケーション上の様々なコンテクストから参照され得ます。であるならば、アプリケーションを安定させようと思うなら、それ全体から参照されるVuexもまた可能な限り安定していなくてはなりません。
しかしながら、様々な理由でVuexストアに変更が発生する = Vuexストアが不安定な状態では、そこに依存するアプリケーション全体までもがその変更の影響を受ける = 不安定になってしまいます。城を建てるのであれば、足元には砂ではなくしっかりした土台が必要です。Vuexストアがアプリケーション全体に影響し得るものである以上、Vuexストアは可能な限り安定性を高めるべきです。
したがって、ここではVuexストアを不安定にさせうる、最終的にはアプリケーション全体の不安定さを誘発しうるものについては、強く排除する考えを取ります。
その結果がVuexストアの責務を逸脱した方法全てをアンチパターンと見做すということです10。まとめ: Vuexストアでするべきこと、しないべきこと
- Vuexストアでするべきこと = Vuexストアの責務 = アプリケーション全体で共有される情報へ、安全にアクセスする方法を提供すること
- Vuexストアでしないべきこと = Vuexストアの責務を逸脱すること全て
です。後者に該当するノウハウは、全てアンチパターンと見做します。
「するべきこと」の根拠は、そもそもとしてVuexが解決する問題は何かということ、
「しないべきこと」の根拠は、SRP/SDPの考え方と、「アプリケーション全体で共有されるデータを管理する」という性質上、Vuexストアは可能な限り安定にするべき、という考えからです。おわりに
この記事では、Vuexの使い方について、「このように書く」「こんなことができる」という使い方の詳細ではなく、「どのように使うべきか」という使い方の基礎11に当たる部分を考えてみました。
Vuexは強力な武器をフロントエンドエンジニアに与えてくれます。しかしながら、その強力さ故に、誤った使い方はその利益より多くの被害を周囲に与えかねません。それこそ「使わない」という選択の方が適切な場面の方が多いかもしれません12。
「大いなる力には大いなる責任が伴う13」という言葉通り、Vuexという有る種の諸刃の剣を使うには、それを振るうべき時と場所を見極め、時には刃を収められるだけの節制が必要だと思います。この記事が、Vuexの使い方について改めて考える契機となれば幸いです。
備考(Vuex公式の紹介について)
Vuex公式のVuexとは何か?より
一つ目は、プロパティ (props) として深く入れ子になったコンポーネントに渡すのは面倒で、兄弟コンポーネントでは単純に機能しません。二つ目は、親子のインスタンスを直接参照したり、イベントを介して複数の状態のコピーを変更、同期することを試みるソリューションに頼っていることがよくあります。これらのパターンは、いずれも脆く、すぐにメンテナンスが困難なコードに繋がります。
では、コンポーネントから共有している状態を抽出し、それをグローバルシングルトンで管理するのはどうでしょうか? これにより、コンポーネントツリーは大きな "ビュー" となり、どのコンポーネントもツリー内のどこにあっても状態にアクセスしたり、アクションをトリガーできます!ん?
どのコンポーネントもツリー内のどこにあっても状態にアクセスしたり、アクションをトリガーできます!
「アプリケーション全体で共有される情報へ、安全にアクセスする方法を提供すること」以外の使用方法を、Vuex公式が推奨 or 想定している疑惑
「どのコンポーネントでも、状態にアクセスしたりアクションをトリガーできる」について、ここでは文章の流れから「props/emitのバケツリレーの代わりに、Vuex使うと楽ちんだよ!」という風に読めそうです14。
それって、「安全なグローバルストア」という責務の他に、Vuexストアをグローバルなイベントバスとしても使おう = 責務の追加、ってことでは・・・。props/emitのバケツリレーが面倒くさいというのはとても良くわかるのですが、それをやってしまうと「このボタンクリックしたらXXを更新したい」「このテキストが変わったら、AAのBBだけ変更したい、他はそのまま」「こことここがチェックされたら、YYをZZに変更したい」という、UI側の都合がどんどんVuexストアに流入してきそうな気配を感じます。
ここまで散々考えてきた「Vuexの目的」「Vuexの使い方」は、根本的に誤っていたのでしょうか?
Vuex公式の言葉なので、ちょっと受け止め方に迷いましたが、この記事ではVuex公式のこの文が不適切だという(些か傲岸不遜な)スタンスを今回は取りたいと思います。
というのも、
- 「Vuex公式が言っている」 → 「正しい設計である」
- 「Vuex公式が言っている」 → 「正しい使い方である」
とは、必ずしも言えないからです。
要件定義やマーケティングの文脈では、「ユーザーは自分が欲しいものを自分で分かっていない」とよく言われます。自分が何をしたいのか、本当に求めているものは何かを正確に把握することは、一般に非常に難しいことです。それと同様に、Vuex公式のメンバーだって、自分達がVuexに何を求めているかを誤解している可能性は十分に有り得ます。もしかしたら、深い意味は無く、単にキャッチーで分かりやすい利用例を挙げてみただけかもしれません。
そうした可能性を考えると、「公式がそう言っているから」と思考停止するよりは、「私はこのように考えるので、この文は間違っていると思う」と主張する方が、私は好ましいと思います。実際、私はこの公式の文はVuexの目的を曖昧にしてしまうと思います(実践的にも、この利用方法はアンチパターンにあたると思います8)。
以上より、Vuex公式の「props/emitのショートカットとしてVuexを使おう」と取れる文について、この記事では「それは不適切だ」というスタンスを取ります。
視点を変えれば、「私達がVuexストアに責務として負わせるべきもの、負わせてはならないものは何か」、という表現も可能だと思います。 ↩
「禁じられています」と言いつつ、実は直接state変更もデフォルトでは可能なようです。Vuexの厳格モードを有効にすれば、直接stateを更新すると例外が発生するようになります。 ↩
責務の内容が、即ち「Vuexストアですべきこと」である、ということです。 ↩
https://ja.wikipedia.org/wiki/SOLID#cite_note-martin-design-principles-4 (at 2019/12/13) ↩
https://web.archive.org/web/20150202200348/http://www.objectmentor.com/resources/articles/srp.pdf (at 2019/12/13) ↩
https://web.archive.org/web/20150906155800/http://www.objectmentor.com/resources/articles/Principles_and_Patterns.pdf p.23 (at 2019/12/13) ↩
原文ではClassと限定されていますが、クラスに限定せずとも一般に言える内容だと思うので、ここではモジュールという言葉にしています。 ↩
アンチパターンの具体例は別記事にします。一旦はLTスライドを参照ください。 ↩
ただし、少なくともVuexにおいて、その判断が好ましい一般的な場面は特に思いつきません。あくまで「可能性として0ではないかもしれない」ぐらいのつもりです。 ↩
今後のVuexの進化やVuexを組み込んだ設計・実装の発展次第では、新たなVuexストアの役割が見出され、また別の責務を応用になるかもしれません。その時は、またその責務に応じてアンチパターンの範囲も変更されるでしょう(と言いつつ、新しい役割を見出すぐらいなら、その役割をよりスマートにこなすライブラリが新たに登場する気もします) ↩
ここでの「基礎」は、文字通り全体の土台となる部分、の意味です。 ↩
個人的には、可能な限り使わない方が好ましいと思っています。 ↩
Rubyメタプログラミングの文脈でよく聞くフレーズですが、出典知りません。アメコミ? ↩
自分の誤読か、あるいは誤訳であって欲しい(が、英語版をgoogle翻訳にかけても同じような文章なので、訳は多分正確・・・)。 ↩
- 投稿日:2019-12-13T00:26:55+09:00
Auth0を使ってプライベートなマークダウンエディタを作る(クライアントサイド編)
認証ってめんどくさい
私は仕事でAWSを使用することが多いのですが、AWSの認証認可サービスといえば
Cognito
です。
一度、調査でGoogleとのフェデレーションを試してみたのですが、なかなか手順が複雑で大変でした。最近Auth0というサービスを知り、Googleアカウントでのログインを爆速で実装できることを知りました。
今回はブラウザ上で動作するマークダウンエディタで、アカウントごとに内容を記録できるものを作成していきます。本記事では、クライアントサイドの実装を行っていきます。
所要時間30分~40分
ほどでできる内容ですので試してみてください。マークダウンエディタの要件
- ブラウザで使用する
- PC、iPhone両方で使用できる
- ログインしておくとテキストを記録でき、次回ログイン時には記録した内容が表示される
システム構成
最近は
サーバーレス
ならぬLambdaless
という考え方があるらしいので、Lambdaはできるだけ使わない方針とします。
(参考:https://www.slideshare.net/mooyoul/lambdaless-and-aws-cdk-191793017)
認証情報の検証は仕方ないので諦めます。Auth0サインアップ ~ サンプルソースダウンロード ~ サンプルアプリ起動 (所要時間:15分)
公式サイト(https://auth0.com/jp/)を開き、右上にある
サインアップ
ボタンからアカウントを作成します。
私はGitHubアカウントを使用したので、
SIGN UP WITH GITHUB
からサインアップしました。
ログインするとダッシュボードが開きます。
CREATE APPLICATION
からアプリを作成していきます。
今回はVue.jsを使用したSPAとして作成するので、以下の内容でアプリを作成しました。
項目 内容 Name My App Choose an application type Single Page Web Applications アプリを作成すると、以下のような画面になるので、
Vue.js
をクリックします。
右下にある
DOWNLOAD SAMPLE
をクリックし、サンプルアプリをダウンロードします。サンプルアプリを起動してみます。
$ npm install $ npm run serveを実行します。
~~~ 略 ~~~ App running at: - Local: http://localhost:3000/ - Network: http://192.168.10.2:3000/ Note that the development build is not optimized. To create a production build, run npm run build.と表示されたら、http://localhost:3000/にアクセスします。
こんな画面が表示されたらOK。
ここまで順調にいけば初見でも15分もあればいけちゃいます。Googleでログインする (所要時間:1分)
先ほど起動したサンプルアプリで、Googleアカウントでログインしてみましょう。
右上のLogin
ボタンをクリック。
すると、Auth0のログイン画面が表示されます。
LOG IN WITH GOOGLE
をクリックします。
お、Googleのログイン画面が現れました!
メールアドレスとパスワードを入力し、ログインします。
アイコンをクリックすると、Gmailで登録している名前も表示されます。
マークダウンエディタの作成 (慣れた人なら15分)
https://jp.vuejs.org/v2/examples/index.htmlにいい感じのサンプルソースがあるので流用します。
使用するnpmモジュールをインストールします。
$ npm install lodash marked以下の資源を追加します。
src/views/Editor.vue<template> <div id="editor"> <textarea :value="input" @input="update"></textarea> <div v-html="compiledMarkdown"></div> </div> </template> <script> import _ from 'lodash'; import marked from 'marked'; export default { name: 'editor', data() { return { input: '# hello', } }, computed: { compiledMarkdown: function () { return marked(this.input, { sanitize: true }) } }, methods: { update: _.debounce(function (e) { this.input = e.target.value }, 300) }, } </script> <style scoped> html, body, #editor { margin: 0; height: 100%; font-family: 'Helvetica Neue', Arial, sans-serif; color: #333; } textarea, #editor div { display: inline-block; width: 49%; height: 100%; vertical-align: top; box-sizing: border-box; padding: 0 20px; } textarea { border: none; border-right: 1px solid #ccc; resize: none; outline: none; background-color: #f6f6f6; font-size: 14px; font-family: 'Monaco', courier, monospace; padding: 20px; } code { color: #f66; } </style>src/router.jsimport Vue from "vue"; import Router from "vue-router"; import Home from "./views/Home.vue"; import Profile from "./views/Profile.vue"; import Editor from "./views/Editor.vue"; // <-- この行を追加 import { authGuard } from "./auth"; Vue.use(Router); const router = new Router({ mode: "history", base: process.env.BASE_URL, routes: [ { path: "/", name: "home", component: Home }, { path: "/profile", name: "profile", component: Profile, beforeEnter: authGuard }, { // <-- ここから path: "/editor", name: "editor", component: Editor, beforeEnter: authGuard } // <-- ここまで追加 ] }); export default router;src/main.jsimport Vue from "vue"; import App from "./App.vue"; import router from "./router"; import { Auth0Plugin } from "./auth"; import HighlightJs from "./directives/highlight"; import { library } from "@fortawesome/fontawesome-svg-core"; import { faLink, faUser, faPowerOff } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome"; import { domain, clientId } from "../auth_config.json"; Vue.config.productionTip = false; Vue.use(Auth0Plugin, { domain, clientId, onRedirectCallback: () => { // router.push( // appState && appState.targetUrl // ? appState.targetUrl // : window.location.pathname // ); router.push('editor'); // <-- このように修正(ログイン後、/editorに移動する) } }); Vue.directive("highlightjs", HighlightJs); library.add(faLink, faUser, faPowerOff); Vue.component("font-awesome-icon", FontAwesomeIcon); new Vue({ router, render: h => h(App) }).$mount("#app");資源の追加・修正が終わると、VSCodeを使っている方はこんな感じになるはずです。
http://localhost:3000/にアクセスしログインすると以下のような画面になり、右側にプレビューが表示されます。
(Qiitaのエディタみたいですね)Auth0を使うと、クライアントサイドは30分で実装できる
Auth0がサンプルソースを提供してくれるおかげで、30分くらいでクライアントサイドの認証の実装を行うことができました。
慣れてくると、単純な導通であれば10分そこそこでできるんじゃないでしょうか。
(Cognitoを初見で使ったときは1日かかりました。)次回はサーバーサイドの実装を行っていきます。
- 投稿日:2019-12-13T00:10:15+09:00
Vue.jsチュートリアル 〜Vue.js + TypeScriptでTrelloもどきを作ろう②〜
この記事はVue.jsチュートリアル 〜Vue.js + TypeScriptでTrelloもどきを作ろう①〜の続編です。
リスト名とカードテキストの編集機能
次はリスト名とカードテキストの編集機能を作りましょう。
今回はcontenteditable
というグローバル属性をtrue
にすることによって、その要素のテキストを編集することにします。(DOMを変更し、それに基づいてデータを変更しています。まだVue.js自体contenteditale
をサポートしていないと思うので、input
要素にした方がいいかもしれません。)更新時の流れは以下の通りにします。
- リスト名がダブルクリックされると、その要素の文字を編集可能にしフォーカスを当てる。
- フォーカスが当たっている状態でEnterキーが押されると、フォーカスを外す。
- フォーカスが外れると、リスト名、またはカードテキストを更新する。
List.vueを編集していきましょう。
リスト名をdiv
要素で囲い、その要素にcontenteditable
属性とイベントリスナを付与します。
「更新時の流れ」より、ダブルクリック時にトリガされる@dblclick
, エンターキー押下時にトリガされる@keypress.enter
, フォーカスを外した時にトリガされる@blur
を登録します。List.vue<template> <div class="list"> + <div + :contenteditable="contenteditable" + @dblclick="onDoubleClick" + @keypress.enter="onKeyPressEnter" + @blur="onBlur" + > {{ list.name }} + </div> <Card v-for="card in list.cards" :key="card.id" class="card" :card="card" /> <input type="text" class="card-input" @change="addCard" /> </div> </template> <script lang="ts"> import { Component, Vue, Prop, Emit, PropSync } from "vue-property-decorator"; import Card from "@/components/Card.vue"; import { IList } from "@/types"; export interface IAddCardEvent { listId: number; text: string; } @Component({ components: { Card } }) export default class List extends Vue { @Prop({ type: Object, required: true }) readonly list!: IList; + contenteditable = false; 〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜 + onDoubleClick(event: MouseEvent & { currentTarget: HTMLDivElement }): void { // 要素のテキストを編集可能にする + this.contenteditable = true; // 要素にフォーカスを当てる + event.currentTarget.focus(); + } + onKeyPressEnter( + event: KeyboardEvent & { currentTarget: HTMLDivElement } + ): void { // 要素からフォーカスを外す + event.currentTarget.blur(); + } + onBlur(event: FocusEvent & { currentTarget: HTMLDivElement }): void { // 要素のテキストを編集不可にする + this.contenteditable = false; + } } </script> 〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜リスト名を更新する処理を書いていきましょう。
今回はリスト名自体, カードテキスト自体を更新するので@PropSync
デコレータを使うことにします。
@PropSync
デコレータを使うことで、データを渡された子コンポーネント側でデータを更新する処理を書くことができ、とても簡潔に書くことができます。
List
コンポーネントに新しくlistName
をprops
として渡して、listName
にイベント修飾子のsync
を付けましょう。
イベント修飾子とはイベントハンドラに付与するオプションのようなものです。
ここでは「@PropSync
デコレータを使用する際にセットで必要になる」としか説明しないので、詳しく知りたい方はこちらを読んでください。App.vue<template> <div id="app"> <List v-for="list in lists" :key="list.id" class="list" :list="list" + :listName.sync="list.name" @add-card="addCard" /> <input type="text" class="list-input" @change="addList" /> </div> </template> 〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜
App.vue
でlistName
として渡したので、@PropSync
の第一引数にlistName
を文字列として指定しましょう。
これでprops
にlistName
が登録されます。
第二引数には@Prop
デコレータと同じオプションを指定します。
デコレータを付与するプロパティ名は何でもいいですが、ここでは公式ドキュメントのようにsyncedListName
としましょう。
型はstring
でもいいですが、ブラケット記法でそのプロパティの型を取得できるのでIList["name"]
としましょう。
(@PropSync("listName", { type: String, required: true }) syncedListName!: IList["name"];
)
これは内部的にはcomputed(算出プロパティ)のgetterとsetterに)以下のように登録されます。<script lang="ts"> export default class List extends Vue { get syncedListName() { return this.listName; } set syncedListName(value) { this.$emit('update:listName', value); } } </script>これで、
this.syncedListName = value
と書くことで、props
を直接更新するような書き方で更新することができるようになります。
「親コンポーネントに値を送って...」のようにせずに済み、とても簡潔に書くことができます。(ただしあまり乱用しすぎると危険な気はします...)List.vue<script lang="ts"> + import { Component, Vue, Prop, Emit, PropSync } from "vue-property-decorator"; import Card from "@/components/Card.vue"; import { IList } from "@/types"; export interface IAddCardEvent { listId: number; text: string; } @Component({ components: { Card } }) export default class List extends Vue { @Prop({ type: Object, required: true }) readonly list!: IList; + @PropSync("listName", { type: String, required: true }) + syncedListName!: IList["name"]; 〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜 onBlur(event: FocusEvent & { currentTarget: HTMLDivElement }): void { + this.syncedListName = event.currentTarget.innerText; this.contenteditable = false; } } </script>それではカードテキストについても同様に書いていきましょう。
List.vue<template> <div class="list"> <div @dblclick="onDoubleClick" @keypress.enter="onKeyPressEnter" @blur="onBlur" > {{ list.name }} </div> <Card v-for="card in list.cards" :key="card.id" class="card" :card="card" + :cardText.sync="card.text" /> <input type="text" class="card-input" @change="addCard" /> </div> </template> 〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜Card.vue<template> <div class="card"> + <div + :contenteditable="contenteditable" + @dblclick="onDoubleClick" + @keypress.enter="onKeyPressEnter" + @blur="onBlur" + > {{ card.text }} + </div> </div> </template> <script lang="ts"> + import { Component, Vue, Prop, PropSync } from "vue-property-decorator"; import { ICard } from "@/types"; @Component export default class Card extends Vue { @Prop({ type: Object, required: true }) readonly card!: ICard; + @PropSync("cardText", { type: String, required: true }) + syncedCardText!: ICard["text"]; + contenteditable = false; + onDoubleClick(event: MouseEvent & { currentTarget: HTMLDivElement }): void { + this.contenteditable = true; + event.currentTarget.focus(); + } + onKeyPressEnter( event: KeyboardEvent & { currentTarget: HTMLDivElement } ): void { + event.currentTarget.blur(); + } + onBlur(event: FocusEvent & { currentTarget: HTMLDivElement }): void { + this.syncedCardText = event.currentTarget.innerText; + this.contenteditable = false; + } } </script>これでリスト名, カードテキストを更新することができるようになりました。
この時点でのコミットリストとカードの削除機能
次はリストとカードの削除機能について見ていきましょう。
削除するためのバツ印をそれぞれリスト名, カードテキストの右に表示し、それがクリックされると削除することにします。
まずバツ印をコンポーネントにしましょう。Cross.vue<template> <span class="cross" @click="click" /> </template> <script lang="ts"> import { Component, Vue, Emit } from "vue-property-decorator"; @Component export default class Cross extends Vue { @Emit() click(): void {} } </script> <style lang="scss" scoped> .cross { display: inline-block; width: 16px; height: 16px; position: absolute; &:before, &:after { width: 100%; border-top: 1px solid #000000; position: absolute; top: 50%; content: ""; } &:before { transform: rotate(-45deg); } &:after { transform: rotate(45deg); } } </style>このバツ印をクリックするとカスタムイベント
click
が発火されます。リストを削除するために必要なデータは、削除するリストのidです。
なので、カスタムイベントclick
をカスタム要素Cross
に登録し、イベントハンドラとしてremoveList
を登録しましょう。
removeList
に@Emit()
を付与することでカスタムイベントremove-list
が発火されます。List.vue<template> <div class="list"> <div class="list-name" :contenteditable="contenteditable" @dblclick="onDoubleClick" @keypress.enter="onKeyPressEnter" @blur="onBlur" > + <Cross @click="removeList" /> {{ list.name }} </div> <Card v-for="card in list.cards" :key="card.id" class="card" :card="card" :cardText.sync="card.text" /> <input type="text" class="card-input" @change="addCard" /> </div> </template> <script lang="ts"> import { Component, Vue, Prop, Emit, PropSync } from "vue-property-decorator"; import Card from "@/components/Card.vue"; + import Cross from "@/components/Cross.vue"; import { IList } from "@/types"; import { IRemoveCardEvent } from "@/components/Card.vue"; export interface IAddCardEvent { listId: number; text: string; } @Component({ components: { Card, + Cross } }) export default class List extends Vue { 〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜 + @Emit() + removeList(): number { + return this.list.id; + } } </script> 〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜List.vueでカスタムイベント
remove-list
が発火されるので、リストを削除するロジックをイベントハンドラに登録しましょう。App.vue<template> <div id="app"> <List v-for="list in lists" :key="list.id" class="list" :list="list" :listName.sync="list.name" @add-card="addCard" + @remove-list="removeList" /> <input type="text" class="list-input" @change="addList" /> </div> </template> <script lang="ts"> import { Component, Vue } from "vue-property-decorator"; import List from "@/components/List.vue"; import { IList } from "@/types"; import { initialLists } from "@/initialData"; import { IAddCardEvent } from "@/components/List.vue"; import { IRemoveCardEvent } from "@/components/Card.vue"; @Component({ components: { List } }) export default class App extends Vue { 〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜 + removeList(listId: number): void { + const listIndex = this.lists.findIndex(list => list.id === listId); // findIndexで見つからない場合は-1を返すのでその場合は早期リターン + if (listIndex === -1) return; + this.lists.splice(listIndex, 1); + } } </script> 〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜これでリストを削除できるようになりました。
カードも同様に削除できるようにします。
カードを削除するためには、どのリストのどのカードなのかを特定する必要があるので、そのカードのidとそのカードのリストのidが必要なデータです。
なので先にList.vueでカードにリストのidを渡しましょう。List.vue<template> <div class="list"> <div class="list-name" @dblclick="onDoubleClick" @keypress.enter="onKeyPressEnter" @blur="onBlur" > <Cross @click="removeList" /> {{ list.name }} </div> <Card v-for="card in list.cards" :key="card.id" class="card" + :listId="list.id" :card="card" :cardText.sync="card.text" /> <input type="text" class="card-input" @change="addCard" /> </div> </template>Card.vue<template> <div class="card"> <div class="card-name" @dblclick="onDoubleClick" @keypress.enter="onKeyPressEnter" @blur="onBlur" > + <Cross @click="removeCard" /> {{ card.text }} </div> </div> </template> <script lang="ts"> + import { Component, Vue, Prop, Emit, PropSync } from "vue-property-decorator"; + import Cross from "@/components/Cross.vue"; + import { ICard, IList } from "@/types"; + export interface IRemoveCardEvent { + listId: number; + cardId: number; + } @Component({ + components: { + Cross + } }) export default class Card extends Vue { + @Prop({ type: Number, required: true }) + listId!: IList["id"]; 〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜 + @Emit() + removeCard(): IRemoveCardEvent { + return { + listId: this.listId, + cardId: this.card.id + }; + } } </script> 〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜List.vue<template> <div class="list"> <div class="list-name" @dblclick="onDoubleClick" @keypress.enter="onKeyPressEnter" @blur="onBlur" > <Cross @click="removeList" /> {{ list.name }} </div> <Card v-for="card in list.cards" :key="card.id" class="card" :listId="list.id" :card="card" :cardText.sync="card.text" + @remove-card="removeCard" /> <input type="text" class="card-input" @change="addCard" /> </div> </template> <script lang="ts"> import { Component, Vue, Prop, Emit, PropSync } from "vue-property-decorator"; import Card from "@/components/Card.vue"; import Cross from "@/components/Cross.vue"; import { IList } from "@/types"; + import { IRemoveCardEvent } from "@/components/Card.vue"; export interface IAddCardEvent { listId: number; text: string; } @Component({ components: { Card, Cross } }) export default class List extends Vue { 〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜 + @Emit() + removeCard(event: IRemoveCardEvent): IRemoveCardEvent { + return event; + } } </script> 〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜App.vue<template> <div id="app"> <List v-for="list in lists" :key="list.id" class="list" :list="list" :listName.sync="list.name" @add-card="addCard" @remove-list="removeList" + @remove-card="removeCard" /> <input type="text" class="list-input" @change="addList" /> </div> </template> <script lang="ts"> import { Component, Vue } from "vue-property-decorator"; import List from "@/components/List.vue"; import { IList } from "@/types"; import { initialLists } from "@/initialData"; import { IAddCardEvent } from "@/components/List.vue"; + import { IRemoveCardEvent } from "@/components/Card.vue"; @Component({ components: { List } }) export default class App extends Vue { 〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜 + removeCard({ listId, cardId }: IRemoveCardEvent): void { + const list = this.lists.find(list => list.id === listId); + if (list === undefined) return; + const cardIndex = list.cards.findIndex(card => card.id === cardId); + if (cardIndex === -1) return; + list.cards.splice(cardIndex, 1); + } } </script> 〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜これでカードを削除することができるようになりました。
この時点でのコミットリスト、カードの移動機能
さていよいよ最後の移動機能です。これでTrelloっぽくなると思います。
いつものようにリストからです。
まず入れ替えるロジックについて考えてみましょう。いくつか方法はあると思います。
ここではリストをドラッグして、カーソルの位置が入れ替え先のリストの横幅に対する縦の中央線の上を超えたら、ドラッグしているリストと入れ替え先のリストを入れ替えるというロジックにしましょう。まず必要なデータはドラッグ中のリストです。
このデータを一時的に保存するために、ドラッグし始めたらdraggedList
というdata
にドラッグ中のリストを代入し、ドラッグが終わったらnull
を代入してリセットしましょう。要素をドラッグするためには、その要素に
draggable
属性を追加します。
ドラッグ関連のイベントを下にまとめます。
参考
イベント名 発生タイミング dragstart ドラッグ開始時 drag ドラッグが継続している間 dragenter ドラッグ要素がドロップ要素に入った時 dragleave ドラッグ要素がドロップ要素から出た時 dragover ドラッグ要素がドロップ要素に重なっている間 drop ドロップ時 dragend ドラッグ終了時 この表からドラッグ開始時に発生するイベントは
dragstart
、ドラッグ終了時に発生するイベントはdragend
ということがわかります。
この2つのイベントリスナを要素に登録します。ここで
native
というイベント修飾子を使いましょう。
カスタム要素にイベントリスナを登録する時にこの修飾子を使うと、カスタム要素のコンポーネントのルート要素にイベントリスナを付与することができます。
具体的には@dragstart.native="イベントハンドラ"
をList
に登録することで、List
コンポーネントの<div class="list"></div>
にこのイベントリスナを付与することができます。
これでListコンポーネント内でイベントリスナを付与したり、emit
する手間が省けました。App.vue<template> <div id="app"> <List v-for="list in lists" :key="list.id" class="list" :list="list" :listName.sync="list.name" @add-card="addCard" @remove-list="removeList" @remove-card="removeCard" + draggable + @dragstart.native="setDraggedList(list, $event)" + @dragend.native="resetDraggedList" /> <input type="text" class="list-input" @change="addList" /> </div> </template> <script lang="ts"> 〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜 @Component({ components: { List } }) export default class App extends Vue { lists: IList[] = initialLists; listCreatedCount = 2; cardCreatedCount = 4; // ドラッグ中のリスト + draggedList: IList | null = null; 〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜 + setDraggedList(list: IList, event: DragEvent): void { // Firefoxでドラッグする際に必要(詳しい説明は省略) + if (event.dataTransfer == null) return; + event.dataTransfer.setData("text/plain", ""); + + this.draggedList = list; + } + resetDraggedList(): void { + this.draggedList = null; + } } </script> 〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜これでドラッグの開始時に、ドラッグしているリストのデータが
draggedList
に代入されます。
そしてドラッグの終了時に、null
が代入されdraggedList
がリセットされます。ここで
$event
という特別な変数が出てきました。
メソッド側でイベントオブジェクトを使う場合は、イベントハンドラに何も渡されなければメソッドの第一引数でイベントオブジェクトを受け取ることができます。
しかしlist
を渡しているので、明示的に$event
を引数に渡すことで、メソッドにイベントオブジェクトを渡すことができます。次にリストを移動させる処理を追加します。
今回はdragover
イベントリスナを選択します。
dragover
はドラッグしている要素が同じ要素に重なっている時に、覆い被さられている側の要素で連続で発生するイベントです。
このdragover
イベントのイベントオブジェクトから覆い被さられている側の要素を取得できます。このイベントの発生時に、カーソルがその要素の右側にある場合は要素の右側にリストを追加し、逆にカーソルが要素の左側にある場合は要素の左側に追加しましょう。
そのためにまずはカーソルの位置がどちら側にあるかを取得します。App.vue<template> <div id="app"> <List v-for="list in lists" :key="list.id" class="list" :list="list" :listName.sync="list.name" @add-card="addCard" @remove-list="removeList" @remove-card="removeCard" draggable @dragstart.native="setDraggedList(list, $event)" + @dragover.native="moveList(list.id, $event)" @dragend.native="resetDraggedList" /> <input type="text" class="list-input" @change="addList" /> </div> </template> <script lang="ts"> import { Component, Vue } from "vue-property-decorator"; import List from "@/components/List.vue"; import { IList, ICard } from "@/types"; import { initialLists } from "@/initialData"; import { IAddCardEvent, IMoveCardEvent } from "@/components/List.vue"; import { IRemoveCardEvent } from "@/components/Card.vue"; + enum CursorPosition { + Left, + Right, + Center, + } @Component({ components: { List } }) export default class App extends Vue { 〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜 + moveList( + listId: number, + event: DragEvent & { currentTarget: HTMLDivElement } + ): void { + if ( + this.draggedList === null || // ドラッグしている要素が無い場合は早期リターン + this.draggedList.id === listId // ドラッグしている要素と重ねられている要素が同じ場合は早期リターン + ) + return; // 重ねられている要素の左端の座標(px) + const left: number = event.currentTarget.getBoundingClientRect().left - 1; // 重ねられている要素の右端の座標(px) + const right: number = event.currentTarget.getBoundingClientRect().right - 1; // 重ねられている要素(の水平方向)の中央の座標(px) + const centerX: number = left + (right - left) / 2; // カーソルが、重ねられている要素のどこにあるか + const cursorPosition: CursorPosition = (() => { // カーソルが左端から中央の場合は左側 + if (left <= event.clientX && event.clientX < centerX) { + return CursorPosition.Left; // カーソルが中央から右端の場合は右側 + } else if (centerX < event.clientX && event.clientX <= right) { + return CursorPosition.Right; // どちらでもない場合は中央 + } else CursorPosition.Center; + })(); // 中央の時は早期リターン + if (cursorPosition === CursorPosition.Center) return; // まずドラッグしているリストを削除する + const draggedListIndex = this.lists.findIndex( + list => list.id === this.draggedList!.id + ); + if (draggedListIndex === -1) return; + this.lists.splice(draggedListIndex, 1); + const listIndex = this.lists.findIndex(list => list.id === list.id); + if (listIndex === -1) return; // カーソルが重ねられている要素の左側にある場合は左、右側にある場合は右に要素を追加する + const toListIndex = + cursorPosition === CursorPosition.left ? listIndex : listIndex + 1; + this.lists.splice(toListIndex, 0, this.draggedList); + } } </script> 〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜enum型は定数のまとまりのような型です。
enum型は型としても値としても使うことができ、値として使う場合はオブジェクト、型として使う場合はオブジェクトのプロパティのunion型として使うことができます。
定数を使う場合は積極的にenum型を使っていきましょう。さて、これでリストを入れ替えることができましたので、カードも同様に入れ替えていきましょう。
App.vue<template> <div id="app"> <List 〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜 + :draggedCardData.sync="draggedCardData" + @move-card="moveCard" /> <input type="text" class="list-input" @change="addList" /> </div> </template> <script lang="ts"> 〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜 enum CursorPosition { Left, Right, Center, + Top, + Bottom, } + export interface ICardData { + listId: number; + card: ICard; + } @Component({ components: { List } }) export default class App extends Vue { lists: IList[] = initialLists; listCreatedCount = 2; cardCreatedCount = 4; draggedList: IList | null = null; + draggedCardData: ICardData | null = null; 〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜 + moveCard({ listId, cardId, event }: IMoveCardEvent): void { + if ( + this.draggedCardData === null || // ドラッグしている要素が無い場合は早期リターン + this.draggedCardData.card.id === cardId // ドラッグしている要素と重ねられている要素が同じ場合は早期リターン + ) + return; // 重ねられている要素の上端の座標(px) + const top: number = event.currentTarget.getBoundingClientRect().top - 1; // 重ねられている要素の下端の座標(px) + const bottom: number = + event.currentTarget.getBoundingClientRect().bottom - 1; // 重ねられている要素(の垂直方向)の中央の座標(px) + const centerY: number = top + (bottom - top) / 2; // カーソルが、重ねられている要素のどこにあるか + const cursorPosition: CursorPosition = (() => { // カーソルが上端から中央の場合は上側 + if (top <= event.clientY && event.clientY < centerY) { + return CursorPosition.Top; // カーソルが下端から中央の場合は下側 + } else if (centerY < event.clientY && event.clientY <= bottom) { + return CursorPosition.Bottom; // どちらでもない場合は中央 + } else return CursorPosition.Center; + })(); // 中央の時は早期リターン + if (cursorPosition === CursorPosition.Center) return; // まずドラッグしているカードを削除する + const draggedCardList = this.lists.find( + list => list.id === this.draggedCardData!.listId + ); + if (draggedCardList === undefined) return; + const draggedCardIndex = draggedCardList.cards.findIndex( + card => card.id === this.draggedCardData!.card.id + ); + if (draggedCardIndex === -1) return; + draggedCardList.cards.splice(draggedCardIndex, 1); + const list = this.lists.find(list => list.id === listId); + if (list === undefined) return; + const cardIndex = list.cards.findIndex(card => card.id === cardId); + if (cardIndex === -1) return; // カーソルが、重ねられている要素の上にある場合は上、下側にある場合は下に要素を追加する + const toCardIndex = + cursorPosition === CursorPosition.Top ? cardIndex : cardIndex + 1; + list.cards.splice(toCardIndex, 0, this.draggedCardData.card); // 属するリストが変わる可能性があるので、移動後のリストのidを代入 + this.draggedCardData.listId = listId; + } } </script> 〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜List.vue<template> <div class="list"> <div class="list-name" :contenteditable="contenteditable" @dblclick="onDoubleClick" @keypress.enter="onKeyPressEnter" @blur="onBlur" > <Cross @click="removeList" /> {{ list.name }} </div> <Card v-for="card in list.cards" :key="card.id" class="card" :listId="list.id" :card="card" :cardText.sync="card.text" @remove-card="removeCard" + draggable + @dragstart.native="onDragStart(card, $event)" + @dragover.native="moveCard(card.id, $event)" + @dragend.native="onDragEnd" /> <input type="text" class="card-input" @change="addCard" /> </div> </template> <script lang="ts"> import { Component, Vue, Prop, Emit, PropSync } from "vue-property-decorator"; import Card from "@/components/Card.vue"; import Cross from "@/components/Cross.vue"; import { IList, ICard } from "@/types"; import { IRemoveCardEvent } from "@/components/Card.vue"; + import { ICardData } from "@/App.vue"; export interface IAddCardEvent { listId: number; text: string; } export interface IMoveCardEvent { listId: number; cardId: number; event: DragEvent & { currentTarget: HTMLDivElement }; } @Component({ components: { Card, Cross } }) export default class List extends Vue { @Prop({ type: Object, required: true }) readonly list!: IList; @PropSync("listName", { type: String, required: true }) syncedListName!: IList["name"]; + @PropSync("draggedCardData", { required: true }) + syncedDraggedCardData!: ICardData | null; 〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜 + onDragStart(card: ICard, event: DragEvent): void { + if (event.dataTransfer == null) return; + event.dataTransfer.setData("text/plain", ""); + this.syncedDraggedCardData = { + listId: this.list.id, + card + }; + } + @Emit() + moveCard( + cardId: number, + event: DragEvent & { currentTarget: HTMLDivElement } + ): IMoveCardEvent { + return { + listId: this.list.id, + cardId, + event + }; + } + onDragEnd(): void { + this.syncedDraggedCardData = null; + } } </script> 〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜これでカードの移動機能を作成できました。
しかしここで1つ問題があります。
カードをドラッグして、「リストを入れ替えるときと同じように」リストの中央線を超えるとリストも入れ替わってしまいます。
これはイベントの伝搬による挙動です。イベントの伝搬とは、イベントの発生元の要素から
window
オブジェクトまでイベントが伝わるという挙動です。
これによりカードでドラッグすると、イベントの発生元の要素から親要素に向かって要素ひとつひとつでdragstartイベントが発生し続けてしまいます。
そしてList
コンポーネントのルート要素に付与したイベントリスナがdragstartイベントをキャプチャしてしまい、リストのonDragStart
イベントハンドラが発火してしまいます。
これが原因でリストも移動してしまいます。これを防ぐには
stop
イベント修飾子を使います。(内部的にはevent.stopPropagation()
を実行しています。)
イベント修飾子は.
で繋げて複数指定することができるので、これをCard
に付与した@dragstart
イベントリスナのnative
の後に繋げましょう。
これにより、この要素以降ではdragstartイベントが発生しなくなります。(繋げる順番で挙動が変わる可能性がありますが、今回はどちらでも変わりません。)List.vue<template> <div class="list"> <div class="list-name" :contenteditable="contenteditable" @dblclick="onDoubleClick" @keypress.enter="onKeyPressEnter" @blur="onBlur" > <Cross @click="removeList" /> {{ list.name }} </div> <Card v-for="card in list.cards" :key="card.id" class="card" :listId="list.id" :card="card" :cardText.sync="card.text" @remove-card="removeCard" draggable + @dragstart.native.stop="onDragStart(card, $event)" @dragover.native="moveCard(card.id, $event)" @dragend.native="onDragEnd" /> <input type="text" class="card-input" @change="addCard" /> </div> </template>リストが移動しなくなりましたね。
最後にリスト内のカードが空の時でも移動できるようにしましょう。
今回はカーソルがリストの要素内に入った瞬間にイベントを発火させたいので、@dragenter
イベントリスナを選択します。App.vue<template> <div id="app"> <List v-for="list in lists" :key="list.id" class="list" :list="list" :listName.sync="list.name" @add-card="addCard" @remove-list="removeList" @remove-card="removeCard" draggable @dragstart.native="setDraggedList(list, $event)" @dragover.native="moveList(list.id, $event)" @dragend.native="resetDraggedList" :draggedCardData.sync="draggedCardData" @move-card="moveCard" + @dragenter.native="moveCardForEmpty(list.id)" /> <input type="text" class="list-input" @change="addList" /> </div> </template> <script lang="ts"> 〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜 @Component({ components: { List } }) export default class App extends Vue { 〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜 + moveCardForEmpty(listId: number): void { + if ( + this.draggedCardData === null || + this.draggedCardData.listId === listId + ) + return; + const list = this.lists.find(list => list.id === listId); + if ( + list === undefined || + list.cards.length !== 0 // リスト内にカードが1つでもある場合は早期リターン + ) + return; + const draggedCardList = this.lists.find( + list => list.id === this.draggedCardData!.listId + ); + if (draggedCardList === undefined) return; + const draggedCardIndex = draggedCardList.cards.findIndex( + card => card.id === this.draggedCardData!.card.id + ); + if (draggedCardIndex === -1) return; + draggedCardList.cards.splice(draggedCardIndex, 1); + list.cards.push(this.draggedCardData.card); + this.draggedCardData.listId = listId; + } } </script>これでリスト内のカードが空の場合でも移動させることができるようになりました。
この時点でのコミットいかがでしたか?やっぱりHTML要素を動かすことができると楽しいですよね。
Vue.js, TypeScriptにはまだまだたくさんの素晴らしい機能があります。
このチュートリアルがきっかけでVue.jsやTypeScriptに興味を持っていただければ何よりです。
- 投稿日:2019-12-13T00:09:24+09:00
Vue.jsチュートリアル 〜Vue.js + TypeScriptでTrelloもどきを作ろう①〜
みなさん、お待たせしました。コスパエンジニアのyoshiharu2580です。
フロントエンド業務に就いてもうすぐ半年が経ちます。
まだまだ未熟者ですがこのあたりで少し初心に返るために、下のTrelloのようなタスク管理ツールを作成するチュートリアルを作りました。
詳細な説明は公式ドキュメントに譲るとして、この記事ではとりあえず動かすことを目標とします。
Vue.js, TypeScriptが初めての方でも、なんとなく理解できましたら幸いです。対象読者
- Vue.jsを触ったことがない人
- TypeScriptを触ったことがない人
- HTML, CSS, JavaScriptが少しわかる人
初期セットアップ
vue-cliで環境を構築します。(公式ドキュメント)
※2019.11.28時点でv 4.0.5$ yarn global add @vue/cli $ vue create my-project# 各項目の、:の後の内容を選択してください。 ? Please pick a preset: Manually select features ? Check the features needed for your project: Babel, TS, CSS Pre-processors, Lin ter ? Use class-style component syntax? Yes ? Use Babel alongside TypeScript (required for modern mode, auto-detected polyfi lls, transpiling JSX)? Yes ? Pick a CSS pre-processor (PostCSS, Autoprefixer and CSS Modules are supported by default): Sass/SCSS (with node-sass) ? Pick a linter / formatter config: Prettier ? Pick additional lint features: Lint on save, Lint and fix on commit ? Where do you prefer placing config for Babel, PostCSS, ESLint, etc.? In dedica ted config filesプロジェクトが作成できたら起動してみましょう。
$ cd trello-clone $ yarn serve画面が表示されました。とても簡単ですね。
ここでVue.jsとTypeScriptの簡単な概要を説明します。
Vue.jsの基本は、JavaScriptのデータとDOMを紐づけるだけのフレームワークです。
JavaScriptのリアクティブなデータが更新されると、それにあわせてDOMが自動的に更新されます。
まずは見た目を変えるためにデータを変えるということだけ覚えておきましょう。TypeScriptは型があるJavaScriptです。
TypeScriptを導入することで、開発をわかりやすく安全にし、結果的に開発スピードを上げることができます。さて、本題に戻ります。
初期セットアップとしてHello.vue
を削除し、App.vue
を以下のようにします。App.vue<template> <div id="app" /> </template> <script lang="ts"> import { Component, Vue } from "vue-property-decorator"; @Component export default class App extends Vue {} </script>まっさらになりましたね。それでは実際に作っていきましょう。
型定義とリストレンダリング
ここではタスクを表示するものをカード、カードを束ねるものをリストと呼ぶことにします。
今回のアプリの要件は以下の通りです。
- リスト、カードの追加機能
- リスト名、カードのテキストの編集機能
- リスト、カードの削除機能
- リスト、カードの移動機能
機能を持つUIの部品のことをコンポーネントといいます。
Vue.jsなどのコンポーネントベースのライブラリでは、このコンポーネントを組み合わせてUIを作っていきます。コンポーネントの設計では、まずデータの型の考察から始めます。
要件を満たすには、リスト、カードのデータ型はそれぞれ以下のようになります。
- リスト
- 自身を特定するためにidを持つ
- リスト名を持つ
- カードの配列を持つ
- カード
- 自身を特定するためにidを持つ
- カードのテキストを持つ
src
ディレクトリ配下にtypes.ts
というファイルを作成し、TypeScriptで型を書いてみましょう。
オブジェクトの型定義には基本interface
を使います。
コンポーネント名をそれぞれCard
,List
とするので、ここではバッティングを防ぐためにオブジェクトの型にプレフィックスとしてinterface
の頭文字Iを付けることにします。types.tsexport interface IList { /* ・idは途中で変えないので、readonly修飾子を付ける(値を変えるとエラーが発生) ・数値なのでnumber型 */ readonly id: number; name: string; // 文字列なのでstring型 cards: ICard[]; // 配列を定義するには 要素[] とする } export interface ICard { readonly id: number; text: string; }モックデータを返すファクトリ関数を別ファイルに定義します。
関数に型をつける際はかっこの後に: 型名
と書きます。
これで戻り値の構造が型と異なる場合にコンパイルエラーが発生します。安全ですね。initialData.tsimport { IList } from "@/types"; export function createInitialLists(): IList[] { return [ { id: 1, name: "リスト1", cards: [ { id: 1, text: "タスク1" }, { id: 2, text: "タスク2" } ] }, { id: 2, name: "リスト2", cards: [ { id: 3, text: "タスク3" }, { id: 4, text: "タスク4" } ] } ]; }
App.vue
のような、拡張子が.vue
のファイルをSFC(単一ファイルコンポーネント)といい、この中でコンポーネントを定義します。
template
タグ内にHTML、script
タグ内にJavaScript、style
タグ内にcssを記述します。
script
タグの属性にlang="ts"
とすることでTypeScriptが使えるようになります。Vueのリアクティブなデータの中で最も基本的なものが
data
です。
クラスのプロパティを定義することでdata
を登録することができます。
先ほど作成したcreateInitialLists
を実行した返り値をlists
に代入しましょう。
data
には型を付けなくてもいいですが、付けることをおすすめします。App.vue〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜 <script lang="ts"> import { Component, Vue } from "vue-property-decorator"; + import { IList } from "@/types"; + import { createInitialLists } from "@/initialData.ts"; @Component export default class App extends Vue { + lists: IList[] = createInitialLists(); } </script>これで
lists
というdata
にモックデータを設定することができました。次に、カードのコンポーネント名を
Card
、リストのコンポーネント名をList
として新しく作成しましょう。
こちらをcomponents
ディレクトリ配下に以下の内容であらかじめ作成しておきます。List.vue<template> <div /> </template> <script lang="ts"> import { Component, Vue } from "vue-property-decorator"; @Component export default class List extends Vue {} </script>Card.vue<template> <div /> </template> <script lang="ts"> import { Component, Vue } from "vue-property-decorator"; @Component export default class Card extends Vue {} </script>作成した
List
コンポーネントをApp.vue
で使えるようにしましょう。
@Component
デコレータのcomponents
オプションにList
を登録すると、template
内でList
というカスタム要素として使用することができます。リストレンダリング(配列の数だけDOM要素を描画)をするには
v-for
を使います。
リストレンダリングしたい要素の属性にv-for="要素 in 配列"
を追加します。
こうすることで、配列の要素を属性, テキスト, 子要素のそれぞれで使うことができるようになります。
ここではv-for="list in lists"
としましょう。
v-for
とセットでkey
属性も追加します。
:key="一意な値"
とすることでレンダリングを最適化することができます。
ここでは:key="list.id"
として、属性値にリストのidを与えましょう。
:属性名(任意)="値"
をコンポーネントの属性に追加することで、渡された子コンポーネントではその属性名で値を受け取れるようになります。
属性値でJavaScriptの式を使うにはv-bind
ディレクティブ(省略記法は:
)を使います。
List
コンポーネントでlist
のデータを使うために、属性名をわかりやすく値の変数名と同じlist
にして、値にlist
を代入しましょう。App.vue<template> <div id="app"> + <List v-for="list in lists" :key="list.id" :list=list /> </div> </template> <script lang="ts"> import { Component, Vue } from "vue-property-decorator"; + import List from "@/components/List.vue"; import { IList } from "@/types"; import { createInitialLists } from "@/initialData.ts"; + @Component({ + components: { + List + } + }) export default class App extends Vue { lists: IList[] = createInitialLists(); } </script>
List.vue
を見ていきましょう。
子コンポーネント内で親から渡されたデータ(これをprops
といいます)を受け取るには、@Prop
デコレータを使って定義します。
@Prop
デコレータの引数にはオプションのオブジェクトを渡すことができます。
そのオプションのtype
プロパティではざっくりとした型を付与することができます。
list
はオブジェクトなのでObject
を指定しましょう。他にはこんな値があります。
required
プロパティは、そのpropsが必須かどうかを指定します。
required: true
にしてpropsが渡されなかった場合はエラーが発生します。
required: true`にしましょう。
できる限りApp.vueで
list
として渡したので、受け取る際の変数名はlist
にします。
変数名の後には型を付与することができます。List.vue<template> <div> + {{ list.name }} <!-- JSの式を二重中括弧で囲うとテキスト展開される(マスタッシュ構文) --> </div> </template> <script lang="ts"> + import { Component, Vue, Prop } from "vue-property-decorator"; // Propを追加 + import { IList } from "@/types"; @Component export default class List extends Vue { + @Prop({ type: Object, required: true }) + list!: IList; } </script>リスト名が表示されました。
list
の後に!
が付いていますね。
これはTypeScriptのNon-null assertion operatorというものです。まずunion型について説明します。
union型とは複数の型の可能性があるということを表します。
型と型を|
で繋ぐように書き、例えば、string | number
は文字列型か数値型の可能性があるということです。
props
は渡されない可能性があるので、undefined
の可能性があります。
これをオプショナルなプロパティといい、undefined
とのunion型と推論されます。
オプショナルなプロパティがundefined
ではないことを表現するためにNon-null assertion operatorを使います。
@Prop
デコレータのオプションで{ required: true }
としており、親からデータが渡されないとエラーが発生することが担保されているので、!
を変数名の後ろに付けます。同様にList.vue内でも
Card
をリストレンダリングしましょう。List.vue<template> <div> {{ list.name }} + <Card v-for="card in list.cards" :key="card.id" :card="card" /> </div> </template> <script lang="ts"> import { Component, Vue, Prop } from "vue-property-decorator"; + import Card from "@/components/Card.vue"; import { IList } from "@/types"; + @Component({ + components: { + Card + } + }) export default class List extends Vue { @Prop({ type: Object, required: true }) list!: IList; } </script>あわせてCard.vueも変更します。
Card.vue<template> + {{ card.text }} </template> <script lang="ts"> import { Component, Vue, Prop } from "vue-property-decorator"; + import { ICard } from "@/types"; @Component export default class Card extends Vue { + @Prop({ type: Object, required: true }) + card!: ICard; } </script>リストレンダリングができました。
cssを使ってわかりやすくしましょう。(cssに関しては、各章の終わりの「この時点でのコミット」を参考にしてください。)
この時点でのコミットリストとカードの追加機能
次はリストの追加機能です。
フォームにリスト名を入力してEnterキーを押したら、新しくリストが追加されるようにしましょう。ブラウザでは、ユーザーがDOM要素に対してクリックなどのアクションを起こした際などにイベントが発生します。
このイベントが発生した時に関数を呼び出すことができます。呼び出す側のことをイベントリスナ、呼び出される関数のことを特にイベントハンドラといいます(とします)。
これらを実際にDOM要素に登録するには、v-on
ディレクティブ(省略記法は@
)を使って`@イベント名="イベントハンドラ"とします。
これで、登録したDOM要素でそのイベントが発生したときにイベントハンドラが呼び出されます。なので、イベントハンドラ内で
data
を更新する処理を書けば、
- ユーザーがアクションを起こすとイベントが発生し、イベントハンドラが呼び出される
- イベントハンドラ内で
data
を更新する- dataが更新されるとVue.jsがDOMを更新する
という流れを作ることができます。
data
を変更する処理は、基本data
が存在するコンポーネント内で定義します。
lists
があるのはApp.vueなのでApp.vueのクラス内のメソッドに定義します。テキスト入力の際に発生するイベントにはinputイベントとchangeイベントがあります。
inputイベントは入力する度に発生し、changeイベントはinput要素からフォーカスを外した際やEnterキーを押した際などに発生します。
ここではchange
イベントリスナを選択します。
change
イベントリスナにaddList
メソッドをイベントハンドラとして紐づけたものを、inputタグに登録しましょう。App.vue<template> <div id="app"> <template v-for="list in lists"> <div class="list-container" :key="list.id"> <List :list="list" /> </div> </template> + <input type="text" @change="addList" /> </div> </template> <script lang="ts"> import { Component, Vue } from "vue-property-decorator"; import List from "@/components/List.vue"; import { IList } from "@/types"; import { createInitialLists } from "@/initialData.ts"; @Component({ components: { List } }) export default class App extends Vue { lists: IList[] = createInitialLists(); // 値を返さない関数の返り値の型としてvoid型を付与 + addList(): void {} } </script> 〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜ここでリストの型についておさらいしましょう。リストの型は以下の通りです。
types.tsexport interface IList { readonly id: number; name: string; cards: ICard[]; } 〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜これにより、新しくリストを追加するには、
- 作成する度に一意のidが付けられ、
- フォームに入力した値がリスト名になり、
- 空のカードの配列を持った、
新しいリストを
lists
に追加すればいいことになります。まずidです。idは、リストを作成した数+1とします。
この値は更新されるので、listCreatedCount
としてdataに登録します。
初期値としてcreateInitialLists()
の戻り値の配列の要素数である2を代入します。(厳密にはcreateInitialLists().length
などにした方がいいかもしれません。)次にリスト名についてです。
イベントハンドラをDOM要素に登録した際に@change="addList"
と、addList
に引数を与えていないので、メソッドの第一引数にイベントオブジェクト(発生したイベントの詳細な情報が詰まったオブジェクト)が渡されます。(addList(event): void {}
)このchangeイベントのイベントオブジェクトに型を付けましょう。
どのイベントにどの型をつければいいかは、TypeScriptの開発元であるMicrosoftのこちらのサイトに載っています。(直接型定義を見ても構いません。GlobalEventHandlersEventMap
型)
見てみると、
型名はEvent
ですね。この型はグローバルに登録されている(tsconfig.json
というTypeScriptの設定ファイルで設定している)ので、import
せずにそのまま使うことができます。
event
に型Event
を付与しましょう。(addList(event: Event }): void {}
)このイベントオブジェクトの中に、フォームに入力した値が入っています。
イベントオブジェクトのcurrentTarget
プロパティが、イベントリスナが実際に登録されたDOM要素で、この中のvalue
プロパティが目当てのそれです。
しかし、Event
型にはcurrentTarget
プロパティがありません。
型Event
はload
イベントなど、DOM要素以外で発生するイベントの型でも使われているからです。とりあえず、現状この
Event
型にはcurrentTarget
プロパティがないので、currentTarget: HTMLInputElement;
(HTMLInputElement
はinput要素の型)というプロパティをEvent
型に追加します。(新しく型を作ってもいいと思います。)
あるオブジェクトの型にプロパティを追加するには、そのプロパティを持つオブジェクト型を&
を使って繋げます。
このようにしてできた型をintersection型といいます。
(event: Event & { currentTarget: HTMLInputElement; }
)それではここまでのコードを見てみましょう。
App.vue〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜 <script lang="ts"> import { Component, Vue } from "vue-property-decorator"; import List from "@/components/List.vue"; import { IList } from "@/types"; import { createInitialLists } from "@/initialData"; @Component({ components: { List } }) export default class App extends Vue { lists: IList[] = createInitialLists(); + listCreatedCount = 2; + addList(event: Event & { currentTarget: HTMLInputElement }): void { + const newList = { + id: this.listCreatedCount + 1, + name: event.currentTarget.value, + cards: [] + }; + this.lists.push(newList); // listsに追加されたため、listCreatedCountをインクリメント + ++this.listCreatedCount; // フォームの値をリセットするために空文字を代入 + event.currentTarget.value = ""; + } } </script> 〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜リストを追加できました。
同様にカードを追加するためのコードを書いていきましょう。
List.vue
内にカードを追加するinput要素を追加し、input要素のchange
イベントリスナにaddCard
メソッドを登録しましょう。List.vue<template> <div class="list"> {{ list.name }} <Card v-for="card in list.cards" :key="card.id" class="card" :card="card" /> + <input type="text" @change="addCard" /> </div> </template>
lists
はApp.vue
にあるので、カードを追加する処理はApp.vue
に書きます。App.vue〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜 <script lang="ts"> import { Component, Vue } from "vue-property-decorator"; import List from "@/components/List.vue"; import { IList } from "@/types"; import { createInitialLists } from "@/initialData"; @Component({ components: { List } }) export default class App extends Vue { 〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜 + addCard(): void {} } </script> 〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜このままではList.vue内で
addCard
を定義していないため、エラーが発生してしまいます。
子コンポーネントで発生したイベントと親コンポーネントで定義したメソッドを紐付けるには、どのようにすればいいのでしょうか。
この場合には以下のようにします。
- 親コンポーネント内で、子コンポーネントのカスタム要素の属性に、独自で定義したカスタムイベントのイベントリスナにイベントハンドラを登録する。
- 子コンポーネント内のイベントハンドラで、そのカスタムイベントを発生(emit)させる。
以上のようにすることで、子コンポーネントで発生したイベントと親コンポーネントで定義したメソッドを紐付けることができます。
まず1から。
HTMLでは大文字は小文字に変換されてしまうため、カスタムイベントのイベントリスナ名は基本ケバブケースにします。
App.vue
でメソッド名をaddCard
としたので、カスタムイベント名をadd-card
としましょうか。(@add-card="addCard"
)
これで親コンポーネント側はOKです。App.vue<template> <div id="app"> <template v-for="list in lists"> <div class="list-container" :key="list.id"> + <List :list="list" @add-card="addCard" /> </div> </template> <input type="text" @change="addList" /> </div> </template> 〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜次に2について。
メソッド名をカスタムイベント名のキャメルケースにし、@Emit
デコレータを付与することで、そのカスタムイベントをemit
することができます。
カスタムイベント名がadd-card
なのでメソッド名をaddCard
とします。List.vue<template> <div class="list"> {{ list.name }} <Card v-for="card in list.cards" :key="card.id" class="card" :card="card" /> + <input type="text" @change="addCard" /> </div> </template> <script lang="ts"> + import { Component, Vue, Prop, Emit } from "vue-property-decorator"; import Card from "@/components/Card.vue"; import { IList } from "@/types"; @Component({ components: { Card } }) export default class List extends Vue { @Prop({ type: Object, required: true }) list!: IList; + @Emit() + addCard(event: Event & { currentTarget: HTMLInputElement }): void {} } </script> 〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜ここで大事なことがあります。
子コンポーネントでemitするメソッドの返り値が、親コンポーネントで受け取れるイベントオブジェクトになるということです。
これでカードを追加するために必要なデータを送ることができます。カードを追加するために必要なデータは、追加するリストのidとカードのテキストです。
これをまずIAddCardEvent
として型定義しましょう。
型を定義して子コンポーネント側のメソッドの返り値の型、親コンポーネント側メソッドの引数の型として付与すれば、齟齬がなく型安全になります。List.vue〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜 <script lang="ts"> + export interface IAddCardEvent { + listId: number; + text: string; + } + import { Component, Vue, Prop, Emit } from "vue-property-decorator"; import Card from "@/components/Card.vue"; import { IList } from "@/types"; @Component({ components: { Card } }) export default class List extends Vue { @Prop({ type: Object, required: true }) list!: IList; @Emit() + addCard(event: Event & { currentTarget: HTMLInputElement }): IAddCardEvent { // 次の処理でリセットしてしまうので変数に格納 + const text = event.currentTarget.value; // フォームの値をリセット + event.currentTarget.value = ""; // 返す内容が複数あるのでオブジェクトで返す + return { + listId: this.list.id, + text + }; } } </script> 〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜この型を
App.vue
側のメソッドの第一引数に付与しましょう。
これで親コンポーネント側でも、このカスタムイベントのイベントオブジェクトの型がIAddCardEvent
であることが約束されました。
(実際にはここまでする必要は無いかもしれません。将来、わざわざ型定義しなくても推論されるようになるといいですね。)また、
App.vue〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜 <script lang="ts"> import { Component, Vue } from "vue-property-decorator"; import List from "@/components/List.vue"; import { IList } from "@/types"; import { createInitialLists } from "@/initialData"; + import { IAddCardEvent } from "@/components/List.vue"; @Component({ components: { List } }) export default class App extends Vue { lists: IList[] = createInitialLists(); listCreatedCount = 2; + cardCreatedCount = 4; 〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜 + addCard({ listId, text }: IAddCardEvent): void { + const list = this.lists.find(list => list.id === listId); /* findは見つからなかった場合undefinedを返す可能性があるので、その場合は早期リターンする (ここではlist: IList | undefined) */ + if (list === undefined) return; + const newCard = { + id: this.cardCreatedCount + 1, + text + }; // ここではlist: IList + list.cards.push(newCard); + + ++this.cardCreatedCount; + } } </script> 〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜これでカードの追加ができるようになりました。
ここでカスタムイベントのおさらいをしておきましょう。
- ユーザーがイベントを発火
- そのイベントに紐づけられた子コンポーネントのイベントハンドラが呼び出される
- その子コンポーネントのイベントハンドラがカスタムイベントをemit(発火)する
- そのカスタムイベントに紐づけられた親コンポーネントのイベントハンドラが呼び出される
という処理の順番になります。
この続きは12/19に公開されます。
お楽しみに。
- 投稿日:2019-12-13T00:06:27+09:00
google-maps-services-jsにてGeocoding APIがiOSで動作しない場合の対応
背景
事故マップというサービスを個人開発で最近始めました。
マップを任意の場所にジャンプさせるために検索フォーム設けています。
ここでユーザーが入力したキーワードをGoogleMaps Geocoding APIにかけて
そのキーワードに対応する緯度経度を取得してMapを移動させるようにしています。また、このGeocoding APIを利用するためにGoogle謹製のgoogle-maps-services-jsを使っていました。
遭遇した問題
実際のコードではこのような感じでリクエストを処理していました。
Vue.jsconst googleMapsClient = require("@google/maps").createClient({ key: "API key XXXXXXXX", Promise: Promise }); findLatLng() { googleMapsClient .geocode({ address: this.searchWord // ユーザーが入力したキーワード }) .asPromise() .then((response) => { console.log(response.json.results); }) .catch(err => { console.log(err); }); }macOS、Androidでは上記コードでレスポンスを取得する事ができていたのですが、
何故かiOSだけ次のようなエラーが出て必ず処理が失敗してしまいました。
TypeError: Request header field User-Agent is not allowed by Access-Control-Allow-Headers.
原因
いろいろ調べてみましたが、正確な原因はわかりませんでした。
しかし、別のモジュールを使ってみたところうまくいったので
google-maps-services-jsの何らかの不具合の可能性が高いです。対応
実際、vue2-geocoderを使ったところ、iOSでもレスポンスを取得できるようになりました。
ちなみにvue2-geocoderは中でXMLHttpRequestを使ってリクエストを投げているだけの非常にシンプルな作りになっています。
vue2-geocoderでの処理例
Vue.jsfindLatLng(evt) { evt.preventDefault() var self = this var addressObj = { address_line_1: this.searchWord } Vue.$geocoder.send(addressObj, response => { console.log(response) if (response.status == 'ZERO_RESULTS') { console.log("no result"); } else { console.log(response.results[0]); } }) }