Blog

源码解读-插槽渲染

time: 2021-05-06 16:26:58
author: heyunjiang

1 插槽使用基本知识

  1. 父级模板里的所有内容都是在父级作用域中编译的;子模板里的所有内容都是在子作用域中编译的
  2. 具名插槽规则:<slot name="header"> + <template v-slot:header>,任何没有被 template + v-slot 包裹的内容,都会被视为 v-slot:default 内容
  3. v-slot 只能添加在 template 上;不过如果只有默认插槽,也可以使用在其他组件上
  4. 作用域插槽:提供插槽 prop,<slot name="header" :user="user"> + <template v-slot:header="slotProps">,从而实现父组件插槽读取子组件数据
  5. 具名插槽缩写:<template #header="slotProps">

2 问题

  1. slot 和 scopedSlot 有什么区别?还有其他类型吗?只有这2种插槽,scopedSlot 是作用域插槽,在父组件编译成函数作为子组件 vm.$scopedSlots 属性,slot 是默认插槽,编译成 vnode 节点,作为子组件 vm.$slots 属性
  2. slot 渲染流程是啥?在父组件编译,作为子组件属性,子组件读取函数或 vnode 节点渲染
  3. 如果具名插槽将将默认插槽内容分成几段,渲染结果是什么?结果是默认的分片会聚合起来,this.$slots.default = []

3 编译结果

// index.vue
<div id="app">
  <HelloWorld>
    hello
    <template v-slot:footer>footer</template>
    world
  </HelloWorld>
</div>
// helloworld.vue
<div class="hello">
  <slot></slot>
  <slot name="footer"></slot>
</div>

// 编译结果
// index.vue
_c('div',{attrs:{"id":"app"}},[_c('HelloWorld',{scopedSlots:_vm._u([{key:"footer",fn:function(){return [_vm._v("footer")]},proxy:true}])},[_vm._v(" hello "),_vm._v(" world ")])],1)
// helloworld.vue
_c('div',{staticClass:"hello"},[_vm._t("default"),_vm._t("footer")],2)

结果分析

  1. 插槽内容被 vm._v 包裹了,作为子组件
  2. slot 被 vm._t 包裹了,作为子组件
  3. 具名插槽内容被渲染成了组件的属性,叫做 scopedSlots,并且通过 vm._u 包裹生成属性值

来看看 _v, _t, _u 是啥

4 源码分析

在 renderMixin 中,installRenderHelpers(Vue.prototype),加载了下列方法

import { createTextVNode } from 'core/vdom/vnode'
import { renderSlot } from './render-slot'
import { resolveScopedSlots } from './resolve-scoped-slots'

export function installRenderHelpers (target: any) {
  target._t = renderSlot
  target._v = createTextVNode
  target._u = resolveScopedSlots
}

归纳如下

  1. vm._v: createTextVNode 创建普通文本节点
  2. vm._t: renderSlot 创建 slot 节点
  3. vm._u: resolveScopedSlots 在父组件创建作用插槽

4.1 resolveScopedSlots

操作父组件的 slot 内容

// template
<div id="app">
  <HelloWorld>
    hello
    <template v-slot:footer="slotProps">footer</template>
  </HelloWorld>
</div>
// 编译结果
_c('div',{attrs:{"id":"app"}},[_c('HelloWorld',{scopedSlots:_vm._u([{key:"footer",fn:function(slotProps){return [_vm._v("footer"+_vm._s(slotProps.number))]}}])},[_vm._v(" hello ")])],1)

编译结果分析

  1. _vm._s 表示 toString
  2. 所有 scopedSlot 节点都被编译成了函数,内部节点是在父组件的作用域中编译
  3. 使用了 vm._u resolveScopedSlots 包装 scopedSlot 函数子节点数组

来看看 resolveScopedSlots 源码,看看它的返回结果是啥

export function resolveScopedSlots (
  fns: ScopedSlotsData,
  res?: Object,
  hasDynamicKeys?: boolean
): { [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) {
      // marker for reverse proxying v-slot without scope on this.$slots
      if (slot.proxy) {
        slot.fn.proxy = true
      }
      res[slot.key] = slot.fn
    }
  }
  return res
}

归纳分析

  1. resolveScopedSlots 执行返回 object,包含了编译生成的 slot 节点
  2. slot 如果使用 template + v-slot,则会 编译成函数
  3. slot 如果为普通节点,则会编译为 _c(‘div’),后续生成 children
  4. 所有 scopedSlot 函数节点,是作为子组件的 vnode.data.scopedSlots 属性,在子组件中调用执行,生成对应节点

问题:

  1. slot 在父级作用域中是编译成了函数,其父组件调用函数生成 vnode 节点渲染,这个 vnode 的 context 是父级作用域的 vm 吗?是的,闭包
  2. 普通节点的 slot,是如何被子组件使用的呢?作为 patch component 的 children 使用,作为 component vnode.componentOptions.children 在组件 render 时生成 vm.$slots 对象
  3. vnode.data.scopedSlots 是如何转换到 vm.$scopedSlots 的?_render + normalizeScopedSlots

4.2 renderSlot

操作的是子组件的 slot 节点

// template
<div class="hello">
  <slot></slot>
  <slot name="footer" :number="2"></slot>
</div>
// 编译结果
_c('div',{staticClass:"hello"},[_vm._t("default"),_vm._t("footer",null,{"number":2})],2)

从编译结果来看,要求 renderSlot 执行结果为子节点 vnode 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) {
      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
  }
}

源码结果

  1. 读取 vm.$scopedSlots[name] 属性,也就是说父组件的 scopedSlot 内容已经编译到一个对象里面,结果是一个函数 object
  2. renderSlot 本质是读取父组件生成的 scopedSlot 函数并执行它返回 vnode 节点
  3. 如果是具名插槽,则通过 vm.$scopedSlots[name] 获取对应 fn,并且把子组件内的作用域插槽参数传入执行 fn,返回对应的 vnode 节点,作为一个当前 render vnode tree 上的一个节点
  4. 如果是 default 插槽内容,会作为子组件的 children vnode 传入,在组件 initRender 中生成 vm.$slots

4.3 具名 slot 渲染结论

到目前为止,我们已经知道具名插槽的渲染流程

  1. 在父级作用域编译生成 函数 对象,作为父组件 vnode.data.scopedSlots 属性传入,在父组件 vm._render 时生成 vm.$scopedSlots
  2. 在父组件内部调用 vm.$scopedSlots 上的 slot 函数,函数返回 vnode 节点数组,作为子组件的 render 方法生成的 vnode tree 上的几个节点

问题:vm.$slots 是啥?和 children 有什么关系
答:vm.$slots 是静态插槽,vm.$scopedSlots 是作用域插槽;$slots 是由 children 生成的

4.4 default slot 渲染流程

再次回顾 components 渲染流程,看看组件的 children 是如何处理的

  1. 在 createElement 入口,如果是组件,则会生成组件 vnode,所有 chidren 会作为 vnode.componentOptions.chidlren 存在
    export function createComponent (
      Ctor: Class<Component> | Function | Object | void,
      data: ?VNodeData,
      context: Component,
      children: ?Array<VNode>,
      tag?: string
    ) {
      const vnode = new VNode(
     `vue-component-${Ctor.cid}${name ? `-${name}` : ''}`,
     data, undefined, undefined, undefined, context,
     { Ctor, propsData, listeners, tag, children },
     asyncFactory
      )
      return vnode
    }
    

函数式组件需要主动调用 context.children 来渲染

  1. patch 渲染组件 vnode,在走 componentVNodeHooks.init 时,会实例化 vm 对象,那么 vnode.componentOptions.children 会怎么处理
// 1 生成的组件 vnode 会作为 new vue 的 options._parentVnode 传入
export function createComponentInstanceForVnode (
  vnode: any,
  parent: any
): Component {
  const options: InternalComponentOptions = {
    _isComponent: true,
    _parentVnode: vnode,
    parent
  }
  return new vnode.componentOptions.Ctor(options)
}
// 2 initRender 将之前的 children 转成 vm.$slots
export function initRender (vm: Component) {
  vm._vnode = null // the root of the child tree
  vm._staticTrees = null // v-once cached trees
  const options = vm.$options
  const parentVnode = vm.$vnode = options._parentVnode // the placeholder node in parent tree
  const renderContext = parentVnode && parentVnode.context
  vm.$slots = resolveSlots(options._renderChildren, renderContext) // _renderChildren 即 component vnode.componentOptions.children,renderContext 即组件自身 vnode.context
  vm.$scopedSlots = emptyObject
}

// 3 在组件调用自身 _render 生成 vnode tree 时,生成 vm.$scopedSlots 对象
Vue.prototype._render = function (): VNode {
  const vm: Component = this
  const { render, _parentVnode } = vm.$options

  if (_parentVnode) {
    vm.$scopedSlots = normalizeScopedSlots(
      _parentVnode.data.scopedSlots,
      vm.$slots,
      vm.$scopedSlots
    )
  }
}

归纳分析

  1. 在 initRender 中调用 resolveSlots 生成 vm.$slots,将 child vnode push 到 vm.$slots.default 中
  2. 在 vm._render 中生成 vm.$scopedSlots 之后,在后续的 render.call 生成 vnode 时,slot 会调用 renderSlot 读取 vm.$scopedSlots 上的 slot 函数

resolveSlots 源码

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]
    (slots.default || (slots.default = [])).push(child)
  }
  return slots
}

5 slot 渲染总结

  1. 编译结果:scopedSlot 被编译成 函数,default slot 被编译成 _c 数组
  2. 首次执行结果存储:所有 scopedSlot 函数存储在父组件 vnode.data.scopedSlots 属性对象中;所有 default slot vnode 作为父组件 vnode.componentsOptions.children 存储
  3. vm.$scopedSlots:父组件渲染在执行 _render 时,会通过 normalizeScopedSlots 生成 vm.$scopedSlots 属性对象
  4. vm.$slots:在 vm._init 时会调用 initInternalComponent 将 opts._renderChildren = vnodeComponentOptions.children; 父组件在初始化调用 initRender 时,会调用 resolveSlots 读取 生成 vm.$slots 属性对象
  5. 父组件编译:所有 slot 会被编译成 vm.renderSlot(‘default’) 执行函数
  6. 父组件 _render:通过 renderSlot 函数执行,读取 vm.$scopedSlots 或 vm.$slots 来生成 vnode,作为完整父组件 vnode tree 上一环
  7. 父组件走完整 vm._update patch 渲染流程

关键点归纳:scopedSlot 会被编译成函数供父组件调用,类似于高阶函数实现

参考文章

vue scopedSlots 官方