关于二次封装elementUi

782 阅读2分钟

关于二次封装elementUi

前言

为什么要做这个事呢,作为一个前端后台开发工程师,最经常做的工作就是后台表格开发,由于业务比较简单,就自己单独封装了一个table表格,来提升开发效率。但是觉得没什么难点亮点,重新整理封装思路,研究简单业务可以高速提效,复杂业务也可以自定义完成的公共组件,并发布到npm上包可以拉取。

搭建基础

首先需要搭建一个vue3 + ts + vite + elementPlus的一个架子;

  1. 创建项目
pnpm create vite my-vue-app --template vue-ts
cd my-vue-app
  1. 安装依赖
pnpm install
pnpm add element-plus
pnpm add -D @types/node
  1. 一些配置
  • package.json
{
  "name": "z-dynamic",
  "private": false,
  "version": "0.0.6",
  "type": "module",
  "types": "dist/types/index.d.ts",
  "import": "dist/z-dynamic.es.js",
  "require": "dist/z-dynamic.umd.js",
  "files": [
    "dist",
    "README.md"
  ],
  "exports": {
    ".": {
      "types": "./dist/types/index.d.ts",
      "import": "./dist/z-dynamic.es.js",
      "require": "./dist/z-dynamic.umd.js"
    }
  },
  "scripts": {
    "dev": "vite",
    "build": "vite build && vue-tsc",
    "preview": "vite preview"
  },
  "dependencies": {
    "@element-plus/icons-vue": "^2.3.1",
    "@types/node": "^22.15.3",
    "element-plus": "^2.9.9",
    "vue": "^3.5.13"
  },
  "devDependencies": {
    "@vitejs/plugin-vue": "^5.2.3",
    "@vue/tsconfig": "^0.7.0",
    "typescript": "~5.8.3",
    "vite": "^6.3.5",
    "vue-tsc": "^2.2.8"
  },
  "peerDependencies": {
    "vue": "^3.5.13",
    "element-plus": "^2.9.9"
  },
  "author": "zhoume",
  "description": "A dynamic table component based on Element Plus",
  "keywords": [
    "vue3",
    "element-plus",
    "table",
    "component"
  ]
}
  • 配置vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'

export default defineConfig({
  plugins: [vue()],
  build: {
    lib: {
      // 指定库的入口文件路径
      entry: path.resolve(__dirname, 'src/index.ts'),
      name: 'ZDynamic',
      fileName: (format) => `z-dynamic.${format}.js`,
      formats:['es', 'umd']
    },
    rollupOptions: {
      // 指定外部依赖,这些依赖不会被打包进库中
      external: ['vue', 'element-plus'],
      output: {
        globals: {
          vue: 'Vue',
          'element-plus': 'ElementPlus'
        },
        exports: 'named'
      }
    },
    cssCodeSplit: true,
    sourcemap: true
  },
  resolve: {
    alias: {
      '@': path.resolve(__dirname, 'src'),
      '~': path.resolve(__dirname, './')
    }
  }
})
  • tsconfig.json
{
  "compilerOptions": {
    "target": "ES2020",
    "useDefineForClassFields": true,
    "module": "ESNext",
    "lib": [
      "ES2020",
      "DOM",
      "DOM.Iterable"
    ],
    "skipLibCheck": true,
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "resolveJsonModule": true,
    "isolatedModules": false,
    "noEmit": false,
    "jsx": "preserve",
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true,
    "declaration": true,
    "declarationDir": "dist/types",
    "emitDeclarationOnly": true,
    "outDir": "dist",
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"]
    },
    "types": ["vite/client"]
  },
  "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
}
  • 修改main.ts
import {createApp} from 'vue'

import 'element-plus/dist/index.css'
import ElementPlus from 'element-plus'

import App from './App.vue'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'

const app = createApp(App)

// 注册Element Plus
app.use(ElementPlus)

// 注册Element Plus图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
  app.component(key, component)
}

app.mount('#app')
  • 写前准备(写组件时测试的位置)
<script setup lang="ts">

</script>

<template>
  <div>
    HelloWorld
  </div>
</template>

<style scoped>

</style>
  • 删除src/components下的HelloWorld.vue 和 src下的style.css
  • 在src下创建types/index.ts用于存放类型
  • 运行项目
pnpm dev

这样一个基础vue3 + vite + ts + elementPlus的基础结构就搭好了。

正式开写

首先分析需求

我们要做的是一个动态表格,传入表格的列的配置和表格的数据,来展示一个表格;其中可以内置一些展示配置如展示图片、标签等;还需要提供一些插槽或者自定义渲染的方式来给使用者自己去渲染自己需要的组件。

创建文件

在src/components下创建d-table.vue文件 先去写一个基础的elementplus的表格出来

<script lang="ts" setup>
const tableData = [
  {
    date: '2016-05-03',
    name: 'Tom',
    address: 'No. 189, Grove St, Los Angeles',
  },
  {
    date: '2016-05-02',
    name: 'Tom',
    address: 'No. 189, Grove St, Los Angeles',
  },
  {
    date: '2016-05-04',
    name: 'Tom',
    address: 'No. 189, Grove St, Los Angeles',
  },
  {
    date: '2016-05-01',
    name: 'Tom',
    address: 'No. 189, Grove St, Los Angeles',
  },
]
</script>

<template>
  <el-table :data="tableData" style="width: 100%">
    <el-table-column prop="date" label="Date" width="180" />
    <el-table-column prop="name" label="Name" width="180" />
    <el-table-column prop="address" label="Address" />
  </el-table>
</template>

app中引入d-table组件

<script setup lang="ts">
import DTable from '@/components/d-table.vue'

</script>

<template>
  <div>
    <DTable/>
  </div>
</template>

<style scoped>

</style>

页面 image.png

列数据与表格数据抽离

将d-table组件中的列数据与表格数据抽离,提取到父组件中通过父传子的方式去传递。

  • d-table文件
<script lang="ts" setup>
import {Props} from "@/types";

const props = withDefaults(defineProps<Props>(), {
  columns: () => [],
  data: () =>[],
})
</script>

<template>
  <el-table :data="props.data" style="width: 100%">
    <template v-for="column in props.columns" :key="`${column.prop}-${column.label}`">
      <el-table-column v-bind="column"/>
    </template>
  </el-table>
</template>
  • app.vue
<script setup lang="ts">
import DTable from '@/components/d-table.vue'
import {ref} from "vue";

const columns = ref([
  {
    prop: 'name',
    label: 'Name',
    width: 180,
  },
  {
    prop: 'date',
    label: 'Date',
    width: 180,
  },
  {
    prop: 'address',
    label: 'Address',
    width: 180,
  },
])
const data = [
  {
    date: '2016-05-03',
    name: 'Tom',
    address: 'No. 189, Grove St, Los Angeles',
  },
  {
    date: '2016-05-02',
    name: 'Tom',
    address: 'No. 189, Grove St, Los Angeles',
  },
  {
    date: '2016-05-04',
    name: 'Tom',
    address: 'No. 189, Grove St, Los Angeles',
  },
  {
    date: '2016-05-01',
    name: 'Tom',
    address: 'No. 189, Grove St, Los Angeles',
  },
]
</script>

<template>
  <div>
    <DTable :columns="columns" :data="data"/>
  </div>
</template>

<style scoped>

</style>
  • types 申明的类型
export interface TableColumn<T = any> {
  prop: string | keyof T
  label: string
  width?: number | string
}

export interface Props<T = any> {
  columns: TableColumn<T>[]
  data?: T[]
}

再看页面和之前一样 image.png 这样已经完成了一个基础表格的封装,但是用这个去做业务需求是远远不够的,对比element的表格还有很多功能需要拿过来;接下来就是做这部分工作。

属性和事件的透传

<script lang="ts" setup>
import {Props} from "@/types";
import type {TableInstance} from 'element-plus'
import {ref, useAttrs} from "vue";

defineOptions({
  name: 'DTable'
})

const props = withDefaults(defineProps<Props>(), {
  columns: () => [],
  data: () => [],
})

const attrs = useAttrs()

const tableRef = ref<TableInstance>()
console.log(attrs, props)

defineExpose({tableRef})
</script>

<template>
  <el-table
    ref="tableRef"
    v-bind="{ ...props, ...attrs }"
  >
    <template v-for="column in props.columns" :key="`${column.prop}-${column.label}`">
      <el-table-column v-bind="column">
      
      </el-table-column>
    </template>
  </el-table>
</template>

这时候再去测试比如说我要给表格加一个边框但是我的组件的props中并没有定义border属性他会通过attrs去传递

<DTable :columns="columns" :data="data" :border="true"/>

image.png console.log(attrs, props)打印结果

image.png 再测试一下事件的透传点击某一行会打印对应的行的内容

const rowClick = (row: any) => {
  console.log(row)
}
</script>

<template>
  <div>
    <DTable @row-click="rowClick" :columns="columns" :data="data" :border="true"/>
  </div>
</template>

打印结果 image.png 接下来进行插槽的透传 element官方提供了三个default append empty 这边用empty做示例 用useSlots()拿到传递的插槽 再通过循环放进模板中

<script lang="ts" setup>
import {Props} from "@/types";
import type {TableInstance} from 'element-plus'
import {ref, useAttrs, useSlots} from "vue";

defineOptions({
  name: 'DTable'
})

const props = withDefaults(defineProps<Props>(), {
  columns: () => [],
  data: () => [],
})

const attrs = useAttrs()
const slots = useSlots()

const tableRef = ref<TableInstance>()

defineExpose({tableRef})
</script>

<template>
  <el-table
    ref="tableRef"
    v-bind="{ ...props, ...attrs }"
  >
    <template v-for="column in props.columns" :key="`${column.prop}-${column.label}`">
      <el-table-column v-bind="column">

      </el-table-column>

    </template>
    <template v-for="(_, name) in slots" #[name]="scope" :key="name">
      <slot :name="name" v-bind="scope" ></slot>
    </template>
  </el-table>
</template>

将数据清空测试

<template>
  <div>
    <DTable @row-click="rowClick" :columns="columns" :data="data" :border="true">
      <template #empty>
        <el-tag></el-tag>
      </template>
    </DTable>
  </div>
</template>

<style scoped>

</style>

结果

image.png

表格列的差异化

我们要知道有什么需要做差异化的,在我们业务需求里有一些表格除了展示文字以外还要展示图片、按钮、输入框、或者其他组件等更奇葩的内容;目前的组件是远远不够的。怎样去做呢,element官方提供了default插槽可以去实现。 首先我们要去先完成官方提供的其他插槽的内容。比如HeaderSlot这边直接给示例

<template>
  <el-table
    ref="tableRef"
    v-bind="{ ...props, ...attrs }"
  >
    <template v-for="column in props.columns" :key="`${column.prop}-${column.label}`">
      <el-table-column v-bind="column">
        <!-- Header Slot -->
        <template #header v-if="column.headerSlot">
          <slot :name="column.headerSlot" :column="column"/>
        </template>
        
        <!-- Default Slot -->
        
      </el-table-column>
    </template>
    <template v-for="(_, name) in slots" #[name]="scope" :key="name">
      <slot :name="name" v-bind="scope" ></slot>
    </template>
  </el-table>
</template>
<script setup lang="ts">
const columns = ref([
  {
    prop: 'name',
    label: 'Name',
    width: 180,
  },
  {
    prop: 'date',
    label: 'Date',
    width: 180,
    headerSlot: 'header'
  },
  {
    prop: 'address',
    label: 'Address',
    width: 180,
  },
])

</script>

<template>
  <div>
    <DTable :columns="columns" :data="data" :border="true">
      <!--名字和columns中的对应-->
      <template #header="scope">
        <div>
          {{ scope.column.label }}
          <el-tooltip content="这是一个提示">
            <el-icon>
              <InfoFilled/>
            </el-icon>
          </el-tooltip>
        </div>
      </template>
    </DTable>
  </div>
</template>

image.png 接下来就是正式做差异化的内容了

  • 提供具名插槽来给用户做差异化的功能
<!-- 2. 具名插槽 -->
<slot
  v-else-if="column.slot"
  :name="column.slot"
  :row="scope.row"
  :column="column"
>
  • 通过自定义的渲染函数和component组件的配合实现
<!-- 1. 自定义渲染函数 -->
<component
  :is="column.customRender"
  v-if="column.customRender"
  :row="scope.row"
  :column="column"
/>
  • 展示过滤文本的位置
<!-- 3. 格式化函数 -->
<template v-else-if="column.formatter">
  {{ column.formatter(scope.row) }}
</template>
  • 和默认位置
<template v-else>
  {{ scope.row[column.prop] }}
</template>

直接上示例

<script lang="ts" setup>
import {Props} from "@/types";
import type {TableInstance} from 'element-plus'
import {ref, useAttrs, useSlots} from "vue";

defineOptions({
  name: 'DTable'
})

const props = withDefaults(defineProps<Props>(), {
  columns: () => [],
  data: () => [],
})

const attrs = useAttrs()
const slots = useSlots()

const tableRef = ref<TableInstance>()

defineExpose({tableRef})
</script>

<template>
  <el-table
    ref="tableRef"
    v-bind="{ ...props, ...attrs }"
  >
    <template v-for="column in props.columns" :key="`${column.prop}-${column.label}`">
      <el-table-column v-bind="column">
        <!-- Header Slot -->
        <template #header v-if="column.headerSlot">
          <slot :name="column.headerSlot" :column="column"/>
        </template>
        
        <!-- Default Slot -->
        <template #default="scope" v-if="!column.type || column.type === 'default'">
          <!-- 1. 自定义渲染函数 -->
          <component
            :is="column.customRender"
            v-if="column.customRender"
            :row="scope.row"
            :column="column"
          />
          <!-- 2. 具名插槽 -->
          <slot
            v-else-if="column.slot"
            :name="column.slot"
            :row="scope.row"
            :column="column"
          >
            {{ scope.row[column.prop] }}
          </slot>
          <!-- 3. 格式化函数 -->
          <template v-else-if="column.formatter">
            {{ column.formatter(scope.row) }}
          </template>
          <!-- 4. 默认渲染 -->
          <template v-else>
            {{ scope.row[column.prop] }}
          </template>
        </template>
      </el-table-column>
    </template>
    <template v-for="(_, name) in slots" #[name]="scope" :key="name">
      <slot :name="name" v-bind="scope" ></slot>
    </template>
  </el-table>
</template>

使用组件 可以用h方法去渲染自定义组件也可以用具名插槽

<script setup lang="ts">
import {h, ref} from 'vue'
import DTable from '@/components/d-table.vue'
import {ElButton} from 'element-plus'
import test from '@/components/test.vue'

import type {TableInstance} from 'element-plus'
// 表格实例引用
const tableRef = ref<TableInstance>()
// 表格数据
const tableData = ref([
  {
    id: 1,
    date: '2024-01-01',
    name: 'Tom',
    address: '北京市朝阳区',
    age: 18
  },
  {
    id: 2,
    date: '2024-01-02',
    name: 'Jerry',
    address: '上海市浦东新区',
    age: 20
  },
  {
    id: 3,
    date: '2024-01-02',
    name: 'Jerry',
    address: '上海市浦东新区',
    age: 20,
    children: [
      {
        id: 31,
        date: '2016-05-01',
        name: 'wangxiaohu',
        address: 'No. 189, Grove St, Los Angeles',
      },
      {
        id: 32,
        date: '2016-05-01',
        name: 'wangxiaohu',
        address: 'No. 189, Grove St, Los Angeles',
      },
    ],
  }
])
// 表格列配置
const columns = ref([
  {
    type: 'selection',
    width: 55
  },
  {
    label: '序号',
    type: 'index',
    width: 55
  },
  {
    prop: 'date',
    label: '日期',
    width: 180,
    // 使用自定义表头插槽
    headerSlot: 'dateHeader'
  },
  {
    prop: 'name',
    label: '姓名',
    width: 180,
    // 使用自定义列插槽
    slot: 'name'
  },
  {
    prop: 'address',
    label: '地址',
    // 使用格式化函数
    formatter: (row: any) => `地址: ${row.address}`
  },
  {
    prop: 'age',
    label: '年龄',
    // 使用自定义渲染函数
    customRender: ({row}: any) => {
      return h('span', {
        style: {
          color: row.age >= 20 ? 'red' : 'green'
        }
      }, row.age)
    }
  },
  {
    prop: 'operation',
    label: '操作',
    width: 200,
    customRender: (scope: any) => {
      const row = scope.row
      return h('div', [
        h(ElButton, {type: 'primary', onClick: () => handleEdit(row)}, () => '编辑'),
        h(test, {row, onClick: () => handleDelete(row)}, () => [])
      ])
    }
  }
])

// 处理编辑
const handleEdit = (row: any) => {
  console.log('编辑', row)
}
// 处理删除
const handleDelete = (row: any) => {
  console.log('删除', row)
}
// 获取表格实例方法示例
const getTableInstance = () => {
  console.log('表格实例:', tableRef.value)
}

const handleSelectionChange = (val: any) => {
  console.log('选中的数据:', val)
}
</script>
<template>
  <div>
    <DTable
      ref="tableRef"
      :columns="columns"
      :data="tableData"
      border
      stripe
      row-key="id"
      @selection-change="handleSelectionChange"
    >
      <!-- 日期表头自定义插槽 -->
      <template #dateHeader="{ column }">
        <el-icon>
          <calendar/>
        </el-icon>
        {{ column.label }}
      </template>
      <!-- 姓名列自定义插槽 -->
      <template #name="{ row }">
        <el-tag>{{ row.name }}</el-tag>
      </template>
    </DTable>
    <el-button @click="getTableInstance">获取表格实例</el-button>
  </div>
</template>

除了多级表头这还需要单独处理以外 element官方提供的大部分功能已经实现,后续再去完善

image.png

打包上传

首先需要做一个打包的入口;在src下创建一个index.ts文件

import {App} from 'vue'
import DTable from '@/components/d-table.vue'
import type { TableColumn } from '@/types'

const components = [DTable]
export type { TableColumn }

export default {
  install(app: App) {
    components.forEach((component: any) => {
      console.log('components', component.name)
      app.component(component.name, component)
    })
  }
}

导出组件和类型文件

接下来就是做打包和上传npm

我这里已经注册过npm账号了自己私下去注册就好

pnpm build 打包
npm login 登录 输入验证码即可
npm publish 上传

image.png 这里就可以看到自己刚刚发的包啦