iT邦幫忙

第 11 屆 iT 邦幫忙鐵人賽

1
Modern Web

讓 TypeScript 成為你全端開發的 ACE!系列 第 40

Day 40. 戰線擴張・模擬戰 — UBike 地圖 X 使用 LeafletJS - Using LeafletJS with TypeScript

https://ithelp.ithome.com.tw/upload/images/20191006/201206148k5ftK2oat.png

閱讀本篇文章前,仔細想想看

  1. 你會如何善用型別推論與註記的機制呢?
  2. 什麼情形可能會出現 any 型別推論出來的行為?如果出現了,要如何處理這類型的案例?

另外,本篇文承接上一篇文,因此如果是跳到這篇的話可以先從上一篇看起喔~

貼心小提示

想要本範例的程式碼 —— 可以參見 Maxwell-Alexius/Iron-Man-Competition 這個 Repo 喔!

以下正文開始

模擬戰 — UBike 地圖應用(三)

今天主要是先把功能給完成,但不代表今天就會結束掉本篇章系列,最後一篇會進行程式碼重構的動作 —— 不過在重構之前,沒有一個完整的功能前,基本上要談到重構的議題,也是沒辦法的。

貼心小提示

另外有一個名詞叫做 Premature Optimization —— 過早優化。儘管對一些比較資深的讀者,可能已經早就聽過它了,不過初入軟體設計的讀者可能還沒聽過,在此就補充一下。(筆者本身也是沒多少經驗

軟體設計比較麻煩的地方應該是 —— 判斷程式是否該優化或者是重構的時間點,過早地將程式碼進行優化的動作反而會導致程式設計上的彈性被緊縮掉。

在還未將功能設計好的前提下,重構程式碼可能會導致功能還沒實踐完畢,就要面對種種設計上的限制,導致破壞掉原來重構出的架構的機率上升。另一種情形可能是在一開始設計軟體的規格時,可能沒有想到後續會出現什麼樣種類的問題等等,也就造成整個軟體的規格又必須改掉,於是有重構沒重構反而沒差,導致浪費時間。

讀者有空可以搜搜看並理解這句話的含義 —— “Premature optimization is the root of all evil.” by Donald Knuth

快速前情提要

上一篇筆者已經把資料處理部分結束:

  1. 宣告 SourceUBikeInfo 這個型別對應 UBike 源頭資料的格式
  2. 宣告 UBikeInfo 為筆者在這個應用程式裡需要用到的格式
  3. 宣告 Districts 這個複合型別將所有台北市行政區的名稱字串 union 起來
  4. 將所有的型別整理到 ./src/data.d.ts 這個檔案
  5. 由以上的型別導出了 fetchData 這個函式 —— 負責從台北市政府開放資料中的 UBike 即時資料系統抓出資料

重述規格

筆者在 UBike 地圖想要實踐的是:

  1. 選擇台北市行政區
  2. 當區域被選擇了,就會更新地圖
  3. 地圖會出現該區域的 UBike 點位
  4. 每個點位如果被滑鼠滑過去,會出現 Tooltip 顯示 Ubike 點位相關的資料,包含:所在行政區以及該站點名稱、現在還有的 UBike 數量以及總 UBike 數量

實踐選擇行政區的 UI

由於筆者想要動態將選項從 districts(行政區的陣列,為 Districts[] 型別)轉換成 HTML 表單相關的元素,必定會需要用到 document.createElement 相關的功能。

貼心小提示

相信會看本系列的讀者們(除非你是真的只有接觸 NodeJS 而沒前端經驗),普遍來說都應該熟悉原生 JS 操作 DOM 的流程。因此真的不清楚 DOM 的操作基礎可以上網查查看 DOM Manipulation 相關的資源啊!

首先,筆者更新一下 index.html 的內容 —— 使用 CSS Absolute Position 將選擇行政區域的 UI 放置在網頁的右上方,並且使用 <select><option> 這兩個元素做為行政區表單選項。

https://ithelp.ithome.com.tw/upload/images/20191006/20120614ctOi5xSjQ2.png

這是目前瀏覽器應該會出現的畫面,筆者刻意將顯示的倍率放大。(如圖一)

https://ithelp.ithome.com.tw/upload/images/20191006/20120614mLJtjFghia.png

以下筆者就簡單地在 index.ts 裡,利用 districts 將所有的行政區選項轉換成 DOM。另外,如果讀者正在跟本篇文章的內容,記得要把 index.html 裡的 <option name="信義區">信義區</option> 砍掉,因為我們要改採動態方式新增 <option> 標籤喔!

https://ithelp.ithome.com.tw/upload/images/20191006/20120614Bznp7Ap8Oi.png

這裡也會出現 TypeScript 的警告訊息喔!(如圖二)

https://ithelp.ithome.com.tw/upload/images/20191006/20120614V7sMjGmLXl.png
圖二:$selectDistrict 可能為 null

ㄧ樣,我們可以使用 Type Guard 解決這個問題:

https://ithelp.ithome.com.tw/upload/images/20191006/20120614LOS8IkEqju.png

我們的問題就解決掉了,TypeScript 判斷我們的 $selectDistrict 這個變數存取的確定是 HTMLElement 型別。(如圖三)

https://ithelp.ithome.com.tw/upload/images/20191006/20120614LlQQZG5KCn.png
圖三:沒有任何錯誤訊息,經過型別檢測就限縮掉 $selectDistrict 之型別為 HTMLElement

使用 LeafletJS 畫出 UBike 點位

筆者在模擬戰 — UBike 地圖的第一篇提到,如果臨時對於 LeafletJS 的功能有疑惑的話,除了可以參考官網的 Doc 外,還有另一招就是翻閱 Leaflet 套件的 Declaration File 喔

技巧到現在還不清楚的讀者,強烈建議看前幾篇文章(已經被 Reference 至少三次囉),後續筆者不會再重複講

首先,由於會需要在不同的行政區畫一連串的 UBike 點位,因此筆者習慣會將所有產生的點位 —— 也就是 Leaflet 的 Markers 匯集在一起,這時候會需要一個變數 —— 儲存聚集所有點位的 LayerGroup

於是筆者先定義一個變數 markerLayer 並且積極註記為 LayerGroup 這個屬於 LeafletJS 提供的型別。這裡使用遲滯性指派(Delayed Initialization) —— 因此一定要積極註記避免 any 型別的出現

https://ithelp.ithome.com.tw/upload/images/20191006/20120614Z6AyoI6pkn.png

接下來,就是實踐主要的功能。

貼心小提示

這邊的實作自由性很高,讀者當然也可以選擇不要使用筆者的做法達到相同的功能,這裡舉一個例子:實踐產生一個 Marker 的函式,並且在外面就可以用 for 迴圈對 UBike 點位進行迭代、匯集、然後再轉換為 LayerGroup 也是可以的。

以下筆者直接使用 fetchData(記得要從 fetchData.ts 載入進來)開始寫下轉換點位到 Leaflet Marker 的動作:

https://ithelp.ithome.com.tw/upload/images/20191006/20120614hSj2psmgSb.png

不過,我們必須先將行政區名稱從 <select> 元素取出來 —— 通常是使用 <select> 這個 DOM 的 value 屬性:

https://ithelp.ithome.com.tw/upload/images/20191006/20120614OjpkymHDws.png

但這會出現一個問題:HTMLElement 並沒有 value 屬性。(如圖四)

https://ithelp.ithome.com.tw/upload/images/20191006/20120614ISByDlZqLt.png
圖四:HTMLElement 型別並沒有 value 屬性

這裡就要考驗讀者對於 DOM 本身的認識 —— 事實上並不是 HTMLElement 提供 value 這個屬性,而是 <select> 元素的 HTMLSelectElement 型別才會提供 value 這個屬性。

也就是說,之前在宣告 $selectDistrict 這個變數時,更好的做法是 —— 積極註記為 HTMLSelectElement

但是這裡要注意一點:$selectDistrict 原本的型別推論結果為 HTMLElement | null 代表 TypeScript 認為這裡有潛在 Bug 提醒開發者說:“你可能並沒有真正地選擇到元素,getElementById 裡的 ID 可能打錯字所以才會沒選到”。

因此,我們不僅僅要把 $selectDistrict 註記為 HTMLSelectElement 型別,另外還要將它和 null 進行複合,這應該才是正確流程,而不是使用 HTMLSelectElement 進行型別壟斷(此為筆者自創名詞,Type Monopolization)的動作:

https://ithelp.ithome.com.tw/upload/images/20191006/20120614RQ0JFEpTxB.png

因此,編輯器也沒有出現警告訊息了。(如圖五)

https://ithelp.ithome.com.tw/upload/images/20191006/20120614hRCS2HSMc6.png

重點 1. 型別註記 V.S. 型別壟斷 Type Annotation V.S. Type Monopolization

型別壟斷為筆者自創名稱 —— 目的是描述型別註記誤用的一種情形,絕對不是官方或其他文章會出現的觀念。

通常,一個型別如果出現了複合的情形,又以 union 為主,如果進行型別註記時 —— 除非你可以抱持 100% 信心可以對型別進行積極註記而取代使用 Type Guard 進行型別限縮,否則貿然地對型別積極註記為複合型別中的其中一種情形而忽略掉其他狀況,此現象被筆者稱之為型別壟斷(Type Monopolization)—— 為強烈建議禁止的行為。

以下以變數 A 為範例,若 A 被 TypeScript 判定,型別推論為 T | U

https://ithelp.ithome.com.tw/upload/images/20191006/20120614Gwp8prouup.png

A 被強行註記為 T 或者是 U而非經過 Type Guard 等安全性處理,此時就犯了型別壟斷的行為。

回過頭來,筆者繼續實作 fetchData 以後,將 UBike 資料轉換成 Leaflet Marker 的程式碼內容。

首先我們必須根據目前選到的行政區對 UBike 資料進行過濾的動作 —— 使用 Array.prototype.filter 這個方法。

https://ithelp.ithome.com.tw/upload/images/20191006/20120614EIc4jj5oZf.png

筆者再次強調 —— 儘管讀者沒有看到以上的程式碼做任何型別註記,但筆者之前就有探討過某些情形是不需要對函式的參數進行註記的,而這些機制也會和泛用型別的機制有關,如果忘記的讀者請記得要好好看一下本系列函式型別篇章以及函式與陣列的相關篇章

filter 裡面的回呼函式的 info 參數會被自動推論為 UBikeInfo,所以並不是說筆者沒有應用到 TypeScript 的型別系統,而是 TypeScript 的型別系統在為筆者工作,確保筆者 100% 不會寫出會出現潛在 Bug 的程式碼。(如圖六)

https://ithelp.ithome.com.tw/upload/images/20191006/20120614zIb2M6khyQ.png
圖六:就算參數 info 沒有被註記,此時的型別推論會自動鎖定 UBikeInfo 型別

第二個步驟就是將過濾出的點位轉換成 Leaflet Marker,這一次筆者使用 Array.prototype.map 方法。

https://ithelp.ithome.com.tw/upload/images/20191006/20120614NpNpaSVEMz.png

這裡提醒一下,如果臨時忘記套件提供的 API 的功能,記得 TypeScript 提供給你的優勢,也就是型別系統;而 TypeScript 會根據 Leaflet 套件的 Declaration File 告訴你 new L.Marker 裡面的參數該填入什麼。(如圖七)

https://ithelp.ithome.com.tw/upload/images/20191006/20120614KyJnYlLodj.png
圖七:new L.Marker 裡面的細節

L.Marker 顯示的內容為:

constructor Marker<any>(
  latlng: L.LatLngExpression,
  options?: L.MarkerOptions | undefined
): L.Marker<any>

代表它除了是 Marker 的建構子函式外,第一個參數必須為 L.LatLngExpression,正好是筆者的範例程式碼 UBikeInfolatlng 對應的型別;第二個參數為 L.MarkerOptions,如果讀者好奇或者臨時忘記有哪些參數可以帶進去,可以用筆者教過的方式,直接查找 Declaration File。(圖八為 L.Marker 的定義;圖九為 L.Marker 第二個參數 —— MarkerOptions 的定義)

https://ithelp.ithome.com.tw/upload/images/20191006/20120614DCHAyFusnz.png
圖八:藉由筆者之前教過的技巧,可以在 VSCode 裡從 L.Marker 找到原本的定義

https://ithelp.ithome.com.tw/upload/images/20191006/20120614EMcggRIxDg.png
圖九:此為 MarkerOptions 的介面定義

如果你會這個技巧,就可以節省時間不用上網查 Doc,直接在編輯器裡就可以查到 Declaration 以及該套件的 API 規格。

除了將 UBikeInfo 轉換成 Marker 外,還需要對 Marker 新增 Tooltip 顯示 UBike 站點的資料與自行車借用狀態。

貼心小提示

Tooltip 是一種提示性視窗,通常就是滑鼠滑到某個功能時,會跳出來的視窗。

https://ithelp.ithome.com.tw/upload/images/20191006/20120614laxS7aqWst.png

marker.bindTooltip 以及一些細節就留給讀者去探索,跟剛剛筆者在解說 new L.Marker 的過程差不多。

以上的程式碼除了對每一個 Marker 新增 Tooltip 外,Leaflet Marker 還提供事件註冊的 API —— 因此筆者註冊兩個分別為 mouseovermouseleave 事件,代表顯示或關閉 Tooltip。

最後,將 Marker 使用 L.layerGroup 包在一起後丟進一開始有宣告過的 markerLayer 變數(為 LayerGroup 型別),並且將其加到地圖裡。

https://ithelp.ithome.com.tw/upload/images/20191006/20120614GkzsRxipL4.png

這樣子,我們就完成基本的程序囉!打開瀏覽器來看,出現預設的區域 中正區 以及該區的 UBike 站點,甚至還可以用滑鼠檢視該地區的站點資訊喔!(如圖十)

https://i.imgur.com/s2pUPlT.gif
圖十:UBike 地圖站點的結果

監聽行政區欄位的事件

儘管已經做好了初步的準備,但筆者還沒有將 $selectDistrict 這個 <select> 元素註冊事件 —— 如果行政區被改變的話,應該要更新地圖的。

首先,由於建立 Leaflet Marker 的程式碼要被重複使用,因此筆者先把它包成函式並先呼叫一次,為的是要初始化地圖的狀態:

https://ithelp.ithome.com.tw/upload/images/20191006/20120614b7ZCF91Ehv.png

由於 updateUBikeMap 參數需要為 Districts 型別,因此除了要記得將 Districtsdata.d.ts 載入進去外,currentDistrict 記得要註記為 Districts

另外,將 $selectDistrict 註冊一個 change 事件並嘗試更新地圖資訊:

https://ithelp.ithome.com.tw/upload/images/20191006/201206148T8GRyphTp.png

以上的程式碼完成了!打開瀏覽器測試結果如圖十一。

https://i.imgur.com/VcMV68e.gif
圖十一:切換行政區時,可以更新整體的地圖狀況

小結

本來筆者還想要多寫一個功能,就是當使用者選擇行政區時,要藉由上一篇宣告過的 districtLatLngMap 取的經緯度後,再讓地圖聚焦到那個座標,這樣子也會比較 User Friendly —— 不過筆者認為,讀者可以先試試看如何實踐這個功能。(提示:map.flyTo 這個方法)

下一篇筆者就先切入程式碼重構部分,敬請期待~


上一篇
Day 39. 戰線擴張・模擬戰 — UBike 地圖 X 資料處理 - Data Processing using Type Alias
下一篇
Day 41. 戰線擴張・模擬戰 — UBike 地圖 X 外觀模式 - Façade Pattern in TypeScript
系列文
讓 TypeScript 成為你全端開發的 ACE!51

尚未有邦友留言

立即登入留言