普通视图

发现新文章,点击刷新页面。
昨天以前首页

前端真的需要懂算法吗?该怎么样学习?

作者 刘大华
2025年10月9日 15:09

前言

大家好,我是大华。这篇文章聊聊前端工程师,到底需不需要学算法?

这个问题看起来简单,但其实挺让人纠结的。一方面,我们每天写页面、调样式、处理交互,好像确实用不上什么高深算法。但另一方面,面试又要考算法,大厂尤其喜欢问,让人头疼。

为什么会这样呢?原因其实很简单:前端越来越复杂了

早期的网页就是展示信息,有点交互就不错了。但现在呢?我们要做复杂应用、数据可视化、3D渲染、性能优化……没有点算法基础,还真搞不定一些高级需求。

所以我的答案是:前端需要懂算法,但不是每个算法都要精通。关键是知道在什么地方用什么算法,能解决问题就行。

接下来,我就具体说说前端哪些地方会用到算法,以及该怎么学习。


一、前端哪些地方会用到算法?

1. 数据处理和转换

前端不再只是展示静态数据了。我们经常需要处理来自API的复杂数据,转换成组件需要的格式。

比如,你拿到一个树形结构的数据,要扁平化处理:

// 原始数据
const treeData = [
  {
    id: 1,
    name: '父节点1',
    children: [
      { id: 2, name: '子节点1' },
      { id: 3, name: '子节点2' }
    ]
  }
];

// 扁平化处理
function flattenTree(nodes) {
  const result = [];
  
  function traverse(nodes) {
    nodes.forEach(node => {
      result.push({ id: node.id, name: node.name });
      if (node.children) {
        traverse(node.children);
      }
    });
  }
  
  traverse(nodes);
  return result;
}

这就是用了树的深度优先遍历算法。

2. 搜索和筛选功能

电商网站的商品筛选、管理后台的表格搜索,都需要算法优化。

比如实现一个模糊搜索:

function fuzzySearch(items, query) {
  return items.filter(item => {
    // 简单的模糊匹配算法
    let index = 0;
    for (let char of query.toLowerCase()) {
      const foundIndex = item.name.toLowerCase().indexOf(char, index);
      if (foundIndex === -1) return false;
      index = foundIndex + 1;
    }
    return true;
  });
}

3. 性能优化

滚动加载、虚拟列表、防抖节流,这些都和算法有关。

虚拟列表的核心算法:

function calculateVisibleRange(containerHeight, scrollTop, itemHeight, itemCount) {
  const startIndex = Math.floor(scrollTop / itemHeight);
  const endIndex = Math.min(
    startIndex + Math.ceil(containerHeight / itemHeight) + 1,
    itemCount
  );
  return { startIndex, endIndex };
}

4. 动画和可视化

做数据可视化或复杂动画时,经常需要数学知识和物理算法。

比如缓动函数:

function easeInOutQuad(t) {
  // 二次缓动函数算法
  return t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;
}

5. 框架和库的使用

React的diff算法、Vue的响应式原理,都用了精妙的算法。理解它们能帮你更好地使用框架。


二、前端该怎么学习算法?

1. 先从实用的学起

不要一上来就啃《算法导论》,很容易会被劝退的。先从实际工作中可能用到的学起:

  • 数组操作:查找、排序、去重
  • 字符串处理:匹配、解析、转换
  • 树结构:遍历、查找、操作
  • 图算法:最短路径、拓扑排序(可视化常用)

2. 按优先级学习

我推荐这个学习顺序:

  1. 基础数据结构(数组、字符串、对象)
  2. 常用算法(排序、搜索、递归)
  3. 树和图的处理
  4. 动态规划(面试常用)
  5. 其他高级算法(按需学习)

3. 用JavaScript实现

看算法书时,用JavaScript实现一遍,不要只用Python或Java。这样印象更深刻,也能积累自己的算法库。

比如实现一个快速排序:

function quickSort(arr) {
  if (arr.length <= 1) return arr;
  
  const pivot = arr[0];
  const left = [];
  const right = [];
  
  for (let i = 1; i < arr.length; i++) {
    if (arr[i] < pivot) {
      left.push(arr[i]);
    } else {
      right.push(arr[i]);
    }
  }
  
  return [...quickSort(left), pivot, ...quickSort(right)];
}

4. 刷题但不要硬刷

LeetCode是好东西,但要有方法:

  • 按类型刷,不要随机刷
  • 重点刷简单和中等难度
  • 理解思路比记住代码重要
  • 总结同类问题的解法模式

5. 结合实际项目

最好的学习方式是用到实际项目中。比如:

  • 优化列表渲染性能时,学习虚拟列表算法
  • 做富文本编辑器时,学习字符串匹配算法
  • 处理复杂表单时,学习状态管理算法

总结

前端需要算法吗?需要,但要有选择地学

不需要成为算法大师,但要能:

  1. 理解常见算法的思想
  2. 在合适的地方用合适的算法
  3. 解决实际业务问题
  4. 通过面试考察

学习算法的关键不是死记硬背,而是培养计算思维——把复杂问题拆解、抽象、模式识别的能力。这种能力比任何具体算法都重要。

希望这篇文章对你有帮助!如果有问题,欢迎在评论区留言讨论~

本文首发于公众号:程序员刘大华,专注分享前后端开发的实战笔记。关注我,少走弯路,一起进步!

📌往期精彩

《SpringBoot 中的 7 种耗时统计方式,你用过几种?》

《Java8 都出这么多年了,Optional 还是没人用?到底卡在哪了?》

《加班到凌晨,我用 Vue3 + ElementUI 写了个可编辑的表格组件》

《vue3 登录页还能这么丝滑?这个 hover 效果太惊艳了》

前端必看!12个JS神级简写技巧,代码效率直接飙升80%,告别加班!

作者 刘大华
2025年10月7日 23:33

前言

哈喽大家好,我是大华。

在日常开发中,我们经常会遇到一些重复、冗长的代码。写起来费劲,读起来费神,维护起来更是头疼。而且代码越复杂,性能可能越受影响。

那有没有办法让代码更简洁、清晰又高效呢?

JavaScript提供了许多现代语法特性,合理使用这些简写技巧,不仅能大幅减少代码量,还能提升可读性和执行效率。

很多资深前端都在用,这篇文章整理了 12 个最实用的 JS 简写技巧,并结合实际场景进行优化和补充,帮助你写出更优雅的代码。


1. 短路运算符:替代简单的 if 判断

以前我们这样写条件判断:

if (isReady) {
    startApp();
}

现在可以用逻辑与(&&)的短路特性简化为一行:

isReady && startApp();

适用场景:当 isReady 为真值时才执行函数。

注意:仅适用于简单条件,避免滥用导致可读性下降。


2. 空值合并运算符(??):精准设置默认值

传统做法是用 || 设置默认值:

let name = username || '默认用户';

但问题来了:如果 username0''false,也会被替换成默认值 —— 这通常不是我们想要的。

推荐使用空值合并运算符:

let name = username ?? '默认用户';

只有当 usernamenullundefined 时才会使用默认值,其他“假值”如 0'' 都会被保留。

最佳实践:处理 API 返回数据或配置项时特别有用。


3. 可选链操作符(?.):安全访问深层属性

想获取一个嵌套对象的属性:

user.address.street.name;

但如果 useraddress 不存在,就会抛出错误。

过去需要层层判断:

const streetName = user && user.address && user.address.street && user.address.street.name;

现在只需:

const streetName = user?.address?.street?.name;

如果任意一层为 null/undefined,返回 undefined 而不会报错。

支持方法调用:obj.method?.() 和数组索引:arr?.[index]


4. 模板字符串:字符串拼接

老式拼接方式容易出错且难看:

let message = 'Hello, ' + name + '! 你的余额是 ' + balance + ' 元。';

用模板字符串(反引号)清爽多了:

let message = `Hello, ${name}! 你的余额是 ${balance} 元。`;

优势:

  • 支持换行
  • 内嵌表达式 ${}
  • 更易维护

5. 解构赋值:快速提取数据

从对象或数组中取值,再也不用手动赋值了。

对象解构:

// 旧写法
let name = user.name;
let age = user.age;

// 新写法
let { name, age } = user;

还可以重命名、设默认值:

let { name: userName, age = 18 } = user;

数组解构:

let [first, second] = list;
let [, , third] = list; // 跳过前两个元素

常用于函数参数解构、React 中的状态提取等。


6. 箭头函数:更简洁的函数定义

传统函数:

function multiply(a, b) {
    return a * b;
}

箭头函数一行搞定:

const multiply = (a, b) => a * b;

优点:

  • 语法简洁
  • 不绑定自己的this,适合事件回调、.map() 等场景

注意:不要在对象方法或需要动态this的地方使用(比如 DOM 事件监听器),否则this指向会出问题。


7. 扩展运算符(...):轻松合并与复制

合并数组:

// 旧:concat()
let combined = array1.concat(array2);

// 新:扩展运算符
let combined = [...array1, ...array2];

插入元素也方便:

let newArr = [...array1, newItem, ...array2];

合并对象:

let config = { ...defaultConfig, ...userConfig };

注意:扩展运算符是浅拷贝!嵌套对象仍共享引用。


8. 数组高阶函数:替代 for 循环

遍历查找元素,过去常用 for 循环:

let found;
for (let i = 0; i < users.length; i++) {
    if (users[i].id === targetId) {
        found = users[i];
        break;
    }
}

现在一行解决:

let found = users.find(user => user.id === targetId);

常用方法推荐:

方法 用途
.find() 查找第一个匹配项
.filter() 过滤出所有符合条件的项
.map() 映射新数组
.some() / .every() 判断是否存在 / 是否全部满足条件
.includes() 判断是否包含某值

示例:

// 获取所有活跃用户的名字
const activeNames = users
  .filter(u => u.isActive)
  .map(u => u.name);

9. 对象属性简写:变量名即属性名

当变量名与属性名一致时,无需重复书写:

let name = '小明';
let age = 25;

// 旧写法
let user = {
    name: name,
    age: age
};

// 新写法
let user = {
    name,
    age
};

特别适合构建 API 请求体、返回对象等场景。


10. 指数运算符(**):更直观的幂运算

以前计算幂要用 Math.pow()

let result = Math.pow(2, 8); // 256

现在直接用 **

let result = 2 ** 8; // 256

更接近数学表达习惯,支持负指数:

let half = 2 ** -1; // 0.5

11. 逻辑赋值运算符:结合条件与赋值

ES2021 引入了三个逻辑赋值运算符,能进一步简化赋值逻辑。

场景一:只在为空时赋值

// 旧写法
if (user.role === null || user.role === undefined) {
    user.role = 'guest';
}

// 新写法
user.role ??= 'guest'; // 等价于 user.role = user.role ?? 'guest';

场景二:只有当前值为真才更新

user.isAdmin &&= false; // 若 isAdmin 为真,则设为 false

场景三:拼接字符串或累加

message += welcomeText; // 原生已有

// 也可以用
message ||= 'default'; // 如果 message 是假值,则赋默认值

小结:

运算符 含义
??= 空值合并赋值
&&= 逻辑与赋值
||= 逻辑或赋值

12. Nullish Coalescing 结合 Optional Chaining

?.?? 结合使用,可以构建极其健壮的数据访问逻辑。

例如:

const displayName = user?.profile?.name ?? '匿名用户';

解释:

  • 先通过可选链安全访问 name
  • 如果结果是 nullundefined,则返回默认值

应用于表单默认值、UI 渲染 fallback 等非常合适。


总结

技巧 关键字/符号
1. 短路运算 &&, ||
2. 空值合并 ??
3. 可选链 ?.
4. 模板字符串 `${}`
5. 解构赋值 {}, []
6. 箭头函数 =>
7. 扩展运算符 ...
8. 数组方法 .find(), .map()
9. 属性简写 { name }
10. 指数运算符 **
11. 逻辑赋值 ??=, &&=, ||=
12. 组合技巧 ?. + ??

它们的好处

  • 减少代码量
  • 提升可读性(适度使用)
  • 增强健壮性(如 ?.??
  • 更贴近现代 JS 编码规范

但也请注意

  • 不要为了使用而过度简化
  • 团队协作中确保成员都熟悉这些语法
  • 在关键路径上优先保证可读性和可调试性

如果你觉得这篇文章对你有帮助,欢迎点赞、收藏、转发!

公众号:程序员刘大华,专注分享前后端开发的实战笔记。关注我,少走弯路,一起进步!

📌往期精彩

《Java8 都出这么多年了,Optional 还是没人用?到底卡在哪了?》

《90%的人不知道!Spring官方早已不推荐@Autowired?这3种注入方式你用对了吗?》

《别再写 TypeScript enum了!新枚举方式让 bundle 瞬间小20%》

《Vue3 的 ref 和 reactive 到底用哪个?90% 的开发者都选错了》

加班到凌晨,我用 Vue3 + ElementUI 写了个可编辑的表格组件

作者 刘大华
2025年10月6日 09:04

前言

大家好,我是大华!

在我们日常的后台管理开发中,表格可以说是最常用的数据展示和操作组件之一了。

很多用户还希望能够直接在线编辑表格数据、插入新行、删除不需要的行,甚至还需要支持各种类型的数据输入。

这时候,一个通用的可编辑表格组件就显得尤为重要。

所以我加班加点整出了这么一个表格组件。

功能预览

先来看下效果图:

20250919_110511.gif

我们看看上面的组件效果图具备了哪些功能:

  • 支持单元格双击编辑
  • 支持右键菜单操作(插入行、删除行)
  • 支持多种输入类型(文本、数字、下拉选择、日期选择)
  • 支持汇总行计算
  • 响应式数据更新
  • 灵活的列配置

核心代码实现

我们一步步来实现这可编辑的表格组件。

1. 组件基础结构

首先,我们定义组件的基本结构和Props

<!-- EditableTable.vue -->
<template>
  <!-- 
    el-table 是 Element Plus 的表格组件
    :data 绑定表格数据
    @cell-dblclick 监听单元格双击事件
    @row-contextmenu 监听行右键点击事件
    :summary-method 指定汇总行计算方法
    :row-class-name 指定行类名生成方法
    v-bind="$attrs" 继承所有未声明的属性
  -->
  <el-table
    :data="tableData"
    @cell-dblclick="handleCellDblClick"
    @row-contextmenu="handleRowRightClick"
    :summary-method="getSummaries"
    :row-class-name="tableRowClassName"
    :border="border"
    :show-summary="showSummary"
    v-bind="$attrs"
  >
    <!-- 序号列 -->
    <el-table-column 
      v-if="showIndex" 
      type="index" 
      label="序号" 
      width="60" 
    />
    
    <!-- 表格列渲染 -->
    <el-table-column
      v-for="column in columns"
      :key="column.prop"
      :prop="column.prop"
      :label="column.label"
      :width="column.width"
    >
      <!-- 使用作用域插槽自定义单元格内容 -->
      <template #default="scope">
        <!-- 根据列类型渲染不同的输入组件 -->
        <!-- 数字输入框 -->
        <el-input-number
          v-if="column.type === 'number' && scope.row[`${column.prop}_editing`]"
          v-model.number="scope.row[column.prop]"
          :min="column.min || 0"
          :max="column.max || 100"
          :step="column.step || 1"
          @blur="scope.row[`${column.prop}_editing`] = false"
          size="small"
        />
        
        <!-- 下拉选择框 -->
        <el-select
          v-else-if="column.type === 'select' && scope.row[`${column.prop}_editing`]"
          v-model="scope.row[column.prop]"
          :multiple="column.multiple || false"
          @blur="scope.row[`${column.prop}_editing`] = false"
          size="small"
        >
          <el-option
            v-for="item in column.options || []"
            :key="item.value"
            :label="item.label"
            :value="item.value"
          />
        </el-select>
        
        <!-- 日期选择器 -->
        <el-date-picker
          v-else-if="column.type === 'date' && scope.row[`${column.prop}_editing`]"
          v-model="scope.row[column.prop]"
          type="date"
          placeholder="选择日期"
          @blur="scope.row[`${column.prop}_editing`] = false"
          size="small"
        />
        
        <!-- 文本编辑框 -->
        <el-input
          v-else-if="scope.row[`${column.prop}_editing`]"
          v-model="scope.row[column.prop]"
          @blur="scope.row[`${column.prop}_editing`] = false"
          size="small"
          autofocus
        />
        
        <!-- 文本显示(非编辑状态) -->
        <div
          v-else
          class="cell-text"
          @dblclick.stop="handleCellDblClick(scope.row, { property: column.prop })"
        >
          {{ formatCellValue(scope.row[column.prop], column) }}
        </div>
      </template>
    </el-table-column>
    
    <!-- 右键菜单 -->
    <div
      v-show="showContextMenu"
      class="context-menu"
      :style="{ top: contextMenuTop + 'px', left: contextMenuLeft + 'px' }"
      @mouseleave="hideContextMenu"
    >
      <el-button @click="insertRowAbove" size="small">上方插入一行</el-button>
      <el-button @click="insertRowBelow" size="small">下方插入一行</el-button>
      <el-button @click="openInsertMultipleDialog(false)" size="small">上方插入多行</el-button>
      <el-button @click="openInsertMultipleDialog(true)" size="small">下方插入多行</el-button>
      <el-button type="danger" @click="deleteCurrentRow" size="small">删除当前行</el-button>
    </div>
  </el-table>
  
  <!-- 插入多行对话框 -->
  <el-dialog
    v-model="showInsertMultipleDialog"
    :title="insertMultipleBelow ? '在下方插入多行' : '在上方插入多行'"
    width="400px"
  >
    <el-input-number
      v-model="insertRowCount"
      :min="1"
      :max="10"
      label="插入行数"
    />
    <template #footer>
      <el-button @click="showInsertMultipleDialog = false">取消</el-button>
      <el-button type="primary" @click="insertMultipleRows">确定</el-button>
    </template>
  </el-dialog>
</template>

<script lang="ts" setup>
// 导入 Vue 相关功能
import { ref, computed, nextTick, watch } from 'vue'
// 导入 Element Plus 组件
import { ElMessageBox, ElMessage } from 'element-plus'

// 定义表格列的接口
interface TableColumn {
  prop: string           // 字段名
  label: string          // 列标题
  type?: 'text' | 'number' | 'select' | 'date'  // 输入类型
  width?: string         // 列宽度
  min?: number           // 最小值(数字类型)
  max?: number           // 最大值(数字类型)
  step?: number          // 步长(数字类型)
  options?: Array<{      // 选项(选择类型)
    value: string | number
    label: string
  }>
  multiple?: boolean     // 是否多选(选择类型)
  formatter?: (value: any) => string // 自定义格式化函数
}

// 定义组件接收的属性
const props = defineProps({
  data: {                // 表格数据
    type: Array,
    default: () => []
  },
  columns: {             // 列配置
    type: Array as () => TableColumn[],
    required: true
  },
  showIndex: {           // 是否显示序号列
    type: Boolean,
    default: false
  },
  border: {              // 是否显示边框
    type: Boolean,
    default: true
  },
  showSummary: {         // 是否显示汇总行
    type: Boolean,
    default: false
  },
  summaryMethod: {       // 自定义汇总方法
    type: Function,
    default: null
  },
  disabledColumns: {     // 禁止编辑的列
    type: Array as () => string[],
    default: () => []
  }
})

// 定义组件可触发的事件
const emit = defineEmits(['update:data', 'row-added', 'row-deleted'])

// 使用计算属性处理表格数据,确保响应式
const tableData = computed({
  get: () => props.data,
  set: (value) => emit('update:data', value)
})

// 右键菜单相关状态
const showContextMenu = ref(false)
const contextMenuTop = ref(0)
const contextMenuLeft = ref(0)
const currentContextRow = ref<any>(null)

// 插入多行相关状态
const showInsertMultipleDialog = ref(false)
const insertRowCount = ref(1)
const insertMultipleBelow = ref(false)

// 检查列是否被禁用编辑
const isDisabledColumn = (prop: string) => {
  return props.disabledColumns.includes(prop)
}

// 格式化单元格显示值
const formatCellValue = (value: any, column: TableColumn) => {
  if (column.formatter) {
    return column.formatter(value)
  }
  
  if (column.type === 'select' && column.options) {
    const option = column.options.find(opt => opt.value === value)
    return option ? option.label : value
  }
  
  return value
}
</script>

<style scoped>
.context-menu {
  position: fixed;
  background: white;
  border: 1px solid #ddd;
  border-radius: 4px;
  padding: 10px;
  box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
  z-index: 2000;
  display: flex;
  flex-direction: column;
  gap: 5px;
}

.cell-text {
  width: 100%;
  height: 100%;
  padding: 8px 0;
  cursor: default;
}

.cell-text:hover {
  background-color: #f5f7fa;
}
</style>

2. 实现单元格编辑功能

单元格编辑是核心功能之一,我们支持多种输入类型:

// 在 script setup 中添加以下代码

// 单元格双击事件处理
const handleCellDblClick = (row: any, column: any) => {
  const prop = column.property
  if (!prop || isDisabledColumn(prop)) return
  
  // 关闭其他单元格的编辑状态
  props.columns.forEach(col => {
    if (col.prop !== prop) {
      row[`${col.prop}_editing`] = false
    }
  })
  
  // 开启当前单元格编辑
  row[`${prop}_editing`] = true
}

// 行右键点击事件处理
const handleRowRightClick = (row: any, column: any, event: MouseEvent) => {
  // 阻止浏览器默认右键菜单
  event.preventDefault()
  
  // 设置当前右键点击的行
  currentContextRow.value = row
  
  // 设置菜单位置
  contextMenuTop.value = event.clientY
  contextMenuLeft.value = event.clientX
  
  // 显示菜单
  showContextMenu.value = true
}

// 隐藏右键菜单
const hideContextMenu = () => {
  showContextMenu.value = false
}

3. 实现行操作功能

右键菜单提供了丰富的行操作功能:

// 创建新行
const createNewRow = () => {
  const newRow: any = {}
  
  // 根据列配置初始化新行的值
  props.columns.forEach(column => {
    // 设置默认值
    if (column.type === 'number') {
      newRow[column.prop] = 0
    } else if (column.type === 'select' && column.options && column.options.length > 0) {
      newRow[column.prop] = column.multiple ? [] : column.options[0].value
    } else {
      newRow[column.prop] = ''
    }
    
    // 初始化编辑状态
    newRow[`${column.prop}_editing`] = false
  })
  
  return newRow
}

// 在上方插入一行
const insertRowAbove = () => {
  if (!currentContextRow.value) return
  
  const index = tableData.value.indexOf(currentContextRow.value)
  if (index === -1) return
  
  const newRow = createNewRow()
  tableData.value.splice(index, 0, newRow)
  emit('row-added', { index, row: newRow })
  hideContextMenu()
}

// 在下方插入一行
const insertRowBelow = () => {
  if (!currentContextRow.value) return
  
  const index = tableData.value.indexOf(currentContextRow.value)
  if (index === -1) return
  
  const newRow = createNewRow()
  tableData.value.splice(index + 1, 0, newRow)
  emit('row-added', { index: index + 1, row: newRow })
  hideContextMenu()
}

// 打开插入多行对话框
const openInsertMultipleDialog = (below: boolean) => {
  insertMultipleBelow.value = below
  insertRowCount.value = 1
  showInsertMultipleDialog.value = true
  hideContextMenu()
}

// 插入多行
const insertMultipleRows = () => {
  if (!currentContextRow.value) return
  
  const index = tableData.value.indexOf(currentContextRow.value)
  if (index === -1) return
  
  const startIndex = insertMultipleBelow.value ? index + 1 : index
  const newRows = Array.from({ length: insertRowCount.value }, createNewRow)
  
  tableData.value.splice(startIndex, 0, ...newRows)
  
  // 触发多个行添加事件
  newRows.forEach((row, i) => {
    emit('row-added', { index: startIndex + i, row })
  })
  
  showInsertMultipleDialog.value = false
}

// 删除当前行
const deleteCurrentRow = () => {
  if (!currentContextRow.value) return
  
  const index = tableData.value.indexOf(currentContextRow.value)
  if (index === -1) return
  
  ElMessageBox.confirm('确定要删除这一行吗?', '提示', {
    confirmButtonText: '确定',
    cancelButtonText: '取消',
    type: 'warning'
  }).then(() => {
    const deletedRow = tableData.value.splice(index, 1)
    emit('row-deleted', { index, row: deletedRow[0] })
    ElMessage.success('删除成功')
    hideContextMenu()
  }).catch(() => {
    // 用户取消删除
    hideContextMenu()
  })
}

4. 实现汇总行功能

汇总行可以自动计算数值列的总和:

// 汇总行计算方法
const getSummaries = (param: any) => {
  // 如果提供了自定义汇总方法,使用自定义方法
  if (props.summaryMethod) {
    return props.summaryMethod(param)
  }
  
  const { columns, data } = param
  const sums: string[] = []
  
  columns.forEach((column: any, index: number) => {
    if (index === 0) {
      sums[index] = '合计'
      return
    }
    
    // 只对数字类型的列进行汇总
    const colConfig = props.columns.find(col => col.prop === column.property)
    if (!colConfig || colConfig.type !== 'number') {
      sums[index] = ''
      return
    }
    
    // 计算总和
    const values = data.map((item: any) => Number(item[column.property]))
    if (values.every((value: any) => isNaN(value))) {
      sums[index] = ''
    } else {
      const sum = values.reduce((prev: number, curr: number) => {
        const value = Number(curr)
        return isNaN(value) ? prev : prev + value
      }, 0)
      sums[index] = `${sum}`
    }
  })
  
  return sums
}

// 行类名生成方法,用于设置汇总行样式
const tableRowClassName = ({ rowIndex }: { rowIndex: number }) => {
  if (props.showSummary && rowIndex === tableData.value.length) {
    return 'summary-row'
  }
  return ''
}

使用示例

现在让我们看看如何使用这个组件:

<!-- Example.vue -->
<template>
  <div class="container">
    <h1>可编辑表格示例</h1>
    
    <EditableTable
      :data="tableData"
      :columns="columns"
      :show-index="true"
      :show-summary="true"
      @update:data="handleDataUpdate"
      @row-added="handleRowAdded"
      @row-deleted="handleRowDeleted"
    />
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import EditableTable from './EditableTable.vue'

// 表格数据
const tableData = ref<any>([
  { id: 1, name: '张三', age: 25, gender: 'male', score: 85, birthdate: '1998-05-12' },
  { id: 2, name: '李四', age: 30, gender: 'female', score: 92, birthdate: '1993-08-24' },
  { id: 3, name: '王五', age: 28, gender: 'male', score: 78, birthdate: '1995-11-03' }
])

// 列配置
const columns = ref<any>([
  { prop: 'name', label: '姓名', width: '120px' },
  { 
    prop: 'age', 
    label: '年龄', 
    type: 'number',
    min: 0,
    max: 150
  },
  { 
    prop: 'gender', 
    label: '性别', 
    type: 'select',
    options: [
      { value: 'male', label: '男' },
      { value: 'female', label: '女' }
    ]
  },
  { 
    prop: 'score', 
    label: '分数', 
    type: 'number',
    min: 0,
    max: 100
  },
  { 
    prop: 'birthdate', 
    label: '出生日期', 
    type: 'date'
  }
])

// 处理数据更新
const handleDataUpdate = (newData: any[]) => {
  tableData.value = newData
  console.log('数据已更新:', newData)
}

// 处理行添加事件
const handleRowAdded = ({ index, row }: { index: number; row: any }) => {
  console.log(`在第 ${index} 行添加了新行:`, row)
}

// 处理行删除事件
const handleRowDeleted = ({ index, row }: { index: number; row: any }) => {
  console.log(`删除了第 ${index} 行:`, row)
}
</script>

<style scoped>
.container {
  padding: 20px;
}
</style>

功能扩展建议

这个组件基本的操作是够用的,你也可以根据实际需求进一步扩展:

  1. 数据验证 - 添加单元格数据验证功能,确保输入数据的正确性
  2. 撤销重做 - 实现操作历史记录,支持撤销和重做操作
  3. 批量操作 - 支持批量编辑和删除,提高操作效率
  4. 列配置 - 允许用户自定义显示哪些列,以及列的显示顺序
  5. 导入导出 - 支持 Excel 导入导出功能,方便数据交换
  6. 分页功能 - 集成分页支持大数据量,提高性能
  7. 行拖拽排序 - 支持通过拖拽调整行顺序
  8. 列宽调整 - 支持通过拖拽调整列宽度

EditableTable 组件完整代码

<template>
  <el-table
    :data="tableData"
    @cell-dblclick="handleCellDblClick"
    @row-contextmenu="handleRowRightClick"
    :summary-method="getSummaries"
    :row-class-name="tableRowClassName"
    :border="border"
    :show-summary="showSummary"
    v-bind="$attrs"
  >
    <!-- 序号列 -->
    <el-table-column
      v-if="showIndex"
      type="index"
      label="序号"
      align="center"
      :resizable="false"
      width="70"
    />

    <!-- 动态列 -->
    <el-table-column
      v-for="column in columns"
      :key="column.prop"
      :prop="column.prop"
      :label="column.label"
      :align="column.align || 'left'"
      :width="column.width"
      :resizable="column.resizable !== false"
    >
      <template #default="scope">
        <!-- 数字输入框 -->
        <el-input-number
          v-if="column.type === 'number'"
          v-model.number="scope.row[column.prop]"
          :min="column.min || 0"
          :max="column.max || 100"
          :step="column.step || 1"
          :precision="column.precision || 0"
          :controls="column.controls !== false"
          :disabled="isDisabled(column, scope.row)"
          style="width: 100%"
        />
        
        <!-- 单选下拉框 -->
        <el-select
          v-else-if="column.type === 'select'"
          v-model="scope.row[column.prop]"
          :multiple="column.multiple || false"
          :multiple-limit="column.multipleLimit || 1"
          :filterable="column.filterable !== false"
          :clearable="column.clearable !== false"
          :disabled="isDisabled(column, scope.row)"
          :placeholder="column.placeholder || '请选择'"
          style="width: 100%"
        >
          <el-option
            v-for="item in column.options || []"
            :key="item[column.valueKey || 'value']"
            :label="item[column.labelKey || 'label']"
            :value="item[column.valueKey || 'value']"
          />
        </el-select>
        
        <!-- 日期选择 -->
        <el-date-picker
          v-else-if="column.type === 'date'"
          v-model="scope.row[column.prop]"
          :type="column.dateType || 'date'"
          :format="column.format || 'YYYY-MM-DD'"
          :value-format="column.valueFormat || 'YYYY-MM-DD'"
          :disabled="isDisabled(column, scope.row)"
          :placeholder="column.placeholder || '选择日期'"
          style="width: 100%"
        />
        
        <!-- 普通文本显示 -->
        <div
          v-else-if="!scope.row[`${column.prop}_editing`]"
          class="cell-text"
          v-html="formatCellValue(scope.row[column.prop], column.formatter)"
        />
        
        <!-- 编辑状态下的文本输入框 -->
        <el-input
          v-else
          :ref="setInputRef(scope.$index, column.prop)"
          v-model="scope.row[column.prop]"
          :type="column.inputType || 'text'"
          :autosize="{ minRows: 1, maxRows: 4 }"
          :disabled="isDisabled(column, scope.row)"
          @blur="scope.row[`${column.prop}_editing`] = false"
          @keyup.enter="scope.row[`${column.prop}_editing`] = false"
        />
      </template>
    </el-table-column>
  </el-table>

  <!-- 右键菜单 -->
  <div
    v-show="showContextMenu"
    id="context-menu"
    class="context-menu"
    @mouseleave="hideContextMenu"
  >
    <el-button type="primary" @click="insertRowAbove">上方插入一行</el-button>
    <el-button @click="openInsertMultipleDialog(false)">上方插入多行</el-button>
    <el-button type="primary" @click="insertRowBelow">下方插入一行</el-button>
    <el-button @click="openInsertMultipleDialog(true)">下方插入多行</el-button>
    <el-button type="danger" @click="deleteCurrentRow">删除当前行</el-button>
  </div>

  <!-- 插入多行对话框 -->
  <el-dialog
    v-model="showInsertDialog"
    title="插入多行"
    width="300px"
    append-to-body
  >
    <el-input-number
      v-model="insertRowCount"
      :min="1"
      :max="20"
      style="width: 100%"
    />
    <template #footer>
      <el-button @click="showInsertDialog = false">取消</el-button>
      <el-button type="primary" @click="insertMultipleRows">确定</el-button>
    </template>
  </el-dialog>
</template>

<script lang="ts" setup>
import { ref, reactive, watch, nextTick, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'

interface TableColumn {
  prop: string
  label: string
  type?: 'text' | 'number' | 'select' | 'date'
  width?: string | number
  align?: 'left' | 'center' | 'right'
  resizable?: boolean
  min?: number
  max?: number
  step?: number
  precision?: number
  options?: any[]
  valueKey?: string
  labelKey?: string
  multiple?: boolean
  multipleLimit?: number
  filterable?: boolean
  clearable?: boolean
  placeholder?: string
  dateType?: string
  format?: string
  valueFormat?: string
  inputType?: string
  disabled?: boolean | ((row: any) => boolean)
  formatter?: (value: any) => string
  controls?: boolean
}

const props = defineProps({
  // 表格数据
  data: {
    type: Array,
    default: () => []
  },
  // 列配置
  columns: {
    type: Array as () => TableColumn[],
    required: true
  },
  // 是否显示序号列
  showIndex: {
    type: Boolean,
    default: true
  },
  // 是否显示边框
  border: {
    type: Boolean,
    default: true
  },
  // 是否显示汇总行
  showSummary: {
    type: Boolean,
    default: false
  },
  // 汇总方法
  summaryMethod: {
    type: Function,
    default: null
  },
  // 禁止编辑的列
  disabledColumns: {
    type: Array as () => string[],
    default: () => []
  }
})

const emit = defineEmits(['update:data', 'row-added', 'row-deleted'])

// 表格数据
const tableData = ref<any[]>([])
// 显示右键菜单
const showContextMenu = ref(false)
// 当前右键的行信息
const currentContextRow = reactive({
  index: null as number | null,
  column: null as string | null,
  isHeader: false
})
// 插入多行对话框
const showInsertDialog = ref(false)
// 插入行数
const insertRowCount = ref(1)
// 是否在下方插入
const insertBelow = ref(false)
// 输入框引用
const inputRefs = ref<Record<string, any>>({})

// 初始化表格数据
watch(() => props.data, (newData) => {
  if (newData && newData.length > 0) {
    tableData.value = newData.map(row => {
      const safeRow = (typeof row === 'object' && row !== null) ? { ...row } : {}
      props.columns.forEach(col => {
        safeRow[`${col.prop}_editing`] = false
      })
      return safeRow
    })
  } else {
    tableData.value = []
  }
}, { immediate: true, deep: true })

// 创建新行数据
const createNewRow = () => {
  const newRow: any = {}
  props.columns.forEach(col => {
    newRow[col.prop] = col.type === 'number' ? 0 : ''
    newRow[`${col.prop}_editing`] = false
  })
  return newRow
}

// 设置输入框引用
const setInputRef = (rowIndex: number, prop: string) => (el: any) => {
  inputRefs.value[`${rowIndex}-${prop}`] = el
}

// 单元格双击事件
const handleCellDblClick = (row: any, column: any) => {
  const prop = column.property
  if (!prop || isDisabledColumn(prop) || isDisabled(row, prop)) return
  
  // 关闭其他单元格的编辑状态
  props.columns.forEach(col => {
    row[`${col.prop}_editing`] = false
  })
  
  // 开启当前单元格编辑状态
  row[`${prop}_editing`] = true
  
  // 聚焦输入框
  nextTick(() => {
    const inputKey = `${row.row_index}-${prop}`
    const input = inputRefs.value[inputKey]
    if (input) {
      input.focus()
    }
  })
}

// 行右键事件
const handleRowRightClick = (row: any, column: any, event: MouseEvent) => {
  event.preventDefault()
  showContextMenu.value = false
  
  // 定位右键菜单
  const menu = document.getElementById('context-menu')
  if (menu) {
    menu.style.left = `${event.clientX}px`
    menu.style.top = `${event.clientY}px`
  }
  
  showContextMenu.value = true
  currentContextRow.index = row.row_index
  currentContextRow.column = column.property
  currentContextRow.isHeader = false
}

// 隐藏右键菜单
const hideContextMenu = () => {
  showContextMenu.value = false
}

// 在上方插入一行
const insertRowAbove = () => {
  if (currentContextRow.index === null) return
  const newRow = createNewRow()
  tableData.value.splice(currentContextRow.index, 0, newRow)
  emit('row-added', { index: currentContextRow.index, row: newRow })
  hideContextMenu()
}

// 在下方插入一行
const insertRowBelow = () => {
  if (currentContextRow.index === null) return
  const newRow = createNewRow()
  tableData.value.splice(currentContextRow.index + 1, 0, newRow)
  emit('row-added', { index: currentContextRow.index + 1, row: newRow })
  hideContextMenu()
}

// 打开插入多行对话框
const openInsertMultipleDialog = (below: boolean) => {
  insertBelow.value = below
  insertRowCount.value = 1
  showInsertDialog.value = true
}

// 插入多行
const insertMultipleRows = () => {
  if (currentContextRow.index === null) return
  
  const newRows = Array.from({ length: insertRowCount.value }, () => createNewRow())
  const insertIndex = insertBelow.value ? currentContextRow.index + 1 : currentContextRow.index
  tableData.value.splice(insertIndex, 0, ...newRows)
  
  emit('row-added', { 
    index: insertIndex, 
    rows: newRows,
    count: insertRowCount.value
  })
  
  showInsertDialog.value = false
  hideContextMenu()
}

// 删除当前行
const deleteCurrentRow = () => {
  if (currentContextRow.index === null) return
  
  ElMessageBox.confirm('确定要删除这一行吗?', '提示', {
    confirmButtonText: '确定',
    cancelButtonText: '取消',
    type: 'warning'
  }).then(() => {
    const deletedRow = tableData.value.splice(currentContextRow.index!, 1)
    emit('row-deleted', { index: currentContextRow.index, row: deletedRow[0] })
    hideContextMenu()
    ElMessage.success('删除成功')
  }).catch(() => {
    hideContextMenu()
  })
}

// 设置行索引
const tableRowClassName = ({ row, rowIndex }: { row: any, rowIndex: number }) => {
  row.row_index = rowIndex
}

// 格式化单元格值
const formatCellValue = (value: any, formatter?: (value: any) => string) => {
  if (formatter) {
    return formatter(value)
  }
  if (value === null || value === undefined) {
    return ''
  }
  return String(value).replace(/(\r\n|\n)/g, '<br/>')
}

// 检查列是否禁用
const isDisabledColumn = (prop: string) => {
  return props.disabledColumns.includes(prop)
}

// 检查单元格是否禁用
const isDisabled = (column: TableColumn | string, row?: any) => {
  if (typeof column === 'string') {
    return isDisabledColumn(column)
  }
  
  if (column.disabled === undefined) {
    return isDisabledColumn(column.prop)
  }
  
  if (typeof column.disabled === 'function') {
    return column.disabled(row)
  }
  
  return column.disabled
}

// 汇总行计算方法
const getSummaries = (param: any) => {
  if (props.summaryMethod) {
    return props.summaryMethod(param)
  }
  
  const { columns, data } = param
  const sums: string[] = []
  columns.forEach((column: any, index: number) => {
    if (index === 0) {
      sums[index] = '合计'
      return
    }
    
    const colConfig = props.columns.find(col => col.prop === column.property)
    if (!colConfig || colConfig.type !== 'number') {
      sums[index] = ''
      return
    }
    
    const values = data.map((item: any) => Number(item[column.property]))
    if (values.every((value: any) => isNaN(value))) {
      sums[index] = ''
    } else {
      sums[index] = `${values.reduce((prev: number, curr: number) => {
        const value = Number(curr)
        return isNaN(value) ? prev : prev + value
      }, 0)}`
    }
  })
  
  return sums
}
</script>

<style scoped>
.cell-text {
  width: 100%;
  min-height: 100%;
  word-break: break-word;
}

.context-menu {
  position: fixed;
  z-index: 9999;
  background: white;
  border: 1px solid #ebeef5;
  border-radius: 4px;
  box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
  padding: 10px;
  display: flex;
  flex-direction: column;
  gap: 5px;
}

.context-menu button {
  width: 100%;
  text-align: left;
}

::v-deep(.el-button+.el-button){
  margin-left: 0;
}
</style>

总结

这个组件不仅提供了基本的数据展示功能,还支持多种编辑方式、右键操作菜单、自动汇总等功能。

关键实现要点:

  • 使用动态渲染支持多种输入类型
  • 利用双击和右键事件提供直观的操作方式
  • 通过统一的 API 设计保证组件易用性
  • 提供丰富的自定义配置选项

希望这个组件能够帮助你在实际项目中提高开发效率!如果你有任何问题或建议,欢迎在评论区留言讨论。

记得给文章点个赞,收藏起来,下次需要时可快速查找哦!

本文首发于公众号:程序员刘大华,专注分享前后端开发的实战笔记。关注我,少走弯路,一起进步!

📌往期精彩

《SpringBoot 中的 7 种耗时统计方式,你用过几种?》

《Java8 都出这么多年了,Optional 还是没人用?到底卡在哪了?》

《加班到凌晨,我用 Vue3 + ElementUI 写了个可编辑的表格组件》

《vue3 登录页还能这么丝滑?这个 hover 效果太惊艳了》

❌
❌