6.4. slot

Vue 的组件提供了一个非常有用的特性 —— slot 插槽,它让组件的实现变的更加灵活。我们平时在开发组件库的时候,为了让组件更加灵活可定制,经常用插槽的方式让用户可以自定义内容。插槽分为普通插槽和作用域插槽,它们可以解决不同的场景,但它是怎么实现的呢,下面我们就从源码的角度来分析插槽的实现原理。

普通插槽

为了更加直观,我们还是通过一个例子来分析插槽的实现:

let AppLayout = {
  template: '<div class="container">' +
  '<header><slot name="header"></slot></header>' +
  '<main><slot>默认内容</slot></main>' +
  '<footer><slot name="footer"></slot></footer>' +
  '</div>'
}
let vm = new Vue({
  el: '#app',
  template: '<div>' +
  '<app-layout>' +
  '<h1 slot="header">{{title}}</h1>' +
  '<p>{{msg}}</p>' +
  '<p slot="footer">{{desc}}</p>' +
  '</app-layout>' +
  '</div>',
  data() {
    return {
      title: '我是标题',
      msg: '我是内容',
      desc: '其它信息'
    }
  },
  components: {
    AppLayout
  }
})

这里我们定义了 AppLayout 子组件,它内部定义了 3 个插槽,2 个为具名插槽,一个 nameheader,一个 namefooter,还有一个没有定义 name 的是默认插槽。 之前填写的内容为默认内容。我们的父组件注册和引用了 AppLayout 的组件,并在组件内部定义了一些元素,用来替换插槽,那么它最终生成的 DOM 如下:

<div>
  <div class="container">
    <header><h1>我是标题</h1></header>
    <main><p>我是内容</p></main>
    <footer><p>其它信息</p></footer>
  </div>
</div>

编译

还是先从编译说起,我们知道编译是发生在调用 vm.$mount 的时候,所以编译的顺序是先编译父组件,再编译子组件。

首先编译父组件,在 parse 阶段,会执行 processSlot 处理 slot,它的定义在 src/compiler/parser/index.js 中:

function processSlot (el) {
  if (el.tag === 'slot') {
    el.slotName = getBindingAttr(el, 'name')
    if (process.env.NODE_ENV !== 'production' && el.key) {
      warn(
        `\`key\` does not work on <slot> because slots are abstract outlets ` +
        `and can possibly expand into multiple elements. ` +
        `Use the key on a wrapping element instead.`
      )
    }
  } else {
    let slotScope
    if (el.tag === 'template') {
      slotScope = getAndRemoveAttr(el, 'scope')
      /* istanbul ignore if */
      if (process.env.NODE_ENV !== 'production' && slotScope) {
        warn(
          `the "scope" attribute for scoped slots have been deprecated and ` +
          `replaced by "slot-scope" since 2.5. The new "slot-scope" attribute ` +
          `can also be used on plain elements in addition to <template> to ` +
          `denote scoped slots.`,
          true
        )
      }
      el.slotScope = slotScope || getAndRemoveAttr(el, 'slot-scope')
    } else if ((slotScope = getAndRemoveAttr(el, 'slot-scope'))) {
      /* istanbul ignore if */
      if (process.env.NODE_ENV !== 'production' && el.attrsMap['v-for']) {
        warn(
          `Ambiguous combined usage of slot-scope and v-for on <${el.tag}> ` +
          `(v-for takes higher priority). Use a wrapper <template> for the ` +
          `scoped slot to make it clearer.`,
          true
        )
      }
      el.slotScope = slotScope
    }
    const slotTarget = getBindingAttr(el, 'slot')
    if (slotTarget) {
      el.slotTarget = slotTarget === '""' ? '"default"' : slotTarget
      // preserve slot as an attribute for native shadow DOM compat
      // only for non-scoped slots.
      if (el.tag !== 'template' && !el.slotScope) {
        addAttr(el, 'slot', slotTarget)
      }
    }
  }
}

当解析到标签上有 slot 属性的时候,会给对应的 AST元素节点添加 slotTarget 属性,然后在 codegen 阶段,在 genData 中会处理 slotTarget,相关代码在 src/compiler/codegen/index.js 中:

if (el.slotTarget && !el.slotScope) {
  data += `slot:${el.slotTarget},`
}

会给 data 添加一个 slot 属性,并指向 slotTarget,之后会用到。在我们的例子中,父组件最终生成的代码如下:

with(this){
  return _c('div',
    [_c('app-layout',
      [_c('h1',{attrs:{"slot":"header"},slot:"header"},
         [_v(_s(title))]),
       _c('p',[_v(_s(msg))]),
       _c('p',{attrs:{"slot":"footer"},slot:"footer"},
         [_v(_s(desc))]
         )
       ])
     ],
   1)}

接下来编译子组件,同样在 parser 阶段会执行 processSlot 处理函数,它的定义在 src/compiler/parser/index.js 中:

function processSlot (el) {
  if (el.tag === 'slot') {
    el.slotName = getBindingAttr(el, 'name')
  }
  // ...
}

当遇到 slot 标签的时候会给对应的 AST 元素节点添加 slotName 属性,然后在 codegen 阶段,会判断如果当前 AST 元素节点是 slot 标签,则执行 genSlot 函数,它的定义在 src/compiler/codegen/index.js 中:

function genSlot (el: ASTElement, state: CodegenState): string {
  const slotName = el.slotName || '"default"'
  const children = genChildren(el, state)
  let res = `_t(${slotName}${children ? `,${children}` : ''}`
  const attrs = el.attrs && `{${el.attrs.map(a => `${camelize(a.name)}:${a.value}`).join(',')}}`
  const bind = el.attrsMap['v-bind']
  if ((attrs || bind) && !children) {
    res += `,null`
  }
  if (attrs) {
    res += `,${attrs}`
  }
  if (bind) {
    res += `${attrs ? '' : ',null'},${bind}`
  }
  return res + ')'
}

我们先不考虑 slot 标签上有 attrs 以及 v-bind 的情况,那么它生成的代码实际上就只有:

const slotName = el.slotName || '"default"'
const children = genChildren(el, state)
let res = `_t(${slotName}${children ? `,${children}` : ''}`

这里的 slotName 从 AST 元素节点对应的属性上取,默认是 default,而 children 对应的就是 slot 开始和闭合标签包裹的内容。来看一下我们例子的子组件最终生成的代码,如下:

with(this) {
  return _c('div',{
    staticClass:"container"
    },[
      _c('header',[_t("header")],2),
      _c('main',[_t("default",[_v("默认内容")])],2),
      _c('footer',[_t("footer")],2)
      ]
   )
}

在编译章节我们了解到,_t 函数对应的就是 renderSlot 方法,它的定义在 src/core/instance/render-heplpers/render-slot.js 中:

/**
 * Runtime helper for rendering <slot>
 */
export function renderSlot (
  name: string,
  fallback: ?Array<VNode>,
  props: ?Object,
  bindObject: ?Object
): ?Array<VNode> {
  const scopedSlotFn = this.$scopedSlots[name]
  let nodes
  if (scopedSlotFn) { // scoped slot
    props = props || {}
    if (bindObject) {
      if (process.env.NODE_ENV !== 'production' && !isObject(bindObject)) {
        warn(
          'slot v-bind without argument expects an Object',
          this
        )
      }
      props = extend(extend({}, bindObject), props)
    }
    nodes = scopedSlotFn(props) || fallback
  } else {
    const slotNodes = this.$slots[name]
    // warn duplicate slot usage
    if (slotNodes) {
      if (process.env.NODE_ENV !== 'production' && slotNodes._rendered) {
        warn(
          `Duplicate presence of slot "${name}" found in the same render tree ` +
          `- this will likely cause render errors.`,
          this
        )
      }
      slotNodes._rendered = true
    }
    nodes = slotNodes || fallback
  }
  const target = props && props.slot
  if (target) {
    return this.$createElement('template', { slot: target }, nodes)
  } else {
    return nodes
  }
}

render-slot 的参数 name 代表插槽名称 slotNamefallback 代表插槽的默认内容生成的 vnode 数组。先忽略 scoped-slot,只看默认插槽逻辑。如果 this.$slot[name] 有值,就返回它对应的 vnode 数组,否则返回 fallback。那么这个 this.$slot 是哪里来的呢?我们知道子组件的 init 时机是在父组件执行 patch 过程的时候,那这个时候父组件已经编译完成了。并且子组件在 init 过程中会执行 initRender 函数,initRender 的时候获取到 vm.$slot,相关代码在 src/core/instance/render.js 中:

export function initRender (vm: Component) {
  // ...
  const parentVnode = vm.$vnode = options._parentVnode // the placeholder node in parent tree
  const renderContext = parentVnode && parentVnode.context
  vm.$slots = resolveSlots(options._renderChildren, renderContext)
}

vm.$slots 是通过执行 resolveSlots(options._renderChildren, renderContext) 返回的,它的定义在 src/core/instance/render-helpers/resolve-slots.js 中:

/**
 * Runtime helper for resolving raw children VNodes into a slot object.
 */
export function resolveSlots (
  children: ?Array<VNode>,
  context: ?Component
): { [key: string]: Array<VNode> } {
  const slots = {}
  if (!children) {
    return slots
  }
  for (let i = 0, l = children.length; i < l; i++) {
    const child = children[i]
    const data = child.data
    // remove slot attribute if the node is resolved as a Vue slot node
    if (data && data.attrs && data.attrs.slot) {
      delete data.attrs.slot
    }
    // named slots should only be respected if the vnode was rendered in the
    // same context.
    if ((child.context === context || child.fnContext === context) &&
      data && data.slot != null
    ) {
      const name = data.slot
      const slot = (slots[name] || (slots[name] = []))
      if (child.tag === 'template') {
        slot.push.apply(slot, child.children || [])
      } else {
        slot.push(child)
      }
    } else {
      (slots.default || (slots.default = [])).push(child)
    }
  }
  // ignore slots that contains only whitespace
  for (const name in slots) {
    if (slots[name].every(isWhitespace)) {
      delete slots[name]
    }
  }
  return slots
}

resolveSlots 方法接收 2 个参数,第一个参数 chilren 对应的是父 vnodechildren,在我们的例子中就是 包裹的内容。第二个参数 context 是父 vnode 的上下文,也就是父组件的 vm 实例。

resolveSlots 函数的逻辑就是遍历 chilren,拿到每一个 childdata,然后通过 data.slot 获取到插槽名称,这个 slot 就是我们之前编译父组件在 codegen 阶段设置的 data.slot。接着以插槽名称为 keychild 添加到 slots 中,如果 data.slot 不存在,则是默认插槽的内容,则把对应的 child 添加到 slots.defaults 中。这样就获取到整个 slots,它是一个对象,key 是插槽名称,value 是一个 vnode 类型的数组,因为它可以有多个同名插槽。

这样我们就拿到了 vm.$slots 了,回到 renderSlot 函数,const slotNodes = this.$slots[name],我们也就能根据插槽名称获取到对应的 vnode 数组了,这个数组里的 vnode 都是在父组件创建的,这样就实现了在父组替换子组件插槽的内容了。

对应的 slot 渲染成 vnodes,作为当前组件渲染 vnodechildren,之后的渲染过程之前分析过,不再赘述。

我们知道在普通插槽中,父组件应用到子组件插槽里的数据都是绑定到父组件的,因为它渲染成 vnode 的时机的上下文是父组件的实例。但是在一些实际开发中,我们想通过子组件的一些数据来决定父组件实现插槽的逻辑,Vue 提供了另一种插槽——作用域插槽,接下来我们就来分析一下它的实现原理。

作用域插槽

为了更加直观,我们也是通过一个例子来分析作用域插槽的实现:

let Child = {
  template: '<div class="child">' +
  '<slot text="Hello " :msg="msg"></slot>' +
  '</div>',
  data() {
    return {
      msg: 'Vue'
    }
  }
}
let vm = new Vue({
  el: '#app',
  template: '<div>' +
  '<child>' +
  '<template slot-scope="props">' +
  '<p>Hello from parent</p>' +
  '<p>{{ props.text + props.msg}}</p>' +
  '</template>' +
  '</child>' +
  '</div>',
  components: {
    Child
  }
})

最终生成的 DOM 结构如下:

<div>
  <div class="child">
    <p>Hello from parent</p>
    <p>Hello Vue</p>
  </div>
</div>

我们可以看到子组件的 slot 标签多了 text 属性,以及 :msg 属性。父组件实现插槽的部分多了一个 template 标签,以及 scope-slot 属性,其实在 Vue 2.5+ 版本,scoped-slot 可以作用在普通元素上。这些就是作用域插槽和普通插槽在写法上的差别。

在编译阶段,仍然是先编译父组件,同样是通过 processSlot 函数去处理 scoped-slot,它的定义在在 src/compiler/parser/index.js 中:

function processSlot (el) {
  // ...
  let slotScope
  if (el.tag === 'template') {
    slotScope = getAndRemoveAttr(el, 'scope')
    /* istanbul ignore if */
    if (process.env.NODE_ENV !== 'production' && slotScope) {
      warn(
        `the "scope" attribute for scoped slots have been deprecated and ` +
        `replaced by "slot-scope" since 2.5. The new "slot-scope" attribute ` +
        `can also be used on plain elements in addition to <template> to ` +
        `denote scoped slots.`,
        true
      )
    }
    el.slotScope = slotScope || getAndRemoveAttr(el, 'slot-scope')
  } else if ((slotScope = getAndRemoveAttr(el, 'slot-scope'))) {
    /* istanbul ignore if */
    if (process.env.NODE_ENV !== 'production' && el.attrsMap['v-for']) {
      warn(
        `Ambiguous combined usage of slot-scope and v-for on <${el.tag}> ` +
        `(v-for takes higher priority). Use a wrapper <template> for the ` +
        `scoped slot to make it clearer.`,
        true
      )
    }
    el.slotScope = slotScope
  }
  // ...
} 

这块逻辑很简单,读取 scoped-slot 属性并赋值给当前 AST 元素节点的 slotScope 属性,接下来在构造 AST 树的时候,会执行以下逻辑:

if (element.elseif || element.else) {
  processIfConditions(element, currentParent)
} else if (element.slotScope) { 
  currentParent.plain = false
  const name = element.slotTarget || '"default"'
  ;(currentParent.scopedSlots || (currentParent.scopedSlots = {}))[name] = element
} else {
  currentParent.children.push(element)
  element.parent = currentParent
}

可以看到对于拥有 scopedSlot 属性的 AST 元素节点而言,是不会作为 children 添加到当前 AST 树中,而是存到父 AST 元素节点的 scopedSlots 属性上,它是一个对象,以插槽名称 namekey

然后在 genData 的过程,会对 scopedSlots 做处理:

if (el.scopedSlots) {
  data += `${genScopedSlots(el.scopedSlots, state)},`
}
function genScopedSlots (
  slots: { [key: string]: ASTElement },
  state: CodegenState
): string {
  return `scopedSlots:_u([${
    Object.keys(slots).map(key => {
      return genScopedSlot(key, slots[key], state)
    }).join(',')
  }])`
}
function genScopedSlot (
  key: string,
  el: ASTElement,
  state: CodegenState
): string {
  if (el.for && !el.forProcessed) {
    return genForScopedSlot(key, el, state)
  }
  const fn = `function(${String(el.slotScope)}){` +
    `return ${el.tag === 'template'
      ? el.if
        ? `${el.if}?${genChildren(el, state) || 'undefined'}:undefined`
        : genChildren(el, state) || 'undefined'
      : genElement(el, state)
    }}`
  return `{key:${key},fn:${fn}}`
}

genScopedSlots 就是对 scopedSlots 对象遍历,执行 genScopedSlot,并把结果用逗号拼接,而 genScopedSlot 是先生成一段函数代码,并且函数的参数就是我们的 slotScope,也就是写在标签属性上的 scoped-slot 对应的值,然后再返回一个对象,key 为插槽名称,fn 为生成的函数代码。

对于我们这个例子而言,父组件最终生成的代码如下:

with(this){
  return _c('div',
    [_c('child',
      {scopedSlots:_u([
        {
          key: "default",
          fn: function(props) {
            return [
              _c('p',[_v("Hello from parent")]),
              _c('p',[_v(_s(props.text + props.msg))])
            ]
          }
        }])
      }
    )],
  1)
}

可以看到它和普通插槽父组件编译结果的一个很明显的区别就是没有 children 了,data 部分多了一个对象,并且执行了 _u 方法,在编译章节我们了解到,_u 函数对的就是 resolveScopedSlots 方法,它的定义在 src/core/instance/render-heplpers/resolve-slots.js 中:

export function resolveScopedSlots (
  fns: ScopedSlotsData, // see flow/vnode
  res?: Object
): { [key: string]: Function } {
  res = res || {}
  for (let i = 0; i < fns.length; i++) {
    if (Array.isArray(fns[i])) {
      resolveScopedSlots(fns[i], res)
    } else {
      res[fns[i].key] = fns[i].fn
    }
  }
  return res
}

其中,fns 是一个数组,每一个数组元素都有一个 key 和一个 fnkey 对应的是插槽的名称,fn 对应一个函数。整个逻辑就是遍历这个 fns 数组,生成一个对象,对象的 key 就是插槽名称,value 就是函数。这个函数的执行时机稍后我们会介绍。

接着我们再来看一下子组件的编译,和普通插槽的过程基本相同,唯一一点区别是在 genSlot 的时候:

function genSlot (el: ASTElement, state: CodegenState): string {
  const slotName = el.slotName || '"default"'
  const children = genChildren(el, state)
  let res = `_t(${slotName}${children ? `,${children}` : ''}`
  const attrs = el.attrs && `{${el.attrs.map(a => `${camelize(a.name)}:${a.value}`).join(',')}}`
  const bind = el.attrsMap['v-bind']
  if ((attrs || bind) && !children) {
    res += `,null`
  }
  if (attrs) {
    res += `,${attrs}`
  }
  if (bind) {
    res += `${attrs ? '' : ',null'},${bind}`
  }
  return res + ')'
}

它会对 attrsv-bind 做处理,对应到我们的例子,最终生成的代码如下:

with(this){
  return _c('div',
    {staticClass:"child"},
    [_t("default",null,
      {text:"Hello ",msg:msg}
    )],
  2)}

_t 方法我们之前介绍过,对应的是 renderSlot 方法:

export function renderSlot (
  name: string,
  fallback: ?Array<VNode>,
  props: ?Object,
  bindObject: ?Object
): ?Array<VNode> {
  const scopedSlotFn = this.$scopedSlots[name]
  let nodes
  if (scopedSlotFn) {
    props = props || {}
    if (bindObject) {
      if (process.env.NODE_ENV !== 'production' && !isObject(bindObject)) {
        warn(
          'slot v-bind without argument expects an Object',
          this
        )
      }
      props = extend(extend({}, bindObject), props)
    }
    nodes = scopedSlotFn(props) || fallback
  } else {
    // ...
  }
  const target = props && props.slot
  if (target) {
    return this.$createElement('template', { slot: target }, nodes)
  } else {
    return nodes
  }
}

我们只关注作用域插槽的逻辑,那么这个 this.$scopedSlots 又是在什么地方定义的呢,原来在子组件的渲染函数执行前,在 vm_render 方法内,有这么一段逻辑,定义在 src/core/instance/render.js 中:

 if (_parentVnode) {
  vm.$scopedSlots = _parentVnode.data.scopedSlots || emptyObject
}

这个 _parentVNode.data.scopedSlots 对应的就是我们在父组件通过执行 resolveScopedSlots 返回的对象。所以回到 genSlot 函数,我们就可以通过插槽的名称拿到对应的 scopedSlotFn,然后把相关的数据扩展到 props 上,作为函数的参数传入,原来之前我们提到的函数这个时候执行,然后返回生成的 vnodes,为后续渲染节点用。

后续流程之前已介绍过,不再赘述,那么至此,作用域插槽的实现也就分析完毕。

总结

通过这一章的分析,我们了解了普通插槽和作用域插槽的实现。它们有一个很大的差别是数据作用域,普通插槽是在父组件编译和渲染阶段生成 vnodes,所以数据的作用域是父组件实例,子组件渲染的时候直接拿到这些渲染好的 vnodes。而对于作用域插槽,父组件在编译和渲染阶段并不会直接生成 vnodes,而是在父节点 vnodedata 中保留一个 scopedSlots 对象,存储着不同名称的插槽以及它们对应的渲染函数,只有在编译和渲染子组件阶段才会执行这个渲染函数生成 vnodes,由于是在子组件环境执行的,所以对应的数据作用域是子组件实例。

简单地说,两种插槽的目的都是让子组件 slot 占位符生成的内容由父组件来决定,但数据的作用域会根据它们 vnodes 渲染时机不同而不同。

下一节:在我们的平时开发工作中,经常为了组件的缓存优化而使用 <keep-alive> 组件,乐此不疲,但很少有人关注它的实现原理,下面就让我们来一探究竟。