做了几年Vue项目,从Vue 2用到Vue 3,要说模板里最容易被忽略又偶尔会被怀念的小功能,过滤器绝对算一个。很多人写模板格式化的时候,第一反应就是用一个管道符加函数名,比如{{ price | formatMoney }},确实很方便。但方便归方便,它有很多边界和限制,尤其到了Vue 3之后直接就被移除了,这中间有不少值得掰扯清楚的东西。
这篇文章想聊的就是过滤器的完整使用规则、它和计算属性/方法之间的边界,以及我在实际项目里踩过的几个坑。内容以Vue 2.x为基准展开,后面单独讲Vue 3的迁移替代方案。不管你是刚接触过滤器的新手,还是正在从Vue 2往Vue 3迁移的老开发,应该都能从这里找到一些能直接落地的经验和思路。
1. 为什么需要过滤器,以及它和计算属性、方法的边界在哪
1.1 从实际场景看过滤器的定位
先说一个最常见的业务画面:后端接口返回的订单金额是分,前端要显示成“¥1,299.00”;返回的时间戳要显示成“2024-06-18 14:30”;用户的手机号要脱敏成“138****6666”。这些都属于典型的数据展示格式化。
如果不用过滤器,你可能会这样写:
<template> <div>{{ formatMoney(order.amount) }}</div> </template> <script> export default { methods: { formatMoney(value) { return '¥' + (value / 100).toFixed(2); } } } </script>这样写没有问题,模板里的formatMoney(order.amount)可读性也还可以。但一旦要嵌套转换,比如“先脱敏,再改成大写”,模板里的方法调用就会变成formatUpper(maskPhone(user.phone)),读起来就吃力了。过滤器的写法是{{ user.phone | maskPhone | toUpper }},数据从左往右流动,每一步做什么,看管道符就行。
所以过滤器的定位就是一句话:它只负责模板里数据的展示层转换,不负责业务逻辑,更不修改源数据。适合的场景包括日期格式化、金额处理、文本截断、大小写转换、枚举值映射、敏感信息脱敏等。
1.2 边界:能用计算属性解决就别用过滤器
过滤器虽然好用,但它不是万能的。我见过不少同学在过滤器里写复杂逻辑,最后维护起来非常痛苦。这里我给一个自己一直在用的判断标准:
- 如果格式化结果只在一处模板里使用,而且逻辑简单(两三行),用过滤器完全没问题。
- 如果格式化结果依赖组件里的多个响应式数据,或者需要缓存复用,优先用计算属性。
- 如果格式化逻辑要在多个组件之间共享,优先抽成独立工具函数,组件内部再封装。
- 如果格式化过程需要访问组件实例的
data、computed、methods,直接放弃过滤器,换方法或计算属性。
计算属性最大的优势是有依赖缓存,只有依赖的响应式数据变化时才会重新计算。过滤器则每次渲染都会重新执行,哪怕输入值没变。这一点在大列表渲染下会有明显性能差异,后面会专门说到。
2. 过滤器注册与作用域:全局 Vue.filter 和组件内 filters 该如何选择
2.1 全局注册的用法、挂载时机和常见误区
全局过滤器通过Vue.filter(id, factory)注册,一般放在main.js里:
Vue.filter('formatMoney', function(value, symbol = '¥') { if (value === null || value === undefined) return '--'; return symbol + (value / 100).toFixed(2); });注册之后,任意组件的模板里都能直接用{{ amount | formatMoney }}。这个API在Vue 2里还有个不常见的小用法:如果只传第一个参数,Vue.filter('formatMoney')返回的是已注册的过滤器函数,可以用来在非模板环境里手动调用,比如在路由守卫或业务模块里复用格式化逻辑。
但这个用法我并不推荐在业务代码里频繁使用,原因有两个。第一,全局过滤器的注册时机有讲究,必须在new Vue()之前完成,否则组件渲染时找不到过滤器会直接报错;第二,全局过滤器本质上是挂在一个全局命名空间上的,项目越做越大,命名冲突的概率就越高。
2.2 局部注册:组件内 filters 的优先级规则
局部过滤器写在组件的filters选项里:
<template> <div>{{ status | statusText }}</div> </template> <script> export default { filters: { statusText(value) { const map = { 1: '待支付', 2: '已支付', 3: '已退款' }; return map[value] || '未知状态'; } } } </script>这里有一个特别容易忽略的优先级规则:如果组件内定义了和全局同名的过滤器,局部过滤器会覆盖全局过滤器。模板解析时先找组件自身filters,找不到再找全局Vue.filter注册表。
正是因为这条规则,我在实际项目里吃过一次亏。当时在某个页面里为了调试临时定义了一个叫formatDate的局部过滤器,覆盖了全局的formatDate,结果页面上所有日期都变成了调试输出格式,排查了很久才发现是覆盖问题。所以这里建议:组件内局部过滤器尽量不要和全局过滤器重名,如果非要重名,一定要明确知道覆盖行为,避免其他同事被误导。
2.3 我的选型标准:可复用性 vs 局部耦合
全局过滤器适合那种横跨多个模块、格式化规则完全统一的场景,比如金额、日期、脱敏。局部过滤器适合那种只在当前组件内有效的展示转换,比如把某个状态码映射成中文文案。
从维护角度说,我现在的习惯是:优先写一个utils/format.js模块,把格式化函数全部以纯函数形式导出:
export function formatMoney(value, symbol = '¥') { if (value === null || value === undefined) return '--'; return symbol + (value / 100).toFixed(2); } export function formatDate(value, fmt = 'YYYY-MM-DD') { // 日期格式化实现 } export function maskPhone(value) { return String(value).replace(/^(\d{3})\d{4}(\d{4})$/, '$1****$2'); }然后在需要的组件里按需引入,本地包一层过滤器,或者直接模板里调用。这样做的好处是,工具函数是纯粹的JavaScript,单元测试好写,Vue 3迁移时也不用改业务逻辑。
3. 模板语法里的真实规则:管道符、参数传递、链式调用
3.1 管道符只能在插值表达式和 v-bind 里用,别再乱试
过滤器的使用位置在Vue官方文档里有明确说明:只能用于双大括号插值和v-bind表达式(从Vue 2.1.0开始支持v-bind)。例如:
<!-- 插值表达式 --> <p>{{ message | capitalize }}</p> <!-- v-bind --> <div :id="rawId | formatId"></div>注意,不能在v-model、v-for、事件绑定等指令里用过滤器。v-model需要的是双向绑定的值,过滤器是只读转换,语义上就冲突。v-for的迭代语法解析也不支持管道符表达式。
可能有人会问,“指令内部不能写过滤器,那我在v-for循环里想格式化每一项怎么办?”办法很简单,有两种:一种是在v-for作用域内直接用一个方法调用,<span>{{ item.amount | formatMoney }}</span>这种其实可以,因为它本质还是插值表达式;另一种是把格式化逻辑放到提交渲染数据之前处理,也就是在computed里先map一遍。
3.2 参数传递的“源数据永远在第一位”规则
过滤器函数接收的第一个参数永远是管道符左边的值,也就是源数据。后面的参数从模板传入。举个例子:
{{ price | formatMoney('$', 2) }}对应的过滤器函数:
function formatMoney(value, symbol, decimals) { // value 来自管道符左边的 price // symbol 是 '$' // decimals 是 2 }这是一个容易搞反的点。很多人第一次用的时候以为第一个参数是模板里传进来的’$’,实际上是源数据排在最前面。如果源数据不是管道符左边第一个表达式,而是整个表达式的结果呢?比如{{ '合计:' + total | formatMoney }},此时过滤器接收的value是'合计:' + total这个字符串拼接后的结果,而不是total本身。所以千万不要把复杂表达式和过滤器混合使用,除非你很清楚整个表达式会被当成一个整体作为源值传入。
3.3 链式调用的执行顺序与性能代价
过滤器支持链式调用,比如{{ message | filterA | filterB }}。执行顺序是:先执行filterA(message)得到结果,再把结果作为filterB的输入继续执行。
Vue.filter('filterA', function(value) { return value + ' A'; }); Vue.filter('filterB', function(value) { return value + ' B'; }); // message 为 hello,最终得到 hello A B链式调用确实能让模板读起来很顺畅,但代价是每次渲染都会完整执行整条链。比如一个列表有100行,每行模板里有3个过滤器链,一次渲染就会有300次函数调用。虽然大多数过滤器的逻辑很简单,不至于造成性能瓶颈,但在数据量大的表格、长列表里,这个开销值得警惕。
我自己的做法是:链路超过两个过滤器的时候,优先封装成一个独立方法或计算属性,避免模板里写太长、太复杂的转换链。
4. 过滤器实际使用中那些真正需要避开的坑,包含排查思路
4.1 过滤器里的 this 无法拿到组件实例:现场还原与排查
这是过滤器最大的一个限制。在Vue 2.x里,过滤器函数在执行时不会被绑定到组件实例上,它的this不是vm,而是undefined(严格模式下)。如果你在过滤器里尝试访问this.userName、this.someData,控制台会直接报错,或者拿到undefined。
我印象很深的一次排查经历:有个页面里的表格列需要根据当前组件里的一个开关状态来决定格式化方式,当时同事在过滤器里写了:
filters: { formatValue(value) { if (this.someFlag) { return value.toUpperCase(); } return value; } }结果页面一加载,所有列都变成了小写,完全不理会someFlag的值。查了半天才发现,this.someFlag压根拿不到,this就是undefined,走了else分支。后来改成在模板里传入参数:
<td>{{ row.value | formatValue(someFlag) }}</td>filters: { formatValue(value, flag) { return flag ? value.toUpperCase() : value; } }这个坑的排查思路其实很简单:先确认过滤器函数里是否存在对this的访问。如果存在,直接改成参数传递,或者干脆换计算属性。千万不要试图在过滤器里通过闭包去拿组件实例,那会让代码的依赖关系变得非常隐晦,别人根本看不懂这个过滤器依赖了什么外部状态。
4.2 别在过滤器内部改动原数据:纯函数约束
过滤器应该是纯函数:同样的输入永远得到同样的输出,并且不修改传入的参数、不产生副作用。我在实际项目里见过有人在过滤器里直接改对象的属性:
filters: { addTag(item) { item.tag = '已处理'; return item; } }看起来好像“顺手”把数据改了,但这样做有几个问题。第一,如果item是来自Vuex或组件的响应式数据,直接修改属性会触发响应式更新,可能导致渲染循环或意外的状态变化。第二,过滤器被调用的次数不一定和渲染次数一一对应,依赖过滤器去修改数据会让整个数据流的可预测性变得很差。
正确的做法是返回一个新值。比如上面这个例子改成:
filters: { withTag(item) { return Object.assign({}, item, { tag: '已处理' }); } }这样源数据不会被污染,渲染出来的还是你想要的展示结果。
4.3 过滤器不会按需缓存:列表渲染时的多次执行风险
计算属性有缓存机制,过滤器没有。每次组件的渲染函数执行时,模板里的过滤器都会被重新调用一遍。换句话说,即使过滤器的输入值没有变化,只要组件发生重新渲染,过滤器就会重新计算。
这在大列表场景下特别明显。举个例子,一个组件里有searchText响应式数据,用户每次输入搜索关键词,整个列表区域都会重新渲染,列表里每一行的日期、金额过滤器全部会重新执行一遍。如果每个过滤器内部还做了正则匹配、日期解析之类的操作,几百行列表就能明显感觉到卡顿。
针对这个问题,建议这样处理列表类数据:
- 在
computed里先把整个列表的格式化结果算好,模板里只取格式化后的字段。 - 或者用子组件隔离列表项,每个子组件只渲染自己那一行,减少无谓的全量渲染。
这两种方案都比依赖过滤器更可控,也是我在项目里实际验证过有效的做法。
4.4 全局过滤器污染隐患与命名覆盖问题
前面提到全局过滤器可能会被局部过滤器覆盖,这里再补充一个更隐蔽的问题:多人协作时,如果大家都在main.js里不断追加全局过滤器,很容易出现“我在组件里定义的statusText和你定义的全局statusText完全不是一个含义”的情况,但因为局部覆盖全局的规则存在,不同组件里同一个过滤器的行为可能完全不一致。
我曾经在一个中大型项目里见过一个全局过滤器叫status,在A页面显示的是中文状态,在B页面由于局部覆盖变成了带颜色的标签HTML,在C页面又变成了英文缩写。排查起来非常费劲,因为模板里都是{{ status | status }}这种写法,代码里根本看不出它到底做什么。
所以我的建议是:全局过滤器的数量要克制,命名要带业务前缀,比如formatMoney、formatDate这种通用语义的可以全局保留,状态映射类的尽量局部化或直接抽成工具函数。
5. Vue 3 已经移除过滤器,我正在用的替代方案
5.1 事实与迁移判断:从 Vue 2 过渡时怎么评估存量代码
Vue 3的官方发布说明里明确写了一个破坏性变更:过滤器功能被移除了。这意味着你在Vue 2项目里写的{{ message | filterName }},直接迁移到Vue 3的模板里会解析出错,因为管道符在JavaScript表达式里是位或运算符,Vue 3不再对模板中的|做特殊处理。
如果你的项目正在从Vue 2升级到Vue 3,存量过滤器代码的评估建议分三步走:
- 先全局搜一下
Vue.filter(和filters: {,把使用范围摸清楚。 - 按用途分类:纯格式化、状态映射、复杂逻辑。
- 纯格式化和状态映射的直接替换成工具函数或计算属性;复杂逻辑本来就不该用过滤器,正好借机重构。
5.2 可复用格式化函数模块 + 模板调用:最接近原写法的迁移路径
最接近原写法的迁移方式是:把过滤器函数原样保存成普通函数,模板里用方法调用的写法替代管道符。
比如原来:
{{ amount | formatMoney }}迁移后:
{{ formatMoney(amount) }}对应的函数定义:
import { formatMoney } from '@/utils/format'; export default { setup() { return { formatMoney }; } }这种写法改动量最小,逻辑保持不动,只是把管道符换成了函数调用。如果格式化函数不需要访问组件状态,这是一个非常稳妥的过渡方案。
需要特别提醒的是,函数调用和过滤器的直觉刚好相反:过滤器的写法是数据在左边、函数在右边,函数调用是函数在左边、数据在括号里。迁移时不要盲目全局替换,最好先在几个典型页面确认渲染结果没有差异。
5.3 使用 computed 保留响应式的格式化结果
如果格式化结果依赖多个响应式数据,或者希望缓存结果,用computed是最合适的。以Composition API为例:
<template> <div>{{ displayPrice }}</div> </template> <script> import { computed } from 'vue'; import { formatMoney } from '@/utils/format'; export default { setup(props) { const displayPrice = computed(() => { return formatMoney(props.order.amount); }); return { displayPrice }; } } </script>这样一来,只有当props.order.amount变化时才会重新格式化,更重要的是,格式化逻辑不再藏在模板里,测试和调试都方便得多。对于表格里的批量格式化,直接在computed里对整个列表做map,渲染时直接读取格式化后的字段,性能表现也会更好。
5.4 借助 globalProperties 实现“伪过滤器”的注意事项
Vue 3里可以用app.config.globalProperties注册全局方法,让所有组件的模板都能直接访问:
// main.js import { createApp } from 'vue'; import App from './App.vue'; const app = createApp(App); app.config.globalProperties.$formatMoney = (value) => { if (value === null || value === undefined) return '--'; return '¥' + (value / 100).toFixed(2); }; app.mount('#app');然后在模板里使用:
<template> <div>{{ $formatMoney(amount) }}</div> </template>这个方案在模板里写起来很接近原来的全局过滤器体验,但我必须提醒几个问题:
globalProperties上的方法在模板里能直接访问,但在setup里不能直接用this.$formatMoney,需要先const instance = getCurrentInstance()再取值,或者干脆在setup里导入工具函数。globalProperties的命名空间是全局的,换一个项目或换一个人接手,可能根本不知道这个方法来自哪里。所以建议只在业务统一格式化规则时使用,而且要写在明确的模块文件里,不要散落在main.js中。- 使用这种方式时,如果将来再做服务端渲染或单元测试,需要额外处理全局对象注入问题,维护成本会高于独立工具函数。
综合来看,我个人现在最推荐的做法还是把格式化逻辑全部收敛到utils/format.js这样的纯函数模块里,组件内的单次格式化用方法,组件间共享的格式化用独立函数,涉及复杂依赖的用computed。这种写法无论在Vue 2还是Vue 3下都成立,不会因为框架升级而推倒重来。
最后再分享一个小习惯:不管用哪种方案,格式化函数的边界值处理一定要做,尤其是null、undefined、空字符串、负数、超大数字这些情况。因为后端数据永远比你想象的更不可控,一个稳定的格式化函数,不会因为某天某个字段返回了个null就让整页白屏。