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

為什麼呢? 這是由於 slot 的特性是保留一個空間可以從外部傳入內容, 而子元件本身對 slot 並沒有控制權,也就是說,子元件完全不知道,也不管 slot 被傳了什麼東西進去。
另外,若是我們希望在子元件內提供「預設內容」,則可以這樣做:
html
<div id="app">
<h1>{{ msg }}</h1>
<!-- 元件內不插入任何內容 -->
<custom-component></custom-component>
</div>js
// 直接將預設內容放在 <slot> 標籤中
app.component('custom-component', {
template: `<div>
Hello!
<div>
<slot>這是預設內容</slot>
</div>
</div>`,
data () {
return {
msg: 'Child !'
}
}
});像這樣,若在外部元件 <custom-component> ... </custom-component> 並未提供任何內容給子元件時, 原本在子元件的 slot 區塊的位置則會出現預先設定好的文字。
具名插槽
具名插槽顧名思義就是「有名字的 slot」,什麼時候會用到呢?像是偶爾我們也會遇到在同一個元件內有多組 slot 的狀況。 讓我們以最前面提到的 lightbox 做為例子。
這裏將 lightBox 元件分為 header 、 body 與 footer 三個部分,並在 header 與 footer 加入 name 屬性來做區隔:
js
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;
}
}
});html
<div id="app">
<!-- 子元件內是空的 -->
<light-box></light-box>
</div>像這樣,點擊按鈕開啟燈箱後,由於什麼都沒有,所以出現的是預設的內容。
若是我們想要在外層帶入對應內容給 lightbox,則可以在外面加上 <template> 與 v-slot: 加指定名稱:
html
<!-- 父層元件引入 <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>我們在直接 light-box 元件裡加入要給燈箱顯示的內容,並在 HTML 標籤裡加入 slot 屬性,指定該節點要出現在哪個對應的 slot 位置。 而未指定 name 的部分,則會全部歸到未命名的 slot,也就是 body 的區塊中。

另外,除了使用 slot 屬性,我們也可以利用 #header 搭配 <template> 標籤做到同樣的效果:
html
<light-box>
<!-- 效果等同 v-slot:header -->
<template #header>
<h2>008JS 好棒棒!</h2>
</template>
</light-box>小提醒
v-slot 只能與 <template> 標籤搭配使用。
另外,沒有提供 name 屬性的 slot, Vue.js 會預設給它一個 default 的名稱, 也就是說,我們可以利用 <template #default> 或 <template v-slot:default> 來指定尚未提供 name 的 slot 區塊。
動態切換具名插槽
slot 也能像我們先前介紹過的 is 動態元件一樣即時切換所在位置,只需要改寫 v-slot 並搭配 [ ]:
html
<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>js
const app = Vue.createApp({
data () {
return {
options: ['header', 'footer', 'default'],
dynamic_slot_name: 'header'
}
}
});
// 下略即可做到動態切換 slot 位置的效果。
Scoped Slots
假設我們今天想做一個多語系的 「Hello World」 訊息框, 於是我們在 lightBox 子元件裡面定義了中文、日文與英文版本的 Hello World,並且由 props 傳入要顯示的語系:
js
props: {
lang: {
type: String,
default: 'tw'
}
},
data: () => ({
helloString: {
'tw': '哈囉!世界!',
'jp': 'ハロー・ワールド!',
'en': 'Hello world!'
},
})那麼問題來了,前面說過,透過 slot 傳入的內容都是由外層父元件所提供, 如果我們希望在子層元件的 slot 也能使用子元件的狀態 (如 data、props 等) ,就需要透過 Vue.js 提供的 Scoped Slots 特性來處理。
讓我們改寫一下前面的範例。
首先是外層元件的 data ,我們定義了三種語系:
js
data: () => ({
langOptions: [
{ name: '繁體中文', val: 'tw' },
{ name: '日本語', val: 'jp' },
{ name: 'English', val: 'en' },
],
lang: 'tw'
})接著,在模板加入下拉選單,以及 <light-box> 裡面加上 lang 這個 prop,讓它可傳入使用者選擇的語系:
html
<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>接著,我們修改 lightBox 子元件內的 slot 標籤,讓它可以將 lightBox 子元件內的 helloString 往外拋:
html
<main>
<slot name="default" v-bind:hello="helloString[lang]"></slot>
</main>這裡的 :hello 看起來跟前面介紹過的 Prop 很像對吧? 其實這就是 Vue.js 所提供的 slot prop,作用就是將子元件內的狀態透過 slot 提供給外層存取。
而外層的模板則需要加上 template 標籤,以及 v-slot:default="props"
html
<light-box :lang="lang">
<template v-slot:default="props">
{{ langOptions.find(d => d.val === lang)['name'] }}:
{{ props.hello }}
</template>
</light-box>
甚至,我們也可再進一步,透過 ES6 物件解構的語法,讓程式碼更簡潔:
html
<light-box :lang="lang">
<template v-slot:default="{ hello }">
{{ langOptions.find(d => d.val === lang)['name'] }}:
{{ hello }}
</template>
</light-box>此時,在 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> 進行渲染,如:
html
<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>
像這樣,由於加上 <teleport to="body"> ... </teleport> 之後,被包覆的 modal-mask 會實際渲染在 <body> ... </body> 裡面, 所以其他部分的程式碼都無需修改,<light-box> 元件的背景遮罩就可以完美覆蓋整個網頁了。
另外,跟 slot 一樣的是,同一個網頁上可以有多個 <teleport>,甚至指向同一個目標:
html
<teleport to="#modals">
<div>A</div>
</teleport>
<teleport to="#modals">
<div>B</div>
</teleport>此時並不會有誰覆蓋誰的問題,而實際生成的結果則是會依照置入的順序來進行渲染:
html
<!-- result-->
<div id="modals">
<div>A</div>
<div>B</div>
</div>小提醒
被 <teleport> 移動位置的 DOM 節點,並不會被刪除後重新建立, 而是會類似前面所介紹過的 <keep-alive> 那樣地保留元件的當下狀態 (包含 data與 HTML 內容等) 。