
做前端的人应该都有印象早期用 jQuery 写“点击菜单文字变色”这种交互代码总是绕不开removeClass、addClass、$(this)这一套。换成 Vue 之后同样一个需求核心逻辑往往就只有几行。这个教程里最常见的“示例1”也就是实现 ul 列表点击文字变色、默认第一个 li 文字变色看起来不起眼但它背后藏的是 Vue 最核心的心智模型界面状态跟着数据走而不是跟着 DOM 走。我之所以想单独写一篇把这个例子讲透是因为很多初学者照着例子敲一遍能跑却说不清楚“为什么改了一个数字样式就自动变了”还有一些人虽然抄会了这十几行代码一碰到 Tab 切换、菜单高亮、步骤条这类同源需求又不知道从哪下手。这篇我会从需求拆解、完整代码、原理说明到踩坑记录都过一遍最后再给几个扩展思路。适合刚学 Vue、正在刷基础示例的读者也适合想把这套逻辑迁移到真实业务组件里的开发者。1. 一个十几行的 Demo切中的却是导航高亮最常见的需求1.1 先还原一下需求场景别小看“点击文字变色”这六个字。随便打开一个网站顶部导航、侧边栏菜单、Tab 页签、步骤条全都是同一个交互模式有一组并列的选项任何时候只有一个处于选中状态选中的那个通过文字颜色、背景色或边框样式区分出来。这类交互的共性是“单选状态”。整个组件无论有多少个 li真正需要维护的状态就一个当前选中的是哪一项。剩下的工作全部是“把状态翻译成样式”。用 Vue 写这个需求最直观的做法就是把“当前选中项”存成一个变量。默认第一个 li 变色不是给第一个 li 写死一个 class而是把这个变量的初始值设为 0让第一个 li 因为“下标等于当前选中项”而获得高亮样式。这件事说起来轻松却是很多人第一次接触时会绕弯子的地方。1.2 目标和一份可直接运行的代码目标明确一下页面上有一个 ul里面有四个 li默认情况下第一个 li 的文字是红色鼠标点击任何一个 li它的文字变红同时其他 li 恢复默认颜色。下面是完整的一份基础实现用 Vue 2 的全局写法方便直接复制成 HTML 文件在浏览器里跑!DOCTYPE html html langzh-CN head meta charsetUTF-8 titleVue ul点击文字变色示例/title style .active { color: #d33; } /style /head body div idapp ul li v-for(item, index) in list :keyindex :class{ active: index currentIndex } clickcurrentIndex index {{ item }} /li /ul /div script srchttps://cdn.jsdelivr.net/npm/vue2.7.16/dist/vue.min.js/script script new Vue({ el: #app, data: { list: [首页, 产品中心, 关于我们, 联系方式], currentIndex: 0 } }); /script /body /html跑起来之后页面上第一项“首页”默认就是红色的点谁谁变红。后面我讲原理和踩坑都会拿这段代码当底子。2. 用索引做高亮解决“默认第一个变色”的最短路径2.1 为什么 currentIndex 一个变量就够用列表里的每一项天生都有一个下标index从 0 开始。既然要表达“当前选中第几项”最自然的状态就是一个数字选中第一项就是 0选中第三项就是 2。这个数字存进data里就叫currentIndex。很多初学者会纠结一个问题为什么不给每一项存一个id或者直接用文字内容去判断比如currentItem item或者用item.id currentId。这么做当然也能实现但在这种静态列表场景里属于过度设计。用下标的好处是列表项本身就是从数组里遍历出来的index天然存在。Vue 的:key默认也可以用下标写法最省事。高亮判断写起来直观index currentIndex。default的第一个 li 变色本质就是currentIndex的初始值等于 0。注意这里不是“让第一个 li 自己有 active”而是“选中状态天然停留在第 0 项”。这两句话听起来差不多实际含义完全不同前者是把结果写死后者是维护一个状态让结果跟着状态走。业务场景一变化比如第二次进入页面要记住上次选中的项只要初始化currentIndex为上次的值就行模板一行都不用改。2.2 把模板里的三件事串起来读那个模板里的 ul 内部其实同时在干三件事第一v-for负责“循环生成 li”。数组里有几个元素页面上就有几个 li每一个 li 都能拿到自己的item和index。第二:class负责“条件样式”。这里的:class是动态 class 绑定后面跟的是一个对象{ active: index currentIndex }。当index currentIndex为true时这个 li 会多出一个active类为false时就没有。Vue 内部会把这个对象里的真值类名合并进元素现有的 class 列表里。第三click负责“点击时改状态”。这里直接写了一句表达式currentIndex index相当于把当前点击项的下标赋给选中状态。这句写完之后Vue 响应式系统会自动发现currentIndex变了然后重新执行模板里所有依赖它的表达式颜色随之切换。注意:class和click前面那个冒号、符号都不能省。少写一个效果就出不来。2.3 到了真实项目里Vue 3 组合式写法和 SFC 结构上面那份代码是 Vue 2 的全量引入写法适合快速验证。如果是在实际工程里用script setup的单文件组件逻辑同样很简单我常用的是这种结构template ul classnav li v-for(item, index) in navList :keyindex :class{ active: index currentIndex } clickhandleSelect(index) {{ item }} /li /ul /template script setup import { ref } from vue const navList ref([首页, 产品中心, 关于我们, 联系方式]) const currentIndex ref(0) function handleSelect(index) { currentIndex.value index } /script style scoped .nav li { cursor: pointer; } .active { color: #d33; } /style在 Vue 3 的script setup里ref包出来的变量在模板中会自动解包所以模板里写currentIndex而不是currentIndex.value但方法内部修改时要写currentIndex.value index。这里我把点击逻辑单独抽成了一个handleSelect方法而不是直接写内联表达式。两种写法都能跑抽方法的好处是后面想加日志、加其他副作用时不用去改模板。3. 从数据到样式Vue到底帮你做了什么为什么不能直接操作DOM3.1 命令式写法和声明式写法对照为了看清 Vue 的价值可以拿老写法对照一下。如果用原生 jQuery 写同一个需求代码大概是这样的$(ul li).on(click, function () { $(ul li).removeClass(active); $(this).addClass(active); }); $(ul li:first).addClass(active);这段代码看起来也不难但它把“状态”散落在 DOM 上高亮效果由哪个元素身上的 class 决定而不是由一个统一的数据决定。一旦逻辑变复杂比如列表项是异步请求来的或者要支持“重置高亮”“外部跳转后更新高亮”你就要到处去寻找、修改、清理 DOM 上的 class代码越堆越乱。Vue 的写法把这个过程换了一个方向业务状态只有一个currentIndex页面只是这个状态的“投影”。你要改高亮就改这个数字其他 li 该恢复默认还是该高亮Vue 根据条件表达式自己算。类比一下这就像用 Excel。你写一个公式列让它等于“某单元格 目标值”时显示红色。你只需要改目标值公式列会自动重算完全不需要手动一格一格去涂颜色。currentIndex就是那个目标值:class就是那条公式。3.2 响应式更新对这种场景只要建立最小认知有人会追问为什么改一个变量DOM 就自动变了这里不需要深入源码建立一个最小认知就够了Vue 在初始化时会把data里的属性转成响应式数据模板编译后生成一个渲染函数这个函数读取了currentIndex于是 Vue 记录下“这个组件依赖 currentIndex”。当点击事件把它改掉时新值触发通知组件重新执行渲染函数算出一份新的虚拟 DOM再和旧的对比找出最小差异最后更新真实 DOM 中该变动的地方。放在这个例子里你点击第三个 licurrentIndex从 0 变成 2。重新执行渲染函数后第一个 li 的index currentIndex从 true 变 false于是它的active类没了第三个 li 变成 true于是加上了active类。这个过程中你从头到尾没有手动操作过一次classList也没碰过任何 DOM 节点这就是“数据驱动”的含义。初学者最容易犯的思维误区是既然最终效果是“某个 li 的文字颜色变了”那我直接在点击事件里改那个 li 的 style 不就行了行是行但那就退回命令式了。一旦你开始习惯“改数据”后面学计算属性、侦听器、组件通信都会顺畅很多。4. 实测常踩的四个坑逐个说明原因和修法这个示例看着简单实际自己敲的时候新手最容易在四个地方卡住。4.1 坑 1点击之后页面完全没反应现象是 li 渲染出来了默认高亮也有但点谁都不变色。先检查模板里的事件写法。如果把click写成了clickcurrentIndex index少了 符号Vue 不会把它当作事件绑定而是当成一个原生click属性直接忽略当然没反应。正确的应该是clickcurrentIndex index。再检查 Vue 实例有没有正确挂载。el: #app对应页面上的div idapp如果 HTML 里写的是classapp或者 el 选择器少写了 #Vue 会报错且整个列表都不会被渲染。还有一种情况是引入了 Vue 之后又在new Vue之前写错了脚本顺序导致Vue is not defined这类错误控制台会直接报出来顺着报错信息找最快。4.2 坑 2默认第一个 li 没有变色只写了:class{ active: index currentIndex }但data里忘记定义currentIndex: 0。这样currentIndex是 undefined0 undefined永远为 false所以第一个 li 不会有高亮。这个补上初始值就好了。另一种情况是根本没有用动态绑定而是直接写了classactive那所有 li 都会是红色不符合“默认第一个”的要求。判断动态类绑定是否生效可以先在浏览器 DevTools 里选中第一个 li看它的 class 属性里到底有没有active有就说明绑定生效没有就去查currentIndex的初始值。最后还要看一眼 CSS。常见问题是写了类似.item.active的样式但 li 上没有item类选择器匹配不上或者样式被其他权重更高的规则覆盖。前端调试样式问题时先确认类名有没有出现再看 CSS 规则能省很多冤枉时间。4.3 坑 3所有 li 同时高亮或者除了当前项其他项都高亮所有 li 同时高亮多半是把:class的条件写成了{ active: currentIndex }。在 JavaScript 里数字 0 是 falsy其他数字是 truthy所以这个条件只在初始状态成立或者完全不成立。正确写法必须把两个下标比较写清楚index currentIndex。至于“当前项没高亮、其他项高亮了”一般是把条件写成了index ! currentIndex这相当于把所有“不是当前项”的元素高亮。如果列表只有三项看起来就是“另外两项变色点中的这项不变”非常迷惑。一个简单的排查方法给非当前项的 li 临时加一个明显样式比如边框然后点击看看哪几项带边框基本一眼就能定位条件写反了。4.4 坑 4列表项动态增删之后高亮位置错乱这个坑在“示例1”这种静态数组里遇不到但一旦数据从接口来或者支持增删就会暴露。问题出在“下标”的语义上下标只在当前这一轮数组排序里稳定数组一变下标就变了。举例列表原本是[A, B, C]你选中了 BcurrentIndex是 1。这时删掉 A数组变成[B, C]B 的下标变成了 0但currentIndex仍然是 1高亮就落到了 C 身上。这显然不符合预期。遇到这种情况建议不要用下标当高亮状态的标识而是给每个列表项一个唯一的idcurrentIndex换成currentId判断条件改成item.id currentId。报名时用下标做v-for的:key也有同样隐患列表中间插入或删除元素时Vue 复用节点可能造成内容错位。稳妥做法是数据项带独立 id 时直接用:keyitem.id别偷懒用 index。5. 把“文字变色”扩展成真正能用的导航和Tab组件5.1 不止变色同一套逻辑控制边框、背景、字号这个示例用active类只改了文字颜色。实际业务里选中态往往是整套视觉变化颜色、边框、背景、阴影、加粗。别去写多个:class把条件都合并进一个对象就行:class{ is-active: index currentIndex, is-disabled: item.disabled }这里item.disabled是数组里某项的扩展字段表示这一项不可点。高亮逻辑仍然只有一个核心条件index currentIndex其他配置都是围绕它做叠加。如果还想控制更内联的样式比如动态设置某一项的字体粗细可以直接用:style:style{ fontWeight: index currentIndex ? bold : normal }能不用内联 style 就尽量不用维护性差。但有些场景确实适合它比如进度条、评分组件按索引计算宽度和颜色这时候对象式的:style非常顺手。5.2 从一个列表到一组页面Tab 内容切换“点击文字变色”再往前走一步就是 Tab 切换。顶部点一个标签下面内容跟着变。状态仍然是那一个currentIndex只需要加一个内容数组用v-show控制显示哪一块div idapp ul li v-for(item, index) in tabs :keyindex :class{ active: index currentIndex } clickcurrentIndex index {{ item }} /li /ul div v-for(content, index) in panes :keyindex v-showindex currentIndex {{ content }} /div /div这里v-show控制的是“内容区”显隐判断条件还是index currentIndex。你发现没有整个交互的本质从来没变过一个状态决定两个区域的样式和显隐。把这一点想通之后步骤条、走马灯、手风琴这些组件都是同一套思路的变体。5.3 把高亮逻辑收进一个组件外部只管传列表等你的列表在很多页面都要用到时就该把这段逻辑封装成组件了。比如做一个CustomNavMenu.vue内部维护currentIndex对外暴露选中的索引方便父组件做联动template ul li v-for(item, index) in items :keyindex :class{ active: index innerIndex } clickhandleClick(index) {{ item }} /li /ul /template script setup import { ref, watch } from vue const props defineProps({ items: { type: Array, required: true }, modelValue: { type: Number, default: 0 } }) const emit defineEmits([update:modelValue]) const innerIndex ref(props.modelValue) watch(() props.modelValue, (val) { innerIndex.value val }) function handleClick(index) { innerIndex.value index emit(update:modelValue, index) } /script父组件使用时直接绑定CustomNavMenu :itemsnavItems v-modelcurrentNavIndex update:modelValuehandleNavChange /这样父组件既能实时知道用户点到了第几项又能从外部强制修改高亮位置比如路由跳转后回到第一项。组件内部几百行的复杂菜单逻辑都被藏起来了外部使用者只需要维护一个currentNavIndex。再往真实业务走一步很多路由导航组件也是类似思路高亮状态不再是一个普通数字而是当前的路径地址判断条件变成“菜单项的路由地址要不要匹配当前路径”。比如官方路由库里的router-link-active、router-link-exact-active就是官方把“高亮”这个状态内置到组件行为里的例子。底层机制比这个示例复杂但心智模型没变有一个唯一的“当前状态”界面围绕它渲染。我在实际带人和写代码的时候对“示例1”这种入门题最看重的一点是代码交出来之后能不能把“状态存在哪、界面怎么跟着状态走”这个问题讲明白。只要把这句话刻进脑子里后面遇到 Tab 切换、菜单高亮、步骤条联动甚至表单步骤条里的当前步骤都会觉得似曾相识。切到组合式 API 之后我自己的习惯是给这类状态取一个能表达业务含义的名字比如activeTabIndex、currentStep而不是一概叫currentIndex。名字越贴近业务代码读起来越轻松。最后再分享一个调试小技巧遇到点击后样式没反应先别急着怀疑 Vue 的响应式坏了打开 Vue 的开发者工具找到组件的data盯着currentIndex的值。如果你点一下这个数字变了样式没变问题在类和 CSS如果这个数字根本没变问题在事件绑定。分开排查几分钟就能定位。