請你說說 Vue 中 slot 和 slot-scope 的原理(2.6.11 深度解析)

  • 2020 年 4 月 11 日
  • 筆記

前言

Vue 中的 slotslot-scope 一直是一個進階的概念,對於我們的日常的組件開發中不常接觸,但是卻非常強大和靈活。

在 Vue 2.6 中

  1. slotslot-scope 在組件內部被統一整合成了 函數
  2. 他們的渲染作用域都是 子組件
  3. 並且都能通過 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,並且響應式的對外拋出 pendingrejected,和非同步執行成功後的數據 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, normalizeScopedSlotstest 組件的實例 this.$slotScopes 就可以訪問到這兩個 foobar 函數。(如果未命名的話,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-ifv-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 就會去觸發子組件強制更新。

有如下三種情況會強制觸發子組件更新:

  1. 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 進行強制更新。

  1. 也是 $stable 屬性相關,舊的 scopedSlots 不穩定

這個很好理解,舊的scopedSlots需要強制更新,那麼渲染後一定要強制更新。

  1. 舊的 $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 版本後對 slotslot-scope 做了一次統一的整合,讓它們全部都變為函數的形式,所有的插槽都可以在 this.$slotScopes 上直接訪問,這讓我們在開發高級組件的時候變得更加方便。

在優化上,Vue 2.6 也儘可能的讓 slot 的更新不觸發父組件的渲染,通過一系列巧妙的判斷和演算法去儘可能避免不必要的渲染。(在 2.5 的版本中,由於生成 slot 的作用域是在父組件中,所以明明是子組件的插槽 slot 的更新是會帶著父組件一起更新的)

之前聽尤大的演講,Vue3 會更多的利用模板的靜態特性做更多的預編譯優化,在文中生成程式碼的過程中我們已經感受到了他為此付出努力,非常期待 Vue3 帶來的更加強悍的性能。

❤️感謝大家