把 Vue 2 的老项目升级到 Vue 3,列表渲染相关的代码是最容易出问题的地方之一。一个典型场景是:原来在某个元素上同时写了 v-for 和 v-if,在 Vue 2 里运行得好好的,迁移到 Vue 3 之后要么直接报错,要么过滤逻辑完全失效。这不是 bug,而是 Vue 3 从设计层面做出的改变——两个指令的优先级被彻底调换了。理解这个变化的来龙去脉,才能写出正确且高性能的列表过滤代码。

一、优先级反转:Vue 2 与 Vue 3 的本质区别
先说结论:在 Vue 2 中,v-for 的优先级高于 v-if,即先执行循环遍历,再对每一次遍历结果做条件判断;而在 Vue 3 中,v-if 的优先级高于 v-for,条件判断先于循环执行。这个顺序的差异带来一个致命问题:如果 v-if 的条件里引用了 v-for 解构出来的循环变量,在 Vue 3 中这个变量根本还不存在。
看一段在 Vue 2 中常见的写法:
<li v-for="user in users" v-if="user.active">
{{ user.name }}
</li>这段代码在 Vue 2 中是合法的。编译器先生成 v-for 的渲染函数,循环体内部再嵌入 v-if 的三元判断,相当于 JavaScript 里的 users.filter(user => user.active) 的效果。而到了 Vue 3,编译器解析到 v-if 时,会把整个节点包装成条件表达式,此时 user 这个变量还没有被定义,控制台会直接抛出类似「user is not defined」的错误,即使编译器没有报错,ESLint 的官方插件也会给出明确的警告。
Vue 团队做出这个调整并非故意制造不兼容,而是基于两点考虑:其一,这种「先循环再过滤」的写法存在性能隐患——即使大部分列表项都会被 v-if 过滤掉,循环本身依然完整执行了一遍;其二,两个指令耦合在同一个元素上,语义模糊,可读性差。官方的态度很明确:不推荐在同一个元素上同时使用这两个指令,应该用更清晰的方式拆分开。
二、从编译产物看优先级差异的底层原因
要真正理解优先级问题,可以看看两个版本编译后的渲染函数。Vue 2 的编译结果大致是这样的结构:
// Vue 2 编译产物(简化示意)
render(h) {
return this._l(this.users, (user) => {
return user.active
? h('li', user.name) // v-if 在循环内部判断
: this._e()
})
}可以看到,users.map 之类的循环在外层,v-if 被编译为循环回调内部的判断逻辑。而 Vue 3 使用了基于 Block Tree 的全新编译策略,v-if 会被编译为一个独立的条件渲染块:
// Vue 3 编译产物(简化示意)
export function render(_ctx, _cache) {
return _ctx.someCondition
? _createBlock(Fragment, _forEach(_ctx.users, (user) => { ... }))
: _createCommentVNode('v-if', true)
}在 Vue 3 的编译输出中,条件判断包裹在最外层,循环块整体成了条件成立时的渲染分支。这就从编译器实现层面解释了为什么 v-if 无法引用循环变量——它在编译后的代码里处于循环作用域之外。Vue 3 编译器甚至会在检测到这种用法时给出警告提示,引导开发者拆分写法。
三、三种正确的替代方案
方案一:用 computed 计算属性做列表过滤(推荐)
这是官方最推荐的方式。把过滤逻辑从模板中抽离到 computed 中,模板只负责渲染,过滤条件变化时会自动触发重新计算,且结果有缓存:
<script setup>
import { computed, ref } from 'vue'
const users = ref([
{ id: 1, name: '张三', active: true },
{ id: 2, name: '李四', active: false },
{ id: 3, name: '王五', active: true }
])
// 过滤逻辑收敛到计算属性中,可测试、可复用
const activeUsers = computed(() => users.value.filter(u => u.active))
</script>
<template>
<li v-for="user in activeUsers" :key="user.id">
{{ user.name }}
</li>
</template>这种写法的好处是显而易见的:过滤只依赖数据本身,computed 有缓存机制,只要 users 不变就不会重复计算;同时业务逻辑从模板挪到了脚本中,单元测试可以直接针对 activeUsers 编写,代码可维护性大幅提升。
方案二:用 template 标签拆分两个指令的作用域
如果条件判断确实需要基于循环外部或内部的变量分两层控制,可以用 <template> 标签嵌套,让 v-for 和 v-if 各占一个节点:
<template v-for="user in users" :key="user.id">
<li v-if="user.active">
{{ user.name }}
</li>
</template>注意 Vue 3 中 key 要写在 <template> 标签上,这一点和 Vue 2 不同。这种写法虽然解决了优先级冲突,但依然存在「循环全量执行、渲染时才过滤」的性能问题,适合列表规模较小、或者过滤条件依赖循环变量之外更多上下文的场景。
方案三:条件在外层时先判断,频繁切换用 v-show
如果 v-if 的条件与循环变量无关,比如「只有登录后才渲染整个列表」,直接把条件提到外层容器即可:
<ul v-if="isLogin">
<li v-for="item in items" :key="item.id">{{ item.text }}</li>
</ul>另外,如果列表项只是显隐频繁切换、数据结构不变,用 v-show 替代 v-if 更合适。两者的区别在于:v-show 只切换 CSS 的 display,元素始终存在于 DOM 中,切换开销极小;v-if 则是真正的条件渲染,切换时要销毁和重建节点及绑定的事件。一个简单的判断标准是切换频率高用 v-show,条件基本不变用 v-if。
四、总结与最佳实践建议
梳理一下核心结论:Vue 2 是先循环后判断,Vue 3 是先判断后循环,优先级完全相反,这也是官方风格指南长期不推荐两者同用的原因。日常开发中建议遵循三条原则:第一,任何列表过滤需求优先考虑 computed,这是性能和可维护性的最优解;第二,结构性的条件控制用 <template> 拆分作用域,保证每个节点只承担一个指令的职责;第三,理解 v-if 与 v-show 的渲染成本差异,按场景选型。
对于正在做 Vue 2 到 Vue 3 迁移的团队,建议在升级前先全局搜索同时包含 v-for 和 v-if 的模板代码,逐个改造为计算属性方案,再配合 ESLint 的 vue/no-use-v-if-with-v-for 规则在 CI 阶段拦截此类写法,避免同类问题再次进入代码库。把模板写干净,编译器才不会替你做模糊的猜测,组件的渲染性能和可读性都会上一个台阶。