點燈坊

失くすものさえない今が強くなるチャンスよ

使用 Component 實現連動下拉選單

Sam Xiao's Avatar 2020-08-10

三連動下拉選單也是實務上常見需求,只要兩個 Component 就可實現二連動下拉選單。

Version

macOS Catalina 10.15.6
WebStorm 2020.2
Vue 2.6.11

Component

myselect000

  • 當選擇 台北市 時,會連動其 顯示
  • 當選擇 大安區 時,會連動其 郵遞區號 顯示

JSON

cities.json

[
  {
    "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" }
    ]
  }
]

所有資料放在 cities.json,實務上會由 API 提供 JSON。

App.vue

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

<script>
import cities from '@/data/cities.json'
import MySelect from '@/components/MySelect.vue'

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>

第 3 行

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

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

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

32 行

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

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

13 行

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

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

15 行

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

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

19 行

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

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

23 行

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

MySelect.vue

<template>
  <select v-model="selectedIndex">
    <option v-for="(x, i) in source" :value="i" :key="i">
      {{ x }}
    </option>
  </select>
</template>

<script>

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

export default {
  name: 'MySelect',
  props: [
    'value',
    'source'
  ],
  computed: {
    selectedIndex,
  }
}
</script>

21 行

props: [
  'value',
  'dataSource'
],
  • 由於使用了 v-model,因此要宣告 value props
  • 宣告 source prop,由外層傳入 MySelect component 所需要的 Array

第 3 行

<option v-for="(x, i) in source" :value="i" :key="i">
  {{ x }}
</option>

由傳入的 source prop 使用 v-for 產生 <option>

i 綁定到 vaule,將 x 用於顯示。

第 2 行

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

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

但別忘了 component 獨特的 unidirectional dataflow:

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

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

11 行

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

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

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

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

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

Explicit Argument

App.vue

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

<script>
import cities from '@/data/cities.json'
import MySelect from '@/components/MySelect.vue'

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

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

let zip = ({ townIndex, areaIndex }) =>
  cities[townIndex].areas[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>

15 行

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

Computed 已可使用 explicit argument,直接在 argument 做 object restructuring 出 model 即可,不必使用 this,因此可以使用 arrow function。

Function Pipeline

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

<script>
import cities from '@/data/cities.json'
import MySelect from '@/components/MySelect.vue'
import { pipe, prop, map, always, nth } from 'ramda'

let towns = pipe(
  map(prop('name')),
  always
)(cities)

let areas = ({ townIndex }) => pipe(
  nth(townIndex),
  prop('areas'),
  map(prop('name'))
)(cities)

let zip = ({ townIndex, areaIndex }) => pipe(
  nth(townIndex),
  prop('areas'),
  nth(areaIndex),
  prop('zip')
)(cities)

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

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

14 行

let towns = pipe(
  map(prop('name')),
  always
)(cities)

towns() computed 也可使用 Ramda 以 Function Pipeline 組合而成。

19 行

let areas = ({ townIndex }) => pipe(
  nth(townIndex),
  prop('areas'),
  map(prop('name'))
)(cities)

areas() computed 也可使用 Ramda 以 Function Pipeline 組合而成。

Conclusion

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