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解构、展开、模板字符串、可选链现代写法,能显著减少样板代码
P1Promise、async/await、try/catch只要涉及请求就绕不开
P2闭包、原型链、事件循环细节面试高频,也是真正理解的标志
P2Symbol、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判断是不够的,我一般会用一套固定的组合:

要判断什么推荐写法说明
是否 nullx === 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 initializationTDZ,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 都会在这一层反复绊你;老老实实花两三周把这几块啃透,后面学框架的速度反而会快很多,因为你终于能看懂它在干什么,而不是只能照抄文档示例。