上周前端群里有人甩了一张截图购物车列表里库存字段是数字 0 的商品卡片上显示的是暂无库存。写这行的人一脸无辜因为他写的是{{ item.stock || 暂无库存 }}逻辑看着挺顺——没值就给兜底文案。问题就出在这个没值上在||的世界里数字 0 和空字符串、布尔 false 一样都被归进了假值。这类问题在 Vue 项目里出现频率极高因为 Vue 把 JavaScript 的表达式原封不动搬进了模板、、、||、??、?.在模板里的行为和你在.js里写的完全一致不会因为套了一层{{ }}就变得安全。这篇就把这几个操作符在 Vue 场景下的真实行为、常见翻车点和落地写法捋一遍不管你是刚上手 Vue 的新人还是写了几年依然靠感觉写的老手都能对照着改点东西。1. 从库存 0 却显示暂无说起模板里与的真实差异1.1 模板表达式最终被编译成了什么要理解这些操作符的行为得先知道模板里的表达式去了哪。Vue 2 的模板编译产物大致是with(this){ return _c(div, null, [_v(_s(item.stock || 暂无库存))]) }也就是说你写在{{ }}里的内容会被原样塞进一个with块再由new Function生成函数执行。Vue 3 在默认的 SFC 预编译模式下换了个思路把标识符重写成_ctx.xxx产物更像_createElementVNode(span, null, _toDisplayString(_ctx.item.stock || 暂无库存), 1)但表达式的运算部分没变——还是交给 JavaScript 引擎按官方规范跑。这件事的第一个推论是不存在Vue 特有的比较规则。你在.js里0 0是 true模板里就是 true你在.js里NaN NaN是 false模板里同样是 false。很多人改不动模板里的类型 bug本质上不是 Vue 的问题是没意识到模板就是 JS。第二个推论是模板表达式是可以被 lint 检查到的。只要项目里用的是vue-eslint-parsereslint-plugin-vue的推荐配置里默认带上{{ }}和指令值里的表达式都会被解析成 ASTeqeqeq这类基础规则同样生效。我见过不少团队只在.js文件上开了严格规则模板里照样满屏其实打开配置就能一起管住下面第 5 节会给具体配置。第三个推论和后面的??、?.有关Vue 3 的模板编译有一份全局白名单Math、Number、Object、JSON、Date、Array、console这些都在里面所以Object.is(a, b)直接写在模板里没问题而window、localStorage或者你挂在全局上的工具函数不在白名单写进模板会在开发环境抛出被访问的属性没有定义在实例上的警告。这条约束决定了有些比较逻辑只能挪进computed或方法里不能硬塞进模板。1.2 抽象相等比较的完整转换链路的官方名字叫抽象相等比较它的转换规则写在规范里一共就那么几条但组合起来足够让人头大。核心链路是这样两边类型相同就直接比这时候等同于类型不同先看是不是null和undefined相遇是就返回 true而且null、undefined都不与其它任何值相等接着布尔值先转数字字符串与数字比较时字符串转数字最后一边是对象、一边是原始值时对象走ToPrimitive先调valueOf拿不到原始值再调toString数组的toString就是join(,)。把这套规则摊开成表翻车点一目了然表达式结果关键转换步骤0 true空字符串转数字得 00 0true字符串 0 转数字得 0 falsetruefalse 转 0 也转 01 truetruetrue 转 11 转 12 truefalsetrue 只转成 1,不等于 2null undefinedtrue规范里的特例分支null 0falsenull 不与 undefined 之外的值相等[] true[]的 toString 是空串[1, 2] 1,2true数组 toString 得到 1,2NaN NaNfalseNaN 不等于任何值包括自己{} {}false两个字面量是不同引用的规则简单到可以用一句话概括类型不同直接 false类型相同再比值唯一的例外是NaN它和谁都不相等。顺带提一个更严格的比较Object.is与只在两种情况上不同Object.is(NaN, NaN)是 trueObject.is(-0, 0)是 false。日常业务里前者的差别几乎用不上但涉及NaN 是否算变化的判断时会救命——比如把 undefined 和 NaN 区分开的缓存逻辑。我的实际经验是除了 null这个刻意用来同时判断 null 和 undefined 的写法其它场景一律用。原因不是教条而是的收益几乎为零省几个字符成本却是每次读代码都要在脑子里跑一遍转换规则。真实项目里我唯一保留的地方就是if (value null)和v-ifitem.detail null写起来短语义清楚还不受0、干扰。1.3 三个在 Vue 项目里真会翻车的场景场景一路由参数永远是字符串。详情页路由/order/:id你从route.params.id拿到的是字符串1024。如果页面里有if (route.params.id 0)判断是不是新建模式这行永远不成立。正确做法是在computed里做一次显式转换const orderId computed(() { const raw route.params.id // 显式转换不做隐式比较 const id Number(raw) return Number.isNaN(id) ? null : id })这里用Number()而不是parseInt()是因为parseInt(12abc)会给你 12而Number(12abc)给你 NaN后者更容易在数据异常时暴露问题。同理route.query.page、route.query.status全是字符串用作el-select的v-model时一定要和选项的value类型对齐否则选中状态出不来——这是组件库里最高频的类型投诉之一。场景二接口返回的枚举和前端常量类型不一致。后端返回1前端写的statusMap用数字1做 keystatusMap[status]取不到页面显示空白。这种问题的排查链路通常是这样先确认接口原始返回看 Network 的 Response别信 console.log 的对象展开再确认本地常量定义最后确认比较处用的是还是对象取值时的隐式转换。对象取键时{1: x}[1]是能取到的因为属性名会被转成字符串所以这类 bug 往往只在判断的地方暴露迷惑性很强。场景三本地存储读出来全是字符串。用uni.setStorageSync(pageSize, 20)存了数字读出来却是20。如果代码里写if (pageSize 20)直接失效。更稳的做法是存的时候JSON.stringify读的时候JSON.parse加 try/catch或者统一在读取工具里做类型归一。我习惯在项目里放一个storage.tsexport function readNumber(key: string, fallback: number): number { try { const parsed JSON.parse(localStorage.getItem(key) ?? null) return typeof parsed number Number.isFinite(parsed) ? parsed : fallback } catch { return fallback } }注意这里typeof parsed number而不是parsed fallback之类的比较——判断类型的场景就该用类型判断比较符解决不了。2.与||在渲染条件里的边界2.1v-if安全插值不安全和||在 Vue 模板里最常见的用法是条件渲染。有一点必须分清楚v-if、v-show接受的是布尔上下文Vue 会对结果做真值判断所以v-iflist.length list[0].name在list为空时只会拿到0然后转成 false不会渲染出多余的字符。但插值{{ }}不一样它把表达式结果转成字符串展示{{ list.length list[0].name }}在空数组时会实实在在渲染出一个0。这个差异导致的经典现象是页面上莫名多出一个0或NaN。排查思路很固定搜索模板里的插值写法尤其是像{{ count 条 }}、{{ obj.value obj.value.text }}这种。修复方式有三种按场景选只是要显示或不显示换成v-if包一层别用插值短路。真的要条件输出文本写成三元{{ list.length ?${list.length} 条: }}返回值可控。逻辑偏复杂直接抽到computed里返回字符串模板只负责展示。第三种是我现在的主力写法。模板里保留的是最终要展示什么而不是怎么算出来好处是模板可读性高逻辑还能单测。别小看这一点一个 30 行的模板里塞三个加两个三元维护成本会成倍上涨。2.2||做默认值时被吃掉的0、和false回到开头那个 bug。||的判定标准是假值JavaScript 的假值一共六个false、0、-0、0n、、null、undefined、NaN严谨说document.all也算但业务里可以忽略。也就是说任何一个字段如果合法取值包括 0 或空字符串就不该用||兜底。const stock 0 const discount 0 const remark stock || 暂无库存 // 暂无库存错了 discount || 1 // 1把没有折扣改成了打一折 remark || 无备注 // 无备注但空字符串可能是有意义的用户明确没填替换成??后只有null和undefined会触发兜底0 和空串都能保留原样。这也解释了为什么现在的新项目我会把默认值这个词分成两类来写缺失兜底用??业务规则兜底用显式判断。比如分页大小params.pageSize ?? 10是缺失兜底而折扣为 0 时展示原价是业务规则得写成discount 0 ? ... : ...用操作符硬兜会把业务语义搞混。这里还有个容易被忽略的点props的数字默认值。如果组件定义时写了default: 1而父组件传了 0props.step || 1同样会变成 1。Vue 3 里既然能用withDefaults声明默认值就不要再在模板里用||兜一遍两层兜底叠加会让人搞不清到底哪层生效const props withDefaults(defineProps{ step?: number label?: string }(), { step: 1, label: }) // 模板里直接用 props.step不要写 props.step || 1label这种字符串字段更要小心写label || 默认标题会让父组件明确传空字符串想让标题为空的需求没法实现。这是组件设计层面的事比单纯的语法问题更值得在评审里讨论。2.3 短路求值用在事件和副作用上的注意事项clickcanSubmit submit()这种写法很常见它能跑通因为 Vue 的模板编译器会把不带;的内联语句包成$event { canSubmit submit() }。但它有两个隐患一是返回值被丢弃语义上不如clickcanSubmit submit()改为clickonSubmit加方法内判断清晰二是和if混用时容易写成表达式语句触发no-unused-expressions之类的 lint 报错评审时也容易被人误认为漏写了判断。真正需要警惕的是优先级。的优先级高于||所以a || b c等价于a || (b c)三元运算符优先级更低a || b ? x : y等价于(a || b) ? x : y。这类优先级问题在模板里比在 JS 里更危险因为模板的格式自由度高换行缩进会给人错误的心理暗示!-- 读起来像 (a || b) c实际是 a || (b c) -- div v-ifisAdmin || isOwner hasPermission.../div !-- 加括号别让人猜 -- div v-ifisAdmin || (isOwner hasPermission).../div我的习惯是只要模板里的条件超过两个操作数就加括号或者抽成computed。团队评审时看到没有括号的三操作数条件一律打回。这不是苛刻是因为半年后回来看这段代码的人包括你自己那时候你不想重新推一遍优先级。另外提醒一句v-if与v-for同用的老问题Vue 2 里v-for优先级更高Vue 3 里v-if优先级更高。如果条件里依赖v-for的循环变量在两个大版本下行为不同写v-ifitem.visible item.enabled这种写法在 Vue 3 会因为拿不到item直接报错。稳妥做法永远是用computed先把列表过滤好模板里只留v-for。3. 空值兜底的三方分工??、?.与||3.1??的判定范围比||窄得多??全称空值合并运算符只在左侧是null或undefined时取右侧其它一切值包括0、、false、NaN都原样返回。把常见值和两种写法摆一起对比差异非常直观const cases [0, , false, NaN, null, undefined] cases.map((v) v || D) // [D, D, D, D, D, D] cases.map((v) v ?? D) // [0, , false, NaN, D, D]所以判断标准很简单只要这个字段的合法值可能是 0、空串或 false就必须用??。数字类字段数量、金额、页码、库存、折扣、排序索引、布尔类字段开关、是否默认、是否允许全都是??的地盘。反过来说如果字段本身语义上就是要么有值要么没有比如用户昵称、备注、可选的图片地址??更安全||在这里没有额外好处。我现在写新代码基本默认用??只有在明确希望空串也走兜底时才写||比如const keyword input.trim() || undefined这里空串转成 undefined 是有意为之语义上就是没输入就当没传。NaN是个特别的坑NaN ?? D返回NaN不会兜底。如果数据来自Number()转换失败NaN 会一路带着走最后在页面上显示成NaN。所以数字字段的兜底通常是两道先Number()转再判Number.isFinite两个都过了才算有效值别指望??帮你挡住 NaN。3.2??与||、混用必须加括号规范里有一条硬性规定??不能和||、直接混用而不加括号否则是语法错误。这个设计其实是保护你——因为a || b ?? c的意图根本不明确编译器干脆不让你写。// 语法错误Unexpected token ?? const a flag || config ?? defaultConfig // 明确意图的两种写法 const a (flag || config) ?? defaultConfig // 先或再兜底 const b flag || (config ?? defaultConfig) // 先兜底再或这个报错最容易在重构时出现。把老的const size opt.size || opt.size2 || 10改成??的过程中很容易写成opt.size ?? opt.size2 || 10然后构建直接挂掉。改的时候建议一次只动一个操作符改完跑一遍构建别一口气全替换。还有一点要注意??和||这两个赋值运算符也有同样的差异。obj.count || 10会在count是 0 时覆盖成 10而obj.count ?? 10只在count是 null 或 undefined 时才赋值。写状态初始化时??通常是更想要的那个。3.3?.在 Vue 2 与 Vue 3 模板里的可用性差异可选链?.解决的是深层取值层层判空的问题user?.profile?.avatar比user user.profile user.profile.avatar短得多也不容易漏掉中间一层。在 Vue 3 的模板里?.、??都能正常编译因为 SFC 编译阶段就把模板转成了标准 JS 表达式剩下的交给打包工具和运行时。Vue 2.7 也把这块能力补齐了因为它把模板编译器升级到了和 Vue 3 同源的版本。麻烦集中在 Vue 2.6 及更早的版本。那时候模板表达式是塞进with(this)里由浏览器执行的现代浏览器本身认识?.所以很多时候能跑但项目里的构建链如果还挂着老的语法解析器或者要兼容旧内核就可能在这一步报错。这种情况下不建议硬扛直接把判空挪进computed// Vue 2.6 项目里的稳妥写法 export default { computed: { avatar() { const profile this.user this.user.profile return (profile profile.avatar) || } } }这里最后一个|| 是故意的头像的兜底语义是没有就用空字符串走默认头像用||比??表达更贴合不过严格说profile.avatar若为 0 不会有问题因为头像只会是字符串或 undefined两种写法在这里等价。判断该用哪个操作符的标准还是那条字段的合法值范围决定兜底方式。用 TypeScript 的项目多一层保障。给可选字段标注?之后strictNullChecks打开时不判空直接访问会编译报错很多?.缺失的问题在写代码阶段就被拦住了。这也是为什么我建议 Vue 3 项目默认上 TS把运行时才发现 undefined的问题提前到编辑器里。4. 响应式体系里的比较引用相等、computed 与 watch4.1在对象上为什么救不了你以及 watch 为什么不触发基础类型用比较值对象和数组比较的是引用地址。{ a: 1 } { a: 1 }永远是 false。这在 Vue 里的直接后果就是watch的行为经常不符合直觉const form reactive({ name: , age: 0 }) // 只监听引用form 整体被替换才触发 watch(form, (val, oldVal) { // 深度监听时 val 和 oldVal 是同一个引用oldVal 拿不到快照 }) // 深度监听内部任意字段变化都触发 watch(form, () { /* ... */ }, { deep: true })两个坑都在上面这段里。第一直接watch(form, ...)不会响应内部字段变化因为响应式代理对象的引用没变。第二开了deep: true之后回调里的oldVal和val指向同一个对象你想做新旧值对比是做不了的。这个限制在 Vue 3 的文档里写得很清楚但每年都有新人踩。解决方法是新值在回调里自己深拷贝一份或者用watch(() ({ ...form }))这种返回新对象的形式——不过注意后者每次依赖变化都会构造新对象oldVal能拿到上一个快照代价是多一次浅拷贝。另一个高频场景是v-for的 key。用数组索引或者对象引用做 key在列表重排时会出现状态错乱本质也是引用比较的问题。key 必须是稳定且唯一的基础类型值通常是业务 id。4.2 computed 的依赖追踪不受操作符影响但返回值会影响下游computed的缓存机制靠的是响应式依赖追踪getter 执行时用到哪些响应式属性就订阅哪些依赖变了才重新求值。这个过程和你在 getter 里用还是没有任何关系。但getter 返回值的类型和身份会影响下游// 返回布尔值下游 v-if 和 watch 都稳定 const isEmpty computed(() list.value.length 0) // 每次返回新数组下游 watch非 deep每次都会触发 const visibleList computed(() list.value.filter((i) i.visible)) // 用 把数字和字符串当同一种可能漏掉真实变化 const sameSize computed(() String(limit.value) String(size.value))第三个例子值得细说。如果limit和size一个是数字一个是字符串用比较会让类型变了但值看起来一样的情况被判定为相等于是这个 computed 的值不变监听它的watch不会触发你就错过了数据来源类型切换这个事件。很多切换筛选条件后列表没刷新的 bug 都属于这一类的变体。所以比较逻辑里我倾向于让类型差异显式暴露出来不相等就不相等该触发触发该刷新刷新。还有一个容易忽略的写法问题computed里如果返回的是构造出来的新对象或者新数组任何一次重新求值都会让下游的浅比较认为变了从而触发额外的渲染或副作用。要避免的话要么让 computed 返回基础类型布尔、字符串、数字要么在返回数组时保证内容不变就不重建或者下游用deep/ 自定义比较来兜。4.3 表单校验和找选中项时该选哪种比较indexOf用的是includes用的是 SameValueZero两者在NaN和-0上的表现不同方法比较算法NaN能否找到-0与0是否相等indexOf严格相等不能相等lastIndexOf严格相等不能相等includesSameValueZero能相等Set/Map的键SameValueZero能相等findIndex回调由你写的比较决定看你写法看你写法这张表的实际价值在查找选中项的场景。表格多选时判断一个 id 是否在选中集合里如果 id 可能是NaN比如数据源里有异常值indexOf会返回 -1includes能找到。还有Set的has也是 SameValueZero所以用Set存选中 id 比数组 indexOf更稳顺便还快——数据量大时这是真实的性能差异几百行表格的多选判断下能明显感觉到。数量校验场景则要注意NaN的比较陷阱。Number.isNaN(x)才是正确的判断方式全局的isNaN(abc)会先做类型转换返回 true把字符串误判成 NaN。校验输入必须是数字时正确写法是typeof x number !Number.isNaN(x)两步都不能少。5. 把这些规则固化到项目里lint、评审和可抄的写法5.1 ESLint 与 TypeScript 配置的具体做法规则靠人记是靠不住的交给工具。ESLint 的扁平配置下一份能直接用的配置大致是这样// eslint.config.js import pluginVue from eslint-plugin-vue import tseslint from typescript-eslint export default [ ...pluginVue.configs[flat/recommended], ...tseslint.configs.recommended, { rules: { // 允许 x null 这一种用来同时判空 null 和 undefined 的写法 eqeqeq: [error, always, { null: ignore }], // 模板里的表达式同样受管 vue/eqeqeq: [error, always, { null: ignore }], // 拦住 a b() 这种被丢弃返回值的表达式语句 no-unused-expressions: [error, { allowShortCircuit: false }], // 强制可选链和空值合并的使用场景 typescript-eslint/prefer-nullish-coalescing: warn, typescript-eslint/prefer-optional-chain: warn } } ]几个取舍要说明。allowShortCircuit: false会拦住flag doSomething()这种写法有人觉得它太严但正是这类写法在模板和逻辑里制造了大量隐性分支我倾向于禁掉改成if。prefer-nullish-coalescing我设为 warn 而不是 error因为它无法判断业务语义有些地方确实需要||的空串兜底行为全强制会逼出无意义的eslint-disable。strict-boolean-expressions这类更激进的规则我没放进默认配置它会把if (list.length)都判为不合规噪音太大适合成熟度很高的团队单独讨论。TypeScript 侧的配置关键是strict: true其中strictNullChecks直接决定了?.和??的必要性——开了这个不判空访问可选属性会直接编译报错比任何 lint 规则都有效。5.2 我在 code review 时一定会看的几行评审模板和逻辑代码时有几个模式我看到就会停下来确认插值里出现或单个变量尤其是在列表内部确认会不会渲染出0。||后面跟着数字或空字符串兜底问一句这个字段的合法值有没有 0 或空串。v-ifcount这种直接拿数字当条件的写法0 会被当成隐藏。 0或! 0出现在路由参数、本地存储、组件库取值之后确认前面做过类型转换。watch监听对象或数组但没有deep确认是不是想监听内部变化。deep: true的回调里用了oldVal确认知不知道该限制。三个以上操作数且没有括号的条件表达式要求加括号或抽computed。这份清单不复杂但能挡掉我在实际项目里见过的大部分相关 bug。团队里跑一段时间后前三条基本会绝迹。5.3 可以直接抄进项目的工具函数与其在每个组件里重复判空不如集中几个工具函数让调用方语义清晰// utils/compare.ts /** 只把 null / undefined 视为缺失 */ export function isNil(v: unknown): v is null | undefined { return v null || v undefined } /** 缺失时兜底与 || 不同0、、false 都会被保留 */ export function fallbackT(v: T | null | undefined, defaultValue: T): T { return isNil(v) ? defaultValue : v } /** 展示层兜底空白字符串也走默认文案 */ export function displayText(v: unknown, placeholder -): string { if (isNil(v) || v ) return placeholder return String(v) } /** 数字归一转不动就返回 null交给调用方决定兜底 */ export function toNumber(v: unknown): number | null { if (typeof v number) return Number.isFinite(v) ? v : null if (typeof v string v.trim() ! ) { const n Number(v) return Number.isFinite(n) ? n : null } return null } /** id 比较两侧统一转字符串再严格比较 */ export function sameId(a: unknown, b: unknown): boolean { if (isNil(a) || isNil(b)) return false return String(a) String(b) }这几个函数的边界划分是我踩坑之后定下来的fallback只管缺失displayText才管展示层的空串两者不要混用。toNumber返回 null 而不是 0是为了让调用方明确知道转换失败和值就是 0的区别——这一点在处理接口数据时特别重要把非法值静默成 0出了问题很难查。sameId用字符串比较是因为前后端 id 类型不一致几乎无法避免与其在每个组件里转换不如集中在一处定规则代价是失去了类型上的严谨性所以只用在 id 这种天然不会出现NaN的字段上。最后分享一个小经验项目里凡是出现类型不一致导致的比较失效我都会顺手在对应的数据入口处加一条归一化比如请求拦截器里统一把后端返回的数值型枚举转成数字。改一处比改二十个组件划算这个习惯帮我省掉了大量重复排查的时间。