最近几个月闲来无事,想着自从毕业那几年之外,已经好久没有沉下心来好好看看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去查找和总结,其实也更加方便。
类型
- JavaScript中,任何不是数值、字符串、布尔值、符号(Symbol)、null和undefined的值都是对象。
- null就表示一个空对象,或者没有对象。注意,null是一个基础值(原始类型),就是它自身,但是typeof会返回一个object,这并非设计逻辑,而是 JavaScript 诞生之初的历史 Bug
- 在 JS 引擎的底层实现中,每个值在内存中都会被标记一个「类型标签」(二进制位):对象(Object):类型标签的前三位为 000,而null则其值为全0,故在typeof判断类型时,null的前三位和对象的前三位一致,故返回object
- undefined你可以理解为,对于该变量、函数参数、返回值的结果“未定义”,对undefined应用typeof操作符会返回”undefined”,表示这个值是该特殊类型的唯一成员。
- Symbol可以理解为一个唯一的标识符或者值,Symbol(‘xxx’)函数每次返回都是一个不同的值。不过Symbol还有一个全局注册表,用来跨模块共享标识,不过需注意命名冲突
- JavaScript与静态语言更大的差别在于,函数和类不仅仅是语言的语法,它们本身就是可以被JavaScript程序操作的值。
- 有点意思,因为函数本身为对象,类也是,它本质上是一个构造函数。其他语言,例如java的类和方法,它是一个对象吗?可以对类和方法进行动态修改或者操作吗?
- 隐式类型转换,目前基本上不要依赖它了
- 通常来说,原始类型,无法使用new来创建原始类型的包装对象,不过由于早起js设计处于便利性的考虑,对于string、number等原始类型,你可以使用new来创建其包装对象,此时返回的是一个object对象。而新出现的symbol和bigint则无法使用new,而且,对于所有原始类型来说,都不要使用new去创建其包装对象。如果需要转换类型,则使用无new的构造函数:
Number('123')、String('abc')
数字
- js用ieee 754表示数字,不过,但是它不只是只有小数才会丢失精度,整数也会丢失精度。精度问题其实可以从这个浮点数表示规则中去理解
- 浮点数能表示的最大值(通过指数)非常大,但是由于在定义中,可以存储的精度数只有53位,所以即使你拥有这么大的数时,但是由于精度数可存的位数限制,导致没有那么多位数来存储完整的数,那么此是就会导致精度丢失。(9007199254740992是最大安全精度整数,900719925474099299可以被js存储起来,但是实际使用时,精度丢失了,其变成了900719925474099300)。整数位可以增加尾数位来提高存储最大精确整数的范围,例如四精度浮点,其尾数位为113位。如果是小数,即使扩展尾数位,也无法解决「十进制小数转二进制无限循环」的问题(比如 0.1 永远无法精确表示)。
- 要理解「小数精度问题的核心是进制转换本质」,首先要明确一个关键事实:
并非所有十进制小数都能被二进制小数「有限位数」表示这是十进制和二进制的数学特性决定的,和尾数位长度无关。- 十进制整数总能表示为 2 的整数次幂之和(可有限二进制位表示),而大部分十进制小数无法表示为 2 的负整数次幂的有限和 二进制只能用无限循环 / 不循环小数逼近,最终因存储位数限制丢失精度。(还需要详细理解数字在计算机中的存储,例如小数如何存储以及如何转换为二进制的,这里用的是:乘 2 取整法)
- 一个十进制小数能被有限位二进制小数精确表示的充要条件是:将小数化为最简分数 n/m 后,分母 m 仅包含质因数 2(即 m = 2ᵏ,k 为非负整数),即二分之一、四分之一、八分之一等,其他的都无法精确表示,会变成无限循环二进制小数,此时,无论尾数位多大,都会被截取,从而丢失精度
- 为什么人类感知不到这个问题?我们看到 console.log(0.1) 显示 0.1,是因为 JS 做了
「十进制格式化优化」把二进制近似值四舍五入到人类易读的十进制位数,并非真的精确存储了 0.1。也就是对于0.1这种数,其实内部存着的二进制数据是一个近似值 - 0.1 和 0.2 存储时都有微小误差,加起来误差累积,结果就不等于 0.3 了。
- 0.3存储的事0.29999xxx,而0.1 + 0.2得出的值是0.3000000004xx,它们本身就不是相等的一个值
- JavaScript 的toString()/ 控制台输出有数值格式化规则:对于双精度浮点数,会选择最短的十进制数来表示它,且这个十进制数转换回双精度后仍等于原数。
- JavaScript 的格式化规则只会将 “能被 0.3 精确表示的那个近似值” 显示为 0.3,而 0.1+0.2 的结果无法被 “0.3” 精确表示,因此只能显示其真实的近似值
- 0.1和0.2截断后,略大于它们自身,而0.3截断后略小于其自身
- 解决方案:用整数的精确性规避二进制小数的进制转换问题。就是给小数乘以一个10 的幂次(定标因子),把小数转化为整数,存储和计算都基于整数(利用整数可精确表示的特性),最终展示时再除以这个幂次还原为小数。
- 例如金融行业,将元转换为分(乘以100),然后进行运算,再将最终结果转换为元。由于金融行业,很少出现除法(倒是也不一定),注意不要超出整数的进度范围,并且转换时,可以用Math.round兜底(0.1 x 100 = 10.000000000000002 的微小误差)
- 仍然存在无限小数,这无法解决误差,需要四舍五入截断
- 解决方案:高精度动态场景:decimal.js/big.js 等库 —— 这类库的底层逻辑是「把小数拆分为十进制数字串 + 指数」,模拟人类的十进制计算,从根本上规避二进制转换问题。
- 1除0不会报错,而是等于Infinity,1除-0等于-Infinity
- 为何null转换为数字时是0,而undefined转换为数字时NaN,理论上它们都是假值?因为它们的语义设计差异,从而有不同的
数值转换定义。都是假值,只是布尔转换时的定义,不会影响数值转换的定义规则。- 这也延伸出一些假值的数字转换定义:空字符串、null、false会被转换为0,因为它们的语义定义是:有意义,但是值为空,例如null 代表 “有占位但无内容”,这种语义和 “数字 0”(有数值但值为零)是契合的。而undefined则完全是“未定义、无意义”,所以是NaN
- bigint:新的数值类型,用于js的大整数。它是js定义的基础数据类型,typeof返回“bigint”。
- bigint和Number类似,不过其不是number类型,它们是平级的。且bigint和number无法混合运算(可以比较),这两者没有明确“谁的范围更大”,不会进行显示类型转换。
JavaScript 数字的内存存储:无专属常量池,但有数值缓存优化
- JavaScript 中的数字(Number 类型,64 位浮点数)没有像字符串那样的 “常量池”(字符串驻留)机制,但引擎会对
小整数范围(-128 至 127)的数值进行缓存优化(称为 “整数池 / 小整数缓存”)超出该范围的数字则为 “单纯的值存储”—— 这与字符串的全局驻留机制有本质区别,且缓存逻辑仅针对特定场景生效。- 注意:该缓存仅对
Number 包装对象生效,原始数字值始终是 “值存储”。所以基本上可以认为没有数字没有缓存
- 注意:该缓存仅对
- 为什么数字没有全局常量池?(设计根源)
- 数字的取值范围远大于字符串
- 原始值的本质是 “值传递”:JavaScript 的原始值(数字、字符串、布尔等)在赋值 / 比较时,本质是值的直接传递,而非引用传递。字符串的 “常量池” 是引擎的优化手段(基于不可变性)。数值的 “值比较” 效率本身极高
- 浮点数的唯一性难以判断:浮点数存在精度问题(如 0.1 + 0.2 !== 0.3),引擎无法简单判断两个 “看似相同” 的浮点数是否真的相等,因此无法实现全局缓存。
字符
- js的字符是Unicode字符,其字符串是
16位值的不可修改的有序序列,其中每个值都表示一个Unicode字符。 - 字符串的length属性是它包含的16位值的个数。JavaScript的字符串(以及数组)使用基于零的索引,因此第一个16位值的索引是0,第二个值的索引是1,以此类推。
- JavaScript使用Unicode字符集的UTF-16编码,因此JavaScript字符串是无符号16位值的序列。(utf-8编码的英文加载到js中,其内存相比文件大小可能翻倍)
- Unicode用码点对应世界所有字符,通常字符码点为16位的,码点超出16位的Unicode字符使用UTF-16规则编码为两个16位值的序列(称为surrogate pair,即“代理对”)。这意味着一个长度为2(两个16位值)的JavaScript字符串可能表示的只是一个Unicode字符。
- JavaScript的
字符串操作方法一般操作的是16位值,而不是字符。换句话说,它们不会特殊对待代理对,不对字符串进行归一化,甚至不保证字符串是格式正确的UTF-16。 - 在ES6中,字符串是可迭代的,如果对字符串使用for/of循环或…操作符,
迭代的是字符而不是16位值。也就是js中迭代时,会正确将其处理为Unicode字符
- JavaScript的
- 字符串比较是通过比较16位值完成的,对于16位值,自然也是一个数字了,例如:
'\uE4F4\uE4F1' > '\uE4F3' // true - Unicode归一化:在Unicode中,同一个字符可能有两种不同的编码形式,而归一化就是将其转换为同一种标准形式(4种方式),解决 “视觉相同、编码不同” 的匹配问题。
- JavaScript 从 ES6 开始原生支持 Unicode 归一化,核心 API 是 String.prototype.normalize(),但需注意:JS 字符串默认不自动归一化,需手动调用该方法。
- 如何理解js的字符串是基于utf-16的,但是我们的网页和编辑器却是utf-8的?
- 分工逻辑:UTF-16 是 JS 字符串的「内存编码」(适配字符处理),UTF-8 是「传输 / 存储编码」(适配效率);
- 转换逻辑:浏览器 / 运行时自动完成 UTF-8 ↔ UTF-16 转换,开发者无需手动处理,即将utf-8的js代码自动转换为utf-16的js代码,js引擎运行时,操作的是utf-16的代码;
- 核心注意点:JS 中 length 是 UTF-16 码元数,处理特殊字符需用 ES6 码点相关 API;网页 / 文件需声明 UTF-8 编码,避免解析错误。
- 空字符串 ‘’ 是 “无字符”,而非 “码点 0 的字符”,注意js在进行大小比较时的规则,js比较是基于Unicode的码点进行比较的(注意在进行相等比较时,需要考虑隐式类型转换后,再进行相等比较):
- 字符串比较时,空字符串因 “无字符可对比”,直接判定为小于任何非空字符串
- 取两个字符串的第 n 个字符(n 从 0 开始);
- 分别获取两个字符的 Unicode 码点(codePointAt(n));
- 比较码点值:若不等,直接返回结果;若相等,n++ 继续对比下一个字符
- 若其中一个字符串先遍历完(长度更短),则长度更短的字符串更小;若长度相同且所有码点相等,则两字符串相等。
- 由于空字符串长度为0,触发了“长度更短则更小”的规则,故其小于任何字符串
- 误区 1:空字符串的码点是 0,误区 2:空字符串等价于
\0'' < 'b':因为空字符串长度为 0;'\0' < 'b':因为\0的码点 0 < b 的码点 98;
JavaScript 字符串不可变性与内存存储:同值字符串的引用复用机制
- JavaScript 中字符串是不可变的原始值,但对于相同内容的字符串,JavaScript 引擎(如 V8)会通过 字符串驻留(String Interning) 机制,让不同变量指向内存中同一个字符串实例,而非存储多份相同的字符串数据。这一机制既利用了字符串的不可变性,又优化了内存占用。
- 字面量声明(’abc’):引擎自动驻留,存入字符串常量池,并且共享引用,不同变量指向同一内存地址
- 动态生成(new String(‘abc’)/ 拼接):非字面量方式创建的字符串,默认不驻留,默认不共享引用,除非引擎优化后主动驻留
- 字面量声明的相同字符串,内存中只存一份,变量共享引用;动态创建的字符串则可能存多份,除非引擎触发驻留优化。
- JavaScript 字符串的不可变性是实现 “同值引用复用” 的核心基础,正因为字符串不可变,引擎才敢让多个变量共享同一个字符串实例
- 字符串驻留(Interning):同值引用复用的实现机制
- JavaScript 引擎会维护一个字符串常量池(String Pool),本质是一个哈希表,键为字符串内容,值为字符串在内存中的地址。当创建字符串字面量时,引擎会执行以下逻辑
- 检查常量池中是否已存在相同内容的字符串;
- 若存在,直接让变量指向该字符串的内存地址;
- 若不存在,在常量池中创建新的字符串实例,再让变量指向它。
- 注意误区:字符串虽为原始值,但
超长字符串(如几 KB/MB 的文本)会被引擎优化存储在堆中,栈中仅保存引用地址—— 这是因为栈的大小有限,无法容纳大体积的字符串数据。
字符串内存图
1 | 常量池:['abc' → 内存地址 0x123] |
布尔值
- JavaScript的任何值都可以转换为布尔值,false和可以转换为它的6个值有时候也被称为假性值(falsy),而所有其他值则被称为真性值(truthy)。在任何JavaScript期待布尔值的时候,假性值都可以当作false,而真性值都可以当作true。
全局对象
- ES2020最终定义了globalThis作为在任何上下文中引用全局对象的标准方式。
- 也就是说,用globalThis,就可以在任意环境中获取其全局对象,这在任何环境都可以适用
- 浏览器时window,node是global
引用类型和基本类型
- 基本类型不可变,直接存储在栈中
- 赋值时,创建值的副本
- 引用类型存放的是数据的引用,存放在堆中
symbol
- 全局唯一标识符,类似生成唯一id或者雪花id等,本来之前的对象属性只能是字符串,现在多了一个symbol了
- symbol看起来像对象
- Symbol 原始值的核心是「唯一标识」(引擎内部用唯一 ID 区分),而 description(描述符 /key)是附加的元信息—— 引擎会为每个 Symbol 原始值关联一份元信息(如描述符、是否为全局 Symbol 等),存储在引擎内部的 “Symbol 注册表” 中,而非原始值本身。
- 而
symbol.description其实等同于为symbol创建一个包装对象,然后基于包装对象,去全局注册表查找其描述符
Symbol('foo')返回一个symbol原始值,即使重复调用,返回的symbol原始值都是不一样的Symbol.for('foo')会注册一个全局symbol,此时使用foo作为key,如果重复调用,返回的就是同一个symbol了(会优先查找是否在全局注册,如果没有则注册并返回)Symbol('foo')和Symbol.for('foo')都会注册到全局Symbol注册表中,只不过会标识是否是全局的。
原始类型的包装对象
JS 中所有原始类型(string/number/boolean/symbol/null/undefined)都有对应的「包装对象」(String/Number/Boolean/Symbol),这是原始类型能调用方法、承载元信息的核心
- 原始值本身是 “无属性、无方法的裸值”(如 123、’abc’、Symbol(‘foo’));
- 当调用原始值的方法(如 Symbol(‘foo’).description)时,引擎会临时创建包装对象,方法 / 属性实际挂载在包装对象上;
- 调用完成后,包装对象立即销毁,原始值本身不变。
- 原始对象无原型链,虽然你可以写,但是其实是临时包装对象的原型链
类型转换
类型转换本身是js中的一个特性,因为它作为动态类型语言,其本身的变量并不明确要求明确的类型,那么在对于其做一些操作和判断时,如果类型不一致,那么此是js引擎会自动进行类型转换,也就是所谓的隐式类型转换。例如数值运算、布尔运算(if)、等价运算之类的。
- 首先你要知道它有这个东西
- 其次,不应该去背各种转换规则,说实话,js隐式类型转换其实也挺复杂的,而且不同操作符、不同类型之间的转换规则和优先级都不同,但是你要知道有些情况下,可能会出现隐式类型转换,并以此尝试去拆解其中的规则和可能遇到的问题,然后去分析即可。而且从最佳实践的角度,理论上应该永远使用
===。- 神奇的:
[] == ![] → true,当比较 x == y 时,引擎会按优先级执行以下步骤(关键步骤): - 若 x 和 y 类型相同,直接比较值;
- 若其中一方是布尔值,先将布尔值转为数字(true→1,false→0);
- 若一方是对象(如数组),另一方是原始值(数字 / 字符串),先将对象转为原始值(ToPrimitive 操作);
- 最终转为同类型的原始值后,再比较值是否相等。
- 上面的
[] == ![]会变成[] == false,然后变成[] == 0,最后则是'' == 0- 其中空数组的ToPrimitive操作结果为空字符串
- 而字符串和数字比较时,字符串转数字,空字符串 “” 转数字是 0,最终就是
0 == 0
- 神奇的:
- if语句将undefined转换为false,
但==操作符永远不会将其操作数转换为布尔值。这对于一些可能看起来难以决断的转换有一些帮助,例如"0" == false // 返回true,这里的false会转换为0,而不是字符串0转换为true再进行比较 - JavaScript规范定义了对象到原始值转换的3种基本算法:注意,这里不是指toString和valueOf方法,而是这些算法或多或少都利用了这两个方法
- 偏数值:该算法返回原始值,而且只要可能就返回数值。
- 偏字符串:该算法返回原始值,而且只要可能就返回字符串。
- 无偏好:该算法不倾向于任何原始值类型,而是由类定义自己的转换规则。JavaScript内置类型除了Date类都实现了偏数值算法。Date类实现了偏字符串算法。
- toString()的任务是返回对象的字符串表示。默认情况下,toString()方法不会返回特别的值(返回
[object Object]),很多类都定义了自己特有的toString()版本,Array类的toString()方法会将数组的每个元素转换为字符串,然后再使用逗号作为分隔符将它们拼接起来。 - valueOf()。这个方法的任务并没有太明确的定义,大体上可以认为它是把对象转换为代表对象的原始值(如果存在这样一个原始值)。而对象是复合体,无原始值,所以valueOf()方法默认情况下只返回对象本身,而非返回原始值。
- 对象到布尔值的转换很简单:所有对象都转换为true。注意:这个转换不需要使用前面介绍的对象到原始值的转换算法,而是直接适用于所有对象。包括空数组,甚至
包括new Boolean(false)这样的包装对象。 - 而js对象在转换时,使用上面三个基本算法(例如js可能期望将对象转换为字符串或者数值,或者
+操作符在面对对象转换时则会使用无偏好),而这三个基本算法则利用了toString和valueOf方法,只不是顺序不同:偏字符串算法首先尝试toString()方法,然后再尝试valueOf,如果还不满足,则报错。无偏好则看具体对象:如果是一个Date对象,则JavaScript使用偏字符串算法。如果是其他类型的对象,则JavaScript使用偏数值算法。
相等比较
js有4个相等算法,参考MDN: 如何正确判断相等性
神奇的相等比较,其特殊之处在于特殊数字+0和-0和NaN的处理
- IsStrictlyEqual
===为严格相等,不会进行隐式类型转换,会对+0和-0和NaN进行特殊处理,注意,NaN和任何值都不相等包括其自身,所以,只要有一方是NaN,那么===返回为false。+0 === -0返回true,其认为这两个值都是一样的。 - IsLooselyEqual
==为相等:会进行隐式类型转换 - SameValue
Object.is(),同值相等:既不进行类型转换,也不对 NaN、-0 和 +0 进行特殊处理(这使它和 === 在除了那些特殊数字值之外的情况具有相同的表现)。 - SameValueZero,零值相等:被许多内置运算使用。零值相等不作为 JavaScript API 公开,但可以通过自定义代码实现(参考上文MDN给出的实现)
Object.is(0, -0)为false,但是Object.is(0n, -0n)为true的原因- Object.is(0, -0) = false:因为 Number 基于 IEEE 754 浮点数,0/-0 是物理上不同的存储值(内存中,符号位是不同的),Object.is 精准识别这种差异;
- Object.is(0n, -0n) = true:因为 BigInt 是整数类型,-0n 无数学意义,引擎会归一化为 0n,二者是同一个值;
- 核心逻辑:Object.is 的匹配规则是「基于类型的底层值相等」——Number 区分符号零,BigInt 不区分。
类型判断
- typeof在判断原始类型返回一个特定的字符串,除了null,它返回object,你需要对该值进行特殊判断
- 对于函数,typeof返回function字符串,虽然他也是一个对象
- 其他都返回object
- 使用Object.prototype.toString().call()技术检查任何JavaScript值,都可以从一个包含类型信息的对象中获取以其他方式无法获取的“类特性
Object.prototype.toString.call([]) // 返回[Object Array] - 通常,上面这个方法判断类型,都比typeof有效。例如Array、Map、Set这种
变量声明
解构赋值
- 解构赋值:在解构赋值中,等号右手端的值是数组或对象(“结构化”的值),而左手端通过模拟数组或对象字面量语法指定一个或多个变量。在解构赋值发生时,会从右侧的值中提取(解构)出一个或多个值,并保存到左侧列出的变量中。
- 数组解构的一个强大特性是它并不要求必须是数组!实际上,赋值的右侧可以是任何可迭代对象
- 注意,解构赋值如果太过复杂,其理解性还不如直接传统的赋值
- 在ES2018中,解构对象时也可以使用剩余形参(不止是数组了)。此时剩余形参的值是一个对象,包含所有未被解构的属性。
const { x, y, ...other} = object // other是一个对象,包含了object对象除了x、y外的其他属性的对象 - 解构是可以嵌套的
- 函数中参数的解构也是常用到的,注意要和函数的剩余参数进行区分。
var变量的特性和行为
很多东西,其实你知道它基本上已经被废弃了,不推荐使用了,通常你只需要简单了解一下即可,不需要刻意去记忆其中的特殊行为,怎么说呢,我认为这些知识点了解即可,因为其本质上不推荐使用了,例如对全局声明的var变量以及其window对象使用delete操作符的结果。花费更多时间去研究和记忆这种特殊行为,能够提供多少帮助吗?我觉得没有多少。
- var不具有块级作用域
- 变量提升,但是在未赋值前,访问是undefined
- var声明在全局作用域下,会成为全局对象的属性(倒不如说会同时被绑定为一个全局属性),无法使用delete删除
- 非严格模式下,如果将一个值赋给尚未使用let、const或var声明的名字,则会创建一个新全局变量,但是他可以被delete删除(神经)
表达式
- 函数表达式,其值为新定义的函数,同时函数是一等公民,它可以作为变量使用
- 无论哪种属性访问表达式,位于
.或[前面的表达式都会先求值(这也看出表达式的执行顺序)。如果求值结果为null或undefined,则表达式会抛出TypeError,因为它们是JavaScript中不能有属性的两个值。 - 在JavaScript中,null和undefined是唯一两个没有属性的值。在使用普通的属性访问表达式时,如果.或[]左侧的表达式求值为null或undefined,会报TypeError。可以使用?.或?.[]语法防止这种错误发生。
- 它是一种短路操作:如果?.左侧的子表达式求值为null或undefined,那么整个表达式立即求值为undefined,不会再进一步尝试访问属性(同时也避免了产生副作用代码的执行,例如语句中的
index++等)。和逻辑运算符类似。
- 它是一种短路操作:如果?.左侧的子表达式求值为null或undefined,那么整个表达式立即求值为undefined,不会再进一步尝试访问属性(同时也避免了产生副作用代码的执行,例如语句中的
- 函数的
参数也是一种表达式(即圆括号内的表达式),求值参数表达式用以产生参数值的列表,意味着,它也可以被短路操作中止(例如fn?.(index++),如果fn为null,那么index不会被加1)
操作符和优先级
操作符优先级这种东西呢,其实也比较复杂,记起来比较麻烦,我的建议是了解即可,而在一些可能出现歧义或者复杂的表达式中,建议实际中直接上括号来改变操作符优先级,至少清晰明了,不要产生歧义。
还需要注意一点的是,如果你发现一些异常,例如++、fn调用中存在副作用和proxy中的代理(例如vue的响应式依赖),它们本身就是存在副作用的东西,这时候其求值顺序和一些优先级可能需要注意,这可能是问题的来源。
- 操作符都有优先级,用来识别表达式中的执行顺序
- 优先级高的操作符先于优先级低的操作符执行。
- 属性访问和调用表达式的优先级基本上最高(高于常见的所有操作符)
- 赋值基本上最低,typeof是优先级最高的操作符,但是它需要一个值
- 神奇的是
??和**这些新增的,对于操作符的优先级没有明确定义,不过建议他和其他操作符用时,要使用括号 - 操作符有:优先级、结合性、操作数类型和操作符结果类型的概念,这里也会包含了隐式类型转换
- “结合性”标明了操作符的结合性。“左”表示结合性为从左到右,“右”表示结合性为从右到左。操作符结合性规定了相同优先级操作的执行顺序。例如两个减法
- 操作符有些是由副作用的,例如场景的
index++和++index,前者在被用于其他表达式或者操作符时,所使用的值为index本身,然后再+1,而后者则是先将index + 1,然后再将其结果应用于其他表达式或者操作符 - 子表达式定义:「子表达式」就是嵌套在一个 “父表达式” 内部、能独立计算出值的最小代码片段,所有复杂表达式都是由一个或多个子表达式组合而成。
JS 中一切能算出值的代码都是「表达式」(比如1+2、obj.a、fn()),而子表达式就是表达式里的 “组件”,比如(1+2) * (3-4)中,1+2和3-4都是*运算的子表达式。- 对于父表达式求值时,需要先计算出子表达式
- 注意:
fn()它是一个整体,表示函数调用表达式,其中的括号,不是一个操作符([]才是一个操作符,表示属性访问表达式)console.log(123)它也是一个表达式 - 第一类:算术子表达式(最基础,数学运算类)
- 注意:复合算术(自增/自减也是算术子表达式):
(x++) + (--x)中x++和-xx本身也需要先被求值,然后再应用+表达式进行求值
- 注意:复合算术(自增/自减也是算术子表达式):
- 第二类:赋值子表达式(赋值操作的核心,有返回值)
赋值表达式本身有返回值(返回赋值后的结果),例如赋值给的是100这个值,那么返回的就是100这个值 - 第三类:逻辑子表达式(布尔运算,短路求值特性)
- 由「任意表达式 + 逻辑运算符(&& || ! ??)」组成,结果可能是布尔值 / 任意值(&&/||/?? 会返回最后一个执行的子表达式结果)
- 短路求值是核心特性(前面的子表达式能确定结果时,后面的不执行)。
- 第四类:成员访问子表达式(访问对象 / 数组的属性 / 元素)
- 通过「. 运算符」或「[] 运算符」访问对象属性 / 数组元素,是开发中高频的子表达式,常嵌套在其他表达式中。
- 第五类:函数调用子表达式(执行函数并获取返回值)
- 由「函数名 + ()」组成,执行函数并返回结果
- 注意:函数调用的参数本身也是子表达式(参数列表里的每个参数都是独立子表达式)。
- 第六类:三元条件子表达式(简洁的条件判断,唯一的三元运算符)
- 由「条件表达式?真值表达式:假值表达式」组成,是 JS 唯一的三元运算符
- 三个部分都是子表达式,常作为赋值 / 逻辑运算的子表达式。
- 其他类:特殊子表达式(易忽略但高频)
- 字面量子表达式:数字、字符串、布尔值、null、undefined、对象 / 数组字面量本身都是子表达式(能独立求值)
- 括号子表达式:用()包裹的任意表达式,优先计算,本身也是子表达式
- 类型转换子表达式:通过Number()/String()/Boolean()等进行类型转换,转换的参数是子表达式(感觉本质也是函数调用)
- 求值顺序
- 书中原话:操作符的优先级和结合性规定了复杂表达式中操作的执行顺序,但它们没有规定
子表达式的求值顺序。JavaScript始终严格按照从左到右的顺序对表达式求值。- 正常的表达式在被执行时中会按照从左到右对表达式求值,但是如果具有短路操作的表达式,例如三元表达式,那么没有被执行的表达式,则不会进行求值,因为其不需要求值。
- 例如,在表达式w = x + y * z中,子表达式w首先被求值(赋值左边不是获取值,而是获取w变量),再对x、y和z求值。然后将y和z相乘,加到x上,再把结果赋值给表达式w表示的变量或属性。在表达式中使用圆括号可以改变乘法、加法和赋值的相对顺序,但不会改变从左到右的求值顺序。
- 这里的求值顺序,不是说计算表达式的值的顺序,而是确定表达式中变量的值是什么的顺序。这里w先求值,是指找到w是什么值,如果w是一个未定义的变量,那么计算表达式则毫无意义了,所以先确定w是什么,也就是所谓的求值,即确定这些变量的具体值,最后再按照操作符优先级计算表达式
- 书中原话:操作符的优先级和结合性规定了复杂表达式中操作的执行顺序,但它们没有规定
参考如下代码(求值顺序),也许你会有歧义,认为乘法操作符会先执行,那么就先对乘法操作的两个fn进行求值,但是实际上并不是,因为fn()是函数表达式,属于子表达式
1 | let i = 0; |
一些操作符的特点
+操作符优先字符串拼接:只要有操作数是字符串或可以转换为字符串的对象,另一个操作数也会被转换为字符串并执行拼接操作。- 关系表达式:NaN不等于自身(即使使用
===),特例。使用a !== a或者Number.isNaN函数(它不会做隐式类型转换)来判断(mdn说,Number.isNaN比isNaN更加健壮,更加推荐,因为其不会做隐式类型转换) - 比较操作符的操作数可能是任何类型。但比较只能针对数值和字符串,因此不是数值或字符串的操作数会被转换类型,字符串则比较字母表顺序,其中“字母表顺序”就是组成字符串的16位Unicode值的数值顺序。
- 数值比较中,如果有一个操作数是(或转换后是)NaN,则这些比较操作符都返回false。注意NaN是一个数字类型的
+操作符偏向字符串,即只要有一个操作数是字符串,它就会执行拼接操作。而比较操作符偏向数值,只有两个操作数均为字符串时才按字符串处理
- 所有对象都是Object的实例。
o instanceof f在确定对象是不是某个类的实例时会考虑“超类”。如果instanceof的左侧操作数不是对象,它会返回false。也就是基本类型装箱没用- 他基于原型链来查找,也就是先找到
f.prototype,然后再从o的原型链([[Prototype]])中去找有等于f.prototype这个值的,如果有,则返回true - instanceof操作符并非检查o是否通过f构造函数初始化,而是检查o是否继承
f.prototype。 - isPrototypeOf()的功能与instanceof操作符类似
- 原始值(数字 / 字符串 / 布尔 /null/undefined/Symbol)无原型链,instanceof 直接返回 false
- fn.prototype.constructor不会影响instanceof的判断
- 他基于原型链来查找,也就是先找到
- 逻辑运算符,需要注意一个是短路,另外一个则是他返回的是逻辑操作符的前面或者后面的那个值,而不是一个布尔值。
- 通过操作赋值,例如
+=、*=,注意下面两个的区别:data[i++] *= 2,data[i++] = data[i++] * 2,前者中data[i++]会被求值一次,而后者会被求值两次,其结果就不一样了,要注意和避免。+=复合操作和代理的get、set:只有「复合赋值 / 自增自减」这类「先读再写」的操作,才会同时触发 get 和 set,纯赋值操作只触发 set。
- 三元操作符也是一种短路操作
- 新操作符
??也是短路的,如果左边不是null或者undefined,则返回左边,否则返回右边。这种新出的操作符,在和&&、||操作符一起用时,需要用圆括号,因为它们没有明确定义优先级
其他-求值表达式
- 对源代码字符串的动态求值是一个强大的语言特性,但这种特性在实际项目当中几乎用不到。(eval)
- 对于像JavaScript这么复杂的语言,无法对用户输入脱敏,因此无法保证在eval()中安全地使用。由于这些安全问题,某些Web服务器使用HTTP的”Content-Security-Policy”头部对整个网站禁用eval()。
- 直接调用eval()使用的是调用上下文的变量环境。任何其他调用方式,包括间接调用,都使用全局对象作为变量环境,因而不能读、写或定义局部变量或函数。这可以让我们把代码字符串作为独立、顶级的脚本来执行。
语句
基本上大部分编程语言的语句都是差不多的,流程控制、循环等等。除了一些语言需要支持某些特性而设计的一些特殊语句需要注意和了解之外,其他都没有太多值得细究的了。
if else if这类语句,其分支判断条件也是一种短路操作,也就是前面判断成功后,后续的if中的表达式会被完全跳过(哪怕其中的变量读取),此时如果表达式中存在副作用,那么不会被执行- 语言中的副作用,为什么通常在编写代码时,要注意一下一些语句、操作符之类的代码中是否包含副作用,或者注意其函数的纯函数特性,就是因为在很多语句或者表达式、操作符中,存在的短路行为,而这些短路如果不是你本身明确需要的特性,那么可能会让你的代码结果偏离其预期。
- 有意思的一个点就是,vue中watch中依赖收集,如果存在if分支,那么在执行watch的函数时,其第一次收集的依赖可能是不全的,因为if分支的短路操作,无法对剩余的if语句中的代码中包含的响应式对象进行读取,从而无法进行依赖收集,这会有问题吗?
- 其实没有,因为在这个场景下,即使没有正确收集到其他if语句中的依赖,但是由于如果其他响应式对象没有变化的话,那么代码永远无法走到这个if条件分支中,所以,即使这个if条件分支中的响应式数据更新了,也不会影响这个watch本身所预期的结果。
- switch语句首先对跟在switch关键字后面的表达式求值,然后再按照顺序求值case表达式,直至遇到匹配的值。这里的匹配使用===全等操作符,而不是==相等操作符,因此表达式必须在没有类型转换的情况下匹配。
- 考虑到在switch语句执行时,并不是所有case表达式都会被求值(也是一种短路),所以应该避免使用包含副效应的case表达式,比如函数调用或赋值表达式。最可靠的做法是在case后面只写常量表达式。
- 字符串的for of迭代,迭代可迭代对象,其变量可以使用const声明
- 字符串是按照Unicode码点而不是UTF-16字符迭代的,也就是他可以正确识别unicode的字符
- for in迭代对象,其变量可以使用const声明
- 关于传统for循环中,其变量初始化无法使用const,因为存在赋值操作,例如(i++)
- 异常处理
- 只要执行了try块中的任何代码(即使try中它是空的,只要执行到了这里),finally子句就一定会执行,无论try块中的代码是怎么执行完的。即使catch中再次抛出了error,那么也会在error向上传播之前执行。即使try中使用了break、return等代码,也仍然会执行(应该是会执行return,然后再执行finally)
- 如果finally块本身由于return、continue、break或throw语句导致跳转,或者调用的方法抛出了异常,则解释器会抛弃等待的跳转或者异常,执行新跳转。
- 异常捕获并处理,是同步的。
- async函数中抛出的错误,也可以给try捕获,前提是await
作用域和作用域链
什么是js作用域
- 作用域是指你在声明变量、函数时,其变量、函数可访问的范围,就是作用域,js是词法作用域,即声明什么地方,就只能在这个地方访问,而这意味着函数内部访问的事函数定义所在的作用域中的变量,而不是调用时的。
- 在 JavaScript 中,作用域(Scope) 是指「变量 / 函数的可访问范围」
- 它规定了代码中哪些部分能访问某个变量 / 函数,哪些部分不能,核心目的是隔离变量、避免命名冲突,同时控制变量的生命周期。
- 与多数现代编程语言一样,JavaScript使用词法作用域(lexical scoping)。这意味着函数执行时使用的是定义函数时生效的变量作用域,而不是调用函数时生效的变量作用域。
- 为了实现词法作用域,JavaScript函数对象的内部状态不仅要包括函数代码,还要包括对函数定义所在作用域的引用。这种函数对象与作用域(即一组变量绑定)组合起来解析函数变量的机制,在计算机科学文献中被称作闭包(closure)
- 闭包是「函数访问外层作用域变量」的特性,不会改变作用域本身,只是延长了外层变量的生命周期
- 函数参数与函数内定义的变量一样,都属于函数作用域
- 作用域类型
- 全局作用域
- 函数作用域
- 块级作用域
- 弥补 var 无块级隔离的缺陷(如 for 循环中 var 定义的变量泄露到外部)var没有块级作用域
- let/const 声明的变量绑定到当前代码块({} 包裹的区域),无变量提升(存在 “暂时性死区”);
- 模块作用域
- 不确定是否存在这个作用域,不过从具体的写法上来看,每个模块之间作用域是互相隔离的,而且,在模块中定义的变量,也需要声明在一个作用域中,所以模块作用域应该存在的。或者你也将其作为一个特殊的块级作用域也可以,只不过其可以导出变量和导入其他模块变量而已。
- 作用域和执行上下文
- 作用域是指变量、函数的查找规则,它是一种静态的规则和规范,类似一个定义,他在代码编写时就已经确定好了
- 执行上下文是动态的,在代码执行时创建的,用于函数或者代码的执行(通常大部分是函数执行上下文),而且函数执行上下文执行完毕后就销毁了,其中会包含具体的变量内容
- 执行上下文会引用作用域形成的作用域链,并且在运行时会根据这个作用域链来查找变量、函数
作用域核心规则
- 作用域链
- 作用域链是 JavaScript 引擎查找变量 / 函数时遵循的「链式查找规则」
- 作用域链是「嵌套作用域」的有序集合,本质是当前执行上下文的作用域与所有外层作用域的引用链,决定了变量的可访问性和查找顺序。
- 变量、函数在进行查找时的顺序,其遵循:当前作用域 -> 外层作用域 -> 全局作用域的查找顺序,要么找到,要么报错。这个查找链条就是作用域链
- 作用域链结构在代码编写时就确定的了,和函数是否执行无关(通常都是函数),只有当函数执行、访问变量时,引擎才会沿着预定义的作用域链查找变量
- 当内层作用域定义了与外层同名的变量时,内层变量会 “遮蔽” 外层变量。
- 赋值操作时:若全程未找到 a 的声明 -> 非严格模式下自动在全局作用域创建 a(严格模式下报错 ReferenceError)。
- with(修改当前作用域链)、eval(动态生成作用域)会改变作用域链的结构,但是都不推荐了
- 变量提升(暂时性死区)
- var和函数会进行变量提升,var和函数声明会将其提升到作用域的顶层,var的变量提升会将其值设置为undefined
- 而let和const不会进行暂时性死区,简单来说就是在声明它们之前使用这些变量时,会抛出一个异常。这个区域就是暂时性死区
先不考虑分类的词法环境,什么OV、LV这种,先以宏观的角度去说明其作用域和作用域链,然后再详细深入其执行上下文和其中的词法作用域等等。
- 首先全局代码执行时,拥有一个全局的执行上下文,先不管里面有什么,笼统说里面有一个词法环境,你只要知道变量会存在这个词法环境里面。
- 然后里面有一个函数,该函数在创建时,会为其创建一个函数执行上下文,此时函数也会有一个词法环境
- 然后函数里面有一个块,此时会产生一个块级作用域(或者说词法环境)
- 然后执行该函数时,如果执行到了这个块级代码,那么此时就会创建一个块级词法环境,并加入到这个函数的词法环境中去,因为块级代码仅有块级词法环境,而不会拥有独立的执行上下文。
- 此时块级代码中声明的变量,会加入到这个块级词法环境中,而块级代码在查找变量时,由于该代码所在的执行上下文是这个函数,而这个函数最新的词法环境是那个块级作用域的词法环境,所以查找变量会优先找到块级作用域中的那些变量
- 当这个块级中的代码执行完毕后,js引擎会销毁掉这个块级作用域,简单来说,是将该块级作用域的词法环境从这个函数的执行上下文中移除(因为块级代码中可能存在闭包,所以仅仅是移除,剩下的交给垃圾回收),那么该函数的其他后续代码在查找变量时,由于块级代码的词法作用域在该函数执行上下文中被移除了,所以查找不到块级作用域中的变量。
全局作用域
- js引擎在启动时创建的全局作用域,JS 中 “最顶层作用域”,且在程序运行期间始终存在
- 全局作用域和全局变量
- 全局作用域和全局变量是 JS 中 “最顶层作用域” 与 “该作用域下变量” 的对应关系 ——全局作用域是所有代码的公共作用域(最外层作用域),全局变量是定义在全局作用域中的变量,可被程序中任何位置访问
- 全局作用域和全局对象的属性绑定:全局变作用域中用var声明、函数定义或者一些特定情况下(隐式全局变量:例如非严格模式赋值给一个未声明的变量,会创建一个全局变量),此时这些变量也会作为一个属性同步到全局对象中去。
- 全局作用域中有一个全局对象这么一个变量,同时还会有其他在全局声明的变量
全局变量 = 全局对象的属性(es6之后又定义)
- Math等对象,它到底是全局作用域中的变量还是全局对象的属性?还是两者都是?还是说变量查找时,会查找全局对象中的属性?
- 结论:Math 这类内置对象,本质是「全局对象(window/globalThis)的属性」,而非 “全局作用域中显式声明的变量”—— 但在作用域查找时,它们会被当作 “全局作用域的标识符” 对待,表现得和全局变量完全一致。
- Math 不是开发者用 var/let/const 声明的 “全局变量”,而是
JS 引擎启动时就挂载到全局对象上的内置属性;但由于全局作用域的查找规则是 “最后查全局对象的属性”,所以访问 Math 时,效果和访问全局变量无差异。- 这是 JS 语言对 “全局作用域” 与 “全局对象” 的底层绑定规则 —— 本质是 “全局作用域的标识符查找,最终会兜底到全局对象的属性”,这是 JS 设计的核心逻辑。
- 把「任意作用域查找标识符」的完整流程拆出来,就会发现 “全局对象属性兜底” 只是最后一步
- 这个兜底规则不是 “特殊规则”,而是 JS 对 “全局作用域” 的定义本身 ——
全局作用域的边界就是 “全局对象的属性”。
- 你可以把全局作用域分为两块:
- 全局作用域中的变量、函数声明
- 全局对象的属性(最终的变量查找边界)
- 当你在代码中写 Math.PI 时,JS 引擎的查找流程是:
- 先沿作用域链查找是否有「名为 Math 的变量声明」(比如是否有 var Math = … 或 let Math = …);
- 若全程未找到 Math 的变量声明,则去「全局对象(window/globalThis)」上查找是否有 Math 属性;
- 找到后返回该属性(即内置的 Math 对象),未找到则报错。
- 你可以在全局作用域声明一个Math(let Math = 12),但是其不会覆盖全局对象中的Math对象,此时window.Math是原js数学对象,但是直接访问Math是12,即window.Math === Math返回false
- 内置的 Math 是全局对象的 “不可配置属性”,无法删除 / 重新定义
块级作用域
- let和const拥有块级作用域
- let和const无法重复声明
- 函数声明(不是函数表达式)在块中,也具有块级作用域(严格模式下,例如if语句)
- ES6 之后
函数声明被纳入块级作用域规则,但不同运行环境(浏览器 / Node.js)有兼容细节,需重点区分。 - 浏览器(如 Chrome)为兼容旧代码,会对块内函数声明做 “怪异处理”(提升 + 覆盖)。比如if else语句中有两个同名函数,都会被提升,且后者会覆盖前者(非严格模式,函数可以重复声明,且后者覆盖前者,看浏览器差异。更细节的怎么说呢,也有,不过了解即可
- ES6 引入块级作用域后,规范明确:函数声明在块内时,其作用域被限制在块内,且行为接近 let(提升到块顶部,但未初始化前访问会报 ReferenceError,即 “暂时性死区”)。
- ES6 之后
- 块级作用域有哪些?单独的块,if语句等。基本只要包含在
{}中的,都拥有一个块级作用域。模块本身就是一个块级作用域,叫做模块作用域
注意:块级作用域会产生一个块级词法环境,但是不会生成执行上下文,其词法环境会在运行时被挂载到其所在的执行上下文中去。
for中的迭代词法作用域
- for循环中使用let如何规避var的问题,这是因为for循环使用let,那么会在循环初始化时声明一次 i,但为每次循环迭代创建独立的块级作用域快照(一个新的块级作用域),而这个独立块级作用域中,会存在一个i的变量,而这个i的变量的值就是let声明的那个i的值(也就是所谓的快照)。你可以参考传统解决var变量的问题方案(利用IIFE创建一个函数作用域),你可以理解为let会帮我们模拟这个IIFE作用域。
IIFE解决var问题:
1 | // 模拟 let 的独立作用域(等价于 let 版 for 循环) |
- 深入理解一下,首先for循环的let声明,在循环中只会声明一次,那它如何做到类似IIFE的效果的?
- 我们可以从词法作用域的角度去理解。
- 首先for循环本身会创建一个词法作用域,我们暂时叫他循环级词法环境。此时该环境中拥有一个i变量(最开始初始化时声明一次)
- 然后每次迭代时,会创建一个新的词法环境,叫做迭代级词法环境(或者每次迭代都是一个新的块级作用域,这样也会生成一个块级词法环境),里面也有一个i变量,且该i变量的值会被初始化为循环级词法环境中的那个i的值,也就是所谓的快照。但是它不会替代那个循环级词法环境来进行作用域链查找。
- 那么在for循环体中,直接访问的i,就是循环级词法环境中的i的值(修改也是找到的这个i)
- 但是,for循环体中声明的函数,例如定时器这种,此时在创建这个函数时,js引擎会为这个函数的词法作用域上添加当前的迭代级词法环境,注意,不是循环级词法环境,那么,这个函数在被执行时,从词法环境去查找时,优先查找到的就是迭代级的词法环境,而非更上一层的循环级词法环境,故实现的效果就和IIFE一样了。
函数作用域
- 这个没啥好说的,函数在代码中就定义好了其作用域,且在运行时会拥有自己的作用域
模块作用域
- 每个 ES6 模块(.js 文件,通过 import/export 标识)都是一个封闭的作用域,模块内定义的变量 / 函数 / 类默认仅在模块内可见,不会污染全局作用域,也不受全局作用域的直接干扰。
- 每个js模块文件就是一个独立的作用域
- 模块作用域特性
- 封闭性:模块内定义的所有标识符(变量、函数、类)默认被 “锁住” 在模块作用域内,外部无法访问;
- 显式导出:只有通过 export 导出的标识符,才能被其他模块通过 import 访问;
- 显式导入:其他模块需通过 import 主动引入,才能使用目标模块导出的标识符;
- 全局对象隔离:模块内的 this 指向 undefined(而非全局对象 window/globalThis),避免意外绑定全局属性。
ES6 模块不仅有执行上下文,还拥有独立的「模块执行上下文(Module Execution Context)」—— 它是 JS 执行上下文体系中与「全局执行上下文」「函数执行上下文」并列的一类,是模块执行的核心环境。- 模块执行上下文有自己的「变量环境(Variable Environment)」和「词法环境(Lexical Environment)」,存储模块内声明的变量、函数、导出列表,与全局执行上下文隔离
- 模块加载后,会创建模块的执行上下文,初始化词法环境(存储模块内变量)
- 执行模块代码(变量赋值、函数定义、处理 export)
- 模块执行上下文保留在内存中(缓存导出的变量),其他模块 import 时,直接复用该上下文的导出结果。
- 同一个模块无论被 import 多少次,仅创建一次模块执行上下文,执行一次模块代码(导出的变量会被缓存)
- 模块作用域的 “外层作用域” 就是全局作用域,模块内代码可以访问全局作用域的标识符(包括全局变量、全局对象的内置属性如 Math/window),但遵循「作用域链查找规则」—— 先查模块自身作用域,再查全局作用域。
- 模块内的函数 / 块级作用域,作用域链则是:函数/块级作用域 → 模块作用域 → 全局作用域
词法环境
词法环境(Lexical Environment)核心
- 词法环境是 ES 规范定义的运行时数据结构, 引擎在执行代码时,会为每个作用域创建对应的词法环境,用于存储变量绑定、外层词法环境引用等(this不确定,也许也在词法环境中)。同时也是查找时的载体。
- 函数
[[Scopes]]中存储的是「词法环境(Lexical Environment,LE)的引用」,但 ES6 规范中,变量环境(Variable Environment,VE)是 LE 的 “别名”(同属一个词法环境结构)—— 因此[[Scopes]]看似只存 LE,实则已覆盖 VE 的内容,并非 “仅代指”,而是规范层面的设计逻辑。 - 重点:很多人误以为 LE 和 VE 是 “两个独立的环境对象”,但 ES6 规范(ECMA-262)的定义:
- 每个执行上下文有且仅有一个「词法环境」,VariableEnvironment 是该词法环境的 “引用别名”;LexicalEnvironment 是同一环境的 “动态更新引用”(仅用于 let/const 的 TDZ 管理)。VariableEnvironment和LexicalEnvironment在执行上下文中都存在(作为属性),但是它们都指向的是同一个对象,即都指向同一个LexicalEnvironment实例,所以说,执行上下文仅有一个词法环境
- LE 和 VE 共享同一个词法环境对象的内存空间,
只是在描述 “变量提升” 时,将var/函数声明的提升逻辑归为 VE 负责,let/const的 TDZ 归为 LE 负责, 但它们指向的是同一个环境,并非两个独立结构。 - LE 和 VE 指向同一个词法环境对象,只是描述的 “关注点不同”—— 不存在 “LE 存 let/const,VE 存 var/函数,且两者是独立对象” 的情况。
- 规范只提词法环境:ES6 引入 LE/VE 区分,核心是为了 “兼容 ES5 并解释 TDZ”,但在描述 “作用域捕获”(即
[[Scopes]])时,规范统一用 “词法环境” 代指这个唯一的环境对象
执行上下文的词法环境结构(简化):
1 | // 执行上下文的词法环境对象(唯一) |
词法环境中的outer链和执行上下文的作用域链
其实你可以不用管其内部结构,因为真的很难说清楚具体结构是否是对的,或者就是这样实现的
- 词法环境中的outer链指向外层词法环境,形成一个词法环境链条。
- 而执行上下文的作用域链你可以仍为是outer词法环境链的扁平化形式
- 执行上下文的 “作用域链”,并不是一个独立的 “数组 / 链表结构”,而是通过当前词法环境的outer引用,递归指向外层词法环境形成的逻辑链, 引擎对外暴露时,会将这个递归的outer链 “扁平化” 为数组形式(即我们感知到的 “作用域链数组”),但底层实现依赖outer。
- 执行上下文的 “作用域链”,是引擎对outer链的上层封装, 将递归的outer链扁平化为数组,方便对外暴露(如调试工具展示),也方便引擎快速遍历
- outer是作用域链的 “幕后构建者”,作用域链是outer链的 “台前展示者”—— 前者负责底层逻辑(能够实现的基础),后者负责对外呈现。
- 你在具体的说明时,就用作用域链就好,毕竟是其outer的外在展示,以哪个进行描述都可以。
词法环境和作用域的关系
- 词法环境(Lexical Environment)和作用域(Scope)并非同一个概念,但高度关联:作用域是 “静态的规则 / 范围”,词法环境是 “运行时的具体实现” —— 前者是 “语法层面的约定”,后者是 “引擎层面的载体”,可以理解为
- 作用域定义了「变量 / 函数可被访问的范围」(静态规则),词法环境是引擎在运行时为该规则创建的「数据结构」(存储变量、this、外层引用等)。
- 作用域是概念和规则,而词法环境是具体实现,并且包含了作用域的查找规则
- 作用域是编译 / 解析阶段就确定的 “规则”—— 在你写完代码的那一刻,每个变量 / 函数的可访问范围就固定了(词法作用域)
执行上下文
注意:下文仅用于理解,具体实现可能不同引擎为了具体的兼容所以不一定会和下面的内容保持一致。
- 执行上下文只有「全局执行上下文」「函数执行上下文」「eval 执行上下文」和 模块执行上下文 四类,块级作用域没有独立的执行上下文,它的词法环境是 “挂载在所属函数 / 全局执行上下文内” 的临时结构。
- 全局执行上下文:唯一且常驻内存,页面或者启动时创建
- 函数执行上下文:函数被调用时(每次调用都创建新实例),执行完毕后出栈销毁;
- 模块执行上下文:单例执行,只执行一次,执行完成后保留引用(多次 import 复用)
执行上下文的核心是词法环境,每个执行上下文都有自己的新的词法环境,词法环境本身是一个链式结构- for或者if等块级作用域在运行时拥有自己的词法环境,但是没有其执行上下文,在实际运行中(假设块级作用域处于一个函数中运行),可以理解当运行到块级作用域的代码时,创建了一个块级词法环境(块级词法环境本身会引用外部的,也就是函数作用域的词法环境),来作为当前函数上下文中的词法环境,然后块级作用域中的代码运行完了后,块级词法环境被移除,恢复成之前的函数作用域的词法环境。参考下文。
参考块级词法环境和函数执行上下文:
1 | // 进入函数但未进入块时: |
说明
- 执行上下文(Execution Context,EC)是 JavaScript 引擎执行代码时创建的运行环境,它封装了代码执行所需的所有信息(变量、函数、作用域链、this 等)
- JS 引擎执行任何代码,都必须在对应的执行上下文中进行—— 没有执行上下文,代码无法运行。
- 闭包形成时,函数指向外部的作用域,这里的作用域是指词法环境还是整个执行上下文?词法环境
- 应该是指向外层上下文的词法环境,更深入的是其
[[Scopes]]引用了创建它的执行上下文中的那个词法环境
- 应该是指向外层上下文的词法环境,更深入的是其
- es6规定函数声明也是属于块级作用域,那么它被定义在上下文的哪个部分?LE还是VE?(也不是那么重要)
- ES6 规范中,块级作用域内的函数声明(如 { function fn() {} })会被归类到「词法环境(LE)」中,而非变量环境(VE)
- 其行为类似let,存在暂时性死区,
- 但是,如果是全局和函数作用域中进行函数声明,则会被归到VE中,为了兼容ES5
- 但是实际浏览器实现时,可能要看其具体的兼容行为,总之,建议块级作用域内不要声明函数,如果要用就用函数表达式
- ES6 规范中,块级作用域内的函数声明(如 { function fn() {} })会被归类到「词法环境(LE)」中,而非变量环境(VE)
- 函数创建时的拥有一个
[[Scopes]]属性,它和执行上下文、以及执行上下文中说的作用域链的关系是什么?- 核心结论:函数的
[[Scopes]]是「静态的词法作用域快照」(函数定义时生成),是构建执行上下文「作用域链」的 “原材料”;执行上下文的作用域链是「动态的运行时结构」(函数执行时生成),由当前上下文的词法环境 +[[Scopes]]组合而成。[[Scopes]]= 函数出厂时自带的 “外层作用域清单”(定义时固定);简单点的话,就是外层执行上下文的那个动态作用域链- 执行上下文 = 函数运行时的 “工作环境”
- 作用域链 = 工作环境中基于 “清单” 组装的 “变量查找路线图”(运行时动态生成)。
- 作用域链是执行上下文的动态结构(执行时生成),包含「当前函数的 LE +
[[Scopes]]」
[[Scopes]]的本质:函数的 “静态作用域快照”,它是函数的内部隐藏属性(ES 规范中的抽象属性,无法直接访问,浏览器调试工具可查看),核心特征- 当函数被创建(如function fn() {}),JS 引擎会捕获当前执行上下文的「词法环境链」,并将其存储到函数的
[[Scopes]]属性中 , 这个过程是 “词法作用域” 的底层体现(作用域由定义位置决定,而非执行位置) [[Scopes]]是一个数组(类似),每一项对应函数外层的「词法环境(Lexical Environment)」引用,从直接外层到全局逐层排列。
- 当函数被创建(如function fn() {}),JS 引擎会捕获当前执行上下文的「词法环境链」,并将其存储到函数的
- 具体实现结构:当你在全局创建一个函数时,
[[Scopes]]属性是一个Scopes[2]类型的值,类似一个数组,只有一个数字索引,而且好像没有原型,可能是内部的数据结构吧。- 0是Script对象,1是Global对象(不是window对象,但是很像,应该不是全局对象,console打印出来输出一个Global字符串,但是typeof是一个object)
- Script对象里面的属性就是你声明的变量
- 核心结论:函数的
- 执行上下文的核心组成
- 变量环境(Variable Environment,VE):存储当前上下文通过 var 声明的变量、function 声明的函数,以及函数参数(arguments)本质上和LE是同一个对象
- 支持「变量提升」—— 代码执行前,JS 引擎会先将 var 变量声明提升为 undefined,function 声明提升为完整函数体
- 词法环境(Lexical Environment,LE):存储当前上下文通过 let/const/class 声明的变量,是 ES6 为解决 var 提升缺陷新增的组件;
- 暂时性死区
- 作用域链(Scope Chain):确定当前上下文可访问的变量 / 函数范围,是「当前上下文的词法环境 + 所有外层上下文的词法环境」组成的链式结构
- 这里的“当前上下文的词法环境”是一个泛指,其实可能实际包含了LE和VE
- 创建时机:执行上下文创建阶段(而非执行阶段),基于代码的静态词法结构确定(这个结构在函数创建或者代码定义时就确定,和函数是否执行无关)
- 查找规则:访问变量时,从作用域链的「当前上下文」开始,依次向上查找,找到即停止,未找到则报错(或兜底到全局对象属性)
- This 绑定:确定当前上下文的 this 指向,不同类型上下文的 this 绑定规则固定
- 变量环境(Variable Environment,VE):存储当前上下文通过 var 声明的变量、function 声明的函数,以及函数参数(arguments)本质上和LE是同一个对象
- 执行上下文的生命周期:执行上下文的生命周期分为「创建阶段」和「执行阶段」,执行完毕后(除全局 / 模块上下文)会被销毁,整个流程由 JS 引擎的「执行栈(调用栈)」管理。
- 阶段 1:创建阶段(代码执行前):JS 引擎在执行代码前,会先完成执行上下文的初始化,核心做 3 件事
- 绑定this:根据上下文类型确定 this 指向
- 如果是函数上下文,this指向为:调用者(隐式绑定)/ 指定对象(显式绑定)
- 创建词法环境(Lexical Environment)
- 初始化「环境记录(Environment Record)」存储 let/const/class 声明的变量(初始为未初始化状态,标记 TDZ);
- 建立「外部词法环境引用(Outer Lexical Environment)」:指向外层上下文的词法环境(形成作用域链)
- 创建变量环境(Variable Environment)其实变量环境指向的对象就是词法环境指向的那个对象,它们是同一个对象引用
- 初始化环境记录:存储 var 变量(赋值 undefined)、function 函数(赋值完整函数体)、函数参数(赋值实参值)
- 外部引用与词法环境共享(即作用域链一致)。
- 绑定this:根据上下文类型确定 this 指向
- 阶段 2:执行阶段(代码执行中):完成创建阶段后,JS 引擎逐行执行代码,核心做 2 件事
- 执行语句:执行具体语句,例如变量赋值,执行函数调用、表达式计算等逻辑
- 函数调用(创建新上下文):若执行过程中调用函数,会暂停当前上下文执行,创建新的函数执行上下文(重复 “创建阶段”),并将其压入「执行栈」顶部,优先执行
- 阶段 3:销毁阶段(代码执行后)
- 函数执行上下文:执行完毕后,从执行栈弹出,其占用的内存(除闭包引用的变量)会被垃圾回收(GC);
- 全局执行上下文:页面关闭 / Node.js 进程退出时才销毁,常驻内存;
- 模块执行上下文:模块加载后常驻内存(单例),供后续 import 复用,直到程序结束。
- 阶段 1:创建阶段(代码执行前):JS 引擎在执行代码前,会先完成执行上下文的初始化,核心做 3 件事
- 关键特性:执行上下文的 “静态” 与 “动态”
- 词法环境(也可以叫词法环境的作用域,其实也可以包括变量环境):静态确定
- 执行上下文的词法环境(作用域链)在代码编写时(定义时) 就已确定,与函数是否执行无关 —— 这就是 “词法作用域”(静态作用域)。
- this绑定:动态确定
- this 指向在代码执行时(调用时) 确定,同一函数在不同调用方式下,this 指向不同
- 变量提升:创建阶段的产物
- 变量提升不是 “语法特性”,而是执行上下文「创建阶段」初始化变量环境 / 词法环境的必然结果
- 词法环境(也可以叫词法环境的作用域,其实也可以包括变量环境):静态确定
- 特殊场景:闭包与执行上下文
- 闭包的本质是「内层函数的执行上下文保留了对外部函数上下文词法环境的引用」,导致外部函数上下文执行完毕后,其变量环境 / 词法环境不会被销毁
创建阶段的可视化:
1 | function fn(a) { |
1 | fn 执行上下文 = { |
执行栈:执行栈(Call Stack)是 JS 引擎用于管理执行上下文的「后进先出(LIFO)」栈结构,核心规则
- 程序启动时,先创建全局执行上下文并压入栈底;
- 执行函数调用时,创建新的函数执行上下文并压入栈顶,成为「活跃上下文」(唯一正在执行的上下文);
- 函数执行完毕,其上下文从栈顶弹出,回到上一个上下文继续执行;
- 所有代码执行完毕,栈中仅保留全局执行上下文。
执行栈变化流程:
1 | function a() { |
作用域链变量查找时的具体逻辑
- 变量查找的核心逻辑:作用域链为 “主路径”,LE/VE 是 “路径上的存储容器”,你可能会疑惑,为什么会分别提到作用域链和LE/VE,这就是变量查找的核心了
- 变量查找的完整流程(分两步)
- 阶段 1:沿作用域链定位「目标词法环境」
- 第一次查:引擎从作用域链的第一个节点(当前函数的词法环境) 开始,依次向上遍历每个节点(外层词法环境 → 全局词法环境),直到找到 “包含目标变量声明” 的词法环境 —— 这一步完全依赖作用域链
- 阶段 2:在目标词法环境的 LE/VE 中查找变量
- 第二次查找:找到目标词法环境后,引擎会在该环境的「变量环境(VE)」和「词法环境(LE)」中(实际是同一环境对象的不同关注点)查找变量
- 阶段 1:沿作用域链定位「目标词法环境」
- 为啥会存在两次查找变量?
- 你可以理解为第一次查找是找到变量在哪个词法环境,第二次查找则是找到这个具体的变量,并且处理其状态
- 例如第二次查找时,变量是存在VE还是LE?是否存在暂时性死区?这些都是要处理的变量状态,这个在第二个阶段去处理。
- 阶段 2 存在的核心原因:词法环境内的 “变量分层存储 + 状态管理”
- 阶段 1 只是定位到 “变量所在的词法环境”,但同一个词法环境内,变量的存储规则和状态是不同的 —— 这些细节必须靠阶段 2 处理,无法在阶段 1 直接完成
- var/function 存在 VE 对应的存储区(提前提升为 undefined 或函数体);
- let/const 存在 LE 对应的存储区(声明前处于 TDZ 未初始化状态)。
- 阶段 1 只能告诉你 “变量在这个词法环境里”,但无法判断它在哪个存储区、是否可用—— 必须靠阶段 2 去遍历该环境的 VE/LE,并处理存储规则
函数
定义函数
- 函数作为一等公民,不仅仅是语法,还是一个值
- 函数声明会创建一个函数对象,并把这个函数对象赋值给指定的名字,其中函数名字就是声明的那个变量,即这个函数名就是变量名
- 理解函数声明,关键是理解函数的名字变成了一个变量,这个变量的值就是函数本身。
- 位于任何JavaScript代码块中的函数声明都会在代码运行之前被处理,而在整个代码块中函数名都会作为一个变量并且绑定为相应的函数对象。无论在作用域中的什么地方声明函数,这些函数都会被“提升”,就好像它们是在该作用域顶部定义的一样。于是在程序中,调用函数的代码可能位于声明函数的代码之前。
- 在ES6以前,函数声明只能出现在JavaScript文件或其他函数的顶部。虽然有些实现弱化了这个限制,但严格来讲在循环体、条件或其他语句块中
定义函数都不合法(不是函数表达式,倒不如说如果需要,推荐使用函数表达式来声明函数)。 - 在ES6的严格模式下,函数声明可以出现在语句块中。不过,在语句块中定义的函数只在该块中有定义,对块的外部不可见。此时你可以理解为es6中定义函数具有块级作用域了,其行为类似let,而且,在块级作用域中,重复声明函数会直接报错(全局作用域和函数作用域为了兼容,有所不同)。在严格模式下,任何作用域的函数重复声明,都会报错。
- es6规范中,块级作用域的函数声明,对齐let,故其拥有暂时性死区,后续TC39修改了规范:即严格模式下的块级函数声明,会被提升到「块作用域顶部」,且创建阶段即完成初始化(直接绑定函数体),因此无 TDZ,声明前可访问。
- 不过es6的模块中的代码,是最严格的严格模式,函数声明全局拥有TDZ(存疑,没有试过,我现在都不敢确定了)不过,知道最佳实践,按照最佳实践去编写代码就可以了。你大概知道有这种可能产生非预期行为的特性就行了。
- 函数表达式
- 如果需要引用自身,也可以带函数名,比如前面的阶乘函数。如果函数表达式包含名字,则该函数的局部作用域中也会包含一个该名字与函数对象的绑定。实际上,函数名就变成了函数体内的一个局部变量。
- JavaScript函数定义不会指定函数形参的类型,函数调用也不对传入的实参进行任何类型检查。事实上,JavaScript函数调用连传入实参的个数都不检查。
- 如果形参默认值是常量(或类似[]、{}这样的字面量表达式),那函数是最容易理解的。但这不是必需的,也可以使用
变量或函数调用计算形参的默认值。对此有一种有意思的情形,即如果函数有多个形参,则可以使用前面参数的值来定义后面参数的默认值 - 在函数体内,剩余形参的值始终是数组。数组有可能为空,但剩余形参永远不可能是undefined(相应地,也要记住,永远不要给剩余形参定义默认值,这样既没有用,也不合法)。
- Arguments对象是一个类数组,有一些奇怪的历史包袱,导致它效率低且难优化,特别是在非严格模式下。在新写的代码中应该避免使用它。在重构老代码时,如果碰到了使用arguments的函数,通常可以将其替换为…args剩余形参
- 在ES2018中,解构对象时也可以使用剩余形参。此时剩余形参的值是一个对象,包含所有未被解构的属性。
- 函数也有length属性,表示函数定义时的实际形参个数,那岂不是符合类数组?你别说,它还真的能传递给Array.from,且传递的函数参数定义了几个,就返回几个包含undefined的数组
- Function构造函数,说实话,确实可能永远都用不到它,它创建的函数,不会使用词法作用域,而是始终编译为顶级函数一样
- 函数作为对象方法声明时,其方法名可以使用动态命名的方法名,例如声明一个可迭代symbol方法时
闭包
- 什么是闭包
- 为了实现词法作用域,JavaScript函数对象的内部状态不仅要包括函数代码,还要包括
对函数定义所在作用域的引用。这种函数对象与作用域(即一组变量绑定)组合起来解析函数变量的机制,在计算机科学文献中被称作闭包(closure)
- 为了实现词法作用域,JavaScript函数对象的内部状态不仅要包括函数代码,还要包括
- 闭包的原理:闭包很简单就是基于作用域链或者说词法环境的角度去解释
- 函数在创建时,除了自身所在的作用域引用之外,还会引用外部创建该函数所在的作用域,形成一个作用域链,从而在函数运行时,函数内部查找变量时,通过这个作用域链就可以查找到外层作用域的变量。
- 严格来讲,所有JavaScript函数都是闭包。但由于多数函数调用与函数定义都在同一作用域内,所以闭包的存在无关紧要。闭包真正值得关注的时候,是定义函数与调用函数的作用域不同的时候。
- 每个闭包都是单独的外层作用域副本(函数的),也就是每次运行都是一个全新的作用域环境,再结合函数每次运行的参数不同,就可以灵活做到许多事情。
- 闭包在外层函数执行完成后,仍然能够保留外层函数的作用域变量
- 因为垃圾回收机制,函数执行完成后,函数上下文仍然存在不会回收(或者作用域链的存在,作用域对象仍然存在),因为函数每次执行都会创建新的函数上下文,所以每个闭包都是独立的函数上下文,互不影响
- 闭包有什么用
- 变量私有化,隔离
- 模块
- 状态缓存
- 变量私有化,隔离
函数调用和this
- this不是常量,它在程序中的
不同地方会求值为不同的值。 - this是面向对象编程中使用的关键字。在方法体中,
this求值为调用方法的对象。 - 作为函数
- this绑定为全局对象,严格模式undefined,非严格模式为window或者global。
- 作为方法
- this绑定当前调者对象
- 如果函数表达式是属性访问表达式,即函数是对象的属性或数组的元素,那么它是一个方法调用表达式。数组这里,你可以理解为数组的元素访问,实际上就是等价于对象的属性访问,数组也是一个对象,其索引本质上也是数组对象的属性罢了,所以这种函数调用和对象的属性方法调用是一致的。
- 构造函数调用(new)
- this绑定是new出来的那个新对象,只要该函数被用作于new的构造函数
- 间接调用:call()和apply(),他们可以改变函数调用的this绑定,不过仍然无法改变箭头函数
- call/apply 的核心逻辑由 JS 引擎(如 V8)在底层实现,具备高性能、完整的边界场景支持
- 开发者可基于 “函数挂载到对象 + 调用时绑定 this” 的规则,实现 call/apply 的核心功能,但无法完全复刻原生的性能和所有边界场景;
- 基于symbol,包装为一个对象,给对象增加属性,然后调用,最后再删除该属性
- bind绑定,这是由js引擎实现的,返回的函数的this绑定是永久的,除了new之外,js引擎会对其特殊处理。同时也支持一点柯里化
- 非 new 调用时:this 永久绑定,call/apply 无法修改;
- new 调用时:忽略 bind 的 this,指向新实例(唯一例外)。
- 模拟bind能复刻 bind 的核心逻辑(永久绑定 this、偏参数、new 兼容);无法 100% 完美复刻原生细节(性能、内置函数兼容、属性继承等);
- 注意bind的原生实现需要满足构造函数兼容,故需要注意其原型继承的问题
- this不是一个变量,也不具有变量那样的作用域机制,其值的确定点在运行时,除了箭头函数中使用的this是在创建时之外。
箭头函数
- 箭头函数只能用于函数表达式(例如变量、函数传参、立即执行函数IIFE的情况),其定位是简洁的表达式函数
- 箭头函数和普通函数差别
- 无独立的this,仅绑定外层this,在创建时就已经确定了,且无法用call、apply、bind进行修改,永远无法修改,注意这里是没有this,你可以理解为箭头函数中的函数执行上下文中,没有this这个上下文对象,所以他只能从作用域中去查找this,也就是找到上层作用域中的this(es6规定箭头函数的「词法环境(Lexical Environment)」中,不包含 this 绑定项,也就是这个函数中没有自己的
调用上下文,即this,而不是绑定为了一个undefined) - 箭头函数不能new,同时也没有prototype,自然无法作为构造函数(构造函数有prototype)使用 new 调用它们会引发 TypeError。它们也无法访问 new.target 关键字。
- 在构造函数中,new.target引用的是被调用的构造函数。如果是在class类中,子类被new创建实例时,那么其new.target就是子类,即使子类调用super触发父类的构造函数执行时,其父类的构造函数中访问的new.target也是指向子类(当然,这通常来说用来做日志的,因为父类不应该知道自己是否有子类)
- 没有arguments对象,只能用es6的可选参数
- 也不要将其作为class类的方法,因为它没有this,无法绑定到实例上去,因为class是构造函数的语法糖,那么它作为构造函数创建时,该原型方法在哪里创建,其this就被绑定到了哪里,所以无法绑定到具体的实例上去。
- 箭头函数不能在其主体中使用 yield,也不能作为生成器函数创建。
- 同时,也不要将其作为dom事件绑定函数,因为同样this无法绑定到dom对象上
- 无独立的this,仅绑定外层this,在创建时就已经确定了,且无法用call、apply、bind进行修改,永远无法修改,注意这里是没有this,你可以理解为箭头函数中的函数执行上下文中,没有this这个上下文对象,所以他只能从作用域中去查找this,也就是找到上层作用域中的this(es6规定箭头函数的「词法环境(Lexical Environment)」中,不包含 this 绑定项,也就是这个函数中没有自己的
构造函数
- 任何普通JavaScript函数(不包括箭头函数、生成器函数和异步函数)都可以用作构造函数,而构造函数调用需要一个prototype属性。为此,每个普通JavaScript函数自动拥有一个prototype属性。
- prototype属性的值是一个对象,它也有一个不可枚举的constructor属性。而这个constructor属性的值就是该函数对象
- 除了使用bind返回的函数(原生),该函数在被new调用时,使用的是底层函数的prototype属性
- 构造函数拥有一个prototype,实例对象通过__proto__或者
[[Prototype]]来指向它- 只有函数对象才有prototype属性
- 可以使用new一个构造函数创建一个对象,如果构造函数没有参数,则可以省略括号:
new Obj() === new Obj- 创建一个空对象的新对象
- 将空对象的__proto__或者
[[Prototype]]的值设置为构造函数的prototype,此时建立好了原型继承(即原型链) - 构造函数中的this绑定为该新创建的对象
- 若构造函数显式返回「对象 / 函数」,则new的结果为该返回值;
- 若构造函数无返回值或者返回原始值(数字 / 字符串 / 布尔 /null/undefined),则new的结果为创建的那个新对象。
- 在函数体内,可以通过一个特殊表达式new.target判断函数是否作为构造函数被调用了。如果有值,则表明是以new来调用的,如果new.target是undefined,那么包含函数就是作为普通函数被调用的,没有使用new关键字。
- new o.m()这种写法的执行顺序
- 先获取o.m作为构造函数,然后再new这个构造函数
- 执行顺序:属性访问符 > new > 函数调用括号。所以不是:
new (o.m())
- fn.prototype.constructor 是 JS 原型体系中指向构造函数本身的 “反向引用”,核心作用是「让实例能追溯到创建它的构造函数」,同时维持原型链的完整性 —— 它是原型机制的 “内置约定”,虽可手动修改,但修改后需注意原型链逻辑的一致性。
原型、原型链和原型继承
- js对象通过原型实现的继承
- 是 “自下而上” 的对象委托—— 没有模板,只有对象,实例遇到未知属性 / 方法时,会沿着原型链 “委托” 给原型对象查找,继承的核心是
“属性和方法的动态查找”,强调 “对象关联”(而非类型)。
- 是 “自下而上” 的对象委托—— 没有模板,只有对象,实例遇到未知属性 / 方法时,会沿着原型链 “委托” 给原型对象查找,继承的核心是
- 判断继承,不止是instanceof可以判断,还可以用一些api,例如isPrototypeOf方法
- 子类继承需要考虑的点(构造函数的方式)
- 子类需要父类的初始化属性,所以子类需要用call或者apple调用一次父类的构造函数
- 子类的原型需要继承父类的原型。
- 使用
子类.prototype = Object.create(父类.prototype)创建一个原型指向父类的对象,并作为子类的prototype属性(不要new一个父类的实例,这样带了不必要的属性,需要额外处理,虽然也可以不用处理,但是额外多了一层无用的原型总归不好) - 正确设置子类prototype.constructor指向子类构造函数(注意,constructor应该是不可枚举的,可以考虑使用Object.defineProperty开设置为不可枚举属性
- 使用
- 额外思考:class中的super调用父类的方法,在构造函数中如何实现?构造函数中的属性还可以通过call或apply还可以解决
- 总不至于创建一个父类的实例,然后通过call或者apply改版this来做到
- 甚至于直接通过父类的prototype原型,找到该方法,然后利用call或者apply来实现吧(这个好像真可行)
- 但是要注意,差别还是很大的,super内部可以动态适配父类,而构造函数硬编码:super不是 “语法糖”,是引擎级的语义:手动用call/apply能模拟 “调用父类方法”,但无法完全复刻super的所有行为(如super.prop属性访问、super()构造器调用的特殊规则)。
- 额外思考:class中的继承,子类会继承父类的静态方法,如何实现?
- 看起来像是子类构造函数,能够使用父类构造函数上的方法,能做到的只有原型链接了,子类和父类都是函数,都继承Function.prototype,那么直接将其原型指向Array构造函数了。
prototype特性
这里指对象的[[prototype]],其实就是原型,也是指__proto__属性
- 对象的prototype特性指定对象从哪里继承属性
- 要查询任何对象的原型,都可以把该对象传给Object.getPrototypeOf()
- 要确定一个对象是不是另一个对象的原型(或原型链中的一环),可以使用isPrototypeOf()方法
- 对象的prototype特性在它创建时会被设定,且通常保持不变。不过,可以使用Object.setPrototypeOf()修改对象的原型
- 一般来说很少需要使用Object.setPrototypeOf()。JavaScript实现可能会基于对象原型固定不变的假设实现激进的优化。这意味着如果你调用过Object.setPrototypeOf(),那么任何使用该被修改对象的代码都可能比正常情况下慢很多。
- isPrototypeOf()的功能与instanceof操作符类似
- JavaScript的一些早期浏览器实现通过
__proto__(前后各有两个下划线)属性暴露了对象的prototype特性。这个属性很早以前就已经被废弃了,但网上仍然有很多已有代码依赖__proto__,所以es标准也要求浏览器必须实现它
js的类-class
- js的类其实是原型或者构造函数的语法糖
- 类声明不会“提升”
- 类声明体中的代码都是严格模式的代码
- 在类体中定义字段的语法,不确定是否标准化了,不过babel的转移语法已经很常见了
- 在类中定义的字段,是类中的实例属性,不是原型属性,方法才是原型的
- 在类的原型对象上定义属性,目前,暂时还未发现有这种语法,你可以在class创建之后,直接在其prototype上加,或者从软件设计的角度看,这种需求真的会有吗?有一个属性,需要在该类下共享,用方法不好吗?或者写成get方法也是一个办法,反正class的get方法是在原型上的。
- class的继承,不仅仅实现了原型继承,还做到了让子类可以使用父类的静态方法和属性,即子类的原型(
__proto__)指向父类这个构造函数,B继承A类,更严谨的说是:Object.setPrototypeOf(B, A) - super的角色很像this关键字,它引用当前对象,但允许访问父类定义的被覆盖的方法。倒不如说,其引用的就是当前对象的父类(在静态方法中的super也是这个道理,当前对象指this或者静态方法中的类)
- class的继承在使用时的super注意事项
- 构造函数必须要调用super,且在使用this之前调用
- 如果能用组合或者说委托,那就尽量不要用继承
- js没有规定真正的抽象类语法,不过可以使用throw来作为一种方式,如果子类未实现该方法,则抛出异常。
对象
- 对象的实际语义是由对象的内部方法(internal method)指定的。所谓内部方法,指的是当我们对一个对象进行操作时在引擎内部调用的方法。在 ECMAScript 规范中使用
[[xxx]]来代表内部方法或内部槽- 包括
[[Get]](用来获取属性) 在内,一个对象必须部署 11 个必要的内部方法(除了[[Get]]和[[Set]]),还有两个额外的必要内部方法:[[Call]] 和 [[Construct]] - 内部方法具有多态性,这类似于面向对象里多态的概念。这就是说,不同类型的对象可能部署了相同的内部方法,但是却具有不同的逻辑(即不同对象的内部方法实现或行为是不同)。例如,普通对象和 Proxy 对象都部署了
[[Get]]这个内部方法,但它们的逻辑是不同的
- 包括
- JavaScript对象是动态的,即可以动态添加和删除属性。
- 除了属性和名字之外,js对象的属性还拥有可写、可枚举、可配置三个属性特征,大多JavaScript内置对象拥有只读、不可枚举或不可配置的属性。不过,默认情况下,我们所创建对象的所有属性都是可写、可枚举和可配置的
- Object.create
- 能够以任意原型创建新对象是一种非常强大的技术
- Object.create()的一个用途是防止对象被某个第三方库函数意外(但非恶意)修改。这种场景下,可以使用该对象作为原型,创建一个新对象,将其传递给第三方库
- 对象的属性查找,会使用原型链,但是对象的属性赋值,则稍微有一点不同,例如如果其继承了一个只读的属性(非自有属性),那么就无法对该属性进行赋值了。
- 现在假设你为对象o的x属性赋值。如果o有一个名为x的自有(非继承)属性,这次赋值就会修改已有x属性的值。否则,这次赋值会在对象o上创建一个名为x的新属性。如果o之前继承了属性x,那么现在这个继承的属性会被新创建的同名属性隐藏。也就是属性赋值时,如果对象中不存在自有属性,但是存在继承属性,也不会覆盖继承的属性,而是创建一个自有属性,并将继承属性给隐藏掉。
- 属性赋值要么失败要么在原始对象上创建或设置属性的规则有一个例外。如果o继承了属性x,而该属性是一个通过设置方法定义的访问器属性,那么就会以该对象作为调用者去调用该访问器属性,即使该访问器属性中设置了其他属性,那么也会在该对象上创建,仍然不会修改其原型
- 关于属性赋值什么时候成功、什么时候失败的规则很容易理解,但是也不容易理解
- 存在一个只读自有属性,则属性设置失败
- 存在一个只读继承属性,则不能用同名自有属性,隐藏只读继承属性(感觉也可以这么理解,你赋值时,需要先去查找,此时查找到了原型链上的一个只读属性,那么此时赋值就会失败)
- 第三种比较复杂:o没有自有属性p,o没有继承通过设置方法定义的属性p,o的extensible特性(参见14.2节)是false。因为p在o上并不存在,如果没有要调用的设置方法,那么p必须要添加到o上。但如果o不可扩展(extensible为false),则不能在它上面定义新属性
- delete操作符只删除自有属性,不删除继承属性
- delete是一个操作符,它并不操作属性的值,而是操作属性本身
- 查询属性
- in操作符要求左边是一个属性名,右边是一个对象。如果对象有包含相应名字的自有属性或继承属性,将返回true
- hasOwnProperty()方法用于测试对象是否有给定名字的属性,且必须要是自有属性,才会返回true
- propertyIsEnumerable()方法细化了hasOwnProperty()测试。如果传入的命名属性是自有属性且这个属性的enumerable特性为true,这个方法会返回true。
- 直接判断其属性是否等于undefined,
a.x !== undefined,基本等同于in,不过,它无法识别出存在某个属性,但是其属性值被设置为undefined的情况,但是in可以
- 遍历数据(枚举属性)
- for in会遍历出所有可枚举属性(自有或者继承的,且非符号属性)对象继承的内置方法是不可枚举的。所以一般只有它需要处理是否是继承属性。
- 先获取属性名,然后再遍历(这些都不会去操作继承属性)
- Object.keys()返回对象可枚举自有属性名的数组。不包含不可枚举属性、继承属性或名字是符号的属性
- Object.getOwnPropertyNames()与Object.keys()类似,但也会返回不可枚举自有属性名的数组,只要它们的名字是字符串。
- Object.getOwnPropertySymbols()返回名字是符号的自有属性,无论是否可枚举。
- Reflect.ownKeys()返回目前对象自身的所有属性名,包括可枚举和不可枚举属性,以及字符串属性和符号属性(参见14.6节)。等同于Object.getOwnPropertyNames和Object.getOwnPropertySymbols之和
- 枚举顺序,挺繁琐的,有需要再自行了解即可。不过map是有顺序的,可以按照其插入顺序进行迭代
- 扩展对象
- Object.assign:对于每个来源对象,它会把该对象的可枚举自有属性(包括名字为符号的属性)复制到目标对象。
- Object.assign()以普通的属性获取和设置方式复制属性,因此如果一个来源对象有获取方法或目标对象有设置方法,则它们会在复制期间被调用,但这些方法本身不会被复制。
- 可以在对象字面量中使用“扩展操作符”…把已有对象的属性复制到新对象中
- 扩展操作符只扩展对象的自有属性,不扩展任何继承属性
- 对象序列化JSON.stringify():将对象转换为字符串的过程
- 函数、RegExp和Error对象以及undefined值不能被序列化或恢复。
- JSON.stringify()只序列化对象的可枚举自有属性。
- 如果属性值无法序列化,则该属性会从输出的字符串中删除。
属性访问器:getter和setter
- 如果只有一个setter设置方法,那它就是只写属性(这种属性通过数据属性是无法实现的),读取这种属性始终会得到undefined。
- 在
对象字面量中,不能为一个已有真实值的变量使用 set,也不能为一个属性设置多个 set。( { set x(v) { }, set x(v) { } } 和 { x: ..., set x(v) { } } 是不允许的 )- 也就是如果你有一个普通的属性x,那么就不能再为编写一个x的setter函数了,get也是一样
- 尽管可以结合使用 getter 和 setter 来创建一个伪属性,但是不可能同时将一个 getter或者setter 绑定到一个属性并且该属性实际上具有一个值。
- 可以使用Object.defineProperty()来为对象的属性添加get或者set
- get和set中的this问题:如果是在class中使用set和get的话,那这个set和get会作为类的原型方法一样存储在原型中,给所有实例共享,那其中的get和set中的this,是原型对象还是实例对象呢?是实例对象,其核心就是可以避免原型导致的共享风险。
数组
- 数组的扩展运算符
...适用于任何可迭代对象,例如字符串,所以将字符串展开为数组,可以用[..."str"] - 创建数组的方式不少,不过最推荐的自然还是字面量,或者再不济就是Array.from,或者数组扩展运算符。如果需要指定长度,则使用Array(n).fill(undefined)
- new Array(x)和Array(x),几乎无区别,区别仅在特殊情况,无需关心
- 数组特殊的地方在于,只要你使用小于
2^32-1的非负整数作为属性名,数组就会自动为你维护length属性的值。 - 数组是一种特殊的对象。用于访问数组元素的方括号与用于访问对象属性的方括号是类似的。JavaScript会将数值数组索引转换为字符串,即索引1会变成字符串“1”,然后再将这个字符串作为属性名。
- 明确区分数组索引和对象属性名是非常有帮助的。所有索引都是属性名,但只有介于0和
2^32-2之间的整数属性名才是索引。所有数组都是对象,可以在数组上以任意名字创建属性。只不过,如果这个属性是数组索引,数组会有特殊的行为,即自动按需更新其length属性 - 可以使用负数或非整数值来索引数组。此时,数值会转换为字符串,而这个字符串会作为属性名。因为这个名字是非负整数,所以会被当成常规的对象属性,而不是数组索引。
- splice()是一个可以插入、删除或替换数组元素的通用方法。这个方法修改length属性并按照需要向更高索引或更低索引移动数组元素。
- map()返回一个新数组,并不修改调用它的数组。如果数组是稀疏的,则缺失元素不会调用我们的函数,但返回的数组也会与原始数组一样稀疏:长度相同,缺失的元素也相同。
- filter()会跳过稀疏数组中缺失的元素,它返回的数组始终是稠密的。因此可以使用filter()方法像下面这样清理掉稀疏数组中的空隙
- 如果在空数组上调用every和some,按照数学的传统,every()返回true,some()返回false。
- 归并函数reduce和reduceRight:如果不传初始值参数,在空数组上调用reduce()会导致TypeError。如果调用它时只有一个值,比如用只包含一个元素的数组调用且不传初始值,或者用空数组调用但传了初始值,则reduce()直接返回这个值,不会调用归并函数。
- 数组打平:flat和flatMap,flat方法可以使用第二个参数指定打平的层级
- flatMap()方法与map()方法相似,只不过返回的数组会自动被打平,就像传给了flat()一样,只不过它仅仅只能展平一层。换句话说,调用a.flatMap(f)等同于(但效率远高于)a.map(f).flat(1),也就是先map后打平
- flatMap()允许把输入元素映射为空数组,这样打平后并不会有元素出现在输出数组中
- concat()方法创建并返回一个新数组,新数组包含调用concat()方法的数组的元素,以及传给concat()的参数。如果这些参数中有数组,则
拼接的是它们的元素而非数组本身。但要注意,concat()不会递归打平数组的数组。concat()并不修改调用它的数组 - 在给unshift()传多个参数时,这些参数会一次性插入数组。这意味着一次插入与多次插入之后的数组顺序不一样。a.unshift(1,2),此时,数组就会变成
a = [1, 2],如果一次次插入,则就是2和1的顺序了 - slice返回子数组,splice删除数组并添加(改变原数组)
- indexOf使用
===来判断,但是无法判断NaN,因为NaN全等为false。而includes则可以检测NaN - sort排序:
- 不传参调用时,sort()按字母顺序对数组元素排序(如有必要,临时把它们转换为字符串再比较),这个可以参考字符串的比较方式(Unicode码点)
- 如果数组包含未定义的元素,它们会被排到数组末尾:
['d', '', 'b0',,undefined,'a'].sort() // ['', 'a', 'b0', 'd', undefined, empty] - 如果第一个参数应该出现在第二个参数前面,比较函数应该返回一个小于0的数值。如果第一个参数应该出现在第二个参数后面,比较函数应该返回一个大于0的数值。如果两个值相等(也就是它们的顺序不重要),则比较函数应该返回0。
- 类数组:事实上,只要对象有一个数值属性length,而且有相应的非负整数属性,那就完全可以视同为数组。
- 实践当中,我们偶尔会碰到“类数组”对象。虽然不能直接在它们上面调用数组方法或期待length属性的特殊行为(因为类数组不继承Array.prototype),但仍然可以通过写给真正数组的代码来遍历它们。说到底,就是因为很多数组算法既适用于真正的数组,也适用于类数组对象。特别是在将数组视为只读或者至少不会修改数组长度的情况下,就更是这样了。
- 多数JavaScript数组方法有意地设计成了泛型方法,因此除了真正的数组,同样也可以用于类数组对象。你可以使用Function.call来调用
- 类数组(Array-like)对象调用 Array.isArray() 会始终返回 false—— 因为 Array.isArray() 是严格判断值是否为 JS 原生 Array 实例,而类数组只是 “长得像数组”(有 length、数字索引)的普通对象,并非 Array 构造的实例。
- Array.isArray() 不依赖原型链、toString 标签或表面结构,而是直接判断值的内部
[[Class]]槽位是否为 “Array”(底层类型标记) - 通常使用Array.from(val)将类数组转换为真正的数组(或者使用展开运算符,不过要求类数组也是一个可迭代对象,例如dom集合、arguments、字符串等)
- Array.isArray() 不依赖原型链、toString 标签或表面结构,而是直接判断值的内部
- 字符串也是类数组,不过字符串是不可修改的值,因此在把它们当成数组来使用时,它们是只读数组。像push()、sort()、reverse()和splice()这些就地修改数组的数组方法,对字符串都不起作用。但尝试用数组方法修改字符串并不会导致错误,只会静默失败。
数组和对象
- 在 JavaScript 中,数组的本质是 “特殊的对象” 它不是与对象完全独立的全新数据结构,而是继承自 Object、并被引擎特殊优化的「有序键值对集合」。ES 规范也明确将 Array 归为 Object 的子类型
- 数组的特殊性仅体现在「引擎对其的 “专属规则约束” 和 “性能优化”」,而非底层结构的本质差异。
- JS 数组的「数字索引」只是语法糖 —— 引擎会自动将数字索引转为字符串键,唯一区别是:数组会根据最大数字键自动维护 length 属性,而普通对象不会。
- 数组的 “索引” 被定义为「符合 ToUint32 规则的字符串键」(即 0、1、2… 转为字符串后的值);
- 数组之所以看起来和普通对象不同,是因为 JS 引擎为它增加了以下 “特殊约束”,这些约束让它表现出 “有序集合” 的特性,而非普通对象的 “无序键值对”
- length自动联动,数组的 length 始终等于「最大数字索引 + 1」,如果手动修改,则超出length的元素会被删除
- 数组的数字索引会被引擎以「连续内存 / 有序结构」存储(V8 等引擎会区分 “快数组”/“慢数组”),遍历、访问速度远高于普通对象的字符串键。
- JavaScript数组是动态的,它们会按需增大或缩小,因此创建数组时无须声明一个固定大小,也无须在大小变化时重新为它们分配空间。
- JavaScript数组是一种特殊的JavaScript对象,因此数组索引更像是属性名,只不过碰巧是整数而已。
关于语言规范的定义和具体的实现细节
- 语言层面的定义是解释说明或者说是定性,但是在实际优化和运行中,对象本身还分为快属性和慢属性来优化本身的性能(而非单一的哈希表),而数组其实也会基于其数据本身来对其底层的结构进行优化,也就是说,其语言规范本身仅仅是定义其语言的行为和特性,而不会限制其本身的具体底层实现细节。例如es规范本身不会定义数组是用什么数据结构去实现的,而是仅仅只是定义数组拥有什么样的行为和特性,剩下的就是js引擎要做的事情了。
- 尤其是js本身是一门解释性语言,官方只有一个es标准规范,而js引擎(解释器)是由其他厂商开发的,例如v8引擎
js数组的存储方式和一些细节,看是否和js对象存在差异
- JavaScript 中对象和数组的存储逻辑,核心差异在于 JS 引擎(如 V8)对数组做了 “针对性的内存布局 + 访问规则优化” 对象默认以 “无序哈希表” 存储,而数组优先用 “连续内存的快数组” 存储,仅在特定条件下降级为哈希表,这也是数组性能高于普通对象的核心原因。
- JS 是 “垃圾回收型语言”,所有对象 / 数组都存储在 堆内存(Heap) 中,栈内存(Stack)仅存储基本类型值、堆内存的引用地址、执行上下文等。
- 普通对象(
{})的核心存储结构是哈希表(Hash Table),也叫 “属性字典”,V8 中称为 PropertyArray + Elements。为了平衡 “查找速度” 和 “内存占用”,V8 对对象属性做了分层存储- 快属性:属性名和值按 “有序数组” 存储,通过哈希映射快速查找(类似数组索引),访问速度接近数组;
- 隐藏类:在快属性查找中,需要借助隐藏类:V8 为每个对象创建 “隐藏类”,记录属性的偏移量、类型等信息,避免重复解析属性结构。这里的哈希映射本质就是借助隐藏类中存放属性的偏移量和类型等信息(你可以理解为通过一个属性数组记录器属性数据的偏移量和类型等信息),在查找时遍历该数组,快速找到属性对应的值的内存位置,跳过了哈希查找和处理哈希冲突等相对耗时的步骤,且还可以优化为机器码,跳过遍历数组的步骤,进一步优化性能。
- 隐藏类的本质是 “为对象属性建立静态的内存索引表”,让属性访问从 “动态哈希查找” 变成 “静态数组寻址”—— 这就是快属性比慢属性(纯哈希表)快的根本原因。
- 慢属性:当对象属性过多(如超过 10 个)或频繁增删属性时,降级为纯哈希表(键值对 + 哈希函数),查找需计算哈希值,速度较慢;
- 快属性:属性名和值按 “有序数组” 存储,通过哈希映射快速查找(类似数组索引),访问速度接近数组;
- 数组的核心优化是 “分场景选择存储结构”:优先用「连续内存的快数组」,仅在稀疏 / 超大等场景降级为「哈希表的慢数组」,且全程复用隐藏类优化。
- 当数组满足「索引连续、元素类型统一、长度适中」时,V8 会用 连续的内存块 存储数组元素,这是数组性能高于对象的核心。其结构类似与c++的数组数据
- 当数组满足以下条件时,V8 会将快数组降级为慢数组(哈希表存储):
- 数组是稀疏数组(索引不连续,如 [1,,3] 或 arr[1000] = 1);
- 数组长度超大(如超过 2^29,约 5 亿),连续内存占用过高;
- 频繁增删非尾部元素(如 arr.splice(0, 1)),导致连续内存频繁重构。
- 哈希表数组:
- 用哈希表存储「索引(字符串键)→ 值」,和普通对象的慢属性类似;
- 仅存储有值的索引,节省稀疏数组的内存(比如 arr[1000] = 1 仅存储键 “1000”,无需分配 1001 个连续内存);
- 其在内存上会更有优势,因为其根据实际数据来占据内存大小
- 数组的其他优化关键
- 元素类型统一优化:很好理解,数组的数据类型一致,则引擎可以使用类型数据来进一步提高性能。若类型混杂(如 [1, “2”, true]),则用「Tagged Pointer」存储(每个元素带类型标记),但仍保持连续内存。
- 数组的 push/unshift 等操作,V8 会预分配额外内存(比如当前 length 为 10,预分配到 16),避免每次扩容都重新申请内存(“扩容策略”:每次扩容为当前容量的 1.5 倍 + 16)。
- 引擎优化的降级规则(避坑关键)
- 稀疏数组:索引不连续(如 arr[1000] = 1,中间 0-999 为空);
- 超大数组:长度超过 2^29(V8 阈值),连续内存占用过高;可以分块存储
- 频繁非尾部修改:如 arr.splice(0, 1)/arr.unshift(),连续内存需频繁移动元素;
- 元素类型极度混杂:如 [1, {}, “a”, true, Symbol()],无法做类型优化;
- 手动设置超大 length:如 arr.length = 1000000 但无元素,触发稀疏数组逻辑。
- 慢数组的 “慢” 是 “和快数组比慢”,而非 “和对象比慢”—— 哈希表的固定开销(计算、冲突)让它无法达到快数组的极致性能,但仍优于无优化的普通对象。
- 慢数组和普通对象的慢属性性能几乎持平(甚至慢数组略优),因为慢数组有两个专属优化
- 索引类型优化:慢数组的键是 “数字转字符串”,引擎可预判键的格式,哈希函数做了针对性优化(比对象的任意字符串键哈希更快);
- length 关联优化:慢数组仍维护 length 属性,遍历(如
for (let i=0; i<length; i++))时无需枚举所有键(对象需用 Object.keys() 枚举,更慢)。
- JS 引擎对数组的优化核心是「把能连续的场景做成连续内存,把稀疏的场景做成哈希表」,而对象只能走哈希表路径 —— 这就是数组访问 / 遍历性能高于普通对象的根本原因。
稀疏数组和密集数组
JavaScript数组可以是稀疏的,即元素不一定具有连续的索引,中间可能有间隙。每个JavaScript数组都有length属性。对于非稀疏数组,这个属性保存数组中元素的个数。对于稀疏数组,length大于所有元素的最高索引。
注意,稀疏数组和密集数组并不是影响数组优化的绝对因素,虽然稀疏数组绝对不可能做快数组的优化,但是密集数组也不一定是快数组。
- 什么是稀疏数组
- 稀疏数组(Sparse Array) 是指「包含 “空缺位置(empty slot)” 的数组」—— 即数组的索引不是连续的,存在 “定义了较大索引但中间索引未赋值” 的情况,核心特征是 length 属性大于实际存在的元素个数。
- 用逗号或者直接
Array(n)创建一个数组,其都会创建一个稀疏数组:Array 构造函数传入单个数字参数时,会创建一个 length 为 n、但无任何元素的稀疏数组
- 稀疏数组的length和密集数组的length
- 稀疏数组的length,仅反映数组最大的索引,但不代表实际的数组元素个数:稀疏数组的 length 始终等于「最大索引 + 1」,与实际有值的元素个数无关
- 稀疏数组和密集数组在一些方法和行为上的差异
- empty:数组的 “未初始化位置”,
不属于数组的可枚举属性,原型链查找时才返回 undefined; - undefined:是一个具体的值,属于数组的可枚举属性。
- empty:数组元素的undefined和空位不一样,它们并不相等,只不过在访问空位的数组元素时,返回undefined而已
- 不同数组方法对于空位和值为undefined时的行为有差异,大多数会跳过空位或者过滤掉空位,可能是由于其属于不可枚举属性吧。
- 不过普通for 循环不会跳过空位,会遍历所有索引(包括 empty),访问返回 undefined,因为for循环本质上是基于长度条件做的循环语句,和数据本身没有任何关系,所以也不会特殊处理所谓的数组空位
- for of循环,则对于稀疏数组没有任何特殊行为,对于空位,都会循环为undefined
- 这是因为数组的迭代器(
Array.prototype[Symbol.iterator]、entries()/keys()/values())遵循「按索引范围(length)遍历,而非按实际属性遍历」的规则,内部你可以认为是基于length的普通for循环。
- 这是因为数组的迭代器(
- empty:数组的 “未初始化位置”,
- 如何避免稀疏数组?
- 如果要检测稀疏数组,则可以使用for循环配合hasOwnProperty,因为稀疏数组中空位本质上其实是没有索引的(其实就是将数组看做对象,他有0、1等索引,但是空位实际上就是没有索引,等同于该对象没有这个属性),自然hasOwnProperty返回为false
- 手动设置超大索引(最易触发):为数组赋值一个远大于当前 length 的索引,中间的索引会成为空位
- 删除数组元素(delete 操作)会删除元素,但保留空位,且不改变length,所以最好不要用
- 不要用 Array(n) 创建数组后直接操作,优先用 Array.from({length: n}) 或 fill 初始化;
- 转换稀疏数组
- 使用Array.from:自动将 empty 转为 undefined,返回密集数组。Array.from() 绝不会创建稀疏数组,如果传递给他的 arrayLike 对象缺少一些索引属性,那么这些属性在新数组中将是 undefined。
- 用Array(5)创建后,使用fill填充