Vue.js 井字遊戲 - OOXX

大家好,我在地下城8樓,題目是3x3井字遊戲,直覺想到最簡單的方式就是暴力檢查,每個Row跟Column去檢查有沒有相等的,最後加上對角線兩條。也看到別樓有分享Alex1&10解法,覺得很高明。

不過我想看看有沒有不一樣的做法,相信一定有。剛剛講到Row跟Column,就讓我想到二維陣列。沒錯,整個井字窗格根本可以用座標來看,今天這個題目的解法會以二維陣列為主軸來解說作法,切版的部分則比較繁瑣且不難所以不會多提,有問題可以再跟我討論。

你可以在這裡看到我的成果,或是參考原始碼

規則

  • 先手為 O,後手為 X,某方獲勝時,上方會紀錄各方的獲勝戰績

  • 每回合結束後,會判定結果頁(平手、O獲勝、X獲勝)

  • 需符合 RWD,能在低螢幕解析度也能遊玩,介面不能超出 x 軸,至少在以下解析度能夠遊玩:

    1. iPhone SE 320px
    2. iPhone 8 375px
    3. iPhone PLUS 414px
  • 請使用瀏覽器離線儲存技術,將戰績保留起來

資料結構

為了讓對應到畫面的資料可以更直觀的操作跟查詢,我是這樣訂定格式的:

data(){
    return {
    ...
     currentPlayer:0,
     gameStatus:[[null,null,null],
                 [null,null,null],   
                 [null,null,null]]
            }}

陣列第0個元素代表的是第一個Row,Row本身也是陣列,剛好由左到右對應到0、1、2的位置,(0,0)對應到最左上,(2,2)對應到最右下,這樣直觀多了,如下圖所示:

然後用0表示O、1表示X,與上述資料結構的currentPlayer一樣,null則表示尚未下棋的位置,不過這邊不建議用0來做任何的判斷,原因稍後再提。

判斷勝利者

因為我用的資料表示是: O為0,X為1,因此如果整列、整行或交叉相加都是0,或是3,代表有人勝出,這時我只要取得currentPlayer就知道獲勝的是誰了。

判斷勝利邏輯

要判斷目前賽況是否有人勝出,我們需要檢查的部分有:

  • 每個Row(橫排)是否有連線
  • 每個Column(直行)是否有連線
  • 對角線是否有連線

判斷對角線的部分比較麻煩,但你應該可以看出,使用陣列的方式可以清楚區分出Row跟Column,讓檢查更方便,檢查的時間點是每次使用者點完要下的棋格之後的瞬間,檢查完最後切換使用者(玩家)。

methods:{
...
onSectionClick(x,y){
         if (this.gameStatus[y][x]!==null) return //防止重複下棋
         this.gameStatus[y][x] = this.currentPlayer//下棋
         this.checkResult()//檢查賽況
         this.togglePlayer()//<---下完後切換使用者

       },}

因為是二維陣列所以要兩層for迴圈,注意這邊因為要先取用Row,所以陣列的操作必須先從y (也就是直排) 開始選取,而不是一般座標的表示方式(x,y),當然可以透過改變資料結構做出比較直觀的使用方式,不過因為沒有很困難,且為了解說方便,這邊就不做調整。

  let status = this.gameStatus
  for(let y=0; y<status.length; y++){
    for(let x=0; x<status[y].length; x++ ){
      // 用status[y][x]取得棋格元素 <-----重要
    }
   }
   

知道取得陣列的方式之後就可以開始逐行逐列檢查啦!

檢查Row / Column是否有連線

想要取得Row整行的和,每次的y必須固定,我是在x為0的時候,取得後面兩個x+1、x+2元素,相加後算出總和,。

methods:{
...
getSumOfRow(x,y){
            let status = this.gameStatus
            if(x!==0 ) return 
             return status[y][x]+
                       status[y][x+1]+
                       status[y][x+2]
        },}

Coloum總和則為x y與上述相反:

 getSumOfColumn(x,y){
            let status = this.gameStatus
            if(y!==0 ) return 
            return   status[y][x]+
                     status[y+1][x]+
                     status[y+2][x]             
        },

取得總合後判斷是否有0或是3就知道有無勝出。

檢查對角線是否有連線

一樣是在迴圈一開始x為0以及y為0的時候做判斷,在取得總和之前,我先看看是不是整個對角線都已經被下過棋了,如果沒有,則直接return 跳出不做任何動作。

methods:{
getSumOfNegativeSlash(x,y){
            let status = this.gameStatus
            if(x!==0 || y!==0 ) return 
            if(  !(this.isNumber(status[y][x]) && 
                  this.isNumber(status[y+1][x+1]) &&
                  this.isNumber(status[y+2][x+2]))) return 
                        
            return   status[y][x]+ status[y+1][x+1]+status[y+2][x+2]
        },
        
}

另一個方向的對角線是同樣的做法,看到這裡的你應該可以推算出邏輯,如果還是不懂,可以參考文章開頭處的專案原始碼。

整合所有的判斷邏輯 - checkResult method

最後整個判斷邏輯如下,我把三種情況(Row / Column / 對腳線)個別寫下條件之後用OR串起來,只要狀況符合其中一種條件,就會結束賽局。

         checkResult(){
            
            let status = this.gameStatus
           for(let y=0; y<status.length; y++){
               for(let x=0; x<status[y].length; x++ ){
                   let sumOfCol = this.getSumOfColumn(x,y) 
                   let sumOfRow = this.getSumOfRow(x,y) 
                   if(
                        (   this.isRowAllNumber(x,y) && 
                            sumOfRow=== 0||sumOfRow=== 3
                        )||(
                            this.isColumnAllNumber(x,y) && 
                            sumOfCol === 0||sumOfCol === 3
                        )||(
                           this.getSumOfNegativeSlash(x,y)===0 || 
                           this.getSumOfNegativeSlash(x,y)===3 || 
                           this.getSumOfPositiveSlash(x,y)===0 || 
                           this.getSumOfPositiveSlash(x,y)===3
                        )){
                      this.setScore()  
                      this.goResultPage(this.currentPlayer)
                    }
               }
           }
           this.checkIfFinish()
        },

這邊有一個蠻麻煩的雷點就是js裡面null+0居然還是0,害我沒辦法直接正確判斷結果,導致我還寫了一個isRowAllNumber來判斷該列/欄是否全是數字,所以不推薦用0跟null搭配來當做相加的判斷。(你可以打開開發者工具試試看)

使用localStorage紀錄分數

這邊我使用localStorage來永久儲存比數,localStorage操作方式有以下幾種:

  • localStorage.setItem(‘key’,‘content’)
  • localStorage.getItem(‘key’)
  • localStorage.removeItem(‘key’)

瀏覽器上有另外一個操作方式很類似的api叫做sessionStorage,差別就在於localStorage除非主動刪除,否則會永久保留在瀏覽器、sessionStorage則是在分頁關閉後就會被刪除。

我把上面三個api另外包成三個function,讓我在各個元件可以方便使用,統一使用方式,以下是setItem的包裝:

export const setStorage = (name, content) => {
    if (!name) return;
    if (typeof content !== "string") {
      content = JSON.stringify(content);
    }
    window.localStorage.setItem(name, content);
  };

getStorage、removeStorage則是類似的方式,在此就不多提。

多思考一些 - 重置分數按鈕

這邊思考的是,既然可以儲存戰績,那如果可以讓玩家手動清除分數,應該會更好用,很多遊戲也有這種功能。所以我將原本入口的START按鈕改為CONTINUE跟RESTART,如果兩邊分數為0才只顯示START。

雖然是小地方但可以增加樂趣,算是一點小優化,給有興趣的人參考。

Vue.js 實作 Canvas 畫板

大家好,我在地下城七樓。在網頁互動上,Canvas的表現速度比起傳統用DOM元素操作的方式快上許多,因此常有某些互動的需求會採用Canvas來達成,這個專案會從基礎開始解說我如何實作Canvas畫板。

你可以在這裡看到我的實作成果,或是在此參考我的程式碼。

這個專案會盡量練習以我的角度寫出好讀的程式碼,什麼是好讀的程式碼?總該有個客觀定義,但以我目前所學來看,通常是有一定經驗的人,看夠別人寫的程式法,才能漸漸分辨好壞。不過俗話說取悅別人之前要先取悅自己,我想在這點上可以列一個最低限度的階段目標,就是先寫出讓自己好讀的程式碼。 畢竟如果自己都看得很吃力,那我想也絕對稱不上好讀。

P.S. 網路上應該可以找到不少討論這一點的文章

Outline

  • Canvas - 指定渲染環境
  • 封裝畫線方法
  • 鉛筆工具
  • 橡皮擦工具
  • Canvas轉成圖片
  • 空心矩形
  • 復原與重做
  • 多思考一些 - 使用者體驗

指定渲染環境

Canvas 會產生一個固定範圍的畫布,但開始繪製圖型之前, 由於canvas可以有很多種渲染環境,我們必須使用getContext()方法來指定想要的渲染方式,這邊畫布使用的是2d環境。

<canvas ref="sketchpad"></canvas>
methods:{
...
 setCanvas(){
      let canvas = this.$refs['sketchpad']
      canvas.width  = window.innerWidth
      canvas.height  = window.innerHeight - 60
      let ctx =  canvas.getContext('2d')
      ctx.lineCap = "round"
      ctx.lineJoin = "round"
      this.canvasContext = ctx
    },
}

封裝畫線方法

基礎

canvas畫線之前要先呼叫beginPath方法告知開始畫線,最後用stroke()畫出邊線或是用fill()填滿,一個畫線的簡單例子:

ctx.beginPath();       // Start a new path
ctx.moveTo(30, 50);    // Move the pen to (30, 50)
ctx.lineTo(150, 100);  // Draw a line to (150, 100)
ctx.stroke();          // Render the path

這段程式是在告訴canvas從座標(30,50)開始畫線到(150,100),不過從beginPath開始,直到stroke()被呼叫為止,才會將線段渲染到畫板上,詳細說明可參考官方文件

簡化

每次要畫線段都要呼叫beginPath() 跟 stroke() 實在是太多餘了,我把每次都相同的部份用methods封裝起來,中間實際會改變的部分我用callback當作參數傳入這個method,這麼一來就可以減少重複的程式碼:

methods:{
    ...
     draw(action){
      let canvasContext = this.canvasContext
      canvasContext.beginPath()
      canvasContext.lineWidth = this.currentSize*2       
      action(canvasContext)
      canvasContext.stroke()
    },

}

實際使用:

 this.draw((ctx)=>{
                        ctx.strokeStyle =  this.currentColor.code
                        ctx.moveTo(this.tempPosition.x,this.tempPosition.y)
                        ctx.lineTo(pos.x,pos.y)
                    })

如果你寫Js一段時間應該會發現常見的map或是filter也是類似的用法。

實作鉛筆工具

鉛筆工具實作上,基本上就是找出滑鼠按下的時候,滑鼠移動的軌跡,再將這些點用線段一個一個連起來,實作前有兩個問題要先想一下:

  • 如何記錄移動軌跡,每個點都記錄?還是只記錄一個?要記錄什麼時間點的軌跡
  • 如何得知滑鼠現在是否為按下的狀態 ?

我實作的方式是,在window新增mousemove監聽事件,事件被觸發時,記錄滑鼠移動到B點時的前一個點A點位置,然後因為在B點時的位置可以直接透過js api取得,這樣就能夠對照前後位置,以實作鉛筆功能。

window.addEventListener('mousemove',(event)=>{
...
let currentPos = this.getCanvasMousePosition(event.clientX,event.clientY)
...
...
this.setCanvasTempPosition(currentPos.x,currentPos.y) 
           
})

取得鼠標位置的細節可參考我的Github。只看程式碼可能有點抽象,第一次移動的時候,tempPosition一定是空值,直到位置發生變化(從A到B)結束後,A點位置才會被記錄到tempPosition,完成前一軌跡記錄。

再來,如何確定滑鼠是否為按下狀態? 我在canvas元素上分別新增mousedown跟mouseup事件,並在Vue data新增一個布林值isCanvasMouseDown,以透過這兩個事件切換滑鼠狀態。

onCanvasMouseDown(){ 
      this.isCanvasMouseDown = true
      ...
    }

onCanvasMouseUp(){
    this.isCanvasMouseDown = false
  }

最後,就剩下canvas畫圖的功夫了,在畫圖之前記得先確認滑鼠狀態以及是否已有軌跡記錄之後再執行動作,否則會出錯:

In MouseMove Event

...
    if(this.isCanvasMouseDown && this.tempPosition){
            let pos = this.getCanvasMousePosition(event.clientX,event.clientY)
            switch(this.currentTool){
              case 'paint-brush' : 
                    this.draw((ctx)=>{
                        ctx.strokeStyle =  this.currentColor.code
                        ctx.moveTo(this.tempPosition.x,this.tempPosition.y)
                        ctx.lineTo(pos.x,pos.y)
                    })
                    break;
                    ...

橡皮擦工具

橡皮擦工具只是把已經畫上的圖案消去,這邊我直接用前面實作的筆刷工具,搭配背景色,可以達成一樣的效果,比較特別的是,我將canvas背景色記錄在vue data裡面,這樣就可以確保橡皮擦工具的顏色跟背景色是相同的值,減少錯誤發生機率。

Canvas 轉成圖片

轉成圖片不是什麼大問題,Canvas元素本身有一個toDataUrl,可以回傳回傳含有圖像的Url,最後再產生一個a元素,將網址貼上去後觸發click事件,就可以模擬點擊,達成下載圖片的功能。

canvasToImage(){
      let url =  this.$refs['sketchpad'].toDataURL("image/png", 1.0).replace("image/png", "image/octet-stream");
      const link = document.createElement('a')
      link.innerText = 'Download'
      link.href = url 
      link.download = 'circle.png'     
      link.click()
    },

空心矩形

這個功能是我思考最久的一部分,canvas要單純畫上矩形的話只要呼叫rect()方法就行,方法說明如下:

ctx.rect(起始位置x, 起始位置y, 長度, 寬度);

這格應該不難理解,最難的是如何達成「滑鼠按下時可一邊調整矩形的大小跟長寬」如果像實作鉛筆功能時,在每個軌跡都畫圖型的話,就會發生災難:

看到這裡你應該可以了解原因了,因為我在每次滑鼠改變位置時都畫了一次。這邊要思考的是:

  • 使用矩形工具,滑鼠按下時要做什麼事情
  • 什麼時候紀錄前一個移動軌跡,什麼時候不要

我的解法是,當使用矩形工具,且偵測到mousedown事件時,在畫圖之前先用getImageData做一個簡單的暫存:

methods:{
...
setTempCanvas(){ 
      let ctx = this.canvasContext
      let canvas = ctx.canvas 
      let tempCanvas = ctx.getImageData(0, 0, canvas.width, canvas.height);
      this.tempCanvas = tempCanvas
    }, 
...
}

並且在每次滑鼠位置改變並且渲染矩形完成後,用putImageData() 把暫存復原,以上面災難圖為例,就是每次畫完之後,只會看到最為面那層。

復與原重做

我找到一篇描述利用window.history模擬瀏覽器上ㄧ頁下一頁功能的文章,實作步驟簡單來說就是在每次畫完圖型後將目前畫面暫存進history裡面,再去透過js操作瀏覽器上頁跟下頁來達成這個功能,蠻有趣的。

多思考一些 - 使用者體驗

有幾個細節是比較容易被忽略的,但是如果有這些小地方可以讓使用者操作或是開發上更順暢,這邊把想到的點列出:

  1. 畫筆畫到一半,滑過工具列時,能不能繼續畫
  2. 調整大小時,能不能用拖拉調整

1.畫筆畫到一半,滑過工具列時,能不能繼續畫

我原本是把Mousemove事件放在canvas上面,後來做到一半才發現當我畫到一半,滑鼠經過工具列,他不會繼續對canvas的滑鼠事件有動作,因此會中斷畫圖,後來我將Mousemove事件放在整個windows上,並判斷現在滑鼠狀態是否是按下(MouseDown),才解決這個問題。

2.調整大小時,能不能直接用拖拉調整

會想到這個是因為很多繪圖軟體都有這個功能,這邊的雷點跟第一點一樣,必須將mouseMove事件放在window上面,否則拖拉到一半滑鼠離開input元素時,就會停止調整Size數值,反而很不好用。

Vue Method:

  checkSizeDrag(dragValue){
      if(dragValue<0){ 
        this.currentSize =  parseInt(this.currentSize)+1
      }else if(dragValue>0&&this.currentSize>=1){
        this.currentSize = parseInt(this.currentSize)-1
      }
    },

In MouseMove Event

 
window.addEventListener('mousemove',(event)=>{
...
    let currentPos = this.getCanvasMousePosition(event.clientX,event.clientY)
    if(this.isSizing && this.tempPosition ){
                let dragValue= currentPos.y- this.tempPosition.y 
                this.checkSizeDrag(dragValue)
    }
...
}

你可以在這裡看到我的實作成果,或是在此參考我的程式碼。

參考文章

canvas基礎:
http://test.domojyun.net/MEMO/Canvas/

pushState 實作復原及重做:
https://ithelp.ithome.com.tw/articles/10195793

Vue.js小遊戲實作-60秒算數挑戰

大家好,我在六角地下城6f,這樓的挑戰是實作小遊戲,題目的邏輯是在六十秒內會產生隨機兩個數字去隨機做加減乘除, 答對就加一分,答錯就扣一分,秒數越少,題目越困難,得分就越高分。

題目詳細規則如下:

  • 0~20 秒為 1位數計算 (ex. 5-3)
  • 21~40 秒為 2 位數計算 (ex. 30*19)
  • 41~60 秒為 3 位數計算 (ex. 332+312)
  • 加減乘除規則請用隨機產生,不可寫死題目,60 秒內可無限次數答題。
  • 0~40 秒答對加一分
  • 41~60 秒答對加五分
  • 答錯扣一分,最多僅能扣到零分

我打算在這個專案做邏輯梳理的練習,因此會盡量把有機會重複使用的部分拆成methods,這個專案只會講述核心部分的邏輯,也就是如何產生題目,以及一些可能的問題點,其他細節部分希望你自己做做看,你可以在這裡看到我實做的成果。或是也可以直接參考原始碼

規則流程-運算:

  1. 產生隨機兩個數字
  2. 產生運算符號(加減乘除)
  3. 等待使用者輸入
  4. 檢查正確答案與使用者輸入是否符合
  5. 產生下一道題目

規則流程-時間:

  1. 挑戰者按下開始後,切換頁面並開始倒數
  2. 倒數60秒
  3. 檢查現在秒數,根據不同秒數決定不同難度的題目及得分
  4. 60秒倒數完畢後,切換顯示頁面,不讓挑戰者繼續輸入
  5. 挑戰者查看得分,等待按下「再次挑戰」後重新此流程

需特別注意的規則細節:

因為數字是隨機產生,因此在某些情況下要注意,否則會有負數產生:

  1. 減法時,會有前後數字大小的問題
  2. 除法時,會有數字大小以及除不盡的問題
  3. 最後挑戰者重新開始時記得clearInterval

專案實作 - 基本配置

這個專案我是使用vue-cli,因為規模並不是很大,也不能跳頁,因此不打算另外切出Component。

資料結構的部分,因為會有重置資料的需求,我把原本習慣寫在data()裡面的資料拉出來,放在獨立的function回傳,這樣我就可以輕易的拿到預設的資料。

// outside Vue instance
function initData(){
    return {
     pages:['start','game','result'],
     operatorList:['+','-','x','÷'],
    operator:'+',
    currentPage:'start',
    score: '000',
    time_remain:60,
    currentTime:'00:00',
    quizNumbers:[],    }
}

// inside Vue instance
export default {
  name: 'app',
  data(){
    return initData()
  }
}

已知狀態有三種,因此我直接根據不同狀態去顯示不同頁面,這邊寫法可以依照自己習慣去更改:

 <div :class="pages[0]" v-if="currentPage==pages[0]"  >
 ...
 </div>
 
  <div :class="pages[1]" v-if="currentPage==pages[1]" >
  ...
  </div>

  <div :class="pages[2]" v-if="currentPage==pages[2]" >
    ...
  </div>

專案實作 - 邏輯實現

倒數方法 - 使用setTimeInterval

要能夠時線倒數,代表必須每隔一秒改變畫面上的時間值,這裡用setTimeInterval最適合不過了,他會每隔一段時間去執行寫入的方法:

methods:{
    ...
     timeReducer(){
         let timer =  setInterval(()=>{
          if( this.time_remain>0){
            this.currentTime = this.convertSeconds(this.time_remain -=1 )
          }else{
            clearInterval(timer)
            this.currentPage= this.pages[2]
          }
        }, 1000)
        return timer
    },
    ...
}

但在使用完後記得要清除這個interval,因為他會一直執行,如果沒有清除,在挑戰者再次開始遊戲時,如果沒有清楚,就會有兩個interval重疊,遊戲就無法順利進行,這邊我是在倒數完之後在setTimeInterval裡面直接使用clearInterval清除。

隨機產生運算子

因為加減乘除運算子必須隨機產生,所以我將這四種運算方式寫在陣列裡面(operatorList),然後用JS的random方法去隨機挑選這個陣列的元素:


methods:{
    ...
    randomOperator(){
      let order = Math.floor(Math.random()*100) % 4 
      this.operator = this.operatorList[order]
    }
    ...
}

取得不同位數的數字

依照上面整理的規則,我們第一個會需要處理的部份是隨機產生數字,JS裡面可以用Math.random() 去解決,但要注意這個方法回傳的值是介於0~1之間的浮點數,後面要自己去調整才能拿到自己想要的格式。

以及要考量到可能會有需要拿不同位數數字的需求,所以在這個method我用digits當作參數來表示欲取得的位數:

 // inside Vue instance 
 ...methods:{
     ...
     getDigits(digits){
      switch(digits){
        case 1 : return Math.floor((Math.random() * 10) + 1) ; 
        case 2 : return Math.floor((Math.random() * 100) + 10) ; 
        case 3 : return Math.floor((Math.random() * 100) + 100) ; 
      }
    },
 } 

取得數字之後的處理

加上setQuizNumber方法,根據不同時間取得不同位數的數字:

    //setQuizNumber
    let digit;
      switch (true){
        case (this.time_remain>=40 && this.time_remain<=60): {
              digit = 1
              break; 
            }
        case (this.time_remain>=20 && this.time_remain<=40): {
              digit = 2
              break;
            }
        case (this.time_remain>=0 && this.time_remain<=20): {
              digit = 3
            break;
        }    
      }
      
      let result = []
      let firstNum=this.getDigits(digit); 
      let secondNum=this.getDigits(digit); 

我們不想要答案有負數或小數點產生,所以在減法時,必須注意第一個數字是否大於第二個,至於加法跟乘法就沒有這個問題,所以我加上判斷,如果第一個數大於第二個,就做swap交換:

    //setQuizNumber
      if(firstNum < secondNum){
        let temp = firstNum
        firstNum = secondNum 
        secondNum = temp 
      }

最後為了在除法時,讓兩數是可以整除的,所以必須確保第二個數字是第一個數字的因數,以及不能有質數出現,我加入的判斷質數的方法isPrime跟取得數字所有因數的方法getFactors,以及後面會有從因數裡面雖機挑選一個數字的需求,所已加入陣列隨機挑選元素的方法getArrayRandomItem:

methods:{
    ...
    isPrime(num) {
      for(var i = 2; i < num; i++)
        if(num % i === 0) return false;
      return num > 1;
    },
    getFactors(number,digits){
      let factors =  Array
        .from(Array(number + 1), (_, i) => i)
        .filter(i => number % i === 0)

      if(digits){
        factors = factors.filter(factor=>(factor+'').length===digits)
      }
        return factors
    },
    getArrayRandomItem(array){
      return  array[Math.floor(Math.random()*array.length)];
    },
    ... 
}

最後過濾掉質數後,從第一個數字的因數(陣列)裡面去隨機選出一個數當作第二個數字,就可以送出了:

 //setQuizNumber
 if(this.operator=='÷' && ( firstNum % secondNum!==0)){
        while(this.isPrime(firstNum)) firstNum = this.getDigits(digit); 
        while(this.isPrime(secondNum)) secondNum = this.getDigits(digit); 
        
        secondNum = this.getArrayRandomItem(this.getFactors(firstNum,digit))
        
      } 
      result = result.concat([firstNum,secondNum]) 
      
      this.quizNumbers = result

做到這裡產生題目的邏輯就完成了。

再次挑戰- 重置所有資料

還記得我一開始把原始狀態拉出來單獨放在function裡嗎?現在就可以拿來用了,使用Object.assign,可以直接對目標物件赴值,如果屬性重複,以參數越後面屬性的值為主(後面寫入的會蓋掉前面的),可以參考 官方文件,順帶一提Object.assign不支援深度拷貝,如果你要複製的物件裡面還要物件,要特別注意,他只會複製那個物件的參考,後面你只要改動到該子物件,則所有該物件的參考都會跟著一起被改變。(參考官方文件

methods:{
    ...
    onResetClick(){
      Object.assign(this.$data, initData());
      this.currentPage = this.pages[0]
    },
    ... 
}

this.$data是Vue裡面提供取得data物件的api,詳細可參考官方文件
到這邊所有核心的邏輯都講完了,再提一次我並沒有從頭到尾講得很詳細,希望你試著自己去理清楚來龍去脈,自己識做看看,若還是看不懂也沒關係,可以參考我的原始碼,在自己做做看,有任何問題可以一起討論,感謝你的收看,下次見啦。

Vue.js + Node.js + OpenAPI 帶你一次了解 CORS 跨域請求

本篇文章是我在六角學院地下城活動5F關卡的實作紀錄,會描述如何架設簡單的Proxy Server去跟政府的公開API拉取資料回來給前端Vue.js介面做互動。

成果可參考這裏(heroku開機要一段時間如果拉不到資料請多重整幾次。)

使用技術及觀念:

  • Vue.js (with vue-cli3)
  • Node.js (express with express-generator)
  • Heroku
  • CORS Headers
  • Jsonp (僅技術講解)

Outline

  • 同源政策
  • Node.js — express generator
  • HTTP 的 CORS Request
  • 修改 Response Header 允許跨域
  • 部署到Heroku
  • Vue-Cli dev 模式下的proxy
  • 什麼是Jsonp
  • 寫在最後

同源政策

一般開發網站的時候,如果是採前後端分離的架構,就會遇到跨域問題。就像今天這個專案,我的目標是抓取台灣空氣品質的開放API,回來做成介面給使用者搜尋,但是在利用axios直接打API的時候,遇到了下面的狀況:


這是因為網頁在傳遞資料的時候,不管是透過傳統XMLHttpRequest(常見的ajax方式) 或是Fetch,都會遵循同源政策(Same Origin Policy),「同源」指的是同個域名底下的資源,因為只能存取相同來源的資料,所以那些跨域的請求就會被阻擋掉。

這是瀏覽器的安全機制,當然並非如此一來就無法跨域存取了,我想到的解決有以下兩種:

  • Jsonp (Json with padding)
  • 使用Proxy 代理伺服器存取該目標API

因為jsonp的相關知識文章應該有不少,且有安全性疑慮(最後面會說明)。本篇會以Node實做Proxy為主來講解。

Node.js — express generator

跟前端有vue-cli 、 react-create-app 等方便又快速的手腳架一樣,許多後端框架也有類似的功能,express-generator就是其中一個。安裝方式非常簡單,如果對接下來流程有任何疑問,可以參考官方文件,有很詳細的使用說明。

在terminal輸入以下指令以安裝

npm install express-generator -g
  1. 輸入 express [你的專案名稱] 就可以產生出以express為基底的專案架構:
express  proxy-server

接下來就可以進入專案去新增我們要的api了。

HTTP 的 CORS Request

就像前面說的,CORS Request並非完全不可行,只是在Server端必須要有一些設定。既然我們不可能修改OpenAPI的Server內容,又因為後端資料交互是不會碰到瀏覽器的(因此就不會因為跨域問題被阻擋),我們就自己架一個Proxy Server來修改Http相關的設定,讓前端可以順利拿到資料。

CORS Request / Response流程

你在 example.com.tw 送出request給某網站的時候,在request body裡面會夾帶一個Origin的header,內容是你網站的Domain名稱:

Origin: http://www.foo.com

而在後端伺服器收到request並且回傳resonse到client的時候,瀏覽器會去看response裡面的header–***「Access-Control-Allow-Origin」***是不是包含剛剛發出request寫的Origin 域名,如果有,資料才會允許被回傳。

修改 Response Header 允許跨域

先到剛剛創造的express專案底下。

先找到我們的Open API URL,等等會用到:

http://opendata.epa.gov.tw/webapi/api/rest/datastore/355000000I-000259

###安裝所需套件
因為我們要利用後端去跟遠端Open API互動,所以這邊我們使用node-rest-client來達成這部分功能,node-rest-client官方文件:

npm install node-rest-client

新增API路由

在剛剛創造的express專案底下的主程式routes資料夾新增 air.js :

air.js

var express = require('express');
var router = express.Router();
var Client = require('node-rest-client').Client;
var client = new Client();
/* GET users listing. */
router.get('/', function(req, res, next) {
    let queries=req.query
    console.log('query: ', queries);
    let url='http://opendata.epa.gov.tw/webapi/api/rest/datastore/355000000I-000259'
    var options = {
        host: url,
        method: 'GET'
      };
    
    client.get(url, function (data, response) { 
        res.json({...data})
    });
});
module.exports = router;

router.get() 是express的router基本寫法,最後將這個包含自定義route的Router實體export出去,之後必須在主程式app.js引入才會有效,可以參考官方文件,可以看到在rest-client抓完資料的callback,我用json方式回傳了Open API 回傳的結果。

  ...
  client.get(url, function (data, response) { 
      res.json({...data})
  });
  ...

主程式app.js路由引入
接下來在app.js裡面將剛剛新增的router引入:

var airRouter = require('./routes/air');
app.use('/air', airRouter);

記得在var app=express()之後才做app.use()這件事,否則會讀不到app這個變數。

關鍵步驟: Response header 設定

將設定用middle的方式寫入,Express內,如果你直接在app.use()裡面傳入一個

function(req, res, next) {
     next()d
 }

那是middleware的意思,即所有request都會經過你傳入的function,且直到呼叫next()之後才會繼續執行,我們在這裡面設定Response 的Header,如此一來所有的Request都會得到相同的Header設定值(也可以在單獨某隻router寫入設定,可視需求改變)。

let allowCrossDomain = function(req, res, next) {
  res.header('Access-Control-Allow-Origin', '*');
  res.header('Access-Control-Allow-Methods', 'GET,PUT,POST,DELETE,OPTIONS');
  next();
}
app.use(allowCrossDomain)

我在Response Header裡面加入了

  • "Access-Control-Allow-Methods"代表允許讓設定的Http Method通過。
  • “Access-Control-Allow-Origin”,其值為’*’。

注意,剛剛說Response回傳到Client之前瀏覽器會先檢查這個Header有沒有跟來源Origin的值ㄧ樣如果是一般前後端分離的sercer這部分的值一般會xxx.com.tw,不會公開。如果你這樣寫,代表大家都可以對你的Server做跨域請求,這邊因為是open API 且是示範性質所以才這樣做。

到這邊其實已經完成了,如果順利的話你的Server應該已經可以拿到遠端API的資料,你可以用

npm run start  
// then go to  http://localhost:3000/air

在本地架起來用瀏覽器或Postman去測試看看。

部署到Heroku

想要在Heroku 架起一台node Server非常簡單,流程大致上是註冊帳號->安裝heroku cli-> 登入-> 到你的專案輸入幾個指令-> 最後 git push heroku master,他自己就會幫你在heroku提供的空間下npm install安裝好需要的套件並且架起來。

這邊就不多做說明,可參考官方教學,相信我,不會花掉你太多時間的,可先用官方提供的專案做練習。需注意的是因為是免費的服務,所以只要半小時沒在使用,他會暫時關閉你的機器,所以有時候連線會比較慢,可不要以為壞掉了。

可參考我的實作成果。

Vue-Cli dev 模式下的proxy

Vue-cli因為是基於webpack,其實有proxy的設定,你可以參考官方文件。但因為這樣只解決開發環境的問題,所以後來我才打算自己架設Proxy。

另外,前端的部分因為比較基礎,就沒有太詳細解說,你可以看看我的Repo,如果還是有不懂的地方,隨時可以聯絡我。

什麼是JSONP

因為傳統 Ajax無法跨域,早期工程師們找到了替代解決方案—< script> tag 的src引用是可以跨域的,於是利用這個原理的jsonp就這麼誕生了(想想你在引入一些js函式庫如lodash的時候是不是可以用cdn方式直接引入執行?)

Jsonp會做什麼事情

  1. 創造一個 < script > tag
  2. 根據你指定的Jsonp位置,設定這個tag的src
  3. 把這個< script > 加到瀏覽器DOM的head
  4. 一但資料載入完畢,會將回傳的資料傳給設定的callback並執行這些內容

Jsonp的安全隱患

如果你使用JsonP,那你帶進來的這些內容,跟你自己寫的jsㄧ樣,可以去存取你的DOM等網頁內容,這種情況下如果無法保證server的安全性,將會是潛在的問題。

寫在最後

前端介面成果:http://underground-air.surge.sh/
Proxy Server成果: https://mu-air-proxy.herokuapp.com/air

到這邊,如果你可以很順的理解以上的內容,那你應該對CORS Request流程有一定的了解了。其實因為時間跟篇幅的關係,有些實作部分我並沒有講的很仔細,但是我都有提供相關文件在該段落的附近,因為我覺得寫程式常常需要的是閱讀文件的能力,希望各位可以學著找到自己所需的資源並補上。在「尋找資源-實作-找問題修Bug」的循環過程中其實也在訓練自己的理解跟整合能力,才會讓你慢慢進入學習的正向循環。

當然在這過程中有任何的不懂或是覺得有筆誤的話都可以隨時聯絡我,我很樂意跟你分享我所知道的知識。

參考文章

STOF上關於CORS的討論

CORS PROTOCAL

how jsonp works?

CORS流程圖

JS世界時間表

這篇講解的是我如何從頭開始研究Javascript時區轉換相關API,以及一些時區相關的奇怪專有詞,最後做出世界時間表,我會盡使用淺顯易懂的方式來說明,如果你看完還是對內容有疑問,可以直接聯絡我 ,一起交流。順帶一提,你沒看錯現在是早上4:30 (X_X)。

Outline

  • TimeStamp是什麼?
  • GTM+8 與 UTC+8 ? 差在哪?
  • toLocaleTimeString的使用
  • 補充:DOM 操作 — cloneNode

TimeStamp是什麼?

TimeStamp 可以想成是某個執行動作的時間點,經常我們在操作資料的時候(最常見新增/修改),會希望留下時間紀錄,以免出問題的時候可以追查。就像公家單位的公文在蓋章的時候都會有時間戳記,系統也是ㄧ樣,只不過有時候不是我們常見的日期格式。

要用js觀察TimeStamp,可以在瀏覽器下:

Date.now(); //1549041946540

你應該可以得到奇怪的一串數字,類似這樣

1549041946540

這個是什麼意思呢?是指從 1970/01/01開始至今的秒數,也就是TimeStamp。
為什麼是1970年?據說那是工程師們自己訂的,Unix系統的誕生日期。一樣透過Date物件可以把這串秒數轉成一般的TimeString:

new Date(Date.now()) // Sat Feb 02 2019 01:32:14 GMT+0800 (台北標準時間)

TimeString 也是String,只不過有一定的格式,才能給Date相關api讀取。
有興趣的人可參考這篇 。

GTM+8 與 UTC+8 ? 差在哪?

其實這兩種格式在一般使用的情況下沒有差別,只是GTM是歷史沿革,UTC是後來科學家發現一種可以更準確測量時間的方式而訂出的格式,他們之間的時間差只在區區幾秒之間(但這足以讓某些科學家抓狂),總之,因為GTM這個格式已經被國際使用許久,所以就算UTC出現以後,還是沒有消失 (跟IEㄧ樣,號稱時代的眼淚,但你不消失我才會流淚)。

W3School的解釋:

The UTC() method returns the number of milliseconds between a specified date and midnight of January 1, 1970, according to universal time. Tip: The Universal Coordinated Time (UTC) is the time set by the World Time Standard. Note: UTC time is the same as GMT time.

基本上把他看做ㄧ樣就行了(除非你真的無法無視閏秒XD)我自己一般還是習慣使用UTC格式。

new Date(Date.now()).toUTCString() // 拿到目前UTC+0 的時間

toLocaleTimeString的使用

Js裡面時間相關api有很多,要算出不同時區的時間其實有不只一種方式,這裡用我覺得比較直觀的toLocaleTimeString(),第一個參數是語系,在後面的Option裡面可以設定很多格式,timeZone就是用來設定時區:

let now = new Date(Date.now())
let month= now.toLocaleDateString(‘en-US’,{timeZone:’Your Time Zone’’,month:’long’})

TimeZone 的格式可以參考這個列表,我拿到時區後,直接綁在html上面,這樣在一開始渲染時,就可以直接拿到,非常直接。

 <li class=”zone London bg-white” id=”London” time-zone=”Europe/London” >…</li>

option 兩個常用設定值

  1. 在Option裡面除了設定timeZone,也可以設定時制
{…hour12:false} //24小時制
  1. 甚至可以直接拿到完整的月份名字,如此一來就不需要另外寫月份的mapping表:
let now = new Date(Date.now())
let month= now.toLocaleTimeString(‘en-US’,{timeZone,month:’long’})
 //”February, 3:44:46 PM”

補充:DOM 操作 — cloneNode

這邊比較特別的是,因為重複的版型我習慣利用類似template的方式去做,所以我在底下區塊把重複的結構拉出來:

 <div id=”zone-template” style=”display:none”>
 <div class=”country”>
 <div class=”name”>NEW YORK</div>
 <i class=”date”>27 JAN,2019</i>
 </div>
 <div class=”time”>02:46 </div>
 </div>

然後在畫面啟動的時候在去跑回圈一個一個掛到<li>裡面,記得使用cloneNode,確保這個版型能夠成功複製到每個區塊,如果單純用appendChild可能會有問題,詳情可以參考這篇。

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×