請你說說 Vue 中 slot 和 slot-scope 的原理(2.6.11 深度解析)
- 2020 年 4 月 11 日
- 筆記
前言
Vue 中的 slot
和 slot-scope
一直是一個進階的概念,對於我們的日常的組件開發中不常接觸,但是卻非常強大和靈活。
在 Vue 2.6 中
slot
和slot-scope
在組件內部被統一整合成了函數
- 他們的渲染作用域都是
子組件
- 並且都能通過
this.$slotScopes
去訪問
這使得這種模式的開發體驗變的更為統一,本篇文章就基於 2.6.11
的最新程式碼來解析它的原理。
對於 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 帶來的更加強悍的性能。