[筆記][Vue.js] Slot的應用

Reading time ~5 minutes

Vue的component裡有一個還蠻好用的東西叫做Slot(文件在此), 尤其適用在開發複雜或巢狀的元件

那Slot是用在什麼樣的地方, 舉個例子, 假設我們有個元件叫做panel:

<panel>
  <div>Inside panel</div>
  <div>Panel content</div>
</panel>

Panel的定義可能會是這樣:

Vue.component('panel', {
  template: '<div><slot></slot></div>'
})

執行範例

簡單的說, 這邊template裡的slot會被拿來放前面例子<panel></panel>裡面的<div>

當然, 這也可以讓我們這個component裡面再放其他的component, 像是這樣的例子:

<b-menu>
 <b-menu-item>menu 1</b-menu-item>
 <b-menu-item>menu 2</b-menu-item>
 <b-menu-item>menu 3</b-menu-item>
 <b-menu-item>menu 4</b-menu-item>
</b-menu>

實作上可以寫成這樣

Vue.component('b-menu', {
  template: '<div><ul><slot></slot></ul></div>'
})

Vue.component('b-menu-item', {
  template: '<li><slot></slot></li>'
})

執行範例

其實這範例看起來好像也沒啥必要寫成component(不過就ul/li), 當然, 實際上的應用可以再更複雜, 再來看一個更複雜的範例:

<b-menu>
	<b-menu-item title="menu 1">Content 1</b-menu-item>
	<b-menu-item title="menu 2">Content 2</b-menu-item>
	<b-menu-item title="menu 3">Content 3</b-menu-item>
	<b-menu-item title="menu 4">Content 4</b-menu-item>
</b-menu>

跟上面不一樣的是, 這次想render成的結果是像這樣:

<div>
  <ul>
    <li><a href="#a1">menu 1</a></li>
    <li><a href="#a2">menu 2</a></li>
    <li><a href="#a3">menu 3</a></li>
    <li><a href="#a4">menu 4</a></li>
  </ul>
  <div id="a1">Content1</div>
  <div id="a2">Content2</div>
  <div id="a3">Content3</div>
  <div id="a4">Content4</div>
</div>

這明顯被切成兩區了, title的部分顯示在<ul>內, 而content卻在另一區, 那這該怎麼做呢?

取得Slot內的子元件(Child component)

上面的例子可以寫成這樣:

Vue.component('b-menu', {
  data () {
  	return {
      items: []
    }
  },
  template: '<div><ul><li v-for="(item,i) in items"><a :href="`#a${i+1}`">{{item.title}}</a></li></ul><div><slot></slot></div></div>',
  mounted () {
     this.items= this.$slots.default.filter(item => item.componentInstance || false).map(item => item.componentInstance)
     for (let [index, item] of this.items.entries()) {
       this.$set(item, 'id', 'a' + (index + 1))
     }
  }
})

Vue.component('b-menu-item', {
  template: '<div :id="id"><slot></slot></div>',
  props: ['title'],
  data () {
    return {
      id: ''
    }
  }
})

要取得slot裡面的child nodes可以用this.$slots.default, 但這個包含所有的child nodes, 如果我們要的只是child components, 那可以檢查這個node是否包含componentInstance

因此, 透過filter和map, 我們可以以this.$slots.default.filter(item => item.componentInstance || false).map(item => item.componentInstance)來取得child components, 在這個例子就包含所有的b-menu-item

這段程式的作法就是取得所有child components放入items這個資料欄位中, 而在template中有<li v-for="(item,i) in items">利用items內的值來渲染<li>的部分

這邊有一點需要注意的是, 這段必須要跑在 mounted()不能在created(), 因為在created()裡面雖然可以用this.$slots.default來取得child nodes, 但這時候child nodes的componentInstace全部都是undefined, 因為這時候child components其實都還沒準備好

處理動態內容

b-menu-item當然也可以用v-for來動態渲染, 像是:

<b-menu>
   <b-menu-item v-for="mItem in menuItems" :title="mItem.title">{{mItem.content}}</b-menu-item>
</b-menu>

這邊的menuItems如果是一個靜態的陣列下面例子, 不會有問題

let vue = new Vue({
  el: '#app',
  data () {
     return {
       menuItems: [
          {
             title: 'menu 1',
             content: 'content 1'
          },
          {
             title: 'menu 2',
             content: 'content 2'
          },
          {
             title: 'menu 3',
             content: 'content 3'
          }
       ]
     }
  }
})

但如果它的內容是由一個async function所產生, 像是

let vue = new Vue({
  el: '#app',
  data () {
     return {
       menuItems: []
     }
  },
  mounted () {
	let vm = this
	doGetSomething(data => {
		vm.menuItems = data
	})
  }
})

你可能會發現畫面完全沒變化, 那是因為我們在b-menumouted()的時候去掃所有的child components, 而menuItems可能在mouted很之後才會被更新, 所以不會被重掃一次, items並不會被更新, 所以畫面也不會有變化, 因此必須要在menuItems資料被更新後再掃一次slot的child components

可以把b-menu改成這樣

Vue.component('b-menu', {
  data () {
  	return {
      items: []
    }
  },
  template: '<div><ul><li v-for="(item,i) in items"><a :href="`#a${i+1}`">{{item.title}}</a></li></ul><div><slot></slot></div></div>',
  methods: {
	updateItems () {
	  	this.items= this.$slots.default.filter(item => item.componentInstance || false).map(item => item.componentInstance)
		for (let [index, item] of this.items.entries()) {
		this.$set(item, 'id', 'a' + (index + 1))
		}
	}
  },
  mounted () {
     updateItems()
  }
})

這樣亦即是, 我們在更新完資料後必須要再呼叫一次updateItems()

為了直接呼叫到b-menu的updateItems, 可以先替他加一個ref="menu", 方便後面存取

<b-menu ref="menu">
   <b-menu-item v-for="mItem in menuItems" :title="mItem.title">{{mItem.content}}</b-menu-item>
</b-menu>

前面更新menuItems的程式可以改寫成這樣:

let vue = new Vue({
  el: '#app',
  data () {
     return {
       menuItems: []
     }
  },
  mounted () {
	let vm = this
	doGetSomething(data => {
		vm.menuItems = data
		vm.$refs.menu.updateItems()
	})
  }
})

這邊透過vm.$refs.menu.updateItems()來更新items

但….還是沒動靜呀…怎麼回事? 因為這時候menuItems才剛被更新, 它先去更新b-munu-item, 如果讓items更新後, 畫面要跟著更新, 就必須要在下一個DOM的更新週期, 也就是使用$nextTick, 如下:

let vue = new Vue({
  el: '#app',
  data () {
     return {
       menuItems: []
     }
  },
  mounted () {
	let vm = this
	doGetSomething(data => {
		vm.menuItems = data
		vm.$nextTick(() => {
			vm.$refs.menu.updateItems()
		})
	})
  }
})

這樣就沒問題了!

但對於一個元件來說, 這樣的設計並不是很好, 變成這個元件必須相依於使用它的程式, 還有沒更好的寫法?

在子原件更新時呼叫父元件呢?

b-menu-item這樣改寫:

Vue.component('b-menu-item', {
  template: '<div :id="id"><slot></slot></div>',
  props: ['title'],
  data () {
    return {
      id: ''
    }
  },
  mounted () {
     this.$parent.updateItems()
  }
})

這樣也是可行的, 當新的b-menu-item被加入slot中時, 就會呼叫一次updateItems

但這是有缺點的:

  1. 每個child component會呼叫一次, 但實際上不需要被呼叫這麼多次, 有點浪費
  2. 這個子原件的設計變成會依賴父元件, 不易與用在其他元件內

所以還是需要一個更好的方式

MutationObserver

這時候就要借用HTML5的MutationObserver, 這個在Vuejs內部也是大量地被使用

使用MutationObserver, 我們可以把b-menu改成這樣:

Vue.component('b-menu', {
  data () {
  	return {
      items: [],
	  domObserver: null
    }
  },
  template: '<div><ul><li v-for="(item,i) in items"><a :href="`#a${i+1}`">{{item.title}}</a></li></ul><div ref="content"><slot></slot></div></div>',
  methods: {
	updateItems () {
	  	this.items= this.$slots.default.filter(item => item.componentInstance || false).map(item => item.componentInstance)
		for (let [index, item] of this.items.entries()) {
		this.$set(item, 'id', 'a' + (index + 1))
		}
	}
  },
  mounted () {
     updateItems()
	 let vm = this
	 vm.domObserver = new MutationObserver((mr, el) => {
		 let shouldUpdate = false
		 for (let m of mr) {
			 if (m.addedNodes.length > 0 || m.removedNodes.length > 0) {
				 shouldUpdate = true
				 break
			 }
		 }

		 if (shouldUpdate) {
			 vm.updateItems()
		 }
	 })

	 vm.domObserver.observer(vm.$refs.content, {childList: true, subtree: true})
  }
})

藉由監控slot的父節點(parent node)的變化來確定是否要去更新items, 這樣一來就不用依賴其他人也可以做到自動更新了