JavaScript 基本语法重学路线:变量、作用域、数组与异步错误处理
我见过不少刚入行的朋友,HTML 和 CSS 写得挺顺,一到 JavaScript 就卡壳,回头翻文档发现每个字都认识,连起来就不知道在说什么。问题往往不在智力,而在于 JS 基本语法这门功课被"跳读"了——大家急着上框架、急着做页面,语法只看了个大概,结果后面写业务时全靠猜和试,报错信息看不懂,调试全靠 console.log 大法。这篇东西就是我想把 JS 基本语法这条线重新捋一遍的产物:不讲花活,只讲变量、类型、运算符、流程控制、函数、对象数组、异步和错误处理这几块地基,把每个关键选择背后的原因说透,把我自己踩过和见别人踩过的坑摊开。不管你是刚学 JS 一两周的新手,还是写了半年 Vue/React 但一直靠"能跑就行"混过来的朋友,照着这条线走一遍,多少都能补上几块漏掉的砖。
1. 重学 JS 基础语法的正确打开方式
1.1 语法不是背下来的,是"对号入座"用出来的
很多人学语法的误区是把 JS 当成一门要背的课,let、const、var的差别抄在笔记上,map和forEach的区别记在小本本里,然后就没有然后了。真正管用的方式不是记忆,而是建立"场景—语法"的映射:当你要写一个不会被重新赋值的配置对象时,脑子里应该自动跳出const;当你要遍历一个数组并且需要返回新数组时,map应该比forEach先冒出来。
这个映射关系怎么建立?我的经验是每学一个语法点,立刻问自己三个问题:它解决什么问题、它在什么场景下会出错、它和长得像的那个东西区别在哪。比如学let的时候,就要立刻对比var,找出"循环里绑定定时器"这个经典场景来验证差异。学===的时候,就要找'1' == 1这种例子来确认隐式转换到底做了什么。语法点只有挂在具体场景上,才会在写代码时被自动调用出来,否则永远是笔记里的一行字。
还有一点很关键:不要试图一次把语法全学完再看框架。语法的价值在于被使用,你学完数组方法之后如果一周不写一次filter,很快就会忘。比较合理的节奏是"学一小块,立刻写三五个小练习把它用掉",用掉之后再看框架源码,你会发现很多以前看不懂的写法突然通了。这不是玄学,是因为框架源码本身就是这些基础语法的高密度组合。
1.2 学习顺序的取舍:哪些必须先啃,哪些可以先放着
JS 的语法范围说大不大,说小也不小。如果按 MDN 的目录顺序从头看,很容易在Symbol、Proxy、生成器这些偏门内容上耗掉大量时间,结果最常用的部分反而没打牢。我一般建议按"使用频率"来排优先级,而不是按文档顺序。
下面的排序是我自己带人时的默认路线,供参考:
| 优先级 | 内容 | 理由 |
|---|---|---|
| P0 | 变量声明、数据类型、类型转换 | 每天都在写,出错率最高 |
| P0 | 运算符,尤其是===、&&、||、?? | 判断逻辑全靠它们,短路特性用得极多 |
| P0 | 函数、作用域、this | 不懂这个,后面所有回调都是黑箱 |
| P0 | 对象与数组常用方法 | 业务代码里 60% 以上的行数在这上面 |
| P1 | 解构、展开、模板字符串、可选链 | 现代写法,能显著减少样板代码 |
| P1 | Promise、async/await、try/catch | 只要涉及请求就绕不开 |
| P2 | 闭包、原型链、事件循环细节 | 面试高频,也是真正理解的标志 |
| P2 | Symbol、Proxy、生成器、迭代器 | 用到再查,不影响日常开发 |
注意这里的"可以先放着"不等于"永远不学",而是说不要在基础不牢的时候硬啃。闭包这种东西,你写三个月业务再回头看,理解成本会低很多,因为它有了具体的语境支撑。
提示:如果你现在连
let和const的区别都说不清楚,就别去看什么"手写 Promise"了,基础不牢的时候看高级内容,只会得到一种"我好像懂了"的错觉。
1.3 一分钟搭好练习环境
学语法最忌讳"只看不写"。最省事的练习环境就是浏览器自带的开发者工具,按 F12 打开控制台,直接敲代码回车就能看到结果,改一行再回车,反馈是即时的。这个环境对新手极其友好的一点是:报错信息完整、可以随时查看变量的实时值,而且不用装任何东西。
如果你需要写多行代码、保存文件,那就用一个 HTML 文件加<script>标签,或者用 Node.js 直接跑.js文件。我个人的习惯是准备一个临时目录,里面放一个index.html,用 Live Server 之类的本地静态服务打开,然后在<script>里写代码,保存即刷新。这种方式的好处是既能用console.log,也能用debugger打断点,调试体验比纯控制台要好。
代码基础结构大概长这样:
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title>JS 语法练习</title> </head> <body> <script> // 在这里写练习代码 const list = [1, 2, 3]; console.log(list.map(n => n * 2)); </script> </body> </html>这个骨架看着简陋,但它能满足语法学习阶段 90% 的需求。不需要脚手架,不需要打包工具,不需要纠结版本,先把语法本身搞明白,工具链是后面的事。
2. 变量声明:var、let、const 到底怎么选
2.1 作用域与提升:三个关键字的分水岭
var、let、const这三个关键字的差别,本质上就两条:作用域范围不一样、初始化时机不一样。var声明的变量挂在函数作用域上,并且会被"提升"到作用域顶部,初始值是undefined;let和const是块级作用域({}之内有效),虽然也存在提升,但在声明语句被执行之前访问会直接报错。
这个差别在循环里体现得最明显。经典面试题那种"for 循环里绑定定时器"的场景,用var输出全是最后一个值,用let输出是每次循环的值。原因不是let有什么魔法,而是let在每一轮循环里都创建了一个新的块级绑定,而var从头到尾只有一个变量,定时器回调执行时循环早就跑完了。
// var 版本:全部输出 3 for (var i = 0; i < 3; i++) { setTimeout(() => console.log(i), 0); } // let 版本:依次输出 0 1 2 for (let j = 0; j < 3; j++) { setTimeout(() => console.log(j), 0); }理解这个例子不是为了应付面试,而是为了在写事件绑定、异步回调时不再莫名其妙地拿到"最后一个值"。我见过不少真实业务里的 bug,根源就是循环里用var存了索引,后面异步取的时候全错位。
2.2 暂时性死区:这不是 bug,是设计
let和const的"暂时性死区"(TDZ)经常吓到新手,报错信息是Cannot access 'x' before initialization,看着像程序坏了,其实是语言在保护你。它的含义是:从进入块级作用域到声明语句执行之间,这个变量处于一种"存在但不可访问"的状态,任何读写都会抛错。
console.log(a); // undefined,var 的提升 var a = 1; console.log(b); // ReferenceError,暂时性死区 let b = 2;为什么要有这么个设计?因为var的提升行为会让代码变得很难推理——你在函数第一行用了一个变量,声明在第五十行,读起来完全看不出它从哪来。TDZ 相当于强制要求"先声明后使用",把这种隐蔽的依赖关系摆到明面上。我个人的态度是:这不是麻烦,是帮你提前发现错误。养成习惯之后,代码的可读性会明显提升。
注意:TDZ 对
typeof也生效。以前有个冷知识说typeof一个未声明的变量不会报错,那是对完全没声明的变量;如果变量在同一个作用域里用let声明了但还没执行到声明语句,typeof一样会抛错。
2.3 const 不是"值不可变"
这是新手最容易误解的一点。const约束的是"绑定不可重新赋值",不是"值本身不能改"。换句话说,const声明的对象,你不能让这个变量指向另一个对象,但你可以随意修改这个对象的属性。
const user = { name: 'A' }; user.name = 'B'; // 允许 user = { name: 'C' }; // TypeError,绑定不可重赋值 const list = [1, 2, 3]; list.push(4); // 允许 list = []; // TypeError所以const实际上只保证了"引用不变",对象内部的不可变性要靠Object.freeze或者干脆不修改来保证。真正需要深冻结的时候,Object.freeze也只冻结第一层,嵌套对象还是能改,需要手动递归或者用第三方工具。
那实际写代码怎么选?我的默认规则是:能用const就用const,需要重新赋值才用let,var除非维护老代码否则不用。这个规则简单粗暴但非常好用,逼着你思考"这个变量后面到底要不要变",大部分变量其实一次赋值就够了。
3. 数据类型与类型判断:先把货分清楚
3.1 七种原始类型加重对象的存储差异
JS 里的数据类型分两大类:原始类型(Primitive)和对象类型(Object)。原始类型一共七种:string、number、boolean、null、undefined、symbol、bigint。剩下的都是对象,包括Object、Array、Function、Date、RegExp、Map、Set这些。
这两类的核心区别在于存储和赋值方式。原始类型是按值存储的,let a = 1; let b = a;之后修改b完全不影响a;对象类型是按引用存储的,let o1 = {x: 1}; let o2 = o1;之后改o2.x会连带改掉o1.x,因为两个变量指向同一个内存地址。
这个差异是整个 JS 里最容易出 bug 的地方之一。比如把对象作为参数传进函数,函数内部修改了属性,外面也会变,因为传的是引用。想避免这种副作用,就得拷贝一份再操作,这就引出了后面"浅拷贝和深拷贝"的话题。
symbol和bigint用得相对少。symbol的特点是每次调用都得到一个独一无二的值,哪怕描述字符串一样也不相等,常用来做对象里的"私有"键,避免命名冲突。bigint解决的是超过Number.MAX_SAFE_INTEGER(2^53 - 1)的整数精度问题,写法是数字后面加个n,但注意它不能和普通数字直接运算。
3.2 typeof 的坑与可靠的判断方式
typeof是新手第一个接触的类型判断工具,但它有两个著名的坑:typeof null返回'object',typeof function返回'function'(虽然函数本质是对象)。第一个坑是历史遗留问题,早期实现里null的类型标签和对象撞了,后来为了兼容就没改。
typeof null // 'object' ← 坑 typeof [] // 'object' ← 数组也是 object typeof function(){} // 'function' typeof undefined // 'undefined' typeof NaN // 'number' ← NaN 是数字类型所以在实际项目里,光靠typeof判断是不够的,我一般会用一套固定的组合:
| 要判断什么 | 推荐写法 | 说明 |
|---|---|---|
| 是否 null | x === null | 不要用 typeof |
| 是否数组 | Array.isArray(x) | 最可靠 |
| 是否普通对象 | Object.prototype.toString.call(x) === '[object Object]' | 能区分数组等 |
| 是否函数 | typeof x === 'function' | 足够用 |
| 是否是有效数字 | Number.isFinite(x) | NaN会返回 false |
| 是否是整数 | Number.isInteger(x) | 比x % 1 === 0清晰 |
这套判断看着繁琐,但用过一次Array.isArray之后基本就不会再想用typeof判断数组了。至于"判断是不是空对象"这种需求,Object.keys(x).length === 0比JSON.stringify靠谱得多,因为后者对undefined和函数属性的处理很怪。
3.3 隐式类型转换:那些年我们写过的玄学比较
JS 是弱类型语言,运算时会自动做类型转换,这套规则完整记下来没意义,但几个高频场景必须心里有数。最有名的就是+号:只要有一边是字符串,另一边就会被转成字符串拼接;两边都是数字才是加法。
1 + '2' // '12' 1 + 2 + '3' // '33' ← 从左到右,先算 1+2 '1' + 2 + 3 // '123' true + 1 // 2 null + 1 // 1 undefined + 1 // NaN除了+,其他算术运算符(-、*、/)都会把操作数转成数字。这就是为什么'10' - 1得到9,而'10' + 1得到'101',看着像是同一类操作,结果天差地别。
还有一个高频陷阱是[]和{}参与比较时的表现,那些东西结果太反直觉,我的建议是别去研究,直接在代码里避免让不同类型做隐式比较。写代码时一旦发现自己在猜"这个结果是啥",就说明该加显式转换了。Number(x)、String(x)、Boolean(x)三个函数,以及更严格的parseInt、parseFloat,把这些用熟,比背规则有用。
4. 运算符与表达式:细节决定你会不会踩坑
4.1 == 和 === 的取舍,别做"和事佬"
==会做类型转换后比较,===不做。社区里曾经有人提议"永远用==",理由是某些场景写起来短,但我的态度很明确:默认一律用===,除非你明确知道自己在干什么。
原因很实际。==的转换规则里有一条让人非常难受:null == undefined返回true,但null和undefined跟其他任何值做==都是false。这意味着x == null其实是"x 是 null 或 undefined"的简写,这个特性有人喜欢用,我不推荐——因为它对读代码的人不友好,别人看到得停下来想一下。
另一个经典是0 == ''返回true、'0' == false返回true、[] == false返回true。这些结果如果你不查规则根本推不出来,而且没有任何业务价值。所以统一用===,遇到需要宽松比较的地方,先显式转换类型再比,代码会好懂很多。
提示:ESLint 里
eqeqeq这条规则建议一直开着,它会把所有==标红,逼你思考到底是笔误还是真需要。绝大部分情况下你会发现都是笔误。
4.2 逻辑运算符返回的不是布尔值
这个点非常重要,但很多教程讲得不清楚。&&和||返回的是操作数本身,不是true或false。a && b的逻辑是:先算a,如果a是假值就直接返回a,否则返回b。a || b反过来,a是真值就返回a,否则返回b。
const name = inputName || '匿名'; // inputName 为空串时用默认值 const user = res && res.data; // 防止 res 为 null 报错这两个写法非常常用,尤其是给参数设默认值、做防御性取值的场景。但要注意它们判断的是"真假值",不是"是否存在"。0、''、false、NaN、null、undefined都是假值,所以count || 10在count为0的时候会错误地返回10。这种 bug 特别隐蔽,因为测试时用的都是正常数据,一到边界就出问题。
后来有了??(空值合并运算符)就是为了解决这个:它只在左边是null或undefined时才取右边,0和''会被正常保留。所以给数字配置项设默认值的时候,用??比||安全得多。
const size = config.size || 10; // size 为 0 时会错误变成 10 const size2 = config.size ?? 10; // 只有 null/undefined 才会变成 10另外&&和||的短路特性还能用来写条件执行,比如isReady && doSomething(),isReady为假时后面的函数不会调用。这种写法简洁,但不要滥用,一连串的&&串起来可读性会崩。
4.3 浮点数与 NaN 的相处方式
0.1 + 0.2 !== 0.3这个梗已经被讲烂了,但它背后的原因值得说清楚:JS 用 IEEE 754 双精度浮点数表示数字,很多十进制小数在二进制里是无限循环的,只能截断存储,于是产生了精度误差。这不是 JS 独有的问题,所有用这个标准的语言都一样。
实际开发里要注意的是:永远不要直接用===比较两个浮点数。正确做法是算差值再和误差范围比较:
function isEqual(a, b, epsilon = Number.EPSILON) { return Math.abs(a - b) < epsilon; }涉及金额的场景更要小心,我的建议是全部用"分"为单位存整数,展示时再除以 100。这样既避免了精度问题,也避免了toFixed四舍五入的坑。
NaN的另一个特点是它不等于自己,NaN === NaN返回false。判断一个值是不是NaN,要用Number.isNaN(x),而不是x === NaN,也不是全局的isNaN——全局isNaN会先做类型转换,isNaN('abc')返回true,但'abc'根本不是NaN这个值,Number.isNaN('abc')才返回false,符合直觉。
5. 流程控制与函数:逻辑骨架怎么搭
5.1 if、switch 与循环的选择
条件判断这块没什么高深的东西,但有几个实践细节值得说。if适合区间判断和复杂条件,switch适合"一个值对多个固定分支"的场景。需要提醒的是switch用的是===比较,而且忘记写break会继续往下执行,这个坑太经典了。现代写法里,如果只是做值映射,用对象字面量可能更清晰:
const statusMap = { pending: '待处理', success: '成功', failed: '失败', }; const label = statusMap[status] ?? '未知状态';循环方面,for适合需要索引和精确控制的场景,forEach适合纯粹遍历,for...of适合遍历可迭代对象(数组、字符串、Map、Set),for...in适合遍历对象的键。这里有个高频错误:不要用for...in遍历数组,它会遍历到原型链上的可枚举属性,而且顺序在复杂情况下不保证。遍历数组就用for...of或者数组方法。
while和do...while用得少,主要场景是"不知道要循环多少次,只知道什么时候停"。用的时候一定要确认循环条件里有能让它终止的变量更新,否则就是死循环,浏览器会直接卡死。
5.2 三种函数写法与 this 指向
JS 里有三种常见的函数写法:函数声明、函数表达式、箭头函数。函数声明会被整体提升,可以在声明之前调用;函数表达式不会,必须先定义后调用。这个差别在代码组织上有影响,比如工具函数习惯用声明式,因为调用位置可以更自由。
真正的重点在this。普通函数的this取决于调用方式:作为对象方法调用时指向那个对象,单独调用时在非严格模式下指向全局对象(严格模式是undefined),用call/apply/bind可以手动指定。而箭头函数没有自己的this,它的this在定义时就确定了,来自外层作用域。
const obj = { name: 'A', say() { console.log(this.name); }, sayLater() { setTimeout(function () { console.log(this.name); // undefined,this 是全局或 undefined }, 0); setTimeout(() => { console.log(this.name); // 'A',箭头函数继承外层 this }, 0); }, };这个场景在实际业务里极其常见,比如在setTimeout、Promise.then、事件回调里要访问组件实例上的数据。我自己的经验是:默认用箭头函数,除非你需要自己的this。需要动态this的情况主要是对象方法、构造函数、以及需要arguments对象的场合。但要注意,箭头函数也不能用作构造函数,new一个箭头函数会直接报错。
5.3 作用域链与闭包的真实用途
作用域链的意思是:函数内部访问一个变量时,先找自己的作用域,找不到就往上层找,一直找到全局。这个链条在函数定义时就确定了,不是调用时确定的,这叫词法作用域。理解这点才能明白为什么闭包能"记住"外部变量。
闭包的定义是"函数引用了外部作用域的变量,并且这个函数在外部作用域之外被执行"。听起来绕,实际用处很直接:做私有变量、做缓存、做防抖节流。
function createCounter() { let count = 0; return { inc() { return ++count; }, get() { return count; }, }; } const counter = createCounter(); counter.inc(); counter.inc(); console.log(counter.get()); // 2这里的count外部根本访问不到,只能通过返回的两个方法操作,这就是模块化的雏形。以前没有ES Module的时候,前端全靠立即执行函数(IIFE)加闭包来隔离作用域,避免污染全局。
注意:闭包会让外部变量一直留在内存里,用多了确实可能造成内存占用上升。但这不是"闭包有毒",大部分场景下这点开销完全可以接受,真正需要警惕的是在循环里大量创建闭包并长期持有,比如给几千个 DOM 节点各绑一个闭包回调却没解绑。
6. 对象与数组:日常写得最多的部分
6.1 对象字面量、解构与可选链
日常业务代码里,对象的创建和读取占了最大比重。字面量语法不用多说,重点说解构和可选链这两块现代语法,它们能显著减少样板代码。
解构的好处是可以一次性从对象或数组里取多个值,还能重命名、给默认值:
const { name, age = 18, city: location = '未知' } = user; const [first, , third = 0] = list;注意对象解构给默认值的条件是"取到的值是undefined",null不会触发默认值。这个细节很容易记错,{ a = 1 } = { a: null }得到的是null而不是1。
可选链?.解决的是深层取值的问题。以前要写res && res.data && res.data.list && res.data.list[0],现在写res?.data?.list?.[0]就行。左边是null或undefined时整个表达式返回undefined,不会抛错。配合空值合并??还能顺手给个兜底值。
const firstItem = res?.data?.list?.[0] ?? null;不过这里有个取舍要注意:可选链会"吃掉"错误。如果你的逻辑期望res一定存在,用了可选链反而让本该报错的 bug 变成静默的undefined,后面更难排查。我的习惯是:只在数据确实可能缺失的地方用可选链,核心路径上该报错就让它报错。
6.2 数组方法的分类记忆法
数组方法一大堆,硬背很容易乱。我一般按"会不会改变原数组"和"返回什么"来分两类记,效率会高很多。
| 分类 | 方法 | 是否改原数组 | 返回值 |
|---|---|---|---|
| 遍历 | forEach | 否 | undefined |
| 遍历 | map | 否 | 新数组 |
| 筛选 | filter | 否 | 新数组 |
| 查找 | find/findIndex | 否 | 元素 / 索引 |
| 判断 | some/every | 否 | 布尔值 |
| 归并 | reduce | 否 | 任意值 |
| 增删 | push/pop/shift/unshift/splice | 是 | 视方法而定 |
| 截取 | slice | 否 | 新数组 |
| 排序 | sort/reverse | 是 | 原数组引用 |
这张表里最容易出错的是sort和splice。sort默认按字符串的 Unicode 编码排序,所以[10, 2, 1].sort()得到[1, 10, 2],数字排序必须传比较函数(a, b) => a - b。而且sort是原地排序,会改掉原数组,如果不希望原数据被污染,先slice()一份再排。
splice也是原地修改,返回的是被删除的元素数组,很多人会误以为返回的是新数组。而slice不改原数组,返回截取的部分。这两个名字长得像,行为完全不同,我建议记的时候联想一下:splice里的 "p" 可以想成 "pick out",是从原数组里挖走。
reduce是最容易被低估的方法,它几乎能做所有遍历类的事情,求和、分组、扁平化、去重都能写。但它的可读性在复杂场景下会下降,如果一段reduce要写十几行还带注释,不如换成普通循环。工具是给人用的,不是用来炫技的。
// 分组示例:按状态归类 const grouped = list.reduce((acc, item) => { (acc[item.status] ||= []).push(item); return acc; }, {});6.3 浅拷贝与深拷贝的老问题
对象和数组是引用类型,直接赋值只是复制了引用。想要"复制一份独立的",就要区分浅拷贝和深拷贝。浅拷贝只复制第一层,嵌套的对象还是共享的;深拷贝会递归复制所有层级。
浅拷贝常用的几种写法:
const copy1 = { ...origin }; const copy2 = Object.assign({}, origin); const copy3 = origin.slice(); // 数组深拷贝最省事的是structuredClone,现代浏览器和 Node 都支持了,能处理Date、Map、Set等类型,比JSON.parse(JSON.stringify())强很多。后者的坑在于会丢掉undefined、函数、Symbol,把Date变成字符串,遇到循环引用直接报错。
const deep = structuredClone(origin);如果环境不支持structuredClone,或者只需要处理纯 JSON 数据,那JSON方案也够用。关键是心里要清楚自己用的是哪种、边界在哪,别在数据里有Date对象的时候拿JSON方案去拷,拷出来的日期变成字符串,后面比较全错。
7. 异步与错误处理:从回调到 async/await
7.1 事件循环的一句话版本
JS 是单线程的,异步靠事件循环实现。一句话概括:同步代码先执行完,然后清空微任务队列,再取一个宏任务执行,执行完再清空微任务,如此循环。
微任务包括Promise.then、queueMicrotask、MutationObserver;宏任务包括setTimeout、setInterval、I/O回调、事件回调。这个顺序决定了很多输出题的答案,也决定了你写setTimeout(fn, 0)时它并不是真的立即执行。
console.log(1); setTimeout(() => console.log(2), 0); Promise.resolve().then(() => console.log(3)); console.log(4); // 输出顺序:1 4 3 2理解这个顺序的实际意义在于:不要在微任务里写耗时操作(会阻塞渲染),也不要在需要"下一帧执行"的地方用setTimeout(fn, 0)代替requestAnimationFrame。原理懂了,选工具就不会瞎选。
7.2 Promise 与 async/await 的落地写法
Promise的三个状态(pending、fulfilled、rejected)和状态不可逆这个特性要知道,但日常写代码更多的是用async/await,它本质上是Promise的语法糖,让异步代码看起来像同步。
async function loadData() { try { const res = await fetch('/api/list'); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); return data; } catch (err) { console.error('加载失败', err); return null; } }这里有几个实践要点。第一,await只能用在async函数里(顶层await在模块中可以用,但不是所有环境都支持)。第二,await后面的表达式如果不是Promise,会被自动包装成已完成的Promise。第三,多个互不依赖的请求不要串行await,用Promise.all并行能省掉一半时间:
const [user, orders] = await Promise.all([fetchUser(), fetchOrders()]);需要注意Promise.all是"一错全错",只要有一个reject整体就失败。如果希望每个请求独立处理,用Promise.allSettled,它会返回每个任务的状态和结果,不会因为单个失败中断。
还有一个老生常谈的问题:forEach里用await是无效的。因为forEach不会等待回调返回的Promise,它的返回值也是undefined。需要串行等待就用for...of,需要并行就用Promise.all配合map。
7.3 try/catch 的边界与错误对象
try/catch能捕获的是同步代码抛出的异常,以及await后面Promise的reject。它捕获不到普通回调里的异步错误,比如setTimeout里面抛错,外层try/catch是抓不到的。这一点非常容易踩。
try { setTimeout(() => { throw new Error('抓不到我'); }, 0); } catch (e) { // 这里不会执行 }错误对象本身有几个属性值得关注:message是描述,name是错误类型,stack是调用栈。自定义错误的时候,继承Error并设置name是好习惯,这样日志里能一眼看出是业务错误还是系统错误。另外在catch里不要只是console.log就完事,要么往上抛,要么给用户一个明确的状态,静默吞掉错误是最难排查的那种。
8. 常见报错与排查技巧实录
8.1 高频报错速查表
写 JS 的前半年,遇到的报错其实高度集中。下面这张表是我整理的"看到就知道大概哪里错了"清单:
| 报错信息 | 常见原因 | 排查方向 |
|---|---|---|
Cannot read properties of undefined (reading 'x') | 对象是undefined还去取属性 | 打印上一层对象,确认数据是否返回 |
x is not defined | 变量没声明或拼错 | 检查拼写、作用域、是不是挂在window上 |
x is not a function | 值不是函数,或方法名写错 | console.log(typeof x)确认类型 |
Cannot access 'x' before initialization | TDZ,let/const提前使用 | 把声明移到使用之前 |
Assignment to constant variable | 给const重新赋值 | 改用let或改为修改属性 |
Unexpected token '<' | 请求返回了 HTML 但按 JSON 解析 | 检查接口地址、看网络面板返回体 |
Maximum call stack size exceeded | 递归没写终止条件 | 检查递归边界 |
这些报错里,第一类占了我见过的问题的绝大部分。根因基本都是"以为数据已经到了,实际上还在路上或者请求失败了"。解法不在于加一堆?.,而在于把数据流搞清楚:谁负责请求、失败时返回什么、渲染时拿到的是什么。
8.2 调试的三个习惯
第一个习惯是先看报错的第一行和最后一个箭头指向的文件行号。很多人一看到红字就慌,直接从最后一行开始读,其实关键信息在开头。调用栈从下往上读,找到第一个自己写的文件那一行,八成就是问题所在。
第二个习惯是用debugger而不是到处console.log。在怀疑出问题的位置写一行debugger,打开开发者工具,代码会停在那里,你可以把鼠标悬停在任意变量上看当前值,可以一步步往下走看它是怎么变的。这个方式比打印十次日志高效得多,而且不会留下垃圾输出。
第三个习惯是复现最小化。遇到一个诡异的 bug,先把它从业务代码里抽出来,写成一个十几行的独立例子。抽的过程中你往往会自己发现原因——因为很多 bug 依赖于一堆你以为是"背景"其实根本无关的代码。这个习惯练熟之后,排查速度会有质的变化。
提示:
console.log打印对象时显示的是"展开时的快照",某些浏览器里会显示引用最新的状态,容易误导。想要固定的快照,用console.log(JSON.parse(JSON.stringify(obj)))或者直接console.table。
9. 练习路线:把语法变成肌肉记忆
9.1 分阶段的练习清单
光看不写,语法永远停留在"认识"的层面。我建议按下面的顺序做练习,每个阶段不用太久,重在覆盖到所有常用点。
第一阶段,纯数据操作。给一个数组,做筛选、排序、求和、分组、去重,全部用数组方法实现,不许用for循环。这一步的目的是把map、filter、reduce、sort这些方法的参数和返回值彻底搞清楚。做完之后你会发现,以前写十几行的逻辑现在三行就完了,但前提是你知道每个方法在干什么。
第二阶段,对象操作。构造一个嵌套对象,练习解构、可选链、浅拷贝、深拷贝,然后验证拷贝之后修改是否影响原对象。这一阶段的关键是建立"引用"的直觉,每次操作前先判断"我改的是不是原来那个"。
第三阶段,函数与闭包。写一个计数器、一个防抖函数、一个节流函数。这三个东西几乎把所有函数相关的知识点都覆盖了:闭包、this、定时器、参数传递。写完再想一遍"为什么变量不会被回收",理解会更深。
第四阶段,异步。用async/await串起两个有依赖的请求,再用Promise.all并行两个无依赖的请求,加上完整的错误处理。这一阶段的目标是能在不看文档的情况下把 try/catch 和数据兜底写对。
9.2 我个人的几个小建议
第一个建议是读别人写的代码,但只读基础语法的部分。找一个开源项目的工具函数目录,看他们怎么用reduce、怎么处理边界、怎么命名变量。这些细节在教程里看不到,但在真实代码里到处都是。
第二个建议是给自己定一个"不用魔法"的规则。所谓魔法,就是你写了但说不清楚为什么这么写的代码。比如a && b || c这种链式表达式,如果你不能一句话说清它的优先级和短路行为,就拆成if。宁可多写两行,也别留下自己都看不懂的代码。
第三个建议是建一个自己的坑位笔记。每踩一个坑,记两行:现象是什么、原因是什么。不用写得多正式,几句话就行。我自己的这份笔记到现在还在加,很多条目后来都变成了团队里的排查清单。语法这种东西,看一遍能记住三成,踩一遍能记住八成,讲给别人听一遍基本就忘不掉了。
最后一个体会是关于节奏的。基础语法这块,快就是慢,慢就是快。急着跳过this和闭包直接去学框架,后面每个 bug 都会在这一层反复绊你;老老实实花两三周把这几块啃透,后面学框架的速度反而会快很多,因为你终于能看懂它在干什么,而不是只能照抄文档示例。