JavaScript Javascript 语义详解 - 从浏览器角度理解代码

daroson · 2014年05月09日 · 最后由 zlfera 回复于 2014年05月09日 · 2650 次阅读

初级,或者资深初级程序员,永远是从人的角度去看代码,结果就会产生“变量提升”(variable hoisting)之类不存在的概念,误人误己。比如国外某程序员写的“进阶教学”博客中,有这么一段代码:

function foo() { return "A"; }

var oldFoo = foo;

function foo() { return oldFoo() + "B"; }

console.log(foo()); // "AB"

它的实际结果是 RangeError: Maximum call stack size exceeded,而在此之前,这位程序员才刚刚讲完什么是“variable hoisting”。

本文旨在教会大家进阶级程序员的第一课,即不再从表面的人的角度去看代码,而是从内在的机器的角度去看代码。而从机器的角度出发,Javascript 代码的开始相当于一个全局 function 的调用,而 function 在调用后并不是马上一行行地运行下去,而是分为两个阶段:初始设定阶段以及正式运行阶段。

设定阶段

例 1:

console.log( a,b() ) var a = 1 function b() { return 2 }

首先在执行前,浏览器会先把这些连着的“字符串”(string)分成一个个“字词”(token),期间会自动在有需要的行尾加上分号,然后再把这些字词组织成一个更方便操作的树状结构(AST),最后才开始执行(execution)。

在设定阶段,会建立一个空的当前代码段(function)的变量表,它可以理解为一个变量名字(identifier)及其内存地址的对照表。

然后会找到 function 这个字词,把它后面的那个字词 (b) 作为这个 function 的名字,再把 { 和 } 这两个字词之间的所有字词 (这里为 return 2) 和一些相关属性(property)生成一个 javascript 里的 Function object,作为这个 function 的值。并把这个值的内存地址和名字作为一个 binding(绑定)存到变量表里。假如名字重复,则覆盖掉之前的值。(在最开始的例子中,后面的 foo 就覆盖掉了前面的 foo,导致 foo/oldFoo 老在调用它自己,结果超出了 call stack 的大小)

这之后会找到 var 这个字词,把它后面的那个字词 (a) 作为这个 variable(变量)的名字,这里会先检查这个名字是否已在变量表中,如果在则直接跳过,因此不会覆盖之前的值,如果不在则分配一个内存地址给它,这个内存地址的值为 undefined。最后把它们作为又一个 binding 存到变量表。

到此设定阶段结束,开始调用 console.log( a,b() ) ,得到的结果为 undefined,2。

例 2:

var a = 1 b(a) function b(c) { console.log(c) }

一开始的设定阶段和例 1 一样,然后先是执行 a = 1,把 1 赋值给 a 的内存地址,于是 a 的值从 undefined 变为了 1。 之后调用 b(a) ,这里会暂停 全局代码 的执行,转而开始执行 b。

在 b 的设定阶段,会先把 a 的值(1)作为 c 的值,并存到变量表里,然后由于没有 function 和 var 这两个字词,设定阶段结束,开始调用 console.log(c) ,得到的结果为 1。

例 3:

var a = 1 b(2) function b(c) { var d = 3 console.log(d,c,a,this) }

首先还是先声名 b 和 a。而且在 b 的声名阶段,会把一个 b 的一个内部变量(无法直接在 Javascript 里访问),名字叫 [[scope]],设为指向它声名时所在的变量表,在这里即全局的变量表,a 和 b 以及它们的内存地址就是被存在这里。

前面提到的 function、var 还有{ }等符号都属于浏览器认识的关键字词(keyword),当浏览器读到不认识的字词(名字)时,会首先到当前所在的变量表里找,如果没找到,就会去这个 [[scope]],即上一层的变量表里面找,直到 [[scope]] 为空(null)时,浏览器就会提示 xxx is not defined。

这里声名完了后开始执行 a=1,之后调用 b(2),,这里会先把把 Window,也就是 global object(全局对象)的内存地址作为 this 的值。然后把 2 赋值给 c,之后声名 d,于是这一层的变量表即只有 c 和 d。

这时 b 的设定阶段结束,开始执行 d=3 和 console.log(d,c,a,this ) ,得到的结果为 3,2,1,Window{ ... } 。

【总结】 在 function 声名时,会设置它的 [[scope]],即它的上一层的变量表,以及 [[FormalParameters]],即所有形式参数的名字,最后是 FunctionBody,即 { 和 } 之间的树形结构化了的代码(AST),并用它生成一个 Function object 作为以后的调用对象。

而代码/function 的设定阶段为: 1.设置 this,不同情况下它的地址也会不同。 a.对一般 function 来说,它被设置成 global/Window。 b.当以 objectXX.methodXX() 形式调用时,它被设置成 objectXX。 c.当以 new XX() 形式时,它被设置成一个新的空 object。 d.当以 XX.call() 或 XX.apply() 形式时,它被设置成第一个参数。

2.设定一个内存地址,它指向这个 function 声名时的 [[scope]]。

3.设定这一层的变量表。这一步又依次分为 a.分配 function 的内存地址并赋值;b.如果 [[FormalParameters]] 里的参数名没有重复,则分配它一个内存地址并赋值;c.如果变量名没重复,则分配它一个内存地址但不赋值。

以上这些共同构成了一个术语叫 execution context 的东西(代码执行上下文),而设定阶段其实就是把它创建起来的过程。

【ES5】 Ecmascript 是 Javascript 的标准规范,以上的讲解主要是基于它的第 3 版(ES3),而当前的版本为 ES5。(第 4 版是个失败所以直接到了第 5 版)

ES3 中的变量表就是一个对象叫做 VariableObject,这种实现更容易理解,但在访问多层以上的变量时速度会越来越慢,后来的浏览器进行了优化,不再使用 VariableObject,优化后的速度基本不会受层数影响。于是 ES5 里把 VariableObject 改为了一种抽象的标准,名字为 lexical environment(词语环境)。

实际这个 lexical environment 就是由原来指向 [[scope]] 的内存地址和 VariableObject 组成,只不过这个内存地址现在叫 outer lexical environment(外部词语环境),而 VariableObject 现在叫 environment record(环境记录),而 this 在 ES5 标准里也改名叫 thisBinding,并与 lexical environment 共同构成 ES5 里的 execution context。

ES3 和 ES5 的概念之间的差别其实不大,但却都有一个差别很大的名字,而 ES5 里还添加了一些这样的东西,因为我认为它们对本文的帮助不大却会大大增加理解难度,所以被我去掉了,想要彻底了解的话可以参考:

http://dmitrysoshnikov.com/ecmascript/es5-chapter-3-1-lexical-environments-common-theory/ http://ecma262-5.com/ELS5_HTML_with_CorrectionNotes.htm#Section_10.2

运行阶段

在 function 后面 { 和 } 之间的源代码在浏览器转换过后会变成一个 语句列表(StatementList),而它的每一行都是一条语句(Statement),function 的运行其实就是对这一条条语句依次 evaluate(求值)的过程。

之所以取名叫 evaluate,可能是因为计算机实质上只会做两件事:对值进行 转换(运算)和 传输(载入/保存)。

【转换】 转换通常是通过操作符(operator)实现的,最常见的是 + 和-等,比较特殊的是==和!等,比较少见的是>>和 typeof 等。

而因为 Javascript 源代码是以 UTF-16 为格式的一长串字母和符号(string),比如"123"其实是 3 个 16 位的"字母" 连成的,所以它在运算前会先转成 IEEE 754 格式的 64 位浮点数。(而它最小可以转成一个 8 位的整数) undefined、true、false、数字(45092,1.345 等)、string("abc", "a bc defg"等)还有“{ ... }”和“[ ... ]”,这种 UTF-16 格式的值可以直接转换为它真正的值的“词语”被术语称为 literal(字面的)。

除了 literal 以外,还有两种“词语”会在运算前作转换:

【a.变量】 变量,其实就是一个 named value(命名了的值),对变量的转换就是把它的名字变成它的值,方法是去查当前变量表,如果查到,则它的值为绑定的内存地址里的那个值;如果没查到,就去查上层变量表([[scope]]),如果 [[scope]] 为空(null)则值它 is not defined。

最近新出版了一本书花了 90 多页专门讲解 scope 和 closure,(不禁让我回想起那些整本都在讲 C 语言指针的书)在这里我只用 2 段话:

scope 即变量表,包括当前的和所有上层的。(scope 中文可以翻译为 一眼可以看到的景物,对于 function 来说,scope 就是 一眼可以看到的变量)

closure,宽泛地说,是 function 这个概念的一种实现,属于变量的 function 就是 closure,即像其它变量一样可传递和返回的 function;(在静态语言的实现中,function 无法传递和返回,因为它们的 function 只有在执行时才得到它需要的变量,执行完后这些变量就消除,而 Javascript 中 function 在声名时便生成了一个上层变量表([[scope]]),只要 function 不消除这个 [[scope]] 就不会消除)而具体地说,为了与 Javascript 中的普通 function 区分,closure 特指那些访问了 [[scope]] 中的变量 的 function。

【b.property】 当浏览器看到 a.b 的时候,它不会去变量表里查 b 的值,因为 b 是 a 的一个 property。property 英文意思除了属性外,更主要的意思是财产,或者说附属品,因此在编程里它的意思可以理解为“附属变量”。

要理解 property,首先要理解 object。如果说 property 对应的是文件,那么 object 对应的就是文件夹,所谓的“一切皆 object”,就是把所有的变量都放到一个 object 里。虽然智商正常的人应该都不会说:“硬盘里的一切皆文件夹”。

正确一点的说法是:“一切皆由 object 组织“。因为 object 可以理解为一种结构,从图像思维上看,多个 object 就会形成一个树状的结构,而这种结构可以用来组织任意数量的人或物,比如现实里的物种/图书/商品的分类,军队/公司/政府的编制等等。

计算机里的数据假如不这么组织,那么一个纯粹的 string/array/function 就只有它自己,没有 property,这样虽然占用的空间少,但像 array.length 或 array.foreach(...) 这样方便的操作就无法实现,更不用说 a.b.c.d 这样的链式调用了。

object,这里暂称它为“变量夹”,特殊的地方在于它里面的变量可以“继承”,使得在子变量夹里可以获取到父变量夹里的变量,但通常一个父变量夹里既有需要被继承的变量,也有不需要被继承的,因此 Javascript 可以在父变量夹里建一个叫 prototype 的变量夹,用来存放那些需要被继承的变量。之后子变量夹里会自动建一个叫 [[proto]] 的内部变量,它的值便是它父变量夹的 prototype。(的内存地址)

任何 object 的 prototype 都可以赋值或更改,但 [[proto]] 是内置的无法直接更改,它取决于 object 的创建方式:

var o = { ... } o.[[proto]] 指向 Object.prototype,这个 Object 是 Javascript 里一个全局 object 变量的名字,所有 Javascript 里的 object 都默认以它为原型。

var o = new Xxx( ... ) o.[[proto]] 指向 Xxx.prototype。

var o = Object.create( Xxx, { ... } ) o.[[proto]] 指向 Xxx,Xxx 可以是 null 也可以是任意 object。

所谓的继承,就是当浏览器在当前变量夹找不到某个 property 时,会自动去它的 [[proto]] 里面找,依此类推,直到 [[proto]] 为 null 为止(property 与变量的查找原理一模一样,这就是为什么 ES3 里会用 VariableObject 来表示变量表)

比如浏览器在 evaluate a.b 时,会先去变量表找 a 的内存地址,而它值就是一个变量夹,然后再到这个变量夹里找 b。而如果是 a[b] 这种形式,则是会先转换 b 的值(evaluate),这个值如果不是字符串(string)会强制转换为字符串,然后再到 a 变量夹里找这个字符串对应的变量。

【传递】 计算机语言中最误导人的运算符无疑是 =,它其实不是在表达一种相等的关系,而是把右边求得的值传递到左边求得的 内存地址 上。我想一个更恰当的符号应该是 ←,比如 a ← b,a ← 3+5。

而值传递在 Javascript 中具体还分为两种,直接和间接传递。直接传递通常用于数字或可以用数字表示的值,比如 true/false 和内存地址(假如 CPU 是 32 位则内存地址就是一个 32 位的 2 进制数,64 位 CPU 则是 64 位,依此类推),而比较大的、无法用一个数表示的值,则不会被直接传递,而是只传递这个值所在的内存地址。比如

a = { a:1,b:2,c:3 }

这里浏览器在把 { a:1,b:2,c:3 } 转换成对应的值后,会返回这个值所在的内存地址,亦即一个数字,然后把这个数字传递给 a 在变量表里对应的内存地址。

内存地址之所以是个数字,是因为某种程度上说内存里的每一个字节(byte)都有一个编号,一个 1GB(giga byte)的内存就有 10 亿以上(2 的 20 次方)个编号,而在硬件底层有一个类似于 getMem(编号,大小) 的机器指令,假如编号是 123,大小是 20,那么这个指令就会把编号 123 到 142 里的 20 个字节取出来。(一台电脑主要有 3 块内存:CPU 内存,显卡内存,声卡内存;想运行软件就要往 CPU 内存传递数据,想播放声音就要往声卡内存传递数据,想显示图像就要往显卡内存传递数据)

Javascript 中除了 =,还有一些其它的值传递,它们理论上说都应该改为用←来表示: { a: 1,b: 'abc' } :{ a← 1,b← 'abc' } a(1,'abc') : a← 1,'abc' return 1 : 1 →

CPU 里有一个寄存器(功能与内存一样,但更快也更贵,只用来存放最经常转换/传递的值),它的值是当前正在执行的机器指令的内存地址,当浏览器往这里传递一个 代表内存地址的数字 时,程序就会跳转到那个地址开始继续执行里面的指令,所以 if、while、switch 等语句实质上也是在传递值。

具体 Javascript 里共有多少种操作符和语句,可以参考 ExpressionsStatements

【理论与实践】 这篇文章里总结了我认为 Javascript 中最重要的理论知识,应该说理解之后除了 EMCA 标准外就不用再看理论相关的书或文章了,这时应把时间花在动手实践上,去熟悉和掌握 原生的 DOM API、第 3 方的库/框架/插件、代码的组织与测试等等。


留着以后细看

需要 登录 后方可回复, 如果你还没有账号请 注册新账号