使用 Vue.js 改寫 React 的官方教程井字棋
- 2020 年 3 月 29 日
- 筆記
React 的官方教程井字棋很好的引導初學者一步步走進 React 的世界,我想類似的教程對 Vue.js 的初學者應該也會有啟發,於是使用 Vue.js 進行了改寫
可以先查看最終的結果,嘗試點擊體驗,我們將逐步地實現這個效果
初始狀態代碼
初始狀態查看
打開初始狀態直接編輯,或者將對應的文件複製下來放置在同一文件夾中
此時只是一個簡單的井字棋格子,以及寫死的下一個選手
初始代碼分析
目前定義了三個組件,分別為 Square,Board 和 Game
Square 目前只是一個普通的按鈕
Vue.component('Square', { template: ` <button class="square"> {{ /* TODO */ }} </button> ` })
- 這樣定義了組件後,別的組件就可以直接以 <Square /> 的方式引用該組件
Board 模版由當前狀態和 9 個 Square 組成
Vue.component('Board', { data() { return { status: `${nextLabel}X`, board: [ [0, 1, 2], [3, 4, 5], [6, 7, 8] ] } }, template: ` <div> <div class="status">{{ status }}</div> <div class="board-row" v-for="(row, index) in board" :key="index"> <Square v-for="square in row" :key="square" /> </div> </div> ` });
- data 定義了當前狀態 status,和 board 的值,這樣在模版中就可以用 {{ status }} 的方式引用狀態值,使用 v-for 將 board 二維數組裡的值兩次循環組裝成井字格
- 在組件中的 data 必須是返回對象的函數而非對象字面值
- v-for 需要有 key 確保性能以及不報警告
Game 模版由 Board 與 後面需要增加的狀態和歷史組成
Vue.component('Game', { template: ` <div class="game"> <div class="game-board"> <Board /> </div> <div class="game-info"> <div>{{ /* status */ }}</div> <ol>{{ /* TODO */ }}</ol> </div> </div> ` });
增加數據處理
增加 props
在 Board 中傳遞一個名為 value 的 prop 到 Square
<Square v-for="square in row" :key="square" :value="square" />
- :value 是 v-bind:value 的縮寫,表示其值是一個表達式
在 Square 的組件定義和模版中增加 value prop
Vue.component('Square', { props: ['value'], template: ` <button class="square"> {{ value }} </button> ` })
- props 為父組件可傳遞給子組件的變量,在父組件調用子組件時在標籤中設置對應屬性,在子組件中使用方法與 data 一致
目前的代碼和效果:0 – 8 的數字分別填充進井字棋格中
增加交互
增加點擊事件至按鈕元素以更新值
Vue.component('Square', { //props: ['value'], data() { return { value: null } }, methods: { setValue() { this.value = 'X'; } }, template: ` <button class="square" @click="setValue"> {{ value }} </button> ` })
- @click 為 v-on:click 的縮寫,其值為點擊需要運行的函數,這裡為組件定義的方法 methods 中的 setValue
- 子組件不能直接更新父組件的值,所以將 value 從 props 改為 data
- data 的值更新,對應模版就會自動更新展示內容
目前的代碼和效果:點擊井字棋格,對應填充 X
完善遊戲
數值提升
為交替落子和確認輸贏,需要統一判斷各格狀態,所以將 value 提升至 Board
Board 增加數據 squares 和方法 handleClick
Vue.component('Board', { data() { return { ... squares: Array(9).fill(null), } }, methods: { handleClick(i) { const squares = this.squares.slice(); if (squares[i]){ alert('此位置已被占!'); return } squares[i] = 'X'; this.squares = squares; } }, template: ` ... <div class="board-row" v-for="(row, index) in board" :key="index"> <Square v-for="square in row" :key="square" :value="squares[square]" @click="handleClick(square)" />
- squares 初始為 9 個 null 組成的數組,井字棋盤為空的狀態
- handleClick 接收對應格子序號的參數,並更新對應的 squares 元素
- 事件觸發的處理函數不是 handleClick(square) 的返回值,而是 handleClick,只是在觸發時會帶上參數值 square
在 Square 的點擊事件處理器中觸發 Board 的點擊事件
Vue.component('Square', { props: ['value'], methods: { setValue() { this.$emit('click'); } },
- value 要從 data 改回到 props
- $emit 可以觸發父組件傳遞的事件
- prop 里的值在父組件更新,子組件模版也會對應更新展示內容
目前的代碼和效果:點擊井字棋格,如果未被占,則填充 X
輪流落子
增加數據 xIsNext,並在點擊時切換
data() { return { ... xIsNext: true } }, methods: { handleClick(i) { ... squares[i] = this.xIsNext ? 'X' : 'O'; this.squares = squares; this.xIsNext = !this.xIsNext; this.status = `${nextLabel}${this.xIsNext ? 'X' : 'O'}`;
- xIsNext 初始值為 true,即 X 先落子
- 點擊後,通過取反交替 xIsNext
- 更新狀態值 status 為下一個落子者
目前的代碼和效果:點擊井字棋格,X 和 O 交替落子
判斷勝者
增加計算勝者的函數
function calculateWinner(squares) { const lines = [ [0, 1, 2], [3, 4, 5], [6, 7, 8], [0, 3, 6], [1, 4, 7], [2, 5, 8], [0, 4, 8], [2, 4, 6], ]; for (let i = 0; i < lines.length; i++) { const [a, b, c] = lines[i]; if (squares[a] && squares[a] === squares[b] && squares[a] === squares[c]) { return squares[a]; } } return null; }
- 列舉可能獲勝的組合,與 squares 數組的值進行比對
增加點擊處理函數的勝者邏輯
if (calculateWinner(squares)) { alert('勝負已定!'); return; } ... const winner = calculateWinner(squares); if (winner) { this.status = '獲勝者: ' + winner; return; }
- 點擊後,如果之前已有取勝,則點擊無效
- 處理落子後,再次判斷是否取勝,更新狀態
目前的代碼和效果:有一方獲勝後, 狀態和點擊處理更新
增加事件旅行
保存歷史記錄
為實現「悔棋」功能,需要記錄每一次落子的整體狀態,相當於棋盤的快照,作為一個歷史記錄,提升至 Game 組件中
在 Game 增加數據 history,將 xIsNext,status 和 handleClick 方法 從 Board 中轉移到 Game 中
Vue.component('Game', { data() { return { history: [{ squares: Array(9).fill(null), }], xIsNext: true, status: `${nextLabel}X` } }, methods: { handleClick(i) { const history = this.history; const current = history[history.length - 1] const squares = current.squares.slice(); ... squares[i] = this.xIsNext ? 'X' : 'O'; history.push({ squares: squares }); ... } }, template: ` <div class="game"> <div class="game-board"> <Board :squares="history[history.length - 1].squares" @click="handleClick" /> ` })
- squares 從 history 的最後一個記錄取值(目前只有一個記錄)
- 落子後,squares 把落子記錄進去後,history 再增加一個記錄
Board 增加 prop squares,handleClick 更新為觸發父組件的事件
Vue.component('Board', { props: ['squares'], methods: { handleClick(i) { this.$emit('click', i); } },
目前的代碼和效果:狀態位置更新,歷史記錄已存儲
展示歷史步驟記錄
把歷史記錄循環展示出來,並綁定點擊事件,通過 stepNumber 的更新顯示對應步驟的記錄
Vue.component('Game', { data() { ... stepNumber: 0, ... } }, methods: { handleClick(i) { const history = this.history.slice(0, this.stepNumber + 1); ... this.history = history.concat([{ squares: squares }]); this.stepNumber = history.length; ... }, jumpTo(step) { if(step === this.stepNumber){ alert('已在' + (0 === step ? '最開始' : `步驟#${step}!`)); return; } this.stepNumber = step; this.xIsNext = (step % 2) === 0; this.status = `${nextLabel}${this.xIsNext ? 'X' : 'O'}`; } }, template: ` <div class="game"> <div class="game-board"> <Board :squares="history[this.stepNumber].squares" @click="handleClick" /> </div> <div class="game-info"> <div>{{ status }}</div> <ol> <li v-for="(squares, index) in history" :key="index" :class="{'move-on': index === stepNumber}"> <button @click="jumpTo(index)">{{ 0 === index ? '回到開始' : '回到步驟#' + index }}</button> ... ` })
- 在 Game 中增加 stepNumber,初始為 0,記錄當前展示的步驟
- 將 Board 的 prop squares 的取值更新為 this.stepNumber 對應的步驟
- handleClick 中以已當前步驟為基礎處理 history,並更新 stepNumber
- 增加方法 jumpTo 處理回到歷史的展示,更新 stepNumber,xIsNext 和 status
最終的代碼和效果:每落一子,都會增加一個歷史步驟,點擊步驟可回到該步
總結
遊戲實現內容
- 交替落子
- 判斷輸贏
- 悔棋重來
展示技術內容
- v-bind 在模版中進行數據綁定
- v-for 在模版中進行數組循環
- v-on 在模版中進行事件傳遞和觸發
- data 在組件的定義和模版自動更新
- prop 在組件的傳遞和模版自動更新