三連動下拉選單也是實務上常見需求,只要兩個 Component 就可實現二連動下拉選單。
Version
macOS Catalina 10.15.6
WebStorm 2020.2
Vue 2.6.11
Component
- 當選擇
台北市
時,會連動其區
顯示 - 當選擇
大安區
時,會連動其郵遞區號
顯示
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
宣告了 townIndex
與 areaIndex
,分別代表第一個 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
會隨著 townIndex
與 areaIndex
改變,因此特別適合使用 computed 使其 reactive。
23 行
let townIndex = function() {
this.areaIndex = 0
}
假如沒有對 townIndex
computed 加以 watch,當 台北市
選 文山區
,然後再選 新竹市
時,就會出現 Cannot read property of undefined
的 runtime 錯誤。
因為 areaIndex
為 11
,而 新竹市
的 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
去改變 cityIndex
與 areaIndex
。
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