- 投稿日:2020-11-19T22:09:36+09:00
SPAとは?
はじめに
最近SPA開発に興味を持ったので、簡単ではありますが、メリット・デメリットなどをまとめました。
(文字だらけですが・・・)SPAとは
「Single Page Application」の略。単一ページ中でコンテンツの切り替えを行うWebアプリケーションのこと。
SPAを開発する上で、Vue.jsやReactなどのJavaScriptのフレームワーク・ライブラリを利用することが多い。
ブラウザでのページ遷移を必要とせずに新しい情報を読み込むことができる。
SPAの代表例
- slack Webページ
- Facebookメッセンジャー Webページ
- GoogleMap
- trello
など・・・
これらはWebではありますが、ページ遷移が無いので、スムーズに情報を閲覧できる。
SPAのメリット
①ユーザーのページ遷移ストレスが無くなる
ページのリロード(読み込み)が無いので、ページ遷移時にいちいちリロードされるのでユーザーにとってはストレスに感じる。
SPAは従来のWebサイトのように、ページ遷移ごとにページを丸々差し替える(新しいHTMLを表示させる)ことがなく、必要な部分のコンテンツのみ遷移させるので、画面全体が再描画されるストレスから解放される。②アプリのようなUX
SPAでは共通の部品は更新しないで、差分だけ更新するので、ユーザー一覧を表示したまま別の一覧ページさせたりすることができる。
③開発の手間を省くことができる
SPAは幅広いUIを実装可能なので、iOSアプリやAndroidアプリのような端末内にダウンロードするネイティブアプリの代用として使用できる。
ネイティブアプリはアプリのリリース前に審査を通過させる必要がある
、デバイスごとの挙動を考慮する必要がある
というハードルがありますが、そういった問題を考慮せずに公開することができる。デメリット
①初期ローディングに時間を要する
SPAは差分だけ更新させる動作をさせるので、フロントエンド側(JavaScript)で記述をするが、そのコード量が増加しがちなので、コードを読み込む時間がかかり、最初のページ読み込み時に時間がかかる。
②実装コストがかかる
ブラウザ側に処理を任せていた部分を実装する必要があるので、特にフロントエンドの実装コストがかかる。
③ネイティブアプリのようにモバイルのホーム画面に登録してもらい辛い
モバイルの場合、Webページをホーム画面に登録してもらえる機会が減る可能性が高い。普段使いには厳しいかも。
④SPAが実装できるエンジニアがまだまだ少ない
国内だと、まだまだSPAはメジャーでは無いので、開発実装に対応できるフロントエンドエンジニアが少ない(少しずつ増えてきているのかも??)
どんなサービスに向いてる?
最初の読み込みは時間がかかるものの、初回読み込み後の各操作については更新のタイムラグが無い。そのため
ユーザー操作が多く、滞在時間が長いサービスがSPAに向いている
。
映画館や新幹線などのチケットの座席指定やグラフ・図などを用いたデータ可視化サービスなどに向いている。これらサービスは、検索が行われる度にページ遷移が発生し、頻繁に待ち時間が発生するのでSPA化することで、検索→結果表示が単一画面上で行われ、待ち時間を最小限に抑えることができる。どんなサービスが向いていない?
滞在時間が長くないサービス、つまり直帰率が高い(1ページのみ閲覧して、ページから離脱してしまった割合)サービスはSPAに不向き
。
ブログなどの1ページのみのサイトなどが挙げられる。
直帰率の高いサービスにSPAを採用しても、最初のアクセス時に読み込み時間を多く要してしまうので時間がかかるばかりでメリットが感じられない。「サービスの挙動を早くするためにSPAを採用したい!!」というのは少し考える必要がある。
SPAは同時に情報処理をすることは得意だが、データ更新が早くなるわけでは無いので、データ処理を早くするためにはバックエンドの構築を再考するのが良い。まとめ
SPAを採用する際はどういうサービスを開発したいのかを明確にして、SPAのメリット&デメリットを考慮しながら導入していきたいところ。
参考
https://digitalidentity.co.jp/blog/creative/about-single-page-application.html
https://www.oro.com/ja/technology/001/
- 投稿日:2020-11-19T20:02:45+09:00
Vue.js、Firebase、axiosでパパッと掲示板!
この記事の概要
超簡単な掲示板アプリをパパっと作成します。
細かいことはいいからとりあえずVue.jsで何かアプリを作ってみたいという方にオススメです。目標物
開発環境
・macOS Catalina 10.15.7
・@vue/cli 4.5.6
・npm 6.9.0
・node v10.16.0前提
・node、npm、vue-cli環境が整っている。
・firebaseのアカウントを作成している。
firebaseのプロジェクト作成。
firebaseに行き
【コンソールへ移動】
→【プロジェクトの作成】または【プロジェクトの追加】プロジェクト名は「vue-test」としておきます。
アナリティクスは無効でOKです。
DBの作成
ロケーションを「asia-norheast1(東京)」に設定。
DBができました。
これでfirebaseの設定は終わりです!
プロジェクト作成
ターミナル$ vue create vue-test $ cd vue-testcd vue-test$axiosをインストール
ターミナルvue-test$ npm install axios
アプリ立ち上げ。
ターミナルvue-test$ npm run serveView作成
App.vue<template> <div> <h1>掲示板!</h1> 名前 <div><input type="text" v-model="name"></div> コメント <div><textarea v-model="comment"></textarea></div> <br> <button @click="submitPosts">投稿する</button> <br><br> <h2>投稿一覧</h2> </div> </template> <script> export default { deta() { return { name: '', comment: '' } }, methods: { submitPosts() { console.log('submit'); } } } </script>
とりあえず【投稿】ボタンを押したら【submit】と出力させておきましょう。
データを送る
まずaxiosを
import
する必要があります。App.vue<script> import axios from 'axios'// <- export default { deta() { return { name: '', comment: '' } }, </script>データを送る為に
axios.post()
を使用します。
第一引数:サーバーのURL
第二引数:データの内容
第三引数:オプション(任意)App.vue<script> import axios from 'axios' export default { deta() { return { name: '', comment: '' } }, methods: { submitPosts() { //----↓ここから-------------- axios.post( "https://firestore.googleapis.com/v1/projects/YOUR_PROJECT_ID/databases/(default)/documents/posts", { fields: { name: { stringValue: this.name }, comment: { stringValue: this.comment } } } ).then(() => { this.name = ''; this.comment = ''; }); //---↑ここまで-------------- } } } </script>今回オプションは取りません。
.then
には通信が成功したときの処理を指定できます。
今回はthis.name
とthis.comment
を空にしています。URLは
https://firestore.googleapis.com/v1/projects/YOUR_PROJECT_ID/databases/(default)/documents/cities/LA
↑を入れます。
こちらに乗ってあるのを持って来ただけです。※※※
しかしこれでは不十分で、URL内のYOUR_PROJECT_ID
の部分を自分のプロジェクトIDに置き換える必要があります。
以下、適宜
YOUR_PROJECT_ID
を自分のプロジェクトIDに置き換える必要があることに注意してください。次に、URL末尾の
cities/LA
を任意のコレクション名(データを格納する場所の名前)にします。
今回はposts
とします。
データが入っています!
データの取得
では今度はサーバーからデータを取ってきましょう。
データの取得はaxios.get()
を使用します。
第一引数:サーバーのURL
第二引数:オプション(任意)サーバーのURLは
axios.post()
で使用したものと全く同じです。取得するタイミングはロード時とデータ送信時に行いたいので、
getPosts
メソッドを作り各所で呼び出しましょう。App.vue<script> import axios from "axios"; export default { data() { return { name: '', comment: '' }; }, //----↓ここから-------------- created() { this.getPosts(); }, //----↑ここまで-------------- methods: { submitPosts() { axios.post( "https://firestore.googleapis.com/v1/projects/YOUR_PROJECT_ID/databases/(default)/documents/posts", { fields: { name: { stringValue: this.name }, comment: { stringValue: this.comment } } } ) .then(() => { this.name = ''; this.comment = ''; //----↓ここ-------------- this.getPosts(); }); }, //----↓ここから-------------- getPosts() { axios.get( "https://firestore.googleapis.com/v1/projects/YOUR_PROJECT_ID/databases/(default)/documents/posts" ) .then(res => { console.log(res.data.documents); }); } //----↑ここまで-------------- } }; </script>
.then(res => {
console.log(res.data.documents);
});
このres
の中に取得したデータが入っているので確認してみます。
バッチリ入っています。
あとはこの配列をv-for
で順番に表示させていきます。データの表示
・
date
に空配列posts
を準備。
・getPosts
が呼ばれたタイミングでres.data.documents
を配列posts
に格納。
・配列posts
をリストレンダリングしています。App.vue<template> <div> <h1>掲示板!</h1>名前 <div> <input type="text" v-model="name"> </div>コメント <div> <textarea v-model="comment"></textarea> </div> <br> <button @click="submitPosts">投稿する</button> <br> <br> <h2>投稿一覧</h2> <!-----↓ここから--------------------------------------------------------> <div v-for="post in posts" :key="post.name"> <hr> <p>名前:{{post.fields.name.stringValue}}</p> <p>コメント:{{post.fields.comment.stringValue}}</p> </div> <!-----↑ここまで--------------------------------------------------------> </div> </template> <script> import axios from "axios"; export default { data() { return { name: '', comment: '', //----↓ここ-------------------------- posts: '' }; }, created() { this.getPosts(); }, methods: { submitPosts() { axios.post( "https://firestore.googleapis.com/v1/projects/YOUR_PROJECT_ID/databases/(default)/documents/posts", { fields: { name: { stringValue: this.name }, comment: { stringValue: this.comment } } } ).then(() => { this.name = ''; this.comment = ''; this.getPosts(); }); }, getPosts() { axios.get( "https://firestore.googleapis.com/v1/projects/YOUR_PROJECT_ID/databases/(default)/documents/posts" ) .then(res => { //----↓ここ-------------------------- this.posts = res.data.documents; }); } } }; </script>完成!
ここまで見て頂きありがとうございました!
とりあえず作って動かすを目的にしているので細かい解説はしていません(←できません)
コピペで動かす際はURLの
YOUR_PROJECT_ID
を適宜自身のプロジェクトIDに置き換えることを注意してください。
- 投稿日:2020-11-19T19:55:30+09:00
【Vue.js】v-modelの正体
はじめに
日頃からv-modelを脳死で使用していたので、v-modelの仕組みを理解するためにgoogle先生に聞きました。
v-modelとは
v-modelとは、入力値や入力値の変更と、そのデータとを結びつけるディレクティブです。
...少し難しいですね。
言葉で説明するのは難しいので、以下のコードをご覧ください。
Vue.js<template> <input type="text" v-model="message" > <p>{{ message }}</p> </template> <script> export default { data() { return { message: '' } } } </script>inputタグの
v-model
とdata()のmessage
をバインディングさせています。すると、このように、
inputに文字を入力すると、バインディングさせた
<p>{{ message }}</p>
にも同じ文字が入力されます。v-modelを使用すると、このような魔法を扱う事ができます。
v-modelは糖衣構文である
糖衣構文とは本来の記述よりも簡単な記述方法という意味です。
「シンタックスシュガー」とも言われます。v-modelが糖衣構文であることは、公式にも明記されています。
v-model はユーザーの入力イベントにおいてデータを更新するための基本的な糖衣構文 (syntax sugar) で、
つまりv-modelは本来、少し難しい記述が必要で、
この記事では、その「本来(正体)」を暴いていきます。v-modelの正体
v-modelの正体は、「v-bind」と「v-on」の2つのディレクティブを組み合わせた糖衣構文です。
これも言葉で説明するのが難しいので、次のコードをご覧ください。
次のコードでは、「v-modelでの記述方法」と「v-bind, v-onでの記述方法」の2通りの記述をしています。Vue.js<template> <div> <!-- v-model使用時 --> <input type="text" v-model="syntactic" placeholder="v-model使用時" > <p>{{ syntactic }}</p> <!-- v-bind, v-on使用時 --> <input type="text" v-bind:value="original" v-on:input="original = $event.target.value" placeholder="v-bind, v-on使用時" > <p>{{ original }}</p> </div> </template> <script> export default { data() { return { syntactic: '', original: '' } } } </script>
v-bind:value="original"
でinputタグのvalue属性とdata()のoriginal
とを紐づけています。また、
v-on:input="original = $event.target.value"
で、inputに入力した際、data()のoriginal
に$event.target.value(入力した文字)を放り込んでいます。2つの挙動の違いも見てみましょう。
このように、どちらで記述しても挙動は変わりません。
積極的に記述が楽なv-model
を使っていきましょう。さいごに
糖衣構文の正体を理解しておくことは、とても大切
(らしい)です。
私自身もv-modelを適当に使用していたので、良い勉強になりました。また、
v-model
を使わない方が良い場面なんかもあるんですかね?
私、気になります!今回は以上です。
- 投稿日:2020-11-19T18:34:11+09:00
[Vue.js]v-for
v-forを使って配列を操作
js //データに配列を作っておきます data:{ fruits:['バナナ','りんご','びわ']html <ul> //fruitsの中身をfruitにいれる{{}}の中身に順番にfruitをいれていく <li v-for ="fruit in fruits">{{fruit}}</li><hr> </ul>
- バナナ
- りんご
- びわ
と表示される
第2引数を渡す
html <ul> ()で囲ってカンマで区切って第二引数 <li v-for ="(fruit,index) in fruits">{{fruit}}</li><hr> </ul>第2引数では配列のインデックス番号が取得できる
html <ul> //(0)バナナ のように表示される <li v-for ="fruit in fruits">({{index}}){{fruit}}</li><hr> </ul>v-forを使ってオブジェクトを操作
js data:{ //データにオブジェクトを作っておく fruits{ yellow:'バナナ', red:'りんご', orange:'オレンジ' } }html <ul> //valueの中にオブジェクトの値を入れていく <li v-for = "value in fruits>{{value}}</li> </ul>第2引数と第3引数で取得できるもの
配列と同じように記述できる
//2番目はキー、3番目はインデックス番号を取得できる <li v-for = "(value,key,index) in fruits>{{value}}</li> </ul>全てのliに下線をつける
テンプレートタグをつけて記述する
ul> //テンプレートタグの中にv-for~を書く <template v-for ="(fruit,index) in fruits"> //表示させる中身はliに書く <li>({{index}}){{fruit}}</li><hr> </template> </ul>
- (0)バナナ
と表示される
- 投稿日:2020-11-19T18:21:13+09:00
【Vue】コンポーネントのインポートを1行で記述する方法
テンプレートをコンポーネントとして読み込む時に、①import fromで読み込む、②componentsプロパティで宣言するという2段回の記述をしていたが、これを1行で記述することができる。
・
components:{ コンポーネント名: () => import("ファイルパス") }
export defaultのcomponentsで定義したコンポーネント名でアロー関数を使ってimportする。
実例
例えば、childTmpというテンプレートを読み込む場合は以下のようになる。
▼2行での記述
(通常)<template> <childTmp :message.sync="msg" /> </template> <script> //①import fromで読み込む import childTmp from "./childTmp" export default { //②componentsプロパティで宣言 components:{ childTmp } } </script>
▼1行での記述(略記)<template> <childTmp :message.sync="msg" /> </template> <script> export default { components:{ //1行のみ childTmp: () => import("./SyncChiled") } } </script>
- 投稿日:2020-11-19T18:17:26+09:00
Vue.jsでクリップボードにコピー
やりたいこと
値をmethodに渡してそれをクリップボードにコピーしたい
template部分
<v-icon @click="copyToClipboard(text)">mdi-content-copy</v-icon>script部分
methods: { copyToClipboard(text) { navigator.clipboard.writeText(text) .then(() => { console.log("copied!") }) .catch(e => { console.error(e) }) } }その他の方法
以下の記事の様に要素を指定してその内容をコピーすることもできる様です
https://qiita.com/ikemura23/items/eec0eed63fc119606451
- 投稿日:2020-11-19T16:41:47+09:00
SPAの詳細ページ実装を簡単にまとめる
概要
vue.jsでアプリを作る際、詳細画面をどのように実装するのか少しわからなったので、自分が理解した範囲でひとつの方法をまとめていきたいと思います。
今回は、vue.jsのrouterを使用します。
routerを定義
index.tsconst routes = [ { path: '/', //パスの設定 component: Index, //表示するコンポーネント name: 'index', //ルートの名前を指定 }, { path: '/search', component: Search, name: 'Search', }, { path: '/:id', component: Detail, name: 'Detail', }, ]検索(Search)ページ
Child.vue<template> </template> <script lang="ts"> import {Vue} from 'vue' class Child extend Vue { /** 下記の方法でパラメータとして渡されたIDを読み取ることが可能 */ private id = this.route.params.id /** 詳細ページへ遷移 */ private router(){ this.$router.push(`/ ${this.id}`) } </script>↓ idは、routerのpathに指定しているものと同様にする必要がある。
this.route.params.id
もしも、pathを'/:name'
としていた場合、this.route.params.name
になる。他にも実装方法はあるとは思いますが、こちらの方法が簡単な方法ではないかなと思います。
- 投稿日:2020-11-19T16:04:04+09:00
Vuexのstateをお手軽に扱えるようにするTips
個人開発中に、Vuexのstateをお手軽に扱えるようにできたので参考までに共有します。
この方法を使うと、computedにいちいちgetterとsetterを記述しなくても、
this.xxx
でstateへアクセスして、値を代入できるようになります!1. mutationsにsetState関数を作る。
setState関数 … 任意のstateに任意の値をセットする処理。
store.jsexport default new Vuex.Store({ state: { xxx: null, // 例として「xxx」という名のstateを定義します。 }, mutations: { setState(state,nameValue){ const stateName = nameValue[0]; const stateValue = nameValue[1]; state[stateName] = stateValue; } } })2. getとsetをreturnしてくれるstate関数を作る。
state.jsexport const state = (stateName)=>({ // 注: アロー関数じゃないと正しく動きません。 get(){ return this.$store.state[stateName] }, set(stateValue){ this.$store.commit( 'setState', [ stateName, stateValue ] ) } })3. computedにstate関数をセットする。
(正確には、state関数の返り値をセットする。)
component.vueimport {state} from 'state.js'; export default { computed: { xxx: state('xxx'), } }4. this.xxxでstateにアクセスできる。
component.vueexport default { computed: /*略*/, methods: { stateTest(){ const getTest = this.xxx; // → this.$store.state.xxx の値が取得できる。 this.xxx = 'setTest'; // → this.$store.commit( 'setState', [ 'xxx', 'setTest' ] ); が実行される。 } } }補足. mapState関数もつくる。
Vuex公式のmapState関数のように、
配列やオブジェクトで一括定義したいと思うなら、mapState関数もつくります。state.jsexport const mapState = (stateNameList)=>{ // 注: アロー関数じゃないと正しく動きません。 const argumentIsArray = Object.prototype.toString.call(stateNameList) === '[object Array]'; const mapState = {}; for(let i in stateNameList){ const computedStateName = argumentIsArray ? stateNameList[i]: i; const storeStateName = stateNameList[i]; mapState[computedStateName] = { get(){return this.$store.state[storeStateName]}, set(v){this.$store.commit('setState',{[storeStateName]:v})} }; } return mapState; }
- 投稿日:2020-11-19T11:29:33+09:00
2020年にNuxt.jsで実装してきたアニメーションをまとめてみた
hey / STORES advent calendar 2020 7日目を担当する @ume-kun1015 です。
2020年の振り返りとして、この記事ではNuxt.jsで実装してきたアニメーションをまとめようと思います。
概要
Nuxt.jsでの開発を行おうとすると、Vue.jsのコミュニティが活発だからか、自然と多くのUIライブラリやアニメーションライブラリを見ます。しかし、自分はそれらを使わず、ほとんどのケースで自分で実装していく派です。
理由としては、
- 要件を満たすものを探し、実際にプロジェクトに入れてみて、要件を満たせるかの検証に時間がかかる。
- ライブラリが提供しているUIとデザイナーから要求されるUIの調整が難しい。
- 将来要求される機能追加や変更を叶えられるかがわからない。
- tree-shakingが未対応であるライブラリの場合、使わない機能のJavaScriptまでimportし、結果プロジェクトのバンドルサイズが増えてしまう。
- バージョン管理も長期的な運用コストにもなる。
- Vue.jsの中にアニメーション実装のための
<transition>
タグや<transition-group>
タグがある。というのがあります。
2020年では、UI・UX向上のためWEBサイトのデザインリニューアルを担当していました。多くのアニメーション実装が必要でしたが、上の理由から、適宜要件に合うように自分で実装してきました。多くの学びがあったため、振り返りを兼ねて、それらの一部をまとめてみようと思います。Vue.jsの
<transition>
を使ったケースと使わなかったケースがあるので、その観点でグループ化しました。開発環境
開発環境は以下のものになります。
- Nuxt.js 2.14.7
- 今回フロントの実装の記事になり、SSRモードで実装する必要はないので、
ssr
オプションはfalse
にして、SPAアプリケーションとして開発しています。components
のオプションはtrue
にして、 コンポーネントの自動importが効くようにしました。- TypeScript 3.9.7
紹介するアニメーションたち
Vue.jsのtransitionを使って実装したもの
- スライドメニュー
- ポップアップ
- アコーディオン
Vue.jsのtransitionを使わないで実装したもの
- モーダル
- ギャラリー
Vue.jsのtransitionを使って実装したもの
Vue.jsでアニメーションを実装すると言えば、上であげた
<transition>
タグを使うことがまず思いつくかと思います。自分も多く使ってきたので、実装してきたアニメーションの中で<transition>
タグを使ったケースをあげたいと思います。スライドメニュー
まずはスライドメニューです。使い方としては、ヘッダーメニューをスライドで開閉を切り替えるようにし、横からスライドで表示するというのがあります。
実装内容もシンプルで、Vuejsの
<transition>
タグのドキュメンテーションの一番最初にあるサンプルを参考にして実装したものです。サンプルは開くときに右から左にスライドし、閉じるときには左から右にスライドします。開くときは、
<transition>
タグの中身のdomがレンダリングされるのにフックして、
- background: 初期値で
opacity: 0
で透明から、transition: opacity 0.15s
で少しずつ背景を黒に変化していきます。- menu: 初期値が
translateX(10%)
で右に少しずらした位置から、transition: all 0.15s ease
で少しずつ、レンダリングが終わったであろう位置までスライドしていきます。逆に閉じるときは、
<transition>
タグの中身のDOMがなくなることにフックして、
- background: 初期値で
opacity: 0
を設定し、そのまま透明にします。そのままDOMが破棄される最中から破棄されたあとは同じ状態を維持するので、leave-active
クラスは何も書かなくても大丈夫です。- menu: 初期値を特に何も設定せず、domが破棄されている最中で
transition: all 0.15s cubic-bezier(1, 0.5, 0.8, 1)
でスライドしていき、消える頃には右に少しずれた位置にopacity: 0
で透明にされているため、ならめかにスライドメニューが消えるという挙動になっています。スライドメニューの中のメニューと背景をクリックしたときに、スライドメニューを閉じる処理を忘れないようにしましょう。
components/molecules/ume-slide-menu.vue<template> <div class="ume-slide-menu"> <transition name="background"> <div v-if="isOpened" class="background" @click="close"> <div class="close-button">×</div> </div> </transition> <transition name="menu"> <div v-if="isOpened" class="menu"> <div class="menu-item-wrapper"> <div class="menu-item" @click="close"> <span>page1</span> </div> <div class="menu-item" @click="close"> <span>page2</span> </div> <div class="menu-item" @click="close"> <span>page3</span> </div> </div> </div> </transition> </div> </template> <script lang="ts"> import Vue from 'vue' export default Vue.extend({ model: { prop: 'isOpened', event: 'close', }, props: { isOpened: { type: Boolean, required: true, }, }, methods: { close() { this.$emit('close') }, }, }) </script> <style lang="scss" scoped> .ume-slide-menu { .background-enter, .background-leave-to { opacity: 0; } .background-enter-active { transition: opacity 0.15s; } .menu-enter, .menu-leave-to { transform: translateX(10%); opacity: 0; } .menu-enter-active { transition: all 0.15s ease; } .menu-leave-active { transition: all 0.15s cubic-bezier(1, 0.5, 0.8, 1); } .background { width: 100%; height: 100%; position: fixed; z-index: 98; top: 0; right: 0; overflow-x: hidden; background-color: rgba(0, 0, 0, 0.6); .close-button { position: absolute; top: 0; left: 25px; font-size: 36px; color: #fff; } } .menu { height: 100%; width: 64%; max-width: 320px; position: fixed; z-index: 99; top: 0; right: 0; background-color: #f3f3f3; overflow-x: hidden; .menu-item-wrapper { background-color: #fff; padding-top: 40px; padding-bottom: 52px; } .menu-item { display: block; cursor: pointer; margin: 0 20px; padding: 17px 0; font-size: 16px; font-weight: bold; border-bottom: thin solid #c7c7cc; color: #4a4a4a; text-decoration: none; line-height: 1; span { vertical-align: middle; } } } } </style>ここでの注意点では、
ume-slide-menu
の呼び出しをv-if
などで制御せずに、呼び出しているコンポーネントをマウントしているときには、スライドメニューのコンポーネントもレンダリングされている必要があります。pages/index.vue<template> <div class="index-page"> <ume-slide-menu v-model="showSlideMenu" /> </div> </template>ポップアップ
次は、ポップアップです。ページが表示されたあとにキャンペーンの告知として表示したり、条件によってボタンをクリックをしたにフックして表示します。スライドメニューと同じで、背景をクリックしたときにも、ポップアップを閉じる処理を入れるのを忘れないようにしましょう。
実装も上のスライドメニューとほぼ同じです。Vue.jsの
<transition>
タグを使って、DOMがレンダリングされるときと破棄されるときまでに、どのような挙動になって欲しいかを、xxx-enter-active
とxxx-leave-active
のcssクラスに記載するだけで、なめらかにポップアップを表示することができます。components/molecules/ume-popup.vue<template> <div class="ume-popup"> <transition name="background"> <div v-if="showPopup" class="background" @click.prevent="$emit('change-popup', false)" /> </transition> <transition name="popup"> <div v-if="showPopup" class="popup-wrapper"> <ume-close class="icon icon-close" @click.prevent="$emit('change-popup', false)" /> <div class="image-wrapper"> <img src="https://picsum.photos/seed/picsum/400/600" /> </div> </div> </transition> </div> </template> <script lang="ts"> import Vue from 'vue' import UmeClose from '~/assets/fonts/close.svg?inline' export default Vue.extend({ components: { UmeClose, }, model: { prop: 'showPopup', event: 'change-popup', }, props: { showPopup: { type: Boolean, required: true, }, }, }) </script> <style lang="scss" scoped> .ume-popup { .background-enter-active { transition: opacity 0.15s; } .background-enter, .background-leave-to { opacity: 0; } .popup-enter-active { transition: all 0.25s ease; } .popup-leave-active { transition: all 0.25s cubic-bezier(1, 0.5, 0.8, 1); } .popup-enter, .popup-leave-to { opacity: 0; } .background { width: 100%; height: 100%; position: fixed; z-index: 1; top: 0; right: 0; overflow-x: hidden; background-color: rgba(35, 24, 21, 0.35); } .popup-wrapper { top: 50%; left: 50%; transform: translate(-50%, -50%); position: fixed; padding: 0.5em 1em; z-index: 2; display: flex; flex-direction: column; .icon-close { height: 36px; fill: black; margin: 0 0 10px auto; } .image-wrapper { display: block; height: 350px; width: 330px; img { height: 100%; width: 100%; border-radius: 10px; } } } } </style>ここでも、上の
ume-slide-menu
と同じく、呼び出し側のほうでv-if
で制御せずに、事前にポップアップのコンポーネントもレンダリングされている必要があります。pages/index.vue<template> <div class="index-page"> <ume-popup v-model="showPopup" /> </div> </template>アコーディオン
狭い限定された枠の中で、メニューをクリックしてコンテンツを開閉したいというケースがあり、その対応でアコーディオンを実装しました。これもOSSのライブラリで要求されるデザインや仕様を満たせるかが不安だったので、自分で実装しました。今回もVue.jsの
<transition>
タグを使っていますが、上2つとは少し違います。どのように違うかというと、今まではトランジション状態をcssで任せていましたが、今回はJavaScriptのほうで制御しています。
コンテンツが開かれたあとのアコーディオンの高さがコンテンツの量に依存し、cssのクラスで言うところの
xxx-enter-to
で指定する高さが動的になることから、cssで静的に決め打ちすることができません。今回はサンプルなので、コンテンツは静的に決まっていますが、実際はAPIのレスポンスに依存するので、高さが動的になっています。動的な高さをどう与えるかというと、トランジションが終わるとき、つまり
xxx-enter-to
のときに、そのコンテンツのラッパーのscrollHeight
を渡すようにすれば解決できます。 Vue.jsの<transition>
タグには、JavaScriptフックがあるので、ここでは@enter
のフックで、アコーディオンのコンテンツのラッパーの高さをscrollHeight
と同じにすれば、動的に高さを決めることができます。ここで注意すべきなのは、アコーディオンを開く前に、一度アコーディオンの高さを
0
にしないとアニメーションが動きません。0
と決め打ちしないとheight: auto
が割り振られてしまい、height: auto
からheight: ${height}px
へのケースでは、transition
が効かなくなってしまいます。逆もしかりで、コンテンツを閉じるときのheight: ${height}px
からheight: auto
へのケースでも、transition
が効かなくなってしまいます。なので、コンテンツが開かれる前と閉じたあとのheight
は0
にしましょう。この0
にするというのも、JavaScriptのフックで実現可能です。(下の例で言うと、@before-enter
と@leave
になります。)components/atoms/ume-accordion.vue<template> <div class="ume-accordion"> <div class="header" @click="$emit('expand')"> <slot name="header" /> <down-arrow v-if="expandable" class="icon" :class="{ rotate: expanded }" /> </div> <transition name="accordion" @before-enter="beforeEnter" @enter="enter" @before-leave="beforeLeave" @leave="leave"> <div v-if="expanded" ref="content" class="content"> <slot name="content" /> </div> </transition> </div> </template> <script lang="ts"> import Vue from 'vue' import DownArrow from '~/assets/fonts/down-arrow.svg?inline' export default Vue.extend({ components: { DownArrow, }, model: { prop: 'expanded', event: 'expand', }, props: { expanded: { type: Boolean, required: true, }, expandable: { type: Boolean, required: true, }, }, mounted() { if (this.$refs.content) { (this.$refs.content as HTMLElement).style.height = `${this.$refs.content.clientHeight}px` } }, methods: { beforeEnter(el: HTMLElement) { el.style.height = '0' }, enter(el: HTMLElement) { el.style.height = el.scrollHeight + 'px' }, beforeLeave(el: HTMLElement) { el.style.height = el.scrollHeight + 'px' }, leave(el: HTMLElement) { el.style.height = '0' }, }, }) </script> <style lang="scss" scoped> .ume-accordion { border-radius: 6px; padding-top: 16px; .header { color: #fff; display: flex; align-items: center; justify-content: space-between; line-height: 1; padding-bottom: 16px; border-bottom: solid 1px #d1d1d6; .icon { display: block; fill: #c7c7cc; height: 14px; width: 14px; transform: rotate(0deg); transition-duration: 0.3s; } .rotate { transform: rotate(180deg); transition-duration: 0.3s; } } .content { padding: 0 12px; overflow: hidden; transition: 0.2s ease-out; } } </style>呼び出し側はこのように書いています。
pages/accordion.vue<template> <ume-accordion v-for="(group, index) in groups" :key="group.id" :expanded="accordionExpanded[index]" :expandable="group.children.length > 0" class="accordion" @click-header-arrow="toggleExpandAccordion($event, index)" > <template v-slot:header> <p>{{ group.name }}</p> </template> <template v-slot:content> <ul v-if="group.children.length > 0"> <li v-for="groupChild in group.children" :key="`${group.id}-${groupChild.id}`"> <div class="child-img"> <img :src="groupChild.src" /> </div> </li> </ul> </template> </ume-accordion> </template> <script lang="ts"> import Vue from 'vue' import { ImageGroup } from '~/types/image' type Data = { groups: ImageGroup[] accordionExpanded: boolean[] } export default Vue.extend({ data(): Data { const groups = [ { id: 1, name: 'scenes', children: [ { id: 1015, src: 'https://picsum.photos/id/1015/200/300' }, { id: 1016, src: 'https://picsum.photos/id/1016/200/300' }, { id: 1018, src: 'https://picsum.photos/id/1018/200/300' }, { id: 1019, src: 'https://picsum.photos/id/1019/200/300' }, { id: 102, src: 'https://picsum.photos/id/102/200/300' }, ], }, { id: 2, name: 'scenes', children: [ { id: 244, src: 'https://picsum.photos/id/244/200/300' }, { id: 237, src: 'https://picsum.photos/id/237/200/300' }, { id: 200, src: 'https://picsum.photos/id/200/200/300' }, { id: 219, src: 'https://picsum.photos/id/219/200/300' }, { id: 169, src: 'https://picsum.photos/id/169/200/300' }, ], }, ] return { groups, accordionExpanded: [true, ...groups.slice(1, groups.length).map(() => false)], } }, computed: { accordionClass() { return (opened: boolean) => { return opened ? 'opened' : '' } }, }, methods: { toggleExpandAccordion(expanded: boolean, ingredientCategoryIndex: number) { this.accordionExpanded = this.accordionExpanded.map((_) => false) this.accordionExpanded[ingredientCategoryIndex] = expanded }, }, }) </script>Vue.jsのtransitionを使わないで実装したもの
上では、
<transition>
タグを使ったケースを紹介しましたが、逆に使わなかったものもあります。
使わなかった理由としては、個人的にVue.jsの<transition>
タグはDOMの表示/非表示の切り替え時のアニメーションには有効ですが、要素の位置や高さを変更させるというアニメーションはあまり向いていないのかなと思っています。下の2つのケースだと、要素の表示と非表示はせず、単純に要素の高さを変えるだけだったり、X軸の位置の変更だけで、要件を満たせることができました。どのように実装したかをまとめたいと思います。
モーダル
UI・UXリニューアルに伴い、類似サービスとの差別化を目指して、下から長さを伸ばすことができ、かつz-indexが効かして画面から浮かび上がっているモーダルを用意しようとなりました。上はサンプルですが、実際にはサービス上にある大量のコンテンツから欲しいものだけを絞り込みできるボタンが複数並べられており、それらをクリックすることで、欲しいものを絞り込みできるものとなっています。
アニメーションが入っている部分としては、モーダルの高さ変更のときの
transition
です。モーダルの上の帯の部分をクリックすることで、指定した高さまでモーダルの高さを広げることができます。逆に、広がったモーダルをデフォルトの高さまで縮めることができます。今回では、要素の高さを調節するだけで要件を満たせるので、上記で書いた通り要素の表示/非表示をするわけではないため、Vue.jsの
<transition>
タグを使いませんでした。components/molecules/ume-expandable-bottom-modal.vue<template> <div ref="modal" class="ume-expandable-bottom-modal"> <div class="modal-content-title" @click="toggleExpand"> <p>タイトル</p> <up-arrow v-if="!expanded" class="icon icon-arrow-up" /> <down-arrow v-else class="icon icon-arrow-down" /> </div> <div class="content"> </div> </div> </template> <script lang="ts"> import Vue, { PropType } from 'vue' import { ImageGroup } from '~/types/image' import UpArrow from '~/assets/fonts/up-arrow.svg?inline' import DownArrow from '~/assets/fonts/down-arrow.svg?inline' type Data = { defaultHeight: number transitionSeconds: number } export default Vue.extend({ components: { UpArrow, DownArrow, }, model: { prop: 'expanded', event: 'toggleExpand', }, props: { expanded: { type: Boolean, required: true, }, groups: { type: Array as PropType<ImageGroup[]>, required: true, }, }, data(): Data { return { defaultHeight: 48, transitionSeconds: 0.5, } }, watch: { expanded(newValue) { if (newValue) { return } (this.$refs.modal as HTMLElement).style.height = `${this.defaultHeight}px` }, transitionSeconds(newValue) { (this.$refs.modal as HTMLElement).style.transition = `${newValue}s ease-out` }, }, mounted() { (this.$refs.modal as HTMLElement).style.transition = `${this.transitionSeconds}s ease-out` }, methods: { expandUpTo(height: number) { (this.$refs.modal as HTMLElement).style.height = `${height}px` }, toggleExpand() { this.$emit('toggleExpand', !this.expanded) }, }, }) </script> <style lang="scss" scoped> .ume-expandable-bottom-modal { width: 100vw; height: 48px; position: fixed; top: auto; right: 0; left: 0; bottom: 0; background: white; cursor: pointer; box-shadow: 0 -9px 10px 0 rgba(0, 0, 0, 0.1); border-radius: 8px 8px 0 0; z-index: 97; .modal-content-title { position: relative; height: 48px; background: 'red'; display: flex; justify-content: center; align-items: center; border-radius: 8px 8px 0 0; .category-title { margin: 6px auto; width: 80%; text-align: center; font-size: 14px; font-weight: bold; color: #000; line-height: 1; } .icon { display: block; text-align: left; fill: #000; position: absolute; right: 20px; height: 14px; } } } </style>ギャラリー
もう1つVue.jsの
<transition>
タグを使わずに実装したアニメーションとして、写真を複数枚並べて、それをスライドさせるギャラリーがあります。使われるシーンとしては、トップページの上部で複数のバナー画像の表示だったり、ランキング表示や1つの商品を紹介する写真を複数する表示するときなどに利用されることが多いかなと思います。上で書いたように、これも
<transition>
タグを使わずに実装しています。矢印クリックで、要素のリストのラッパーのtranslateY
を要素の長さ分、加算したり減算することで、リストの位置をずらす仕組みになっています。リストをラップしているDOMは、指定した長さで固定されているので、実際に目に見えるリストのコンテンツを表示するという考えです。スライドの速度はリストのcssにtransition
を書くことで調整できます。component/molecules/ume-gallery.vue<template> <div class="ume-gallery"> <div @click="previous"> <left-arrow :class="`icon icon-arrow-left ${previousclickableClass}`" /> </div> <div ref="slide-list-wrapper" class="slide-list-wrapper"> <ol ref="slide-list" class="slide-list"> <li v-for="(slideListElement, index) in slideListElements" :key="index"> <slot name="slide-list-element" :slide-list-element="slideListElement" /> </li> </ol> </div> <div @click="following"> <right-arrow :class="`icon icon-arrow-right ${followingClickableClass}`" /> </div> </div> </template> <script lang="ts"> import Vue, { PropType } from 'vue' import { Image } from '~/types/image' import RightArrow from '~/assets/fonts/right-arrow.svg?inline' import LeftArrow from '~/assets/fonts/left-arrow.svg?inline' const firstDisplayNum = 4 type Data = { transformX: number offset: number largestDisplayedNum: number } export default Vue.extend({ components: { RightArrow, LeftArrow, }, props: { slideListElements: { type: Array as PropType<Image[]>, required: true, }, width: { type: Number, required: true, }, }, data(): Data { return { transformX: 0, offset: 24, largestDisplayedNum: firstDisplayNum, } }, computed: { previousclickableClass(): string { return this.canSlidePrevious ? 'clickable' : 'non-clickable' }, followingClickableClass(): string { return this.canSlideFollowing ? 'clickable' : 'non-clickable' }, canSlidePrevious(): boolean { return this.largestDisplayedNum > firstDisplayNum }, canSlideFollowing(): boolean { return this.largestDisplayedNum < this.slideListElements.length }, slideElementStyle(): { 'min-width': string } { return { 'min-width': `${this.width}px` } }, }, mounted() { (this.$refs['slide-list-wrapper'] as HTMLElement).style.width = `${(this.width + this.offset) * firstDisplayNum}px` }, methods: { previous(): void { if (!this.canSlidePrevious) { return } this.transformX += this.width + this.offset (this.$refs.slide as HTMLElement).style.transform = `translate(${this.transformX}px, 0)` this.largestDisplayedNum-- }, following(): void { if (!this.canSlideFollowing) { return } this.transformX -= this.width + this.offset (this.$refs.slide as HTMLElement).style.transform = `translate(${this.transformX}px, 0)` this.largestDisplayedNum++ }, }, }) </script> <style lang="scss" scoped> .ume-gallery { display: flex; justify-content: center; align-items: center; @media only screen and (max-width: 940px) { margin: 0; } .icon { height: 24px; width: 28px; } .clickable { fill: #c7c7cc; } .non-clickable { fill: #d1d1d6; } .slide-list-wrapper { overflow: hidden; margin: 0 auto; .slide-list { transition: 0.5s; display: flex; } } } </style>まとめ
ざっと書いてきましたが、2020年にNuxt.jsで実装してきたアニメーションをVue.jsの
<transition>
タグを使ったケースと使わなかったケースでまとめてみました。元々はOSSのライブラリを使うときのコストを下げたい、またデザインや仕様変更に柔軟に対応できるように、自分で実装してきましたが、自分で手を動かして実装した分、多くのことを学んだと思っています。振り返りをして思ったのは、アニメーションの実装って楽しいと再認識したことです。プログラミングに挑戦したいときっかけにもなった「自分が作ったものが動く」という感動を思い出し、初心に返ることができました。
少しはできることが増えたのかなと思いつつも、まだまだリッチなアニメーションだとスラスラ実装できないレベルです。来年は自分のフロント力をもっと伸ばしていける年になるといいなと思っています。最後まで読んでいただき、ありがとうございました。
(上で書いたコードはGithubのレポジトリにまとめました。もし参考になったなどあれば、starしてくれると嬉しいです。)明日はSTORES 予約の@yksihimotoさんによる、「next.js + Fullcalendar v5を攻略する」です!お楽しみに!
- 投稿日:2020-11-19T06:35:56+09:00
Vue3でchart.jsを使用してみる。
まえがき
Vueでchart.jsを使用する際にはvue-chartjsのパッケージを使用するのが一般的なようです。しかし今現在(2020/11/19)、vue-chartjsはVue3に対応していないようなので、chart.jsをそのまま使ってみました。
この記事はその時のメモ的な感じになりますので方法としては間違っているかもしれません。方法
まずはchart.jsのパッケージをインストールします。npmで行いました。
npm install chart.js --save次に書いたソースコードを載せます。
<template lang="pug"> canvas#pentagonChart </template> <script> import { mapState } from "vuex"; import { Chart } from "chart.js"; export default { methods: { createChart() { const seasonal_sense = this.evaluatedClothes.seasonal_sense; const formality = this.evaluatedClothes.formality; const trend = this.evaluatedClothes.trend; const value = this.evaluatedClothes.value; const colorfulness = this.evaluatedClothes.colorfulness; new Chart(this.ctx, { type: "radar", data: { labels: [ "季節感", "トレンド感", "カラフルさ", "フォーマルさ", "価格帯" ], datasets: [ { label: "あなたの結果", data: [seasonal_sense, trend, colorfulness, formality, value], backgroundColor: "RGBA(33,33,33, 0.5)", borderColor: "RGBA(33,33,33, 1)", borderWidth: 1, pointBackgroundColor: "RGB(11,11,11)" } ] }, options: { title: { display: true }, scale: { ticks: { suggestedMin: 0, suggestedMax: 10, stepSize: 1, callback: function(value) { return value + "pt"; } } } } }); } }, computed: mapState("evaluation", { evaluatedClothes: "evaluatedClothes" }), watch: { evaluatedClothes: function() { this.createChart(); } }, mounted() { this.ctx = document.getElementById("pentagonChart"); this.createChart(); } }; </script>テンプレート
このテンプレートはpugで書かれています。htmlでもどちらでも良いですが、canvasを置いて任意のidを設定しておきましょう。
<template lang="pug"> canvas#pentagonChart </template>スクリプト
chart.jsから任意のグラフをインポートします。今回はレーダーチャート(多角形のグラフ)を使用するので、Chartをインポートします。
methodsにチャートを生成するような関数をchart.jsの記法を参考にして書いていきます。ラベルや値の代入などは任意です。datasets → dataに出力したい値が入るようにします。今回はstoreからmapStateで読み取った値がそのまま入るようにしています。
mounted()でcanvasを取得するためにdocument.getElementByIdした後、methodsに設定した描写関数を呼び出します。このときに取得したcanvasを共有できるように書きます。たぶんなのですが、mounted()で描写関数を呼び出さないとライフサイクル的にcanvasを取得することができません。created()に書いてしまうとcanvasが取得できないためか、真っ白になります。ライフサイクルを参考にして書いていきましょう。
storeの値が変わった時に変更を反映させたいため、watchに処理を書いておきます。storeに置いてあるであろうevaluatedClothesが変更されたときに、グラフを再描写させることで更新しています。
これで完成です。
<script> import { mapState } from "vuex"; import { Chart } from "chart.js"; export default { methods: { createChart() { const seasonal_sense = this.evaluatedClothes.seasonal_sense; const formality = this.evaluatedClothes.formality; const trend = this.evaluatedClothes.trend; const value = this.evaluatedClothes.value; const colorfulness = this.evaluatedClothes.colorfulness; new Chart(this.ctx, { type: "radar", data: { labels: [ "季節感", "トレンド感", "カラフルさ", "フォーマルさ", "価格帯" ], datasets: [ { label: "あなたの結果", data: [seasonal_sense, trend, colorfulness, formality, value], backgroundColor: "RGBA(33,33,33, 0.5)", borderColor: "RGBA(33,33,33, 1)", borderWidth: 1, pointBackgroundColor: "RGB(11,11,11)" } ] }, options: { title: { display: true }, scale: { ticks: { suggestedMin: 0, suggestedMax: 10, stepSize: 1, callback: function(value) { return value + "pt"; } } } } }); } }, computed: mapState("evaluation", { evaluatedClothes: "evaluatedClothes" }), watch: { evaluatedClothes: function() { this.createChart(); } }, mounted() { this.ctx = document.getElementById("pentagonChart"); this.createChart(); } }; </script>さいごに
今回はchart.jsをそのままVue3で使用してみました。フロントエンドのフレームワークは、何かのjsをラッパーしたパッケージがたくさんあってとても便利ですが、使いたいjsパッケージがラッパーされてないこともあると思います。ラッパーされているものも、されていないものも、臨機応変に使っていきたいです。
- 投稿日:2020-11-19T06:07:03+09:00
Vue.jsで作成したアプリをfirebaseにデプロイする方法(Firebase Hosting Setup Completeへの対処)
概要
React.jsやVue.jsで作成したアプリのホスティングの方法にはいくつかありますが、今回はVue.jsのfirebase Hostingによるホスティング方法について説明します。
Firebase Hosting Setup Completeの対処法がイマイチ見つからなかったので、多く出ている方法ですが今回記事にしました。手順
1, firebaseにログインする
// firebase-cliをインストールする npm install -g firebase-tools //firebase にログインする。 firebase login ※ログアウト方法は firebase logout2, 必要な設定をして、リリースする
//初期化処理を行う firebase init // サービスを選択する →「Hosting」を選択 →Projectを選択。 (firebase側で手動で作っていればUse an existing project ) (なければ Create a new project ) // 以下の質問に下記画像のように回答する What do you want to use as your public directory? (public) dist Configure as a single-page app (rewrite all urls to /index.html)? Yes File dist/index.html already exists. Overwrite? No // デプロイする。 firebase deploy // 下記のように表示されるので、Hosting URLをブラウザで開くと、デプロイが確認できる。 ✔ Deploy complete! Project Console: https://console.firebase.google.com/project/プロジェクト名/overview Hosting URL: https://プロジェクト名.firebaseapp.com3,Firebase Hosting Setup Completeへの対処
もしも、リリースしたページにアクセスしたのに
Firebase Hosting Setup Complete
としか表示されないことがあるかと思います。
これはfirebaseがホスティングしているファイルパスが正しくない場合に起きる状態です。
対処としては、下記2点が正しくできているかを確認してください。
1、firebase.jsonのpublicがdistになっているか
- 投稿日:2020-11-19T04:46:49+09:00
vue-infinite-loadingをTypeScriptで使うQuickHack、あるいはinstanceofが使えないクラスの型の保証について
よく見かける無限スクロール。使う用事があったのでVueで簡単に実装できる方法を調べてみました。
vue-infinite-loading
というのがポピュラーなようです。$ npm install vue-infinite-loading環境
- Vue 2.6.11
- Vuetify 2.3.18
- TypeScript 4.0.5
コード
前提
サーバーサイドにはページング機能付きで楽曲情報(Song)を返すAPIがある
ページサイズ16で下方向無限スクロールを実装する
セレクトボックスの値が変更されると、それまでに読み込んだデータは破棄して再度1ページ目から表示開始
エラー処理は省略<template> <v-container> <v-row> <v-vol> <v-select :items="selectItems" v-model="selectedGroup" @change="onChangeGroup"/> </v-vol> </v-row> <v-row> <v-col v-for="item in songList" key="item.id> <v-img :src="item.coverImage"/> </v-col> <InfiniteLoading ref="infiniteLoading" @infinite="onEndOfPage"/> <v-row> </v-container> </template> <script lang="ts"> import { Component, Vue } from 'vue-property-decorator'; import axios, { AxiosResponse, AxiosError } from 'axios'; import InfiniteLoading, { StateChanger } from 'vue-infinite-loading'; interface Song { title: string; artist: string; } @Component({ components: { InfiniteLoading } }) export default class InfiniteLoadingTest extends Vue { songList: string[] = []; page = 0; gotAllData = false; selectItems: string[] = ['μ\'s', 'Aqours', '虹ヶ咲', 'Liella!']; selectedGroup = ''; get pageSize() { return 16; } async mounted() { const params = { offset: 0, limit: this.pageSize }; const songList: AxiosResponse<Song[]> = await axios.get<Song[]>('https://example.com/songs', {params: params}); this.songList = res.data; let loading: InfiniteLoading | null = null; // eslint-disable-next-line @typescript-eslint/no-explicit-any const isInfiniteLoading = (x: any): x is InfiniteLoading => (x !== null && typeof x === 'object' && typeof x.distance === 'number'); if (isInfiniteLoading(this.$refs.infiniteLoading)) { loading = this.$refs.infiniteLoading; } if (!loading) { throw 'にゃーん'; } if (res.data.length < this.pageSize) { this.gotAllData = true; loading.stateChanger.complete(); } else { loading.stateChanger.loaded(); } } async onEndOfPage($state: StateChanger): void { if (this.gotAllData) { return; } this.page++; const params = { offset: this.page * this.pageSize, limit: this.pageSize }; const songList: AxiosResponse<Song[]> = await axios.get<Song[]>('https://example.com/songs', {params: params}); this.songList = this.songList.concat(res.data); if (res.data.length < this.pageSize) { this.gotAllData = true; $state.complete(); } else { $state.loaded(); } } async onChangeGroup() { let loading: InfiniteLoading | null = null; // eslint-disable-next-line @typescript-eslint/no-explicit-any const isInfiniteLoading = (x: any): x is InfiniteLoading => (x !== null && typeof x === 'object' && typeof x.distance === 'number'); if (isInfiniteLoading(this.$refs.infiniteLoading)) { loading = this.$refs.infiniteLoading; } if (!loading) { throw 'にゃーん'; } loading.stateChanger.reset(); const params = { offset: 0, limit: this.pageSize, group: this.selectedGroup }; const songList: AxiosResponse<Song[]> = await axios.get<Song[]>('https://example.com/songs', {params: params}); this.songList = songList.data; this.gotAlldata = songList.data.length < this.pageSize; if (this.gotAllData) { loading.stateChanger.complete(); } else { loading.stateChanger.loaded(); } } } </script>全部の説明書き始めると長くなりすぎるので悩んだところだけ説明します。
this.$refs
を通して要素に直接アクセスするのはTypeScriptだと鬼門だと思ってるんですが、今回もInfinoteLoadingの状態管理のために要素アクセスが必要で、それも普段使っている手が通じなかったので厄介でした。よくやるのはいろいろな型が入っている可能性があるオブジェクトに対して
const isVue = (x: unknown): x is Vue => x instanceof Vue; isVue(someObjectLikeVue);のようにしてタイプガードで型を判定・保証するものですが、今回
InfiniteLoading
であることを確認しようとして同様に実装するとx instanceof InfiniteLoading; => TypeError: Right-hand side of 'instanceof' is not callableはて?
型定義を確認すると
index.d.tsexport default class InfiniteLoading extends Vue { // The trigger distance distance: number; // The load spinner type spinner: SpinnerType; // The scroll direction direction: DirectionType; // Whether find the element which has `infinite-wrapper` attribute as the scroll wrapper forceUseInfiniteWrapper: boolean | string; // Infinite event handler onInfinite: ($state: StateChanger) => void; // The method collection used to change infinite state stateChanger: StateChanger; // Slots $slots: Slots; static install: PluginFunction<InfiniteOptions>; }ごく普通のクラスに見えるけどこれがcallableではないということらしい。
JavaScriptで書くなら
instanceof
の右辺はコンストラクタオブジェクトである必要がある。わかる。ただそれがTypeScriptだとどうなるのか、正直よくわかっていません。で、かなり無理矢理回避しているのがコード内に何箇所かあるこの部分
// eslint-disable-next-line @typescript-eslint/no-explicit-any const isInfiniteLoading = (x: any): x is InfiniteLoading => (x !== null && typeof x === 'object' && typeof x.distance === 'number'); if (isInfiniteLoading(this.$refs.infiniteLoading)) { loading = this.$refs.infiniteLoading; }TypeScriptでは
catch
以外でany
使ったら負けですが、ここは本当にどうしようもないので許してください、が1行目。
あとはnullでなく、何らかのオブジェクトであり、number型のdistance
プロパティを持っていればまあ多分InfiniteLoading
だろう、ヨシ!この方法が厳密に型安全かと言われるとそうではないんですが、ライブラリ側のTypeScriptサポートが甘いと起こり得る問題のような気がします。似たようなハマり方をした際は参考としてどうぞ。