mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-11-15 21:53:49 +08:00
mod
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
<audio id="audio" title="02 | 函数即对象:一篇文章彻底搞懂JavaScript的函数特点" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/19/6f/199ab3bc83790a513f0e8a57359b5f6f.mp3"></audio>
|
||||
|
||||
你好,我是李兵。这是我们专栏的第二讲,我们来看下“函数是一等公民”背后的含义。
|
||||
|
||||
如果你熟悉了一门其他流行语言,再来使用JavaScript,那么JavaScript中的函数可能会给你造成一些误解,比如在JavaScript中,你可以将一个函数赋值给一个变量,还可以将函数作为一个参数传递给另外一个函数,甚至可以使得一个函数返回另外一个函数,这在一些主流语言中都很难实现。
|
||||
|
||||
JavaScript中的函数非常灵活,其根本原因在于**JavaScript中的函数就是一种特殊的对象**,我们把JavaScript中的函数称为**一等公民(First Class Function)。**
|
||||
|
||||
基于函数是一等公民的设计,使得JavaScript非常容易实现一些特性,比如闭包,还有函数式编程等,而其他语言要实现这些特性就显得比较困难,比如要在C++中实现闭包需要实现大量复杂的代码,而且使用起来也异常复杂。
|
||||
|
||||
函数式编程和闭包在实际的项目中会经常遇到,如果不了解这些特性,那么在你使用第三方代码时就会非常吃力,同时自己也很难使用这些特性写出优雅的代码,因此我们很有必要了解这些特性的底层机制。
|
||||
|
||||
另外,在我们后续课程介绍V8工作机制时,会学习V8是怎么实现闭包的,还会学习V8是如何将JavaScript的动态特性静态化以加快代码的执行速度,这些内容都涉及到JavaScript中的函数底层特性。
|
||||
|
||||
今天,我们就来深入分析下,JavaScript中的“函数”到底有怎样的特点。
|
||||
|
||||
## 什么是JavaScript中的对象?
|
||||
|
||||
既然在JavaScript中,函数就是一种特殊的对象,那我们首先要明白,什么是JavaScript中的“对象”?它和面向对象语言中的“对象”有什么区别?
|
||||
|
||||
和其他主流语言不一样的是,JavaScript是一门**基于对象(****Object-Based)**的语言,可以说JavaScript中大部分的内容都是由对象构成的,诸如函数、数组,也可以说JavaScript是建立在对象之上的语言。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/9e/f8/9e946bbdc54f5e1347f7b593f8f6fff8.jpg" alt="" title="基于对象的设计">
|
||||
|
||||
而这些对象在运行时可以动态修改其内容,这造就了JavaScript的超级灵活特性。不过,因为JavaScript太灵活了,也加大了理解和使用这门语言的难度。
|
||||
|
||||
虽然JavaScript是基于对象设计的,但是它却不是一门**面向对象的语言(Object—Oriented Programming Language)**,因为面向对象语言天生支持**封装、继承、多态**,但是JavaScript并没有直接提供多态的支持,因此要在JavaScript中使用多态并不是一件容易的事。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ef/00/eff1c1c773835b79ce597a84b2f94a00.jpg" alt="" title="面向对象的语言">
|
||||
|
||||
除了对多态支持的不好,JavaScript实现继承的方式和面向对象的语言实现继承的方式同样存在很大的差异。
|
||||
|
||||
面向对象语言是由语言本身对继承做了充分的支持,并提供了大量的关键字,如public、protected、friend、interface等,众多的关键字使得面向对象语言的继承变得异常繁琐和复杂,而JavaScript中实现继承的方式却非常简单清爽,**只是在对象中添加了一个称为原型的属性,把继承的对象通过原型链接起来,就实现了继承,我们把这种继承方式称为基于原型链继承。**关于V8是如何支持原型的,我们会在《[05 | 原型链:V8是如何实现对象继承的?](https://time.geekbang.org/column/article/215425)》这节课做具体介绍。
|
||||
|
||||
既然“JavaScript中的对象”和“面向对象语言中的对象”存在巨大差异,那么在JavaScript中,我们所谈论的对象到底是指什么呢?
|
||||
|
||||
其实JavaScript中的对象非常简单,每个对象就是由一组组属性和值构成的集合,比如我使用下面代码创建了一个person对象:
|
||||
|
||||
```
|
||||
var person=new Object();
|
||||
person.firstname="John";
|
||||
person.lastname="Doe";
|
||||
person.age=50;
|
||||
person.eyecolor="blue";
|
||||
|
||||
```
|
||||
|
||||
这个对象里面有四个属性,为了直观理解,你可以参看下图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d0/23/d07e174001a29765a3575908e3704123.jpg" alt="" title="对象的构成">
|
||||
|
||||
上图展示了对象person的结构,我们可以看到蓝色的属性在左边,黄色的值在右边,有多组属性和值组成,这就是JavaScript中的对象,虽然JavaScript对象用途非常广泛,使用的方式也非常之多,但是万变不离其宗,其核心本质都就是由一组组属性和值组成的集合,抓住了这一点,当我们再分析对象时,就会轻松很多。
|
||||
|
||||
之所以JavaScript中对象的用途这么广,是因为对象的值可以是任意类型的数据,我们可以改造下上面的那段代码,来看看对象的值都有哪些类型?改造后的代码如下所示:
|
||||
|
||||
```
|
||||
var person=new Object()
|
||||
person.firstname="John"
|
||||
person.lastname="Doe"
|
||||
person.info = new Object()
|
||||
person.info.age=50
|
||||
person.info.eyecolor="blue"
|
||||
person.showinfo = function (){
|
||||
console.log(/*...*/)
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们可以先画出这段代码的内存布局,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f7/17/f73524e4cae884747ae528d999fc1117.jpg" alt="" title="属性值类型">
|
||||
|
||||
观察上图,我们可以看出来,对象的属性值有三种类型:
|
||||
|
||||
第一种是**原始类型(primitive)**,所谓的原始类的数据,是指值本身无法被改变,比如JavaScript中的字符串就是原始类型,如果你修改了JavaScript中字符串的值,那么V8会返回给你一个新的字符串,原始字符串并没有被改变,我们称这些类型的值为“原始值”。
|
||||
|
||||
JavaScript中的原始值主要包括null、undefined、boolean、number、string、bigint、symbol 这七种。
|
||||
|
||||
第二种就是我们现在介绍的**对象类型(Object)**,对象的属性值也可以是另外一个对象,比如上图中的info属性值就是一个对象。
|
||||
|
||||
第三种是**函数类型(Function)**,如果对象中的属性值是函数,那么我们把这个属性称为方法,所以我们又说对象具备属性和方法,那么上图中的showinfo就是person对象的一个方法。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8c/a6/8c33fa6c6e0cef5795292f0a21ee36a6.jpg" alt="" title="对象属性值的三种类型">
|
||||
|
||||
## 函数的本质
|
||||
|
||||
分析完对象,现在我们就能更好地理解JavaScript中函数的概念了。
|
||||
|
||||
在这节课开始我就提到,在JavaScript中,函数是一种特殊的对象,它和对象一样可以拥有属性和值,但是函数和普通对象不同的是,函数可以被调用。
|
||||
|
||||
我们先来看一段JavaScript代码,在这段代码中,我们定义了一个函数foo,接下来我们给foo函数设置了myName和uName的属性。
|
||||
|
||||
```
|
||||
function foo(){
|
||||
var test = 1
|
||||
}
|
||||
foo.myName = 1
|
||||
foo.uName = 2
|
||||
console.log(foo.myName)
|
||||
|
||||
```
|
||||
|
||||
既然是函数,那么它也可以被调用。比如你定义了一个函数,便可以通过函数名称加小括号来实现函数的调用,代码如下所示:
|
||||
|
||||
```
|
||||
function foo(){
|
||||
var test = 1
|
||||
console.log(test)
|
||||
}
|
||||
foo()
|
||||
|
||||
```
|
||||
|
||||
除了使用函数名称来实现函数的调用,还可以直接调用一个匿名函数,代码如下所示:
|
||||
|
||||
```
|
||||
(function (){
|
||||
var test = 1
|
||||
console.log(test)
|
||||
})()
|
||||
|
||||
```
|
||||
|
||||
那么,V8内部是怎么实现函数可调用特性的呢?
|
||||
|
||||
其实在V8内部,会为函数对象添加了两个隐藏属性,具体属性如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/9e/e2/9e274227d637ce8abc4a098587613de2.jpg" alt="" title="函数对象具有隐藏属性">
|
||||
|
||||
也就是说,函数除了可以拥有常用类型的属性值之外,还拥有两个隐藏属性,分别是name属性和code属性。
|
||||
|
||||
隐藏name属性的值就是函数名称,如果某个函数没有设置函数名,如下面这段函数:
|
||||
|
||||
```
|
||||
(function (){
|
||||
var test = 1
|
||||
console.log(test)
|
||||
})()
|
||||
|
||||
```
|
||||
|
||||
该函数对象的默认的name属性值就是anonymous,表示该函数对象没有被设置名称。另外一个隐藏属性是code属性,其值表示函数代码,以字符串的形式存储在内存中。当执行到一个函数调用语句时,V8便会从函数对象中取出code属性值,也就是函数代码,然后再解释执行这段函数代码。
|
||||
|
||||
## 函数是一等公民
|
||||
|
||||
因为函数是一种特殊的对象,所以在JavaScript中,函数可以赋值给一个变量,也可以作为函数的参数,还可以作为函数的返回值。**如果某个编程语言的函数,可以和这个语言的数据类型做一样的事情,我们就把这个语言中的函数称为一等公民。**支持函数是一等公民的语言可以使得代码逻辑更加清晰,代码更加简洁。
|
||||
|
||||
但是由于函数的“可被调用”的特性,使得实现函数的可赋值、可传参和可作为返回值等特性变得有一点麻烦。为什么?
|
||||
|
||||
我们知道,在执行JavaScript函数的过程中,为了实现变量的查找,V8会为其维护一个作用域链,如果函数中使用了某个变量,但是在函数内部又没有定义该变量,那么函数就会沿着作用域链去外部的作用域中查找该变量,具体流程如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8b/fd/8bb90b190362e3a00e5a260bad6829fd.jpg" alt="" title="查找变量">
|
||||
|
||||
从图中可以看出,当函数内部引用了外部的变量时,使用这个函数进行赋值、传参或作为返回值,你还需要保证这些被引用的外部变量是确定存在的,这就是让函数作为一等公民麻烦的地方,因为虚拟机还需要处理函数引用的外部变量。我们来看一段简单的代码:
|
||||
|
||||
```
|
||||
function foo(){
|
||||
var number = 1
|
||||
function bar(){
|
||||
number++
|
||||
console.log(number)
|
||||
}
|
||||
return bar
|
||||
}
|
||||
var mybar = foo()
|
||||
mybar()
|
||||
|
||||
```
|
||||
|
||||
观察上段代码可以看到,我们在foo函数中定义了一个新的bar函数,并且bar函数引用了foo函数中的变量number,当调用foo函数的时候,它会返回bar函数。
|
||||
|
||||
那么所谓的“函数是一等公民”就体现在,如果要返回函数bar给外部,那么即便foo函数执行结束了,其内部定义的number变量也不能被销毁,因为bar函数依然引用了该变量。
|
||||
|
||||
我们也把这种将外部变量和和函数绑定起来的技术称为闭包。V8在实现闭包的特性时也做了大量的额外的工作,关于闭包的详细实现,我们会在《[12 | 延迟解析:V8是如何实现闭包的?](https://time.geekbang.org/column/article/223168)》这节课再介绍。
|
||||
|
||||
另外基于函数是一等公民,我们可以轻松使用JavaScript来实现目前比较流行的函数式编程,函数式编程规则很少,非常优美,不过这并不是本专栏的重点,所以我们先略开不讲。
|
||||
|
||||
## 总结
|
||||
|
||||
好了,今天的内容就介绍到这里,下面我来总结下本文的主要内容。
|
||||
|
||||
本文我们围绕JavaScript中的函数来展开介绍,JavaScript中的函数非常灵活,既可以被调用,还可以作为变量、参数和返回值,这些特性使得函数的用法非常多,这也导致了函数变得有些复杂,因此本文的目的就是要讲清楚函数到底是什么?
|
||||
|
||||
因为函数是一种特殊的对象,所以我们先介绍了JavaScript中的对象,JavaScript中的对象就是由一组一组属性和值组成的集合,既然函数也是对象,那么函数也是由一组组值和属性组成的集合,我们还在文中使用了一段代码证明了这点。
|
||||
|
||||
因为函数作为一个对象,是可以被赋值、作为参数,还可以作为返回值的,那么如果一个函数返回了另外一个函数,那么就应该返回该函数所有相关的内容。
|
||||
|
||||
接下来,我们又介绍了一个函数到底关联了哪些内容:
|
||||
|
||||
- 函数作为一个对象,它有自己的属性和值,所以函数关联了基础的属性和值;
|
||||
- 函数之所以成为特殊的对象,这个特殊的地方是函数可以“被调用”,所以一个函数被调用时,它还需要关联相关的执行上下文。
|
||||
|
||||
结合以上两点,JavaScript中的函数就实现了“函数是一等公民”的特性。
|
||||
|
||||
## 思考题
|
||||
|
||||
本文我们从对象聊到了闭包,那么留给你的问题是,哪些语言天生支持“函数是一等公民”?欢迎你在留言区与我分享讨论。
|
||||
|
||||
感谢你的阅读,如果你觉得这一讲的内容对你有所启发,也欢迎把它分享给你的朋友。
|
||||
@@ -0,0 +1,195 @@
|
||||
<audio id="audio" title="03 | 快属性和慢属性:V8是怎样提升对象属性访问速度的?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/fb/bb/fb3e7b801528c255e041f54249c75cbb.mp3"></audio>
|
||||
|
||||
你好,我是李兵。
|
||||
|
||||
在前面的课程中,我们介绍了JavaScript中的对象是由一组组属性和值的集合,从JavaScript语言的角度来看,JavaScript对象像一个字典,字符串作为键名,任意对象可以作为键值,可以通过键名读写键值。
|
||||
|
||||
然而在V8实现对象存储时,并没有完全采用字典的存储方式,这主要是出于性能的考量。因为字典是非线性的数据结构,查询效率会低于线性的数据结构,V8为了提升存储和查找效率,采用了一套复杂的存储策略。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c9/ef/c970cdc7b89bfe0a12e560fe94fcdfef.jpg" alt="" title="线性结构和非线性结构">
|
||||
|
||||
今天这节课我们就来分析下V8采用了哪些策略提升了对象属性的访问速度。
|
||||
|
||||
## 常规属性(properties)和排序属性(element)
|
||||
|
||||
在开始之前,我们先来了解什么是对象中的**常规属性**和**排序属性**,你可以先参考下面这样一段代码:
|
||||
|
||||
```
|
||||
function Foo() {
|
||||
this[100] = 'test-100'
|
||||
this[1] = 'test-1'
|
||||
this["B"] = 'bar-B'
|
||||
this[50] = 'test-50'
|
||||
this[9] = 'test-9'
|
||||
this[8] = 'test-8'
|
||||
this[3] = 'test-3'
|
||||
this[5] = 'test-5'
|
||||
this["A"] = 'bar-A'
|
||||
this["C"] = 'bar-C'
|
||||
}
|
||||
var bar = new Foo()
|
||||
|
||||
|
||||
for(key in bar){
|
||||
console.log(`index:${key} value:${bar[key]}`)
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在上面这段代码中,我们利用构造函数Foo创建了一个bar对象,在构造函数中,我们给bar对象设置了很多属性,包括了数字属性和字符串属性,然后我们枚举出来了bar对象中所有的属性,并将其一一打印出来,下面就是执行这段代码所打印出来的结果:
|
||||
|
||||
```
|
||||
index:1 value:test-1
|
||||
index:3 value:test-3
|
||||
index:5 value:test-5
|
||||
index:8 value:test-8
|
||||
index:9 value:test-9
|
||||
index:50 value:test-50
|
||||
index:100 value:test-100
|
||||
index:B value:bar-B
|
||||
index:A value:bar-A
|
||||
index:C value:bar-C
|
||||
|
||||
```
|
||||
|
||||
观察这段打印出来的数据,我们发现打印出来的属性顺序并不是我们设置的顺序,我们设置属性的时候是乱序设置的,比如开始先设置100,然后又设置了1,但是输出的内容却非常规律,总的来说体现在以下两点:
|
||||
|
||||
- 设置的数字属性被最先打印出来了,并且是按照数字大小的顺序打印的;
|
||||
- 设置的字符串属性依然是按照之前的设置顺序打印的,比如我们是按照B、A、C的顺序设置的,打印出来依然是这个顺序。
|
||||
|
||||
之所以出现这样的结果,是因为在ECMAScript规范中定义了**数字属性应该按照索引值大小升序排列,字符串属性根据创建时的顺序升序排列。**
|
||||
|
||||
在这里我们把对象中的数字属性称为**排序属性**,在V8中被称为**elements**,字符串属性就被称为**常规属性**,在V8中被称为**properties**。
|
||||
|
||||
在V8内部,为了有效地提升存储和访问这两种属性的性能,分别使用了两个**线性数据结构**来分别保存排序属性和常规属性,具体结构如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/af/75/af2654db3d3a2e0b9a9eaa25e862cc75.jpg" alt="" title="V8内部的对象构造">
|
||||
|
||||
通过上图我们可以发现,bar对象包含了两个隐藏属性:elements属性和properties属性,elements属性指向了elements对象,在elements对象中,会按照顺序存放排序属性,properties属性则指向了properties对象,在properties对象中,会按照创建时的顺序保存了常规属性。
|
||||
|
||||
分解成这两种线性数据结构之后,如果执行索引操作,那么V8会先从elements属性中按照顺序读取所有的元素,然后再在properties属性中读取所有的元素,这样就完成一次索引操作。
|
||||
|
||||
## 快属性和慢属性
|
||||
|
||||
将不同的属性分别保存到elements属性和properties属性中,无疑简化了程序的复杂度,但是在查找元素时,却多了一步操作,比如执行 `bar.B`这个语句来查找B的属性值,那么在V8会先查找出properties属性所指向的对象properties,然后再在properties对象中查找B属性,这种方式在查找过程中增加了一步操作,因此会影响到元素的查找效率。
|
||||
|
||||
基于这个原因,V8采取了一个权衡的策略以加快查找属性的效率,这个策略是将部分常规属性直接存储到对象本身,我们把这称为**对象内属性(in-object properties)。**对象在内存中的展现形式你可以参看下图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f1/3e/f12b4c6f6e631ce51d5b4f288dbfb13e.jpg" alt="" title="对象内属性">
|
||||
|
||||
采用对象内属性之后,常规属性就被保存到bar对象本身了,这样当再次使用`bar.B`来查找B的属性值时,V8就可以直接从bar对象本身去获取该值就可以了,这种方式减少查找属性值的步骤,增加了查找效率。
|
||||
|
||||
不过对象内属性的数量是固定的,默认是10个,如果添加的属性超出了对象分配的空间,则它们将被保存在常规属性存储中。虽然属性存储多了一层间接层,但可以自由地扩容。
|
||||
|
||||
通常,我们将保存在线性数据结构中的属性称之为“快属性”,因为线性数据结构中只需要通过索引即可以访问到属性,虽然访问线性结构的速度快,但是如果从线性结构中添加或者删除大量的属性时,则执行效率会非常低,这主要因为会产生大量时间和内存开销。
|
||||
|
||||
因此,如果一个对象的属性过多时,V8就会采取另外一种存储策略,那就是“慢属性”策略,但慢属性的对象内部会有独立的非线性数据结构(词典)作为属性存储容器。所有的属性元信息不再是线性存储的,而是直接保存在属性字典中。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e8/17/e8ce990dce53295a414ce79e38149917.jpg" alt="" title="慢属性是如何存储的">
|
||||
|
||||
## 实践:在Chrome中查看对象布局
|
||||
|
||||
现在我们知道了V8是怎么存储对象的了,接下来我们来结合Chrome中的内存快照,来看看对象在内存中是如何布局的?
|
||||
|
||||
你可以打开Chrome开发者工具,先选择控制台标签,然后在控制台中执行以下代码查看内存快照:
|
||||
|
||||
```
|
||||
function Foo(property_num,element_num) {
|
||||
//添加可索引属性
|
||||
for (let i = 0; i < element_num; i++) {
|
||||
this[i] = `element${i}`
|
||||
}
|
||||
//添加常规属性
|
||||
for (let i = 0; i < property_num; i++) {
|
||||
let ppt = `property${i}`
|
||||
this[ppt] = ppt
|
||||
}
|
||||
}
|
||||
var bar = new Foo(10,10)
|
||||
|
||||
```
|
||||
|
||||
上面我们创建了一个构造函数,可以利用该构造函数创建了新的对象,我给该构造函数设置了两个参数property_num、element_num,分别代表创建常规属性的个数和排序属性的个数,我们先将这两种类型的个数都设置为10个,然后利用该构造函数创建了一个新的bar对象。
|
||||
|
||||
创建了函数对象,接下来我们就来看看构造函数和对象在内存中的状态。你可以将Chrome开发者工具切换到Memory标签,然后点击左侧的小圆圈就可以捕获当前的内存快照,最终截图如下所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d2/d3/d2a123d127a2895d9f0d09be61cc55d3.png" alt="" title="V8内存快照截图">
|
||||
|
||||
上图就是收集了当前内存快照的界面,要想查找我们刚才创建的对象,你可以在搜索框里面输入构造函数Foo,Chrome会列出所有经过构造函数Foo创建的对象,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/2b/89/2b4ee447d061f72026ca38d6dfc25389.png" alt="" title="从内存快照搜索构造函数">
|
||||
|
||||
观察上图,我们搜索出来了所有经过构造函数Foo创建的对象,点开Foo的那个下拉列表,第一个就是刚才创建的bar对象,我们可以看到bar对象有一个elements属性,这里面就包含我们创造的所有的排序属性,那么怎么没有常规属性对象呢?
|
||||
|
||||
这是因为只创建了10个常规属性,所以V8将这些常规属性直接做成了bar对象的对象内属性。
|
||||
|
||||
所以这时候的数据内存布局是这样的:
|
||||
|
||||
- 10个常规属性作为对象内属性,存放在bar函数内部;
|
||||
- 10个排序属性存放在elements中。
|
||||
|
||||
接下来我们可以将创建的对象属性的个数调整到20个,你可以在控制台执行下面这段代码:
|
||||
|
||||
```
|
||||
var bar2 = new Foo(20,10)
|
||||
|
||||
```
|
||||
|
||||
然后我们再重新生成内存快照,再来看看生成的图片:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ef/6e/ef117bbc99504f1daea52a0831a5756e.png" alt="" title=" 利用构造函数生成的对象">
|
||||
|
||||
我们可以看到,构造函数Foo下面已经有了两个对象了,其中一个bar,另外一个是bar2,我们点开第一个bar2对象,内容如下所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/49/86/49c1f8e735e5b7772f3d54fb53eae386.png" alt="" title="查看对象属性">
|
||||
|
||||
由于创建的常用属性超过了10个,所以另外10个常用属性就被保存到properties中了,注意因为properties中只有10个属性,所以依然是线性的数据结构,我们可以看其都是按照创建时的顺序来排列的。
|
||||
|
||||
所以这时候属性的内存布局是这样的:
|
||||
|
||||
- 10属性直接存放在bar2的对象内;
|
||||
- 10个常规属性以线性数据结构的方式存放在properties属性里面;
|
||||
- 10个数字属性存放在elements属性里面。
|
||||
|
||||
如果常用属性太多了,比如创建了100个,那么我们再来看看其内存分布,你可以执行下面这段代码:
|
||||
|
||||
```
|
||||
var bar3 = new Foo(100,10)
|
||||
|
||||
```
|
||||
|
||||
然后以同样的方式打开bar3,查看其内存布局,最终如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/da/69/dab6d6e2291117781e4294f27113d469.png" alt="" title="利用字典存放常规元素">
|
||||
|
||||
结合上图,我们可以看到,这时候的properties属性里面的数据并不是线性存储的,而是以非线性的字典形式存储的,所以这时候属性的内存布局是这样的:
|
||||
|
||||
- 10属性直接存放在bar3的对象内;
|
||||
- 90个常规属性以非线性字典的这种数据结构方式存放在properties属性里面;
|
||||
- 10个数字属性存放在elements属性里面。
|
||||
|
||||
## 其他属性
|
||||
|
||||
好了,现在我们知道V8是怎么存储对象的了,不过这里还有几个重要的隐藏属性我还没有介绍,下面我们就来简单地看下。你可以先看下图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/82/4d/82463f4c1a4bf5fb8920b0099284e84d.png" alt="" title="其他隐藏属性">
|
||||
|
||||
观察上图,除了elements和properties属性,V8还为每个对象实现了map属性和__proto__属性。__proto__属性就是原型,是用来实现JavaScript继承的,我们会在下一节来介绍;而map则是隐藏类,我们会在《[15 | 隐藏类:如何在内存中快速查找对象属性?](https://time.geekbang.org/column/article/226417)》这一节中介绍其工作机制。
|
||||
|
||||
## 总结
|
||||
|
||||
好了,本节的内容就介绍到这里,下面我来总结下本文的主要内容:
|
||||
|
||||
本文我们的主要目标是介绍V8内部是如何存储对象的,因为JavaScript中的对象是由一组组属性和值组成的,所以最简单的方式是使用一个字典来保存属性和值,但是由于字典是非线性结构,所以如果使用字典,读取效率会大大降低。
|
||||
|
||||
为了提升查找效率,V8在对象中添加了两个隐藏属性,排序属性和常规属性,element属性指向了elements对象,在elements对象中,会按照顺序存放排序属性。properties属性则指向了properties对象,在properties对象中,会按照创建时的顺序保存常规属性。
|
||||
|
||||
通过引入这两个属性,加速了V8查找属性的速度,为了更加进一步提升查找效率,V8还实现了内置内属性的策略,当常规属性少于一定数量时,V8就会将这些常规属性直接写进对象中,这样又节省了一个中间步骤。
|
||||
|
||||
但是如果对象中的属性过多时,或者存在反复添加或者删除属性的操作,那么V8就会将线性的存储模式降级为非线性的字典存储模式,这样虽然降低了查找速度,但是却提升了修改对象的属性的速度。
|
||||
|
||||
## 思考题
|
||||
|
||||
通常,我们不建议使用delete来删除属性,你能结合文中介绍的快属性和慢属性,给出不建议使用delete的原因吗?欢迎你在留言区与我分享讨论。
|
||||
|
||||
感谢你的阅读,如果你觉得这一讲的内容对你有所启发,也欢迎把它分享给你的朋友。
|
||||
@@ -0,0 +1,329 @@
|
||||
<audio id="audio" title="04 | 函数表达式:涉及大量概念,函数表达式到底该怎么学?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c6/18/c6fc2973e24ba918316f707b783c4e18.mp3"></audio>
|
||||
|
||||
你好,我是李兵。
|
||||
|
||||
前面几节我们聊了V8中的对象和函数,并介绍了函数为什么会被称为是一等公民,了解这些之后,我们就可以来学习函数表达式了。
|
||||
|
||||
函数表达式在JavaScript中非常基础也非常重要,使用函数表达式可以用来实现代码隐藏,还可以实现变量隔离,所以函数表达式被广泛地应用在各个项目中,了解函数表达式的底层工作机制,可以帮助我们更加深刻地理解项目。
|
||||
|
||||
但是,学好函数表达式并不容易。因为它涉及到了很多底层概念,比如表达式、语句、函数即对象(在JavaScript中)等,而且函数表达式和函数声明看起来类似,都是定义一个函数,然后再调用该函数,很容易把二者搞混淆了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/51/31/51ae06e8a9dc4a589958065429bec231.jpg" alt="" title="你知道两者的区别吗?">
|
||||
|
||||
实际上,函数表达式和函数声明有着本质上的差异。理解了这种差异,你对函数表达式的理解也就加深了。
|
||||
|
||||
## 函数声明与函数表达式的差异
|
||||
|
||||
那么它们具体有什么差异呢?我们先来看一段代码:
|
||||
|
||||
```
|
||||
foo()
|
||||
function foo(){
|
||||
console.log('foo')
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在这段代码中,我声明了一个foo函数,然后在foo函数之前调用了foo函数,执行这段代码,我们看到foo函数被正确执行了。(你可能会好奇,代码不是自上而下执行吗,为什么在函数声明之前就可以调用该函数了呢?这个问题我们先留一边,后文中会进行解答。)
|
||||
|
||||
再来看另外一段代码:
|
||||
|
||||
```
|
||||
foo()
|
||||
var foo = function (){
|
||||
console.log('foo')
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在这段代码中,我定义了一个变量foo,然后将一个函数赋值给了变量foo,同样在源码中,我们也是在foo函数的前面调用foo,执行这段代码,我们发现报错了,提示的错误信息如下所示:
|
||||
|
||||
```
|
||||
VM130:1 Uncaught TypeError: foo is not a function
|
||||
at <anonymous>:1:1
|
||||
|
||||
```
|
||||
|
||||
这是告诉我们,变量foo并不是一个函数,所以无法被调用。
|
||||
|
||||
同样是在定义的函数之前调用函数,第一段代码就可以正确执行,而第二段代码却报错,这是为什么呢?
|
||||
|
||||
其主要原因是这两种定义函数的方式具有不同语义,不同的语义触发了不同的行为。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a7/10/a74668eb5bf183538ce9b47a20eb0610.jpg" alt="" title="不同的语义,触发不同的行为">
|
||||
|
||||
因为语义不同,所以我们给这两种定义函数的方式使用了不同的名称,第一种称之为**函数声明**,第二种称之为**函数表达式。**
|
||||
|
||||
下面我们就来分别分析下,函数声明和函数表达式的语义,以及V8是怎么处理函数声明和函数表达式的。
|
||||
|
||||
## V8是怎么处理函数声明的?
|
||||
|
||||
我们先来看函数声明,**函数声明**定义了一个具有指定参数的函数,其声明语法如下所示:
|
||||
|
||||
```
|
||||
function name([param,[, param,[..., param]]]) {
|
||||
[statements]
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
接下来我们来看看V8是怎么处理函数声明的。
|
||||
|
||||
我们知道,V8在执行JavaScript的过程中,会先对其进行编译,然后再执行,比如下面这段代码:
|
||||
|
||||
```
|
||||
var x = 5
|
||||
function foo(){
|
||||
console.log('Foo')
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
V8执行这段代码的流程大致如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/49/32/49eb14dd3c00438988595896c348c732.jpg" alt="">
|
||||
|
||||
在编译阶段,如果解析到函数声明,那么V8会将这个函数声明转换为内存中的函数对象,并将其放到作用域中。同样,如果解析到了某个变量声明,也会将其放到作用域中,但是会将其值设置为undefined,表示该变量还未被使用。
|
||||
|
||||
然后在V8执行阶段,如果使用了某个变量,或者调用了某个函数,那么V8便会去作用域查找相关内容。
|
||||
|
||||
关于作用域的数据,你也可以使用D8来查看,具体操作方式如下:
|
||||
|
||||
- 将这段代码保存到test.js中;
|
||||
- 使用“d8 --print-scopes test.js”命令即可查看作用域的状态。
|
||||
|
||||
执行这段指令之后,打印出如下信息:
|
||||
|
||||
```
|
||||
Global scope:
|
||||
global { // (0x7fb62281ca48) (0, 50)
|
||||
// will be compiled
|
||||
// 1 stack slots
|
||||
// temporary vars:
|
||||
TEMPORARY .result; // (0x7fb62281cfe8) local[0]
|
||||
// local vars:
|
||||
VAR x; // (0x7fb62281cc98)
|
||||
VAR foo; // (0x7fb62281cf40)
|
||||
|
||||
|
||||
function foo () { // (0x7fb62281cd50) (22, 50)
|
||||
// lazily parsed
|
||||
// 2 heap slots
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
上面这段就是V8生成的作用域,我们可以看到,作用域中包含了变量x和foo,变量x的默认值是undefined,变量foo指向了foo函数对象,foo函数对象被V8存放在内存中的堆空间了,这些变量都是在编译阶段被装进作用域中的。
|
||||
|
||||
因为在执行之前,这些变量都被提升到作用域中了,所以在执行阶段,V8当然就能获取到所有的定义变量了。我们把这种在编译阶段,将所有的变量提升到作用域的过程称为**变量提升**。
|
||||
|
||||
了解了变量提升,我们就能解释,为什么可以在函数声明之前调用该函数了,这是因为声明的函数在编译阶段就被提升到作用域中,在执行阶段,只要是在作用域中存在的变量或者对象,都是可以使用的。
|
||||
|
||||
对于变量提升,函数和普通的对象还是存在一些差异的,通过上面的分析我们知道,如果是一个普通变量,变量提升之后的值都是undefined,如果是声明的函数,那么变量提升之后的值则是函数对象,我们可以通过下面的代码来实践下:
|
||||
|
||||
```
|
||||
console.log(x)
|
||||
console.log(foo)
|
||||
var x = 5
|
||||
function foo(){
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
执行上面这段代码,我们可以看到,普通变量x的值就是undefined,而函数对象foo的值则是完整的对象,那这又是为什么呢?这就是涉及到表达式和语句的区别了。
|
||||
|
||||
简单地理解,表达式就是表示值的式子,而语句是操作值的式子。
|
||||
|
||||
比如:
|
||||
|
||||
```
|
||||
x = 5
|
||||
|
||||
```
|
||||
|
||||
就是表达式,因为执行这段代码,它会返回一个值。同样,`6 === 5` 也是一个表达式,因为它会返回False。
|
||||
|
||||
而语句则不同了,比如你定义了一个变量:
|
||||
|
||||
```
|
||||
var x
|
||||
|
||||
```
|
||||
|
||||
这就是一个语句,执行该语句时,V8并不会返回任何值给你。
|
||||
|
||||
同样,当我声明了一个函数时,这个函数声明也是一个语句,比如下面这段函数声明:
|
||||
|
||||
```
|
||||
function foo(){
|
||||
return 1
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
当执行到这段代码时,V8并没有返回任何的值,它只是解析foo函数,并将函数对象存储到内存中。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/24/43/244971073e6e41d10cefbb1de13bb343.jpg" alt="" title="表达式和语句">
|
||||
|
||||
了解了表达式和语句的区别,接下来我们继续分析上面的问题。我们知道,在V8执行`var x = 5`这段代码时,会认为它是两段代码,一段是定义变量的语句,一段是赋值的表达式,如下所示:
|
||||
|
||||
```
|
||||
var x = undefined
|
||||
x = 5
|
||||
|
||||
```
|
||||
|
||||
首先,在变量提升阶段,V8并不会执行赋值的表达式,该阶段只会分析基础的语句,比如变量的定义,函数的声明。
|
||||
|
||||
而这两行代码是在不同的阶段完成的,`var x` 是在编译阶段完成的,也可以说是在变量提升阶段完成的,而`x = 5`是表达式,所有的表达式都是在执行阶段完成的。
|
||||
|
||||
在变量提升阶段,V8将这些变量存放在作用域时,还会给它们赋一个默认的undefined值,所以在定义一个普通的变量之前,使用该变量,那么该变量的值就是undefined。
|
||||
|
||||
现在我们知道,**表达式是不会在编译阶段执行的**,那么函数声明是表达式还是语句呢?你可以看下面这段函数声明:
|
||||
|
||||
```
|
||||
function foo(){
|
||||
console.log('Foo')
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
执行上面这段代码,它并没有输出任何内容,所以可以肯定,函数声明并不是一个表达式,而是一个语句。V8在变量提升阶段,如果遇到函数声明,那么V8同样会对该函数声明执行变量提升操作。
|
||||
|
||||
函数也是一个对象,所以在编译阶段,V8就会将整个函数对象提升到作用域中,并不是给该函数名称赋一个undefined,理解这一点尤为重要。
|
||||
|
||||
总的来说,在V8解析JavaScript源码的过程中,如果遇到普通的变量声明,那么便会将其提升到作用域中,并给该变量赋值为undefined,如果遇到的是函数声明,那么V8会在内存中为声明生成函数对象,并将该对象提升到作用域中。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/ec/e6/ec7dc43a09baf57985b1cefda1caf4e6.jpg" alt="">
|
||||
|
||||
## V8是怎么处理函数表达式的?
|
||||
|
||||
了解了函数声明,我们再来看看函数表达式。**我们在一个表达式中使用function来定义一个函数,那么就把该函数称为函数表达式。**
|
||||
|
||||
比如:
|
||||
|
||||
```
|
||||
foo = 1
|
||||
|
||||
```
|
||||
|
||||
它是一个表达式,这时候我们把右边的数字1替换成函数定义,那么这就变成了函数表达式,如下所示:
|
||||
|
||||
```
|
||||
foo = function (){
|
||||
console.log('foo')
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
函数表达式与函数声明的最主要区别有以下三点:
|
||||
|
||||
- 函数表达式是在表达式语句中使用function的,最典型的表达式是“a=b”这种形式,因为函数也是一个对象,我们把“a = function (){}”这种方式称为函数表达式;
|
||||
- 在函数表达式中,可以省略函数名称,从而创建匿名函数(anonymous functions);
|
||||
- 一个函数表达式可以被用作一个即时调用的函数表达式——IIFE(Immediately Invoked Function Expression)。
|
||||
|
||||
了解了函数表达式,我们就来分析这段代码:
|
||||
|
||||
```
|
||||
foo()
|
||||
var foo = function (){
|
||||
console.log('foo')
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
当执行这段代码的时候,V8在编译阶段会先查找声明语句,你可以把这段代码拆分为下面两行代码:
|
||||
|
||||
```
|
||||
var foo = undefined
|
||||
foo = function (){
|
||||
console.log('foo')
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
第一行是声明语句,所以V8在解析阶段,就会在作用域中创建该对象,并将该对象设置为undefined,第二行是函数表达式,在编译阶段,V8并不会处理函数表达式,所以也就不会将该函数表达式提升到作用域中了。
|
||||
|
||||
那么在函数表达式之前调用该函数foo,此时的foo只是指向了undefined,所以就相当于调用一个undefined,而undefined只是一个原生对象,并不是函数,所以当然会报错了。
|
||||
|
||||
## 立即调用的函数表达式(IIFE)
|
||||
|
||||
现在我们知道了,在编译阶段,V8并不会处理函数表达式,而JavaScript中的**立即函数调用表达式**正是使用了这个特性来实现了非常广泛的应用,下面我们就来一起看看立即函数调用表达式。
|
||||
|
||||
JavaScript中有一个圆括号运算符,圆括号里面可以放一个表达式,比如下面的代码:
|
||||
|
||||
```
|
||||
(a=3)
|
||||
|
||||
```
|
||||
|
||||
括号里面是一个表达式,整个语句也是一个表达式,最终输出3。
|
||||
|
||||
如果在小括号里面放上一段函数的定义,如下所示:
|
||||
|
||||
```
|
||||
(function () {
|
||||
//statements
|
||||
})
|
||||
|
||||
```
|
||||
|
||||
因为小括号之间存放的必须是表达式,所以如果在小阔号里面定义一个函数,那么V8就会把这个函数看成是函数表达式,执行时它会返回一个函数对象。
|
||||
|
||||
存放在括号里面的函数便是一个函数表达式,它会返回一个函数对象,如果我直接在表达式后面加上调用的括号,这就称为**立即调用函数表达式**(IIFE),比如下面代码:
|
||||
|
||||
```
|
||||
(function () {
|
||||
//statements
|
||||
})()
|
||||
|
||||
```
|
||||
|
||||
因为函数立即表达式也是一个表达式,所以V8在编译阶段,并不会为该表达式创建函数对象。**这样的一个好处就是不会污染环境,函数和函数内部的变量都不会被其他部分的代码访问到。**
|
||||
|
||||
在ES6之前,JavaScript中没有私有作用域的概念,如果在多人开发的项目中,你模块中的变量可能覆盖掉别人的变量,所以使用函数立即表达式就可以将我们内部变量封装起来,避免了相互之间的变量污染。
|
||||
|
||||
另外,因为函数立即表达式是立即执行的,所以将一个函数立即表达式赋给一个变量时,不是存储 IIFE 本身,而是存储 IIFE 执行后返回的结果。如下所示:
|
||||
|
||||
```
|
||||
var a = (function () {
|
||||
return 1
|
||||
})()
|
||||
|
||||
```
|
||||
|
||||
## 总结
|
||||
|
||||
今天我们主要学习V8是如何处理函数表达式的。函数表达式在实际的项目应用中非常广,不过由于函数声明和函数表达式之间非常类似,非常容易引起人们的误解,所以我们先从通过两段容易让人误解的代码,分析了函数声明和函数表达式之间的区别。函数声明的本质是语句,而函数表达式的本质则是表达式。
|
||||
|
||||
函数声明和变量声明类似,V8在编译阶段,都会对其执行变量提升的操作,将它们提升到作用域中,在执行阶段,如果使用了某个变量,就可以直接去作用域中去查找。
|
||||
|
||||
不过V8对于提升函数和提升变量的策略是不同的,如果提升了一个变量,那么V8在将变量提升到作用域中时,还会为其设置默认值undefined,如果是函数声明,那么V8会在内存中创建该函数对象,并提升整个函数对象。
|
||||
|
||||
函数表达式也是表达式的一种,在编译阶段,V8并不会将表达式中的函数对象提升到全局作用域中,所以无法在函数表达式之前使用该函数。函数立即表达式是一种特别的表达式,主要用来封装一些变量、函数,可以起到变量隔离和代码隐藏的作用,因此在一些大的开源项目中有广泛的应用。
|
||||
|
||||
## 思考题
|
||||
|
||||
留给你一道经典面试题,看看下面这两段代码打印出来的结果是什么?欢迎你在留言区与我分享讨论。
|
||||
|
||||
```
|
||||
var n = 1;
|
||||
(function foo(){
|
||||
n = 100;
|
||||
console.log(n);
|
||||
}())
|
||||
console.log(n);
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
var n = 1;
|
||||
function foo(){
|
||||
n = 100;
|
||||
console.log(n);
|
||||
}
|
||||
console.log(n);
|
||||
foo()
|
||||
|
||||
```
|
||||
|
||||
感谢你的阅读,如果你觉得这一讲的内容对你有所启发,也欢迎把它分享给你的朋友。
|
||||
248
极客时间专栏/图解 Google V8/JavaScript设计思想篇/05|原型链:V8是如何实现对象继承的?.md
Normal file
248
极客时间专栏/图解 Google V8/JavaScript设计思想篇/05|原型链:V8是如何实现对象继承的?.md
Normal file
@@ -0,0 +1,248 @@
|
||||
<audio id="audio" title="05|原型链:V8是如何实现对象继承的?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/26/85/268824a5ccb33a5e0d1a27c31369e485.mp3"></audio>
|
||||
|
||||
你好,我是李兵。
|
||||
|
||||
在前面两节中,我们分析了什么是JavaScript中的对象,以及V8内部是怎么存储对象的,本节我们继续深入学习对象,一起来聊聊V8是如何实现JavaScript中对象继承的。
|
||||
|
||||
简单地理解,**继承就是一个对象可以访问另外一个对象中的属性和方法**,比如我有一个B对象,该对象继承了A对象,那么B对象便可以直接访问A对象中的属性和方法,你可以参考下图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c9/7b/c91e103f535679a4f6901d0b4ff8cb7b.jpg" alt="" title="什么是继承">
|
||||
|
||||
观察上图,因为B继承了A,那么B可以直接使用A中的color属性,就像这个属性是B自带的一样。
|
||||
|
||||
不同的语言实现继承的方式是不同的,其中最典型的两种方式是**基于类的设计**和**基于原型继承的设计**。
|
||||
|
||||
C++、Java、C#这些语言都是基于经典的类继承的设计模式,这种模式最大的特点就是提供了非常复杂的规则,并提供了非常多的关键字,诸如class、friend、protected、private、interface等,通过组合使用这些关键字,就可以实现继承。
|
||||
|
||||
使用基于类的继承时,如果业务复杂,那么你需要创建大量的对象,然后需要维护非常复杂的继承关系,这会导致代码过度复杂和臃肿,另外引入了这么多关键字也给设计带来了更大的复杂度。
|
||||
|
||||
而JavaScript的继承方式和其他面向对象的继承方式有着很大差别,JavaScript本身不提供一个class 实现。虽然标准委员会在 ES2015/ES6 中引入了 class 关键字,但那只是语法糖,JavaScript 的继承依然和基于类的继承没有一点关系。所以当你看到JavaScript出现了class关键字时,不要以为JavaScript也是面向对象语言了。
|
||||
|
||||
JavaScript仅仅在对象中引入了一个原型的属性,就实现了语言的继承机制,基于原型的继承省去了很多基于类继承时的繁文缛节,简洁而优美。
|
||||
|
||||
## 原型继承是如何实现的?
|
||||
|
||||
那么,基于原型继承是如何实现的呢?我们参看下图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/68/ca/687740eecf5aad32403cc00a751233ca.jpg" alt="">
|
||||
|
||||
有一个对象C,它包含了一个属性“type”,那么对象C是可以直接访问它自己的属性type的,这点毫无疑问。
|
||||
|
||||
怎样让C对象像访问自己的属性一样,访问B对象呢?
|
||||
|
||||
上节我们从V8的内存快照看到,JavaScript的每个对象都包含了一个隐藏属性__proto__ ,我们就把该隐藏属性__proto__称之为该**对象的原型(prototype)**,__proto__指向了内存中的另外一个对象,我们就把__proto__指向的对象称为该对象的**原型对象**,那么该对象就可以直接访问其原型对象的方法或者属性。
|
||||
|
||||
比如我让C对象的原型指向B对象,那么便可以利用C对象来直接访问B对象中的属性或者方法了,最终的效果如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/6e/ac/6e0edf92883d97be06a94dc5431967ac.jpg" alt="">
|
||||
|
||||
观察上图,当C对象将它的__proto__属性指向了B对象后,那么通过对象C来访问对象B中的name属性时,V8会先从对象C中查找,但是并没有查找到,接下来V8继续在其原型对象B中查找,因为对象B中包含了name属性,那么V8就直接返回对象B中的name属性值,虽然C和B是两个不同的对象,但是使用的时候,B的属性看上去就像是C的属性一样。
|
||||
|
||||
同样的方式,B也是一个对象,它也有自己的__proto__属性,比如它的属性指向了内存中另外一块对象A,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/63/88/63bd704eb45646e2f46af426196a3d88.jpg" alt="">
|
||||
|
||||
从图中可以看到,对象A有个属性是color,那么通过C.color访问color属性时,V8会先在C对象内部查找,但是没有查找到,接着继续在C对象的原型对象B中查找,但是依然没有查找到,那么继续去对象B的原型对象A中查找,因为color在对象A中,那么V8就返回该属性值。
|
||||
|
||||
我们看到使用C.name和C.color时,给人的感觉属性name和color都是对象C本身的属性,但实际上这些属性都是位于原型对象上,我们把这个查找属性的路径称为**原型链,**它像一个链条一样,将几个原型链接了起来。
|
||||
|
||||
在这里还要注意一点,不要将原型链接和作用域链搞混淆了,作用域链是沿着函数的作用域一级一级来查找变量的,而原型链是沿着对象的原型一级一级来查找属性的,虽然它们的实现方式是类似的,但是它们的用途是不同的,关于作用域链,我会在《06 | 作用域链:V8是如何查找变量的?》这节课来介绍。
|
||||
|
||||
关于继承,还有一种情况,如果我有另外一个对象D,它可以和C共同拥有同一个原型对象B,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/44/4a/44a91019e752ae2e7d6b709562d2554a.jpg" alt="">
|
||||
|
||||
因为对象C和对象D的原型都指向了对象B,所以它们共同拥有同一个原型对象,当我通过D去访问name属性或者color属性时,返回的值和使用对象C访问name属性和color属性是一样的,因为它们是同一个数据。
|
||||
|
||||
我们再来回顾下继承的概念:**继承就是一个对象可以访问另外一个对象中的属性和方法,<strong>在**JavaScript中,我们通过原型和原型链的方式来实现了继承特性。</strong>
|
||||
|
||||
通过上面的分析,你可以看到在JavaScript中的继承非常简洁,就是每个对象都有一个原型属性,该属性指向了原型对象,查找属性的时候,JavaScript虚拟机会沿着原型一层一层向上查找,直至找到正确的属性。所以对于JavaScript中的原型继承,你不需要把它想得过度复杂。
|
||||
|
||||
## 实践:利用__proto__实现继承
|
||||
|
||||
了解了JavaScript中的原型和原型链继承之后,下面我们就可以通过一个例子,看看原型是怎么应用在JavaScript中的,你可以先看下面这段代码:
|
||||
|
||||
```
|
||||
var animal = {
|
||||
type: "Default",
|
||||
color: "Default",
|
||||
getInfo: function () {
|
||||
return `Type is: ${this.type},color is ${this.color}.`
|
||||
}
|
||||
}
|
||||
var dog = {
|
||||
type: "Dog",
|
||||
color: "Black",
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在这段代码中,我创建了两个对象animal和dog,我想让dog对象继承于animal对象,那么最直接的方式就是将dog的原型指向对象animal,应该怎么操作呢?
|
||||
|
||||
我们可以通过设置dog对象中的__proto__属性,将其指向animal,代码是这样的:
|
||||
|
||||
```
|
||||
dog.__proto__ = animal
|
||||
|
||||
```
|
||||
|
||||
设置之后,我们就可以使用dog来调用animal中的getInfo方法了。
|
||||
|
||||
```
|
||||
dog.getInfo()
|
||||
|
||||
```
|
||||
|
||||
你可以尝试调用下,看看输出的内容。在这里留给你一个关于“this”的小思考题:调用dog.getInfo()时,getInfo函数中的this.type和this.color都是什么值?为什么?
|
||||
|
||||
还有一点我们要注意,通常隐藏属性是不能使用JavaScript来直接与之交互的。虽然现代浏览器都开了一个口子,让JavaScript可以访问隐藏属性 _**proto**_,但是在实际项目中,我们不应该直接通过_**proto**_ 来访问或者修改该属性,其主要原因有两个:
|
||||
|
||||
- 首先,这是隐藏属性,并不是标准定义的;
|
||||
- 其次,使用该属性会造成严重的性能问题。
|
||||
|
||||
我们之所以在课程中使用 _**proto**_ 属性,主要是为了方便教学,将其他的一些复杂的概念先抛到一边,这样有利于你循序渐进地掌握我们的课程内容,但是我并不推荐你这么做。那应该怎么去正确地设置对象的原型对象呢?
|
||||
|
||||
答案是使用构造函数来创建对象,下面我们就来详细解释这个过程。
|
||||
|
||||
## 构造函数是怎么创建对象的?
|
||||
|
||||
比如我们要创建一个dog对象,我可以先创建一个DogFactory的函数,属性通过参数进行传递,在函数体内,通过this设置属性值。代码如下所示:
|
||||
|
||||
```
|
||||
function DogFactory(type,color){
|
||||
this.type = type
|
||||
this.color = color
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
然后再结合关键字“new”就可以创建对象了,创建对象的代码如下所示:
|
||||
|
||||
```
|
||||
var dog = new DogFactory('Dog','Black')
|
||||
|
||||
```
|
||||
|
||||
通过这种方式,我们就把后面的函数称为构造函数,因为通过执行new配合一个函数,JavaScript虚拟机便会返回一个对象。如果你没有详细研究过这个问题,很可能对这种操作感到迷惑,为什么通过new关键字配合一个函数,就会返回一个对象呢?
|
||||
|
||||
关于JavaScript为什么要采用这种怪异的写法,我们文章最后再来介绍,先来看看这段代码的深层含义。
|
||||
|
||||
其实当V8执行上面这段代码时,V8会在背后悄悄地做了以下几件事情,模拟代码如下所示:
|
||||
|
||||
```
|
||||
var dog = {}
|
||||
dog.__proto__ = DogFactory.prototype
|
||||
DogFactory.call(dog,'Dog','Black')
|
||||
|
||||
```
|
||||
|
||||
为了加深你的理解,我画了上面这段代码的执行流程图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/19/8c/19c63a16ec6b6bb67f0a7e74b284398c.jpg" alt="">
|
||||
|
||||
观察上图,我们可以看到执行流程分为三步:
|
||||
|
||||
- 首先,创建了一个空白对象dog;
|
||||
- 然后,将DogFactory的prototype属性设置为dog的原型对象,这就是给dog对象设置原型对象的关键一步,我们后面来介绍;
|
||||
- 最后,再使用dog来调用DogFactory,这时候DogFactory函数中的this就指向了对象dog,然后在DogFactory函数中,利用this对对象dog执行属性填充操作,最终就创建了对象dog。
|
||||
|
||||
## 构造函数怎么实现继承?
|
||||
|
||||
好了,现在我们可以通过构造函数来创建对象了,接下来我们就看看构造函数是如何实现继承的?你可以先看下面这段代码:
|
||||
|
||||
```
|
||||
function DogFactory(type,color){
|
||||
this.type = type
|
||||
this.color = color
|
||||
//Mammalia
|
||||
//恒温
|
||||
this.constant_temperature = 1
|
||||
}
|
||||
var dog1 = new DogFactory('Dog','Black')
|
||||
var dog2 = new DogFactory('Dog','Black')
|
||||
var dog3 = new DogFactory('Dog','Black')
|
||||
|
||||
```
|
||||
|
||||
我利用上面这段代码创建了三个dog对象,每个对象都占用了一块空间,占用空间示意图如下所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/9a/2b/9aff57c8992de8b11b70439797a3862b.jpg" alt="">
|
||||
|
||||
从图中可以看出来,对象dog1到dog3中的constant_temperature属性都占用了一块空间,但是这是一个通用的属性,表示所有的dog对象都是恒温动物,所以没有必要在每个对象中都为该属性分配一块空间,我们可以将该属性设置公用的。
|
||||
|
||||
怎么设置呢?
|
||||
|
||||
还记得我们介绍函数时提到关于函数有两个隐藏属性吗?这两个隐藏属性就是name和code,其实函数还有另外一个隐藏属性,那就是prototype,刚才介绍构造函数时我们也提到过。一个函数有以下几个隐藏属性:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ec/e7/ec19366c204bcc0b30b9b46448cbbee7.jpg" alt=""><br>
|
||||
每个函数对象中都有一个公开的prototype属性,当你将这个函数作为构造函数来创建一个新的对象时,新创建对象的原型对象就指向了该函数的prototype属性。当然了,如果你只是正常调用该函数,那么prototype属性将不起作用。
|
||||
|
||||
现在我们知道了新对象的原型对象指向了构造函数的prototype属性,当你通过一个构造函数创建多个对象的时候,这几个对象的原型都指向了该函数的prototype属性,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/1d/4d/1d5e7c1f7006974aec657e8a3e9e864d.jpg" alt="">
|
||||
|
||||
这时候我们可以将constant_temperature属性添加到DogFactory的prototype属性上,代码如下所示:
|
||||
|
||||
```
|
||||
function DogFactory(type,color){
|
||||
this.type = type
|
||||
this.color = color
|
||||
//Mammalia
|
||||
}
|
||||
DogFactory. prototype.constant_temperature = 1
|
||||
var dog1 = new DogFactory('Dog','Black')
|
||||
var dog2 = new DogFactory('Dog','Black')
|
||||
var dog3 = new DogFactory('Dog','Black')
|
||||
|
||||
```
|
||||
|
||||
这样我们三个dog对象的原型对象都指向了prototype,而prototype又包含了constant_temperature属性,这就是我们实现继承的正确方式。
|
||||
|
||||
## 一段关于new的历史
|
||||
|
||||
现在我们知道new关键字结合构造函数,就能生成一个对象,不过这种方式很怪异,为什么要这样呢?要了解这背后的原因,我们需要了解一段关于关于JavaScript的历史。
|
||||
|
||||
JavaScript是Brendan Eich发明的,那是个“战乱”的时代,各种大公司相互争霸,有Sun、微软、网景、甲骨文等公司,它们都有推出自己的语言,其中最炙手可热的编程语言是Sun的Java,而JavaScript就是这个时候诞生的。当时创造JavaScript的目的仅仅是为了让浏览器页面可以动起来,所以尽可能采用简化的方式来设计JavaScript,所以本质上来说,Java和JavaScript的关系就像雷锋和雷峰塔的关系。
|
||||
|
||||
那么之所以叫JavaScript是出于市场原因考量的,因为一门新的语言需要吸引新的开发者,而当时最大的开发者群体就是Java,于是JavaScript就蹭了Java的热度,事后,这一招被证明的确有效果。
|
||||
|
||||
虽然叫JavaScript,但是其编程方式和Java比起来,依然存在着非常大的差异,其中Java中使用最频繁的代码就是创建一个对象,如下所示:
|
||||
|
||||
```
|
||||
CreateInstance instance = new CreateInstance();
|
||||
|
||||
```
|
||||
|
||||
当时JavaScript并没有使用这种方式来创建对象,因为JavaScript中的对象和Java中的对象是完全不一样的,因此,完全没有必要使用关键字new来创建一个新对象的,但是为了进一步吸引Java程序员,依然需要在语法层面去蹭Java热点,所以JavaScript中就被硬生生地强制加入了非常不协调的关键字new,然后使用new来创造对象就变成这样了:
|
||||
|
||||
```
|
||||
var bar = new Foo()
|
||||
|
||||
```
|
||||
|
||||
Java程序员看到这段代码时,当然会感到倍感亲切,觉得Java和JavaScript非常相似,那么使用JavaScript也就天经地义了。不过代码形式只是表象,其背后原理是完全不同的。
|
||||
|
||||
了解了这段历史之后,我们知道JavaScript的new关键字设计并不合理,但是站在市场角度来说,它的出现又是非常成功的,成功地推广了JavaScript。
|
||||
|
||||
## 总结
|
||||
|
||||
好了,今天的主要内容就介绍到这里,下面我们来回顾下。
|
||||
|
||||
今天我们的主要目的是介绍清楚JavaScript中的继承机制,这涉及到了原型继承机制,虽然基于原型的继承机制本身比较简单,但是在JavaScript中,这是通过关键字new加上构造函数来体现的。这种方式非常绕,且不符合人的直觉,如果直接上来就介绍new加构造函数是怎么工作的,可能会把你给绕晕了。
|
||||
|
||||
于是我先通过每个对象中都有的隐含属性__proto__,来介绍了什么是原型和原型链。V8为每个对象都设置了一个__proto__属性,该属性直接指向了该对象的原型对象,原型对象也有自己的__proto__属性,这些属性串连在一起就成了原型链。
|
||||
|
||||
不过在JavaScript中,并不建议直接使用__proto__属性,主要有两个原因。
|
||||
|
||||
- 一,这是隐藏属性,并不是标准定义的;
|
||||
- 二,使用该属性会造成严重的性能问题。
|
||||
|
||||
所以,在JavaScript中,是使用new加上构造函数的这种组合来创建对象和实现对象的继承。不过使用这种方式隐含的语义过于隐晦,所以理解起来有点难度。
|
||||
|
||||
为什么JavaScript中要使用这种怪异的方式来创建对象?为了理解这个问题,我们回顾了一段JavaScript的历史。由于当前的Java非常流行,基于市场推广的考虑,JavaScript采取了蹭Java热度的策略,在语言命名上使用了Java字样,在语法形式上也模仿了Java。事实上通过这些策略,确实为JavaScript带来了市场上的成功。不过你依然要记住,JavaScript和Java是完全两种不同的语言。
|
||||
|
||||
## 思考题
|
||||
|
||||
我们知道函数也是一个对象,所以函数也有自己的__proto__属性,那么今天留给你的思考题是:DogFactory是一个函数,那么“DogFactory.prototype”和“DogFactory._**proto**_”这两个属性之间有关联吗?欢迎你在留言区与我分享讨论。
|
||||
|
||||
感谢你的阅读,如果你觉得这一讲的内容对你有所启发,也欢迎把它分享给你的朋友。
|
||||
209
极客时间专栏/图解 Google V8/JavaScript设计思想篇/06|作用域链:V8是如何查找变量的?.md
Normal file
209
极客时间专栏/图解 Google V8/JavaScript设计思想篇/06|作用域链:V8是如何查找变量的?.md
Normal file
@@ -0,0 +1,209 @@
|
||||
<audio id="audio" title="06|作用域链:V8是如何查找变量的?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/49/24/4928edb48a30bde6ab96cab327763824.mp3"></audio>
|
||||
|
||||
你好,我是李兵。
|
||||
|
||||
在前面我们介绍了JavaScript的继承是基于原型链的,原型链将一个个原型对象串起来,从而实现对象属性的查找,今天我们要聊一个和原型链类似的话题,那就是作用域链。
|
||||
|
||||
作用域链就是将一个个作用域串起来,实现变量查找的路径。讨论作用域链,实际就是在讨论按照什么路径查找变量的问题。
|
||||
|
||||
我们知道,作用域就是存放变量和函数的地方,全局环境有全局作用域,全局作用域中存放了全局变量和全局函数。每个函数也有自己的作用域,函数作用域中存放了函数中定义的变量。
|
||||
|
||||
当在函数内部使用一个变量的时候,V8便会去作用域中去查找。我们通过一段在函数内部查找变量的代码来具体看一下:
|
||||
|
||||
```
|
||||
var name = '极客时间'
|
||||
var type = 'global'
|
||||
|
||||
|
||||
function foo(){
|
||||
var name = 'foo'
|
||||
console.log(name)
|
||||
console.log(type)
|
||||
}
|
||||
|
||||
|
||||
function bar(){
|
||||
var name = 'bar'
|
||||
var type = 'function'
|
||||
foo()
|
||||
}
|
||||
bar()
|
||||
|
||||
```
|
||||
|
||||
在这段代码中,我们在全局环境中声明了变量name和type,同时还定义了bar函数和foo函数,在bar函数中又再次定义了变量name和type,在foo函数中再次定义了变量name。
|
||||
|
||||
函数的调用关系是:在全局环境中调用bar函数,在bar函数中调用foo函数,在foo函数中打印出来变量name和type的值。
|
||||
|
||||
当执行到foo函数时,首先需要打印出变量name的值,而我们在三个地方都定义了变量name,那么究竟应该使用哪个变量呢?
|
||||
|
||||
在foo函数中使用了变量name,那么V8就应该先使用foo函数内部定义的变量name,最终的结果确实如此,也符合我们的直觉。
|
||||
|
||||
接下来,foo函数继续打印变量type,但是在foo函数内部并没有定义变量type,而是在全局环境中和调用foo函数的bar函数中分别定义了变量type,那么这时候的问题来了,你觉得foo函数中打印出来的变量type是bar函数中的,还是全局环境中的呢?
|
||||
|
||||
## 什么是函数作用域和全局作用域?
|
||||
|
||||
要解释清楚这个问题,我们需要从作用域的工作原理讲起。
|
||||
|
||||
每个函数在执行时都需要查找自己的作用域,我们称为函数作用域,在执行阶段,在执行一个函数时,当该函数需要使用某个变量或者调用了某个函数时,便会优先在该函数作用域中查找相关内容。
|
||||
|
||||
我们再来看一段代码:
|
||||
|
||||
```
|
||||
var x = 4
|
||||
var test
|
||||
function test_scope() {
|
||||
var name = 'foo'
|
||||
console.log(name)
|
||||
console.log(type)
|
||||
console.log(test)
|
||||
var type = 'function'
|
||||
test = 1
|
||||
console.log(x)
|
||||
}
|
||||
test_scope()
|
||||
|
||||
```
|
||||
|
||||
在上面的代码中,我们定义了一个test_scope函数,那么在V8执行test_scope函数的时候,在编译阶段会为test_scope函数创建一个作用域,在test_scope函数中定义的变量和声明的函数都会丢到该作用域中,因为我们在test_scope函数中定了三个变量,那么常见的作用域就包含有这三个变量。
|
||||
|
||||
你可以通过Chrome的控制台来直观感受下test_scope函数的作用域,先打开包含这段代码的页面,然后打开开发者工具,接着在test_scope函数中的第二段代码加上断点,然后刷新该页面。当执行到该断点时,V8会暂停整个执行流程,这时候我们就可以通过右边的区域面板来查看当前函数的执行状态。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e3/5a/e346a7664d91a80e5d694d20ea21275a.png" alt="" title="观察作用域">
|
||||
|
||||
你可以参考图中右侧的Scope项,然后点击展开该项,这个Local就是当前函数test_scope的作用域。在test_scope函数中定义的变量都包含到了Local中,如变量name、type,另外系统还为我们添加了另外一个隐藏变量this,V8还会默认将隐藏变量this存放到作用域中。
|
||||
|
||||
另外你还需要注意下,第一个test1,我并没有采用var等关键字来声明,所以test1并不会出现在test_scope函数的作用域中,而是属于this所指向的对象。(this的工作机制不是本文讨论的重点,不展开介绍。如果你感兴趣,可以在《浏览器工作原理与实践》专栏中《[11 | this:从JavaScript执行上下文的视角讲清楚this](https://time.geekbang.org/column/article/128427)》这一讲查看。)
|
||||
|
||||
那么另一个问题来了,我在test_scope函数使用了变量x,但是在test_scope函数的作用域中,并没有定义变量x,那么V8应该如何获取变量x?
|
||||
|
||||
如果在当前函数作用域中没有查找到变量,那么V8会去全局作用域中去查找,这个查找的线路就称为作用域链。
|
||||
|
||||
全局作用域和函数作用域类似,也是存放变量和函数的地方,但是它们还是有点不一样: **全局作用域是在V8启动过程中就创建了,且一直保存在内存中不会被销毁的,直至V8退出。** **而函数作用域是在执行该函数时创建的,当函数执行结束之后,函数作用域就随之被销毁掉了**。
|
||||
|
||||
全局作用域中包含了很多全局变量,比如全局的this值,如果是浏览器,全局作用域中还有window、document、opener等非常多的方法和对象,如果是node环境,那么会有Global、File等内容。
|
||||
|
||||
V8启动之后就进入正常的消息循环状态,这时候就可以执行代码了,比如执行到上面那段脚本时,V8会先解析顶层(Top Level)代码,我们可以看到,在顶层代码中定义了变量x,这时候V8就会将变量x添加到全局作用域中。
|
||||
|
||||
## 作用域链是怎么工作的?
|
||||
|
||||
理解了作用域和作用域链,我们再回过头来看文章开头的那道思考题: “foo函数中打印出来的变量type是bar函数中的呢,还是全局环境中的呢?”我把这段代码复制到下面:
|
||||
|
||||
```
|
||||
var name = '极客时间'
|
||||
var type = 'global'
|
||||
|
||||
|
||||
function foo(){
|
||||
var name = 'foo'
|
||||
console.log(name)
|
||||
console.log(type)
|
||||
}
|
||||
|
||||
|
||||
function bar(){
|
||||
var name = 'bar'
|
||||
var type = 'function'
|
||||
foo()
|
||||
}
|
||||
bar()
|
||||
|
||||
```
|
||||
|
||||
现在,我们结合V8执行这段代码的流程来具体分析下。首先当V8启动时,会创建全局作用域,全局作用域中包括了this、window等变量,还有一些全局的Web API接口,创建的作用域如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/58/77/589622b2f517ce06487d3edbe28cf277.jpg" alt="" title="全局作用域">
|
||||
|
||||
V8启动之后,消息循环系统便开始工作了,这时候,我输入了这段代码,让其执行。
|
||||
|
||||
V8会先编译顶层代码,在编译过程中会将顶层定义的变量和声明的函数都添加到全局作用域中,最终的全局作用域如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/53/e8/532151cb2713a229550af215962deee8.jpg" alt="" title="全局作用域">
|
||||
|
||||
全局作用域创建完成之后,V8便进入了执行状态。前面我们介绍了变量提升,因为变量提升的原因,你可以把上面这段代码分解为如下两个部分:
|
||||
|
||||
```
|
||||
//======解析阶段--实现变量提升=======
|
||||
var name = undefined
|
||||
var type = undefined
|
||||
function foo(){
|
||||
var name = 'foo'
|
||||
console.log(name)
|
||||
console.log(type)
|
||||
}
|
||||
function bar(){
|
||||
var name = 'bar'
|
||||
var type = 'function'
|
||||
foo()
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
//====执行阶段========
|
||||
name = '极客时间'
|
||||
type = 'global'
|
||||
bar()
|
||||
|
||||
```
|
||||
|
||||
第一部分是在编译过程中完成的,此时全局作用中两个变量的值依然是undefined,然后进入执行阶段;第二部代码就是执行时的顺序,首先全局作用域中的两个变量赋值“极客时间”和“global”,然后就开始执行函数bar的调用了。
|
||||
|
||||
当V8执行bar函数的时候,同样需要经历两个阶段:编译和执行。在编译阶段,V8会为bar函数创建函数作用域,最终效果如下所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e2/ce/e2958bebf2ef52023c5e514259ae2cce.jpg" alt="" title="bar函数作用域">
|
||||
|
||||
然后进入了bar函数的执行阶段。在bar函数中,只是简单地调用foo函数,因此V8又开始执行foo函数了。
|
||||
|
||||
同样,在编译foo函数的过程中,会创建foo函数的作用域,最终创建效果如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/9d/d5/9dc20e0f38d04ae96296787c7190cad5.jpg" alt="" title="foo函数作用域">
|
||||
|
||||
好了,这时候我们就有了三个作用域了,分别是全局作用域、bar的函数作用域、foo的函数作用域。
|
||||
|
||||
现在我们就可以将刚才提到的问题转换为作用域链的问题了:foo函数查找变量的路径到底是什么?
|
||||
|
||||
- 沿着foo函数作用域–>bar函数作用域–>全局作用域;
|
||||
- 还是,沿着foo函数作用域—>全局作用域?
|
||||
|
||||
因为JavaScript是基于词法作用域的,词法作用域就是指,查找作用域的顺序是按照函数定义时的位置来决定的。bar和foo函数的外部代码都是全局代码,所以无论你是在bar函数中查找变量,还是在foo函数中查找变量,其查找顺序都是按照当前函数作用域–>全局作用域这个路径来的。
|
||||
|
||||
由于我们代码中的foo函数和bar函数都是在全局下面定义的,所以在foo函数中使用了type,最终打印出来的值就是全局作用域中的type。
|
||||
|
||||
你可以参考下面这张图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/82/8c/82c84c81f8c94915d4965ce38d285e8c.jpg" alt="">
|
||||
|
||||
另外,我再展开说一些。因为词法作用域是根据函数在代码中的位置来确定的,作用域是在声明函数时就确定好的了,所以我们也将词法作用域称为静态作用域。
|
||||
|
||||
和静态作用域相对的是动态作用域,动态作用域并不关心函数和作用域是如何声明以及在何处声明的,只关心它们从**何处调用**。换句话说,作用域链是基于调用栈的,而不是基于函数定义的位置的。(动态作用域不是本文讨论的重点,如果你感兴趣,可以参考《浏览器工作原理与实践》专栏中的《[10 | 作用域链和闭包 :代码中出现相同的变量,JavaScript引擎是如何选择的?](https://time.geekbang.org/column/article/127495)》这一节。)
|
||||
|
||||
## 总结
|
||||
|
||||
今天,我们主要解释了一个问题,那就是在一个函数中,如果使用了一个变量,或者调用了另外一个函数,V8将会怎么去查找该变量或者函数。
|
||||
|
||||
为了解释清楚这个问题,我们引入了作用域的概念。作用域就是用来存放变量和函数的地方,全局作用域中存放了全局环境中声明的变量和函数,函数作用域中存放了函数中声明的变量和函数。当在某个函数中使用某个变量时,V8就会去这些作用域中查找相关变量。沿着这些作用域查找的路径,我们就称为作用域链。
|
||||
|
||||
要了解查找路径,我们需要明白词法作用域,词法作用域是按照代码定义时的位置决定的,而JavaScript所采用的作用域机制就是词法作用域,所以作用域链的路径就是按照词法作用域来实现的。
|
||||
|
||||
## 思考题
|
||||
|
||||
我将文章开头那段代码稍微调整了下,foo函数并不是在全局环境中声明的,而是在bar函数中声明的,改造后的代码如下所示:
|
||||
|
||||
```
|
||||
var name = '极客时间'
|
||||
var type = 'global'
|
||||
function bar() {
|
||||
var type = 'function'
|
||||
function foo() {
|
||||
console.log(type)
|
||||
}
|
||||
foo()
|
||||
}
|
||||
bar()
|
||||
|
||||
```
|
||||
|
||||
那么执行这段代码之后,打印出来的内容是什么?欢迎你在留言区与我分享讨论。
|
||||
|
||||
感谢你的阅读,如果你觉得这一讲的内容对你有所启发,也欢迎把它分享给你的朋友。
|
||||
225
极客时间专栏/图解 Google V8/JavaScript设计思想篇/07|类型转换:V8是怎么实现1+“2”的?.md
Normal file
225
极客时间专栏/图解 Google V8/JavaScript设计思想篇/07|类型转换:V8是怎么实现1+“2”的?.md
Normal file
@@ -0,0 +1,225 @@
|
||||
<audio id="audio" title="07|类型转换:V8是怎么实现1+“2”的?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/90/5c/90577bbf77f238c7a7ac2a56bb048a5c.mp3"></audio>
|
||||
|
||||
你好,我是李兵。
|
||||
|
||||
前面我们花了很多篇幅聊了JavaScript中最基础却很容易被忽略的“对象”,以及V8是怎么处理“对象”的,今天我们继续来聊另一个非常基础,同时也是很容易被大家忽略的问题,那就是JavaScript中的“类型系统”。
|
||||
|
||||
在理解这个概念之前,你可以先思考一个简单的表达式,那就是在JavaScript中,“1+‘2’等于多少?”
|
||||
|
||||
其实这相当于是在问,在JavaScript中,让数字和字符串相加是会报错,还是可以正确执行。如果能正确执行,那么结果是等于数字3,还是字符串“3”,还是字符串“12”呢?
|
||||
|
||||
如果你尝试在Python中使用数字和字符串进行相加操作,那么Python虚拟机会直接返回一个执行错误,错误提示是这样的:
|
||||
|
||||
```
|
||||
>>> 1+'2'
|
||||
Traceback (most recent call last):
|
||||
File "<stdin>", line 1, in <module>
|
||||
TypeError: unsupported operand type(s) for +: 'int' and 'str'
|
||||
|
||||
```
|
||||
|
||||
这段错误代码提示了这是个类型错误,表明Python并不支持数字类型和字符串类型相加的操作。
|
||||
|
||||
不过在JavaScript中执行这段表达式,是可以返回一个结果的,最终返回的结果是字符串“12”。
|
||||
|
||||
最终结果如下所示:
|
||||
|
||||
```
|
||||
>>> 1+'2'
|
||||
>>> "12"
|
||||
|
||||
```
|
||||
|
||||
为什么同样一条的表达式,在Python和JavaScript中执行会有不同的结果?为什么在JavaScript中执行,输出的是字符串“12”,不是数字3或者字符串“3”呢?
|
||||
|
||||
## 什么是类型系统(Type System)?
|
||||
|
||||
在这个简单的表达式中,涉及到了两种不同类型的数据的相加。要想理清以上两个问题,我们就需要知道类型的概念,以及JavaScript操作类型的策略。
|
||||
|
||||
对机器语言来说,所有的数据都是一堆二进制代码,CPU处理这些数据的时候,并没有类型的概念,CPU所做的仅仅是移动数据,比如对其进行移位,相加或相乘。
|
||||
|
||||
而在高级语言中,我们都会为操作的数据赋予指定的类型,类型可以确认一个值或者一组值具有特定的意义和目的。所以,类型是高级语言中的概念。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e0/3f/e0dfa246212ec1070ac8aac6bc0f1a3f.jpg" alt="">
|
||||
|
||||
比如在C/C++中,你需要为要处理的每条数据指定类型,这样定义变量:
|
||||
|
||||
```
|
||||
int counter = 100 # 赋值整型变量
|
||||
float miles = 1000.0 # 浮点型
|
||||
char* name = "John" # 字符串
|
||||
|
||||
```
|
||||
|
||||
C/C++编译器负责将这些数据片段转换为供CPU处理的正确命令,通常是二进制的机器代码。
|
||||
|
||||
在某些更高级的语言中,还可以根据数据推断出类型,比如在Python或JavaScript中,你就不必为数据指定专门的数据类型,在Python中,你可以这样定义变量:
|
||||
|
||||
```
|
||||
counter = 100 # 赋值整型变量
|
||||
miles = 1000.0 # 浮点型
|
||||
name = "John" # 字符串
|
||||
|
||||
```
|
||||
|
||||
在JavaScript中,你可以这样定义变量:
|
||||
|
||||
```
|
||||
var counter = 100 # 赋值整型变量
|
||||
let miles = 1000.0 # 浮点型
|
||||
const name = "John" # 字符串
|
||||
|
||||
```
|
||||
|
||||
虽然Python和JavaScript定义变量的方式不同,但是它们都不需要直接指定变量的类型,因为虚拟机会根据数据自动推导出类型。
|
||||
|
||||
通用的类型有数字类型、字符串、Boolean类型等等,引入了这些类型之后,编译器或者解释器就可以根据类型来限制一些有害的或者没有意义的操作。
|
||||
|
||||
比如在Python语言中,如果使用字符串和数字相加就会报错,因为Python觉得这是没有意义的。而在JavaScript中,字符串和数字相加是有意义的,可以使用字符串和数字进行相加的。再比如,你让一个字符串和一个字符串相乘,这个操作是没有意义的,所有语言几乎都会禁止该操作。
|
||||
|
||||
每种语言都定义了自己的类型,还定义了如何操作这些类型,另外还定义了这些类型应该如何相互作用,我们就把这称为**类型系统**。
|
||||
|
||||
关于类型系统,[wiki百科](https://zh.wikipedia.org/zh-cn/%E9%A1%9E%E5%9E%8B%E7%B3%BB%E7%B5%B1)上是这样解释的:
|
||||
|
||||
>
|
||||
在计算机科学中,类型系统(type system)用于定义如何将编程语言中的数值和表达式归类为许多不同的类型,如何操作这些类型,这些类型如何互相作用。
|
||||
|
||||
|
||||
直观地理解,一门语言的类型系统定义了各种类型之间应该如何相互操作,比如,两种不同类型相加应该如何处理,两种相同的类型相加又应该如何处理等。还规定了各种不同类型应该如何相互转换,比如字符串类型如何转换为数字类型。
|
||||
|
||||
一个语言的类型系统越强大,那编译器能帮程序员检查的东西就越多,程序员定义“检查规则”的方式就越灵活。
|
||||
|
||||
## V8是怎么执行加法操作的?
|
||||
|
||||
了解了JavaScript中的类型系统,接下来我们就可以来看看V8是怎么处理1+“2”的了。
|
||||
|
||||
当有两个值相加的时候,比如:
|
||||
|
||||
```
|
||||
a+b
|
||||
|
||||
```
|
||||
|
||||
V8会严格根据ECMAScript规范来执行操作。ECMAScript是一个语言标准,JavaScript就是ECMAScript的一个实现,比如在ECMAScript就定义了怎么执行加法操作,如下所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/2d/98/2d483835d08d2a9d5143d26e09ad4a98.png" alt="" title="ECMAScript定义加法语义">
|
||||
|
||||
具体细节你也可以[参考规范](https://www.ecma-international.org/ecma-262/6.0/index.html#sec-addition-operator-plus-runtime-semantics-evaluation),我将标准定义的内容翻译如下:
|
||||
|
||||
>
|
||||
AdditiveExpression : AdditiveExpression + MultiplicativeExpression
|
||||
|
||||
|
||||
1. 把第一个表达式(AdditiveExpression)的值赋值给左引用(lref)。
|
||||
1. 使用GetValue(lref)获取左引用(lref)的计算结果,并赋值给左值。
|
||||
1. 使用[ReturnIfAbrupt](https://www.ecma-international.org/ecma-262/6.0/index.html#sec-returnifabrupt)(lval)如果报错就返回错误。
|
||||
1. 把第二个表达式(MultiplicativeExpression)的值赋值给右引用(rref)。
|
||||
1. 使用GetValue(rref)获取右引用(rref)的计算结果,并赋值给rval。
|
||||
1. 使用[ReturnIfAbrupt](https://www.ecma-international.org/ecma-262/6.0/index.html#sec-returnifabrupt)(rval)如果报错就返回错误。
|
||||
1. 使用ToPrimitive(lval)获取左值(lval)的计算结果,并将其赋值给左原生值(lprim)。
|
||||
1. 使用ToPrimitive(rval)获取右值(rval)的计算结果,并将其赋值给右原生值(rprim)。
|
||||
<li>如果Type(lprim)和Type(rprim)中有一个是String,则:<br>
|
||||
a. 把ToString(lprim)的结果赋给左字符串(lstr);<br>
|
||||
b. 把ToString(rprim)的结果赋给右字符串(rstr);<br>
|
||||
c. 返回左字符串(lstr)和右字符串(rstr)拼接的字符串。</li>
|
||||
1. 把ToNumber(lprim)的结果赋给左数字(lnum)。
|
||||
1. 把ToNumber(rprim)的结果赋给右数字(rnum)。
|
||||
1. 返回左数字(lnum)和右数字(rnum)相加的数值。
|
||||
|
||||
通俗地理解,V8会提供了一个ToPrimitive方法,其作用是将a和b转换为原生数据类型,其转换流程如下:
|
||||
|
||||
- 先检测该对象中是否存在valueOf方法,如果有并返回了原始类型,那么就使用该值进行强制类型转换;
|
||||
- 如果valueOf没有返回原始类型,那么就使用toString方法的返回值;
|
||||
- 如果vauleOf和toString两个方法都不返回基本类型值,便会触发一个TypeError的错误。
|
||||
|
||||
将对象转换为原生类型的流程图如下所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d1/aa/d150309b74f2c06e66011cf3e177dbaa.jpg" alt="">
|
||||
|
||||
当V8执行1+“2”时,因为这是两个原始值相加,原始值相加的时候,如果其中一项是字符串,那么V8会默认将另外一个值也转换为字符串,相当于执行了下面的操作:
|
||||
|
||||
```
|
||||
Number(1).toString() + "2"
|
||||
|
||||
```
|
||||
|
||||
这里,把数字1偷偷转换为字符串“1”的过程也称为强制类型转换,因为这种转换是隐式的,所以如果我们不熟悉语义,那么就很容易判断错误。
|
||||
|
||||
我们还可以再看一个例子来验证上面流程,你可以看下面的代码:
|
||||
|
||||
```
|
||||
var Obj = {
|
||||
toString() {
|
||||
return '200'
|
||||
},
|
||||
valueOf() {
|
||||
return 100
|
||||
}
|
||||
}
|
||||
Obj+3
|
||||
|
||||
```
|
||||
|
||||
执行这段代码,你觉得应该返回什么内容呢?
|
||||
|
||||
上面我们介绍过了,由于需要先使用ToPrimitive方法将Obj转换为原生类型,而ToPrimitive会优先调用对象中的valueOf方法,由于valueOf返回了100,那么Obj就会被转换为数字100,那么数字100加数字3,那么结果当然是103了。
|
||||
|
||||
如果我改造下代码,让valueOf方法和toString方法都返回对象,其改造后的代码如下:
|
||||
|
||||
```
|
||||
var Obj = {
|
||||
toString() {
|
||||
return new Object()
|
||||
},
|
||||
valueOf() {
|
||||
return new Object()
|
||||
}
|
||||
}
|
||||
Obj+3
|
||||
|
||||
```
|
||||
|
||||
再执行这段代码,你觉得应该返回什么内容呢?
|
||||
|
||||
因为ToPrimitive会先调用valueOf方法,发现返回的是一个对象,并不是原生类型,当ToPrimitive继续调用toString方法时,发现toString返回的也是一个对象,都是对象,就无法执行相加运算了,这时候虚拟机就会抛出一个异常,异常如下所示:
|
||||
|
||||
```
|
||||
VM263:9 Uncaught TypeError: Cannot convert object to primitive value
|
||||
at <anonymous>:9:6
|
||||
|
||||
```
|
||||
|
||||
提示的是类型错误,错误原因是无法将对象类型转换为原生类型。
|
||||
|
||||
所以说,在执行加法操作的时候,V8会通过ToPrimitive方法将对象类型转换为原生类型,最后就是两个原生类型相加,如果其中一个值的类型是字符串时,则另一个值也需要强制转换为字符串,然后做字符串的连接运算。在其他情况时,所有的值都会转换为数字类型值,然后做数字的相加。
|
||||
|
||||
## 总结
|
||||
|
||||
今天我们主要学习了JavaScript中的类型系统是怎么工作的。类型系统定义了语言应当如何操作类型,以及这些类型如何互相作用。因为Python和JavaScript的类型系统差异,所以当处理同样的表达式时,返回的结果是不同的。
|
||||
|
||||
在Python中,数字和字符串相加会抛出异常,这是因为Python认为字符串和数字相加是无意义的,所以限制其操作。
|
||||
|
||||
在JavaScript中,数字和字符串相加会返回一个新的字符串,这是因为JavaScript认为字符串和数字相加是有意义的,V8会将其中的数字转换为字符,然后执行两个字符串的相加操作,最终得到的是一个新的字符串。
|
||||
|
||||
在JavaScript中,类型系统是依据ECMAScript标准来实现的,所以V8会严格根据ECMAScript标准来执行。在执行加法过程中,V8会先通过ToPrimitive函数,将对象转换为原生的字符串或者是数字类型,在转换过程中,ToPrimitive会先调用对象的valueOf方法,如果没有valueOf方法,则调用toString方法,如果vauleOf和toString两个方法都不返回基本类型值,便会触发一个TypeError的错误。
|
||||
|
||||
## 思考题
|
||||
|
||||
我们一起来分析一段代码:
|
||||
|
||||
```
|
||||
var Obj = {
|
||||
toString() {
|
||||
return "200"
|
||||
},
|
||||
valueOf() {
|
||||
return 100
|
||||
}
|
||||
}
|
||||
Obj+"3"
|
||||
|
||||
```
|
||||
|
||||
你觉得执行这段代码会打印出什么内容呢?欢迎你在留言区与我分享讨论。
|
||||
|
||||
感谢你的阅读,如果你觉得这一讲的内容对你有所启发,也欢迎把它分享给你的朋友。
|
||||
397
极客时间专栏/图解 Google V8/JavaScript设计思想篇/08|答疑:如何构建和使用V8的调试工具d8?.md
Normal file
397
极客时间专栏/图解 Google V8/JavaScript设计思想篇/08|答疑:如何构建和使用V8的调试工具d8?.md
Normal file
@@ -0,0 +1,397 @@
|
||||
<audio id="audio" title="08|答疑:如何构建和使用V8的调试工具d8?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b3/22/b37372984fef17e8f13b8313b43eff22.mp3"></audio>
|
||||
|
||||
你好,我是李兵。
|
||||
|
||||
今天是我们第一单元的答疑环节,课后有很多同学留言问我关于d8的问题,所以今天我们就来专门讲讲,如何构建和使用V8的调试工具d8。
|
||||
|
||||
d8是一个非常有用的调试工具,你可以把它看成是debug for V8的缩写。我们可以使用d8来查看V8在执行JavaScript过程中的各种中间数据,比如作用域、AST、字节码、优化的二进制代码、垃圾回收的状态,还可以使用d8提供的私有API查看一些内部信息。
|
||||
|
||||
## 如何通过V8的源码构建D8?
|
||||
|
||||
通常,我们没有直接获取d8的途径,而是需要通过编译V8的源码来生成d8,接下来,我们就先来看看如何构建d8。
|
||||
|
||||
其实并不难,总的来说,大体分为三部分。首先我们需要先下载V8的源码,然后再生成工程文件,最后编译V8的工程并生成d8。
|
||||
|
||||
接下来我们就来具体操作一下。考虑到使用Windows系统的同学比较多,所以下面的操作,我们的默认环境是Windows系统,Mac OS和Liunx的配置会简单一些。
|
||||
|
||||
### 安装VPN
|
||||
|
||||
V8并不是一个单一的版本库,它还引用了很多第三方的版本库,大多是版本库我们都无法直接访问,所以,在下载代码过程中,你得先准备一个VPN。
|
||||
|
||||
### 下载编译工具链:depot_tools
|
||||
|
||||
有了VPN,接下来我们需要下载编译工具链:**depot_tools**,后续V8源码的下载、配置和编译都是由depot_tools来完成的,你可以直接点击下载:[depot_tools bundle](https://storage.googleapis.com/chrome-infra/depot_tools.zip)。
|
||||
|
||||
depot_tools压缩包下载到本地之后,解压压缩包,比如你解压到以下这个路径中:
|
||||
|
||||
```
|
||||
C:\src\depot_tools
|
||||
|
||||
```
|
||||
|
||||
然后需要将这个路径添加到环境变量中,这样我们就可以在控制台中使用gclient了。
|
||||
|
||||
### 设置环境变量
|
||||
|
||||
接下来,还需要往系统环境变量中添加变量 DEPOT_TOOLS_WIN_TOOLCHAIN ,值设为 0。
|
||||
|
||||
```
|
||||
DEPOT_TOOLS_WIN_TOOLCHAIN = 0
|
||||
|
||||
```
|
||||
|
||||
这个环境变量的作用是告诉 deppt_tools,使用本地已安装的默认的 Visual Studio 版本去编译,否则 depot_tools 会使用 Google 内部默认的版本。
|
||||
|
||||
然后你可以在命令行中测试下是否可以使用:
|
||||
|
||||
```
|
||||
gclient sync
|
||||
|
||||
```
|
||||
|
||||
### 安装VS2019
|
||||
|
||||
在Windows系统下面,depot_tools使用了VS2019,因为VS2019自带了编译V8的编译器,所以需要安装VS2019时,安装时,你需要选择以下两项内容:
|
||||
|
||||
- Desktop development with C++;
|
||||
- MFC/ATL support。
|
||||
|
||||
因为编译V8时,使用了这两项所提供的基础开发环境。
|
||||
|
||||
### 下载V8源码
|
||||
|
||||
安装了VS2019,接下来就可以使用depot_tools来下载V8源码了,具体下载命令如下所示:
|
||||
|
||||
```
|
||||
d:
|
||||
mkdir v8
|
||||
cd v8
|
||||
fetch v8
|
||||
cd v8
|
||||
|
||||
```
|
||||
|
||||
执行这个命令就会开始下载V8源码,这个过程可能比较漫长,下载时间主要取决于你的网速和VPN的速度。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f2/19/f229112fb9b09b1e21311bde117e2d19.png" alt="">
|
||||
|
||||
### 配置工程
|
||||
|
||||
代码下载完成之后,就需要配置工程了,我们使用gn来配置。
|
||||
|
||||
```
|
||||
cd v8
|
||||
|
||||
|
||||
gn gen out.gn/x64.release --args='is_debug=false target_cpu="x64" v8_target_cpu="arm64" use_goma=true'
|
||||
|
||||
```
|
||||
|
||||
如果是Mac系统,你可以使用:
|
||||
|
||||
```
|
||||
gn gen out/gn --ide=xcode
|
||||
|
||||
```
|
||||
|
||||
用它来生成工程。
|
||||
|
||||
gn是一个跨平台的构建系统,用来构建Ninja工程,Ninja是一个跨平台的编译系统,比如可以通过gn构建Chromium还有V8的工程文件,然后使用Ninja来执行编译,可以使用gn和Ninja来配合使用构建跨平台的工程,这些工程可以在MacOS、Linux、Windows等平台上进行编译。在gn之前,Google使用了gyp来构建,由于gn的效率更高,所以现在都在使用gn。
|
||||
|
||||
下面我们来看下生成V8工程的一些基础配置项:
|
||||
|
||||
- is_debug = false 编译成release版本;
|
||||
- is_component_build = true 编译成动态链接库而不是很大的可执行文件;
|
||||
- symbol_level = 0 将所有的debug符号放在一起,可以加速二次编译,并加速链接过程;
|
||||
- ide = vs2019 ide=xcode。
|
||||
|
||||
工程生成好之后,你就可以去 v8\out.gn\x64.release 这个目录下查看生成的工程文件。如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/78/2b/787e04fce091f53725de70e751c26a2b.png" alt="">
|
||||
|
||||
### 编译d8
|
||||
|
||||
生成了d8的工程配置文件,接下来就可以编译d8了,你可以使用下面的命令:
|
||||
|
||||
```
|
||||
ninja -C out.gn/x64.release
|
||||
|
||||
```
|
||||
|
||||
如果想编译特定目标,比如d8,可以使用下面的命令:
|
||||
|
||||
```
|
||||
ninja -C out.gn/x64.release d8
|
||||
|
||||
```
|
||||
|
||||
这个命令只会编译和d8所依赖的工程,然后就开始执行编译流程了。如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/6d/a7/6de500efbc98810829b3721e1450cca7.png" alt="">
|
||||
|
||||
编译时间取决于你硬盘读写速度和CPU的个数,比如我的电脑是10核CPU,ssd硬盘,整个编译过程大概花费了15分钟。
|
||||
|
||||
最终编译结束之后,你就可以去 v8\out.gn\x64.release 查看生成的文件,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/67/65/675b203ea4764f1f46c71efc523be765.png" alt="">
|
||||
|
||||
我们可以看到d8也在其中。
|
||||
|
||||
我将编译出来的d8也放到了网上,如果你不想编译,也可以点击[这里](https://github.com/binaryacademy/geektime-v8/blob/master/out.gn.rar)直接下载使用。
|
||||
|
||||
## 如何使用d8?
|
||||
|
||||
好了,现在我们编译出来了d8 ,接下来我们将d8所在的目录,v8\out.gn\x64.release添加到环境变量“PATH”的路径中,这样我们就可以在控制台中使用d8了。
|
||||
|
||||
我们先来测试下能不能使用d8,你可以使用下面这个命令,在控制台中执行d8:
|
||||
|
||||
```
|
||||
d8 --help
|
||||
|
||||
```
|
||||
|
||||
最终显示出来了一大堆命令,如下所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/db/ee/db12c94880607238b0c7bb2815f5feee.png" alt="">
|
||||
|
||||
我们可以看到上图中打印出来了很多行,每一行其实都对应着一个命令,比如`print-bytecode`就是查看生成的字节码,`print-opt-code`是要查看优化后的代码,`turbofan-stats`是打印出来优化编译器的一些统计数据的命令,每个命令后面都有一个括号,括号里面是介绍这个命令的具体用途。
|
||||
|
||||
使用d8进行调试方式如下:
|
||||
|
||||
```
|
||||
d8 test.js --print-bytecode
|
||||
|
||||
```
|
||||
|
||||
d8后面跟上文件名和要执行的命令,如果执行上面这行命令,就会打印出test.js文件所生成的字节码。
|
||||
|
||||
不过,通过d8 --help打印出来的列表非常长,如果过滤特定的命令,你可以使用下面的命令来查看:
|
||||
|
||||
```
|
||||
d8 --help |grep print
|
||||
|
||||
```
|
||||
|
||||
这样我们就能查看d8有多少关于print的命令,如果你使用了Windows系统,可能缺少grep程序,你可以去[这里](https://sourceforge.net/projects/gnuwin32/files/grep/2.5.4/grep-2.5.4-setup.exe/download?use_mirror=managedway)下载。
|
||||
|
||||
安装完成之后,记得手动将grep 程序所在的目录添加到环境变量PATH中,这样才能在控制台使用grep 命令。
|
||||
|
||||
最终打印出来带有print字样的命令,包含以下内容:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f3/4d/f3aac9c42b46bffec88f564f461f2a4d.png" alt="">
|
||||
|
||||
d8的命令很多,如果有时间,你可以逐一试下。接下来下面我们挑其中一些重点的命令来介绍下,比如`trace-gc`,`trace-opt-verbose`。这些命令涉及到了编译流水线的中间数据,垃圾回收器执行状态等,熟悉使用这些命令可以帮助我们更加深刻理解编译流水线和垃圾回收器的执行状态。
|
||||
|
||||
在使用d8执行一段代码之前,你需要将你的JavaScript源码保存到一个js文件中,我们把所需要需要观察的代码都存放到test.js这个文件中。
|
||||
|
||||
### 打印优化数据
|
||||
|
||||
你可以使用`--print-ast`来查看中间生成的AST,使用`---print-scope`来查看中间生成的作用域,`--print-bytecode`来查看中间生成的字节码。除了这些数据之外,**我们还可以使用d8来打印一些优化的数据**,比如下面这样一段代码:
|
||||
|
||||
```
|
||||
let a = {x:1}
|
||||
function bar(obj) {
|
||||
return obj.x
|
||||
}
|
||||
|
||||
|
||||
function foo () {
|
||||
let ret = 0
|
||||
for(let i = 1; i < 7049; i++) {
|
||||
ret += bar(a)
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
|
||||
foo()
|
||||
|
||||
```
|
||||
|
||||
当V8先执行到这段代码的时候,监控到while循环会一直被执行,于是判断这是一块热点代码,于是,V8就会将热点代码编译为优化后的二进制代码,你可以通过下面的命令来查看:
|
||||
|
||||
```
|
||||
d8 --trace-opt-verbose test.js
|
||||
|
||||
```
|
||||
|
||||
执行这段命令之后,提示如下所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a1/49/a10dab15be233aab9cc17c328c163f49.png" alt="">
|
||||
|
||||
观察上图,我们可以看到终端中出现了一段优化的提示:
|
||||
|
||||
```
|
||||
<JSFunction foo (sfi = 0x2c730824fe21)> for optimized recompilation, reason: small function]
|
||||
|
||||
```
|
||||
|
||||
这就是告诉我们,已经使用TurboFan优化编译器将函数foo优化成了二进制代码,执行foo时,实际上是执行优化过的二进制代码。
|
||||
|
||||
现在我们把foo函数中的循环加到10万,再来查看优化信息,最终效果如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ab/f5/abb9ff93abc9b6e3f848a940d46dd3f5.png" alt="">
|
||||
|
||||
我们看到又出现了一条新的优化信息,新的提示信息如下:
|
||||
|
||||
```
|
||||
<JSFunction foo (sfi = 0xc9c0824fe21)> using TurboFan OSR]
|
||||
|
||||
```
|
||||
|
||||
这段提示是说,由于循环次数过多,V8采取了TurboFan 的OSR优化,OSR全称是**On-Stack Replacement,**它是一种在运行时替换正在运行的函数的栈帧的技术,如果在foo函数中,每次调用bar函数时,都要创建bar函数的栈帧,等bar函数执行结束之后,又要销毁bar函数的栈帧。
|
||||
|
||||
通常情况下,这没有问题,但是在foo函数中,采用了大量的循环来重复调用bar函数,这就意味着V8需要不断为bar函数创建栈帧,销毁栈帧,那么这样势必会影响到foo函数的执行效率。
|
||||
|
||||
于是,V8采用了OSR技术,将bar函数和foo函数合并成一个新的函数,具体你可以参考下图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/87/50/879e5b6582d03a34d1668e33fd5f9a50.png" alt="">
|
||||
|
||||
如果我在foo函数里面执行了10万次循环,在循环体内调用了10万次bar函数,那么V8会实现两次优化,第一次是将foo函数编译成优化的二进制代码,第二次是将foo函数和bar函数合成为一个新的函数。
|
||||
|
||||
网上有一篇介绍OSR的文章也不错,叫[on-stack replacement in v8](https://wingolog.org/archives/2011/06/20/on-stack-replacement-in-v8),如果你感兴趣可以查看下。
|
||||
|
||||
### 查看垃圾回收
|
||||
|
||||
我们还可以通过d8来查看垃圾回收的状态,你可以参看下面这段代码:
|
||||
|
||||
```
|
||||
function strToArray(str) {
|
||||
let i = 0
|
||||
const len = str.length
|
||||
let arr = new Uint16Array(str.length)
|
||||
for (; i < len; ++i) {
|
||||
arr[i] = str.charCodeAt(i)
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
|
||||
function foo() {
|
||||
let i = 0
|
||||
let str = 'test V8 GC'
|
||||
while (i++ < 1e5) {
|
||||
strToArray(str);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
foo()
|
||||
|
||||
```
|
||||
|
||||
上面这段代码,我们重复将一段字符串转换为数组,并重复在堆中申请内存,将转换后的数组存放在内存中。我们可以通过`trace-gc`来查看这段代码的内存回收状态,执行下面这段命令:
|
||||
|
||||
```
|
||||
d8 --trace-gc test.js
|
||||
|
||||
```
|
||||
|
||||
最终打印出来的结果如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ec/26/ec85beff05ec4d5429aa9f80ecb33b26.png" alt="">
|
||||
|
||||
你会看到一堆提示,如下:
|
||||
|
||||
```
|
||||
Scavenge 1.2 (2.4) -> 0.3 (3.4) MB, 0.9 / 0.0 ms (average mu = 1.000, current mu = 1.000) allocation failure
|
||||
|
||||
```
|
||||
|
||||
这句话的意思是提示“Scavenge … 分配失败”,是因为垃圾回收器**Scavenge**所负责的空间已经满了,Scavenge主要回收V8中“**新生代**”中的内存,大多数对象都是分配在新生代内存中,内存分配到新生代中是非常快速的,但是新生代的空间却非常小,通常在1~8 MB之间,一旦空间被填满,Scavenge就会进行“清理”操作。
|
||||
|
||||
上面这段代码之所以能频繁触发新生代的垃圾回收,是因为它频繁地去申请内存,而申请内存之后,这块内存就立马变得无效了,为了减少垃圾回收的频率,我们尽量避免申请不必要的内存,比如我们可以换种方式来实现上述代码,如下所示:
|
||||
|
||||
```
|
||||
function strToArray(str, bufferView) {
|
||||
let i = 0
|
||||
const len = str.length
|
||||
for (; i < len; ++i) {
|
||||
bufferView[i] = str.charCodeAt(i);
|
||||
}
|
||||
return bufferView;
|
||||
}
|
||||
function foo() {
|
||||
let i = 0
|
||||
let str = 'test V8 GC'
|
||||
let buffer = new ArrayBuffer(str.length * 2)
|
||||
let bufferView = new Uint16Array(buffer);
|
||||
while (i++ < 1e5) {
|
||||
strToArray(str,bufferView);
|
||||
}
|
||||
}
|
||||
foo()
|
||||
|
||||
```
|
||||
|
||||
我们将strToArray中分配的内存块,提前到了foo函数中分配,这样我们就不需要每次在strToArray函数分配内存了,再次执行`trace-gc`的命令:
|
||||
|
||||
```
|
||||
d8 --trace-gc test.js
|
||||
|
||||
```
|
||||
|
||||
我们就会看到,这时候没有任何垃圾回收的提示了,这也意味着这时没有任何垃圾分配的操作了。
|
||||
|
||||
### 内部方法
|
||||
|
||||
另外,你还可以使用V8所提供的一些内部方法,只需要在启动V8时传入`allow-natives-syntax`命令,具体使用方式如下所示:
|
||||
|
||||
```
|
||||
d8 --allow-natives-syntax test.js
|
||||
|
||||
```
|
||||
|
||||
还记得我们在《[03|快属性和慢属性:V8采用了哪些策略提升了对象属性的访问速度?](https://time.geekbang.org/column/article/213250)》讲到的快属性和慢属性吗?
|
||||
|
||||
我们可以通过内部方法HasFastProperties来检查一个对象是否拥有快属性,比如下面这段代码:
|
||||
|
||||
```
|
||||
function Foo(property_num,element_num) {
|
||||
//添加可索引属性
|
||||
for (let i = 0; i < element_num; i++) {
|
||||
this[i] = `element${i}`
|
||||
}
|
||||
//添加常规属性
|
||||
for (let i = 0; i < property_num; i++) {
|
||||
let ppt = `property${i}`
|
||||
this[ppt] = ppt
|
||||
}
|
||||
}
|
||||
var bar = new Foo(10,10)
|
||||
console.log(%HasFastProperties(bar));
|
||||
delete bar.property2
|
||||
console.log(%HasFastProperties(bar));
|
||||
|
||||
```
|
||||
|
||||
我们执行下面这个命令:
|
||||
|
||||
```
|
||||
d8 test.js --allow-natives-syntax
|
||||
|
||||
```
|
||||
|
||||
通过传入`allow-natives-syntax`命令,就能使用`HasFastProperties`这一类内部接口,默认情况下,我们知道V8中的对象都提供了快属性,不过使用了`delete bar.property2`之后,就没有快属性了,我们可以通过`HasFastProperties`来判断。
|
||||
|
||||
所以可以得出,使用delete时候,我们查找属性的速度就会变慢,这也是我们尽量不要使用delete的原因。
|
||||
|
||||
除了`HasFastProperties`方法之外,V8提供的内部方法还有很多,比如你可以使用`GetHeapUsage`来查看堆的使用状态,可以使用`CollectGarbage`来主动触发垃圾回收,诸如`HaveSameMap`、`HasDoubleElements`等,具体命令细节你可以参考[这里](https://github.com/v8/v8/blob/4b9b23521e6fd42373ebbcb20ebe03bf445494f9/src/runtime/runtime.h)。
|
||||
|
||||
## 总结
|
||||
|
||||
好了,今天的内容就介绍到这里,我们来简单总结一下。
|
||||
|
||||
d8是个非常有用的调试工具,能够帮助我们发现我们的代码是否可以被V8高效地执行,比如通过d8查看代码有没有被JIT编译器优化,还可以通过d8内置的一些接口查看更多的代码内部信息,而且通过使用d8,我们会接触各种实际的优化策略,学习这些策略并结合V8的工作原理,可以让我们更加接地气地了解V8的工作机制。
|
||||
|
||||
通过源码来构建d8的流程比较简单,首先下载V8的编译工具链:depot_tools,然后再利用depot_tools下载源码、生成工程、编译工程,这就实现了通过源码编译d8。这个过程不难,但涉及到了许多工具,在配置过程中可能会遇到一些坑,不过按照流程操作应该能顺利编译出来d8。
|
||||
|
||||
接下来我们重点讨论了如何使用d8,我们可以通过传入不同的命令,让d8来分析V8在执行JavaScript过程中的一些中间数据。你应该熟练掌握d8的使用方式,在后续课程中我们应该还会反复引用本节的一些内容。
|
||||
|
||||
## 思考题
|
||||
|
||||
c/c++中有内联(Inline)函数,和我们文中分析的OSR类似,内联函数和V8中所采用的OSR优化手段类似,都是在执行过程中将两个函数合并成一个,这样在执行代码的过程中,就减少了栈帧切换操作,增加了执行效率,那么今天留给你的思考题是,你认为在什么情况下,V8会将多个函数合成一个函数?
|
||||
|
||||
你可以列举一个实际的例子,并使用d8来分析V8是否对你的例子进行了优化操作。欢迎你在留言区与我分享讨论。
|
||||
|
||||
感谢你的阅读,如果你觉得这一讲的内容对你有所启发,也欢迎把它分享给你的朋友。
|
||||
Reference in New Issue
Block a user