做后台管理系统做得多了,你会遇到一个特别高频的诉求:接口给你返回一串数字状态码,页面上却要显示“已支付”“待发货”“已关闭”这种人话;数据库里存的是时间戳,表格里要显示“2024-06-18 14:30:00”;明明是0.1元的东西,库里存了10,展示的时候得自己加上两位小数。这些活儿,干一次两次不觉得什么,每个页面都来一遍就烦了。这时vue生态里到处都在用的formatter就派上了用场。
这篇文章就围绕vue中formatter的使用方法展开,不绕弯子,直接讲清楚formatter是什么、在哪些组件里能用、怎么优雅地封装复用,以及我在实际项目里踩过的一些坑。内容适合正在写vue2/vue3业务代码的兄弟,也适合那些刚接触element-plus、iView这类组件库、被formatter参数搞懵的新手。
1. formatter到底是个什么东西
1.1 从“格式化”这个词说起
formatter翻译过来就是“格式化器”,本质上它是一个函数,输入一个原始值,输出一个适合展示的值。很多兄弟第一次接触它是在el-table-column上,看到官方文档里给了formatter这样一个属性,于是照着例子写了一个函数,发现表格那一列的内容变了,但不知道背后的参数从哪来。
要理解formatter,先得建立一个朴素的认知:前端展示层和使用层经常是分离的。接口返回的数据是给程序用的,状态码、时间戳、布尔值、枚举值,机器看着方便;但页面是给人看的,人需要的是“成功”“失败”“2023-01-01”这种直观信息。formatter就是夹在数据和视图中间的那个翻译官。
用生活类比就是:你收到一封英文邮件,内容是一堆专业术语,你不能直接把邮件原封不动转发给只会中文的老板,你得先翻译一遍。formatter干的就是这个翻译的活,只不过它翻译的是编程世界里的“数据格式”。
1.2 vue生态里最常见的三类formatter
我在项目里把formatter的用法归成三大类,搞清楚这三类,基本就能应对90%的业务场景:
- 表格列格式化:最典型的是
el-table-column的formatter属性,用来处理单元格展示内容。每一列的formatter都是一个函数,组件在渲染单元格时自动调用。 - 输入格式化:用户输入的时候,实时把内容改造成指定格式,比如输入手机号自动加
-,输入金额自动补两位小数。这类在el-input+v-model的场景里通常要自己用watch或者computed去实现,或者使用组件库提供的特定props。 - 统一字典/枚举转换:后台管理系统最常见的场景之一,状态码映射成文本。这类formatter可以抽成公共函数,全局复用,避免每个页面重复写map映射。
理解这个分类之后,再看具体用法就顺了。很多新手卡壳是因为只会在表格里用formatter,一旦出了表格就不知道怎么处理,但实际上核心思路是通的:找一个合适的位置,把原始值做一次转换。
2. 表格组件中的formatter实战
2.1 el-table-column的formatter基本用法
先看最经典的写法,以element-plus为例:
<template> <el-table :data="tableData" border> <el-table-column prop="status" label="状态" :formatter="formatStatus" /> </el-table> </template> <script setup> const tableData = [ { id: 1, status: 1 }, { id: 2, status: 0 }, ] function formatStatus(row, column, cellValue, index) { const map = { 1: '启用', 0: '禁用' } return map[cellValue] ?? '-' } </script>这里有个特别容易懵的点:formatter函数接收的四个参数分别是什么?我刚开始也记不住,后来总结了一套口诀:row是当前行数据对象,column是当前列配置对象,cellValue是当前单元格的原始值,index是行索引。
row:当前遍历到的这一行数据,比如{ id: 1, status: 1 }。通过row.xxx能取到这一行其他字段的值,这是实现“根据A列的值决定B列怎么显示”的关键。column:当前列配置,包含了label、prop、property等列属性。用到的场景相对少,但当你需要根据列的某种属性做判断时它就有用了。cellValue:重点中的重点。它表示当前单元格绑定的prop字段的原始值。注意:如果你在el-table-column上写了prop="status",而formatter里没用cellValue而是写了row.status,两种方式取到的值是一样的,但习惯上用cellValue更直接,因为它是组件帮你精确取好的。index:行的索引,从0开始。偶尔会用在“序号”列里,比如return index + 1。
再说一个我在项目中常用的技巧:formatter函数不一定非得写在模板里内联,完全可以抽到js文件里去。比如建一个utils/formatters.js,把通用格式化方法都放进去,哪个页面要用就import进来。这样既清爽又便于统一维护。后面我会专门展开讲如何封装。
2.2 formatter函数里到底能做什么
formatter函数本身是一个普通js函数,所以它里面能干的事情非常丰富,不受什么限制。但从实践经验看,常见的需求主要集中在这几类:
第一类:状态码转文本
这是最基础的需求。接口返回1和0,页面要显示“启用”和“禁用”,甚至有些状态是一个流程:0未提交、1待审核、2审核通过、3已驳回。这时用一个map映射就足够了:
function formatApproveStatus(row, column, cellValue) { const statusMap = { 0: '未提交', 1: '待审核', 2: '审核通过', 3: '已驳回' } return statusMap[cellValue] ?? '未知' }第二类:日期和时间格式化
后端接口返回时间戳或者ISO字符串,表格里显示成YYYY-MM-DD HH:mm:ss。不要手写补零逻辑,直接用工具库。我常用dayjs,体积小,API友好:
import dayjs from 'dayjs' function formatDateTime(row, column, cellValue) { if (!cellValue) return '-' return dayjs(cellValue).format('YYYY-MM-DD HH:mm:ss') }这里注意一个问题:有些后端返回的时间是字符串"2024-01-01T00:00:00.000Z",有些返回的是毫秒时间戳1704038400000,还有少数是秒级时间戳1704038400。dayjs在处理秒级时间戳时容易出偏差,因为JS的时间戳是毫秒为单位的。需要先做一次转换:
function formatTimestamp(row, column, cellValue) { if (!cellValue) return '-' // 如果是秒级时间戳,乘以1000 const ms = String(cellValue).length === 10 ? cellValue * 1000 : cellValue return dayjs(ms).format('YYYY-MM-DD HH:mm') }第三类:数字格式化
金额千分位、保留小数、百分比转换,这类格式化也高频出现。比如后端返回的是分(integer),前端要显示成元(带两位小数):
function formatMoney(row, column, cellValue) { if (typeof cellValue !== 'number') return '-' return (cellValue / 100).toLocaleString('zh-CN', { style: 'currency', currency: 'CNY' }) }toLocaleString这个API很强大,能同时搞定千分位和货币符号,比自己正则拼接省事多了。
第四类:多字段拼接
这是formatter参数中row发挥最大价值的场景。比如学生列表里firstName和lastName是两个字段,但表格要显示完整姓名;或者要显示“张三(销售部)”这种混合内容,那就不能只用cellValue了:
function formatEmployee(row, column, cellValue) { return `${row.name}(${row.department})` }这类需求用formatter非常合适,因为你在模板里如果写{{ row.name }}({{ row.department }}),虽然也能显示,但一旦逻辑复杂,模板会变得很难看。用formatter把展示逻辑收进js,模板就干净了。
2.3 给formatter传额外参数的闭包技巧
新手经常卡在这样一个问题上:formatter函数只能接收组件传进来的那四个固定参数,那如果我想再额外传一个参数怎么办?比如一个枚举字典的key,我需要根据这个key动态决定映射哪个map。
直接写在模板上确实不行,但我们可以利用闭包来做。在Vue里给它包一层函数,外层接收自定义参数,内层返回一个满足formatter签名的新函数:
function formatWithDict(dictKey) { return function (row, column, cellValue) { const dict = { status: { 1: '启用', 0: '禁用' }, userType: { 1: '管理员', 2: '普通用户' } } const map = dict[dictKey] || {} return map[cellValue] ?? '-' } }模版里这样用:
<el-table-column prop="status" label="状态" :formatter="formatWithDict('status')" /> <el-table-column prop="userType" label="用户类型" :formatter="formatWithDict('userType')" />这里formatWithDict('status')返回的是一个“已经记住了dictKey这个变量”的新函数,这个新函数才是组件真正调用的formatter。闭包在这里的作用就是把额外的参数“包”进函数内部。
有兄弟可能会问:直接在组件里写() => formatStatus(row, column, cellValue, 'status')不也行吗?可以,但注意模板里的写法会带来性能问题——每次渲染都会创建一个新函数。而且闭包封装的方式更通用、更好测试。我在项目里倾向于把这种“造函数”的方法放在公共工具里,页面只关心传哪个字典key进去。
3. 日期、金额和状态:formatter的高频场景拆解
3.1 时间类格式化:别再手写补零了
我发现很多初学Vue的兄弟在格式化时间时会自己写一套“补零大法”:
function formatDate(cellValue) { const d = new Date(cellValue) const year = d.getFullYear() const month = d.getMonth() + 1 < 10 ? `0${d.getMonth() + 1}` : d.getMonth() + 1 const day = d.getDate() < 10 ? `0${d.getDate()}` : d.getDate() return `${year}-${month}-${day}` }不是说这种做法不对,而是这种代码又长又容易出错,比如月份要+1这个坑,几乎每个前端都踩过。用dayjs只需要一行:
import dayjs from 'dayjs' function formatDate(row, column, cellValue) { return cellValue ? dayjs(cellValue).format('YYYY-MM-DD') : '-' }如果要兼容v2和v3的项目,我建议统一封装一个parseTime函数,底层用dayjs,上层暴露format参数:
import dayjs from 'dayjs' export function parseTime(time, pattern = 'YYYY-MM-DD HH:mm:ss') { if (!time) return '-' return dayjs(time).format(pattern) }这样所有页面、所有表格都用这个函数,哪天产品说“日期格式不要斜杠了,要改成横杠”,或者“时间要把秒去掉”,你只需要改一个工具函数就能全局生效。这就是封装的价值。
3.2 金额千分位与单位换算
金额格式化除了前面提到的货币符号,还有一个常见需求是“元转万元”。“总营收”那一列,数据大的离谱,显示13位数根本没法看:
function formatWan(row, column, cellValue) { if (typeof cellValue !== 'number') return '-' return (cellValue / 10000).toFixed(2) + '万' }这里有个值得说道的点:toFixed(2)返回的是字符串,而且会四舍五入,所以显示上基本够用。如果你希望数字保持number类型方便后续排序,就不要用toFixed,改用Math.round(cellValue / 10000 * 100) / 100。
另一个高频场景是“用逗号分隔大数字”,也就是千分位。直接用toLocaleString:
function formatThousands(row, column, cellValue) { if (cellValue === null || cellValue === undefined || cellValue === '') return '-' return Number(cellValue).toLocaleString('zh-CN') }注意一定要先Number()一下,因为后端偶尔返回字符串数字,"12345".toLocaleString()不会生效,会原样返回。这种小细节如果不加处理,接口一返字符串,格式化就静默失效了。
3.3 枚举状态映射与字典表
后台管理系统的字典表,几乎是无处不在的。订单状态、审核状态、用户角色、商品上下架状态……每一个都有一堆数字编码。很多兄弟在页面里写一个巨大的map,然后在formatter里引用,写多了会发现不同页面里同样的1/2/3代表的意义不一样,map维护起来非常痛苦。
我推荐的做法是:字典表统一用一个模块管理,然后用闭包的方式把字典key传给formatter。比如我写过这样的代码:
// utils/dict.js const dictMap = { orderStatus: { 0: '待支付', 1: '已支付', 2: '已发货', 3: '已完成', 4: '已取消' }, auditStatus: { 1: '待审核', 2: '审核通过', 3: '审核驳回' } } export function getDictLabel(dictKey, value) { const dict = dictMap[dictKey] || {} return dict[value] ?? '-' } // 造一个formatter函数 export function dictFormatter(dictKey) { return (row, column, cellValue) => getDictLabel(dictKey, cellValue) }模板里使用:
<el-table-column prop="orderStatus" label="订单状态" :formatter="dictFormatter('orderStatus')" />这种写法最大的好处是数据驱动。如果后端把字典表做成接口动态返回了,你只要把dictMap替换成响应式的数据,所有用到这个formatter的表格都会同步更新,不需要逐个页面去改。
4. 自定义formatter的进阶玩法
4.1 在formatter里返回VNode或组件
刚才那些例子返回的都是字符串,够用但不惊艳。有些业务场景,比如状态列想显示一个el-tag标签,用不同颜色区分状态;或者想显示一个操作按钮,点击触发事件。很多兄弟一看表格的formatter只能返回文本,就直接去用slot插槽了。其实formatter配合h函数也能返回VNode,只是容易踩坑,踩过之后就明白套路了。
先看简单示例,返回一个el-tag:
import { h } from 'vue' function formatStatusTag(row, column, cellValue) { const typeMap = { 0: 'info', 1: 'success', 2: 'warning', 3: 'danger' } const textMap = { 0: '未提交', 1: '已通过', 2: '审核中', 3: '已驳回' } return h( 'el-tag', { type: typeMap[cellValue] || 'info' }, () => textMap[cellValue] || '-' ) }需要注意两个点。第一,h函数是从vue包里导入的,不是组件内自动注入的,模板式写法在script setup里要记得import { h } from 'vue'。第二,element-plus的el-tag在函数式写法下,type属性要作为第二个参数的props传入,子节点用第三个参数,可以是一个函数() => textMap[cellValue],也可以是一个字符串。
那事件绑定怎么办?比如我想在表格那一列显示一个“查看详情”按钮,点击跳转路由。函数式创建VNode时同样可以绑定事件,第二参数里写onClick即可:
import { h } from 'vue' import { useRouter } from 'vue-router' function formatAction(row, column, cellValue) { const router = useRouter() return h( 'el-button', { type: 'primary', link: true, onClick: () => router.push(`/detail/${row.id}`) }, () => '查看详情' ) }但有一说一,如果业务很复杂,强烈建议直接用slot插槽,不要在formatter里硬堆VNode。原因有两点:一是formatter里写VNode导致模板的可读性下降,业务长了根本维护不过来;二是容易触发一些更新问题,比如事件回调捕获到的是旧的响应式状态。我自己的经验法则是:只是简单换样式、加个tag,用formatter没问题;一旦有多个按钮、复杂交互,痛痛快快去用#default插槽。
4.2 结合i18n做国际化格式化
如果项目要做国际化,页面上的状态文本也得跟着语言切换。这时候如果formatter里写死中文文本,就很尴尬了。正确的姿势是在formatter里调用vue-i18n的t函数。
import { useI18n } from 'vue-i18n' function useDictFormatter() { const { t } = useI18n() const formatOrderStatus = (row, column, cellValue) => { const key = `order.status.${cellValue}` return t(key) } return { formatOrderStatus } }在模板里这样用:
<el-table-column prop="status" label="订单状态" :formatter="formatOrderStatus" />注意一个细节:useI18n()必须在setup的上下文中调用,所以如果你把formatOrderStatus抽取到独立的js文件里,就不能直接在模块顶层调用useI18n。要么把函数写成组合式函数,在组件setup里调用后再传给模板;要么在formatter内部通过i18n.global.t来获取翻译函数。我个人更推荐前者,语义更清晰:
// composables/useFormatters.js import { useI18n } from 'vue-i18n' export function useOrderFormatters() { const { t } = useI18n() const formatOrderStatus = (row, column, cellValue) => { return t(`order.status.${cellValue}`) } return { formatOrderStatus } }组件里使用时:
<script setup> import { useOrderFormatters } from '@/composables/useFormatters' const { formatOrderStatus } = useOrderFormatters() </script>这种方法还能顺带解决一个问题:不同模块的字典前缀不同,统一在composables里管理,避免一坨工具函数越写越乱。
4.3 表单输入的格式与反格式
formatter不止用于展示,也常用于用户输入。比如el-input绑定一个金额,期望用户输入1,234.56,提交时再还原成1234.56。很多兄弟只在展示时做了格式化,用户编辑时直接看到一个原始数字,体验很割裂。
要实现输入格式化,思路是用computed的get和set。get负责把原始值格式化成展示格式,set负责把用户新输入的值解析回原始格式。下面我用一个“每三位加逗号”的示例说明:
<script setup> import { ref, computed } from 'vue' const rawValue = ref(null) const displayValue = computed({ get() { if (rawValue.value == null) return '' return Number(rawValue.value).toLocaleString('zh-CN') }, set(value) { // 去掉所有非数字和非小数点的字符 rawValue.value = value.replace(/[^\d.]/g, '') } }) </script> <template> <el-input v-model="displayValue" placeholder="请输入金额" /> </template>这就是一个“反格式化”的过程。用户输入1,200.50,set里把逗号去掉,存入rawValue的是1200.50;然后get又把数字转为千分位字符串,显示成1,200.5或1,200.50(取决于toLocaleString的参数)。这种做法在没有组件库专门支持的时候很实用。
有些组件库会在输入框上提供formatter和parser这两个props,比如某些开源组件就支持把显示值和实际值分开。处理逻辑和上面一样:formatter是显示时怎么格式化,parser是把输入的字符串解析回原始值。具体语法以项目里用的组件库文档为准,但概念是通用的。
4.4 抽离公共formatter模块的工程实践
如果项目里表格特别多,我强烈建议把formatter当成一个独立工程模块来管理。我的习惯是建一个utils/formatter.js,专门放各种通用格式化函数,并且按职责分类:
// utils/formatter.js import dayjs from 'dayjs' import { getDictLabel } from './dict' // 时间类 export const formatDate = (row, column, value) => value ? dayjs(value).format('YYYY-MM-DD') : '-' export const formatDateTime = (row, column, value) => value ? dayjs(value).format('YYYY-MM-DD HH:mm:ss') : '-' // 数字类 export const formatMoney = (row, column, value) => typeof value === 'number' ? value.toLocaleString('zh-CN') : '-' export const formatPercent = (row, column, value) => typeof value === 'number' ? `${value}%` : '-' // 字典类 export const dictFormatter = (dictKey) => (row, column, value) => getDictLabel(dictKey, value)然后按需引入:
import { formatDateTime, formatMoney, dictFormatter } from '@/utils/formatter'工程化之后的好处很明显:代码复用率高、职责单一、测试也好写。如果你有时间,甚至可以给这些纯函数写单元测试,输入一个时间戳,断言输出格式对不对。以后重构时心里就有底了。
5. formatter常见坑与排查实录
5.1 formatter返回HTML字符串不会渲染
这是一个超级常见的坑:新手在formatter里写了return '<span style="color:red">异常</span>',然后在页面上看到的就是一段明文标签,没有变成红色的“异常”。原因是formatter的返回值会被当作纯文本插入到单元格中,而不是当作HTML解析。
要渲染HTML有两种方案:一是用v-html指令,但在el-table-column的formatter里没办法直接使用v-html。二是用前面讲的h函数返回VNode,这样Vue可以正确渲染。三是改用插槽方式,在模板里直接写HTML结构。我自己的建议是:除非是简单的连续文本拼接,否则不要在formatter里拼HTML字符串,那样既危险又有xss漏洞的风险。复杂一点的展示就用插槽,安全又清晰。
5.2 formatter格式化后,排序、筛选、导出时而失灵
这个坑容易出现在需求很复杂的后台项目里。比如金额列格式化成了"1,234.56"这种字符串,表格列开启了sortable。结果一排序,发现顺序不对。原因很好理解:排序比较的是显示出来的字符串,而不是原始数字。字符串按字典序排序,"10,000"会排在"2,000"前面。
解决办法有两个思路:
第一个思路,把排序指定到原始字段上。element-plus的el-table-column支持sort-by属性,你可以让它按prop值排序,而不是按formatter后的内容排序:
<el-table-column prop="amount" label="金额" :formatter="formatMoney" :sort-by="'amount'" sortable />第二个思路,在formatter里返回一个“结构化”的值,既保留原始值供排序,又显示格式化后的文本。不过这种操作在不同组件库里支持度不一样,不如第一种方案直接。同理,导出excel时也经常遇到问题:很多导出的实现是遍历表格的单元格文本,如果你格式化后导出,拿到的就是“已支付”而不是1。这个其实比排序还隐蔽,因为导出通常用的是另一套逻辑。我踩过坑后总结的经验是:定义好“展示格式化”和“导出格式化”两套工具函数,别偷懒只用一个。导出时按需调用纯数据转换,而不是复用展示类formatter。
5.3 大数据量表格中formatter的性能问题
formatter函数本质上是渲染时对每个单元格执行一次函数调用。几百条数据、每行十几列,同一列会被调用几百次。正常的formatter都很轻量,字符串拼接、map取值,性能影响可以忽略。但如果你在formatter里去请求接口、做复杂的正则匹配、或者用JSON.parse解析大文本,那么在滚动或排序时就会有明显的卡顿感。
我在一个报表项目里遇到过一个问题:其中一列的formatter里调用了dayjs().format(),但传入的却是时间字段的字符串,当时觉得没什么,结果表格渲染200行数据时耗时明显。排查后发现并不是dayjs多慢,而是formatter在初始化时把所有单元格都渲染了,然后排序时又全部重渲染一遍,反复触发格式化逻辑。解决方案是:把可以提前算好的数据预处理掉,不要在formatter里做重复的高成本计算。
比如从接口拿到数据后,先做一次数据映射,把status转成statusText,把timestamp转成dateText,表格里直接展示这些预处理字段,完全不依赖formatter。当然,这样做了之后表的字段会变多,但换来的是渲染速度快不少。数据量小无所谓,数据量大了就能感觉到差别。
5.4 formatter与插槽混用时的优先级困惑
还有一个新人容易迷糊的地方:如果el-table-column同时配置了formatter和#default插槽,最后显示哪个?根据组件库的实现,插槽的优先级通常会高于formatter。也就是说,你写了插槽,formatter大概率就不生效了。
这其实是合理的:formatter是“默认渲染方式”的补充,而插槽是更灵活的重写。所以如果一个列要先判断“有没有插槽”,就该知道优先写插槽里的内容,formatter就当备胎,或者干脆二者选其一,不要同时配置,否则容易造成“这段逻辑为什么没生效”的困惑。
5.5 响应式数据更新后formatter不刷新的陷阱
最后说一个我在Vue2项目里踩过的大坑:formatter从外部读取了一个响应式对象,但修改这个对象时,表格列不重新渲染。原因通常是对象引用没变,只是内部属性变了,而formatter依赖的是这个对象本身。因为Vue2的响应式系统在监听对象整体变化时可能不够灵敏,或者你新加了一个属性但没有用$set。
应对办法有两个:
一是用computed或者watch主动触发视图更新。比如当字典加载完成后,给表格数据源重新赋值一个副本,强制重渲染:
function loadDict() { getDictList().then(res => { dictMap.value = res.data // 触发视图更新 tableData.value = [...tableData.value] }) }二是尽可能把formatter需要的值当作参数传入table数据,而不是在formatter内部依赖外部变量的“身份”。在Vue3里这个问题少很多,因为Proxy的响应式捕获更深,但依然存在一个“formatter引用外部函数,外部函数拿到的还是旧闭包变量”的情况要注意。如果遇到这种问题,优先检查一下:formatter里到底引用了哪个变量,这个变量在数据更新后指向的引用地址有没有变化。这个排查思路比盲目刷新表格管用得多。
6. 写在最后的个人体会
这篇文章把vue中formatter的使用方法从基础到进阶大体过了一遍。我在实际项目里最深的感受是:formatter不复杂,但它是一个典型的“小工具大讲究”的功能点。用好了,能让模板空前简洁,让数据、状态、展示三层各司其职;用不好,就会陷入“这里格式化一下,那里又格式化不生效”的泥潭。
给大家一个我在团队里推的习惯:新建一个utils/formatter.js,把所有的formatter函数集中在里面,有时间就加一点注释,注明输入是什么、输出是什么、依赖什么字典。这样不管谁接手的项目,看到那个文件就能明白整个系统的展示规则。另外,如果遇到一个列既可以在formatter里实现又可以用插槽实现,我通常会选择插槽——虽然写法上啰嗦一点,但模板里能直接看到DOM结构,后边维护的成本更低。formatter适合的是“简单、纯文本、可复用性强”的转换,插槽适合的是“复杂结构、有交互”的场景,这个边界把握好,你的表格代码会干净很多。
最后再分享一个小技巧:在用element-plus的表格时,如果你不确定一个配置是支持formatter还是只支持slot,第一件事不是百度,而是直接去组件源码里搜“formatter”这个字段。开源组件库的好处就在这,所有props都是源码里明明白白写着的,看一遍源码,比看十篇博客都管用。