前面学习的 Props 让组件支持不同配置实现不同功能。但有些时候希望子组件模板中的部分内容可以定制化,Props 就显得不够灵活和易用。Vue.js 受到 Web Component 草案启发,通过插槽实现内容分发:允许在父组件中编写 DOM,并在子组件渲染时把 DOM 添加到子组件的插槽中。
分析插槽实现前,先回顾插槽的使用方法。
插槽的用法
简单例子,假设有 TodoButton 子组件:
<button class="todo-button">
<slot></slot>
</button>父组件中这么使用 TodoButton 组件:
<todo-button>
<!-- 添加一个字体图标 -->
<i class="icon icon-plus"></i>
Add todo
</todo-button>即在 todo-button 标签内部编写插槽中的 DOM 内容,最终 TodoButton 组件渲染的 HTML:
<button class="todo-button">
<!-- 添加一个字体图标 -->
<i class="icon icon-plus"></i>
Add todo
</button>这是最简单的普通插槽用法。有时希望子组件有多个插槽,再举例,假设有布局组件 Layout:
<div class="layout">
<header>
<slot name="header"></slot>
</header>
<main>
<slot></slot>
</main>
<footer>
<slot name="footer"></slot>
</footer>
</div>Layout 组件中定义了多个插槽,其中两个插槽标签加了 name 属性(未设置 name 则默认 name 为 default)。父组件中使用 Layout 组件:
<template>
<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>
</template>
<script>
export default {
data (){
return {
header: 'Here might be a page title',
main: 'A paragraph for the main content.',
footer: 'Here\'s some contact info'
}
}
}
</script>使用 template 以及 v-slot 指令把内部 DOM 分发到子组件对应插槽中,最终 Layout 组件渲染的 HTML:
<div class="layout">
<header>
<h1>Here might be a page title</h1>
</header>
<main>
<p>A paragraph for the main content.</p>
</main>
<footer>
<p>Here's some contact info</p>
</footer>
</div>这是命名插槽用法,实现了在组件中定义多个插槽的需求。注意:父组件在插槽中引入的数据,作用域是父组件的。
有时希望父组件填充插槽内容时使用子组件的数据,Vue.js 提供作用域插槽实现该需求。
举例,TodoList 子组件:
<template>
<ul>
<li v-for="(item, index) in items">
<slot :item="item"></slot>
</li>
</ul>
</template>
<script>
export default {
data() {
return {
items: ['Feed a cat', 'Buy milk']
}
}
}
</script>给 slot 标签加上 item 属性,目的是传递子组件中的 item 数据。父组件中这么使用 TodoList 组件:
<todo-list>
<template v-slot:default="slotProps">
<i class="icon icon-check"></i>
<span class="green">{{ slotProps.item }}<span>
</template>
</todo-list>v-slot 指令的值为 slotProps(一个对象),包含子组件往 slot 标签中添加的 props。本例中 v-slot 包含 item 属性,可在内部使用 slotProps.item,最终 TodoList 子组件渲染的 HTML:
<ul>
<li v-for="(item, index) in items">
<i class="icon icon-check"></i>
<span class="green">{{ item }}<span>
</li>
</ul>这是作用域插槽用法,实现了父组件填写插槽内容时使用子组件传递数据的需求。
以上是插槽常见使用方式,下面分析插槽背后的实现原理。
插槽的实现
分析代码前,先明确插槽的特点:在父组件中编写子组件插槽部分的模板,子组件渲染时把这部分模板内容填充到插槽中。
因此父组件渲染阶段,子组件插槽部分的 DOM 不能渲染,需要通过某种方式保留,等子组件渲染时再渲染。顺着这个思路分析具体实现。
仍通过例子分析插槽实现的整个流程,先看父组件模板:
<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, createVNode as _createVNode, 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(() => [
_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
}))
}前面学习过 createBlock,其内部通过 createVNode 创建 vnode,注意 createBlock 的第三个参数表示创建的 vnode 子节点,本例中是一个对象。
通常创建 vnode 传入的子节点是数组,对于对象类型的子节点,内部做了哪些处理?回顾 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
}normalizeChildren 的主要作用是标准化 children 并获取 vnode 的节点类型 shapeFlag。
关注插槽相关逻辑:处理后 vnode.children 仍是传入的对象数据,vnode.shapeFlag 会与 slot 子节点类型 SLOTS_CHILDREN 做或运算。由于 vnode 本身的 shapeFlag 是 STATEFUL_COMPONENT,运算后的 shapeFlag 是 SLOTS_CHILDREN | STATEFUL_COMPONENT。
确定 shapeFlag 会影响后续 patch 过程。patch 中根据 vnode 的 type 和 shapeFlag 决定后续执行逻辑,回顾它的实现:
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 流程,回顾它的实现:
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
}instance.vnode 即组件 vnode,从中可拿到子组件实例、props、children 等数据。setupComponent 执行过程中通过 initSlots 初始化插槽,传入 instance 和 children,看它的实现:
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 拿到插槽数据。
子组件初始化过程中已拿到父组件传入的插槽数据,下面分析子组件如何把这些插槽数据渲染到页面上。
看子组件的模板:
<div class="layout">
<header>
<slot name="header"></slot>
</header>
<main>
<slot></slot>
</main>
<footer>
<slot name="footer"></slot>
</footer>
</div>借助模板编译工具查看编译后的 render 函数:
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")
])
]))
}子组件的插槽部分 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 第一个参数 slots 即 instance.slots(子组件初始化时已获得),第二个参数是 name。
实现很简单:根据 name 获取对应的插槽函数 slot,再通过 createBlock 创建 vnode 节点,类型是 Fragment,children 是执行 slot 插槽函数的返回值。
看 slot 函数长什么样,先分析示例中的 instance.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 */)
])它是执行 _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 设为当前渲染的组件实例,接着执行 fn,执行完毕再把之前的 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 挂载到页面上。插槽的 patch 过程有什么不同?
vnode 的 type 是 Fragment,执行 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 的代码用函数封装,子组件渲染时根据插槽名在对象中找到对应函数并执行,做真正的渲染。
总结
插槽是父子组件间内容分发机制:父组件把插槽内容编译成带渲染上下文的函数,子组件通过 initSlots 把它保存到 instance.slots,渲染时由 renderSlot 按插槽名取出函数并执行为 Fragment 节点。关键点是 withCtx 保证了插槽内容在父组件的作用域中渲染,从而实现作用域插槽。
本文的相关代码在源代码中的位置如下: packages/runtime-core/src/componentSlots.ts packages/runtime-core/src/vnode.ts packages/runtime-core/src/renderer.ts packages/runtime-core/src/helpers/withRenderContext.ts