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-menu
的mouted()
的時候去掃所有的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
但這是有缺點的:
- 每個child component會呼叫一次, 但實際上不需要被呼叫這麼多次, 有點浪費
- 這個子原件的設計變成會依賴父元件, 不易與用在其他元件內
所以還是需要一個更好的方式
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, 這樣一來就不用依賴其他人也可以做到自動更新了