This commit is contained in:
louzefeng
2024-07-11 05:50:32 +00:00
parent bf99793fd0
commit d3828a7aee
6071 changed files with 0 additions and 0 deletions

View File

@@ -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=&quot;John&quot;;
person.lastname=&quot;Doe&quot;;
person.age=50;
person.eyecolor=&quot;blue&quot;;
```
这个对象里面有四个属性,为了直观理解,你可以参看下图:
<img src="https://static001.geekbang.org/resource/image/d0/23/d07e174001a29765a3575908e3704123.jpg" alt="" title="对象的构成">
上图展示了对象person的结构我们可以看到蓝色的属性在左边黄色的值在右边有多组属性和值组成这就是JavaScript中的对象虽然JavaScript对象用途非常广泛使用的方式也非常之多但是万变不离其宗其核心本质都就是由一组组属性和值组成的集合抓住了这一点当我们再分析对象时就会轻松很多。
之所以JavaScript中对象的用途这么广是因为对象的值可以是任意类型的数据我们可以改造下上面的那段代码来看看对象的值都有哪些类型改造后的代码如下所示
```
var person=new Object()
person.firstname=&quot;John&quot;
person.lastname=&quot;Doe&quot;
person.info = new Object()
person.info.age=50
person.info.eyecolor=&quot;blue&quot;
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中的函数就实现了“函数是一等公民”的特性。
## 思考题
本文我们从对象聊到了闭包,那么留给你的问题是,哪些语言天生支持“函数是一等公民”?欢迎你在留言区与我分享讨论。
感谢你的阅读,如果你觉得这一讲的内容对你有所启发,也欢迎把它分享给你的朋友。

View File

@@ -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[&quot;B&quot;] = 'bar-B'
this[50] = 'test-50'
this[9] = 'test-9'
this[8] = 'test-8'
this[3] = 'test-3'
this[5] = 'test-5'
this[&quot;A&quot;] = 'bar-A'
this[&quot;C&quot;] = '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 &lt; element_num; i++) {
this[i] = `element${i}`
}
//添加常规属性
for (let i = 0; i &lt; 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内存快照截图">
上图就是收集了当前内存快照的界面要想查找我们刚才创建的对象你可以在搜索框里面输入构造函数FooChrome会列出所有经过构造函数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的原因吗欢迎你在留言区与我分享讨论。
感谢你的阅读,如果你觉得这一讲的内容对你有所启发,也欢迎把它分享给你的朋友。

View File

@@ -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 &lt;anonymous&gt;: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
- 一个函数表达式可以被用作一个即时调用的函数表达式——IIFEImmediately 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()
```
感谢你的阅读,如果你觉得这一讲的内容对你有所启发,也欢迎把它分享给你的朋友。

View 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: &quot;Default&quot;,
color: &quot;Default&quot;,
getInfo: function () {
return `Type is: ${this.type}color is ${this.color}.`
}
}
var dog = {
type: &quot;Dog&quot;,
color: &quot;Black&quot;,
}
```
在这段代码中我创建了两个对象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**_”这两个属性之间有关联吗欢迎你在留言区与我分享讨论。
感谢你的阅读,如果你觉得这一讲的内容对你有所启发,也欢迎把它分享给你的朋友。

View 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另外系统还为我们添加了另外一个隐藏变量thisV8还会默认将隐藏变量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函数作用域&gt;bar函数作用域&gt;全局作用域;
- 还是沿着foo函数作用域—&gt;全局作用域?
因为JavaScript是基于词法作用域的词法作用域就是指查找作用域的顺序是按照函数定义时的位置来决定的。bar和foo函数的外部代码都是全局代码所以无论你是在bar函数中查找变量还是在foo函数中查找变量其查找顺序都是按照当前函数作用域&gt;全局作用域这个路径来的。
由于我们代码中的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()
```
那么执行这段代码之后,打印出来的内容是什么?欢迎你在留言区与我分享讨论。
感谢你的阅读,如果你觉得这一讲的内容对你有所启发,也欢迎把它分享给你的朋友。

View 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虚拟机会直接返回一个执行错误错误提示是这样的
```
&gt;&gt;&gt; 1+'2'
Traceback (most recent call last):
File &quot;&lt;stdin&gt;&quot;, line 1, in &lt;module&gt;
TypeError: unsupported operand type(s) for +: 'int' and 'str'
```
这段错误代码提示了这是个类型错误表明Python并不支持数字类型和字符串类型相加的操作。
不过在JavaScript中执行这段表达式是可以返回一个结果的最终返回的结果是字符串“12”。
最终结果如下所示:
```
&gt;&gt;&gt; 1+'2'
&gt;&gt;&gt; &quot;12&quot;
```
为什么同样一条的表达式在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 = &quot;John&quot; # 字符串
```
C/C++编译器负责将这些数据片段转换为供CPU处理的正确命令通常是二进制的机器代码。
在某些更高级的语言中还可以根据数据推断出类型比如在Python或JavaScript中你就不必为数据指定专门的数据类型在Python中你可以这样定义变量
```
counter = 100 # 赋值整型变量
miles = 1000.0 # 浮点型
name = &quot;John&quot; # 字符串
```
在JavaScript中你可以这样定义变量
```
var counter = 100 # 赋值整型变量
let miles = 1000.0 # 浮点型
const name = &quot;John&quot; # 字符串
```
虽然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() + &quot;2&quot;
```
这里把数字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 &lt;anonymous&gt;: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 &quot;200&quot;
},
valueOf() {
return 100
}
}
Obj+&quot;3&quot;
```
你觉得执行这段代码会打印出什么内容呢?欢迎你在留言区与我分享讨论。
感谢你的阅读,如果你觉得这一讲的内容对你有所启发,也欢迎把它分享给你的朋友。

View 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=&quot;x64&quot; v8_target_cpu=&quot;arm64&quot; 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核CPUssd硬盘整个编译过程大概花费了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 &lt; 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="">
观察上图,我们可以看到终端中出现了一段优化的提示:
```
&lt;JSFunction foo (sfi = 0x2c730824fe21)&gt; for optimized recompilation, reason: small function]
```
这就是告诉我们已经使用TurboFan优化编译器将函数foo优化成了二进制代码执行foo时实际上是执行优化过的二进制代码。
现在我们把foo函数中的循环加到10万再来查看优化信息最终效果如下图所示
<img src="https://static001.geekbang.org/resource/image/ab/f5/abb9ff93abc9b6e3f848a940d46dd3f5.png" alt="">
我们看到又出现了一条新的优化信息,新的提示信息如下:
```
&lt;JSFunction foo (sfi = 0xc9c0824fe21)&gt; 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 &lt; len; ++i) {
arr[i] = str.charCodeAt(i)
}
return arr;
}
function foo() {
let i = 0
let str = 'test V8 GC'
while (i++ &lt; 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) -&gt; 0.3 (3.4) MB, 0.9 / 0.0 ms (average mu = 1.000, current mu = 1.000) allocation failure
```
这句话的意思是提示“Scavenge … 分配失败”,是因为垃圾回收器**Scavenge**所负责的空间已经满了Scavenge主要回收V8中“**新生代**”中的内存大多数对象都是分配在新生代内存中内存分配到新生代中是非常快速的但是新生代的空间却非常小通常在18 MB之间一旦空间被填满Scavenge就会进行“清理”操作。
上面这段代码之所以能频繁触发新生代的垃圾回收,是因为它频繁地去申请内存,而申请内存之后,这块内存就立马变得无效了,为了减少垃圾回收的频率,我们尽量避免申请不必要的内存,比如我们可以换种方式来实现上述代码,如下所示:
```
function strToArray(str, bufferView) {
let i = 0
const len = str.length
for (; i &lt; 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++ &lt; 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 &lt; element_num; i++) {
this[i] = `element${i}`
}
//添加常规属性
for (let i = 0; i &lt; 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是否对你的例子进行了优化操作。欢迎你在留言区与我分享讨论。
感谢你的阅读,如果你觉得这一讲的内容对你有所启发,也欢迎把它分享给你的朋友。