前阵子帮同事review一个后台管理项目,表格里有一列叫“订单状态”,后端返回的是0、1、2、3这样的数字,页面上一字排开全是干巴巴的数字。产品看了说看不懂,需求方说历史接口不能动,那只能前端自己处理。这种场景做前端的应该都不陌生——后端给你的是“数据”,页面要展示的是“信息”,从原始值到展示文案这层转换,在Vue里最直接、最常用的方案就是formatter。这篇文章我就把formatter的用法、参数、适用边界和踩过的坑一次性讲明白,不管是刚接触Vue的新手,还是已经写了两年表格的老手,应该都能从中拿到点能直接用的东西。
1. formatter到底解决什么问题:先看一个让人头大的表格渲染场景
1.1 后端返回的和页面要展示的从来不是一回事
大多数业务系统背后都有一个铁律:数据库里存什么,接口就吐什么。状态字段存的是tinyint,时间的存储可能是时间戳也可能是字符串,金额字段为了精确经常是分单位的整数,性别、类型、渠道这些字段更是清一色的代码值。
但用户不关心这些。用户看到“2”不知道是“已发货”还是“已驳回”,看到“1699999999000”还得心里换算一下才知道是哪天。于是前端工程师大部分工作时间都花在“翻译”上:把状态码翻译成中文标签,把时间戳翻译成“2025-02-14 10:30:00”,把分翻译成元,把数字加上千分位。
这个“翻译”动作,在Vue里有好几种实现路径,而formatter是其中性价比最高的一种——它不侵入数据层,不改动接口返回的原始数据,只是在你渲染那一刻提供一个中转函数。
1.2 模板里的三元表达式写多了就是灾难
没接触过formatter的人,通常会写成这样:
<el-table-column prop="status" label="状态"> <template #default="{ row }"> <span>{{ row.status === 0 ? '待审核' : row.status === 1 ? '已通过' : row.status === 2 ? '已驳回' : '未知' }}</span> </template> </el-table-column>你仔细读这段,嵌套了四层三元表达式,逻辑上没有错,但可读性已经接近崩溃。如果这个表格有七八列都需要类似转换,模板会被撑成一个“意大利面”。而formatter做的事情,就是把这段翻译逻辑从模板里抽出来,变成一个独立的、可测试的函数:
<el-table-column prop="status" label="状态" :formatter="formatStatus" />模板里一行搞定,翻译逻辑挪到script里维护。这才是formatter存在的真正意义:它不是功能上无可替代,而是让代码结构变干净、让翻译逻辑可复用。
1.3 formatter的定位:一个“翻译”函数
用我的话来说,formatter就是表格列的一个“翻译接口”。它接收原始值,返回展示值,中间怎么转换完全由你决定。它做的是纯展示层的处理,不改写源数据,不触发额外的网络请求,不参与排序和筛选。
理解了这个定位,后面所有的API细节、踩坑案例,你都能自己推断出来。
2. 从Element Plus的table-column说起:formatter的函数签名和执行时机
2.1 四个参数的含义,分别什么时候用
在Element UI和Element Plus里,table-column组件提供了一个formatter属性,类型是Function,完整签名是:
/** * @param {Object} row 当前行的完整数据对象 * @param {Object} column 当前列配置对象 * @param {*} cellValue 当前单元格的值,即 row[column.property] * @param {Number} index 当前行的索引,从0开始 * @returns {*} 返回的值就是单元格最终展示的内容 */ const formatter = (row, column, cellValue, index) => { // 返回字符串、数字都可以 }这4个参数的实际用途,我整理了一个表,方便你按需取用:
| 参数 | 类型 | 典型使用场景 |
|---|---|---|
| row | Object | 需要读取同行的其他字段,比如用type值去匹配另一个字段 |
| column | Object | 很少用,但可以拿到当前列的property、label等配置 |
| cellValue | 任意 | 最常用,就是当前格子的原始值,翻译逻辑主要处理它 |
| index | Number | 做斑马纹、行号列、前N行高亮 |
一个比较典型的使用index的场景是序号列:
const formatIndex = (row, column, cellValue, index) => { return index + 1; };第二个参数column,别看用得少,偶尔有奇效。比如多语言环境下要根据列的label动态拼文案,就可以从column.label里取。
2.2 formatter到底改没改原始数据
这是个高频误解,特别容易在team里引发争议。formatter执行完后,你打印row,会发现原始数据纹丝不动。它只是在渲染层返回了一个替代值,Vue把formatter的返回值当作单元格的文本内容填充进DOM。
这意味着三件事:
- 排序、筛选、导出用的是原始值,不是格式化后的值
- 如果你做导出功能,记得自己再格式化一遍,formatter不会帮你处理导出
- 如果想从formatter里改row的某个字段并依赖它触发视图更新,这不是formatter的活,别这么干
2.3 Vue 2的Element UI和Vue 3的Element Plus用法差异
从Element UI切到Element Plus,formatter的用法基本是平移的,API层面没有破坏性变化。唯一的区别在于Vue 3的setup语法下,formatter函数通常定义为普通const函数或箭头函数,不再依赖this。
我遇到过一些老项目从Vue 2迁移到Vue 3,原本在Vue 2里用options API的methods定义formatter,迁移后只要函数内部没有用this,基本复制过去就能跑。如果有,那就得一并改造,这个问题我在第5章节详细讲。
3. 实战三连:日期格式化、金额千分位、状态值转中文
3.1 日期时间戳:别手写补零,用dayjs但要知道边界
日期格式化是formatter最常干的活。后端给时间戳(通常是毫秒),前端要展示成“2025-02-14 10:30:00”,新手容易陷入手写补零的泥潭:
const formatDate = (row, column, cellValue) => { const d = new Date(cellValue); const pad = (n) => (n < 10 ? '0' + n : '' + n); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; };能用,但没必要。项目里装了dayjs的情况下,直接:
import dayjs from 'dayjs'; const formatDate = (row, column, cellValue) => { if (!cellValue) return '-'; return dayjs(cellValue).format('YYYY-MM-DD HH:mm:ss'); };但这里有两个边界你得注意:
cellValue可能是秒级时间戳(10位数),dayjs默认按毫秒解析,直接格式化会得到1970年附近的错误日期。稳妥做法是先判断位数:String(cellValue).length === 10 ? cellValue * 1000 : cellValue- 后端可能返回ISO字符串如“2025-02-14T02:30:00.000Z”,这是UTC时间,dayjs.local()转换成本地时间再格式化,否则会跟用户所在时区对不上。别问我怎么知道的,线上反馈“所有时间快了8小时”那次,就是这个原因。
3.2 金额千分位:toLocaleString也有坑
金额格式化也很常见,尤其是给数字加千分位。大多数人第一反应是toLocaleString:
const formatMoney = (row, column, cellValue) => { if (cellValue === null || cellValue === undefined) return '-'; return Number(cellValue).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2, }); };这么写日常够了,但有两个坑:
toLocaleString在不同浏览器、不同Node版本下的实现细节有差异,极端情况下甚至受操作系统区域设置影响。跨端项目(比如内嵌Electron)里偶发出现“每个数字都变成带特殊空格”的怪问题,多半和它有关。- 如果后端返回的是字符串类型的大金额,比如“999999999999”,
Number()转换后可能超出安全范围,精度直接漂移。
更可控的写法是用正则做千分位:
const formatMoney = (row, column, cellValue) => { if (cellValue === null || cellValue === undefined || cellValue === '') return '-'; const num = Number(cellValue); if (isNaN(num)) return '-'; const [intPart, decimalPart] = String(num).split('.'); const formattedInt = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ','); return decimalPart ? `${formattedInt}.${decimalPart}` : formattedInt; };这条正则/\B(?=(\d{3})+(?!\d))/g是整个方案的核心,建议直接收藏。它匹配的是“非单词边界且后面跟着3的倍数位数字”的位置,恰好就是千分位该插逗号的位置。
3.3 状态码映射:对象字典法比switch更干净
状态映射应该算formatter里的“Hello World”。我见过有人写一长串if-else,也有人写switch,但最舒服的还是对象字典:
const ORDER_STATUS_MAP = { 0: '待审核', 1: '已通过', 2: '已驳回', 3: '已完成', }; const formatOrderStatus = (row, column, cellValue) => { // ?? 而不是 ||,避免 0 被当成 falsy 变成兜底文案 return ORDER_STATUS_MAP[cellValue] ?? '未知状态'; };这里有一个非常关键的细节:查字典时用的是??(空值合并运算符),不是||。因为0是falsy,如果用||,状态为0时会直接返回“未知状态”,这属于线上事故级别的bug。我见过不止一次。
如果状态需要联动颜色,formatter只能返回文本,颜色就得配合tag-type之类的属性来处理。这时候就得提到下一节的内容——formatter不是万能的。
4. formatter和scoped slot怎么选:我的一条判断线
4.1 什么时候formatter直接搞定
我的判断标准很简单:只要目标是“把原始值变成一段纯文本”,就用formatter。
纯文本场景包括:
- 数字加千分位、保留几位小数
- 时间戳转日期字符串
- 状态码映射成中文
- 长文本截断(加上省略号)
- 根据index生成序号
这些场景formatter一行属性就能搞定,模板干净,逻辑集中,测试也方便。
4.2 什么时候必须上scoped slot
一旦展示内容里出现了非文本元素,比如按钮、标签、图标、图片、链接,或者要同时渲染多个字段,formatter就使不上劲了。因为formatter的返回值最终会被当作文本插入,你不能返回一段带事件的HTML字符串。
这里必须用scoped slot:
<el-table-column prop="status" label="状态"> <template #default="{ row }"> <el-tag :type="row.status === 1 ? 'success' : row.status === 0 ? 'warning' : 'danger'"> {{ statusText(row.status) }} </el-tag> </template> </el-table-column>另外一个值得注意的case:表格单元格里有多个字段要拼一起展示,比如“张三(138****1234)”这种,formatter也能做,因为row参数可以拿到整行数据:
const formatUserCell = (row, column, cellValue) => { return `${row.name}(${row.phone.replace(/^(\d{3})\d{4}(\d{4})$/, '$1****$2')})`; };但如果你想在“张三”后面单独加一个小icon,那scoped slot依然是不二之选。
4.3 性能层面的一点提醒
Element Plus在渲染表格时会对每个单元格调用formatter。数据量大、列数多的表格,formatter会被高频触发。
如果formatter内部有昂贵的计算——比如new Date().toLocaleString()的反复调用、复杂的正则替换、甚至网络请求(千万别在formatter里发请求)——表格滚动、数据更新时就会明显卡顿。
我的建议:
- formatter保持纯函数,尽量只做计算,不做副作用
- 如果格式化目标值基本不会变,可以在数据处理阶段就把展示字段算好,存到row上,比如
row._statusText,表格直接用prop="_statusText",虽然牺牲了一点内存,但省了重复计算 - 不要为了炫技在formatter里做递归、深拷贝这类重操作
5. 实战踩坑记录:this指向、空值兜底和重复渲染
5.1 最经典的坑:formatter里拿不到this
这个问题在Vue 2时代几乎是必踩的。很多人这么写:
export default { data() { return { statusMap: { 0: '待审核', 1: '已通过' } }; }, methods: { formatStatus(row, column, cellValue) { return this.statusMap[cellValue] || '未知'; }, }, };模板里:formatter="formatStatus",看起来没毛病,但Element内部调用formatter时,是把函数当成普通函数调用的,并没有绑定组件的this。于是执行到this.statusMap的时候,this要么是undefined(严格模式),要么指向window(非严格模式),结果自然报错。
解决方案有两种:
一种是不用this,把依赖的状态提到模块作用域:
const STATUS_MAP = { 0: '待审核', 1: '已通过' }; export default { methods: { formatStatus(row, column, cellValue) { return STATUS_MAP[cellValue] || '未知'; }, }, };另一种是使用闭包捕获。在Vue 3的setup里,由于setup内定义的函数天然形成闭包,可以直接读取响应式状态:
<script setup> import { ref } from 'vue'; const statusMap = ref({ 0: '待审核', 1: '已通过' }); const formatStatus = (row, column, cellValue) => { return statusMap.value[cellValue] ?? '未知'; }; </script>这样即使statusMap后续通过接口动态更新,formatter也能读到最新值。记住一个原则:在Vue 3的setup里,永远不要指望this,用闭包传递依赖。
5.2 空值和异常输入,formatter必须有兜底
后端接口的数据质量,永远不能假设是100%可靠的。字段有可能缺、有可能为null、有可能给你个意想不到的类型。
formatter里不加空值保护,线上页面就会出现一整列“undefined”或者“null”,非常难看。我自己的习惯是每个formatter函数第一行就处理空值:
const formatPercent = (row, column, cellValue) => { if (cellValue === null || cellValue === undefined || cellValue === '') return '-'; const num = Number(cellValue); if (isNaN(num)) return '-'; return `${num.toFixed(2)}%`; };这套“空值返回占位符”的写法,建议形成肌肉记忆。
5.3 每次渲染都会执行,别在formatter里放大定时器或请求
前面提过formatter是渲染期函数,重复调用是常态。有一次同事在formatter里调了个接口根据code拉名称,结果表格一刷新,接口被打了N次,后端日志瞬间炸了。
如果你确实需要异步获取数据后再展示,正确姿势是在数据加载完成后,先把展示字段算出来放到row上,再用prop直接渲染。比如:
const res = await getList(); res.data.forEach((item) => { item.statusText = ORDER_STATUS_MAP[item.status] ?? '未知'; }); rows.value = res.data;这个方案还有一个额外的好处:排序和导出时,如果需要按“状态名称”排序,可以直接用statusText字段,不用每次都调用格式化逻辑。
6. 进阶玩法:把formatter抽象成可复用的格式化函数库
6.1 先写纯函数,再包一层高阶函数
项目里表格一多,你会发现格式化逻辑高度重复——这里格式化时间,那里也格式化时间,只是格式略有不同。这时候就应该把formatter从组件里抽出来,单独维护一个utils/format.js。
我推荐先写纯格式化函数,不跟表格耦合:
// utils/format.js export function formatDate(value, pattern = 'YYYY-MM-DD HH:mm:ss') { if (!value) return '-'; // ... } export function formatMoney(value) { // ... } export function formatStatus(value, map) { return map[value] ?? '未知'; }然后写一个高阶函数,把纯函数适配成表格formatter需要的签名:
// utils/table-formatter.js export function makeFormatter(formatFn) { return (row, column, cellValue, index) => { return formatFn(cellValue, row, column, index); }; }组件里用法变成:
import { formatDate, formatMoney } from '@/utils/format'; import { makeFormatter } from '@/utils/table-formatter'; const dateFormatter = makeFormatter((value) => formatDate(value, 'YYYY-MM-DD')); const moneyFormatter = makeFormatter(formatMoney);优势很明显:纯函数可以单测,可以复用,甚至可以直接在非表格场景(比如详情页、导出逻辑)里调用;表格相关的适配层是薄薄的一层,基本不用维护。
6.2 结合TypeScript的类型推导
如果项目用了TypeScript,上面的高阶函数可以写出很舒服的类型。表格formatter的签名固定,你可以在makeFormatter里约束好类型,让使用方获得完整的类型提示:
type FormatterFn = (row: any, column: any, cellValue: any, index: number) => any; export function makeFormatter<T extends (...args: any[]) => any>( formatFn: (cellValue: any, row?: any, column?: any, index?: number) => ReturnType<T> ): FormatterFn { return (row, column, cellValue, index) => formatFn(cellValue, row, column, index); }这样在表格里写:formatter="moneyFormatter"时,编辑器能自动提示参数含义,比裸写一堆any要舒服得多。
6.3 顺带聊聊Vue 3为什么把filters删了
聊到复用格式化逻辑,就绕不开Vue 2时代另一个方案——filters。Vue 3发布时官方直接把filters删了,理由是“过滤器本质上就是函数,没必要引入额外语法”。这个决定对formatter反而是一个利好:Vue 3里,函数是唯一的一等公民,formatter就是函数,没有任何替代语法需要学。
如果你是从Vue 2项目迁移上来的,原来写在filters里的日期格式化逻辑,正好可以顺势改造成utils/format.js里的纯函数,然后在formatter、模板表达式、计算属性三个场合随便用:
<template> <span>{{ formatDate(row.createTime) }}</span> </template>模板里直接调函数,Vue 3是支持的,因为setup里定义的const函数会自动暴露给模板。这相当于用更直接的方式取代了filter的语法糖。
对我个人来说,formatter最舒服的一点是它的心智负担极低:你不用记住Vue有多少种格式化手段,只需要记住“表格列要文本转换,就用formatter;要复杂DOM,就用scoped slot”这一条判断线,然后把所有格式化逻辑收敛成纯函数,就够用了。最后再分享一个小经验:凡是formatter里写超过三行的逻辑,我都会顺手把它挪到utils/format.js里,因为这个函数大概率以后别的地方也要用。等你项目里表格多起来,会发现这个习惯帮你省下大量重复劳动。