初学 JavaScript 的时候我总觉得基础语法不过是“背规则”var、let、const 记一记for 循环会写函数会调就算过关了。直到真正上手写项目、被各种隐式转换和变量提升坑到怀疑人生才意识到基础语法的每一个细节都值得认真对待。这篇笔记是我系统重学 JavaScript 基础语法后的第一篇整理围绕变量声明、数据类型与转换、运算符、控制流和函数这五个核心模块把容易忽略、容易踩坑的地方全部拆开讲清楚。无论你是刚入门的初学者还是写过一阵子但没系统梳理过的开发者这篇笔记都能帮你把地基打得更扎实。1. 变量声明var、let、const 的那些微妙差异1.1 作用域函数级与块级的天壤之别很多人一开始写 JavaScript 只认一个 var因为它是这门语言最初就有的声明方式。但 var 的作用域规则非常特殊它属于函数作用域而不是块级作用域。这意味着在 if、for、while 这些花括号内部用 var 声明的变量在花括号外面依然能够访问到。if (true) { var message hello; } console.log(message); // 输出 hello不会报错 if (true) { let scoped world; } console.log(scoped); // 报错scoped is not defined这段代码是理解 var 与 let 差异的第一个切口。var 声明的 message 虽然在 if 块内但它的作用域是整个函数。而 let 声明的 scoped 是块级变量脱离块之后就会销毁。实际编码中块级作用域能帮我们避免非常多的命名冲突和内存占用问题所以在现代 JavaScript 里let 已经全面替代了 var 在日常代码中的位置。for 循环是最能体现差异的场景。用 var 写循环变量循环结束后变量依然存在而且在回调函数里会出现经典的“循环变量共享”问题后文讲到闭包时还会再涉及。用 let 写循环变量每次迭代都会创建一个新的绑定这就是 let 在 for 循环中和 var 行为不一样的根本原因。1.2 变量提升理解“先使用后声明”背后的机制变量提升是 var 声明最著名的坑。所谓提升并不是变量真的被移动到了作用域顶部而是 JavaScript 引擎在编译阶段会先扫描当前作用域内的 var 声明将其登记到作用域的顶部并默认初始化为 undefined。这个过程发生在任何代码执行之前。console.log(a); // 输出 undefined而不是报错 var a 10;在大多数编程语言里使用一个尚未声明的变量会直接抛出错误但 JavaScript 的 var 会输出 undefined。原因就是声明被提升了但赋值没有被提升。把上面的代码理解成下面这个等价过程就明白了var a; console.log(a); a 10;let 和 const 也会提升但提升的方式完全不同。它们同样会被登记到作用域顶部但在初始化之前访问会进入暂时性死区引擎会抛出 ReferenceError 错误。这个差异是刻意设计的目的是尽早暴露“变量未初始化就被使用”的编程错误。console.log(b); // 报错Cannot access b before initialization let b 20;我个人在写代码时的习惯是能用 const 就用 const确实需要重新赋值的变量才使用 let任何时候都不在新代码里使用 var。这样做的收益非常直接——你不需要思考这个变量会不会被意外修改代码的可读性和可维护性都大幅提升。1.3 const 的本质绑定不可变而非值不可变const 有一个非常普遍的误解很多人以为 const 声明一个对象后对象里的属性就不能再改了。这个理解是错的。const 保证的是变量绑定不可变也就是说你不能再给这个变量重新赋值但对象内部的属性、数组里的元素都可以自由修改。const config { name: demo, }; config.name new name; // 合法可以修改属性 config {}; // 报错Assignment to constant variable const list [1, 2, 3]; list.push(4); // 合法可以修改数组内容 list []; // 报错Assignment to constant variable理解这一点非常重要否则你会陷入“用 const 声明对象后修改属性居然不报错是不是我代码有问题”的困惑。要彻底冻结一个对象可以使用 Object.freeze()但它只能浅冻结嵌套对象依然可以修改这是另一个话题了基础语法阶段先记住 const 的本质即可。2. 数据类型与类型转换为什么 1 1 不等于 22.1 七种数据类型与 typeof 的坑JavaScript 目前有七种数据类型其中六种是原始类型一种是引用类型。原始类型包括 string、number、boolean、undefined、null、symbol引用类型就是 object数组、函数、日期、正则等都属于 object 的分支。还有一个 bigint 类型专门应对超大整数ES2020 才正式纳入规范。typeof 是检测类型最常用的运算符但它有几个臭名昭著的历史性坑点新手第一次遇到基本都会懵typeof null; // object typeof []; // object typeof function () {}; // function typeof undefined; // undefined typeof Symbol(); // symboltypeof null 返回 object 是 JavaScript 诞生初期的设计失误因为 null 的二进制表示是全部为 0引擎就把它判定为对象了。这个 bug 牵涉到太多存量代码官方一直没有修复。所以判断 null 要用 value null而不是 typeof 的结果。typeof 数组返回 object 也不够精确要识别数组可以用 Array.isArray() 方法。判断一个对象是不是普通对象常用的方法是 Object.prototype.toString.call()它返回类似 [object Array] 或 [object Object] 的字符串比 typeof 精确得多。这段代码我在项目里写了几百次基础语法阶段就记住这个套路后面会少走很多弯路。2.2 隐式转换加号的双重身份与宽松相等JavaScript 的隐式类型转换是最容易引发 bug 的地方没有之一。加号运算符会被同时用于数学加法与字符串拼接具体执行哪种操作取决于操作数的类型。只要有一个操作数是字符串加号就会把另一个操作数转换成字符串进行拼接。console.log(1 1); // 11 console.log(1 1); // 2 console.log(1 true); // 2true 被转成 1 console.log(1 true); // 1true console.log(1 null); // 1null 被转成 0 console.log(1 undefined); // NaNundefined 被转成 NaN看到 1 true 等于 2第一次接触的同学可能会觉得匪夷所思但这背后是布尔值参与数学运算时的转换规则true 转 1false 转 0。null 在数值转换时变成 0undefined 则变成 NaN。这一串规则不需要刻意死记你需要建立的是一种“先想类型再想结果”的直觉。宽松相等运算符 会触发隐式转换这也是很多 bug 的源泉。明确规则是null undefined 成立NaN 不等于任何东西包括它自己字符串和数字比较时字符串会被转成数字布尔值会先转成数字再参与比较。console.log(0 ); // true console.log(0 false); // true console.log( false); // true console.log([] ); // true console.log([] 0); // true这一串结果看起来非常离谱但追根溯源都是调用了对象的 toString 或 valueOf 方法进行原始值转换。现代开发规范普遍要求使用严格相等 和 !因为它们在比较之前不会做任何类型转换只有类型和值都相同才返回 true。这是一个非常低成本的规避策略强烈建议从一开始就养成这个习惯。2.3 NaN 与数字安全性的边界NaN 是一个特殊值表示“不是一个数字”。它的产生场景非常多比如把字符串解析为数字失败、数学运算中出现 undefined、0 除以 0 等。NaN 最反直觉的特性是它不等于它自己console.log(NaN NaN); // false这导致你不能用 x NaN 来判断一个值是不是 NaN必须使用全局函数 Number.isNaN()。注意全局的 isNaN() 函数有隐式转换的问题isNaN(hello) 会先尝试把 hello 转成数字转换失败后返回 true这就不够纯粹。Number.isNaN() 则不会做类型转换严格判断值本身是否为 NaN。console.log(isNaN(hello)); // true因为 hello 转数字失败 console.log(Number.isNaN(hello)); // false因为 hello 不是 NaN 这个值还有个相关的坑是浮点数的精度问题。JavaScript 的 number 类型是基于双精度浮点数标准存储的0.1 0.2 的结果不是 0.3而是 0.30000000000000004。这不是 JavaScript 的 bug所有使用二进制浮点数的编程语言都有这个问题。基础语法阶段不需要深究实现原理但你要知道不能直接用 判断浮点数运算结果需要引入一个误差范围来进行比较。3. 运算符与表达式从比较规则到短路求值的细节3.1 逻辑运算符的真值判定与短路规则逻辑运算符 和 || 并不总是返回布尔值这是一个被很多教程忽略的知识点。它们的求值结果是操作数本身而不仅仅是 true 或 false。 的规则是如果左边是假值返回左边如果左边是真值返回右边。|| 的规则是如果左边是真值返回左边如果左边是假值返回右边。console.log(a b); // b console.log(0 b); // 0 console.log(a || b); // a console.log(0 || b); // b console.log(0 || undefined); // undefined这个特性在实战中非常有用。比如给函数参数设置默认值过去常见的是使用逻辑或param param || defaultValue。但这个写法的缺陷在于当传入的值本身就是假值0、空字符串、false时会被意外地替换成默认值。在现代代码里更推荐用 ?? 空值合并运算符来处理默认值它只在左边是 null 或 undefined 时才返回右边。const a 0 || 10; // 10意外替换 const b 0 ?? 10; // 0符合预期 const c undefined ?? 10; // 10符合预期短路求值指的是逻辑运算符在执行过程中根据左侧的结果提前结束计算不再执行右侧表达式。这个机制可以用来安全地访问可能不存在的属性比如 obj obj.nameobj 为 null 时直接返回 null不会去访问 name 导致报错。现代 JavaScript 提供了 ?. 可选链运算符写法更简洁obj?.name。基础语法阶段把 的短路规则理解透后续看可选链的实现也会更通透。3.2 三元表达式嵌套是坏味道的信号三元表达式是 if 语句的精简形式语法是 condition ? expr1 : expr2。它能在一行内完成条件赋值可读性通常优于等价的 if 语句。但三元表达式一旦嵌套代码就会变得极难阅读。const result score 90 ? 优秀 : score 60 ? 及格 : 不及格;这种嵌套写法虽然合法可读性却很差尤其是条件再多一层的时候整行代码就成了一团乱麻。我自己的经验是三元表达式最多处理一个条件分支超过一个分支就用 if 或 switch 改写。代码首先是给人看的其次是给机器执行的这个顺序绝对不能颠倒。3.3 运算符优先级别考验自己和读者的记忆力JavaScript 运算符优先级是一个庞大而复杂的表完整记住并不现实。关键是识别几个容易出问题的组合。最简单有效的防御策略是加括号而不是依赖优先级规则。const isAdult age 18 hasId true; const isValid (a b) || (c d);还有一个比较隐蔽的坑是赋值运算符的返回值。赋值表达式会返回被赋的值所以在 if 条件里写 if (x 5) 实际上是先给 x 赋值 5再判断 5 的布尔真值结果是 true。如果本意是判断相等却错用了赋值运算符程序不会报错行为却完全错误这种 bug 最难排查。现代 lint 工具一般都会针对这个模式给出警告。4. 控制流if、switch 与循环里的坑4.1 真假值对照哪些值会被判定为 falseif 语句执行时会对条件表达式进行布尔转换在 JavaScript 中只有六个假值false、0、空字符串 、null、undefined、NaN。其余所有值包括空数组 []、空对象 {}、字符串 false、数字 -0都会被转换为 true。if ([]) { console.log(空数组为真); // 会执行 } if ({}) { console.log(空对象为真); // 会执行 } if (false) { console.log(字符串 false 为真); // 会执行 }这六个假值的对照表值得打印出来贴在显示器上。很多判断逻辑出错根源都在于对空数组和空对象的布尔值判断产生了错误预期。尤其是在写后端接口判空逻辑时如果用 if (data) 来判断一个数组是否为空结果永远为真你还要再用 data.length 进一步判断。4.2 switch 的穿透现象与类型比较switch 语句有一个非常古老却仍然被很多人忽略的问题case 之间的穿透。当某个 case 匹配成功后如果没有 break 终止程序会继续执行下一个 case 的代码块。这种设计在某些场景下是有意为之的比如多个 case 共享同一段逻辑但更多时候是因为漏写 break 导致的意外。const day 2; switch (day) { case 1: console.log(周一); case 2: console.log(周二); default: console.log(默认); } // 输出周二、默认上面这个例子case 2 匹配后没有 break程序继续执行了 default。这种 bug 在代码审查里出现的频率非常高。另外需要特别指出的是switch 的比较使用的是严格相等 不会做类型转换。所以 switch(2) 永远不会匹配 case 2因为一个是字符串一个是数字。写 switch 的规范是每个 case 必须以 break、return、throw 中的一种结束default 永远放在最后并且不要省略。能坚持这个规范switch 相关的绝大多数问题都可以避免。4.3 循环for-in、for-of、forEach 怎么选JavaScript 里有很多遍历方式每种适用的场景完全不同。for 循环最原始但最灵活可以控制循环变量、条件、累加方式还能用 break 提前终止。for-in 用来遍历对象的键名但要特别注意它会继承原型链上的可枚举属性而且遍历顺序在不同环境下并不保证一致实际开发中已经很少直接使用。for-of 是 ES6 引入的遍历方式适用于数组、字符串、Set、Map 等可迭代对象直接获取元素值而不是索引语义清晰。但 for-of 在普通对象上不能直接使用因为普通对象默认不是可迭代的。forEach 是数组的高阶方法写法简洁但不能在回调中用 break 中断要中断还得靠抛出异常或者改用 for-of。const arr [10, 20, 30]; for (const item of arr) { if (item 20) break; // 可以中断 console.log(item); // 10 } arr.forEach((item) { if (item 20) return; // 只结束本次回调循环继续 console.log(item); // 10, 30 });我个人的选择策略是数组遍历优先用 for-of 或 forEach需要索引就用传统的 for 循环需要中断用 for-of对象遍历用 Object.keys() 配合 for-of永远不要用 for-in 遍历数组。这套策略可以让遍历代码既清晰又高效。5. 函数基础声明方式、作用域链与参数陷阱5.1 函数声明与函数表达式的区别JavaScript 中创建函数的方式主要有两种函数声明和函数表达式。函数声明使用 function 关键字直接定义它会被整体提升因此在声明之前调用也不会报错。函数表达式则是把函数赋值给一个变量变量声明会被提升但赋值不会所以在赋值之前调用会报类型错误。sayHello(); // 输出 hello函数声明被提升 function sayHello() { console.log(hello); } greet(); // 报错greet is not a function const greet function () { console.log(hi); };这个差异在编码规范上有很大的影响。现代代码风格普遍推荐使用函数表达式或箭头函数而不是函数声明因为这样可以确保函数在定义之后才被使用代码的执行顺序更可预测。箭头函数是 ES6 引入的简化写法它没有自己的 this也不适合作为构造函数使用基础语法阶段先记住这些差异深入理解 this 的细节需要在后续笔记里单独展开。5.2 arguments 与剩余参数在普通函数内部有一个隐式的 arguments 对象它包含了调用时传入的所有参数即使函数声明里没有对应的形参。arguments 是一个类数组对象有 length 属性但没有数组的方法。过去我们想把 arguments 转成真数组需要写 Array.prototype.slice.call(arguments)非常绕。ES6 之后引入了剩余参数语法直接用 ...args 接收多余参数得到的就是一个真数组function sum(...numbers) { return numbers.reduce((total, num) total num, 0); } console.log(sum(1, 2, 3)); // 6剩余参数不仅写法简洁语义也更清晰。需要注意剩余参数必须是函数的最后一个形参否则解析会报错。在箭头函数中不能使用 arguments 对象但可以使用剩余参数这进一步强化了剩余参数在现代代码中的主导地位。5.3 作用域链与闭包的底层逻辑函数执行时查找变量的过程是沿着作用域链逐级向上搜索的。作用域链本质上是一个嵌套的函数作用域结构内层函数可以访问外层函数的变量但外层函数不能访问内层函数的变量。这个机制是理解闭包的基础。闭包指的是函数与其定义时所在作用域的组合。当内层函数引用了外层函数的变量并且在外层函数返回之后继续存活时就形成了闭包。经典案例就是计数器function createCounter() { let count 0; return function () { count; return count; }; } const counter createCounter(); console.log(counter()); // 1 console.log(counter()); // 2createCounter 返回的函数引用了外层变量 count即使外层函数已经执行完毕count 依然存活在闭包中。这个特性让函数拥有私有状态但同时也意味着被闭包引用的变量不会被垃圾回收如果大量使用闭包内存占用会随之增加。理解闭包的关键就是搞清楚变量查找顺着作用域链走而不是按调用栈走。6. 字符串、数组和对象的基础操作与常用套路6.1 字符串不可变与常用方法字符串是原始类型一旦创建就不可修改。你以为在修改字符串的方法实际上都是返回一个新字符串原字符串保持不变。这个特性与其他语言中的可变字符串类型有本质区别。let str hello; str.toUpperCase(); console.log(str); // hello原字符串未变 const upper str.toUpperCase(); // 必须接收返回值 console.log(upper); // HELLO字符串的常用方法分成几类查找类包括 indexOf、includes、startsWith、endsWith截取类包括 slice、substring、substr其中 substr 已废弃不建议再使用分割合并类包括 split、join大小写转换类包括 toUpperCase、toLowerCase。还有一个非常常用的模板字符串特性用反引号包裹内容可以在字符串中嵌入变量const name 张三; const greeting 你好${name};模板字符串还支持多行文本不需要手动拼接换行符这在生成长文本或 HTML 模板片段时尤其好用。6.2 数组的增删改查与高阶方法数组是最常用的数据结构。增删操作包括 push、pop、shift、unshift 四个基础方法涉及的方法非常多初始阶段需要区分哪些方法会修改原数组哪些会返回新数组。push 和 pop 在数组尾部操作shift 和 unshift 在数组头部操作它们在性能和语义上完全不同。数组头部操作的时间复杂度是 O(n)因为需要移动所有元素日常处理大数据量时要避免频繁使用 shift 和 unshift。数组的高阶方法是函数式编程风格的精华最常见的是 map、filter、reduce 三个。map 对每个元素执行函数并返回新数组filter 根据条件过滤元素并返回新数组reduce 把数组归约为一个值。这三个方法配合箭头函数可以替代大量传统的 for 循环代码const nums [1, 2, 3, 4, 5]; const doubled nums.map((n) n * 2); // [2, 4, 6, 8, 10] const evens nums.filter((n) n % 2 0); // [2, 4] const total nums.reduce((sum, n) sum n, 0); // 15使用这些高阶方法时一个常见的错误是在 map 或 filter 的回调里忘记写 return。箭头函数如果函数体用了花括号就必须显式 return 才能返回值。这是一个频率极高的低级错误我自己也踩过好多次代码不报错但结果永远是 undefined 或空数组排查时要第一时间想到这个可能性。6.3 对象属性的增删与遍历方式对象是 JavaScript 中最灵活的容器。新增属性直接赋值即可删除属性使用 delete 运算符。delete 的返回结果是布尔值表示是否成功删除但有很多边界情况会导致返回值为 true 却没有真正删除比如属性本身是不可配置的。基础语法阶段先记住基本用法即可。对象的键名会被自动转换成字符串所以 obj[1] 和 obj[1] 是同一个属性。Symbol 作为键名时不会被转换成字符串。遍历对象属性有几种方式Object.keys() 返回自身的可枚举键名数组Object.values() 返回值数组Object.entries() 返回键值对数组。这三种方法在现代代码中已经完全取代了 for-in 循环。const user { name: 李四, age: 30 }; Object.keys(user).forEach((key) { console.log(key, user[key]); }); Object.entries(user).forEach(([key, value]) { console.log(key, value); });解构赋值是 ES6 提供的又一利器它可以快速从对象或数组中提取值到独立变量。对象解构按属性名匹配数组解构按位置匹配。配合默认值语法还能优雅地处理属性缺失的情况const { name, age 18 } user; const [first, second, ...rest] [1, 2, 3, 4, 5]; console.log(name, age); // 李四 30 console.log(first, rest); // 1 [3, 4, 5]解构赋值在函数参数接收对象时最常用可以在函数签名里直接提取需要的属性代码瞬间变得精简且意图清晰。7. 初学阶段最值得养成的三个好习惯7.1 用严格模式约束自己早期 JavaScript 的语法宽松得像没系安全带很多看似无害的写法暗藏隐患。在代码文件或函数顶部加上 use strict; 指令可以让引擎进入严格模式对隐患行为抛出错误而不是静默接受。使用未声明的变量、修改只读属性、重复声明参数等行为在严格模式下都会被打回原形。use strict; undeclared 10; // 报错undeclared is not defined现代 ES 模块和大部分构建工具默认启用严格模式但如果你还在写传统脚本文件养成手动添加严格模式指令的习惯能最大程度避免低级的语法陷阱。7.2 在浏览器控制台里多动手实验基础语法光看不练很难真正形成直觉。我学每一节内容时都会打开浏览器开发者工具的控制台手动敲一遍示例代码故意改参数观察输出变化。尤其是在理解隐式转换和运算符优先级时多做实验比背规则表有效得多。控制台还能直接查看对象的原型链和属性结构对理解引用类型的内部机制非常有帮助。console.log(0.1 0.2); console.log(typeof null); console.log([] []);对每一行输出都问自己“为什么”回答不上来就去查文档这个过程比任何教程都扎实。我翻看过很多初学者的代码大部分错误的根源其实都是少做这一步实验。7.3 用 lint 工具提前拦截低级错误lint 工具可以在代码运行之前发现很多基础语法层面的问题。未使用变量、隐式类型转换、 宽松比较、嵌套三元表达式、console 残留等都能给出警告或错误提示。虽然基础语法学习阶段可以暂缓配置复杂的规则集但提前体验 lint 的反馈机制能让你在第一时间知道哪些写法是不被推荐、容易出问题的比踩坑之后再回头改要高效得多。我到现在依然记忆深刻的一次教训是在一个项目里因为 number 和 string 的宽松比较导致误判排查了三小时才定位到问题。从那之后我写任何 JavaScript 代码都不会用 这个习惯帮我避掉了大量潜在的逻辑错误。基础语法阶段的学习重点不是把所有规则背得滚瓜烂熟而是建立一套针对代码行为的直觉判断体系知道哪些写法容易出问题、出问题时如何快速定位、为什么会有这样的设计。这份笔记只是第一篇后面的函数进阶、对象原型、异步机制等内容都是在这个基础上逐层展开的。扎实的地基才是走得更远的前提。