Vue2 から Vue3 へ移行する ボタンイベント編

前回の続き

こんな風に CRUD 機能が付いた画面を Vue2 から Vue3 へ移植してみる(実質的には Vue3 から Vue2 形式を書き起こしている)。通常ならば、上部のリスト部分とか、下部の編集項目のところを Vue コンポーネント化するところなのだが、その前段階として、1枚のベタの *.vue ファイルに書き起こす。

template 部分

template>
    <div>
        <h1>Mos Main by vue2</h1>
        <div>
            <ul>
            <li v-for="it in items" :key="it.id">
                    {{ it.id }} : <a @click="onClickItem(it)">{{ it.title }}</a>
                </li> 
            </ul>
        </div>
        <hr />
        <button @click="onClickCreateItem" class="btn btn-primary">新規作成</button>   
        <button @click="onClickUpdateItem" class="btn btn-secondary">更新</button> 
        <button @click="onClickDeleteItem" class="btn btn-danger">削除</button>
        <hr />
        <div>
        <!-- カテゴリ情報を表示 -->
            <div v-if="mode == 0">
                <table class="table">
                    <thead>
                        <tr>
                            <th>項目</th>
                            <th>値</th>
                        </tr>
                    </thead>
                    <tbody>
                        <tr>
                            <td>id: </td><td>{{  cur.id }}</td>
                        </tr>
                        <tr>
                            <td>title: </td><td>{{  cur.title }}</td>
                        </tr>
                        <tr>
                            <td>image: </td><td>{{  cur.image }}</td>
                        </tr>
                        <tr>
                            <td>作成日時: </td><td>{{  cur.created_at }}</td>
                        </tr>
                        <tr>
                            <td>更新日時: </td><td>{{  cur.updated_at }}</td>
                        </tr>
                    </tbody>
                </table>
            </div>
            <div v-if="mode == 1">
                <div>新規作成のモード</div>
                <table class="table">
                    <thead>
                        <tr>
                            <th>項目</th>
                            <th>値</th>
                        </tr>
                    </thead>
                    <tbody>
                    <tr>
                        <td>title: </td>
                        <td><input v-model="cur.title" placeholder="タイトル" /></td>
                    </tr>
                    <tr>
                        <td>image: </td>
                        <td><input v-model="cur.image" placeholder="画像" /></td>
                    </tr>
                </tbody>
                </table>
                <button @click="onClickCommit" class="btn btn-primary">登録</button> 
                <button @click="onClickClear" class="btn btn-secondary">クリア</button>
            </div>
            <div v-if="mode == 2">
                <div>更新のモード</div>
                <table class="table">
                    <thead>
                        <tr>
                            <th>項目</th>
                            <th>値</th>
                        </tr>
                    </thead>
                    <tbody>
                        <tr>
                            <td>id: </td>
                            <td>{{  cur.id }}</td>
                        </tr>
                        <tr>
                            <td>title: </td>
                            <td><input v-model="cur.title" placeholder="タイトル" /></td>
                        </tr>
                        <tr>
                            <td>image: </td>
                            <td><input v-model="cur.image" placeholder="画像" /></td>
                        </tr>
                        <tr>
                            <td>作成日時: </td><td>{{  cur.created_at }}</td>
                        </tr>
                        <tr>
                            <td>更新日時: </td><td>{{  cur.updated_at }}</td>
                        </tr>
                    </tbody>
                </table>
                <button @click="onClickCommit" class="btn btn-primary">登録</button> 
                <button @click="onClickClear" class="btn btn-secondary">戻す</button>
            </div>
            <div v-if="mode == 3">
                <div>削除のモード</div>
                <table class="table">
                    <thead>
                        <tr>
                            <th>項目</th>
                            <th>値</th>
                        </tr>
                    </thead>
                    <tbody>
                        <tr>
                            <td>id: </td><td>{{  cur.id }}</td>
                        </tr>
                        <tr>
                            <td>title: </td><td>{{  cur.title }}</td>
                        </tr>
                        <tr>
                            <td>image: </td><td>{{  cur.image }}</td>
                        </tr>
                        <tr>
                            <td>作成日時: </td><td>{{  cur.created_at }}</td>
                        </tr>
                        <tr>
                            <td>更新日時: </td><td>{{  cur.updated_at }}</td>
                        </tr>
                    </tbody>
                </table>
                <button @click="onClickDelete" class="btn btn-danger">削除する</button> 
            </div>
        </div>
    </div>
</template>

リストの部分は共通にしておいて、CURD 機能を v-if で切り分ける。初心者っぽいけど、新人教育では初心者なのでこれで問題なし。最初は v-if で区切っておいて、徐々に Vue コンポーネントに慣れていくというスタイルにする。いきなり Atomic Design に進むのもアリなんだろうけど、歴史的な経緯を追っておくほうが体系的で覚えやすい。なによりも、業務システムが Vue2 と Vue3 が混在している状態なので両方覚えるのは必須なのだから。

            <div v-if="mode == 2">
                <div>更新のモード</div>
                <table class="table">
                    <thead>
                        <tr>
                            <th>項目</th>
                            <th>値</th>
                        </tr>
                    </thead>
                    <tbody>
                        <tr>
                            <td>id: </td>
                            <td>{{  cur.id }}</td>
                        </tr>
                        <tr>
                            <td>title: </td>
                            <td><input v-model="cur.title" placeholder="タイトル" /></td>
                        </tr>
                        <tr>
                            <td>image: </td>
                            <td><input v-model="cur.image" placeholder="画像" /></td>
                        </tr>
                        <tr>
                            <td>作成日時: </td><td>{{  cur.created_at }}</td>
                        </tr>
                        <tr>
                            <td>更新日時: </td><td>{{  cur.updated_at }}</td>
                        </tr>
                    </tbody>
                </table>
                <button @click="onClickCommit" class="btn btn-primary">登録</button> 
                <button @click="onClickClear" class="btn btn-secondary">戻す</button>
            </div>

更新するときの画面は

  • テキスト入力(input)を v-model で参照させる(双方向なので)
  • ボタン(button)のイベントは @click で渡す

というノーマルな形にする。:value を使うとかトリッキーな方法もあるのだが、ここはノーマルに v-model を使ったほうが移植性がよい。

vue2 では v-if に即値を使ってしまっているが、vue3 では enum が使える

        <div v-if="mode == MODE.UPDATE">
            <div>更新のモード</div>
            <table class="table">
                <thead>
                    <tr>
                        <th>項目</th>
                        <th>値</th>
                    </tr>
                </thead>

vue2 でも const すれば定義値を使うこともできるけど、1画面内ならば即値でも十分なのでこの形式にしておく。

vue2 の script コード

import axios from 'axios'

export default {
  name: 'MosMain',
  props: {
  },
  data() {
    return {
        items: [],  // カテゴリ一覧
        cur: {},    // 選択したカテゴリ
        mode: 0,    // 表示モード
    }
  },
  mounted() {
    axios.get('http://localhost:8000/api/category')
    .then ( response => {
        console.log( response )
        this.items = response.data
    })
  },
  methods: {
    getCategoryById( id ) 
    {
        axios.get(`http://localhost:8000/api/category/${id}`)
        .then ( response => {
            console.log( response )
            this.cur = response.data
        })
    },
    // 新規作成のAPIを呼ぶ
    createCategory( item ) {
        console.log( "called createCategory" )
        // TODO: POST を記述する
        return 
    },
    // 更新のAPIを呼ぶ
    updateCategory( item ) {
        console.log( "called updateCategory" )
        // TODO: PUT を記述する
        return 
    },
    // 選択項目を新規作成する
    onClickCreateItem() {
        console.log( "onClickCreateItem " + this.cur.title )
        this.mode = 1
        this.cur.id = 0
        this.cur.title = ""
        this.cur.image = ""
    },
    // 登録ボタンを押下
    onClickCommit() {
        console.log( "onClickCommit " + this.cur.title )
        console.log( "onClickCommit " + this.cur.image )
        if ( this.mode == 1 ) {
            // 新規作成の場合 POST を呼ぶ
            createCategory( this.cur )
        } else {
            // 更新の場合 PUT を呼ぶ
            updateCategory( this.cur )
        }
    },
    // クリアボタンを押下
    onClickClear() {
        console.log( "onClickClear ")
        if ( this.mode == 1 ) {
            // 新規作成の場合
            this.cur.title = ""
            this.cur.image = ""
        } else {
            const original = this.items.find( x => x.id == this.cur.id )
            if ( original ) {
                // 更新の場合
                this.cur.title = original.title
                this.cur.image = original.image
            }
        }
    },
    // 
    onClickDelete() {
        console.log( "onClickDelete ")
        // 削除 API を呼び出す
        deleteCategory( this.cur )
    },

    // リストで項目を選択
    onClickItem( item )
    {
        console.log( "onClickItem " + item.title )
        this.getCategoryById( item.id )
    },  
  
    /**
     * 選択項目を更新する
     */
    onClickUpdateItem() {
        if ( !this.cur.id ) {
            console.log( "ERROR: onClickUpdateItem cur.value.id is null" )
            return 
        }
        console.log( "onClickUpdateItem " + this.cur.title )
        this.mode = 2
    },

    /**
     * 選択項目を削除する
     */
    onClickDeleteItem() {
        if ( !this.cur.id ) {
            console.log( "ERROR: onClickDeleteItem cur.value.id is null" )
            return 
        }
        console.log( "onClickDeleteItem " + this.cur.title )
        this.mode = 3
    },
  }
}

その壱と同じように、

  • export default で公開する
  • props, data, mounted, methods ブロックで区切る
  • ボタンのイベントとボタンから呼び出す関数は、methods 内に突っ込んである

data を参照するのに、this.* を付けないといけない。「付けないといけない」と書いたが、C++ や C#, Java などを使っていると、クラス内でのフィールド変数は this.* を付けたほうが解りやすいので、こういう風に書くことが多い。最近の Visual Studio 2022 では this なしが推奨されてるけども、this を付けるほうが外部なのか内部なのかわかりやすいのと、this.* としたところでインテリセンスが効くので、便利だったりする。ただし、最近の傾向としては、できるだけ外部の変数を参照しない=クラス内あるいは関数内だけでおさまるようにする、関数型っぽい書き方が推奨されているので、this と打つのは「面倒くさい」というのはそうなのだろう。F# の let mut と同じように、変更可能な変数の書き方のほうが *面倒くさい* ほうが、心理的に楽に流れる(これは五行大儀の前文にも掛かれている、というネタを披露しておく)ので行動経済学的にも理にかなっている。

よって、vue2 のコードでは、this.items や this.cur が頻発する。どうせローカルでしか参照しない方針ならば this は不要であろうという思想が vue3 にはある。

vue3 の script

では、同じコードを vue3 形式で書いてみよう

import { ref, onMounted } from 'vue'
import axios from 'axios'

/**
 * カテゴリのクラス
 */
class Category {
    id: number
    title: string
    category: string
    image: string
    created_at: string | null
    updated_at: string | null
    is_delete: boolean
}

// カテゴリ一覧
const items = ref([] as Category[])
// 選択したカテゴリ
const cur = ref({} as Category)
// 表示モード(列挙型)
enum MODE {
    DETAIL,     // 詳細
    NEW,        // 新規作成
    UPDATE,     // 更新
    DELETE,     // 削除
}
const mode = ref(MODE.DETAIL)



/**
 * カテゴリ一覧を取得する
 */
function getCategories() {
    axios.get('http://localhost:8000/api/category')
    .then ( response => {
        console.log( response )
        items.value = response.data
    })
}
/**
 * ひとつのカテゴリを取得する
 */
 function getCategoryById( id: number  ) {
    axios.get(`http://localhost:8000/api/category/${id}`)
    .then ( response => {
        console.log( response )
        cur.value = response.data
    })
}

/**
 * 新規作成のAPIを呼ぶ
 * @param item 更新する Category 
 */
function createCategory( item: Category ) {
    console.log( "called createCategory" )
    // TODO: POST を記述する
    return 
}

/**
 * 更新のAPIを呼ぶ
 * @param item 更新する Category 
 */
function updateCategory( item: Category ) {
    console.log( "called updateCategory" )
    // TODO: PUT を記述する
    return 
}


/**
 * 削除のAPIを呼ぶ
 * @param item 削除する Category 
 */
 function deleteCategory( item: Category ) {
    console.log( "called deleteCategory" )
    // TODO: DELETE を記述する
    return 
}

/**
 * ひとつのカテゴリを表示する
 * @param item 選択したカテゴリ
 */
function onClickItem( item : Category) {
    console.log( "onClickItem " + item.title )
    mode.value = MODE.DETAIL
    getCategoryById( item.id )
}

/**
 * 選択項目を新規作成する
 */
function onClickCreateItem() {
    console.log( "onClickCreateItem " + cur.value.title )
    mode.value = MODE.NEW
    cur.value.id = 0
    cur.value.title = ""
    cur.value.image = ""
}

/**
 * 登録ボタンを押下
 */
function onClickCommit() {
    console.log( "onClickCommit " + cur.value.title )
    console.log( "onClickCommit " + cur.value.image )
    if ( mode.value == MODE.NEW ) {
        // 新規作成の場合 POST を呼ぶ
        createCategory( cur.value )
    } else {
        // 更新の場合 PUT を呼ぶ
        updateCategory( cur.value )
    }
}

/**
 * クリアボタンを押下
 */
 function onClickClear() {
    console.log( "onClickClear ")
    if ( mode.value == MODE.NEW ) {
        // 新規作成の場合
        cur.value.title = ""
        cur.value.image = ""
    } else {
        const original = items.value.find( x => x.id == cur.value.id )
        if ( original ) {
            // 更新の場合
            cur.value.title = original.title
            cur.value.image = original.image
        }
    }
}

/**
 * 削除するボタンを押下
 */
function onClickDelete() {
    console.log( "onClickDelete ")
    // 削除 API を呼び出す
    deleteCategory( cur.value )
}

/**
 * 選択項目を更新する
 */
function onClickUpdateItem() {
    if ( !cur.value.id ) {
        console.log( "ERROR: onClickUpdateItem cur.value.id is null" )
        return 
    }
    console.log( "onClickUpdateItem " + cur.value.title )
    mode.value = MODE.UPDATE
    
}

/**
 * 選択項目を削除する
 */
function onClickDeleteItem() {
    if ( !cur.value.id ) {
        console.log( "ERROR: onClickDeleteItem cur.value.id is null" )
        return 
    }
    console.log( "onClickDeleteItem " + cur.value.title )
    mode.value = MODE.DELETE

    /*
    const result = confirm("削除してよろしいですか?")
    if ( result == true ) {
        deleteCategory( cur.value )
    }
    */
}

// ページ表示時に、カテゴリ一覧を取得する
onMounted(()=>{
    getCategories() 
})

コードは TypeScript なので、Category というクラスを定義しているのと、 MODE 列挙子を定義しているのと違いはあるが、同じ動作にしてある。

画面を表示するときのモードは リアクティブを使う

const mode = ref(MODE.DETAIL)

リアクティブという用語がでてくるが、実は vue2 ではうまく隠蔽化されていたものが、vue3 ではむき出しになってしまっただけである。理由は、むき出しにしたほうがステートありとステート無しの画面が意図的に区別ができて画面の表示スピードが上がる、のだが、vue2 ユーザーが vue3 に移行するときに躓くのはここが大きいだろう。

/**
 * クリアボタンを押下
 */
 function onClickClear() {
    console.log( "onClickClear ")
    if ( mode.value == MODE.NEW ) {
        // 新規作成の場合
        cur.value.title = ""
        cur.value.image = ""
    } else {
        const original = items.value.find( x => x.id == cur.value.id )
        if ( original ) {
            // 更新の場合
            cur.value.title = original.title
            cur.value.image = original.image
        }
    }
}

クリアボタンを押下したときに、編集画面を空白あるいは戻す処理を記述したものである。

画面のモードとして新規作成と更新がある訳だが、この部分は別々の関数にわけてもよい。実際に template 上では、新規作成では「クリア」、更新では「戻す」になっていて、ちょっと混乱した形になっている。これは新人研修用に少し混乱させることが目的でもある(実際の業務では、このようにモードで切り替えることが多いので)ので、これで良しとする。

vue2 では this.cur や this.items で参照されていたものが、vue3 では cur.value.* や items.value.* になっている。vlaue が付いているのはリアクティブ(変更通知が可能)となっているので、変更通知が必要のない固定値の場合には、cur.* や items.* と書くことができる。vue3 の本で、このあたりの違いが掛かれているのが無いのが残念なところなのだが、まあ、初学な人には「vue3 ではリアクティブ ref や reactive を使います!」としたほうが迷いが少ないので、そっちのほうがいいだろう。慣れてくるとスピードを優先させて、ref なしと ref ありを区別するとよい。

お次は、編集画面を vue コンポーネント化する

さて、いよいよ vue コンポーネントを使った形に書き換えていく。

vue2 で props と $emits を使って親子のコンポーネント間でデータをやり取りしたものを、vue3 ではどういう風に書くのかということになる。まだ Atomic Design のほうまで至らないが(私的には至らなくても良いと思っている、その理由は別途書こう)。MVVM パターンとか、Flux とかを混ぜると、その「危険度」がわかると思う。

カテゴリー: 開発 パーマリンク