前言
Vue3探秘系列文章链接:
不止响应式:Vue3探秘系列— 虚拟结点vnode的页面挂载之旅(一)
不止响应式:Vue3探秘系列— diff算法的完整过程(三)
计算属性:Vue3探秘系列— computed的实现原理(六)
Hello~大家好。我是秋天的一阵风
在Vue中我们知道组件是可以能够通过 props
接收任意类型的 JavaScript
数据,那么如果我想往组件传递一个模板内容可不可以呢?答案当然是可以的。在Vue中提供了slot
插槽组件来实现这个功能,我们先来介绍插槽的几种使用方式。
一、插槽的几种使用方式
1. 普通插槽
举例来说,这里有一个 <FancyButton>
组件,可以像这样使用:
<FancyButton>
Click me! <!-- 插槽内容 -->
</FancyButton>
而 <FancyButton>
的模板是这样的:
<button class="fancy-btn">
<slot></slot> <!-- 插槽出口 -->
</button>
<slot>
元素是一个插槽出口 (slot outlet),标示了父元素提供的插槽内容 (slot content) 将在哪里被渲染。
最终渲染出的 DOM 是这样:
<button class="fancy-btn">Click me!</button>
2. 具名插槽
有时在一个组件中包含多个插槽出口是很有用的。举例来说,在一个 <BaseLayout>
组件中,有如下模板:
<div class="container">
<header>
<!-- 标题内容放这里 -->
</header>
<main>
<!-- 主要内容放这里 -->
</main>
<footer>
<!-- 底部内容放这里 -->
</footer>
</div>
对于这种场景,<slot>
元素可以有一个特殊的 attribute name
,用来给各个插槽分配唯一的 ID,以确定每一处要渲染的内容:
<div class="container">
<header>
<slot name="header"></slot>
</header>
<main>
<slot></slot>
</main>
<footer>
<slot name="footer"></slot>
</footer>
</div>
这类带 name
的插槽被称为具名插槽 (named slots)。没有提供 name
的 <slot>
出口会隐式地命名为“default”。
在父组件中使用 <BaseLayout>
时,我们需要一种方式将多个插槽内容传入到各自目标插槽的出口。此时就需要用到具名插槽了:
要为具名插槽传入内容,我们需要使用一个含 v-slot
指令的 <template>
元素,并将目标插槽的名字传给该指令
<BaseLayout>
<template v-slot:header>
<!-- header 插槽的内容放这里 -->
</template>
</BaseLayout>
v-slot
有对应的简写 #
,因此 <template v-slot:header>
可以简写为 <template #header>
。其意思就是“将这部分模板片段传入子组件的 header 插槽中”。
完整代码:
<BaseLayout>
<template #header>
<h1>Here might be a page title</h1>
</template>
<template #default>
<p>A paragraph for the main content.</p>
<p>And another one.</p>
</template>
<template #footer>
<p>Here's some contact info</p>
</template>
</BaseLayout>
具体效果:
<div class="container">
<header>
<h1>Here might be a page title</h1>
</header>
<main>
<p>A paragraph for the main content.</p>
<p>And another one.</p>
</main>
<footer>
<p>Here's some contact info</p>
</footer>
</div>
3. 作用域插槽
(1) 渲染作用域
在介绍作用域插槽之前,我们必须先了解插槽的渲染作用域。
插槽内容可以访问到父组件的数据作用域,因为插槽内容本身是在父组件模板中定义的。举例来说:
<span>{{ message }}</span>
<FancyButton>{{ message }}</FancyButton>
这里的两个 {{ message }}
插值表达式渲染的内容都是一样的。
但是,插槽内容无法访问子组件的数据。
Vue 模板中的表达式只能访问其定义时所处的作用域,这和 JavaScript 的词法作用域规则是一致的。
换言之:
父组件模板中的表达式只能访问父组件的作用域;子组件模板中的表达式只能访问子组件的作用域。
(2) 使用案例
前面提到过父组件中是无法访问到子组件作用域的,那在某些特殊场景,父组件需要用到子组件数据的时候该怎么办呢?也就是需要子组件在渲染的时候将数据提供给插槽。
我们也确实有办法这么做!可以像对组件传递 props
那样,向一个插槽的出口上传递 attributes
:
<!-- <MyComponent> 的模板 -->
<div>
<slot :text="greetingMessage" :count="1"></slot>
</div>
当需要接收插槽 props
时,默认插槽和具名插槽的使用方式有一些小区别。下面我们将先展示默认插槽如何接受 props,通过子组件标签上的 v-slot
指令,直接接收到了一个插槽 props 对象:
<MyComponent v-slot="slotProps">
{{ slotProps.text }} {{ slotProps.count }}
</MyComponent>
子组件传入插槽的 props 作为了 v-slot
指令的值,可以在插槽内的表达式中访问。
你可以将作用域插槽类比为一个传入子组件的函数。子组件会将相应的props
作为参数传给它:
MyComponent({
// 类比默认插槽,将其想成一个函数
default: (slotProps) => {
return `${slotProps.text} ${slotProps.count}`
}
})
function MyComponent(slots) {
const greetingMessage = 'hello'
return `<div>${
// 在插槽函数调用时传入 props
slots.default({ text: greetingMessage, count: 1 })
}</div>`
}
二、插槽的实现原理
插槽的实现其实可以分成两部分,一个是在父组件中准备好子组件插槽部分的模板内部。一个在是在子组件渲染的时候,把这个模板内容填充到子组件对应的插槽中。
但是问题来了,在父组件渲染时,是不能渲染子组件插槽部分的内容。所以我们需要先保存下来,等到子组件渲染的时候再取出来使用。
1. 父组件渲染时的处理
为了更方便的理解,我们先准备一个父组件模板:
<layout>
<template v-slot:header>
<h1>{{ header }}</h1>
</template>
<template v-slot:default>
<p>{{ main }}</p>
</template>
<template v-slot:footer>
<p>{{ footer }}</p>
</template>
</layout>
我们还是跟之前一样借助官方提供的模板导出工具平台 查看它编译后的render函数:
import { toDisplayString as _toDisplayString, createElementVNode as _createElementVNode, resolveComponent as _resolveComponent, withCtx as _withCtx, openBlock as _openBlock, createBlock as _createBlock } from "vue"
export function render(_ctx, _cache, $props, $setup, $data, $options) {
const _component_layout = _resolveComponent("layout")
return (_openBlock(), _createBlock(_component_layout, null, {
header: _withCtx(() => [
_createElementVNode("h1", null, _toDisplayString(_ctx.header), 1 /* TEXT */)
]),
default: _withCtx(() => [
_createElementVNode("p", null, _toDisplayString(_ctx.main), 1 /* TEXT */)
]),
footer: _withCtx(() => [
_createElementVNode("p", null, _toDisplayString(_ctx.footer), 1 /* TEXT */)
]),
_: 1 /* STABLE */
}))
}
// Check the console for the AST
(1) createBlock
我们重点关注createBlock
这个函数,第三个参数接收了一个对象。createBlock
内部实际会调用 createVNode
函数,我们看它的实现:
function createVNode(type,props = null,children = null) {
if (props) {
// 处理 props 相关逻辑,标准化 class 和 style
}
// 对 vnode 类型信息编码
// 创建 vnode 对象
const vnode = {
type,
props
// 其他一些属性
}
// 标准化子节点,把不同数据类型的 children 转成数组或者文本类型
normalizeChildren(vnode, children) return vnode
}
其中,normalizeChildren
就是用来处理传入的参数 children
,我们来看一下它的实现:
function normalizeChildren (vnode, children) {
let type = 0
const { shapeFlag } = vnode
if (children == null) {
children = null
}
else if (isArray(children)) {
type = 16 /* ARRAY_CHILDREN */
}
else if (typeof children === 'object') {
// 标准化 slot 子节点
if ((shapeFlag & 1 /* ELEMENT */ || shapeFlag & 64 /* TELEPORT */) && children.default) {
// 处理 Teleport 的情况
normalizeChildren(vnode, children.default())
return
}
else {
// 确定 vnode 子节点类型为 slot 子节点
type = 32 /* SLOTS_CHILDREN */
const slotFlag = children._
if (!slotFlag && !(InternalObjectKey in children)) {
children._ctx = currentRenderingInstance
}
else if (slotFlag === 3 /* FORWARDED */ && currentRenderingInstance) {
// 处理类型为 FORWARDED 的情况
if (currentRenderingInstance.vnode.patchFlag & 1024 /* DYNAMIC_SLOTS */) {
children._ = 2 /* DYNAMIC */
vnode.patchFlag |= 1024 /* DYNAMIC_SLOTS */
}
else {
children._ = 1 /* STABLE */
}
}
}
}
else if (isFunction(children)) {
children = { default: children, _ctx: currentRenderingInstance }
type = 32 /* SLOTS_CHILDREN */
}
else {
children = String(children)
if (shapeFlag & 64 /* TELEPORT */) {
type = 16 /* ARRAY_CHILDREN */
children = [createTextVNode(children)]
}
else {
type = 8 /* TEXT_CHILDREN */
}
}
vnode.children = children
vnode.shapeFlag |= type
}
-
往
vnode.children
属性上赋值传入的对象数据 -
vnode.shapeFlag
会与slot
子节点类型SLOTS_CHILDREN
进行或运算,由于vnode
本身的shapFlag
是STATEFUL_COMPONENT
,所以运算后的shapeFlag
是SLOTS_CHILDREN | STATEFUL_COMPONENT
。 -
不同的
shapeFlag
会影响后续的patch
过程,我们知道在patch
中会根据 vnode 的type
和shapeFlag
来决定后续的执行逻辑,我们来回顾一下它的实现:
(2)patch 函数
const patch = (n1, n2, container, anchor = null, parentComponent = null, parentSuspense = null, isSVG = false, optimized = false) => {
// 如果存在新旧节点, 且新旧节点类型不同,则销毁旧节点
if (n1 && !isSameVNodeType(n1, n2)) {
anchor = getNextHostNode(n1)
unmount(n1, parentComponent, parentSuspense, true)
n1 = null
}
const { type, shapeFlag } = n2
switch (type) {
case Text:
// 处理文本节点
break
case Comment:
// 处理注释节点
break
case Static:
// 处理静态节点
break
case Fragment:
// 处理 Fragment 元素
break
default:
if (shapeFlag & 1 /* ELEMENT */) {
// 处理普通 DOM 元素
}
else if (shapeFlag & 6 /* COMPONENT */) {
// 处理组件
processComponent(n1, n2, container, anchor, parentComponent, parentSuspense, isSVG, optimized)
}
else if (shapeFlag & 64 /* TELEPORT */) {
// 处理 TELEPORT
}
else if (shapeFlag & 128 /* SUSPENSE */) {
// 处理 SUSPENSE
}
}
}
这里由于 type
是组件对象,shapeFlag
满足shapeFlag&6
的情况,所以会走到processComponent
的逻辑,递归去渲染子组件。
至此,带有子节点插槽的组件与普通的组件渲染并无区别,还是通过递归的方式去渲染子组件。
渲染子组件又会执行组件的渲染逻辑了,这个流程我们在前面的章节已经分析过,其中有一个 setupComponent
的流程,我们来回顾一下它的实现:
(3)setupComponent 函数
function setupComponent (instance, isSSR = false) {
const { props, children, shapeFlag } = instance.vnode
// 判断是否是一个有状态的组件
const isStateful = shapeFlag & 4
// 初始化 props
initProps(instance, props, isStateful, isSSR)
// 初始化插槽
initSlots(instance, children)
// 设置有状态的组件实例
const setupResult = isStateful
? setupStatefulComponent(instance, isSSR)
: undefined
return setupResult
}
这里从vnode
里面取出了props
和children
等等数据,然后在执行initSlots
初始化 插槽的时候传入instance
和children
数据
(4)initSlots 函数
const initSlots = (instance, children) => {
if (instance.vnode.shapeFlag & 32 /* SLOTS_CHILDREN */) {
const type = children._
if (type) {
instance.slots = children
def(children, '_', type)
}
else {
normalizeObjectSlots(children, (instance.slots = {}))
}
}
else {
instance.slots = {}
if (children) {
normalizeVNodeSlots(instance, children)
}
}
def(instance.slots, InternalObjectKey, 1)
}
initSlots
的实现逻辑很简单,这里的 children
就是前面传入的插槽对象数据,然后我们把它保留到 instance.slots
对象中,后续我们就可以从 instance.slots
拿到插槽的数据了。
2. 子组件渲染时的处理
子组件在初始化过程中拿到父组件传入的插槽数据,现在就差最后一步,子组件将插槽数据渲染到页面上。
我们先来看子组件的模板
<div class="layout">
<header>
<slot name="header"></slot>
</header>
<main>
<slot></slot>
</main>
<footer>
<slot name="footer"></slot>
</footer>
</div>
import { renderSlot as _renderSlot, createVNode as _createVNode, openBlock as _openBlock, createBlock as _createBlock } from "vue"
export function render(_ctx, _cache, $props, $setup, $data, $options) {
return (_openBlock(), _createBlock("div", { class: "layout" }, [
_createVNode("header", null, [
_renderSlot(_ctx.$slots, "header")
]),
_createVNode("main", null, [
_renderSlot(_ctx.$slots, "default")
]),
_createVNode("footer", null, [
_renderSlot(_ctx.$slots, "footer")
])
]))
}
(1) renderSlot 函数
通过编译后的代码我们可以看出,子组件的插槽部分的 DOM
主要通过renderSlot
方法渲染生成的,我们来看它的实现:
function renderSlot(slots, name, props = {}, fallback) {
let slot = slots[name];
return (openBlock(),
createBlock(Fragment, { key: props.key }, slot ? slot(props) : fallback ? fallback() : [], slots._ === 1 /* STABLE */
? 64 /* STABLE_FRAGMENT */
: -2 /* BAIL */));
}
renderSlot
的实现非常简单,就是根据传入的name
,去slots
中找到对应的slot
插槽数据。 slots
也就是下面这个数据
{
header: _withCtx(() => [
_createVNode("h1", null, _toDisplayString(_ctx.header), 1 /* TEXT */)
]),
default: _withCtx(() => [
_createVNode("p", null, _toDisplayString(_ctx.main), 1 /* TEXT */)
]),
footer: _withCtx(() => [
_createVNode("p", null, _toDisplayString(_ctx.footer), 1 /* TEXT */)
]),
_: 1
}
那么对于 name
为 header
,它的值就是:
_withCtx(() => [
_createVNode("h1", null, _toDisplayString(_ctx.header), 1 /* TEXT */)
])
找到对应的slot插槽数据之后,会通过 createBlock
创建了 vnode
节点
注意,它的类型是一个 Fragment,children 是执行 slot 插槽函数的返回值。
(2)withCtx
slot
函数其实是执行 _withCtx
函数后的返回值,我们接着看 withCtx
函数的实现:
function withCtx(fn, ctx = currentRenderingInstance) {
if (!ctx)
return fn
return function renderFnWithContext() {
const owner = currentRenderingInstance
setCurrentRenderingInstance(ctx)
const res = fn.apply(null, arguments)
setCurrentRenderingInstance(owner)
return res
}
}
-
withCtx
的实现很简单,它支持传入一个函数fn
和执行的上下文变量ctx
,它的默认值是currentRenderingInstance
,也就是执行render
函数时的当前组件实例。 -
withCtx
会返回一个新的函数,这个函数执行的时候,会先保存当前渲染的组件实例owner
,然后把ctx
设置为当前渲染的组件实例,接着执行f
n,执行完毕后,再把之前的owner
设置为当前组件实例。 -
这么做就是为了保证在子组件中渲染具体插槽内容时,它的渲染组件实例是父组件实例,这样也就保证它的数据作用域也是父组件的了。
所以对于 header
这个 slot
,它的slot
函数的返回值是一个数组,如下:
[
_createVNode("h1", null, _toDisplayString(_ctx.header), 1 /* TEXT */)
]
我们回到 renderSlot
函数,最终插槽对应的 vnode
渲染就变成了如下函数
createBlock(Fragment, { key: props.key }, [_createVNode("h1", null, _toDisplayString(_ctx.header), 1 /* TEXT */)], 64 /* STABLE_FRAGMENT */)
我们知道,createBlock
内部是会执行 createVNode
创建 vnode
,vnode
创建完后,仍然会通过patch
把 vnode
挂载到页面上,那么对于插槽的渲染,patch
过程又有什么不同呢?
(3)processFragment
注意这里我们的 vnode
的 type
是 Fragement
,所以在执行patch
的时候,会执行 processFragment
逻辑,我们来看它的实现:
const processFragment = (n1, n2, container, anchor, parentComponent, parentSuspense, isSVG, optimized) => {
const fragmentStartAnchor = (n2.el = n1 ? n1.el : hostCreateText(''))
const fragmentEndAnchor = (n2.anchor = n1 ? n1.anchor : hostCreateText(''))
let { patchFlag } = n2
if (patchFlag > 0) {
optimized = true
}
if (n1 == null) {
//插入节点
// 先在前后插入两个空文本节点
hostInsert(fragmentStartAnchor, container, anchor)
hostInsert(fragmentEndAnchor, container, anchor)
// 再挂载子节点
mountChildren(n2.children, container, fragmentEndAnchor, parentComponent, parentSuspense, isSVG, optimized)
} else {
// 更新节点
}
}
我们只分析挂载子节点的过程,所以 n1 的值为 null
,n2 就是我们前面创建的 vnode
节点,它的children
是一个数组。
processFragment
函数首先通过hostInsert
在容器的前后插入两个空文本节点,然后在以尾文本节点作为参考锚点,通过 mountChildren
把children
挂载到container
容器中。
至此,我们就完成了子组件插槽内容的渲染。
总结
可以看到,插槽的实现实际上就是一种延时渲染,把父组件中编写的插槽内容保存到一个对象上,并且把具体渲染 DOM
的代码用函数的方式封装,然后在子组件渲染的时候,根据插槽名在对象中找到对应的函数,然后执行这些函数做真正的渲染。