[Vue] 運用 Scoped Slots 降低 v-for 迴圈數
最近工作上碰到一個情境,有個自製的 table 組件,要追加新的欄位上去。但是當前的寫法的父子層組件搭配方式,會有迴圈數過多的現象。概念上大概是這樣:
// Child
tr loop (固定有 1 個)
// Parent
td loop (不確定 0 ~ n 個)
td loop
td loop
...
用時間複雜度計算的話,也就是一個 loop 跑 n 圈,會有 m 個 n。
O(n + n + n + …) = O(n * (1 + m))
這篇文章將會介紹,如何在不大改原架構的前提下,將其優化成 O(n):
// 其餘的其實不需要
tr loop
冗贅的 O(n * (1 + m)) v-for
把實際的組件簡化一下的話,大概是這種感覺:
// Vue2
// Parent.vue
<script setup>
import { ref } from 'vue'
import MyTable from './MyTable.vue'
const data = ref([
{ id: 1, colA: 'colA', colB: 'colB' },
{ id: 2, colA: 'colA', colB: 'colB' },
{ id: 3, colA: 'colA', colB: 'colB' },
])
</script>
<template>
<div>
<MyTable :data="data">
<td v-for="product in data" :slot="product.id">{{ product.id }}</td>
<td v-for="product in data" :slot="product.id">{{ product.colA }}</td>
<td v-for="product in data" :slot="product.id">{{ product.colB }}</td>
</MyTable>
</div>
</template>
MyTable 是個共用組件,利用 slot 的方式把各頁面差異化的 td 欄位插進去。
MyTable 則大概是長這樣:
// MyTable.vue
<script setup>
defineProps({
data: Array,
})
</script>
<template>
<tbody>
<tr v-for="row in data">
// ...公版的 td (省略)
// 各頁面客製化的 td
<slot :name="row.id"></slot>
</tr>
</tbody>
</template>
在內部就已經對 tr row 做迴圈,並生成一組公版的 td col,這部分先省略掉,重點是後面利用 slot 插入的客製化 td。
由於子層是這樣的寫法,父層要插入 td 時,為了拿到 data
陣列中,各自的 row.id
。所以父層被迫也得去跑迴圈,才能對應到 slot name。
Vue slot 工作的方式,可以想像成 function 的引數那樣,JS 會先處理引數,然後才把結果帶到 function 中。
// JS 會先算完 1 + 1 + 1 之後, 才餵給 function
const result = mathAdd(1 + 1 + 1)
而 Vue / React 自然也會遵照這個形式,先把被插進去的 html 解析完之後,才會初始化組件本身。
<Compnent>
<div>...</div>
<div>...</div>
// ...
</Component>
所以父層的這種寫法,勢必會先跑 3 次迴圈 O(3n),然後子層又再跑一次迴圈 n,變成 O(4n)。
// Parent
<MyTable :data="data">
<td v-for="product in data" :slot="product.id">{{ product.id }}</td>
<td v-for="product in data" :slot="product.id">{{ product.colA }}</td>
<td v-for="product in data" :slot="product.id">{{ product.colB }}</td>
</MyTable>
// MyTable
<tr v-for="row in data">
<slot :name="row.id"></slot>
</tr>
也就是每插入一個客製化的 td 就會多跑一個 n,最終變成了 O(n * (1 + m)) 的狀態,這顯然是很不理想的。
我們可在父子層都打上一個 console.log 去計數,就可以清楚的看到,父層的 parent 先被解析,有 3 個 td 跑了 3n,最後子層又跑了 1n。
// Parent
const count = () => console.log('parent')
<td v-for="product in data" :slot="product.id">
{{ product.id }} {{ count() }}
</td>
// MyTable
const count = () => console.log('child')
<tr v-for="row in data">
{{ count() }}
<slot :name="row.id"></slot>
</tr>
階段一、優化為 O(2n)
來看這個父層。他把相同的迴圈重複做了 n 次,這顯然是沒有必要的。
// Parent
<MyTable :data="data">
<td v-for="product in data" :slot="product.id">{{ product.id }}</td>
<td v-for="product in data" :slot="product.id">{{ product.colA }}</td>
<td v-for="product in data" :slot="product.id">{{ product.colB }}</td>
</MyTable>
合併到一起,跑一次就好了。
// Parent
<MyTable :data="data">
<div v-for="product in data" :key="product.id" :slot="product.id">
{{ count() }}
<td>{{ product.id }}</td>
<td>{{ product.name }}</td>
<td>{{ product.colA }}</td>
</div>
</MyTable>
這邊由於 Vue2 的限制,用 template 下 v-for 會很難搞,所以改用 div 來包。如果是 Vue3 的話,可以直接用 template。
由於 3 個 td 的重複迴圈被整合了,可以預期他會從 3n 變成 n。我們已經打好了 count() 計數器,執行的話會發現成功變成了 O(2n)。
階段二、優化為 O(n)
追根究柢,父層的迴圈仍然是多餘的,到底有沒有方法可以讓父層不跑迴圈呢? 研究了一陣子 Vue 的機制之後,發現有一個方法是可以做到的,也就是 Scoped Slots (舊版本為 slot-scope)。
這機制簡單來說,slot 是把父層的東西插到子層,被插進去的 block 享有的是父層的作用域。也就是說,slot 可以讓原本應該在子層的 html block,直接拿到父層的 state。形同是父層 pass data 到子層。
而這個 Scoped Slots 則是反過來,他可以把子層的 state 傳給父層,有一點像是 this
的概念,父層透過一個指定的變數名,去借代為子層的實際變數,屆時子層在運行時,會拿子層的 state 來執行。
實際寫起來會變成這樣。
// Parent
<MyTable :data="data">
<template #customTd="{ row: product }">
{{ count() }}
<td>{{ product.id }}</td>
<td>{{ product.name }}</td>
<td>{{ product.colA }}</td>
</template>
</MyTable>
// MyTable
<tr v-for="row in data">
{{ count() }}
<slot name="customTd" :row="row"></slot>
</tr>
先看子層 MyTable,slot name 從動態的 row.id
改為靜態固定的 "custmTd"
。然後加上一個 row
屬性把遍歷時取得的 row data 給綁進去。
這樣父層就可以用 props 的概念,直接拿到子層傳進去的 row data。由於 slot name 變成了一個特定的 string,父層也就不再需要跑迴圈了。
最後看 console 的結果,會發現兩個很明顯的差異之處:
1. 變成 child 先運行了
2. console.log 不再被疊加,變成了交錯呈現
從第一點可以觀察出,程式的運行方式變了,因為父層不跑迴圈了,所以被插入的 slot 可以先不執行,等到子層運行碰到了 <slot>
之後,才會去尋找父層有沒有被插入對應 name 的 slot。
也就是父層的 slot 被拉進了子層的 v-for 執行序,一起執行了。大概就是這樣的感覺。
// 父層的 slot 成功進到子層迴圈相同的執行序
data.forEach((row) => {
console.log('child')
console.log('parent')
})
所以這個 console.log 的結果,應該要這樣看,這就是交錯呈現的原因。
// 第一圈
child
parent
// 第二圈
child
parent
// 第三圈
child
parent
可以再做個更細的確認,把 count() 再帶入一個 number 去計數。
// 父子層都做一樣的修改
let sum = 0
const count = () => console.log('child', ++sum)
從這個結果就能確認,只跑了 1 個迴圈。成功的從 O(n * (1 + m)) 降到了 O(n),完成了優化!! (灑花)