前記事の続編をそれぞれのフレームワーク毎に分類し、時流の変化を追いやすくしたものです。
今回は、前回で補完できなかった、コンポーネント周りのもっと突っ込んだ操作を含めた解説ができたらと思います。また、前回まではローカルサーバ上で操作していましたが、実践的な力を身に付けていくにはやはりサーバを用いた環境で制御していくべきなので、今回VueはVue CLIで制御していきます。また、記述形式は2023年現在でも最も一般に浸透しているcomposition APIによるものとなります。
※名称も今まではVue.jsという名称で履修してきましたが、Vue3が登場してからはReactと同様にjsとつけないよう場合も多くなってきた(知名度も浸透したから)ようなので、当記事ではVueで紹介していきます。
※今回学習する内容は
- 5章 コンポーネント制御(電卓)
- 6章 ルーティング制御(買い物かご)
- 7章 スタイル制御(写真検索システム)
- 8章 TypeScriptとscript setup(Todoシステム)
となります。
※昨今はVueでもTypeScriptでの記述が主流になってきているので、8章からTypeScriptとVue3.2から採用されたscript setup記法について解説していきます。ちなみにsetup記法はあくまでcomposition APIとして紹介されているものであり、従来の記法をシンタックスシュガー化したものであるほか、TypeScriptとの親和性向上のためでもあるようです。
import構文の共通ルール
その前に、JavaScriptのimport構文のルールを把握しておく必要があるはずです。
- A:import Fuga from './Hoge'
- B:import {Fuga} from './Hoge'
この2つの違いを把握しておかないと後々エラーを多発することになります。AはHogeという外部ファイルをFugaという名称で定義するという意味です(敢えて別名にする必要はないので、普通はインポートファイル名と同一です)。対してBはHogeという外部ファイルの中から定義されたFugaというオブジェクトを利用するという意味です。なので、Reactの例でいえば
import React,{useState,useEffect,useContext} from 'React'
これはReactという外部ファイルをReactという名称で利用する、かつuseState、useEffect、useContextというオブジェクトを利用するという命令になります。
演習5 コンポーネント制御(電卓)
ここではVueに対し、親子関係を持つコンポーネント制御で簡易な電卓を作成していきます。それにはプッシュキーとそれを押下したタイミングでの制御が必要となりますが、その際にプッシュキーの部品を子コンポーネント化することで、効率よくシステムを構築することができます。
その前に、なぜ親子のコンポーネントに分割、階層化するかですが、結論からいえば冗長な記述を回避するためです。
■Vueで制御する
Vueは割と柔軟にコンポーネントの制御ができますが、その柔軟な制御を縛っているのが、厳格な命名規則です。そこで実践に入る前に、Vueで用いる命名規則を紹介しておく必要があるでしょう。
-
ケバブケース …ケバブ(トルコ料理の肉の串焼き)のようにabc-xyzとハイフンで接続した記法です。テンプレート名に用いていましたが、Vue3では全てを以下のパスカルケースで記述するのがセオリーになりました。参考までにabc_xyzとアンダーバーで接続するのはスネークケースですが、これはVueで使用しません。
-
パスカルケース …AbcXyzと文字の先頭のみ英大となる記法です。コンポーネントファイル名に用いており、たとえば、親コンポーネントから子コンポーネントを呼び出す場合、子コンポーネント名はAbcXyz.vueとなります。なお、abcXyzと先頭を英小で記述する記法はキャメルケース(キャメルとはラクダの意)と名付けられ、これはメソッド名などに用います。
その命名規則を踏まえておいてから、コンポーネント制御の仕組みを見ていきます。そのコンポーネント名ですが
- 親コンポーネント MyCalc.vue
- 子コンポーネント SetKey.vue
と定義しています。
■アプリケーションの作成
アプリケーションを作りたい任意のディレクトリを作成し、そこの上階層からvueCLIをインストール、そこからアプリケーションを自動作成します。
html: npm install -g @vue/cli
そこからアプリケーションを作成していきます。
html: vue create 任意のアプリケーション名(先程作成したディレクトリと同名で構わない)
※overwrite?(上書きしますか)と質問されますが、構わず許可してください。
■Vueのバージョン及び機能確認
以下のコマンドで関連ライブラリを確認できます。
# npm list vue
■コンポーネントの構造
まず、具体的なシステムに入る前にVueにおけるコンポーネントの構造ですが、Vueはテンプレートとコンポーネントなどが一体化したインラインテンプレートが標準実装となります。そして大本になるテンプレートがApp.vueであり、ここにコンポーネント化したプログラムを設定していくことになります。
最低限、記述が必要なのは以下の3箇所です。
- テンプレートタグ内のテンプレートをパスカルケースで記述
- 外部ファイルを呼び出すimport文の付与(呼び出したファイル名はパスカルケースで定義)
- コンポーネント内に使用する親コンポーネント名の記述(パスカルケースで記述)
<template>
<div id="app">
<MyCalc /><!-- 親コンポーネントのテンプレート名(パスカルケース) -->
</div>
</template>
<script>
import MyCalc from './components/MyCalc.vue' //今回呼び出すコンポーネントファイル(パスカルケース)
export default{
name: 'app', //部品の外側
components:{
MyCalc, //今回の電卓機能を動かす親コンポーネント(パスカルケース)
}
}
</script>
■Vue3(composition API)の特徴
Vue3の最大の特徴はcomposition APIが標準実装され、オブジェクトの分割代入が可能になったので、Reactの関数コンポーネントに近いような記述形式で制御が可能になり、データのやり取りがかなり円滑になりました。
また、Vue3はテンプレートに複数のエレメントを返すこともできるようになったので、単一タグにするだけの目的に使用していた冗長な<div>タグを回避できます。それから、逐一コンポーネントにおいて、使用テンプレートのプロパティを定義する必要がなくなったので、テンプレート名にパスカルケースを記述することも可能となっています。
■Vue3での親コンポーネントの記述
composition APIでは変数定義、メソッド、その他プロパティメソッド類(算出プロパティ、監視プロパティなど)はsetupメソッド内で標準関数のように扱うことができ、変数を定義してから、最後にreturn{使用した変数を外部に返す}
として返せば、そのままテンプレートで活用できます(データ用の変数はいったんstateオブジェクトのメンバとして格納されるので、最初から展開したい場合は後述するonMountedフックなどで展開するといいでしょう)。
//変数とメソッド、プロパティ類はsetup()内で変数に返す
setup(){
//変数はこのように値渡しにしておく(これによってリアルタイムな変数の同期処理が可能になる)
let state = reactive({
vals:[
[['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','+']],
],
data: {
lnum: null,
cnum: 0,
sum: 0,
str: '',
sign: '',
},
})
//定義したオブジェクトをここで外部に返す。括弧はブラケットなので注意
return{
state
}
}
}
</script>
※setupメソッドは後述するライフサイクルフックの一つで、Vue2以前のcreatedメソッドと同じ働きを持っています。
■親コンポーネントの制御
では、親子コンポーネントの働きを解説するため、具体的に電卓を作っていきます。ここでの注意点ですが、コンポーネントを階層化する場合、Vueの場合は変数を親から子、子から親へのやりとりは可能なのですが、変数は親コンポーネントで定義しないと、子コンポーネント同士で変数のやりとりはできないので、制御のたびに変数が初期化されてしまいます。
なお、値の受け取り、受け渡し処理に関しては子コンポーネントで制御を行っています(後述)。
ここで処理の要となっているのはSetKey
という子コンポーネント内に記述されたテンプレートで、これをv-forディレクティブでループさせることで、冗長な記述を回避し、処理の分担を明確化しています。
<template>
<div v-for="(val,idx) in state.vals">
<SetKey :dataFromParent="state.data" :v= "v" @from-child="receiveData" v-for="(v,i) in val"></set-key>
</div>
<p>打ち込んだ文字:{{state.data.str}}</p>
<p>合計:{{state.data.sum}}</p>
</template>
<script>
import { reactive } from 'vue' //reactiveメソッドの使用を駆らず記述しておく。
import SetKey from './SetKey.vue' //呼び出す子コンポーネント
export default {
//コンポーネントの指定
components: {
SetKey //子コンポーネントの定義
},
//変数とメソッド、プロパティ類はsetup()内で変数に返す
setup(){
//変数はこのように値渡しにしておく(これによってリアルタイムな変数の同期処理が可能になる)
let state = reactive({
vals:[
[['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','+']],
],
data: {
lnum: null,
cnum: 0,
sum: 0,
str: '',
sign: '',
},
})
//methodsはそのまま関数として記述する
const receiveData = (data)=>{
state.data = data
}
//定義したオブジェクトをここで外部に返す。括弧はブラケットなので注意
return{
state,receiveData
}
}
}
</script>
また、変数定義に用いるreactiveメソッドですが、これはオブジェクトを制御するために用いるもので、このメソッドを通すと全オブジェクトが値渡しとなります。参照渡しにするrefというメソッドもあるのですが、こちらは一つのオブジェクトしか定義できないので、使い分けるといいでしょう。
※setupメソッドにおいて、変数はそのままでは使えないので、reactiveかrefのいずれかのメソッドを通して使用することになります。また、後述しますが別コンポーネントに受け渡す際には、reactiveメソッドを通したデータは分割代入できないので、受け取り用のメソッド処理(toRefsかtoRef)が必要になります。
■子コンポーネントを紐づける
親コンポーネントから子コンポーネントを紐づけるには
- ファイルのインポート
- テンプレートの定義
この2つが必須です。そこで記述されている部分が
import SetKey from './SetKey.vue' //子コンポーネントのインポート
export default {
name: 'MyCalc', //親コンポーネントの名前(パスカルケース)
components: {
SetKey, //子コンポーネントの定義
}
},
という部分です。ただ、これだけだと値の受け渡しができていません。
■親コンポーネントから子コンポーネントへの値の受け渡し
親コンポーネントから子コンポーネントへ値を受け渡すにはv-bindディレクティブを用いることで可能となっています。
v-bind:子コンポーネントで受け取る変数 = '子コンポーネントに送りたい変数'
v-bindは省略記法が使えるので、:子コンポーネントで受け取る変数 = '子コンポーネントに送りたい変数'
と記述できます(ここでは流れをわかりやすくするために、あえて親子で変数を区別していますが、名称は同一で問題はありません)。
それで今回のシステムでは
:dataFromParent="data"
と記述しています。
■子コンポーネントの記述
子コンポーネントで親コンポーネントから値を受け取る場合は、受け取りたい変数をpropsプロパティから受け取ることになります。変数vはv-forディレクティブによって、SetKeyテンプレートに代入されていった親コンポーネントvalsの値となり、dataFromParentは、前述したように親コンポーネントから受け渡す変数dataとなります。
<template>
<button type="button" @click="getChar">{{v[1]}}</button>
</template>
<script>
export default{
props:{
v: Array,
dataFromParent: Object
},
emits: ['from-child'], //emitを用いる場合は必ず記述しておく['xxxx']は値を授受するためのキー値
setup(props, {emit}){ //ここにemitと使用メソッドを記述するのを忘れないこと
const getChar = ()=>{
let chr = props.v[0]
let str = props.v[1]
let data = props.dataFromParent
let lnum = data.lnum
let cnum = data.cnum
let sum = data.sum
let sign = data.sign
let strtmp = data.str
str = strtmp + str
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.str = str
data.lnum = lnum
data.cnum = cnum
data.sign = sign
data.sum = sum
emit('from-child',data) //受け渡し用の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
}
return{
getChar,calc
}
}
}
</script>
■子コンポーネントから親コンポーネントに値を転送する
計算などの処理を終えた変数は再度親コンポーネントに値を転送する必要があるのですが、それがメソッドgetCharの最終行に記述している
emit('from-child',data)
という記述部分となります。emit
とは、日本語に直訳すると放出という意味であり、名の通り子コンポーネントから変数を親コンポーネントへ放出(転送)しようとしているわけです。また、記述ルールがあり
emit('親コンポーネントで受け取るためのv-onディレクティブ',親コンポーネントへ返す変数)
となっています。
これで親から子へプッシュキーの値を受け渡しすることができ、子コンポーネントで処理されたデータを親コンポーネントに同期させ、電卓が機能することになります。
※注意点として、emitメソッドを使用する場合はemitsプロパティの記述を忘れないようにしましょう。
export default{
emits: ['hoge'], //ここを追記するのを忘れない('hoge'は任意のキー)
setup(props,{emit}){ //ここにemitメソッドを追記
const fugafuga = ()=>{
const data = "ほげほげ"
emit('hoge',data) //第一引数にキー、第二引数に転送したい値
}
}
}
■子コンポーネントの値を親コンポーネントで受け取る
親コンポーネントが子コンポーネントから値を受け取る場合はvue-on(省略形は@)ディレクティブを用います。
@v-onディレクティブ名=変数受け取り用のメソッド名
今回の場合は@from-child="receiveData"がそこに該当します。
<template>
<div v-for="(val,idx) in state.vals">
<SetKey :dataFromParent="state.data" :v= "v" @from-child="receiveData" v-for="(v,i) in val"></SetKey>
</div>
<p>打ち込んだ文字:{{state.data.str}}</p>
<p>合計:{{state.data.sum}}</p>
</template>
<script>
import { reactive } from 'vue' //reactiveメソッドの使用を駆らず記述しておく。
import SetKey from './SetKey.vue' //呼び出す子コンポーネント
export default {
//コンポーネントの指定
components: {
SetKey //子コンポーネントの定義
},
setup(){
/*中略*/
//子コンポーネントからの値の受け取り
const receiveData = (data)=>{
state.data = data
}
return{
state,receiveData
}
}
}
</script>
■Vue3でカレンダーを制御
次は別のページ解説用に作成した万年カレンダーですが、ここに用いたライフサイクルフックとそして子コンポーネントに参照渡しするtoRefs()メソッドの説明を補足します。
※算出プロパティと監視プロパティは第3章参照
Calender.vue
<template>
<div id="con">
<table class="tbl" border="1">
<caption class="changer">
<select v-model="state.selYear" @change="changeYear()">
<option v-for="(y,yidx) in state.years" :key="yidx">{{y}}</option>
</select>年
<select v-model="state.selMonth" @change="changeMonth()">
<option v-for="(m,midx) in state.months" :key="midx">{{m}}</option>
</select>月
</caption>
<thead>
<tr>
<th>Sun</th>
<th>Mon</th>
<th>Tue</th>
<th>Wed</th>
<th>Thi</th>
<th>Fri</th>
<th>Sat</th>
</tr>
</thead>
<tbody>
<CalTr v-for="(week,widx) in state.calboxes"
:key="widx"
:week="week"
:word="state.word" //検索文字はstateオブジェクトから展開すること
></CalTr>
</tbody>
</table>
<label>予定を記述した後、日付をクリック!<input type="text" v-model="state.word" ></label>
</div>
</template>
<script>
import { reactive,onMounted,watch } from 'vue'
import CalTr from './CalTr.vue'
export default{
components:{
CalTr,
},
setup(){
let state = reactive({
months:[1,2,3,4,5,6,7,8,9,10,11,12],
years: [],
year: '',
month: '',
selYear: '',
selMonth: '',
days:{
date: '',
schedule: '',
},
calboxes: [], //カレンダーボックスの格納
word: [], //スケジュールの入力
})
onMounted(()=>{
let d = new Date();
let year = d.getFullYear()
let month = d.getMonth() + 1
makeCalBox(year,month) //カレンダーボックスの作成
makeYearPulldown(year) //年プルダウンの生成
state.selMonth = month
state.selYear = year
})
watch(state.calboxes,()=>{
state.word = ''
})
//年の構築
const makeYearPulldown = (year)=>{
let years = state.years
for(let y = 1900;y <= year;y++){
years.push(y)
}
return years
}
//年の入れ替え
const changeYear = ()=>{
let year = state.selYear
let month = state.selMonth
makeCalBox(year,month) //カレンダーボックスの作成
return year
}
//月の入れ替え
const changeMonth = ()=>{
let year = state.selYear
let month = state.selMonth
makeCalBox(year,month) //カレンダーボックスの作成
return month
}
//カレンダーボックスの構築
const makeCalBox = (year,month)=>{
let d = new Date(year,month-1,1)
let lastday = getLastDay(year,month)
let buf_blank = d.getDay()
let cnt_calbox = buf_blank + lastday + 1
let calboxes = state.calboxes
calboxes = []
let weeks_tmp = []
let days = days
let day
for(let i = 1; i < cnt_calbox;i++){
day = i - buf_blank
if(day <= 0){
day = ''
}
days = {"date":day,"schedule":''}
if(i % 7 === 0 ){
calboxes.push(weeks_tmp)
weeks_tmp.push(days)
weeks_tmp = []
}else{
weeks_tmp.push(days)
}
}
calboxes.push(weeks_tmp)
state.calboxes = calboxes
}
//最終日を取得
const getLastDay = (year,month)=>{
let lastday = 0
if(month == 2){
if((year % 4 === 0 && year % 100 !== 0 )|| year % 400 == 0){
lastday = 29
}else{
lastday = 28
}
}else if(month < 8){
if(month % 2 === 1){
lastday = 31
}else{
lastday = 30
}
}else if(month >= 8){
if(month % 2 === 0){
lastday = 31
}else{
lastday = 30
}
}
return lastday
}
return{
state,makeYearPulldown,changeYear,changeMonth,makeCalBox,getLastDay
}
}
}
</script>
■ライフサイクルフックについて
Vue3におけるライフサイクルフックは大きく変更があり、DOM生成前に動作するcreatedメソッドは廃止され、setupメソッド内に直接記入することで対応できるようになりました。またDOM生成直後に動作するmountedメソッドは以下のようにonMounted(()=>{})と定型の記述方式になります(他のライフサイクルフックもonXxxxxというメソッドになっている)。また、各ライフサイクルフックから値を返したい場合はstateオブジェクトに値を返しておきます。
onMounted(()=>{
//処理を行う
state.hoge = hoge //stateに返したい場合
})
■子孫コンポーネントでデータを同期する
setupメソッドからが本題の場所となり、電卓との違いは子コンポーネントに受け渡したデータを同期させる働きを持たせています。ここでday.dateという変数とday.scheduleという変数に着目してください。day.dateはシステム呼出時に制御されるので普通にpropsで値を渡すことができます。一方、composition APIはreactiveを用いて渡したpropsは分割代入できない(つまりは、データ同期のための更新処理ができない)という重要な仕様があるために、day.scheduleはそのままだと更新できません。
したがって、ここではtoRefsメソッドを用いて、weekの値を参照渡しする必要があります。こうすれば、変数weekは分割代入が可能になるので、day.scheduleの値を更新できます。一方、wordは逐一、値が入れ替わるものなので、こっちは逆にtoRefsで参照渡ししてしまうと、今まで代入した値と連動してしまうので、propsから値渡しする必要があります。
※前述したようにVue3のcomposition APIではVue2以前のoptionsAPIで用いたオブジェクト更新、削除用のVue.setメソッド(ここでは紹介していませんがVue.deleteも)は使用不可になっています。なので、ここは普通に代入処理を用いる必要があります。
<template>
<td class="td" width="180px" @click="setSchedule(day,didx)">
<dl>
<dt>
<p class="daystamp">{{ day.date }}</p>
</dt>
<dd>{{ day.schedule }}</dd>
</dl>
</td>
</template>
<script>
import { toRefs } from 'vue'
export default{
props:[
"day",
"didx",
"week",
"word",
],
setup(props){
const { week } = toRefs(props) //weekは同期をとる必要があるので、toRefsで渡す
const setSchedule = (day,didx)=>{
day.schedule = props.word //wordは同期をとってはいけないので、propsから渡す
week[didx] = day.schedule //参照渡ししているので普通に代入で同期が取れる
}
return{
setSchedule
}
}
}
</script>
■子コンポーネント
子コンポーネントは特に問題ありませんが、参考までに。
<template>
<tr>
<CalTd v-for="(day,didx) in week"
:key="didx"
:day="day"
:didx="didx"
:word="word"
:week="week"
></CalTd>
</tr>
</template>
<script>
import CalTd from './CalTd.vue'
export default{
components:{
CalTd,
},
props:[
"week",
"word",
],
}
</script>
演習5のまとめ
このようにコンポーネントの分割の目的は冗長なエレメントを集約し、テンプレート化することで無駄な記述を回避するためです。また、それにあたって変数の受け渡しの処理が必要になります。
要約するとこうなります。
- 変数定義は親コンポーネントに記述する(子コンポーネントに記述しても毎回初期化される)
- 親から子を呼び出す場合、コンポーネントの定義が必要。
- 親から子への値の受け渡しは、親コンポーネントのテンプレートにv:bindディレクティブ、子コンポーネントにpropsプロパティを設定する。
- 子から親への値の受け渡しは、受け渡したい変数にemitメソッドを用い、親コンポーネントのテンプレートに設定したv:onディレクティブを用いて、親コンポーネントから任意のメソッドで受け取る。
- reactiveメソッドで制御した親コンポーネントのデータから子コンポーネントに参照渡しする場合はtoRefsかtoRefメソッドを用いる。これを用いないと分割代入ができない。
演習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
※Vue3でvue-routerを使用する場合、Vue-router4以上が必要で、それにはVue3のバージョンもvue3.2以上が必要になります。適宜バージョンをアップさせてインストールしましょう。
■main.jsにvue-router使用の明記
インストールができたらmain.jsにルーティングファイルの紐づけをしておきます。
import App from './GlobalState.vue' //親となるコンポーネント
import router from './router' //ルーティングファイルの紐づけ(後で解説)
/*中略*/
const app = createApp(App)
app.use(router) //これを追記して、vue-routerを使用できるようにする
これが親コンポーネントです。ここに紐づけた子コンポーネントMainNavigationにルーティング先を制御しています。
<template>
<MainNavigation /><!-- これがルーティングを制御した子コンポーネント -->
</template>
<script>
import MainNavigation from './pages/MainNavigation.vue'
export default {
components:{
MainNavigation
}
}
</script>
システムの構造は以下のようになっています
■src
- ■css(デザイン制御用、表示は割愛)
- ■pages
- MainNavigation.vue //子コンポーネント(ナビ部分。ここにルーティング用のリンクを記述)
- Products.vue //商品一覧(ここに商品を入れるボタンがある)
- Detail.vue //商品詳細
- Cart.vue //買い物かご(ここに商品差し戻し、購入のボタンがある)
- ShopContext.vue //オブジェクト情報の格納
- GlobalState.vue //親コンポーネント(ここからデータを受け渡す)
- Reducers.vue //共通の処理制御用
- router.js //ルーティング制御用
■Vue-routerによるルーティングの仕組み
vue-routerにおけるルーティングの仕組みとしては、<router-link>
タグが画面遷移用リンクになり、toプロパティで遷移先のパスを指定できます。そして、router-viewタグに遷移先が表示されます。
<template>
<main class="main-navigation">
<ul>
<!-- `router.js` で定義したルーティングルールとの紐付けを行っている -->
<li><router-link to="/">Products</router-link></li>
<li><router-link to="/cart">Cart()</router-link></li>
</ul>
</main>
<router-view></router-view><!--ここに表示される-->
</template>
■ルーティング制御スクリプト
そして、ルーティング制御用のスクリプトは以下のようになっています(vue2までとはかなり記述ルールが異なっているので、注意が必要です)。pathが遷移先、nameが外部ファイル名、componentが呼び出し用のコンポーネント名になります。そして、ルーティングでリンクさせるコンポーネントに対しては、必ずimportコマンドでファイルを呼び出しておく必要があります。
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(process.env.BASE_URL),
routes: routes,
})
export default router
このようにすれば、ナビ(MainNavigation.js)にあるrouter-linkタグのtoプロパティに紐づいたURIに遷移し、router-viewタグに表示されます。
■ルーティング先のテンプレートファイル
ルーティング先のテンプレートファイルを見ていきます。ここでは商品陳列用のProducts.vueというファイルと、買い物かご用のCart.vueというファイルがありますが、いずれも今までのような親子関係を持っていません(このようなコンポーネント関係を兄弟コンポーネントと呼ぶみたいです)。
<template>
<main class="products">
<ul>
<li v-for="(product,i) in storage.products" :key="i">
<div >
<router-link to="/" @click="getId(product.id)"><strong>{{ product.title }}</strong></router-link>
- {{ product.price}}円
<template v-if="product.stock > 0 "> 【残り{{product.stock}}個】 </template>
</div>
<div>
<button @click="rdc.reducer('add',product,storage)">かごに入れる</button>
</div>
</li>
</ul>
</main>
</template>
<script>
import { inject,onMounted} from 'vue'
import {useRouter } from 'vue-router'
import Reducers from "./reducers2.vue"
export default {
setup(){
const router = useRouter() //ルーティング転送用(後述)
const {storage} = inject('key')
const rdc = Reducers()
const getId = (id)=>{
router.push(`/detail?id=${id}`)
}
return{
rdc,storage,getId
}
}
}
</script>
■Vueのルーティング先でデータをやりとりする
兄弟コンポーネントに対してデータをやり取りする場合、provideとinjectという便利なメソッドを使えば、簡単にデータの譲受ができるようです。
■ 親コンポーネントの記述
親コンポーネントGlobalState.vueに対し、以下の記述をしていきます。
provideメソッドは名の通り、データを共通キーによって分配できるという便利なデータ受け渡し用メソッドで、以下のようになっています。また、データは必ずreactiveメソッドを用いて値渡しにしておく必要があります。
provide(任意のキー,受け渡すデータ)
<template>
<MainNavigation />
</template>
<script>
import MainNavigation from './pages/MainNavigation.vue'
import Storage from "./pages/shopContext.vue"
import {provide,reactive} from "vue"
export default {
components:{
MainNavigation
},
setup(){
const state = reactive({storage:Storage()})
provide('key',state) //データを兄弟コンポーネントに受け渡す。keyは任意の共通キー
}
}
</script>
Storageの中身は以下のようになっています。
<script>
const Storage = ()=>{
return{
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, //残額
}
}
export default Storage
</script>
■データの受け取り
データの受け取りはinjectメソッドを用いて受け取ります。injectは引数に先程provideメソッドの第1引数で受け渡しに用いた共通のキーを代入することで、第2引数のデータをそのまま受け取ることができます。これをsetupメソッドの中で展開して、returnで返すだけです。
const data = inject(provideで設定したキー)
<template>
<main class="products">
<ul>
<li v-for="(product,i) in storage.products" :key="i">
<div >
<strong>{{ product.title }}</strong> - {{ product.price}}円
<template v-if="product.stock > 0 "> 【残り{{product.stock}}個】 </template>
</div>
<div>
<button @click="rdc.reducer('add',product,storage)">かごに入れる</button>
</div>
</li>
</ul>
</main>
</template>
<script>
import { inject} from 'vue'
import Reducers from "./reducers.vue"
export default {
setup(){
const {storage} = inject('key') //データの受け取り
const rdc = Reducers()
return{
rdc,storage
}
}
}
</script>
また、共通処理用の関数reducer.vueは以下のようになっています。
<script>
const Reducers= ()=>{
setup:{
//買い物かごの調整
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 = (articles,state)=>{
const stat = state
let updatedArticles = [...articles] //所持品
let tmp_cart = [...stat.cart]
for( let cart of tmp_cart){
let articlesIndex = 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 reducer = (mode,selected,storage)=>{
switch(mode){
case "add": addProductToCart(selected,storage)
break
case "remove": removeProductFromCart(selected,storage)
break
case "buy": buyIt(selected,storage)
break
}
}
return{
reducer
}
}
}
export default Reducers
</script>
ナビ画面にもデータを行き渡らせます。
<template>
<main class="main-navigation">
<ul>
<li><router-link to="/">Products</router-link></li>
<li><router-link to="/cart">Cart({{state.cartlen}})</router-link></li>
</ul>
</main>
<router-view></router-view>
</template>
<script>
import { inject,watch,reactive } from 'vue'
export default {
setup(){
const {storage} = inject('key')
let state = reactive({
cartlen: 0,
artlen: 0,
})
watch([storage],()=>{
state.cartlen = getTotalQuantity(storage.cart)
})
const getTotalQuantity = (arr)=>{
return arr.reduce((count,curItem)=>{
return count + curItem.quantity //買い物かごの個数
},0)
}
return{
state
}
}
}
</script>
■詳細ページを作成(パラメータのやりとり)
では、商品一覧ページProducts.vueの商品名部分に詳細ページ(Detail.vue)のリンクを貼っていきます。その際に商品番号のパラメータ受け渡しが必須になります。そして、URL上のパラメータをやりとりする際にはuseRouterとルーティング情報を取得するuseRouteオブジェクトが必要になります。簡単なおさらいとして、
- useRouter ネットワークにパラメータを受け渡す側
- useRoute ネットワークからパラメータを受け取る側
このように覚えればいいでしょう。
■パラメータを転送する
パラメータを転送する場合、URLに変数をそのまま埋め込もうとしても文字化けしてしまうのでうまくいきません。なので、テンプレートにクリックイベントを記述し、そのイベントからuseRouterオブジェクトが持つpush
メソッドでパラメータを埋め込むようにすればうまくいきます。ここではgetId
という任意に作成したメソッドがデータ送信を行ってくれます。
useRouterはルーティング先にデータを転送するためなどに用いるvue-router内のオブジェクトで、このオブジェクトにあるpushメソッドを用いて**ルーティング先にデータを転送することができます。
<template>
<main class="products">
<ul>
<li v-for="(product,i) in storage.products" :key="i">
<div >
<router-link to="/" @click="getId(product.id)"><strong>{{ product.title }}</strong></router-link>
- {{ product.price}}円
<template v-if="product.stock > 0 "> 【残り{{product.stock}}個】 </template>
</div>
<div>
<button @click="rdc.reducer('add',product,storage)">かごに入れる</button>
</div>
</li>
</ul>
</main>
</template>
<script>
import { inject,onMounted} from 'vue'
import {useRouter } from 'vue-router' //使用するのはuseRouter
import Reducers from "./reducers2.vue"
export default {
setup(){
const router = useRouter() //プロトタイプ作成
const {storage} = inject('key')
const rdc = Reducers()
//リンクボタンに紐づけたイベント
const getId = (id)=>{
id = id.replace("p","")
router.push(`/detail/${id}`) //pushメソッドでパラメータごとパスを埋め込む
}
return{
rdc,storage,viewDetail
}
}
}
</script>
■ルーティング設定
router.jsには以下のように追記しておきましょう。このpathで設定した:idの部分が、パラメータの取得用キーとなります。
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で返せば、変数を展開することができます。
<template>
<ul>
<li>{{ state.item.title }}</li>
</ul>
</template>
<script>
import { inject,reactive,onMounted} from 'vue'
import { useRoute } from 'vue-router'
export default{
setup(){
const {storage} = inject('key')
const state = reactive({item:[]})
const route = useRoute() //ルーティング情報の取得
onMounted(()=>{
const id = route.params.id //パスパラメータ情報を取得
const selid = `p${id}` //検索idと一致させる
const item = storage.products.find((item)=>item.id === selid)
state.item = item
})
return{
state
}
}
}
</script>
■クエリパラメータから受け渡しする
今度はクエリパラメータからgetで取得するように書き換えてみます。書き換えが必要な部分だけをピックアップしました。Vueはrouter.pushに自在にパラメータを埋め込める上に、取得もroute.queryから自在にクエリパラメータを取得できるので、比較的書き換えが楽です。
const getId = (id)=>{
router.push(`/detail?id=${id}`) //送信するパスを書き換える
}
{
path: "/detail", //クエリパラメータの場合は表記不要
name: "detail",
component: Detail,
},
onMounted(()=>{
const selid = route.query.id //今度はqueryプロパティに属している
const item = storage.products.find((item)=>item.id === selid)
state.item = item
})
■戻るボタンを実装する
ルーティング先から前ページに戻る場合はuseRouterオブジェクトのbackメソッドを用います。テンプレートで使用する場合はreturnを忘れないようにしましょう。
<template>
<ul>
<li>{{ state.item.title }}</li>
</ul>
<button @click="router.back()">戻る</button>
</template>
<script>
import { inject,reactive,onMounted} from 'vue'
import { useRoute,useRouter } from 'vue-router' //useRouterを追記しておく
export default Component({
setup(){
const router = useRouter()
return{
router //きちんと返しておくこと
}
}
})
ほかにも任意の世代に戻るrouter.go(-num)
(numには数値が入る。-1だとrouter.backと同じ)、任意のディレクトリに遷移するrouter.push('ディレクトリ名')もあります。
演習6のまとめ
■Vueのまとめ
- vue-routerライブラリが必須。設定ファイル(main.js)にvue-router使用を追記する。
- リンクは<router-link to="パス">タグでリンク元を、<router-view>でリンク先を表示する。
- ルーティング情報はrouter.jsスクリプトに記述する。
- 兄弟コンポーネントへのデータのやりとりはprovideで転送、injectで受け取る。その際にuseRouterオブジェクトを使用し、pushでデータ更新を送信する。
- パラメータを送信するにはuseRouterオブジェクトのpushメソッドを使用し、パラメータを受け取るにはuseRouteオブジェクトからパラメータを受け取る。
- 一つ前の画面に戻る場合は、useRouterオブジェクトのbackメソッドを使用する。イベントはv-on:click(@click)ディレクティブで実行する
演習7 スタイル制御(写真検索システム)
JSフレームワークの魅力はスタイル属性もリアルタイムに制御できることです。そこで、Vue、React、Angularで先程とは大きく書き直した写真検索システムにfont-awesomeのアイコンを使って気に入った画像を「いいね!」(ハートアイコンを赤に着色)できるようにしてみました。
なお、font-awesomeを使用する場合は、予めプロジェクトにインストールしておく必要があります。
■Vueでスタイル制御する
ひとまず、FontAwesomeを使用できるようにしておきましょう。自分はfont-awesome-iconタグをfa
と略して使用しています。
//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')
<template>
<div class="named-slot">
<slot name="s2">
<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 }}
<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>
</slot>
</div>
</template>
<script>
import { reactive,onMounted,computed,watch } from 'vue'
import CitiesJson from '../assets/json/city.json'
import StatesJson from '../assets/json/state.json'
export default {
setup(){
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
}
return{
state, isActive,colorSet,selectCountry,selectArea,searchWord,clear,imagePath
}
}
}
</script>
<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なら適用が解除される仕組みです。
<!-- activeクラスがtrueならactiveクラスのスタイルが適用される -->
<fa icon="heart" :class="{active:true}" />
<style scoped>
.active{
color: red;
}
</style>
※ アイコンオブジェクトにイベントを付与してはいけません。ブラウザの仕様により、labelからとfa(アイコンタグ)の双方からイベントが二重に発され、ステータスが一巡して元通りになります(機能していないのと同じ振る舞い)。
<!-- 1回だけイベントが実行されるので、正常に動く -->
<label @click="colorSet(city.id,$event)">
<fa
icon="heart"
:class="{active:isActive(city.id)}"
/>
</label>
<!-- labelタグとfaタグの双方でイベントが実行されるので、ステータスが一巡りしてしまう -->
<label >
<fa
icon="heart"
:class="{active:isActive(city.id)}"
@click="colorSet(city.id,$event)"
/>
</label>
演習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に書き換えるのは簡単で、コンポーネントファイルに3つのステップを踏むだけです。
- scriptタグのlangプロパティにtsと記述
- vueファイルからdefineComponentオブジェクトをインポート
- コンポーネント名をcomponentからdefineComponentに変更
これで、コンポーネントはTypeScript対応となります。もし、VueがTypeScriptに対応していない場合はdefineComponentオブジェクトが未定義となるはずなので、それで判断して下さい。
<template>
<router-view
</template>
<script lang="ts">//lang="ts"とする
import { defineComponent, provide } from 'vue' //defineComponentオブジェクトをインポート
import todoStore, { todoKey } from '@/store/todo'
//defineComponentとなっているか?
export default defineComponent({
name: 'App',
setup(){
provide(todoKey,todoStore)
}
})
</script>
■Vue3.2のscript setupに書き換える
ではこれをVue3.2のscript setupで記述していこうと思います。script setupは新たに覚える必要のある新規の記法ではなく、あくまでcomposition APIの基本に従った記法です。しかも、非常に記述が簡潔(驚異的なほどのシンタックスシュガー化)になっているので、それまでのcomposition APIを学んできた人なら全く抵抗なく書き換えができます。その方法は以下のステップを踏むだけです。また、テンプレートの順番はどっちに置いても問題ないようです(公式はSvelteっぽくScriptのあとに記述していますが)。
setupメソッド関数の外枠とreturnを削除
敢えて変更点を非表示にしてみましたが、どれだけ書きやすくなったかが一目瞭然だと思います。
<template>
<h2>TODO一覧</h2>
<Suspense>
<template #default>
<Todos /><!-- 同期用コンポーネントでTodoItemコンポーネントに紐づけ -->
</template>
<template #fallback>
<div>Loading...</div>
</template>
</Suspense>
<router-link to="/new">新規作成</router-link>
</template>
<!-- <script lang="ts"> -->
<script setup lang="ts">
import { ref,onErrorCaptured } from 'vue'
//import { defineComponent,ref,onErrorCaptured } from 'vue'
import Todos from '@/components/Todos.vue'
//export default defineComponent({
// components:{
// Todos,
// },
// setup(){
const error = ref<unknown>(null)
onErrorCaptured((e)=>{
error.value = e
return true
})
// return{
// error,
// }
// }
//})
</script>
composition APIを記述していて面倒、煩雑だと思っていた部分を見事に解消しており、コンポーネント名定義すら不要となりました。ですが、そこで疑問が生じてくるはずです。setupメソッドによって制御されていたprops、emitsプロパティやreturnメソッドはどうやって記述するのでしょうか。それの代替策が以下に挙げる記述で、これらは既に予約されている機能(コンパイラマクロ)なので外部からインポートする必要はありません。
■definePropsとdefineEmits
子コンポーネントに記述されていたデータ受け渡し用のpropsとemitsは以下のようになります。
props → defineProps([任意の変数])
emit = defineEmits([任意の転送用ディレクティブ])
<script setup lang="ts">
import { Todo } from '@/store/todo/types_todo'
import { PropType } from 'vue'
import { useFormatDate } from '@/composables/use-format-date'
//親コンポーネントから呼び出されたオブジェクト
const props = defineProps({todo: {
type: Object as PropType<Todo>,
required: true
}}
)
//親コンポーネントに返すためのemitメソッド
const emit = defineEmits(['clickDelete','clickEdit','clickDetail'])
//methods 親コンポーネント内のメソッドへemitする
const clickDelete = ()=>{
emit('clickDelete',props.todo.id)
}
const clickEdit = ()=>{
emit('clickEdit',props.todo.id)
}
const clickDetail = ()=>{
emit('clickDetail',props.todo.id)
}
//computed
const formatDate = useFormatDate(props.todo.createdAt)
</script>
■defineExpose(DOM生成前に変数を評価したい場合)
script setupによって生成された変数はDOM生成後に評価されます。そのため、SPAの遷移先で変数をそのまま表示しようとしても、未定義エラーとなります。
そこでDOM生成前に変数を評価したいsetupメソッドにおけるreturnの代わりになるのがdefineExposeというコンパイラマクロで、これに変数を用意しておくことで、DOM生成前に評価したい変数を処理することが可能になります。
<template>
<h2>TODOを編集する</h2>
<div v-if="error">ID:{{ id }}のTODOが見つかりませんでした</div>
<form v-else @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/todo/types_todo'
import { todoKey } from '@/store/todo'
const todoStore = inject(todoKey) //
if(!todoStore){
throw new Error('todoStore is not provided')
}
const router = useRouter()
const route = useRoute()
const id = Number(route.params.id) //idパラメータの取得
let data = []
let error = true
try{
const todo = todoStore.getTodo(id)
//定義変数
data = reactive<Params>({
title: todo.title,
description: todo.description,
status: todo.status,
})
const onSubmit = ()=>{
const { title,description,status} = data
//更新メソッドの実行
todoStore.updateTodo(id,{
...todo,
title,
description,
status,
})
router.push('/') //一覧画面へ遷移
}
error = false
}catch(e){
error = true
}
//DOM生成前に変数を評価する
defineExpose({
data,id,error
})
</script>
Todoアプリを再構築する
script setupの基礎を踏まえたところで、Todoアプリを最低限の機能だけに絞って再構築してみました。
ルートページとトップページ
ルートページはここまでシンプルになります。また、前述したprovideとinjectのお陰で、煩雑だったデータのやりとりも非常に簡潔に描写できますし、このprovideはイベントも受け渡すことができます。また、ルーティングはルーティングファイルから制御しているので、逐一ルーティング制御用のオブジェクトをインポートする必要もありません。
<template>
<router-view></router-view>
</template>
<script setup lang="ts">
import { provide } from 'vue'
import todoStore, { todoKey } from '@/store'
provide(todoKey,todoStore) //各ページに分配
</script>
トップページは子コンポーネントを紐づけるだけです。
<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記法はセレクタ指定が不要になったので、各種ディレクティブの名称をパスカルケースでダイレクトに記述しても大丈夫です。
<template>
<ul>
<TodoItem
v-for="todo in todoStore.state.todos"
:key="todo.id"
:todo="todo"
@sendMode="sendMode"
>
</TodoItem>
</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フックのようにプロパティ名を統一して、受け渡す値に処理分岐を盛り込んでもいいです(この場合の方がカスタムイベントの記述が少なくて済む)。
<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制御の部分を見ていきます。それぞれのルーティングは以下のように記述しています。
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に代入します。また、フォームも制御することができたりします。
<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を使用します。
<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から分配してくれます。
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')
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記法に準拠しています。