JavaScript这门语言,从最初只能在浏览器里做表单校验的脚本,一路发展成现在前后端通吃的常青树,基础语法反而是很多人忽略的部分。我见过不少写了几年JS的同学,框架API用得很溜,但一追问var和let到底差在哪、为什么[] + []是空字符串,就开始含糊。这些基础问题平时不显山露水,一旦遇到复杂业务逻辑、性能优化或者多端适配,就会跳出来咬人。
这篇内容我打算把这些基础语法里的关键细节、容易踩坑的行为、以及背后的设计逻辑都过一遍。不是教科书式的罗列,而是按照实际开发中真正会遇到的场景来讲。适合刚入门想系统打基础的同学,也适合写了一阵子想查漏补缺的人。读完你会发现,JS的语法看似松散,其实每个特性背后都有明确的设计意图,理解了这些意图,记语法就不需要死记硬背了。
1. 变量声明:var、let、const之间的取舍细节
变量声明是接触JS的第一个知识点,但也是第一个容易栽跟头的地方。很多人一开始只用var,后来知道有let和const,就机械地全都换成const,至于为什么这么换、三者底层行为有什么差异,并没有深究。这一节我们把三个声明方式放在一起对比着看。
1.1 从var到let:作用域规则的变化
var声明的作用域是函数级,这意味着在for循环或者if代码块里用var声明的变量,循环结束后依然存在,而且能被外部访问到。这在实践中会引发一个经典问题:循环内创建的函数,捕获的永远是同一个变量。
for (var i = 0; i < 5; i++) { setTimeout(function() { console.log(i); // 输出 5 个 5,而不是 0,1,2,3,4 }, 100); }这段代码的行为让很多新手困惑。原因在于var i的函数作用域只有一层,循环并不会为每次迭代创建独立的作用域,setTimeout回调执行时读取的是同一个i,而循环已经结束,i变成了5。而把var换成let之后,for循环的每次迭代都会创建一个新的块级作用域绑定,所以回调捕获的是每次迭代独立的i值,输出就是0到4。
除了块级作用域,let还消灭了变量提升带来的“未声明先使用”问题。用var声明变量时,声明会被提升到作用域顶部,但赋值没有提升,所以代码里出现“先使用后声明”时不会报错,只会得到undefined。这种隐晦行为容易掩盖真实的代码逻辑错误。let采用了暂时性死区的设计,在声明语句执行之前访问变量会直接抛出ReferenceError,等于在语法层面帮我们拦截了一类低级错误。
1.2 const到底锁住了什么
const是“常量”的缩写,很多人以为它代表值不可变。实际理解应该是:const锁住的是变量名与内存地址的绑定关系,或者说这个绑定不可重新赋值,但绑定指向的对象内部结构是自由的。
const obj = { name: 'hello' }; obj.name = 'world'; // 合法,对象属性可以修改 obj = { name: 'new' }; // 报错,不能重新绑定数组同理,const arr = []之后,arr.push()、arr[0] = 1都是允许的,唯独arr = somethingElse不行。这个设计取舍背后的逻辑是:在工程实践中,大部分变量我们只希望它被赋值一次,而对象和数组的内部修改是常态。如果const锁死整个对象,那开发效率会大打折扣,JS的很多用法都会变得很别扭。
我在实际项目里经常见到有人过度使用const,把所有声明都写成const,哪怕后面明明要重新赋值,结果运行报错,只好改成let。这倒不是什么大问题,但理解const的本质之后,你就可以更从容地决定何时使用它,而不是靠试错。
1.3 实际项目中的声明规范
基于上面的差异,我个人推荐的声明策略很清晰:默认全部用const,只有当变量确实需要重新赋值时才改用let,var在业务代码里不再使用。这样写出来的代码有几个明显好处:第一,代码阅读者一眼就能区分哪些绑定是稳定的、哪些是可变的,逻辑意图更明确;第二,块级作用域避免了变量泄漏到外部,减少命名冲突;第三,配合let的暂时性死区,很多低级错误在运行前就被拦截。
const appName = 'demo'; let count = 0; count = count + 1; // 只有这里才需要 let2. 数据类型与隐式转换:搞懂JS的“性格”
JavaScript的数据类型设计在整个编程语言生态里都算比较特别的。它既有基本类型的简单直接,又有引用类型的灵活多变,最让人头疼的是隐式转换的规则——各种类型在一起运算时,JS会自动进行类型转换,而这些转换的结果经常超出直觉。
2.1 七种数据类型快速梳理
ECMAScript标准定义了七种原始类型加一种引用类型。原始类型包括:number(数字,包含整数和浮点数以及NaN)、string(字符串)、boolean(布尔值)、null(空值)、undefined(未定义)、symbol(唯一标识符)、bigint(大整数)。引用类型主要是object,往下细分又有数组、函数、日期、正则等。
用typeof运算符可以查看类型,但要注意几个历史遗留的坑:
| 表达式 | typeof结果 | 说明 |
|---|---|---|
typeof 42 | "number" | 数字类型 |
typeof 'abc' | "string" | 字符串类型 |
typeof true | "boolean" | 布尔类型 |
typeof undefined | "undefined" | 未定义 |
typeof null | "object" | null被误判为object |
typeof [] | "object" | 数组也是object |
typeof function(){} | "function" | 函数有独立判断 |
typeof null === "object"是语言早期的bug,但因为无数代码已经依赖这个行为,改不掉了。所以判断null要用value === null这种全等比较。
2.2 隐式转换最容易出错的场景
隐式转换最典型的场景是加号+运算符。JS的+既做数学加法也做字符串拼接,规则是:如果有一个操作数是字符串,就把另一个也转成字符串做拼接;如果没有字符串,才尝试做数字加法。
console.log(1 + '2'); // "12",字符串拼接 console.log(1 + 2); // 3,数字加法 console.log('1' + 2); // "12" console.log(1 + 2 + '3'); // "33",先算数字加法再拼接这里有个容易产生歧义的点:1 + 2 + '3'输出的是"33"而不是"123",因为加法从左到右结合,先执行了1 + 2 = 3,再和'3'拼接。这种顺序依赖在实际开发里经常埋雷,尤其是拼接口参数或者动态生成DOM字符串的时候,很容易因为类型没对齐拼出错误的字符串。
等号运算符也比较容易踩坑。==会做隐式转换再比较,===要求类型和值都相同才返回true。比如null == undefined是true,0 == ''是true,false == 0也是true。虽然==在特定场景下有它的用途,但在工程代码里原则上全程使用===可以省掉一整类“明明值看起来一样却不相等”的问题。
2.3 类型判断的三种姿势
判断类型有多套方案,选哪种取决于你要判断什么。对于原始类型,typeof足够用。对于数组,Array.isArray()是专门的方法,也是最可靠的。对于其他对象类型的细分判断,推荐用Object.prototype.toString.call():
const date = new Date(); console.log(Object.prototype.toString.call(date)); // "[object Date]" const str = 'abc'; console.log(Object.prototype.toString.call(str)); // "[object String]" const arr = [1, 2]; console.log(Object.prototype.toString.call(arr)); // "[object Array]"这个方法的原理是读取对象内部的Symbol.toStringTag标记,几乎所有内置类型都有明确的标记,所以判断结果非常稳定。很多第三方库的底层类型判断都是这么实现的。
另外要特别提一下instanceof的局限。它检查的是原型链,所以对于跨环境(比如iframe之间)的对象,可能判断不准确,而且原始类型用instanceof没有意义。实际开发中,优先使用上述方案,instanceof主要用于判断对象是否属于某一个自建类。
3. 函数:从声明方式到this指向的完整解读
函数是JS的一等公民,这一点怎么强调都不过分。理解函数,不仅要掌握调用的语法,还要明白声明方式、参数处理、以及this的绑定规则。这节内容是对日常写代码影响最大的部分。
3.1 函数声明与函数表达式:提升规则的差异
函数有两种基本的定义方式。一种是函数声明:
function sayHi() { console.log('hi'); }另一种是函数表达式:
const sayHi = function() { console.log('hi'); };两者最大的差异在于提升规则。函数声明整体会被提升到作用域顶部,所以你在定义它的代码之前调用也能正常工作。函数表达式本质上是用const声明变量然后赋一个函数,没有整体提升,在赋值之前调用会报错。
foo(); // 正常输出"foo" function foo() { console.log('foo'); } bar(); // 报错:Cannot access 'bar' before initialization const bar = function() { console.log('bar'); };这个差异在大型项目里很重要。函数声明的提升特性让代码可以利用之后的定义,但对可读性和依赖管理是不够友好的,因为隐式的依赖违背了自上而下的阅读习惯。目前主流的代码风格是尽量使用函数表达式配合const声明,让函数的定义顺序就是使用顺序,代码更可预测。
3.2 箭头函数为什么没有自己的this
this是JS里最反直觉的概念之一。普通函数的this在调用时动态确定,谁调用了它,this就指向谁;箭头函数的this在定义时确定,继承的是外层作用域的this。
看一个日常开发高频场景:
const timer = { seconds: 0, start: function() { setInterval(function() { this.seconds++; // this指向window,不是timer }, 1000); } };普通函数在setInterval回调里执行时,调用者是全局环境,所以this指向了全局对象,修改的根本不是timer.seconds。这也是回调函数里this丢失的经典问题。
换成箭头函数,问题迎刃而解:
const timer = { seconds: 0, start: function() { setInterval(() => { this.seconds++; // this继承start外层的词法环境,指向timer }, 1000); } };箭头函数之所以能在大量框架代码中流行,核心原因就是它让this绑定变得可预期。但注意,箭头函数不适合用作对象的方法,因为方法里的this本来就应该指向对象本身,如果用了箭头函数,this反而会指向外层作用域。
3.3 参数默认值与rest参数
ES6为函数参数带来了默认值和不定参数,这两个特性在工程里的使用频率非常高。
默认参数让函数在缺少实参时有兜底行为:
function greet(name = '陌生人') { console.log(`你好,${name}`); } greet(); // 你好,陌生人注意默认参数只对undefined生效,传null不会触发默认值,因为在语义上null是一个有意的空值。
不定参数用...表示,把多余参数收集成一个数组:
function sum(...numbers) { return numbers.reduce((acc, cur) => acc + cur, 0); } console.log(sum(1, 2, 3)); // 6这里一个细节:rest参数和ES5时代的arguments对象区别在于,arguments是类数组,需要用Array.from转成真正数组才能使用数组方法,而rest参数本身就是一个真正的数组。现在写新代码基本可以全面使用rest参数,arguments只在极老的兼容场景里才会见到。
4. 对象、数组与解构赋值:现代开发的语法基石
日常业务逻辑主要就是在操作对象和数组。ES6之后推出的对象字面量增强、解构赋值、扩展运算符,让这部分代码写起来简洁得多,但很多新手并没有完全理解这些语法糖背后的展开规则,导致使用不当。
4.1 对象字面量的增强写法
当属性名和变量名一致时,可以直接简写:
const name = 'demo'; const age = 3; const project = { name, age }; // 等价于 { name: name, age: age }方法也能简写,省略function关键字和冒号:
const obj = { sayHi() { console.log('hi'); } };还有一个容易忽略的语法是计算属性名,可以在对象的字面量里用方括号表达式作为属性名:
const key = 'dynamicKey'; const obj = { [key]: 'value' };这在需要根据变量动态设置属性名的场景里非常实用,以前必须创建完对象后单独赋值,现在可以在声明时直接定义。
4.2 解构赋值:从繁到简的关键
解构赋值是从对象或数组里提取值并赋值给变量的语法。对象解构按属性名匹配:
const user = { id: 1, name: 'alice', email: 'alice@example.com' }; const { name, email } = user; console.log(name); // alice console.log(email); // alice@example.com还可以重命名:
const { name: displayName } = user; console.log(displayName); // alice数组解构按顺序匹配:
const colors = ['red', 'green', 'blue']; const [first, second] = colors; console.log(first); // red console.log(second); // green解构配合默认值在函数参数处理中特别顺手:
function drawChart({ width = 100, height = 100, type = 'bar' } = {}) { console.log(width, height, type); } drawChart({ width: 50 }); // 50 100 bar这里的技巧是解构参数的默认值设为{},这样即使调用时不传任何参数,函数也能正常工作,内部解构本身的默认值才会生效。否则直接解构undefined会报错。
4.3 数组常用方法选型与sort的坑
数组方法繁多,但高频的核心方法就那么几个,选对方法能让代码意图非常清晰。map返回等长的新数组,用于逐元素变换;filter筛选满足条件的元素;forEach用于遍历并执行副作用;find返回第一个匹配的元素;some和every做逻辑判断;reduce做聚合计算。
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 firstEven = nums.find(n => n % 2 === 0); // 2 const total = nums.reduce((sum, n) => sum + n, 0); // 15使用sort时有个特别容易忽略的坑:排序默认按字符串编码顺序,[10, 9, 100]排序后可能得到[10, 100, 9]。必须传比较函数:
const arr = [10, 9, 100]; arr.sort((a, b) => a - b); // 数字升序比较函数返回负数表示a排在b前面,返回正数表示a排在b后面。如果想降序就b - a。这个规则在所有使用比较函数的场景通用。
5. 基础语法常见坑与排查技巧实录
基础语法听起来是“基础”,但真正的经验往往藏在踩坑之后。这一节我把自己见过的典型问题和排查思路整理成一份速查,这些场景在真实项目中出现的频率非常高。
5.1 隐式转换引发的类型事故
前端最常见的类型事故来源是表单输入。所有表单控件的value都是字符串,即使输入框里写的是数字。所以在做加法或比较前,不先转换就很容易出问题。
const inputA = document.getElementById('numA').value; // "5" const inputB = document.getElementById('numB').value; // "3" console.log(inputA + inputB); // "53",不是8转换数字有三套常用方案:Number(str)、parseInt(str)、一元加号+str。它们的差异在于对非纯数字串的容忍度。Number('12px')返回NaN,parseInt('12px')返回12,+str和Number行为基本一致。如果确定输入是纯数字格式,Number最严谨;如果是要从单位字符串里提取数字,parseInt更合适。注意parseInt最好传第二个参数进制基数,以免遇到'08'这类旧式八进制问题。
判断结果是不是数字还有一个常见的细节:NaN不等于任何东西,包括它自己。判断失败要使用Number.isNaN(value)而不是value === NaN。
5.2 作用域和提升导致的幽灵问题
变量提升在复杂代码中经常制造难以定位的bug。比如在条件分支里使用var声明变量,虽然看起来只在某个分支赋值,但因为提升机制,变量在作用域顶部就已经存在,只是值为undefined,条件不满足时读取到的就是undefined而非报错。
function test(flag) { if (flag) { var message = 'ok'; } console.log(message); // 没有报错,输出undefined或'ok' }这类问题在代码审查阶段很难发现,因为人的直觉是“变量声明在if块里,块外面不应该能访问”。解决办法就是把所有变量声明统一放在函数顶部,或者直接用let把变量限制在块级作用域内。
5.3 setTimeout的this指向与异步时序
setTimeout和事件回调里this丢失的问题前面已经提过。另外还有一个容易被忽略的时序问题:setTimeout(fn, 0)并不能保证fn在“当前代码之后立即执行”,它只是把fn放到任务队列里,让它在当前同步代码执行完毕后、浏览器渲染更新前运行。
console.log('script start'); setTimeout(() => { console.log('timeout'); }, 0); Promise.resolve().then(() => { console.log('promise'); }); console.log('script end');实际的输出顺序是:script start、script end、promise、timeout。因为微任务(Promise回调)会先于宏任务(setTimeout回调)执行。在需要调整执行顺序的场景里,这个差异会影响功能实现。虽然这属于异步机制而不是纯语法的范畴,但理解它以后,调试奇怪的执行顺序会快得多。
5.4 新手最容易踩的六个坑速查表
| 坑点 | 错误示范 | 正确解法 | 排查技巧 |
|---|---|---|---|
| 字符串拼接数字 | '1' + 2得到'12' | 先Number('1')再做加法 | 打印typeof确认两端类型 |
| 用==比较 | 0 == ''为true | 全部使用=== | 搜索代码里==改成=== |
| 判断null | typeof null得到"object" | value === null | 直接用全等比较 |
| 数组sort不传比较函数 | [10,9,100].sort()结果不对 | sort((a,b) => a-b) | 打印排序前后数组对比 |
| 回调里用普通函数的this | this指向全局对象 | 改用箭头函数 | 在回调开头加console.log(this) |
| for循环var声明闭包 | 循环输出同一个值 | 改let声明 | 给循环变量打印每次迭代值 |
最后再分享一个我自己的排查习惯:遇到语法相关的诡异问题,第一件事不是猜原因,而是把相关变量的类型用console.log(typeof value)打印出来看一眼。大半问题都是类型不一致导致的,类型对了,很多猜谜式的debug根本不会发生。JavaScript的基础语法范围不算大,但每一个特性都影响深远,花时间把这一层理解扎实,后面学框架、写业务都很顺。代码写多了你会发现,真正决定代码稳定性的,往往不是用了多新的框架,而是这些基础语法有没有用对。