最近几个月闲来无事,想着自从毕业那几年之外,已经好久没有沉下心来好好看看js这门语言了,趁着年底的这几个月,我又重新找了本《JavaScript权威指南》第7版好好回顾了一下JavaScript这门语法,并且在阅读的过程中,收集了一下书中的重点知识以及根据自身疑问找到的一些资料,用以后续可能的回顾。
前言
JavaScript权威指南第7版这本书我在微信读书上,大概花费了30个小时读完了,总共跨越12天,平均差不多每天2到3个小时吧,理论上,读完这本书的天数其实应该更少的,不过,我在读的过程中,很多时候都是读读停停,因为我在整理部分重要的知识点,并且对于其中有疑问的内容都会结合AI来进行梳理和扩展,所以整个花费的天数跨度比较久。
在读这本书时,我感觉还是又比较不错的收获的,除了对于已知的内容,有了大概的了解之外和知识点重新复习之外,对于之前还没有那么深入或者只是知其然而不知其所以然的内容,也有了更好、更深入的理解。顺便在学习的过程中,也对其语言中的一些额外知识点有了扩展,感觉也是非常有意思的内容。
不完全知识点总结
JavaScript语言
- 核心规范
- 类型
- 数字
- 字符
- 对象类型
- 引用类型和基本类型
- 类型转换
- 类型判断
- 相等比较
- 语句和表达式
- 操作符
- 变量
- 变量声明
- 作用域
- 全局作用域
- 函数作用域
- 块级作用域
- 特殊的for循环迭代作用域
- 模块作用域
- 作用域链
- 词法环境
- 执行上下文
- 函数
- 闭包
- this
- 箭头函数
- 构造函数
- 原型和原型继承
- class
- 对象
- 原型链
- 数组
- 数组性能优化
- 模块
- js标准库
- set和map
- 类型数组:Typearray
- buffer
- 正则表达式
- 日期和时间
- js错误对象
- 序列化
- 定时器
- 迭代器和生成器
- 异步JavaScript
- 回调
- promise期约
- async/await
- 元编程
- 对象的属性特征:defineProperty
- 反射API
- 代理Proxy
- 类型
- 浏览器(宿主环境、客户端)
- 事件
- dom
- web API
- canvas、svg
- 历史、导航
- 网络
- 存储
- 工作线程
- 消息传递
- 浏览器加载和运行js脚本
- 事件循环
- 垃圾回收
- 通用中断机制
- node(宿主环境、服务端)
- 异步、非阻塞、单线程、IO密集型
- 网络任务
- 文件任务
- 数据库任务
- 异步、非阻塞、单线程、IO密集型
本书中有很多东西讲的我感觉也算比较深入了,或者说,其内容只有那么多,所以才能在一本书中讲的那么深入,而一些涉及很多的功能、特性、API啥的,讲的就没有那么细致了,可能局限于篇幅吧,而且这里主要讲的是JavaScript,但是有一些东西在我看来对于前端非常重要,但是他也没有提到的,例如事件循环、词法作用域、作用域链、垃圾回收等等,怎么说呢,这对于熟悉JavaScript可能不是必要的,因为也许太深入了,但是对于深入掌握JavaScript和前端来说,确感觉必不可少。
总之,对于大概了解大部分JavaScript内容和基础来说,其内容足够,但是还有部分内容,是需要自己去扩展和深入的。
下面则是我在阅读过程中,从书中或者AI中提炼出来的一些知识内容,仅供参考,在需要的时也可以稍微帮自己回忆一下。
下面则是提炼的知识内容。
前提说明
- 以目前的js环境来说,或者说生态来说,基本上我们编写的js,都需要在严格模式下去执行的(至少es模块都会跑在严格模式下)
- 即使es定义了很多规范,不过由于浏览器为了兼容性考虑,如果不在严格模式下运行,其行为还是和规范不一定一致的,这一点需要清楚
- 其实很多东西的细节,在MDN上都有详细的说明了,如果遇到不了解的,可以去翻翻,不过现在配合AI去查找和总结,其实也更加方便。
WHATWG
WHATWG 是Web Hypertext Application Technology Working Group的缩写,中文译为网页超文本应用技术工作组,它是一个致力于开发和维护 HTML 及相关 Web 技术标准的核心组织,对现代网页开发的技术规范有着决定性影响。
其最核心的成果是主导了 HTML5 标准的研发。这一标准解决了早期 HTML 功能有限的问题,新增了 Canvas、Video 等标签,以及一系列适配现代 Web 应用的特性,彻底改变了网页只能展示简单文本和图片的局面。此外,WHATWG 还负责维护 DOM(文档对象模型)、Fetch API 等关键 Web 技术规范,且它维护的这些规范多以 “Living Standard(动态标准)” 形式存在,会根据 Web 技术的发展持续迭代更新,而非固定版本。
其和W3C最初处于竞争状态,后来逐渐走向合作。如今 WHATWG 已成为 Web 核心技术标准的核心维护方。由于谷歌、微软等后续也加入到相关技术协作中,其制定的标准天然贴合主流浏览器的实现需求,几乎所有现代浏览器都会优先遵循它维护的规范。这使得它的技术标准能够快速落地,直接决定了开发者开发 Web 应用时可使用的核心功能与技术边界。
模块
目前模块的生态,你需要知道es模块的规范,其次,node模块使用的是commonjs规范,除了这个commonjs规范之外,浏览器和node是两个模块系统实现的宿主(最常见),其中浏览器支持es模块,而node支持commonjs模块和扩展的es模块(增加了裸模块的node_modules查找)
- 你需要了解commonjs规范和es模块规范
- 同时还需要大致了解其差异
- 你需要了解node中的commonjs规范,以及其es规范
- 同时,你要知道在node中如何支持两种模块规范的同时运行并且存在哪些兼容性问题
es6定义标准化的模块
- 实践中,模块化的作用主要体现在封装和隐藏私有实现细节,以及保证全局命名空间清洁上,因而模块之间不会意外修改各自定义的变量、函数和类。
基于类、对象和闭包的模块
- 类之所以成为模块,是因为对象是模块:给一个JavaScript对象定义属性非常像声明变量,但给对象添加属性不影响程序的全局命名空间,也不影响其他对象的属性。JavaScript定义了不少数学函数和常量,但并没有把它们定义在全局命名空间中,而是将它们分组作为属性定义在全局Math对象上。
- 问题:类和对象没有提供任何方式来隐藏模块的内部实现细节。
- 其实这里的类,更多的是像之前提到的模块对象,例如全局对象,jquery、Swipe这种全局对象,其本质上也就是将一个库的所有功能都放到一个对象下面,这样就可以将这个全局对象视为一个模块,不会将自身的一些API都全部暴露在全局对象下面,更好的防止命名冲突和覆盖,再配上后续的立即调用的函数来隐藏自身不需要暴露的细节,这样一个库就可以只需要暴露一个全局对象,那么需要处理命名冲突的也只有这一个全局对象了。
- 在函数中声明的局部变量和嵌套函数都是函数私有的。这意味着我们可以使用立即调用的函数表达式来实现某种模块化,把实现细节和辅助函数隐藏在包装函数中,只将模块的公共API作为函数的值返回。
- 同时,基于闭包的私有变量的隐藏细节,在一个JavaScript代码文件开头和末尾插入一些文本,把它转换为类似的模块是一个相当机械的过程。如果存在打包工具,并且存在一个导入模块和导出模块API的规范,那么只需要按照一个规范去编写导入模块代码和导入模块代码,再交由API去打包为浏览器识别的代码,不就实现了模块化了?而es模块之前,这个由require和module.exports来实现
UMD、AMD和CMD
模块化的发展过程,在es模块出来之前的,前端面临着:
- 全局作用域污染,命名冲突等
- 依赖管理混乱,例如需要手动控制不同脚本的加载顺序
- 早期解决方案(如 IIFE、命名空间)仅能隔离作用域,无法解决 “依赖自动管理”
AMD:浏览器端异步模块化的首个标准化尝试(了解即可)
- 核心目标是 “解决浏览器端异步加载模块 + 依赖管理”。
- 特点
- 异步加载:模块和依赖都异步加载,不阻塞浏览器渲染;
- 提前执行:依赖模块加载完成后立即执行,再执行当前模块;
- 语法特征:通过 define 定义模块,require 加载模块,依赖数组显式声明。
- 首个成熟的浏览器异步模块化方案,解决了依赖加载顺序问题,适配前端环境;
- 问题
- 语法冗余(依赖数组 + 回调函数);
- “提前执行” 可能加载未使用的模块,造成性能浪费;
- 与 Node.js CommonJS 规范不兼容,前后端代码无法复用。
- 典型代表:RequireJS
- 衰落原因:语法繁琐,且未解决 “前后端通用” 问题,逐步被 CMD/UMD 替代。
1 | // 定义模块(带依赖) |
CMD:贴合 CommonJS 的浏览器异步模块化(按需执行)- 我只能说,这个也挺老的了,和CommonJS不是同一个东西,也仅需了解即可
- 由 SeaJS 作者玉伯(阿里) 提出,全称 Common Module Definition(通用模块定义),核心目标是 “兼容 CommonJS 语法,实现浏览器端按需加载”。
- 核心设计(对标 CommonJS,优化 AMD)
- 异步加载 + 延迟执行:依赖模块加载后不立即执行,等到 require 调用时才执行(按需执行);
- 就近依赖:无需提前声明依赖数组,在代码中就近 require 即可,更贴近 CommonJS 写法;
- 语法特征:define 接收 function(require, exports, module),与 Node.js 写法一致。
- 优势:
- 语法更贴近 CommonJS,降低前后端代码迁移成本;
- “延迟执行” 减少无用模块执行,性能更优;
- 局限:
- 仅适配浏览器端,仍无法直接在 Node.js 运行;
- 生态单一(主要是 SeaJS),社区影响力弱于 AMD/UMD;
- 依赖动态分析(毕竟是动态依赖),构建工具优化难度高。
- 代表:seajs
- 衰落原因:阿里停止维护 SeaJS,且 ES6 模块和 Webpack 等构建工具崛起,按需加载可通过构建工具实现。
1 | // 定义模块(就近依赖,延迟执行) |
UMD:跨环境的通用模块化方案(AMD+CommonJS + 全局)非主流,通常用于兼容老项目和兜底用的,或者cdn分发
- AMD/CMD 仅适配浏览器,CommonJS 仅适配 Node.js,前端库 / 框架(如 jQuery、Vue 早期)需要 “一份代码兼容所有环境”,因此 UMD(Universal Module Definition,通用模块定义)应运而生 —— 它不是全新规范,而是 AMD + CommonJS + 全局变量 的兼容封装。
- 核心设计
- 环境检测:运行时判断当前环境(Node.js/AMD/ 浏览器全局),自动适配对应模块化规范;
- 无侵入兼容:底层复用 AMD/CommonJS 逻辑,上层提供统一接口;
- 语法特征:通过自执行函数封装环境判断逻辑。
- 本质上其实就是判断当前环境是什么,例如在node还是再浏览器,用的AMD还是CMD等等这种
- 这
专门是给第三方库来进行分发自己的包用的 - 优势:
- 一站式兼容所有环境(Node.js/AMD/ 浏览器),是前端库的 “标配”;
- 无需修改核心逻辑,仅需外层封装,成本低;
- 局限:
- 增加代码冗余(环境判断逻辑);
- 本质是 “兼容层”,未解决模块化本身的性能 / 语法问题。
- 生态与现状
- 代表场景:jQuery、Vue 2、Lodash 等通用库的分发版本;
- 现状:仍广泛用于库的打包(如 umd 格式),但业务代码已被 ES6 模块替代。
- 应用场景:它目前仅用于CDN 分发、兼容老项目(无构建工具 / AMD 环境)、非模块化环境,作为 “全兼容兜底”方案
- IIFE 逐步替代 UMD 成为 “纯浏览器 CDN 首选”
1 | // UMD 典型实现 |
模块化规范的整体演进脉络:全局变量/IIFE(2009前)→ AMD(2009,浏览器异步)→ CMD(2011,浏览器+CommonJS)→ UMD(2010后,跨环境)→ ES6 模块(2015,语言级标准)→ 构建工具+ES6 模块(现状)
现阶段模块化场景主流
- 现阶段无论是业务项目还是 npm 库,构建的核心产物都是
CJS 和 ESM - ES Module(ESM):
- 现代构建工具(Vite/Rollup/Webpack 5+)的 “一等公民”,支持 tree-shaking、静态分析,是前端工程化的首选;
- 浏览器原生支持
(<script type="module">),Node.js 16+ 全面支持("type": "module") - npm 库的 “未来形态”:越来越多库仅发布 ESM 格式(如 Vue 3 源码、Vite 生态包)。
- CommonJS(CJS):
- 兼容 Node.js 老版本(<16)、老打包工具(Webpack 4-)、CommonJS 生态(如 require 加载);
- 作为 “Node.js 兜底方案”,确保库能被 require 加载(如 const lib = require(‘xxx’))。
- UMD:用于浏览器运行,作为全场景的兜底,cdn分发、无构建工具场景、AMD老代码兼容、非模块化场景
主流库会同时输出 ESM/CJS/UMD(部分加 IIFE),通过 package.json 声明入口
1 | { |
es模块
es模块核心规范
- ES6模块化与Node的模块化在概念上是相同的:
- 每个文件本身都是模块,在文件中定义的常量、变量、函数和类对这个文件而言都是私有的,除非它们被显式导出。
- 另外,一个模块导出的值只有在显式导入它们的模块中才可以使用。
ES6模块与Node模块的区别在于导入和导出所用的语法,以及浏览器中定义模块的方式。
- es模块核心定义
- 在常规脚本中,顶级声明的变量、函数和类会进入被所有脚本共享的全局上下文。es6模块有自己的
模块作用域,和全局作用域隔离,模块不会污染全局作用域 - 模块之间只能使用import和export语句导入和导出其他模块的内容,只能显式暴露API,从而隐藏内部不想暴露的细节
- es模块中,所有代码都是严格模式,且更为严格
- es模块有自己的
模块执行上下文,可以参考记录一的执行上下文相关的内容- 其最重要的特点就是:单例执行,每个模块只执行一次,执行完成后保留引用(后续多次 import 时复用)
- 静态依赖管理:模块的执行、依赖加载遵循「
静态分析」规则(编译期确定导入导出,而非运行时),从而支持tree-shaking,但不仅仅是tree-shaking,只不过其静态分析能力刚好比较适合tree-shaking- 下文有说明,在浏览器解析加载es模块时,其本身的加载机制就分为编译和运行两部分,而编译部分就会进行静态分析。
- 在常规脚本中,顶级声明的变量、函数和类会进入被所有脚本共享的全局上下文。es6模块有自己的
- node在13版本开始正式支持es6模块(非实验性)
- 但是要注意,node所支持的es6模块式按照其所有规范实现的,还是仅仅只是语法糖?
es6语法特点
- export导出
export {A , B}:这个语法看起来是export关键字后跟一个对象字面量(使用了简化写法),但这里的花括号实际上不会定义对象字面量。这个导出语法仅仅是要求在一对花括号中给出一个逗号分隔的标识符列表。- 使用export的常规导出
只对有名字的声明有效(具名导出,导出的是一个变量)。用export default的默认导出则可以导出任意表达式(默认导出,导出的是一个值),包括匿名函数表达式和匿名类表达式。这意味着如果使用export default,则可以导出对象字面量。因此,与export语法不同,位于export default后面的花括号是实实在在会被导出的对象字面量。(是否可以理解为export default需要的是一个值,而export后面需要的是一个变量,且带有名字的) - 导出声明不受暂时性死区规则的限制。你可以在声明名称 X 之前声明当前模块导出 X
- 重要特点:要注意
export关键字只能出现在JavaScript代码的顶层。不能在类、函数、循环或条件内部导出值(这是ES6模块系统的重要特性,用以支持静态分析:模块导出的值在每次运行时都相同,而导出的符号可以在模块实际运行前确定)- commonjs规范不要求其导出的语法只能在顶层,但是推荐在顶层,否则会出现导出滞后,预期不一致的情况
- 导入import
- 导入模块不要求其一定在顶层,导入与函数声明类似,
会被“提升”到顶部,因此所有导入的值在模块代码运行时都是可用的。可以参考下文的模块加载 - 通常静态导入放在模块顶层,且在一起,而非顶层的import中,通常只使用import()动态导入,而不是静态导入
- 导入路径规则:导入需匹配导出方式(命名导入 / 默认导入),路径需完整(相对路径 / 绝对路径,浏览器需带后缀)。
- 浏览器环境:必须带文件后缀(如 ./module.js,不可省略 .js);
- Node.js 环境:可省略后缀,可导入目录(需配置 package.json 的 main/exports);
- 模块标识符字符串必须是一个以“/”开头的绝对路径,或者是一个以“./”或“../”开头的相对路径,又或者是一个带有协议及主机名的完整URL。ES6规范不允许类似“util.js”的非限定模块标识符字符串,因为它存在歧义。不过打包工具倒是可以,因为它拥有配置可以标识
- 导入的变量是「只读绑定」(不可修改,指该变量不能赋值):PI = 3; // TypeError: Assignment to constant variable
- 如果是引用类型内部属性则能修改(如导入对象的属性、数组的元素)
- 导入与函数声明类似,会被“提升”到顶部,因此所有导入的值在模块代码运行时都是可用的。且它就像一个常量一样,无法被修改
- 仅加载模块不导入接口(执行模块代码):import ‘./module.js’;
import './init.js':这样的模块会在被首次导入时运行一次(之后再导入时则什么也不做)。如果你需要某个模块的初始化功能,那么理论上可以这么做,但是我觉得,更好的方式应该是导出一个默认的init初始化模块函数,这样可以显式的去初始化模块。- 模块标识符:它以
常量字符串字面量的形式在单引号或双引号中给出(不能使用变量或其他值作为字符串的表达式,也不能把字符串放在反引号中,因为模板字面量有可能插入变量,并非只包含常量值)- 浏览器中,这个字符串会被解释为一个相对于导入模块位置的URL(在Node中,或当使用打包工具时,这个字符串会被解释为相对于当前模块的文件名,不过这在实践中没有太大差别)。
- 该条语句可以同时导入具名值和默认值:
import abc, { x, y } from './xxx',abc就是默认导出的值
- 导入模块不要求其一定在顶层,导入与函数声明类似,
- es模块的顶层await,包含顶层 await 的 ESM 模块会成为「异步模块」,依赖它的模块需等待其 await 执行完成后,才能完成自身的初始化(即代码执行)。
- 如果A - B - C,C是异步模块,那么B需要等等C异步执行完成后才执行,而A需要异步等待B执行完成后才执行
- 整个链路没有卡死,只是执行顺序变为 “C 完成 → B 完成 → A 完成” 的异步串行,而非同步并行
- 执行阶段的暂停仅影响当前模块:C 的 await 只暂停自身的执行,不会影响其他模块的决议 / 实例化,只是上游模块会等待其执行完成后才会实例化。
- 注意,如果B还引用了D模块,D是一个正常的同步模块,那么D会立即执行,即使B中C的导入语法在D的前面,C的异步等待不会影响D的模块执行和加载
- es模块使用
as关键字来对导入的变量进行重命名- 导出时,也可以重命名,且只能在export花括号中使用,也是使用
as关键字,不是变量的冒号啊
- 导出时,也可以重命名,且只能在export花括号中使用,也是使用
- default在模块中算是一个关键字了,因为import或者export语句都可能用到这个default作为语法,其含义就是指代模块的默认导出
import * as命令空间导入时,此时它提供了一个名为 default 的键,用于访问默认导出。- 使用import()来动态导入模块,并且此时该导入可以不需要在顶层。同时import()导入返回 Promise。
- import()调用并不会导致该模块变成异步模块,你将其视为一个普通的异步函数调用就好,虽然他也会对异步加载的模块走es模块解析那一套就是了。
- 等该模块加载执行完毕后,promise会决议
- es模块中的import()异步导入,其返回值等同于
import * as- 动态import()虽然看起来像函数调用,但其实并不是。事实上,
import()是一个操作符
- 动态import()虽然看起来像函数调用,但其实并不是。事实上,
- import.meta这个特殊语法引用一个对象,这个对象包含当前执行模块的元数据。
- 其中,这个对象的url属性是加载模块时使用的URL(在Node中是file://URL)
- es的静态分析
- ES 模块的「静态分析」是其核心优势(支持 tree-shaking、依赖预构建等),而import()动态导入、顶层await虽让模块具备 “异步特性”,但仅影响静态分析的 “范围”,不破坏 ESM 核心的静态规则—— 静态分析仍能覆盖模块内的静态导入导出,仅对异步部分做 “降级处理”。
- 静态分析的 “核心对象”:ESM 静态分析的核心是「编译期解析模块的静态导入导出声明」,目的是:
- 构建模块依赖图(确定模块间的静态依赖关系);
- 标记导出的绑定(为只读、实时更新做准备);
- 识别未使用的导出(为 tree-shaking 提供依据)。
- 静态分析的 “边界” 是:
仅处理编译期可确定的语法(import/export顶层静态声明),对运行时动态逻辑天然不覆盖。 - import()的本质:运行时动态操作,脱离静态分析范畴
- 你把它作为一个简单的异步任务就好,即使是
await import('xxx.js')这种,他也只是一种加载模块的普通异步任务,和一般的异步任务没有任何差别,那么es模块对于它不会做静态分析也合理。 - 它属于 “模块加载的补充方案”,而非 ESM 静态语法的一部分。
- import()导入的模块不会被纳入静态依赖图,仅在运行时加入依赖关系
- import()只能被动参与tree-shaking,影响有限
- import()不会反向更新静态分析树
- 你把它作为一个简单的异步任务就好,即使是
1 | // c.mjs |
ESM 成为标准的核心原因
- 语言级原生支持:摆脱 “社区补丁” 的局限性
- 语法内置
- 作用域原生隔离:模块作用域是 JS 执行上下文的原生类型
- 严格模式默认启用
- 静态特性:适配现代工程化的核心需求
- Tree-Shaking原生支持
- 静态分析可以支持依赖预构建 / 按需加载
- 类型检查友好
- 全环境适配:从浏览器到 Node.js 的统一
- 设计上的先进性:解决传统方案的核心缺陷
- 导出绑定(实时更新):修复 CJS “值拷贝” 的坑
- CJS 导出的是 “值拷贝”(导出后模块内修改值,外部无法同步),而 ESM 导出的是 “变量绑定”(实时引用),逻辑更直观
- 循环依赖友好:先绑定后执行,无致命错误
- 异步模块支持:顶层 await 适配现代异步场景
- ES2022 新增的「顶层 await」让 ESM 可直接在模块顶层处理异步逻辑(如加载远程配置、初始化数据库),无需包裹函数,适配现代异步开发范式
- 导出绑定(实时更新):修复 CJS “值拷贝” 的坑
- 生态演进的必然性:社区与厂商的共同推动
- 生态的集体转向让 ESM 形成 “正循环”:开发者用 ESM → 工具优化 ESM → 更多开发者使用 ESM,最终取代所有非标准方案。
- 最终:CJS 是 “服务端临时方案”,AMD 是 “浏览器临时方案”,而 ESM 是 JS 语言层面为 “全场景模块化” 设计的最终解
es模块运行机制
- import 语句会被引擎提升到当前模块的最顶部(无论书写在代码的哪个位置,只要在顶层),但这只是「语法层面的声明提升」——
模块的加载和初始化时机,由依赖图决定,而非 import 语句的书写顺序。他主要是处理的变量作用域问题 - es模块加载执行分为两步,一步是编译(静态分析,构建依赖图),第二部是执行(执行模块,按照依赖层级初始化)
- 第一步:静态分析,构建依赖图
- 解析模块的 import/export 语句,收集依赖路径,构建依赖树
- 检查导入导出的语法合法性(如名称是否存在、是否动态使用)
- 标记导入的变量为「只读绑定」,并关联所有导出 / 导入的变量绑定关系(静态绑定,而非动态对象引用)。
- 这里的导入会关联到导出模块的对应变量,其实重点就在于关联,它并非简单的变量赋值,更像一种变量同步。
- 这里的构建依赖树,是基于当前加载的模块文件的,也就是浏览器对于这个模块的处理时,就对其执行编译和执行两个步骤,等同于将这个当前模块作为依赖树的顶层模块或者根模块
- 第二步:运行时
- 加载:按依赖图顺序执行模块(先执行依赖模块,再执行当前模块),初始化变量、执行顶层函数声明等。
- 实例化:每个模块仅执行一次(单例),后续重复导入复用同一实例。为每个模块创建模块执行上下文,执行完成后会保留模块的引用。
- 导入的变量与导出模块的变量保持「实时绑定」(导出模块修改值,导入模块会同步更新)(
这种是建立引用关系,而非复制值);- 模块拥有模块作用域,且导入模块后,其实等同于在当前词法环境中添加了这些导出的变量
- 也可能是这些模块中的变量是一种特殊的值,其值是对模块导出的那些变量的地址引用,而非值引用,这样就可以做到实时同步
- 或者更简单,就是底层代码的机制
- 无论 import 语句在代码中写在哪里,
依赖模块的执行永远早于当前模块,即当前模块的代码执行时,其导入模块的代码都已经执行且导入变量都有值了 - 模块的执行顺序由「依赖层级」决定,而非 import 语句的书写顺序;
- 同级依赖按导入书写顺序执行
- 每个模块仅执行一次(单例),后续 import 仅复用已初始化的导出绑定。
- 引擎通过静态分析已明确依赖层级,import 的书写顺序不影响依赖树的执行优先级。(如果同级则看语法顺序)
- import() 是动态导入(返回 Promise),属于运行时加载,无提升特性,执行时机由代码位置决定
- ESM 导出的是变量绑定(而非 CommonJS 的值拷贝,你可以理解,es中其他模块获取的是其他模块的那个变量的地址,而不是那个变量的值,类似于引用那个变量),修改模块内变量会同步反映到导入方。即使是原始值
浏览器中使用模块
- 带有type=”module”属性的脚本会像带有defer属性的脚本一样被加载和执行。
- ES6模块的一个非常棒的特性是每个模块的导入都是静态的。因此只要有一个起始模块,浏览器就可以加载它导入的所有模块,然后加载第一批模块导入的所有模块,以此类推,直到加载完所有程序代码。
- 如果你用模块写了一个JavaScript程序(且没有使用代码打包工具把所有模块都整合到一个非JavaScript模块文件中),那必须使用一个带有
type="module"属性的<script>标签来加载这个程序的顶级模块。这样,浏览器会加载你指定的模块,并加载这个模块导入的所有模块,以及(递归地)加载所有这些模块导入的模块。 - import语句中的模块标识符可以被看成相对URL。而
<script type="module">标签用于标记一个模块化程序的起点。这个起点模块导入的任何模块预期都不会出现在<script>标签中。这些依赖模块会像常规JavaScript文件一样按需加载,而且会像常规ES6模块一样在严格模式下执行。 - 使用
<script type="module">标签定义模块化JavaScript程序的主入口可以像下面这样简单:<script type="module">import './main.js'</script> - 但
<script type="module">增加了跨源加载的限制,即只能从包含模块的HTML文档所在的域加载模块,除非服务器添加了适当的CORS头部允许跨源加载 <script type="module">其加载和执行时机类似于defer标记,异步加载,按顺序执行,不阻塞渲染,那子模块的加载呢?- 注意,这个script标记的顶层模块,是最后执行的,因为需要其依赖的模块按照依赖图按照顺序执行完成后,才会开始执行这个顶层入口模块,所以不同的
<script type="module">理应也是遵循defer的顺序来执行的。通常来说,也不会出现两个这个顶层模块脚本,一个入口不就够了。
- 注意,这个script标记的顶层模块,是最后执行的,因为需要其依赖的模块按照依赖图按照顺序执行完成后,才会开始执行这个顶层入口模块,所以不同的
commonjs模块
- CommonJS(简称 CJS)是
服务端优先的模块化规范,由 Node.js 落地实现,也是 ES6 模块(ESM)普及前前端模块化的重要过渡方案。其核心目标是解决 Node.js 端 “模块作用域隔离、依赖管理、文件系统加载” 的问题,至今仍是 Node.js 原生默认的模块化规范。 - 设计背景:为什么诞生 CommonJS?2009 年前后,JavaScript 语言本身无模块化规范:
- 浏览器端靠 IIFE 隔离作用域,但 Node.js 作为服务端运行环境,需要更完善的模块化能力;
- 服务端场景需求:
同步加载模块(依赖文件系统)、模块单例执行、跨文件依赖管理; - 社区需一套统一的规范,让 JS 代码能在服务端 “按模块组织、按需加载”。
- CommonJS 规范由此诞生,核心定位是:为
非浏览器环境(Node.js)设计的同步模块化方案。 - CJS 本质是 Node.js
通过 “函数包裹模块代码” 模拟的模块化,而非语言原生特性
核心语法:CJS 的导入 / 导出规则
- CJS 的语法核心围绕 module、exports、require 三个核心对象,全部为
运行时动态执行。 - 对于node来说,这些JavaScript代码文件被假定始终存在于一个快速文件系统中。与通过相对较慢的网络连接读取JavaScript文件的浏览器不同,把所有Node代码都写到一个JavaScript文件中既无必要也无益处。
- 每个文件都是一个拥有私有命名空间的独立模块。
- 在一个文件中定义的常量、变量、函数和类对该文件而言都是私有的,除非该文件会导出它们。而被模块导出的值只有被另一个模块显式导入后才会在该模块中可见
- 导出(暴露模块接口)
- CJS 通过 module.exports 或 exports 对象暴露模块接口,本质是 “运行时赋值”。
- 基础导出:module.exports(推荐),module.exports 是模块的 “导出对象”,可赋值任意类型(对象、函数、基本类型),是 CJS 导出的核心方式。
- 简化导出:exports 别名,exports 是 module.exports 的引用(浅拷贝),可直接给 exports 赋值属性,但不可直接覆盖 exports(会断开module.exports的引用)。
- module.exports 优先级高于 exports:若同时使用,以 module.exports 为准;
- commonjs导出的是
值拷贝,导入一次之后,该模块获取的值就已经确定了,后续模块内修改不会同步到外部模块(除非是引用类型内部修改这种)基本上不要考虑对于导出的内容进行动态修改了,也不应该这样(引用类型中的属性修改还是正常的,没有影响)- 其实风险点在于,如果导出的就是一个函数或者一个对象或者值呢?后续如果直接依然覆盖module.exports呢?这就是理解上的差异,导致可能写出这种带有风险的代码
- 风险的核心是「开发者的认知偏差」—— 容易混淆 “基本类型值拷贝” 和 “引用类型引用拷贝”,写出逻辑不一致、难以调试的代码
- 例如:
module.exports = { count, increment: () => count++ }; // count是一个数字变量,这么写确实有问题了 - 引用类型 “意外修改” 导致全局副作用
- 例如:
- exports对象是该模块中的全局对象,该模块下的代码都可见
- module.exports的默认值与exports引用的是同一个对象。
- 导入(引入模块接口)
- CJS 通过 require() 函数导入模块,支持同步加载,返回目标模块的 module.exports 对象。
- CommonJS 的 require 导入无提升,
模块加载 / 执行时机完全由代码书写顺序决定 - require如果携带路径标识符,那么就会根据你设置的具体路径去寻找模块,如果不是一个路径标识符(
是否想携带/或者./),则寻找的是内置模块或者第三方模块(按照模块查找规范) - 导入路径规则:CJS 支持多种路径类型,Node.js 会按优先级解析:
- 核心模块:如 require(‘fs’)、require(‘path’)(Node.js 内置模块,优先级最高);
- 第三方模块:如 require(‘lodash’)(从 node_modules 目录加载);
- 如果是相对 / 绝对路径:如 require(‘./utils.js’)、require(‘/root/utils.js’)(可省略 .js 后缀,Node.js 自动补全)。
- 动态导入(CJS 核心特性)require() 可在任意位置(条件语句、函数内)调用,路径可动态拼接,这是 CJS 与 ESM 的核心区别
- require() 是同步加载:加载时会阻塞后续代码执行(适合服务端,不适合浏览器);
- 模块单例执行:同一模块被多次 require,仅第一次加载时执行代码,后续直接返回缓存的 module.exports;
- 循环依赖处理:CJS 会返回 “未完成的导出对象”(可能包含 undefined),需手动处理。
- node_modules导入查找顺序
- 对第三方模块,Node.js 从当前模块所在目录开始,逐级向上遍历父目录,直到找到包含目标模块的 node_modules,或到达文件系统根目录
- 就近原则,首先按照文件当前目录下的node_modules,如果没有找上级目录的node_modules,一直找到根目录
- 现代 Node.js(v10+)中,若未手动配置 NODE_PATH/npm link,require
不会主动查找全局模块目录了,现在也不怎么推荐全局模块了,从之前的默认,改为手动触发了(配置NODE_PATH) - 破坏 “就近原则”:全局模块会覆盖本地模块,导致依赖版本不可控;
- 跨环境不一致:不同机器的 NODE_PATH 配置不同,易引发 “本地能跑、线上报错”;
- 现代 Node.js(v10+)中,若未手动配置 NODE_PATH/npm link,require
- 定位模块的入口文件
- 读取模块目录下的 package.json,查找 main 字段(如 “main”: “index.js”);
- 若无 package.json 或 main 字段不存在,默认查找 index.js(CJS)/index.mjs(ESM);
- 若开启 ESM(package.json type: module),尝试找到module字段,优先查找 index.mjs 或按 exports 字段(下文详解)。
- ESM 与 CJS 的查找差异
- 文件后缀:ESM 强制要求完整后缀(如 import ‘./utils.js’,不可省略 .js),而 CJS 可省略
- ESM 优先读取 package.json 的 exports 字段(模块化导出映射),而非 main 字段
- 通过Exports对象和require()函数定义和使用的模块是内置于Node中的。但如果使用webpack等打包工具来处理代码,也可以对浏览器中运行的代码使用这种风格的模块。目前,这种做法仍然非常常见,很多在浏览器上运行的代码都是这么做的。不过,通常用的是es的模块
- CJS原生不支持顶层await,其是ESM的专属
- CJS 的核心设计是「服务端同步加载」,而 await 是异步语义,两者从底层逻辑上冲突
- require() 是同步阻塞式加载
- CJS 模块编译后是同步执行的函数,无异步async上下文
运行原理:CJS 模块的加载 / 执行机制
- 其导入时的加载和执行时同步的,立即的,运行时的,即运行到哪里就会立即同步执行模块的加载和运行,完成后返回
- 加载阶段
- Node.js 接收到 require(modulePath) 后,先解析路径(补全后缀、查找 node_modules),确定模块的绝对路径。
- 编译阶段
- 将模块文件编译为 JS 函数,注入核心变量,例如
__filename, __dirname- module:当前模块的元数据对象(包含 exports、id、filename 等)
- 将模块文件编译为 JS 函数,注入核心变量,例如
- 执行阶段:调用编译后的函数,执行模块内的代码
- 执行过程中,给 module.exports/exports 赋值,完成导出;
- 若有循环依赖(如 A→B→A),B 会获取 A 未完成的 module.exports(可能包含 undefined)。
- 缓存阶段:执行完成后,将 module.exports 缓存到 require.cache 中
- 后续再次 require 该模块时,直接从缓存读取,不重复执行代码;
- 可手动删除缓存(
delete require.cache[modulePath]),实现模块重新加载。
node将模块代码编译为函数代码:
1 | // 模块编译后的伪代码 |
CJS 的优缺点
- 优点
- 适配服务端:同步加载契合 Node.js 文件系统特性,开发体验好;
- 动态灵活性:require() 可动态拼接路径、条件加载,适配复杂业务逻辑;
- 简单易用:语法直观,无需额外编译,Node.js 原生支持;
- 生态丰富:早期 npm 包几乎全部基于 CJS 开发,兼容范围广。
- 缺点
不适合浏览器:同步加载会阻塞浏览器渲染,需打包工具(Webpack)转为异步;- 无 Tree-Shaking:
动态依赖无法静态分析,无法剔除未使用代码,产物体积大; - 导出值拷贝:导出后模块内修改值,外部无法同步更新,易引发逻辑错误;
- 循环依赖处理差:易出现 undefined,需手动设计模块初始化逻辑。
es模块和node的commonjs模块的区别
| 维度 | CommonJS (CJS) | ES Module (ESM) |
|---|---|---|
| 设计目标 | 服务端(Node.js)同步模块化 | 全环境(浏览器 / Node.js)静态模块化 |
| 加载时机 | 运行时动态加载(require 可任意位置) | 编译期静态加载(import 仅顶层) |
| 导出特性 | 导出值拷贝(运行时赋值) | 导出绑定(实时更新,编译期确定) |
| 加载方式 | 同步加载(阻塞执行) | 异步加载(浏览器,根据依赖图执行)/ 同步加载(Node.js) |
| 作用域 this | this 指向 module.exports(模块作用域) | this 指向 undefined (模块作用域) |
| Tree-Shaking | 不支持(动态依赖无法静态分析) | 原生支持(静态依赖可精准剔除) |
| 循环依赖 | 返回未完成的导出对象(可能 undefined) | 先绑定后执行(实时更新,无致命错误) |
| 路径规则 | 可省略后缀(如 require(‘./a’)) | 浏览器需带后缀(如 import ‘./a.js’) |
- 模块解析规则差异
- es模块必须要带扩展名
./utils.js,否则找不到模块,而commonjs规范则可以省略扩展名,即es规定的路径规则更加严格 - 导入目录下的模块,es模块需要显式指定索引文件,而commonjs不需要,例如
import a from './utils/index.js',而commonjs可以省略index.js
- es模块必须要带扩展名
- 由于es模块的import和export是静态的,所以可以很好的基于文件代码进行静态分析,例如树摇,而commonjs则不行了,其是动态的。
- es模块规定导入和导出的是变量绑定,而非值拷贝,而node的commonjs模块导入的是值
- es模块是编译期静态绑定,运行时实时追踪
- commonjs是运行时同步拷贝,一次性赋值
- 注意:前端打包工具(Webpack/Rollup/Vite 等)为兼容低版本浏览器,会将 ESM 编译为 CommonJS(或 IIFE),此时 ESM 的「变量绑定」特性会被抹平,若代码依赖该特性,必然出现逻辑错误
- 建议要么使用引用类型,这样可以处理导出原始类型的尴尬
- 不要导出可变的原始类型,如果需要改原始类型,但是其又可能会变,那建议导出函数进行判断和校验,而不要导出变量,隐藏其细节的同时还可以避免依赖这种特性。
- 从工程角度,模块导出的变量应尽量是 “只读的”,动态状态建议通过状态管理库(Redux/Vuex/Pinia)管理,而非依赖 ESM 绑定特性 —— 这不仅规避编译问题,也让代码逻辑更清晰。
- es不支持在分支条件内导出,而commonjs可以在任意位置导出
- es模块的加载和运行是静态分析+按依赖运行,而commonjs则是动态解析并按照运行时加载和运行模块
- es没有
__dirname/__filename等变量,而commonjs有 - es模块顶层支持异步,例如顶层await,而commonjs顶层不能支持顶层await,即commonjs无法原生支持异步模块,需要自行兼容
node下的es模块
了解node中es模块和commonjs模块之间混用时的一些特点和限制,可以帮你了解到你通常可能遇到的问题
- node实现的es,拥有:
- 独立的模块加载器,不同于commonjs的模块加载器,专门给es模块用的
- 独立的解析规则,ESM 遵循 URL 解析规则(支持
.//../相对路径,必须带扩展名,且扩展了es模块,同样支持加载node_modules中的模块,同commonjs规则一样),而 CommonJS 遵循文件系统解析规则(可省略扩展名、查找 node_modules); - 独立作用域,且作用域的内容完全兼容es模块,无 require/module/exports 等变量,不能使用
__dirname/__filename等变量
- es模块规范仅定义了模块的语法、静态分析规则、导出 / 导入绑定逻辑,但未规定模块的 “解析和加载机制” —— 规范将 “如何查找模块文件” 的权限完全交给了 “宿主环境”(浏览器 / Node.js/Deno 等)。
- node中的es模块,同样遵循es模块的规范(并非语法糖),同时他也扩展了自身的模块解析算法,增加了裸模块解析规则(
import a from 'lodash' // node支持查找node_modules,而浏览器不支持,即支持裸模块的加载,是为了同comomonjs规范的查找规则和生态对齐 - 在node中的第三方模块,package中可以使用exports字段精确控制你的模块的导出路径
- 可通过 import.meta.url 获取当前模块的 URL 路径(替代 CJS 的 __dirname/__filename);
node为了兼容es模块和commonjs的混用的处理:
- 核心原则是:ESM 可导入 CommonJS 模块,CommonJS 不能直接导入 ESM 模块(需动态导入)
- ESM 加载 CommonJS 模块时,Node.js 会将 CommonJS 的 module.exports 封装为 ESM 的默认导出
- 将 CommonJS 模块的 module.exports 对象,作为 ESM 的 default 导出
- 没有具名导出,named export ,如果需要类似es模块中的解构导入写法是不行的,你需要先导入后,再手动解构
- 值拷贝(而非绑定):由于 CommonJS 导出的是值拷贝,ESM 导入后无法获取模块内变量的实时更新(与纯 ESM 的绑定不同)
- 不过由于整个modeule.export是一个对象,作为default了的话,至少也是一个引用类型的,所以影响不会特别大,除非有不同的分支导出了不同的对象,不过由于缓存只执行一次,所以,基本上也没有修改的可能了
- ESM 导入 CommonJS 模块时,无法使用 import * as cjs from ‘./cjs.js’ 获取 named export(所有属性都会挂在 cjs.default 下)
- CommonJS 的 require 是同步加载,而 ESM 模块可能有异步依赖(如 import()或者顶层await),因此 Node.js 禁止 CommonJS 用 require 同步导入 ESM,仅允许通过 import() 动态导入(返回 Promise)。
- import() 是唯一桥接方式,且返回的 ESM 模块保留 “绑定特性”(与纯 ESM 导入一致);
- CommonJS 动态导入 ESM 时,需注意 ESM 模块的解析规则(必须带扩展名,如 ./esm.mjs 而非 ./esm)。
- 文件扩展名与 package.json 的 “类型标记”(兼容的核心开关)
- 即node配合文件后缀:mjs、cjs(文件后缀优先级高)和package.json文件的type来确定是es模块(module)还是commonjs(commonjs默认)
- 循环依赖差异
- CommonJS 循环依赖:加载时返回未完成的 module.exports(可能拿到 undefined);
- ESM 循环依赖:保留变量绑定,即使循环依赖也能获取实时值(更健壮)。
循环依赖问题和处理
es的循环依赖:模块A导入模块B的a变量并同时也将a给导出出去,而模块B也导入模块A的a变量并同时也将a给导出出去。
- ESM 天生支持循环依赖(A 导入 B,B 导入 A),核心逻辑是「绑定优先,执行延后」
- 原理:编译期先建立变量绑定,运行期按依赖图执行,未执行的模块变量暂时为 undefined,执行后自动同步。
CJS处理循环依赖
- 若有循环依赖(如 A→B→A),B 会获取 A 未完成的 module.exports(可能包含 undefined)
- 循环依赖处理,通常都是判断模块是否已经在处理过程了,如果是,则直接获取其结果(即使是空的数据,module.exports是一个空对象
{}),肯定不会让其循环加载和执行的。
循环依赖示例(CJS 处理逻辑)
1 | // a.js |
差异
- CJS 循环依赖会返回 “未完成的导出对象”(易出现 undefined),
- ESM 先建立变量绑定再执行代码,即使循环依赖也不会抛出 ReferenceError,仅
临时 undefined(后续同步更新),容错性更高。
现代打包工具使用的模块系统
现代打包工具(浏览器运行环境)在将模块打包完成后,其加载方式是一种自定义加载规范还是说遵循某一种模块规范?
- 现代打包工具(Webpack/Vite/Rollup)将 ES 模块(ESM)打包后,浏览器端的最终产物并非严格遵循原生 ESM/CJS/AMD 规范,而是基于「浏览器运行时特性」设计的「自定义加载逻辑」 但该逻辑会兼容浏览器的原生模块能力,且底层复用了模块化的核心思想(作用域隔离、依赖管理)。
- 这种打包后的规范需要考虑的是什么?
- 首先肯定不是完全的原生es模块,因为浏览器不一定支持
- 作用域隔离,不污染全局作用域。那么就需要类似函数这种函数作用域来隔离(例如立即执行函数)
- main主入口文件,同样也需要隔离作用域
- 每个文件作为一个模块(es和commonjs规范都按照文件作为一个模块),在打包过程中,可以合并多个模块为一个模块
- 执行时机
- 异步执行、动态执行、同步执行,在运行时加载模块时,如果模块为未执行,那么就执行并缓存,如果已经执行则使用缓存
- 核心就是
- 模块注册表:定义所有模块
- 模块缓存:缓存已经被加载执行的模块
- 模块加载器:加载并执行模块,获取其暴露的API
- 生成环境:自定义加载规范的核心实现(以 Webpack/Rollup 为例)
- 打包工具将多个 ESM 模块合并为单个 / 多个 chunk 文件后,会放弃原生 import/export(避免浏览器跨域 / 路径问题),转而实现一套轻量的「自定义模块加载系统」,核心由 3 部分组成
- 模块注册表:管理所有模块的定义
- 打包工具会为每个原始 ESM 模块分配唯一 ID,构建一个「模块注册表」(通常是对象),存储模块的定义函数(包含模块的核心逻辑)
- 模块加载器:处理依赖解析与模块执行
- 实现一个自定义加载函数(如
__webpack_require__),替代原生 ESM 的导入逻辑,核心逻辑- 缓存已执行的模块(单例特性,同 ESM/CJS);
- 按模块 ID 查找注册表,执行模块定义函数;
- 处理模块间的依赖关系(替代原生依赖图):其实也不需要特殊处理,因为本身所有模块注册表和模块缓存都已经有了,除非是异步的,那么所有模块都已经注册好了
- 实现一个自定义加载函数(如
- 入口执行:启动整个模块系统:最后通过自执行函数(IIFE)执行入口模块,完成整个应用的加载
- 模块注册表:管理所有模块的定义
- 关键特征:自定义规范的核心设计
- 作用域隔离:通过函数作用域替代原生模块作用域,避免全局污染(同 ESM);
- 单例执行:模块缓存机制确保每个模块仅执行一次(同 ESM/CJS);
- 依赖管理:通过模块 ID 和自定义 require 函数解析依赖(替代原生 ESM 的静态依赖图);
- 无原生模块化依赖:最终产物是普通 JS 文件,可通过
<script>直接加载,无需浏览器支持原生 ESM
- 开发环境:遵循原生 ESM 规范(以 Vite 为例)
- 现代打包工具的开发环境(如 Vite、Webpack 5+ 开发模式)为了提升效率,会保留原生 ESM 规范
- Vite:开发时不打包,直接将源码转为原生 ESM,通过
<script type="module">加载入口,通常只是加载入口。后续的异步路由,可能分割了,也可能没有分割(不过大型项目,应该会分割吧,不然同一时间加载所有的模块其文件也太多了)- 浏览器原生解析 import/export,依赖预构建仅处理第三方包(转为 ESM);
- 核心:完全遵循原生 ESM 规范,无自定义加载逻辑;
- 核心原因:开发效率优先
- 原生 ESM 支持热模块替换(HMR)、按需加载,无需重新打包整个应用;
- 避免自定义加载逻辑的额外开销
Tree-shaking
为啥es模块可以更好的支持tree-shaking,它是如何分析代码的,如何做到 tree-shaking的?
Tree-Shaking(摇树优化)的本质是 “剔除代码中未被使用的导出(dead code)”,而 ES 模块(ESM)之所以能完美支持这一优化,核心是其静态语法特性让打包工具能在编译期精准分析代码依赖关系 , 相比 CommonJS(CJS)等动态模块化方案,ESM 从语法层面为 Tree-Shaking 提供了 “可分析、可预测、可剔除” 的基础。
静态分析
- 基于es6的静态分析,即在未运行时,就可以根据语法确定导出哪些成员,导入哪些模块的哪些内容
- Tree-Shaking 的关键是 “编译期确定哪些代码未被使用”,而模块化规范的 “静态 / 动态” 特性直接决定了分析可行性
- es6模块:语法层面确定依赖,没有动态依赖关系(import()方案不会被视为静态语法,而是视为语句,普通的异步任务,不会破坏静态分析)
- commonjs是动态确定导入和导出的,可以根据条件来确定其依赖和导出内容,分析更难,需要根据代码逻辑进行依赖分析,而es只需要根据语法进行依赖分析。所以commonjs这种动态导入导出的,要么有时候细粒度不够。要么分析成本更高
- 导入导出时机:es6在编译器就可以确定,commonjs支持动态导出(运行时确定,可以切套到if条件语句中)
- 导出:es6在编译期就可以确定导出标识符(具名导出、默认导出default),commonjs支持动态导出(运行时确定,可以切套到if条件语句中)
- 依赖关系确定性:es6明确规定导入模块标识符不能动态,而commonjs可以支持动态模块标识符。
import * as xxx是 Tree Shaking 的 “天敌”,在追求代码体积优化的场景下,应尽量使用具名导入替代这种写法。- 因为
将所有内容作为一个命名空间后,其变成了动态的了,理论上可以通过xxx[name]的方式动态访问其中的对象,所以会影响其Tree Shaking的静态分析。
- 因为
- 你在编写时也一样,使用了具名导出后,如果再使用一个默认导出,且这个default默认导出导出了所有的内容(用一个对象包裹)那么使用者直接使用默认导出时,基本上就等同于引用了这个模块的所有导出了,都不能被Tree Shaking,除非用更激进的动态语法分析来Tree Shaking。
- 具体效果仍受「对象构建方式」和「打包工具静态分析能力」的影响。看代码分析的能力了。也就是听天由命的情况。或者也看Tree Shaking的策略是否激进了。
- 若想保留 Tree Shaking 能力,
建议放弃 “默认导出聚合所有成员” 的写法 尽量具名导出、具名导入,defalut导出不要聚合其他的具名导出成员。- 尽量减少使用命名空间的习惯,如果要导入再导出,直接使用
export * from xxxx 的语法,这种特殊语法应该能识别其中的- 注意:
import * as xxx from 'xx'; export default xxx;这种写法,会导致外部模块只能导入一个全量的动态default对象,可能会影响Tree Shaking的。你要知道default语法导出的是一个对象,它是具有动态特性的。
- 注意:
代码剔除
- 编译器确定,抽象语法树
- 打包工具(Rollup/Webpack/Vite)对 ESM 的 Tree-Shaking 分析分为
“依赖图构建→未使用导出标记→死代码剔除”三个核心步骤,全程基于静态分析- 步骤 1:构建静态模块依赖图(编译期):工具首先遍历所有 ESM 模块,解析每个模块的
- 静态导入导出声明:提取import { a } from ‘./b.js’中的导入名称a,以及export const b = 1中的导出名称b;
- 依赖路径:记录模块间的静态依赖关系(如 A→B→C);
- 导出映射表:为每个模块生成 “导出名称→代码位置” 的映射(如export const foo = () => {} → foo对应该函数的代码块)。
- 步骤 2:标记 “未被使用的导出”(死代码标记)
- 工具从入口模块开始,按依赖图反向标记:未被标记的导出(如moduleB的bar/Baz)被标记为 “死代码”
- 特殊处理:
- 若导出是 “副作用代码”(如export const a = console.log(‘hello’)),需判断是否为 “纯函数 / 纯值”—— 纯值可剔除,有副作用的需保留;
- 若使用
import * as mod from './module.js',工具会标记 “所有导出均被使用”(无法静态判断具体用了哪个,因此 Tree-Shaking 失效)。
- 步骤 3:剔除死代码(编译期 / 压缩期)标记完成后,工具分两步剔除死代码:
- 导出级剔除:直接删除未被使用的导出
- 语句级剔除:若导出依赖的内部变量未被其他代码使用,连带删除内部变量(如
const bar = () => {}; export { bar }→ 同时删除bar函数和导出声明); - 压缩优化:结合 Terser 等压缩工具,进一步剔除空语句、未引用变量,最终生成精简代码。
- 步骤 1:构建静态模块依赖图(编译期):工具首先遍历所有 ESM 模块,解析每个模块的
- 上面的细节主要在剔除死代码上,需要确保剔除的代码没有副作用
- Tree-Shaking 是 “安全地删除未被使用的代码”,而 “有副作用的代码” 是指 “执行后会对外部环境产生不可逆影响的代码”—— 即使代码未被显式引用,删除它也会改变程序行为,因此工具会被迫保留这类代码,导致 Tree-Shaking 失效。
- 动态导入对 Tree-Shaking 的影响,import()动态导入本身不影响静态 Tree-Shaking,但需注意:
- 动态导入的模块会被单独分包,其内部的 ESM 代码仍可正常 Tree-Shaking;
- 若动态导入的路径是动态的(如import(./${name}.js)),工具无法静态分析,该模块的 Tree-Shaking 失效。
- 误区:认为 “只要用 ESM 就一定能 Tree-Shaking”,以下场景会导致 Tree-Shaking 失效:
- 使用import * as mod from ‘./module.js’(无法确定使用哪些导出);
- 导出有副作用的代码(如export const a = console.log(‘hello’));它执行了打印
- 将导出赋值给全局变量(如window.foo = foo; export { foo });
- 模块被标记为有副作用(package.json的sideEffects配置错误)。
- 误区:混淆 “导出级剔除” 和 “代码级剔除”
- Tree-Shaking 优先剔除 “未被使用的导出”,但如果导出的代码内部引用了其他未导出的变量,这些变量也会被连带剔除。简单来说,最终没被使用的代码通常会被剔除。
- 误区:认为 CJS 也能完整 Tree-Shaking
- CJS 的动态特性导致其仅能实现 “有限 Tree-Shaking”(如 Webpack 的
usedExports优化),但无法像 ESM 一样精准: - CJS 的module.exports = { a: 1, b: 2 }是运行时赋值,工具无法确定b是否被使用;
- CJS 的require可在条件语句中调用(if (flag) require(‘./a.js’)),依赖图不可预测。
- CJS 的动态特性导致其仅能实现 “有限 Tree-Shaking”(如 Webpack 的
Tree-shaking真正复杂的点在于如何识别和分析哪些代码可以安全剔除,不影响业务,哪些代码剔除后会影响业务,这个是Tree-shaking本身最难以处理的部分。
模块常见问题
- 报错:“Cannot find module ‘xxx’ imported from xxx”
- 原因 1:本地模块省略扩展名 → 补充 .js/.mjs 扩展名;
- 原因 2:第三方模块无 ESM 版本 → 确认模块是否支持 ESM(可查 package.json 的 “type”: “module” 或 “module” 字段);
- 原因 3:node_modules 未安装 → 执行 npm install。
- 导入 CommonJS 第三方模块时,named export 报错
- 原因:不要具名导入,CommonJS 模块只有默认导出,无 named export(也就是具名导出,这是因为node在esm加载commonjs时的转换规则所定义的);
- 解决方案:先导入默认导出,再解构
迭代器和生成器
迭代器
- 可迭代对象
- 可迭代对象指的是任何具有专用迭代器方法,且该方法返回迭代器对象的对象。
- 可迭代对象的迭代器方法没有使用惯用名称,而是使用了符号Symbol.iterator作为名字。
- 迭代器对象
- 迭代器对象指的是任何具有next()方法,且该方法返回迭代结果对象的对象。
- 你可以理解为,迭代器每次调用next方法,其都会获取可迭代对象的一个值,直到每个值都获取一遍后,即迭代完成
- 这里的next可以考虑使用闭包实现
- 迭代器调用第一次next时,才会惰性的获取第一个值,你可以这么理解吧
- 迭代结果对象
- 迭代结果对象是具有属性value和done的对象。
- 要迭代一个可迭代对象,首先要调用其迭代器方法获得一个迭代器对象。然后,重复调用这个迭代器对象的next()方法,直至返回done属性为true的迭代结果对象。
- 内置可迭代数据类型的
迭代器对象本身也是可迭代的(也就是说,它们有一个名为Symbol.iterator的方法,返回它们自己)有部分时候有用吧,但是大部分时候估计没用- 例如你手动对一个可迭代对象迭代了几个元素后,想要将剩余元素转换到另外一个地方(例如数组迭代了一半,想要将剩余元素放到另外一个数组中,就可以直接使用扩展运算符
...去操作那个迭代器对象。 - 你别说,这种对于一些代码的写法上,倒是感觉会有一些帮助,或者说简化。理论上你的返回值需要一个可迭代对象时,不需要再将其包装为一个可迭代对象,而是将其包装为一个迭代器对象返回就像,因为迭代器对象本身就是可迭代的,迭代的是它自身。
- 例如你手动对一个可迭代对象迭代了几个元素后,想要将剩余元素转换到另外一个地方(例如数组迭代了一半,想要将剩余元素放到另外一个数组中,就可以直接使用扩展运算符
- 除了next()方法,迭代器对象还可以实现return()方法。如果迭代在next()返回done属性为true的迭代结果之前停止(最常见的原因是通过break语句提前退出for/of循环),那么解释器就会检查迭代器对象是否有return()方法。如果有,解释器就会调用它(不传参数),让迭代器有机会关闭文件、释放内存,或者做一些其他清理工作。
生成器
- 生成器本身怎么说呢,由于想要实现一个可迭代对象,或者自定义对某些对象的迭代,需要实现迭代器方法和迭代器对象,这些起来有点麻烦,所以出来了一个生成器。用来简化迭代器的编写
- 生成器是一种使用强大的新ES6语法定义的迭代器,特别适合要迭代的值不是某个数据结构的元素,而是计算结果的场景
- 此时就用到了生成器函数
- 箭头函数无法作为生成器
- 调用生成器函数并不会实际执行函数体,而是返回一个生成器对象。这个生成器对象是一个迭代器(生成器对象是一个兼容的迭代器对象)。调用它的next()方法会导致生成器函数的函数体从头(或从当前位置)开始执行,直至遇见一个yield语句。
- yield语句的值会成为调用迭代器的next()方法的返回值。
- 生成器对象的next接受参数时,其作为当前位置的yield语句的返回值。
- 生成器函数中回送其他可迭代对象元素的操作很常见,所以ES6为它定义了特殊语法。
yield*关键字与yield类似,但它不是只回送一个值,而是迭代可迭代对象并回送得到的每个值。 - 生成器还是挺有用的,主要是配合
...或者for of可以实现自动迭代,而不是手动迭代,可以简化逻辑
高级特性
- 生成器函数最常见的用途是创建迭代器,但生成器的基本特性是可以暂停计算,回送中间结果,然后在某个时刻再恢复计算。这意味着生成器拥有超越迭代器的特性
- 对于返回值的生成器,最后一次调用next()返回的对象的value和done都有定义:value是生成器返回的值,done是true(表示没有可迭代的值了)。
最后这个值会被for/of循环和扩展操作符忽略,但手工迭代时可以通过显式调用next()得到,即在done为true,value就是整个生成器函数的返回值 - yield是一个表达式(回送表达式),可以有值。
- 关于yield的暂定和恢复
- 调用生成器的next()方法时,生成器函数会一直运行直到到达一个yield表达式。yield关键字后面的表达式会被求值,该值成为next()调用的返回值。此时,生成器函数就在求值yield表达式的中途停了下来。下一次调用生成器的next()方法时,传给next()的参数会变成暂停的yield表达式的值(第一次调用会丢弃其参数)。换句话说,生成器通过yield向调用者返回值,而调用者通过next()给生成器传值。生成器和调用者是两个独立的执行流,它们交替传值(和控制权)。
- 除了通过next()为生成器提供输入之外,生成器还可以调用它的return()和throw()方法,改变生成器的控制流。
- 生成器也有一个return函数,但是其执行什么我们未知,那么我们可以在生成器函数中定义一个try finally语句,保证生成器返回时(在finally块中)做一些必要的清理工作。在强制生成器返回时,生成器内置的return()方法可以保证这些清理代码运行(生成器也不会再被使用)。
- 可以利用生成器来掩盖程序中的异步逻辑
- co如何实现的?他的作用是用于生成器函数自动执行,并以此来做到异步流程管理的
- 利用promise来自动执行生成器函数的执行流程。
- 即yield返回必须是一个promise,此时在promise决议之后,利用then绑定的resole或者reject,来自动执行生成器的next,以达到类似的异步函数的自动执行。
- 其实这个和async和await非常像,把await当做yield(而await后面也预期接收一个promise),然后内部再变成一个自动执行生成器流程的函数(类似co那样的),那么基本上就是async函数等同于:生成器函数 + co机制 + promise的综合语法糖了。
注意:利用生成器来做这些事情会造成代码非常难以理解或解释。不过,一切都过去了,这么做唯一有实践价值的场景就是管理异步代码。JavaScript已经专门为此新增了async和await关键字(参见第13章),因此没有任何理由再以这种方式滥用生成器了。(或许自定义数据结构的迭代也许还有些用处,协程暂时就算了吧)
异步JavaScript
promise
规范来源
- Promise 的多规范源于 ES6 之前 “异步编程无官方标准”,社区先提出 Promise/A → 优化为 Promise/A+(事实标准);
- ES6 Promise 是Promise/A+ 的官方化实现,完全兼容 Promise/A+,并新增了静态方法、语法糖等官方扩展;
- Promise/A+ 是社区统一的 “底线标准”,ES6 Promise 是在此基础上的官方标准化版本,新增了更易用的 API,成为现代 JS 异步编程的唯一标准。
- 至于其他的各种promise/B和Promise/A都可以丢掉了。
Promise/A+ 或者说es promise的详细规范
- ES 的 Promise 标准核心定义于 ECMAScript 2015 (ES6/ES2015) 及后续修订版本(如 ES2020、ES2021),其基础完全遵循社区的 Promise/A+ 规范,并在其之上补充了官方静态方法、语法糖等扩展。
- 期约的then方法绑定的回调方法,
都是异步执行的,不管什么情况 - 如果一个回调返回了的是一个异步promise,那么
后续返回的那一个promise需要等待该异步promise兑现后才会兑现。
规范说明
- Promise 是一个对象,代表一个异步操作的最终完成(或失败)及其结果值,其核心是「状态驱动」的异步流程管控。
- 状态不可逆:一开始是padding,然后变成成功或者失败,且一旦变成了其中任意一个,其状态就不会再变了。且相关状态绑定的值也是不可变的。
- then方法规范
- 参数规则:
- onFulfilled:可选,状态变为 fulfilled 时执行的回调,接收 value 为参数;
- onRejected:可选,状态变为 rejected 时执行的回调,接收 reason 为参数;
- 若参数非函数,会被「忽略」(视为 “透传”:then(null, onRejected) 等价于 catch(onRejected),then(onFulfilled, null) 等价于透传成功值)。
- 等同于:onFulfilled的默认函数是:
(x) => x,onRejected的默认错误函数是:(x) => {throw x},这是为了保证异常错误能够向下传递
- 返回值:then 必须返回一个新的 Promise 实例(与原 Promise 无关联),新 Promise 的状态由then中的回调执行结果决定(参考下文的决议算法相关)
- 若回调返回普通值(非 Promise/thenable)→ 新 Promise 决议为 fulfilled,值为该返回值;
- 若回调返回 Promise/thenable → 新 Promise 「同化」其状态(等待该对象决议,同步状态);
- 若回调抛出异常 → 新 Promise 决议为 rejected,理由为该异常;
- 若回调未执行(原 Promise 未决议)→ 新 Promise 保持 pending
- 执行时机
- onFulfilled/onRejected 必须「异步执行」(规范要求放入「微任务队列」,如 queueMicrotask),即使 Promise 已决议,回调也不会同步执行。
- 参数规则:
- catch方法规范
- ES 规范将其定义为「语法糖」,等价于 Promise.prototype.then(undefined, onRejected)
- finally:立即返回一个新的 Promise,finally() 方法类似于调用
then(onFinally, onFinally)。然而,也会有些不同,- 猜测:也许 finally() 内部会调用 then()也说不定,所以才能够透传其成功和失败的状态
- 无论原 Promise 是 fulfilled 还是 rejected,onFinally 都会执行;
- onFinally 无参数(无法获取 value/reason);
- 返回新 Promise,状态与原 Promise 一致(若 onFinally 抛出异常,则新 Promise 变为 rejected);
- 清理操作(如关闭加载动画、释放资源),不影响原 Promise 的值 / 理由。
- 在 finally 回调函数中抛出错误(或返回被拒绝的 promise)仍会导致返回的 promise 被拒绝。例如,
Promise.reject(3).finally(() => { throw 99; }) 和 Promise.reject(3).finally(() => Promise.reject(99))都以理由 99 拒绝返回的 promise。
- 核心方法(es6以及后续扩展)
- Promise.resolve(value)和Promise.reject(value),可以参考下文
- 若 value 是 Promise 实例 → 直接返回该实例(无新包装);
- 若 value 是 thenable 对象(有 then 方法的对象)→ 执行 then 方法,同化其状态;
- 其他值 → 返回 fulfilled 状态的 Promise,value 为成功值;
- 无参数调用 → 返回 fulfilled 状态的 Promise,值为 undefined。
- Promise.all:批量处理多个 Promise,「全成功才成功」,有一个失败则会理解决议为失败
- 注意:若有 Promise 拒绝,其余 Promise 仍会执行(但结果被忽略)
- Promise.race(iterable):批量处理多个 Promise,「第一个决议的结果决定最终状态」
- 第一个完成决议(无论 fulfilled/rejected)的元素,直接决定新 Promise 的状态和值 / 理由
- 注意:其余 Promise 仍会执行(但结果被忽略)
- Promise.allSettled:批量处理多个 Promise,「等待所有元素决议(无论成败)」
- Promise.any(iterable):批量处理多个 Promise,「第一个成功的结果决定最终状态」
- 第一个决议为 fulfilled 的元素 → 新 Promise 决议为 fulfilled,值为该成功值
- 所有元素都决议为 rejected → 新 Promise 决议为 rejected,理由为 AggregateError(包含所有拒绝理由的错误对象);
- Promise.resolve(value)和Promise.reject(value),可以参考下文
- 规范核心规则
- 状态不可逆:一旦从 pending 转为 fulfilled/rejected,状态永久固定,value/reason 不可修改;
- 回调异步执行:所有 onFulfilled/onRejected 必须异步执行(微任务队列),即使 Promise 已决议;
- 决议算法(Promise Resolution Procedure):
- resolve 回调 /Promise.resolve 必须遵循该算法:
- 若传入自身 Promise → 抛出 TypeError(避免循环引用);
- 若传入 Promise/thenable → 递归同化状态;
- 普通值 → 直接决议为 fulfilled;
- 拒绝无同化:
- reject 回调 /Promise.reject 不执行任何状态同化,传入值直接作为 reason;
- 异常透传:
- 若 then/catch 回调抛出异常,会透传到下一个 catch/then 的 onRejected 回调
说明
- 期约是一个对象,表示异步操作的结果。即异步结果的容器。很广泛的一种说明。
- 在最简单的情况下,期约就是一种处理回调的不同方式。不过,使用期约也有实际的好处。可以解决回调的嵌套地狱。其实promise本质上也是处理异步回调的,不过其处理的方式是一种线性的链式表达,能够更加容易阅读和理解
- 回调的另一个问题是难以处理错误。如果一个异步函数(或异步调用的回调)抛出异常,则该异常没有办法传播到异步操作的发起者。异步编程的一个基本事实就是它破坏了异常处理。对此,一个补救方式是使用回调参数严密跟踪和传播错误并返回值。但这样非常麻烦,容易出错。期约则标准化了异步错误处理,通过期约链提供了一种让错误正确传播的途径
promise的核心(其实更多的是只需要了解核心即可,一些非常微妙的特性,可以考虑简单了解即可)
- 创建一个promise对象时,接收一个同步函数,存在两个参数:resolve和reject,当异步任务完成,调用resolve并传递数据,如果异步出现异常,则调用reject并传递错误。
- promise对象支持then方法,可以绑定resolve和reject调用时的回调函数
- 注意,你只要调用了then方法,那么即使其两个参数你都没有传递(或者只传递了其中一个),那么内部会为没有传递的添加一个默认的处理函数,因为then怎样都会返回一个promise,其总要被决议,所以给个默认处理函数倒是也合理。
- resolve的默认函数是:
(x) => x,reject的默认错误函数是:(x) => {throw x},这是为了保证异常错误能够向下传递
- promise支持catch方法,支持绑定reject调用时的回调函数,它是
promise.then(null, fn)的语法糖。- catch仅仅只是语法糖,没有所谓的特殊的功能,它真的仅仅和
promise.then(null, fn)是等价的,而我们看起来在整个promise的链最后添加一个catch似乎是能够捕获整个链中出现的,并不是因为catch有多特殊,而是promise本身的异常传递机制就是这样的:Promise 链的异常会 “透传” 到第一个能处理它的 onRejected 回调。你在最后添加一个promise.then(null, fn)也是和catch同样的一个效果。
- catch仅仅只是语法糖,没有所谓的特殊的功能,它真的仅仅和
- finally方法
- 类似try/catch/finally语句的finally子句。如果你在期约链中添加一个.finally()调用,那么传给.finally()的回调会在期约落定时被调用。无论这个期约是兑现还是被拒绝,你的回调都会被调用,而且调用时不会给它传任何参数,因此你也无法知晓期约是兑现了还是被拒绝了。
- promise的这几个方法都支持返回一个新的promise,新的promise的状态基于上一个绑定的promise执行的then或者catch绑定的回调函数
- 这里的逻辑其实挺复杂的,而且之前还分为很多不同的promise规范,不过现在来看其核心是Promise/A+规范(或者说es的官方规范,毕竟这两个规范都是兼容的)
- 当API被设计为使用这种方法链时只会有一个对象,它的每个方法都返回对象本身,以便后续调用。然而这并不是期约的工作方式。我们在写.then()调用链时,并不会在一个期约上注册多个回调。相反,每个then()方法调用都返回一个新期约对象。这个新期约对象
在传给then()的函数执行结束才会兑现。
- 如果多次调用
一个期约对象的then()方法,则指定的每个函数都会在预期计算完成后被调用。 - 期约表示的是一次计算,
每个通过then()方法注册的函数都只会被调用一次。有必要指出的是,即便调用then()时异步计算已经完成,传给then()的函数也会被异步调用。(期约状态变化时触发一次,后续添加then时异步触发一次) - 期约的错误处理
- 同步计算出错会抛出一个异常,该异常会沿调用栈向上一直传播到一个处理它的catch子句。而异步计算在运行时,它的调用者已经不在调用栈里,因此如果出现错误,根本没办法向调用者抛回异常。
- 一个错误只要传给了.catch()回调,就会停止在期约链中向下传播。.catch()回调可以抛出新错误,但如果正常返回,那这个返回值就会用于解决或兑现与之关联的期约,从而停止错误传播。
promise的解决(这里指new Promise((resolve) => {resolve(v)})的resolve(v)这一句)和Promise.resolve()底层的细节和含义
- Promise 的 “解决(resolve)”
- “解决” 是 Promise 状态流转的核心动作,对应 Promise 从 pending 转为 fulfilled(成功)的过程(注意:“解决”不等于“成功”,特殊场景下 resolve 可能触发拒绝),底层遵循 ES6/Promise/A+ 规范的「决议算法(Promise Resolution Procedure)」。
- 当调用 Promise 构造器的 resolve 回调(new Promise((resolve, reject) => { resolve(value) }))时,本质是触发
「决议算法」,而不是直接将其promise设置为成功,其状态还需要由决议算法决定,也就是resolve回调期间,其promise状态还不一定确定。 - 注意,决议算法中的v不仅仅是fulfill回调的值,也可能是onRejected回调的值,这可以解释为何如果一个异常被catch的回调捕获了之后,如果该回调返回一个正常的值,后续的promise会触发成功的回调函数。
- “解决” 的目标是将 Promise 与 value 关联,而非简单 “设为成功”;
- 若 value 是 Promise / 类 Promise 对象,会 “同化” 其状态(等待该对象决议,再同步自身状态);
- 仅当 value 是普通值时,“解决” 才等价于 “成功(fulfilled)”。
- 如果一个then中的回调(不管是成功或者失败的回调)被调用了(并且执行完成后得到了结果,没有抛出异常),那么下一个promise会进入“解决”状态,即下一个promise等同于触发了其构造函数的
resolve(上个then回调函数的结果),此时触发决议算法。这也解释了为啥,如果一个then的回调返回了一个promise,那么其状态能够同步和关联到下一个promise - Promise.resolve() 是 ES6 提供的静态方法,本质是「快速创建已决议 Promise 的语法糖」,底层直接复用上述 “决议算法”,但有明确的封装逻辑。
- 若 value 已是 Promise 实例,直接返回(避免重复包装)
- 否则,创建新 Promise 并执行决议算法,等同于:
new Promise((resolve, reject) => { resolve(value) }),即通过创建一个promise并调用resolve来触发决议算法。
- Promise.resolve() 不是 “万能包装”
- 它无法将 “已拒绝的 Promise” 转为成功:Promise.resolve(Promise.reject(err)) 仍返回拒绝的 Promise;
- 它仅 “接管状态”,而非 “修改状态”。
- 无论 resolve 的值是普通值还是 Promise,onFulfilled/onRejected 回调始终异步执行(微任务),这是规范强制要求
- reject()和Promise.reject()静态方法
- Promise 构造器的 reject 回调(
new Promise((resolve, reject) => reject(reason)))是触发 Promise 拒绝的核心动作,其底层逻辑远比重置 resolve 的决议算法简单,且无递归 / 同化逻辑。直接就是将promise设置为拒绝,且触发微任务的异常回调- 无视reject()的参数,
不管其值是原始值,还是promise,直接将其值作为拒绝的理由
- 无视reject()的参数,
- Promise.reject()静态方法直接类似于调用
new Promise((resolve, reject) => reject(reason))- 永远是 rejected(无例外)
- 无视传入的值类型,直接将值作为拒绝理由绑定,即使其传入了一个promise,也直接返回一个新的promise。
- 关于决议算法的说明
- 由于决议算法的实现,如果某个promise在决议时(resolve(p)),其值p是一个promise,那么这个promise的状态就由这个p的状态同步或者说关联,那么如果p的状态本身也时需要决议的呢?也就是由另外一个p2的promise状态关联的,那么以此下去是否就可以做到任意循环任意次数的promise了。不过会有点绕。不过,建议
for await搞定串行的
- 由于决议算法的实现,如果某个promise在决议时(resolve(p)),其值p是一个promise,那么这个promise的状态就由这个p的状态同步或者说关联,那么如果p的状态本身也时需要决议的呢?也就是由另外一个p2的promise状态关联的,那么以此下去是否就可以做到任意循环任意次数的promise了。不过会有点绕。不过,建议
promise的决议算法:
1 | // 伪代码:Promise 决议算法核心 |
关于解决的很有意思的一个例子:
1 | // 场景2:resolve 另一个 Promise → 同化其状态 |
一些promise的API
- Promise.resolve()和Promise.reject()
- Promise.all
- Promise.all()接收一个期约对象的数组作为输入,返回一个期约。如果输入期约中的任意一个拒绝,返回的期约也将拒绝;否则,返回的期约会以每个输入期约兑现值的数组兑现。
- Promise.race()返回一个期约,这个期约会在输入数组中的期约有一个兑现或拒绝时马上兑现或拒绝(或者,如果输入数组中有非期约值,则直接返回其中第一个非期约值)。
- ES2020中,Promise.allSettled()也接收一个输入期约的数组,与Promise.all()一样。但是,
Promise.allSettled()永远不拒绝返回的期约,而是会等所有输入期约全部落定后兑现。- 这个返回的期约解决为一个对象数组,其中每个对象都对应一个输入期约,且都有一个status属性,值为fulfilled或rejected。
promise的一些问题:
- Promise 解决了回调地狱,但多层嵌套的异步逻辑仍会导致「.then 链过长 / 嵌套过深」,使用不当,其理解仍然可能混乱
- promise链的异常和传递和回调值类型差异的理解较为难
async和await
理论上async函数本身的了解和功能,你可以不需要了解或者深入生成器函数的原理(不过已经了解了的更好)
- 这两个新关键字极大简化了期约的使用,允许我们像编写因网络请求或其他异步事件而阻塞的同步代码一样,编写基于期约的异步代码。虽然理解期约的工作原理仍然很重要,但在通过async和await使用它们时,很多复杂性(有时候甚至连期约自身的存在感)都消失了。
- 基础的async函数babel转换,也许是直接转换为多个promise嵌套去实现的也说不定。
- async函数是生成器函数和promise的语法糖
- 把await当做yield(而await后面也预期接收一个promise),然后内部再变成一个自动执行生成器流程的函数(类似co那样的),那么基本上就是async函数等同于:生成器函数 + co机制 + promise的综合语法糖了。
- 生成器机制的流程控制 + promise + 基于promise的自动执行(类似co)
特点
- async函数如果没有await,那么整个函数体的执行都是
同步的,或者说调用一个async函数时,第一个await表达式之前的所有代码都是同步执行的。- 不过整个async函数的返回是一个promise,其决议后的回调是需要异步的(满足promise定义)
- await期待一个promise,如果不是,则会将其包装为一个promise
- 每个 await 表达式之后的代码可以被认为存在于
.then回调中。通过这种方式,可以通过函数的每个可重入步骤来逐步构建promise 链。而返回值构成了链中的最后一个环。 - async整个函数会将其中的await包装为promise的then,不过这种包装是惰性的、随着控制链交替分阶段构建的,即只有运行到当前的await时,才会将其包装到then中,
如果一个async函数中,有一个promise先被创建,但是没有及时为其包装then,也不是第一个await的,那么在调用到它的await语句之前reject了,那么整个async函数返回的promise是无法捕获到这个错误的(即使你为其添加catch也没有用) - async函数调用后会自行创建一个新的promise并返回(即使你返回了一个promise),不要试图将返回的promise值和其他值做比较。
- 理解await:可以把await关键字想象成分隔代码体的记号,它们把函数体分隔成相对独立的同步代码块。js引擎可以把函数体分割成一系列独立的子函数,每个子函数都将被传给位于它前面的以await标记的那个期约的then()方法。
- 可以想象为把await后面的代码,放到一个await的那个promise的then方法里面
异步迭代器:for await(await 配合 for of循环)
- 类似这种语法:
for await (const promise of array) { xxx // 这里面也可以继续使用await },也就是这个迭代的await可以自动等待每个迭代的期约决议后再继续执行。同时花括号里面也可以继续使用await。 - 若迭代对象是普通可迭代对象(如普通数组),for await…of 等价于 for…of,但循环体仍可使用 await;
- 若迭代对象是异步可迭代对象(如异步生成器、ReadableStream),for await…of 会等待每个迭代项的 Promise 决议后,再执行循环体。
- 异步迭代器与常规迭代器非常相似,但有两个重要区别。
- 第一,异步可迭代对象以符号名字Symbol.asyncIterator而非Symbol.iterator实现了一个方法(如前所示,for/await与常规迭代器兼容,但它更适合异步可迭代对象,因此会在尝试Symbol.iterator法前先尝试Symbol.asyncIterator方法)。
- 第二,异步迭代器的next()方法返回一个期约,解决为一个迭代器结果对象,而不是直接返回一个迭代器结果对象。
- 其中细微差别在于,next返回一个期约,决议后才有一个value和done,而普通的迭代器对象进行异步迭代时,其迭代完成了,也就是直接获取到了迭代器结果对象,只不过其value是一个promise,还需要我们去等待决议
- 声明为async的异步生成器同时具有异步函数和生成器的特性,即可以像在常规异步函数中一样使用await,也可以像在常规生成器中一样使用yield。但通过yield生成的值会自动包装到期约中。就连异步生成器的语法也是
async function和function *的组合:async function *。 - 继续往下就有点复杂了,说实话,很少直接去编写和使用,了解即可。有点复杂了。需要详细去了解。
js标准库
set和map
集合和映射
- 集合是基于严格相等,类似
===来判断的,注意一下特别的值,例如NaN?不过其已经对NaN做了特殊处理了。 - 集合对于判断成员是否是集合成员做了非常多的优化,所以相比数组的判断成员来说,要快很多
- 集合和map的迭代顺序是按照其插入时的顺序进行迭代的。
- 在大数据的情况下,其map的性能比对象稳定,且性能会更好
- 相比于对象
- API友好
- 任意键
- 数据在较大,频繁进行添加删除时更好更稳定
- 可迭代
- 具有稳定的插入顺序,在迭代时可以预测其迭代顺序
- 不过无法序列化
- weakMap和weakSet
- 弱引用,其键不会阻止垃圾回收(注意,是键不会被垃圾回收,不是值,值仍然会阻止垃圾回收)
- 键被垃圾回收时,其值也被垃圾回收了
- 键只能设置为引用类型(非原始类型),因为只有引用类型才能在其被收回时,无法释放其值。
- 其键无法被访问,因为没有对其引用,所以,weakMap不是一个可迭代对象,无法对其进行迭代,也没有size。
- 总之,你不知道什么时候,weakMap其中的键就没有了,神奇吧
- 思考为什么会存在weakMap:如果有一个map,里面有一个引用类型的键和一个值。如果引用类型在外部你自己丢弃掉了,也就是你自己无法访问该引用对象了,此时理论上你也无法再删除map中的这个键了,这就是导致如果你不用clear去情况map的情况下,这个键出现了内存泄漏。你既无法访问它,也无法删除掉它,它由于map的引用,也导致无法被垃圾回收回收掉。
- 那如果假设,这种场景下,你使用的是weakMap,出现这个情况了,由于weakMap的键的引用不会影响垃圾回收,故下次垃圾回收时,其会被标记为不可达,所以就会被垃圾回收回收掉,从而不会产生内存泄漏。并且连带着weakMap中的那个值也可以被正常垃圾回收掉
- weakSet不允许添加原始值、没有size、不可迭代,基本上和weakMap类似
注意:set的forEach可能会导致无限循环的,如果你在forEach中重新删除并添加同一个值,那么该值会被重新forEach执行。尽量来说,不要在循环中,去修改set的值,如果真要修改,那么使用一个新的set(map也许也会有这个问题)
语言规范中对此有明确的说明:在调用 forEach 遍历 Set 集合时,如果一个值已经被访问过了,但该值被删除并重新添加到集合,如果此时 forEach 遍历没有结束,那么该值会重新被访问
类型数组
什么是类型数组(定型数组)
- 类型数组不是数组,Array.isArray返回false,但是它们实现多数的数组方法
- 定型数组的长度是固定的,因此length属性是只读的,而定型数组并未实现改变数组长度的方法(如push()、pop()、unshift()、shift()和splice()),但实现了修改数组内容而不改变长度的方法(如sort()、reverse()和fill())
- 固定类型(数值):定型数组的元素全部都是数值。与常规JavaScript数组不同,定型数组允许指定存储在数组中的数值的类型(有符号和无符号数组以及IEEE-754浮点数)和大小(8位到64位)
- 这里的固定数值类型是指,创建一个类型数组时,那么数组中的每个元素的数值类型是什么,例如创建一个new Uint16Array(10),那么表示这个类型数组有10个元素,每个元素时一个无符号的16位整形数字,其每个元素可以存放最大65535的数字。
- 如果我定义一个64位无符号整数,那么其理论上的数值精度会受到影响吗?如果不受到影响,那么在提取其数值时,总会转换为js的number,64位还是比js的双精度浮点的53位要长的
- 好吧,上面的思考有道理,可惜,64位的,只有浮点数的,没有整数的,整数最多32位的。64位在es2020新增了bigInt,返回的也是bigInt
- 固定长度:创建定型数组时必须指定长度,且该长度不能再改变。
- 初始化位0:定型数组的元素在创建时始终都会被初始化为0
说明
- 使用buffer创建类型数组时,要注意数组的内存必须是对齐的,所以如果你指定了字节偏移量,那么这个值应该是类型大小的倍数。
- 注意,类型数组他不是buffer,类型数组的底层是buffer,或者说,你可以基于一个buffer,用不同的类型数组(无符号8位整数、有符号16位整数、32位单精度浮点数等)的读取方式去读取这个buffer中的值。类型数组是buffer的读取视图。
- ArrayBuffer类不允许读取或写入分配的任何字节。但是可以创建使用该缓冲区内存的定型数组,通过这个数组来读取或写入该内存。
- 注意:类型数组的一些方法,看似返回了一个新的类型数组(例如subarray),但是,返回的新类型数组底层和原有的类型数组底层的buffer用的是同一个,即这个新的类型数组,是原有类型数组底层buffer的一个
新的类型数组视图罢了。 - 每个类型数组有3个和ArrayBuffer相关的属性:buffer属性是数组的ArrayBuffer,byteOffset是数组数据在这个底层缓冲区的起点位置,而byteLength是数组数据的字节长度。
- 注意:你可以像对任何JavaScript对象一样对ArrayBuffer使用数值索引。但这样做并不会访问缓冲区中的字节,只会导致难解的bug:
buffer[1] = 255 // 你这里并不是将缓冲区中的1索引数据设置为255,而是为buffer这个缓冲区对象设置了1这么一个属性。- 也就是浏览器js中的ArrayBuffer是不可读写的:ArrayBuffer类不允许读取或写入分配的任何字节(这里指不能直接对ArrayBuffer进行读写操作)。
- 你只能通过类型数组视图,来访问和读取ArrayBuffer中的数据(同时也可以修改)
其他
- Uint8ClampedArray是Uint8Array的一种特殊变体。这两种类型都保存无符号字节,可表示的数值范围是0到255。对Uint8Array来说,如果要存储到数组元素的值大于255或小于0,这个值会“翻转”为其他值。这涉及计算机内存的底层工作机制,速度非常快。Uint8ClampedArray还会额外做一些类型检查,如果你要存储的值大于255或小于0,那它会“固定”为255或0,而不会翻转(这种固定行为对HTML
<canvas>元素的低级API操作像素颜色是必需的)。 - 大端序和小端序的问题
- DataView为10种定型数组类定义了get和set方法,所有这些读取方法和set方法都接收一个可选的布尔值作为参数,来控制读取或者写入时是大端序还是小端序。
正则表达式
暂时跳过,粗略了解和过一下就行
- 通常来说,深入正则可能比你想的要难得多
- 除此之外需要了解一下处理字符串的一些API中能够接收字符串参数的API有哪些
- 正则比较难写,不过现在有AI,应该会好很多吧
说明
- 正则表达式字面量创建的不是一个字符串,也不是原始类型,而是一个RegExp对象,等价于new出来的RegExp对象
- RegExp()构造函数主要用于动态创建正则表达式,即创建那些无法用正则表达式字面量语法表示的正则表达式
- API方法(String中的)
- 最简单的是search(),这个方法接收一个正则表达式参数,返回第一个匹配项起点字符的位置,如果没有找到匹配项,则返回-1
- replace()方法执行搜索替换操作。它接收一个正则表达式作为第一个参数,接收一个替换字符串作为第二个参数。
- match()是字符串最通用的正则表达式方法,它只有一个正则表达式参数(或者如果参数不是正则表达式,会把它传给RegExp()构造函数),返回一个数组,其中包含匹配的结果;如果没有找到匹配项,就返回null
- matchAll()方法是ES2020中定义的,在2020年年初已经被现代浏览器和Node实现。matchAll()接收一个带g标志的正则表达式。但它并不像match()那样返回所有匹配项的数组,而是返回一个迭代器,每次迭代都产生一个与使用match()时传入非全局RegExp得到的匹配对象相同的对象。
- API方法(RegExp对象)
- RegExp类的test()方法是使用正则表达式的最简单方式。该方法接收一个字符串参数,如果字符串与模式匹配则返回true,如果没有找到匹配项则返回false
- RegExp的exec()方法是使用正则表达式最通常、最强大的方式。该方法接收一个字符串参数,并从这个字符串寻找匹配。如果没有找到匹配项,则返回null。而如果找到了匹配项,则会返回一个数组,跟字符串的match()方法在非全局搜索时返回的数组一样。
日期与时间
这个其实也没有太多要了解的,不过要了解一下时区和本地时间的概念。同时一般生产环境用的是dayjs这种成熟的库
- Date API有个奇怪的地方,即每年第一个月对应数值0,而每月第一天对应数值1。如果省略时间字段,Date()构造函数默认它们都为0,将时间设置为半夜12点
- js返回的时间戳是毫秒
关于时区的事项
- 关注时区的注意事项:
存储用 UTC,展示用北京时间(例子),判断基于 UTC 对比,确保全球用户看到的活动时间统一锚定北京时间。 - UTC 时区:世界协调时间(Universal Time Coordinated),是全球统一的时间标准,无夏令时 / 时区偏移,所有时区都以 UTC 为基准计算偏移(如 UTC+8、UTC-5);
- 本地时区:JS 运行环境(浏览器 / Node.js)的默认时区,由操作系统 / 运行环境设置(如中国用户的本地时区通常是「Asia/Shanghai」,即 UTC+8);
- 北京时间(CST):固定为 UTC+8(东八区),无夏令时调整,是中国统一使用的时区(对应 IANA 时区标识符 Asia/Shanghai,而非 CST 缩写 ——CST 可能歧义为美国中部时间)。
- 假设有一个UTC时间字符串,转换为北京时间,则需要增加8个小时,如果一个北京时间,需要转换为一个UTC时间,则需要减少8个小时
- 北京时间 = UTC 时间 + 8 小时
- UTC 时间 = 北京时间 - 8 小时
- UTC 时间: 2025-12-08T10:00:00Z(Z 代表 UTC)其中如果带有Z这个字符串,则表明是一个UTC时间,否则,为本地时间
- 这种日期时间格式是ISO 8601日期格式,Z你可以认为是zero,表示0时区,也就是UTC时间,而包含其他时区的格式为:时区偏移,北京时间 +08:00,
2025-12-08T14:30:00+08:00,这个时间表示,当前时间为14:30,但是偏移UTC时间8个小时。
- 这种日期时间格式是ISO 8601日期格式,Z你可以认为是zero,表示0时区,也就是UTC时间,而包含其他时区的格式为:时区偏移,北京时间 +08:00,
js错误Error对象
- JavaScript定义了一个Error类。惯常的做法是使用Error类或其子类的实例作为throw抛出的错误。
- 使用Error对象的一个主要原因就是在创建Error对象时,该对象能够捕获JavaScript的栈状态,如果异常未被捕获,则会显示包含错误消息的栈跟踪信息,而这对排查错误很有帮助(注意,栈跟踪信息会展示创建Error对象的地方,而不是throw语句抛出它的地方。如果你始终在抛出之前创建该对象,如throw new Error(),就不会造成任何困惑)。
- 虽然ECMAScript标准并没有定义,但Node和所有现代浏览器也都在Error对象上定义了
stack属性。这个属性的值是一个多行字符串,包含创建错误对象时JavaScript调用栈的栈跟踪信息。在捕获到异常错误时,可以将这个属性的信息作为日志收集起来。
序列化
- 当程序需要保存数据或需要通过网络连接向另一个程序传输数据时,必须将内存中的数据结构转换为字节或字符的序列,才可以保存或传输。此时可以考虑使用序列化,而JSON序列化是其一种
- JSON不支持其他JavaScript类型,如Map、Set、RegExp、Date或定型数组。但不管怎么说,实践已经证明JSON是一种非常通用的数据格式,就连很多非JavaScript程序都支持它。数组会被其JSON为字符串,但是反序列化时,不会转换为Date对象
- JSON.strngify和JSON.parse
- 如果不考虑将序列化之后的数据保存到文件中,或者通过网络发送出去,可以使用这对函数(以没有那么高效的方式)创建对象的深度副。即这也是一种深拷贝
- 如果JSON.stringify()在序列化时碰到了JSON格式原生不支持的值,它会查找这个值是否有toJSON()方法。如果有这个方法,就会调用它,然后将其返回值字符串化以代替原始值。
- 通常来说,虽然JSON你可以自定义解析和恢复数据,但是如果这样做了,那么也牺牲了可移植性以及与庞大JSON工具、语言生态的兼容性
国际化
- JavaScript国际化API包括3个类:Intl.NumberFormat、Intl.DateTimeFormat和Intl.Collator。这3个类允许我们以适合当地的方式格式化数值(包括货币数量和百分数)、日期和时间,以及以适合当地的方式比较字符串。
控制台API-console
能说的不多,了解即可,而且最多其中有些不常见的console方法,倒不如说更多的是了解浏览器的调试反而更重要
URL API
- URL类可以解析URL,同时允许修改已有的URL(如添加搜索参数或修改路径),还可以正确处理对不同URL组件的转义和反转义。
- 遗留的URL API:在URL API标准化之前,JavaScript语言也曾多次尝试支持对URL的转义和反转义。
- encodeURI()和decodeURI()
- encodeURI()接收一个字符串参数,返回一个新字符串,新字符串中非ASCII字符及某些ASCII字符(如空格)会被转义。decodeURI()正好相反
- 因为encodeURI()是要编码整个URL,所以不会转义URL分隔符(如/、?和#)。但这意味着encodeURI()不能正确地处理其组件中包含这些字符的URL(例如参数中包含了这些字符,则不会被编码)
- 这里是指:它仅编码 URI 中「非法字符」(如空格、中文),保留 URI 分隔符(: / ? & = # 等)
- encodeURIComponent()和decodeURIComponent()
- 这对函数与encodeURI()和decodeURI()类似,只不过它们专门用于转义URL的单个组件,因此它们也会转义用于分隔URL组件的/、?和#字符。这两个函数是最有用的遗留URL函数,但要注意encodeURIComponent()也会转义路径名中的/字符,而这可能并不是我们想要的。另外它也会把查询参数中的空格转换为%20,而实际上查询参数中的空格应该被转义为+。
- 这里是指:它编码所有非标准 ASCII 字符(包括 URI 分隔符),仅保留 A-Z a-z 0-9 - _ . ! ~ * ‘ ( )
- encodeURI()和decodeURI()
- 简单来说,完整的url编码,使用encodeURI,而如果是编码url的参数,则使用encodeURIComponent
- 避免直接使用 encodeURIComponent ()/encodeURI () 处理复杂 URI / 参数,优先选择 URLSearchParams 或第三方库,减少兼容问题和踩坑概率
- 这些遗留函数的根本问题在于它们都在寻求把一种编码模式应用给URL的所有部分,而事实却是URL的不同部分使用的是不同的编码方案。如果想正确地格式化和编码URL,最简单的办法就是使用URL类完成所有URL相关的操作。
- 现代浏览器 / Node.js 18+ → 优先用 URL/URLSearchParams API
- 复杂参数处理 → 用 qs/URI.js 库;
定时器
定时器的核心不是 “精准定时”,而是 “异步调度”,理解事件循环的执行顺序,才能避开绝大多数坑点。
- setTimeout()和setInterval():这两个函数至今没有被写进核心语言标准,但所有浏览器和Node都支持,属于JavaScript标准库的事实标准。
- 如果省略传给setTimeout()的第二个参数,则该参数默认值为0。但这并不意味着你的函数会立即被调用,只意味着这个函数会被注册到某个地方,将被“尽可能快地”调用。如果浏览器由于处理用户输入或其他事件而没有空闲,那么调用这个函数的时机可能在10毫秒甚至更长时间以后。
- 而且其内部可能需要触发:校验定时器阶段,如果发现定时器时间到了,则将其加入到循环队列中去
- 这里的延迟时间含义,是至少多少秒后执行,而非绝对时间执行,也就是定义和实际执行时间基本都要大于设置的延迟时间。
- 浏览器为避免定时器占用过多资源,规定 setTimeout/setInterval 的最小延迟为 4ms(即使设置 delay=0,实际延迟至少 4ms);
- setInterval用的少了,可以使用嵌套setTimeout模拟
- 浏览器为节省性能,后台标签页中 setTimeout/setInterval 的最小延迟会升至 1000ms;或者直接暂停
- 动画专用:requestAnimationFrame
- 刷新率同步:默认 60Hz 屏幕下,回调每~16.67ms 执行一次(与屏幕重绘节奏一致),无多余执行;
- 后台暂停:标签页处于后台时,requestAnimationFrame 会暂停执行(节省性能),而 setInterval 仍会执行;
- 精度更高:无事件循环的宏任务延迟,动画更流畅。