顯示具有 技術分享 標籤的文章。 顯示所有文章
顯示具有 技術分享 標籤的文章。 顯示所有文章

2022年8月7日

程式是人寫出來的,測試也是

本文章首次刊登於 Web 長島計畫《第三期:測試,這不是測試

軟體測試是什麼

軟體開發的過程中,除了功能本身的開發外,還有一個很重要的環節是「測試」。有的人聽到「寫測試」,直覺反應是「好麻煩」,或覺得這只是個浪費時間的事。如果程式一開始就寫的好,為什麼還需要花時間來寫測試呢?
當然,如果能確定程式是「正確無誤的」,就可以省去寫測試的時間。然而,實際情況下,程式是人寫出來的;是人寫的,就有機會犯錯,只是錯的多或少罷了。
如果把整個「軟體開發」的過程比喻做「寫考卷」,開發者就像是寫考卷的學生。不管你有沒有讀書,都還是可以作答並交出考卷。至於要怎麼知道答案寫的正不正確、能夠得到多少分數,就得要靠「對答案」才能知道了。「對答案」這個動作,其實就是「測試」在做的事。測試是在核對開發者開發出來的功能,是不是確實符合 PM 撰寫的產品規格。
相信讀者一定有過這樣的經驗:我們認為這題答案一定是 A,但解答卻是 D。寫程式也是一樣,你可能會認為,程式這樣寫應該就「對了」。然而,程式執行後到底會不會有問題,還是得要做了「測試」才知道。
實際上,平常在開發功能的過程中,開發者就一直在做測試了。
假設你正在開發一個提示視窗。你預期使用者點了問號的按鈕後,這個視窗就會彈出在畫面上。於是,在開發好這個功能後,你會實際去點這個按鈕。點下去之後,如果這個提示視窗也真的有跳出來,你才會認為,你成功完成這個功能了。在這個前端的例子中,你「預期」點了按鈕應該要有反應,且程式執行後,也「確實」得到你預期的結果。後端的例子可以是這樣的:在開發好 API 後,你會透過工具來實際看看發出請求後的「實際結果」,確認這些結果是否和你的「預期結果」相同。
這個比較「預期結果」和「實際結果」是否相同的過程,就是測試的核心概念。這裡的一個重點是,如果連你自己也不知道預期的正確結果是什麼,那根本沒辦法開始進行測試
如果連你自己也不知道預期的正確結果是什麼,那根本沒辦法開始進行測試

用程式測試程式:寫測試

既然說「比較『預期結果』和『實際結果』是否相同的過程」就已經是在進行測試,為什麼我們還需要額外花時間寫測試呢?其中最大的差別就在於「手動」和「自動」。
開發者之所以要「寫測試」,就是通過程式的方式,把手動測試這個繁瑣的流程給自動化。如此,就可以在每次產品上線前,讓程式自動跑一次完整的測試,而不需要手動測試每一項功能。剩下來的時間,不論是要用來開發新功能、外出買一杯珍珠奶茶、或是轉頭和同事聊聊天,都要比每次重複手動執行測試開心得多。
除此之外,測試檔本身就是一份帶有記錄的文件。開發者可以寫下當初開發時沒留意到的情境,當成測試案例,避免未來有其他開發者重蹈覆轍。
接著就讓我們來看一下,通常測試實際是怎麼寫的。

實際的測試範例

讓我們以 JavaScript 的 Jest 為例來看一段實際測試的程式碼。
假設我們寫了一個名為 sum 的函式,這個函式應該要把參數的值相加後回傳。如果 sum 這個函式帶入的參數是 1 和 2,應該預期可以得到 3。這時候針對 sum 這個函式,就可以寫出如下的測試:
it('sums numbers', () => {
  const actualResult = sum(1, 2);
  const expectedResult = 3;

  expect(actualResult).toEqual(expectedResult);
});
即使你不會寫 JavaScript,從變數的命名,應該也可以猜想這段程式執行的內容:
  • 這裡有一個名為 sum 的函式,我們把這個函式執行的結果存成變數 actualResult
  • 接著把預期的正確結果存成變數 expectedResult
  • 最後透過 expect 這個方法,來比較「實際執行的結果(actualResult)」是不是和預期的結果(expectedResult)相同(toEqual
上面這個過程就是最基本測試的概念。透過程式來測試寫好的程式,將可以不用再手動透過 console.log() 的方式把結果列印出,再比對原本寫的 sum 有沒有錯。以上這些動作,都可以透過程式自動幫我們進行檢測。
如果測試中預期結果和函式實際執行後的結果相同時,就會得到 PASS:
Screen Shot 2021-12-20 at 12.34.10 AM
相反的,如果 sum 這個函式的實作邏輯有錯誤,sum(1, 2) 得到的並不是 3 時,就會顯示 FAIL:
Screen Shot 2021-12-20 at 12.36.16 AM
這裡測試的結果告訴開發者,雖然我們預期會得到 3,但實際上得到的是 0,所以測試並不通過。
這裡雖然是以 JavaScript 來舉例,但實際上,測試的核心概念在不同語言間是通用的,都是去比較「預期的正確結果」和「程式執行後的實際結果」是否相同來判斷。
例如,以 Go 語言來說:
func TestAdd(t *testing.T) {
	actualResult := Sum(1, 2)
	expectedResult := 3

	if actualResult != expectedResult {
		t.Fail()
	}
}
同樣會看到需要去比較 actualResult 是否和 expectedResult 相同,如果不同的話,該測試就會 Fail。
如果讀者有在 LeetCode 刷題的經驗,實際上,當你提交答案時,背後運行的邏輯就和執行測試是一樣的。它已經預先列好了預期的正確結果(expectedResult),然後用你答案中所寫的函式去實際執行,看看得到的實際結果(actualResult)和預期的正確結果是否相同,來判斷你有沒有通過測試。這些測試,都可以透過程式實際執行,而不需要人工額外核對。

測試的類型

你可能好奇,透過上面這種方法,就可以針對各種不同情境,像是網頁 UI、API 的 request-response 進行測試嗎?
答案是肯定的。根據測試的對象和範疇不同,可以分成幾種不同類型的測試。像是上面的例子中,我們針對單一 sum 這個函式所進行的測試,一般我們就稱作「單元測試(unit testing)」。另一方面,如果是需要多個函式共同運作才能達到的測試,例如測試某個前端框架中的元件(component)、或是測試一個 API 回應的資料是否正確這類的,則會稱作「整合測試(integration testing)」。還有一種甚至使用程式的方式,來模擬使用者實際操作網頁的行為,就好像真的有一個使用者在操作網頁一般,看整個網站的功能能否正常運作;這種類型的測試則稱作「端對端測試(End to End Testing)」。
但不論是上面那一種,核心的概念都還是一樣的:
  • 都是透過程式,而不是人工手動,來測試原本的程式邏輯是否正確
  • 開發者都需要先有一個預期正確的結果,再拿實際執行後的結果,和預期的結果做比對

一定要寫測試嗎

答案是「不一定」。更精確來說,答案會隨時空背景而有不同。
撰寫測試的好處,除了確保「目前」程式的穩定性外,還有一個重點:我們想要省去「未來」重複測試時,所需的人力和時間成本。寫測試絕對是個好習慣,但寫測試的當下,就需要額外的時間和人力;如果未來程式邏輯有改動,測試也勢必需要再跟著調整。
所以,如果你只是初步有個想法,想要實作看看其他人使用的反應,未來程式邏輯改動的幅度還很大;又或者只是寫個簡單的小工具,未來也不會再添加新功能,我會認為,你未必需要在一開始就補齊測試。甚至,就算假設你的專案已經稍有規模,然而你的公司目前採取的商業模式,是以開發新功能以確保新客戶簽約為主,你也未必需要把有限的資源,投入在補齊測試的撰寫上。
要特別留意的重點是,測試這件事情絕對不是全有全無;不是說你要寫測試,就一定每隻程式都要寫到。相較於完全不寫測試,或者一寫就全部的程式都要有測試,或許可以先針對你有疑慮、或是規格比較確定的程式來撰寫測試。筆者認為,相較全有全無的做法,這樣的策略比較有彈性,而且才能夠走得長久。

測試不是考試分數,寫愈多不代表程式品質就越好

有些入門的開發者,在認識了測試的概念後,會誤以為測試寫越多,程式的品質就一定越好、bug 也一定會越少。因此,她們花了非常多的時間和篇幅在寫測試。然而,千萬切記,測試本身也程式,就和開發功能時所寫出來的程式一樣,絕對不是越多越好。比起亂槍打鳥,寫了一大堆不可能發生的測試案例,更好的策略,是用更精簡、清楚的方式,來完成預期的測試項目。
有時,甚至會看到「球員兼裁判」的荒謬情況,直接把程式執行的實際結果,當成「預期的正確結果」來進行測試。這種荒謬的測試方式,欠缺對於程式正確結果的思考。舉例來說,我們知道 sum(1, 2) 要得到 3,但現在這個程式卻回傳了 0。然而,如果寫測試的開發者沒有去思考正確的答案應該要是 3,而是直接拿 0 當作預期的結果,就會寫出這樣的測試:
it('sums numbers', () => {
  const actualResult = sum(1, 2); // 0
  const expectedResult = 0; // 直接拿 sum (1, 2) 的結果當作預期的正確結果

  expect(actualResult).toEqual(expectedResult);
});
這時候測試確實會 PASS 通過,且測試的覆蓋率也有提升,但這樣的測試有意義嗎?
寫測試和軟體開發一樣,都需要思考和確保程式品質,絕對不是越多越好。在網路上,也有許多文章在說明如何寫出有品質的測試。有興趣的讀者可再根據自己所使用的程式語言,去查找相關的 best practice 或 guideline。

測試的實踐

由於撰寫測試的確需要花上額外的時間,所以在許多公司中,會有品質保證團隊(Quality Assurance Team)。QA 團隊會整理和思考各種可能的測試情境,並且根據需求,撰寫不同類型的測試(大多是端對端測試)。另外,也會再搭配手動和自動化測試,確保產品在上線後能良好運作。
除了公司中有專職的 QA 團隊,來進行軟體測試外,還有一種開發流程稱作「測試驅動開發(Test-Driven Development,TDD)」。前面我們有提到,測試的核心概念就是去比較預期結果和實際結果。一般來說,我們會先把功能開發完後,才去寫測試來檢驗開發的功能是否符合預期,但 TDD 的流程則相反。
在 TDD 中會先撰寫測試,也就是先定義好預期的正確結果是什麼。不過,由於還沒有實際開發這個功能,所以可以預期的,一開始測試執行的結果一定是失敗的。然而,因為已經把預期的結果都定義好了,所以開發者接著要做的,就是開始實作這些功能,把原先失敗的測試,最終都轉變為成功後才算是完成開發。透過 TDD,能夠讓開發者養成先思考再實作的習慣,先釐清功能的需求、規格和介面後才能開始實作。
這篇文章中,說明了撰寫測試的許多好處,但要特別留意的是:透過測試的撰寫,可以有效避免程式可能的錯誤,但並不能保證程式一定沒有問題。畢竟,使用者實際的使用環境,往往比測試時複雜許多。除了 App 本身的穩定度之外,還會受限於 App 執行的環境,例如作業系統、網路速度、硬體規格等等。甚至,使用者的操作複雜且意想不到,也都可能導致意料之外的問題產生。這也就是為什麼,有些軟體升級後,馬上就會在推出一版更新(patch)來修正問題--因為實際的環境,往往比測試複雜更多。不過,這並不是不寫測試的理由。畢竟,儘管寫測試也許不能讓你的軟體完全沒問題,但不做測試的軟體,絕對有更高的機會,連正常的流程都走不完就壞了。
透過測試的撰寫,可以有效避免程式可能的錯誤,但並不能保證程式一定沒有問題

2017年5月16日

SVG 原地旋轉

SVG 物體旋轉的時候是以 <svg></svg> 的左上角當作圓心進行旋轉,因此當我們直接對 svg 內的元素進行 rotate 時,常常不是我們想要的效果。若我們想要讓物體在原地進行選轉的話,可以使用一些技巧。

操作範例

可以搭配範例看下面的說明,SVG 原地旋轉 @ Codepen

STEP 0: 原本方形的位置

假設我們原本在 <svg></svg> 中畫一個方形:
<svg>
    <!--   原本方形的位置(400, 300)   -->
    <rect x="400" y="300" width="40" height="30" stroke="#41b883" stroke-width="2" fill="#41CEC0"></rect>
</svg>

STEP 1: 先讓物體進行位移(translate)把圓心移到原物體中心點

為了讓這個方形能在原地旋轉,我們要把旋轉的圓心透過 transalte 移動到方形的中心點:
我們可以計算出方形的中心點是:
// x' = x + width / 2
x' = 400 + 40 / 2 = 420
// y' = y + height / 2
y' = 300 + 30 / 2 = 315
因此我們可以寫 transform="transalte(420, 315)"
<!--  STEP1: 先移動旋轉圓心的位置    -->
<!--   x' = x + width /2  -->
<!--   y' = y + height / 2   -->
<!--   translate(400 + 40/2, 300 + 30/2)   -->
<rect x="400" y="300" width="40" height="30" stroke="#41b883" stroke-width="2" fill="#41CEC0" transform="translate(420, 315)"></rect>

STEP 2: 選擇想要旋轉的角度

接著看你需要旋轉的角度,使用 rotate,在這裡我旋轉 45 度。
<!--   STEP2: 進行旋轉   -->
<!--   rotate(45)   -->
<rect x="400" y="300" width="40" height="30" stroke="#41b883" stroke-width="2" fill="#41CEC0" transform="translate(420, 315) rotate(45)"></rect>

STEP 3: 將方形移回原本的位置

最後,我們一樣透過 translate ,把原本位移的距離在扣回去,就可以讓旋轉後的物體回到原本的位置:
<!--   STEP3: 移回原本的位置   -->
<!--   translate(-420, -315)   -->
<rect x="400" y="300" width="40" height="30" stroke="#41b883" stroke-width="2" fill="#41CEC0" transform="translate(420, 315) rotate(45) translate(-420, -315)"></rect>
這樣就完成了原地旋轉。

其實如果你知道怎麼計算旋轉的中心點後,也可以一器合成的把它寫在 rorate 裡面,對多數人來說,這樣的作法也比較直觀好想像,我們只需要在 rotate(角度, 旋轉中心X, 旋轉中心Y) 裡面給定旋轉的中心點就可以了。
像是這樣子:
<!--   直接把旋轉中心點給在 rotate 之中   -->
<!--   rotate(45, 420, 315)   -->
<rect x="400" y="300" width="40" height="30" stroke="#41b883" stroke-width="2" fill="#41CEC0" transform=" rotate(45, 420, 315)"></rect>
一樣可以達到原地旋轉的效果,但是如果你以後看到那種先位移再旋轉,之後再位移的作法,你也可以知道它背後的意義和邏輯了。

2017年5月14日

[掘竅] 為什麼畫面沒有隨資料更新 - Vue 響應式原理(Reactivity)

之前在在一開始接觸框架的時候,不是很清楚響應式原理到底是什麼,一直會把它和 responsive 這個東西搞混,因為兩個在中文翻起來都是響應式,官網雖然有提及這個部分的說明,但小時候不懂事就給它忽略過去了。
最近在用 Vue 做一些東西的時候,才慢慢瞭解到 Reactivity 的意思和重要性,在使用 Vue 的過程中,我們會發現當我們修改 JavaScript 的資料時,畫面就會自動的更新產生變化,之所以能這麼方便操作,都是由於 Vue 背後的響應式系統(reactivity system),可見 Reactivity 在 Vue 中有多重要!!
也因此,錯誤的使用可能會導致 Vue 的 data 不會有即時更新的效果。
在 Vue 當中,每個組件實例都有相對應的 watcher 實例物件 ,它會紀錄組件渲染過程中所有被觸碰到的屬性,當這些屬性的 setter 被觸發時,就會通知 watcher,進而促使組件重新渲染。
然而,有些情況下你可能會發現即使已經為 JavaScript 中的資料重新設值時,畫面卻沒有重新渲染,這有可能是受限於 JavaScript 物件底層的一些限制,讓我們來看看什麼情況下會導致資料更新了,畫面卻沒有重新渲染:

物件部分:一開始沒有被註冊到的物件不會響應式更新

這是一開始在使用 Vue 或搭配 AJAX 取得資料時很容易疏忽的部分。假設我們現在要做一個計數器,分別記錄不同動物的數量,當我點擊按鈕的時候,該動物的數量就會增加,template 的部分像這樣:
<!-- template -->
<button @click="addCount('dog')">addDogs</button>
<button @click="addCount('cat')">addCats</button>
<button @click="addCount('penguin')">addPenguin</button>
<p>dog {{ counter.dog }}, cat {{ counter.cat }}, penguin {{ counter.penguin }}</p>
在一開始的時,我們在 datacounter 屬性中註冊了 counter.dogcounter.cat 這兩個屬性,但是並沒有把 penguin 給註冊進去,我們可能想說等在 created Hook 中再把資料送進去(通常因為是要透過 AJAX 取的資料內容),順便建立 penguin 屬性就好,因此這時候 JavaScript 的部分像這樣:
new Vue({
  el: '#unregister-object',
  data: {
    // 一開始沒有註冊 penguin,只註冊了 dog 和 cat
    counter: {
      dog: 0,
      cat: 0
    }
  },
  methods: {
    addCount(name) {
      this.counter[name] ++
    }
  },
  created() {
    // 在 created 的時候才建立 penguin 順便設值為 0
    this.counter.penguin = 0
  },
  updated() {
    // 讓我們可以知道組件有被更新
    console.log('view updated')
  }
})
可以點這裡操作範例 @ Codepen
這時候你會發現一個現象,從 console 中我們可以看到,當你按了 addDogsaddCats 時,這個組件會立即的更新。可是當你按 addPenguin 時,畫面卻毫無反應,也不會更新。你可能會以為資料沒有被設定進去,可是如果你又回去點addDogsaddCats 讓畫面更新時,你會發現其實 penguin 的資料有被設定進去,只是沒有產生響應式的變化重新渲染畫面:
所以碰到這樣的問題,我們可以怎麼做呢?

方法一:在 data 中要把所有需要響應式的資料設定進去

如果你希望你的資料能夠在變更時讓畫面重新渲染,達到響應式的效果(Reactive),那麼最簡單的方式是在一開始的 data 中就把資料設定進去,在這裡我們就只要把 penguin 加到 data 中就可以,像是這樣:
data: {
  counter: {
    dog: 0,
    cat: 0,
    penguin: 0       // 一開始就把要響應式變化的資料設定進來
  }
}

方法二:動態添加新的響應式屬性

除了上面的方式,我們也可以透過 vm.$set(object, key, value) 動態新增響應式的屬性,那麼我們只需要在原本的 created(){...} 中改成:
// 也可以使用 Vue.set(object, key, value)
created() {
  // 在 created 的時候才建立 penguin 順便設值為 0
  // 使用 $set 動態新增響應式組件
  this.$set(this.counter, 'penguin', 0)
}
如此當 penguin 被點擊時,一樣會更新組件,重新渲染頁面。

方法三:建立新的物件

另一種當我們要一次更改或新增較多的屬性時,可以透過 Object.assign() 的方法重新建立一個新的物件讓 Vue 去監控,因此,在 created 中可以寫成:
created () {
  // 透過建立新的物件來新增物件的屬性
  this.counter = Object.assign({}, this.counter, {
    penguin: 0,
    dog: 5
  })
}

陣列部分:利用陣列索引直接設值時

剛剛提到的主要是物件的部分,陣列同樣的也會在某些情況下沒有辦法產生響應式的變化,因此如果我們希望畫面會隨著資料能夠有響應式的變化時,應該特別留意和避免。
Template 如下,主要是列出一個動物清單,當我按下 Change Animal 時,它會根據我所輸入的內容以陣列索引的方式變更陣列中的資料內容。另外我們多寫了一個 forceUpdate 的按鈕,點下去之後可以強制更新 vue 組件。
<!-- template -->
<div id="change-by-array-index" v-cloak="v-cloak">
  <h3>利用索引值變更 index 0 的值</h3>
  <input type="text" v-model="animal"/>
  <button v-on:click="changeAnimal">Change Animal</button>
  <ul>
    <li v-for="item in animals" v-bind:key="item">{{ item }}</li>
  </ul>
  <button v-on:click="$forceUpdate()">forceUpdate</button>
</div>
JS 的部分有 changeAnimal 這個 function 會把使用者填寫的資料以陣列索引的方式代入 animals[0]
new Vue({
  el: '#change-by-array-index',
  data: {
    animal: '',
    animals: ['<YOURAnimal>', 'cat', 'penguin', 'bird', 'rabbit']
  },
  methods: {
    changeAnimal () {
      // 當利用陣列索引直接設置值時
      this.animals[0] = this.animal
    }
  }
})
可以點這裡操作範例 @ Codepen
這時候會碰到跟剛剛物件時一樣的問題,就是當我填完 input 內容,按下 changeAnimal 的按鍵時,畫面沒有任何反應,這是因為 以陣列索引值的方式修改資料內容時,Vue 無法監測到 ,因此雖然資料已經代進去了,但是 Vue 不會更新。
和剛剛類似,你可以透過按下 forceUpdate 這個按鈕來讓 Vue 強制更新,強制更新後你就會發現,畫面更新成你剛剛填入的資料內容,表示其實剛剛是有把資料設定到 Vue 當中,只是 Vue 沒有監測到,因此沒有觸發組件去做 update

方法一:使用 Vue 可觀察到的陣列方法

在 Vue 中包含一組可以觀察陣列的方法,而這些方法將能促使畫面重新渲染。這些方法包含:push()pop()shift()unshift()splice()sort()reverse()
因此,回到剛剛的例子上,我們可以使用 arr.splice(startIndex, deleteCount, addItem) 這個方法來把陣列中的內容抽換掉,因此我們可以把 methods 改成:
methods: {
  changeAnimal () {
    // 利用 arr.splice(startIndex, deleteCount, addItem)
    this.animals.splice(0, 1, this.animal)
  }
}

方法二:使用 vm.$set

和物件類似,我們也可以使用 vm.$set(array, index, value) 來達到響應式修改資料內容,我們可以把 methods 改成:
methods: {
  changeAnimal () {
  // 利用 vm.$set(array, index, value) 方法
    this.$set(this.animals, 0, this.animal)
  }
}
如此一樣可以達到響應式變換陣列的資料內容。

瞭解 Vue Reactivity 的原理

See the Pen [Demo] Vue Reactivity in Vanilla JavaScript by PJCHEN (@PJCHENder) on CodePen.

總結

當你對於 Vue 的響應式原理(Reactivity System)有了更多的瞭解後,相信你可以少踩到一些莫名其妙或不必要的坑。
操作範例:

參考資料

2017年5月9日

那些關於 Vue 的小細節 - Computed 中 getter 和 setter 觸發的時間點




文章撰寫時使用 vue@2.3.2
在 Vue 中 computed 是經常會使用到的屬性,因為在 Vue 中透過 computed 會 cache 住沒有改變的資料,因此正確且適當的使用 computed 將可以減少資料重新運算的次數,讓網頁的效能提升。
但是在使用的過程中,有時候會發現 computed 怎麼樣就是不被觸發,這當中有些細節是我們可以進一步瞭解的。

computed 的基本觀念

在 Vue 中,computed 的屬性可以被視為像是 data 一樣,可以讀取和設值,因此在 computed 中可以分成 getter(讀取) 和 setter(設值),在沒有寫 setter 的情況下,computed 預設只有 getter ,也就是只能讀取,不能改變設值。
雖然說 computed 內的屬性可以被視為像是 data 一樣,但在使用上,一般還是會讓 computed 類似唯讀的狀態,也就是去處理 `data` 資料,然後把它吐出來使用。
另外,在 getter 中,要記得搭配使用 return 來把值返回出來。
基本的寫法如下:
預設只有 getter 的 computed
    
new Vue({
    computed: {
        computedData: function () {
            return // ...
        }
    }
})
    
有 setter 和 getter 的 computed
    
new Vue({
    computed: {
        computedData: {
            get: function () {
                return // ...
            },
            set: function () {
                // ...
            }
        }
    }
})
    

程式範例連結

下面的部分包含許多程式說明,你可以開啟這份 Codepen ,然後開啟 console 視窗搭配閱讀。

一般情況下 getter 觸發的時間點

在一般的情況下,我們可以這樣使用 computed (只有 getter)來更新資料,你可以直接打開 Codepen 的第一部分,或者程式碼如下:
    
    
<!-- template -->
<div id="computed-basic" v-cloak="v-cloak"> 
    <h3 class="title-border">一般情況 computed getter 被觸發的時間點</h3>
    <form class="pure-form">
      <input type="text" v-model="firstName"/>
      <input type="text" v-model="lastName"/>
    </form>
    <p>{{fullName}}</p>
</div>

    
    
// js: computed-basic
new Vue({
    el: '#computed-basic',
    data: {
        firstName: 'PJ',
        lastName: 'Chen'
    },
    computed: {
        fullName () {
            console.log('computed getter')
            return this.firstName + ' ' + this.lastName
        }
    },
    updated () {
        console.log('updated')
    }
})
    
在這個情況下,我們只要輸入 input 的內容,改變了 this.firstNamethis.lastName 時,就會觸發 getter,也就是說,computed 的 getter 會觀察被寫在裡面的資料,一般來說,當被觀察的資料改變時,這個 getter 就會被觸發
聽起來非常合理,但是我們用 console.log() 看一下,分別看 computed getterupdated 的時間點,你會發現,當我們在 input 輸入資料,會觸發 computed,同時也會觸發這個 vm 的 updated
img

getter 的例外情況:資料變更但 getter 不會被觸發

剛剛我們提到當 getter 裡面被觀察的資料有變更時,就會觸發 computed,但這個說其實並不完全正確,有些時候畫面更新了,資料變更了,但其實不會觸發 computed 裡面的 getter。
如果我們把 template 中的 fullName 拿掉,換成 firstName 和 lastName 時(程式碼如下):
     
<p> firstName: {{ firstName }}, lastName: {{ lastName }} </p>
    
也就是當我們的 template 中沒有馬上用到這個 computed 的資料時(這裡的話就是指 fullName),那麼 Vue 不知道你要用到 fullName ,因此即使我們變更了 this.firstNamethis.lastName,依然不會觸發 getter 。
我們可以在 console 中看到,firstName 和 lastName 資料變更的情況下,只會一直得到 updated 而已,computed 中的 getter 並不會被觸發。
img

一般情況下 setter 觸發的時間點

接著,讓我們來看一下在一般的情況下, computedsetter 什麼時候會被觸發:

<!-- template -->
 <div id="computed-setter-basic" v-cloak="v-cloak"> 
    <h3 class="title-border">一般情況 computed setter 被觸發的時間點</h3>
    <form class="pure-form">
      <input type="text" v-model="fullName"/>
    </form>
    <p>firstName: {{ firstName }} <br/> lastName: {{ lastName }}</p>
 </div>



// js
// computed-setter-basic
new Vue({
    el: '#computed-setter-basic',
    data: {
        firstName: 'PJ',
        lastName: 'Chen'
    },
    computed: {
        fullName: {
            get () {
                console.log('computed getter')
                return this.firstName + ' ' + this.lastName
            },
            set (value) {
                console.log('computed setter')
                this.firstName = value.split(' ')[0]
                this.lastName = value.split(' ')[1]
            }
        }
    },
    updated () {
        console.log('updated')
    }
})

template 中,我們可以看到,我們的 input 是直接綁 v-model="fullName" ,因此他會直接去修改 fullName 的值,而當前 fullName 是 computed 中的一個屬性,我們說過 computed 中的屬性就和 data 類似可以取值(getter)和設值(setter),這時候因為我們要對 fullName 設值,自然就會對應到 fullName 裡面的 setter(如果沒有設定 setter 是無法對 fullName 設值的)。
簡單來說,當 computed 的屬性要被設值時,就會觸發 setter,從 console 中我們也可以看到,當我在 input 中輸入內容時,fullName 會改變,fullName 改變的情況會觸發 setter ,接著,因為我的 setter 中所做的事會變更到 getter 中所觀察的資料,這時候才又觸發 getter 執行,最後重新 updated 畫面。也就是從 setter -> getter -> updated 這樣的過程,如下圖所示:
img

觸發 setter 不必然會觸發 getter

在上面的例子中,我們會先觸發 setter , 接著觸發 getter,最後 updated 畫面。但是其實 getter 會被觸發是因為我們在 setter 中變更到了被 getter 所觀察的資料。也就是說,如果我們的 setter 在執行時,並不會觸發 getter 所觀察的資料的話,那麼 getter 就不會被觸發。
例如,當我把上面程式碼的 setter 中對於資料的變更註解掉時:

set (value) {
    console.log('computed setter')
    // this.firstName = value.split(' ')[0]
    // this.lastName = value.split(' ')[1]
}
那麼即時我們在 input 中輸入內容,都只會觸發 setter 而不會觸發 getter。換句話說,setter 和 getter 是獨立觸發的,兩個被觸發的時間點是不同的。
img

總結

在這篇文章中,我們進一步瞭解了 Vue computed 中的 gettersetter,有幾個重點可以整理一下
  1. getter 和 setter 彼次觸發的時間點是獨立的。 getter 在大部分的時候是當內部觀察的資料有改變時會被觸發;setter 則是當被觀察的物件本身有改變時會被觸發。
  2. getter 在畫面中沒有使用到被觀察的物件時,不會被觸發。
這篇的內容主要是根據自身的經驗和理解,如果有任何錯誤,都歡迎不吝告知,以避免錯誤的知識傳遞,謝謝!

5/10 更新,感謝網友@Aysh Su ( 聖涵 / 亞所 ) 修正觀念錯誤

2017年3月31日

[技術分享] 實做 SVG 中的位移與縮放(SVG Translate and Zoom Scale)-拖曳與縮放功能實做(下)




最後,我們要來實做出針對 SVG 這個"畫布"本身進行縮放和拖拉的效果(我們拖拉的是整個 SVG 元素,而不是 SVG 當中的各個圖案)。我們先談位移,接著再來說明縮放的功能。其實如果你瞭解 viewBox 的話,你就可以大概知道怎麼進行縮放和拖移了,因為透過設定 viewBox 中 <min-x> <min-y> <width> <height> 這四個不同的屬性值,我們就可以實做出陀拖曳和縮放的效果,而在實做過程中,比較容以卡住的地方,會是在 SVG 座標系和 viewport 座標系的轉換間,因為 SVG 座標系的單位是可以彈性改變的。

SVG 拖拉功能:觀念


我試著用這張圖來說明整個 SVG 畫布在拖移的觀念。

將 SVG 畫布從左邊拖到右邊

為了方便觀念的理解,我們先把情況簡化,在這裡我們只做水平的位移,所以會變的只有 viewBox 中的 x-min。再次強調,雖然這張圖看起來很像是拖曳這個圓點(<circle></circle>),但實際上我們拖曳的是整個 SVG 元素(<svg></svg>)。

拖曳的過程其實只是 viewBox 中 x-min 的改變,但為了要計算 x-min 應該要給它多少,我們會需要幾個的一連串步驟。我用下面這張圖來說明要進行的步驟,圓形當中標示的是步驟的順序。

實做 SVG 拖拉效果步驟

1. 為了之後座標系統的轉換,我們要先取的當前 SVG 元素的 viewBox 值,假設一開始的值是(x, y, w, h),這裡我們只需關注的 x ,簡稱viewBoxX0。

2. 透過上一篇所說明的,這時候我們可以取得滑鼠點擊下去時的 clientX 的值,簡稱為 clientX0,這裡對應到的值是 10。

3. 透過上一篇所說明的,我們可以把 clientX 的值轉成 SVG 座標系統中的值,簡稱為 svgX0,這裡對應到的值是 20。

4. 接著按住滑鼠拖動畫布,在移動的過程中,可以得到當前滑鼠座標的 clientX 值,簡稱為 clientX1,這裡對應到的值是 20。

5. 同樣的,我們可以把 clientX 的值轉成 SVG 座標系統中的值,簡稱為 svgX1,這裡對應到值是 40。

6. 我們可以計算滑鼠拖拉時,在 SVG 座標系統中的位移(svgX0 - svgX1),這裡就會是 (20 - 40)。

7. 我們可以得到最終 viewBox x 要代入的值就是 viewBoxX0 + (svgX0 - svgX1),也就是 viewBoxX0 + (20 - 40),意義上來說,就是將 SVG 座標系統整個向右移動 20 單位,所以整個 SVG 座標就向右移動了 20 單位。

這個部分可能需要多揣摩一下。同理,我們也可以知道,如果有進行上下拖拉的話,viewBox 的 y 會改變,而最終 viewBox 中 y 的值就是 viewBoxY0 + (svgY0 - svgY1)

總結一下:
在 viewBox 中 x 最後要代入的值為 viewBoxX1 = viewBoxX0 + (svgX1 - svgX2)
在 viewBox 中 y 最後要代入的值為 viewBoxY1 = viewBoxY0 + (svgY1 - svgY2)

再來我們就可以把這樣的觀念實做成程式碼了。

SVG 拖拉功能:實做


把上面的文字轉成程式碼的話,會長的像這樣子:
  
/*  
開始:滑鼠拖拉的效果
*/
let moving
//  滑鼠點下,開始拖拉
function mouseDown(e){
  moving = true
}
//  拖拉的移動過程
function drag(e){
  if(moving === true){
    
     // 1. 取得一開始的 viewBox 值,原本是字串,拆成陣列,方便之後運算
    let startViewBox = svg.getAttribute('viewBox').split(' ').map( n => parseFloat(n))

    //  2. 取得滑鼠當前 viewport 中 client 座標值
    let startClient = {
      x: e.clientX,
      y: e.clientY
    }

    //  3. 計算對應回去的 SVG 座標值
    let newSVGPoint = svg.createSVGPoint()
    let CTM = svg.getScreenCTM()
    newSVGPoint.x = startClient.x
    newSVGPoint.y = startClient.y
    let startSVGPoint = newSVGPoint.matrixTransform(CTM.inverse())
    
    //  4. 計算拖曳後滑鼠所在的 viewport client 座標值
    let moveToClient = {
      x: e.clientX + e.movementX, //  movement 可以取得滑鼠位移量
      y: e.clientY + e.movementY
    }
    
    //  5. 計算對應回去的 SVG 座標值
    newSVGPoint = svg.createSVGPoint()
    CTM = svg.getScreenCTM()
    newSVGPoint.x = moveToClient.x
    newSVGPoint.y = moveToClient.y
    let moveToSVGPoint = newSVGPoint.matrixTransform(CTM.inverse())
    
    //  6. 計算位移量
    let delta = {
      dx: startSVGPoint.x - moveToSVGPoint.x,
      dy: startSVGPoint.y - moveToSVGPoint.y
    }
    
    //  7. 設定新的 viewBox 值
    let moveToViewBox = `${startViewBox[0] + delta.dx} ${startViewBox[1] + delta.dy} ${startViewBox[2]} ${startViewBox[3]}` 
    svg.setAttribute('viewBox', moveToViewBox)
    console.log(moveToViewBox)
  }
}
//  滑鼠點擊結束(拖曳結束)
function mouseUp(){
    moving = false
    showViewBox()
} //  結束:滑鼠拖拉的效果
  

最後要記得把事件綁在 svg 元素上:
  
  //  拖曳的事件
  svg.addEventListener('mousedown', mouseDown, false)
  svg.addEventListener('mousemove', drag, false)
  svg.addEventListener('mouseup', mouseUp, false)
  

這樣就可以讓自由的拖動整個 SVG 元素了。

SVG 縮放功能:觀念


縮放的作法其實一樣是透過去改變 viewBox 的設定值,只是剛剛改變的是 <min-x>min-y,而縮放要改變的是 <width><height>

另外,前幾篇文章中有提到一個很重要的觀念,就是透過 viewBox 來縮放時,實際上它是從整個 SVG 元素的左上角(下圖中橘色點)進行縮放。

縮放時是以 SVG 左上角為中心點進行縮放,也就是橘色點

讓我們利用下面這張圖更清楚的看看為什麼在縮放的過程中,一開始的圓點為什麼會跑到那個位置。為了簡化理解,我們還是先只看 X 軸,在一開始的時候,SVG 座標系統中的 1 單 位等於 viewport 座標系統中的 1px,圓點對應回去的 clientX 是 10,利用 CTM 將座標系統轉換換會得到 SVG X 是 20 ;在縮放的過程中,會以 SVG 元素的左上角為原點進行縮放;縮放後整個 SVG 座標系統的單位尺寸就改變了,在這裡因為我放大兩倍,所以SVG 座標系統的 1 單位會變成 viewport 座標系統中的 2px,但是圓點的 SVG x 仍然是 20 。

就是因為:
1. 以 SVG 左上角原點做縮放(左上角這個原點其實會是當時 viewBox 的 min-x 和 min-y 的值。)
2. SVG 座標系統對應回 viewport 座標系統的單位尺寸已經改變。
所以這個圓點就在放大的過程中被往右下方移動了(放大兩倍是利用讓 viewBox 的 width/2 和 height/2)。

一開始圓點的 svgX 是 20,縮放後仍然是20,但是對於 viewport 的 clientX 改變了

這個情況會使得我們點擊的點,在縮放的過程中,相對於 viewport Client 的座標值會一直改變(如下圖中圓圈 1)。

為了不讓使用者在縮放時,覺得我們點擊的那個點離我們越來越遠,所以我們在縮放完後要再把這個點搬回使用者點擊的那個起始點,讓使用者感覺這個點看起來想是在原地縮放(如下圖中圓圈 2)。

縮放的過程中,我們點擊的點相對於 viewport Client 的座標值會一直改變,為了讓使用者感覺是在圓點縮放,需要把放大後的點移回原本被縮放的點

現在知道觀念後,可以試著把實做的步驟列出來,整個流程會像這樣子:

縮放實做觀念流程

1. 取得一開始的 viewBox ,這樣我們才知道要以什麼作為縮放的依據,簡稱 viewBox0,其中的 width 簡稱為 viewBox0.width。

2. 取得滑鼠執行縮放位置的 viewPort Client 座標,並利用 CTM 對應取得 SVG 座標,簡稱 clientX0 和 svgX0。

3. 進行縮放,如果要讓原本的尺寸縮放兩倍的話,width 和 height 就要除以兩倍;簡稱 r 為我們縮放的倍率、縮放後的 viewBox 為 viewBox 1;所以 viewBox1.width = viewBox1.width / rviewBox1.height = viewBox1.height / r

4. 將一開始滑鼠的執行縮放位置的 viewPort Client 座標(也就是 clientX1),利用新的 CTM (CTM 每次只要縮放或位移後都會改變),轉換出對應的 SVG 座標,簡稱 svgX1。

5. 取得在縮放過程中該圓點的位移量 (svgX0 - svgX1)

6. 得到最終的 viewBox2 為,viewBoxX2 = viewBoxX0 + (svgX0 - svgX1),同理 viewBoxY2 = viewBoxY0 + (svgY0 - svgY1)

觀念說明完了,程式碼就能夠打出來了XD

SVG 縮放功能:實做


實做的程式碼如下:

  
/*  
開始:滑鼠縮放的效果
*/
function zoom(e){
    //  1.取得一開始的 viewBox。
    let startViewBox = svg.getAttribute('viewBox').split(' ').map( n => parseFloat(n))
    
    //  2.取得滑鼠執行縮放位置的 viewPort Client 座標,並利用 CTM 對應取得 SVG 座標。
    
    //  2.1 取得滑鼠執行縮放的位置
    let startClient = {
      x: e.clientX,
      y: e.clientY
    }

    //  2.2 轉換成 SVG 座標系統中的 SVG 座標點
    let newSVGPoint = svg.createSVGPoint()
    let CTM = svg.getScreenCTM()
    newSVGPoint.x = startClient.x
    newSVGPoint.y = startClient.y
    let startSVGPoint = newSVGPoint.matrixTransform(CTM.inverse())
    
    
    //  3.進行縮放,如果要讓原本的尺寸縮放兩倍的話。
    //  3.1 設定縮放倍率
    let r 
    if (e.deltaY > 0) {
      r = 0.9
    } else if (e.deltaY < 0) {
      r = 1.1
    } else {
      r = 1
    }
    //  3.2 進行縮放
    svg.setAttribute('viewBox', `${startViewBox[0]} ${startViewBox[1]} ${startViewBox[2] * r} ${startViewBox[3] * r}`)
    
    //  4.將一開始滑鼠的執行縮放位置的 viewPort Client 座標利用新的 CTM ,轉換出對應的 SVG 座標。
    CTM = svg.getScreenCTM()
    let moveToSVGPoint = newSVGPoint.matrixTransform(CTM.inverse())
    
    //  5.取得在縮放過程中該圓點的位移量 `(svgX0 - svgX1)`。
    let delta = {
      dx: startSVGPoint.x - moveToSVGPoint.x,
      dy: startSVGPoint.y - moveToSVGPoint.y
    }
    
    //  6.設定最終的 viewBox2 
    let middleViewBox = svg.getAttribute('viewBox').split(' ').map( n => parseFloat(n))
    let moveBackViewBox = `${middleViewBox[0] + delta.dx} ${middleViewBox[1] + delta.dy} ${middleViewBox[2]} ${middleViewBox[3]}` 
    svg.setAttribute('viewBox', moveBackViewBox)
    
    //  更新 viewBox 資訊
    showViewBox()
} //  結束:滑鼠縮放的效果
  

最後,一樣要記得把事件綁上去:
  
//  縮放的事件
svg.addEventListener('wheel', zoom, false)
  

最終實做成品與程式碼


後記


這三篇文章的整理了實做 SVG 縮放和拖移的作法,花了相當多時間在理解並和同事討論激盪了許久,希望能夠讓同樣碰到這塊的人,花比較少的時間就瞭解背後的原理和過程。

原本希望能夠用簡單易懂的方式讓大家都能夠瞭解並實做出 SVG 的拖曳和縮放,但實際上在說明的時候還是免不了要用到很多數學上位移的觀念,而且 viewBox 這個東西也需要自己花一些時間去玩它和感覺它。

希望這篇文章能夠對你有幫助,若有問題也歡迎留言,作法也許不是最好的,觀念也許有些瑕疵,但都歡迎留言討論或提供建議。

SVG 應用學習資源


最後列出一些關於 SVG 我覺得還不錯的資源

2017年3月22日

[技術分享] 利用 SVG 中的 CTM 進行座標系統的轉換(SVG Coordinate System Transform Matrix)-拖曳與縮放功能實做(中)




在這一系列文章中我們把 SVG 整個元素視為一個畫布(Canvas),而不是去 SVG 討論裡面的各個圖形,另外,只討論當 viewport 和 viewBox 兩者比例相等的情況。

在上一篇文章中([教學] 理解 SVG 中的 Viewport 和 ViewBox-拖曳與縮放功能實做(上)),說明到了在 viewport 座標系統中,1px 的大小就會是實際 1px 的大小;但在 viewBox 的 SVG 座標系統中,則可以透過 ViewBox 的設定,去進行縮放和移動,進而使的圖案本身有位移(translate)和縮放(scale)的效果。

為了要讓我們能夠實做出拖拉和縮放的效果,有幾點是我們需要進一步說明的。

實做效果和原始碼


這是我們這一篇文章希望做出來的效果,我們能夠取的 viewport 的座標點,並轉換成 SVG 座標系統中的座標值。另外我另外加了幾個按鈕,讓你可以直接設定 viewBox 並觀察其中圓心的座標會有如何的變化,建議可以在 jsfiddle 中比較方便操作和感受。

詳細的作法會在下面說明:


複習SVG 座標系統和 viewport 座標系統


從上篇文章我們知道 viewport 的座標系統可以視為是相對於瀏覽器不會變動的座標系統(1px 就是 1px),但是 SVG 座標系統則是會變動的,隨著給定不同的 viewBox 值,SVG 座標系統中的 1 個單位以有可能等於、大於或小於 1px。

這三張圖對於這個觀念的理解真的很重要很重要,所以讓我們快速地再看一次(灰色的是 viewport 座標系統;藍色的是 SVG 座標系統):

當 viewBox 等同於 viewport 的情況

當 viewport 等同於 viewBox 時可以看到鸚鵡右下角的藍點不論在 viewport 座標系統中的 offset 座標點或 SVG 座標系統中的座標點,座標值都是(200, 300),你可以對應到上方側和左方側的指標:

viewBox 和 viewport 尺寸大小相同時,offset 座標值等於 svg

當 viewBox 小於 viewport 的情況

當 viewBox 小於 viewport 時(這裡 viewBox = “400 300 0 0”),因為 viewBox 會先被裁切然後填滿整個 viewport ,所以在 SVG 座標系統藍點雖然還是(200, 300),可視實際上 viewport 座標系統中的 offset 座標值對應回去是(400, 600),也就是說在這個情況下 SVG 座標系統中的 1 單位,會是 viewport 座標系統的 2 單位,也就是 2px。

此時 SVG 座標系統中的 1 單位,會是 viewport 座標系統的 2 單位,也就是 2px

當 viewBox 大於 viewport 的情況

當 viewBox 大於 viewport 時(這裡 viewBox = “1600 1200 0 0”),因為 viewBox 會先被裁切然後填滿整個 viewport ,所以在 SVG 座標系統藍點雖然還是(200, 300),可視實際上 viewport 座標系統中對應回去是(100, 150),也就是說在這個情況下 SVG 座標系統中的 1 單位,會是 viewport 座標系統的 0.5 單位,也就是 0.5px。

此時 SVG 座標系統中的 1 單位,會是 viewport 座標系統的 0.5 單位,也就是 0.5px

SVG 座標系統和 viewport 座標系統的轉換:觀念


如果你對於上面的觀念還不清楚的話,建議可以再把第一篇文章看過一次,或在想清楚。接下來我們要說明如何在 SVG 座標系統和 viewport 座標系統中做轉換。

另外,在上一篇文章中,我們有提到 viewBox = "x-min y-min width height" 這四個屬性值,而在上面的範例中,敏銳的讀者應該會發現, 不論是放大或縮小,都是從最左上方的點開始(橘點),這點非常很重要,之後在實做縮放的時候會用到這個觀念。

不論是放大或縮小,都是從最左上方的點開始(橘點)
其實 SVG 座標系統和 viewport 座標系統中是有辦法做相對應的轉換的。在上面的說明中,為了方便比較 viewport 座標系統和 SVG 座標系統單位大小的不同,我們都是用 offset 的座標點,但是如果我們希望能在 viewport 座標系統和 SVG 座標系統間做相對的轉換,我們就會需要用到 client 這個座標名稱。

client 座標和 offset 座標一樣都是屬於 viewport 座標系統,只是前者是以瀏覽器視窗左上角為原點(0, 0);後者則是以容器左上角為原點。
client 座標和 offset 座標一樣都是屬於 viewport 座標系統,只是前者是以瀏覽器視窗左上角為原點(0, 0);後者則是以容器左上角為原點

SVG 座標系統和 viewport 座標系統間的轉換需要一些數學的運算(電腦會幫你算),但為了避免 大家(我)看到數學就害怕(看到數學就先暈一半),我們先用簡單的方式來理解這兩者座標的轉換。

作法其實很簡單,想像有一個叫做 CTM 的魔術棒,透過這個魔術棒,我們就可以將 viewport 座標系統中的 client(x, y) 轉換成 SVG 座標系統中的 svg(x, y);相似地,我們可以透過 CTM 這個魔術幫棒將 SVG 座標系統中的 svg(x, y) 轉換為 viewport 座標系統中的 client(x, y):


更精確的說,當我們要從 SVG 座標系統 → viewport(client) 座標系統時,是利用 CTM X SVG 座標系統中的座標,可以得到 viewport 座標系統中的座標值:


可是如果要從 viewport(client) 座標系統 → SVG 座標系統,那麼我們的 CTM 也要反轉 一下才能得會 SVG 座標。為什麼要把 CTM 反轉過來才能用呢?你可以想像你要用鑰匙開鎖和解鎖,開鎖的時候要順時鐘轉,解鎖的時候要用逆時針轉,CTM 的反轉也可以用這樣的概念來想。


這裡有一點要留意的是兩個座標系統間的轉換時,我們用的是 viewport 座標系統中的 client(x, y) 而不是 offset(x, y),這點要非常留意

CTM 的真面貌


好吧!一下魔術棒,一下要鑰匙的,你可能覺得很煩,我就直接說了,其實 CTM 就是個 3 x 3 的矩陣,全名是 current transform matrix。什麼…!!矩陣…!!那我需要把高中的數學課本拿出來嗎??


就說用魔術棒想會比較好吧,其實,如果你只是想要瞭解基本的位移和縮放,只要瞭解 viewBox 裡面的四個屬性值,知道有魔術棒這個東西可以幫助我們把座標點從 viewport(client) 座標系統和 SVG 座標間做轉換就好了,還有很重要的一點就是是,每次我們透過設定 viewBox 去對 SVG 進行縮放或位移後,魔術棒(CTM)的樣式都會變的不一樣這樣就可以了。

但是如果你想要更深入瞭解 SVG 的各種變化,像是選旋轉(rotate)、歪斜(skewed)或者應用到 SVG 當中的各個元素的話(例如,<circle></circle>、<path></path>),那你可能就需要更深入的瞭解 CTM 矩陣。

至於為什麼魔術棒需要像鑰匙一樣向左轉、向右轉,其實就是反矩陣的概念。

為了閱讀上的舒適度,我會把實際上矩陣轉換的公式寫在文中最後,如果不排斥的話,可以最後再看,或者就先直接當成它是一個魔術棒吧!

至於要如何取得 CTM 這個魔術棒,就讓我們實際看一下 code 吧!

SVG 座標系統和 viewport 座標系統的轉換:實做


接下來我們會看到許多 JavaScrip Web API 來協助我們進行 SVG 的操作,但是要注意的是,在這裡我們都是針對 SVG 這個元素(也就是 ,<svg></svg> 個 tag 本身),而不是針對 svg 裡面的圖案(例如 `<circle></circle>, <line></line> 等等)進行操作。

在 JavaScript Web API 中可以直接透過 SVGGraphicsElement.getScreenCTM() 這個方法,只要針對某個 svg 元素就可以讓我們直接取得 CTM,那麼到底要如何實際易應用呢?

取得 viewport 座標系統的座標值(clientXY, offsetXY)

我們先在 HTML 建立 SVG 元素:
  
<svg id="svg" width="600" height="300" viewBox="0 0 600 300">
  <rect x="0" y="0" fill="none" stroke="#EB7B2D" stroke-width="2" width="600" height="300" />
  <circle cx="300" cy="150" r="5" fill="#EB7B2D" />
</svg>

<!-- 取得滑鼠座標資訊 -->
<div id="info">
  <p id="offset">offset</p>
  <p id="client">client</p>
  <p id="showSvg">svg</p>
</div>
  
透過 <svg width="600" height="300">,我們定義了 viewport 為 600 x 300(px)。利用 <circle></circle>,我們在 SVG 中畫了一個圓心在(300, 150)、半徑(r)為 5 的圓,要注意的是,這裡的(300, 150)指的是 SVG 座標系統。

另外利用 <rect x="0" y="0" fill="none" stroke="#EB7B2D" stroke-width="2" width="600" height="300" /> 也在 SVG 中幫我們畫一個邊框,這能夠幫助我們看之後 SVG 座標系統的位移和縮放。

接著,我們利用 JS 來取得 viewport 座標系統中的 offset 和 client 座標值,並且希望滑鼠滑動的時候可以馬上取得該滑鼠所在的 viewport 座標值,因此我們在 svg 這個元素上把上 mousemove 事件 svg.addEventListener('mousemove', reportCurrentPoint),當滑鼠一滑動,就觸發去執行 reportCurrentPoint 這個函式:
  
function reportCurrentPoint (e) {
    //  選取 HTML 各元素
    const info = document.getElementById('info')
    const offset = document.getElementById('offset')
    const client = document.getElementById('client')
    const showSvg = document.getElementById('showSvg')
  
    //    取得 viewport 座標系統中的 offset 和 client 座標值
    offset.textContent = `offset (${e.offsetX}, ${e.offsetY})`
    client.textContent = `client (${e.clientX}, ${e.clientY})`
}
const svg = document.getElementById('svg')
svg.addEventListener('mousemove', reportCurrentPoint)
  

利用則是利用 ES6 中模版字串符的方式代入 offset 和 client 座標值。

在我們的範例中,你會發現 offset 和 client 的值相差 9px ,當我們把滑鼠移到這個 container 的最左上角時,你會發現 offset(0, 0),client(9, 9),也就是從 container 左上角和 window 左上角的差值。

(註:一般來說 client 的值會以瀏覽器視窗左上角的圓點,但在示範所用的 fiddle 中,因為它是分割視窗的緣故,所以是以Result 視窗的左上角為原點)

取的 SVG 座標系統中的座標值

接著我們要寫 JS ,要在座標系統中的座標可以彼此轉換,我們要先在 SVG 座標系統中建立一個點,可以直接使用 SVGElement.createSVGPoint() 這個方法,這個點建立起來後會是一個 SVGPoint 物件,包含屬性 x 和屬性 y ,初始值會是(0, 0)。

然後,我們可以利用 SVGGraphicsElement.getScreenCTM() 來取得魔術棒 CTM。
  
const svg = document.getElementById('svg')  //  選取 SVG 元素
let SVGPoint = svg.createSVGPoint()         //  建立一個 SVG 座標,初始值為(0,0)
let CTM = svg.getScreenCTM()                //  取的該元素的 CTM

/*
**  svgPoint(SVG 座標系統) * CTM ---> client (viewport 座標系統)
*/
//  把 SVG 的座標點給進去
SVGPoint.x = 300
SVGPoint.y = 150
clientPoint = SVGPoint.matrixTransform(CTM) //  client(309, 159)

/*
**  client (viewport 座標系統) * CTM^(-1) ---> svgPoint(SVG 座標系統)
*/
SVGPoint = clientPoint.matrixTransform(CTM.inverse())   //  svg(300, 150)
  

SVG 座標系統轉換成 viewport 座標系統的 client 座標點,可以直接使用 CTM。

因此假設我想要知道圓心的 svg(300, 150) 轉換之後在 viewport 的 client 座標是多少,我們可以利用 SVGPoint.x = 300; SVGPoint.y = 150 把座標點給進去,接著讓這個 svg 座標點透過魔術棒 CTM (實際上做的是矩陣相乘,CTM * svg)轉換為 client 座標點,於是寫 clientPoint = SVGPoint.matrixTransform(CTM),我們就能得到 client(309, 159) 這個屬於 viewport 座標系統的座標值。

viewport 座標系統的 client 座標點要轉換為 SVG 座標系統的 SVGPoint時,則使用反轉後的 CTM(CTM的反矩陣)。

我們也可以把剛剛得到的 client(309, 159) 代入反轉過的 CTM 中轉回 SVG 座標系統的座標點(一樣是矩陣相乘,只是被乘的是 CTM 的反矩陣, CTM^(-1) x client),寫法是 SVGPoint = clientPoint.matrixTransform(CTM.inverse()) ,這樣我就會得回 SVG 座標系統中的座標值。

把 SVG 的轉換也綁到滑鼠移動的事件上

一般來說,因為 viewport 座標系統的尺寸是確定的(1px 就是 1px),所以我們比較常利用 client 座標來回推 SVG 座標系統的座標值,也就是 CTM^(-1) x client。

現在,就讓我們把 client 座標轉換為 SVG 座標的寫法綁在 mousemove 這個事件上,讓滑鼠移動的時候,都能夠馬上取得 SVG 座標系統中的座標值。
  
function reportCurrentPoint (e) {
    //  選取 HTML 各元素
    const info = document.getElementById('info')
    const offset = document.getElementById('offset')
    const client = document.getElementById('client')
    const showSvg = document.getElementById('showSvg')
    const svgElement = document.getElementById('svgElement')
  
    //  取得 viewport 座標系統中的 offset 和 client 座標值,並顯示於視窗上
    offset.textContent = `offset (${e.offsetX}, ${e.offsetY})`
    client.textContent = `client (${e.clientX}, ${e.clientY})`
  
    //  建立 SVG 座標點(0, 0)
    const clientPoint = svg.createSVGPoint()
    //  取得 CTM
    const CTM = svg.getScreenCTM()                      
    //  將 SVG 座標點的 x, y 設成 client(x, y)
    clientPoint.x = e.clientX                                   
    clientPoint.y = e.clientY
    //  將 client 的座標點轉成 SVG 座標點
    SVGPoint = clientPoint.matrixTransform(CTM.inverse())   
    //  將資訊顯示於視窗上
    showSvg.textContent = `svg (${SVGPoint.x.toFixed(0)}, ${SVGPoint.y.toFixed(0)})`
}
svg.addEventListener('mousemove', reportCurrentPoint)
  
在這裡我把最後顯示的 SVG 座標加上 SVGPoint.x.toFixed(0) 加上 toFixed(0) ,因為矩陣轉換之後得到的會是很多為的小數,不方便觀看,因此我就四捨五入把小數點去除。

改變 viewBox 看看座標系統會有什麼樣的影響


接著,你可以改變設定在 SVG 中的 viewBox 值,看看對於 SVG 的座標系統會有什麼樣的影響,在 viewBox 中前兩項(min-xmin-y)控制了位移;後兩項控制了縮放(widthheight),為了方便瞭解,會建議你在後兩項的設定和 viewport 是等比例的縮放,也就是我們原本的 viewport 是 600 x 300 ,你可以把後面這兩項等比例放大設為 1200 x 600 或縮小 300 x 150,來看效果。

例如,一開始 viewport 和 viewBox 的尺寸相等,且沒有位移時(viewBox = "0 0 600 300"),圓心的點座標會如下圖:


但是當我把 viewBox width 和 height 做等比例的改變後(viewBox = "0 0 450 225"),你會發現圓點的座標,在 SVG 座標系統仍然是 svg(300, 150),但是在 viewport 的座標系統中 offset(400, 200),client(409, 209)。這是因為當我的 viewBox 比例縮小為原本的 0.75 倍時,SVG 座標系統則會被放大(從左上角的地方放大),進而把圓點從中心的位置往右下推動,而圓也跟著放大了。

是從左上方當中心開始做縮放

你可以再把 viewBox 設定成不同的值測試看看,這將有助於你更能夠掌握 viewport 和 ViewBox 之間的關係。

進一步瞭解 CTM


在這裡我們會簡單說明 CTM 矩陣,如果你只是想要瞭解如何用 viewBox 做到縮放和拖移的效果,沒有瞭解 CTM 矩陣並不會有太大的影響。但是如何你想要對於座標點是如何在兩個不同的座標系統間轉換,瞭解一下矩陣的運算是很有幫助了。另外,在這裡我們是針對 SVG 這個 tag 本身去取得 CTM ,如果你是想要針對 SVG 裡的圖形(例如,<circle></circle>, <path></path>)去做變化,其背後的原理是相似的,但這就不在我們這篇討論的範圍內了。

CTM 概念說明

CTM 它的本質是一個 3 X 3 的矩陣,長的像這樣子:
CTM 矩陣的樣子
其中 e 和 f 控制的是位移(translate);a 和 d 控制的是縮放(scale)。

更深入的話,透過 a, b, c, d, e, f 的改變,我們就能達到各種縮放、位移、選轉、變形等等的效果。

在矩陣的概念中,我們則是將座標點用這樣的方式表示:
以矩陣的方式表示座標點

所以從 SVG 座標系統的座標點透過 CTM 轉到 viewport 座標系統的座標點,公式就像這樣:

SVG 座標系統的座標點透過 CTM 轉到 viewport 座標系統的座標點

如果想從 viewport 座標系統轉回 SVG 座標系統就需要利用到 CTM 的反矩陣(以-1表示),公式像這樣:

viewport 座標系統轉回 SVG 座標系統就需要利用到 CTM 的反矩陣(以-1表示)

讓我們來實做看看

這裡推薦一個很方便的網站-矩陣計算器,我們就用這個網站來驗證我們上面程式的結果。

現在我把上面範例中的 viewBox = "0 0 450 225" 的設定,透過上面所教得方式,我們將滑鼠游標移到圓心,可以發現在 viewport 座標系統中的 client(409, 209),接著,利用 svg.getScreenCTM() 我們可以取得當前 SVG 元素的 CTM ,算出來是的 matrix(a, b, c, d, e, f) = matrix(1.3333, 0, 0, 1.3333, 9, 9),現在我們就可以把這些值代入矩陣計算機中,按下等號:



計算的結果就像這樣,你可以看到最後的結果就是(300, 150),而這也就是我們上面利用程式所顯示的 SVG 座標值:

計算方式對應回我們的公式

重點總結


  • 利用魔術棒(CTM)可以讓 viewport 座標系統中的 client 座標值和 SVG 座標系統中的座標值做轉換。
  • 要記得轉換回去的是 viewport 的 clientXY 而不是 offsetXY

參考資料