十条会打破你JavaScript思维模式的日常使用规则
探讨了十种微妙的JavaScript行为——从const的可变性问题到闭包以及异步错误处理——这些行为会悄悄地在经验丰富的开发者的代码中引发故障。
一旦熟悉了 JavaScript 的语法,它就不再让人感到畏惧。你不会再因为缺少分号而犯错,异步回调也变得习以为常,let 和 const 之间的区别也会自然而然地被理解。在完成足够多的项目后,这种语言就会像老朋友一样亲近。你能一眼识别出常见的错误,对 Promise 的执行流程也有不错的直觉,同时明白无论某些 API 如何随意将 null 和 undefined 混为一谈,它们其实是不同的概念。
但熟悉并不会消除所有的意外,只是改变了它们的形态。初学时的困惑会被更为隐蔽、更危险的假设所取代。你知道对象是通过引用传递的,却仍会忘记浅拷贝会导致嵌套数据被共享。你知道 Promise 依赖微任务,但在多个队列开始交互时仍会误判执行顺序。你知道强制转换的存在,却仍会忽略那些隐藏在看似无害的比较、排序调用或属性查询背后的无声转换。
JavaScript中最棘手的部分往往并非那些罕见的特殊案例或语言小知识,而是以意想不到方式组合在一起的普通规则。每一行代码看起来都正常,可读性也不错,经过简单审查也能通过。令人意外的是,运行时将这些代码拼接在一起的方式,与阅读代码时的心理预期并不一致。
以下这十种行为之所以总是让经验丰富的团队犯错,正是因为它们隐藏在看似完全正常的代码之中。
1. const保护的是绑定,而非对象
讲解 const 时常见的简述是“它创建了一个不可更改的值”。这种说法对基本数据类型来说很合适,但用在数组和对象上就不成立。const 实际上保证的是该变量不能被重新赋值以指向其他对象,它并未规定该变量所指向的对象是否可以被修改。
用 const 声明的 user 对象仍然可以添加新属性,其嵌套的 settings 对象也可以被更新。用 const 声明的数组仍然可以向其中添加元素、进行排序或清空。从 JavaScript 的视角来看,这些操作都不会影响变量的绑定关系,该变量依然指向的是最初那个对象。
在那些极度依赖不可变性概念的代码库中,这种期望与现实之间的差距依然会引发真正的漏洞。某个配置对象被当作常量导入,但某个模块却悄悄修改了它,结果所有使用该引用的其他模块都会立即察觉到变化。存储在应用状态中的数组被就地排序,这不仅改变了“当前”值,还影响了代码中其他部分认为已经是固定历史快照的旧值。某个测试修改了共享的测试用例对象,结果一个完全无关的测试也开始出错,但这种情况仅发生在测试运行器恰好以不同顺序执行测试时。
const 关键字会给人一种虚假的安全感,它看起来像是围绕该值的一层保护壳,但实际上运行时只保护变量的指针,而从不保护其指向的结构。
如果真的需要不可变性,就必须自己实现它。这可能意味着返回全新的对象而非修改现有对象,对重要的值应用 Object.freeze,使用基于不可变状态的库,或者设计函数使其从一开始就不传递对内部可变数据的引用。即便使用了 Object.freeze,也仅能锁定顶层对象;除非同时冻结所有嵌套对象,否则内部层依然会像以前一样可变。
大多数经验丰富的开发者都能凭记忆说出这条规则,但每当代码的视觉风格让人误以为它提供了比 JavaScript 实际所能提供的更强的保障时,这种惊讶就会再次出现。
2. 对象展开操作的实际效果并非看上去那样
使用 { ...obj } 来复制对象已成为 JavaScript 中常用的惯用法之一。它的代码结构清晰,非常适合将默认值与覆盖值合并,还能在不修改原对象的情况下生成其修改后的版本。
从视觉上看,它似乎是一个完整且独立的副本。但实际上,这个复制对象仅深入了一层。
const original = {
profile: {
name: "Umar",
skills: ["JavaScript", "Node.js"],
},
};
const copy = { ...original };
copy.profile.skills.push("TypeScript");
当这段代码运行后,新添加的技能也会出现在 original.profile.skills 中。虽然顶层对象确实是新的,但嵌套在其内部的 profile 对象以及再嵌套其中的 skills 数组,依然与原对象所指向的完全相同。
这正是该漏洞如此难以根除的原因:最外层的行为完全符合预期。检查 copy !== original 会返回 true,这让人误以为已经实现了彻底的分离。而共享修改的问题只有在深入一层后才会显现。
在合并配置对象时,这种传播机制还会带来第二个意外。使用 { ...defaults, ...options } 会替换整个嵌套对象,而非合并其中的各个键值。如果 options 覆盖了嵌套对象中的某个设置,那么原本在 defaults 中与它共存的所有对应设置也会一同消失,尽管调用者根本无意修改它们。
解决方案并非自动执行“深度克隆所有内容”。完整的深度克隆成本高昂,可能会移除你在其他地方依赖的对象标识,还会复制本应保持共享的数据。真正重要的是确定哪些特定的嵌套路径需要成为独立的副本。针对这些路径进行明确处理,使用合适的不可变更新工具,或重新组织深度嵌套的数据,以便一目了然地看到所有权边界。
当确实只需要浅层复制时,对象展开功能就能按预期正常工作。但一旦其简洁的语法被误认为是完整的深度克隆,它就会变成一个隐患。
3. 普通相等性可能隐藏多种转换
经验最丰富的开发者通常会选择使用 ===,正是为避免 == 所带来的强制类型转换问题。这种习惯确实很有用,但它并不能防止 JavaScript 所进行的所有隐式转换——还有很多其他类型的转换会出现在与等号运算符无关的地方。
对象属性键就是一个很好的例子。除了符号之外,所有对象键在底层其实都是字符串。因此,无论是设置 object[1] 还是之后读取 object["1"],实际上都在操作同一个属性。那些将数字标识符和字符串标识符视为完全不同概念的代码很容易因此措手不及。
关系型比较会根据比较的对象进行不同的转换。两个字符串会按字典顺序(逐个字符)进行比较,而将数字与数值字符串比较时则可能触发数值比较。正因如此,"20" < "100" 的结果为假,而 20 < "100" 的结果为真。只要改变值的来源,即使显示的值看起来完全相同,排序或验证逻辑的行为也可能会发生变化。
+运算符尤其棘手,因为它既可用于数字加法,也可用于字符串连接,而JavaScript会根据上下文决定使用哪种功能。在一系列加法运算中较早出现的单个字符串就可能会改变其后所有内容的解释方式。由于从表单输入、URL查询参数以及其他HTML源获取的值通常都是字符串形式,因此原本对内部数字值能正常工作的表达式,在与用户输入相关联时可能会悄然切换为字符串连接模式。
经验丰富的团队避免这些陷阱的方法是在边界处就明确规范数据类型,而非依赖操作符即时正确解析原始输入。标识符从一开始就被确定为字符串或数字之一。金额会被转换为经过验证的数值类型。在进行任何比较之前,日期会被解析为合适的时间类型或固定的ISO字符串。
强制类型转换本身并非真正的问题,JavaScript的相关规则定义清晰且一致。真正的问题在于,这些规则会在代码中没有任何明显迹象表明正在发生类型转换的情况下持续触发。
4. Array.prototype.sort 会原位重新排序,默认按文本进行比较
排序看似是数组上最简单的操作之一,但实际上它暗含着两种常让人犯错的行为。
第一个意外在于它会修改原数组。调用 .sort() 会重新排列传入的数组,并返回对该数组的引用。人们很容易将返回值存储在新的变量中,以为原数组仍保持原有顺序,结果却发现两个引用实际上都指向同一个已被重新排序的数组。
第二个意外是当未提供比较函数时排序的工作原理。此时 JavaScript 会将每个元素转换为字符串,然后按字典顺序进行排列。如果在数字数组上使用此功能,得到的结果可能会是 1, 100, 20, 3 这样的顺序,而非升序的数字排列。
这种组合在前端状态管理中会带来危险。想象这样一个组件:它在渲染之前对数组进行排序,却没意识到该数组实际上是对缓存数据或服务器提供数据的共享引用。这样一来它就会修改这个共享的源数据。而读取相同底层数据的兄弟组件也会看到新的排序结果,尽管它们自己并未调用过排序函数。由于数组的内容虽然变了,但其标识(引用)始终没有改变,因此变化检测和缓存机制也可能会出错——浅层比较自然无法发现任何差异。
现代 JavaScript 提供了非修改性的替代方案:在支持该功能的环境中可以使用 toSorted(),而在不支持的情况下,标准的解决办法仍是在排序前复制数组。对于数值排序,你仍然需要为 .sort() 提供一个明确的比较函数,用以实现你所期望的比较逻辑。
更重要的结论是,方法名称并不能完全反映其功能规范。“排序”仅描述了最终的结果,却无法说明该方法是否会在原数组上直接修改数据、如何转换数值以进行比较、具备何种稳定性保证,或是是否满足某些特定领域的排序需求。即便经验丰富的开发者,也常常因为认为某个方法很常规而忽视去深入了解其实际工作原理,从而犯错。
5. Date对象表示单个瞬间——而大多数输入无法直接对应到这一瞬间
JavaScript中的时区问题并非源于时区概念本身的复杂性,而是因为一段简短的字符串实际上包含了远超表面看上去的隐含假设。
从底层来看,Date对象实际上是一个时间戳:以UTC为基准测量的精确瞬间。但人们实际处理的大多数日期——生日、截止日期、计费周期、预定会议时间——都属于日历概念,而非宇宙时间的固定点,而且它们与各时区的关联方式也各不相同。
如果解析仅包含日期的字符串后再使用本地时间进行显示,用户看到的日历日期会因所在地点不同而变化。本应表示“8月3日”的数值可能会被解读为UTC时间午夜,而在其他地方的本地显示则会呈现为8月2日。同样,若时间戳未明确标注UTC偏移量,其含义也会因具体的字符串格式及解析方式的不同而有所差异。
夏令时更是增加了复杂性。在Date对象上添加固定毫秒数并不一定等同于在本地时间中增加一天——在实行时钟调整时区的某些日子里,实际时长甚至可达23小时或25小时。
经验丰富的开发者仍会遇到这个问题,因为他们的代码依赖单一的 Date 类型来同时表示多个互不相关的概念。类型系统中没有任何信息能表明某个值究竟代表精确的时间点、单纯的日历日期,还是需要在特定地区解读的本地时间。
完善的系统通过明确而非隐含地表达意图来解决这一问题。时间点会附带 UTC 偏移量,或直接以 UTC 格式表示。仅包含日期信息的值会保持原样,而不会被不必要地升级为完整的时间戳。针对特定地区的日程安排会保留其创建时所使用的时区信息。解析和格式化操作会在系统中明确界定的位置进行,而不会在日期出现或被读取的任何地方随意进行。
因此,令人惊讶的通常并非涉及时区问题,而是那些看似无害的转换早已悄悄决定了该使用哪个时区。
6. 承诺可以在其回调实际执行之前就得到解决
已解决的承诺看起来像是任务已完成,但通过 .then 连接到的函数——或是位于 await 之后的代码——并不会在当前正在执行的同步代码中间立即运行。相反,它们会被放入微任务队列中,在之后再执行。
这种排队机制虽然能形成一定的执行顺序,但一旦承诺对象、定时器、事件处理程序以及普通的同步代码开始混合使用,仍可能让经验丰富的开发者措手不及。已经解析的承诺对象会将其后续执行任务推迟到以后,而当前正在运行的同步代码则会持续执行而不受干扰。通常情况下,这个被排队的后续任务会先于为下一个任务安排的任何定时器回调执行——即便是一个延迟为零毫秒的setTimeout也是如此。
真正的实际隐患并非在测验中猜测控制台输出的顺序,而是未能理解“Promise 已解析”与“其处理函数确实已执行”是两个不同的时间点。通过 Promise 处理函数触发的状态更新可能还无法被同一同步执行栈中后续运行的代码看到。测试可能在待处理的微任务有机会完成之前就检查了相关值。此外,如果微任务的链过长,就会导致计时器和渲染工作被推迟到比预期更晚的时间,因为运行时总会先处理完整个微任务队列才会继续执行其他操作。
当代码依赖这种偶然的顺序而非明确的序列时,就会变得脆弱。正因如此,经验丰富的开发者会在确实需要一步跟随另一步时选择返回并等待 Promise,利用框架提供的各种生命周期钩子,并避免将零延迟计时器视为同步其他任务的可靠方式。
事件循环本身的行为是一致的——是语法让多个独立的执行时间线看起来比实际更接近。即使应用程序的其他部分尚未意识到这一点,Promise 已经可以持有其最终值。
7. 用 try/catch 包装 async 调用并不能保证能捕获任何异常
将函数调用包裹在try/catch块中,似乎可以保护该调用免受失败影响。但对于异步函数而言,这种保护是否有效完全取决于你是否等待了返回的promise。
try {
saveAuditLog(record);
} catch (error) {
reportError(error);
}
如果saveAuditLog在返回promise之前就同步抛出异常,那么catch块就能很好地处理它。但如果是异步函数且在之后出现故障,当它拒绝执行时,已经返回了promise且执行早已离开了try块。此时的拒绝状态存在于那个promise中——与已经完成的同步调用毫无关系。
添加 await 可以将拒绝状态重新关联到外层的 try/catch 结构,但前提是所写的函数本身允许使用 await。将承诺传递到另一个处理链中也能保持这种错误关联关系。而仅仅在缩进的错误处理代码中调用异步函数,既不使用 await 也不返回结果,则无法实现这一目的。
这种错误在事件处理函数、数组回调函数以及库钩子中屡见不鲜,因为这些上下文中的 API 通常不知道该如何处理异步回调返回的承诺——往往直接忽略它。虽然回调仍然会正确地触发拒绝,但却没有代码来处理该错误。根据运行环境的不同,这可能会表现为未处理的拒绝警告、记录在案的错误、进程崩溃,或者任务默默地无人处理。
那些因依赖 promise 所有权而非代码缩进而导致追踪失败问题的开发者们,他们想知道究竟是哪个作用域在等待异步操作完成,拒绝信号会在何处被转化为可操作的反馈,以及那些被故意忽略的调用是否仍有有效的途径来报告失败。
异步错误是沿着 promise 链传递的,而与大括号的嵌套深度无关。
8. 解构赋值中的默认值仅适用于 undefined
解构赋值时的默认值似乎是一种防止输入缺失的便捷保护措施。
const { timeout = 5000 } = options;
该默认值仅在 timeout 为 undefined 或对象中根本不存在该属性时才会被应用。对于 null、0、空字符串或任何其他明确指定的值,则不会使用默认值。
通常这正是你想要的行为。使用零可能是有意为之,用于取消延迟,而 null 可能也有其特定的含义。问题在于有人误以为默认值可以涵盖所有“无法使用”的情况。API 可能会返回 null,但后续代码却试图对其执行数学运算。表单字段可能以空字符串的形式提交,因此默认值永远不会被触发。配置加载器可能会仔细区分“变量未设置”与“变量被明确设置为空值”的情况,但使用该配置的代码却将这两种情况视为相同,并回退到默认值。
函数的默认参数遵循相同的逻辑:不带参数调用函数时将使用默认值,但若明确传入null则不会使用默认值。当数据通过JSON载荷、数据库行、表单提交以及第三方API传递时——这些地方都会经常出现null——这种区别就显得尤为重要。
那些依赖这种模式的开发者通常会将默认值处理与验证分开处理。默认值用于回答“未提供参数时会发生什么”,而验证则用于判断“所提供的内容是否真正合格”。若将这两项功能合并到同一句简洁的语法中,不良数据就可能会被误认为是已正确初始化的。
该语言的规则在这里是明确且一致的。混淆的产生完全是因为“default”这个词让人误以为其覆盖范围比实际上仅限于undefined状态的严格规则更广。
9. 缺失的属性、被删除的属性与undefined是三回事
JavaScript允许对象属性持有undefined值,或者根本不存在于该对象中。直接读取这两种情况都会得到undefined,因此它们看起来可以互换——但实际上并非如此。
in运算符、Object.hasOwn、Object.keys、展开语法、迭代机制、模式验证器以及JSON序列化等功能都可以用来区分这些差异。例如,JSON.stringify会完全忽略值为undefined的属性,而数组中存储undefined的值则又会以不同的方式处理。对象合并功能甚至可能用undefined覆盖原本正常的值,即便执行合并操作的人只是打算保持该字段不变。
这种差异往往是导致细微更新错误的常见原因。后端的 PATCH 请求数据可能会将被省略的字段视为“无需修改”,而将明确设置为 null 的字段视为“需清除”。与此同时,前端表单对于用户从未触动的字段可能会生成 undefined 值——但即便将这些表单数据放入更新对象中,这些 undefined 属性仍会被保留下来,从而在合并过程中覆盖原有的值。
为避免这种情况,应刻意明确定义更新规则,而非让其由偶然因素决定。无论采用何种序列化或对象合并机制,都不应允许省略、undefined、null 或真正为空的值因机制作用而自动被赋予特定含义。
在 TypeScript 中,这一点尤为重要:可选属性与类型中包含 undefined 的必选属性代表了两种结构上完全不同的含义——即便后续的应用代码最终对它们进行相同处理。更严格的编译器设置可以在类型层面明确区分这两种属性,但运行时在应用程序中传递的实际数据仍需进行自身的验证。
那些在读取时看似相同的两个值,在跨越网络边界或经过更新操作后,仍可能代表截然不同的数据契约。
10. 闭包保存的是变量,而非被冻结在时间中的快照
闭包是 JavaScript 提供的强大工具之一。函数能够保持对其定义作用域中变量的访问权限,这正是回调函数、工厂函数、事件处理程序以及模块模式能够如此自然地运行的原因。
人们常用的简述是闭包“记住了一个值”。更准确的描述是它保留了对变量绑定的访问权。如果在闭包实际执行之前该变量的值发生了变化,闭包将看到的是新的值,而非其创建时的值。
这就是涉及 var 的循环错误背后的经典解释,但经验更丰富的开发者往往会在异步代码和用户界面逻辑中遇到更为隐蔽的同类问题。回调函数可能会读取在操作开始后已被修改的配置对象;事件处理程序可能会使用已经过时的状态数据;而延迟执行的函数则可能在开发者以为其会使用函数调度时对象所呈现的状态的情况下,对可变对象的当前状态进行操作。
框架会在这一基础上叠加自身的生命周期规则,这往往会使相关效应更加明显。例如在 React 中,某个特定渲染过程中创建的回调函数会绑定该次渲染时存在的值。即便底层的 JavaScript 闭包功能完全按设计正常工作,这也可能表现为旧状态的行为。人们之所以感到意外,是因为期望回调函数能够以某种方式自动获取最新的状态——但闭包并非如此运作。
正确的解决方案取决于你的实际需求。有时你确实需要操作开始时的原始值,这种情况下提前获取一个稳定的快照是最佳选择。有时你需要最新的值,这就需要类似引用式的当前参考或函数式更新。还有些时候,正确的做法是让变化的依赖项完全重新创建回调函数。
一个有用的问题是:延迟执行的任务应该反映其被调度时的世界状态,还是实际运行时的世界状态?闭包本身对此并无看法——它会忠实保留你的代码所建立的任何关系,即便那并非你原本想要创建的关系。
JavaScript的行为始终如一——但我们的思维捷径却未必
这里提到的所有行为都不是随意设计的怪癖。const锁定的是绑定本身,而非其中的值。展开操作仅能复制一层深度的元素。默认的数组排序会先将元素转换为字符串。Promise处理函数作为微任务执行。解构赋值默认会对undefined作出特殊处理。闭包保留的是词法绑定,而非固定值。语言会始终如一地严格执行每一条规则。
令人意外的是,开发者往往依赖比运行时实际执行的规则更简单的思维模型。我们认为常量“无法改变”,展开操作“会创建副本”,异步调用“在try/catch块中执行”,闭包“能记住某个值”。这些简化在某些情况下很有用,但一旦出现新的需求恰好依赖于那些被忽略的细节时,问题就出现了。
保护经验丰富的开发人员的并非记住日益增多的琐碎知识——而是在数据跨越边界时养成明确约定规则的习惯。这意味着要规范来自外部世界的数值,将“缺失”与“无效”视为不同的概念,避免共享引用被意外修改,明确界定谁负责处理异步操作的错误,保持时间戳和时区的原始含义,并提前决定延迟回调应基于旧状态还是当前状态来执行操作。
编写可靠的 JavaScript 并非要避免使用该语言提供的所有灵活功能,而是要在不误以为其简洁语法能带来超出实际能力的性能的前提下合理运用这些功能。
JavaScript总能让经验丰富的开发者感到意外,因为经验会让他们对那些看起来熟悉的代码产生信任。但问题在于,这些看似熟悉的语法结构仍可能暗中隐藏着关于引用、类型转换、执行时机以及错误处理方式的决策——这些决策只有当系统中的其他部分发生变化时才会显现出来。
这种语言几乎总是严格按照指令来执行操作。
令人惊讶的是,人们往往要等到发现代码实际执行的操作时才会恍然大悟。
相关阅读
- 那些会悄悄破坏代码的JavaScript和TypeScript常见陷阱——介绍了从NaN比较到异步时序处理以及类型强制转换等那些看似正确实则会导致错误的JavaScript和TypeScript细微问题。