# 2-4 編譯作用域與 Slot 插槽
前面我們介紹過了元件的拆分,以及如何做到元件之間的資料傳遞與事件溝通。 但若只是把重複利用的元件拆分出來,在某些場景下顯然還無法滿足我們的需求。
試想一下,今天我們做了一個電商網站,由於行銷廣告的需求, 我們在首頁、商品頁、品牌形象頁等都需要做個燈箱 (lightbox) 的效果,而這些燈箱的內容與行為可能又各自有所不同。
請各位讀者想一想,這個時候,你會怎麼處理?
一個燈箱就製作一個元件? 顯然不合理。 將燈箱獨立成一個元件呢? 聽起來是個好辦法。
再來,燈箱內容呢? 是要透過 props 傳進去嗎? 要傳入的東西好像又太多,而且各個不同燈箱之間樣版的內容結構又各有不同。
那麼,本小節要介紹的 slot (插槽) 就很適合用來處理這種類型的需求。
# 元件的編譯作用域
在介紹 slot 之前,我們先來談談元件的編譯作用域。 什麼是編譯作用域呢?
如同多數程式語言都有變數作用範圍 (scope) 的概念,編譯作用域可以將它想像成是「元件的 scope」。
舉例來說,前面我們曾經介紹過,當外層元件與內層元件的 data 都有相同名稱的屬性時:
const app = Vue.createApp({
data() {
return {
msg: 'Parent !'
}
}
});
app.component('custom-component', {
template: `<div>Hello!</div>`,
data () {
return {
msg: 'Child!'
}
}
});
app.mount('#app');
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
以上面這個例子來說,內外元件都各自擁有 msg 這個 data 屬性,但是外層與內層的 msg 實際上是不同的兩個屬性。
然後問題來了,如果今天我們的模板內容是這樣的:
<div id="app">
<h1>{{ msg }}</h1>
<!-- 猜猜看,此時畫面會出現什麼? -->
<custom-component>
{{ msg }}
</custom-component>
</div>
2
3
4
5
6
7
8
猜猜看,此時畫面會出現什麼?
答案是外層的 <h1></h1> 會出現父層的 Parent !
而在子層內的 <custom-component> 內的 {{ msg }} 會被 <custom-component> 原本所定義的 template 模板內容所取代。
<div id="app">
<h1>{{ msg }}</h1>
<custom-component>
<!-- 大多數情況下,這裡放任何內容都是無意義的 -->
{{ msg }}
</custom-component>
</div>
2
3
4
5
6
7
8
這是由於 Vue.js 在編譯元件的模板 (template) 時,會以元件模板的所定義內容為主。
也就是說,即使在 <custom-component> 內放入任何內容, Vue.js 在元件編譯成網頁模板的時候,會自動無視裡面的東西,並且以子元件的模板來替換掉。
除了某個例外,就是本節要介紹的重點: slot 。
# Slot (插槽)
slot 在官方文件的名稱叫做「插槽」, 有洞才能插 顧名思義,就是在子元件上面開個洞,
由外層元件將內容置放在至子層元件指定的位置中。
讓我們來改寫一下前面的範例:
app.component('custom-component', {
template: `<div>
Hello!
<div>
<slot></slot>
</div>
</div>`,
data () {
return {
msg: 'Child !'
}
}
});
2
3
4
5
6
7
8
9
10
11
12
13
<custom-component>
{{ msg }}
</custom-component>
2
3
像這樣,我們在子元件 customComponent 加上了一個 slot 標籤後,神奇的事情發生了:
原本應該定義在父層元件的 Parent !,此時居然出現在子層元件的 slot 標籤位置中。
而且值得注意的是,這裡的 {{ msg }} 是父層的 Parent ! 而非子層所屬的 {{ msg }} 內容。

為什麼呢? 這是由於 slot 的特性是保留一個空間可以從外部傳入內容,
而子元件本身對 slot 並沒有控制權,也就是說,子元件完全不知道,也不管 slot 被傳了什麼東西進去。
另外,若是我們希望在子元件內提供「預設內容」,則可以這樣做:
<div id="app">
<h1>{{ msg }}</h1>
<!-- 元件內不插入任何內容 -->
<custom-component></custom-component>
</div>
2
3
4
5
6
// 直接將預設內容放在 <slot> 標籤中
app.component('custom-component', {
template: `<div>
Hello!
<div>
<slot>這是預設內容</slot>
</div>
</div>`,
data () {
return {
msg: 'Child !'
}
}
});
2
3
4
5
6
7
8
9
10
11
12
13
14
像這樣,若在外部元件 <custom-component> ... </custom-component> 並未提供任何內容給子元件時,
原本在子元件的 slot 區塊的位置則會出現預先設定好的文字。
# 具名插槽
具名插槽顧名思義就是「有名字的 slot」,什麼時候會用到呢?像是偶爾我們也會遇到在同一個元件內有多組 slot 的狀況。
讓我們以最前面提到的 lightbox 做為例子。
這裏將 lightBox 元件分為 header 、 body 與 footer 三個部分,並在 header 與 footer 加入 name 屬性來做區隔:
app.component('light-box', {
template: `
<div class="lightbox">
<div class="modal-mask" :style="modalStyle">
<div class="modal-container" @click.self="toggleModal">
<div class="modal-body">
<header>
<slot name="header">Default Header</slot>
</header>
<hr>
<main>
<slot>Default Body</slot>
</main>
<hr>
<footer>
<slot name="footer">Default Footer</slot>
</footer>
</div>
</div>
</div>
<button @click="isShow = true">Click Me</button>
</div>`,
data: () => ({ isShow: false }),
computed: {
modalStyle() {
return {
'display': this.isShow ? '' : 'none'
};
}
},
methods: {
toggleModal() {
this.isShow = !this.isShow;
}
}
});
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
<div id="app">
<!-- 子元件內是空的 -->
<light-box></light-box>
</div>
2
3
4
5
像這樣,點擊按鈕開啟燈箱後,由於什麼都沒有,所以出現的是預設的內容。
若是我們想要在外層帶入對應內容給 lightbox,則可以在外面加上 <template> 與 v-slot: 加指定名稱:
<!-- 父層元件引入 <light-box> -->
<div id="app">
<light-box>
<template v-slot:header>
<h2>008JS 好棒棒!</h2>
</template>
<template v-slot:footer>
<h2>大家快來買!</h2>
</template>
<div>
<a href="..." target="_blank">購書傳送門</a>
</div>
</light-box>
</div>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
我們在直接 light-box 元件裡加入要給燈箱顯示的內容,並在 HTML 標籤裡加入 slot 屬性,指定該節點要出現在哪個對應的 slot 位置。
而未指定 name 的部分,則會全部歸到未命名的 slot,也就是 body 的區塊中。

另外,除了使用 slot 屬性,我們也可以利用 #header 搭配 <template> 標籤做到同樣的效果:
<light-box>
<!-- 效果等同 v-slot:header -->
<template #header>
<h2>008JS 好棒棒!</h2>
</template>
</light-box>
2
3
4
5
6
小提醒
v-slot 只能與 <template> 標籤搭配使用。
另外,沒有提供 name 屬性的 slot, Vue.js 會預設給它一個 default 的名稱,
也就是說,我們可以利用 <template #default> 或 <template v-slot:default> 來指定尚未提供 name 的 slot 區塊。
# 動態切換具名插槽
slot 也能像我們先前介紹過的 is 動態元件一樣即時切換所在位置,只需要改寫 v-slot 並搭配 [ ]:
<div id="app">
<label v-for="opt in options">
<input type="radio" :value="opt" v-model="dynamic_slot_name"> {{ opt }}
</label>
<light-box>
<!-- 透過所選的 dynamic_slot_name 動態切換對應的 slot -->
<template v-slot:[dynamic_slot_name]>
<h2>008JS 好棒棒!</h2>
</template>
</light-box>
</div>
2
3
4
5
6
7
8
9
10
11
12
const app = Vue.createApp({
data () {
return {
options: ['header', 'footer', 'default'],
dynamic_slot_name: 'header'
}
}
});
// 下略
2
3
4
5
6
7
8
9
10
即可做到動態切換 slot 位置的效果。
# Scoped Slots
假設我們今天想做一個多語系的 「Hello World」 訊息框,
於是我們在 lightBox 子元件裡面定義了中文、日文與英文版本的 Hello World,並且由 props 傳入要顯示的語系:
props: {
lang: {
type: String,
default: 'tw'
}
},
data: () => ({
helloString: {
'tw': '哈囉!世界!',
'jp': 'ハロー・ワールド!',
'en': 'Hello world!'
},
})
2
3
4
5
6
7
8
9
10
11
12
13
那麼問題來了,前面說過,透過 slot 傳入的內容都是由外層父元件所提供,
如果我們希望在子層元件的 slot 也能使用子元件的狀態 (如 data、props 等) ,就需要透過 Vue.js 提供的 Scoped Slots 特性來處理。
讓我們改寫一下前面的範例。
首先是外層元件的 data ,我們定義了三種語系:
data: () => ({
langOptions: [
{ name: '繁體中文', val: 'tw' },
{ name: '日本語', val: 'jp' },
{ name: 'English', val: 'en' },
],
lang: 'tw'
})
2
3
4
5
6
7
8
接著,在模板加入下拉選單,以及 <light-box> 裡面加上 lang 這個 prop,讓它可傳入使用者選擇的語系:
<p>
請選擇:
<select v-model="lang">
<option v-for="n in langOptions" :value="n.val">{{ n.name }}</option>
</select>
</p>
<light-box :lang="lang">
{{ langOptions.find(d => d.val === lang)['name'] }}
</light-box>
2
3
4
5
6
7
8
9
10
接著,我們修改 lightBox 子元件內的 slot 標籤,讓它可以將 lightBox 子元件內的 helloString 往外拋:
<main>
<slot name="default" v-bind:hello="helloString[lang]"></slot>
</main>
2
3
這裡的 :hello 看起來跟前面介紹過的 Prop 很像對吧?
其實這就是 Vue.js 所提供的 slot prop,作用就是將子元件內的狀態透過 slot 提供給外層存取。
而外層的模板則需要加上 template 標籤,以及 v-slot:default="props"
<light-box :lang="lang">
<template v-slot:default="props">
{{ langOptions.find(d => d.val === lang)['name'] }}:
{{ props.hello }}
</template>
</light-box>
2
3
4
5
6

甚至,我們也可再進一步,透過 ES6 物件解構的語法,讓程式碼更簡潔:
<light-box :lang="lang">
<template v-slot:default="{ hello }">
{{ langOptions.find(d => d.val === lang)['name'] }}:
{{ hello }}
</template>
</light-box>
2
3
4
5
6
此時,在 lightBox 子元件 :hello 就會變成父層 props 的物件屬性,我們就可以取得對應的內容了。
# teleport (Vue 3.0 新增)
延續前面範例,雖然到目前為止我們已經可以順利地透過 slot 來完成 lightbox 的功能。
但是讀者們可能已經發現,由於我們將 lightbox 封裝在 <light-box> 元件的關係,導致燈箱的背景無法將整個網頁遮蔽的問題:

在過去,我們可能會透過 CSS 來硬改 position 與 z-index 等方式來處理,但這樣仍然會因為各種不可掌控的因素無法作用,像是外層 CSS 的衝突等。
那麼,這個小節為讀者們介紹由 Vue 3.0 新加入的 <teleport>,就是一個非常好用的解決方案。
<teleport> 的作用是可以將模板中特定的 DOM 移動至我們所指定的位置渲染。
以前面的 <light-box> 元件為例,我們只需在模板中將 .modal-mask 用 <teleport> 標籤來包覆,
並加上 to="body" 來告訴 Vue.js 我們希望將這個節點移動到 <body> 進行渲染,如:
<div class="lightbox">
<teleport to="body">
<div class="modal-mask" :style="modalStyle">
<div class="modal-container" @click.self="toggleModal">
<div class="modal-body">
<main>
<slot name="default"></slot>
</main>
</div>
</div>
</div>
</teleport>
<button @click="isShow = true">Click Me</button>
</div>
2
3
4
5
6
7
8
9
10
11
12
13
14
15

像這樣,由於加上 <teleport to="body"> ... </teleport> 之後,被包覆的 modal-mask 會實際渲染在 <body> ... </body> 裡面,
所以其他部分的程式碼都無需修改,<light-box> 元件的背景遮罩就可以完美覆蓋整個網頁了。
另外,跟 slot 一樣的是,同一個網頁上可以有多個 <teleport>,甚至指向同一個目標:
<teleport to="#modals">
<div>A</div>
</teleport>
<teleport to="#modals">
<div>B</div>
</teleport>
2
3
4
5
6
7
此時並不會有誰覆蓋誰的問題,而實際生成的結果則是會依照置入的順序來進行渲染:
<!-- result-->
<div id="modals">
<div>A</div>
<div>B</div>
</div>
2
3
4
5
小提醒
被 <teleport> 移動位置的 DOM 節點,並不會被刪除後重新建立,
而是會類似前面所介紹過的 <keep-alive> 那樣地保留元件的當下狀態 (包含 data與 HTML 內容等) 。