點燈坊

學而時習之,不亦悅乎

使用 Vue Component 實現連動下拉選單

Sam Xiao's Avatar 2019-06-16

三連動下拉選單也是實務上常見需求,練習使用 Vue 實現二連動下拉選單即可。

Version

macOS Mojave 10.14.5
Node 12.4.0
Vue CLI 3.8.4
Vue 2.6.10

Prerequisites

  • 使用 ES6 + Vue File
  • 請設計一個 my-select.vue,只包含一個 <select></select>
  • App.vue 要包含兩個 <my-select></my-select>,並顯示最後所選擇的 zip

vuefile000

App.vue

<template>
  <div>
    <my-select v-model="townIndex" :data-source="towns"></my-select>
    <my-select v-model="areaIndex" :data-source="areas"></my-select>
    {{ zip }}
  </div>
</template>

<script>
import mySelect from './components/my-select.vue'

let cities = [
  {
    name: '基隆市',
    areas: [
      { name: '仁愛區', zip: '200' },
      { name: '信義區', zip: '201' },
      { name: '中正區', zip: '202' },
      { name: '中山區', zip: '203' },
      { name: '安樂區', zip: '204' },
      { name: '暖暖區', zip: '205' },
      { name: '七堵區', zip: '206' },
    ],
  },
  {
    name: '台北市',
    areas: [
      { name: '中正區', zip: '300' },
      { name: '大同區', zip: '301' },
      { name: '中山區', zip: '302' },
      { name: '松山區', zip: '303' },
      { name: '大安區', zip: '304' },
      { name: '萬華區', zip: '305' },
      { name: '信義區', zip: '306' },
      { name: '士林區', zip: '307' },
      { name: '北投區', zip: '308' },
      { name: '內湖區', zip: '309' },
      { name: '南港區', zip: '310' },
      { name: '文山區', zip: '311' },
    ],
  },
  {
    name: '新竹市',
    areas: [
      { name: '新竹市', zip: '400' },
    ],
  },
];

let towns = () => cities.map(x => x.name);

let areas = function() {
  return cities[this.townIndex].areas.map(x => x.name);
};

let zip = function() {
  return cities[this.townIndex].areas[this.areaIndex].zip;
};

let townIndex = function() {
  this.areaIndex = 0;
};

export default {
  name: 'app',
  components: {
    mySelect
  },
  data: () => ({
    townIndex: 0,
    areaIndex: 0,
  }),
  computed: {
    towns,
    areas,
    zip,
  },
  watch: {
    townIndex,
  }
}
</script>

12 行

let cities = [
  {
    name: '基隆市',
    areas: [
      { name: '仁愛區', zip: '200' },
      { name: '信義區', zip: '201' },
      { name: '中正區', zip: '202' },
      { name: '中山區', zip: '203' },
      { name: '安樂區', zip: '204' },
      { name: '暖暖區', zip: '205' },
      { name: '七堵區', zip: '206' },
    ],
  },
  {
    name: '台北市',
    areas: [
      { name: '中正區', zip: '300' },
      { name: '大同區', zip: '301' },
      { name: '中山區', zip: '302' },
      { name: '松山區', zip: '303' },
      { name: '大安區', zip: '304' },
      { name: '萬華區', zip: '305' },
      { name: '信義區', zip: '306' },
      { name: '士林區', zip: '307' },
      { name: '北投區', zip: '308' },
      { name: '內湖區', zip: '309' },
      { name: '南港區', zip: '310' },
      { name: '文山區', zip: '311' },
    ],
  },
  {
    name: '新竹市',
    areas: [
      { name: '新竹市', zip: '400' },
    ],
  },
];

提供模仿 API 所回傳的 data,注意並沒有宣告在 Vue instance 的 data 內,因為這些資料並沒有要綁定到 HTML template 內。

並不是所有的資料都要宣告在 data 內,只有 HTML template 要綁定資料才需要,若只是一般 function 共用資料則不必

以 FP 角度,只有要處理 HTML template 這種 side effect 的資料,才須宣告在 data

第 3 行

<my-select v-model="townIndex" :data-source="towns"></my-select>
<my-select v-model="areaIndex" :data-source="areas"></my-select>

設計 <my-select/> component,但我們希望設計什麼 props 讓外層能與 component 溝通呢 ?

  • 希望外層的 index 能被 two way binding,既能傳入 初始值,且當 component 的內 index 改變,也能影響外層 index,因此使用了 v-model
  • <my-select> 所需要的 array 透過 data-source props 傳入

69 行

data: () => ({
  townIndex: 0,
  areaIndex: 0,
}),

由於希望 index 能被 two way binding,因此在data 宣告了 townIndexareaIndex,分別代表第一個 component 與第二個 component 所選擇的 index。

因為 data function 沒使用到 this context,可使用 arrow function

50 行

let towns = () => cities.map(x => x.name);

let areas = function() {
  return cities[this.townIndex].areas.map(x => x.name);
};

let zip = function() {
  return cities[this.townIndex].areas[this.areaIndex].zip;
};

定義 townsareaszip 三個 computed,其本質都是 function。

let towns = () => cities.map(x => x.name);

town computed 將傳入第一個 component 的 data-source props,由於只需 map() 整理過的 name property,因此特別適合使用 computed

因為 town() 沒使用到 this context,可大膽使用 arrow function

let areas = function() {
  return cities[this.townIndex].areas.map(x => x.name);
};

area computed 將傳入第二個 component 的 data-source props,會與 user 所選擇的 城市 連動,也就是 areas 會隨著 townIndex 改變,且只需 map() 整理過的 name property,因此特別適合使用 computed

因為 areas() 使用了 this context 存取 data,所以只能使用 function expression

let zip = function() {
  return cities[this.townIndex].areas[this.areaIndex].zip;
};

最後的 zip,會與 user 所選擇的 城市鄉鎮區 同步,也就是 zip 會隨著 townIndexareaIndex 改變,因此特別適合使用 computed

因為 zip() 使用了 this context 存取 data,所以只能使用 function expression

60 行

let townIndex = function() {
  this.areaIndex = 0;
};

假如沒有對 townIndex computed 加以 watch,當 台北市文山區,然後再選 新竹市 時,就會出現 Cannot read property of undefined 的 runtime 錯誤。

因為 areaIndex11,而 新竹市area 只有一筆 (故意的),已經超出 area array 的 length,所以 runtime 錯誤。

解決方法就是當 townIndex 變化時,同時對 areaIndex reset 為 0,所以要使用 watch

因為 townIndex() 使用了 this context 存取 data,所以只能使用 function expression

my-select.vue

<template>
  <select v-model="selectedIndex">
    <option v-for="(item, index) in dataSource" :value="index" :key="index">
      {{ item }}
    </option>
  </select>
</template>

<script>

let selectedIndex = {
  get: function() {
    return this.value;
  },
  set: function(val) {
    this.$emit('input', val)
  }
};

export default {
  name: 'my-select',
  props: [
    'value',
    'dataSource'
  ],
  computed: {
    selectedIndex,
  }
}
</script>

21 行

props: [
  'value',
  'dataSource'
],
  • 由於使用了 v-model,因此要宣告 value props
  • 宣告 dataSource props,由外層傳入 <my-select> 所需要的 array

第 3 行

<option v-for="(item, index) in dataSource" :value="index" :key="index">
  {{ item }}
</option>

由傳入的 dataSource props 使用 v-for 產生 <option/>

index 綁定到 vaule,將 item 用於顯示。

使用 v-for 時,別忘了也要將 index 綁定到 key

第 2 行

<select v-model="selectedIndex">
</select>

由於我們希望將 user 所選擇的 index 能傳出 component,直覺會將 v-model 直接對 value props 綁定。

但別忘了 Vue Component 獨特的 unidirectional dataflow:

Data 只會由外層往內層傳,而不會由內層往外層傳

因此就算使用 v-model 綁定 value props 也沒用,而是該乖乖使用 event。

第 10 行

let selectedIndex = {
  get: function() {
    return this.value;
  },
  set: function(val) {
    this.$emit('input', val)
  }
};

computed 除了可以使用 function 回傳 value 外,也可以使用 object 搭配 getter 與 setter。

如此 <select/> 的初始值既可透過 getter 由 value props 傳進來,亦可透過 setter 發出 event。

當 user 改變 <select/> 選擇時,就會觸發 setter,此時就可使用 this.$emit() 觸發 input event,並將 index 透過 event 傳出去。

外層就可透過 v-model 去改變 cityIndexareaIndex

因為 getter 與 setter 都使用了 this context,所以只能使用 function expression

Conclusion

  • 使用 MVVM 時,盡量少去思考 HTML 與 DOM event,這樣又會回去 jQuery 思維,而是要使用 data binding 方式:思考資料如何改變 ( computed )、思考如何監聽資料 ( watch );也就是腦筋只考慮改變 data,而不是去考慮改變 HTML;改變 data 是我們的責任,跟 HTML 打交道是 Vue 的責任
  • 跟 HTML 耦合越深,將來就越難寫 unit test;唯有將注意力放在 data,將來才好寫 unit test
  • 因為 props 是 unidirectional flow,不能使用 v-model 直接綁定 props,而要改用 computed pattern,並透過其 setter 去 emit event

Sample Code

完整範例可在我的 GitHub 上找到

See Also

Vue Component 基礎概念
Vue Component 之 Props
Vue Component 之 Event