简介:这是一份面向JavaScript初学者的系统教程,适合对编程感兴趣但尚未系统接触过JS的人,也适合有一定基础想深入理解核心概念的开发者,目标是解决语法零散、DOM操作不熟、异步逻辑混淆等常见入门难题。资源包仅含1个docx文档,大小19KB,内容精炼且便于离线阅读;目前已有88人学习。文档从变量声明、数据类型、运算符与控制结构讲起,用示例逐步说明函数定义与调用、对象属性与方法,再延伸到DOM元素获取、内容修改和事件监听,并清晰介绍回调函数、Promise和async/await等异步编程方案。教程同时建议读者制作时钟、待办列表、计算器等小项目来巩固知识,并推荐MDN、Eloquent JavaScript等免费学习资源,帮助初学者建立完整知识体系,快速过渡到实际项目开发。
1. 从临时脚本到前端基石:单线程的 JavaScript 凭什么撑起整个现代 Web
JavaScript 可能是唯一一门“槽点越多、用得越广”的语言:单线程却被用来承载所有并发效果,var 的提升和 this 的飘忽让一代又一代新手翻车,可它依然同时统治着浏览器、服务端和移动端。反直觉的地方在于:它不做多线程,却几乎从没因为网络请求把页面卡死过。
这靠的不是运气,而是一套任务调度机制——事件循环把执行切成一块块同步代码,异步操作完成后再排队回来,单线程反而成了这套模型最可靠的底座。入门文档开头那句“轻量级、解释型或即时编译型、多范式”其实暗含了全部答案:这些特性不是缺陷,而是它在不同环境里突围出来的原因。
下面顺着这份文档的五个模块——变量、函数、对象、DOM、异步——逐层往下拆,你会看到每个“坑”背后的设计动机,而不只是记住怎么绕。
2. 变量与作用域:var 的提升、let 的暂时性死区到底改了什么
教程里一句话带过:var 存在提升,let/const 有块级作用域。但“提升”到底是编译期行为还是运行时行为?let 的块级作用域为什么能在声明前访问时直接抛错?答案藏在语言引擎的两阶段工作方式里:先整体扫描登记,再逐行执行。理解了这两步,变量这块就不再是背诵结论。
2.1 提升不是“把代码搬上去”:var 的编译期登记
最典型的提升现象长这样:
console.log(a); // undefined,而不是 ReferenceError var a = 10; console.log(a); // 10看起来像是“引擎把声明搬到了顶部”,实际上引擎在执行前会对整个作用域做一次扫描,把所有 var 声明和 function 声明登记到该作用域的环境记录(Environment Record)里,var 的绑定初始值为 undefined。声明的登记发生在执行之前,这就是“提升”的真相——它不是一个移动代码的动作,而是一个提前登记的动作。赋值a = 10仍然停留在第二行,执行到那里才真正写入值。
提示:提升只提升声明,不提升赋值。所以提前读取输出的是 undefined,而不是 10。
函数声明则完全不一样,它是整体提升的,包括函数体:
greet("World"); // 即使函数声明在后面,也能正常调用 function greet(name) { return "Hello, " + name; }引擎在扫描阶段就把 greet 的完整定义登记好了,所以“先调用后声明”也能跑。多个函数互相递归引用,靠的正是这份提前登记。但把同样的函数改写成函数表达式var greet = function(name) {...},提前调用就会抛 TypeError——因为此时 greet 只是 undefined,undefined 不可调用。这个区别经常出现在真实的模块化代码里:有人把函数声明改成表达式做“统一风格”,结果破坏了启动顺序。
为什么设计者保留这种“看起来乱”的行为?因为早期网页脚本大量依赖先调用后声明,浏览器兼容性让这个行为成了既定事实。后来引入 let/const 时,规范就顺势做出了更严格的新规则。
2.2 暂时性死区:let/const 的防呆机制
let 和 const 同样会被提前登记进环境记录,但它们的绑定初始状态与 var 有本质差异:
{ // 从这里开始进入暂时性死区 console.log(x); // ReferenceError: Cannot access 'x' before initialization let x = 42; // 执行到这行,TDZ 结束 }x 的绑定在进入这个块的那一刻就存在了,否则块内更靠后的代码无法形成闭包引用它。但绑定处于“未初始化”状态,任何读取或赋值都会抛 ReferenceError。声明前的这段区域就叫暂时性死区(TDZ)。设计动机很直白:把“读到一个未初始化的变量”从静默的 undefined 变成显式报错。var 时代最大的隐性 bug 就是“以为有值、其实是 undefined”,TDZ 从机制上杜绝了这种隐患。
有个细节值得注意:对未声明的变量执行typeof会返回 "undefined",但对处于 TDZ 的变量执行typeof会直接抛错。这意味着 typeof 不再是万能的“安全探测”手段,从 var 迁移到 let/const 的人经常在这里踩坑。
const 是在 let 之上再加一层约束:绑定不可重新赋值。注意不可变的是绑定关系,不是值本身——const obj = {a: 1}; obj.a = 2;完全合法,因为 obj 这个绑定仍然指向同一个对象,只是对象内部状态变了。很多项目里用 const 声明数组后依然可以 push、改元素,原理就在这里。
| 声明方式 | 作用域 | 声明前访问 | 可重新赋值 | 全局挂载 |
|---|---|---|---|---|
| var | 函数作用域 | undefined,不报错 | 可 | 挂到全局对象上 |
| let | 块级作用域 | ReferenceError(TDZ) | 可 | 不挂全局 |
| const | 块级作用域 | ReferenceError(TDZ) | 绑定不可 | 不挂全局 |
表中最后一项也常被忽略:全局 var 会挂到全局对象上,所以老代码里window.version能读到 var 声明的变量;改成 let/const 后,全局对象上就没有这个属性了。不少历史代码迁移时在这里静默失效,表现就是“换个写法,全局变量突然找不到了”。
2.3 闭包:函数带着出生环境到处走
作用域决定了变量能不能被读到,闭包则决定了“读到的那个环境能活多久”。看一个最经典的例子:
function createCounter() { let count = 0; return function () { // 返回的函数引用了 createCounter 的词法环境 count += 1; return count; }; } const counter = createCounter(); console.log(counter()); // 1 console.log(counter()); // 2普通理解是“返回的函数记住了 count”,机制层面的解释是:每个函数对象在创建时都携带一个内部引用 [[Environment]],指向创建它的词法环境。createCounter 执行结束后,它的环境按理该被回收,但返回的匿名函数还引用着这个环境,垃圾回收器发现它仍可达,就不会动它。于是 count 变成了一个只能通过 counter 访问的私有状态——外部代码碰不到它,这正是闭包模拟私有属性的经典方式。
作用域链在运行时是链式查找的:当前函数环境 -> 外层环境 -> 再外层 -> 全局环境。读取 count 时,引擎先看匿名函数自己的环境,没有再往上找;赋值也一样,沿着链找到最近的绑定并写入。这也解释了闭包和垃圾回收的一个常见误区:“闭包导致内存泄漏”——真正泄漏的从来不是闭包本身,而是无意中让大对象被长生命周期函数引用住,比如把整个 DOM 节点挂在闭包里。
var/let 在闭包上的差异会在循环场景里集中爆发,这件事留到异步避坑章节用完整例子展开。现在先记住一个结论:闭包不是语法糖,它是词法作用域加函数一等公民这两个机制自然叠加的产物。JavaScript 里所有“记住状态”的写法都建立在它上面。
3. 函数与对象:一等公民、原型链与 this 绑定的底层逻辑
如果说变量机制是地基,函数和对象就是承重墙。教程把它们分开讲,但引擎层面两者是同一套东西:函数是可调用对象,对象靠原型链关联。这一章把它们放在一起拆,是因为很多“为什么”必须合起来才看得清。
3.1 一等公民:为什么函数能当参数又能当返回值
“函数是第一等公民”的意思是:函数和其他值(数字、字符串、对象)享有同等待遇,可以赋值给变量、作为参数传进别的函数、作为返回值从函数里出来。其他语言想做到类似效果通常要搞函数指针、接口或反射,JavaScript 不需要这些中间层,原因就是函数本身是一个对象——对象该有的属性它全都有,还多了一个内部方法 [[Call]],把括号语法fn()映射成一次调用。
function higherOrder(fn, value) { // fn 是函数对象的引用,在内部被调用,不是调用结果 return fn(value) + "!"; } function shout(text) { return text.toUpperCase(); } console.log(higherOrder(shout, "hello")); // HELLO!传进去的是函数对象本身,不是调用结果。新手最常见的手误是写成higherOrder(shout(), "hello")——shout() 会立即执行返回 "HELLO",再被当参数传进去,fn 就变成了字符串,调用时直接 TypeError。理解“函数引用”和“函数调用结果”的差别,是使用高阶函数的第一道坎。
高阶函数真正的价值是把“做什么”和“怎么做”解耦:回调函数说明做什么,框架代码决定怎么做。教程里的 DOM 事件监听、Promise 的 then 回调,全部建立在这个机制上。数组的 map/filter/reduce 也是一样的套路——接收一个函数作为参数,内部遍历数组逐项调用。自己手写一遍 map 就能体会:函数能当参数,让“算法骨架”和“具体逻辑”可以分开复用。
3.2 原型链:为什么数组能用 map,普通对象不行
教程说“数组和函数也是对象的一种”,这句话背后是 JavaScript 独特的继承模型。它没有“类复制”式的继承,而是对象之间通过内部引用 [[Prototype]] 连成一条查找链:
const arr = [1, 2, 3]; console.log(arr.hasOwnProperty("map")); // false,map 不在 arr 自身 // 真正能调到 map 的原因: // arr -> Array.prototype -> Object.prototype -> null console.log(Array.prototype.hasOwnProperty("map")); // true属性访问的规则是:先查对象自身属性,查不到就沿 [[Prototype]] 链逐层往上找,找到即返回。arr 自己没有 map,但链上的 Array.prototype 有,所以 arr.map 可用。数组、普通对象、函数三者的差异,本质是它们各自的 prototype 挂在不同的对象上。这也是“在原型上加方法,所有实例都能用”的机制来源,同时也是“千万别乱改 Object.prototype”的原因——一条链被污染,全天下对象都会受影响。
用构造函数的视角看更清楚:每个函数都有一个 prototype 属性;new 出来的实例,其 [[Prototype]] 指向构造函数的 prototype。ES6 的 class 只是对这层关系的语法糖包装:
class Person { constructor(name) { this.name = name; } greet() { return "Hello, " + this.name; } } const john = new Person("John"); console.log(john.greet()); // Hello, John console.log(john.hasOwnProperty("greet")); // false,方法在原型上这里有一个内存层面的实质差异:教程里的对象字面量写法{greet: function(){...}}是每个实例各存一份函数副本;class 语法定义的方法全部共享 Person.prototype 上的一份。几千个实例时,后者省下的内存非常可观。所以“方法放原型、状态放实例”不是风格偏好,而是有明确工程收益的设计。
new 关键字的执行过程也值得拆开:它内部做了四件事——创建一个新对象、把新对象的 [[Prototype]] 指向构造函数的 prototype、把构造函数里的 this 绑定到新对象并执行、返回新对象(除非构造函数显式返回了对象)。this 的变化就藏在这一步里,下一节展开。
3.3 this 绑定:调用方式决定 this,而不是定义位置
教程里的person.greet()输出了 Hello, John,看起来 this 指向 person 是理所当然的。但同一个函数换个姿势调用,结果可能完全不一样:
const person = { name: "John", greet: function () { console.log("Hello, " + this.name); } }; person.greet(); // Hello, John(隐式绑定:this = person) const unbound = person.greet; unbound(); // this 丢失,读 this.name 读到 undefined const bound = person.greet.bind(person); bound(); // Hello, John(显式绑定:bind 固定了 this)this 不是“定义时决定”的,而是“调用时由调用表达式决定”的。person.greet()的调用上下文是 person;把函数从对象上取下来裸调用,上下文就没了,严格模式下 this 退化为 undefined,非严格模式退化为全局对象。bind/call/apply 可以显式指定 this,new 会把 this 绑定到新实例。四条规则按优先级从高到低:new > bind/call/apply > 方法调用 > 裸调用。
| 调用形式 | this 指向 | 典型场景 |
|---|---|---|
| obj.fn() 方法调用 | obj | person.greet() |
| fn() 裸调用 | 严格模式 undefined,否则全局对象 | 回调函数内部 |
| fn.call(obj)/apply/bind | 传入的对象 | 显式指定上下文 |
| new Fn() | 新创建的实例 | 构造函数 |
箭头函数是唯一的例外:它没有自己的 this 绑定,创建时直接从外层词法环境捕获 this,之后无论怎么调用都不变。事件回调里“this 丢了”是最高频的翻车现场,解法通常就是改用箭头函数写回调,或者提前const self = this捕获。理解这条,DOM 事件监听和异步回调里的 this 问题基本就免疫了。
4. DOM 操作:从 HTML 字符串到可编程对象树的完整链路
教程里 DOM 操作给了三个动作:获取元素、改内容、加事件。但“获取元素”拿到的到底是什么?“改内容”为什么不是改字符串?这一章把链路拆开:HTML 怎么变成树、事件怎么在树里传播、改动怎么触发渲染。
4.1 文档树是怎么来的:解析、节点与活引用
浏览器拿到 HTML 后,先做词法分析把字节流切成 token,再按标签的嵌套关系构建出一棵 DOM 树——树的每个节点都是一个对象,有标签名、属性、子节点列表。这棵树不是 HTML 文本的“快照”,而是可编程的活对象。document.getElementById返回的引用会实时反映后续改动,这就是为什么代码里可以先拿到元素、再修改属性,页面马上跟着变。
// 获取元素:返回文档中 id 匹配的节点,找不到返回 null let element = document.getElementById("myElement"); // 修改内容:innerHTML 会把字符串重新解析成子节点并替换原内容 element.innerHTML = "New content"; // 添加事件监听器:回调在事件被派发到该节点时执行 element.addEventListener("click", function () { alert("Element was clicked!"); });getElementById 在浏览器内部维护着 id 到节点的映射,所以查找是常数级的,比 querySelector 遍历选择器更快。innerHTML 的赋值则是重操作:传入的字符串会被 HTML 解析器重新解析成一棵子树,替换掉原来的子节点。如果只想改里面的纯文本,textContent 更合适——它不触发 HTML 解析,也不会误把字符串里的尖括号当标签。
注意:innerHTML 会把原有子节点连同它们身上绑定的事件监听器一起丢掉。动态列表里如果先绑好了事件又用 innerHTML 整体重刷,监听器会全部失效。这种问题排查起来很痛苦,因为代码看起来每一步都对。
安全性也要提一句:把用户输入直接拼进 innerHTML,等于让那段字符串拥有解析执行 HTML 的能力。用 textContent 或 createElement 逐步构建节点,是从源头避开注入的常规做法。
4.2 事件冒泡与捕获:为什么监听一个父节点就够了
事件机制不是“发生在谁身上就停在哪”。一次点击会经历三个阶段:从根节点向下穿透的捕获阶段、到达目标节点的目标阶段、再向上回传的冒泡阶段。addEventListener 的第三个参数传 true 就在捕获阶段触发,默认 false 则在冒泡阶段触发。这个模型衍生出最实用的技巧——事件委托:
document.querySelector("#list").addEventListener("click", function (e) { // e.target 是实际被点击的元素,不是监听器所在的 #list const li = e.target.closest("li"); if (li) { console.log("点到了:", li.textContent); } });点击任意一个子项,事件都会冒泡到 #list,由容器统一处理。e.target 给出真正被点击的节点,closest 沿祖先链找到最近的 li。这样一来,动态新增的列表项不需要逐个绑定监听器,新增即生效,也不用担心重刷 innerHTML 把监听器冲掉。内存上也划算:一万个 li 各绑一个监听器,和容器上只绑一个,差距是数量级的。
委托失效最常见的场景是:中间层调用了 stopPropagation 阻止冒泡。如果某些隔离交互的代码强制停掉冒泡,外层的委托就收不到事件了。排查时先想清楚三阶段路径,再确认每一层有没有人“截胡”。另一个相关细节是滚动类事件:在监听器里做复杂计算时,可以考虑传{passive: true}告诉浏览器“我不会调用 preventDefault”,这样滚动性能会有明显改善。
4.3 重排与重绘:为什么改几条内容会卡到掉帧
DOM 是树,但浏览器最终画出来的是像素。每次修改 DOM,渲染进程要走一条流水线:样式计算 -> 布局(重排)-> 绘制(重绘)-> 合成。改变几何尺寸、增删节点会触发重排,重排必然带重绘;只改颜色这类不涉及几何的属性可以只触发重绘。卡顿的根源往往不是单次操作贵,而是操作被排成了“一次一刷”的低效序列:
// 反例:循环里一次一改,每次迭代都可能触发重排 for (let i = 0; i < 1000; i++) { list.appendChild(document.createElement("li")); } // 正确姿势:先把节点攒在 DocumentFragment 里 const frag = document.createDocumentFragment(); for (let i = 0; i < 1000; i++) { const li = document.createElement("li"); li.textContent = "item " + i; frag.appendChild(li); // 此时 document 不受影响 } list.appendChild(frag); // 一次性挂载,把多次重排合并成一次DocumentFragment 是一个脱离主文档的轻量容器,往里加节点不会触发任何布局计算,最后一次性挂接才触发一次重排。另一个隐蔽的杀手是“强制同步布局”:在两次 DOM 写入之间读取 offsetWidth、scrollHeight 等布局属性,渲染进程为了返回准确值,会被迫立刻执行还没排队的布局计算。写-读-写-读交替会让浏览器反复做无用的布局,习惯里叫 layout thrashing。正确做法是“批量写、批量读”,把对布局属性的读取收集到一起,避免和写入交错。
5. 异步编程避坑:事件循环、微任务与 Promise 的真实执行顺序
教程说 JavaScript 是单线程的,同时又说有异步编程,这两句话靠事件循环连起来。很多人在这一章栽跟头,不是因为概念难,而是因为“回调执行顺序”和直觉相反。这一章把事件循环、微任务、Promise、async/await 按执行顺序串起来,最后给四个最常见的翻车现场。
5.1 事件循环:setTimeout 为什么总是不准时
单线程意味着同一时刻只有一段同步代码在跑,但浏览器不能被网络请求阻塞住。解决方案是:同步代码在主线程的调用栈上执行;定时器、网络、事件这类异步操作交给浏览器其他线程处理;完成后把回调投进任务队列;调用栈清空时,事件循环从队列取出下一个任务执行。这就是“单线程却不卡”的全部秘密。
console.log("A"); setTimeout(function () { console.log("B"); }, 0); console.log("C"); // 实际输出顺序:A C BsetTimeout 的第二个参数 0 不代表“立刻执行”,只代表“尽快入队”。主线程要先把当前这轮同步任务 A、C 全部跑完,才轮到队列里的 B。所以延迟时间是个下限——“至少等这么久”,而不是“到点就执行”。用它做高精度定时器、倒计时、动画节拍,误差会随主线程负载越来越大。这不是玄学,是队列机制的直接推论。
真实的渲染时机也插在这一环里:浏览器通常会在宏任务之间执行渲染。所以 setTimeout 里改 DOM,效果往往比同步改更平滑,因为改动会被合并到下一次渲染。理解这个位置关系,很多“为什么 setTimeout 包一层就好用了”的谜题就解开了。
5.2 Promise 与微任务:then 回调排在谁前面
Promise 核心是个状态机:pending、fulfilled、rejected,状态一旦落定不可逆。构造函数里的 executor 是同步执行的,resolve/reject 只是把状态翻转;真正异步的是 then 里注册的回调——它们进入微任务队列,在当前任务结束、下一个宏任务开始之前全部清空。
let promise = new Promise(function (resolve, reject) { // executor 同步执行;这里注册一个 1 秒后的定时器 setTimeout(function () { resolve("Success!"); }, 1000); }); promise.then(function (result) { console.log(result); // 输出:Success! }); // 更直观的顺序对比 console.log(1); Promise.resolve().then(() => console.log(2)); setTimeout(() => console.log(3), 0); console.log(4); // 输出:1 4 2 3Promise.resolve().then的回调是微任务,setTimeout 的回调是宏任务。当前同步代码打印 1、4 结束后,事件循环先清空微任务队列打印 2,之后才执行宏任务打印 3。微任务永远插队到宏任务之前,这是理解一切 Promise 顺序问题的主线。对 then 回调来说,它在微任务里执行,意味着“状态一落定,后续微任务立刻就能看到”,现代前端框架的批量更新机制也依赖这条顺序。
then 链的每次 then 都会产生一个新 Promise,所以状态和错误可以沿着链传递。reject 或抛出的异常会沿链找最近的 catch;如果整条链都没有 catch,错误就变成无人认领的 unhandledrejection,控制台会直接报出来。
5.3 async/await:语法糖背后的状态机
async/await 没有引入新的并发模型,它就是 Promise 的语法糖:async 函数必然返回一个 Promise,await 会把后面的代码打包成“等 Promise 落定后继续执行”的续体。引擎通常会把函数体改写成状态机风格的实现,每次 await 都是一次挂起和恢复:
async function loadUser() { try { const data = await slowFetch(); // 挂起当前函数,控制权交回主线程 return data; // slowFetch 落定后恢复执行 } catch (err) { console.error("加载失败:", err); return null; } } loadUser().then((data) => { if (data) { console.log("拿到了数据"); } });await 不是“把异步变同步”,而是“把后面的代码注册成 then 回调”。好处是书写顺序恢复了同步直觉,try/catch 能像抓同步异常一样处理异步错误。代价是很多人把 await 用成了串行:循环里逐个 await,或者在两个互不依赖的请求上依次等待,明明可以并行却退化成排队。两个请求互不依赖时,应该用 Promise.all 发起并行,再统一 await:
// 并行发起两个请求,全部落定后一次性解包 const [user, posts] = await Promise.all([slowFetchUser(), slowFetchPosts()]);Promise.all 接收一个 Promise 数组,返回一个新 Promise,全部落定后把结果按序解包成数组。它还有一个常用变体 Promise.allSettled,等待所有请求结束而不提前失败,适合“部分失败也能继续”的场景。
5.4 避坑:异步代码里最常见的四个翻车现场
第一个是循环闭包问题,老生常谈但每次都会再犯,先把代码摆出来:
for (var i = 0; i < 3; i++) { setTimeout(function () { console.log(i); // 3 3 3,不是 0 1 2 }, 0); } for (let i = 0; i < 3; i++) { setTimeout(function () { console.log(i); // 0 1 2 }, 0); }现象:第一个循环打印三个 3。原因:var 是函数作用域,三个回调共享同一个变量 i,回调执行时 i 已经走到 3。解决:改用 let,每次迭代都会创建一个新的块级绑定,三个闭包各持一个副本。这是上一章闭包机制的直接应用,也是 var 时代用 IIFE 手动造作用域才能解决的老问题。
第二个坑是 Promise 没有 catch。现象:接口失败时页面毫无反应,控制台出现 unhandledrejection。原因:reject 之后没有任何 handler 接住错误,异常悬空。解决:每条 then 链尾部补 .catch,或者用 async/await 加 try/catch 覆盖。
第三个坑更隐蔽:try/catch 包不住 setTimeout 回调里的异常。
async function bad() { try { setTimeout(() => { throw new Error("boom"); }, 0); } catch (e) { console.log("根本捕获不到"); } }现象:回调抛错,catch 完全没执行。原因:setTimeout 的回调在另一个任务里运行,不在 try 的同步路径上,异常只能冒泡到全局。解决:把异步操作封装成 Promise,在 async 函数里 await 它,异常才能沿 Promise 链被 catch 接住。判断标准很简单:catch 只能接住“同一条执行路径”上的错误,跨任务、跨队列的异常不会自动汇入。
第四个坑是请求竞态。现象:用户快速点击按钮,请求发了三四次,先发出的慢响应后返回,把新结果覆盖了。原因:没有对过期请求做隔离。解决:用 AbortController 取消之前的请求,或者用一个请求序号/标志位,只采纳最新一次响应。这在搜索输入联想里几乎是必踩的,不处理就是“打一个字弹一堆旧结果”。
6. 设计取舍:为什么 var 没被删掉,以及“不完美”背后的演进逻辑
把前面五章串起来,会看到一个共性:JavaScript 几乎所有“历史包袱”都不是没人想改,而是不能改。var 的提升和函数作用域坑多,但大量存量网页依赖这个行为;typeof null返回 "object" 是个公认的 bug,但 null 作为对象类型“空引用”的语义已经写进无数判断逻辑。语言标准不能像应用代码一样发个新版本就推倒重来,那会破坏所有现存网页,这是 Web 平台不可动摇的兼容性底线。所以演进策略变成了“加法而不是换血”:let/const 是加新声明方式,Promise 和 async/await 是加新语法糖,class 是加新写法——旧东西全部保留,新东西在旁边生长。这也是为什么今天写 JavaScript 要同时掌握两套心智模型:一套解释老代码为什么那样,一套指导新代码怎么写。
单线程事件循环的选择也值得重新评价。早期浏览器不是没想过多线程操作 DOM,但多线程意味着对同一棵文档树要加锁、要处理并发访问的竞态,复杂度会急剧上升。事件循环放弃并行,换取了一个简单可靠的模型:任何时刻只有一段代码在改状态,开发者不需要考虑锁。代价是性能上限被单线程卡住,但 Web 场景的大量时间是 I/O 等待,非阻塞加回调把等待时间让出来给其他任务,整体吞吐反而比粗糙的加锁多线程更好。这个取舍在服务端同样成立,服务端运行时生态放弃多线程并发模型、拥抱事件循环,和浏览器是一脉相承的思路。
往深一层看,Promise 和 async/await 的演进也遵循同一逻辑:回调地狱不是语法问题,是控制流表达能力的不足;Promise 把“回调在什么时候恢复”变成可组合的对象;async/await 再把恢复点写成同步样式的代码。每一层都没有改变底层的事件循环,只是换了面向开发者的表达方式。引擎层面 await 会被编译成状态机这件事提醒我:评价一个语法特性,要看它编译后是什么,而不是表面像什么。
从我自己的习惯说起:每次写完异步代码,我都会强制自己先在心里走一遍事件循环的顺序——哪段是同步任务,哪个回调进微任务,哪个进宏任务,然后打印验证一遍执行顺序。这一步花不了两分钟,但能挡掉无数看似难查的竞态问题。从那以后我也习惯了在改 DOM 时先问“这一次要触发重排还是重绘”,在处理 Promise 时先问“这条链有没有人接住错误”。希望你也能带着这套机制去重读文档,从“记住结论”变成“推得出结论”,希望帮到你。
本文还有配套的精品资源,点击获取