ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Vue 中 ==、===、、|| 的隐形坑与工程规范

Vue 中 ==、===、、|| 的隐形坑与工程规范 上周同事拿着一段代码来找我说v-if里的判断明明是对的页面上那块内容就是不出现。我打开一看写的是v-if$route.query.tab 1。地址栏里确实是?tab1但$route.query.tab是字符串1而又恰好做了类型转换——按说这个能过。真正的问题在于他另一处写的是两处逻辑不一致改了一处忘了另一处。这类问题在 Vue 项目里出现频率高得离谱因为它不像死循环或者空指针那样直接报错而是安安静静地给你一个错的结果你盯着屏幕看半天也看不出哪里不对。、、、||这四个东西是 JavaScript 里最基础的操作符基础到很多人觉得这有什么好讲的。但恰恰是这种没什么好讲的的东西在 Vue 的模板表达式、props 默认值、computed 计算属性、动态 class 拼接、路由参数判断这些场景里反复制造 bug。模板里的表达式和我们平时在.js文件里写的代码有一个本质区别它被编译过、被作用域包裹过、在某些版本里还被特殊解析过所以同一条表达式写在script里没问题写在template里就可能翻车。这篇东西我打算把自己这几年在 Vue 项目里踩过的、以及帮别人排查过的相关坑整理一遍从编译原理讲到具体配置从模板写法讲到代码评审清单。不管你是刚学 Vue 的新人还是已经写过几个完整项目的老手应该都能从中找到一两条自己中招过的写法。1. 这几个操作符凭什么值得单独拎出来讲1.1 模板表达式不是装饰它最终会变成可执行代码很多人对 Vue 模板有一种误解觉得template里的东西是一种配置或者标记语言跟 JavaScript 是两码事。实际上不是。Vue 的模板最终会被编译成渲染函数模板里的每一个表达式都会原封不动地变成 JavaScript 代码而且是被包裹在特定作用域里的 JavaScript 代码。你在模板里写{{ count 1 }}编译后大致会变成_toDisplayString(_ctx.count 1)这样的形式你写v-ifflag ready编译出来的是用这个表达式控制的三元运算。Vue 3 的编译器在生成代码的时候会尝试给每个标识符加上_ctx.前缀这个动作叫前缀分析。它靠词法扫描判断哪些是组件实例上的属性、哪些是局部变量、哪些是全局对象。一旦表达式复杂到它判断不了比如里面混了可选链、解构、复杂的成员访问它就会保守处理把整段表达式用with(_ctx)包起来。这个过程本身不会改变表达式的运算结果但它说明了一个事实模板表达式是真实的 JS 代码的隐式类型转换不会因为它写在模板里就消失。Vue 2 的情况更直白。Vue 2 的模板编译出来是with(this){ return ... }的形式也就是说模板里的a实际就是this.a。这个with带来两个后果一是所有表达式都在实例作用域里求值二是表达式里能直接访问的标识符范围比你想象的大得多。所以如果你在模板里写了个拼错的变量名它不会报未定义而是安静地返回undefined然后undefined null为真逻辑就悄悄跑偏了。理解了这一点后面的所有坑都顺理成章了模板表达式就是代码代码里的类型转换规则一条都不会少。1.2 三个真实翻车场景先建立问题感先说第一个场景这个问题我在三个不同的项目里都遇到过。项目用路由 query 做筛选状态地址是/list?type2代码里写v-if$route.query.type 2。打开页面一看筛选没生效。原因很简单路由 query 里的值不管你在地址栏写的是数字还是字母拿到的永远是字符串。2 2是false所以这个分支永远走不到。而2 2是true于是有人把改成就好了。这个好了是假象因为一旦 URL 变成?type0202 2依然是true而用户心里想的可能是另一个筛选值。正确做法是显式转换类型而不是依赖的宽容。第二个场景更隐蔽。组件有个 props 叫pageSize类型是Number父组件传了个0表示不分页全量加载。子组件里写const size this.pageSize || 20结果传0的时候0是假值||直接跳到20全量加载变成了每页 20 条。这个 bug 的可怕之处在于测试的时候父组件传的是正常数值一切正常只有走全量这条特殊路径的用户才会遇到。而且它不会报错只会让接口请求带上一个意料之外的参数。第三个场景关于假值判断。列表组件里写v-iflist想表达有数据才渲染结果空数组[]是真值列表空了之后各种奇怪的空状态依然会渲染出来。改成v-iflist.length才对。同样的写法出现在错误提示上v-iferrorMsg接口返回空字符串时确实不显示看起来没问题但当后端返回字符串0表示某种状态码时它又变成真值了。这三个场景有一个共同点代码都能跑没有报错逻辑在大多数情况下是对的只有边界值才会暴露问题。这类 bug 最消耗时间因为你的第一反应通常是是不是接口没返回是不是缓存问题是不是这个组件没挂载排查方向全错了。2. 和 的选择别拿能用就行当标准2.1 相等比较的底层规则拆解和的核心差别只有一个前者在两边类型不同的时候会先做类型转换再比较后者类型不同直接返回false。问题在于的转换规则相当复杂复杂到语言规范里专门有一张表来定义。我平时给别人讲这块会把最容易搞混的几条单独拎出来。null undefined是true但null 0是falseundefined 0也是false。这一条经常让人困惑因为直觉上undefined转成数字是NaNNaN 0当然是false但null转成数字应该是0才对为什么null 0是false原因是规范里规定null和undefined只互相相等不参与数字转换。所以if (x null)这个写法的语义其实是x 是 null 或者 undefined 中的任意一个而不是x 等于零。再看字符串和数字0 0是true 0也是true 0还是true。最后这条特别坑因为一个只包含空格的字符串会被转成0跟数字零相等。表单校验里如果用判断输入框的值是否等于零用户敲了一个空格进去就通过了。布尔值的转换更绕。true 1、false 0都是true但true 1也是true因为true先转成1再和1转成1比较。到了对象这里[] false是true因为空数组转成字符串是空字符串空字符串转成数字是0false转成数字也是0。这条经常出现在输入框有没有填内容的判断里如果值是空数组它等于false你以为在做存在性判断实际在做数值比较。我把这些整理成一张表方便对照表达式结果关键原因null undefinedtrue规范特例二者只互相相等null 0falsenull不参与数字转换0 0true字符串转数字后比较 0true空字符串转数字为 0 0true仅含空白的字符串转数字为 0[] falsetrue数组转字符串再到数字两边都是 0NaN NaNfalse唯一不等于自身的值Object.is(NaN, NaN)true同值相等可用来判 NaN-0 0true严格相等不区分正负零的规则简单得多类型必须一致然后比较值。对象和数组比较的是引用不是内容。[1,2] [1,2]永远是false因为它们是两个不同的对象。这一点在 Vue 里会引出一个单独的话题我在第 4 章展开讲。2.2 Vue 项目里最容易中招的四类相等判断第一类是路由参数。$route.query和$route.params里的值无论你在地址栏或者router.push里写的是数字还是布尔取出来都是字符串。写 1永远不成立写 1能成立但语义含糊。我自己的处理习惯是在router.push的时候就把参数统一成字符串判断时也用字符串比如route.query.tab 1两边约定一致比用兜着走要靠谱得多。如果确实需要数字就在拿到参数的位置做一次Number()转换把转换收敛到一个地方别散落在各个组件里。第二类是 props 默认值。Vue 的 props 默认值机制是只有传了undefined才用默认值也就是说父组件传null、传0、传默认值都不会生效。所以default: 20是可靠的兜底方式而const size props.pageSize || 20是不可靠的。这两种写法的差别在于前者只在真正没传的时候兜底后者会把所有假值都当成没传。我在代码评审里看到||出现在 props 相关的兜底逻辑里基本都会提出改成默认值配置。第三类是表单控件的值。原生输入框的value是字符串Element Plus 之类的组件库在没加.number修饰符或者没配置类型转换时v-model绑定的也是字符串。用户输入1你拿到1用 1判断必然失败。这个坑在分页跳转、数量校验、库存判断里特别多。我的做法是给数字类输入统一加.number或者在提交前用Number()归一化绝不在比较的时候临时补一个。第四类是接口返回的 ID。同一个 ID 字段有的接口返回数字有的返回字符串这在后端不算什么大事但前端如果写死了就会踩雷。比较两个来源的 ID 时我的习惯是统一String(id) String(targetId)把两边都转成字符串再比。这样既不依赖的隐式规则也不会因为后端某天改了返回类型就炸掉。我列一个选择标准出来你可以在团队里直接对着用场景推荐写法原因判断是 null 还是 undefinedx null语义明确一条搞定两种情况判断接口 ID 是否相等String(a) String(b)不依赖后端类型稳定性路由参数比较统一用字符串或者先Number()路由参数天然是字符串props 默认值用default配置项只有undefined才触发默认值表单数字比较先转换再控件值默认是字符串判断 NaNNumber.isNaN(x)或Object.is和都判不出 NaN2.3 我的项目里怎么落地 eqeqeq 规则上面这些道理讲一百遍不如让工具帮你拦一遍。ESLint 的eqeqeq规则就是干这个的。我现在的项目里基本都开着它配置大致是这样// .eslintrc.js module.exports { rules: { // script 部分除 null 比较外一律用 eqeqeq: [error, always, { null: ignore }], // template 部分需要插件专门支持 vue/eqeqeq: [error, always, { null: ignore }] } }这里有个细节很多人不知道核心的eqeqeq规则只作用于script里的代码模板里的表达式它是管不到的。要检查v-if、v-show、插值里写的得用 eslint-plugin-vue 提供的vue/eqeqeq。我第一次配的时候只加了核心规则结果模板里的一个都没被拦住排查了半天才发现是这个问题。如果你的项目还没装这个插件用vue-eslint-parser加上插件后模板表达式才会进入规则扫描范围。{ null: ignore }这个选项也是刻意留的口子。判断 null 和 undefined 的时候x null是公认的简洁写法比x null || x undefined好读所以我给它开了绿灯。但除了这一种情况其他任何地方出现我都要求改成并且显式处理类型转换。还有一点要提醒改了规则之后存量代码可能会冒出一大批报错。别一股脑全改改成是有可能改变运行时行为的特别是那些靠的类型转换侥幸工作的代码。我的做法是先跑一遍eslint --fix看看自动修了多少剩下的人工逐条确认重点看两边类型是否可能不一致。急着批量替换很容易把一个正常运行的功能改成永远进不去的分支。3. 与 || 的短路糖和雷只隔一层窗户纸3.1 短路的本质返回的是操作数不是布尔值和||最容易被误解的一点是它们返回的不是true或false而是参与运算的某个操作数本身。a || b的规则是如果a是真值就返回a否则返回b。a b的规则是如果a是假值就返回a否则返回b。理解了这一点很多现象就解释得通了。0 || default返回的是defaulthello || default返回的是hellonull null.name返回的是null而不是报错——因为在求null.name之前就已经短路了。这个短路特性是最有用的地方也是它在 Vue 模板里常见的写法来源。因为返回的是操作数而不是布尔值所以把或者||的结果直接绑到属性上就可能出现意料之外的值。比如:titleuser user.name当user是null的时候这个属性的值就是null而不是空字符串。在 Vue 的 class 绑定里这倒没什么问题因为 Vue 在归一化 class 的时候会过滤掉假值但如果是:data-idid id.toString()id为0的时候>computed: { pageSize() { // 传 0 表示不分页但这里会被替换成 20 return this.config.pageSize || 20 } }正确做法是用空值合并操作符??它只在左边是null或者undefined的时候才返回右边computed: { pageSize() { // 只有没配置的时候才用 20配置成 0 会原样保留 return this.config.pageSize ?? 20 } }??和||的区别就在这儿||判断的是假值??判断的是空值。凡是数字、字符串、布尔值可能取到假值的场景都应该考虑用??。反过来如果业务上就是要把空字符串也当成没填来处理比如用户名没填就显示游客那||反而是对的因为它替你把空字符串一起兜了。这里还牵扯到一个 Vue 特有的坑布尔类型的 props。Vue 对声明为Boolean类型的 prop 有一套特殊的值转换规则。如果你在模板里写my-comp disabled也就是不写值只写属性名Vue 会把它转成true如果你写my-comp disabled也是true但如果你写my-comp disabledfalse它就变成了字符串false而字符串false是真值所以组件内部的v-ifdisabled会成立组件以为自己被禁用了。这个坑的解法只有一个布尔属性永远用v-bind绑定写:disabledfalse让 Vue 拿到真正的布尔值。我在评审的时候只要看到布尔属性写成disabledfalse这种形式一律要求改掉。再补一个相关的坑v-if里判断字符串。有错误就显示提示这个需求如果写成v-iferrorMsg当后端返回的errorMsg是字符串0时它是真值提示会显示出来这可能是对的也可能是错的取决于0在后端语义里代表什么。如果0本身就是一种需要展示的状态那没问题如果0表示无错误那就翻车了。所以v-if里的表达式尽量用显式比较v-iferrorMsg ! 或者v-iferrorCode ! 0把意图写清楚别依赖真值假值的隐式判断。3.3 模板里的 写法与 v-if / 三元的取舍在模板里最常见的用途是动态 class。比如div :class[isActive is-active, size large is-large]这个写法能成立是因为 Vue 在处理 class 绑定的时候会遍历数组、过滤掉假值所以当isActive为false时数组里的那一项是false会被丢掉不会变成一个叫 false 的类名。这一点很多人不知道担心会拼出classfalse其实不会。但同样的写法用在别的属性上就不一定安全了。:styleisActive styleObj在isActive为false时值是falseVue 的 style 归一化也会过滤假值所以没问题。但如果你把它用在自定义组件的 props 上:configisActive configObj那false会原样传进去组件的 props 类型校验会报 warning。这种情况下用三元更稳妥:configisActive ? configObj : {}。那什么时候用什么时候用三元我的判断标准是看另一个分支要不要有值。如果某个分支的结果就是什么都不加用更简洁如果两个分支都需要具体的值用三元更清楚。像:classhasBorder border这种只有一个有效分支的场景读起来最顺而:classhasBorder ? border : no-border这种两边都有意义的就别硬用绕。还有一个容易忽略的点用在v-if里的时候其实可以拆开写成多个v-if但没必要。v-ifuser user.isAdmin !user.isBanned这种链式判断Vue 编译出来就是一个三元表达式里套着的逻辑与短路是从左往右的user为空时后面的成员访问不会执行所以不用担心报错。但如果是v-ifuser.info.nameuser为空时就会直接抛错因为这里没有短路保护。这也是为什么很多人在模板里写v-ifuser user.info user.info.name虽然啰嗦但确实是有效的防护。Vue 3 的项目里可以用可选链简化成v-ifuser?.info?.name读起来清爽得多前提是你的构建链路支持。4. 响应式数据做比较为什么 有时看着像失效了4.1 Proxy 与 defineProperty 下的取值行为Vue 3 用 Proxy 实现响应式Vue 2 用Object.defineProperty。这两种实现对取值这件事的处理方式不同直接影响到比较结果。Vue 3 的reactive()内部维护了一张代理缓存表同一个原始对象反复访问返回的是同一个 Proxy 实例。也就是说import { reactive, toRaw } from vue const raw { count: 0 } const state reactive(raw) console.log(state state) // true同一个代理对象 console.log(state.count state.count) // true原始值比较 console.log(state raw) // false代理和原始对象不是一回事 console.log(toRaw(state) raw) // true用 toRaw 拿回原始对象最后两行是重点。如果你写state raw永远是false因为代理对象和原始对象是两个不同的引用。这在做缓存判断、外部状态同步的时候会踩坑。如果需要拿到原始对象来比较用toRaw()转换一下。我在做判断两份数据是不是同一份这类逻辑时会先把两边都toRaw再比引用避免被代理层干扰。还有一种情况是ref和.value。ref返回的是一个带.value属性的包装对象在script setup里必须写count.value 0写成count 0永远是false因为你在拿包装对象和数字比较。但在模板里Vue 会自动解包写{{ count 0 }}是能正常工作的。这种script 里要.value、template 里不要的差异是新手最容易搞混的地方之一。我的经验是只要出现模板里对、方法里不对的诡异现象先检查是不是漏了.value。4.2 对象数组比较的实战姿势与 watch 的配合对象和数组的比较是引用比较内容一样但引用不同就不相等。这个规则本身简单麻烦的是它在 computed 和 watch 里造成的影响。先说 computed。下面这段代码很常见computed: { activeList() { return this.list.filter(item item.status 1) } }如果list变了activeList每次都会返回一个全新的数组哪怕过滤出来的内容完全一样引用也是新的。这时候如果有watch监听activeList就会触发回调如果把它作为 props 传给子组件子组件也会收到变化的通知。想避免这种情况要么把比较逻辑改成内容比较要么在 watch 里自己做判断。内容比较的写法有这么几种。最简单的是JSON.stringifyconst isSame JSON.stringify(a) JSON.stringify(b)它的优点是写起来快缺点是依赖属性顺序且遇到undefined、函数、循环引用会失效。第二种是浅比较只比第一层的键值function shallowEqual(a, b) { if (a b) return true if (typeof a ! object || typeof b ! object || a null || b null) return false const keysA Object.keys(a) const keysB Object.keys(b) if (keysA.length ! keysB.length) return false return keysA.every(key a[key] b[key]) }这个函数在处理 props 变化时特别有用因为 Vue 本身的 props 更新判断就是浅比较的思路理解它有助于你知道为什么改了这个 prop 子组件没重新渲染。如果比较的是数组还有个更简单的办法把数组转成有序的字符串再比但前提是元素本身是可序列化的原始值。数组里放对象的时候这个办法就不靠谱了。我的习惯是数组元素是 ID 之类的原始值时用join()或者逐项元素是对象时老老实实写比较函数别图省事。顺带说一句watch的配置。默认情况下watch是浅比较的监听一个对象时只有对象引用整体被替换才会触发回调要监听内部属性变化得加deep: true。加了 deep 之后Vue 会递归遍历对象建立依赖大对象上会有性能开销。我一般会评估一下对象规模几百个字段以内问题不大上万条的数据列表就不要开 deep 了改成监听具体的字段更合适。5. 从配置到评审把操作符规范真正落到项目里5.1 ESLint、编辑器与构建链路的配置清单规范写在文档里没人看写进工具才有约束力。我这边的一套配置按重要性排下来大致是这样。第一层是 ESLint 规则。除了前面提到的eqeqeq和vue/eqeqeq还有几条值得一起开// .eslintrc.js module.exports { rules: { eqeqeq: [error, always, { null: ignore }], vue/eqeqeq: [error, always, { null: ignore }], no-unused-expressions: [error, { allowShortCircuit: true, // 允许 a b() 这种写法 allowTernary: true }], no-constant-binary-expression: error } }最后一条no-constant-binary-expression可能有人没听过它能拦住a || true、x x这类永远成立或者永远不成立的表达式。这类代码一般出现在重构之后逻辑被删了但残留了操作符看着还挺像回事实际上分支永远走同一个方向。我上个月就在一个老项目里发现了三个这样的残留都是改需求时留下的。第二层是编辑器。VSCode 里装好 ESLint 插件把eslint.validate配置成包含vue类型这样打开.vue文件就能实时看到模板里的波浪线。很多人装完插件发现只检查 script 不检查 template就是这里没配。另外把editor.codeActionsOnSave打开保存时自动修复能一键处理掉大部分格式问题。第三层是构建和版本适配。如果你的项目要兼容较老的浏览器??和?.需要经过 Babel 或者 esbuild 降级确认你的构建配置里这些语法在转换列表内。Vue 3 的模板表达式会被编译进渲染函数跟随构建流程走所以一般能正常降级。Vue 2 的老项目里写可选链要谨慎模板编译和后处理链路不一定覆盖稳妥的办法是在computed里把数据先处理干净模板里只做简单取值。我建议在项目的 README 或者团队文档里放一张能用的语法清单明确写出哪些语法可以直接用、哪些要走构建配置、哪些不要用。这份清单不用长十行以内但能省掉大量为什么张三写能跑李四写就报错的沟通成本。5.2 代码评审清单与常见问题速查表工具能拦住的都是小问题真正难搞的是那些语法正确但语义有问题的写法。我在评审的时候会重点扫这几类。看到 props 相关的兜底逻辑检查是不是用了||。看到路由或表单参数直接和数字比较检查类型是否匹配。看到v-if里判断长度、计数、状态码检查是否考虑过零值。看到布尔属性写成不带冒号的形式一律要求加v-bind。看到computed返回新对象又有watch监听检查是否会重复触发。我把常见问题和处理方式整理成一张表遇到的时候可以对照着查现象可能原因处理方式路由参数比较永远不成立query 参数是字符串统一字符串比较或先Number()props 传 0 但组件用了默认值表单数字判断失败控件值为字符串加.number或提交前转换布尔属性传false却生效字符串false是真值改用:propfalse空数组仍然渲染列表[]是真值判断list.length两个 ID 明明一样却不相等一边数字一边字符串String(a) String(b)computed 老是重复触发每次返回新引用内容比较或浅比较模板里变量拼错不报错作用域解析返回 undefined开启模板类型检查或仔细核对这张表我放在项目 wiki 里新人进来第一天就能看到比口头交代管用得多。6. 排查实录几个我亲手处理过的线上问题6.1 路由参数比较永远为 false 的排查链条这个问题的排查过程挺有代表性我完整复盘一下。现象是从列表页点进详情页详情页的某个 Tab 没有默认选中。代码是这样的mounted() { if (this.$route.query.tab 1) { this.activeTab detail } else if (this.$route.query.tab 2) { this.activeTab log } }我的排查顺序是这样的。第一步在mounted里打日志打印this.$route.query.tab和typeof this.$route.query.tab看到输出是1和string。问题立刻定位了1 1是false两个分支都不成立。第二步判断是改比较方式还是改传参方式。如果只改这一处改成 1能解决问题但项目里还有七八处类似用法逐处改容易漏。所以我选择在router.push的地方统一处理跳转时把参数转成字符串再传同时在详情页用Number()转换一次两边都做明确处理。第三步加一道防线。在computed里把 tab 的计算封装起来computed: { tabIndex() { const raw this.$route.query.tab const num Number(raw) return Number.isNaN(num) ? 0 : num } }这样组件内部只依赖tabIndex这个数字所有类型转换集中在一个地方既好测也好改。这个案例的教训是路由参数的类型问题应该在入口处解决不要让它渗透到各个业务组件里。只要有一处用兜着其他地方的错误就会被掩盖等到某天有人把改成一堆问题同时冒出来。6.2 打包后行为看起来变了的定位思路还有一种情况比较吓人开发环境一切正常打包上线之后行为变了。遇到这种问题第一反应往往是是不是压缩把代码压坏了是不是样式被覆盖了。样式方面的原因确实常见但如果是逻辑变了我一般先确认压缩有没有改变短路语义。结论是不会。压缩工具只改变量名和空白不会改变、||的求值顺序。但是有两个例外值得注意。第一个是副作用被消除。有些打包工具在开启激进优化时会判断某个表达式的返回值没被使用而删掉它。如果你写了condition doSomething()这种依赖短路触发副作用的写法理论上存在被误判的风险。我遇到的实际情况是某次配置调整后模板里clickhandler handler()这种写法触发了 lint 警告改成了在方法内部判断更安全。第二个是环境变量引起的分支差异。process.env.NODE_ENV production这类判断在构建时会被静态替换替换之后可能整个分支被裁掉导致某些调试代码在生产包里消失了。这个行为是设计如此不是 bug但如果你的逻辑依赖了某个只在开发环境存在的值就会表现出打包后行为变了。排查这类问题的顺序我建议是这样先确认是不是纯样式问题把生产的 CSS 和本地对比再确认是不是环境变量导致的分支差异搜一遍代码里的process.env最后才怀疑构建产物。顺序反了的话很容易在构建配置里瞎折腾半天最后发现是个.bordered 的嵌套写错了。我个人在实际项目里最深的体会是和的选择表面上是风格问题实际上是在替未来的自己省事。用的时候你会被迫去想清楚两边的类型到底是什么这个思考过程本身就能拦住一大批 bug。而和||用得顺不顺手很大程度上取决于你有没有建立起返回的是操作数不是布尔值这个意识一旦建立起来什么时候用??、什么时候干脆写清楚 if判断起来就快多了。
返回列表