likes
comments
collection
share

JS 语言设计十问十答

作者站长头像
站长
· 阅读数 18

概述

本文希望能有在一些细节点上分析JS语言的设计。在这个过程中体会JS这门语言的背后的设计思想。

正文

1. For块级作用域问题

for 循环本身没有形式分块,虽然看起来是被 {} ( JavaScript 不存在语句级别的变量作用域) ,但是当使用 let/const 时无论你是不是自己写 {} 都会有形式分块,其实是在独立的环境里登记了标识符。但如果是 var 声明,则被独⽴登记在外层,for 语句只是引⽤它。

看下面这段代码:let 声明看起来被重复执行,为什么可以这样写呢

for (let x of [1, 2, 3]) {
  ...
}

因为块级作用域上面要记录变量,要记录变量就需要实例化,块语句的实例化环境是在它被执⾏时才构建的。为此, for 循环会创建一个新环境,确保每次声明一个新的 x ,也就是说:for / while 等语句的循环体—作为块语句—是会被 多次初始化的,也就意味着所有的 let / const 声明将被重新初始化。

下次循环变量时,应该是上一次循环结束时的值,所以这些环境必须首尾连接去取上次的值,这就意味着历史环境无法自由释放。JS 采取的是直接复制,但是还是会比没有形式分块的性能低不少。let / const 前我们是用IIFE解决这类问题,因为 IIFE 也是通过每次实例化一个新的环境来解决的,所以 let / const 本质并没有减少性能的开销。

为什么 for 循环要为 let / const 创建块级作用域呢?这是因为使用 const / let 时 for 循环有了形式分块了,这是为了可以从父级块去引用到变量,也就是说让声明在 for 循环中的 let 有意义。

for...of、for...in 都是一样的、包括 while(很多引擎中 while 循环的内部是用 for 循环实现的)

2. 函数式语言中是通过函数递归来实现的,那为什么有了循环又创建出了可迭代对象呢?

这是 JS 中一种特定的循环逻辑:“计算”是函数式语⾔的核⼼,⽽约定“可计算对象”就是这个核⼼中最先设置的条件。为了表达如何循环处理“可计算对象” ,JavaScript 设计了⼀种迭代的函数行为,⽽ JS 的可计算对象就称为可迭代对象。 任何 JavaScript 的对象都可以表现为可迭代对象只要该对象的内部槽 [[Iterator]] 中填写了⼀个有效的迭代⽅法即可。

typeof (new Array)[Symbol.iterator]
'function'

3. 闭包与实例化环境的实现

JavaScript 中⽤“闭包”来指代⼀个函数实例在运⾏期的作⽤域。其初始信息是引擎在 处理调⽤运算符“()”的时候,由⼀个内部阶段来构建的。闭包内的初始信息就是函数代码体中的那些声明。由于这⾥函数已经被调⽤,因此这个构建的结果将体现在调⽤上下⽂的环境中,闭包是函数实例在环境中的⼀个复制的映像,闭包是动态的,函数实例是静态的。

事实上所有 4 种可执⾏结构都存在“实例化”这⼀过程。 我们发现不能在⽤⼀个变量名来控制加载的模块名,这是因为模块实例化的顺序是深度遍历的,叶⼦节点是优先完成实例化的。 全局环境先建立,但其中的代码执⾏是更晚的,因此模块的实例化必须不能依赖任何由⽤⼾ 代码创建的全局变量。模块是不可能访问到父模块初始化的内容的。这并不是 JavaScript 的语法禁⽌这种⾏为,⽽是在处理 import / export 声明时,全局中任何的变量都未被赋值。即使是在当前模块中,可执⾏代码也是晚于实例化⾏为的,所以也不能在 import / export 声明时使⽤模块内的变量。

4. 数组如何实现?

JS 中的数组除了可以使用数字类型的下标来存取,也完全可以使⽤数值字符串作为下标来访问数组成员,但这时在语义上却有所不同。这种情况下是将数组作为对象来进⾏“名-值”存取的。 JavaScript 中的数组既是⽤下标存取的索引数组,也是⽀持属性存取的 关联数组。因此,在将数组视为普通对象并⽤ for...in 语句列举时,是可以列举到那些数值的索引下标的。 不但数组可以作为对象使⽤,反过来,某些对象也可以⽤作数组,这称为类数组对象。 所有数组都是可迭代对象,但类数组对象却不⼀定可迭代,但是可迭代对象也不⼀定都是数组。

5. 为什么JavaScript总是尽量⽤数字值⽐较来实现等值检测?

这主要 是因为 JavaScript 内部的数据存储格式适合这⼀操作。同样的原因,字符串检测通常会存在⾮常⼤的开销。严格来说,必须对字符串中的每⼀个字符进⾏⽐较。这也是 null 与 undefined 总是相等的原因。

比如: 下面这段代码要比较六次才能得到 true

console.log(‘asanas’ ==  ‘asanas’) 

6. 为什么要设计 null ?

null 是为了未来会赋值但还没有赋值的对象占位用的,所以 null 具备对象的一切特性,因此可以使⽤对象的内置属性 和⽅法( toString 、 valueOf 等),⽽且 instanceof 运算也会返回 true ,甚至可以使用 for...in 语句,因为在默认情况下,它只有原⽣对象的⼀些内置成员。⽽ for...in 语句并不列举它们,所以空⽩对象在 for...in 中也并不产⽣任何效果。

console.log(typeof null)
'object'

for (var n in null) {
    ...
}
// 可以执行

7. 原型链的实现机制是什么?

这其实是因为原型的复制机制的取舍。原型其实就是构造器用于生成实例的模板,JS所谓的继承性其实就是继承原型对象的属性和方法。

  1. 构造复制:内存空间急剧消耗
  2. 写时复制: obj1obj2 等同于他们的原型,读取不复制,写时复制,但是对于经常需要写操作的系统来说,这种方法并不经济。
  3. 读遍历:把复制的粒度从整个原型变成了成员,写时才将成员的信息复制到实例的映像上。这时我们发现,obj 仍然是⼀个指向原型的引⽤,内存开销比较经济。这就引出了原型链和自有属性表的概念。那么其实 JS 提供的Object.getOwnPropertyDescriptor()Object.getOwnPropertyNames() 方法就是访问自有属性表的。但是原型继承中的写复制在“引用类型”和“值类型”的表现不一样(复制引用时所有实例都只想同一个引用)。这意味着必须保留一个构造过程,出现了 Object.assign() 这样的方法。

面向对象决定了子类与父类具有相似性,原型继承决定了相似性是在构造时决定的(请看上例), 为了保证子类和父类一定具有相似性,所以约定对象实例必须持有原型。JS的对象系统的一个特别的好处是可以在实例构造后动态修改,这也是动态语言和面向对象继承的交汇点。

8. 为什么在类的构造方法中,不能在 super() 前使用 this 引用?

我们知道,如果没有写构造方法,JS 会默认添加:

Constructor(…args) { 
    Super(…args) 
} 

原型继承,子类依赖父类,所以父类的创建一定在子类之前。但是类继承不同,new Ex() 时,从 Ex 开始并上溯至基类,调用每个构造器(基类一定是 Object ),上溯的过程中每个类的构造方法都先调用 super() 回溯原型链,才能确保基类最先被创建。

Var obj = new Object; 
GrandmaEx.call(obj) 
ParentEx.call(obj) 

所以不能在 super() 前使用 this 。也就是说,这就是⼀个链式的函数调⽤和返回的效果。

但如果是非派生类就不可以调用 super (但具有事实上的 super ),这其实是因为 JS 的一项传统设计:new 运算会为构造器创建一个对象作为 this 引用。也就是说这个 this 还没进入构造方法就已经创建好了,我们甚至可以在构造函数中自定义 this 对象。

在 new 之前我们其实是使用 Object.create()Object.setPrototypeOf() 去操作原型,我认为是 JS 对旧设计的修补,也是一种不太好的设计,向用户开放了对象系统的核心结构。

但是继承树的顶端呢?

  1. 如果没有构造⽅法,则默认的构造⽅法将会因为调⽤ super() 时访问到 null ⽽抛出异常
  2. ⽤⼾代码可以定制构造⽅法,例如可以使⽤ Object.create() 来创建实例。

所以 this 其实可以有两种来源,一是通过 super 上溯,二是手动在构造方法里返回,这也是为什么我们可以在构造方法里动态创建 this 的原因。

9. JS中如何体现多态?

多态需要类型的模糊。JS 是弱类型的,例如 typeof 要么返回对象要么返回非对象,因此 JS 本身类型就是模糊的,我们也可以动态添加属性使其变得像某种对象,比如我们会通过判断是否具有 then 方法判断这个对象是否是 Promise ,这也就是“鸭子类型”。

if ( typeof promise?.then === 'function' ) { 
    // 那么这个值为 thenable 
}

10. 对于属性的操作内部是如何实现的?

我们知道对象其实就是属性方法的集合,对于对象来说,属性的描述符与本身的作用无关,主要就是为了描述与集合的种种关系,生命一个对象,本质上就是为所有属性设置描述符。

属性描述符:

  • 数据描述:Value \ writable

  • 性质描述:enumerable \ configurable

  • 存取描述:get \ set(读写时调用)

以赋值为例:

其⼀,如果⼀个属性是不存在的,那么将隐式地创建⼀个数据描 述符,且 WritableEnumerableConfigurable 均为 true 值。然后将赋值 操作的值填⼊ Value 中。

其⼆,如果⼀个属性是当前对象⾃有的,那么赋值操作将变成更 新已存在的数据描述符。在这种情况下,只要 Writable 不是 false ,那么值会被填⼊ Value 中,否则什么也不做。

其三,如果是继承来的属性,会重新在子类中创建,初始化属性描述符,这也就是原型链继承的机制:读遍历。

其四,如果⼀个属性使⽤的是存取描述符,那么⽆论它的读写性质是什么,都会调⽤读写器。

对象内部槽 [[extensible]] 会影响属性表的行为.

属性表的状态:

  1. preventExtensions:不能添加新属性也不能重置原型(同时Seal和freeze)
  2. Seal: 不能添加新属性也不能删除既有属性
  • [[extensible]] = false

  • configurable = false(列举所有自有属性并将 configurable 设为 false

  1. freeze:使所有属性只读,且不能再添加、删除属性
  • [[extensible]] = false

  • configurable = false

  • writable = false

也就是说:

  1. 赋值时,检查 writable 属性
  2. 修改和删除属性时检查 configurable 属性
  3. 创建属性或覆盖父属性时又需要创建属性描述符,而创建属性描述符时又会检查 [[extensible]]

最后

关注公众号「Goodme前端团队」,获取更多干货实践,欢迎交流分享~