# 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 內容等) 。