[Vue源码学习] 插槽(上)

531 阅读3分钟

系列文章

前言

Vue中,可以通过插槽实现内容的分发,在2.6.0版本之前,是通过slotslot-scope选项,来区分普通插槽和作用域插槽,它们之间有很大的区别,普通插槽是在父实例执行render的过程中,立即创建插槽对应的VNode,而作用域插槽是在子实例执行render的过程中,通过normalizeScopedSlots方法解析data.scopedSlots创建其对应的VNode,为了统一实现插槽的功能,所以在2.6.0版本之后,Vue提供了v-slot指令,用来统一普通插槽和作用域插槽,现在只要使用v-slot指令,那么其内部的内容,就会自动定义为作用域插槽,也就是说,它们都是在子实例执行render的过程中,才去创建其对应的VNode,那么接下来,就根据一个简单的例子,来看看v-slot是如何实现插槽功能的,示例如下所示:

Vue.component('parent', {
  template: `
<div class="parent">
  <child>
    <template v-slot:header>
      <div>Parent Heaader</div>
    </template>
    <template v-slot:default="slotProps">
      <div>Parent {{slotProps.message}}</div>
    </template>
    <template v-slot:footer>
      <div>Parent Footer</div>
    </template>
  </child>
</div>
    `
})
Vue.component('child', {
  template: `
<div class="child">
  <slot name="header">
    <div>Header</div>
  </slot>
  <slot :message="message">
    <div>Main</div>
  </slot>
  <slot name="footer">
    <div>Footer</div>
  </slot>
</div>
`,
  data() {
    return {
      message: 'Main'
    }
  }
})

v-slot

首先来看看在编译的过程中,Vue是如何处理父模板中定义的v-slot指令的。

在编译的parse阶段,在解析开始标签时,会将标签上的每对属性保存在ASTattrsListattrsMap中,当解析到对应的结束标签时,会调用processElement方法来处理AST上的属性,代码如下所示:

/* compiler/parser/index.js */
function closeElement(element) {
  // ...
  if (!inVPre && !element.processed) {
    element = processElement(element, options)
  }
  // ...
}

export function processElement(
  element: ASTElement,
  options: CompilerOptions
) {
  // ...
  processSlotContent(element)
  // ...
}

对于包含v-slot指令的AST节点来说,它会调用processSlotContent方法做进一步的处理,代码如下所示:

/* compiler/parser/index.js */
export const emptySlotScopeToken = `_empty_`

const slotRE = /^v-slot(:|$)|^#/

function processSlotContent(el) {
  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
  }

  // 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'))
    }
  }

  // 2.6 v-slot syntax
  if (process.env.NEW_SLOT_SYNTAX) {
    if (el.tag === 'template') {
      // v-slot on <template>
      const slotBinding = getAndRemoveAttrByRegex(el, slotRE)
      if (slotBinding) {
        // ...
        const { name, dynamic } = getSlotName(slotBinding)
        el.slotTarget = name
        el.slotTargetDynamic = dynamic
        el.slotScope = slotBinding.value || emptySlotScopeToken // force it into a scoped slot for perf
      }
    } else {
      // v-slot on component, denotes default slot
      const slotBinding = getAndRemoveAttrByRegex(el, slotRE)
      if (slotBinding) {
        // ...
        // add the component's children to its default slot
        const slots = el.scopedSlots || (el.scopedSlots = {})
        const { name, dynamic } = getSlotName(slotBinding)
        const slotContainer = slots[name] = createASTElement('template', [], el)
        slotContainer.slotTarget = name
        slotContainer.slotTargetDynamic = dynamic
        slotContainer.children = el.children.filter((c: any) => {
          if (!c.slotScope) {
            c.parent = slotContainer
            return true
          }
        })
        slotContainer.slotScope = slotBinding.value || emptySlotScopeToken
        // remove children as they are returned from scopedSlots now
        el.children = []
        // mark el non-plain so data gets generated
        el.plain = false
      }
    }
  }
}

function getSlotName(binding) {
  let name = binding.name.replace(slotRE, '')
  if (!name) {
    if (binding.name[0] !== '#') {
      name = 'default'
    } else if (process.env.NODE_ENV !== 'production') {
      warn(
        `v-slot shorthand syntax requires a slot name.`,
        binding
      )
    }
  }
  return dynamicArgRE.test(name)
    // dynamic [name]
    ? { name: name.slice(1, -1), dynamic: true }
    // static name
    : { name: `"${name}"`, dynamic: false }
}

可以看到,在processSlotContent方法中,前面的两段代码是用来兼容2.6.0之前的逻辑,用来处理了scopeslot-scopeslot,之后的逻辑就是对新指令v-slot的处理。因为v-slot指令不仅可以添加到第一级<template>标签上,还可以添加到组件本身,所以Vue使用el.tag === 'template'来判断具体是哪一种情况,来做不同的处理。

  • 如果v-slot指令在<template>标签上,首先通过getAndRemoveAttrByRegex方法从AST.attrsList中获取v-slot对应的数据,如果存在v-slot指令,就通过getSlotName方法,从参数中提取对应的slotTargetslotTargetDynamic,最后通过slotBinding.value取出对应的slotScope,如果v-slot指令中不包含插槽数据slotProps,就使用emptySlotScopeToken代替。例如<template v-slot:default="slotProps"></template>模板,取出的slotTarget就是defaultslotTargetDynamic就是falseslotScope就是slotProps

  • 如果v-slot指令在组件上,这时会创建一个新的AST节点slotContainer,将原先组件中的插槽内容移到slotContainer中,这样就如同前一种情况,在slotContainer上添加slotTargetslotTargetDynamicslotScope,不同的是,在构造完成后,会将slotContainer添加到组件ASTscopedSlots属性中,同时将组件ASTchildren置为空。

通过上面的处理,可以发现,只要使用了v-slot指令,此时的slotScope总是会取得一个值,在没有手动设置值时也会取得默认值emptySlotScopeToken,正是通过这个处理,Vue才可以统一使用作用域插槽。

调用完processElement方法后,此时AST节点上已经添加了slotTargetslotTargetDynamicslotScope等属性,回到最开始的closeElement方法:

/* compiler/parser/index.js */
function closeElement(element) {
  // ...
  if (currentParent && !element.forbidden) {
    if (element.elseif || element.else) {
      // ...
    } else {
      if (element.slotScope) {
        // scoped slot
        // keep it in the children list so that v-else(-if) conditions can
        // find it as the prev node.
        const name = element.slotTarget || '"default"'
          ; (currentParent.scopedSlots || (currentParent.scopedSlots = {}))[name] = element
      }
      currentParent.children.push(element)
      element.parent = currentParent
    }
  }

  // final children cleanup
  // filter out scoped slots
  element.children = element.children.filter(c => !(c: any).slotScope)
  // ...
}

可以看到,当v-slot指令在<template>标签上时,template对应的AST节点会具有slotScope属性,那么就会在父AST节点,也就是组件AST节点的scopedSlots对象上,将当前template节点作为插槽添加进去。当所有的子节点都完成闭合操作后,就会执行父节点的闭合操作,由于插槽在本质上不属于子节点,而且在处理子节点的过程中,已经将插槽添加到父节点的scopedSlots中,所以在父节点闭合时会执行element.children.filter(c => !(c: any).slotScope),将所有的作用域插槽从父节点的children中移除,这样一来,作用域插槽就只能通过父节点的scopedSlots属性进行访问了。

在将模板转换成AST节点后,就会进行编译的generate阶段,用来将AST节点转换成最终的代码,在这个过程中,会调用genData方法,用来处理AST上的数据,当检测到AST上存在scopedSlots属性时,就会调用genScopedSlots方法来处理插槽,代码如下所示:

/* compiler/codegen/index.js */
export function genData(el: ASTElement, state: CodegenState): string {
  // ...
  // scoped slots
  if (el.scopedSlots) {
    data += `${genScopedSlots(el, el.scopedSlots, state)},`
  }
  // ...
}

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}]${
    needsForceUpdate ? `,null,true` : ``
    }${
    !needsForceUpdate && needsKey ? `,null,false,${hash(generatedSlots)}` : ``
    })`
}

function genScopedSlot(
  el: ASTElement,
  state: CodegenState
): string {
  const isLegacySyntax = el.attrsMap['slot-scope']
  // ...
  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)
    }}`
  // reverse proxy v-slot without scope on this.$slots
  const reverseProxy = slotScope ? `` : `,proxy:true`
  return `{key:${el.slotTarget || `"default"`},fn:${fn}${reverseProxy}}`
}

可以看到,在genScopedSlots方法中,就是遍历scopedSlots,然后调用genScopedSlot方法,将生成VNode的代码封装在一个函数中,同时,将slotScope的值作为此函数的第一个形参,所以在2.6.0中,只要使用了v-slot指令,插槽就会成为生成一个函数,也就是作用域插槽的形式,遍历完scopedSlots后,将生成的插槽数组用_u方法包裹,该方法会在运行时中再详细介绍。

经过parsegenerate阶段的处理,Vue已经成功完成对父模板的编译过程,示例代码中对应的渲染函数如下所示:

function anonymous() {
  with (this) {
    return _c('div', {
      staticClass: "parent"
    }, [_c('child', {
      scopedSlots: _u([{
        key: "header",
        fn: function () {
          return [_c('div', [_v("Parent Heaader")])]
        },
        proxy: true
      }, {
        key: "default",
        fn: function (slotProps) {
          return [_c('div', [_v("Parent " + _s(slotProps.message))])]
        }
      }, {
        key: "footer",
        fn: function () {
          return [_c('div', [_v("Parent Footer")])]
        },
        proxy: true
      }])
    })], 1)
  }
}

上面的proxy属性表示虽然这里是作用域插槽的形式,但是在运行时需要将其代理到$slot中。

v-slot指令是在父组件中使用的,接下来就来看看在子组件中,Vue是如何处理<slot>标签的。

slot标签

同样是在编译的parse阶段,在处理结束标签时,会执行processElement方法,然后在其中又会执行processSlotOutlet方法,用来处理<slot>标签,代码如下所示:

/* compiler/parser/index.js */
export function processElement(
  element: ASTElement,
  options: CompilerOptions
) {
  // ...
  processSlotOutlet(element)
  // ...
}

function processSlotOutlet(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.`,
        getRawBindingAttr(el, 'key')
      )
    }
  }
}

可以看到,processSlotOutlet方法就是在<slot>标签上获取name选项,并赋值给ASTslotName属性。然后在编译的generate阶段,在genElement方法中,会对slot标签做特殊处理,代码如下所示:

/* compiler/codegen/index.js */
export function genElement(el: ASTElement, state: CodegenState): string {
  // ...
  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 + ')'
}

可以看到,当遇到slot标签时,会调用genSlot方法,在该方法中,首先会使用slotName作为插槽的名称,然后调用genChildren方法,生成此插槽对应的后备内容,然后调用genProps方法,从AST上获取待传入插槽函数的数据作为实参,最后使用_t方法包裹,该方法同样是在运行时中用来处理VNode节点的,之后再进行详细的分析。

经过上面的处理,Vue已经成功完成对子模板的编译过程,示例代码中对应的渲染函数如下所示:

function anonymous() {
  with (this) {
    return _c('div', {
      staticClass: "child"
    }, [
      _t("header", [_c('div', [_v("Header")])]),
      _v(" "),
      _t("default", [_c('div', [_v("Main")])], {
        "message": message
      }),
      _v(" "),
      _t("footer", [_c('div', [_v("Footer")])])
    ], 2)
  }
}

总结

在编译的过程中,Vue会对v-slot指令和<slot>标签做不同的处理,在处理v-slot指令时,会将插槽的内容构建成一个个的函数,然后使用_u函数包裹,最后赋值给组件ASTscopedSlots<slot>标签会使用_t函数包裹,内部会传入对应的插槽名称、后备内容、参数等。编译的结果都是为运行时服务的,那么在下一章节中,我们再详细分析在运行时中Vue是如何处理插槽的。