“Day1先从模板语法讲起”——这是我给自己排的迁移学习计划里,最不起眼但其实最能影响全局的一环。
先说背景。我手里有一个跑了两年的Vue2老项目,功能稳定,但新需求越提越多,团队新同学打开IDE第一句就是“这项目怎么还在用Options API”。于是决定系统性过一遍Vue3,每天一个主题,做笔记、写样例、踩坑。Day1选的不是组合式API也不是响应式原理,而是模板语法。为什么?因为模板语法是离业务最近的一层,项目里几乎每个页面都要接触,而且它表面上看起来“Vue2和Vue3好像差不多”,实际规则改了不少。很多迁移问题表面上像是逻辑写错了,藏着根因却是模板语法升级导致的。
这篇笔记会把Vue2升级Vue3过程中,模板语法涉及的核心差异、必改点、实操迁移过程和排查经验一次讲清楚。适合刚开始接触Vue3、或者老项目准备往Vue3迁的同学参考。内容不绕弯,能抄作业的直接抄。
1. 为什么强调先啃模板语法
1.1 模板语法是迁移中改动最密集的一层
Vue2和Vue3在组件写法上的差异,很大一部分集中在模板里。组合式API你可以在新代码里慢慢习惯,但老页面的<template>部分,不升级就编译不过,或者编译过了但运行逻辑完全变了。模板语法决定了项目里视图层怎么写,组件怎么通信,列表怎么渲染。这一层不动,其余工作都推不动。
有朋友会觉得,模板语法不就是{{ }}插值、v-if、v-for这些吗,有什么好学的。其实不是。Vue2到Vue3,模板编译器的解析规则、指令优先级、语法糖实现方式都变了。最典型的例子就是v-if和v-for同用时的优先级反转,代码不报错、页面结果却不一样。这种“静默差异”最坑,因为它不会在编译期拦住你,只有运行后发现列表渲染多了、少了,才会慢慢追到模板逻辑上。
1.2 要分清“编译报错”和“运行行为差异”两类问题
我刚开始迁移时,最大的困扰不是报错多,而是报了错看不懂,或者根本没报错但行为不对。后来我习惯把所有模板差异分成两类去看:
第一类是编译期直接报错,比如filters被移除了、v-model绑定的属性名不对,这类问题其实安全,因为编译不过就改,改完就好了。第二类是编译期不报错,但运行行为完全不同,比如v-if和v-for优先级变了、v-bind覆盖顺序变了、ref在v-for中不再自动收集数组了。这类问题最危险,因为代码看起来完全正常,甚至官方文档里旧的示例也能跑,只是结果表现得莫名其妙。
所以学好模板语法,本质上是学清楚这些“不报错但变了”的规则。这也是为什么我建议迁移动手前,先花一天专门过模板语法。
2. 实测必改清单:Vue2模板改成Vue3的6个核心差异
2.1 多根节点:从单根限制到Fragment自由
Vue2时代,每个组件的template必须有一个根节点,否则编译直接报错“Component template should contain exactly one root element”。所以以前经常能看到有人在外层套一个无意义的<div>,只是为了满足单根限制。
Vue3支持多个根节点,这个变化本身是解放,但有几个连带的小坑值得提。第一个是样式问题,以前外层<div>往往承担了布局样式,比如class="wrapper",改成多根后,原来的CSS选择器如果依赖这个外层节点,样式会丢。第二个是attribute继承问题,多根组件如果没有显式声明继承哪个元素,组件上传入的class等属性不会自动落到某个根节点上,Vue3会给出警告“Extraneous non-props attributes”。处理方式是给对应根元素加上v-bind="$attrs",或者用defineOptions({ inheritAttrs: false })统一管理。
<!-- Vue2 写法 --> <template> <div class="wrapper"> <header>...</header> <main>...</main> </div> </template> <!-- Vue3 可以这样 --> <template> <header>...</header> <main>...</main> </template>迁移时别贪多根写法,除非你确认不依赖旧的包裹节点样式。为了降低风险,老组件可以先保留原结构,只把语法问题解决掉。
2.2 v-if与v-for优先级反转的逻辑隐患
这个是我认为Day1里最值得记的知识点。Vue2中,v-for的优先级高于v-if,也就是先循环,再在每次循环里判断条件。Vue3中反过来了,v-if优先级高于v-for,也就是先判断外层条件,再进入循环。
听起来平平无奇,实际上一旦两者作用于同一个节点,代码逻辑可能完全变味。常见写法是这样的:
<div v-for="item in list" v-if="item.status === 'active'" :key="item.id"> {{ item.name }} </div>Vue2里这个代码是先遍历list,再过滤出status === 'active'的项。Vue3里则先判断item.status,但此时item在v-if作用域中还不存在,直接报错“Property 'item' was accessed during render but is not defined”,或者渲染出不符合预期的内容。
最稳妥的新写法是把过滤逻辑拿到计算属性里,模板只负责渲染结果:
const activeList = computed(() => props.list.filter(item => item.status === 'active'));<div v-for="item in activeList" :key="item.id"> {{ item.name }} </div>如果你确实需要层级嵌套的复杂判断,也可以用<template>包一层,让v-for在<template>上,v-if放到具体元素上。这样既符合Vue3的优先级规则,逻辑也清楚。
2.3 v-model在组件上的语法糖升级
Vue2的v-model本质是:value加@input的组合。自定义组件上,你可以通过model选项修改绑定的属性和事件名。Vue3把这套机制清理了,默认改成:modelValue加@update:modelValue。
<!-- Vue2 等价写法 --> <ChildComponent :value="msg" @input="msg = $event" /> <!-- Vue3 等价写法 --> <ChildComponent :modelValue="msg" @update:modelValue="msg = $event" />这个差异对自定义组件影响非常大。以前你在组件内部用model: { prop: 'value', event: 'change' }定制过,迁移到Vue3后要改成defineModel()或者手动声明modelValue和update:modelValue。
.sync修饰符也一并并入了v-model。Vue2里写<Child :visible.sync="show" />的地方,Vue3改成<Child v-model:visible="show" />。这其实让“多个v-model”成了官方标准用法,因为你可以写v-model:title、v-model:content。
还有一点,原生元素上的v-model.number、v-model.trim在Vue3中依然可用,这个没变。真正容易出问题的是自定义组件上没有按新语法糖命名事件,导致父组件绑定了半天,子组件压根不触发update:modelValue。
2.4 v-bind与v-on合并绑定的覆盖顺序
这个差异属于“不查文档绝对会踩”的类型。Vue2中,在一个元素上同时写v-bind="object"和具体属性,具体属性永远覆盖展开对象里的同名属性。Vue3中改成按时序覆盖——先写的被后写的覆盖。
<!-- Vue2 最终 id 一定等于 app --> <div v-bind="{ id: 'objId' }" id="app"></div> <!-- Vue3 结果不同 --> <div v-bind="{ id: 'objId' }" id="app"></div> <!-- 先展开对象,再应用 id,最终 id 等于 app --> <div id="app" v-bind="{ id: 'objId' }"></div> <!-- 先应用 id,再展开对象,最终 id 等于 objId -->这个规则变化会影响动态配置类组件,比如一个组件接收一堆透传属性,又在模板里手动覆盖了class或style。迁移时如果发现样式或属性没有按预期生效,优先检查v-bind和普通属性的书写顺序。
监听器方面也有类似细节。Vue2里v-on和$listeners是分开的,Vue3把$listeners并入了$attrs,组件传参和透传的习惯都得改。
2.5 filters被移除后的三种替代写法
Vue3移除了filters,模板里不再支持管道符写法。这是编译期直接报错的类型,很好发现,但很多老项目中filters的使用量很大,得提前想好替代方案。
最直接的替代是方法调用:
<!-- Vue2 --> <div>{{ formatTime(item.time) | dateFormat }}</div> <!-- Vue3 --> <div>{{ dateFormat(formatTime(item.time)) }}</div>这里我踩过一个坑:Vue3模板里虽然支持方法调用,但如果方法内部逻辑依赖this,组合式API的setup中this不是组件实例,方法要写成普通函数并把依赖数据作为参数传入,别在函数里直接访问this.xxx。
第二种替代是计算属性,适合单个值的格式化。第三种是全局处理,把公共格式化函数挂到app.config.globalProperties上,或者直接用globalProperties注册后模板里使用。这里有一个和Vue2时期挂Vue.prototype类似的地方,迁移时要注意:Vue3的模板对全局变量访问有白名单限制,后面会单独说。
2.6 模板表达式中的全局访问白名单变化
Vue3模板表达式能访问的全局变量范围比很多人想象中窄。模板里可以直接用Math、Date、RegExp这些白名单内的对象,但自定义的全局变量不行。不要在模板里直接写类似{{ MyHelper.format(item) }}的代码,除非MyHelper已经通过globalProperties全局注册。
当你在模板里引用一个未定义的变量,Vue3给出的警告会是“Property 'xxx' was accessed during render but is not defined on instance”。遇到这个提示,先在组件数据里找,没有就检查是不是打算注册为全局属性。这个坑在迁移一些工具函数较多的老项目时特别常见,以前挂Vue.prototype的方法,升级后全部要改为app.config.globalProperties注册。
3. 手把手实操:一个Vue2组件模板的完整迁移记录
3.1 迁移前先做体检
为了不纸上谈兵,我拿了一个正在开发中的用户列表组件做迁移实验。它的模板不长,但很有代表性:有v-model输入框、有v-for渲染列表、有v-if过滤空状态、有filters格式化分数、有ref收集输入框、还有点击事件和动态class。
迁移之前,我先在Vue2的源码里搜了一遍模板中出现的所有指令和插值写法,列了一个检查清单:
- 插值表达式里有没有用
filters管道符 v-for和v-if是否在同一个节点上- 自定义组件上的
v-model是否依赖了旧语法糖 v-bind和普通属性是否同时出现ref是否用在v-for内- 模板里是否引用了未注册的全局方法
检查结果:管道符一处,v-for和v-if同用一处,ref="scoreInput"在v-for内一处。这三个就是需要动手改的位置。
3.2 逐块替换模板代码的具体过程
老模板结构大致是这样的:
<template> <div class="user-list"> <input v-model="keyword" class="search" placeholder="搜索用户名" /> <div v-if="!keyword && !filteredUsers.length" class="empty"> 暂无匹配用户 </div> <div v-for="item in filterByStatus(filteredUsers)" v-if="item.status === 'active'" :key="item.id" class="user-item" :class="{ highlight: item.score > 90 }" @click="selectUser(item)" > <span>{{ item.name }} - {{ item.score | formatScore }}</span> <button @click.stop="removeUser(item.id)">删除</button> <input ref="scoreInput" v-model.number="item.score" type="number" /> </div> </div> </template>改完后是这样:
<template> <div class="user-list"> <input v-model="keyword" class="search" placeholder="搜索用户名" /> <div v-if="!keyword && !activeUsers.length" class="empty"> 暂无匹配用户 </div> <template v-for="item in activeUsers" :key="item.id"> <div class="user-item" :class="{ highlight: item.score > 90 }" @click="selectUser(item)" > <span>{{ item.name }} - {{ formatScore(item.score) }}</span> <button @click.stop="removeUser(item.id)">删除</button> <input :ref="el => setScoreInputRef(el, item.id)" v-model.number="item.score" type="number" /> </div> </template> </div> </template>第一处改动:把filterByStatus(filteredUsers)和v-if="item.status === 'active'"合并成一个计算属性activeUsers。原来的逻辑是现过滤关键字,再在模板里通过方法过滤状态,最后又用v-if判断一次,三层叠加写得晦涩。新写法把所有过滤逻辑收敛到一处,模板里只剩一个v-for,优先级问题从根上消失。
第二处改动:{{ item.score | formatScore }}改成{{ formatScore(item.score) }}。formatScore定义为一个普通方法,如果格式化逻辑较重,也可以改成计算属性返回新数组。
第三处改动:ref="scoreInput"改成:ref="el => setScoreInputRef(el, item.id)"。原因后面会讲,这是Vue3的一个大变化。
3.3 编译、运行与回归验证
迁移完第一遍,我故意先跑一次编译,看看会暴露什么问题。果然报错信息直接指向了filters语法。把管道符改成函数调用后,编译通过了。
运行过程中,开发工具里出现了另一个警告:Property "item" was accessed during render but is not defined。这是老的v-for和v-if同用写法残留导致的,因为模板里某个v-if还在引用item。我把所有v-if和v-for拆开后,警告消失。
然后我重点验证了ref行为。Vue2中,v-for里的ref会自动变成一个数组,你直接拿this.$refs.scoreInput[0]就能操作第一个输入框。Vue3中不会自动收集了,ref在v-for里更像一个普通的ref属性,你给ref传一个函数,每一轮渲染都会调用这个函数,参数就是当前元素。代码里我实现了一个setScoreInputRef,用Map按item.id存起来,后续要用的时候直接按id取。
const scoreInputRefs = new Map(); const setScoreInputRef = (el, id) => { if (el) { scoreInputRefs.set(id, el); } else { scoreInputRefs.delete(id); } };这个写法还兼顾了组件卸载时el为null的清理逻辑,比Vue2的数组式ref更精确。迁移后老项目中凡是“拿到所有子组件ref、写操作第N个”的逻辑,都要重新思考。这个点比过滤器更隐蔽,不跑一次很难发现。
回归验证时,我还检查了动态class、v-model.number、事件修饰符@click.stop,这些在Vue3中写法一致,没有额外改动。整体迁移下来,最花时间的不是改代码,而是把旧逻辑里的隐含行为搞清楚。
4. 模板迁移报错排查:常见问题与解决实录
4.1 编译报错高频Top榜
模板迁移中碰到的报错,来来去去就那几种。我整理了这段时间遇见的Top列表:
| 报错信息 | 出现原因 | 解决办法 |
|---|---|---|
Property "xxx" was accessed during render but is not defined | 模板中引用了未定义变量,或v-if引用了v-for作用域变量 | 检查变量来源,拆分v-if和v-for |
Filters are not supported | 还在用管道符 | 改为方法、计算属性或全局函数 |
Extraneous non-props attributes passed to component | 多根组件未指定属性继承位置 | 用v-bind="$attrs"或关闭自动继承 |
Component template should contain exactly one root element | Vue2老模板迁移时未调整 | 合并根节点,或确认是否需要用多根结构 |
v-model value binding is missing | 自定义组件没有正确声明modelValue | 按新语法糖绑定modelValue和update:modelValue |
这些报错其实都不可怕,可怕的是开发环境没报错,线上才有问题。所以排查模板问题时,我的习惯是先看浏览器控制台的完整警告栈,再定位到具体的<template>片段,最后回到Vue2老代码里比对原逻辑。
4.2 v-model绑定失效的三种典型场景
v-model迁移中最容易出问题的,不是原生元素,而是自定义组件。
第一种场景:子组件内部还在用value和input作为事件名,父组件已经按Vue3的modelValue绑定了。结果是输入框能显示,但一改动父组件数据不更新。修复方式是子组件内改掉声明名,或者统一改成defineModel。
第二种场景:原来用.sync的地方全改成v-model:xxx后,事件名没改干净。.sync在Vue2里要求子组件$emit('update:title', value),Vue3的v-model:title也期待update:title,这部分变化不大,但如果你在Vue2里通过model选项自定义了prop和event,迁移时容易漏掉update前缀。
第三种场景:多个v-model混用,比如一个组件同时绑定v-model="value"和v-model:title="title"。Vue3是支持的,但要注意事件命名空间不要写串了,绑定值也别都指向同一个变量。
4.3 $listeners、$attrs与filters的报错处理
Vue2中有$listeners和$attrs两个独立对象,分别收集事件监听器和非props属性。Vue3把两者合并了,$listeners被移除,所有事件和非props属性都汇总到$attrs中。
老代码里如果写了v-on="$listeners",迁移后要改成v-bind="$attrs"。这里面有一个容易忽略的细节,$attrs现在包含事件,比如onClick、onChange,使用的命名规范是on加事件名,不再是原来的@命令式。如果你在子组件内部手动遍历$attrs,要按onXxx这个格式取。
另外,$children在Vue3里也被移除了,组件树访问要靠ref或提供注入。这个虽然不是模板语法,但经常和ref的使用混在一起排查。
过滤器的问题前面说过,这里补充一个建议:迁移前先全局搜一遍|,把模板管道符全部挑出来,单独列一个filter迁移清单。因为filters在Vue2里可以用在插值、v-bind、v-for等位置,不全局梳理就容易漏。
4.4 模板调试辅助:善用编译器提示与官方迁移工具
排查模板问题时,不要只盯着自己的代码。Vue3开发环境给的警告信息已经比Vue2详细很多,很多是中文描述,直接告诉你“这是模板编译问题”还是“运行时警告”。我建议打开浏览器控制台,过滤vue.runtime.esm-bundler.js相关的日志,逐条看。
官方迁移工具也能帮忙。它会扫描老项目代码,指出哪些地方涉及不兼容语法,包括模板里的filters、v-model用法、slot语法等。但它的判断比较机械,改完不一定完全符合新逻辑,还是要自己再核对一遍。
还有一个很实用的调试技巧:给template里的可疑表达式临时加上一行console.log,直接在开发工具里输出。Vue3模板中表达式是支持调用方法并传参的,临时打印很方便,等定位完问题再删除。
5. 迁移过程中容易被忽略的周边影响
5.1 编辑器插件、ESLint与代码规范的影响
模板语法迁移不只是运行时的事,开发环境和代码规范也会变。Vue2时代的编辑器主力插件是Vetur,Vue3配合最好的是Volar。Vetur和Volar在模板语法高亮、自动补全、类型提示上存在差异。升级后我做的第一件事就是把Vetur关掉,装Volar,否则模板的自动补全会一直按Vue2习惯来。
ESLint这边的影响更大。以前老项目可能没启用vue/multi-word-component-names这样的规则,Vue3脚手架默认模板会提示组件名必须是多个单词。模板中书写组件名时,也要注意风格一致:<UserCard />还是<user-card />在Vue3中都支持,但同一处别混用。
如果ESLint开启了vue/no-v-for-template-key这类规则,还会强制要求key放在真实节点而不是template上。这直接和v-for拆分结构相关,提前配置好能少走弯路。
5.2 TypeScript与模板类型检查的新变化
老项目如果没上TypeScript,可以暂时跳过这条。但只要用了TS,Vue3的模板类型检查能力比Vue2强很多。Volar会自动推断:class、v-model、事件参数的类型,模板里传错类型直接标红。迁移时这一层会带来额外“报错”,但本质是好事。
要注意的是Composition API里声明变量后,模板中能直接使用setup返回的变量和方法,类型都能推断。以前Vue2里this.$emit('change', value)没有任何类型约束,Vue3里建议在defineEmits里声明事件及参数类型,模板里调用$emit也会被校验。这部分虽然不属于纯模板语法,但和模板中事件处理函数的写法强相关。
5.3 性能提升与模板编译的底层优化
Vue3模板编译做了不少优化,比如静态节点提升、静态属性提升、事件缓存、动态节点打标记。这些优化对开发者是透明的,但理解它们能帮助你写出更“适配”新编译器的模板。
一个典型例子是模板中动态绑定的数量。Vue3的patchFlags会让编译器精准知道哪些节点是动态的,性能对比时动态节点越多优化效果越明显。写模板时没必要刻意拆分,但要注意别在模板里写复杂三元表达式和多重嵌套方法调用,这些表达式会被编译成每次渲染都重新计算的动态节点,性能远不如提前在计算属性里处理完。
我对这个底层优化的感受是,模板语法从来都不仅是“写法问题”,它和编译策略、响应式系统是关联的。Day1虽然只讲模板,但已经能感受到Vue3整套编译器思路的转变。
最后再分享一个今天记录时最深刻的体会:Vue2转Vue3,真正困难的地方不在新特性多难学,而在于默认Vue2写法一定正确的惯性思维。模板语法这一关迈过去以后,后面看组合式API反而轻松,因为很多之前靠混乱的options和filters硬撑的代码,到了新写法里都找到了更清晰的位置。我给自己排的Day2是响应式相关,到时候再继续记录新的踩坑经验。