从封装一个表格组件开始,带你深入学习vue3前言:最近开发的一个移动端项目使用的技术栈是vue3+vant4,需求开发中
前言:最近开发的一个移动端项目使用的技术栈是vue3+vant4,需求开发中需要以表格的形式展示信息,在vant官方文档找了半天没看到table组件,移动端就不该展示表格?再重新引入一个ui框架有点小题大作,于是就自己封装一下吧。
实现效果
仓库地址
Gitee: gitee.com/sz-liunian/…
需求分析
我们主要实现的功能包括:
-
列展示:使用columns配置,具体参数请看下表。
-
表头吸顶:表头和内容分别使用table实现,表头吸顶使用sticky实现。
-
列固定:固定展示在页面的左侧或者右侧,不随页面的滚动而滚动,我们可以把固定的列和非固定列分别作为一个表格展示,非固定列在水平方向超出可视区滚动,具体实现请查看代码
-
列宽度设置:使用 <colgroup> 实现,通过使用 <colgroup> 标签,可以向整个列应用样式,而不需要重复为每个单元格或每一行设置样式。
-
单元格边框:移动端1px会比较粗,我们使用css scale缩放50%,这样看起来会比较细。
-
表头自定义:可以给表头设置插槽,给要自定义的列设置slotHeader:true,然后自定义插槽内容
-
每一列自定义: 使用插槽实现,插槽name值为每一列key值。
-
单元格点击事件:监听事件,使用emit触发事件。
其他实现的功能都比较简单,就不一一叙述了,具体参数请看表格:
传的参数
参数 | 说明 | 类型 | 默认值 |
---|---|---|---|
showTitle | 是否展示表头 | boolean | true |
showList | 是否展示列表 | boolean | true |
columns | 表格列的配置描述见下表 | array | [] |
data | 列表数据 | array | [] |
showBorder | 是否显示单元格边框 | boolean | false |
isHeaderFixed | 表头是否吸顶 | boolean | true |
headerFixedTopValue | 页面划动,表头吸顶top值 | Number | 88 (单位px) |
height | 行高度 | number、string | 50(单位px) |
columns配置
参数 | 说明 | 类型 | 默认值 |
---|---|---|---|
title | 表头展示内容 | string (支持传html) | '' |
width | 单元格宽度 | number | 70(单位px) |
widthAuto | 宽度自适应,会把剩余宽度按比例分配给设置了该字段的列 | boolean | false |
key | 当前列要展示的字段 | string 每一个 表头选项,key要保持惟一 | -(无默认值) |
fixed | 列是否固定,可选 'left' 'right' | string | -(无默认值) |
align | 单元格对齐方式, 可选 left、center、 right | string | left |
ellipsis | 超过宽度将自动省略 | boolean | false |
slotHeader | title 插槽key,可使用此字段添加插槽 | string | - |
headerBackground | 表头背景色 | string | '#fff' |
formatter | 列表单元格格式化函数 | Function(h, row, column, index) | - |
util | 单位 | number、string |
事件
参数 | 说明 | 类型 |
---|---|---|
handlerClick | row 点击事件 | Function(row, index) |
handlerCellClick | cell 点击事件 | Function(row, column) |
插槽: 表头插槽使用 columns 属性 slotHeader自定义表头内容 内容插槽使用 columns 属性key自定义单元格内容
index组件
html代码如图:
<template>
<div class="table-block">
<div class="table-block-list" ref="tableRef">
<div
v-for="item in unref(columnArr)"
:key="item.fixed"
class="table-block-list-content"
:class="[item.shadow && `content-${item.fixed}`]"
:style="{
width: item.width
}"
>
<tableTitle
v-if="showTitle"
v-bind="$attrs"
:columns="item.titleList"
:rowWidth="item.colWidth"
:fixedCol="item.fixed"
:isXScroll="isXScroll"
:id="item.fixed === 'center' ? rowTitleId : ''"
@scroll="e => onscrollHandler(e, rowListId)"
>
<template v-for="ite in item.headerSlots" #[ite.slotHeader]="{ titleItem }">
<slot :name="ite.slotHeader" :titleItem="titleItem"> </slot>
</template>
</tableTitle>
<list
v-if="showList && data.length > 0"
v-bind="$attrs"
:columns="item.titleList"
:data="data"
:rowWidth="item.colWidth"
:fixedCol="item.fixed"
:isXScroll="isXScroll"
:id="item.fixed === 'center' ? rowListId : ''"
@scroll="e => onscrollHandler(e, rowTitleId)"
>
</list>
</div>
</div>
</div>
</template>
列数据处理
从html结构中我们可看出,表头和主题内容封装了两个组件,通过遍历columnArr展示左侧固定列、中间列和右侧固定列,我们要关注的是columnArr,下面我们就来看一下怎么处理的
<script setup lang="ts">
import { ref, reactive, computed, unref, onMounted, nextTick, useSlots } from 'vue'
import tableTitle from './title.vue'
import list from './list.vue'
const props = defineProps({
// 展示的列
columns: {
type: Array,
default: () => []
},
// 源数据
data: {
type: Array,
default: () => []
},
// 是否显示标题头
showTitle: {
type: Boolean,
default: true
},
// 是否显示列表数据
showList: {
type: Boolean,
default: true
}
})
// 设置一个屏幕默认宽度
const defaultConst = reactive({
viewWidth: 360
})
// 获取屏幕宽度
function getViewWidth(dom: HTMLElement): Number {
if (!dom.value) {
return
}
const tableBlockStyle = window.getComputedStyle(dom.value)
const width = +tableBlockStyle.width.replace('px', '')
const paddingLeft = +tableBlockStyle.paddingLeft.replace('px', '')
const paddingRight = +tableBlockStyle.paddingRight.replace('px', '')
const widthSum = width - paddingLeft - paddingRight
return !isNaN(widthSum) ? widthSum : defaultConst.viewWidth
}
const tableRef = ref(null)
onMounted(() => {
nextTick(() => {
// 获取屏幕宽度
defaultConst.viewWidth = getViewWidth(tableRef)
})
})
const fixedWidthDefault = ref(70) // 单元格默认宽度
// 获取列宽和
function getColWidth(arr: Array, defaultWidth: Number): Number {
return +arr
.reduce((pre, cur) => {
pre += cur.width ? +cur.width : defaultWidth
return pre
}, 0)
.toFixed(2)
}
// 计算表格每一行宽度
const rowWidth = computed(() => {
return props.columns.reduce((pre, cur) => {
pre += cur.width ? +cur.width : fixedWidthDefault
return pre
}, 0)
})
// x轴宽度超出屏幕宽度
const isXScroll = computed(() => {
return rowWidth.value - defaultConst.viewWidth > 0
})
const rowTitleId = ref('rowTitle' + Math.ceil(Math.random() * 10 ** 10))
const rowListId = ref('rowList' + Math.ceil(Math.random() * 10 ** 10))
// title和list同步滚动
const onscrollHandler = function(e: HTMLElement, id: Number) {
const dom = document.querySelector(`#${id}`)
if (dom) {
dom.scrollLeft = e?.target?.scrollLeft
}
}
const slots = useSlots()
// 获取title 插槽
function getHeaderSlots(arr: Array) {
return arr.filter(item => item.slotHeader && slots[item.slotHeader])
}
// 渲染的列包括左侧固定列,中间列,右侧固定列
const fixedArr = ['left', 'center', 'right']
// 最终遍历的数据
const columnArr = computed(() => {
let obj = fixedArr.reduce((pre, key) => {
pre[key] = []
return pre
}, {})
props.columns.forEach((item, index) => {
// fixed 值为 String
if (item.fixed === 'left' || item.fixed === 'right') {
obj[item.fixed].push(item)
} else {
obj.center.push(item)
}
})
// 分别计算左侧固定列,中间列,右侧固定列宽度,
const wt = fixedArr.reduce((pre, key) => {
pre[key] = getColWidth(obj[key], fixedWidthDefault.value)
return pre
}, {})
const arr = []
Object.keys(obj).forEach(key => {
if (obj[key].length > 0) {
arr.push({
titleList: obj[key], // 左中右每一列数据
col: obj[key],
fixed: key,
colWidth: wt[key], // 列宽度
// 中间可视区宽度需动态计算
width: ['left', 'right'].includes(key) ? wt[key] + 'px' : `calc(100% - ${wt.left + wt.right}px)`,
headerSlots: getHeaderSlots(obj[key]),
shadow: key !== 'center' ? isXScroll : false
})
}
})
// 最后一列添加类名,添加类名是为了设置边框样式
const obje = arr[arr.length - 1]
const setLastCol = function(colArr: Array) {
colArr.forEach((item, index) => {
if (colArr.length - 1 === index) {
item.isLastCol = true
}
})
}
if (obje) {
setLastCol(obje.col)
setLastCol(obje.titleList)
}
return arr
})
</script>
<style lang="less" scoped>
.table-block {
width: 100%;
max-width: 100%;
.table-block-list {
width: 100%;
max-width: 100%;
padding: 0 10px;
display: flex;
justify-content: flex-start;
&-content {
position: relative;
&.content-left::before,
&.content-right::before {
content: '';
position: absolute;
width: 8px;
height: 100%;
top: 0;
bottom: 0;
right: -7px;
background: linear-gradient(270deg, rgba(255, 255, 255, 0) 0%, #f1f1f1 100%);
z-index: 80;
}
&.content-right::before {
left: -1px;
}
}
}
}
</style>
title组件:
通过tsx实现表头渲染,tsx相比template运行更快,根据参数添加不同类或样式,通过类名控制表格样式,这里需要注意的是使用插槽slots要先判断该插槽是否存在,不然会报错,具体代码如下
<script lang="tsx">
import { type ExtractPropTypes, computed, defineComponent } from 'vue'
const listProps = {
// 标题
columns: {
type: Array,
default: []
},
// 数据
data: {
type: Array,
default: () => []
},
// 列表渲染中key使用的字段,不传rowKey默认使用index
rowKey: {
type: String,
default: ''
},
// 是否显示边框
showBorder: {
type: Boolean,
default: false
},
// 是否显示底部边框
borderBottom: {
type: Boolean,
default: true
},
rowWidth: {
type: Number,
default: 375
},
// 行高
height: {
type: [Number, String],
default: 50 // 'auto' 默认 0.5rem
},
fixedCol: {
type: String,
default: 'center'
},
// 合并单元格方法
mergeCellMethods: {
type: Function,
default: () => [1, 1]
},
// 单元格默认宽度
fixedWidthDefault: {
type: [Number, String],
default: 70
},
// 固定在左侧的长度
fixedLeftLength: {
type: Number,
default: 0
},
// 未固定的列长度
centerColumnLength: {
type: Number,
default: 0
}
}
export default defineComponent({
props: listProps,
setup(props, { slots, attrs, emit }) {
// 自定义单元格展示内容
const liCellSpan = (item: Object, v: Object, rowIndex: Number) => {
let val = ![null, undefined, ''].includes(v[item.key]) ? v[item.key] : ''
val = val ? val + (item.util || '') : ''
return (
<span
class="li-cell-span"
v-html={typeof item.formatter === 'function' ? item.formatter(h, v, item, rowIndex) : val || '-'}
></span>
)
}
// 插槽
const cellContent = (item: Object, v: Object, rowIndex: Number) => {
const slotName = slots[item.key]
return (
<div
class={['li-cell-content', { ellipsis: item.ellipsis }]}
style={{
'max-height': props.height === 'auto' ? 'auto' : props.height + 'px'
}}
>
// 这里要先判断插槽是否存在
{typeof slotName === 'function'
? slotName({
item: v,
titleItem: item
})
: liCellSpan(item, v, rowIndex)}
</div>
)
}
// 单元格样式
const tdCell = (v: Object, rowIndex: Number) => {
return props.columns.map((item, index, arr) => {
let val = ![null, undefined, ''].includes(v[item.key]) ? v[item.key] : ''
return (
<td
key={item.key}
class={[
'li-cell',
item.key,
{ 'show-border': props.showBorder },
{ 'border-bottom': props.borderBottom },
{ 'column-last': item.isLastCol }
]}
style={{
'text-align': item.align || 'left'
}}
onClick={e => props.$emit('handlerCellClick', v, item, e)}
>
{cellContent(item, v, index)}
</td>
)
})
}
// 行样式
const tableTbody = () => {
return (
<tbody class="table-tbody">
{props.data.map((item, index) => {
return (
<tr
key={props.rowKey in item ? item[props.rowKey] : index}
style={{
height: props.height === 'auto' ? 'auto' : props.height + 'px'
}}
onClick={e => props.$emit('handlerClick', item, index)}
>
{tdCell(item, index)}
</tr>
)
})}
</tbody>
)
}
// 列样式
const colgroups = () => {
return (
<colgroup>
{props.columns.map((item, index) => {
let colWidth = 100
try {
colWidth = (+item.width || props.fixedWidthDefault).toFixed(2)
} catch (e) {}
return (
<col
key={index}
attrs={{
key: item.key
}}
style={{
width: props.fixedCol === 'center' && item.widthAuto ? 'auto' : colWidth + 'px'
}}
></col>
)
})}
</colgroup>
)
}
return () => (
<div class={['table-list', { 'table-list-scroll': props.fixedCol === 'center' }]}>
<table
style={{
width: props.rowWidth + 'px'
}}
>
{[colgroups(), tableTbody()]}
</table>
</div>
)
}
})
</script>
<style lang="less" scoped>
.table-title {
max-width: 100%;
&.fixed-top {
position: sticky;
left: 0;
top: 88px;
z-index: 70;
}
&-center {
overflow: scroll;
&::-webkit-scrollbar {
display: none;
width: 0;
background: transparent;
}
}
table {
min-width: 100%;
min-height: 30px;
table-layout: fixed;
background-color: #fff;
// position: relative;
.title-top-border::before {
content: '';
width: 100%;
height: 1px;
background-color: #f1f1f1;
position: absolute;
top: 0;
left: 0;
z-index: 80;
transform: scale(1, 0.5);
}
th {
background-color: #fff;
position: relative;
line-height: 1;
vertical-align: inherit;
img {
width: 11px;
height: 14px;
margin-left: 4px;
vertical-align: middle;
}
.table-title-cell {
padding: 4px 8px;
&-sort {
display: flex;
align-items: center;
&-block {
display: inline-block;
}
}
&-span {
display: inline-block;
vertical-align: middle;
}
}
&.border-bottom::after {
content: '';
width: 100%;
height: 1px;
background-color: #f1f1f1;
position: absolute;
bottom: 0;
left: 0;
z-index: 10;
-webkit-transform: scale(1, 0.5);
transform: scale(1, 0.5);
}
&.show-border::after {
content: '';
position: absolute;
left: 0;
top: 0;
width: 200%;
height: 200%;
background-color: transparent;
// border-top: 1px solid #F1F1F1;
border-left: 1px solid #f1f1f1;
border-bottom: 1px solid #f1f1f1;
transform-origin: 0 0;
transform: scale(0.5);
box-sizing: border-box;
pointer-events: none;
}
&.show-border.column-last::after {
border-right: 1px solid #f1f1f1;
}
}
}
}
</style>
list组件
表格主题同样使用了tsx语法,根据参数添加不同类或样式,通过类名控制表格样式,具体代码如下
<script lang="tsx">
import { type ExtractPropTypes, computed, defineComponent } from 'vue'
const listProps = {
// 标题
columns: {
type: Array,
default: []
},
// 数据
data: {
type: Array,
default: () => []
},
// 列表渲染中key使用的字段,不传rowKey默认使用index
rowKey: {
type: String,
default: ''
},
// 是否显示边框
showBorder: {
type: Boolean,
default: false
},
// 是否显示底部边框
borderBottom: {
type: Boolean,
default: true
},
rowWidth: {
type: Number,
default: 375
},
// 行高
height: {
type: [Number, String],
default: 50 // 'auto' 默认 0.5rem
},
fixedCol: {
type: String,
default: 'center'
},
// 合并单元格方法
mergeCellMethods: {
type: Function,
default: () => [1, 1]
},
// 当前1rem对应px值
htmlFontSize: {
type: [Number, String],
default: 100
},
// 单元格默认宽度
fixedWidthDefault: {
type: [Number, String],
default: 70
},
// 固定在左侧的长度
fixedLeftLength: {
type: Number,
default: 0
},
// 未固定的列长度
centerColumnLength: {
type: Number,
default: 0
}
}
export default defineComponent({
props: listProps,
setup(props, { slots, attrs, emit }) {
// 自定义单元格展示内容
const liCellSpan = (item: Object, v: Object, rowIndex: Number) => {
let val = ![null, undefined, ''].includes(v[item.key]) ? v[item.key] : ''
val = val ? val + (item.util || '') : ''
return (
<span
class="li-cell-span"
v-html={typeof item.formatter === 'function' ? item.formatter(h, v, item, rowIndex) : val || '-'}
></span>
)
}
// 插槽
const cellContent = (item: Object, v: Object, rowIndex: Number) => {
const slotName = slots[item.key]
return (
<div
class={['li-cell-content', { ellipsis: item.ellipsis }]}
style={{
'max-height': props.height === 'auto' ? 'auto' : props.height + 'px'
}}
>
{typeof slotName === 'function'
? slotName({
item: v,
titleItem: item
})
: liCellSpan(item, v, rowIndex)}
</div>
)
}
// 单元格样式
const tdCell = (v: Object, rowIndex: Number) => {
return props.columns.map((item, index, arr) => {
let val = ![null, undefined, ''].includes(v[item.key]) ? v[item.key] : ''
return (
<td
key={item.key}
class={[
'li-cell',
item.key,
{ 'show-border': props.showBorder },
{ 'border-bottom': props.borderBottom },
{ 'column-last': item.isLastCol }
]}
style={{
'text-align': item.align || 'left'
}}
onClick={e => props.$emit('handlerCellClick', v, item, e)}
>
{cellContent(item, v, index)}
</td>
)
})
}
// 行样式
const tableTbody = () => {
return (
<tbody class="table-tbody">
{props.data.map((item, index) => {
return (
<tr
key={props.rowKey in item ? item[props.rowKey] : index}
style={{
height: props.height === 'auto' ? 'auto' : props.height + 'px'
}}
onClick={e => props.$emit('handlerClick', item, index)}
>
{tdCell(item, index)}
</tr>
)
})}
</tbody>
)
}
// 列样式
const colgroups = () => {
return (
<colgroup>
{props.columns.map((item, index) => {
let colWidth = 100
try {
colWidth = (+item.width || props.fixedWidthDefault).toFixed(2)
} catch (e) {}
return (
<col
key={index}
attrs={{
key: item.key
}}
style={{
width: props.fixedCol === 'center' && item.widthAuto ? 'auto' : colWidth + 'px'
}}
></col>
)
})}
</colgroup>
)
}
return () => (
<div class={['table-list', { 'table-list-scroll': props.fixedCol === 'center' }]}>
<table
style={{
width: props.rowWidth + 'px'
}}
>
{[colgroups(), tableTbody()]}
</table>
</div>
)
}
})
</script>
<style lang="less" scoped>
.table-list {
max-width: 100%;
&-scroll {
overflow: scroll;
&::-webkit-scrollbar {
display: none;
width: 0;
background: transparent;
}
}
table {
min-width: 100%;
background-color: #fff;
table-layout: fixed;
tr {
min-width: 100%;
min-height: 30px;
flex-direction: row;
align-items: center;
font-size: 14px;
font-weight: 400;
color: #222222;
box-sizing: border-box;
position: relative;
.li-cell {
text-align: left;
font-size: 14px;
line-height: 20px;
font-weight: 400;
color: #222222;
background-color: #fff;
align-items: center;
position: relative;
vertical-align: middle;
&-content {
padding: 4px 8px;
max-width: 100%;
overflow: hidden;
// white-space: nowrap;
&.ellipsis {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
word-break: break-all;
white-space: nowrap;
}
}
&.border-bottom::after {
content: '';
width: 100%;
height: 1px;
background-color: #f1f1f1;
position: absolute;
bottom: 0;
left: 0;
z-index: 10;
-webkit-transform: scale(1, 0.5);
transform: scale(1, 0.5);
}
&.show-border::after {
content: '';
position: absolute;
left: 0;
top: 0;
width: 200%;
height: 200%;
background-color: transparent;
border-left: 1px solid #f1f1f1;
border-bottom: 1px solid #f1f1f1;
transform-origin: 0 0;
transform: scale(0.5);
box-sizing: border-box;
pointer-events: none;
}
&.show-border.column-last::after {
border-right: 1px solid #f1f1f1;
}
&.box-right {
text-align: right;
padding-right: 10px;
}
}
}
}
}
</style>
使用组件
首先引入组件,然后绑定参数和渲染的数据,这里使用mockjs模拟数据
<table-list
:columns="titleList"
:data="dataList"
showBorder
>
<template #slotDate="{item, titleItem}">
<div class="user-info">
<span
class="ellipsis"
>{{ titleItem.title }}</span>
</div>
</template>
</table-list>
<script setup lang="ts">
import Mock from 'mockjs'
import tableList from '@/components/table/index.vue'
// 设置数据
function setData(len, obj) {
return Array.from({length: len}).map(item => {
return Mock.mock(obj)
})
}
const titleList = [
{
title: '日期',
width: 100,
key: 'date',
ellipsis: true,
fixed: 'left',
slotHeader: 'slotDate'
},
{
title: '姓名',
width: 95,
key: 'name',
sort: 2,
fieldName: 1,
align: 'right',
commaSplice: true,
// formatter
},
{
title: '年龄',
width: 95,
key: 'age',
sort: 2,
fieldName: 1,
align: 'right',
commaSplice: true,
},
{
title: '公司',
width: 95,
key: 'company',
sort: 2,
fieldName: 1,
align: 'right',
commaSplice: true,
},
{
title: '岗位',
width: 95,
key: 'work',
sort: 2,
fieldName: 1,
align: 'right',
commaSplice: true,
},
{
title: '城市',
width: 130,
key: 'city',
align: 'right',
commaSplice: true,
sort: 0,
fieldName: 3,
ellipsis: true
}
]
const dataObj = {
date: '@date',
name: "@cname",
age: '@integer(24, 65)',
company: '@pick(["阿里", "腾讯", "字节"])',
work: '@pick(["产品", "运营", "ui", "前端", "后端", "测试", "运维"])',
city: '@city()'
}
const dataList = setData(Mock.mock('@natural(15, 25)'), dataObj)
</script>
转载自:https://juejin.cn/post/7423318322367004707