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,确实需要重新赋值时才降级成let,var在新代码里基本不用。这个默认值不是教条,而是让读代码的人一眼看出“这个变量后面还会不会变”,减少翻回去找赋值点的次数。
还有一处容易踩的细节:在for循环的初始化表达式里用const,循环第一轮之后要执行i++重新赋值,会直接报错。所以循环计数器只能let,这不是风格问题,是语法要求。
2. 类型这件事,靠背定义没用,得从值的行为反推
JavaScript 基本语法里关于类型的部分,很多人是靠背“有几种数据类型”过关的,但真正写代码时用得上的是另一套东西:这个值赋给另一个变量会不会互相影响、怎么可靠地判断它是什么、它和别人比较时会发生什么。这三个问题背后都指向同一个知识点——原始类型和引用类型的区别。
2.1 赋值时到底复制了什么
原始类型(字符串、数字、布尔、null、undefined、Symbol、BigInt)的变量里存的就是值本身,赋值是复制一份:
let a = 10; let b = a; b = 20; console.log(a); // 10,a 不受影响引用类型(对象、数组、函数等)的变量里存的是一个指向堆内存的地址,赋值复制的是地址:
let x = { count: 1 }; let y = x; y.count = 99; console.log(x.count); // 99,x 被改了这个差别是后面所有“拷贝”“深拷贝”“数据莫名其妙被改”问题的根子。我见过一种很典型的写法:把一个配置对象作为参数传给函数,函数内部为了图省事直接改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 [] // 'object'typeof null返回'object'是从早期实现延续下来的行为,改不了,只能绕开。而数组用typeof也判断不出来。所以实际项目里我一般这么分工:
| 想判断的东西 | 推荐写法 | 说明 |
|---|---|---|
| 是否为数组 | Array.isArray(v) | 跨执行环境也可靠 |
| 是否为 NaN | Number.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',从左到右先算 1+2=3,再拼接 '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 | 对象的可枚举属性 | 能 | 会 | 遍历对象键,且需配合hasOwnProperty |
forEach | 数组 | 不能 | 不涉及 | 纯副作用遍历,不需要提前退出 |
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配合await:
for (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、函数、Symbol,Date会变成字符串,循环引用直接报错;structuredClone是现在比较推荐的方案,支持循环引用和Date、Map、Set,但不能克隆函数和 DOM 节点。如果数据结构里带函数,就得自己写递归或者借助专门的库。选哪种取决于数据里有什么,别默认哪一种万能。