TSX 中 Vue3 插槽的使用与实现
在 TSX 语法中不支持使用 <template v-slot>
和 <slot>
实现插槽的渲染与传递,需要我们自行实现,不过还好官方给我们提供了一些方法,包括社区也提供了可在 TSX 中使用的自定义指令,我们来看看具体怎么实现吧。
setup 获取插槽
所有插槽都有名字
请注意,在 TSX 中,所有插槽都是具名插槽,它们都是有名字的,默认插槽的名字就叫 default
,例如:
上图中就有三个具名插槽,分别叫做 default
、fullName
和gender
。
我们如何获取它们呢?
非响应式的+有状态的 插槽对象
在 setup()
函数的第二个参数也就是 Setup 上下文对象
中会暴露出一个非响应式的插槽对象 slots,我们可以从中获取到所有传递过来的插槽,如上图所示。
有状态
是指 slots
对象总是会随着组件自身的更新而更新。这意味着你应当避免解构它们,并始终通过 slots.x
的形式使用其中的属性。
非响应式
则是指,如果你想要基于 slots
的改变来执行副作用,那么你应该在 onBeforeUpdate
生命周期钩子中编写相关逻辑。
渲染插槽
每一个插槽都在 slots
上暴露为一个函数,返回一个 vnode 数组,通过函数的调用即可渲染插槽:
// Son.tsx
export default defineComponent({
setup(_, { slots }) {
return () => (
<div>
{slots.default()}
{slots.gender()}
{slots.fullName()}
</div>
);
}
})
传递插槽
在 TSX 中,我们需要在组件中传递一个插槽函数或者是一个包含插槽函数的对象而非是数组,插槽函数的返回值同一个正常的渲染函数的返回值一样 —— 并且在子组件中被访问时总是会被转化为一个 vnodes 数组。例如:
// Parent.tsx
import { defineComponent } from 'vue';
import Son from './Son';
export default defineComponent({
setup() {
return () => (
<Son>
{{
default: () => 123,
gender: () => <span>男</span>,
fullName: () => [<span>Lebron</span>, <span>James</span>],
}}
</Son>
);
}
})
作用域插槽传递参数
如果插槽是一个作用域插槽,那么我们可以往该插槽函数中传递参数,这样在父组件中就能接收到子组件暴露出来的作用域了。
// Son.tsx
export default defineComponent({
setup(_, ctx) {
return () => (
<div>
// 传递参数,有多少传多少
{ctx.slots.fullName({
firstName: 'Lebron',
lastName: 'James',
})}
</div>
);
},
});
// Parent.tsx
export default defineComponent({
setup() {
return () => (
<Son>
{{
// 这里能接受到所有传递过来的数据
fullName(slotProps) {
return [
<span>{slotProps.firstName}</span>,
<span>{slotProps.lastName}</span>,
];
},
}}
</Son>
);
},
});
等价的 template 模板语法:
<div>
<slot name="fullName" firstName="Lebron" lastName="James"></slot>
</div>
<Son>
<template #fullName="slotProps">
<span>{slotProps.firstName}</span>,
<span>{slotProps.lastName}</span>,
</template>
</Son>
v-slots 指令
除了直接在组件中传递包含插槽函数的对象外,还可以通过 v-slots
指令传递,这样就可以将插槽内容单独抽取出来:
export default defineComponent({
setup() {
const slots = {
default: () => 123,
gender: () => <span>男</span>,
fullName(slotProps) {
return [
<span>{slotProps.firstName}</span>,
<span>{slotProps.lastName}</span>,
];
},
};
return () => <Son v-slots={slots} />;
},
});
这样,结构和数据就进行了分离,看着更加干净清晰,也便于我们后期的维护。
当然,默认插槽也可以标签的形式直接写在组件里,就像这样:
return () => (
<Son v-slots={slots}>
<span>123</span>
</Son>
);
不过它的优先级没有写在 slots
对象中的优先级高,当 slots
对象中的 default
插槽函数和组件内的默认插槽同时存在时,会以 default
函数为主,后者会被忽略。
但是,这里需要指出的是,在 Vue 官方文档中,并没有 v-slots
这个指令,我们之所以能在 TSX 中使用它是因为项目中安装了 @vitejs/plugin-vue-jsx
插件,该插件允许以 JSX 的方式来编写 Vue 代码。
有兴趣的小伙伴可以查看源码:babel-plugin-jsx/src/transform-vue-jsx.ts
h() 函数
h() 函数十分灵活和强大,在 .vue、.ts、.tsx 中都能够用它来编写组件,也可以使用它来帮助我们传递和渲染插槽。
第一个参数 type
既可以是一个字符串 (用于原生元素) 也可以是一个 Vue 组件定义;
第二个参数 props
是要传递的 prop;
第三个参数 children
是子节点。
h()函数传递插槽
当创建一个组件的 vnode 时,子节点必须以插槽函数进行传递。如果组件只有默认槽,可以使用单个插槽函数进行传递。否则,必须以插槽函数的对象形式来传递。可以看以下示例:
- 传递单个默认插槽
import Son from './Son'; // 默认插槽 export default defineComponent({ setup() { return () => h(Son, () => 'hello'); }, }); // 等价 JSX 语法: <Son>{() => 'hello'}</Son>
- 传递具名插槽
请注意:在使用 h() 函数传递具名插槽时,第二个参数必须是 null,以避免 slot 对象被当成 prop 处理。// 具名插槽 export default defineComponent({ setup() { return () => h(Son, null, { default: () => 'default slot', foo: () => h('div', 'foo'), bar: () => [h('span', 'one'), h('span', 'two')] }); }, }); // 等价 JSX 语法: <Son> {{ default: () => 'default slot', foo: () => <div>foo</div>, bar: () => [<span>one</span>, <span>two</span>] }} </Son>
h() 函数渲染插槽
插槽的渲染和之前一样,都是调用插槽函数,只是把标签替换成了 h() 函数。有需要传递作用域的,记得在调用时传入参数即可:
// Son.tsx
import { defineComponent, h } from 'vue';
export default defineComponent({
setup(_, { slots }) {
return () => {
return [
h('div', slots.default()),
h(
'div',
slots.fullName({
firstName: 'Lebron',
lastName: 'James',
})
),
];
};
},
});
// 等价 JSX 语法:
// <div><slot /></div>
<div>{slots.default()}</div>
// <div><slot name="fullName" firstName="Lebron" lastName="James" /></div>
<div>
{ctx.slots.fullName({
firstName: 'Lebron',
lastName: 'James',
})}
</div>
插槽的工作原理与实现
当我们会使用 TSX 去手动实现插槽的传递和渲染后,我们离插槽的工作原理就更进一步了。比如我有一个 <MyComponent> 组件,我们来看看父组件在使用 <MyComponent> 时,他们内部都是怎么工作的。
<MyComponent>
<template #header>
<h1>我是标题</h1>
</template>
<template #body>
<section>我是内容</section>
</template>
</MyComponent>
上面这段父组件的模板会被编译成如下渲染函数:
function render() {
type: MyComponent,
children: {
header() {
return { type: 'h1', children: '我是标题' }
},
body() {
return { type: 'section', children: '我是内容' }
},
}
}
可以看到,组件模板中的插槽内容会被编译为插槽函数,而插槽函数的返回值就是具体的插槽内容。组件 MyComponent 的模板则会被编译成如下渲染函数:
// MyComponent
function render() {
return [
{ type: 'header', children: [this.$slots.header()] },
{ type: 'body', children: [this.$slots.body()] }
]
}
是不是和我们自己在 TSX 中使用插槽很像?可以看到,渲染插槽内容的过程,就是调用插槽函数并渲染由其返回的内容的过程。这与 React 中 render props 的概念非常相似。
运行时的实现
运行时的实现上,插槽则依赖于 setupContext
中的 slots
对象,如以下代码所示:
function mountComponent(vnode, container, anchor) {
// 省略部分代码
// 直接使用编译好的 vnode.children 对象作为 slots 对象即可
const slots = vnode.children || {}
// 将 slots 对象添加到 setupContext 中
const setupContext = { attrs, emit, slots }
}
可以看到,最基本的 slots 的实现非常简单。只需要将编译好的 vnode.children 作为 slots 对象,然后将 slots 对象添加到 setupContext 对象中。为了在 render 函数内和生命周期钩子函数 内能够通过 this.$slots
来访问插槽内容,我们还需要在 renderContext
中特殊对待 $slots
属性,如下面的代码所示:
function mountComponent(vnode, container, anchor) {
// 略......
const slots = vnode.children || {}
const instance = {
state,
props: shallowReactive(props),
isMounted: false,
subTree: null,
// 将插槽添加到组件实例上
slots
}
// 略......
const renderContext = new Proxy(instance, {
get(t, k, r) {
const { state, props, slots } = t
// 当 k 的值为 $slots 时,直接返回组件实例上的 slots
if (k === '$slots') return slots
},
set (t, k, v, r) { // 略...... }
})
}
我们对渲染上下文 renderContext
代理对象的 get
拦截函数做了特殊处理,当读取的键是 $slots
时,直接返回组件实例上的 slots
对象,这样用户就可以通过 this.$slots
来访问插槽内容 了。
总结
首先,我们可以通过传递 插槽函数对象 和调用插槽函数来实现插槽的传递与渲染;
其次,我们也可以使用 h() 函数来替代标签达到同样的目的;
最后,我们看了下 Vue 内部插槽的工作原理,模板编译成了渲染函数,而渲染过程就是一个调用插槽函数并渲染由其返回的内容的过程。
当然,Vue 还是推荐在绝大多数情况下使用模板来创建你的页面(也就是直接编写HTML代码)。
然而在一些场景中,你可能真的需要 JavaScript 强大的编程的能力,通过JS代码来生成HTML代码。这时你可以用 渲染函数 & JSX,它比模板更接近编译器,也能让我们更好的了解其背后的原理。
参考资料
- 渲染函数 & JSX | Vue 官方文档
- $slots | Vue 官方文档
- Setup 上下文 | Vue 官方文档
- babel-plugin-jsx | Github
- 霍春阳. Vue.js 设计与实现. 北京: 人民邮电出版社, 2022: 316-318
转载自:https://juejin.cn/post/7245829131244798012