[Vue] 運用 Scoped Slots 降低 v-for 迴圈數

Lastor
Code 隨筆放置場
9 min readDec 31, 2022

最近工作上碰到一個情境,有個自製的 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),完成了優化!! (灑花)

--

--

Lastor
Code 隨筆放置場

Web Frontend / 3D Modeling / Game and Animation. 設計本科生,前遊戲業 3D Artist,專擅日本動畫與遊戲相關領域。現在轉職為前端工程師,以專業遊戲美術的角度涉足 Web 前端開發。