简介一份面向JavaScript初学者的系统入门教程以docx文档形式呈现帮助读者从零建立对JS语言核心知识的完整认知。教程内容覆盖变量声明var、let、const的区别与作用域、数据类型、运算符、控制结构、函数、对象、DOM操作及异步编程等关键模块每个知识点均配有示例代码适合零基础入门或有一定编程基础但希望系统梳理JavaScript概念的读者。资源打包为1个docx文件压缩包大小仅19KB内容轻量、便于快速阅读已有88人学习浏览。通过逐步讲解与代码演示读者可以掌握变量与类型的运用、函数与对象的使用、通过DOM动态操作网页内容并理解基于回调、Promise与async/await的异步编程思路为实际开发打好基础。1. JavaScript的体制一门「单线程」语言凭什么扛起整个现代前端单线程、动态弱类型、原型对象、事件循环。这四个词几乎出现在每一本 JavaScript 入门书的前几页但很少有人停下来追问为什么偏偏是这些设计一门最初只是为了「给网页加点交互」而仓促诞生的语言为什么最终成为整个现代前端的基石甚至反向杀回服务器端答案藏在 JavaScript 的核心体制里它本质上是一个「单线程执行 事件循环调度 基于原型链的对象模型」的组合。初学者当玄学背的变量提升、闭包、this 丢失、Promise 执行顺序其实是同一套底层机制的四个侧面。把这些机制拆开看透你再看 JavaScript 的「反常设计」会从记结论变成推导结论。下文从这份教程覆盖的核心主题——变量、函数、对象、DOM 与异步——逐层拆解试着回答「为什么这样设计、凭什么能 work」。2. 变量声明三兄弟为什么 var 能提升let 却有「暂时性死区」2.1 var 的「提升」是登记不是搬运很多教程讲 var直接说「变量被提升到了作用域顶部」。这个说法有迷惑性——引擎并不会真的把代码「搬」到上面去。真实的机制是这样的JavaScript 引擎在执行一段代码之前会做一次「预扫描」把作用域内所有 var 声明和函数声明的名字收集起来登记进当前执行上下文的环境记录里var 登记的初始值是 undefined。这就是声明登记阶段。等到真正执行到var x 10这一行发生的事情只是「给一个已经存在的名字赋值」。console.log(a); // undefined不报错 var a 10; console.log(a); // 10第一行能跑通不是因为代码被移位了而是预扫描阶段已经把a登记在了当前环境里。函数声明同理但函数声明的登记是「整体登记」整个函数体在预扫描时就位所以函数声明可以在定义前调用。我一般把这段过程分成两个阶段理解声明阶段负责造「名字」执行阶段负责赋「值」。名字先出现值后出现中间那一段空档就是人们说的「提升」的真相。注意var a 10这句在引擎眼里是两条指令声明a和赋值10前者已经被预扫描抢跑了。2.2 块级作用域花括号里多了一块环境记录let 和 const 与 var 最大的差异是作用域。在运行时每进入一个花括号代码块if、for、while甚至单独的{}引擎都会创建一个新的词法环境嵌套在外部环境里。这个环境有自己的环境记录let/const 声明的变量就存在这里。换句话说块级作用域不是语法层面的「规则」而是运行时真实存在的「对象」——一块环境记录。let x 1; if (true) { let x 2; console.log(x); // 2当前块级环境里找到了 } console.log(x); // 1回到外层环境查找这里要引出作用域链的运行时实现环境记录之间靠一个outer指针串成链表。你在函数内部访问一个变量引擎先查当前函数的环境记录查不到就顺着outer往外层找直到全局环境。这就是「作用域链查找」的底层动作——不是抽象概念而是一条实际存在的链表。花括号创建的新环境记录挂在链上内部声明的x存进新环境与外层x互不干扰。用 var 则不同var 没有块级环境的概念它登记的永远是最近的函数环境或全局环境所以for循环里用 var 声明的变量循环结束之后依然活着这也为后面要讲的坑埋下了伏笔。2.3 暂时性死区引擎给 let/const 加的一道初始化锁严格来说let/const 也有「提升」——引擎预扫描时同样会登记它们。区别在于登记时这些名字会被标记为「未初始化」在初始化语句执行之前访问引擎直接抛 ReferenceError。这个「声明了但还没初始化」的区间就是暂时性死区。console.log(y); // ReferenceError: Cannot access y before initialization let y 20;注意一个关键细节报错信息是Cannot access before initialization而不是y is not defined。这两者差别很大——not defined是说环境记录里根本没有这个名字TDZ 是说名字存在但处于「未初始化」状态引擎不允许读取。环境记录里找不到名字和找到名字但标记未初始化是两条不同的错误路径。为什么偏偏要设计 TDZ因为 var 那种「默认 undefined」的行为掩盖了一个严重问题你声明了变量但忘了赋值读取时拿到 undefined程序不报错bug 被拖延到运行时某个隐蔽角落才暴露。TDZ 把错误提前到「访问的第一瞬间」暴露宁可报错也不静默失败——这种设计取向在 JavaScript 后续版本里反复出现。我习惯把 TDZ 理解为引擎给 let/const 加的一道「初始化检查锁」锁在赋值执行的那一刻才打开。提示const 要求声明时必须赋值并且不能重赋值。注意「不能重赋值」不等于「不能修改属性」——const p {name: a}之后p.name b合法因为变的是 p 指向的对象内部不是 p 这个绑定本身。「绑定」是名字到值的关联const 锁定的是关联关系不是值对象的内部状态。2.4 三种声明的取舍对比声明方式作用域预扫描后初始值可重新赋值可重复声明var函数/全局undefined是是let块级未初始化TDZ是否const块级未初始化TDZ否否这张表里最值得琢磨的是「可重复声明」一列。var 允许在同一作用域重复声明本质是「后声明覆盖前声明」这给大型项目埋了不少雷——两个文件里的同名全局变量可能互相踩。let/const 禁止重复声明则是在语法解析阶段就能报错把冲突提前暴露。从 var 到 let/constJavaScript 走的是一条「把运行时错误变成编译时错误」的路子这个取向在理解整个语言进化史时非常重要。3. 闭包与 this三起 JS「玄学」bug 的现场排查3.1 案例一循环里用 var 绑定事件回调拿到的全是最后一个值现象给一组按钮绑定点击事件点任意按钮控制台打印出来的都是最后一个下标。复现代码for (var i 0; i 3; i) { document.getElementById(btn i).addEventListener(click, function () { console.log(i); // 无论点哪个按钮都打印 3 }); }原因var 声明的i登记在 for 循环所在的函数作用域里整个作用域只有一份i。循环结束后i走到 3三个事件回调引用的其实是同一个变量。点击发生时读取i拿到的已经是循环结束后的 3。注意回调函数没有「记住当时的 i」它记住的是「名字 i」而这个名字最终指向 3——这句话是理解闭包的关键。解决把 var 换成 let。引擎每次迭代会创建新的词法环境i在每一轮循环里各存一份闭包引用的是各自环境里的i。也可以用「闭包工厂」把i作为参数传给立即执行函数让每一轮的i变成该闭包的局部变量。从那以后我在循环里一律用 let这条血泪经验几乎再没犯过。3.2 案例二对象方法被拆出来调用this 变成 undefined现象对象方法单独赋值给变量后再调用this 不再是原对象const person { name: JS, greet() { console.log(Hello, ${this.name}); } }; const fn person.greet; fn(); // TypeError: Cannot read properties of undefined (reading name)原因this 的指向不是由「函数定义在哪里」决定的而是由「函数被怎么调用」决定的。person.greet()这种调用方式调用主体是 person引擎把 this 绑定为 person。而fn()是裸调用在严格模式下 this 是 undefinedthis.name就成了读 undefined 的属性。方法内部的 this 没有「记住」它来自哪个对象——JavaScript 的函数本身是无状态的行为包上下文是在调用点临时决定的。解决用fn.bind(person)把 this 固定成 person或者用箭头函数定义方法箭头函数没有自己的 this词汇上向外层借用又或者保证调用姿势永远是person.greet()。我排查这类 bug 的第一条习惯先看调用点不看定义处。this 丢失九成是调用姿势变了。3.3 案例三闭包「记住」的是一个变量不是那一刻的值现象定时器里连续打印计数器预期每隔一秒打印 1、2、3实际打印 3、3、3for (var i 1; i 3; i) { setTimeout(function () { console.log(i); }, i * 1000); }原因闭包捕获的是变量绑定本身不是快照。setTimeout 的回调执行时循环早已结束读到的是i的最终值 3。这里和案例一共享同一个底层原因只是换了一层皮。很多人以为闭包会「复制一份当时的 i」不对——闭包捕获的是环境记录里那个名字后续对这个名字的每一次赋值闭包都会实时看到。这也是「为什么这个值又变了」这类诡异问题的根源。解决let 或 IIFE 传参。除此之外还要养成一个习惯闭包里引用基本类型变量时先问一句「这个变量以后还会变吗」。如果会变就该考虑把值固化。3.4 一套可复用的排查流程遇到闭包或 this 相关的诡异行为我一般按三步走看变量声明方式var 还是 letvar 的情况下先怀疑「共享变量」问题。看调用点函数是裸调用、方法调用还是作为回调传入裸调用且严格模式下this 就是 undefined。看捕获目标闭包引用的变量是基本类型还是对象基本类型看当前值对象看引用。这三步能覆盖绝大多数 JavaScript 作用域与 this 相关的坑。出现报错别急着搜答案把调用点翻译成引擎视角的「谁在调、读哪个环境记录」基本就能自己推出来。4. 函数与对象一等公民是如何用原型链替代「类继承」的4.1 函数是一等公民把「行为」当成值传JavaScript 里函数也是对象——具体说是 Function 类型的对象。这意味着函数可以赋给变量、装进数组、作为参数传给另一个函数、被另一个函数返回。这个「一等公民」地位是整个异步编程的地基setTimeout 接收一个函数当参数Promise 的 then 接收一个函数当参数事件监听的 addEventListener 也接收函数。它们的本质都是「在某个时机由引擎来调用你传进来的函数」——这叫控制反转你只管描述「要做什么」不用管什么时候做、在哪做。function compose(f, g) { return function (x) { return f(g(x)); }; } const add1 x x 1; const double x x * 2; const run compose(add1, double); console.log(run(3)); // 7先 double 得 6再 add1 得 7这段代码里compose 返回了一个匿名函数这个匿名函数捕获了参数 f 和 g构成了闭包。调用 run(3) 时先执行内部约束好的g(3) 6再执行f(6) 7。这里能成立的关键就是「函数可以被返回」——函数体是被延迟执行的「值」而闭包保证了 f、g 不会在 compose 返回后消失。日常写的防抖节流、柯里化、装饰器都是这套机制的变体。理解「函数即值」再回头看回调风格就会明白它并不是什么复杂设计只是一个语言特性被反复复用。4.2 原型链属性查找是一条运行时动态的链JavaScript 对象是「键值对集合」这是常识。但对象身上还有一个隐藏属性[[Prototype]]它指向另一个对象通常是某个构造函数的 prototype 对象。读取对象属性时引擎先查对象自身属性查不到就顺着[[Prototype]]往上找直到 null。这条链路就是原型链。function Person(name) { this.name name; } Person.prototype.sayHello function () { return Hello, ${this.name}; }; const p1 new Person(JS); console.log(p1.sayHello()); // Hello, JS console.log(p1.toString()); // [object Object]从 Object.prototype 找到new Person(JS)本质上做了四件事创建一个新对象、把新对象的[[Prototype]]指向 Person.prototype、以新对象为 this 调用 Person 函数、如果构造函数没有显式返回对象就返回这个新对象。关键点p1 自身没有 sayHello 属性引擎沿着「p1 → Person.prototype」这条链找到了方法toString 则来自更上层的 Object.prototype。原型链查找是运行时的动态过程——你在 Person.prototype 上后补一个方法所有已有的实例立刻能用。这种「运行时按需扩展」的能力是传统类继承做起来很别扭、原型链却天然擅长的事。4.3 「一切皆对象」是个伪命题装箱机制「JavaScript 里一切皆对象」是流传很广但不严谨的说法。字符串、数字、布尔这些基本类型在运行时不是对象是原始值。那么问题来了abc.length为什么能用let s abc; console.log(s.length); // 3临时装箱后访问 let n 12.5; console.log(n.toFixed(1)); // 12.5临时装箱后调用因为引擎在「访问属性的一瞬间」把原始值临时包装成了对应的包装对象String、Number、Boolean访问结束后这个临时对象立刻被丢弃。这个隐式转换叫装箱。理解它就能明白为什么给基本类型挂属性是无效的s.custom 1执行时装箱对象马上就没了下一次读s.custom又是新的装箱对象。如果你发现「给字符串加了属性却丢了」先别怀疑引擎坏了——它压根没打算让你绑在临时对象上。装箱机制是 JavaScript 类型系统里「看似矛盾、实际自洽」的典型语言用一层临时包装让原始值也能享受对象方法同时又不改变原始值的本质。4.4 class 语法糖换了套礼服的原型链ES6 的 class 并没有发明新的继承体系它编译后的运行时结构依然是那根原型链。class 里定义的方法会成为 prototype 上的方法constructor 就是原来的构造函数。看到 class 别以为 JavaScript 终于拥抱了传统「类继承」——底层那条委托链没变变的只是书写方式。class Animal { constructor(name) { this.name name; } speak() { return ${this.name} makes a sound; } } class Dog extends Animal { speak() { return ${this.name} barks; } }Dog.prototype的内部[[Prototype]]指向Animal.prototype所以 Dog 的实例能一路委托到 Animal 的方法。class 只是把「构造函数 原型方法」打包成更符合其他语言习惯的样子内部依然是对象与对象之间的委托关系。设计上的取舍在于委托模型更灵活但理解成本高class 牺牲了一部分「非主流玩法」换来了更低的入门门槛和更好的可读性。5. 事件循环与异步单线程凭什么扛住高并发5.1 单线程的一个线程配一群后台干活的线程JavaScript 主线程只有一条同一时刻只能执行一段代码。但浏览器并不只有一个线程渲染线程、网络线程、定时器线程各司其职。JavaScript 的「单线程」指的是「执行 JS 代码的调用栈只有一个」但浏览器环境把耗时的活外包给了其他线程等线程干完往消息队列里丢一个任务JS 线程空闲时就去取。这个模型就是事件循环。这里值得澄清一个常见误解async、Promise、setTimeout 都不是「并行执行」而是「延后执行」。真正的计算还是在一条线程上排队做所谓异步只是改变了「什么时候被排进队列」。网络请求的耗时发生在浏览器的网络线程里JS 线程并没有阻塞等结果回来了回调被插入队列。这个「不排队等待、先干别的去」的调度策略让单线程也能同时挂起成百上千个请求。DOM 操作也受这个模型约束操作 DOM 的代码本身是同步执行的但浏览器把样式计算和绘制放在下一个渲染时机所以同一帧内多次改 DOM 会合并处理而不是改一次画一次。5.2 宏任务与微任务队列也有优先级事件循环的队列分两层宏任务队列和微任务队列。setTimeout、setInterval、DOM 事件、IO 操作的回调进宏任务队列Promise.then/catch/finally、queueMicrotask、MutationObserver 的回调进微任务队列。每次事件循环先处理一个宏任务然后把当前微任务队列里所有任务全部清空再取下一个宏任务。微任务不仅能插队还能在清理过程中生成新的微任务这些新任务会在当前这一轮全部执行完不会拖到下一轮。console.log(1: 同步); setTimeout(() console.log(2: 宏任务), 0); Promise.resolve().then(() console.log(3: 微任务)); console.log(4: 同步);输出顺序是 1、4、3、2。解析同步代码先跑完Promise.then 的回调进了微任务队列宏任务队列里躺着 setTimeout 回调调用栈清空后引擎先清空微任务队列再回来取宏任务。setTimeout 设置 0 毫秒不代表「立刻执行」只是「尽快排进宏任务队列」实际执行要等当前宏任务结束并清空微任务之后。面试里那道经典输出顺序题把队列层级关系搞清楚答案根本不用背。队列任务来源执行时机宏任务队列setTimeout、setInterval、DOM 事件、IO每次事件循环取一个微任务队列Promise then、queueMicrotask、MutationObserver每个宏任务结束后全部清空5.3 Promise一个带状态机的任务盒Promise 的核心实现是状态机。一个 Promise 实例只能从 pending 变为 fulfilled 或 rejected状态一旦定型不可逆。resolve 和 reject 就是触发状态转移的两个开关。then 注册的回调会进入微任务队列而不是立即执行。const p new Promise(function (resolve, reject) { setTimeout(function () { resolve(成功); }, 1000); }); p.then(v console.log(v)); console.log(先执行这行);代码执行流程Promise 构造器里的函数会同步执行里面只是发起了一个 setTimeout然后立即返回此时 p 的状态还是 pending最后一行同步代码先执行1 秒后定时器回调触发 resolve把 p 置为 fulfilled并把 p.then 里注册的回调推进微任务队列JS 线程空闲后打印「成功」。整个过程中主线程没有被任何一步卡住这就是 Promise 给异步代码带来的「可编排」能力——多个异步步骤可以用链式 then 串起来避免回调一层套一层的金字塔。链式 then 的机制是then 本身返回一个新的 Promise所以可以继续 then直到状态落定。5.4 async/await给异步穿上同步外衣async/await 本质上是 Promise 加生成器的语法糖。async 函数执行到 await 那一行会「暂停」把 await 后面的 Promise 挂到事件循环上让出线程等 Promise 落定恢复执行把结果当作表达式的值赋给 await 左侧。暂停与恢复正是生成器的核心能力async/await 把它的状态管理整个隐藏掉了。async function main() { console.log(A); const result await new Promise(r setTimeout(() r(done), 500)); console.log(result); } main(); // A →约500ms后done这里有两个常被忽略的细节async 函数返回的永远是一个 Promise即使函数体里没有 await调用 main() 时同步部分先执行打印 A遇到 await 挂起main 函数让出调用栈。之后 Promise 落定恢复执行打印 done。如果 await 那里抛错整个 async 函数返回的 Promise 变成 rejected 状态用 try/catch 包住 await 就能捕获。从回调到 Promise 再到 async/await是一次次「可读性革命」——Promise 解决了回调的信任问题状态只能转移一次async/await 解决了嵌套问题用同步写法表达异步流程但底层的事件循环调度机制一直没有变过。6. 设计取舍为什么 var 该退场却一直在异步为什么越改越好读回头看 JavaScript 的演进有一条贯穿始终的原则只加新的不删旧的。var 的语义在现代 JavaScript 里已经被 let/const 全面超越但你不能移除它——浏览器里有海量的存量代码依赖 var 的提升和函数作用域语义一旦移除或彻底改变行为整个现有网页生态会瞬间崩塌。向上兼容是 Web 平台的生存底线于是 var 与 let 共存新代码用新语义旧代码运行不误。这不是「设计懒惰」而是平台语言特有的约束它不能像普通软件一样发布一个「破坏性大版本」。原型链也没被「改造」成真正的类继承。class 语法糖把常见写法规范化了但底层的委托机制原样保留。原因是委托模型在对象间共享行为这件事上极度灵活——运行时给 prototype 加方法所有实例立即生效没有编译步骤、没有重编译成本。这个特性对浏览器这种「代码常驻、热更新频繁」的环境是宝贵的。代价是理解成本高新手很难一眼看透 this 的传递和原型链的方向。所以 JavaScript 的取舍是「保留灵活的核心在上面盖一层好用的语法」——class 是那层外壳原型链是内核。异步的演进则展示了另一条思路回调把控制反转交给外部Promise 把状态管理收进一个可组合的对象async/await 再把组合过程变成线性的同步代码。每一步都没有推翻前一步的运行时模型只是换了一层更接近人类思考习惯的表达方式。这其实是语言设计里最难的部分——不改变底层调度却彻底改变写代码的感受。从那以后我学任何 JavaScript 新特性都会先问两个问题它解决了什么痛点代价是什么而不是直接背 API。这份教程从 var 一路讲到 async/await正好覆盖了 JavaScript 机制演进的完整链条配合代码逐个在浏览器里跑一遍比碎片化刷短视频建立的理解要扎实得多。需要这份资料的话进资源详情页直接取就行希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?