[Vue 源码] 逐步理解插槽设计(下)

295 阅读3分钟

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

具名插槽

在实际开发过程中,往往需要灵活使用插槽进行通用组件的开发,要求组件每个模版对应子组件中的每个插槽,这时候可以使用 <slot> 中的 name 属性,称之为具名插槽

var child = {
  template: `<div class="child"><slot name="header"></slot></div>`,
}
var vm = new Vue({
  el: '#app',
  components: {
    child
  },
  template: `<div id="app"><child><template v-slot:header><span>头部</span></template></child></div>`,
})

模版编译的差别

父组件在编译 AST 阶段和普通节点的过程不同,具名插槽一般会在 template 模版中使用 v-slot 来指定使用哪一个插槽,在这一阶段会在编译阶段特殊处理,最终生成的 AST 树会携带 scopedSlots 用来记录具名插槽的内容,最终在 AST 中保存的插槽模版信息如下图

具名插槽 AST

最终生成 render 函数如下

with(this){return _c('div',{attrs:{"id":"app"}},[_c('child',{scopedSlots:_u([{key:"header",fn:function(){return undefined},proxy:true}])})],1)}

通过上面的 render 函数可以看出,父组件的插槽内容用 _u 函数封装成数组的形式,并复制到 scopedSlots 属性中,而每一个插槽以对象形式描述, key 代表插槽名, fn 是一个返回执行结果的函数。

父组件虚拟 DOM 生成阶段

在获取到 render 函数之后,下一阶段就是生成父组件的虚拟 DOM , 其中 _u 函数的原型是 resolveScopedSlots ,该函数第一个参数就是插槽数组

export function resolveScopedSlots (
  fns: ScopedSlotsData, // see flow/vnode
  res?: Object,
  // the following are added in 2.6
  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]
    // fn 是数组是,需要进行递归处理
    if (Array.isArray(slot)) {
      resolveScopedSlots(slot, res, hasDynamicKeys)
    } else if (slot) {
      // marker for reverse proxying v-slot without scope on this.$slots
      if (slot.proxy) {
        slot.fn.proxy = true
      }
      res[slot.key] = slot.fn
    }
  }
  if (contentHashKey) {
    (res: any).$key = contentHashKey
  }
  return res
}

最终父组件的虚拟 DOM 节点的 data 属性上多了 scopedSlots 数组。回顾一下,具名插槽和普通插槽的实现上有明显的不同,普通插槽是以 componentOptions.child 的形式保留在父组件中;而具名插槽是以 scopedSlots 属性的形式存储到 data 中。

具名插槽虚拟 DOM

子组件渲染虚拟 DOM 过程

和普通插槽类似,子组件渲染真实节点的过程中会执行子组件的 render 函数中的 _t 方法, 也就是 renderSlot 方法

function renderSlot (
  name: string,
  fallback: ?Array<VNode>,
  props: ?Object,
  bindObject: ?Object
): ?Array<VNode> {
  // 由于具名插槽的相关信息会存入 this.$scopedSlots
  const scopedSlotFn = this.$scopedSlots[name]
  let nodes
  // 因此 scopedSlotFn 为 true ,进入该分支
  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 {
    nodes = this.$slots[name] || fallback
  }

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

从上面的代码中可以看出,对与具名插槽的核心逻辑就是 nodes = scopedSlotFn(props) || fallback , scopedSlotFn 函数就是虚拟 DOM 中保存的插槽相关的 render 函数。

到此,关于具名插槽的相关流程就已经分析完了

作用域插槽

Vue 开发中,我们可以使用作用域插槽让父组件访问到子组件的数据,具体用法是在子组件中以属性的方式记录数据,父组件通过 v-slot:[name]=[props] 的形式拿到子组件传递的值。 子组件在 <slot> 元素上的属性称为 插槽 Props

var child = {
  template: `<div><slot :user="user"></div>`,
  data() {
    return {
      user: {
        firstname: 'test'
      }
    }
  }
}
var vm = new Vue({
  el: '#app',
  components: {
    child
  },
  template: `<div id="app"><child><template v-slot:default="slotProps">{{slotProps.user.firstname}}</template></child></div>`
})

父组件编译阶段

作用域插槽和具名插槽类似,区别在于 v-slot 定义了一个插槽 props 的名字,参考对于具名插槽的分析,生成 render 函数阶段 fn 函数会携带 props 参数传入,即

with(this){return _c('div',{attrs:{"id":"app"}},[_c('child',{scopedSlots:_u([{key:"default",fn:function(slotProps){return [_v(_s(slotProps.user.firstname))]}}])})],1)}

子组件渲染

在子组件编译阶段, :user=user 会以属性的形式解析,最终在 render 函数生成阶段以对象参数的形式传递给 _t 函数

with(this){return _c('div',[_t("default",null,{"user":user})],2)}

子组件渲染虚拟 DOM 阶段,根据前面分析会执行 renderSlot 函数,对于作用域插槽的处理,几种体现在函数传入的第三个参数

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
      props = extend(extend({}, bindObject), props)
    }
    nodes = scopedSlotFn(props) || fallback
  } else {
    nodes = this.$slots[name] || fallback
  }

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

最终将子组件的插槽 props 作为参数传递给执行函数执行。