vue 的插槽实现原理 之 具名插槽
前言
在 vue -- 插槽的使用方法 中不是说到了嘛,对插槽的编译作用域中的一句话不是很理解:
父级模板里的所有内容都是在父级作用域中编译的;子模板里的所有内容都是在子作用域中编译的。
那么就来继续探究一下插槽的实现原理,本期的内容也是稍微有点长的,就分为了上下两部分,这一次先来看看具名插槽的实现方式。
透过现象看本质
插槽的本质是实现内容分发,实现内容分发,需要两个条件:
- 占位符
- 分发内容
组件内部定义的slot标签,就是所谓的占位符,父组件中写入的插槽内容,就是要分发的内容。 插槽处理本质就是将指定内容放到指定位置。
组件挂载顺序
在进入 slot 讲解之前我们必须要知道的父子组件挂载流程: 父组件状态初始化 ( data 、 computed 、 watch ...、包括插槽的初始化) ==> 父组件 beforeCreate ==> 实现父组件中的数据响应式 ==>> 父组件 created ==>> 进入模板编译阶段生成 render() 渲染函数 ==>>父组件顺利执行到 beforeMount 阶段 ==>> 在 patch 的过程中发现子组件的存在时会暂停父组件的虚拟节点转换,转而开始子组件的实例化过程 ==>> 子组件 beforeCreate ==>> 子组件 created ==>> 子组件 beforeMount ==>> 子组件 mounted 将生成的真实DOM挂载到父组件的DOM上 ==>> 继续完成父组件的 mounted
tips:如果对组件加载流程不太清楚的同学可以先去看看前面的两篇文章: 组件创建的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。
参考
转载自:https://juejin.cn/post/7245613765692391481