Skip to content
On this page

插槽 slot

Vue允许我们为组件自定义子模版,这部分内容会替换组件模版中slot标签,这就是插槽。那么子组件在渲染过程中是怎么获取到父组件对应的插槽模版的,现在就通过源码来分析。

普通插槽

来看一个普通插槽的例子:

js
import Vue from 'vue';

const Child = {
  template:
    '<div class="container">' +
    '<header><slot name="header"></slot></header>' +
    '<main><slot>默认内容</slot></main>' +
    '<footer><slot name="footer"></slot></footer>' +
    '</div>'
};

new Vue({
  el: '#app',
  template:
    '<div>' +
    '<Child>' +
    '<h1 slot="header">{{title}}</h1>' +
    '<p>{{msg}}</p>' +
    '<p slot="footer">{{desc}}</p>' +
    '</Child>' +
    '</div>',
  data() {
    return {
      title: '我是标题',
      msg: '我是内容',
      desc: '其它信息'
    };
  },
  components: { Child }
});

在看源码前,带着几个疑问:

  • 在编译阶段是怎么解析父组件的slot属性和子组件的slot标签
  • 创建slot虚拟节点的代码是怎么样的
  • 在运行时,子组件生成slot的虚拟节点是怎么获取到父组件对应的插槽模版

父组件渲染函数

在父组件的编译解析阶段,会在src/compiler/parser/index.jsprocessSlotContent方法解析带slot属性的标签。对于我们例子会命中该方法的下面逻辑:

js
// slot="xxx"
const slotTarget = getBindingAttr(el, 'slot')
if (slotTarget) {
  el.slotTarget = slotTarget === '""' ? '"default"' : slotTarget
  el.slotTargetDynamic = !!(el.attrsMap[':slot'] || el.attrsMap['v-bind:slot'])
  // 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, getRawBindingAttr(el, 'slot'))
  }
}

这个方法获取属性slot对应的值slotTarget,然后在对应ast的节点上增加slotTarget属性,并在attrs属性集合上增加对象{name: 'slot', value: slotTarget}

在代码生成的genData会对slotTarget属性的ast节点进行处理:

js
// only for non-scoped slots
if (el.slotTarget && !el.slotScope) {
  data += `slot:${el.slotTarget},`
}

这个逻辑是在渲染函数代码的data加上slot属性,值就是我们该解析标签获取的slotTarget。所以我们例子的父组件的渲染函数代码为:

js
with (this) {
  return _c(
    'div',
    [
      _c('Child', [
        _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
  );
}

子组件渲染函数

子组件的解析阶段要对slot标签进行处理。在解析入口文件的processSlotOutlet方法中处理,它只是在对应的ast的节点加上slotName属性,值为我们设置的插槽name:

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

在代码生成阶段,如果遇到ast节点的tag是slot的话,会调用genSlot函数进行统一处理:

js
// src/compiler/codegen/index.js

export function genElement (el: ASTElement, state: CodegenState): string {

// ...

else if (el.tag === 'slot') {
  return genSlot(el, state)
}
  
  // ...
}

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.dynamicAttrs
    ? genProps((el.attrs || []).concat(el.dynamicAttrs || []).map(attr => ({
        // slot props are camelized
        name: camelize(attr.name),
        value: attr.value,
        dynamic: attr.dynamic
      })))
    : null
  const bind = el.attrsMap['v-bind']
  if ((attrs || bind) && !children) {
    res += `,null`
  }
  if (attrs) {
    res += `,${attrs}`
  }
  if (bind) {
    res += `${attrs ? '' : ',null'},${bind}`
  }
  return res + ')'
}

这个函数对于我们例子只会执行下面的关键逻辑:

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

其他部分是获取slot标签的属性,这个是作用域插槽的处理,我们稍后再分析。children是插槽的默认内容的渲染代码,所以我们的slot标签的生成代码是使用_t函数包裹。最终,我们来看下子组件的渲染函数代码:

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

运行时阶段

父组件执行render函数和正常一样,在创建组件占位虚拟节点时,组件包裹的每个插槽vnode也会被创建。另外会把children作为占位节点的组件属性:

js
const vnode = new VNode(
  `vue-component-${Ctor.cid}${name ? `-${name}` : ''}`,
  data, undefined, undefined, undefined, context,
  { Ctor, propsData, listeners, tag, children },
  asyncFactory
)

在子组件实例初始化合并配置中,会把组件的占位节点的children属性给实例配置的_renderChildren属性:

js
export function initInternalComponent (vm: Component, options: InternalComponentOptions) {

// ...
 const parentVnode = options._parentVnode
const vnodeComponentOptions = parentVnode.componentOptions
opts._renderChildren = vnodeComponentOptions.children
}

然后执行initRender方法进行渲染的初始化工作,这个方法中会调用resolveSlots方法获取组件实例的vm.$slots的值:

js
vm.$slots = resolveSlots(options._renderChildren, renderContext)

resolveSlots方法定义在src/core/instance/render-helpers/resolve-slots.js中:

js
// 获取组件实例的vm.$slots
export function resolveSlots (
  children: ?Array<VNode>,
  context: ?Component
): { [key: string]: Array<VNode> } {
  if (!children || !children.length) {
    return {}
  }
  const 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
  // 删除空白的slot节点
  for (const name in slots) {
    if (slots[name].every(isWhitespace)) {
      delete slots[name]
    }
  }
  return slots
}

这个方法children是值组件标签包含的虚拟节点,也就是组件实例的_renderChildren属性值。这个方法循环children子节点,获取节点data属性的slot值作为返回结果对象的key,对应的值就是该子节点。所以这个方法就是构造slot名到虚拟节点映射对象,对于我们例子的结果是:

接着子组件挂载并执行自身的render函数,对应slot节点在编译阶段知道它会用_t函数创建。这个函数是Vue虚拟节点的渲染辅助函数之一,它们的定义入口在src/core/instance/render-helpers/index.js:

js
export function installRenderHelpers (target: any) {
  target._o = markOnce
  target._n = toNumber
  target._s = toString
  target._l = renderList
  target._t = renderSlot
  target._q = looseEqual
  target._i = looseIndexOf
  target._m = renderStatic
  target._f = resolveFilter
  target._k = checkKeyCodes
  target._b = bindObjectProps
  target._v = createTextVNode
  target._e = createEmptyVNode
  target._u = resolveScopedSlots
  target._g = bindObjectListeners
  target._d = bindDynamicKeys
  target._p = prependModifier
}

所以_t对应的就是renderSlot函数,在定义在src/core/instance/render-helpers/render-slot.js:

js
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
    // ...
  } else {
    nodes = this.$slots[name] || fallback
  }

  const target = props && props.slot
  if (target) {
    return this.$createElement('template', { slot: target }, nodes)
  } else {
    return nodes
  }
}

对应作用域插槽逻辑不看,它其实是通过this.$slots[name]那到对应slot名的虚拟节点,因为vm.$slots在初始化阶段已经处理。如果拿不到就取fallback,它是插槽节点的默认内容节点。最终,我们子组件就可以拿到对应的父组件插槽模版进行渲染,注意的是,插槽模版的虚拟节点是在父组件渲染完成的,所以模版的状态只能来自父组件实例,这也是和作用域插槽不同的一点。

作用域插槽

同样,先来看一下例子:

js
import Vue from 'vue';

const Child = {
  template: `
    <div class="child">
      <slot text="Hello " :msg="msg"></slot>
    </div>`,
  data() {
    return {
      msg: 'Vue'
    };
  }
};

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 }
});

父组件渲染函数

在编译解析阶段处理slot属性的processSlotContent函数命中下面的逻辑:

js
let slotScope
if (el.tag === 'template') {
  slotScope = getAndRemoveAttr(el, 'scope')
  el.slotScope = slotScope || getAndRemoveAttr(el, 'slot-scope')
} else if ((slotScope = getAndRemoveAttr(el, 'slot-scope'))) {
  el.slotScope = slotScope
}

它会在对应的ast节点增加slotScope属性,值为设置的子组件提供的插槽数据,在我们例子就是props。然后在构造ast树的时候,对于有slotScope属性的节点,会执行下面的逻辑:

js
if (element.slotScope) {
  const name = element.slotTarget || '"default"'
  ;(currentParent.scopedSlots || (currentParent.scopedSlots = {}))[name] = element
}

currentParent表示当前ast节点的父节点。这段代码是在作用域插槽节点的父节点上增加一个scopedSlots对象,这个对象是以插槽名为key,插槽ast节点为值的映射对象。在我们例子中,会把template的ast节点添加到Child节点的scopedSlots对象上:

在代码生成阶段会对拥有scopedSlots属性的节点进行处理:

js
// scoped slots
if (el.scopedSlots) {
  data += `${genScopedSlots(el, el.scopedSlots, state)},`
}

genScopedSlots方法就是对作用域插槽ast节点对象的处理:

js
function genScopedSlots(
  el: ASTElement,
  slots: { [key: string]: ASTElement },
  state: CodegenState
): string { 

  const generatedSlots = Object.keys(slots)
    .map(key => genScopedSlot(slots[key], state))
    .join(',')

  return `scopedSlots:_u([${generatedSlots}])`
}

这个方法对每个具名插槽节点作为参数调用genScopedSlot方法生成代码,并且最后包含在数组里面作为_u的参数。来看下genScopedSlot的定义:

js
unction genScopedSlot (
  el: ASTElement,
  state: CodegenState
): string {

  const slotScope = el.slotScope === emptySlotScopeToken
    ? ``
    : String(el.slotScope)
  const fn = `function(${slotScope}){` +
    `return ${el.tag === 'template'
      ? el.if && isLegacySyntax
        ? `(${el.if})?${genChildren(el, state) || 'undefined'}:undefined`
        : genChildren(el, state) || 'undefined'
      : genElement(el, state)
    }}`

  return `{key:${el.slotTarget || `"default"`},fn:${fn}}`
}

这个方法主要是返回一个对象的代码。该对象的key具名插槽的名称,fn为构造的函数代码,它的参数为我们自定义的获取子组件的数据对象,函数体插槽节点的渲染代码。对于我们例子,最后得到的渲染代码为:

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

可以看出来这个和普通插槽的区别就是组件Child没有了children,而是在data增加了scopedSlots属性。它是每个具名插槽对应的模版获取函数,这个在运行时会用到。

子组件渲染函数

对于作用域插槽子组件的生成代码和普通插槽不同的是它会去处理slot标签上的属性,它们合并成一个对象作为_t函数的第三个参数。最终我们子组件的渲染代码为:

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

运行时阶段

对于父组件在执行render函数时,在创建Child虚拟节点时候会调用_u函数去创建scopedSlots属性的值。该函数定义在src/core/instance/render-helpers/resolve-scoped-slots.jsresolveScopedSlots方法:

js
export function resolveScopedSlots (
  fns: ScopedSlotsData, 
  res?: Object,
  hasDynamicKeys?: boolean,
  contentHashKey?: number
): { [key: string]: Function, $stable: boolean } {
  res = res || { $stable: !hasDynamicKeys }
  for (let i = 0; i < fns.length; i++) {
    const slot = fns[i]
    if (Array.isArray(slot)) {
      resolveScopedSlots(slot, res, hasDynamicKeys)
    } else if (slot) {
      if (slot.proxy) {
        slot.fn.proxy = true
      }
      res[slot.key] = slot.fn
    }
  }
  if (contentHashKey) {
    (res: any).$key = contentHashKey
  }
  return res
}

这个函数把传入的插槽获取函数数据转换成一个映射对象。对象的key为插槽的名称,值为插槽模版获取函数。所以,我们例子的Child组件vnode的scopedSlots属性最终为:

js
{ 
  "default": function(props) {
    return [
      _c('p', [_v('Hello from parent')]),
      _v(' '),
      _c('p', [_v(_s(props.text + props.msg))])
    ];
  }
}

在我们子组件执行render函数之前有下面一点逻辑:

js
// 作用域插槽处理
if (_parentVnode) {
  vm.$scopedSlots = normalizeScopedSlots(
    _parentVnode.data.scopedSlots,
    vm.$slots,
    vm.$scopedSlots
  )
}

这段主要就是把Child组件占位符虚拟节点的scopedSlots最终会赋值到组件实例的$scopedSlots属性上。然后在创建slot虚拟节点的时候执行renderSlot函数会走下面逻辑:

js
const scopedSlotFn = this.$scopedSlots[name]
let nodes
if (scopedSlotFn) { // scoped slot
  props = props || {}
  nodes = scopedSlotFn(props) || fallback
} 

其中props是_t函数的第三个参数,也就是我们例子的{ text: 'Hello ', msg: msg }。因为创建slot节点是在子组件环境,所以对应的msg也能取到正确的值。然后作为参数传给我们插槽模版获取函数scopedSlotFn,最终创建正确的插槽模版vnode。

到现在,我们就在子组件中正确渲染我们插入的作用域模版了。你会发现,父组件提供的插槽模版的vnode最终是在子组件执行创建的,也是因为我们模版中用到了子组件的状态,这是和普通插槽原理的最大区别。

总结

到现在,我们就知道了Vue两种插槽的实现原理。它们两个之间不同的是,普通插槽是在父组件编译和渲染生成好插槽模版vnode,在子组件渲染是直接获取父组件生成好的vnode。作用域插槽在父组件不会生成插槽模版vnode,而是在组件占位vnode上用scopedSlots保存这不同具名插槽的获取模版函数,然后在子组件渲染的时候把prop对象作为参数调用该函数获取正确的插槽模版vnode。

总之,插槽的实现就是要在子组件生成slot的虚拟节点是能够找到正确的模版和数据作用域。