作用域深入探究

  作用域是每种计算机语言最重要的基础之一,也是JavaScript最重要的概念之一。要想真正的深入了解JavaScript,了解其作用域链非常必要。而且作用域链的层级关系是JavaScript性能优化的一个重要部分,这关系到变量在内存里的读写速度。
本文将帮助大家深入了解JavaScript作用域和作用域链的工作原理。

JavaScript的作用域是什么

作用域,在维基百科上解释是:在电脑程序设计中,作用域(scope,或译作有效范围)是名字(name)与实体(entity)的绑定(binding)保持有效的那部分计算机程序。
简单的说,作用域就是变量与函数的可访问范围,即作用域控制着变量与函数的可见性和生命周期。在JavaScript中,变量的作用域有全局作用域和局部作用域两种,局部作用域又称为函数作用域。

全局作用域

在代码中任何地方都能访问到的对象拥有全局作用域,一般来说以下几种情形拥有全局作用域:

程序最外层定义的函数或者变量

1
2
3
4
5
6
7
8
9
10
11
12
var a = "tsrot";
function hello(){
alert(a);
}

function sayHello(){
hello();
}

alert(a); //能访问到tsrot
hello(); //能访问到tsrot
sayHello(); //能访问到hello函数,然后也能访问到tsrot

所有末定义直接赋值的变量(不推荐)

1
2
3
4
5
6
7
function hello(){
a = "tsrot";
var b = "hello tsrot";
}

alert(a); //能访问到tsrot
alert(b); //error 不能访问

所有window对象的属性和方法

一般情况下,window对象的内置属性都拥有全局作用域,例如window.name、window.location、window.top等等。

局部作用域(函数作用域)

局部作用域在函数内创建,在函数内可访问,函数外不可访问。

1
2
3
4
5
6
7
function hello(){
var a = "tsrot";
alert(a);
}

hello(); //函数内可访问到tsrot
alert(a); //error not defined

作用域链是什么

了解作用域链之前我们要知道一下几个概念:

  • 变量和函数的声明
  • 函数的生命周期
  • Activetion Object(AO)、Variable Object(VO)

变量和函数的声明

在JavaScript引擎解析JavaScript代码的时候,首先,JavaScript引擎会把变量和函数的声明提前进行预解析,然后再去执行其他代码。

变量声明:变量的声明只有一种方式,那就是用var关键字声明,直接赋值不是一种声明方式。这仅仅是在全局对象上创建了新的属性(而不是变量)。它们有一下区别:
(1)因为它只是一种赋值,所以不会声明提前

1
2
3
4
alert(a); // undefined
alert(b); // error "b" is not defined
b = 10;
var a = 20;

(2)直接赋值形式是在执行阶段创建

1
2
3
4
5
6
alert(a); // undefined, 这个大家都知道
b = 10;
alert(b); // 10, 代码执行阶段创建

var a = 20;
alert(a); // 20, 代码执行阶段修改

(3)变量不能删除(delete),属性可以删除

1
2
3
4
5
6
7
8
9
10
11
12
13
a = 10;
alert(window.a); // 10

alert(delete a); // true

alert(window.a); // undefined

var b = 20;
alert(window.b); // 20

alert(delete b); // false

alert(window.b); // 仍然为 20,因为变量是不能够删除的。

但是,这里有一个意外情况,就是在“eval”的上下文中,变量是可以删除的:

1
2
3
4
5
6
eval('var a = 10;');
alert(window.a); // 10

alert(delete a); // true

alert(window.a); // undefined

有些debug工具也是可以删除的,因为它们使用了 eval()方法来执行代码的。

函数声明:函数的声明有三种方式

  1. function name( ){ }直接创建方式

    1
    2
    3
    4
    5
    function add(a,b){
    return a+b;
    }

    add(5,4);
  2. new Funtion构建函数创建

    1
    2
    3
    var add=new Function("a", "b", "return a+b;");

    add(4,5);
  3. 给变量赋值匿名函数方法创建

    1
    2
    3
    4
    5
    var add = function(a,b){
    return a+b;
    }

    add(4,5);

后面两种方法,在声明前访问时,返回的都是一个undefined的变量。当然,在声明后访问它们都是一个function的函数。

注意:如果变量名和函数名声明时相同,函数优先声明。

1
2
3
4
5
6
7
8
9
10
alert(x); // function

var x = 10;
alert(x); // 10

x = 20;

function x() {};

alert(x); // 20

函数的生命周期

函数的的生命周期分为创建和执行两个阶段。

在函数创建阶段,JS解析引擎进行预解析,会将函数声明提前,同时将该函数放到全局作用域中或当前函数的上一级函数的局部作用域中。

在函数执行阶段,JS引擎会将当前函数的局部变量和内部函数进行声明提前,然后再执行业务代码,当函数执行完退出时,释放该函数的执行上下文,并注销该函数的局部变量。

什么是AO、VO

英文解释:
AO:Activetion Object(活动对象)
VO:Variable Object(变量对象)

VO对应的是函数创建阶段,JS解析引擎进行预解析时,所有的变量和函数的声明,统称为Variable Object。该变量与执行上下文相关,知道自己的数据存储在哪里,并且知道如何访问。VO是一个与执行上下文相关的特殊对象,它存储着在上下文中声明的以下内容:

  • 变量 (var, 变量声明);
  • 函数声明 (FunctionDeclaration, 缩写为FD);
  • 函数的形参

举个例子:

1
2
3
4
5
6
7
8
function add(a,b){
var sum = a + b;
function say(){
alert(sum);
}
return sum;
}
// sum,say,a,b 组合的对象就是VO,不过该对象的值基本上都是undefined

AO对应的是函数执行阶段,当函数被调用执行时,会建立一个执行上下文,该执行上下文包含了函数所需的所有变量,该变量共同组成了一个新的对象就是Activetion Object。该对象包含了:

  • 函数的所有局部变量
  • 函数的所有命名参数
  • 函数的参数集合
  • 函数的this指向

举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function add(a,b){
var sum = a + b;
function say(){
alert(sum);
}
return sum;
}

add(4,5);
// 我用JS对象来表示AO
// AO = {
// this : window,
// arguments : [4,5],
// a : 4,
// b : 5,
// say : <reference to function>,
// sum : undefined
// }

JavaScript作用域链

现在我们回到主题,作用域链。

当代码在一个环境中执行时,会创建变量对象的一个作用域链(scope chain)来保证对执行环境有权访问的变量和函数的有序访问。作用域第一个对象始终是当前执行代码所在环境的变量对象(VO)。

1
2
3
4
function add(a,b){
var sum = a + b;
return sum;
}

假设函数是在全局作用域中创建的,在函数a创建的时候,它的作用域链填入全局对象,全局对象中有所有全局变量,此时的全局变量就是VO。此时的作用域链就是:

1
2
3
4
5
6
7
8
此时作用域链(Scope Chain)只有一级,就为Global Object

scope(add) -> Global Object(VO)

VO = {
this : window,
add : <reference to function>
}

如果是函数执行阶段,那么将其activation object(AO)作为作用域链第一个对象,第二个对象是上级函数的执行上下文AO,下一个对象依次类推。

1
add(4,5);

例如,调用add后的作用域链是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
此时作用域链(Scope Chain)有两级,第一级为AO,然后Global Object(VO)

scope(add) -> AO -> VO

AO = {
this : window,
arguments : [4,5],
a : 4,
b : 5,
sum : undefined
}

VO = {
this : window,
add : <reference to function>
}

在函数运行过程中标识符的解析是沿着作用域链一级一级搜索的过程,从第一个对象开始,逐级向后回溯,直到找到同名标识符为止,找到后不再继续遍历,找不到就报错。

看过上面的内容后,可能还有人不懂,我再通熟易懂的解释一遍,先举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var x = 10;

function foo() {
var y = 20;

function bar() {
var z = 30;

console.log(x + y + z);
};

bar()
};

foo();

上面代码的输出结果为”60″,函数bar可以直接访问”z”,然后通过作用域链访问上层的”x”和”y”。此时的作用域链为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
此时作用域链(Scope Chain)有三级,第一级为bar AO,第二级为foo AO,然后Global Object(VO)

scope -> bar.AO -> foo.AO -> Global Object

bar.AO = {
z : 30,
__parent__ : foo.AO
}

foo.AO = {
y : 20,
bar : <reference to function>,
__parent__ : <Global Object>
}

Global Object = {
x : 10,
foo : <reference to function>,
__parent__ : null
}

作为一个良好的开发者必需考虑程序的运行性能,作用域链的层级关系是JavaScript性能优化的一个重要部分。因为这关系到变量在内存里的读写速度。

作用域链的层级关系

尽量使用局部变量

从作用域链的结构可以看出,在执行上下文的作用域链中,标识符所在的位置越深,读写速度就会越慢。

全局变量总是存在于执行上下文作用域链的最末端,因此在标识符解析的时候,查找全局变量是最慢的,并且全局变量将常驻内存直到程序退出,而局部变量会在函数运行完直接销毁。所以,在编写代码的时候应尽量少使用全局变量,尽可能使用局部变量。

一个好的经验法则是:如果一个跨作用域的对象被引用了一次以上,则先把它存储到局部变量里再使用。例如:

1
2
3
4
5
6
7
8
9
function toggle(){
if(document.getElementById('btn').className == 'active'){
document.getElementById('btn').className = '';
//do someThing
}else{
document.getElementById('btn').className = 'active';
//do someThing
}
}

以上代码中document.getElementById('btn').className被引用了三次,查找该变量必须遍历整个作用域链,直到最后在全局对象中才能找到document,然后再去找它的方法和属性,这样严重的影响了程序性能。我们可以改为:

1
2
3
4
5
6
7
8
9
10
function toggle(){
var btnClass = document.getElementById('btn').className;
if(btnClass == 'active'){
btnClass = '';
//do someThing
}else{
btnClass = 'active';
//do someThing
}
}

尽量不要改变作用域链

函数每次执行时对应的执行上下文都是独一无二的,所以多次调用同一个函数就会导致创建多个执行上下文,当函数执行完毕,执行上下文会被销毁。每一个执行上下文都和一个作用域链关联。一般情况下,在执行上下文运行的过程中,其作用域链只会被 with 语句和 catch 语句影响。

with语句是对象的快捷应用方式,用来避免书写重复代码。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var o = {href:"github.com"};  
var href = "blog.xieliqun.com";

function buildUrl(){
var q = "?name=tsrot";
with(o){
var url = href+q;
}
return url;
}

var result = buildUrl();
alert(result); //github.com?name=tsrot
alert(href); //blog.xieliqun.com

第一个alert使用的o对象里的href,所以弹出github.com?name=tsrot,第二个alert使用的就是全局的href。

当代码运行到with语句时,运行期上下文的作用域链临时被改变了。一个新的可变对象被创建,它包含了参数指定的对象的所有属性。这个对象将被推入作用域链的头部,这意味着函数的所有局部变量现在处于第二个作用域链对象中,因此访问代价更高了。此时的作用域链为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

scope -> with.AO -> with.VO -> buildUrl.AO -> Global Object

with.AO = {
url : undefined
}

with.VO = {
href : "github.com"
}

buildUrl.AO = {
q : "?name=tsrot"
}

Global Object = {
o : {href:"github.com"},
href : "blog.xieliqun.com",
buildUrl : <reference to function>,
result : undefined
}

另外一个会改变作用域链的是try-catch语句中的catch语句。当try代码块中发生错误时,执行过程会跳转到catch语句,然后把异常对象推入一个可变对象并置于作用域的头部。在catch代码块内部,函数的所有局部变量将会被放在第二个作用域链对象中。

闭包问题

一个函数只要内部函数未销毁(内部函数存在被调用的可能),这个函数就不会销毁,将一直存在于内存中,只有所有内部函数都销毁了,并所有的业务代码都已执行完,这个函数才会被释放。我们看看最常见的闭包问题:

1
2
3
4
5
6
7
8
9
10
11
function show(){
var li = document.getElementsByTagName('li');
var length = li.length;
for(var i=0;i<length;i++){
li[i].onclick = function(){
alert(i);
}
}
}

show();

当点击li标签时,弹出的一直都是length的大小。这是一个比较经典的错误。为什么会这样呢?

由于show的内部函数(click事件处理程序时刻有调用可能),所以show的作用域链不能被销毁(只能页面卸载是销毁),i的值一直保持for循环执行完后的length值,此时的click的函数只是进行了声明而未运行,当click触发的时候,函数才开始执行,这个时候i的值已经是length了。所以每次触发onclick的时候才会alert length。我们进行改一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
function show(){
var li = document.getElementsByTagName('li');
var length = li.length;
for(var i=0;i<length;i++){
(function(n){
li[n].onclick = function(){
alert(n);
}
})(i)
}
}

show();

为什么这样就行了呢,这时候onclick引用的变量变成了n,而由于立即执行函数的原因,每个onclick函数在作用域链中分别保持着对应的n(0~length-1),这时候就可以了。

闭包会使子函数保持其作用域链的所有变量及函数与内存中,内存消耗很大,在使用的时候尽量销毁父函数不再使用的变量。你经常访问一些范围之外的标识符,每次访问都将导致一些性能损失。