尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

JavaScript 核心机制:动态类型、事件循环与原型链

JavaScript 核心机制:动态类型、事件循环与原型链 JavaScript 大概是编程语言里被误解最多的那个名字蹭了 Java 的热度写起来又带着一股 C 语言的血缘真正跑起来却和这两者没有半点关系。做了这么多年前端每次给新人讲这门语言我都会从同一个问题入手——你到底在学什么是浏览器里的按钮交互是 Node.js 里的服务端逻辑还是小程序里的页面渲染这些场景的底层执行者都是同一个 JavaScript但它的性格在不同环境里被放大了不同的侧面。下面的内容就想把这些“性格特点”摊开来聊一聊动态类型、原型链、单线程异步、函数一等公民以及 ES6 之后和 TypeScript 时代的新变化。不管你是刚入门还是写过几年我希望你读完能明白一件事JavaScript 的很多坑恰恰就是它最有价值的那部分。1. 先厘清 JavaScript 到底是什么语言定位与运行时边界1.1 它不是 Java 的简化版语法血缘与本质差异写 JS 的人多少都被问过“JS 和 Java 什么关系”。公开场合的标准回答是“没有关系只是名字类似”。严格说语法上 JS 受 C 语言影响很大if、for、while 那套长得像 Java但类型系统、对象模型、执行模型完全是另一套东西。Java 是编译到字节码再运行变量要声明类型类是组织代码的唯一主要方式JS 是解释执行现代引擎里也有 JIT 编译器变量不需要类型标注对象基于原型。这个本质差异决定了后面所有特点为什么你可以let a 1之后再a hello为什么函数能到处传递为什么this会跟想象中不一样。有个不太严谨但好记的类比Java 像一个在工位上按部门流程办事的员工每件事都要填表、走审批JavaScript 更像一个自由职业者什么都能接但时间管理全靠一套“事件循环”机制。这个类比放到后面单线程部分再展开。语言层面还有一个常被忽略的点JS 的变量提升和函数声明提升。var声明的变量会被提升到作用域顶部初始值为undefinedlet和const也有提升但存在暂时性死区在声明前访问会直接报ReferenceError。这个设计让“先使用再声明”的老式代码能运行但也给新人埋了不少雷。ES6 之后我写代码基本只用let/const只有在维护老代码时才会去分析那些 var 提升带来的神奇行为。1.2 同时身兼三职核心语言、DOM API、宿主环境很多初学者把“JavaScript 语言”和document.getElementById混为一谈。我在带新人的时候会让他们先分清三样东西ECMAScript 规范定义的语言核心变量、运算符、流程控制、对象、函数、原型链、Promise 等。Web 平台的 APIDOM、BOM比如 window、navigator、fetch、localStorage 等这些不是语言本身而是宿主环境提供的接口。宿主环境浏览器、Node.js、Deno、小程序运行时分别提供了不同的扩展能力。拿window.setTimeout举例setTimeout并不是 ECMAScript 的一部分它是浏览器/Node.js 宿主提供的定时器 API。所以在 Node.js 里能看到global.setTimeout但不会有document。弄清楚这个边界你就不会在一个纯 Node 脚本里报ReferenceError: document is not defined时怪 JS 语言有问题——应该怪宿主环境不对。这个“语言与宿主分离”的特点是 JavaScript 能从一个浏览器脚本语言扩张到服务端、桌面端Electron、移动端甚至嵌入式领域的根本原因。语言核心保持中性不绑定任何平台平台相关的能力全部通过注入全局对象实现。这也是为什么你可以在完全不接触浏览器的情况下写纯算法题也可以在浏览器里用navigator.geolocation拿到定位信息。热搜词里出现的javascript:void(0)也可以从这个角度解释。很多人网页里看到a hrefjavascript:void(0)其实是浏览器地址栏执行一段 JS 表达式void把表达式结果变成undefined用来阻止默认跳转。这是“语言被宿主环境嵌入”的一个典型痕迹也是老一代前端常用的防跳转手段。2. 动态和平等类型系统与隐式转换的真实脾气2.1 弱类型到底弱在哪typeof 和隐式转换的关键行为JS 是动态弱类型语言。动态意味着变量本身不绑定类型同一个变量可以先后装字符串、数字、对象。弱类型意味着类型转换很随意尤其在进行加减乘除和比较时引擎会自动帮你转换。这两点叠加带来了极高的灵活性和一些匪夷所思的行为。比如5 - 1结果是4因为减号强制转数字。5 1结果是51因为加号优先转字符串。[] []结果是空数组先变成空字符串。{} []在控制台可能显示0因为开头的{}被当成语句块而不是对象。这些不是 bug是隐式转换的规则。理解规则比吐槽规则更实用。实际开发中最危险的场景往往是用户输入从输入框拿到的永远都是字符串如果你直接用inputValue 1得到的可能是字符串拼接而不是数值加一。我在处理表单时一定会用Number(inputValue)或parseInt(inputValue, 10)做显式转换绝不在业务逻辑里依赖隐式转换。类型检测最常用的typeof也有自己的特点typeof null object这在语言设计初期是个 bug但二十多年都没改因为修复会破坏现存代码。你只能通过value null来判断空值。同样typeof 未声明变量不会抛错而是返回undefined这跟let/const的暂时性死区又不一样。对于数组的判断typeof []返回object所以判断数组得用Array.isArray(arr)。这个需求在序列化、遍历、深度克隆时特别常见我在封装工具函数时几乎必写一行Array.isArray。2.2 宽松相等与严格相等 与 的实战判断既然提到类型转换就绕不开和。新手最常被建议“一律用”这当然安全但搞清楚的规则能帮你读懂老代码也能避免在面试里被问住。真正需要理解的核心是如果两边的类型不同会先尝试把它们转成相同类型再比较。规则大致是数字和字符串比较时字符串转数字。布尔值参与比较时先转数字。对象和基础类型比较时对象调用valueOf/toString。一个比较实用的场景是判断一个值是不是空。有些人写if (value ! null)这里的!用了宽松不等意思是“不是null也不是undefined”。因为 null同时匹配null和undefined这是少数几个比更方便的地方。但如果你想要明确区分null和undefined就还是老老实实 null和 undefined。很多新手写if (x 0)来判断数字 0如果x是或者false也都会通过因为空字符串转数字是0false转数字也是0。这就是隐式转换的坑。我的习惯是在判断值是否存在时用 null在业务逻辑里一律避免任何迷惑。这里可以贴一张我整理的类型转换对照表方便你快速查阅左操作数右操作数的结果部分说明11true字符串转数字0true空字符串转数字 0false0true布尔值转数字 0nullundefinedtrue特殊规则二者相等null0falsenull 不会被转成 0[]true空数组 toString 为空字符串{}[object Object]true对象调用 toString这张表的记忆成本比查规则低很多看两遍就能形成条件反射。遇到模棱两可的情况我建议直接在控制台里console.log跑一遍比查文档快。3. 单线程却不会卡死的秘密事件循环下的异步特点3.1 为什么 JavaScript 是单线程的从浏览器渲染说起JavaScript 的另一个显著特点是单线程。为什么会这样设计因为它最初的生命线是浏览器里的 DOM 脚本。如果两个线程同时操作同一个 DOM浏览器没法决定谁先谁后界面会乱套。与其加锁不如干脆单线程所有任务排队执行。这个决定一直沿用到 Node.js即使在服务端多核场景下你写的 JS 业务代码也只在单线程里跑。单线程的代价是不能有耗时太长的同步任务。一个while(true)就能把页面卡死因为渲染线程和 JS 线程是互斥的。所以语言提供了异步 APIsetTimeout、fetch、事件回调。这些 API 不是把任务放到另一个线程并行计算而是把任务挂到事件队列里等当前执行栈空了再取出来执行。这就是“非阻塞”和“并发”的区别并发是靠切换不是靠并行。我在调试性能问题时经常用 Performance 面板看主线程的任务分配。如果一段同步代码超过 100ms用户就会感觉到卡顿。优化思路通常是把大任务拆成多个小任务用setTimeout分片执行或者把纯计算放到 Web Worker 里。Web Worker 是浏览器提供的真正的多线程但它不能操作 DOM只能做计算和通信。JS 主线程还是单线程Worker 只是在旁边帮你干活。3.2 事件循环如何撑起回调、Promise 与 async/await事件循环Event Loop是这个单线程异步模型的引擎。它的大致流程是执行栈先执行同步代码遇到异步任务就交给对应的宿主 API比如setTimeout交给浏览器定时器fetch交给网络模块这些 API 完成后把回调放入任务队列执行栈清空后事件循环取出队列里的第一个回调执行再取下一个如此反复。但队列其实还分微任务microtask和宏任务macrotask微任务优先。这也是 Promise 回调为什么总是比setTimeout回调先执行的原因。一段经典的验证代码是setTimeout(() { console.log(setTimeout); }, 0); Promise.resolve().then(() { console.log(promise); });在浏览器里执行输出顺序是promise然后setTimeout。因为Promise.then进入微任务队列微任务队列会在当前宏任务结束后立即清空而setTimeout至少会延迟到下一个宏任务。React/Vue 的nextTick原理也是优先走微任务所以状态更新后想立即读取 DOM 时必须等nextTick回调执行。理解微任务和宏任务对排查实际问题的顺序很关键。比如我在写某个读进度条组件时想在状态更新后立刻读取 DOM 的渲染结果第一次直接读发现还是旧值。原因是状态更新后组件渲染是异步的Vue/React 的更新队列而你的读取代码同步执行了。这时可以用nextTick或者requestAnimationFrame把读取挪到渲染之后。这个坑不是框架的错是 JS 事件循环机制的外在表现。从回调到 Promise 再到 async/await本质都是在同一个事件循环模型上做的语法优化。Promise 把回调嵌套拍平async/await 把 Promise 链写成同步风格的代码。但别被表面骗了await后面的代码仍然是异步的它只是在引擎里帮你在 Promise 完成后继续执行。我在早期写代码的时候以为await会真正阻塞线程结果发现界面还能响应后来才明白这是自动把后续代码包进then了。这个理解一旦修正调异步顺序就顺了很多。4. 函数和对象的不寻常设计一等函数、原型链与 this 绑定4.1 一等函数为什么可以“函数作为参数和返回值”在 JavaScript 里函数是对象的一种可以赋值给变量、塞进数组、作为参数传出去、作为返回值返回。这就是“一等函数”First-class function也是 JavaScript 能玩出各种编程范式的底气回调、高阶函数、函数式编程、装饰器思想全都建立在这个特性上。因为函数是对象所以可以给函数挂属性fn.name、fn.length是内置属性还有人会用fn.cache做 memoization。这也是“函数是一等公民”的直观体现。实际开发中我用得最多的高阶函数是debounce防抖和throttle节流它们的本质就是返回一个新函数新函数内部用闭包记录时间和参数在原来的函数外面加了一层“门卫”。如果不理解“函数可作为返回值”这个特点你只能去框架源码里抄一个一旦需求变化就不知道怎么改。闭包是另一个和一等函数紧密结合的特点。内部函数可以引用外部函数的局部变量并且这个变量在外部函数执行完后仍然活着。这个机制让状态得以在函数间传递但也很容易造成内存泄漏。比如在循环里注册事件回调引用了循环变量如果不知道闭包捕获的是变量引用而不是值就会得到一堆相同的输出。ES6 的let已经解决了这个经典问题但理解背后的原理仍然重要。4.2 原型链与 class 语法基于原型的继承是什么样的另一个特征是原型链Prototype Chain。JavaScript 的继承其实是“对象直接委托给另一个对象”。每个对象内部都有一个隐藏的[[Prototype]]访问属性时先在自身上找找不到就顺着原型链往上。ES6 的class语法只是原型链的语法糖new一个实例、extends一个类底层还是把原型链串起来。很多新手会觉得class出现后原型就过时了其实不然。数组为什么能调用map、reduce、filter因为Array.prototype里有这些方法数组靠原型链找到了它们。你给Array.prototype挂一个自定义方法所有数组都能用但不建议改内置原型会造成污染。理解原型链还有一个好处调试时看到对象下面写着[[Prototype]]而不是__proto__时你知道那只是标准接口变了。__proto__是非标准访问器现在已经不建议用推荐使用Object.getPrototypeOf/Object.setPrototypeOf。原型链的查找是动态的。如果你在原型上新增方法已经创建出来的实例也能立刻访问到因为实例和原型之间是引用关系不是值拷贝。这个特性在框架设计里被大量使用Vue 的响应式拦截、React 的事件系统都在利用原型和属性查找机制做灵活性拓展。4.3 this 四规则调用处决定不是定义处决定this可能是 JavaScript 里最有“特点”的部分了。很多语言里this是绑在定义处的作用域上但 JS 的this绑定是运行时由调用方式决定的。我总结了四个规则普通函数调用this指向全局对象严格模式是undefined。对象方法调用this指向调用它的对象。通过call/apply/bind显式指定this指向指定的值。使用new构造调用this指向新创建的对象。优先级大约是new 显式绑定 对象方法调用 默认绑定。箭头函数是例外它没有自己的this外层的this是什么就是什么。用箭头函数写回调能避免一个经典坑const obj { name: 前端, getName: function() { setTimeout(function() { console.log(this.name); // undefined因为 setTimeout 里 this 是全局 }, 100); } };如果改用箭头函数包一层this就会捕获getName调用时的this输出正常。理解了这个特点你会发现网上那些“this 指向大全”表格其实都不如“看调用方式”这一条来得通用。我把this的常见调用场景整理成了一个表调用方式this 指向典型场景普通函数fn()全局对象严格模式 undefined独立调用对象方法obj.fn()obj对象调用fn.call(obj)/fn.apply(obj)obj显式指定fn.bind(obj)obj显式指定返回新函数new fn()新建的对象构造函数箭头函数外层函数的 this回调、事件bind有一个细节它返回的是一个新函数且不能和call/apply同时使用再绑定。一旦bind过之后再call是无效的。我常用bind来绑定事件处理器的this或者提前固定参数做函数柯里化。5. 语言演进的现实特点ES6 之后变了什么以及 TypeScript 补什么5.1 ES6 带来的模块化和语法糖JavaScript 语言特性一直在演进ES6ECMAScript 2015是一个分水岭。前面说的let/const、箭头函数、class、Promise、模板字符串、解构赋值都是 ES6 带来的。之后每年都有新版本ES2017 引入async/awaitES2018 引入展开运算符和余参数ES2020 加入可选链和空值合并ES2021 加入逻辑赋值ES2022 加入顶层awaitES2023 到现在各种数组方法也一直在补。现在写代码基本不用考虑 ES5 时代的var和作用域坑但如果你维护老项目还是会遇到。可选链?.和空值合并??是我最常用的两个新特性。以前判断嵌套对象要写a a.b a.b.c现在直接a?.b?.c。??只针对null和undefined兜底不像||那样会把0和也带走。这两个特性极大减少了防御式代码的噪音。模块化这个特点尤其值得说。ES6 之前网页里的 JS 文件都是通过script标签加载共享全局变量冲突频发。后来社区造出了 CommonJSNode 用和 AMD浏览器用再到 ES6 原生模块import/export。Node.js 现在也支持 ESM但历史包袱导致文件后缀和package.json的type字段经常让人摸不着头脑。我的建议是新项目统一用 ESM客户端代码直接import服务端 Node 在package.json里配type: module避免混用。模块化带来的好处不只是隔离作用域还有静态分析能力。打包工具Vite、Webpack能通过import/export做 tree-shaking把没用到的代码删掉。这个特点对工程化影响深远你可以放心写各种工具函数打包时只保留被调用的部分体积控制就轻松很多。5.2 JavaScript 的边界为什么需要 TypeScript聊 JS 特点不能不提 TypeScript。TS 并没有改变 JavaScript 的语义它是给 JS 加了一层静态类型系统和编译期检查最后编译成 JS 运行。为什么需要它因为 JavaScript 的动态类型太自由了身体好的时候效率极高身体差的时候调试成本爆炸。一个函数传进来的参数到底是string还是number如果不打印日志你根本不知道。TS 在声明阶段就把这层约束补上。还应该提到TS 并不是“另一个语言”它只是 JS 的超集。你可以在 TS 文件里写完全合法的 JS然后逐步加类型。这个渐进的特性让老项目也能平滑迁移。我见过很多团队把 JS 项目迁移到 TS第一步只是把文件后缀改成.ts然后一步步补接口定义并不需要推倒重来。做前端这几年我的体感很直接小项目原型阶段JS 的“动态”是优势写起来快超过一定规模比如多个人协作、接口字段几十个动态类型就会变成坑一个undefined就能让你排查半天。TS 不是 JS 的对立面而是“给工具加约束”的产物。真正的 JS 特点还是那个它既是脚本语言又是平台语言给你自由也考验你自律。另外TS 的类型系统本身也很有意思比如联合类型、交叉类型、泛型、条件类型。写多了你会发现类型其实是一种“可执行的文档”它比注释更可靠因为编译期会强制校验。配合 IDE 的自动补全开发效率能提升一截。我现在的个人项目也默认开 TS哪怕是个几百行的小工具也能避免“改一个字段引来三个报错”的尴尬。回想起来JavaScript 最让我服气的一点就是它敢把“不完美”公开晾着——隐式转换、this规则、原型链、单线程每一个看起来都像槽点但它们组合在一起却成为全球使用最广泛、生态最庞大的语言。我早期写代码时也被各种怪行为坑过但后来慢慢意识到踩坑的过程恰恰是理解语言性格的过程。如果你问我学 JavaScript 什么最重要我的答案是先别急着记 API把这几个独有的特点——动态类型、事件循环、原型链、this绑定——彻底吃透后面所有框架和工具都会变得好解释。最后分享一个小习惯遇到诡异行为先打开 DevTools 在控制台里跑一下把每一步的类型打印出来看类型转换到底发生在哪个环节很多时候答案就自己跳出来了。
返回列表