likes
comments
collection
share

vue 的插槽实现原理 之 具名插槽

作者站长头像
站长
· 阅读数 19

前言

vue -- 插槽的使用方法 中不是说到了嘛,对插槽的编译作用域中的一句话不是很理解:

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

那么就来继续探究一下插槽的实现原理,本期的内容也是稍微有点长的,就分为了上下两部分,这一次先来看看具名插槽的实现方式。

透过现象看本质

插槽的本质是实现内容分发,实现内容分发,需要两个条件:

  1. 占位符
  2. 分发内容

组件内部定义的slot标签,就是所谓的占位符,父组件中写入的插槽内容,就是要分发的内容。 插槽处理本质就是将指定内容放到指定位置。

组件挂载顺序

在进入 slot 讲解之前我们必须要知道的父子组件挂载流程: 父组件状态初始化 ( data 、 computed 、 watch ...、包括插槽的初始化) ==> 父组件 beforeCreate ==> 实现父组件中的数据响应式 ==>> 父组件 created ==>> 进入模板编译阶段生成 render() 渲染函数 ==>>父组件顺利执行到 beforeMount 阶段 ==>> 在 patch 的过程中发现子组件的存在时会暂停父组件的虚拟节点转换,转而开始子组件的实例化过程 ==>> 子组件 beforeCreate ==>> 子组件 created ==>> 子组件 beforeMount ==>> 子组件 mounted 将生成的真实DOM挂载到父组件的DOM上 ==>> 继续完成父组件的 mounted

tips:如果对组件加载流程不太清楚的同学可以先去看看前面的两篇文章: 组件创建的vue生命周期 以及 模板编译 的相关内容,补充完这两个知识点之后会更利于本文的学习和理解。

由此可见父组件的实例先于子组件实例生成。当 Vue 开始实例化一个组件时,它会先实例化该组件的父级组件,然后再实例化该组件本身,在子组件中通常需要访问父组件的数据和方法,所以必须要确保父组件已经被实例化。 所以根据生命周期我们也能更好的理解官网上的这句话 vue 的插槽实现原理 之 具名插槽 它强调了在 Vue 中父组件和子组件的模板是分别编译的,当父组件被编译时,Vue 会将父组件的模板编译成渲染函数,并将其放入父组件的作用域中。在遇到子组件时也会将子组件的模板编译成渲染函数,确保在父组件和子组件中使用的变量和方法不会相互干扰,从而避免了作用域冲突的问题。

父组件挂载的后续

这里再补充一下父组件是如何发现自身存在子组件的呢: 在 beforeMount 之后初始化 render watcher (渲染 watcher ),此时会调用 updateComponent 回调触发 render 函数将虚拟 dom 转化成真实 el 挂载到页面上,在执行 patch 的过程中比较节点的差异。当发现新节点是组件时触发 createElm 创建新的元素 。

在 createdElm 中,如果 vnode 是组件则会调用 createComponent() 创建组件,(子组件也会产生递归的)它会将上一个父组件的 mounted 生命周期函数停滞也就是将 invokeInsertHook 方法停滞,转而去执行子组件的 _init ,等待子组件执行完以后跳出递归才能执行,所以父组件的 mounted 生命周期函数必定是等到所有子组件执行完挂载之后再执行。

具名插槽的实现

前面铺垫了那么久终于要开始进入正题了😂,接下来看看插槽到底是怎么实现的,我们先从具名插槽开始 这里再来回忆一下具名插槽的使方式:

<!-- 在父组件中引用 child 通过 slot 使用具名插槽,将内容插入到子组件对应的位置上 -->
<div>
  <child>
    <h1 slot="header">{{title}}</h1>
    <p>{{message}}</p>
    <p slot="footer">{{desc}}</p>
  </child>
</div>


<!-- 子组件 -->
  
<div class="container">
  <header><slot name="header"></slot></header>
  <main><slot>默认内容</slot></main>
  <footer><slot name="footer"></slot></footer>
</div>


父组件在编译后的可执行字符串:

with(this){
	return _c(
    "div",
    [
      _c("child", [
        _c( // header 的插槽内容
          "h1",
          {
            attrs: {  // 插槽名称
              slot: "header",
            },
            slot: "header", // 插槽名称
          },
          [_v(_s(title))]
        ),
        _v(" "),
        _c("p", [_v(_s(message))]), // 默认插槽
        _v(" "),
        _c( // footer 的插槽内容
          "p",
          {
            attrs: { // 插槽名称
              slot: "footer",
            },
            slot: "footer",  // 插槽名称
          },
          [_v(_s(desc))]
        ),
      ]),
    ],
    1
  );

}

在父组件进行编译模板时,会先执行父组件的 _render 方法来创建一个 VNode 对象。在创建 VNode 的过程中,如果遇到了子组件,它也会为子组件创建对应的 VNode 对象,并将子组件的 VNode 对象添加到父组件的componentOptions.children 属性中去。而这些子节点的 VNode 对象实际上就是插槽的内容。

编译后的子节点上会挂载一个 slot 属性,值为插槽名称;并且 attrs 属性中也会多一个 slot属性。而默认插槽没有添加任何属性。

在父组件执行 patch 的过程中发现子组件时就会先将父组件挂起,直接进入到子组件的初始化阶段,并完成后续子组件的编译和挂载。

子组件的初始化

在遇到子组件时会调用 _init()方法进入到子组件的初始化,在初始化过程中调用 initInternalComponent 方法拿到父组件上的相关配置信息,并赋值给子组件自身的配置项。

function initMixin(Vue: Class<Component>) {
  Vue.prototype._init = function(options) {
    if (options && options._isComponent) {
      // 对子组件进行初始化 
      // 通过 initInternalComponent 拿到父组件拥有的相关配置信息
      initInternalComponent(vm, options);
    }
    initLifecycle(vm)  //   初始化组件实例关系属性,比如 $parent、$children、$root、$refs 等
    /**
     * 初始化自定义事件,这里需要注意一点,所以我们在 <comp @click="handleClick" /> 上注册的事件,监听者不是父组件,
     * 而是子组件本身,也就是说事件的派发和监听者都是子组件本身,和父组件无关
    */
    initEvents(vm)

    //  渲染初始化 初始化插槽  获取 this.$slots  定义 this._c 即 createElement 方法  也是平时所说到的 h 函数
    initRender(vm)

    // 执行 beforeCreate 生命周期钩子函数
    callHook(vm, 'beforeCreate')

    // 初始化组件的 inject 配置项,得到 result[key] = val 形式的配置对象,对结果数据进行响应式处理,并代理每个 key 到 vm 实例
    initInjections(vm)

    // 数据响应式的重点,处理 props、methods、data、computed、watch
    initState(vm)

    // 解析组件配置项上的 provide 对象,将其挂载到 vm._provided 属性上
    initProvide(vm) // resolve provide after data/props

    // 执行 created 生命周期钩子函数
    callHook(vm, 'created')
    
  	// 如果发现配置项上有 el 选项,则自动调用 $mount 方法
    // el 选项为 Vue 实例的挂载目标,
    if (vm.$options.el) {
      vm.$mount(vm.$options.el)
    }
  }  
}


//  实例化子组件,并将其与父级组件实例和父级元素节点关联起来。
// 确保在组件渲染时能够正确地处理父子组件之间的关系。
function initInternalComponent(vm: Component, options: InternalComponentOptions) {
  const opts = vm.$options = Object.create(vm.constructor.options)
  const parentVnode = options._parentVnode
  opts.parent = options.parent
  opts._parentVnode = parentVnode
  // componentOptions 为子节点记录的相关信息  
  // 从父级虚拟节点的组件配置中获取子虚拟节点的相关信息,并将其赋值给选项对象 opts。
  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
  }
}

在 initInternalComponent 中将父组件需要分发的内容赋值给子组件的_renderChildren 选项 当子组件创建实例时,会从父组件的 VNode中获取 $props$listeners 以及插槽信息 本质上父组件和子组件之间的数据传递是通过 VNode 来实现的。

对于插槽来说 _init() 中最关键的环节是** initRender(vm)**,在这里将进行插槽的初始化,获取插槽的配置项。

function initRender (vm: Component) {
  vm._vnode = null 
  vm._staticTrees = null 
  // 合并后的 option 赋值
  // 找到父组件节点 parentVnode  和渲染内容 renderContext
  const options = vm.$options
  const parentVnode = vm.$vnode = options._parentVnode 
  const renderContext = parentVnode && parentVnode.context

  // 初始化解析插槽  resolveSlots 找出当前组件下父组件传入的slot,并且返回;与子组件的插槽对应
  // resolveSlots方法的参数是插槽内容(VNode 数组)和父级Vue实例
  vm.$slots = resolveSlots(options._renderChildren, renderContext)
  vm.$scopedSlots = emptyObject
  
  //  createElement 主要用于生成虚拟DOM
  // 将 createElement 函数挂载到 vm 的 _c 和 $createElement 两个属性上
  vm._c = (a, b, c, d) => createElement(vm, a, b, c, d, false)
  vm.$createElement = (a, b, c, d) => createElement(vm, a, b, c, d, true)
	...
}

将配置的 _renderChildren 属性做规范化处理,并将他挂载到实例的 $slot 属性上,同时对渲染相关的属性和方法进行初始化其中包括:

  • $vnode:当前组件对应的虚拟 DOM 节点
  • $slots:当前组件插槽内容的对象
  • $scopedSlots:当前组件作用域插槽内容的对象
  • _c:创建虚拟 DOM 节点的函数
  • $createElement:创建虚拟 DOM 节点的函数

解析父组件的分发内容

在 resolveSlot 中 通过 _renderchildren 获取父组件的插槽内容,并将其分发到子组件中去,分别存入对应的插槽对象中,最后返回一个包含所有插槽的对象作为子组件的属性。 在解析插槽的过程中插槽是具名的,也可以是默认的,根据不同的情况采取对应的处理方式。

export function resolveSlots (
  children: ?Array<VNode>,
  context: ?Component    // 指向父级 Vue 实例
): { [key: string]: Array<VNode> } {
  // children是父组件需要分发到子组件的Vnode节点,如果不存在,则表示没有分发内容
  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
    if (data && data.attrs && data.attrs.slot) {
      // 遍历VNode数组,如果有 data.attrs.slot 则将此属性删除, 避免重复解析
      delete data.attrs.slot
    }
    // 因为 slot 的 vnode 是在父组件实例的作用域中生成的,所以 child.context 指向父组件
    // 判断当前子节点的 Vue 实例是否和父级 Vue 实例相同的,
    // 通过 data.slot 判断是具名插槽还是默认插槽
    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] }对象返回
      // 将默认插槽以数组的形式赋值给 default 属性返回给子组件,并最终以 $slot 属性的形式保存在子组件的实例中。
      (slots.default || (slots.default = [])).push(child)
    }
  }
  // 遍历slots,将注释 VNode或者是空字符串的文本VNode去掉
  for (const name in slots) {
    if (slots[name].every(isWhitespace)) {
      delete slots[name]
    }
  }
  return slots
}

判断当前子节点的 Vue 实例和父级 Vue 实例是否相同,以确保插槽节点和子组件实例都在同一个作用域中,且判断该节点是否有 data.slot 属性, 由于插槽的 vNode是在父组件实例中创建的,所以child.context === context 是成立的,

  • 若当前节点中包含 data.slot 属性则表示当前节点是一个具名插槽节点,需要将该节点加入对应插槽名称的数组中;
  • 否则,将该节点加入默认插槽的数组中。

此时解析插槽后得到的 slots对象并赋值给子组件的 vm.$slots 属性:

vm.$slots = {
  header: [VNode],
  footer: [VNode],
  default: [VNode]
}

对子组件的挂载

随后子组件也会走挂载的流程,先将 template 模板编译成 render 函数,再根据 render 函数生成 VNode。最后,VNode 会被渲染成真实 DOM 挂载到父组件上。

在解析 AST 阶段,slot 标签和其他普通标签的处理方式相同,但是在 AST 生成 render 函数阶段,处理 slot 标签时会使用_t 函数进行包裹。

这里主要讲解 AST 语法树生成 render 的过程:

// generate 用于将 AST(抽象语法树)节点转换为可执行的渲染函数
function generate (ast,options): {
  // 实例化 CodegenState 对象 参数是编译选项, 最终得到 state 大部分属性 和 options 一样
  const state = new CodegenState(options)

  // 得到最终的代码字符串, _c(tag, data, children, normalizationType)
  const code = ast ? (ast.tag === 'script' ? 'null' : genElement(ast, state)) : '_c("div")'
  
  return {
    render: `with(this){return ${code}}`,
    staticRenderFns: state.staticRenderFns
  }
}

// 根据 AST (抽象语法树)节点生成对应的渲染函数字符串,
// 这里对 slot 进行了特判,遇到插槽 执行genSlot() 进行相应的转化
function genElement (el: ASTElement, state: CodegenState): string {
  ...
  if (el.tag === 'slot') {
     // 针对slot标签的处理
    return genSlot(el, state)
  }
  ...
}

genSlot() 对插槽进行可执行字符串的转化,将插槽用 _t 函数包裹起来,以便在组件实例被渲染时使用。

function genSlot (el: ASTElement, state: CodegenState): string {
  // 获取插槽的名称,若不存在则为默认插槽
  const slotName = el.slotName || '"default"'
  // 如果子组件的插槽还有子元素,递归调执行子元素的创建过程
  const children = genChildren(el, state)
  // 用 _t函数 包裹
  let res = `_t(${slotName}${children ? `,function(){return ${children}}` : ''}`
  // 处理插槽节点的属性、bind:
  // 如果插槽节点存在属性,则使用 genProps 函数将其转换为字符串形式,并将其添加到渲染函数代码中。
  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 + ')'
}

此时子组件编译得到的渲染函数字符串:

with(this){
	return _c("div", { staticClass: "container" }, [
    _c("header", [_t("header")], 2),
    _v(" "),
    _c("main", [_t("default", [_v("默认内容")])], 2),
    _v(" "),
    _c("footer", [_t("footer")], 2),
  ]);
}s

将子组件渲染为 Vnode 的过程中, render 函数执行阶段会执行 _t() 函数,_t 函数就是 renderSlot 函数简写,它会在 Vnode 树中进行分发内容的替换,将父组件的分发内容替换到 <slot> 标签所在的位置。

target._t = renderSlot

// 用于渲染组件的插槽内容, 根据插槽名称和传入的参数,获取对应的插槽内容,并将其渲染为虚拟 DOM 节点。
export function renderSlot (
  name: string,
  fallbackRender: ?((() => Array<VNode>) | Array<VNode>),
  props: ?Object,
  bindObject: ?Object
): ?Array<VNode> {

  // 从 $scopedSlots 中取到对应的插槽函数,然后执行这个函数,得到虚拟节点,然后返回虚拟节点
  // 默认 slotname 为 default
  const scopedSlotFn = this.$scopedSlots[name]
  let nodes

  if (scopedSlotFn) { // 作用域插槽
    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) ||
      (typeof fallbackRender === 'function' ? fallbackRender() : fallbackRender)
  } else {
     // 如果父占位符组件没有插槽内容,this.$slots不会有值,此时 vNode 节点为后备内容节点。
    nodes =
      this.$slots[name] ||
      (typeof fallbackRender === 'function' ? fallbackRender() : fallbackRender)
  }

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

在渲染子组件插槽的过程中 renderSlot 会根据传入的 $slots 名称获取父组件的分发内容,如果没有找到对应插槽的 VNode,则会调用 fallback 创建后备内容的 VNode,最终用父元素的插槽替换掉子组件的 <slot>内容 。

插槽创建的流程

父组件在编译过程中遇到子组件时也会将子组件编译成对应的 VNode,并将其添加到自身的componentOptions.children 数组中,当子组件创建实例时将父组件上生成的插槽 Vnode 挂载到自身的 $slots 上,在后续生成子组件的 VNode过程中,遇到 <slot> 标签则根据插槽名称从 vm.$slots 获取对应的 VNode,如果没有则创建后备 VNode。最终将子组件的 <slot>内容替换成父组件生成的插槽 VNode。

参考

  1. Vue源码(十)插槽原理
  2. vue源码分析-插槽原理