[Vue 源码分析] 逐步理解插槽设计(上)

31 阅读4分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 2 月更文挑战」的第 11 天,点击查看活动详情

插槽

Vue 中允许通过插槽的形式,以一种不同于严格的父子关系的方式组合组件,插槽提供了一种将内容放置到新位置或使组件更通用的方式。 Vue 按照将插槽分成普通插槽、具名插槽、作用域插槽。

普通插槽

插槽使用 <slot></slot> 作为子组件承载分发的载体,简单的用法如下

const child = {
  template: `<div class="child"><slot></slot></div>`
}

const vm = new Vue({
  el: "#root",
  components: {
    child
  },
  template: `<div><child><span>name</span></child></div>`
})

//最终子组件渲染结果
<div class="child"><span>name</span></div>

挂载原理

插槽的原理,设计到了组件系统从编译到渲染整个流程,在这里,先回顾组件的相关流程

  • 从根实例开始,进行实例的挂载,如果有手写的 render 函数,则直接进入 $mount 流程
  • 只有 template 模版则需要对模版进行解析,这里分为两个阶段,一个时将模版解析为 AST 树,另一个是根据不同平台生成执行代码,例如 render 函数
  • 拿到 render 函数之后,进入 $mount 流程, $mount 也分为两步,第一步是将 render 函数生成虚拟 DOM , 如果遇到子组件会生成子组件,子组件会以 vue-component-tag 标记,另一部是将虚拟 DOM 渲染成真正的 DOM 节点
  • 创建真实 DOM 节点过程中,如果遇到组件占位符,则会进行子组件的实例化过程,这个过程又会到第一步

回到组件实例化的流程中,父组件会优先于子组件的进行实例挂载,模版的解析和 render 函数的生成在处理上没有特殊的差异。在 render 函数生成虚拟 DOM 过程中,这个阶段会遇到组件占位符,因此会创建子组件的虚拟 DOMcreateComponent 执行了创建子组件的虚拟 DOM

function createComponent (
  Ctor: Class<Component> | Function | Object | void,
  data: ?VNodeData,
  context: Component,
  children: ?Array<VNode>, // 父组件需要分发的内容
  tag?: string
): VNode | Array<VNode> | void {
  // ... 

  // install component management hooks onto the placeholder node 
  // 安装组件钩子函数
  installComponentHooks(data)

  // return a placeholder vnode
  const name = Ctor.options.name || tag
  // 创建子组件的虚拟 DOM, 其中父组件中需要分发的内容会以选项的形式传递给 Vnode 构造函数
  const vnode = new VNode(
    `vue-component-${Ctor.cid}${name ? `-${name}` : ''}`,
    data, undefined, undefined, undefined, context,
    { Ctor, propsData, listeners, tag, children },
    asyncFactory
  )

  return vnode
}

createComponent 函数接受的第四个参数 children 就是父组件需要分发的内容。在创建子组件虚拟 DOM 过程中,会以 componentOptions 配置传入 Vnode 构造函数中。

子组件流程

父组件的最后一个阶段就是将虚拟 DOM 渲染为真正的 DOM 节点,在这个过程中如果遇到子组件的虚拟 DOM 会优先实例化子组件并进行一系列子组件的渲染流程。子组件在初始化过程中,会调用 initInternalComponent 方法,拿到父组件拥有过的相关配置,并保存在自身的配置选项中

Vue.prototype._init = function (options?: Object) {

  // a flag to avoid this being observed
  vm._isVue = true
  // merge options
  if (options && options._isComponent) {
    // 在初始化子组件时,合并子组件的 options
    initInternalComponent(vm, options)
  } else {
    // 外部调用 new Vue 时合并 options
    vm.$options = mergeOptions(
      resolveConstructorOptions(vm.constructor),
      options || {},
      vm
    )
  }
}

function initInternalComponent (vm: Component, options: InternalComponentOptions) {
  const opts = vm.$options = Object.create(vm.constructor.options)
  // doing this because it's faster than dynamic enumeration.
  const parentVnode = options._parentVnode
  opts.parent = options.parent
  opts._parentVnode = parentVnode


  const vnodeComponentOptions = parentVnode.componentOptions
  opts.propsData = vnodeComponentOptions.propsData
  opts._parentListeners = vnodeComponentOptions.listeners
  // 将父组件需要分发的内容复制给子组件的选项配置 _renderChildren
  opts._renderChildren = vnodeComponentOptions.children
  opts._componentTag = vnodeComponentOptions.tag

  if (options.render) {
    opts.render = options.render
    opts.staticRenderFns = options.staticRenderFns
  }
}

最终在子组件实例的配置中拿到了父组件中保存的分发内容,记录在组件实例 $options._renderChildren 中,这是第二步的重点。

接下来是子组件的实例化,会进入 initRender 阶段,这个过程会将配置的 _renderChildren 属性做规范化处理,并将他赋值给实例上的 $slot 属性,这是第三步的重点

function initRender(vm: Component){
  // ....
  vm.$slots = resolveSlots(options._renderChildren, renderContext)
}

function resolveSlots (
  children: ?Array<VNode>,
  context: ?Component
): { [key: string]: Array<VNode> } {
  // children 是父组件需要分发到子组件中的虚拟 DOM 节点,如果不存在,则没有分发内容
  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 {
      // 普通插槽,核心逻辑是构造 {default: [children]} 对象返回
      (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
}

对于普通插槽而言,主要逻辑就是将需要分发的内容以属性的形式保存在子组件实例的 $slot 属性中。随后,子组件也会走挂载流程,同样会经历模版编译得到 render 函数,在到虚拟 DOM ,最后渲染真实 DOM 的过程。在解析 AST 阶段, slot 标签和其他标签的处理相同,不同之处在于 AST 生成 render 函数的阶段 ,对 slot 标签的处理,会使用 _t 函数进行包裹。

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)
  // 通过 _t 函数包裹
  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 + ')'
}

第五步到了子组件渲染为虚拟 DOM 的过程, render 函数执行阶段会执行 _t 函数, _t 函数 renderSlot 函数简写,该函数会在虚拟 DOM 树中进行分发内容的替换。

Vue.prototype._render = function (): VNode {
  if (_parentVnode) {
    // slot 的规范化处理
    vm.$scopedSlots = normalizeScopedSlots(
      _parentVnode.data.scopedSlots,
      vm.$slots,
      vm.$scopedSlots
    )
  }
}

在获取到 $scopedSlots 之后,会执行真正的 render 函数,来看下 _t 函数的执行逻辑

export function renderSlot (
  name: string,
  fallback: ?Array<VNode>, // slot 插槽的后背内容
  props: ?Object,  // 子组件传给父组件的值(针对作用域插槽)
  bindObject: ?Object
): ?Array<VNode> {
  // 拿到父组件插槽内容的执行函数,默认的 name 为 default
  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)
    }
    // 执行函数是将子组件传递给父组件的值传入 fn
    nodes = scopedSlotFn(props) || fallback
  } else {
    // 如果占位符中没有插槽内容, this.$slot[name] 不会有值,则使用后备节点的内容
    nodes = this.$slots[name] || fallback
  }

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

renderSlot 执行过程会拿到父组件需要分发的内容,最终虚拟 DOM 树将父组件中的插槽替换成子组件的 slot 组件。最后一步就是子组件真实节点的渲染,这一过程与普通组件一致。

后备插槽

有时需要为插槽设置具体的后备内容(也就是默认的)后备内容会在没有提供内容是被渲染

const child = {
  template: "<div>后备内容</div>"
}

const vm = new Vue({
  el: "#app",
  components: {
    child
  },
  template: "<div><child></child></div>"
})
// 父组件中没有设置插槽的内容,子组件的 slot 会渲染后备内容

当父组件中没有需要分发的内容是,子组件会默认显示插槽里面的内容。

  • 父组件渲染过程中由于没有需要分发的字节点,所以不在需要 componentOptions.children 属性来记录内容
  • 子组件中获取不到 $slot 属性的内容
  • 子组件的 render 函数最后在执行 _t 函数是会携带第二个参数,该参数以数组形式传入
  • 在执行 renderSlot 方法是,第二个参数 fallback 有值,且 this.$slotsundefinedvnode 会直接返回作为渲染对象。
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 {
    // 父组件中的组件占位符中没有提供插槽内容, 这是会使用后备内容 fallback
    nodes = this.$slots[name] || fallback
  }

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

最终,在父组件没有提供分发内容时,会渲染 slot 的后备内容

Vue 官方文档中,对与 slot 存在这样一条规则

父级模版里的所有内容搜是在父级作用域中编译的;子模版里的所有内容都是在子用于里编译的

父组件模版的内容在父组件编译阶段就确定了,并且保存在 componentOptions 属性中,而子组件自身初始化 init 的过程,这个过程同样会进行子作用域的模版编译,因此两部分是相互独立的。