JavaScript基本语法避坑:变量声明、类型判断与对象拷贝

发布时间:2026/10/31 19:39:39
JavaScript基本语法避坑:变量声明、类型判断与对象拷贝 学 JavaScript 基本语法这件事很多人卡住的地方并不在“看不看得懂”而在“写不写得对”。我见过不少从页面结构和样式转过来的朋友教程从头翻到尾每行代码都认识可一旦关掉教学视频自己动手问题就全冒出来了控制台一句xxx is not defined能盯着看十分钟循环里绑了五个按钮点哪个都弹出同一个结果明明两个值看起来一样if判断就是进不去。这些都不是逻辑思维不够而是 JavaScript 基本语法里那些“书本上一句话带过、实际天天遇到”的细节没过关。下面这些内容就是围绕这件事展开的把变量声明、类型判断、运算符、流程控制、函数、对象与数组这些最基础的语法点按“为什么会这样设计”“哪里最容易写错”“怎么改才稳”的顺序捋一遍适合刚写完第一个脚本的新手也适合写了半年但总觉得代码“能跑但不放心”的人对照自查。1. 变量声明那行写错后面大半时间都在还债变量声明是 JavaScript 基本语法的第一课也是最容易被跳过去的一课。多数教程只花两分钟讲完var、let、const三个关键字然后直接进入条件判断。可我自己的经验是初学者代码里那些“莫名其妙”的 bug有一半以上追根溯源都能落到声明方式上。这一节把三个关键字的差别拆到能直接对上具体现象的程度。1.1 var 的函数作用域与提升为什么它在循环里总出问题var的核心特征有两条作用域是函数级而非块级以及声明会被提升到作用域顶部。这两条单独看都不难合在一起就会产生让人抓头的现象。function demo() { console.log(a); // undefined不报错 var a 1; console.log(a); // 1 }第一行能打印出undefined而不是抛错原因是var a这行声明被“提”到了函数最开头但赋值还留在原位置所以访问时变量已存在、值为空。这就是所谓的提升。它不会报错但会给出一个“看起来像是空值”的结果比直接报错更难查因为它把错误藏起来了。更典型的是块级失效if (true) { var msg hello; } console.log(msg); // hello居然能访问到在别的语言里块内声明的变量出了块就该消失但var不认块只认函数。这个特性在写简单脚本时没感觉一旦代码里出现循环加事件绑定麻烦立刻显形for (var i 0; i 3; i) { setTimeout(function () { console.log(i); // 3 3 3 }, 0); }三次打印全是 3。因为三个回调共享同一个函数作用域的i等回调真正执行时循环早就跑完i已经变成 3。这个例子在面试里被讲烂了但很多人背了答案却没真正理解问题不是“闭包很难”而是var根本没有为每一轮循环创建独立的绑定。1.2 let 与 const 补上的两个洞块级作用域和暂时性死区let和const是后来补上的它们和var的差别不只是“新的写法”。第一它们遵守块级作用域{ }就是一个边界第二它们虽然也会被提升但提升后处于暂时性死区在声明语句执行之前访问会直接抛ReferenceError而不是给你undefined。{ // console.log(x); // ReferenceError: Cannot access x before initialization let x 1; console.log(x); // 1 } // console.log(x); // ReferenceError: x is not defined这里有两种完全不同的报错值得分清楚暂时性死区报的是“在初始化前不能访问”出了块报的是“未定义”。前者说明变量确实存在、只是还没轮到它后者说明压根不在这个作用域里。新手看到两串英文容易一律当成“变量没定义”结果改了半天位置改不对。同样的循环换成let问题就消失了for (let i 0; i 3; i) { setTimeout(() console.log(i), 0); // 0 1 2 }因为每一轮循环let都会创建一个新的绑定三个回调各拿各的。这就是块级作用域在真实场景里的价值不是概念上的讲究。1.3 const 不等于“值不能改”而是“绑定不能换”新手对const最大的误解是“用了它变量就冻结了”。实际约束的是绑定关系不能让这个名字重新指向另一个值但值本身如果是对象对象内部该怎么改还怎么改。const user { name: A }; user.name B; // 完全合法 // user { name: C }; // TypeError: Assignment to constant variable. const list [1, 2, 3]; list.push(4); // 合法list 还是同一个数组真正要冻结内容得用Object.freeze而且它只冻结第一层嵌套对象照样能改。所以日常写代码我的习惯是默认用const确实需要重新赋值时才降级成letvar在新代码里基本不用。这个默认值不是教条而是让读代码的人一眼看出“这个变量后面还会不会变”减少翻回去找赋值点的次数。还有一处容易踩的细节在for循环的初始化表达式里用const循环第一轮之后要执行i重新赋值会直接报错。所以循环计数器只能let这不是风格问题是语法要求。2. 类型这件事靠背定义没用得从值的行为反推JavaScript 基本语法里关于类型的部分很多人是靠背“有几种数据类型”过关的但真正写代码时用得上的是另一套东西这个值赋给另一个变量会不会互相影响、怎么可靠地判断它是什么、它和别人比较时会发生什么。这三个问题背后都指向同一个知识点——原始类型和引用类型的区别。2.1 赋值时到底复制了什么原始类型字符串、数字、布尔、null、undefined、Symbol、BigInt的变量里存的就是值本身赋值是复制一份let a 10; let b a; b 20; console.log(a); // 10a 不受影响引用类型对象、数组、函数等的变量里存的是一个指向堆内存的地址赋值复制的是地址let x { count: 1 }; let y x; y.count 99; console.log(x.count); // 99x 被改了这个差别是后面所有“拷贝”“深拷贝”“数据莫名其妙被改”问题的根子。我见过一种很典型的写法把一个配置对象作为参数传给函数函数内部为了图省事直接改config.timeout 3000结果调用方那边的配置也被改了几百行之外的地方出现超时异常。这类 bug 排查起来特别费劲因为修改点和出错点隔得很远。稳妥的做法是函数内部要改就先浅拷贝一份const cfg { ...config }。2.2 typeof 够用但不够可靠typeof是语法层面最直接的类型判断工具但它有几个必须记住的坑typeof 1 // number typeof a // string typeof true // boolean typeof undefined // undefined typeof function(){} // function typeof null // object ← 历史遗留问题不是笔误 typeof [] // objecttypeof null返回object是从早期实现延续下来的行为改不了只能绕开。而数组用typeof也判断不出来。所以实际项目里我一般这么分工想判断的东西推荐写法说明是否为数组Array.isArray(v)跨执行环境也可靠是否为 NaNNumber.isNaN(v)不会做隐式转换是否为整数Number.isInteger(v)比v % 1 0更直观是否为普通对象v ! null typeof v object !Array.isArray(v)排除 null 和数组精确类型Object.prototype.toString.call(v)返回[object Date]这类结果null / undefined 同时判断v null唯一推荐用的场合isNaN和Number.isNaN的区别值得单独说一句。isNaN(abc)返回true因为它会先把参数转成数字abc转不了就成了NaN所以判定为“是 NaN”。这明显不符合直觉abc本身不是NaN它是个字符串。Number.isNaN(abc)返回false因为它不做转换只回答“这个值是不是那个特殊的 NaN”。写校验逻辑时用错这个会把合法输入判成非法。2.3 显式转换与隐式转换主动的和偷偷的转换分两类。显式的由你调用Number()、String()、Boolean()、parseInt()这些函数隐式的发生在比较、运算、条件判断里由引擎自动完成。真正麻烦的是第二类。Number() // 0 Number( 12 ) // 12 Number(12px) // NaN parseInt(12px) // 12遇到非数字就停 parseInt(08) // 8现代环境默认十进制 Boolean() // false Boolean(0) // true非空字符串都是 true Boolean(0) // false Boolean([]) // true空数组也是 true Boolean({}) // true空对象也是 trueBoolean([])为true这条经常让新手翻车判断“数组是否为空”时写if (arr)是没用的空数组照样进分支得写if (arr.length 0)。同理Boolean(0)是true从表单里读出来的0字符串如果直接当条件用会走到“有值”的分支里去。数字精度也归在这一类里。0.1 0.2得到的是0.30000000000000004这是二进制浮点的固有特性不是 JavaScript 独有的毛病。涉及金额计算时我的处理方式是先转成整数分再算或者用Math.abs(a - b) Number.EPSILON做近似比较绝不直接比两个浮点结果。2.4 和 的选择不要一刀切比较类型和值会先做类型转换再比较。主流建议是默认用但“默认”不等于“绝对”有几个场合反而更省事null undefined // true null undefined // false 0 // true ← 这种就该避免 [] false // true ← 更该避免 5 5 // true结论很清楚的隐式转换规则复杂且反直觉日常一律用唯一值得保留的用法是v null因为它一次性覆盖null和undefined两种情况写起来比v null || v undefined干净得多而且这条规则的语义是明确的、没有歧义的。3. 运算符和表达式短小的一行里藏着最多细节运算符看起来是最没技术含量的语法加减乘除谁不会。可真到了排查线上问题的时候恰恰是这类“一行表达式”最容易埋雷。原因是它们被写得最短、看得最快、最不设防。这一节挑几个日常高频又高频出错的点讲。3.1 短路求值不只是“简化 if”和||在 JavaScript 里返回的不一定是布尔值而是决定结果的那个操作数。这一点被低估得很厉害。const name input || 默认名; // input 为假值时用默认值 isLogin loadUserInfo(); // 条件执行这两行都很常见但第一行有隐患如果input是0或者空字符串它们本身是合法值却因为“假值”被替换成了默认值。用户昵称填了空字符串想表示“不要昵称”结果被换成默认名这种问题在产品层面很难解释。3.2 ?? 才是“只判空”的正确工具空值合并运算符??解决的就是上面这个问题只有左边是null或undefined时才取右边0、、false都会被保留。0 || 100 // 100 0 ?? 100 // 0 || x // x ?? x // null ?? y // y需要注意的是??不能和、||直接混用而不加括号语法会直接报SyntaxError。这种报错信息看起来莫名其妙但只要记住“混用必须加括号”就能避开// const v a || b ?? c; // SyntaxError const v (a || b) ?? c; // 合法我在实际项目里定了个简单规则处理配置项、接口返回字段这类“值可能是 0 或空串”的场景一律用??处理纯布尔判断用||或者干脆写完整的if。这条规则执行下来因为“默认值覆盖了有效值”引发的问题基本没再出现过。3.3 可选链 ?. 让深层取值不用再层层判断以前取一个深层字段得这么写const city user user.address user.address.city;现在可以写成user?.address?.city。它的规则是左边是null或undefined就整体返回undefined不再往下取。几个边界要知道?.不能放在赋值语句左边obj?.a 1是语法错误arr?.[0]和fn?.()分别是数组索引和函数调用的可选写法它只防null/undefined不防“属性存在但类型不对”比如user.address是个字符串user.address?.city依然是undefined而不是报错这种静默失败有时候更难查。配合??用起来很顺手const timeout config?.request?.timeout ?? 5000;一行搞定“层层取不到就给默认值”比嵌套三元表达式可读性好太多。3.4 优先级和类型混算导致的隐蔽错误是重载的两边都是数字做加法只要有一边是字符串就做拼接。从表单、localStorage、URL 参数拿到的值默认都是字符串这就很容易出问题1 2 // 12 1 - 2 // -1减号只有数字语义会转成数字 3 * 4 // 12 1 2 3 // 33从左到右先算 123再拼接 1 2 3 // 123最后两行的差异纯粹是结合顺序造成的看一眼就懂但在没有断点的长表达式里极容易看漏。我的做法是只要涉及外部来源的数字先Number()转一次再参与运算别指望引擎帮你猜。另外a b || c d这种混写也别省括号优先级高于||实际等价于a b || (c d)可读性差且容易改错。4. 流程控制和循环能跑通和跑得对是两回事条件语句和循环是 JavaScript 基本语法里最“看起来没问题”的部分语法简单到几乎不会写错。但写对和写对是两码事尤其在选择用哪种循环、以及在循环里做异步操作的时候差别会非常明显。4.1 四种循环方式怎么选写法遍历对象能中断是否遍历原型链适用场景for (let i...)数组、类数组能不涉及需要索引、需要精确控制步长for...of可迭代对象能不涉及数组、字符串、Map、Set 的常规遍历for...in对象的可枚举属性能会遍历对象键且需配合hasOwnPropertyforEach数组不能不涉及纯副作用遍历不需要提前退出for...in用在数组上是常见的错误用法因为它遍历的是键名字符串形式的0、1还会把原型上扩展的属性一起带出来。如果某个第三方脚本往Array.prototype上加了方法你的循环就会多跑几轮。真要遍历数组for...of或普通for都比它合适。forEach的问题是不能break。很多人写了一半才发现需要在某个条件下停只能改成for...of或者用some/every变通。如果一开始就预判到“可能会提前结束”直接选for...of更省事。4.2 循环里做异步最常见的一类顺序错误// 错误预期三个请求依次发出并等待结果 urls.forEach(async (url) { const res await fetch(url); console.log(res); }); console.log(全部完成); // 这行会先执行forEach不会等回调里的await它只负责把回调挨个调一遍就返回了所以“全部完成”会最先打印。要按顺序串行执行得用for...of配合awaitfor (const url of urls) { const res await fetch(url); console.log(res); } console.log(全部完成); // 现在才轮到它要并发执行并等全部结束用Promise.all(urls.map(u fetch(u)))。这里的区别不是风格问题是执行模型的差别写错了就得不到想要的结果。4.3 switch 的匹配规则和穿透switch用的是严格相等比较所以case 1匹配不到数字1。这一点在从输入框取值时特别容易中招因为输入框取出来永远是字符串。另一个是case穿透忘了写break会把后面的分支一起执行偶尔有人故意用它做合并分支但更多时候是漏写。switch (status) { case ok: case success: // 故意合并两个值走同一段逻辑 handleOk(); break; default: handleOther(); }合并分支这种用法是有意为之读代码的人也能看懂。但如果分支之间逻辑不同还漏了break问题就隐蔽了因为不会报错只是执行了多余的分支。现在的编辑器一般会提示别忽略这类警告。5. 函数语法糖下面压着 this、参数和闭包函数是 JavaScript 的一等公民围绕它衍生出的语法细节也是最多的。这一节不讲设计模式只讲那些“不知道就一定会写错”的语法规则。5.1 函数声明和函数表达式的时序差异foo(); // 正常执行 function foo() { console.log(声明式); } bar(); // TypeError: bar is not a function var bar function () { console.log(表达式); };函数声明会整体提升包括函数体所以在定义前调用没问题函数表达式只是变量提升调用时变量还是undefined所以报的是“不是函数”而不是“未定义”。这两个报错信息不一样排查方向也不同前者说明名字存在但值不对后者说明名字根本不存在或者是暂时性死区。5.2 箭头函数不是 function 的简写版箭头函数省掉了function关键字但它同时丢掉了几个东西这些区别必须记牢没有自己的this取外层作用域的this没有arguments对象要用剩余参数...args不能用作构造函数new会报错没有prototype属性不能用yield不能做生成器。最有价值的当然是this的继承特性。在对象方法里嵌套回调时用普通函数this会丢用箭头函数就稳const counter { count: 0, start() { setInterval(() { this.count; // 这里的 this 指向 counter }, 1000); } };反过来如果确实需要一个动态的this比如给 DOM 元素绑定处理函数并希望this指向元素那就得用普通函数箭头函数反而会取到外层的this行为不符合预期。所以“无脑用箭头函数”是不对的得看this应该指向谁。5.3 默认参数只在 undefined 时生效function greet(name 访客) { return hi name; } greet(); // hi 访客 greet(undefined); // hi 访客 greet(null); // hi null ← null 不会触发默认值 greet(); // hi null和空字符串都不会触发默认值只有undefined会。这个规则和??是一致的也和解构的默认值规则一致。如果接口返回的字段可能是null而你想让null也走默认逻辑得显式处理const name data.name ?? 访客。默认参数的求值时机也值得知道它在每次调用时求值而不是定义时所以可以写function f(a, b a * 2) {}这种依赖前面参数的默认值。5.4 闭包不是玄学是“函数记住了它出生时的环境”闭包这个词被讲得很玄其实描述的就是一个事实函数内部的函数可以访问外层函数的变量即使外层函数已经执行完了。最实用的场景是封装私有状态function createCounter() { let count 0; return { inc() { return count; }, get() { return count; } }; } const c createCounter(); c.inc(); c.inc(); console.log(c.get()); // 2count外部拿不到只能通过返回的方法访问和修改这就是模块化的雏形。用的时候要注意代价被闭包引用的变量不会被回收。如果闭包里挂着一个大数组或者 DOM 引用而闭包本身长期存活比如绑在全局事件上内存就一直占着。在单页应用里频繁挂载卸载组件的场景下这类泄漏累积起来是能感觉到的。我的习惯是在组件销毁时把绑定的监听器和定时器显式清掉别指望垃圾回收帮你兜底。5.5 参数传递都是按值只是值的内容不同经常有人说“对象是按引用传递的”这个说法不准确。准确的表述是JavaScript 只有按值传递只不过引用类型的“值”是一个地址。区别体现在这个例子上function change(obj) { obj.name changed; // 影响外部因为改的是同一个对象 } function reassign(obj) { obj { name: new }; // 不影响外部只是让形参指向了新对象 }如果真是按引用传递reassign也应该影响外部但实际不会。理解这一点就不会在函数里写出“我明明改了参数为什么外面没变”的困惑。6. 对象与数组引用语义下的赋值、拷贝和解构对象和数组是日常写得最多的结构围绕它们的语法细节也最密。这一节集中在拷贝、解构和数组方法这三块都是“不知道就一定会踩”的地方。6.1 赋值、浅拷贝、深拷贝三者的真实区别操作写法第一层嵌套层赋值const b a共享共享浅拷贝{ ...a }/Object.assign({}, a)独立共享深拷贝structuredClone(a)独立独立浅拷贝只复制第一层嵌套对象还是同一个引用const a { user: { name: A } }; const b { ...a }; b.user.name B; console.log(a.user.name); // B外层独立了内层没有深拷贝有几种做法各有短板JSON.parse(JSON.stringify())会丢掉undefined、函数、SymbolDate会变成字符串循环引用直接报错structuredClone是现在比较推荐的方案支持循环引用和Date、Map、Set但不能克隆函数和 DOM 节点。如果数据结构里带函数就得自己写递归或者借助专门的库。选哪种取决于数据里有什么别默认哪一种万能。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询