// Generic prototype for all letters.
let letter = {
getNumber() {
return this.number;
}
};
let a = {number: 1, __proto__: letter};
let b = {number: 2, __proto__: letter};
// ...
let z = {number: 26, __proto__: letter};
console.log(
a.getNumber(), // 1
b.getNumber(), // 2
z.getNumber(), // 26
);
class Letter {
constructor(number) {
this.number = number;
}
getNumber() {
return this.number;
}
}
let a = new Letter(1);
let b = new Letter(2);
// ...
let z = new Letter(26);
console.log(
a.getNumber(), // 1
b.getNumber(), // 2
z.getNumber(), // 26
);
注: ECMAScript 中基于类的继承是在基于原型的代理之上实现的。
注: 一个“类”只是理论上的抽象。技术上来说,它可以像 Java 或 C++ 一样通过静态分配来实现,也可以像 JavaScript、Python、Ruby 一样通过动态分配(代理)来实现。
function Letter(number) {
this.number = number;
}
Letter.prototype.getNumber = function() {
return this.number;
};
let a = new Letter(1);
let b = new Letter(2);
// ...
let z = new Letter(26);
console.log(
a.getNumber(), // 1
b.getNumber(), // 2
z.getNumber(), // 26
);
一个对象环境记录的例子就是全局环境记录。这种记录也有相关联的绑定对象,它可以存储记录中的一些属性,而不是全部,反之亦然(译者注:不同的可以看下面的示例代码)。绑定对象也可以通过 this 得到。
// Legacy variables using `var`.
var x = 10;
// Modern variables using `let`.
let y = 20;
// Both are added to the environment record:
console.log(
x, // 10
y, // 20
);
// But only `x` is added to the "binding object".
// The binding object of the global environment
// is the global object, and equals to `this`:
console.log(
this.x, // 10
this.y, // undefined!
);
// Binding object can store a name which is not
// added to the environment record, since it's
// not a valid identifier:
this['not valid ID'] = 30;
**加粗文字**
console.log(
this['not valid ID'], // 30
);
上述代码可以表示为下图:
需要注意的是,绑定对象的存在是为了兼容遗留的结构,例如 var 声明和with 语句,它们也将它们的对象作为绑定对象提供。这就是环境被表示为简单对象的历史原因。现在,环境模型更加优化,但其结果是,我们无法再将绑定作为属性访问(译者注:如上面的代码中我们不能通过 this.y 访问 y 的值)。
let x = 10;
function foo() {
console.log(x);
}
function bar(funArg) {
let x = 20;
funArg(); // 10, not 20!
}
// Pass `foo` as an argument to `bar`.
bar(foo);
对于函数 foo 来说,x 是自由变量。当 foo 函数被激活时(通过
funArg 参数) - 应该在哪里解析 x 的绑定?是创建函数的外部作用域还是调用函数的调用者作用域?正如我们所见,调用者即 bar 函数,也提供了 x 的绑定 - 值为 20 。
函参问题的第二个子类型被称为 upward funarg problem 。它们之间唯一的区别是捕捉环境的生命周期比创建它的环境更长。
我们看例子:
function foo() {
let x = 10;
// Closure, capturing environment of `foo`.
function bar() {
return x;
}
// Upward funarg.
return bar;
}
let x = 20;
// Call to `foo` returns `bar` closure.
let bar = foo();
bar(); // 10, not 20!
this 主要的用例是基于类的 OOP。一个实例方法(在原型上定义)存在于一个范例中,但在该类的所有实例中共享。
class Point {
constructor(x, y) {
this._x = x;
this._y = y;
}
getX() {
return this._x;
}
getY() {
return this._y;
}
}
let p1 = new Point(1, 2);
let p2 = new Point(3, 4);
// Can access `getX`, and `getY` from
// both instances (they are passed as `this`).
console.log(
p1.getX(), // 1
p2.getX(), // 3
);
// Generic Movable interface (mixin).
let Movable = {
/**
* This function is generic, and works with any
* object, which provides `_x`, and `_y` properties,
* regardless of the class of this object.
*/
move(x, y) {
this._x = x;
this._y = y;
},
};
let p1 = new Point(1, 2);
// Make `p1` movable.
Object.assign(p1, Movable);
// Can access `move` method.
p1.move(100, 200);
console.log(p1.getX()); // 100
作为替代方案,mixin 也可以应用于原型级别,而不是像上例中每个实例做的那样。
为了展示 this 值的动态性,考虑下面例子,我们把这个例子留给读者来解决:
function foo() {
return this;
}
let bar = {
foo,
baz() {
return this;
},
};
// `foo`
console.log(
foo(), // global or undefined
bar.foo(), // bar
(bar.foo)(), // bar
(bar.foo = bar.foo)(), // global
);
// `bar.baz`
console.log(bar.baz()); // bar
let savedBaz = bar.baz;
console.log(savedBaz()); // global
因为只通过查看 foo 函数的源代码,我们不能知道它在特定的调用中 this 的值是什么,所以我们说 this 值是动态作用域。
注: 您可以在这篇文章中得到关于如何确定 this 值的详细解释,以及为什么上面的代码是那样的结果。
箭头函数 中 this 值比较特殊:其 this 是词法的(静态的),而不是动态的。即他们的函数环境记录不提供 this 值,它是从父环境中获取的。
var x = 10;
let foo = {
x: 20,
// Dynamic `this`.
bar() {
return this.x;
},
// Lexical `this`.
baz: () => this.x,
qux() {
// Lexical this within the invocation.
let arrow = () => this.x;
return arrow();
},
};
console.log(
foo.bar(), // 20, from `foo`
foo.baz(), // 10, from global
foo.qux(), // 20, from `foo` and arrow
);
// Enqueue a new promise on the PromiseJobs queue.
new Promise(resolve => setTimeout(() => resolve(10), 0))
.then(value => console.log(value));
// This log is executed earlier, since it's still a
// running context, and job cannot start executing first
console.log(20);
// Output: 20, 10
async function later() {
return await Promise.resolve(10);
}
(async () => {
let data = await later();
console.log(data); // 10
})();
// Also happens earlier, since async execution
// is queued on the PromiseJobs queue.
console.log(20);
// Output: 20, 10
// In the `index.html`:
// Shared data between this agent, and another worker.
let sharedHeap = new SharedArrayBuffer(16);
// Our view of the data.
let heapArray = new Int32Array(sharedHeap);
// Create a new agent (worker).
let agentSmith = new Worker('agent-smith.js');
agentSmith.onmessage = (message) => {
// Agent sends the index of the data it modified.
let modifiedIndex = message.data;
// Check the data is modified:
console.log(heapArray[modifiedIndex]); // 100
};
// Send the shared data to the agent.
agentSmith.postMessage(sharedHeap);
worker 的代码如下:
// agent-smith.js
/**
* Receive shared array buffer in this worker.
*/
onmessage = (message) => {
// Worker's view of the shared data.
let heapArray = new Int32Array(message.data);
let indexToModify = 1;
heapArray[indexToModify] = 100;
// Send the index as a message back.
postMessage(indexToModify);
};
文章其他语言版本:俄语
这篇文章是 JavaScript. The Core 演讲的第二版,文章内容专注于 ECMAScript 编程语言和其运行时系统的核心组件。
面向读者: 有经验的开发者、专家
文章第一版 涵盖了 JS 语言通用的方面,该文章描述的抽象大多来自古老的 ES3 规范,也引用了一些 ES5 和 ES6( ES2015 )的变更。
从 ES2015 开始,规范更改了一些核心组件的描述和结构,引入了新的模型等等。所以这篇文章我将聚焦新的抽象,更新的术语和在规范版本更替中仍然维护并保持一致的非常基本的 JS 结构。
文章涵盖 ES2017+ 运行时系统的内容。
我将从对象的概念开始讲起,它是 ECMAScript 的根本。
对象
ECMAScript 是一门面向对象、基于原型进行组织的编程语言,且它的核心抽象为对象的概念。
我们来看一个基本的对象示例。对象的原型可通过内部的
[[Prototype]]
属性引用,在用户代码层面则是暴露在__proto__
属性上。代码如下:
上面的对象有两个显式的属性和一个隐藏的
__proto__
属性,它是 point 对象的原型引用:原型对象用于实现动态分配机制的继承。我们先思考一下原型链概念,以便详细了解这个机制。
原型
所有对象在创建的时候都会得到原型。如果没有显式地设置原型,那么对象接收默认原型作为它们的继承对象。
原型可以通过
__proto__
属性或Object.create
方法显式的设置。任何对象都可作为其它对象的原型,且原型本身可以有原型。如果对象的原型不为
null
,原型的原型不为null
,以此类推,这就叫做原型链。规则非常简单:如果对象自身没有一个属性,就会试图在原型上解析属性,然后原型的原型,直到查找完整个原型链。
技术上来说这个机制被称为动态分配或代理。
如果属性最终都没有在原型链上找到的话,那么返回
undefined
值。从上面的代码可以知道,一个默认的对象实际上永远不为空--它总是从
Object.prototype
继承一些东西。如果想要创建一个无原型的字典(dictionary),我们必须明确地将原型设为null
:动态分配机制允许继承链完全可变,提供修改代理对象的能力:
从上面
Object.prototype
示例我们知道同一个原型可以给多个对象共享。从这个原理出发,ECMAScript 实现了基于类的继承。我们看下示例,并且深入了解 JS 的 “类(class)” 抽象。类
当多个对象共享同一个初始的状态和行为时,它们就形成了一个类。
假如我们需要多个对象继承同一个原型,我们当然可以创建这个原型并显式的继承它:
我们可以从下图看到这些关系:
然而这明显很繁琐。类抽象正是服务这个目的 - 作为一个语法糖(和构造器在语义上所做的一样,但是是更友好的语法形式),它让我们使用更方便的模式创建那些对象:
技术上来说一个“类”表示“构造函数 + 原型”的组合。因此构造函数创建对象并自动设置新创建实例的原型。这个原型存储在
<ConstructorFunction>.prototype
属性上。我们可以显式的使用构造函数。此外,在类抽象引入之前,JS 开发者过去因为没有更好的选择而这样做(我们依然可以在互联网上找到大量这样的遗留代码):
创建单级的构造函数非常简单,而从父类继承的模式则要求更多的模板代码。目前这些模板代码作为实现细节被隐藏,而这正是在我们创建 JavaScript 类时在底层所发生的。
我们看一下对象和它们的类的关系:
上图显示了每个对象都有一个关联的原型。就连构造函数(类)也有原型也就是
Function.prototype
。我们看到 a、b 和 c 是 Letter 的实例,它们的原型是Letter.prototype
。你可以在文章 ES3. 7.1 OOP: The general theory 中找到关于 OPP 通用概念(包括基于类、基于原型等的详细介绍)的详细讨论。
现在我们已经了解了 ECMAScript 对象间的基本关系,让我们更深入的了解 JS 运行时系统。我们将会看到,几乎所有的东西都可以用对象表示。
执行上下文
为了执行 JS 代码并追踪其运行时的计算,ECMAScript 规范定义了 执行上下文(execution context) 的概念。逻辑上执行上下文是用 栈 来保持的(执行上下文栈我们一会就会看到),它与 调用栈(call stack) 的通用概念相对应。
ECMAScript 代码有几种类型:全局代码、函数和
eval
;它们都在各自的执行上下文中运行。不同的代码类型及其适当的对象可能会影响执行上下文的结构:例如,生成器函数(generator functions) 会将其 生成器对象(generator object) 保存在上下文中。我们看一个递归函数调用:
当一个函数调用时,就创建一个新的执行上下文并把它压入栈 - 这时它就成了活跃的执行上下文。当函数返回时,其上下文就从栈中推出。
我们将调用另一个上下文的上下文称为调用者(caller)。被调用的上下文因此就叫做被调用者(callee)。在上面的例子中,
recursive
函数同时承担着上述两者角色:调用者和被调用者 - 当递归地调用自身。在上面的例子中,我们对栈有“压入-推出”的修改:
我们可以看到,全局上下文一直都在栈的底部,它是在执行任何其他上下文之前创建的。
你可以在这篇文章中找到更多关于执行上下文的详细内容。
一般情况下,一个上下文中的代码会运行到结束,然而正如我们上面所提到的,一些对象 - 如生成器,可能会违反栈后进先出的顺序。一个生成器函数可能会挂起其运行上下文并在完成之前将其从栈中移除。当生成器再次激活时,其上下文恢复并再次被压入栈:
上面代码中的
yield
语句返回值给调用者并将上下文推出。第二次调用next
时,相同的上下文再次被压入栈并恢复。这样的上下文会比创建它的调用者生命周期更长,因此违反了后进先出的结构。现在我们将讨论执行上下文的重要组成部分;特别是 ECMAScript 运行时如何管理变量的存储和代码中嵌套块创建的作用域(scope)。这是 词法环境(lexical environments) 的通用概念,它在 JS 中用来存储数据和解决“ 函数参数问题(Funarg problem) ” - 和 闭包(closure) 的机制一起。
环境
每个执行上下文都有一个相关的词法环境。
所以,一个环境是在某个范围内定义的变量,函数和类的存储。
从技术上来说,一个环境是由一个环境记录(一个将标识符映射到值的实际存储表)和一个对父项(可能是
null
)的引用这一对组成。看代码:
上面代码的全局上下文和
foo
函数的上下文的环境结构如下图所示:从逻辑上讲,这使我们想起上面讨论过的原型链。并且标识符解析的规则也非常相似:如果在自己的环境中找不到变量,则尝试在父级环境中、在父级父级中查找它,以此类推 - 直到整个环境链都查找完成。
这就解释了:为什么变量
x
被解析为100
,而不是10
- 它是直接在foo
自己的环境中找到;为什么我们可以访问参数z
- 它也只是存储在激活环境中;也是为什么我们可以访问变量y
- 它是在父级环境中找到的。与原型类似,相同的父级环境可以由多个子环境共享:例如,两个全局函数共享相同的全局环境。
环境记录因类型而异。有对象环境记录和声明式环境记录。在声明式记录之上还有函数环境记录和模块环境记录。每种类型的记录都有它的特性。但是,标识符解析的通用机制在所有环境中都是通用的,并且不依赖于记录的类型。
一个对象环境记录的例子就是全局环境记录。这种记录也有相关联的绑定对象,它可以存储记录中的一些属性,而不是全部,反之亦然(译者注:不同的可以看下面的示例代码)。绑定对象也可以通过
this
得到。上述代码可以表示为下图:
需要注意的是,绑定对象的存在是为了兼容遗留的结构,例如
var
声明和with
语句,它们也将它们的对象作为绑定对象提供。这就是环境被表示为简单对象的历史原因。现在,环境模型更加优化,但其结果是,我们无法再将绑定作为属性访问(译者注:如上面的代码中我们不能通过this.y
访问y
的值)。我们已经看到环境是如何通过父链接相关联的。现在我们将看到一个环境的生命周期如何比创造它的上下文环境的更久。这是我们即将讨论的闭包机制的基础。
闭包
ECMAScript中的函数是头等的(first-class)。这个概念是函数式编程的基础,这些方面也被 JavaScript 所支持。
与头等函数概念相关的是所谓的“函参问题(Funarg problem)”(或“一个函数参数的问题”)。当一个函数需要处理自由变量时,这个问题就会出现。
我们来看看函参问题,并看它在 ECMAScript 中是如何解决的。
考虑下面的代码片段:
对于函数
foo
来说,x
是自由变量。当foo
函数被激活时(通过funArg
参数) - 应该在哪里解析x
的绑定?是创建函数的外部作用域还是调用函数的调用者作用域?正如我们所见,调用者即bar
函数,也提供了x
的绑定 - 值为 20 。上面描述的用例被称为 downward funarg problem,即在确定绑定的正确环境时的模糊性:它应该是创建时的环境,还是调用时的环境?
这是通过使用静态作用域的协议来解决的,也就是创建时的作用域。
静态作用域有时也被称为词法作用域,因此也就是词法环境的命名由来。
从技术上来说,静态作用域是通过捕获创建函数的环境来实现的。
在我们的例子中,
foo
函数捕获的环境是全局环境:我们可以看到一个环境引用了一个函数,而这个函数又回引了环境。
函参问题的第二个子类型被称为 upward funarg problem 。它们之间唯一的区别是捕捉环境的生命周期比创建它的环境更长。
我们看例子:
同样,从技术上来说,它与捕获定义环境的确切机制没有区别。只是这种情况下,如果没有闭包,
foo
的激活环境就会被销毁。但是我们捕获了它,所以它不能被释放,并被保留 - 以支持静态作用域语义。人们对闭包的理解通常是不完整的 - 开发人员通常考虑闭包仅仅依据 upward funarg problem(实际上是更合理)。但是,正如我们所看到的,downward 和 upward funarg problem 的技术机制是完全一样的 - 就是静态作用域的机制。
正如我们上面提到的,与原型类似,几个闭包可以共享相同的父环境。这允许它们访问和修改共享数据:
由于在包含
count
变量的作用域内创建了两个闭包:increment
和decrement
,所以它们共享这个父作用域。也就是说,捕获总是“通过引用” 发生 - 意味着对整个父环境的引用被存储。有些语言可能捕获的是值,制作捕获的变量的副本,并且不允许在父作用域中更改它。但是,重复一遍,在 JS 中,它始终是对父范围的引用。
你可以在链接文章中找到有关闭包和函参问题的详细讨论。
所有的标识符都是静态的作用域。然而,在 ECMAScript 中有一个值是动态作用域的。那就是
this
的值。this
this
值是一个特殊的对象,它是动态地、隐式地传递给上下文中的代码。我们可以把它看作是一个隐含的额外参数,我们可以访问,但是不能修改。this
值的目的是为多个对象执行相同的代码。this
主要的用例是基于类的 OOP。一个实例方法(在原型上定义)存在于一个范例中,但在该类的所有实例中共享。当
getX
方法被激活时,会创建一个新的环境来存储局部变量和参数。另外,函数环境记录得到传递来的[[ThisValue]]
,它是根据函数的调用方式动态绑定的。当用p1
调用时,this
值恰好是p1
,第二种情况下是p2
。this
的另一个应用是泛型接口函数,它可以用在 mixin 或 traits 中。在下面的例子中,
Movable
接口包含泛型函数move
,它期望这个 mixin 的用户实现_x
和_y
属性:作为替代方案,mixin 也可以应用于原型级别,而不是像上例中每个实例做的那样。
为了展示
this
值的动态性,考虑下面例子,我们把这个例子留给读者来解决:因为只通过查看
foo
函数的源代码,我们不能知道它在特定的调用中this
的值是什么,所以我们说this
值是动态作用域。箭头函数 中
this
值比较特殊:其this
是词法的(静态的),而不是动态的。即他们的函数环境记录不提供this
值,它是从父环境中获取的。就像我们所说的,在全局上下文,
this
值是全局对象(全局环境记录的绑定对象)。以前只有一个全局对象。在当前版本的规范中,可能有多个全局对象,这是 代码领域(code realms) 的一部分。我们来讨论一下这个结构。领域
在求值之前,所有 ECMAScript 代码都必须与一个领域相关联。从技术上来说,一个领域只是为一个上下文提供全局环境。
当一个执行上下文被创建时,它与一个特定的代码领域相关联,这个代码领域为这个上下文提供了全局环境。该关联在未来将保持不变。
规范的当前版本没有提供显式创建领域的能力,但是它们可以由实现隐含地创建。不过有一个将这个API暴露给用户代码的提案。
从逻辑上来说,堆栈中的每个上下文总是与其领域相关联:
现在我们正在接近 ECMAScript 运行时的全貌了。然而,我们仍然需要看到代码的入口点和初始化过程。这是由 jobs(作业) 和 job queues(作业队列) 机制管理的。
Job
有一些操作可以被推迟的,并在执行上下文堆栈上有可用点时立即执行。
Job 在 作业队列 中排队,在当前的规范版本中有两个作业队列 ScriptJobs 和 PromiseJobs 。
ScriptJobs 队列中的初始 job 是我们程序的主要入口 - 初始化已加载且求值的脚本:创建一个领域,创建一个全局上下文,并且与这个领域相关联,它被推入堆栈,全局代码被执行。
需要注意的是,ScriptJobs 队列管理着脚本和模块两者。
此外,这个上下文可以执行其他上下文,或使其他 jobs 到队列中排队。一个可以产生排队的 job 就是promise。
如果没有正在运行的执行上下文,并且执行上下文堆栈为空,则 ECMAScript 实现会从作业队列中移除第一个 job,创建执行上下文并开始执行。
示例:
async 函数 可以等待(await) promise 执行,所以它们也使 promise 作业排队:
现在我们已经非常接近当前 JS 宇宙的最终画面。马上我们将看到我们讨论的所有组件的主要所有者 - 代理商(Agents)。
Agent
ECMAScript中的 并发(concurrency) 和 并行(parallelism) 是使用 代理模式(Agent pattern) 的实现的。代理模式非常接近参与者模式(Actor pattern) - 一个具有消息传递风格的轻量级进程。
依赖代理的实现可以在同一个线程上运行,也可以在单独的线程上运行。浏览器环境中的
Worker
代理是代理概念的一个例子。代理的状态是相互隔离的,可以通过发送消息进行通信。一些数据可以在代理之间共享,例如
SharedArrayBuffer
。代理也可以组合成代理集群。在下面的例子中,
index.html
调用agent-smith.js
worker ,传递共享的内存块:worker 的代码如下:
你可以在链接页面得到示例的完整代码。
(需要注意的是,如果你在本地运行这个例子,请在 Firefox 中运行它,因为由于安全原因,Chrome 不允许从本地文件加载 web worker)
下图展示了 ECMAScript 运行时:
如图所示,那就是在 ECMAScript 引擎下发生的事情!
现在文章到了结尾的时候。这是我们可以在概述文章中涵盖的 JS 核心的信息量。就像我们提到的,JS 代码可以被分组成模块,对象的属性可以被
Proxy
对象追踪等等。 - 许多用户级别的细节可以在 JavaScript 语言的不同文档中找到。尽管我们试图表示一个 ECMAScript 程序本身的逻辑结构,希望能够澄清这些细节。如果你有任何问题,建议或反馈意见,我将一如既往地乐于在评论中讨论这些问题。
我要感谢 TC-39 的代表和规范编辑帮助澄清本文。该讨论可以在这个 Twitter 主题中找到。
祝学习 ECMAScript 好运!
Written by: Dmitry Soshnikov Published on: November 14th, 2017