前言
javascript 是我接触到的第二门编程语言,第一门是 C 语言。然后才是 C++、Java 还有其它一些什么。所以我对 JavaScript 是非常有感情的,毕竟使用它有十多年了。早就想写一篇关于 JavaScript 方面的东西,但是在博客园中,写 JavaScript 的文章是最多的,从入门的学习笔记到高手的心得体会一应俱全,不管我怎么写,都难免落入俗套,所以迟迟没有动笔。另外一个原因,也是因为在 Ubuntu 环境中一直没有找到很好的 JavaScript 开发工具,这种困境直到 Node.js 和 Visual Studio Code 的出现才完全解除。
十多年前,对 JavaScript 的介绍都是说他是基于对象的编程语言,而从没有哪本书会说 JavaScript 是一门面向对象的编程语言。基于对象很好理解,毕竟在 JavaScript 中一切都是对象,我们随时可以使用点号操作符来调用某个对象的方法。但是十多年前,我们编写 JavaScript 程序时,都是像 C 语言那样使用函数来组织我们的程序的,只有在论坛的某个角落中,有少数的高手会偶尔提到你可以通过修改某个对象的prototype
来让你的函数达到更高层次的复用,直到 Flash 的 ActionScript 出现时,才有人系统介绍基于原型的继承。十余年后的现在,使用 JavaScript 的原型链和闭包来模拟经典的面向对象程序设计已经是广为流传的方案,所以,说 JavaScript 是一门面向对象的编程语言也丝毫不为过。
我喜欢 JavaScript,是因为它非常具有表现力,你可以在其中发挥你的想象力来组织各种不可思议的程序写法。也许 JavaScript 语言并不完美,它有很多缺陷和陷阱,而正是这些很有特色的语言特性,让 JavaScript 的世界出现了很多奇技淫巧。
对象和原型链
JavaScript 是一门基于对象的编程语言,在 JavaScript 中一切都是对象,包括函数,也是被当成第一等的对象对待,这正是 JavaScript 极其富有表现力的原因。在 JavaScript 中,创建一个对象可以这么写:
var someThing = new Object();
这和在其它面向对象的语言中使用某个类的构造函数创建一个对象是一模一样的。但是在 JavaScript 中,这不是最推荐的写法,使用对象字面量来定义一个对象更简洁,如下:
var anotherThing = {};
这两个语句其本质是一样的,都是生成一个空对象。对象字面量也可以用来写数组以及更加复杂的对象,这样:
var weekDays = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
这样:
var person = {
name : "youxia",
age : 30,
gender : "male",
sayHello : function(){ return "Hello, my name is " + this.name; }
}
甚至这样数组和对象互相嵌套:
var workers = [{name : "somebody", speciality : "Java"}, {name : "another", speciality : ["html", "CSS", "JavaScript"]}];
需要注意的是,对象字面量中的分隔符都是逗号而不是分号,而且即使 JavaScript 对象字面量的写法和 JSON 的格式相似度很高,但是它们还是有本质的区别的。
在我们捣鼓 JavaScript 的过程中,工具是非常重要的。我这里介绍的第一个工具就是 Chromium 浏览器中自带的 JavaScript 控制台。在 Ubuntu 中安装 Chromium 浏览器只需要一个命令就可以搞定,如下:
sudo apt-get install chromium
启动 Chromium 浏览器后,只需要按 F12 就可以调出 JavaScript 控制台。当然,在菜单中找出来也可以。下面,让我把上面的示例代码输入到 JavaScript 控制台中,一是可以看看我们写的代码是否有语法错误,二是可以看看 JavaScript 对象的真面目。如下图:
对于博客园中广大的前端攻城狮来讲,Chromium 的 JavaScript 控制台已经是一个烂大街的工具了,在控制台中写console.log("Hello, World!");
就像是在 C 语言中写printf("Hello, World!");
一样成为了入门标配。在控制台中输入 JavaScript 语句后,一按 Enter 该行代码就立即执行,如果要输入多行代码怎么办呢?一个办法就是按 Shift+Enter 进行换行,另外一个办法就是在别的编辑器中写好然后复制粘贴。其实在 Chromium 的 JavaScript 控制台中还有一些不那么广泛流传的小技巧,比如使用console.dir()
函数输出 JavaScript 对象的内部结构,如下图:
从图中,可以很容易看出每一个对象的属性、方法和原型链。
和其它的面向对象编程语言不同, JavaScript 不是基于类的代码复用体系,它选择了一种很奇特的基于原型的代码复用机制。通俗点说,如果你想创建很多对象,而这些对象有某些相同的属性和行为,你为每一个对象编写单独的代码肯定是不合算的。在其它的面向对象编程语言中,你可以先设计一个类,然后再以这个类为模板来创建对象。我这里称这种方式为经典的面向对象体系。而在 JavaScript 中,解决这个问题的方式是把一个对象作为另外一个对象的原型,拥有相同原型的对象自然拥有了相同的属性和行为。对象拥有原型,原型又有原型的原型,最终构成一个原型链。当访问一个对象的属性或方法的时候,先在对象本身中查找,如果找不到,则到原型中查找,如果还是找不到,则进一步在原型的原型中查找,一直到原型链的最末端。在现代 JavaScript 模式中,硬是用函数、闭包和原型链模拟了经典的面向对象体系。
原型这个概念本身并不复杂,复杂的是 JavaScript 中的隐式原型和函数对象。什么是隐式原型,就是说在 JavaScript 中不管你以什么方式创建一个对象,它都会自动给你生成一个原型对象,我们的对象中,有一个隐藏的__proto__
属性,它指向这个自动生成的原型对象;并且在 JavaScript 中不管你以什么方式创建一个对象,它最终都是从构造函数生成的,以对象字面量构造的对象也有构造函数,它们分别是Object()
和Array()
,每一个构造函数都有一个自动生成的prototype
属性,它也指向那个自动生成的原型对象。而且在 JavaScript 中一切都是对象,构造函数也不例外,所以构造函数既有prototype
属性,又有__proto__
属性。再而且,自动生成的原型对象也是对象,所以它也应该有自己的原型对象。你看,说起来都这么拗口,理解就更加不容易了,更何况 JavaScript 中还内置了Object()
、Array()
、String()
、Number()
、Boolean()
、Function()
这一系列的构造函数。看来不画个图是真的理不顺了。下面我们来抽丝剥茧。
先考察空对象someThing
,哪怕它是以对象字面量的方式创建的,它也是从构造函数Object()
构造出来的。这时,JavaScript 会自动创建一个原型对象,我们称这个原型对象为Object.prototype
,构造函数Object()
的prototype
属性指向这个对象,对象someThing
的__proto__
属性也指向这个对象。也就是说,构造函数Object()
的prototype
属性和对象someThing
的__proto__
属性指向的是同一个原型对象。而且,这个原型对象中有一个constructor
属性,它又指回了构造函数Object()
,这样形成了一个环形的连接。如下图:
要注意的是,这个图中所显示的关系是对象刚创建出来的时候的情况,这些属性的指向都是可以随意修改的,改了就不是这个样子了。下面在 JavaScript 控制台中验证一下上图中的关系:
请注意,构造函数Object()
的prototype
属性和__proto__
属性是不同的,只有函数对象才同时具有这两个属性,普通对象只有__proto__
属性,而且这个__proto__
属性是隐藏属性,不是每个浏览器都允许访问的,比如 IE 浏览器。下面,我们来看看 IE 浏览器的开发者工具:
这是一个反面教材,它既不支持console.dir()
来查看对象,也不允许访问__proto__
内部属性。所以,在后面我讲到继承时,需要使用特殊的技巧来避免在我们的代码中使用__proto__
内部属性。上面的例子和示意图中,都只说构造函数Object()
的prototype
属性指向原型对象,没有说构造函数Object()
的__proto__
属性指向哪里,那么它究竟指向哪里呢?这里先留一点悬念。
下一步,我们自己创建一个构造函数,然后使用这个构造函数创建一个对象,看看它们之间原型的关系,代码是这样的:
function Person(name, age, gender){
this.name = name;
this.age = age;
this.gender = gender;
}
Person.prototype.sayHello = function(){ return "Hello, my name is " + this.name; };
var somebody = new Person("youxia", 30, "male");
输入到 Chromium 的 JavaScript 控制台中,然后使用console.dir()
分别查看构造函数Person()
和对象somebody
,如下两图:
用图片来表示它们之间的关系,应该是这样的:
我使用蓝色表示构造函数,黄色表示对象,如果是 JavaScript 自带的构造函数和 prototype 对象,则颜色深一些。从上图中可以看出,构造函数Person()
有一个prototype
属性和一个__proto__
属性,__proto__
属性的指向依然留悬念,prototype
属性指向Person.prototype
对象,这是系统在我们定义构造函数Person()
的时候,自动创建的一个和构造函数Person()
相关联的原型对象,请注意,这个原型对象是和构造函数Person()
相关联的原型对象,而不是构造函数Person()
的原型对象。当我们使用构造函数Person()
创建对象somebody
时,somebody
的原型就是这个系统自动创建的原型对象Person.prototype
,就是说对象somebody
的__proto__
属性指向原型对象Person.prototype
。而这个原型对象中有一个constructor
属性,又指回构造函数Person()
,形成一个环。这和空对象和构造函数Object()
是一样的。而且原型对象Person.prototype
的__proto__
属性指向Object.prototype
。如果在这个图中把空对象和构造函数Object()
加进去的话,看起来是这样的:
有点复杂了,是吗?不过这还不算最复杂的,想想看,如果把JavaScript 内置的Object()
、Array()
、String()
、Number()
、Boolean()
、Function()
这一系列的构造函数以及与它们相关联的原型对象都加进去,会是什么情况?每一个构造函数都有一个和它相关联的原型对象,Object()
有Object.prototype
,Array()
有Array.prototype
,依此类推。其中最特殊的是Function()
和Function.prototype
,因为所有的函数和构造函数都是对象,所以所有的函数和构造函数都有构造函数,而这个构造函数就是Function()
。也就是说,所有的函数和构造函数都是由Function()
生成,包括Function()
本身。所以,所有的构造函数的__proto__
属性都应该指向Function.prototype
,前面留的悬念终于有答案了。如果只考虑构造函数Person()
、Object()
和Function()
及其关联的原型对象,在不解决悬念的情况下,图形是这样的:
可以看到,每一个构造函数和它关联的原型对象构成一个环,而且每一个构造函数的__proto__
属性无所指。通过前面的分析我们知道,每一个函数和构造函数的__proto__
属性应该都指向Function.prototype
。我用红线标出这个关系,结果应该如下图:
如果我们画出前面提到过的所有构造函数、对象、原型对象的全家福,会是个什么样子呢?请看下图:
晕菜了没?欢迎指出错误。把图一画,就发现其实 JavaScript 中的原型链没有那么复杂,有几个内置构造函数就有几个配套的原型对象而已。我这里只画了六个内置构造函数和一个自定义构造函数,还有几个内置构造函数没有画,比如Date()
、Math()
、Error()
、RegExp()
,但是这不影响我们理解。写到这里,是不是应该介绍一下我使用的画图工具了?
我使用的画图工具Graphviz
在我的 Linux 系列中,有一篇介绍画图工具的文章,不过我这次使用的工具是另辟蹊径的 Graphviz,据说这是一个由贝尔实验室的几个牛人开发和使用的画流程图的工具,它使用一种脚本语言定义图形元素,然后自动进行布局和生成图片。首先,在 Ubuntu 中安装 Graphiz 非常简单,一个命令的事儿:
sudo apt-get install graphviz
然后,创建一个文本文件,我这里把它命名为sample.gv
,其内容如下:
digraph GraphvizDemo{
Alone_Node;
Node1 -> Node2 -> Node3;
}
这是一个最简单的图形定义文件了,在 Graphviz 中图形仅仅由三个元素组成,它们分别是:1、Graph,代表整个图形,上面源代码中的digraph GraphvizDemo{}
就定义了一个 Graph,我们还可以定义 SubGraph,代表子图形,可以用 SubGraph 将图形中的元素分组;2、Node,代表图形中的一个节点,可以看到 Node 的定义非常简单,上面源码中的Alone_Node;
就是定义了一个节点;3、Edge,代表连接 Node 的边,上面源码中的Node1 -> Node2 -> Node3;
就是定义了三个节点和两条边,可以先定义节点再定义边,也可以直接在定义边的同时定义节点。然后,调用 Graphviz 中的dot
命令,就可以生成图形了:
dot -Tpng sample.gv > sample.png
生成的图形如下:
上面的图形中都是用的默认属性,所以看起来效果不咋地。我们可以为其中的元素定义属性,包括定义节点的形状、边的形状、节点之间的距离、字体的大小和颜色等等。比如下面是一个稍微复杂点的例子:
digraph GraphvizDemo{
nodesep=0.5;
ranksep=0.5;
node [shape="record",style="filled",color="black",fillcolor="#f4a582",fontname="consolas",fontsize=15];
edge [style="solid",color="#053061"];
root [label="<l>left|<r>right"];
left [label="<l>left|<r>right"];
right [label="<l>left|<r>right"];
leaf1 [label="<l>left|<r>right"];
leaf2 [label="<l>left|<r>right"];
leaf3 [label="<l>left|<r>right"];
leaf4 [label="<l>left|<r>right"];
root:l:s -> left:n;
root:r:s -> right:n;
left:l:s -> leaf1:n;
left:r:s -> leaf2:n;
right:l:s -> leaf3:n;
right:r:s -> leaf4:n;
}
在这个例子中,我们使用了nodesep=0.5;
和ranksep=0.5
设置了 Graph 的全局属性,使用了node [shape=...];
和[edge [style=...];
这样的语句设置了 Node 和 Edge 的全局属性,并且在每一个 Node 和 Edge 后面分别设置了它们自己的属性。在这些属性中,比较特别的是 Node 的shape
属性,我将它设置为record
,这样就可以很方便地利用 Node 的label
属性来绘制出类似表格的效果了。同时,在定义 Edge 的时候还可以指定箭头的起始点。
执行dot
命令,可以得到这样的图形:
是不是漂亮了很多?虽然以上工作使用任何文本编辑器都可以完成,但是为了提高工作效率,我当然要祭出我的神器 Eclipse 了。在 Eclipse 中可以定义外部工具,所以我写一个 shell 脚本,将它定义为一个外部工具,这样,每次编写完图形定义文件,点一下鼠标,就可以自动生成图片了。使用 Eclipse 还可以解决预览的问题,只需要编写一个 html 页面,该页面中只包含生成的图片,就可以利用 Eclipse 自带的 Web 浏览器预览图片了。这样,每次改动图形定义文件后,只需要点一下鼠标生成图片,再点一下鼠标刷新浏览器就可以实时预览图片了。虽然不是所见即所得,但是工作效率已经很高了。请看动画:
作用域链、上下文环境和闭包
关于变量的作用域这个问题应该不用多讲,凡是接触编程的童鞋,无不都要从这个基础的概念开始。变量作用域的通用规则其实很简单,无非三条:1.内层的代码可以访问外层代码定义的变量,外层代码不能访问内层代码定义的变量;2.变量要先定义后使用;3.退出代码的作用域时,变量会被销毁。以 C 语言代码为例:
int a0 = 0;
{
int a1 = 1;
printf("%d\\n", a0); //可以访问外层变量,打印 0
printf("%d\\n", a2); //错误,变量 a2 还没定义呢
int a2 = 2; //变量要先定义后使用
}
/* 而且,退出作用域后,变量 a1 和 a2 会被自动销毁 */
printf("%d\\n", a1); //错误,外层代码不能访问内层变量
但是在 JavaScript 中,以上三条规则都有可能会被打破。从现在开始,我们就要开始踩坑了,在 JavaScript 语言满满的陷阱中,关于变量这一块的最多。首先第一个坑, JavaScript 中没有块作用域,只有函数作用域。也就是说,要在 JavaScript 中实现以上类似 C 语言的效果,我们的代码应该这样写:
var a0 = 0;
function someFunc(){
var a1 = 1;
console.log(a1); //可以访问外层变量,打印 0
console.log(a2); //你以为会出现错误,因为变量没有定义,但是你错了,这里不会发生错误,而是打印 undefined
var a2 = 2;
}
someFunc();
/* someFunc()执行完之后,变量 a1 和 a2 会被自动销毁 */
console.log(a1); //错误,外层代码不能访问内层变量
把这段代码复制到控制台中验证一下,我就不截图了,毕竟我这是一篇超长的熊文,图片太多会被骂的,大家自己验证就可以了。注意,定义函数后需要调用它,函数内的代码才会执行,为了方便,我以后把它写成定义完后立即调用的自执行格式。这里碰到的第二个坑就是变量提升,在 JavaScript 中,你本以为没有定义变量 a2 就使用会出现错误,哪知道定义在后面的var a2 = 2;
被提升到代码块的前面了,结果就输出 undefined
。把上面的例子稍微改一改,就可以看到经典的变量提升的坑,如下:
var a0 = 0;
(function (){
var a1 = 1;
console.log(a0); //本以为会访问外层变量a0,打印 0,哪知道定义在后面的 var a0 = 1; 被提升了,所以打印 undefined
var a0 = 1;
})(); //为了省事,写成匿名函数自执行格式
console.log(a1); //错误,外层代码不能访问内层变量
本以为这里会访问外层变量a0,打印 0,哪知道定义在后面的 var a0 = 1; 被提升了,所以打印 undefined。为什么是 undefined 而不是 1 呢?那是因为变量提升只是提升了变量的定义,没有提升变量的赋值。不仅变量定义会被提升,函数定义也会被提升,这也是一个经典的坑。如下代码:
if(true){ //因为条件恒为true,所以肯定会执行这个分支
function someFunc(){
console.log("true");
}
}else{
function someFunc(){
console.log("false");
}
}
someFunc(); //本以为会输出 true,结果却输出 false,就是因为定义在 else 分支中的函数被提升了,覆盖了定义在 true 分支中的函数
当然,以上 Bug 只会在部分浏览器中出现,在 Chromium 和 FireFox 中还是能正确输出 true 的。为了避免函数定义的提升造成的问题,在这种情况下,我们可以使用函数表达式而不是函数定义,代码如下:
if(true){ //因为条件恒为true,所以肯定会执行这个分支
var someFunc = function(){
console.log("true");
}
}else{
var someFunc = function(){
console.log("false");
}
}
someFunc();
关于函数定义和函数表达式的区别,我这里就不深入讨论了。
内层代码可以访问外层变量,所以内层代码在访问一个变量的时候,会从内层到外层逐层搜索该变量,这就是变量作用域链,理解这一点有时有助于我们优化 JavaScript 代码的执行速度,对变量的搜索的路径越短,代码执行就越快。另外,除了全局变量外,定义在函数内部的变量只有在函数执行的时候后,这个变量才会被创建,这就是执行上下文,装逼说法叫 context,每一个函数执行的时候就会创建一个 context。前面提过,在 C 语言中,一个代码块退出的时候,这个代码块的 context 和里面的变量也会被销毁,但是在 JavaScript 函数执行结束后,函数的 context 和里面的变量会被销毁吗?那可不一定哦。如果一个函数中定义的变量被捕获,那么这个函数的 context 和里面的变量就会保留,比如闭包。这个不叫坑,叫语言特性。
在博客园中,有很多人写闭包,但是都写得无比复杂,定义也不是很准确。其实闭包就是定义在内层的函数捕获了定义在外层函数中的变量,并把内层函数传递到外层函数的作用域之外执行,则外层函数的 context 不能销毁,就形成了闭包。把内层函数传递到外层函数的作用域之外有很多方法,最常见的是使用return
,其它的方法还有把内层函数赋值给全局对象的属性,或者设置为某个控件的事件处理程序,甚至使用setTimeout
和setInterval
都可以。
其实闭包并不是 JavaScript 语言特有的概念,只要是把函数当成头等对象的语言都有。C 语言和早期的 C++ 和 Java 没有,想想看,我们根本就没办法在上述语言中定义函数内部的函数。不过自从 C++ 和 Java 引入了 lambda 表达式之后,就有了闭包的概念了。
下面,我们来探索 JavaScript 中的函数执行上下文和闭包。为了印象深刻,我这里定义了一个嵌套四层的函数,函数first()
返回定义在first()
内的second()
,second()
返回定义在second()
内的third()
,third()
再返回一个匿名函数,代码如下:
var a0 = 0;
var b0 = "Global context";
function first(){
var a1 = 1;
var b1 = "first() context";
function second(){
var a2 = 2;
var b2 = "second() context";
function third(){
var a3 = 3;
var b3 = "third() context";
return function(){
var a4 = 4;
var b4 = "what\'s matter, can I see it?";
console.log([ a1, a2, a3, a4 ]);
console.log([ b1, b2, b3, b4]);
}
}
return third;
}
return second;
}
然后,调用var what = first()()();
返回最内层的匿名函数,使用console.dir(what);
来查看这个匿名函数,如下图:
从图中可以看到,返回的最内层函数被命名为function anonymous()
,其中有一个<function scope>
属性,将它展开,可以看到由于function anonymous()
对外层变量a1
、a2
、a3
、b1
、b2
、b3
的捕获而产生了三个 Closure,也就是闭包,而function anonymous()
不仅可以访问这三个闭包中的变量,还可以访问 Global 中的变量。
下面问题来了,为什么我们看不到我们定义的变量a4
和b4
呢?因为a4
和b4
只有在function anonymous()
被执行后才会产生。我们这里只是返回了function anonymous()
,还没有执行它呢。其实就算执行它我们也看不到变量a4
和b4
所在的 context,因为函数的执行总是一闪而过,如果没有形成闭包,函数一执行完该 context 就销毁了。除非我们能让该函数执行到快完的时候定住。有什么办法呢?你是不是想到了调试器?只要我们在这个函数中设置一个 breakpoint,是不是就可以看到它的 context 了呢?
Chromium 当然是自带调试功能的。不过要想在 Chromium 中调试代码就得把以上 JavaScript 代码加到 HTML 页面中。我懒得这么做。这里,我就要祭出 Node.js 和 Visual Studio Code 了。在 Ubuntu 中安装 Node.js 非常方便,只需要使用如下命令:
sudo apt-get install nodejs
sudo apt-get install nodejs-legacy
为什么要安装nodejs-legacy
呢?那是因为nodejs
中的命令是nodejs
,而nodejs-legacy
中的命令是node
,同时安装这两个包可以兼容不同的命令调用方式,其实它们本质是一样的。而编辑器技术哪家强?自从有了 Visual Studio Code 自然就不考虑其它的了。不过 Visual Studio Code 需要自己去它的 官网 下载。
把上面的代码写成一个.js
文件,然后在编辑器中每个函数的返回点设置断点,直接使用 Node.js 的调试功能,就可以查看所有的函数执行时的 context 了,如下动图:
把断点设置在每一个函数的最后一条语句,按 F5 开始调试,每次暂停都可以看到这个函数执行时产生的 context,在这个 context 中,可以看到该函数中定义的变量和函数,也就是其中显示的Local
范围的变量,以及该函数可以访问的外层变量,也就是其中显示的Closure
和Global
范围的变量。使用调试功能,我们终于可以看到a4
和b4
了,同时还可以发现,在每一个函数的 context 中,都有一个特殊的变量this
,下一节,我们来讨论函数
和this
,函数
、原型
、闭包
和this
是使用 JavaScript 模拟经典的基于类的面向对象编程的基本要素。不过在进入下一节之前,我还要来展示一下 Eclipse。
Eclipse 的最新版本 neon 终于改进了,在前一个版本中,它只支持 ECMAScript 3,而且其网页预览还是使用的 Webkit-1.0,在今年发布的这个新版本中,终于支持 ECMAScript 5了,Webkit 也用到了最新版。还加入了对 Node.js 的支持。不过 Eclipse 中关于 JavaScript 的智能提示似乎还是很差劲。Eclipse 的更新速度实在是太慢了。不过用 Eclipse 配合 Node.js 调试 JavaScript 也还不错,下面直接上图:
还有 Eclipse 的死对头,IntelliJ IDEA 和 WebStorm 调试 JavaScript 也是不错的,我就不多说了。
关于内层函数怎么捕获变量的问题,在编程语言界还有一个经典的争议,那就是关于词法作用域和动态作用域的争议。所谓词法作用域,就是在函数定义时的环境中去寻找外层变量,而动态作用域,就是在函数运行时的环境中去寻找外层变量。大多数现在程序设计语言都是采用词法作用域规则,而只有为数不多的几种语言采用动态作用域规则,包括APL、Snobol和Lisp的某些方言,还有 C 语言中的宏定义。很显然, JavaScript 采用的是词法作用域,变量的作用域链是在函数定义的时候就决定了的。而对于动态作用域的例子,我们可以看看如下的用 LISP 语言定义的一个函数:
(let ((y 7))
(defun scope-test (x) (list x y)))
这个函数调用时,如果是采用动态作用域的语言中,如 emacs lisp,它不是在定义它的环境中去寻找自由变量y
,也就是说y
的值不是7
,而是在它运行的环境中向前回溯,寻找变量y
的值,所以这样的代码:
(let ((y 5))
(scope-test 3))
在 emacs lisp 的运行结果为(3 5)
,而在采用词法作用域规则的编程语言中,如 common lisp,它会在定义函数的环境中寻找自由变量y
的值,所以这段代码的运行结果为(3 7)
。
另外,还有一个关于闭包和循环的一个经典的坑,当闭包遇到循环的时候,如下代码:
(function(){
var i;
for(i = 1; i <= 10; i++){
setTimeout(function(){console.log(i);}, 500); //本以为会输出数字 1-10,结果输出了 10 次 11
}
})();
在上面代码中,我为了简洁,都使用了匿名函数。之所以会出现这样意想不到的结果,就是因为定义在内层的匿名函数都捕获了外层函数中的变量i
,所以当它们运行的时候,都是输出的这个i
的最终的值,那就是11
。如果要想得到预期的输出 1-10 这样的结果,就应该在定义内层函数的时候让它接受一个参数,然后把i
当做参数传递给它。代码改成这样就行:
(function(){
var i;
for(i = 1; i <= 10; i++){
setTimeout((function(a){console.log(a);})(i), 500);
}
})();
全部写成匿名函数自调用格式简洁是简洁了不少,但是可读性就差了许多。网上的关于这个坑的描述所用的示例代码往往是将内层函数设置为某个按钮的onClick
事件处理程序,而我不想在我的示范中和 BOM、DOM 产生太多的耦合,所以我选择了setTimeout()
。如果不信,可以自己在 Chromium 的 JavaScript 控制台中验证效果。
函数和this
从前面的调试过程中我们可以看出,每一个函数执行的 context 中都有一个特殊的变量this
。对this
大家都不会陌生,很多面向对象的编程语言中都有,但是在 JavaScript 中,this
会稍有不同,它的取值会随着函数的调用方式不同而变化。JavaScript 中函数的调用方式多种多样,总结起来主要有四种:
- 做为构造函数调用,比如前面的
new Person();
、new Object();
; - 做为对象的方法调用,比如前面的
somebody.sayHello();
; - 做为普通函数调用,这是用得最多的,比如前面的
first();
、what();
; - 通过
apply
、call
、bind
方式调用,这种调用方式我后面会举例。
在第一种调用方式中,this
的取值就是该构造函数即将创建的对象。在第二种方式中,this
的取值就是该方法所在的对象。这两种调用方式和经典的面向对象编程语言没有什么不同,非常容易理解。第三种方式,做为普通函数调用,这时,函数中的this
永远都指向全局对象,不管函数的定义嵌套得有多深,切记切记。而第四中调用方法最特别,它可以改变函数中this
的取值,因此,这种方式调用最灵活,妙用最多,这个需要几个例子才能说明。先回顾一下我前面定义的Person()
构造函数以及somebody
对象:
function Person(name, age, gender){
this.name = name;
this.age = age;
this.gender = gender;
}
Person.prototype.sayHello = function(){ console.log("Hello, my name is " + this.name); };
var somebody = new Person("youxia", 30, "male");
如果我们调用:
somebody.sayHello(); //sayHello()中的this指向somebody,所以输出"Hello, my name is youxia"
那么这个sayHello();
方法中的this
指向somebody
对象,所以输出结果很符合预期。但是,如果该函数不是通过对象的方法调用,结果就会大不相同。比如这样:
var sayHi = somebody.sayHello;
sayHi(); //做为普通函数调用,该函数中的this指向全局变量所以输出"Hello, my name is "
在上面的例子中,因为全局变量中没有name
属性,所以输出的结果中就没有名字了。
然后,我为了偷懒,不想定义一个构造函数,只使用对象字面量定义了一个对象worker
,代表一个具有Java
技术的程序员,如下:
var worker = {name:"javaer", speciality:"Java"};
这个对象没有sayHello()
方法,但是我们可以这样借用somebody
的sayHello()
方法:
somebody.sayHello.call(worker); //输出"Hello, my name is javaer"
所有的函数都可以通过.call()
、.apply()
、.bind()
的形式调用,因为这三个方法是定义在Function.prototype
中的,而所有的函数的原型链中都有Function.prototype
。这三个函数都会把调用函数的this
设置为这几个方法的第一个参数。所不同者,.call()
是接受任意多个参数,而.apply()
只接受两个参数,其第二个参数必须是一个数组,而.bind()
返回另外一个函数,这个函数的this
绑定到.bind()
的参数所指定的对象。
可以看到,如果某个对象具有和其它对象相同的属性,比如这里的name
属性,就通过.call()
的方式借用别的对象的方法。由于.apply()
接受的第二个参数是一个数组,所以,如果有某个函数本身只接受不定数量的参数,而要操作的确是一个数组的时候,就可以用.apply()
来在它们之间适配。最常见的例子就是Math.max()
方法,该方法接受的是不定数量的参数,假如我们手头只有一个数组,比如这样:
var numbers = [3, 2, 5, 1, 7, 9, 8, 2];
而我们又要找出数组中的最大值的话,可以这样调用:
Math.max.apply(null, numbers);
把第一个参数设置为null
,则Math.max()
中的this
就会自动指向全局对象。不过在这个例子中,this
的值不重要。这里只是改变了Math.max()
方法接受参数的形式。
在 JavaScript 中经常使用.call()
调用来借用内置对象的方法,最常见的是借用Object.prototype.toString()
方法。虽然我们所有的对象都是从Object
继承,所有的对象都有从Object
继承的toString()
方法,但是,这些方法可以随时被重写。比如在我们前面定义的Person
类中,我们可以重写它的toString()
方法,如下:
Person.prototype.toString = function(){
return \'Person {name: "\' + this.name + \'", age: \' + this.age + \', gender: "\' + this.gender + \'"}\';
}
这时,调用somebody
的toString()
方法,会得到这样的输出:
somebody.toString();
//输出 "Person {name: "youxia", age: 30, gender: "male"}"
但是如果借用Object.prototype.toString()
方法,则会得到另外一种输出:
Object.prototype.toString.call(somebody);
//输出 "[object Object]"
所以这种技术常被各种库用来判断对象的类型。如下:
Object.prototype.toString.call(somebody);
//输出 "[object Object]"
Object.prototype.toString.call(Person);
//输出 "[object Function]"
Object.prototype.toString.call("Hello, World!");
//输出 "[object String]"
Object.prototype.toString.以上是关于深入理解JavaScript,这一篇就够了的主要内容,如果未能解决你的问题,请参考以下文章