数据存取是计算机科学中最常见的操作,如何安排数据的存储位置不仅关系到代码在执行过程中数据的检索速度,更影响着整个程序的设计思维。这篇文章将对javascript语言中数据存取的相关知识进行深入的讨论。通过对本文的阅读和学习,你可以理解并掌握:
- js存储数据的位置以及优化方式
- js作用域链的实质以及改变作用域链的方式
- js闭包的实质与闭包导致的内存泄露
- 为什么ES5的严格模式会禁用with
- 原型链与数据存储
- js中使用var声明的变量声明提升的本质
javascript中有以下四种基本的数据存储位置:
1 字面量
字面量只代表自身,不存储在特定的位置,比如下面的匿名函数
$btn.click(function(){... ...});
2 本地变量
本地变量使用var声明,从相对存取数据位置而言的局部变量和字面量中存取速度的性能差异是微不足道的。
3 数组成员
以数字作为索引
4 对象成员
以字符串作为索引
一般而言,从字面量和局部变量获取数据的速度要快于从对象或数组的属性中获取数据的速度,但在性能方面很大程度上取决于浏览器本身。
显然把数据存储到局部变量中会在存取数据方面带来性能提升,但是将数据存储在对象的属性中却更有利于代码的设计与架构。所以在选择存储方式时,需要综合考虑其利弊。一般而言,编码时,还是推荐使用面向对象的原则,把相关的数据信息与操作封装在一个对象中,但要避免对象的深层嵌套,比如下面这样,因为每增加一层对象,就会增加一份代价:
var foo = {
bar:{
student: {
name:'John Doe'
}
}
}
foo.bar.student.name // => John Doe
如果经常会使用到对象的某个属性或者方法,那么可以选择把它缓存到局部变量中,以加快它的读取速度,比如:
var isArray = Array.isArray,
slice = Array.prototype.slice;
function foo() {
var arr = slice.apply(arguments);
console.log(isArray(arr));
}
foo(); // =>true
但注意上面介绍的方式在针对DOM方法时,不会按照我们想象的那样工作:
var gid = document.getElementById;
console.log(gid('foo').innerText); // 报错 Illegal invocation
作用域的管理,简而言之:内层作用域可以访问外层作用域的变量,而反之,内层作用域的变量对外层是不可见的。但其原理究竟如何呢?
一切的一切都要从Function构造器说起。在javascript中,万物皆对象,函数也是对象,由Function构造函数产生。在函数初始化时,Function构造函数既会为函数添加一些可以由程序员操作的属性,比如:prototype和name,还会为其添加一些程序员无法访问,仅供javascript引擎访问的属性,其中有一个属性叫做[[scope]]
,它就是传说中的作用域。
[[scope]]
属性即指向该函数的作用域链,它规定了哪些属性可以被对象访问。以下面这个全局函数为例:
var c = 'foo';
function add(a, b){
return a+b
}
作用域链实质上是一个对象链表(可以假想成一个数组),其第一个元素是一个包含了所有全局范围内定义的变量的对象,其中就包括:document、navigator、c等等。
在执行函数add时,比如下面的代码:
var d = add(1,2);
引擎会创建一个独一无二的函数执行上下文(也称执行环境),并把函数add的[[scope]]
属性复制一份作为执行环境自己的作用域链。之后,它会创建一个包含了该运行函数所有的局部变量,参数以及this的活动对象,并把它推送至自己作用域链的最顶端。注意函数的作用域链和执行环境的作用域链是不同的。
当在函数的逻辑中寻找变量时,我们的javascript引擎就会从上到下的遍历函数执行上下文作用域链的元素,直至找到与查找的变量名称相同的属性为止。实际上,这个搜索过程会对性能造成影响。即:拥有该变量的对象元素在作用域链中越靠前,越容易找到,损耗越小。
这其实也说明使用var声明的变量声明提前的原因。因为在函数执行时,先创建包含函数所有局部变量的活动对象,再去执行函数逻辑。
一般而言,作用域链一旦确定就无法改变,但JS中提供了两种方式可以用来改变作用域链,它们是:with和catch子语句。
ES5的严格模式下明文规定禁止使用with,但只知道它会影响性能而不知为何的同学应该不在少数
看下面的代码:
var obj = {
nickname:'Kyle',
age: 21
};
function foo() {
var bar = 'bar';
var nickname = 'Agent';
with(obj){
console.log(nickname); // Kyle
console.log(age); // 21
console.log(bar); // bar
}
}
foo();
使用with语句的本质,是将with语句后面括号中的对象直接添加到函数执行上下文作用域链的顶部,这使得nickname、age在访问时像是使用局部变量一样。但这会导致很严重的性能损耗,因为当我们试着去访问真正的局部变量,比如bar时,所有的局部变量存储在作用域链的第二个对象中了,这增加了访问代价。
而且,上面在访问nickname时,根据作用域链自顶向下搜索的原则,obj的nickname属性先被找到,立即返回结果,而局部变量nickname则被obj的nickname属性遮蔽了。
根据上述原因,ES5的严格模式中决定杜绝对with语句的使用。
catch子句也能够改变函数的作用域链。在try语句块中出现错误时,执行过程会自动跳转到catch子语句中,并把一个异常对象推到作用域的首位。
虽然使用try-catch时,会改变作用域链,增加访问局部变量时性能的消耗,但瑕不掩瑜,try-catch仍然是非常有用的语句。使用函数委托的方式能够把catch子句对性能的损耗降低到最小:
try{
// some error
}catch(err){
handleError(err)
};
这样做只执行了一条语句,并且没有访问局部变量,所以作用域链的临时改变就不会影响代码性能。
综上所述:改变作用域链后,访问局部变量会对性能造成影响,因为包含局部变量的活动对象不再位于作用域链的首位。
闭包是javascript中最重要的特性之一,简而言之,闭包指的是:能够记住创建它的环境的函数。相信通过对上文的阅读,你已经大概对闭包的实现有了一个基本的猜想。
我们通过下面这个简单的例子来学习闭包的本质
function test(){
var bar = 'hello';
return function(){
alert(bar);
}
}
test()(); // 弹出hello
首先我们的test函数被初始化,其[[scope]]
作用域链中只有一个对象,该对象包含了全局范围内定义的所有变量,比如document。我们给它起一个别名叫做global
。当test函数被执行时,引擎会为其创建一个执行上下文,执行上下文会将函数本身的[[scope]]
属性完全拷贝过来,作为其作用域链,并且创建一个包含该函数内部所有局部变量和参数的活动变量(我们为它起名叫active
),然后将其推送到执行上下文作用域链的首位。
但故事并没有结束,因为在这个函数的执行过程中,初始化了另一个匿名函数。在初始化这个匿名函数时,其作用域链的[[scope]]属性当然会被创建它的环境中所定义的变量所组成的对象所填充,而创建它的上下文,也就是函数test中所定义的变量所组成的对象,正是先前在执行函数test时创建的活动对象active
,这样匿名函数作用域链的第一个元素指向对象active,第二个则元素执行创建test函数的环境,也就是global
对象,因为global已经是全局了,所以到此为止。但如果还有环境,那么继续向下排列。由于匿名函数的[[scope]]属性包含了与执行环境作用域相同的对象引用,因此,函数test在执行完毕后,活动对象active不会随着执行环境一同销毁。这也就是闭包的底层原理了。
根据上述原理,下面的代码会导致内存泄露:
function test(){
var bar = 'hello',
foo = 'foo';
return function(){
alert(bar);
}
}
test()(); // 弹出hello
上面的代码中,foo永远也不会被使用到,但是它仍然始终存在于活动对象中,这样就会导致内存泄露。
这个部分大部分参考我写的另一篇文章《轻松理解javascript原型》,你可以在我的博客上查找到原文。
在javascript中,函数是对象,我们可以把函数存储在一个变量中,也可以给函数添加属性。JS中所有的函数都由一个叫做Function的构造器创建。当一个函数对象被创建时,Function构造器会”隐蔽地”给这个函数对象添加一个叫做prototype的属性,其值是一个包含函数本身(constuctor)的对象:
this.prototype = {constructor : this}
其中,prototype就是“传说中”的原型,而的this指的就是函数本身。javascript会“公平地”为每个函数创建原型对象。无论这个函数以后是否用作构造函数。
下面的代码是个很好的例子:
function sayHello () {
}
console.log(sayHello.prototype) //=> { constuctor : sayHello(), __proto__ : Object}
你会发现还有一个叫做__proto__
的属性,这又是什么呢?先不要乱了阵脚,继续向下看。
当函数“有志气”成为一名构造函数的时候,prototype属性开始真正发挥作用。new运算符是一名优秀的“工匠”,它可以使用对象模具——构造函数产生一个个的实例对象。
当new运算符使用构造函数产生对象实例时,会“强制性地”在新对象中添加一个叫做__proto__
的属性作为”隐秘连接“,它的值就等于它的构造函数prototype属性的值,换句话说,使这它与其构造函数的prototype属性指向同一个对象。
显然,每一个javascript对象都会拥有一个叫做__proto__
的属性,因为javascript中所有的对象都隐式或显式地由构造函数new出,于是,也可以说在javscript中没有真正意义上的空对象。
当然,我们的new运算符没有忘记它的“老本行”:它会将构造函数中定义的实例属性或方法(this.属性)添加到新创建的对象中。
下面的代码或许能够帮助你理解:
function Student (name) {
this.name = name;
}
// 为构造器的prototype新增一个属性
Student.prototype.age = 20;
var Tom = new Student("Tom");
console.log(Tom.name); // => Tom
console.log(Tom.__proto__.constructor); // =>function Student() {this.name = name}
console.log(Tom.__proto__.age); // =>20
简而言之,原型prototype是javascript函数的一个属性,当这个函数作为构造器产生实例时,new运算符会获得函数的prototype属性的值并将其赋给对象实例的__proto__
属性,并以此作为隐秘连接。因此,你在构造函数的prototype属性中设置的值都会被该构造器的实例所拥有。
之所以还不说原型链,是因为我想先试着不把事情变得那么复杂:还是以上面的Student伪类为例。Tom对象的__proto__
属性来自其构造器Student的prototype属性,这个应该很好理解。但是,问题是Student的prototype也是一个对象,它有我们设置的age属性,更有每个对象都拥有的__proto__
属性。那么问题来了,Student的prototype对象是谁创建的呢,它的__proto__
值从来自哪里呢?
Object构造器是无名英雄——它创建所有以对象字面量表示的对象。Student的prototype对象正是由Object构造器创建的,它的__protot__
值是在Object构造器的prototype属性。
希望下面的例子能够帮助你理解:
var obj = {};
console.log(obj.constructor); // =>function Object() {native code}
console.log('__proto__' in obj); // =>true
好的,原型链在我们试图从某个对象获取某个属性(或方法)时发挥作用。如果那个属性刚好像下面这样存在于这个对象之中,那无需多虑,直接返回即可。
var student = {name : 'Jack'}
student.name // =>Jack
但是,如果这个属性不直接存在于这个对象中,那么javascript会在这个对象的构造器的prototype属性,也就是这个对象的__proto__
属性中进行查找。
由于访问__proto__
并非官方ECMA标准的一部分,所以后面我们都说”其构造函数的prototype属性”,而不说“这个对象的__proto__
属性“了。
好吧,如果找到,则直接返回,否则,继续这个循环,因为prototype的值也是对象:继续在 /该对象的构造器的prototype对象/ 的构造器的prototype属性中寻找……。
所以你该知道,由于prototype属性一定是一个对象,因此原型链或者说查找中的最后一站是Object.prototype。如果查找到这里仍然没有发现,则循环结束,返回undefined。
因为这种链查找机制的存在,上面的代码得到了简化,这也是Javascript中继承的基石:
console.log(Tom.__proto__.age); // =>20
console.log(Tom.age); // =>20
好吧,我希望通过下面的例子带你拉通走一遍:
var arr = [];
console.log(arr.foo); //=>undefined
首先,当JS得知要访问arr的foo属性时,他首先会在arr对象里查找foo属性,但是结局令人失望。之后,它会去查找arr的构造函数即Array的prototype属性,看是否能在这里查找到什么线索,结果也没有。最后,它会去查找Array的prototype对象的构造函数——Object的prototype属性——仍然没有找到,搜索结束,返回undefined。
之所以举一个原生的构造函数的例子是因为我一直害怕因为使用自定义的例子而给大家带来一种只有自定义的构造函数才可以这样的错觉。你要知道,这篇文章所讲述的道理适合一切的构造器。
好了,让我们看一个自定义的构造器并在原型链上查找到属性的”好“例子:
Object.prototype.foo = "some foo";
function Student(name) {
this.name = name;
}
// 为构造器的prototype新增一个属性
Student.prototype.age = 20;
var Tom = new Student("Tom");
console.log(Tom.name); // => Tom
console.log(Tom.age); // =>20
console.log(Tom.foo); // =>some foo
这里要说明的是,原型链在查找时,会使用它查找到的第一个值;一旦找到,立即返回,不会再往下进行寻找。
对js数据存取的深入探究有利于加深我们对js底层原理与实现的思考与认知,但其难点在于偏理论性,不易实践,也难于测试。