前言
工作上遇到一個頁面,是要呈現一個巢狀的 list
每個 list 底下,還會有自己的屬於自己的 list
而每個 list item 都有一個 checkbox 在前面
HTML 會長得像以下結構:
<ul>
<li>
<input type="checkbox" />
<label>1</label>
<ul>
<li>
<input type="checkbox" />
<label>1-1</label>
</li>
<li>
<input type="checkbox" />
<label>1-2</label>
<ul>
<li>
<input type="checkbox" />
<label>1-2-1</label>
</li>
<li>
<input type="checkbox" />
<label>1-2-2</label>
</li>
<!-- 繼續往下長.... -->
</ul>
</li>
</ul>
</li>
<li>
<input type="checkbox" />
<label>2</label>
</li>
</ul>
目前只有固定的三層,可以直接寫死三層的 list 結構
但這不是一個靈活的結構,因爲未來某一天若要添加第四層,就得在 HTML 再添加下一層的 <li>
我希望的是用 data-driven 的方式去做渲染
也就是 data 有幾層巢狀,HTML 的部分就會依據 data 長出幾層
遞迴函式
巢狀的資料結構,我第一個想到的就是「遞迴」
某些場合我們會使用遞迴函式
函式可以遞迴,也就是在自己裡面呼叫自己,那元件是否也可行呢?
於是我查了一下關於 recursive component 的文章
發現 Vue 與 React 雖然寫法不太一樣,但都有遞迴元件的方式達成不斷巢狀渲染的目的
這裡就用 Vue 來示範
以下分三個方向來討論,分別是:
- 資料面
- 顯示面
- 互動面
資料面:巢狀的列表
首先,先定義一下 data 的型別:
type Item = {
id: string;
isCheck: boolean;
children: Item[]; // <-- 這裡又是 type Item,不斷往下長⋯⋯
};
type List = Item[];
從 children 的型別可以看出,type Item
也是一個遞迴的結構
因此,我們的 data 的結構就會是 type List
的樣子:
var nestedList: List = [
{ id: "1", isCheck: true, children: [] },
{
id: "2",
children: [
{ id: "2-1", isCheck: true, children: [] },
{
id: "2-2",
isCheck: true,
children: [{ id: "2-2-1", isCheck: true, children: [] }],
},
],
isCheck: true,
},
];
我們在這裡只寫三層的 list 結構就好,三層若可以正常顯示,四層、五層、⋯⋯、n 層也不會有問題
顯示面:遞迴元件
接下來就是 HTML 的部分,我們定義一個元件,就叫做RecursiveList
吧:
<template>
<div>
<ul>
<li v-bind:key="item.id" v-for="item in list">
<input type="checkbox" />
<label>{{ item.id }}</label>
</li>
</ul>
</div>
</template>
<script>
export default {
name: "RecursiveList",
props: {
list: {
type: Array,
default: () => [],
},
},
};
</script>
這裡可以渲染第一層的 item 了,因此我們著手進行第二層以下
如果每個 <li>
有 children,就要再跑一次 v-for
去渲染
遞迴函式呼叫自己,就是爲了避免寫重複的邏輯、做重複的工作,遞迴元件也是同樣的道理
因此,我們在元件裡建立遞迴的結構:
<template>
<div>
<ul>
<li v-bind:key="item.id" v-for="item in list">
<input type="checkbox" v-bind:checked="item.isCheck" />
<label>{{ item.id }}</label>
<!-- ⬇️ 這裡使用遞迴 ⬇️ -->
<RecursiveList v-bind:list="item.children" v-if="item.children" />
</li>
</ul>
</div>
</template>
<script>
// ...
</script>
從此,無論是第三層、第四層、……、第 n 層,都可以渲染出來了~
到這裡爲止,僅只是顯示方面,別忘了每一個 item 裡面還有一個 checkbox
接下來,我們要為這個 checkbox 增加互動行爲
互動面:往子層與往父層
我們做出的巢狀的 <ul>
、<li>
結構之後,每個 item 的 checkbox 互動行爲又是如何呢?
這裡有兩個條件要符合:
- 當任一 checkbox 被 check/uncheck 時,該 checkbox 底下的所有 checkbox 都要被 check/uncheck
- 每個 checkbox 的下一層有任一 checkbox 為 check 狀態,它自身就要被 check
當 checkbox 點擊的當下,以這個 list item 的元件為主體(以下簡稱當事人),分為兩個方向:
- 向下的操作:改變所有子層(及子層的子層 ⋯⋯ 等) checkbox 的值
- 向上的檢查:確認父層是否要變動
改變子層
首先,我們來做第一個條件的互動:
當事人若爲 check,底下所有子孫都要 check,也就是全選;反之,則全不選
所以我們新增一個 handleChildren
的方法
<template>
<div>
<ul>
<li v-bind:key="item.id" v-for="item in list">
<input type="checkbox" v-bind:checked="item.isCheck" v-on:change="handleChildren($event, item)" />
<label>{{ item.id }}</label>
<RecursiveList v-bind:list="item.children" v-if="item.children" />
</li>
</ul>
</div>
</template>
<script>
export default {
// ...
methods: {
handleChildren(event, item) {
const newValue = event.target.checked;
function changeAll(item, value) {
item.children.forEach((child) => {
child.isCheck = value;
changeAll(child, value);
});
}
changeAll(item, newValue);
item.isCheck = newValue; // 更新自身的值
},
},
};
</script>
在 handleChildren
方法裡面,changeAll
這個函式也是做遞迴呼叫
將 handleChildren
傳進來的 item 底下所有的 children 都掃過一遍
最後也別忘了更新自己本身的值
父層的判斷
完成了向下的操作,接下來看向上的檢查
當事人觸發 change 事件後,若為 check,則父層不用做任何檢查
但若為 uncheck,他的父層(parent)要判定其子層,也就是當事人的平輩層(sibling)是否都沒有 check,若都沒有,就要 uncheck
這個父層若 uncheck 了,那又要執行一次這個父層的父層(aka 當事人的祖父層、grandparent)的檢查 ⋯⋯
也就是會一路往上每一層都做檢查,直到最上層
在當事人的視野裡,我們只從 props 接收到 list,也就是父層的 children 這個陣列
壓根不知道自己的父層是誰
於是,他必須通知自己的父層,也就是遞迴元件的上一層去做 children 的檢查
此時,Vue 的 $emit
就派上用場了
還記得前些年,前高雄市長韓國瑜的口號嗎?
沒錯,那就是「貨出去,人進來,高雄發大財」
來改編一下這句話:
event 出去,props 進來,前端發大財
利用 $emit
的特性,來通知上層:
你底下的其中一個子層 uncheck 了,請檢查自己該不該繼續 check
於是,新增一個 handleParent
方法
<template>
<div>
<ul>
<li v-bind:key="item.id" v-for="item in list">
<input
type="checkbox"
v-bind:checked="item.isCheck"
v-on:change="handleChildren($event, item), handleParent()"
/>
<label>{{ item.id }}</label>
<RecursiveList
v-bind:list="item.children"
v-if="item.children"
v-on:emit-parent="handleParent(item)"
/>
</li>
</ul>
</div>
</template>
<script>
export default {
// ...
methods: {
// ...
handleChildren() {
// ...
},
handleParent(item) {
this.$emit("emit-parent");
if (!item) return; // 沒有帶參數,表示是當事人,故不檢查
const childrenHasSomeChecked = item.children.some((item) => item.isCheck);
item.isCheck = childrenHasSomeChecked;
},
},
};
</script>
有別於一般 $emit
的寫法,這個 $emit
不帶任何 payload 出去,只是發送 emit-parent
事件
遞迴到上一層之後,emit-parent
事件觸發後執行的函式,依然是 handleParent
方法
此外,還從參數帶了父層的 item 進來,以便做 check 的檢查
至此就大功告成了
當事人父層的父層檢查、父層祖父層檢查,也都是同樣的邏輯
重複的工作,就交給遞迴去做了
emit-parent
事件會一路 $emit
上去
在最上層,也就是第一次出現 RecursiveList
的 App 元件裡面
用 handleEmitTop
方法去接 emit-parent
事件,並用 console.log
紀錄下來:
<template>
<div id="app">
<RecursiveList v-bind:list="nestedList" v-on:emit-parent="handleEmitTop($event)" />
</div>
</template>
<script>
import RecursiveList from "./components/RecursiveList.vue";
export default {
name: "App",
components: {
RecursiveList,
},
data() {
return {
nestedList: [
// ...
],
};
},
methods: {
handleEmitTop() {
console.log("do nothing");
},
},
};
</script>
果然會跟預想的一樣,每次的點擊,無論哪個 checkbox
每次都會一路 $emit
到最上層,然後印出 do nothing
但最上層沒有 checkbox 了,所以無需檢查,因此不用做任何事
程式碼全貌
上面的所有程式碼,請參考以下 CodeSandbox:
後記
這次是因工作上的需要,而研究了關於遞迴的寫法
雖然這個不確定會巢狀幾層的 data 結構,會造成後端定義 model 的困擾而沒有採用
但也是一個不錯的嘗試
希望未來能有機會用到這種簡潔的寫法
Vue 因為框架的特性,直接對 data 的操作比較簡單,而無需在意是哪一層、哪一個 children
雖然過程中還挺手忙腳亂的,不知道哪一層是哪一層,然後誤打誤撞的寫出可以動的東西
然而其實只需要專注在當事人身上就好了
話說回來,隨意修改 data 而不透過 props 去傳遞新的 data ,確實不是個好作法,但也確實能達到目的
若換作是 React,想必寫法會很不一樣
後後記
如果你好奇:文中那張照片、站在沙灘高舉雙手的人是誰?
他是蘋果電腦的共同創辦人:史蒂夫·沃茲尼克(Steve Wozniak)
那是 1982 年沃茲尼克所舉辦的音樂節 US Festival 現場
在他的自傳中提到自己夢想著籌辦音樂節
雖然音樂節圓滿舉辦,但不僅沒賺錢,反而虧損
可是他本人還是很開心,非常享受音樂節的每一刻
這張照片的肢體語言令我印象深刻
也充分表現出他的心情
就算沒有發大財,每天還是要過得開心