Books

JavaScript 语言精粹与编程实践

语法

语法综述

语言中的标识符大致分为两类,用于绑定语义逻辑的语法关键字,和用于绑定数据及其存储位置的变量。两者分别限定了逻辑的作用域以及变量的生存周期,所以所谓“声明”,即约定了数据的生存周期以及逻辑的作用域;编程也就被解释成了“说明数据和逻辑”的过程。

可以使用 --check 指令检测语法错误:

echo '"hello world"' | node -c

声明语法

JS “识别的” 7 种数据类型叫做基本数据类型(第一类类型1),识别是以 typeof 运算符为准的。早期的 JS 语言中,正则是可执行的(实现了 [[call]] 内部方法),所以 typeof 会返回 function,这一 bug 后来被修正。

撇开 OO 不论,JS 中有以下几种类型系统可以讨论:

  • 基本数据类型:undefined、string、number、object2、function、boolean、symbol、bigint
  • 值类型和引用类型3

如果算上规范,可以再增加两种类型系统:

  • ECMAScript 语言类型:Null、Undefined、String、Symbol、Number、BigInt、Object
  • ECMAScript 规范类型:List、Record、Relation、Set、Completion Record、Reference、Property Description、Lexical Environment、Environtment Record,为叙述语言类型提供帮助

变量声明一共两种形式:显式声明和隐式声明。隐式声明即赋值语句中出现了未声明的变量的情况,这种声明方式不可靠;其余的声明,包括 try...catch 子句中的异常变量都是显式声明。

let 和 var 声明一个不同的地方在于 let 声明不会在全局对象上挂新的属性。

需要注意的是尽管规范确定了常量声明不能再绑定,但是在 ES5 兼容环境或是 ES6 模拟环境中(如使用只读属性模拟常量),修改常量不会抛出异常。

相关字符串字面量的一些冷知识:

  • 大于 U+FFFF 的字符的长度在 UTF8 文件夹的长度是 2,但是过换编码,结果不同,试了下 GBK 下的结果为 1。
  • 空字符串也能作为对象的键。
  • 字符串模板本质上是一个字面量的引用,在 JS 内部表达为一个带 raw 属性的类数组对象,这点可通过 String.raw 函数间接验证。题外话,String.raw 搭配 new RegExp 非常好用哦。

表达式运算

运算符不仅是各类标点,还有许多单词,如 typeof、void、new、in、delete、instanceof、yield、await 这些都算。

在规范中经常看到基本表达式(Primary Expression)的概念,它主要由两部分组成:

  • 单值表达式:表达式的结果既该值,如 this、super、new.target、arguments 以及各种变量引用以及原始值、正则的字面量
  • 非原始值字面量(数组、对象、函数)以及表达式分组运算(())

可以发现,除了单值表达式外,表达式的结果应该是运算后得到的值(应该至少有一个运算符)。运算符得到的结果类型不外乎值类型、引用类型或 undefined 三种。

ES5 规范了将字符串作为类数组对象,也就是说可以通过下标存取取得字符串的值,但是不能改变它。从 ES6 开始,字符串添加了 Symbol.iterator 属性,可通过展开操作符、yield* 或 for...of 语句操作。

在位运算操作中,运算目标将强制为一个有符号的 32 位整数:非数值转化为数值、浮点数先向零取整,所以常见到“1.1 | 0”这种取证操作并不是位运算有什么魔法,而是在“|0”前,1.1 就被取整了,而任何数按位或 0 会得到其本身。

等值检测的一个缺陷是它不会区分 +0 和 -0,而这两者在数学运算中被认为是两个不同的数;它也不能区分 NaN,所以 ES6 新增了 Object.is 方法来判断 +0、-0 以及 NaN。

Object.is(+0, -0) // false
Object.is(NaN, NaN) // true

在比较判定中,JS 总是倾向将操作数转换为数字进行比较,因为字符串比较性能更差。比较运算同理,当字符串和非字符串进行比较时,会尝试将字符串转换为数字。

有一个特例,符号虽然能转化为布尔值,但是不在等值判定算法其中,也就不等值于 true:

Boolean(Symbol()) // true
Symbol() == true // false

赋值也是运算符,所以左右两侧都是操作数。按照表达式的概念,操作数可以是值也可以是引用,所以类似“1 = 1”的表达式是可以通过语法检测的,不过只是在其他阶段会抛出引用错误。

echo "1 = 1" | node -c

函数调用也有“隐式调用”这一概念:

  • 使用 new 运算符
  • 模板处理函数 + 模板字符串调用
  • 函数作为属性存取器且发生了属性存取操作时
  • 函数作为符号属性(如 Symbol.hasInstance)并触发了对应操作时
  • 使用 Proxy 创建了源函数的代理对象后,调用代理对象会隐式调用源函数
  • 使用 bind 将源函数绑定为目标函数后,调用目标函数会隐式调用源函数

一直以为使用 bind 时是“创造”了新函数,啊,这里好迷。更迷的是,使用 new 运算符时虽然能见到函数名后面紧跟的小括号,但那不是调用运算符,而是 new 运算符的参数传入表,起调用作用的是 new 运算符,所以是隐式调用。

typeof 在运算符中是一个很特殊的存在,一般变量在表达式中都是以值参与运算,而 typeof 是求值的类型,可以无视标识符是否声明过,比如:

typeof x // undefined

但有一个很疑惑的地方是,由于暂时性死区的存在,typeof 运算符也并不总是这么安全。

typeof x // ReferenceError
let x

完整的运算符优先级列表如下:

运算符描述
()成组运算
.、、new ()对象成员存取、数组下标、带传参列表的 new 运算符
()、new函数调用、new 运算符
++、--后置递增、后置递减
+、-、++、--、~、!、delete、typeof、void前置加、前置减、前置递增、前置递减、按位取反、逻辑非、delete、typeof、void
*、/、%乘法、除法、取模
+、-、+加法、减法、字符串连接
<<、>>、>>>移位运算符
<、<=、>=、>、in、instanceof关系运算符、in、instanceof
==、!=、===、!==等值检测
&按位与
^按位异或
|按位或
&&逻辑与
||逻辑或
?:三木运算
=、oP=赋值、运算赋值
yield、yield*yield 表达式
...展开运算符
逗号运算符、多重求值

需要注意的是 new 的优先级问题。以往的博客一直在强调使用 new 运算符时一定要带上括号,而不是混用带括号和不带括号两种用法。两种用法最显著的区别在于,它们的运算优先级不一样,以点号存取运算符为例:

function A () {
  this.b = 'b'
  console.log('A().b')
}
A.b = function () {
  console.log('A.b')
}
new A.b // A.b
new A().b // A().b

语句

按照类型,JS 中的语句可分为:声明语句、表达式语句、分支语句、循环语句、控制结构(continue、break 等)、其他(空语句、妇科语句、调式语句)、标签化语句。

需要注意的点:

  • 调试语句(debugger;)用于开启宿主环境的调试器
  • 除了分号外,EOF 也可以作为语句的结束标志(我没有试验成功)
  • 大括号并不是 for...in 等语句的语法元素,但却是 try...catch 中的语法元素

解释一下为什么声明函数后没有办法直接加上小括号调用该函数,由于花括号是函数声明语句的语法成分(而不是理解为复合语句)所以解释器会自动在大括号结束后插入空格。

function log(arg) {
  console.log(arg)
}(2)

在 for...in 等语句中声明的变量,如果是 var 声明,那么变量作用域将等同于当前所在函数级别,let 及 const 声明则等同于当前语句的块级作用域级别。try...catch 子句中显式声明的变量虽然没有 let、var 等声明关键字,但是经过测试发现其等同于 var 声明。

try { throw 'test' } catch (e) {
  console.log(e); // test
  var e = 1;
}

标签不能作用与注释语句,因为注释会被解释器忽略,所以标签作用会渗透到下一个语句;同时他不能作用域导入导出、函数或类声明语句,因为这些语句没有可执行的意义。

continue 子句不允许跳转到“当前/外层的单个循环语句起始”之外的地方,所以在循环语句外面加上花括号是会报语法错误的:

// it works !
test: for (i = 1; i < 3; i++) {
  for (j = 5; j < 8; j++) {
    if (j === 6) continue test;
    else console.log(i, j)
  }
}
// SyntaxError !
test: {
  for (i = 1; i < 3; i++) {
    for (j = 5; j < 8; j++) {
      if (j === 6) continue test;
      else console.log(i, j)
    }
  }
}

ES2019 后,try...catch 允许省略 catch 中的 exception 声明部分。

try...finnally 中的结束处理的执行顺序需要注意,它会在 try 中的 return 后以及 try 中的 break 前执行。

模块

模块有几种导入方式:默认导入、名字导入、命名空间导入,其中默认导入能和另外两种组合使用,但是后两者不能混用。

import defaultExport, { toolA } from 'test.mjs'
import defaultExport, * as namespace from 'test.mjs'

其中,名字导入中声明的标识符是本地的名字,但他是通过不可变间接绑定(immutable indirect binding)绑定到了源模块中的名字,所以不能够被修改,修改时会发生类型错误(修改时和常量类似)。

模块载入发生在执行之前装载模块的阶段。JS 使用深度遍历分析模块的依赖关系并递归装载,语法分析阶段只会在导出表建立对应名字的项,而名字与值的绑定要等到执行阶段才能完成,这和 var 声明类似,同时也能说明为什么允许导出值,比如:export default 1+2。

为什么处于严格模式时,用户代码没有办法动态地在模块顶层的命名空间中新增名字或标识符?

严格模式

严格模式使用一段字面量字符串“use strict”开启,它是一种“指示前缀”,当然,带上分号后也可以被称为“字面量表达式语句”。除了指示前缀这种方法,使用宿主的运行参数也可以开启,如“node --use_strict”。此外,ESModule 中的代码默认是以严格模式运行的,所以要小心顶层的 this 了!

“use strict”的位置非常严格,它必须是“第一个”语句,就算它前面有空语句或者其本身在标签语句中也不行。

总的来说,严格模式一共有七条限制:

  • 对象字面量中不能有相同的属性声明
  • 函数实参列表中不能有同名参数
  • 不能声明、重写或删除 eval、arguments 标识符
  • 不允许使用八进制数字字面量
  • 不能删除显式声明的标识符、名字或具名函数
  • 新增了 implements、interface、let、package、private、protected、public、static、yield 这些关键字
  • 禁用 with 语句
  • 禁用隐式声明
  • 禁止扩展不可扩展对象、禁止删除封装对象或冻结对象的属性、禁止删除不可配置的属性或写只读属性
  • 禁止访问 fn.caller 以及 arguments.callee 属性

有两种方法可以在严格模式中以非严格模式运行代码:

  • 间接调用 eval 函数
  • 使用 new Function 构造的新函数

运算符的二义性

某些标点符号的二义性(比如加号和连接运算符)使得引擎无法在语法分析阶段就确定符号的具体作用。

由于加法是值运算,所以涉及到对象加法,那么结果类型便变得不那么可控,这是 JS 中的类型系统饱受诟病的原因之一。这个问题的根源在于 JS 是动态语言,它具有动态类型绑定的特征。

某些语句或在特定语义下会发生类型转换,比如 if、while 语句的括号内的表达式会被隐式转换为布尔值,with 语句内括号中的表达式会被隐式转为对象。

一些语法结构被称为“Cover...”,因为在语法分析时,它包含了两种可能的推断,比如 async() 可以理解为调用 async 函数或是匿名同步函数的开头部分,因此 async () 也被称为 “Cover CallExpression And AsyncArrowHead”。

面向对象语言特性

语法综述

for...in 和 for...of 分别用来遍历对象的成员名和成员值,其中 for...of 不仅仅是用来设计为遍历数组的,所有实现了迭代器接口的对象都可以使用它,Array、Map、Set、String、TypedArray、arguments 等,这些对象也叫做集合(collections)。

从技术上来说,只有在内存中连续布局的才是多维数组,所以把 JS 种这种可以使数组分量指向其它数组的数组叫做“数组的数组”或“交错数组”。

正则中八进制和分组引用是冲突的(\ddd 和 \nn),当发生歧义时,优先理解为分组引用,若找到对应引用才理解为八进制字符匹配。

ES5 严格模式下,字面量中不允出现许同一个属性的名字声明和存取器声明,但这个限制在 ES6 被取消了。为什么要取消,没搞懂。

var obj={
  set test(x){
    c='other';
  },
  get test(){
    return c
  },
  // 经过测试发现,声明按照只有最后一个生效,
  // 比如下面这行会覆盖上面两个属性存取器
  test: 'test'
}

ES6 的 class 本质上是声明构造器的一种方式,因而所谓类继承,其实也是传统原型继承模式的一种表现方式。extends 类似以下代码。

// class A extends B { constructor() { super(/* 传入参数 */) } }
A.prototype = new B(/* 传入参数 */)
A.prototype.constructor = A

使用 class 声明的代码是处于严格模式的,这意味这 extends 声明中的代码也同样会处于严格模式,当然,必须是 extends 字面量声明(正常代码几乎不会这么做)。

类的静态成员方法也可以使用 super.x() 的方式调用,只是 this 会绑定到类的 constructor 上。

使用 Object.getOwnPropertyNames 可以列举对象的内置属性,但是规范只是推荐性质地约定了其中部分属性名,所以具体实现依赖引擎。使用 for...in 可以列举对象的成员名,但是顺序不可控,而使用 for...of 列举对象的成员值其顺序是可控的,只是它是调用对象内置的迭代器。严谨一点可以说 for...of 是列举“集合成员”而不是“对象成员”,假设你给数组对象新增了 'test' 名字的属性,那么 for...of 是不会遍历其值的。

总结一下遍历对象成员的方法:

键名显隐式语法描述
一般键名显式for...in可列举的成员名(包含原型链)
一般键名显式Object.prototype.map、Object.prototype.entries、......
一般键名显式 & 隐式Object.getOwnPropertyNames()所有非符号的自由属性名
符号键名键名显式 & 隐式Object.getOwnPropertySymbols()所有符号键名的自有属性名

非常老的引擎中可能不支持 in 运算符,一种比 objprop 更好的替代方案是 typeof(objprop) !== 'undefined',因为前者会因隐式转换检测不出来某些假值。

delete 运算符有些特殊的地方:

  • 可以删除某些全局属性,比如 window.isNaN,但是如果全局属性是通过 var 声明然后挂载到 window 上那就无法删除了
  • 只在尝试删除不能被删除的属性才返回 false,其他时候,删除一个不存在的属性(删除继承得来的属性)都会返回 true

原型继承

一个对象的继承特性有三种实现方案,基于类、基于原型或是基于元类。JS 使用原型继承实现对象系统,并基于原型继承实现了具备类继承特征的对象系统。

对象的构造过程可以简单地理解为“对原型的复制”,但是复制的时机是一个问题,可以在构造时就完整复制一个新对象,但这样内存消耗过大;类似操作系统动态链接库,它采用了写时复制,也就是在对象发生读操作时直接读原型,发生写操作时再完整复制出一个新对象来,以减轻内存消耗;JS 采用了更细粒度的方法,在每个对象中维护了一个成员表,表中只维护对象的“自有属性”。所以在 JS 的原型继承实现中必须保证:

  • 读对象的属性时优先读取对象的自有属性表
  • 如果没有在自有属性表上找到指定属性,则尝试遍历对象原型,以及原型的原型,直到访问为空或是找到该属性。

也由于自有属性表的存在,在实现原型继承中的属性继承时,子类的属性的元属性如读写性不会继承父类,而是在自有属性表中进行维护。

函数与构造器之间没有明显的界限,唯一的区别只在于原型的 prototype 属性是不是一个有意义的值。ES6 之后的对象方法没有该属性,所以说对象方法也就不能被作为构造器调用。但是很遗憾,没复现,可能哪里理解有偏差:

function test () {}
var a = { testb () {} }
a.testb.prototype = test.prototype
new a.testb() // TypeError

规范里只中找到函数对象对内部方法 [[constructor]] 的描述:没有实现内部构造器方法的函数对象不能作为构造器。

最简单和直观的实现继承的方法是修改构造器的 prototype 属性以维护一个显式的原型链,这种方法也被称为“构造器原型链”。

function Parent () {}
function Child () {}
Child.prototype = new Parent()

不过,由于原型上的 constructor 属性会回指构造函数,所以这种方法暗示 Child 的实例是由 Parent 构造的,显然是错误的,需要进行修改。

// 这种方法动态地修改了原型(有点“野马脱缰”了)
Child.prototype.constructor = Child
// 这种方法叫做圣杯模式
function Parent () {}
function Child () {
  this.constructor = Child
}
Child.prototype = new Parent()

修改原型是一种动态语言独有的特性,代表了继承层次可以“从无到有”的过程,理论上我们可以先构建一个没有任何成员的类属关系的继承系统,然后通过不断地修改原型,从而获得一个完整的对象系统。7

类继承

JS 中的类继承本质上还是通过原型继承实现的。使用类继承时,不仅会维护类和类之间的层级关系,也会维护对象(类也是对象)原型之间的继承关系,后者是 JS 自带的语义。使用原型继承仿制类继承关系的代码类似:

/* 类声明 */
class Parent {}
class Child extends Parent {}
/* 仿制代码 */
function Parent() {}
function Child() {}
Object.setPrototypeOf(Child, Parent)
Object.setPrototypeOf(Child.prototype, Parent.prototype)

类是静态声明,其内部的成员或方法也是声明,也因此不能直接在声明的方法内部直接引用其名字。

super 并不是一个运算符,在规范中其只被称为关键字。另外,new 算然是运算符,但是 new.target 的出现打破了这个认知。

super 有效地解决了调用父类方法的问题,如果不使用 super 关键字,那么只能:

object.prototype.method = function () {
  const thisClass = this.constructor
  const parentClass = thisClass.prototype.constructor
  const parentMethod = parentClass.method
  parentMethod()
}

super 的指向由几个规则所限制:

  • 在类构造器中,super 指向父类构造器,this 指向 new 新创建的实例
  • 在类构造器中,super.xxx 指向父类原型方法 xxx,this 指向 new 新创建的实例
  • 在类方法中,super.xxx 指向父类原型方法 xxx,this 指向调用此方法的实例
  • 在静态类方法中,super.xxx 指向父类方法 xxx,this 指向调用此方法的类
  • 在字面量的方法中,super.xxx 指向字面量的原型方法 xxx,this 指向调用此方法时的 this

总结可以得出规律:

  • super 和 super.xxx 都可以类构造器中使用,但其余情况只能使用 super.xxx
  • 调用 super.xxx 时,this 绑定的为调用方法时的 this 对象

super 也是基于声明时所在的对象来进行计算的,就算把对象方法赋值给了其他方法,其绑定的语义也不会随着调用方不同而转移:

proto = {data: 'test'}
obj = { test() { console.log(super.data) } }
Object.setPrototypeOf(obj, proto)

obj2 = Object.create(null)
obj2.test = obj.test
obj2.test() // 'test'

此外,super 在不同的语义下可能指向父类也可能指向父类原型,是为了在语义上同时支持类和原型继承,这也会带来一些困惑,比如想要在类方法中调用父类静态属性时,使用 super 还是做不到(只能使用 Object.getPrototypeOf(thisClass).constructor.xxx 替代),所以对象方法中缺乏一个重要的语法词汇“parent”来指示当前对象在它类继承链上的父类这种关系。

类继承和原型继承还有一个很不一样的区别:实例生成的时机不一样。原型继承中,实例是由 new 运算符生成的,构造逻辑直接由 new 调用的构造器掌握,而在类继承中,this 实例由 Object 构造,再沿着继承链上的构造器自顶向下执行完成构造过程。所以这引出一条著名的限制:在构造器调用 super 之前,都不能访问 this 实例。

类如果继承了 null,那么语义上来说,没有办法调用 super、super.xxx。如果不更改默认构造器,那么它就是一个“纯静态类”。

class StaticClass extends null {
  static pow () {}
}

P196,这个 new.target.prototype 没看懂。

对象系统

对象系统面对的核心问题是“对象间应该如何组织”。如果有一种规则,能通过把对象之间的联系理清,把对象组织起来,演化为新系统,那么称这个系统是面向对象系统。

JS 的封装能力很弱,在历史上,只能通过变量作用域来封装变化。其变量作用域只有表达式、函数作用域和全局作用域三种,所以只能模拟经典面向对象中的 public 和 private 两种封装状态。近几年来因讨论大热的类的私有属性,在提案中也有因现 JS 变量作用域太弱而提出新的作用域这种解决方案。

多态意味着类型的模糊与类型的确认,在某些语言中,被表达为 as 和 is 两种运算。JS 是弱类型且无类型检测,这意味这类型的模糊是自然而然发生的,对象的多态性被转换为了执行时的动态性。类型的确认可以使用 instanceof 运算符,不过也有小小的缺陷,它无法确认类和类之间的继承关系,这种情况下 isPrototypeOf 方法更像 is 运算。

class A {}
class B extends A {}
B instanceof A // false
A.isPrototypeOf(B) // true

方法即函数类型的属性,根据其所在的属性表的位置,可以分为对象方法和原型方法两种。

this 实在函数执行时动态传入的,规则有三:

  • 当前上下文中的 this(箭头函数和 super、super.xxx)
  • 根据存取运算符将左操作符作为 this 传入
  • 使用 call、apply、Reflect.apply、bind 等方法为函数指定 this

如果函数在调用时得到的 this 值是 undefined 或 null,那么:

  • 如果运行在严格模式中,不改变 this 的值,否则
  • 使用全局对象作为 this 值

在早期对象系统中,所谓 Messages 是指对象的方法,而不是“事件”;在 JS 中,方法是属性存取与函数调用连续运算的效果。从语言层面扔掉“事件系统”的概念,有助于理解引擎实现方面的更多细节。

类抄写和原型继承正好是互补的两种方案:类抄写的成员访问效率更高,内存占用也更高,而原型继承正好相反。JS 选择了原型继承,增加了 Object.assign 以及 JSON.stringify 等方法用来缩减原型继承链,在 ES6 中还新增了 Object.create、Object.setPrototypeOf 等方法开放应用层访问 JS 对象系统的核心结构,以支持灵活的继承关系,这体现了其函数式、动态性的语言特征。虽然 JS 中的类继承基于原型继承实现,但是其核心理念与其存在矛盾:类继承倾向于在基类中实现更基础、更稳定和更通用的对象性质,以减轻子类和最终实例在实现上的负担。面向更轻量的原型继承还是更深层次类继承,是 JS 语言设计摇摆不定的一个主要体现。在使用时具体选择哪一种对象系统风格,在此给出几点建议:

  • 在大型系统中采用类继承,其静态的继承关系及支持静态语法检测等特性可以帮助开发者简化大型系统的开发和业务逻辑的实现
  • 在小型结构或体系的局部中采用原型继承,兼顾优美和灵活的实现

标准规范下的 JS 拥有 38 个内置对象,再加上 Arguments 对象,一并称为原生对象。某些宿主环境会把自己自己提供的对象也叫做“原生对象”,这个“规范原生对象”不是一个东西,需要区分开来。

在 JS 的对象系统中,所有对象同可以在某种程度上通过 global 访问得到,甚至 undefined 也是,仅有 Null 对象的实例 null 字面量不在 global 的范围内。

// 获得所有原生对象、宿主对象等
Object.getOwnPropertyNames(global)

// 验证 undefined 是否存在 global 对象中
Object.getOwnPropertyDescriptor(global, 'undefined') // {value: undefined /* ... */ }

数组本质上与对象没什么不同,处于概念的一致性,我们可以认为数组的实现在各引擎中也不一定是连续存储的。为了解决数组储存的不连续性及元素的不一致性带来性能问题,JS 提供了类型化数组这种集合。

JS 中的结构化数据分两种,一种是类型化数组,通常使用 ArrayBuffer 以及其界面 DataView 来操作数据,另一种是 JSON,可以使用 JSON.parse、JSON.stringify 来操作。需要注意的是,由于 JSON 格式的数据不仅仅只是对象,它还可以包括 number、string、bollean 和 null,所以使用 JSON.parse 时需要判断数据类型。

JS 中的内置对象除了具有在对象系统上的封装、继承、多态之外,还有一些额外的特殊效果。

对象特殊效果
Number、String、Boolean、Symbol包装类({}).toString()
Object调用包装类new Object(5) + new Object(3)
Array自动维护 length 属性
Date日期对象相关的运算
Function创建可执行的函数
RegExp可执行仅在某些宿主中
Proxy代理目标对象、回收代理
TypedArray、DataView创建及绑定 buffer
ArrayBuffer、SharedArray、Buffer初始化 buffer 并维护 byteLength 属性
WeakMap、WeakSet不修改引用并自动回收对象

这些特殊效果被引擎绑定在特定的构造器上,其中大多数可以被类继承继承得到。所以,如果你使用原型继承的方式继承这些特殊效果,是无效的,归根到底是因为使用类创建新的实例时,实例是由基类构造的,所以特殊效果得以在实例中实现。当然,可以改写传统的原型继承代码,以仿制类继承中实例创建的逻辑:

function MyDate(...args) {
  const Base = Date.prototype.constructor
  const instance = Object.setPrototypeOf(new Base(...args), MyDate.prototype)
  return instance
}
Object.setPrototypeOf(MyDate.prototype, Date.prototype)

console.log(new MyDate()) // 会隐式调用 Date.prototype.toISOString()

可定制的对象属性

一般来说,对继承而来的属性赋值会导致对象在自身属性表中新创一个项,有几种情况例外:

  • 在继承来的属性是可配置的情况下,如果继承的属性是不可写属性,那么不会在对象自身新建属性,同时
  • 如果继承的属性不可枚举,那么新属性也不可枚举,同时
  • 如果继承的属性只存在存取描述符,那么无论其读写性,都不会创建新属性

对象有一个内部属性[[Extensible]]用来描述是否可以在其属性表中添加和删除属性,默认是 true。JS 提供了一组方法用于属性表的维护:

Object.xxx 方法方法说明对自有属性表的操作检查方法
preventExtensions(obj)使实例不能添加新属性,也不可重置原型add、delete、updateisExtensible(obj)
seal(obj)使实例不能新增新属性,也不能删除既有属性add、delete、updateisSealed(obj)
freeze(obj)使实例所有属性只读,且不能再添加、删除属性add、delete、updateisFrozen(obj)

由于 isExtensible、isSealed、isFrozen 这几个方法都是动态计算返回的结果,在一些边界情况,结果可能和你想象的不同。isSealed 和 isFrozen 检查所有自有属性表中的属性,并分别确认所有属性的属性描述符 configurable 和 writable 都为 false。

  • 如果使用 preventExtensions 对空对象设置防止扩展,那么此对象同时是密封和冻结的,因为其自有属性表是空的。
  • 存取属性不受 freeze 状态影响,所以 freeze 可以设置属性描述符 writable 为 false,但是存取属性仍能正常运作。
  • 当原型冻结或指定属性只读时,复制运算就会失效,此时只能使用 Object.defineXXX 重新声明属性。

运行期侵入

最早用于运行期侵入的语法元素是 __proto__,它可以让开发人员直接操作对象的原型;valueOf 以及 toString 等方法则被归类到动态语言的特性中去了。

对象在 JS 的内部被描述为具有一些内部槽的结构体,比方说普通对象有 [[Prototype]] 以及 [[Extensible]] 两个槽位,而函数则会多出 [[Realm]] 和 [[ScriptOrModule]] 这两个。某些符号属性可以访问这些内部槽位的,但是限于符号属性总是在对象的自有属性表中维护,而不是作为语言机制(内部槽),所以影响力有限。

对象可以用自己的处理过程来覆盖内部方法,这也就是 Proxy 起作用的原因。Proxy 和内部方法的对应关系如下:

内部方法handler.xxx
[[GetPrototypeOf]]getPrototypeOf()
[[SetPrototypeOf]]setPrototypeOf()
[[IsExtensible]]isExtensible()
[[PreventExtensions]]preventExtensions()
[[GetOwnProperty]]getOwnPropertyDescriptor()
[[HasProperty]]has()
[[DefineOwnProperty]]defineProperty()
[[Get]]get()
[[Set]]set()
[[Delete]]deleteProperty()
[[OwnPropertyKeys]]ownKeys()
[[Call]]apply()
[[Construct]]construct()

使用 Proxy 看起来可以给对象建立一道完美的防火墙,但墙上其实还是有两道裂缝:

  • 一些语言机制会绕过内部方法行动,比方说就算给箭头函数新增带构造器的陷阱,它仍然不能作为构造器使用。
  • 某些方法不具有原子性,比方说调用 [[Set]] 设置属性时实际上会调用 target 和 receiver 以及他们原型上的 [[GetOwnProperty]] 确认属性的描述符。

使用代理替换某些内置对象的原型,可以无侵入式影响运行环境:

function intrudeOnPrototype(Fn, handler) {
  const originPrototype = Object.getPrototypeOf(Fn.prototype)
  const target = Object.create(originPrototype)
  const { proxy: newPrototype, revoke } = Proxy.revocable(target, handler)
  Object.setPrototypeOf(Fn.prototype, newPrototype)
  return () => revoke(Object.setPrototypeOf(Fn.prototype, originPrototype))
}
const recovery = intrudeOnPrototype(String, {
  get: function (target, prop) {
    if (prop === 'test') {
      return 'test'
    } else {
      return Reflect.get(...arguments)
    }
  }
})
console.log(''.test) // test

元编程系统

就看懂了类类型是怎么来的,其余的没看懂...

结构化

概述

按照对计算过程不同的认识产生了不同的计算模型,计算机语言按照不同计算模型可分为:命令式、函数式、逻辑式和面向对象语言四种。

整个命令式语言的发展过程,都与冯诺依曼计算机体系存在直接关系。这种计算机以“储存”和“处理”为核心,而在编程语言中,两者分别被抽象为“内存”和“运算”。所以命令式语言的核心在于“通过运算改变内存(中的数据)”。按照这种方法,我们可以把命令式、函数式、逻辑式和面向对象语言统一为命令式和说明式两种大类。其中,结构化编程和面向对象编程时命令式语言主要的实现手段,也是其演化中的两个阶段。

对结构化的解释包含三个部分:控制结构、组织结构和数据结构,分别相关顺序分支循环,表达式、语句块、包和基本数据结构、复杂数据结构等概念。

面向对象解决了结构化带来诸多问题的三点:

  • 使用更细化的可见性设定解决数据的具体含义和关系绑定问题。
  • 使用继承解决脱离使用环境和算法的结构缺乏通用性的问题。
  • 使用泛型解决类型与逻辑僵化影响了表达的问题。

对象是比结构更高层次的抽象,它绑定了数据、关系以及运算,也潜在描述了它如何支撑整个体系架构与业务逻辑,仍未突破“结构影响算法”的边界。如果把面向对象系统理解为数据、行为和关系的复合体,那么再向上一层的抽象便是接口。接口只暴露数据体的逻辑行为能力,而不暴露这种能力的实现方法和基于的数据特性。

据 Brendan Eich 解释,JS 的语源来自 AWK、C、HyperTalk 和 Self,分别借鉴了其使用关联数组、语法、事件控制页面的思想以及基于原型的对象系统。

基本的组织元素

"一个程序可以看成由一串珍珠组成的项链",如此看来,标识符就是一颗颗珍珠,而语句就是丝线。

元素物理形态静态动态
标识符变量声明、函数声明、类声明非严格模式下的 var 声明、非严格模式下的函数声明
表达式模板字符串值、箭头函数体通过 eval 执行表达式语句
语句.js 文件块和块级作用域eval()
模块.mjs 文件import、export、require()import().then()

总是可以将源代码文本视为由空白字符等隔开的语法记号(Tokens)。Tokens 简单表明他们不存在语义上的假设,而如果要包含语义,则可进一步分为:标识符、标点符号、字面量、模板以及 Invalid Tokens(如保留字)。

表达式由 0~1 个运算符以及至少一个操作数组成,其表达名字与值之间的运算,并返回名字与值。

声明要么用于声明标识符的名字,要么用于声明名字与值的关系。

字面量和初始化器该如何区分?规范希望字面量的表示中不包含运算过程,而 1,2,3 这种语法在引擎看来可以包含运算过程,所以叫做初始化器,换种说法也可以叫做“字面量风格的(数组、对象、函数表达式等)”。

语言的组织元素可以只包括三个基本部分:逻辑的、值的、形式结构的。前两者用于约束一个最小的可计算系统,最后一个用于让这个系统在形式上具有确定性。

数据的可变性称为状态,编程的目的是使系统对外解释内部的状态集合。我们说编程的复杂度,也即该集合的解释成本。与此相关的,在结构化程序设计中提出的分支原则、自顶向下、单入口单出口(SESE)以及包括信息隐蔽在内的结构化基础理论,无一例外地是在控制数据的可变性,进而达到降低系统整体编程复杂性的目的。

在串型编程的结构化理论下,一个基础的控制结构(顺序、分支、循环)只有一个入口和一个出口,无论其状态有多少,逻辑使用只存在一个确定的结果值。当状态和基于状态的正确性都不存在的时候,这意味着我们从系统中抽离了状态、循环以及但入口但出口等前置条件,整个系统从串型的结构化走向了并行的非结构化。

Promise 的语法缺陷在于他让初学者认为 then 以及 catch 都是对源 promise 对象的状态进行处理,然而每个 promise 方法都会立即返回一个新的 promise。

声明

除了 const 和 namespace4 之外的所有语句声明的名字都是使用 MutableBinding 来创建的,字面量风格的值(比如函数表达式)以及严格模式下函数内的 arguments 是个例外。

!(function test() {
  test = 100
  console.log(test) // [Function ...]
  arguments = [] // Uncaught SyntaxError: Unexpected eval or arguments in strict mode
}())

函数声明的函数名和 var 声明采用的是同一作用域机制。规范对一些特殊情况下的函数声明做了补充,也认为它们是在“顶级作用域”做的声明:

// 在 test 的作用域中,x、y、z 名字皆被初始化
function test() {
  {function x () {}};
  test: function y () {}
  if (true) function z () {}
}

语句与代码分块

代码除了声明语句,剩下的内容将用于陈述被组织的元素及其间的结构方法,又或是表达经过上述过程之后的结果值。元素间的结构方法,也被称为代码分块,一般简单语句以及子块组成。这也意味着形式分块是语句的唯一结构方法。被语句组织元素只有:标签声明、标识符声明、表达式和语句。

多重分支语句(switch...)只有一个分块,所有分支都共享这个分块,所以 case 以后不能重复声明变量。

包括 for...in、for...of 等在内的具有声明名字能力的语句,在使用 let、const 声明时,无论 forBody 是不是复合语句,它总会有一个自己的形式分块(loopEnv),声明存在与这个形式分块中;如果是 var 声明,那么声明存在于父作用域,for 语句只是引用它。由于循环会多次执行,但 let、const 无法多次声明,所以规范要求 for 语句为每次的循环创建一个新的环境,iterationEnv5,每次创建时,从上一个环境中复制所有 let、const 神明的变量值到新环境(或者直接覆盖),因此能起到类似闭包的效果。

JS 约定所有的声明必须在语法分析期处理,这意味着 JS 在语言设计的方向,尤其是在结构化的方向上更偏向于实现为静态语言。这方便为以后的类型化、类型推导、预编译、执行器优化等特性做准备。

某些语句执行后无值,比如声明语句、空语句,其他语句按照规则返回特定的值,如果没有之从到任何产生值的语句,则返回 undefined,如:

eval(`if (true) ; else ;`) // undefined

组织形式分块的方法

变量或成员在代码中的可见性区间被称为“作用域”,当作用域是通过静态词法分析得出的时候,它就被称为词法作用域。词法环境是词法作用域的运行期实现。

数据声明语句中存在块级作用域,如:

const x = 1, y = x
console.log(y) // 1

表达式级别的作用域只存在于 eval 调用中。

没有形式分块的单一语句(single-statement)是没有块级作用域的意义的,因此以下语句会报语法错误:

if (1) let x = 1

函数的名字是变量名(varNames)而不是词法名字(lexicalNames),所以在 if 等语句内声明的函数会被提升到外部。

规范对全局对象的成员和操作等做了最基本的约定,但没有完全限制这个对象的创建和使用方法,所以不同宿主环境对全局对象的理解可能不同。例如,在浏览器中全局对象是指 window,而 nodejs 环境下则指 global 对象。

在顺序执行的代码中,我们需要通过一些手段来获得变更程序执行流程的能力,这一能力本质上来说转义为了作用域的变更。一般来说,有以下规则:

  • 词法作用域互不相交。
  • 词法作用域间可以存在平行或包含关系。高级别可以包含低级别,反之则不成立。
  • 高级别流程变更语句可以跨越低级别的作用域。

JS 可以使用 continue、break、return、throw 等语句跳出作用域,但没有实现平级作用域间来回跳转的能力(Pascal 的 goto)。比外,GOTO 语句已被证明是不必要的,且可能会带来危害。

层次结构程序设计

符号有隐匿名字的特性,但它不能更改其可见性。

const method = obj[Object.getPropertySymbols[0]]
method.call(obj)

super 绑定了一段“访问父类”的逻辑,由于父类可以重置,这段逻辑就是多态的。这与“由于子类可以覆盖方法,所以对象是多态的”的基本逻辑是一致的。

变量作用域

变量作用域有三个特殊之处:

  • 严格模式下,eval 中会同时创建词法作用域和变量作用域。
  • 模块和严格模式下的函数一样,其变量作用域是作为词法作用域的一部分来实现的。
  • 全局的变量作用域被映射到了全局对象的属性上,并非一个独立的环境。

和 var 声明的变量在初始化时被置为 undefined 不同,函数声明在初始化时就绑定了函数体。

由 eval 动态创建的全局变量(全局属性)是允许动态删除的(delete)。

私有属性和私有字段的纷争

(_ _)。゜zzZ

函数式语言特性

概述

基于冯诺依曼体系架构设的程序设计语言,必然面对具有储存系统的计算机体系,并依赖储存(如内存)进行运算。这意味着在硬件层面上,冯诺依曼体系亲命令式的程序语言。这一方面产生了类似 JS 这种多范型语言,另一方面产生 JVM 这种能够进行某些函数式运算的虚拟机环境。

函数式语言中的“函数”,应该理解为“Lambda(函数)”,它除了可以被调用之外,还需满足:可作为操作数、可保存数据(闭包)、无副作用这三个性质。

JS中的函数

默认参数、剩余参数和模板参数被统称为“非简单参数”。当函数参数声明中使用了非简单参数时,函数会进入一种特殊模式,带以下状态:

  • 无法显式使用“use strict”切换到严格模式。
  • 不接受重名参数。
  • 形参和 arguments 之间将解除绑定关系。

函数的长度属性依赖参数的性质(简单参数还是非简单参数),而 arguments 只指代函数调用时传入的实参。

function test(a, b = 1) {
  console.log(test.length, arguments.length)
}
test(1, 2) // 1,2

表达式最后返回的是值而不是引用6,所以诸如 (0, a.b)() 这种调用会丢失 this 指向。与此同理,函数调用时传参也是传值,而不是引用,这也是作为非惰性求值的实现结果。

window.x = 1
const a = {
  x: 2,
  b () {
    console.log(this.x)
  }
}
console.log(a.b()) // 2
console.log((0, a.b)()) // 1

具名函数在表达式中时不会声明标识符。

要区分“方法”和“将函数作为对象的属性”。我们说方法因为没有初始化[[Constructor]]内部槽所以不能作为构造器并非指函数属性,比如:

const a = {
  b: function test() {},
  test() {}
}
new a.b() // {}
new a.test() // TypeError

方法的特性总结以下三点:

  • 不能作为构造器。
  • 除了生成器方法,没有内部原型。
  • 方法不能具名。

绑定函数的内部原型与原函数一致,类似于会执行以下代码。也因此如果访问绑定函数原型上的方法没啥问题,但是访问原函数的自有属性就会出错了。

Object.setPrototypeOf(boundFn, Object.getPrototypeOf(targetFn))

此外,绑定函数的 new.target 逻辑丢失,new.target 仍指向原函数。

function a () {
  console.log(new.target === a)
}
console.log(new (a.bind())) // true

class 作为函数调用会抛出运行时错误,由于 class 是在 [[Call]] 内部槽中进行禁止调用处理的,所以 class 的代理对象可以设置 apply 陷阱以拦截并触发正常调用。

如果需要在递归中保持 this 引用,可以使用方法声明加箭头函数的写法:

const obj = {
  step: 0,
  run (init) {
    const exec = x => {
      if (x > 0) {
        console.log(x)
        this.step += 1
        exec(x - 1)
      } else {
        console.log('steps: ', this.step)
      }
    }
    return exec(init)
  }
}
obj.run(10) // steps: 10

函数的行为

诸如 arguments.callee、fn.caller、fn.arguments 等属性是早期规范用于访问执行上下文栈的一种手段,现规范已在严格模式下将其移除。

将调用函数时“持有有效的 this 引用”叫做方法调用。

规范中的引用类型(Ref)由三个部分构成:base、referencedName、strict,分别指代对象所在环境、引用该对象的名字、当前是否处于严格模式。

迭代器界面中的 return 和 throw 需要外部代码负责,如:

function* GetThisDone() {
  try {
    yield 1
    yield 10
    yield 1
  } finally {
    console.log('done')
  }
}
const get = GetThisDone()
let val
while (val = get.next().value) {
  if (val < 10) {
    console.log('right val:', val)
  } else {
    console.log('wrong val')
    get.return()
  }
}
// right val: 1
// wrong val
// done

闭包

模块和全局脚本不能被实例化,而函数可以。函数实例运行期间生成的词法环境结构,也就是闭包;它也可看作执行期的作用域链,因为其外部引用指向函数实例被调用时的作用域。

函数执行的大致步骤是:用户代码调用 -> 创建环境(Environment) -> 绑定 this -> 执行代码(EvaluateBody = 初始化闭包 + 执行用户代码(Evaluating))。

  • 在创建环境时,会将标识符列表指向函数实例所在作用域。这在概念上而言就是初始化的闭包;闭包本质上是一个对作用域的引用。
  • 标识符列表包含变量环境以及词法环境,两者指向根据是否是严格模式有所不同。严格模式下的 eval 作用域块中有自己的 varDecls,所以严格模式下的函数内不需要同时维护变量环境和词法环境了,作用域引用指向词法环境,后者再包含指向变量环境的引用。
  • 除了绑定 this 以及处理标识符列表,执行代码时还会处理函数实例内的顶层函数列表、参数列表以及 arguments 参数。参数列表中的标识符优先级最高。

由于闭包中不包含函数声明的名字,所以这个名字类似 var 声明是允许重绑定的:

function test () {
  test = 1
}
test()
console.log(typeof test) // number

全局环境中,使用 [[VarNames]] 内部列表来区分全局属性或全局 var 声明,不使用 eval 动态添加到全局的变量是不能被删除的。

对象闭包(对象环境的运行时结构)所包含的只有对象的成员名,无论该成员名是否是动态加入的。

let a = { value:1 }
let b = 1
with (a) {
 var value = 100
 a.b = 1
 b = 2
}
console.log(a, b, value) // { value:100, b:2 }, 1, undefined

块级作用域所创建的环境在语句执行结束后被销毁。

while、do...while 语句在部分引擎实现中会被转换为 for 语句,所以其环境会创建多次。

在纯函数式语言中,一个比包内构造的对象只能被自己持有,或者通过函数返回一遍被其他闭包引用,也就是说函数和闭包因为总能在确知的情况下被销毁,这有利于内存回收。而 JS 的函数允许副作用,所以只能通过引用计数的形式回收内存。

闭包内标识符系统的优先级规则:

  • 内部函数声明 > 函数参数名列表
  • 参数中的 arguments 名字 > 函数的 arguments
  • var 声明的名字如果已存在,则不再创建新变量

this 是作为关键字来识别和处理的,所以对象闭包中对象的 this 属性不会影响 this 的执行。

动态语言特性

概述

如果在陈述时无法确定而必须在执行时才能确定语义,那么该语言是动态的,反之为静态的。列如,对于 a+b,在运行时才能确定他是字符串连接还是数值求和。

JS 的动态语言特性概括为:动态类型、动态重写、动态存取的数据结构、动态的变量作用域、词法作用域以及动态执行能力。

对象与值类型之间的转换

with 语句以及 for...in/of 语句会触发值类型的“包装”,可以理解为是语句的副作用。诸如此类的副作用还见 while、if 语句对表达式转换为布尔值的语义、对象声明时属性需要是字符串。

var toString = () => console.log(1)
with (1) {
  // equal to (1).toString = ...
  toString = () => console.log(2)
}
console.log(toString()) // 1

在其它场景下,值类型的隐式包装一般由成员存取符触发。

在包装类原型上的 valueOf 和 toString 方法不影响值运算:

String.prototype.valueOf = () => 0
console.log(+'1') // 1
console.log(+new Object('1')) // 0

任何对象在布尔运算中(包括使用 Boolean 构造器强制类型0p转换)都被当成对象,只有在值运算中才会使用隐式转换的规则:

const f = new Object(false)
console.log(+f) // 0
console.log(!!f) // true
console.log(Boolean(f)) // true

使用 Symbol.toPrimitive 的优先级高于 valueOf 和 toString 方法,不过不影响后两种方法的直接调用,比如,NodeJS 的 console.log 会直接调用对象的 valueOf 方法。

值类型的转换

引用类型自身并不参与值运算,它在运算中仅:标识值数据、提供存取值数据规则、传递标识,因此在运算系统中,引用到值以及从值到值的运算才是主要目标。

switch 对表达式采用的比较算法是类似严格相等运算的算法,会优先进行类型检测而不会发生类型转换过程。

使用 Number 构造器进行显式转换要比使用 parseInt、parseFloat 方法好,后两种方法预期传入的内容是字符串,无论参数类型都会先将其转换为字符串,这会导致某些问题:

parseInt(1e35) // 1

因为 undefined 意味着“缺失”,所以某些情况下将其转换为字符串的语义显得有些奇怪:

String() // ''
String(undefined) // undefined

对象与数组的动态特性

JS 中的数组在实现上和对象是一致的,所以它表现出关联数组而不是索引数组的特征,并不是连续存储,而且下标可能出现空洞。访问空洞下标可以得到值 undefined,但空洞本身类似对象自有属性表中一个不存在的属性,只要数组对象原型上的属性表也不存在这个属性,空洞下标就不会被任何数组方法处理:

const a = new Array(10)
a[1] = 1
a[4] = 1
a[7] = 1
a.sort(() => Math.random() - 0.5) // [1, 1, 1, empty x 7]

视图是类型化数组绑定的数据的一组接口,类型化数组使用视图绕过了 JS 内置的数据类型转换规则,也绕过了对 length 和数组下标作为对象属性在 JS 中的一切限制。

类数组对象最重要的两个属性是 length 和 Symbol.iterator,分别给与它被大部分 Array 原型方法(以及展开运算符)以及被当作集合对象操作的能力。

重写

let 与 const 声明的标识符的延迟绑定,也就是说标识符在语法分析期是没有类型和值的,类型和值要推迟到运行期才能决定;而将值赋给标识符这个过程也就是绑定。规范中,在环境构建过程中,从“标识符名”到“环境中可访问的标识符”叫做创建绑定,let 和 const 分别对应可变绑定和不可变绑定。也就是说 const 的不可写性质是语义决定的。此外,var x = 0 和 const x = 0 中 x = 0 的语义分别是赋值表达式以及绑定也好理解了。

因为历史原因,undefined 在非严格模式下是可写的,只是在新的浏览器中值不会改变。严格模式下,arguments 和 undefined 都是不可写的,但是前者是以“声明环境记录”登记到词法环境的,语义上的不可写,后者则是作为 global 对象上的属性,根据属性描述符决定的不可写性质。

限制标识符动态重写的主要相关两个概念:引用类型以及绑定的不变性,通常分别对应运行时的引用错误与类型错误;限制对象属性重写主要是属性描述符,其内部使用内部方法[[Set]]来重写值,但是此内部方法用代理与反射跳过:

const a = Object.defineProperties({}, {
  test: {
    value: 100,
    configurable: true
  }
})
a.test = 1
console.log(a.test) // 100
const b = new Proxy(a, {
  set (target, key, value) {
    if (key === 'test') {
      // 仅当该属性的 configurable 为 true 才能成功
      return Reflect.defineProperty(target, key, { value })
    } else {
      return Reflect.set(target, key, value)
    }
  }
})
b.test = 1
console.log(b.test) // 1

自增自减运算符会隐式的转换操作数的类型。

类声明的 extends 部分是执行时期的语义,而执行这段代码时类还未完成初始化,所以以下语句会报初始化错误:

class a extends a {}

由于 global 对象可视为从 Object 构造器中构造出来的对象,所以修改 Object.prototype 也会带来变量声明之类的效果。

for...of 和 for...in 的行为不一样,for...in 在执行时会一次性取出对象的属性表,所以对动态加入的属性不敏感;for...of 中,迭代器使用的是索引值,并且迭代次数动态根据 length 属性改变,所以能枚举到某些动态加入的值。

类似 this、super 以及 new.target 虽然也能作为标识符或者对象属性中的某部分访问,但是在单独使用时,引擎会将它们作为特定的语法结构解析,也因此不能作为标识符使用。比如在 with 语句中,单独使用 this 并不能访问到对象的 this 属性。同理,delete、yield、void 会被解析为运算符;true、false、null 会被解析为操作数;但有特殊情况:

with (a = { undefined: 'test' }) {
  delete undefined
}
console.log(a) // {}

动态绑定

间接调用 eval 的 this 引用指向全局对象,同时还处于非严格模式。间接调用可能包括:

function indirect() {
  const exec = eval
  const getEval = () => eval
  // 单值表达式
  exec('console.log(this === globalThis)')
  // 函数返回
  getEval()('console.log(this === globalThis)')
  // 携带逗号运算符的分组运算符
  ;(0, eval)('console.log(this === globalThis)')
  // eval.call 等方法
  eval.call((), 'console.log(this === globalThis)')
}
indirect.bind({})()

有个看起来比较像特殊情况的是分组运算符中不携带逗号运算符的例子,比如:(eval)('')。此时返回最后一个表达式的 Result。如果携带逗号运算符,表达式最终返回 Value,所以这时又类似间接调用。

除了间接调用 eval 函数,在全局代码块顶层直接使用 eval 时,其 this 引用指向全局对象。

x = 100
const obj = { x: 200 }
with (obj) { 
  eval(console.log(x, this.x)) // 200 100
}

动态创建的函数作用域总是全局,且除非指定 “use strict”,不然它总是默认以非严格模式执行。

动态方法调用

从 ES6 开始,函数和方法的一个显著区别在于有没有有效的 this。

apply 函数的第二个参数可以使用数组或者类数组对象,所以在大多数引擎中他们的效率要比 call 方法稍高。

早期的 NodeJS 中的 console.log 方法以及 Firefox 中的 document.writeLn 依赖隐式的 this,如果没有绑定到正确的 console 或 document 上就会报错。

使用 bind 函数后得到结果的并不是新的函数,只是没有 prototype 属性(在部分版本的 V8 的是线上有 Bug),可以使用以下代码验证:

function Test() {}
TestAnother = Test.bind({})
console.log(new Test() instanceof TestAnother) // true
console.log(test2.prototype) // undefined

严格模式禁用 arguments.callee.caller 和 fn.caller 的理由是通过函数的栈可以访问到上层函数并经由 arguments 的引用修改部分参数、改变参数长度,甚至使用 sort 方法重排序传入参数。

通用执行环境的实现

跳过。

勘误?

  • P71,属性读取器
  • P77,逻辑与、按位非
  • P107,catch 子句隐式声明
  • P134,第二段代码,computedName 括号
  • P148,ES8
  • P179,MyObject() 有没有必要用括号
  • P206,语言仅提供了...能力而已
  • P252,注2 的位置,以及应当标明加粗部分是 handler.xxx 和内部方法不同名的部分
  • P258,setPrototype(..., atom),同 P259 的 setPrototype,没看懂
  • P424,最后一行,存取运算符(.)的优先级高于不带参数表的 new 运算符
  • P536,表格最后一行 String
  • P535,存取描述符
  • P564,运算符

Footnotes

  1. “第一类”也就是 first-class,类似“一等公民”,意味有着不能分割、不能被重述的概念。
  2. 在 ES6 之前,JS 被称为“基于对象语言”,而当其支持 class、super 等关键字后,被称为“支持类继承的面向对象系统”。
  3. 在赋值语句中字符串是个例外,按照引用类型处理。
  4. namespace 用来记录一个引擎中所有导出的名字。
  5. for 语句中 iterationEnv 的父级指向 loopEnv,而 for...in、for...of 等语句的 iterationEnv 父级直接指向其所在的块。
  6. 引用是指规范中的 Reference,取值是指规范中的 getValue()。

Copyright © 2024 Lionad - CC-BY-NC-CD-4.0