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

晨曦時夢見兮發表於2020-04-06

前言

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))])];
        },
      },
    ]),
  });
}
複製程式碼

然後 test 元件的例項 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>
複製程式碼

我們已經有了一定的前置知識,知道 Vue 的元件不是遞迴更新的,但是 slotScopes 的函式執行是發生在子元件內的,父元件在更新的時候一定是有某種方式去通知子元件也進行更新。

沒錯,這個通知就發生在 updateChildComponent 裡:

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

相關文章