
前阵子帮同事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 propstatus label状态 template #default{ row } span{{ row.status 0 ? 待审核 : row.status 1 ? 已通过 : row.status 2 ? 已驳回 : 未知 }}/span /template /el-table-column你仔细读这段嵌套了四层三元表达式逻辑上没有错但可读性已经接近崩溃。如果这个表格有七八列都需要类似转换模板会被撑成一个“意大利面”。而formatter做的事情就是把这段翻译逻辑从模板里抽出来变成一个独立的、可测试的函数el-table-column propstatus label状态 :formatterformatStatus /模板里一行搞定翻译逻辑挪到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个参数的实际用途我整理了一个表方便你按需取用参数类型典型使用场景rowObject需要读取同行的其他字段比如用type值去匹配另一个字段columnObject很少用但可以拿到当前列的property、label等配置cellValue任意最常用就是当前格子的原始值翻译逻辑主要处理它indexNumber做斑马纹、行号列、前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 Plusformatter的用法基本是平移的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也有坑金额格式化也很常见尤其是给数字加千分位。大多数人第一反应是toLocaleStringconst 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 slotel-table-column propstatus label状态 template #default{ row } el-tag :typerow.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] || 未知; }, }, };模板里:formatterformatStatus看起来没毛病但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 makeFormatterT extends (...args: any[]) any( formatFn: (cellValue: any, row?: any, column?: any, index?: number) ReturnTypeT ): FormatterFn { return (row, column, cellValue, index) formatFn(cellValue, row, column, index); }这样在表格里写:formattermoneyFormatter时编辑器能自动提示参数含义比裸写一堆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里因为这个函数大概率以后别的地方也要用。等你项目里表格多起来会发现这个习惯帮你省下大量重复劳动。