前言
Vue 中的
slot
和
slot-scope
一直是一個進階的概念,對于我們的日常的元件開發中不常接觸,但是卻非常強大和靈活。
在 Vue 2.6 中
-
和slot
在元件内部被統一整合成了slot-scope
函數
- 他們的渲染作用域都是
子元件
- 并且都能通過
去通路this.$slotScopes
這使得這種模式的開發體驗變的更為統一,本篇文章就基于
2.6.11
的最新代碼來解析它的原理。
對于 2.6 版本更新的插槽文法,如果你還不太了解,可以看看這篇尤大的官宣
Vue 2.6 釋出了
舉個簡單的例子,社群有個異步流程管理的庫:
vue-promised
,它的用法是這樣的:
<Promised :promise="usersPromise">
<template v-slot:pending>
<p>Loading...</p>
</template>
<template v-slot="data">
<ul>
<li v-for="user in data">{{ user.name }}</li>
</ul>
</template>
<template v-slot:rejected="error">
<p>Error: {{ error.message }}</p>
</template>
</Promised>
複制代碼
複制
可以看到,我們隻要把一個用來處理請求的異步
promise
傳遞給元件,它就會自動幫我們去完成這個
promise
,并且響應式的對外抛出
pending
,
rejected
,和異步執行成功後的資料
data
。
這可以大大簡化我們的異步開發體驗,原本我們要手動執行這個
promise
,手動管理狀态處理錯誤等等……
而這一切強大的功能都得益于Vue 提供的
slot-scope
功能,它在封裝的靈活性上甚至有點接近于
Hook
,元件甚至可以完全不關心
UI
渲染,隻幫助父元件管理一些
狀态
。
類比 React
如果你有
React
的開發經驗,其實這就類比
React
中的
renderProps
去了解就好了。(如果你沒有
React
開發經驗,請跳過)
import React from 'react'
import ReactDOM from 'react-dom'
import PropTypes from 'prop-types'
// 這是一個對外提供滑鼠位置的 render props 元件
class Mouse extends React.Component {
state = { x: 0, y: 0 }
handleMouseMove = (event) => {
this.setState({
x: event.clientX,
y: event.clientY
})
}
render() {
return (
<div style={{ height: '100%' }} onMouseMove={this.handleMouseMove}>
// 這裡把 children 當做函數執行,來對外提供子元件内部的 state
{this.props.children(this.state)}
</div>
)
}
}
class App extends React.Component {
render() {
return (
<div style={{ height: '100%' }}>
// 這裡就很像 Vue 的 作用域插槽
<Mouse>
({ x, y }) => (
// render prop 給了我們所需要的 state 來渲染我們想要的
<h1>The mouse position is ({x}, {y})</h1>
)
</Mouse>
</div>
)
}
})
ReactDOM.render(<App/>, document.getElementById('app'))
複制代碼
複制
原了解析
初始化
對于這樣的一個例子來說
<test>
<template v-slot:bar>
<span>Hello</span>
</template>
<template v-slot:foo="prop">
<span>{{prop.msg}}</span>
</template>
</test>
複制代碼
複制
這段模闆會被編譯成這樣:
with (this) {
return _c("test", {
scopedSlots: _u([
{
key: "bar",
fn: function () {
return [_c("span", [_v("Hello")])];
},
},
{
key: "foo",
fn: function (prop) {
return [_c("span", [_v(_s(prop.msg))])];
},
},
]),
});
}
複制代碼
複制
然後經過初始化時的一系列處理(
resolveScopedSlots
,
normalizeScopedSlots
)
test
元件的執行個體
this.$slotScopes
就可以通路到這兩個
foo
、
bar
函數。(如果未命名的話,
key
會是
default
。)
進入
test
元件内部,假設它是這樣定義的:
<div>
<slot name="bar"></slot>
<slot name="foo" v-bind="{ msg }"></slot>
</div>
<script>
new Vue({
name: "test",
data() {
return {
msg: "World",
};
},
mounted() {
// 一秒後更新
setTimeout(() => {
this.msg = "Changed";
}, 1000);
},
});
</script>
複制代碼
複制
那麼
template
就會被編譯為這樣的函數:
with (this) {
return _c("div", [_t("bar"), _t("foo", null, null, { msg })], 2);
}
複制代碼
複制
已經有那麼些端倪了,接下來就研究一下
_t
函數的實作,就可以接近真相了。
_t
也就是
renderSlot
的别名,簡化後的實作是這樣的:
export function renderSlot (
name: string,
fallback: ?Array<VNode>,
props: ?Object,
bindObject: ?Object
): ?Array<VNode> {
// 通過 name 拿到函數
const scopedSlotFn = this.$scopedSlots[name]
let nodes
if (scopedSlotFn) { // scoped slot
props = props || {}
// 執行函數傳回 vnode
nodes = scopedSlotFn(props) || fallback
}
return nodes
}
複制代碼
複制
其實很簡單,
如果是
普通插槽
,就直接調用函數生成
vnode
,如果是
作用域插槽
,
就直接帶着
props
也就是
{ msg }
去調用函數生成
vnode
。 2.6 版本後統一為函數的插槽降低了很多心智負擔。
更新
在上面的
test
元件中, 1s 後我們通過
this.msg = "Changed";
觸發響應式更新,此時編譯後的
render
函數:
with (this) {
return _c("div", [_t("bar"), _t("foo", null, null, { msg })], 2);
}
複制代碼
複制
重新執行,此時的
msg
已經是更新後的
Changed
了,自然也就實作了更新。
一種特殊情況是,在父元件的作用于裡也使用了響應式的屬性并更新,比如這樣:
<test>
<template v-slot:bar>
<span>Hello</span>
</template>
<template v-slot:foo="prop">
<span>{{prop.msg}}</span>
</template>
</test>
<script>
new Vue({
name: "App",
el: "#app",
mounted() {
setTimeout(() => {
this.msgInParent = "Changed";
}, 1000);
},
data() {
return {
msgInParent: "msgInParent",
};
},
components: {
test: {
name: "test",
data() {
return {
msg: "World",
};
},
template: `
<div>
<slot name="bar"></slot>
<slot name="foo" v-bind="{ msg }"></slot>
</div>
`,
},
},
});
</script>
複制代碼
複制
其實,是因為執行
_t
函數時,全局的元件渲染上下文是
子元件
,那麼依賴收集自然也就是收集到
子元件
的依賴了。是以在
msgInParent
更新後,其實是直接去觸發子元件的重新渲染的,對比 2.5 的版本,這是一個優化。
那麼還有一些額外的情況,比如說
template
上有
v-if
、
v-for
這種情況,舉個例子來說:
<test>
<template v-slot:bar v-if="show">
<span>Hello</span>
</template>
</test>
複制代碼
複制
function render() {
with(this) {
return _c('test', {
scopedSlots: _u([(show) ? {
key: "bar",
fn: function () {
return [_c('span', [_v("Hello")])]
},
proxy: true
} : null], null, true)
})
}
}
複制代碼
複制
注意這裡的
_u
内部直接是一個三元表達式,讀取
_u
是發生在父元件的
_render
中,那麼此時子元件是收集不到這個
show
的依賴的,是以說
show
的更新隻會觸發父元件的更新,那這種情況下子元件是怎麼重新執行
$scopedSlot
函數并重渲染的呢?
我們已經有了一定的前置知識:Vue的更新粒度,知道
Vue
的元件不是
遞歸更新
的,但是
slotScopes
的函數執行是發生在子元件内的,父元件在更新的時候一定是有某種方式去通知子元件也進行更新。
其實這個過程就發生在父元件的重渲染的
patchVnode
中,到了
test
元件的
patch
過程,進入了
updateChildComponent
這個函數後,會去檢查它的
slot
是否是
穩定
的,顯然
v-if
控制的
slot
是非常不穩定的。
const newScopedSlots = parentVnode.data.scopedSlots
const oldScopedSlots = vm.$scopedSlots
const hasDynamicScopedSlot = !!(
(newScopedSlots && !newScopedSlots.$stable) ||
(oldScopedSlots !== emptyObject && !oldScopedSlots.$stable) ||
(newScopedSlots && vm.$scopedSlots.$key !== newScopedSlots.$key)
)
// Any static slot children from the parent may have changed during parent's
// update. Dynamic scoped slots may also have changed. In such cases, a forced
// update is necessary to ensure correctness.
const needsForceUpdate = !!hasDynamicScopedSlot
if (needsForceUpdate) {
// 這裡的 vm 對應 test 也就是子元件的執行個體,相當于觸發了子元件強制渲染。
vm.$forceUpdate()
}
複制代碼
複制
這裡有一些優化措施,并不是說隻要有
slotScope
就會去觸發子元件強制更新。
有如下三種情況會強制觸發子元件更新:
-
上的scopedSlots
屬性為$stable
false
一路追尋這個邏輯,最終發現這個
$stable
是
_u
也就是
resolveScopedSlots
函數的第三個參數決定的,由于這個
_u
是由編譯器生成
render
函數時生成的的,那麼就到
codegen
的邏輯中去看:
let needsForceUpdate = el.for || Object.keys(slots).some(key => {
const slot = slots[key]
return (
slot.slotTargetDynamic ||
slot.if ||
slot.for ||
containsSlotChild(slot) // is passing down slot from parent which may be dynamic
)
})
複制代碼
複制
簡單來說,就是用到了一些動态文法的情況下,就會通知子元件對這段
scopedSlots
進行強制更新。
- 也是
屬性相關,舊的$stable
不穩定scopedSlots
這個很好了解,舊的
scopedSlots
需要強制更新,那麼渲染後一定要強制更新。
- 舊的
不等于新的$key
$key
這個邏輯比較有意思,一路追回去看
$key
的生成,可以看到是
_u
的第四個參數
contentHashKey
,這個
contentHashKey
是在
codegen
的時候利用
hash
算法對生成代碼的字元串進行計算得到的,也就是說,這串函數的生成的
字元串
改變了,就需要強制更新子元件。
function hash(str) {
let hash = 5381
let i = str.length
while(i) {
hash = (hash * 33) ^ str.charCodeAt(--i)
}
return hash >>> 0
}
複制代碼
複制
總結
Vue 2.6 版本後對
slot
和
slot-scope
做了一次統一的整合,讓它們全部都變為函數的形式,所有的插槽都可以在
this.$slotScopes
上直接通路,這讓我們在開發進階元件的時候變得更加友善。
在優化上,Vue 2.6 也盡可能的讓
slot
的更新不觸發父元件的渲染,通過一系列巧妙的判斷和算法去盡可能避免不必要的渲染。(在 2.5 的版本中,由于生成
slot
的作用域是在父元件中,是以明明是子元件的插槽
slot
的更新是會帶着父元件一起更新的)
之前聽尤大的演講,Vue3 會更多的利用模闆的靜态特性做更多的
預編譯優化
,在文中生成代碼的過程中我們已經感受到了他為此付出努力,非常期待 Vue3 帶來的更加強悍的性能。