前言

Vue 中的slot和slot-scope一直是一个进阶的概念,对于我们的日常的组件开发中不常接触,但是却非常强大和灵活。

在 Vue 2.6 中

slot和slot-scope在组件内部被统一整合成了函数

他们的渲染作用域都是子组件

并且都能通过this.$slotScopes去访问

这使得这种模式的开发体验变的更为统一,本篇文章就基于2.6.11的最新代码来解析它的原理。

对于 2.6 版本更新的插槽语法,如果你还不太了解,可以看看这篇尤大的官宣

Vue 2.6 发布了[1]

举个简单的例子,社区有个异步流程管理的库: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 = { x0y0 }

  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",
        fnfunction ({
          return [_c("span", [_v("Hello")])];
        },
      },
      {
        key"foo",
        fnfunction (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"nullnull, { msg })], 2);
}

已经有那么些端倪了,接下来就研究一下_t函数的实现,就可以接近真相了。

_t也就是renderSlot的别名,简化后的实现是这样的:

export function renderSlot (
  name: string,
  fallback: ?Array,
  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"nullnull, { 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`
          

            
            
          

        `
,
      },
    },
  });
</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",
        fnfunction ({
          return [_c('span', [_v("Hello")])]
        },
        proxytrue
      } : null], nulltrue)
    })
  }
}

注意这里的_u内部直接是一个三元表达式,读取_u是发生在父组件的_render中,那么此时子组件是收集不到这个show的依赖的,所以说show的更新只会触发父组件的更新,那这种情况下子组件是怎么重新执行$scopedSlot函数并重渲染的呢?

我们已经有了一定的前置知识:Vue的更新粒度[2],知道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 带来的更加强悍的性能。

参考资料

[1]

Vue 2.6 发布了:

[2]

Vue的更新粒度:#heading-3

限 时 特 惠: 本站每日持续更新海量各大内部创业教程,一年会员只需98元,全站资源免费下载 点击查看详情
站 长 微 信: lzxmw777

发表回复

您的电子邮箱地址不会被公开。 必填项已用*标注