Vue2.x 源码 -slot

上一篇:Vue2.x 源码 - 异步组件

Vue 的组件提供了⼀个⾮常有⽤的特性 —— slot 插槽,它让组件的实现变的更加灵活。我们平时在 开发组件库的时候,为了让组件更加灵活可定制,经常⽤插槽的⽅式让⽤户可以⾃定义内容。

插槽分为:默认插槽、匿名插槽 、命名插槽 和 作⽤域插槽,它们可以解决不同的场景,但它是怎么实现的呢,下⾯我们就从源码的⾓ 度来分析插槽的实现原理。

Vue 2.6.0之后采用全新v-slot语法取代之前的slot、slot-scope

源码

组件生命周期
slot 并不是独立的一个模块,而是贯穿整个编译、渲染的过程中;

1、Vue 组件渲染过程:父beforeCreate => 父created => 父beforeMount => 子beforeCreate => 子created => 子beforeMount => 子mounted => 父mounted

2、Vue 组件更新过程:父beforeUpdate=> 子beforeUpdate=> 子updated=> 父updated

3、Vue 组件销毁过程:父beforeDestroy=> 子beforeDestroy=> 子destroyed=> 父destroyed

4、Vue 组件挂载顺序:

1、父组件初始化,如果不需要编译则直接开始挂载;需要编译则先编译然后挂载;
2、挂载过程中会将render函数生成vnode;这个过程主要错了下面几件事:构造子类构造器、安装组件钩子函数、实例化vnode;
3、vnode之后会走_update,然后会_patch_,在patch 过程中执行createElm 创建真实节点的时候,遇到组件节点则 createComponent 来创建组件;
4、createComponent 会调用之前绑定的钩子函数 init,这里会创建子组件实例 child 然后执行 child.$mount;
5、接下来就开始子组件的初始化,编译,渲染;

整个挂载过程就是组件不断循环递归 render、update、patch 的过程,直到整颗树挂载完毕为止。

子组件初始化

在父组件 vnode 实例化之后,在进行 patch 的时候,发现子节点是一个组件节点,这个时候会进行子组件 vnode 的实例化操作;在 _init 中,先在子组件中拿到父组件保存的内容,记录在子组件实例$options._renderChildren中;

Vue.prototype._init = function(options) {
  ....
  if (options && options._isComponent) {
    initInternalComponent(vm, options);
  }
  ....
  initRender(vm)
}
function initInternalComponent (vm, options) {
  var opts = vm.$options = Object.create(vm.constructor.options);
  var parentVnode = options._parentVnode;
  // componentOptions为子vnode记录的相关信息
  var vnodeComponentOptions = parentVnode.componentOptions;
  // 父组件需要分发的内容赋值给子选项配置的_renderChildren
  opts._renderChildren = vnodeComponentOptions.children;
  ....
}

然后在 initRender 的时候,会将配置的_renderChildren属性做规范化处理,并将他赋值给子实例上的$slot属性,看看下面的操作:

  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)
  vm.$scopedSlots = emptyObject

这里定义了实例上的 $slots 、$scopedSlots 属性,$scopedSlots 定义为空对象,$slots 是通过 resolveSlots 返回的,在 src/core/instance/render-helpers/resolve-slots.js 中:

export function resolveSlots (
  children: ?Array<VNode>, //slot 的 vnode 内容
  context: ?Component //slot 所在实例
): { [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]
     // 当前子节点的data属性
    const data = child.data
    // 如果节点被解析为Vue槽位节点,则删除槽位属性
    if (data && data.attrs && data.attrs.slot) {
      delete data.attrs.slot
    }
    // 判断是否为具名插槽,如果为具名插槽,还需要 子组件 / 函数子组件 渲染上下文一致。
    if ((child.context === context || child.fnContext === context) &&
      data && data.slot != null
    ) {
    // 获取slot的名称
      const name = data.slot
      // 如果slots[name]不存在,则初始化为一个空数组
      const slot = (slots[name] || (slots[name] = []))
      // 如果是tempalte元素 则把template的children添加进数组中
      if (child.tag === 'template') {
        slot.push.apply(slot, child.children || [])
      } else {
        slot.push(child)
      }
    } else {
    // 返回匿名default插槽VNode数组
      (slots.default || (slots.default = [])).push(child)
    }
  }
  // 忽略仅仅包含whitespace的插槽
  for (const name in slots) {
    if (slots[name].every(isWhitespace)) {
      delete slots[name]
    }
  }
  return slots
}

1、两个入参:
第一个参数:是当前节点对应父节点的所有子节点;
第二个参数:父节点的上下文,也是父组件的实例;
2、循环子节点,拿到 data 属性;如果当前插槽已经被解析则删除这个插槽;
3、如果是命名插槽,获取插槽名称,将对应的 child 添加到 slots中,否则就加入匿名插槽的数组中;
4、从 slots 中删除掉所有名称为空格的插槽;

resolveSlots 方法主要是对 options._renderChildren 通过 name 将 <slot> 替换成相应的内容,并将解析的结果赋值到 vm.$slots;

父 / 子组件编译(parse)

在解析 AST 阶段,插槽和其他普通标签的处理是相同的,不同的是在编译阶段,在 AST 生成 render 函数的时候,对插槽的处理会用 _t 函数进行包裹;

编译父组件时,会执行 parseHTML 方法然后调用 processElement 方法,里面有个 processSlotContent 方法来处理插槽相关内容;

1、在 src/compiler/parser/index.js 里面:

const slotRE = /^v-slot(:|$)|^#/
export const emptySlotScopeToken = `_empty_`
function processSlotContent (el) {
  let slotScope
  // ...省略代码
  if (el.tag === 'template') {
    // v-slot on <template>
    const slotBinding = getAndRemoveAttrByRegex(el, slotRE)
    if (slotBinding) {
      // ..异常处理
      const { name, dynamic } = getSlotName(slotBinding)
      el.slotTarget = name
      el.slotTargetDynamic = dynamic
      el.slotScope = slotBinding.value || emptySlotScopeToken
    }
  }
  // ...省略代码
}

1、首先调用getAndRemoveAttrByRegex方法并给第二个参数传入slotRE正则表达式,用来获取并移除当前 ast 对象上的 v-slot 属性;
2、随后通过调用getSlotName方法来获取插槽的名字以及获取是否为动态插槽名;
3、将插槽名字赋值给 slotTargetslotTargetDynamic 赋值是否是动态组件;
4、最后如果正则解析到有作用域插槽,则赋值给slotScope属性,如果没有则取一个默认的值_empty_

然后在 closeElement 方法中:

if (element.slotScope) {
  // scoped slot
  // keep it in the children list so that v-else(-if) conditions can
  // find it as the prev node.
  const name = element.slotTarget || '"default"'
  ;(currentParent.scopedSlots || (currentParent.scopedSlots = {}))[name] = element
}
currentParent.children.push(element)
element.parent = currentParent

当 ast 对象存在 slotScope 属性的时候,把当前 ast 节点挂到父级的 scopedSlots 属性上面;然后又维护了父子 AST 的树形结构;

上面两步主要是给对应的 AST 元素节点添加 slotTarget 属性;然后在 codegen 阶段,在 genData 中会处理 slotTarget

2、在 src/compiler/codegen/index.js 中:
父组件的 AST 节点上会有 el.slot = xxx ,tag 是 component 或者 element都会走 genData 方法;

export function genData (el: ASTElement, state: CodegenState): string {
  // ...省略代码
  // slot target
  // only for non-scoped slots
  if (el.slotTarget && !el.slotScope) {
    data += `slot:${el.slotTarget},`
  }
  // scoped slots
  if (el.scopedSlots) {
    data += `${genScopedSlots(el, el.scopedSlots, state)},`
  }
  // ...省略代码
}

会给 data 添加⼀个 slot 属性,并指向 slotTarget ,之后会⽤到;

由于父组件有 scopedSlots 属性,所以会调用 genScopedSlots 方法来处理:

function genScopedSlots (
  el: ASTElement,
  slots: { [key: string]: ASTElement },
  state: CodegenState
): string {
  // 默认情况下,有作用域的槽被认为是“稳定的”,这允许只有有作用域槽的子组件跳过来自父组件的强制更新。
  // 但在某些情况下,我们必须摆脱这种优化,例如,如果槽包含动态名称,有v-if或v-for。
  let needsForceUpdate = el.for || Object.keys(slots).some(key => {
    const slot = slots[key]
    return (
      slot.slotTargetDynamic ||
      slot.if ||
      slot.for ||
      containsSlotChild(slot) // 传递槽的父组件可能是动态的
    )
  })
  // 如果具有限定范围槽位的组件位于条件分支中,可以使用不同的组件重用相同的组件编制内容。
  // 为了避免这种情况,我们生成一个基于生成的所有槽位内容的代码。
  let needsKey = !!el.if
  // 遍历所有父组件,slotScope不为空或者存在for 则强制更新
  if (!needsForceUpdate) {
    let parent = el.parent
    while (parent) {
      if (
        (parent.slotScope && parent.slotScope !== emptySlotScopeToken) ||
        parent.for
      ) {
        needsForceUpdate = true
        break
      }
      if (parent.if) {
        needsKey = true
      }
      parent = parent.parent
    }
  }

  const generatedSlots = Object.keys(slots)
    .map(key => genScopedSlot(slots[key], state))
    .join(',')

  return `scopedSlots:_u([${generatedSlots}]${
    needsForceUpdate ? `,null,true` : ``
  }${
    !needsForceUpdate && needsKey ? `,null,false,${hash(generatedSlots)}` : ``
  })`
}

genScopedSlot会拼凑每个slots,对 fn 变量赋值 ,然后会在genScopedSlots 里面返回scopedSlots:_u([])函数字符串;

子组件的编译(codegen)

parse 阶段跟上面一样,但是到了 codegen 阶段就有了差别:当调用genElement方法时,子组件的 tag 为 slot 所以会调用 genSlot 方法:

function genSlot (el: ASTElement, state: CodegenState): string {
  const slotName = el.slotName || '"default"'
  const children = genChildren(el, state)
  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 + ')'
}

genSlot 从 AST 元素节点对应的属性上取 slotName,默认是 default ,⽽ children 对应的就是 slot 开始和闭合标签包裹的内容 ,返回一个字符串 _t(...略),这个_t函数最后在运行时会生成VNode;

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

上面这些编译阶段代码可以理解成对 slot 数据格式的整理,方便后面使用,如果想看看数据的转换格式可以手动调试一步一步看看数据的变化。

render 生成 vnode 阶段

rebder 函数生成 vnode 的时候会调用 _c、_u、_v以及 _t 这些函数,在这几个函数中我们重点关注_u_t这两个函数,_u函数的代码如下,它定义在src/core/instance/render-helpers/resolve-scoped-slots.js文件中:

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

resolveScopedSlots函数调用的时候,我们传递了一个fns数组,在这个方法中首先会遍历fns,然后把当前遍历的对象赋值到res对象中,其中slot.key当做键,slot.fn当做值,返回 res;主要作用是处理命名插槽;

_t函数的代码如下,它定义在src/core/instance/render-helpers/render-slot.js文件中:

// 渲染slot组件内容
export function renderSlot (
  name: string,
  fallback: ?Array<VNode>, // slot插槽后备内容(针对后备内容)
  props: ?Object, // 子传给父的值
  bindObject: ?Object
): ?Array<VNode> {
//拿到父组件插槽的执行函数,默认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) || fallback
  } else {
  // 如果父占位符组件没有插槽内容,this.$slots不会有值,此时vnode节点为后备内容节点。
    nodes = this.$slots[name] || fallback
  }
  const target = props && props.slot
  if (target) {
    return this.$createElement('template', { slot: target }, nodes)
  } else {
    return nodes
  }
}

renderSlot执行过程会拿到父组件需要分发的内容,最终Vnode树将父元素的插槽替换掉子组件的slot组件。

默认插槽

//子组件
<template>
	<div>
		<slot>默认的插槽</slot>
	</div>
</template>
<script>
export default {
	name:'child'
}
</script>
//父组件
<child>插槽内容</child>

子组件插槽中有默认值的情况,如果父组件不传入对应的值,则显示插槽默认值,如果传值了则显示传递过来的值,一般用以按钮的文字显示;

源码流程:

1、父组件渲染过程中没有需要分发的子节点,所以 s l o t 里 面 就 没 有 内 容 ; 2 、 子 组 件 在 t 方 法 中 , 该 方 法 第 二 个 参 数 以 数 组 的 方 式 传 入 s l o t 标 签 的 默 认 内 容 ; 3 、 渲 染 子 节 点 的 时 候 执 行 r e n d e r S l o t 第 二 个 参 数 有 值 , slot里面就没有内容; 2、子组件在 _t 方法中,该方法第二个参数以数组的方式传入 slot 标签的默认内容; 3、渲染子节点的时候执行 renderSlot 第二个参数有值, slot2tslot3renderSlotslot 没值,就会返回默认内容作为渲染对象;

匿名插槽

//子组件
<template>
	<div>
		<slot></slot>
	</div>
</template>
<script>
export default {
	name:'child'
}
</script>

//父组件
<child>插槽内容</child>

这里会将 <child> 标签包裹的内容渲染的子组件的 <slot> 标签里面,一般匿名插槽是在只有一个插槽的时候使用,当然也可以配合名称插槽一起使用;可以用 v-slot="default" 来表示;

源码流程:

1、父组件渲染的时候拿到分发内容存到 $slot.default (resolveSlots 方法中处理)中;
2、子组件在 _t 方法中第一个参数将slotName 传入,这里传入的是 default;
3、 渲染子节点的时候执行 renderSlot 第一个参数 $slot[name] 有值,就会返回 $slot[name] 对应的插槽内容作为渲染对象;

命名插槽

//子组件
<template>
	<div>
		<slot name=“header”></slot>
		<slot></slot>
		<slot name=“footer”></slot>
	</div>
</template>
<script>
export default {
	name:'child'
}
</script>

//父组件
<child>
	<template v-slot:header>插槽头部</template>
	<template v-slot>插槽头部</template>
	<template v-slot:footer>插槽尾部</template>
</child>

父组件里的插槽内容会根据 name 和 v-slot 对应的值匹配,根据匹配到的名称将对应的内容渲染到对应的位置;

源码流程:

1、在父组件编译阶段用 scopedSlots 来记录命名插槽的内容;
2、在父组件生成 vnode 阶段,在父组件的 vnode 节点的 data 对象上增加 scopedSlots 数组;
3、子组件解析时,在子组件的实例上的 s c o p e d S l o t s 属 性 保 存 父 组 件 插 槽 相 关 内 容 , 这 时 的 内 容 是 一 函 数 的 方 式 保 存 ; 4 、 子 组 件 在 r e n d e r S l o t 方 法 时 , scopedSlots 属性保存父组件插槽相关内容,这时的内容是一函数的方式保存; 4、子组件在 renderSlot 方法时, scopedSlots4renderSlotscopedSlots 有值,会执行 nodes = scopedSlotFn(props) , 执行对应名称的方法;

命名插槽和普通插槽实现上有明显的不同,普通插槽是以 componentOptions.children的形式保留在父组件中,而具名插槽是以 scopedSlots 属性的形式存储到 data 属性中。

作用域插槽

//子组件
<template>
	<div>
		<slot v-bind:user="user">{{user.a}}</slot>
	</div>
</template>
<script>
export default {
	name:'child',
	data(){
		return {
			user:{
				a:'1',
				b:'2'
			}
		}
	}
}
</script>

//父组件
<child>
	<template v-slot:default="slotProps">{{slotProps.user.b}}</template>
</child>

让父组件插槽能访问子组件里面的数据;
1、在子组件中,将 user 作为 slot 标签的属性绑定来;
2、父组件用 v-slot:default 来定义绑定的名称,通过这个名称可以拿到子组件绑定在 slot 上的对象的值;
3、这样就可以在父组件的作用域下使用子组件的属性了。

源码流程:

1、作用域插槽和具名插槽在父组件的用法基本相同,区别在于 v-slot 定义了一个插槽 props 的名字,参考对于具名插槽的分析,生成render 函数阶段(resolveScopedSlots) fn 函数会携带 props 参数传入;
2、子组件编译阶段(genSlot),会将子组件的 props 以属性的形式解析,在 render 函数生成的时候以对象参数的形式传入给 _t 函数,第三个参数;
3、子组件渲染阶段,分析 renderSlot 的时候,会拿到第三个参数 props ,并传递给执行函数执行;

插槽的更新

上面说的是插槽的编译、渲染过程,那么插槽是怎么更新的呢?下面来看看。

在父组件上响应式变量发生变化时,父组件会开始更新,在更新过程中触发 _patch_ 方法,其中核心部分在 prepatch 钩子函数中调用的 updateChildComponent 方法,在 src/core/instance/lifeCycle.js 文件中:

export function updateChildComponent (
  vm: Component,
  propsData: ?Object,
  listeners: ?Object,
  parentVnode: MountedComponentVNode,
  renderChildren: ?Array<VNode>
) {
  // ...省略代码
  const newScopedSlots = parentVnode.data.scopedSlots
  const oldScopedSlots = vm.$scopedSlots
  const hasDynamicScopedSlot = !!(
    (newScopedSlots && !newScopedSlots.$stable) ||
    (oldScopedSlots !== emptyObject && !oldScopedSlots.$stable) ||
    (newScopedSlots && vm.$scopedSlots.$key !== newScopedSlots.$key)
  )
  const needsForceUpdate = !!(
    renderChildren ||               // has new static slots
    vm.$options._renderChildren ||  // has old static slots
    hasDynamicScopedSlot
  )
  // ...省略代码
  // resolve slots + force update if has children
  if (needsForceUpdate) {
    vm.$slots = resolveSlots(renderChildren, parentVnode.context)
    vm.$forceUpdate()
  }
}

needsForceUpdate 为真则强制进行子组件的重新渲染,可以发现 needsForceUpdate 是受到 $stable$key这两个属性影响;

除了父组件响应式变量的变化会造成强制子组件重新渲染外,v-if 、v-for 以及动态插槽名在父组件上使用时,数据变化时也会触发子组件的重新渲染(可参考 genScopedSlots 方法中 needsForceUpdate 取值的部分)。

下一篇:Vue2.x 源码 - keep-alive

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值