开启掘金成长之旅!这是我参与「掘金日新计划 · 2 月更文挑战」的第 11 天,点击查看活动详情
插槽
Vue
中允许通过插槽的形式,以一种不同于严格的父子关系的方式组合组件,插槽提供了一种将内容放置到新位置或使组件更通用的方式。Vue
按照将插槽分成普通插槽、具名插槽、作用域插槽。
普通插槽
插槽使用 <slot></slot>
作为子组件承载分发的载体,简单的用法如下
const child = {
template: `<div class="child"><slot></slot></div>`
}
const vm = new Vue({
el: "#root",
components: {
child
},
template: `<div><child><span>name</span></child></div>`
})
//最终子组件渲染结果
<div class="child"><span>name</span></div>
挂载原理
插槽的原理,设计到了组件系统从编译到渲染整个流程,在这里,先回顾组件的相关流程
- 从根实例开始,进行实例的挂载,如果有手写的
render
函数,则直接进入$mount
流程 - 只有
template
模版则需要对模版进行解析,这里分为两个阶段,一个时将模版解析为AST
树,另一个是根据不同平台生成执行代码,例如render
函数 - 拿到
render
函数之后,进入$mount
流程,$mount
也分为两步,第一步是将render
函数生成虚拟 DOM , 如果遇到子组件会生成子组件,子组件会以vue-component-
为tag
标记,另一部是将虚拟DOM
渲染成真正的DOM
节点 - 创建真实
DOM
节点过程中,如果遇到组件占位符,则会进行子组件的实例化过程,这个过程又会到第一步
回到组件实例化的流程中,父组件会优先于子组件的进行实例挂载,模版的解析和 render
函数的生成在处理上没有特殊的差异。在 render
函数生成虚拟 DOM
过程中,这个阶段会遇到组件占位符,因此会创建子组件的虚拟 DOM
, createComponent
执行了创建子组件的虚拟 DOM
。
function createComponent (
Ctor: Class<Component> | Function | Object | void,
data: ?VNodeData,
context: Component,
children: ?Array<VNode>, // 父组件需要分发的内容
tag?: string
): VNode | Array<VNode> | void {
// ...
// install component management hooks onto the placeholder node
// 安装组件钩子函数
installComponentHooks(data)
// return a placeholder vnode
const name = Ctor.options.name || tag
// 创建子组件的虚拟 DOM, 其中父组件中需要分发的内容会以选项的形式传递给 Vnode 构造函数
const vnode = new VNode(
`vue-component-${Ctor.cid}${name ? `-${name}` : ''}`,
data, undefined, undefined, undefined, context,
{ Ctor, propsData, listeners, tag, children },
asyncFactory
)
return vnode
}
createComponent
函数接受的第四个参数 children
就是父组件需要分发的内容。在创建子组件虚拟 DOM
过程中,会以 componentOptions
配置传入 Vnode
构造函数中。
子组件流程
父组件的最后一个阶段就是将虚拟 DOM
渲染为真正的 DOM
节点,在这个过程中如果遇到子组件的虚拟 DOM
会优先实例化子组件并进行一系列子组件的渲染流程。子组件在初始化过程中,会调用 initInternalComponent
方法,拿到父组件拥有过的相关配置,并保存在自身的配置选项中
Vue.prototype._init = function (options?: Object) {
// a flag to avoid this being observed
vm._isVue = true
// merge options
if (options && options._isComponent) {
// 在初始化子组件时,合并子组件的 options
initInternalComponent(vm, options)
} else {
// 外部调用 new Vue 时合并 options
vm.$options = mergeOptions(
resolveConstructorOptions(vm.constructor),
options || {},
vm
)
}
}
function initInternalComponent (vm: Component, options: InternalComponentOptions) {
const opts = vm.$options = Object.create(vm.constructor.options)
// doing this because it's faster than dynamic enumeration.
const parentVnode = options._parentVnode
opts.parent = options.parent
opts._parentVnode = parentVnode
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
}
}
最终在子组件实例的配置中拿到了父组件中保存的分发内容,记录在组件实例 $options._renderChildren
中,这是第二步的重点。
接下来是子组件的实例化,会进入 initRender
阶段,这个过程会将配置的 _renderChildren
属性做规范化处理,并将他赋值给实例上的 $slot
属性,这是第三步的重点
function initRender(vm: Component){
// ....
vm.$slots = resolveSlots(options._renderChildren, renderContext)
}
function resolveSlots (
children: ?Array<VNode>,
context: ?Component
): { [key: string]: Array<VNode> } {
// children 是父组件需要分发到子组件中的虚拟 DOM 节点,如果不存在,则没有分发内容
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
// remove slot attribute if the node is resolved as a Vue slot node
if (data && data.attrs && data.attrs.slot) {
delete data.attrs.slot
}
// named slots should only be respected if the vnode was rendered in the
// same context.
// 具名插槽分支逻辑
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]} 对象返回
(slots.default || (slots.default = [])).push(child)
}
}
// ignore slots that contains only whitespace
for (const name in slots) {
if (slots[name].every(isWhitespace)) {
delete slots[name]
}
}
return slots
}
对于普通插槽而言,主要逻辑就是将需要分发的内容以属性的形式保存在子组件实例的 $slot
属性中。随后,子组件也会走挂载流程,同样会经历模版编译得到 render
函数,在到虚拟 DOM
,最后渲染真实 DOM
的过程。在解析 AST
阶段, slot
标签和其他标签的处理相同,不同之处在于 AST
生成 render
函数的阶段 ,对 slot
标签的处理,会使用 _t
函数进行包裹。
function genElement (el: ASTElement, state: CodegenState): string {
if (el.tag === 'slot') {
return genSlot(el, state)
}
}
function genSlot (el: ASTElement, state: CodegenState): string {
const slotName = el.slotName || '"default"'
// 如果子组件的插槽还有元素,则递归执行子组件的元素创建
const children = genChildren(el, state)
// 通过 _t 函数包裹
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 + ')'
}
第五步到了子组件渲染为虚拟 DOM
的过程, render
函数执行阶段会执行 _t
函数, _t
函数 renderSlot
函数简写,该函数会在虚拟 DOM
树中进行分发内容的替换。
Vue.prototype._render = function (): VNode {
if (_parentVnode) {
// slot 的规范化处理
vm.$scopedSlots = normalizeScopedSlots(
_parentVnode.data.scopedSlots,
vm.$slots,
vm.$scopedSlots
)
}
}
在获取到 $scopedSlots
之后,会执行真正的 render
函数,来看下 _t
函数的执行逻辑
export function renderSlot (
name: string,
fallback: ?Array<VNode>, // slot 插槽的后背内容
props: ?Object, // 子组件传给父组件的值(针对作用域插槽)
bindObject: ?Object
): ?Array<VNode> {
// 拿到父组件插槽内容的执行函数,默认的 name 为 default
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 = extend(extend({}, bindObject), props)
}
// 执行函数是将子组件传递给父组件的值传入 fn
nodes = scopedSlotFn(props) || fallback
} else {
// 如果占位符中没有插槽内容, this.$slot[name] 不会有值,则使用后备节点的内容
nodes = this.$slots[name] || fallback
}
const target = props && props.slot
if (target) {
return this.$createElement('template', { slot: target }, nodes)
} else {
return nodes
}
}
renderSlot
执行过程会拿到父组件需要分发的内容,最终虚拟 DOM
树将父组件中的插槽替换成子组件的 slot
组件。最后一步就是子组件真实节点的渲染,这一过程与普通组件一致。
后备插槽
有时需要为插槽设置具体的后备内容(也就是默认的)后备内容会在没有提供内容是被渲染
const child = {
template: "<div>后备内容</div>"
}
const vm = new Vue({
el: "#app",
components: {
child
},
template: "<div><child></child></div>"
})
// 父组件中没有设置插槽的内容,子组件的 slot 会渲染后备内容
当父组件中没有需要分发的内容是,子组件会默认显示插槽里面的内容。
- 父组件渲染过程中由于没有需要分发的字节点,所以不在需要
componentOptions.children
属性来记录内容 - 子组件中获取不到
$slot
属性的内容 - 子组件的
render
函数最后在执行 _t 函数是会携带第二个参数,该参数以数组形式传入 - 在执行
renderSlot
方法是,第二个参数fallback
有值,且this.$slots
为undefined
,vnode
会直接返回作为渲染对象。
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 = extend(extend({}, bindObject), props)
}
nodes = scopedSlotFn(props) || fallback
} else {
// 父组件中的组件占位符中没有提供插槽内容, 这是会使用后备内容 fallback
nodes = this.$slots[name] || fallback
}
const target = props && props.slot
if (target) {
return this.$createElement('template', { slot: target }, nodes)
} else {
return nodes
}
}
最终,在父组件没有提供分发内容时,会渲染 slot
的后备内容
在 Vue
官方文档中,对与 slot
存在这样一条规则
父级模版里的所有内容搜是在父级作用域中编译的;子模版里的所有内容都是在子用于里编译的
父组件模版的内容在父组件编译阶段就确定了,并且保存在 componentOptions
属性中,而子组件自身初始化 init
的过程,这个过程同样会进行子作用域的模版编译,因此两部分是相互独立的。