上一篇(JavaScript中你所不知道的Object(一))说到,Object对象有大量的内部属性,而其中多数和外部属性的操作有关。最后留了个悬念,就是Boolean、Date、Number、String、Function等有更多的内部属性,而它们分别是什么呢?
这些内部属性不能像Object的内部属性一样一言以蔽之,因为它们各有各的用处和特点。其中核心的部分自然是最特殊的对象,Function对象。我们先从简单的开始:
- [[PrimitiveValue]]: 值的类型是基础数据类型。所以所有的包装类比如Boolean、Number、String都有此内部属性,其中Date也有,用来存储时间戳。
- [[Construct]]: 值的类型是方法。传入参数列表,返回Object。Function对象特有,当使用new func()调用的就是这个内部属性。所以拥有这个属性的方法也可以叫做构造器。注意区分构造器的prototype对象下的constructor属性。
- [[Call]]: 值的类型是方法。传入参数列表,返回任意数据类型。Function对象特有,当使用func()调用的就是这个内部属性。
- [[HasInstance]]: 值的类型是方法。传入任意值,返回Boolean。检测这个参数的原型链上是否有此函数的prototype,即检测参数及其原型中是否有对象是此函数作为构造器时创建的。
- [[Scope]]: Function对象特有,每当函数执行时使用此内部属性新建作用域链加入到新的执行环境中。
- [[FormalParameters]]: 形参列表。Function对象特有。
- [[Code]]: JS代码。Function对象特有。
- [[TargetFunction]]: 如下
- [[BoundThis]]: 如下
- [[BoundArguments]]: 如下
- [[ParameterMap]]: 形参列表。arguments对象特有。
以上三个为Function.prototype.bind创建的函数特有的内部属性。
var arr1 = [1, 4, 5], arr2 = [2, 3]; var func = Array.prototype.splice.bind(arr1, 1, 0); func.apply(arr1, arr2); console.log(arr1); //[1, 2, 3, 4, 5]
上面列出的属性乍看一下好像都能理解,但细思恐极,比如标红的作用域和执行环境,都是很抽象的概念,我们现在就来完整的剖析下这些概念。
首先引入的概念就是可执行代码。ES规定三种可执行代码:全局代码、Eval代码、函数代码。当执行到这三种代码的时候,解释器会创建并进入新的执行环境,当代码运行完毕的时候,解释器会退出并销毁当前执行环境,并回到前一个执行环境。
到现在为止执行环境还是一个抽象的概念,那执行环境的具体实现是怎样的呢?首先,它有三个要素:词法环境、变量环境、this绑定。this绑定我们都比较熟悉,那词法环境和变量环境分别是干什么用的呢?简单而不太严谨的说,词法环境就是作用域链,用来取变量的值;而变量环境暂时理解为当前作用域吧,用来赋变量的值。我们举个栗子吧:
var a = 0; function test0() { console.log(a); //0 console.log(window.a); //0 } function test1() { a = 1; console.log(a); //1 console.log(window.a); //1 } function test2() { var a = 2; console.log(a); //2 console.log(window.a); //1 } test0(); test1(); test2();
这里涉及4个执行环境:全局执行环境、test0-2的执行环境。
进入test0的执行环境以后,要打印出a,当前的作用域中没有定义这个变量,于是沿着作用域链找,找到了全局执行环境中定义的a,于是打印出来0,相信这里没什么问题。
进入test1的执行环境后,这时已经退出了test0的执行环境。这时候给a赋了一个值,可能就有人立即想到,赋值?ok,变量环境,作用于当前作用域,所以a应该是1,window.a应该还是0,但结果却不是这样的。我们把a = 1拆开来看,首先是取a这个变量,当前作用域是没有这个变量的,所以a这个变量指向了全局执行环境中的a,然后才是赋值,1自然赋给了全局执行环境中的a。那么怎么让当前作用域中有这个变量呢?声明!即test2中的var a = 2,我们同样拆开来看,先是声明了a,所以在当前作用域中绑定了a这个变量,即在变量环境中添加了a,然后取a这个变量,从作用域链中的当前作用域就找到了a,最后把2赋给a。
所以我们来规范一下上面的定义:词法环境用于查找变量,可以理解为作用域链。变量环境呢,不能再理解为作用域了,它是一个用来存储当前执行环境和变量之间的绑定信息的对象。注意这里隐藏了一个关键点:取变量是以作用域为单位查找的,而声明变量是以执行环境为单位存储的。不理解没关系,继续往下走。
例子中细心的话可能察觉到一丝不对劲:为什么我在全局执行环境中声明的变量可以通过全局对象访问呢,那么我在test2中声明的a可以通过test2.a访问吗?
当然是不行的。因为作用域分为两类:一种是声明式的,一种是对象式的。function产生的作用域是声明式的,而全局执行环境对应的作用域是对象式的。对象式作用域中声明的所有变量都可以通过此对象的属性进行访问,而声明式则可以定义不可以被修改的变量,你在严格模式下修改function中的arguments试试。
相信看到这里基本已经陷入混乱了。我来整理几个问题:作用域和执行环境到底什么关系?声明式和对象式作用域只对应function和全局吗?
首先,一个执行环境中是可以产生多个作用域的,但都有一个基础作用域。然后其他作用域怎么生成呢?声明式的作用域还有一种方式生成,就是catch语句,我们在catch(e){}的语句中可以通过e访问到错误对象,就是生成了一个声明式的作用域,并在这个作用域里添加了变量e。而对象式的作用域也同样还有一种方式生成,就是with语句。
var a = { name: 'tarol' }, name = 'okal'; with(a) { console.log(name); //tarol console.log(window.name); //okal name = 'ctarol'; console.log(name); //ctarol console.log(window.name); //okal var age = 18; } console.log(a.age); //undefined console.log(window.age); //18
例子中,进入with语句后,生成了一个新的对象式作用域,并添加到了作用域链的头部,所以在语句中对变量name的访问是取a.name的值,对变量name的赋值也是对a.name的赋值。疑难点在var age = 18后,age这个属性没有赋给a,而是赋给了window。就像小伙a看上了姑娘age,说好了也订了婚,最后姑娘嫁给了a的老大window(怎么有种曹操和关羽的既视感)。其实原因就在上面,with语句修改了执行环境的词法环境,所以把访问变量的规则改了,但是没有修改变量环境,所以声明的变量统统都给了全局执行环境中的基础作用域(a:我怎么把这茬给忘了?)。
另外把var age = 18中的var去掉,结果还是一样的。因为娶变量的时候,媒婆找啊找,找到最后一个作用域了都没找到,于是就停到那里了,突然看到有个值送上门来,也懒得换地方了,当场就在这个作用域把这个值打扮成了个变量。所以前一个例子中去掉var a = 0也不影响test1()的结果。
注意!注意!注意!对整个作用域链中未定义的变量赋值,这个变量会绑定到作用域链的尾部,而给这个原型链中未定义的属性赋值,这个属性会绑到原型链的头部即当前的对象中。一次给两个栗子:
var a = {}; function test() { with(a) { age = 19; } } test(); console.log(window.age); //19
var a = {}, b; b = Object.create(a); b.age = 18; console.log(a.age); //undefined
好了,说了那么多,回到之前的内部属性[[Scope]]。它是在创建函数生成,值是创建时的作用域链;并在执行函数时取用,生成新执行环境中的作用域链。
注意这个[[Scope]]是创建函数是生成!
注意这个[[Scope]]是创建函数是生成!
注意这个[[Scope]]是创建函数是生成!所以无论在哪里执行这个函数作用域链都是一样的。
给个栗子:
var a = { age: 18 }, age = 19; with(a) { var test0 = function() { console.log(age); }; function test1() { console.log(age); } test0(); //18 test1(); //19 } !function() { var age = 20; function test2() { console.log(age); } test0(); //18 test1(); //19 test2(); //20 }() test0(); //18 test1(); //19
需要注意的是,在with语句中新建函数,如果此函数的作用域链中想插入a,要用test0的声明方式,而不是test1。至于为什么?那是变量声明和函数声明的时序问题,在进入执行环境后,会先遍历所有的变量声明和函数声明,会生成函数对象,但不会对变量赋值。这也是为什么JS中函数可以先调用后声明,因为你随便写在哪里,一进入执行环境就函数就生成了。而test1函数生成的时候还没有run到with语句,[[Scope]]自然是全局执行环境的基础作用域链。test0开始只声明个undefined的变量,到with语句才进行赋值,所以这个函数的[[Scope]]中加入了a。
那么,[[Scope]]又是怎么生成作用域的呢,还有之前说到,全局代码和function代码都存在基础作用域,甚至是with语句,它们的作用域都是怎么生成的呢?
首先,作用域都是在开始执行的时候生成的。
全局代码和with语句生成作用域的过程是类似的,会绑定原型链上所有属性名和属性值作为变量名和变量值到作用域上。
而function代码是先通过[[Scope]]生成新的作用域链,并绑定形参名和实参值作为变量名和变量值到作用域链的顶部即当前的作用域上。另外还有个特殊的变量,即arguments。arguments是一个特殊的对象,但不是数组对象,它在原型链上的位置处于倒数第三位,也就是原生Object对象的位置。那么,要把arguments装作用域,拢共分几步?
四步:
- 通过Object构建对象,并用实参数组的长度初始化此对象的length属性
- 遍历实参数组,以index为属性名,对应实参为属性值给此对象添加属性
- 将形参数组赋给此对象的[[ParameterMap]](arguments特有的内部属性),如果不在严格模式下,给此对象添加callee和caller属性
- 绑定arguments到作用域,并指向此对象
写的比较乱,而且为了便于理解,并没有规范化一些概念,比如Environment Records变成了作用域,Lexical Environments变成了作用域链,以后再行整理。