3
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

JSフレームワークVueでデータ処理、まとめ版(script setup対応済)

Last updated at Posted at 2023-03-31

※演習6にコンポーザブルとPiniaの記事、演習7にスロット(途中)とVuetifyの記事を新たに作成しました

前記事の続編をそれぞれのフレームワーク毎に分類し、時流の変化を追いやすくしたものです。

今回は、前回で補完できなかった、コンポーネント周りのもっと突っ込んだ操作を含めた解説ができたらと思います。また、前回まではローカルサーバ上で操作していましたが、実践的な力を身に付けていくにはやはりサーバを用いた環境で制御していくべきなので、今回VueはVue CLIをローカルサーバ上で制御していきます。また、記述形式は2025年現在でも最も一般に浸透しているcomposition API(script setup記法。Vue3.0時代のexport defaultは公式非推奨となっています)によるものとなります。setup記法はあくまでcomposition APIとして紹介されているものであり、元の記法のボイラープレート(冗長な記述)を取っ払ってシンタックスシュガー化したものであるほか、TypeScriptとの親和性向上のためでもあるようです。

※options APIでの記法は2025年2月現在でも使用可能ですが、本記事では説明しません。敢えてoptions APIを残しているのは後方互換性維持のためのようです。

また、名称も今まではVue.jsという名称で履修してきましたが、Vue3が登場してからはReactと同様にjsとつけないよう場合も多くなってきた(知名度も浸透したから)ようなので、当記事ではVueで紹介していきます。

※今回学習する内容は

  • 5章 コンポーネント制御(電卓)
  • 6章 ルーティング制御(買い物かご)
  • 7章 スタイル制御(写真検索システム)
  • 8章 TypeScriptとscript setup(Todoシステム)

となります。

昨今はVueでもTypeScriptでの記述が主流になってきているので、8章からTypeScriptについて触れています。

import構文の共通ルール

その前に、JavaScriptのimport構文のルールを把握しておく必要があるはずです。

  • A:import Fuga from './Hoge'
  • B:import {Fuga} from './Hoge'

この2つの違いを把握しておかないと後々エラーを多発することになります。AはHogeという外部ファイルをFugaという名称で定義するという意味です(敢えて別名にする必要はないので、普通はインポートファイル名と同一です)。対してBはHogeという外部ファイルの中から定義されたFugaというオブジェクトを利用するという意味です。なので、Vueの例でいえば

import vue,{reactive,ref} from 'vue'

これはvueという外部ファイルをvueという名称で利用する、かつreactive,refという関数オブジェクトを利用するという命令になります(Vue3からはimport Vue は不要です)。

ちなみにVueでよく利用するインポート必須メソッドとして、変数処理用メソッド(ref、reactive)、プロパティメソッド(computed、Watch、watchEffect)、ライフサイクルフック(onMount、onBeforeMount)、データ関連メソッド(toRef、toRefs、provide、inject)などがあります。

※なおdefineModel、defineProps、defineEmits、defineExposeといったコンパイラマクロは呼び出し不要です。

演習5 コンポーネント制御(電卓)

ここではVueに対し、親子関係を持つコンポーネント制御で簡易な電卓を作成していきます。それにはプッシュキーとそれを押下したタイミングでの制御が必要となりますが、その際にプッシュキーの部品を子コンポーネント化することで、効率よくシステムを構築することができます。

その前に、なぜ親子のコンポーネントに分割、階層化するかですが、結論からいえば冗長な記述を回避するためです。

■Vueで制御する

Vueは割と柔軟にコンポーネントの制御ができるようになり、冗長で複雑だったセレクタ指定も廃止されました。そそして、昨今ではほぼすべてパスカルケースで記述するようになっています。

  • パスカルケース …AbcXyzと文字の先頭のみ英大となる記法です。コンポーネントファイル名に用いており、たとえば、親コンポーネントから子コンポーネントを呼び出す場合、子コンポーネント名はAbcXyz.vueとなります。なお、abcXyzと先頭を英小で記述する記法はキャメルケース(キャメルとはラクダの意)と名付けられ、これはメソッド名などに用います。

  • 【Vue】単一ファイルコンポーネントの命名規則まとめ【ファイル名から記法まで】

その命名規則を踏まえておいてから、コンポーネント制御の仕組みを見ていきます。そのコンポーネント名ですが

  • 親コンポーネント Calc.vue
  • 子コンポーネント Setkeys.vue

と定義しています。

■プロジェクトの作成(Viteでの方法)

vueとViteは制作者が同じで、エラー対応も十分な体制を整えているのでViteでサクッと作ってしまいましょう(Viteは高速起動のメリットばかり知られているようですが、実は環境構築も超高速で、しかも至って明瞭です)。

# npm init vite@latest

プロジェクト名を質問してくるので、任意の名称をつけます。それから使用言語はVueを選択します。TypeScriptもこの際選択しておきましょう。

引き続き、依存関係にある関連モジュールを一式インストールしておきます。

#cd 作成したプロジェクト名
# npm i

あとは起動するだけです(Vueだと標準で1~2秒ぐらいです)。

# npm run dev

ローカルサーバの場合はvite.config.tsに追記しておきます。

server:{
     host: true,
     port: 3000, //番号は任意
}

そこから演習用アプリケーションを作成していきます。

■アプリケーションの構造

まず、具体的な演習に入る前に、Vueにおけるコンポーネントの構造についておさらいです。Vueはテンプレートとコンポーネントなどが一体化したインラインテンプレートが標準実装となります。そして大本になるテンプレートがApp.vueであり、ここにコンポーネント化したプログラムを設定していくことになります。

index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vite + Vue + TS</title>
  </head>
  <body>
    <div id="app"><!-- ここにアプリケーションを構築していく --></div>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

このappは以下の設定ファイルによって制御されます。また、ここにルーティングやスタイルなどの情報を追記していきます(演習6、7参照)。

main.ts
import { createApp } from 'vue';
import App from './App.vue'
createApp(App)
.mount('#app');

crateApp(App)によってアプリケーションコンポーネントが呼び出されます。新たに作成する場合は、下に挙げるアプリケーションコンポーネント(App.vue)から作成した方が後々便利です。

App.vue
<script setup>
import Calc from './component/Calc.vue' //演習コンポーネントのインポート
</script>

<template>
	<Calc />
</template>

script setupになってからは、セレクタ指定が不要になっているので、その分ファイル名をコンポーネント名と照合させなければいけません。したがってCalcというコンポーネントを作成する場合は、必ずファイル名もCalc.vueとパスカルケースで記述する必要があります。

Vue3におけるインラインテンプレート周りの改善点

Vue3はテンプレートに複数のエレメントを返すこともできるので、Vue2以前から懸案の問題であった単一タグにするだけの目的に使用していた冗長な<div>タグを回避できます。

■Vue3での親コンポーネントの記述

composition APIでは変数定義、メソッド、その他プロパティメソッド類(算出プロパティ、監視プロパティなど)はsetupメソッド内で標準関数のように扱うことができます。

Calc.vue
<script setup>
import { reactive,ref } from 'vue';
import SetKey from './Setkeys.vue';
const state = reactive({
	pushkeys:
	[
		[['7','7'],['8','8'],['9','9'],['div','÷']],
		[['4','4'],['5','5'],['6','6'],['mul','×']],
		[['1','1'],['2','2'],['3','3'],['sub','-']],
		[['0','0'],['eq','='],['c','C'],['add','+']],
	],
})
let str = ref('');
let sum = ref();
//methodsはそのまま関数として記述する
const bind = (chara,total)=>{
	str.value += chara
	sum.value = total
}
</script>
<template>
	<SetKey :pushkeys="state.pushkeys" :bind="bind" />
	<p>打ち込んだ文字:{{str}}</p>
	<p>合計:{{sum}}</p>
</template>

※setupメソッドは後述するライフサイクルフックの一つで、Vue2以前のcreatedメソッドと同じ働きを持っています。script setupはスクリプトタグの中に埋め込んだ形となっているので、スクリプト内に記述するだけで以前のcreateフックと同じ働きを持ちます(閉じたままになっていますので、事前に展開したい場合はBeforeMountフックや演習8に登場するdefineExposeを使う手もあります。)。

■親コンポーネントの制御

では、親子コンポーネントの働きを解説するため具体的に電卓を作っていきます。

ここで処理の要となっているのはSetkeysという子コンポーネント内に記述されたテンプレートで、これをv-forディレクティブでループさせることで、冗長な記述(16個分のキーボタンを同じ記述で作ること)を回避し、処理の分担を明確化しています。

Calc.vue
<script setup>
import { reactive,ref } from 'vue';
import SetKey from './Setkeys.vue';
const state = reactive({
	pushkeys:
	[
		[['7','7'],['8','8'],['9','9'],['div','÷']],
		[['4','4'],['5','5'],['6','6'],['mul','×']],
		[['1','1'],['2','2'],['3','3'],['sub','-']],
		[['0','0'],['eq','='],['c','C'],['add','+']],
	],
})
let str = ref('');
let sum = ref();
//methodsはそのまま関数として記述する
const bind = (chara,total)=>{
	str.value += chara
	sum.value = total
}
</script>
<template>
	<SetKey :pushkeys="state.pushkeys" :bind="bind" />
	<p>打ち込んだ文字:{{str}}</p>
	<p>合計:{{sum}}</p>
</template>

※おさらいですが、setupメソッドにおいて変数はそのままではテンプレートで使用できないので、reactiveかrefのいずれかを通して使用することになります。

■子コンポーネントを紐づける

親コンポーネントから子コンポーネントを紐づけるには、以前はオブジェクト名の紐づけ(セレクタ指定)も必要でしたが、script setupでは

  • ファイルのインポート

これだけで連携可能です。ただし、ファイル名はコンポーネント名と統一しておく必要があります。

vue
import SetKey from './Setkeys.vue'; //子コンポーネントのインポート
<template>
	<SetKey />
</template>

という部分です。ただ、これだけだと値の受け渡しができないのでv-bindディレクティブを用いて受け渡します。

■コンポーネントのディバインディング(defineModel)

script setupは以前、親から子へはdefineProps、子から親へはdefineEmitsを使用していましたが、Vue3.4からはdefineModelという、Svelteの$propsルーンのように双方向でやりとり可能なコンパイラマクロが正規リリースされました。

v-bind:キー名 = '任意のコンポーネントに送りたい変数'

v-bind:hoge、v-model:hoge、いずれも双方向のバインディング可能です。

■子コンポーネントの制御

子コンポーネントで親コンポーネントから値を受け取る場合は、defineModelから受け取るだけです。ただし、受け取った値は必ずrefを通した形となるので、valueでの展開が必要です。

Setkeys.vue
<script setup>
import { reactive,ref } from 'vue';
const pushkeys = defineModel("pushkeys");
const bind = defineModel("bind");
let items = ref({
	lnum: null,
	cnum: 0,
	sign: '',			
})
const getChar = (chr,str)=>{
	let data = items.value
	let lnum = data.lnum
	let cnum = data.cnum
	let sign = data.sign
	let sum = 0
	if(chr.match(/[0-9]/g)!== null){
		let num = parseInt(chr)
		cnum = cnum * 10 + num //数値が打ち込まれるごとに桁をずらしていく
	}else if(chr.match(/(c|eq)/g) == null){
		if(lnum != null){
			lnum = calc(sign,lnum,cnum)
		}else{
			if(chr == "sub"){
			lnum = 0
			}
			lnum = cnum
		}
		sign = chr
		cnum = 0
	}else if( chr == "eq"){
	  lnum = calc(sign,lnum,cnum)
	  sum = lnum
	}else{
		lnum = null
		cnum = 0
		sum = 0
		str = ''
	}
	data.lnum = lnum
	data.cnum = cnum
	data.sign = sign
	console.log(bind)
	bind.value(str,sum) //受け渡し用のemitメソッド
}
const calc = (mode,lnum,cnum)=>{
	switch(mode){
		case "add": lnum = cnum + lnum
		break;
		case "sub": lnum = lnum - cnum
		break;
		case "mul": lnum = lnum * cnum
		break;
		case "div": lnum = lnum / cnum
		break;
	}
	return lnum		
}
</script>

<template>
	<div v-for="(val,idx) in pushkeys">
		<span v-for="(v,i) in val">
			<button @click="getChar(v[0],v[1])">{{v[1]}}</button>
		</span>
	</div>
</template>

definePropsとdefineEmitsを利用した場合

すべてがdefineModelで片付きそうですが、defineModelには欠点もあります。それは全部が開いた状態、つまりリアクティブになってしまうので、値を同期させたくない、子コンポーネントに値だけを送出したい場合はdefinePropsを用いた方が良い場合もあります。

また、子コンポーネントを親コンポーネントに返すコールバック関数を明示したい場合はdefineEmitsの方が記述が明瞭です(前例ではコールバック関数にもbind.valueとなってしまっている)。

では、同じシステムを今度は従来のdefinePropsとdefineEmitsで書き換えてみます。

Calc.vue
<script setup>
import { reactive,ref } from 'vue';
import Setkey from './Setkeys.vue'
const state = reactive({
	pushkeys:
	[
		[['7','7'],['8','8'],['9','9'],['div','÷']],
		[['4','4'],['5','5'],['6','6'],['mul','×']],
		[['1','1'],['2','2'],['3','3'],['sub','-']],
		[['0','0'],['eq','='],['c','C'],['add','+']],
	],
})
let str = ref('');
let sum = ref();
//methodsはそのまま関数として記述する
const bind = (v)=>{
	str.value += v[0]
	sum.value = v[1]
}
</script>
<template>
	<Setkey :pushkeys="state.pushkeys" @bind="bind" />
	<p>打ち込んだ文字:{{str}}</p>
	<p>合計:{{sum}}</p>
</template>
Setkeys.vue
<script setup>
import { reactive,ref } from 'vue';
const {pushkeys} = defineProps(["pushkeys"]);
const bind = defineEmits(["bind"]);
let items = ref({
	lnum: null,
	cnum: 0,
	sign: '',			
})
const getChar = (chr,str)=>{
	let data = items.value
	let lnum = data.lnum
	let cnum = data.cnum
	let sign = data.sign
	let sum = 0
	if(chr.match(/[0-9]/g)!== null){
		let num = parseInt(chr)
		cnum = cnum * 10 + num //数値が打ち込まれるごとに桁をずらしていく
	}else if(chr.match(/(c|eq)/g) == null){
		if(lnum != null){
			lnum = calc(sign,lnum,cnum)
		}else{
			if(chr == "sub"){
			lnum = 0
			}
			lnum = cnum
		}
		sign = chr
		cnum = 0
	}else if( chr == "eq"){
	  lnum = calc(sign,lnum,cnum)
	  sum = lnum
	}else{
		lnum = null
		cnum = 0
		sum = 0
		str = ''
	}
	data.lnum = lnum
	data.cnum = cnum
	data.sign = sign
	bind("bind",[str,sum]) //受け渡し用のemitメソッド
}
const calc = (mode,lnum,cnum)=>{
	switch(mode){
		case "add": lnum = cnum + lnum
		break;
		case "sub": lnum = lnum - cnum
		break;
		case "mul": lnum = lnum * cnum
		break;
		case "div": lnum = lnum / cnum
		break;
	}
	return lnum		
}
</script>

<template>
	<div v-for="(val,idx) in pushkeys">
		<span v-for="(v,i) in val">
			<button @click="getChar(v[0],v[1])">{{v[1]}}</button>
		</span>
	</div>
</template>

definePropsの特長

definePropsは一見、あまり変わっていないように見受けられますが、本来は

const props = defineProps(["pushkeys"]);
const pushkeys = props.pushkeys; 

この形を省略して、直接展開しているだけです(ダイレクトにオブジェクトを開くのは非推奨の方法)。

ですが、ここでtoRefなどの有無でリアクティブを取りたい場合と取りたくない場合を取捨選択できるので、場合によってはdefinePropsの方が優れていますし、reactiveメソッドの効果をそのまま継承しているので、展開も逐一valueを付与する必要ありません。

const pushkeys = toRef(props.pushkeys); //このような処理ができる
console.log(pushkeys) //valueは不要

defineEmitsの特長

対するdefineEmitsは、逐一エミッターを実行するためのキーが必要になるので、一見不便に見受けられます。ですが、defineModelだと必ずrefを通した状態になってしまうため、コールバック関数に対してもhoge.value()といった珍妙な表記となります。

ですが、defineEmitsを用いれば、エミッターと受取用関数を任意に指定できるので

子コンポーネント

const bind = defineEmits(['hoge']);
bind("hoge",data)

親コンポーネント

const onreceive = (val)=>{
    console.log(val)
}
< @onreceive="bind">

と自然なデータの流れを保つことができます(ただし、引数は一箇所しか指定できません)。後述するPinia(Vuexの後継ストア管理ライブラリ)などを用いてストア管理する場合は便利です。

toRef・toRefs

toRefというメソッドは、子コンポーネントに送り込んだprops上の変数に対し、値を再代入したい場合に使用するものです。先程の電卓に対し、入力式と計算結果を返す部分を子要素に移動させてみます。

setkeys.vue
<script setup>
import { ref,toRef } from 'vue';
const props = defineProps(["pushkeys","str","sum"]);
const pushkeys = props.pushkeys
const str = toRef(props.str)
const sum = toRef(props.sum)

これで、strとsumはrefを通した場合と同じ扱いになるので、再代入が可能です。これはVueのルールであり、propsから取得した変数は、そのままでは再代入不可(リアクティブが解除される)という仕様があるためです(前述defineModelは、すでにrefが通された形となります。なので全部リアクティブになります)。

definePropsを用いる場合はセットで覚えておきましょう。

toRefsは元のキーがオブジェクト(変数itemにstr、sumのプロパティが存在している)になっていた場合です。その場合は以下のようにして展開できます。

setkeys.vue
<script setup>
const props = defineProps(["pushkeys","item"]);
const pushkeys = props.pushkeys
import {ref,toRefs} from 'vue'
const {str,sum} = toRefs(props.item)

演習6 ルーティング(買い物かご)

今までは親子コンポーネントの説明はしていますが、あくまで単一のページのみの制御でした。ですが、世の中のWEBページやアプリケーションは複数のページを自在に行き来できます。それを制御しているのがルーティングという機能です。

フレームワークにおけるルーティングとはフレームワークのように基本となるリンク元のコンポーネントがあって、パスの指定によってコンポーネントを切替できるというものです。もっと専門的な言葉を用いれば、SPA(SINGLE PAGE APPLICATION)というものに対し、URIを振り分けて各種コンポーネントファイルによって紐付けられたインターフェースを表示するというものです。

VueではVue-routerというライブラリが必須となります。フレームワークにリンクタグとリンク先を表示するタグが存在し、toというパス記述用のプロパティが存在しています。また、データ転送用、データ受取用のライブラリが用意されており、それらを受け渡しと受け取り、そして更新のタイミングで実行していきます。

■Vueでルーティング制御

まずはVue3でルーティングを実施するためには、いくつかの初期設定が必要です。

■vue-router使用の準備

絶対に欠かせないのがvue-routerというライブラリです。インストールされているかどうか事前に確認しておきます。

#npm list vue -g

これでvue-routerに関する記述がなければ、インストールしておきましょう。

■npmかyarnでvue-routerをインストール(例はnpm)

#npm install vue-router@4

■main.jsにvue-router使用の明記

インストールができたらmain.jsにルーティングファイルの紐づけをしておきます。

main.js
import { createApp } from 'vue';
import router from './router' //ルーティングファイルの紐づけ(後で解説)
/*中略*/
const app = createApp(App)
app.use(router) //これを追記して、vue-routerを使用できるようにする
.mount('#app')

システムの構造は以下のようになっています

■src
   - ■css(デザイン制御用、表示は割愛)
   - ■Cart
       - Products.vue //商品一覧(ここに商品を入れるボタンがある)
       - Detail.vue //商品詳細
       - Cart.vue //買い物かご(ここに商品差し戻し、購入のボタンがある)
       - ShopContext.vue //ストアオブジェクト情報の格納
     - GlobalState.vue //トップコンポーネント(ここからデータを受け渡す)
     - Reducers.vue //共通の処理制御(コンポーザブル)
   - router.js //ルーティング制御用
   - main.js //基本設定
   - App.vue //基本コンポーネント

今回はApp.vueの中にシステムを構築していきます。GlobalState.vueがトップコンポーネントとなります。

<script setup>
import GlobalState from './Cart/GlobalState.vue'
</script>

<template>
	<GlobalState />
</template>

■Vue-routerによるルーティングの仕組み

vue-routerにおけるルーティングの仕組みとしては、<router-link>タグが画面遷移用リンクになり、toプロパティで遷移先のパスを指定できます。そして、router-viewタグに遷移先が表示されます。

GlobalState.vue
<template>
	<ul class="main-navigation">
		<nav>
			<li class="nav"><router-link to="/">Products()</router-link></li>
			<li class="nav"><router-link to="cart">Cart({{ cnt_cart }})</router-link></li>
		</nav>
	</ul>
	<div>
		<router-view /><!--ここに表示される-->
	</div>
</template>

■ルーティング制御スクリプト

そして、ルーティング制御用のスクリプトは以下のようになっています(vue2までとはかなり記述ルールが異なっているので、注意が必要です)。pathが遷移先、nameが外部ファイル名、componentが呼び出し用のコンポーネント名になります。そして、ルーティングでリンクさせるコンポーネントに対しては、必ずimportコマンドでファイルを呼び出しておく必要があります。

router.js
import {createRouter,createWebHistory} from "vue-router"
import Products from "./pages/Products.vue" //ルーティングで制御したいコンポーネント
import Cart from "./pages/Cart.vue" //ルーティングで制御したいコンポーネント
//ルーティング情報
const routes = [
	{
		path: "/", //トップに表示する場合
		name: "products",
		component: Products,
	},
	{
		path:"/cart", //ディレクトリ名を明示する場合
		name: "cart",
		component: Cart,
	},
];

const router = createRouter({
	history: createWebHistory(),
	routes: routes,
})
export default router

このようにすれば、ナビ部分にあるrouter-linkタグのtoプロパティに紐づいたURIに遷移し、router-viewタグに表示されます。

■Vueのルーティング先でデータをやりとりする

SPAのような兄弟コンポーネントに対してデータをやり取りする場合、provideとinjectというメソッドを使えば、簡単にデータのやりとりができます。

■ トップコンポーネントの記述

トップコンポーネントGlobalState.vueに対し、以下の記述をしていきます。

provide

provideメソッドは名の通り、データを共通キーによって分配できるという便利なデータ受け渡し用メソッドで、以下のようになっています。また、データは必ずreactiveメソッドを用いて値渡しにしておく必要があります。

provide(任意のキー,受け渡すデータ)

ただし、provideはどんな時にも使えるものではなく、2つの大きな使用条件があります。

  • setup直下で使用すること
  • リアクティブな値を転送すること(必ずrefもしくはreactiveを通すこと)

したがって、転送データのShopContextに対し、一度reactiveメソッドを通してから転送準備をします。

GlobalState.vue
<script setup>
import { inject,watch,reactive,ref,provide } from 'vue'
import Storage from './ShopContext.js'
const store = reactive(Storage()); //リアクティブにする
console.log(store)
const cnt_cart = ref(0)

watch([store],()=>{
	cnt_cart.value = getTotalQuantity(store.cart)
})
const getTotalQuantity = (arr)=>{
	return arr.reduce((count,curItem)=>{
		return count + curItem.quantity //買い物かごの個数
	},0)
}
provide('ctx',store);
</script>

<template>
	<ul class="main-navigation">
		<nav>
			<li class="nav"><router-link to="/">Products()</router-link></li>
			<li class="nav"><router-link to="cart">Cart({{ cnt_cart }})</router-link></li>
		</nav>
	</ul>
	<div>
		<router-view /><!--ここに表示される-->
	</div>
</template>

■ルーティング先のテンプレートファイル

ルーティング先のテンプレートファイルを見ていきます。ここでは商品陳列用のProducts.vueというファイルと、買い物かご用のCart.vueというファイルがありますが、いずれも今までのような親子関係を持っていません(このようなコンポーネント関係を兄弟コンポーネントと呼びます)。

■データの受取

演習5ではコンパイラマクロを使用しましたが、SPAの場合、データの受取はinjectメソッドを用いて簡単に受け取ることができます。

inject

injectは引数に先程provideメソッドの第1引数で受け渡しに用いた共通のキーを代入することで、第2引数のデータをそのまま受け取ることができます。これをsetupメソッドの中で展開するだけです。

const data = inject(provideで設定したキー)

Cart.vue
<script setup>
import { inject } from 'vue';
import cpReducer from "./reducers.js";
const store = inject('ctx');  //ストアデータの受取
const cp = cpReducer(); //コンポーザブルの使用
</script>

<template>
<article class="cart">
	<p v-if="store.cart.length <= 0">No Item in the Cart!</p>
  <ul v-for="(cartItem,i) in store.cart">
      <li>
        <div>
          <strong>{{ cartItem.title }}</strong> - {{ cartItem.price }}円
          ({{ cartItem.quantity }})
        </div>
        <div>
          <button @click="cp.dispatcher('remove',cartItem.id,store)">買い物かごから戻す(1個ずつ)</button>
        </div>
      </li>
  </ul>
  <h3>合計: {{ store.total}}</h3>
  <h3>残高: {{ store.money }}</h3>
  <button 
	v-if="store.money >0 &&  store.total >= 0"
	@click="cp.dispatcher('buy',null,store)">
	購入</button>
</article>
</template>

商品一覧画面は以下のようになっています。

Products.vue
<script setup>
import { inject,onMounted} from 'vue';
import {useRouter } from 'vue-router';
import cpReducer from "./reducers.js";
const router = useRouter(); //ルーティング転送用(後述)
const store = inject('ctx'); //ストアデータの受取
const cp = cpReducer(); //コンポーザブル
const getId = (id)=>{
	router.push(`/detail?id=${id}`);
}
</script>

<template>
<article class="products">
	<ul>
		<li v-for="(product,i) in store.products" :key="i">
			<div >
				<strong>{{ product.title }}</strong> - {{ product.price}}<template v-if="product.stock > 0 "> 【残り{{product.stock}}個】 </template>
			</div>
			<div>
			<button @click="cp.dispatcher('add',product,store)">かごに入れる</button>
			</div>
		</li>
	</ul>
</article>
</template>

コンポーザブル(処理の集約)

コンポーザブルは、どうもVue初心者にとって壁となっている(かつては公式サイトにも難解な部分かも知れないと説明されていました)ようですが、更新版の公式説明にも書いているように、外部の関数で処理し、戻り値をコンポーネントに展開しているだけです。また、公式の実例に載っているuseHogeという名称が紛らわしい(Reactのフックと混同しがちですが、中身はReactのカスタムフックと同じです)だけで、別に名前はなんでもいいみたいです。

コンポーザブルのメリットはコンポーネント間で各処理を共有できることです。したがって、ひとまずはデータ処理をコンポーザブルとして集約してみました(戻り値をコールバック関数にすれば、メソッドを呼び出すこともできます)。

共通処理用のコンポーザブルreducer.vueは以下のようになっています。コンポーザブルを作成するポイントはreturn{} で値を返す点です。またコンポーザブルはjsまたはtsで作成するようにしましょう。script setupは使用できません。また、vueファイルもテンプレートが存在しないので警告が表示されます。

reducer.js
//cpは任意でコンポーザブルを略しただけ(useReducerだと紛らわしいので)
const cpReducer= ()=>{
	//買い物かごの調整
	const addProductToCart = (product,state)=>{
		let cartIndex = null
		const stat = state
		//買い物かごの調整
		const updatedCart = stat.cart;
		const updatedItemIndex = updatedCart.findIndex(
			item => item.id === product.id
		);
		if (updatedItemIndex < 0) {
			updatedCart.push({ ...product, quantity: 1,stock: 0 });
			cartIndex = updatedCart.length -1 //カートの最後尾
		} else {
			const updatedItem = { ...updatedCart[updatedItemIndex] }
			updatedItem.quantity++;
			updatedCart[updatedItemIndex] = updatedItem;
			cartIndex = updatedItemIndex //加算対象のインデックス
		}
		stat.cart = updatedCart
		//商品在庫の調整
		const updatedProducts = stat.products //商品情報
		const productid = updatedCart[cartIndex].id //在庫減算対象の商品
		const productIndex = updatedProducts.findIndex(
			p => productid === p.id
		)
		const tmpProduct = { ...updatedProducts[productIndex] }
		tmpProduct.stock-- //在庫の減算
		updatedProducts[productIndex] = tmpProduct
		stat.products = updatedProducts
		//合計金額の調整
		const total = stat.total 
		const sum = getSummary(updatedCart,total)
		stat.total = sum
		state = {...state,stat}
	}
	//カートから商品の返却
	const removeProductFromCart = (productId,state)=>{
		const stat = state
		const updatedCart = [...stat.cart];
		const updatedItemIndex = updatedCart.findIndex(item => item.id === productId);
		const updatedItem = { ...updatedCart[updatedItemIndex] }
		updatedItem.quantity--
		if (updatedItem.quantity <= 0) {
			updatedCart.splice(updatedItemIndex, 1);
		} else {
			updatedCart[updatedItemIndex] = updatedItem;
		}
		stat.cart = updatedCart
		//商品在庫の調整
		const updatedProducts = [...stat.products] //商品情報
		const productIndex = updatedProducts.findIndex(
			p => p.id === productId
		)
		const tmpProduct = { ...updatedProducts[productIndex] }
		tmpProduct.stock++ //在庫の加算
		updatedProducts[productIndex] = tmpProduct
		stat.products = updatedProducts
		//合計金額の調整
		let sum = getSummary(updatedCart,stat.total)
		stat.total = sum
		state = {...state,stat}
	}
	//購入手続き
	const buyIt = (state)=>{
		const stat = state
		console.log(stat)
		let updatedArticles = [...stat.articles] //所持品
		let tmp_cart = [...stat.cart]
		
		for( let cart of tmp_cart){
			let articlesIndex = stat.articles.findIndex(
				a => a.id === cart.id
			)
			if (articlesIndex < 0) {
				updatedArticles.push(cart);
			} else {
				const tmpArticles = { ...articles[articlesIndex] }
				tmpArticles.quantity++;
				updatedArticles[articlesIndex] = tmpArticles;
			}
		}
		stat.articles = updatedArticles
		let summary = getSummary(tmp_cart,stat.total)
		let rest = stat.money - summary
		stat.money = rest
		tmp_cart.splice(0)
		summary = 0
		stat.cart = tmp_cart
		stat.total = summary
		state = {...state,stat}
	}
	//合計金額の算出
	const getSummary = (cart,total)=>{
		const sum = cart.reduce((total,{price = 0,quantity})=> total + price * quantity,0)
		return sum
	}
	const dispatcher = (mode,dif,store)=>{
		switch(mode){
			case "add": addProductToCart(dif,store)
			break
			case "remove": removeProductFromCart(dif,store)
			break
			case "buy": buyIt(store)
			break
		}
		
	}
	return { dispatcher }; //ここに、コンポーザブルに返したい値を記述する(コールバック関数も可能)
}
export default cpReducer

コンポーザブルを使用する

コンポーザブルを使用する場合はスクリプトファイルからプロトタイプを作成して活用します。

Produvts.vue
<script setup>
import cpReducer from "./reducers.js"; //コンポーザブル
const store = inject('ctx'); //ストアデータの受取
const cp = cpReducer(); //コンポーザブルのプロトタイプ
</script>
//cp.xxxxで使用可能(return で返されたメソッドのみが有効)

先程の商品一覧画面(Products.vue)やカート画面(Cart.vue)などにも使用されているので、確認してみてください。

【応用】Piniaでストアオブジェクトを管理する

PiniaはVue専用のストア管理ライブラリで、以前Vuexというものもありましたが、ここからmutation機能を取り払って、より利用しやすいように改良を加えたものです(Vuexと開発元は同じ)。

mutationとは変化(へんげ)などを意味する言葉で、つまり値の代入を意味します。Vueもかつてはセッタ関数というものがあり、そのタイミングで代入と更新を分別していたのですが、ECMA6からは分割代入などを用いた再代入処理(代入と更新がほぼ同時)が当たり前となっているので、それでmutation機能がオミットされたのでしょう。

参考にしたページ

※別途インストールとmain.tsでの設定が必要です(参考先参照)

# npm install pinia
main./ts
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import router from './router.js';
import App from './App.vue'
const pinia = createPinia();

createApp(App)
.use(router)
.use(pinia)
.mount('#app')

そして、今回はこのpiniaを使って、ストアオブジェクトの処理メソッドであるコンポーザブルにも連携してしまおうと思います。これで逐一、ストアオブジェクトを各コンポーネントから受け渡さなくて済むようになります。

ストアオブジェクトを改良する

ストアオブジェクトの段階からPiniaに結びつけておきます。またアクションからコンポーザブルを呼び出すようにします(コンポーザブルとの連携)。

ShopContext.js
import { defineStore } from 'pinia'; //piniaの呼び出し
import cpReducer from './reducers.js'; //コンポーザブル
export const useStore = defineStore('reducer',{
    //ストアオブジェクトの設定
	state:()=>({
		storage:{
			products:[
					{ id: "p1", title: "花王 バブ ゆず", price: 60, stock: 10 },
					{ id: "p2", title: "バスクリン きき湯", price: 798 , stock: 3 },
					{ id: "p3", title: "アース 温素 琥珀の湯", price: 980, stock: 2 },
					{ id: "p4", title: "白元アース いい湯旅立ちボトル", price: 398, stock: 6 },
					{ id: "p5", title: "クラシエ 旅の宿", price: 598, stock: 7 }
			],
			cart: [],
			articles: [],
			money: 10000, 
			total: 0, //残額
		}
	}),
	//ゲッタ
	getters:{
		getStore(state){
			return state.storage;
		}
	},
	//処理
	actions:{		
		dispatch(props){
			const cp = cpReducer(); 
			cp.shopReducer(props.mode,props.dif,this.storage) //処理用のコンポーザブル
		}
	}
});

データの受取

データの受取はトップコンポーネントに用意します。ゲッタに設定したgetStoreメソッドから値を返すようにします。また、piniaのゲッタから取得する場合、reactiveは不要になります。

GlobalState.vue
<script setup>
import { useStore } from './ShopContext.js';
const  cp = useStore();
const store = cp.getStore; //ストアオブジェクト
/*後は同じ*/

アクションの伝播

対する各種コンポーネントへは、Piniaによって制御されたアクションだけを返すようにします。このアクションは引数も使えるので、dispatchメソッドの引数に必要な機能(mode)と差分データ(dif)を代入しておけば、あとはpiniaがコンポーザブルへ必要なデータを伝播してくれます。

Products.vue
<script setup>
const store = inject('ctx');
import { useStore } from './ShopContext.js';
const  cp = useStore();
</script>

<template>
<article class="products">
			<div>
			<button @click="cp.dispatch({'mode':'add','dif':product})">かごに入れる</button>
			</div>
	</ul>
</article>
</template>

コンポーザブルと結びつける

コンポーザブルの名前も少し変えておきます。これが、piniaのアクションに設定したshopReducerそのものとなります。

ShopContext.js
import { defineStore } from 'pinia'; //piniaの呼び出し
import cpReducer from './reducers.js'; //コンポーザブル
export const useStore = defineStore('reducer',{
    /*中略*/
	//処理
	actions:{
        //propsは任意の変数で(各コンポーネントからデータを受け取る)
		dispatch(props){
			const cp = cpReducer(); 
			cp.shopReducer(props.mode,props.dif,this.storage) //処理分岐のコンポーザブル
		}
	}
});
reducer.js
const cpReducer= ()=>{
    /*中略*/
    //処理分岐のコンポーザブル
	const shopReducer = (mode,dif,store)=>{
		switch(mode){
			case "add": addProductToCart(dif,store)
			break
			case "remove": removeProductFromCart(dif,store)
			break
			case "buy": buyIt(store)
			break
		}
		
	}
	return { shopReducer }; 
}
export default cpReducer

Vueはどのタイミングでも同期制御できるメリットがある反面、ストア管理は少し面倒な部分がありました。しかし、Piniaとコンポーザブルを活用することで、堅牢性が高く、処理が確実なストア管理ができるようになります。

■詳細ページを作成(パラメータのやりとり)

では、商品一覧ページProducts.vueの商品名部分に詳細ページ(Detail.vue)のリンクを貼っていきます。その際に商品番号のパラメータ受け渡しが必須になります。そして、URL上のパラメータをやりとりする際にはuseRouterとルーティング情報を取得するuseRouteオブジェクトが必要になります。簡単なおさらいとして、

  • useRouter ネットワークにパラメータを受け渡す側
  • useRoute ネットワークからパラメータを受け取る側

このように覚えればいいでしょう。

■パラメータを転送する

パラメータを転送する場合、以下のようにバッククォートを用いれば、変数を埋め込むことができます。

vue.Products.vue
<template>
<article class="products">
	<ul>
		<li v-for="(product,i) in store.products" :key="i">
			<div >
				<router-link to="/" @click="router.push(`/detail/${product.id}`)">
				<strong>{{ product.title }}</strong> - {{ product.price}}</router-link>
				<template v-if="product.stock > 0 "> 【残り{{product.stock}}個】 </template>
			</div>
			<div>
			<button @click="cp.dispatcher('add',product,store)">かごに入れる</button>
			</div>
		</li>
	</ul>
</article>
</template>

■ルーティング設定

router.jsには以下のように追記しておきましょう。このpathで設定した:idの部分が、パラメータの取得用キーとなります。

router.js
import Detail from "./pages/Detail.vue" //コンポーネントを追記

const routes = [
	{
		path: "/",
		name: "products",
		component: Products,
	},
  //ルーティング情報を追記
	{
		path: "/detail/:id", //パラメータを以下のように記述しておけば、:idで受け取ることができる
		name: "detail",
		component: Detail,
	},
]

■パスパラメータを受け取る

では、詳細情報を記述したコンポーネントでパスパラメータを受け取ります。今度は受け取り側なのでuseRouteを使用し、パスパラメータを取得します(route.params.idが:idと同値)。それをonMountedフック内で作業し、変数をstateで返せば、変数を展開することができます。

Detail.vue
<script setup>
import { inject,ref,onBeforeMount} from 'vue'
import { useRoute,useRouter } from 'vue-router'
const store = inject('ctx')
const item = ref([])
const route = useRoute(); //ルーティング情報の取得
const router = useRouter(); //ルータ情報の取得
onBeforeMount(()=>{
	const id = route.params.id //パスパラメータ情報を取得
	item.value = store.products.find((item)=>item.id  === id)
})
</script>
<template>
	<div>
	<ul>
		<li>{{ item.title }}</li>
	</ul>
	<button @click="router.back()">戻る</button>
	</div>
</template>

onBeforeMount

ライフサイクルフックの一種で、こちらを用いるとエレメントの生成前に処理ができます。書き方はonMountedと同じです。

■クエリパラメータから受け渡しする

今度はクエリパラメータからgetで取得するように書き換えてみます。書き換えが必要な部分だけをピックアップしました。Vueはrouter.pushkから自在にパラメータを埋め込める上に、取得もroute.queryから自在にクエリパラメータを取得できるので、比較的書き換えが楽です。

Products.vue
	<router-link to="/" @click="router.push(`/detail/?id=${product.id}`)">
router.js
	{
		path: "/detail", //クエリパラメータの場合は表記不要
		name: "detail",
		component: Detail,
	},
Detail.vue
		onMounted(()=>{
			const selid = route.query.id //今度はqueryプロパティに属している
			const item = storage.products.find((item)=>item.id === selid)
			state.item = item
		})

■戻るボタンを実装する

ルーティング先から前ページに戻る場合はuseRouterオブジェクトのrouter.backメソッドを用います。

Detail.vue
import { useRouter } from 'vue-router'; //vue-routerからインポートする
const router = useRouter(); //ルータ情報の取得
<template>
	<button @click="router.back()">戻る</button>
</template>

ほかにも任意の世代に戻るrouter.go(-num)(numには数値が入る。-1だとrouter.backと同じ)、任意のディレクトリに遷移するrouter.push('ディレクトリ名')もあります。

演習7 スタイル制御(写真検索システム)

JSフレームワークの魅力はスタイル属性もリアルタイムに制御できることです。そこで、Vue、React、Angularで先程とは大きく書き直した写真検索システムにfont-awesomeのアイコンを使って気に入った画像を「いいね!」(ハートアイコンを赤に着色)できるようにしてみました。

なお、font-awesomeを使用する場合は、予めプロジェクトにインストールしておく必要があります。

■Vueでスタイル制御する

ひとまず、FontAwesomeを使用できるようにしておきましょう。自分はfont-awesome-iconタグをfaと略して使用しています。

main.js
//Vue3
import { createApp } from 'vue'
import App from './App.vue'
import App from './MyWorld.vue'
import { library } from '@fortawesome/fontawesome-svg-core' //ライブラリ
import { fas } from '@fortawesome/free-solid-svg-icons' //font-awesome内のアイコン
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome' //vue対応用のライブラリ
library.add(fas) //アイコンを追加
const app = createApp(App) 
app.component('fa',FontAwesomeIcon) //faと定義
app.mount('#app')

練習用プログラムに実装する

Vue-lesson7.vue
<script setup>
import { library } from "@fortawesome/fontawesome-svg-core";
import { faHeart } from "@fortawesome/free-solid-svg-icons";
import { reactive,onMounted,computed,watch } from 'vue'
import CitiesJson from '../assets/json/city.json'
import StatesJson from '../assets/json/state.json'
const state = reactive({
	txtbox: '', //フォームに入力された値
	//検索対象のリスト
	countries: [
		{ab:"US",name:"United States"},
		{ab:"JP",name:"Japan"},
		{ab:"CN",name:"China"},
	],
	word: '',
	cities: [],
	areas: [],
	sel_country : '', //選択した国
	opt_areas: [] , //選択候補となるエリア
	sel_area : '', //選択したエリア
	hit_cities_by_area: [], //エリアから該当する都市を絞り込み
	hit_cities_by_word: [], //検索文字から該当する都市を絞り込み
	hit_cities: [], 
})
//mounted
onMounted(async()=>{
	state.cities = CitiesJson
	state.areas = StatesJson
})
//computed
//国名から該当するエリアを絞り込み
const selectCountry= computed({
	get:()=>{
		const sel_country = state.sel_country
		const opt_areas = state.areas.filter((v)=>{ return v.country === sel_country})
		state.opt_areas = opt_areas
		return sel_country
	},
	set:(sel_country)=>{
		state.sel_country = sel_country
	}
})
//エリアから該当する都市を絞り込み
const selectArea= computed({
	get:()=>{
		const sel_area = state.sel_area
		const hit_cities_by_area = state.cities.filter((v)=>{ return v.state === sel_area })
		state.hit_cities_by_area = hit_cities_by_area
		return sel_area
	},
	set:(sel_area)=>{
		state.sel_area = sel_area
	}
})
//フリーワード検索
const searchWord= computed({
	get:()=>{
		const word = state.word
		const hit_cities_by_word = state.cities.filter((v)=>{
			const item = v.name.replace(/[A-Z]/g, function(ch) {return String.fromCharCode(ch.charCodeAt(0) | 32);})
			return item.includes(word) && word
		})
		state.hit_cities_by_word = hit_cities_by_word
		return word
	},
	set:(word)=>{
		state.word = word
	}
})
//watch
watch([selectArea,searchWord],()=>{
	const hit_cities_by_area = state.hit_cities_by_area
	const hit_cities_by_word = state.hit_cities_by_word
	const len_area = hit_cities_by_area.length
	const len_word = hit_cities_by_word.length
	let hits = []
	if(len_area > 0 && len_word > 0 ){
		hits = require('lodash').intersection(hit_cities_by_area, hit_cities_by_word)
	}else if(len_area > 0){
		hits = hit_cities_by_area
	}else if(len_word > 0){
		hits = hit_cities_by_word
	}else{
		hits = []
	}
	state.hit_cities = hits
})
//methods
//検索のクリア
const clear = ()=>{
	state.word = ''
	state.sel_country = ''
	state.sel_area = ''
}
//画像を読み込む
const imagePath = (src)=>{
	if(src != ''){
		return require(`@/assets/img/${src}`)
	}
}
//アイコンの色を塗る
const isActive = (selid)=>{
		const hit_cities = state.hit_cities
		return !!hit_cities.find(function(item){
				return selid === item.id && item.act === true
		})
}
//クリックイベント
const colorSet = (id)=>{
		const hit_cities = [...state.hit_cities]
		const selecteditem = hit_cities.find((item)=>{ return (item.id === id) })
		hit_cities.map((item,idx) => {
				if(item.id === id ){
						if(selecteditem.act === false){
							selecteditem.act = true
						}else{
							selecteditem.act = false
						}
				}
		})
		state.hit_cities = hit_cities
}
</script>

<template>
	<div>
	<div id="con">
	<label> 国の選択 </label>
	<select v-model="selectCountry">
		<option value=''> --国-- </option>
		<option v-for="country in state.countries" :value="country.ab" :key="country.id" >{{ country.name }}</option>
	</select>
	<label> エリアの選択</label>
	<select v-model="selectArea">
		<option value=""> --エリア-- </option>
		<option v-for="area in state.opt_areas" :value="area.code" :key="area.id">{{ area.name }}</option>
	</select>
	<br>
	検索文字を入力してください
	<input type="text" v-model="searchWord">
	<button @click="clear()">クリア</button>
		<div v-if="state.hit_cities.length > 0">
			<div>検索文字:{{ state.word }}</div>
			<div>ヒット数:{{ state.hit_cities.length }}</div>
			<ul  class="ul_datas">
				<li class="li_data" v-for="city in state.hit_cities" :key="city.id">
					<label class="lb_hit" >{{ city.name }}&nbsp;
						<label @click="colorSet(city.id,$event)">
							<fa 
								icon="heart" 
								:class="{act:isActive(city.id)}"
							/>
						</label>
					</label>
					<br>
						<img :src="imagePath(city.src)" :alt="city.name">
				</li>
			</ul>
		</div>
	<div v-else-if="searchWord != ''">
		<div>候補なし</div>
	</div>
	</div>
	</div>
</template>

<style scoped>
/*cssは中略*/
.act{
	color: red;
}
</style>

■スタイル制御の動き

この動きで必要になるのは、各写真に対して、「いいね」状態かどうかを判定させること、そして「いいね」状態を保持させることです。また、それにあたって必要なディレクティブがv-bind:classディレクティブで、このディレクティブは任意のクラスに対し、bool判定で有効、無効を切り替えることができます(なので、CSSは既にstyleタグに記述しておくことが前提です)。

また、複数のデータに対し「いいね」状態を保持させておくためには、別途ステータスを確保しておくオブジェクトを用意する必要があります(それを用意しないと一斉に「いいね」が点灯したり、解除したりします)。

■具体的な動き

今回はhit_citiesという都市情報を格納したjsonを移動させたオブジェクトにactという「いいね」状態を格納するプロパティを設け、クリックイベントcolorSetメソッドを発火させて対象のオブジェクトを抽出、そこからactプロパティの状態を取得し、falseならtrue、trueならfalseに切り換え、それを再度、hit_citiesオブジェクトに格納します。

引き続き、v-bind:classディレクティブに紐付いたisActiveメソッドが動き、選択したidと一致し、かつhit_citiesオブジェクトをループさせ、actプロパティの値がtrueのデータに対し、activeクラスの設定が有効になります(!!は判定結果をboolで返すという接頭辞です)。

つまり、v-bind:class={active: true}ならばactiveクラスのcolorプロパティが適用され、falseなら適用が解除される仕組みです。

vue
      <!-- activeクラスがtrueならactiveクラスのスタイルが適用される -->
	<fa icon="heart" :class="{active:true}" />
<style scoped>
.active{
    color: red;
}
</style>

アイコンオブジェクトにイベントを付与してはいけません。ブラウザの仕様により、labelからとfa(アイコンタグ)の双方からイベントが二重に発され、ステータスが一巡して元通りになります(機能していないのと同じ振る舞い)。

vue-lesson7.vue
<!-- 1回だけイベントが実行されるので、正常に動く -->
    <label @click="colorSet(city.id,$event)">
        <fa 
            icon="heart" 
            :class="{active:isActive(city.id)}"
        />
    </label>
vue
<!-- labelタグとfaタグの双方でイベントが実行されるので、ステータスが一巡りしてしまう -->
    <label >
        <fa 
            icon="heart" 
            :class="{active:isActive(city.id)}"
             @click="colorSet(city.id,$event)"
        />
    </label>

【応用】スロットとVuetify

VueでCSSを制御すると、簡単に動的制御が可能です。ですが、その真骨頂はVuetifyを使用してこそでしょう。

VueにはVuetifyというフォーム部品などの装飾用ライブラリがあり、script setupになってからVuetify3がリリースされました。Vuetifyの最大のメリットはなんといってもデザインと制御プログラムが、ある程度一体化していることで、従来のCSSだけでも動的なフォーム制御が可能になります。

したがって、このVuetifyを活用することによって簡単に作成できる部品があります。それが、今回実装するツールチップ、スライダー、メニューバー、そしてダイアログです(他にプルダウンやラジオボタンといった定番の部品もあり、モーダルやテーブルなども簡単に作れます)。

ツールチップはかつてjQueryなどを使用していた頃数十行にわたる記述が必要だったりしましたが、Veutifyを使用すればわずか数行です。また、このツールチップはslotの学習にも使えるので、並行して説明したいと思います。

また、スライダーも外部ライブラリに依存したりしていましたが、Vuetifyを使えば簡単に制御が可能になります。

ちなみにVuetifyを使いこなすにはスロット算出プロパティの知識必須です。逆にこの2つを頭に叩き込んでおけば、簡単に動的な制御ができます。

Vuetify使用の準備

Vuetifyを使用するには一通りの準備が必要です。大事なポイントは本体ライブラリをインストールするだけではなく、それをVueアプリケーションに紐づけること、またそのアプリケーションを正しく表示するためにmdiというデザイン装飾用CSSライブラリをインポートしておくことです。

main.js
import { createApp } from 'vue';
import vuetify from './plugins/vuetify'
import './style.css'
import App from './App.vue'

import { library } from '@fortawesome/fontawesome-svg-core'; //ライブラリ
import { fas,faHeart } from '@fortawesome/free-solid-svg-icons'; //font-awesome内のアイコン
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'; //vue対応用のライブラリ
library.add(fas); //アイコンを追加
library.add(faHeart)

createApp(App)
.use(vuetify)
.component('fa',FontAwesomeIcon)
.mount('#app');

これで準備完了です。

Vuetifyの部品と構成

Vuetifyは基本というタグが構成されており、その内部の仕様はプロパティで制御されます。したがって、そのプロパティをv-bindディレクティブにして変数をセットすること(valueなら:value、classなら:classという風に)で動的な制御が可能となります。

この共通ルールさえ把握してしまえば、VuetifyはVueの延長線上で簡単に扱えます。

ツールチップの実装(Vuetify+スロット)

Vuetifyを使ってツールチップを実装してみました。ツールチップは<v-tooltip>と記述し、特定のタグをフォーカスした際にtextプロパティ上の値が表示されます。そのフォーカス部分を制御するのがスロットであり、<template v-slot:activator>スロットに設定されたツールチップをアクティベート(立ち上げ)させます。あとはそれをどこに紐づけるかを制御するのがv-bindディレクティブです。今回は検索結果の画像に対してなので、imgタグに指定しています。

Lesson7.vue
				<li class="li_data" v-for="city in state.hit_cities" :key="city.id">
				<v-tooltip :text="city.name" location="bottom" class="vr">
				<template v-slot:activator="{ props }">
					<label class="lb_hit" >{{ city.name }}&nbsp;
						<label @click="colorSet(city.id,$event)">
							<fa 
								icon="heart" 
								:class="{act:isActive(city.id)}"
							/>
						</label>
					</label>
					<br>
						<img :src="imagePath(city)" :alt="city.name" 
						class="image_style" v-bind="props"/>
				</template>
				</v-tooltip>
				</li>

スロット

ここで、スロットについて初めて説明を入れます(slotはスタイル制御によって初めて効果を発揮するものなので)。

slotは差し込み、挿入という意味です。カジノのスロットマシーンを想像するよりはPCの機能差し込み口(スロット)をイメージした方がいいでしょう。つまり、コンポーネント上の特定の部分に値などを予め差し込んでおく機能なので、こまめにメッセージが切り替わる部品などを制御するのに適しています。

※以下追記予定

スライダーの実装(Vuetify+算出プロパティ)

サイズなどを動的に変更するスライダーもVuetifyで簡単に作れる部品の一つです。今回はスライダーを使用して、検索結果枠のサイズを5段階に変更してみます。今度はCSSプロパティのパラメータを動的に変更させるので、算出プロパティが必須になります。

スライダーは<v-slider>タグで作れ、v-modelでその値を制御できます。また必要となるプロパティですが、まずは最小値を指定する:min、最大値を指定する:max、間隔を指定する:step、あとは目盛り指定となるshow-ticks、ラベル表示をするthumb-labelなどがあります。

Lesson7.vue
			  <v-slider v-model="boxsize" 
			  :min="148" :max="248" :step="25" show-ticks thumb-label
			  ></v-slider>

この動的な値を反映させるのが算出プロパティで、これをスタイル属性に紐づけます。

Lesson7.vue
<scirpt setup>
const boxsize = ref(198) //まずは初期値を設定すること(単位は不要)
//算出プロパティで動的に制御したい値を制御
const size = computed(()=>{
	return `${boxsize.value}px`
})
</script>
<template>
		<div v-if="state.hit_cities.length > 0">
			<div>検索文字:{{ state.word }}</div>
			<div>ヒット数{{ state.hit_cities.length }}</div>
			<div>BOXサイズ調整</div>
			  <v-slider v-model="boxsize" 
			  :min="148" :max="248" :step="25" show-ticks thumb-label
			  ></v-slider>
			<ul class="ul_datas" >
				<li class="li_data" v-for="city in state.hit_cities" 
				:key="city.id" :style="{'width':size}">
				<v-tooltip :text="city.name" location="bottom" class="vr">
				<template v-slot:activator="{ props }">
					<article v-bind="props">
					<label class="lb_hit" >{{ city.name }}&nbsp;
						<label @click="colorSet(city.id,$event)">
							<fa 
								icon="heart" 
								:class="{act:isActive(city.id)}"
							/>
						</label>
					</label>
					<br>
						<img :src="imagePath(city)" :alt="city.name" 
						class="image_style" />
					</article>
				</template>
				</v-tooltip>
				</li>
			</ul>
		</div>
	<div v-else-if="searchWord != ''">
		<div>検索結果なし</div>
	</div>
</template>

ちなみにVue3.2から可能になったCSSからv-bindで紐づける方法は、今回の場合は成功しませんでした。

メニューバーの実装(Vuetify+スロット+算出プロパティ)

今度はエリア選択をプルダウンからメニューバーに変更してみます。メニューバーはスロットを利用して内部のリストを作成し、選択したパラメータに対し算出プロパティを使用して動的に検索条件を入れ替えて処理仕組みとなっています。

わかりやすく、プルダウンと比較してみます。重要なポイントはスロットで選択肢を表示させていることと、そのリストに対してはv-listを使用していることです。また値の取得はv-model:selectedを使用しますが、その場合取得した値はオブジェクトとなるので、算出プロパティのセッタに対する引数を少し書き換えています。

※v-list-groupはVuetify3で廃止されました。

Lesson7.vue
<script setup>
//エリアから該当する都市を絞り込み
const selectArea= computed({
	get:()=>{
		const sel_area = state.sel_area
		const hit_cities_by_area = state.cities.filter((v)=>{ return v.state === sel_area })
		console.log(hit_cities_by_area)
		state.hit_cities_by_area = hit_cities_by_area
		return sel_area
	},
	set:(sel_area)=>{
		state.sel_area = sel_area[0] //引数はオブジェクトになっていr
	}
})
</script>
<!-- メニューバー -->
	<v-menu open-on-click>
		<template v-slot:activator="{ props }">
			<v-btn color="primary" v-bind="props">エリアを選択する
			</v-btn>
		</template>
		<v-list v-model:selected="selectArea">
			<v-list-item v-for="area in state.opt_areas" :value="area.code" :key="area.id">
				<v-list-item-title>{{ area.name }}</v-list-item-title>
			</v-list-item>
		</v-list>
	</v-menu>

<!-- プルダウン -->
	<select v-model="selectArea">
		<option value=""> --エリア-- </option>
		<option v-for="area in state.opt_areas" :value="area.code" :key="area.id">{{ area.name }}</option>
	</select>

ほかにダイアログやモーダルなども比較的簡単に作れます。

演習8:TypeScript(TODOアプリ)

では、昨今Vueでも主流となってきているTypeScriptで記述してみます。VueでTypeScript対応させるにはインストール時にtypescript使用を設定する必要があります。途中から対応させるのはライブラリ互換性の食い違いで失敗することが多く、かなり作業が困難なので、最初からプロジェクトを作成した方がいいでしょう。

このサイトを参考に作成しています。

プロジェクトのsrcフォルダ直下の構造はこのようにしています

■components
   - Todos.vue //todoリストの親コンポーネント
   - TodoItem.vue //各todoリスト制御の子コンポーネント
■router
   - router.ts //ルーティングファイル
■store
   ■todo
       - index.ts //制御用メソッド
       - types_todo.ts //パラメータ・インターフェースの格納
■views
   - Todo.vue //todoのトップコンポーネント
   - AddTodo.vue //todoの新規作成
   - EditTodo.vue //todoの修正
   - DetailTodo.vue //todoの詳細(元サイトにはない)
App.vue //親コンポーネント
main.ts

■TypeScript対応にする

TypeScriptに書き換えるのは簡単です。

  • scriptタグのlangプロパティにtsと記述
    これで、コンポーネントはTypeScript対応となります。
App.vue
<script setup lang="ts">//lang="ts"とする
import { defineComponent, provide } from 'vue' //defineComponentオブジェクトをインポート
import todoStore, { todoKey } from '@/store/todo'
</script>
<template>
  <router-view />
</template>

Todoアプリを再構築する

script setupの基礎を踏まえたところで、Todoアプリを最低限の機能だけに絞って再構築してみました。

ルートページとトップページ

ルートページはここまでシンプルになります。また、前述したprovideとinjectのお陰で、煩雑だったデータのやりとりも非常に簡潔に描写できますし、このprovideはイベントも受け渡すことができます。また、ルーティングはルーティングファイルから制御しているので、逐一ルーティング制御用のオブジェクトをインポートする必要もありません。

App.vue
<template>
  <router-view></router-view>
</template>
<script setup lang="ts">
import { provide } from 'vue'
import todoStore, { todoKey } from '@/store'
provide(todoKey,todoStore) //各ページに分配
</script>

トップページは子コンポーネントを紐づけるだけです。

Todo.vue
<template>
	<h2>TODO一覧</h2>
	<Todos /><!-- 同期用コンポーネントでTodoItemコンポーネントに紐づけ -->
	<router-link to="/add">新規作成</router-link>
</template>
<script setup lang="ts">
import Todos from '@/components/Todos.vue'
</script>

Todoリスト一覧と各Todoの制御

ではTodosコンポーネントの中身を見ていきます。TodosコンポーネントはTodoリスト一覧を制御しているので、v-forディレクティブによって制御されています。そして親コンポーネントに受け渡すデータをv:bindディレクティブの省略形で、子コンポーネントから受け取るデータをv:onディレクティブで制御しています。

なお、子コンポーネントから値を受け取らずにprovideされたデータを子コンポーネントでinjectする方法もないことはないですが、基本親子のコンポーネント化は親から子にデータを受け渡していくものなので、以下のように親コンポーネントからinjectで受け取るのがセオリーです。

それからscript setup記法はセレクタ指定が不要になったので、各種ディレクティブの名称をパスカルケースでダイレクトに記述しても大丈夫です。

Todos.vue
<template>
<ul>
  <TodoItem
    v-for="todo in todoStore.state.todos"
    :key="todo.id"
    :todo="todo"
    @sendMode="sendMode"
  />
</ul>
</template>
<script setup lang="ts">
import { inject } from 'vue'
import { useRouter } from 'vue-router'
import TodoItem from '../components/TodoItem.vue'
import { todoKey } from '../store/index'
const todoStore = inject(todoKey)
const router = useRouter()

//編集画面に遷移
const sendMode = (state) => {
  if(state.mode !== "delete"){
    router.push(`/${state.mode}/${state.id}`) //対象ページに遷移
  }else if(state.mode === "delete"){
      todoStore.deleteTodo(state.id)
      router.push('/') //一覧画面へ遷移
  }
}
</script>

対して、各Todoを制御するTodoItem.vueは以下のように記述しています。前述したように親コンポーネントと従属関係にある子コンポーネントでデータを受け取るpropsとデータを受け渡すemitsはそれぞれdefinePropsとdefineEmitsとなります。

emitsプロパティはReactのuseReducerフックのようにプロパティ名を統一して、受け渡す値に処理分岐を盛り込んでもいいです(この場合の方がカスタムイベントの記述が少なくて済む)。

TodoItem.vue
<template>
  <!-- 各Todo子コンポーネント -->
	<div class="card">
		<div>
			<span class="title" @click="sendMode('detail')">{{todo.title}}</span>
			<span class="status" :class="todo.status">{{todo.status}}</span>
		</div>
		<div class="action">
			<button @click="sendMode('edit')">修正</button>
			<button @click="sendMode('delete')">削除</button>
		</div>
	</div>
</template>
<script setup lang="ts">
import { Todo } from '@/store/todo/types_todo'
import { PropType } from 'vue'
const props = defineProps({todo: {
			type: Object as PropType<Todo>,
			required: true
		}}
)
const emit = defineEmits(['sendMode']) //emitで用いるプロパティ
//methods 親コンポーネント内のメソッドへemitする
const sendMode = (mode)=>{
  emit('sendMode',{mode:mode,id:props.todo.id})
}

CRUDを制御する

ではそれぞれのTodoに対してCRUD制御の部分を見ていきます。それぞれのルーティングは以下のように記述しています。

Router.ts
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router'
import Todos from '@/views/todo.vue' //一覧画面
import AddTodo from '@/views/AddTodo.vue' //新規登録画面
import EditTodo from '@/views/EditTodo.vue' //修正画面
import DetailTodo from '@/views/DetailTodo.vue' //詳細画面
const routes: Array<RouteRecordRaw> = [
   {
     path: '/',
     name: 'Todos',
     component: Todos,
   },
   {
     path: '/add',
     name: 'AddTodo',
     component: AddTodo,
   },
  {
     path: '/edit/:id',
     name: 'EditTodo',
     component: EditTodo,
  },
  {
     path: '/detail/:id',
     name: 'DetailTodo',
     component: DetailTodo,
  },
]

const router = createRouter({
  history: createWebHistory(process.env.BASE_URL),
  routes,
})

export default router

Todoを登録する

登録ページは以下のようになっています。Paramsは任意に作成された型です。また、v:on-submitディレクティブを用いれば、v-modelに紐づいている全フォームデータを取得することができるので便利です。

対するidはフォームのrefプロパティで作成しています。refプロパティのおさらいですが、refプロパティはイニシャライズ必須で、直接代入せずにhoge.valueに代入します。また、フォームも制御することができたりします。

AddTodo.vue
<template>
	<h2>TODOの作成</h2>
	<form @submit.prevent="onSubmit">
    <div>
      <label for="id">ID</label>
      <input type="text" ref="refid" v-model="setid" />
    </div>
		<div>
			<label for="title"> タイトル</label>
			<input type="text" id="title" v-model="data.title" />
		</div>
		<div>
			<label for="description"></label>
			<textarea id="description" v-model="data.description" />
		</div>
		<div>
			<label for="status">ステータス</label>
			<select id="status" v-model="data.status">
				<option value="waiting">waiting</option>
				<option value="working">working</option>
				<option value="completed">completed</option>
				<option value="pending">pending</option>
			</select>
		</div>
		<button @click="onSubmit">作成する</button>
	</form>
</template>
<script setup lang="ts">
import { inject,ref,reactive,onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { Params } from '@/store/types_todo' 
import { todoKey } from '@/store/index'
const todoStore = inject(todoKey)
const date = new Date()
const refid = ref(null)
const setid = ref(null)
onMounted(()=>{
  setid.value = date.getTime()
})
const router = useRouter()
const data = reactive<Params>({
  title: '',
  description: '',
  status: 'waiting'
})
//実行ボタン
const onSubmit = ()=>{
  //addTodoメソッドの実行(index.ts内)
  const id = refid.value.value //refid.valueからのinput.value
  todoStore.addTodo({...data,id})
  router.push('/') //画面遷移
}
</script>

Todoを修正する

Todoを修正する場合は、前述した通りテンプレートの展開前に変数を制御しないといけないので、defineExposeを使用します。このdefineExposeは初代composition APIにあったreturnと似た働きを持ち、エレメント生成前に変数を展開させることができます。

EditTodo.vue
<template>
	<h2>TODOを編集する</h2>
	<form @submit.prevent="onSubmit">
    <div>
      <label for="title">タイトル</label>
      <input type="text" id="title" v-model="data.title" />
    </div>
    <div>
      <label for="description">説明</label>
      <textarea id="description" v-model="data.description" />
    </div>
    <div>
    <label for="status">ステータス</label>
			<select id="status" v-model="data.status">
        <option value="waiting">waiting</option>
        <option value="working">working</option>
        <option value="completed">completed</option>
        <option value="pending">pending</option>
			</select>
    </div>
    <button @click="onSubmit">更新する</button>
  </form>
  <button @click="router.back()">戻る</button>
</template>
<script setup lang="ts">
import { inject,reactive } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { Params } from '@/store/types_todo'
import { todoKey } from '@/store/index'
const todoStore = inject(todoKey) //
const router = useRouter()
const route = useRoute()
const id = Number(route.params.id) //idパラメータの取得
let data = []
let error = true
const todo = todoStore.state.todos.find((item)=> item.id === id)
//定義変数
data = reactive<Params>({
  title: todo.title,
  description: todo.description,
  status: todo.status,
})
const onSubmit = ()=>{
  //更新メソッドの実行
  todoStore.updateTodo(id,data)
  router.push('/') //一覧画面へ遷移
}
//変数dataをテンプレート展開前に返す
defineExpose({
  data
})
</script>

各種処理制御用のファイル

各種処理用のファイルはindex.tsにて制御しています。全データを制御しているのは変数stateで、ここでデータの更新があれば、また更新データに対し、provideから分配してくれます。

index.ts
import {InjectionKey, reactive, readonly } from 'vue'
import {TodoState, TodoStore, Todo } from '@/store/types_todo'

const state = reactive<TodoState>({
	todos: []
})

//todoの追加
/*Paramは任意で設定したパラメータ*/
const addTodo = (todo: Todo)=>{
	state.todos.push(todo)
}

//todoの更新
const updateTodo = (id:number,todo: Todo)=>{
	const idx = state.todos.findIndex((item)=> item.id === id)
	state.todos[idx] = todo
}

//todoの削除
const deleteTodo =(id:number)=>{
	const idx = state.todos.findIndex((item)=> item.id === id)
	state.todos.splice(idx,1)
}

//各種メソッドの定義
const todoStore: TodoStore = {
  state: readonly(state),
  addTodo,
  updateTodo,
  deleteTodo,
}

export default todoStore

//injectキーの設定
export const todoKey: InjectionKey<TodoStore> = Symbol('todoKey')
types_todo.ts
import { DeepReadonly} from 'vue'

export type Status = 'waiting'|'working'|'completed'|'pending'

//Todoデータのインターフェース
export interface Todo{
	id: number
	title: string
	description: string
	status: Status //上記typeで定義されたステータス
}

//入力フォームから取得した新規登録の各種内容を制御
export type Params = Pick<Todo, 'title'|'description'|'status'>

export interface TodoState{
	todos: Todo[]
}

//各種メソッドの設定用インターフェース
export interface TodoStore{
	state: DeepReadonly<TodoState> //再帰的にReadonlyを記述する
	addTodo: (todo: Todo)=> void
	updateTodo: (id: number,todo: Todo)=> void
	deleteTodo: (id:number) => void
}

演習9:Nuxt.js ※書きかけ

Vueをある程度マスターしておけばNuxt.jsも問題なく、すんなりと入れると思います。ただ、Nuxt.jsはバージョンによって使用できる記法が異なっているのでそこに注意しましょう。また、Nuxt3はViteにも対応しているので、構築する場合はViteの方が能率が上がります(スピードが全然違います)。ちなみに読みは『ナクスト』です。

では、Nuxtが今までのVueのSPAとはどう違うかですが、Nuxtの場合ルーティング、そしてコンポーネント構成を自動化してくれます。なのでファイルが増えていっても逐一それをルーティングファイルに追加していく必要がありません。しかもCMSと比較して断然高速です。したがって、ブログやニュース系サイト、レシピやオークションなどの投稿系サイトなどの開発に最適です。

また、世間ではNext.jsが有名ですが、Next.jsはReactベースなのに対しNuxt.jsはVueベースであり、Nuxt3はsetup script記法に準拠しています。

3
2
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?