天天看點

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

前言

Vue 中的

slot

slot-scope

一直是一個進階的概念,對于我們的日常的元件開發中不常接觸,但是卻非常強大和靈活。

在 Vue 2.6 中

  1. slot

    slot-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

,并且響應式的對外抛出

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

就會去觸發子元件強制更新。

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

  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 版本後對

slot

slot-scope

做了一次統一的整合,讓它們全部都變為函數的形式,所有的插槽都可以在

this.$slotScopes

上直接通路,這讓我們在開發進階元件的時候變得更加友善。

在優化上,Vue 2.6 也盡可能的讓

slot

的更新不觸發父元件的渲染,通過一系列巧妙的判斷和算法去盡可能避免不必要的渲染。(在 2.5 的版本中,由于生成

slot

的作用域是在父元件中,是以明明是子元件的插槽

slot

的更新是會帶着父元件一起更新的)

之前聽尤大的演講,Vue3 會更多的利用模闆的靜态特性做更多的

預編譯優化

,在文中生成代碼的過程中我們已經感受到了他為此付出努力,非常期待 Vue3 帶來的更加強悍的性能。

❤️感謝大家