
先把话说在前面JavaScript 这门语言很多初学者都有一种“奇怪的熟悉感”——每个单词都认识放到一起却不知道它到底干了什么。问题往往不是出在语法本身而是出在一个最基础、最容易被忽略的概念上语句。你写的每一行 JavaScript 代码本质上都是在编排一条条语句让它们按照你的意愿顺序执行、选择执行、循环执行、或者在出错时安全退出。语句就是 JavaScript 的最小执行单元是整个逻辑流程的承重墙。这篇内容我想把 JavaScript 的语句体系从头到尾捋一遍包括变量声明、条件分支、循环遍历、异常处理、以及那些平时不怎么用到但偶尔会“杀人”的冷门语句。适合刚入门、正在啃基础语法的人也适合已经写了几年、但对某些细节比如 try 里的 return 和 finally 谁说了算还没搞清楚的开发者。我会尽量用大白话加实际代码来讲保证你看完能直接用到项目里。1. 先搞清楚语句和表达式的区别就是代码的分寸感1.1 表达式有值语句有“任务”很多人混着用“语句”和“表达式”其实它们的区别很简单一句就能说清表达式expression可以计算出一个值语句statement执行一个动作。比如1 1是一个表达式你把它丢给浏览器控制台它能返回2但1 1;这条语句虽然也是一个合法语句却没有“返回结果”这种说法——语句不产生值它只是执行完就结束了。变量声明let a 1 1整体是一条声明语句右侧的1 1是表达式语句负责把表达式的计算结果存进变量。理解这个区别有什么用呢最大的用处是当你要判断某段代码能不能作为“值”被传递、被 return、被放进模板字符串里时你就知道“语句不行表达式才行”。// 表达式可以作为参数传递 const sum add(1, 2); // 语句不能作为参数传递 // const result if (a b) { a } // 报错if 是语句不是表达式如果你写过 React 或 Vue应该对“JSX 的花括号里只能写表达式”这条规则很有感觉。背后的原因就是表达式有值、语句不产值。1.2 分号、ASI 与 return 换行陷阱语句最常见的边界问题是分号。JavaScript 有一个自动分号插入机制ASIAutomatic Semicolon Insertion它会在你漏写分号时“自作主张”地帮你补上。大部分情况下它挺聪明但少数情况下会坑你。最经典的坑就是 return 后面换行function foo() { return { name: holarain }; } console.log(foo()); // undefined而不是 { name: holarain }ASI 看到return后面换行了就自动补了一个分号于是函数变成了return;后面的对象根本不会被返回。这个坑从 ES5 时代一直坑到现在所以项目规范里几乎都会有一条return 和返回值写在同一行。还有[开头的语句也有类似问题。假设上一行结尾没有加分号下一行以[开头ASI 可能把两行合起来解析导致意想不到的结果。所以我现在写代码的策略是要么老老实实加分号要么像 Prettier 这类工具一样统一“不加分号但是保证项目里全靠工具兜底”。但无论选哪种你都得知道 ASI 的存在别在 return、throw、break、continue 后面裸换行。1.3 语句的分类全家福JavaScript 里的语句大概可以分成下面这几类后面每一类我都会单独展开分类代表性语句一句点评声明语句var、let、const、function、class、import给变量、函数、模块“上户口”条件语句if/else、switch让代码长眼睛看情况走分支循环语句for、while、do-while、for-in、for-of把重复劳动交给机器跳转语句break、continue、return、throw、label改变执行流程的方向异常处理语句try/catch/finally让报错变得可控、可商量杂项语句debugger、with、空语句要么废弃要么只在特殊场景用看到这个全家福你心里应该有个底所谓“语法基础”很大程度就是在熟悉这些语句的脾气。接下来按顺序逐个过一遍。2. 声明语句变量、常量和函数的进场规则2.1 const 优先、let 兜底、var 退役变量声明是 JS 里最常用的语句没有之一。现状一句话const优先let兜底var能不用就不用。var的问题在于它的作用域是“函数级”不是“块级”。这意味着在 for 循环里用var声明的变量循环结束后还能在外面访问到这跟大多数现代语言的习惯不一样也是闭包问题的经典来源for (var i 0; i 3; i) { setTimeout(() console.log(i)); // 输出 3 3 3 } for (let j 0; j 3; j) { setTimeout(() console.log(j)); // 输出 0 1 2 }let是块级作用域每一次迭代都会创建一次独立的j绑定所以异步回调拿到的值是“那一次迭代的值”。var则只有一个贯穿整个函数的绑定循环结束后它已经被加到了 3回调拿到的自然就是 3。const和let在作用域规则上完全一样区别只在const声明时必须赋值而且不能对绑定重新赋值。注意它只是不能重新绑定变量名不是不能修改对象内部的属性const config { url: /api }; config.url /api/v2; // 合法const 管得了绑定管不了对象内部 config {}; // 报错Assignment to constant variable我的习惯是能用const的地方全部const确实需要重新赋值才换let。这不仅是风格问题还能帮你早期发现“这个变量是不是被我不小心重新赋了值”这类逻辑错误。2.2 函数声明与函数表达式的差异函数有两种声明方式写法上就差几个字行为上差别很大// 函数声明 function greet() { return hello; } // 函数表达式 const greet2 function () { return hello; };函数声明会被“提升”hoisting也就是说你可以在它定义之前调用它。因为 JavaScript 引擎在编译阶段就把函数声明挂到了当前作用域顶端。函数表达式则遵守变量的规则必须定义之后才能调用否则就报Cannot access greet2 before initializationlet/const 的情况或者not a functionvar 且未赋值的情况。实际开发里我更偏爱函数表达式尤其是const fn () {}这种箭头函数写法因为它把函数当成值来使用语义更清晰也避免了一些“函数提升掩盖了初始化顺序问题”的隐患。不过在需要递归调用的场景比如遍历树结构命名函数声明反而更方便因为函数内部可以用自己的名字。2.3 声明提升与暂时性死区面试常问的两块硬骨头var有提升let、const也有提升但有一区之别。var提升时会被初始化为undefined所以你在声明前访问不会报错只是拿到一个 undefined。let/const提升时不会初始化所以在“声明前的位置”访问会直接报 ReferenceError这段区域就叫“暂时性死区”TDZTemporal Dead Zoneconsole.log(a); // undefinedvar 的“提升”带默认值 var a 1; console.log(b); // ReferenceError: Cannot access b before initialization let b 2;这个设计其实是为了“尽早暴露错误”。如果变量还没初始化就能用你拿到的多半是 undefined 或 0 这类诡异默认值错误反而不容易被发现。TDZ 就是告诉开发者先声明再使用。放在工程实践里我建议把所有声明尽量集中到作用域顶部一是视觉上清爽二是不小心踩到 TDZ 的概率也会降低。3. 分支语句让代码长出判断力3.1 if/else 的分支组织与悬空 elseif/else 是日常写最多的语句没有之一。最基础但最容易翻车的点是“省略大括号”。JavaScript 允许 if 后面只跟一条语句if (a b) console.log(a 大); console.log(b 大); // 这行是独立语句不管条件成不成立都会执行第二个console.log虽然缩进了但它根本不属于 if 分支。这种代码非常误导人所以我建议不管 if 里面只有一行还是在开发中一律加大括号。而且加了大括号之后后续要往分支里塞代码时也更安全不会出现“忘了括号导致逻辑错误”的问题。“悬空 else”dangling else是另一个经典陷阱。当一个 if 后面嵌套了另一个 if-else而这个 else 前面有两个 if 时else 会就近匹配if (a) if (b) { console.log(a 且 b); } else { // 这个 else 匹配的是 if (b)不是 if (a) console.log(a 且非 b); }如果你想表达“非 a”这个分支必须用大括号把内层的 if 包起来if (a) { if (b) { // ... } } else { // 非 a }我的经验是一旦 if/else 的嵌套超过两层就不要硬叠了。要么拆成单独的子函数要么用 switch 或查表法对象映射替代。代码是给人读的嵌套地狱读一遍就头大。3.2 switch 的穿透行为与严格相等比较switch 的格式很简单switch (status) { case pending: // 处理草稿状态 break; case published: // 处理已发布状态 break; default: // 其他状态走这里 }它有两个容易踩的坑。第一是“fall-through”穿透如果某个 case 结尾漏了break执行会直接掉进下一个 case而且不收任何检查费。有些老手会故意利用穿透合并多个 case比如case 1: case 2: case 3:一起处理这没问题但一定要在代码里注释说明“故意穿透”否则后来维护的人大概率以为是你忘了写 break。第二是 switch 用的是严格相等比较不是宽松比较。这意味着switch (1)不会匹配case 1类型不同就是不同。在浏览器或 Node 里常常会收到字符串类型的参数如果你习惯性地写case 1就会直接掉进 default这类问题排查起来还挺隐蔽的。3.3 代替小分支的三元表达式与逻辑短路在需要“二选一”或者“取默认值”的时候可以用三元表达式const tip isLogin ? 欢迎回来 : 请先登录;三元是表达式可以直接赋值给变量比写一整个 if/else 简洁得多。但有个原则三元只适合简单分支如果嵌套三元超过一层阅读成本剧增直接换 if/else 或者提前 return。逻辑短路也是个实用的分支技巧。和||本身是逻辑运算符但它们的求值顺序可以用来做条件执行// 等价于 if (user user.name) { ... } user console.log(user.name); // 等价于 if (!cache) { cache data; } const list cache || fetchData();a || b的意思是 a 为真就用 a否则用 ba b的意思是 a 为真才执行 b。这类写法在取默认值、可选链兜底时非常常见但要注意别写成脑筋急转弯式的长串比如a b || c d这种逻辑太绕不如拆开用 if。4. 循环语句重复劳动交给机器4.1 for、while、do-while 的适用场景循环是处理重复任务的主力。经典for适合那种“知道要循环多少次”的场景for (let i 0; i list.length; i) { // ... }while适合“不知道多少次只要条件成立就继续”的场景。一个典型例子是读取流数据或者轮询某个状态直到达标let retry 0; while (retry 5 !isSuccess) { retry; // 尝试请求 }do-while比while多了一层“先执行一次再判断”的语义但它实际用得很少因为大多数情况下“至少要执行一次”可以写成“循环体前先执行一次”再加 while。我见到的大部分 do-while 代码其实都能用 while 写得更清楚。唯一可能要用的场景是比如生成不重复的随机数至少要先随机一次再判断是否和已有值冲突。4.2 for-in 与 for-of 的遍历差异很多新手分不清for-in和for-of一句话区分for-in遍历的是“键名”for-of遍历的是“键值”。const user { name: HoRain, age: 1 }; for (const key in user) { console.log(key); // name、age } for (const value of Object.values(user)) { console.log(value); // HoRain、1 }for-in有一个容易踩的坑它会把原型链上可枚举的属性也遍历出来。所以在遍历对象时要么配合Object.prototype.hasOwnProperty.call(obj, key)过滤要么干脆用Object.keys(obj)拿键数组再遍历。for-of可以作用于任何“可迭代对象”包括数组、字符串、Set、Map以及生成器。它比传统 for 写法更简洁而且在循环体内用break、continue也完全没问题。要注意的是普通对象默认不是可迭代的所以直接for (const v of obj)会报obj is not iterable得先转成Object.values(obj)或Object.entries(obj)。4.3 break、continue 和 label 跳转控制break是“终止整个循环”continue是“跳过当前这一次迭代直接进入下一次”。这两个比较简单。还有一个很少用但面试偶尔会问的label标签语句它的作用是可以跳出多层嵌套循环outer: for (let i 0; i 3; i) { for (let j 0; j 3; j) { if (i * j 2) { break outer; // 跳出两层循环 } } }不用 label 的替代方案是把循环逻辑抽成一个函数用return来提前退出。我个人在项目里几乎不用 label因为它的可读性太差了而且一旦嵌套深了团队里很容易有人改出问题。能用 return 就用 return。5. 异常处理语句让报错变得可以商量5.1 try/catch/finally 的执行顺序与返回值覆盖try/catch/finally三个块执行顺序有个硬规则try先执行如果抛错就进catch无论有没有错误都会执行finally。容易忽略的细节是finally的执行在“函数返回”之前function demo() { try { return from try; } finally { console.log(finally 一定执行); } } console.log(demo()); // 输出finally 一定执行 // 输出from try这里有一个极具迷惑性的版本如果finally里也有 return它会覆盖 try 里的返回值。function demo2() { try { return from try; } finally { return from finally; } } console.log(demo2()); // from finally这个行为非常反直觉而且很容易在代码审查时漏掉。我的建议是不要在finally里写 return也不要在 finally 里做复杂的逻辑修改它只适合释放资源、关闭连接、重置状态这类“善后”工作。catch也有一点值得注意早期的 JavaScript 语法里 catch 必须接收一个参数比如catch (e)但现在ES2019 起可以省略参数try { // ... } catch { // 我不关心错误对象只关心别中断比如上报日志 }这在你“只管兜底不具体处理错误”时比较方便但生产中我还是建议尽量拿到错误对象因为你至少得知道是什么原因出的错。5.2 throw 的正确姿势与自定义错误throw是抛出异常的语句。一个很容易忽略的点是你可以 throw 任何东西字符串、数字、普通对象都能扔出来。但这非常不专业。因为如果你 throw 一个字符串堆栈信息丢了调用方catch (e)里拿到一个字符串也不知道该怎么处理。标准做法是 throw Error 实例或者自定义错误类class ValidationError extends Error { constructor(message) { super(message); this.name ValidationError; } } function validateAge(age) { if (age 0) { throw new ValidationError(年龄不能为负数); } }这样处理的好处有三个一是e.message稳定可读二是错误有堆栈信息排查问题方便三是调用方可以靠e.name或者instanceof区分错误类型决定是“参数有问题”还是“服务端 500”。我在后端接口层处理业务校验时经常用这个模式业务错误和系统错误分开处理日志也更好定位。6. 冷门语句与工程实战把语句组织成能上线的代码6.1 with、debugger、空语句等“小众”语句JavaScript 里有些语句你平时根本不会用但看到时要认识它。with语句可以改变作用域链把对象的属性当成变量直接用const obj { a: 1, b: 2 }; with (obj) { console.log(a b); // 3 }听着很爽但代价极大引擎没办法在编译期确定变量到底来自哪里性能优化全部失效而且代码语义不清晰。这个语句在严格模式里直接禁用日常项目也绝对不要用。debugger语句会在代码执行到这里时触发调试断点前提是浏览器或 Node 的调试工具开着。它适合临时加在“想停下来看变量”的位置但上线前必须清理否则用户开着 DevTools 就会看到断点。空语句就是只有一个分号;。它本身什么都不做最常见的使用场景是配合循环写“空循环体”。但我极度不推荐因为代码审查时极易被认为是多余分号而且空循环往往意味着“忙等待”几乎总是可以改成其他写法。还有import和export它们是模块化时代的“声明语句”。在模块环境里import要写在文件顶层不能嵌套在条件分支里。这一点和 CommonJS 里的require不同后者本质上是个函数调用可以在 if 里面调用。ES Module 的静态结构设计是为了让打包工具能做 tree-shaking所以“顶层静态导入”是硬规则。6.2 一个真实场景登录校验函数中的语句组织说了这么多理论来个组合拳。假设你要写一个用户注册的校验逻辑里面会用到 if、for-of、throw、try/catch以及 const 声明。一个靠谱的写法是这样的class ValidationError extends Error { constructor(message) { super(message); this.name ValidationError; } } function validateRegisterInput(input) { const { username, password } input; if (!username || username.trim() ) { throw new ValidationError(用户名不能为空); } if (password.length 8) { throw new ValidationError(密码长度至少 8 位); } const illegalChars [--, /*, ;, ]; for (const char of illegalChars) { if (password.includes(char)) { throw new ValidationError(密码不能包含非法字符${char}); } } return { valid: true }; } try { const result validateRegisterInput({ username: holarain, password: 12345678 }); console.log(result); } catch (error) { if (error instanceof ValidationError) { console.error(校验失败, error.message); } else { console.error(系统错误, error.message); } }这段代码没有用任何高深技巧但语句组织是清晰的。核心逻辑就是“抢在错误前面 throw”一旦校验不通过函数立刻退出后面的代码根本不会执行。这种“快速失败”模式比层层嵌套 if/else 好读得多。6.3 语句层面的代码规范建议从语句的角度我给团队定的规范大概有这么几条所有 if/else、循环体都写大括号哪怕只有一行。const优先let兜底var禁止lint 直接开 error。不要在finally里 return 或修改返回结果。switch的 fall-through 必须注释说明。不要用with严格模式下它本身就报错。每一条语句尽量只做一件事不要一行塞三四个逻辑。这些规范在 ESLint 里几乎都有对应规则配置好以后很多坑在提交代码前就能被拦下来。语句是代码的最小骨架骨架歪了后面填再多业务逻辑也是危房。7. 常见报错与排查技巧实录7.1 SyntaxError、ReferenceError、TypeError 速查运行 JavaScript 时报错大概率就在这三类错误里。记住了排查问题会快很多。错误类型典型触发语句排查方向SyntaxError语句写错、括号没闭合、漏了冒号最简单语法错误连执行都到不了ReferenceError访问了未声明的变量、TDZ 区域内的变量查变量声明顺序、作用域、是否拼错TypeError对 undefined/null 调方法、对普通对象调用函数查“值”是否为空类型是否是预期类型最常见的其实是第三个。Cannot read properties of undefined (reading xxx)这行报错翻译成人话就是有一个变量是 undefined你还在它身上取属性。排查思路就是往报错堆栈里看定位到哪一行再看那个变量在之前是不是可能没被赋值。7.2 实战排错从运行时报错反推语句问题给大家一个真实排错思路。假设报错长这样TypeError: skills.map is not a function如果你看到xxx.map is not a function第一反应不是怀疑map本身而是怀疑skills数据类型。可能后端返回的是{ skills: js }而不是{ skills: [...] }也可能你在初始化时没给默认值。一个稳妥的防御写法是const skills data.skills ?? []; skills.map(...)在语句层面这背后的规则是当你不确定一个值的类型时先用自己的语句把它“规整”成你能处理的结构再执行拆解、遍历等操作。你会发现很多运行时错误其实不是“JS 坏了”而是“某条语句在运行前没有安排好前置条件”。7.3 用语句覆盖率白盒测试里的语句覆盖检验代码质量最后提一个测试相关、但和语句密不可分的概念语句覆盖Statement Coverage。白盒测试里有一种要求是所有可执行语句至少被执行一次。它看起来很简单但能覆盖很多低级错误。比如我们的validateRegisterInput函数如果只写了一个正常用例可能很多分支语句根本没跑过。测试框架如 Vitest、Jest能报告代码覆盖率但别只看“覆盖率 85%”就以为万事大吉。语句覆盖只代表“每一行都执行过”不代表“每个分支的方向都执行过”。最好再配合分支覆盖把 if 的两个方向都走到把 catch 的错误分支也走到。我个人在项目里的习惯是做一个函数前先想清楚它的语句有几个出口。return 语句多不是坏事反而说明分支清晰。最怕的是那种只有一个大 return、中间全是“改一个状态变量”的写法那种代码改起来像走迷宫。语句层面的“单入多出”比“单入单出”往往更易读。写 JavaScript 写了这么多年最大的体会是你不需要记住每一句语法的官方定义但要形成一套自己的“语句肌肉记忆”。看到 if 先想有没有 else看到循环先想会不会死循环看到 try 先想 finally 里能不能碰返回值看到 var 就想把它换成 let 或 const。这些细节单独拎出来都不起眼但合在一起就是代码质量和排查效率的分水岭。希望这篇把 JavaScript 语句从入门到精通的梳理能给你之后写代码时多个抓手。