尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Vue formatter完全指南:从表格格式化到工程化封装与避坑

Vue formatter完全指南:从表格格式化到工程化封装与避坑 做后台管理系统做得多了你会遇到一个特别高频的诉求接口给你返回一串数字状态码页面上却要显示“已支付”“待发货”“已关闭”这种人话数据库里存的是时间戳表格里要显示“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-inputv-model的场景里通常要自己用watch或者computed去实现或者使用组件库提供的特定props。统一字典/枚举转换后台管理系统最常见的场景之一状态码映射成文本。这类formatter可以抽成公共函数全局复用避免每个页面重复写map映射。理解这个分类之后再看具体用法就顺了。很多新手卡壳是因为只会在表格里用formatter一旦出了表格就不知道怎么处理但实际上核心思路是通的找一个合适的位置把原始值做一次转换。2. 表格组件中的formatter实战2.1 el-table-column的formatter基本用法先看最经典的写法以element-plus为例template el-table :datatableData border el-table-column propstatus label状态 :formatterformatStatus / /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上写了propstatus而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 propstatus label状态 :formatterformatWithDict(status) / el-table-column propuserType label用户类型 :formatterformatWithDict(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。另一个高频场景是“用逗号分隔大数字”也就是千分位。直接用toLocaleStringfunction 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 proporderStatus label订单状态 :formatterdictFormatter(orderStatus) /这种写法最大的好处是数据驱动。如果后端把字典表做成接口动态返回了你只要把dictMap替换成响应式的数据所有用到这个formatter的表格都会同步更新不需要逐个页面去改。4. 自定义formatter的进阶玩法4.1 在formatter里返回VNode或组件刚才那些例子返回的都是字符串够用但不惊艳。有些业务场景比如状态列想显示一个el-tag标签用不同颜色区分状态或者想显示一个操作按钮点击触发事件。很多兄弟一看表格的formatter只能返回文本就直接去用slot插槽了。其实formatter配合h函数也能返回VNode只是容易踩坑踩过之后就明白套路了。先看简单示例返回一个el-tagimport { 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 propstatus label订单状态 :formatterformatOrderStatus /注意一个细节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-modeldisplayValue placeholder请输入金额 / /template这就是一个“反格式化”的过程。用户输入1,200.50set里把逗号去掉存入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 stylecolor: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 propamount label金额 :formatterformatMoney :sort-byamount 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都是源码里明明白白写着的看一遍源码比看十篇博客都管用。
返回列表