vue3击鼓传花式实现插槽透传,后使用渲染函数改进
前言
在封装el-table组件时遇到这样一个场景,关于插槽透传。props透传可以通过provide/inject,插槽透传如何实现?孙组件如何使用父组件传递给子组件的插槽?曾孙组件又如何使用父组件传递给子组件的插槽?
首先,组件链条分析:父组件TestStandardTable引用了子组件StandardTable且传递了多个具名插槽(这些插槽用于自定义列的渲染),而子组件StandardTable又引用了组件RenderTableColumn(渲染列),即组件链条:TestStandardTable(父)-> StandardTable(子)-> RenderTableColumn(孙)-> RenderTableColumn(曾孙,自己调用了自己)
。那么问题来了:组件RenderTableColumn如何使用组件StandardTable接收的插槽呢?
插槽
父子组件之间的通信,父组件可通过props向子组件传递数据,也支持通过插槽内容向子组件传入自定义模板内容。
插槽包含两部分内容:(1)插槽内容,由父组件定义;(2)插槽出口,由子组件通过 标签实现。
-
插槽内容:由包含v-slot指令的template标签包裹;
- v-slot指定格式:
v-slot:插槽名称="scope"
,例如v-slot:default="scope"
或解构形式v-slot:default="{row,$index}"
; - v-slot指令简写形式为# :即
<template v-slot:default>...</template>
可简写为<template #default>...</template>
;
- v-slot指定格式:
-
插槽出口:通过slot标签输出
- 具名插槽的输出需要指定name(默认为default,默认插槽可省略name);
- 传递数据:可使用v-bind指令或其简写形式;
-
获取父组件传入的插槽对象:
- 组合式 API:
setup(props, context){console.log('父组件传入的插槽对象', context.slots)}
; - 钩子:
import {useSlots} from "vue";console.log('父组件传入的插槽对象', useSlots())
;
- 组合式 API:
默认插槽
<!-- 默认插槽 -->
<el-button>新增</el-button>
<!-- 提供默认名称的默认插槽 -->
<el-button>
<template v-slot:default>新增</template>
</el-button>
具名插槽
<!-- 具名插槽 -->
<!-- 父组件定义插槽内容 -->
<StandardTable>
<!-- 默认插槽的名称是default,可省略名字 -->
<template v-slot:default>
<el-button type="text">编辑</el-button>
<el-button type="text">删除</el-button>
</template>
<!-- 具名插槽(如果有多个插槽就要定名字了)并接收数据 -->
<template v-slot:score_xian="scope">
<span>{{scope.row.score_xian}}</span>
</template>
</StandardTable>
场景分析
二次封装el-table组件为standard-table,接收tableData,其类型如下:
export type tableDataProps = {
columns: columnProps[], // 列信息
list: object[], // 数据列表
pageData?: pageDataProps, // 分页数据
loading?: boolean, // 加载状态
}
针对列el-table-columns,做了一些定制化配置:
其代码结构如下:
父组件TestStandardTable
调用组件StandardTable,并提供自定义列模板
<StandardTable :tableData="tableData">
<!-- 提供插槽内容, 具名插槽, 自定义列模板 -->
<template #score_xian="{row}">
<span>{{row.score_xian}}</span>
</template>
<template #default="{row}">
<el-button type="text">编辑</el-button>
<el-button type="text" @click="execRemove(row)">删除</el-button>
</template>
</StandardTable>
<script setup lang="ts">
import useTable from '@/hooks/useTable'
const {tableData} = useTable({list:[]})
tableData.columns = [
{ id:4, label:"2023年理科一批分数线", prop:"creFile", minWidth:"120px", multiHeader:true, list:[
{ id:42, label:"河南", prop:"score_henan", minWidth:"180px", multiHeader:true, list:[
{ id:42, label:"郑州", prop:"score_henan", minWidth:"180px", },
{ id:42, label:"洛阳", prop:"score_henan", minWidth:"180px", },
]},
{ id:42, label:"浙江", prop:"score_zhejiang", minWidth:"180px", },
{ id:42, label:"安徽", prop:"score_anhui", minWidth:"180px", },
{ id:41, label:"西安", prop:"score_xian", minWidth:"180px", slot:"score_xian"},
]},
{ id:6, label:"操作", prop:"", slot:"default", minWidth:"120px", fixed:"right", },
]
tableData.list = [{
id: 1,
score_xian:"443分",
score_anhui:"482分",
score_zhejiang:"488分",
score_henan:"514分",
}]
const execRemove = (row={})=> {
console.log('删除', row)
}
</script>
组件StandardTable
渲染自定义列,因为支持多级表头,故将自定义列功能抽离出来即组件RenderTableColumn。
<el-table :data="tableData.list">
<template v-for="(item) in tableData.columns" :key="item">
<RenderTableColumn :data="item" />
</template>
</el-table>
<script setup lang="ts">
import {useSlots, PropType} from "vue"
import type {tableDataProps} from '../../types'
const slots = useSlots()
console.log('插槽', slots) // 打印有两个插槽,score_xian 和 default
defineProps({
tableData: Object as PropType<tableDataProps>
})
</script>
组件RenderTableColumn
<el-table-column :label="data.label">
<!-- 自定义表头内容(插槽) -->
<template v-if="data.header" #header="{column, $index}">
<slot :name="data.header" v-bind="{column, $index}"></slot>
</template>
<template v-slot="{row, $index}">
<!-- 支持多级表头 -->
<template v-if="data.multiHeader && data.list && data.list.length">
<!-- 渲染列 -->
<RenderTableColumn v-for="childColumn in data.list" :key="childColumn" :data="childColumn" />
</template>
<!-- 自定义列内容渲染, 级别:render>slot>prop>default slot -->
<!-- render -->
<template v-if="data.render && typeof data.render === 'function'">
<span v-html="data.render(row, $index)"></span>
</template>
<!-- 具名插槽 -->
<template v-else-if="data.slot">
<slot :name="data.slot" v-bind="{row, $index}"></slot>
</template>
<!-- prop -->
<template v-else-if="data.prop">{{row[data.prop] || '-'}}</template>
<!-- 默认插槽 -->
<template v-else>
<slot v-bind="{row, $index}"></slot>
</template>
</template>
</el-table-column>
<script setup lang="ts">
import {useSlots} from "vue"
const slots = useSlots()
console.log('插槽', slots) // 打印的是空的,没有任何插槽传入进来
</script>
运行结果:
发现我们在TestStandardTable.vue中定义的两个自定义列模板没有生效,为什么呢?
插槽透传
由于在TestStandardTable.vue向子组件StandardTable传入了自定义列模板(提供了两个插槽)以自定义展示列。按需求,这两个插槽是在el-table-columns接收展示的,但是插槽是由父组件流向子组件的,如何实现父组件给子组件的插槽流向孙组件呢即如何实现插槽透传?
这里笔者想到的解决方案是:借助父子组件插槽流动特性(父组件传递,子组件接收),父传子,子传孙... ,修改上述代码如下。
<!-- StandardTable.vue -->
<el-table>
<template v-for="(item) in tableData.columns" :key="item">
<RenderTableColumn :data="item">
<!-- 插槽透传 -->
<template v-for="itemSlot in Object.keys(slots)" :key="itemSlot" v-slot:[itemSlot]="temp">
<slot :name="itemSlot" v-bind="temp"></slot>
</template>
</RenderTableColumn>
</template>
</el-table>
<!-- RenderTableColumn.vue -->
<el-table-column :label="data.label">
<!-- 自定义表头内容(插槽) -->
<template v-slot="{row, $index}">
<!-- 支持多级表头 -->
<template v-if="data.multiHeader && data.list && data.list.length">
<RenderTableColumn v-for="childColumn in data.list" :key="childColumn" :data="childColumn">
<!-- 插槽透传 -->
<template v-for="itemSlot in Object.keys(slots)" :key="itemSlot" v-slot:[itemSlot]="temp">
<slot :name="itemSlot" v-bind="temp"></slot>
</template>
</RenderTableColumn>
</template>
<!-- 省略 -->
</template>
</el-table-column>
再次运行:
OK,搞定!现在,需求是实现了,但是是通过父子、子孙等的传递,这样有个弊端:如果插槽内容的逻辑复杂,这种击鼓传花的方式会带来过多的损耗。
如果你有更好的方式,请留言~
关键代码
<RenderTableColumn :data="item">
<!-- 插槽透传 -->
<template v-for="itemSlot in Object.keys(slots)" :key="itemSlot" v-slot:[itemSlot]="temp">
<slot :name="itemSlot" v-bind="temp"></slot>
</template>
</RenderTableColumn>
<script setup lang="ts">
import {useSlots} from "vue"
const slots = useSlots()
</script>
优化方案一
掘友提供了一个新思路,只在使用插槽的时候再去往上找以避免层层传递带来的损耗,根据组件实例上的$parent属性,向上找指定名称的插槽,露水晰123也在尝试这种方式。这里涉及一些预备知识,Vue3渲染机制、渲染函数、渲染函数语法和声明渲染函数。
首先做准备工作,了解Vue3的渲染机制:将语法糖template内部代码编译成渲染函数,渲染函数会返回一个生成虚拟dom树的函数,然后根据虚拟dom树渲染成真实的dom树。如果组件的编程性更强,就可直接使用渲染函数。声明渲染方式的方式也有两种:(1)组合式API的setup()返回渲染函数;(2)选项式API的render()返回渲染函数。
现使用渲染函数实现组件RenderTableColumn功能
新建文件RenderColumnList.ts,关键代码如下:
import {inject, h, resolveComponent} from "vue"
import {ElTableColumn} from "element-plus"
import type {columnProps, tableDataProps} from "../../types"
export default {
name: "RenderColumnList",
components: {
ElTableColumn,
},
setup() {
const rhTableStore = inject("rhTableStore")
// 渲染列的信息, renderColumnList不可为箭头函数,因为call不能改变其this指向
function renderColumnList(columns=[]) {
const $self = this
return columns.map((column)=> {
let slot = {} // 插槽
if (column.header) {
// generateColumnSlot 从自身往上找指定的插槽
slot.header = (scope)=> generateColumnSlot.call($self, column.header, scope) // 自定义表头
}
slot.default = (scope)=> {
const {row, $index} = scope
if (column.multiHeader && Array.isArray(column.list) && column.list.length) {
return renderColumnList.call($self, column.list) // 多级表头,递归调用
} else if (column.render && typeof column.render === "function") {
return h("span", column.render(row, $index))
} else if (column.slot) {
return generateColumnSlot.call($self, column.slot, scope)
} else if (column.prop) {
return row[column.prop] || "-"
} else {
return generateColumnSlot.call($self, "default", scope)
}
}
// 引用组件方式:h(resolveComponent("ElTableColumn")) 加载全局组件;也可h(ElTableColumn),但需在compoennts注册或在组合式API中导入
// genColumnProps 生成当前列的参数对象
return h(ElTableColumn, genColumnProps(column, rhTableStore), slot)}
)
}
return {
renderColumnList,
}
},
render() {
const {columns} = this.$attrs
const {renderColumnList} = this
const $self = this
return renderColumnList.call($self, columns) // renderColumnList内部需借助当前this查找父组件
},
}
在render函数中,使用方法renderColumnList来生成列,为什么不直接在render里生成呢? 因为要支持多级表头的设置,多级表头就意味着递归调用自身,如果我们在render里直接渲染列,递归调用自身**h(RenderColumnList)**会报错:RenderColumnList在初始化之前使用了
。因function的声明会被提升到全局,故可将渲染列这块代码拉出去,做函数调用。
同步修改StandardTable
<!-- 更改部分 -->
<RenderColumnList :columns="columns" />
辅助函数
/**
* 从自己开始往上查找指定的插槽
* @param slotName 插槽名称
*/
function findParentSlot ($self:object, slotName:string="default") {
// console.log("往上找节点的插槽", $self.$slots)
const slot = $self.$slots[slotName]
const parent = $self.$parent
return slot || (parent ? findParentSlot(parent, slotName) : null)
}
/**
* 生成插槽
* @param slotName 插槽名称
* @param scope 插槽作用域
* @returns
*/
function generateColumnSlot (slotName:string, scope:object) {
const tempSlot = findParentSlot(this, slotName)
return tempSlot ? tempSlot(scope) : undefined
}
/**
* 生成列的属性
* @param data
* @param rhTableStore
* @returns
*/
function genColumnProps (data:columnProps, rhTableStore:tableDataProps) {
const obj = {
"label": data.label,
"prop": data.prop,
"width": data.width,
"minWidth": data.minWidth,
"fixed": data.fixed,
"align": data.align || rhTableStore.align,
}
Object.keys(obj).forEach(key=> {
if (!obj[key]) delete obj[key] // 过滤掉为空的属性
})
return obj
}
小小彩蛋
call、apply和bind的异同之处
相同点:
- 强制更改function的this的指向;
- 不能更改ES6箭头函数的this,因为箭头函数的this是上级父作用域的,其本身没有this;
不同点:
- call与apply的传参方式不同,call是单个单个传参(例如:call(this, parms1, parms2, parms3)),apply是以数组的形式传参(apply(this, [parms1, parms2, parms3]));
- call与bind的传参方式相同,但是fn.call和fn.apply中的fn都是立即执行,而fn.bind会返回接受了传参的fn,这一点可以用在柯里化;
若有异议,请留言议之~
转载自:https://juejin.cn/post/7252170693675237436