# 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');
1
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>
1
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>
1
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 !'
    }
  }
});
1
2
3
4
5
6
7
8
9
10
11
12
13
<custom-component>
  {{ msg }}
</custom-component>
1
2
3

像這樣,我們在子元件 customComponent 加上了一個 slot 標籤後,神奇的事情發生了:

試一試

原本應該定義在父層元件的 Parent !,此時居然出現在子層元件的 slot 標籤位置中。

而且值得注意的是,這裡的 {{ msg }} 是父層的 Parent ! 而非子層所屬的 {{ msg }} 內容。

Slot

為什麼呢? 這是由於 slot 的特性是保留一個空間可以從外部傳入內容, 而子元件本身對 slot 並沒有控制權,也就是說,子元件完全不知道,也不管 slot 被傳了什麼東西進去。

另外,若是我們希望在子元件內提供「預設內容」,則可以這樣做:

<div id="app">
  <h1>{{ msg }}</h1>
  
  <!-- 元件內不插入任何內容 -->
  <custom-component></custom-component>
</div>
1
2
3
4
5
6





 









// 直接將預設內容放在 <slot> 標籤中
app.component('custom-component', {
  template: `<div> 
    Hello!
    <div>
      <slot>這是預設內容</slot>
    </div>
  </div>`,
  data () {
    return {
      msg: 'Child !'
    }
  }
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
試一試

像這樣,若在外部元件 <custom-component> ... </custom-component> 並未提供任何內容給子元件時, 原本在子元件的 slot 區塊的位置則會出現預先設定好的文字。

# 具名插槽

具名插槽顧名思義就是「有名字的 slot」,什麼時候會用到呢?像是偶爾我們也會遇到在同一個元件內有多組 slot 的狀況。 讓我們以最前面提到的 lightbox 做為例子。

這裏將 lightBox 元件分為 headerbodyfooter 三個部分,並在 headerfooter 加入 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;
    }
  }
});
1
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>
1
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>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

我們在直接 light-box 元件裡加入要給燈箱顯示的內容,並在 HTML 標籤裡加入 slot 屬性,指定該節點要出現在哪個對應的 slot 位置。 而未指定 name 的部分,則會全部歸到未命名的 slot,也就是 body 的區塊中。

Slot

試一試

另外,除了使用 slot 屬性,我們也可以利用 #header 搭配 <template> 標籤做到同樣的效果:



 




<light-box>
  <!-- 效果等同 v-slot:header -->
  <template #header>
    <h2>008JS 好棒棒!</h2>
  </template>
</light-box>
1
2
3
4
5
6

小提醒

v-slot 只能與 <template> 標籤搭配使用。

另外,沒有提供 name 屬性的 slot, Vue.js 會預設給它一個 default 的名稱, 也就是說,我們可以利用 <template #default><template v-slot:default> 來指定尚未提供 nameslot 區塊。

# 動態切換具名插槽

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>
1
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'
    }
  }
});

// 下略
1
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!'
  },
})
1
2
3
4
5
6
7
8
9
10
11
12
13

那麼問題來了,前面說過,透過 slot 傳入的內容都是由外層父元件所提供, 如果我們希望在子層元件的 slot 也能使用子元件的狀態 (如 dataprops 等) ,就需要透過 Vue.js 提供的 Scoped Slots 特性來處理。

讓我們改寫一下前面的範例。

首先是外層元件的 data ,我們定義了三種語系:

data: () => ({
  langOptions: [
    { name: '繁體中文', val: 'tw' },
    { name: '日本語', val: 'jp' },
    { name: 'English', val: 'en' },
  ],
  lang: 'tw'
})
1
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>
1
2
3
4
5
6
7
8
9
10
試一試

接著,我們修改 lightBox 子元件內的 slot 標籤,讓它可以將 lightBox 子元件內的 helloString 往外拋:

<main>
  <slot name="default" v-bind:hello="helloString[lang]"></slot>
</main>
1
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>
1
2
3
4
5
6

Slot

甚至,我們也可再進一步,透過 ES6 物件解構的語法,讓程式碼更簡潔:


 
 
 
 


<light-box :lang="lang">
  <template v-slot:default="{ hello }">
    {{ langOptions.find(d => d.val === lang)['name'] }}:
    {{ hello }}
  </template>
</light-box>
1
2
3
4
5
6

此時,在 lightBox 子元件 :hello 就會變成父層 props 的物件屬性,我們就可以取得對應的內容了。

試一試

# teleport (Vue 3.0 新增)

延續前面範例,雖然到目前為止我們已經可以順利地透過 slot 來完成 lightbox 的功能。

但是讀者們可能已經發現,由於我們將 lightbox 封裝在 <light-box> 元件的關係,導致燈箱的背景無法將整個網頁遮蔽的問題:

遮不住的 lightbox

在過去,我們可能會透過 CSS 來硬改 positionz-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>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
試一試

加上  的 lightbox

像這樣,由於加上 <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>
1
2
3
4
5
6
7

此時並不會有誰覆蓋誰的問題,而實際生成的結果則是會依照置入的順序來進行渲染:

<!-- result-->
<div id="modals">
  <div>A</div>
  <div>B</div>
</div>
1
2
3
4
5

小提醒

<teleport> 移動位置的 DOM 節點,並不會被刪除後重新建立, 而是會類似前面所介紹過的 <keep-alive> 那樣地保留元件的當下狀態 (包含 data與 HTML 內容等) 。

Last Updated: 12/28/2020, 7:34:16 PM