likes
comments
collection
share

vue3击鼓传花式实现插槽透传,后使用渲染函数改进

作者站长头像
站长
· 阅读数 18

前言

在封装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>
  • 插槽出口:通过slot标签输出

    • 具名插槽的输出需要指定name(默认为default,默认插槽可省略name);
    • 传递数据:可使用v-bind指令或其简写形式;
  • 获取父组件传入的插槽对象:

    • 组合式 API:setup(props, context){console.log('父组件传入的插槽对象', context.slots)}
    • 钩子:import {useSlots} from "vue";console.log('父组件传入的插槽对象', useSlots())

默认插槽

<!-- 默认插槽 -->
<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,做了一些定制化配置: vue3击鼓传花式实现插槽透传,后使用渲染函数改进

其代码结构如下:

父组件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>

运行结果: vue3击鼓传花式实现插槽透传,后使用渲染函数改进

发现我们在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>

再次运行: vue3击鼓传花式实现插槽透传,后使用渲染函数改进

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()返回渲染函数。 vue3击鼓传花式实现插槽透传,后使用渲染函数改进

现使用渲染函数实现组件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,这一点可以用在柯里化;

若有异议,请留言议之~