Vue.js 新手如何製作口罩地圖?一起來貢獻小小力量吧!

最近世界各地疫情嚴峻,筆者以口罩地圖作為 Vue.js 練習主題的主因在於,前陣子閱讀口罩地圖先發者、好想工作室創辦人- Howard(吳展瑋先生)解釋為何而做口罩地圖時所說的一段話:

工程師就是這樣,會自己生出工具,也會想要自己生出證據,口罩地圖就是我想證明口罩政策是行得通最好的證據;難的是動力,多數人可能覺得與自己無關,就不想做。

看完這段話,筆者就在想:「我也想做一個口罩地圖,雖然我是 Vue.js 新手,但就算是新手也想做出真實使用者可以使用的工具,並且出點小小力量啊!」

因而誕生這篇文章。

開始實作前,簡單說明筆者撰寫此文時的 Vue.js 程度與看完文章的你可以從中獲得什麼。筆者平常是以後端開發為主,首次使用 Vue.js 是參加 Kuro 老師主講的 Vue.js Meetup,這是筆者第一次運用前端框架 Vue.js 實作簡單的幻燈片效果。而這次製作的簡易版口罩地圖,是筆者看完卡斯伯老師的教學影片後,實作時無參考教學,靠著筆記和自行查找文件完成,並將其整理成簡單的說明,期望 step by step 讓同樣是新手的你一起實作,並記錄一些新手可能踩的雷和觀念釐清。

那就一起開始實作囉!Let’ s go!👇

概要

由於本文篇幅較多,為了讓不同程度的讀者能快速跳躍至想了解的部分,所以此概要設計為跳躍連結,請與網頁右下角的 scrollup 一起服用。

安裝工具

  • 安裝 Chrome 擴充套件 Vue.js devtools,主要用於確認 Vue.js 元件是否有正確讀取資料。
  • 請確認環境中是否已安裝 Vue CLI。
$ vue --version
#=> @vue/cli 4.3.1

若尚未安裝 Vue CLI,可以參考此文件或在下方指令中擇一執行進行安裝。

安裝 Vue CLI 的目的在於,Vue CLI 能幫助我們快速建立 Vue.js 專案所需環境,讓我們無需花費多餘的時間去自行建立可能會使用到的眾多檔案。

$ npm install -g @vue/cli
# OR
$ yarn global add @vue/cli

筆者使用的套件管理工具是 npm,故之後與套件安裝相關實作將統一使用 npm CLI 指令說明。

建立環境

使用 Vue CLI 指令建立 Vue.js 專案。建立專案時,需要設定專案所需規範;專案建立完畢後,請進入專案並開啟 server。

# create vue repository
$ vue create mask-map-demo

# set repository specification
Vue CLI v4.3.1
? Please pick a preset: Manually select features
? Check the features needed for your project: Babel, CSS Pre-processors, Linter
? Pick a CSS pre-processor (PostCSS, Autoprefixer and CSS Modules are supported by default): Sass/SCSS (with node-sass)
? Pick a linter / formatter config: Airbnb
? Pick additional lint features: Lint on save
? Where do you prefer placing config for Babel, ESLint, etc.? In dedicated config files
? Save this as a preset for future projects? No

# change directory
$ cd mask-map-demo

# open server
$ npm run serve

開啟瀏覽器並輸入 http://localhost:8080/,看到 Vue.js 的迎賓畫面就代表專案建立成功囉~ 🎉

Vue.js 口罩地圖實作教學-建立全新的 Vue.js 專案

建立一個新的 Vue.js 專案後,將 App.vue 檔案中與 HelloWorld.vue 相關的程式碼刪除。刪除完畢後,瀏覽器的迎賓畫面會變成一片空白,程式碼如下所示:

<template>
  <div id="app">
  </div>
</template>

<script>
export default {
  name: 'App',
  components: {
  },
};
</script>

<style lang="scss">
</style>

將畫面不需要的元件清除後,接下來就可以實作口罩地圖的基本介面了。

建立基本介面

使用指令安裝 Bootstrap 套件。

$ npm install bootstrap

每安裝完套件後,可以在 `package.json` 檔案中查看 `dependencies` 物件,`dependencies` 會顯示該專案安裝過的所有套件名稱以及使用的版本。

注意:每次安裝完新的套件,請記得重新啟用 server。

安裝完套件後,我們需要先導入(import)套件才能使用它,因此,請先在 App.vue 檔案裡導入 Bootstrap,放置位子為 style tag 裡。

<style lang="scss">
@import 'bootstrap/scss/bootstrap';
</style>

套件正確導入後,接著在相同檔案的 template tag 中使用 HTML 語法建立基本架構,並使用 Boostrap 快速排版,這邊須留意要給 #map 高度,不然會發生地圖無法完整呈現的問題。

<template>
  <div id="app">
    <div class="row no-gutters">
      <!-- 選擇所在區域 -->
      <div class="toolbox col-sm-3 p-2 bg-white">
        <div class="form-group d-flex">
          <label for="cityName" class="col-form-label mr-2 text-right">縣市</label>
          <div class="flex-fill">
            <select id="cityName" class="form-control"></select>
          </div>
        </div>
        <div class="form-group d-flex">
          <label for="area" class="col-form-label mr-2 text-right">地區</label>
          <div class="flex-fill">
            <select id="area" class="form-control"></select>
          </div>
        </div>
      </div>

      <!-- 顯示藥局位置 -->
      <div class="col-sm-9">
        <div id="map"></div>
      </div>
    </div>
  </div>
</template>

<!-- ... -->

<style lang="scss">
  @import 'bootstrap/scss/bootstrap';

  #map {
    position: relative;
    height: 100vh;
  }
</style>

Vue.js render 畫面的方式是由 main.js 這個進入檔案(entry file)開始,在 main.js 內的 render function 會先 render App.vue 檔案,當 App.vue 的元件刻畫完畢後,才會將畫面顯示在 index.html。由此可知,要寫一個功能完整的 Vue.js application,其實無需編寫模板檔案(.html)。

當基本介面刻畫完後,我們接著先處理縣市/地區資料以及藥局資料。

製作縣市/地區下拉選單

選單的選項需分別列出台灣所有的縣市/地區供使用者選擇,但我們不太可能自己一個一個建立,因此這邊會找開源資料來做進一步處理。而取得資料的方式有兩種,一種是透過內部 .json 檔案,另外一種是透過 AJAX,這部分我們先使用內部檔案的方式取得資料。

請先下載各縣市區域資料至專案 /src/assets 資料夾內,接著導入(import)專案內部資料以利實作縣市/地區的下拉選單,並製作 cityNameselect 兩個元件,在 Vue.js 中,我們可以使用 data 來製作資料元件。

<script>
// 導入內部檔案
import cityName from './assets/cityName.json';

export default {
  name: 'App',
  // 製作元件
  data: () => ({
    cityName,
    // select 先暫時設定為空物件
    select: {},
  }),
  // ...
};
</script>

template tag 中新增 option tag 搭配 Vue.js 的雙向綁定便利方法,來產生下拉選單的內容。

<template>
  // ...
  // 選擇縣市選單
  <select id="cityName" class="form-control" v-model="select.city">
    <option value="">請選擇縣市</option>
    <option :value="c.CityName" v-for="c in cityName" :key="c.CityName">
      {{ c.CityName }}
    </option>
  </select>

  // ...

  // 選擇地區選單
  <select id="area" class="form-control" v-model="select.area">
    <option value="">請選擇地區</option>
    // 建立專案時若有選擇 Airbnb 相關規範設定的話
    // 需留意過長的程式碼會造成 line length error,換行即可解決此問題
    <option :value="a.AreaName" v-for="a in cityName.find((city) => city.CityName === select.city).AreaList" :key="a.AreaName">
      {{ a.AreaName }}
    </option>
  </select>
  // ...
</template>

這邊會逐一說明上方的程式碼在做些什麼事。

  • v-model 用於實現雙向綁定,透過監聽使用者輸入的事件來更新 value,簡單說就是當使用者選擇選單時,則 select 物件的值會跟著一起改變。
  • v-for 是以迭代方式,依序產生 cityName 元件的縣市/地區資料。
  • {{ }} 用於畫面顯示資料。

再來預設查詢的區域位置,只需要在元件中給予期望的預設值。需留意字串的文字要與 cityName.json 提供的文字相同,若有錯字則會出錯,例如 API 提供的資料文字是「台北市」,若打成「臺北市」的話,在尋找區域資料 AreaList 時,Console 會報錯 "TypeError: Cannot read property 'AreaList' of undefined",這是因為 CityName 的錯字造成找不到對應的 AreaList

<script>
  // ...
  data: () => ({
    cityName,
    select: {
      // 筆者有改過下載的區域資料,故 city 是簡寫的台北市,正常下載是臺北市
      city: '台北市',
      area: '中正區',
    },
  }),
  // ...
</script>

Vue.js 口罩地圖實作教學-選擇領取口罩的區域

原本沒有選項的選單,現在已經分別有各縣市及其地區的資料,接著換另一種方式來處理我們所需要的藥局資料。

使用 AJAX 取得遠端資料

使用指令安裝 vue-axios 套件。

axios 是基於 HTTP 規範來取得遠端資料的工具,而 vue-axios 則是將 axios 包裝在 Vue.js 的套件之中。

$ npm install --save axios vue-axios

還記得前面提醒過套件安裝完畢後,要做兩件什麼重要的事情嗎?

就是要重新開啟 server,並且要導入套件後才能使用它,所以請在 main.js 檔案裡導入 axios 和 vue-axios。

導入時,建議養成習慣將外部套件與內部檔案分開,並記得要使用導入的外部套件,不要導入套件後沒有使用。

// 外部套件
import Vue from 'vue';
import axios from 'axios';
import VueAxios from 'vue-axios';

// 內部檔案
import App from './App.vue';

Vue.config.productionTip = false;
// 導入的外部套件要記得使用
Vue.use(VueAxios, axios);

套件準備完畢,就一起來取得重要的遠端資料囉!

我們可以使用 vue-axios 提供的方法 this.$http.get(api) 來獲得遠端資料。
API 路徑:https://raw.githubusercontent.com/kiang/pharmacies/master/json/points.json

<script>
// ...
export default {
  name: 'App',
  data: () => ({
    cityName,
    select: {
      city: '台北市',
      area: '中正區',
    },
  }),
  components: {
  },
  mounted() {
    const api = 'https://raw.githubusercontent.com/kiang/pharmacies/master/json/points.json';
    this.$http.get(api).then((response) => {
      // 將結果印出來看看
      console.log(response);
    });
  },
};
</script>

接著來檢查一下,這邊使用 console.log(response); 在瀏覽器的 Console 中檢查是否有正確取得資料,並將取得的資料結構展開後,需看到如下圖所示的資訊,但 API 資料結構可能會不定期更新,若你看到的資訊與圖片所示有些微差異是正常的,僅須留意資料物件中的 key 名稱是否相同,若有所不同,稍後實作過程中,請將 key 換成你取得資料物件的名稱。

Vue.js 口罩地圖實作教學-vue-axios 取得藥局資料

確定能夠正確取得資料後,接著我們要將從遠端取得的資料做成一個元件。

先製作一個空陣列 data,並在取得遠端資料的 function 中,將空陣列 data 指定為遠端資料結構中所提供的藥局資訊即可。而要製作的元件之所以設定為空陣列而非空物件的原因在於,我們從上圖的 Console 檢查中可以得知欲指定的內容 data.features 是一個陣列(Array)。

<script>
// ...
export default {
  name: 'App',
  // Vue.js 元件
  data: () => ({
    // 空陣列 data
    data: [],
    cityName,
    select: {
      city: '台北市',
      area: '中正區',
    },
  }),
  components: {
  },
  mounted() {
    const api = 'https://raw.githubusercontent.com/kiang/pharmacies/master/json/points.json';
    this.$http.get(api).then((response) => {
      // 將空陣列 data 指定為遠端獲得的資料,資料僅需取得藥局資訊即可
      this.data = response.data.features;
    });
  },
};
</script>

那麼製作好 data 元件後,要如何確認這個元件裡真的有資料呢?

我們可以使用已經安裝好的 Chrome 擴充套件 Vue.js devtools 來檢查 data 這個元件裡是否有資料。

Vue.js 口罩地圖實作教學-使用 Vue.js devtools 檢查元件資料

確認元件有正確的資料後,接下來我們要產生地圖並且將資料顯示在地圖上囉!

使用 OSM 和 Leaflet 製作地圖與標記藥局

OSM (OpenStreetMap) 是一種開源的街圖工具,使用 OSM 原因很簡單,因為它是免費的,對於我們要做一個練習專案相對足夠,但也正因為 OSM 是免費的資源,它只是單純的圖資,並沒有提供其他可用功能,若我們想在開源圖資 OSM 上做一些操作,就需要搭配 JavaScript library - Leaflet 一同使用

我們可以參考 Leaflet 提供的文件說明,跟著做就能夠快速產生一個地圖喔!

請先使用指令安裝 Leaflet 套件。

$ npm install leaflet

安裝套件的動作做到第三次,你是否已經記得套件安裝完要做哪兩件事情呢?

就是重新開啟 server 並且導入套件,導入位置請放在 App.vue 檔案的 script tag 裡。

<script>
// L 代表 Leaflet
import L from 'leaflet';

export default {
  // ...
};
</script>

並在 ./public/index.html 檔案中使用 CDN 的方式載入 Leaflet 所需要的 CSS 樣式,程式碼如下:

<head>
  <meta charset="utf-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width,initial-scale=1.0">
  <link rel="icon" href="<%= BASE_URL %>favicon.ico">
  <!-- 使用 CDN 方式載入 CSS -->
  <link rel="stylesheet" href="https://unpkg.com/leaflet@1.6.0/dist/leaflet.css"
    integrity="sha512-xwE/Az9zrjBIphAcBb3F6JVqxf46+CDLwfLMHloNu6KEQCAWi6HcDUbeOfBIptF7tcCzusKFjFw2yuvEpDL9wQ=="
    crossorigin=""/>
  <title><%= htmlWebpackPlugin.options.title %></title>
</head>

完成 Leaflet 的設定後,請先切換至 App.vue 檔案,接下來我們會在 script tag 裡編寫程式碼來產生一個地圖。

建立地圖

首先,製作一個名為 openStreetMap 的空物件。

<script>
import L from 'leaflet';

// 設定空物件
let openStreetMap = {};

export default {
  // ...
};
</script>

這個 openStreetMap 空物件主要會有兩個用途:

  • 設定畫面中 #map 的基本設定,例如顯示的中心位置、縮放比例等。
  • 將之後疊加在地圖上的操作指定加在 openStreetMap 之上。

接著就來將上述所提及的兩個用途進行實作,這邊會逐一說明下方程式碼在做什麼事。

  • Leaflet 的 map 方法接收兩個參數:
    • 第一個參數是地圖擺放位置的 id,在實作基本介面時,我們有先寫好 <div id="map"></div>
    • 第二個參數是一個物件,center 設定一開始顯示地圖的座標(這邊設定五倍的座標 XD),而 zoom 則是縮放比例。
  • Leaflet 的 tileLayer 方法主要是對疊加在地圖上的操作進行設定。
  • addTo(); 方法是將這些疊加在地圖上的操作指定加在 openStreetMap 這個物件上。
<script>
import L from 'leaflet';

let openStreetMap = {};

export default {
  // ...
  mounted() {
    // ...
    openStreetMap = L.map('map', {
      center: [25.042474, 121.513729],
      // 可以嘗試改變 zoom 的數值
      // 筆者嘗試後覺得 18 的縮放比例是較適當的查詢範圍
      zoom: 18,
    });

    L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
      attribution: 'Map data &copy; <a href="https://www.openstreetmap.org/">OpenStreetMap</a> contributors, <a href="https://creativecommons.org/licenses/by-sa/2.0/">CC-BY-SA</a>, Imagery © <a href="https://www.mapbox.com/">Mapbox</a>',
      maxZoom: 20,
    }).addTo(openStreetMap);
  },
};
</script>

完成上述的實作後,就趕快到網頁去看看,你就能夠看到地圖喔!若地圖沒有正常顯示,請先確認 CSS 樣式是否有給 #map 高度。

Vue.js 口罩地圖實作教學-使用 OSM 和 Leaflet 建立地圖

疊加藥局標記

成功產生地圖後,接著我們需要在地圖上顯示藥局標記,標記的位置會根據經緯度來定位,在前面的實作中,我們已經透過 AJAX 方法取得藥局位置經緯度資料了,那在實作藥局標記前,我們需要先知道兩件事情:

  • 我們已經取得每間藥局資料,但我們還需要將這包資料進行篩選,因為口罩地圖是依據使用者所選擇的區域進行查詢,所以藥局標記出現的位置需為「縣市/地區下拉選單的值」與「藥局的縣市/地區的值」相符。
  • 再取得篩選過的特定地區藥局資料後,我們要根據藥局資料中提供的經緯度在地圖上新增標記。

那就一起來依序實作上述的兩件事情,第一件事我們會使用 computed 取得篩選過後的資料,並且使用 watch 來監聽篩選資料是否有變化。

<script>
  data: () => ({
    // ...
  }),
  computed: {
    pharmacies() {
      return this.data.filter((pharmacy) => {
        if (!this.select.area) {
          return pharmacy.properties.county === this.select.city;
        }
        return pharmacy.properties.town === this.select.area;
      });
    },
  },
  watch: {
    pharmacies(value) {
      console.log(value);
    },
  },
  mounted() {
    // ....
  },
</script>

這邊會逐一說明上方程式碼在做什麼事。

  • computed 用於要改變已經存在的資料,簡單來說,現有的資料是一大包藥局資料,但我們想要透過下拉選單值的變化將現有的藥局資料改為特定區域的藥局資料。所以在其內建立 pharmacies 方法,透過 filter 篩選藥局,且藥局位置資料需符合下拉選單縣市與地區的值。
  • watch 用於監聽資料是否有變動,並且可以在資料變動後做一些事情,這邊我們先用 console.log(value); 來查看是否有正確監聽到 pharmacies 以及 value 是否為篩選過後的資料。

回到網頁,開啟檢查工具後會看到下方 GIF 動圖呈現的畫面,Console 會隨著使用者選擇選單,印出與選單區域相同位置的藥局資料。

Vue.js 口罩地圖實作教學-印出與選單區域相同位置的藥局資料

能夠正確取得篩選過的特定地區藥局資料後,我們再來做第二件事情,我們要根據藥局資料中提供的經緯度在地圖上新增標記。

這部分我們使用 methods 處理,並建立 updateMap(); 方法來更新地圖標記,在 updateMap(); 方法中,會透過 forEach() 迭代方式以及 Leaflet 提供的 marker 方法來將該區域的藥局位置標記出來,並將 watch 監聽的 pharmacies 在資料變動後做的事情改為 updateMap();

這部分須留意,新增標記的操作對象應為篩選過後的藥局資料,可以想像 computed 回傳的物件就跟從 data 回傳的物件一樣,都是 Vue.js 的元件,所以別忘了使用 this 喔!

<script>
  watch: {
    // 當選單的值發生變化,就會執行 updateMap(); 方法更新地圖標記
    pharmacies(value) {
      this.updateMap();
    },
  },
  methods: {
    updateMap() {
      this.pharmacies.forEach((pharmacy) => {
        // 透過藥局經緯度疊加標記
        L.marker([
          pharmacy.geometry.coordinates[1],
          pharmacy.geometry.coordinates[0],
        ]).addTo(openStreetMap);
      });
    },
  },
  mounted: {
    // ....
  },
</script>

重新整理網頁後,就如下圖所示,地圖會出現該區域的所有藥局位置囉!

Vue.js 口罩地圖實作教學-使用 Leaflet marker 在地圖上顯示藥局位置

試著變換選單地區,看看藥局標記是否會如下方 GIF 動圖一樣正確新增標記。

Vue.js 口罩地圖實作教學-切換區域並顯示對應區域的藥局標記

移除舊標記

從上方 GIF 動圖可以發現,我們只要重選一次選單,地圖上的標記就會不斷增加。在 Leaflet 中,新增一個 Marker 等同在地圖上疊加一層圖層,若同時有 N 個 Marker,則會疊加 N 個圖層,這時我們為了避免圖層無限疊加,而造成網頁載入過慢的問題發生,應該在更新地圖前,先移除現有標記,再重新繪製。

<script>
  // ...
  updateMap() {
    // clear markers
    openStreetMap.eachLayer((layer) => {
      if (layer instanceof L.Marker) {
        openStreetMap.removeLayer(layer);
      }
    });

    // add markers
    this.pharmacies.forEach((pharmacy) => {
      // ...
    });
  },
  // ...
</script>

試著變換選單地區,看看藥局標記是否會如下方 Gif 動圖一樣先移除舊標記後再新增標記。

Vue.js 口罩地圖實作教學-切換區域時,先移除舊標記再新增新標記

顯示藥局資訊

最後,這些在地圖上的標記,需在被點擊時,跳出 Popup 小視窗,顯示的內容應為該藥局的相關資訊與大眾最想知道的口罩數量,完成此功能才算製作完一個簡易版的口罩地圖。

Leaflet 有提供在 maker 上綁 Popup 的方法 bindPopup();,只需要加上此方法並接收一個 HTML 樣式,在點擊標記後,就能看到 Popup 小視窗中所設定的藥局資訊囉!

<script>
  methods: {
    updateMap() {
      // ...
      this.pharmacies.forEach((pharmacy) => {
        L.marker([
          pharmacy.geometry.coordinates[1],
          pharmacy.geometry.coordinates[0],
        ]).addTo(openStreetMap).bindPopup(`<p><strong style="font-size: 20px;">${pharmacy.properties.name}</strong></p>
          <strong style="font-size: 16px; color: #d45345;">口罩剩餘:成人 - ${pharmacy.properties.mask_adult ? `${pharmacy.properties.mask_adult} 個` : '未取得資料'} / 兒童 - ${pharmacy.properties.mask_child ? `${pharmacy.properties.mask_child} 個` : '未取得資料'}</strong><br>
          地址: ${pharmacy.properties.address}<br>
          電話: ${pharmacy.properties.phone}<br>
          <small>最後更新時間: ${pharmacy.properties.updated}</small>`);
      });
    },
  },
</script>

Vue.js 口罩地圖實作教學-點擊藥局標記並顯示口罩庫存資訊

簡易版口罩地圖功能完成囉~ 🎉

小結

此次製作簡易版口罩地圖,是筆者第一次在沒有使用後端技術下完成一個簡單的查詢網頁,非常有趣的初體驗 ❤️

由於筆者平時也較少接觸 JavaScript,此次實作稍深入了解取得遠端資料的方式(喚醒學員時期記憶)筆者認為「口罩地圖」很適合 Vue.js 新手作為練手主題,它有常見練手主題「TODO List」所沒有的資料取得與處理需求,但我們在工作中最常做的就是資料處理,因此十分推薦 Vue.js 新手入坑嘗試。

若想了解更多 Vue.js 的理論基礎,並將理論轉換成開發能力的朋友,非常推薦 Kuro 老師的 Vue.js 與 Vuex 前端開發實戰課程 - 假日班,課程中你可以了解到以下 Vue.js 重要的觀念:

  • Vue.js v2.6 的基礎觀念與設計邏輯
  • Vue.js 元件系統
  • 透過 Vuex 管理狀態
  • Vue.js 開發工具與相關開發生態圈
  • 比較各種前端框架與 Vue.js 的差異

透過課程幫助我們快速建立對 Vue.js 的完整架構,減少自我摸索走冤枉路的時間成本,一起與後端工程師在開發實務上更通暢!

最後,十分感謝這次技術文章 Reviewer - Meng-Ying 在閱讀的過程中所提出的修正建議和看完文章後的提問,這次的 Reviewer 是位後端開發者,也同樣是 Vue.js 新手,他在看完本文後也實作完一個口罩地圖喔!一起來熱血製作出你的口罩地圖吧~ 🎉

參考資料