原稿:语雀 · 作用域插槽 · 原目录: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
  }
})

普通插槽通常用于父组件向子组件插入内容,当未显式指定插槽名时(具名插槽),默认使用 default 作为插槽名;以上的父组件编译后的渲染函数如下,可以看到插槽本身会在父组件渲染过程中调用 createElement() 生成 vnode,因此当中的 this 指向父组件,获取的数据也取自父组件

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

子组件渲染函数如下,对于插槽部分调用了 _t 函数,对应 renderSlot()

with(this) {
  return _c('div',{
    staticClass:"container"
    },[
      _c('header',[_t("header")],2),
      _c('main',[_t("default",[_v("默认内容")])],2),
      _c('footer',[_t("footer")],2)
      ]
   )
}
/**
 * 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 }
    else {
    const slotNodes = this.$slots[name]
    if (slotNodes) {
      slotNodes._rendered = true
    }
    nodes = slotNodes || fallback
  }

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

在 renderSlot() 中有 const slotNodes = this.$slots

namename

,而 $slots 是子组件在 initRender() 中从父组件中获取到的,它是一个对象,其中每个值都是 vnodes 数组,每个 vnodes 都是在父组件执行 __render() 时生成的,这样就实现在父组件替换子组件插槽的内容

作用域插槽

作用域插槽和普通插槽的区别在于,它的数据来自于子组件中,即执行渲染函数时,this 指向的是子组件实例

当有如下代码

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

父组件渲染函数结果如下,可以看到,原来的父组件插槽对应的 children 数组消失了,而 data 部分多了一个对象 scopedSlots,并且执行了 _u 方法,传入了一个数组,当中每个元素是一个指定了 key 的函数组合

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

_u 函数对的就是 resolveScopedSlots(),后者遍历 fns 数组,生成一个数组,数组的 key 就是插槽名称,value 就是函数,然后返回该对象

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}

子组件的渲染函数结果如下,和普通插槽子组件相同,都执行了 _t 函数,即 renderSlot()

with(this){
  return _c('div',
    {staticClass:"child"},
    [_t("default",null,
      {text:"Hello ",msg:msg}
    )],
  2)}
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) {
      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
  }
}

再次进入 _t 函数,在作用域插槽的情况下会执行 const scopedSlotFn = this.$scopedSlots

namename

,那么这个 this.$scopedSlots 又是在什么地方定义的呢?在子组件的渲染函数执行前,在 vm._render() 内,有这么一段逻辑

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

而这个 _parentVNode.data.scopedSlots 对应的就是我们在父组件通过执行 _u 函数得到的数组,因此这里通过插槽名获取该插槽对应的 scopedSlotFn 函数,然后执行,此时是子组件渲染的过程,因此 this 指向的是子组件实例,访问的也是子组件作用域,此时执行 createElement() 生成的 vnode 自然会从子组件获取数据

小结

普通插槽: 2. 父组件模板编译后渲染函数中对设置了 slot 属性的节点生成 _c 函数,将插槽名作为 data 参数;父组件渲染阶段执行 createElement() 生成 vnodes,所以数据的作用域是父组件实例 4. 子组件模板编译后渲染函数中对 <slot> 节点生成 _t 函数;渲染的时候通过 $slots 直接拿到在父组件中生成的 vnodes

作用域插槽: 2. 父组件模板编译后渲染函数中对置了 slot-scope 属性的 <template> ,生成 scopeSlots 对象作为 data 参数,该对象调用 _u 函数;父组件渲染阶段调用 _u 方法,生成一个数组,每个数组项时一个由多个 _c 函数组合的函数 4. 子组件模板编译后渲染函数中对 <slot> 节点生成 _t 函数;渲染时执行,通过 $scopedSlots 拿到父组件渲染时生成的 scopedSlotFn,在子组件中执行,生成 vnodes,期间调用 createElement 时的 this 执行子组件实例,因此数据都来自子组件

简单地说,两种插槽的目的都是让子组件 slot 占位符生成的内容由父组件来决定,但数据的作用域会根据它们 vnodes 渲染时机不同而不同:普通插槽直接在父组件渲染时生成了插入内容的 vnodes,而作用域插槽在父组件渲染时只得到了 scopeSlotFn,作用域插槽函数的执行会在子组件渲染中