goddyzhao

May 07

Charles: Mac下的fiddler -

这款软件是和fiddler最像的代理软件。需要和firefox配合使用(内置了firefox的插件,自动安装)。不过不管怎么说,它满足了我经常要代理文件来debug的需求。btw,它是收费的!

Apr 27

动态修改script标签中的src属性存在的问题

今天某个同事遇到一个诡异的问题,问题描述如下:

需求:通过脚本动态修改script标签的src来载入一段外部脚本并执行
实现方式(#1)

<script type="text/javascript" id="external-script">
</script>
<script type="text/javascript">
    document.getElementById('external-script').src='url2';
</script>

url2的内容如下

alert('I am dynamic');

结果

注意实现方式中,第一段的script标签中间是有内容的(空格、换行符以及回车符)。

如何来解释这个问题呢?要解释这个问题,我们来看两个变种的例子,第一个例子(明确内联内容),如下所示(#2):

<script type="text/javascript" id="external-script">
alert('I am inline');
</script>
<script type="text/javascript">
    document.getElementById('external-script').src='url2';
</script>

结果如下:

再来看看第二个变种的例子(#3):

<script type="text/javascript" id="external-script" src="url1">
alert('I am inline script');
</script>
<script type="text/javascript">
    document.getElementById('external-script').src='url2';
</script>

其中url1的内容如下:

alert('I am url1');

结果如下:

首先这里肯定的是src属性是修改成功的,可以通过看dom的变化看到src已经设置进去了。这个时候我们比对这三个例子,思考几十秒。分析下这三个例子,其实#2和#1是一样的,这里用#2是为了说明#1中的空格、换行符以及回车符会被浏览器认为是内联的脚本。通过比对#2和#3,是不是会让你想到什么?没错,我们第一个会想到的就是:当script标签既有src属性又有内联脚本的时候浏览器该如何处理? , 先来解释这个问题。

一谈到浏览器应该怎样处理,就不得不翻出各种宝典,这次不再是葵花宝典了,而是九阴真经(W3C的HTML4标准),标准中关于script标签的src部分有如下一段话:

If the src attribute is not set, user agents must interpret the contents of the element as the script. If the src has a URI value, user agents must ignore the element’s contents and retrieve the script via the URI

上面这段话的意思就是说:如果src没有设置,那么就执行内联脚本,如果src设置了浏览器就必须忽略内敛脚本而要去请求src指定的url的内容

这解释了为什么#3中标准浏览器(甚至IE6,7,8)都没有执行内联脚本(因为src设置了url1)。

搞清楚了这个基础问题之后,接下来问题就定位到了动态修改script的src属性的时候浏览器如何处理? ,从结果来看,标准的浏览器都没有去请求url2(更改src无效),这回IE6,7,8终于犯傻了。当然了,咱们也不能随随便便说人家犯傻,要拿出证据,这个时候继续拿出九阴真经W3C的HTML5标准,其中有这样一句话:

Changing the src, type, charset, async, and defer attributes dynamically has no direct effect; these attribute are only used at specific times described below.

意思就是说:修改src是没用的,对src的处理只会在特定的时候进行(个人猜测就是第一次看到这个属性的时候浏览器会去做相应处理,之后就无视它了)。

好了,这下真相大白了:这解释了为啥#3和#1中除了IE6,7,8之外其他浏览器都没有去请求url2(IE9请求了,但没执行),而且实验发现IE6,7,8对动态修改src都会做请求执行处理。

最后,这个故事至少告诉我们:写script标签的时候千万别手贱打回车。

参考文档:

Apr 05

如何判断页面的编码?

在写爬虫程序(web页面)的时候,经常需要去解析页面的内容,而在解析前就必须要知道该页面是用何种字符集来编码的,这样才能有效的避免乱码的问题。

那么如何才能得知目的页面的编码呢?让我们来看看来自“W3C”的官方解释:

  1. An HTTP “charset” parameter in a “Content-Type” field
  2. A META declaration with “http-equiv” set to “Content-Type” and a value set for “charset”
  3. The charset attribute set on an element that designates an external resource

上述描述中表现了检测页面编码的优先级,也就是说首先会看http头信息中的“Content-Type”字段、如果没有,就去看Meta信息,还没有的话,对于一些外链(如css、JavaScript)就会看这种元素专门的charset字段。如果检查完上述三种方式之后还是无法确定呢?那就采用默认的ISO-8859-1字符集来解析。

顺便提一句,既然是W3C的标准,那就说明标准浏览器都是这么工作的哦!

Mar 01

通过什么途径能够深入了解JavaScript引擎是如何工作的?

昨天收到一封来自深圳的一位前端童鞋的邮件,邮件内容如下(很抱歉,未经过他的允许,公开邮件内容,不过我相信其他人肯定也有同样的问题,所以,直接把问题原文抛出来):

“读了你的几篇关于JS(变量对象、作用域、上下文、执行代码)的文章,我个人觉得有点抽象,难以深刻理解。我想请教下通过什么途径能够深入点的了解javascript解析引擎在执行代码前后是怎么工作的,ecma英文版实在看不下去呵呵。”

其实这个问题个人觉得太笼统了,直接回答很难回答,所以,我打算先把他的问题拆解成如下几个子问题,并对其表达个人的观点,希望对有同样困惑的童鞋能够有所帮助。

1. 什么是JavaScript解析引擎?

简单地说,JavaScript解析引擎就是能够“读懂”JavaScript代码,并准确地给出代码运行结果的一段程序。比方说,当你写了 var a = 1 + 1; 这样一段代码,JavaScript引擎做的事情就是看懂(解析)你这段代码,并且将a的值变为2。

学过编译原理的人都知道,对于静态语言来说(如Java、C++、C),处理上述这些事情的叫编译器(Compiler),相应地对于JavaScript这样的动态语言则叫解释器(Interpreter)。这两者的区别用一句话来概括就是:编译器是将源代码编译为另外一种代码(比如机器码,或者字节码),而解释器是直接解析并将代码运行结果输出。 比方说,firebug的console就是一个JavaScript的解释器。

但是,现在很难去界定说,JavaScript引擎它到底算是个解释器还是个编译器,因为,比如像V8(Chrome的JS引擎),它其实为了提高JS的运行性能,在运行之前会先将JS编译为本地的机器码(native machine code),然后再去执行机器码(这样速度就快很多),相信大家对JIT(Just In Time Compilation)一定不陌生吧。

我个人认为,不需要过分去强调JavaScript解析引擎到底是什么,了解它究竟做了什么事情我个人认为就可以了。对于编译器或者解释器究竟是如何看懂代码的,翻出大学编译课的教材就可以了。

这里还要强调的就是,JavaScript引擎本身也是程序,代码编写而成。比如V8就是用C/C++写的。

2. JavaScript解析引擎与ECMAScript是什么关系?

JavaScript引擎是一段程序,我们写的JavaScript代码也是程序,如何让程序去读懂程序呢?这就需要定义规则。比如,之前提到的var a = 1 + 1;,它表示:

上述这些就是规则,有了它就等于有了衡量的标准,JavaScript引擎就可以根据这个标准去解析JavaScript代码了。那么这里的ECMAScript就是定义了这些规则。其中ECMAScript 262这份文档,就是对JavaScript这门语言定义了一整套完整的标准。其中包括:

标准的JavaScript引擎就会根据这套文档去实现,注意这里强调了标准,因为也有不按照标准来实现的,比如IE的JS引擎。这也是为什么JavaScript会有兼容性的问题。至于为什么IE的JS引擎不按照标准来实现,就要说到浏览器大战了,这里就不赘述了,自行Google之。

所以,简单的说,ECMAScript定义了语言的标准,JavaScript引擎根据它来实现,这就是两者的关系。

3. JavaScript解析引擎与浏览器又是什么关系?

简单地说,JavaScript引擎是浏览器的组成部分之一。因为浏览器还要做很多别的事情,比如解析页面、渲染页面、Cookie管理、历史记录等等。那么,既然是组成部分,因此一般情况下JavaScript引擎都是浏览器开发商自行开发的。比如:IE9的Chakra、Firefox的TraceMonkey、Chrome的V8等等。

从而也看出,不同浏览器都采用了不同的JavaScript引擎。因此,我们只能说要深入了解哪个JavaScript引擎

4. 深入了解其内部原理的途径有哪些?

搞清楚了前面三个问题,那这个问题就好回答了。个人认为,主要途径有如下几种(依次由浅入深):

5. 以上几种方式中第一种都很难看明白怎么办?

其实第一种方式中的文章,作者已经将文档中内容提炼出来,用通俗易懂的方式阐述出来了。如果,看起来还觉得吃力,那说明还缺少两块的东西:

以上就是个人对这个问题的看法,除此之外,我觉得,学习任何技术都不能操之过急,要把基础打扎实了,这样学什么都会很快。

Dec 09

说说为什么 [] == ![] 为true

此前在微博上无意中看到有人问“为什么alert([] == ![])会是true?”
刚看到这个问题我也说不上来究竟是什么原因,只知道这个肯定又是和==操作相关的类型转换问题。
于是,就翻开了“葵花宝典(ECMA-262-5th)”,你懂的。

在宝典的帮助下,我尝试着来解释下该问题的原因:

宝典中的关于==操作的工作描述如下(11.9.1):

The production EqualityExpression: EqualityExpression == RelationalExpression is evaluated as follows:
1. Let lref be the result of evaluating EqualityExpression
2. Let lval be GetValue(lref)
3. Let rref be the result of evaluating RelationalExpression
4. Let rval be GetValue(rref)
5. Return the result of performing abstract equality comparison rval==lval

  1. 先求GetValue([])
  2. 再求GetValue(![])
  3. 最后求 GetValue([]) == GetValue(![])

先要搞清楚GetValue方法是干嘛的,继续看宝典关于GetValue的描述(8.7.1):

  1. If Type(V) is not Reference, return V
  2. ….

对于解释我们的问题,看到这里就足够了,因为[]和![]都不属于Reference,所以,GetValue([])和GetValue(![])都返回自身。
这里关于什么是Reference不想再赘述了,要详细了解的可以看宝典(8.7)。
那么,上述问题进一步转化成了如下问题:

  1. GetValue([])为 []
  2. GetValue(![])为 false, (这里!会使得[]强制转化为Boolean类型)
  3. 这里就成了求 [] == false的问题

也就是说: [] == ![] 现在转化为了 [] == false

宝典中第五步就提到了根据”abstract equality comparison”来求最后的结果。 现在先来看看[]和false的类型,两者类型显而易见,前者是Object,后者Boolean。
然后,我们进一步来看看这个算法是如何的(11.9.3),以下只列出了和我们这个问题相关的算法步骤,其中有这么一条:

The comparison x == y, where x and y are values, produces true or false. Such a comparison is performed as follows:
7. If Type(y) is Boolean, return the result of the comparison x == ToNumber(y)

这句话很容易理解,就是要把y转化类型为数值,也就是说false变为0。
这样以来,问题有变成了求: [] == 0

继续看宝典中这个算法(11.9.3),其中有这么一条:

The comparison x == y, where x and y are values, produces true or false. Such a comparison is performed as follows:
9. If Type(x) is Object and Type(y) is either String or Number, return the result of the comparison ToPrimitive(x) == y.

于是,问题有变成了求: ToPrimitive([]) == 0

其中对于Object有这种转换描述:

Return a default value for the Object. The default value of an object is retrieved by calling the [[DefaultValue]] internal method of the object,
passing the optional hint PreferredType. The behaviour of the
[[DefaultValue]] internal method is defined by this specification for all native ECMAScript objects in 8.12.8.

继续顺藤摸瓜,看[[DefaultValue]](hint),我们的例子中hint是Number,因为它是和0去做比较。
根据宝典(8.12.8)描述:

When the [[DefaultValue]] internal method of O is called with hint Number, the following steps are taken:
1. Let valueOf be the result of calling the [[Get]] internal method of object O with argument “valueOf”.
2. If IsCallable(valueOf) is true then
a. Let val be the result of calling the [[Call]] internal method of valueOf, with O as the this value and an empty argument list.
b. If val is a primitive value, return val.
3. Let toString be the result of calling the [[Get]] internal method of object O with argument “toString”.
a. Let str be the result of calling the [[Call]] internal method of toString, with O as the this value and an empty argument list.
b. If str is a primitive value, return str.

这里不对valueOf再去做赘述了,MDN上面有很简短的说明,大致意思如下:

默认,每个对象都有从Object继承下来的valueOf方法。其中每个内置的核心对象都会重载该方法来返回正确的值,对于没有基础类型值的对象,则返回对象自身。

那么,对于我们的情况来说,进入了算法中的2,但是,因为val是对象不是基础类型,所以继续进入第3步,这个时候关键来了:
开始调用[]的toString方法,这个时候会返回"",一个空的字符串,因此ToPrimitive([])为""
因此,问题又转化成了:
"" == 0

现在答案就很明显了,根据宝典的==工作原理如下描述(11.9.3):

The comparison x == y, where x and y are values, produces true or false. Such a comparison is performed as follows:
5. If Type(x) is String and Type(y) is Number, return the result of the comparison ToNumber(x) == y.

好了,根据算法,会将""转化为数值类型,那么自然就变成了0,于是 0 == 0 是很自然而然的。

总结:
最终问题就从: [] == ![] 变成了 0 == 0。答案自然是true了。
其实遇到这种语言层面的问题,直接看宝典即可。

说明:
以上诸如 11.9.3 这样的数字均表示葵花宝典中的章节。

参考资料


Nov 10

Rise of Node - Part I -

Node入门介绍,内容涵盖Node的家族史,以及核心的单线程非阻塞概念,最后有大量的学习资源给初学Node的童鞋

Oct 16

使用node-inspector来调试node

大部分程序员(比如我),开发的过程中,其实只有20%的时间在写代码,另外80%的时间都在调试代码。完全符合著名的80/20法则
好吧,我承认我在滥用伟大的法则,废话很多。其实,我就想说明我们大部分时间都在调试(Debug)而不是在写代码

所以,要想尽一切办法提高调试的效率,这样有助于提高开发效率。 对于node的调试,官方wiki中有在eclipse中调试node的文章。

但是,不知为何,我在eclipse中总是不成功(eclipse 3.7 + ubuntu 11.04),况且,要是不用eclipse那咋办呢?
于是,我就另谋出路,在github上一番寻觅之后,发现了:node-inspector
用了一把之后,感觉神清气爽,遂推荐给大家。

那么如何使用呢?其实官方有很详细的教程,不过这里我还是要赘述下,并且以joyent官方的例子来作为调试代码:

// dbgtest.js

var sys=require('sys');
var count = 0;

sys.debug("Starting ...");

function timer_tick() {
  count = count+1;
  sys.debug("Tick count: " + count);
  if (count === 10) {
    count += 1000;
    sys.debug("Set break here");
 }
 setTimeout(timer_tick, 1000);
}

timer_tick();

具体调试步骤如下:

debugger listening on port 5858

说明已经开始监听了。

 info  - socket.io started
visit http://0.0.0.0:8080/debug?port=5858 to start debugging

通过看log也大致能够猜到了,node-inspector就是利用socket.io来监听5858端口实现的。

这里很明显看到,直接代码就停在第一行,那么,接下来如何断点来debug相信身为前端的童鞋就不用我再赘述了吧。

Oct 15

JavaScript内部原理实践——真的懂JavaScript吗?

通过翻译了Dmitry A.Soshnikov的关于ECMAScript-262-3 JavaScript内部原理的文章, 从理论角度对JavaScript中部分特性的内部工作机制有了一定的了解。
但是,邓爷爷说过:“实践才是检验真理的唯一标准”
所以,我打算通过从内部原理来解释一些经常在笔试或者面试中遇到的关于JavaScript语言层面的题目来进一步学习和掌握JavaScript内部工作原理。

那么,首先就是要去找那些题目,google了一圈终于找到了来自Dmitry Baranovskiy的非常著名的5个问题, 这5个问题,NCZ给出了非常清楚的解释。 不过,我还是想尝试下从low-level——JavaScript内部工作机制的角度去解释下这些问题。

好吧,我承认我废话很多,那就开始吧。

问题 #1


if (!("a" in window)) {
    var a = 1;
}
alert(a);

解释:
这个问题,初一看感觉答案很自然是1。因为从上到下执行下去,if语句中的条件应该为 true,因为”a”的确是没有定义啊。 随后,顺理成章地进入 var a = 1;,最后,alert出来就应该是1。

而事实上,从JavaScript内部工作原理去看,在变量对象中讲过, JavaScript处理上下文分为两个阶段:

可以理解为,第一个阶段是静态处理阶段,第二个阶段为动态处理阶段。

而在静态处理阶段,就会创建 变量对象(variable object),并且将变量申明作为属性进行填充。
到了执行阶段,才会根据执行情况,来对变量对象中属性(就是申明的变量)的值进行更新。

针对这个问题,其实际过程如下:

VO(global) = {
    a: undefined
}

所以,这个时候,a其实已经存在了。

问题 #2


var a = 1,
    b = function a(x) {
        x && a(--x);
    };
alert(a);

解释:
这个问题,第一反应可能会是将 function a打印出来。因为明明就看到了function a了。看似,也顺其自然。

但是,事实并非如此。还是和此前一个问题一样。从两个阶段来分析:

VO(global) = {
    a: undefined,
    b: undefined
}
VO(global) = {
    x: undefined,
    a: 1
}

所以,最后alert(a)的结果是1。

问题 #3


function a(x) {
    return x * 2;
}
var a;
alert(a);

解释:
这个问题,很多人可能会以为是: undefined。理由可能是,明明看到了 var a定义在了function a的后面,感觉应该会覆盖之前a的申明。

事实又是怎样的呢? 老套路,从两个阶段来分析:

VO(global) = {
    a: 引用了函数申明“x”
}

所以,最终的结果是:函数a。

问题 #4


function b(x, y, a) {
    arguments[2] = 10;
    alert(a);
}
b(1, 2, 3);

解释:
个人感觉这个问题其实不是很复杂。这里也不需要从两个阶段去分析了。根据 变量对象中介绍的,arguments对象的properties-indexes和实际传递的参数是共享的 也就是说,通过arguments[2]修改的参数,也会影响到a,所以,这里的值是10。但是,要注意的是和实际传递的值,所以,如果把上述问题改成如下形式:

function b(x, y, a) {
    arguments[2] = 10;
    alert(a);
}
b(1, 2);

结果就会是: undefined。因为,并没有传递a的值。

问题 #5


function a() {
    alert(this);
}
a.call(null);

解释:
这个问题,可能会比较困惑。因为懂call的童鞋都会觉得,call的时候把null传递为了当前的上下文了。里面的this应该是null才对啊。

事实却是: 前面都没错,this会是null。但是,this中介绍过,null是没有任何意义的,因此,最终会变成全局对象。 所以,这里结果就变成了全局对象。 这里还有ECMAScript-262-3标准文档中的一句话作为证据:
“If thisArg is null or undefined, the called function is passed the global object as the this value. Otherwise, the called function is passed ToObject(thisArg) as the this value.”

总结


上面这5个问题其实也只是牵涉到了JavaScript内部原理中的部分知识点,要想了解更多,还是建议读完JavaScript内部原理系列 以及去看Dmitry A.Soshnikov的文章。

Oct 11

闭包(Closures)

说明


此文译自Dmitry A.Soshnikov 的文章closures
另,此文还有另外一位同事(彭森材)共同参译

概要


本文将介绍一个在JavaScript经常会拿来讨论的话题 —— 闭包(closure)。闭包其实已经是个老生常谈的话题了; 有大量文章都介绍过闭包的内容(其中不失一些很好的文章,比如,扩展阅读中Richard Cornford的文章就非常好), 尽管如此,这里还是要试着从理论角度来讨论下闭包,看看ECMAScript中的闭包内部究竟是如何工作的。

正如在此前文章中提到的,这些文章都是系列文章,相互之间都是有关联的。因此,为了更好的理解本文要介绍的内容, 建议先去阅读下第四章 - 作用域链第二章 - 变量对象

概论


在讨论ECMAScript闭包之前,先来介绍下函数式编程(与ECMA-262-3 标准无关)中一些基本定义。 然而,为了更好的解释这些定义,这里还是拿ECMAScript来举例。

众所周知,在函数式语言中(ECMAScript也支持这种风格),函数即是数据。就比方说,函数可以保存在变量中,可以当参数传递给其他函数,还可以当返回值返回等等。 这类函数有特殊的名字和结构。

定义


函数式参数(“Funarg”) —— 是指值为函数的参数。

如下例子:

function exampleFunc(funArg) {
  funArg();
}
 
exampleFunc(function () {
  alert('funArg');
});

上述例子中funarg的实参是一个传递给exampleFunc的匿名函数。

反过来,接受函数式参数的函数称为 高阶函数(high-order function 简称:HOF)。还可以称作:函数式函数 或者 偏数理的叫法:操作符函数。 上述例子中,exampleFunc 就是这样的函数。

此前提到的,函数不仅可以作为参数,还可以作为返回值。这类以函数为返回值的函数称为 _带函数值的函数(functions with functional value or function valued functions)。

(function functionValued() {
  return function () {
    alert('returned function is called');
  };
})()();

可以以正常数据形式存在的函数(比方说:当参数传递,接受函数式参数或者以函数值返回)都称作 第一类函数(一般说第一类对象)。 在ECMAScript中,所有的函数都是第一类对象。

接受自己作为参数的函数,称为 自应用函数(auto-applicative function 或者 self-applicative function)

(function selfApplicative(funArg) {
 
  if (funArg && funArg === selfApplicative) {
    alert('self-applicative');
    return;
  }
 
  selfApplicative(selfApplicative);
 
})();

以自己为返回值的函数称为 自复制函数(auto-replicative function 或者 self-replicative function)。 通常,“自复制”这个词用在文学作品中:

(function selfReplicative() {
  return selfReplicative;
})();

在函数式参数中定义的变量,在“funarg”激活时就能够访问了(因为存储上下文数据的变量对象每次在进入上下文的时候就创建出来了):

function testFn(funArg) {
 
  // 激活funarg, 本地变量localVar可访问
  funArg(10); // 20
  funArg(20); // 30
 
}
 
testFn(function (arg) {
 
  var localVar = 10;
  alert(arg + localVar);
 
});

然而,我们知道(特别在第四章中提到的),在ECMAScript中,函数是可以封装在父函数中的,并可以使用父函数上下文的变量。 这个特性会引发 funarg问题

Funarg问题


面向堆栈的编程语言中,函数的本地变量都是保存在 堆栈上的, 每当函数激活的时候,这些变量和函数参数都会压栈到该堆栈上。

当函数返回的时候,这些参数又会从堆栈中移除。这种模型对将函数作为函数式值使用的时候有很大的限制(比方说,作为返回值从父函数中返回)。 绝大部分情况下,问题会出现在当函数有 自由变量的时候。

自由变量是指在函数中使用的,但既不是函数参数也不是函数的局部变量的变量

如下所示:

function testFn() {
 
  var localVar = 10;
 
  function innerFn(innerParam) {
    alert(innerParam + localVar);
  }
 
  return innerFn;
}
 
var someFn = testFn();
someFn(20); // 30

上述例子中,对于innerFn函数来说,localVar就属于自由变量。

对于采用 面向堆栈模型来存储局部变量的系统而言,就意味着当testFn函数调用结束后,其局部变量都会从堆栈中移除。 这样一来,当从外部对innerFn进行函数调用的时候,就会发生错误(因为localVar变量已经不存在了)。

而且,上述例子在 面向堆栈实现模型中,要想将innerFn以返回值返回根本是不可能的。 因为它也是testFn函数的局部变量,也会随着testFn的返回而移除。

还有一个函数对象问题和当系统采用动态作用域,函数作为函数参数使用的时候有关。

看如下例子(伪代码):

var z = 10;
 
function foo() {
  alert(z);
}
 
foo(); // 10 – 静态作用域和动态作用域情况下都是
 
(function () {
 
  var z = 20;
  foo(); // 10 – 静态作用域情况下, 20 – 动态作用域情况下
 
})();
 
// 将foo函数以参数传递情况也是一样的
 
(function (funArg) {
 
  var z = 30;
  funArg(); // 10 – 静态作用域情况下, 30 – 动态作用域情况下
 
})(foo);

我们看到,采用动态作用域,变量(标识符)处理是通过动态堆栈来管理的。 因此,自由变量是在当前活跃的动态链中查询的,而不是在函数创建的时候保存起来的静态作用域链中查询的。

这样就会产生冲突。比方说,即使Z仍然存在(与之前从堆栈中移除变量的例子相反),还是会有这样一个问题: 在不同的函数调用中,Z的值到底取哪个呢(从哪个上下文,哪个作用域中查询)?

上述描述的就是两类 funarg问题 —— 取决于是否将函数以返回值返回(第一类问题)以及是否将函数当函数参数使用(第二类问题)。

为了解决上述问题,就引入了 闭包的概念。

闭包


闭包是代码块和创建该代码块的上下文中数据的结合。

让我们来看下面这个例子(伪代码):

var x = 20;
 
function foo() {
  alert(x); // 自由变量 "x" == 20
}
 
// foo的闭包
fooClosure = {
  call: foo // 对函数的引用
  lexicalEnvironment: {x: 20} // 查询自由变量的上下文
};

上述例子中,“fooClosure”部分是伪代码。对应的,在ECMAScript中,“foo”函数已经有了一个内部属性——创建该函数上下文的作用域链。

这里“lexical”是不言而喻的,通常是省略的。上述例子中是为了强调在闭包创建的同时,上下文的数据就会保存起来。 当下次调用该函数的时候,自由变量就可以在保存的(闭包)上下文中找到了,正如上述代码所示,变量“z”的值总是10。

定义中我们使用的比较广义的词 —— “代码块”,然而,通常(在ECMAScript中)会使用我们经常用到的函数。 当然了,并不是所有对闭包的实现都会将闭包和函数绑在一起,比方说,在Ruby语言中,闭包就有可能是: 一个程序对象(procedure object), 一个lambda表达式或者是代码块。

对于要实现将局部变量在上下文销毁后仍然保存下来,基于堆栈的实现显然是不适用的(因为与基于堆栈的结构相矛盾)。 因此在这种情况下,上层作用域的闭包数据是通过 动态分配内存的方式来实现的(基于“堆”的实现),配合使用垃圾回收器(garbage collector简称GC)和 引用计数(reference counting)。 这种实现方式比基于堆栈的实现性能要低,然而,任何一种实现总是可以优化的: 可以分析函数是否使用了自由变量,函数式参数或者函数式值,然后根据情况来决定 —— 是将数据存放在堆栈中还是堆中。

ECMAScript闭包的实现


讨论完理论部分,接下来让我们来介绍下ECMAScript中闭包究竟是如何实现的。 这里还是有必要再次强调下:ECMAScript只使用静态(词法)作用域(而诸如Perl这样的语言,既可以使用静态作用域也可以使用动态作用域进行变量声明)。

var x = 10;
 
function foo() {
  alert(x);
}
 
(function (funArg) {
 
  var x = 20;
 
  // funArg的变量 "x" 是静态保存的,在该函数创建的时候就保存了
 
  funArg(); // 10, 而不是 20
 
})(foo);

从技术角度来说,创建该函数的上层上下文的数据是保存在函数的内部属性 [[Scope]]中的。 如果你还不了解什么是[[Scope]],建议你先阅读第四章, 该章节对[[Scope]]作了非常详细的介绍。如果你对[[Scope]]和作用域链的知识完全理解了的话,那对闭包也就完全理解了。

根据函数创建的算法,我们看到 在ECMAScript中,所有的函数都是闭包,因为它们都是在创建的时候就保存了上层上下文的作用域链(除开异常的情况) (不管这个函数后续是否会激活 —— [[Scope]]在函数创建的时候就有了):

var x = 10;
 
function foo() {
  alert(x);
}
 
// foo is a closure
foo: <FunctionObject> = {
  [[Call]]: <code block of foo>,
  [[Scope]]: [
    global: {
      x: 10
    }
  ],
  ... // other properties
};

正如此前提到过的,出于优化的目的,当函数不使用自由变量的时候,实现层可能就不会保存上层作用域链。 然而,ECMAScript-262-3标准中并未对此作任何说明;因此,严格来说 —— 所有函数都会在创建的时候将上层作用域链保存在[[Scope]]中。

有些实现中,允许对闭包作用域直接进行访问。比如Rhino,针对函数的[[Scope]]属性,对应有一个非标准的 __parent__属性,在第二章中作过介绍:

var global = this;
var x = 10;
 
var foo = (function () {
 
  var y = 20;
 
  return function () {
    alert(y);
  };
 
})();
 
foo(); // 20
alert(foo.__parent__.y); // 20
 
foo.__parent__.y = 30;
foo(); // 30
 
// 还可以操作作用域链
alert(foo.__parent__.__parent__ === global); // true
alert(foo.__parent__.__parent__.x); // 10

“万能”的[[Scope]]


这里还要注意的是:在ECMAScript中,同一个上下文中创建的闭包是共用一个[[Scope]]属性的。 也就是说,某个闭包对其中的变量做修改会影响到其他闭包对其变量的读取:

var firstClosure;
var secondClosure;
 
function foo() {
 
  var x = 1;
 
  firstClosure = function () { return ++x; };
  secondClosure = function () { return --x; };
 
  x = 2; // 对AO["x"]产生了影响, 其值在两个闭包的[[Scope]]中
 
  alert(firstClosure()); // 3, 通过 firstClosure.[[Scope]]
}
 
foo();
 
alert(firstClosure()); // 4
alert(secondClosure()); // 3

正因为这个特性,很多人都会犯一个非常常见的错误: 当在循环中创建了函数,然后将循环的索引值和每个函数绑定的时候,通常得到的结果不是预期的(预期是希望每个函数都能够获取各自对应的索引值)。

var data = [];
 
for (var k = 0; k < 3; k++) {
  data[k] = function () {
    alert(k);
  };
}
 
data[0](); // 3, 而不是 0
data[1](); // 3, 而不是 1
data[2](); // 3, 而不是 2

上述例子就证明了 —— 同一个上下文中创建的闭包是共用一个[[Scope]]属性的。因此上层上下文中的变量“k”是可以很容易就被改变的。

如下所示:

activeContext.Scope = [
  ... // higher variable objects
  {data: [...], k: 3} // activation object
];
 
data[0].[[Scope]] === Scope;
data[1].[[Scope]] === Scope;
data[2].[[Scope]] === Scope;

这样一来,在函数激活的时候,最终使用到的k就已经变成了3了。

如下所示,创建一个额外的闭包就可以解决这个问题了:

var data = [];
 
for (var k = 0; k < 3; k++) {
  data[k] = (function _helper(x) {
    return function () {
      alert(x);
    };
  })(k); // 将 "k" 值传递进去
}
 
// 现在就对了
data[0](); // 0
data[1](); // 1
data[2](); // 2

上述例子中,函数“_helper”创建出来之后,通过参数“k”激活。其返回值也是个函数,该函数保存在对应的数组元素中。 这种技术产生了如下效果: 在函数激活时,每次“_helper”都会创建一个新的变量对象,其中含有参数“x”,“x”的值就是传递进来的“k”的值。 这样一来,返回的函数的[[Scope]]就成了如下所示:

data[0].[[Scope]] === [
  ... // 更上层的变量对象
  上层上下文的AO: {data: [...], k: 3},
  _helper上下文的AO: {x: 0}
];
 
data[1].[[Scope]] === [
  ... // 更上层的变量对象
  上层上下文的AO: {data: [...], k: 3},
  _helper上下文的AO: {x: 1}
];
 
data[2].[[Scope]] === [
  ... // 更上层的变量对象
  上层上下文的AO: {data: [...], k: 3},
  _helper上下文的AO: {x: 2}
];

我们看到,这个时候函数的[[Scope]]属性就有了真正想要的值了,为了达到这样的目的,我们不得不在[[Scope]]中创建额外的变量对象。 要注意的是,在返回的函数中,如果要获取“k”的值,那么该值还是会是3。

顺便提下,大量介绍JavaScript的文章都认为只有额外创建的函数才是闭包,这种说法是错误的。 实践得出,这种方式是最有效的,然而,从理论角度来说,在ECMAScript中所有的函数都是闭包。

然而,上述提到的方法并不是唯一的方法。通过其他方式也可以获得正确的“k”的值,如下所示:

var data = [];
 
for (var k = 0; k < 3; k++) {
  (data[k] = function () {
    alert(arguments.callee.x);
  }).x = k; // 将“k”存储为函数的一个属性
}
 
// 同样也是可行的
data[0](); // 0
data[1](); // 1
data[2](); // 2

Funarg和return


另外一个特性是从闭包中返回。在ECMAScript中,闭包中的返回语句会将控制流返回给调用上下文(调用者)。 而在其他语言中,比如,Ruby,有很多中形式的闭包,相应的处理闭包返回也都不同,下面几种方式都是可能的:可能直接返回给调用者,或者在某些情况下——直接从上下文退出。

ECMAScript标准的退出行为如下:

function getElement() {
 
  [1, 2, 3].forEach(function (element) {
 
    if (element % 2 == 0) {
      // 返回给函数"forEach",
      // 而不会从getElement函数返回
      alert('found: ' + element); // found: 2
      return element;
    }
 
  });
 
  return null;
}
 
alert(getElement()); // null, 而不是 2

然而,在ECMAScript中通过try catch可以实现如下效果:

var $break = {};
 
function getElement() {
 
  try {
 
    [1, 2, 3].forEach(function (element) {
 
      if (element % 2 == 0) {
        // 直接从getElement"返回"
        alert('found: ' + element); // found: 2
        $break.data = element;
        throw $break;
      }
 
    });
 
  } catch (e) {
    if (e == $break) {
      return $break.data;
    }
  }
 
  return null;
}
 
alert(getElement()); // 2

理论版本


通常,程序员会错误的认为,只有匿名函数才是闭包。其实并非如此,正如我们所看到的 —— 正是因为作用域链,使得所有的函数都是闭包(与函数类型无关: 匿名函数,FE,NFE,FD都是闭包), 这里只有一类函数除外,那就是通过Function构造器创建的函数,因为其[[Scope]]只包含全局对象。 为了更好的澄清该问题,我们对ECMAScript中的闭包作两个定义(即两种闭包):

ECMAScript中,闭包指的是:

闭包实践


实际使用的时候,闭包可以创建出非常优雅的设计,允许对funarg上定义的多种计算方式进行定制。 如下就是数组排序的例子,它接受一个排序条件函数作为参数:

[1, 2, 3].sort(function (a, b) {
  ... // 排序条件
});

同样的例子还有,数组的map方法(并非所有的实现都支持数组map方法,SpiderMonkey从1.6版本开始有支持),该方法根据函数中定义的条件将原数组映射到一个新的数组中:

[1, 2, 3].map(function (element) {
  return element * 2;
}); // [2, 4, 6]

使用函数式参数,可以很方便的实现一个搜索方法,并且可以支持无穷多的搜索条件:

someCollection.find(function (element) {
  return element.someProperty == 'searchCondition';
});

还有应用函数,比如常见的forEach方法,将funarg应用到每个数组元素:

[1, 2, 3].forEach(function (element) {
  if (element % 2 != 0) {
    alert(element);
  }
}); // 1, 3

顺便提下,函数对象的 applycall方法,在函数式编程中也可以用作应用函数。 apply和call已经在讨论“this”的时候介绍过了;这里,我们将它们看作是应用函数 —— 应用到参数中的函数(在apply中是参数列表,在call中是独立的参数):

(function () {
  alert([].join.call(arguments, ';')); // 1;2;3
}).apply(this, [1, 2, 3]);

闭包还有另外一个非常重要的应用 —— 延迟调用:

var a = 10;
setTimeout(function () {
  alert(a); // 10, 一秒钟后
}, 1000);

也可以用于回调函数:

...
var x = 10;
// only for example
xmlHttpRequestObject.onreadystatechange = function () {
  // 当数据就绪的时候,才会调用;
  // 这里,不论是在哪个上下文中创建,变量“x”的值已经存在了
  alert(x); // 10
};
..

还可以用于封装作用域来隐藏辅助对象:

var foo = {};
 
// initialization
(function (object) {
 
  var x = 10;
 
  object.getX = function _getX() {
    return x;
  };
 
})(foo);
 
alert(foo.getX()); // get closured "x" – 10

总结


本文介绍了更多关于ECMAScript-262-3的理论知识,而我认为,这些基础的理论有助于理解ECMAScript中闭包的概念。

扩展阅读


Oct 10

函数(Functions)

说明


此文译自Dmitry A.Soshnikov 的文章Functions

概要


本文将给大家介绍ECMAScript中的一般对象之一——函数。我们将着重介绍不同类型的函数以及不同类型的函数是如何影响上下文的变量对象以及函数的作用域链的。 我们还会解释经常会问到的问题,诸如:“不同方式创建出来的函数会不一样吗?(如果会,那么到底有什么不一样呢?)”:

var foo = function () {
  ...
};

上述方式创建的函数和如下方式创建的有什么不同?

function foo() {
  ...
}

如下代码中,为啥一个函数要用括号包起来呢?

(function () {
  ...
})();

由于本文和此前几篇文章都是有关联的,因此,要想完全搞懂这部分内容,建议先去阅读第二章-变量对象 以及第四章-作用域链

下面,来我们先来介绍下函数类型。

函数类型


ECMAScript中包含三类函数,每一类都有各自的特性。

函数声明(Function Declaration)


函数声明(简称FD)是指这样的函数

* 有函数名
* 代码位置在:要么在程序级别或者直接在另外一个函数的函数体(FunctionBody)中
* 在进入上下文时创建出来的
* 会影响变量对象
* 是以如下形式声明的

function exampleFunc() {
  ...
}

这类函数的主要特性是:只有它们可以影响变量对象(存储在上下文的VO中)。此特性同时也引出了非常重要的一点(变量对象的天生特性导致的) —— 它们在执行代码阶段就已经存在了(因为FD在进入上下文阶段就收集到了VO中)。

下面是例子(从代码位置上来看,函数调用在声明之前):

foo();
 
function foo() {
  alert('foo');
}

从定义中还提到了非常重要的一点 —— 函数声明在代码中的位置:

// 函数声明可以直接在程序级别的全局上下文中
function globalFD() {
  // 或者直接在另外一个函数的函数体中
  function innerFD() {}
}

除了上述提到了两个位置,其他位置均不能出现函数声明 —— 比方说,在表达式的位置或者是代码块中进行函数声明都是不可以的。

介绍完了函数声明,接下来介绍函数表达式function expression)。

函数表达式


函数表达式(简称:FE)是指这样的函数:
* 代码位置必须要在表达式的位置
* 名字可有可无
* 不会影响变量对象
* 在执行代码阶段创建出来

这类函数的主要特性是:它们的代码总是在表达式的位置。最简单的表达式的例子就是赋值表达式:

var foo = function () {
  ...
};

上述例子中将一个匿名FE赋值给了变量“foo”,之后该函数就可以通过“foo”来访问了—— foo()。

正如定义中提到的,FE也可以有名字:

var foo = function _foo() {
  ...
};

这里要注意的是,在FE的外部可以通过变量“foo”——foo()来访问,而在函数内部(比如递归调用),还可以用“_foo”(译者注:但在外部是无法使用“_foo”的)。

当FE有名字的时候,它很难和FD作区分。不过,如果仔细看这两者的定义的话,要区分它们还是很容易的: FE总是在表达式的位置。 如下例子展示的各类ECMAScript表达式都属于FE:

// 在括号中(grouping operator)只可能是表达式
(function foo() {});
 
// 在数组初始化中 —— 同样也只能是表达式
[function bar() {}];
 
// 逗号操作符也只能跟表达式
1, function baz() {};

定义中还提到FE是在执行代码阶段创建的,并且不是存储在变量对象上的。如下所示:

// 不论是在定义前还是定义后,FE都是无法访问的
// (因为它是在代码执行阶段创建出来的),
 
alert(foo); // "foo" is not defined
 
(function foo() {});
 
// 后面也没用,因为它根本就不在VO中
 
alert(foo);  // "foo" is not defined

问题来了,FE要来干嘛?其实答案是很明显的 —— 在表达式中使用,从而避免对变量对象造成“污染”。最简单的例子就是将函数作为参数传递给另外一个函数:

function foo(callback) {
  callback();
}
 
foo(function bar() {
  alert('foo.bar');
});
 
foo(function baz() {
  alert('foo.baz');
});

上述例子中,部分变量存储了对FE的引用,这样函数就会保留在内存中并在之后,可以通过变量来访问(因为变量是可以影响VO的):

var foo = function () {
  alert('foo');
};
 
foo();

如下例子是通过创建一个封装的作用域来对外部上下文隐藏辅助数据(例子中我们使用FE使得函数创建后就立马执行):

var foo = {};
 
(function initialize() {
 
  var x = 10;
 
  foo.bar = function () {
    alert(x);
  };
 
})();
 
foo.bar(); // 10;
 
alert(x); // "x" is not defined

我们看到函数“foo.bar”(通过其[[Scope]]属性)获得了对函数“initialize”内部变量“x”的访问。 而同样的“x”在外部就无法访问到。很多库都使用这种策略来创建“私有”数据以及隐藏辅助数据。通常,这样的情况下FE的名字都会省略掉:

(function () {
 
  // 初始化作用域
 
})();

还有一个FE的例子是:在执行代码阶段在条件语句中创建FE,这种方式也不会影响VO:

var foo = 10;
 
var bar = (foo % 2 == 0
  ? function () { alert(0); }
  : function () { alert(1); }
);
 
bar(); // 0

“有关括号”的问题


让我们回到本文之初,来回答下此前提到的问题 —— “为什么在函数创建之后立即进行函数调用时,需要用括号将其包起来?”。 要回答此问题,需要先介绍下关于表达式语句的限制。

标准中提到,表达式语句(ExpressionStatement)不能以左大括号{开始 —— 因为这样一来就和代码块冲突了, 也不能以function关键字开始,因为这样一来又和函数声明冲突了。比方说,以如下所示的方式来定义一个立马要执行的函数:

function () {
  ...
}();
 
// or with a name
 
function foo() {
  ...
}();

对于这两种情况,解释器都会抛出错误,只是原因不同。

如果我们是在全局代码(程序级别)中这样定义函数,解释器会以函数声明来处理,因为它看到了是以function开始的。 在第一个例子中,会抛出语法错误,原因是既然是个函数声明,则缺少函数名了(一个函数声明其名字是必须的)。

而在第二个例子中,看上去已经有了名字了(foo),应该会正确执行。然而,这里还是会抛出语法错误 —— 组操作符内部缺少表达式。 这里要注意的是,这个例子中,函数声明后面的()会被当组操作符来处理,而非函数调用的()。因此,如果我们有如下代码:

// "foo" 是函数声明
// 并且是在进入上下文的时候创建的
 
alert(foo); // function
 
function foo(x) {
  alert(x);
}(1); // 这里只是组操作符,并非调用!
 
foo(10); // 这里就是调用了, 10

上述代码其实就是如下代码:

// function declaration
function foo(x) {
  alert(x);
}
 
// 含表达式的组操作符
(1);
 
// 另外一个组操作符
// 包含一个函数表达式
(function () {});
 
// 这里面也是表达式
("foo");
 
// etc

当这样的定义出现在语句位置时,也会发生冲突并产生语法错误:

if (true) function foo() {alert(1)}

上述结构根据标准规定是不合法的。(表达式是不能以function关键字开始的),然而,正如我们在后面要看到的,没有一种实现对其抛出错误, 它们各自按照自己的方式在处理。

讲了这么多,那究竟要怎么写才能达到创建一个函数后立马就进行调用的目的呢? 答案是很明显的。它必须要是个函数表达式,而不能是函数声明。而创建表达式最简单的方式就是使用上述提到的组操作符。因为在组操作符中只可能是表达式。 这样一来解释器也不会纠结了,会果断将其以FE的方式来处理。这样的函数将在执行阶段创建出来,然后立马执行,随后被移除(如果有没有对其的引用的话):

(function foo(x) {
  alert(x);
})(1); // 好了,这样就是函数调用了,而不再是组操作符了,1

要注意的是,在下面的例子中,函数调用,其括号就不再是必须的了,因为函数本来就在表达式的位置了,解释器自然会以FE来处理,并且会在执行代码阶段创建该函数:

var foo = {
 
  bar: function (x) {
    return x % 2 != 0 ? 'yes' : 'no';
  }(1)
 
};
 
alert(foo.bar); // 'yes'

因此,对“括号有关”问题的完整的回答则如下所示:

如果要在函数创建后立马进行函数调用,并且函数不在表达式的位置时,括号就是必须的 —— 这样情况下,其实是手动的将其转换成了FE。 而当解释器直接将其以FE的方式处理的时候,说明FE本身就在函数表达式的位置 —— 这个时候括号就不是必须的了。

另外,除了使用括号的方式将函数转换成为FE之外,还有其他的方式,如下所示:

1, function () {
  alert('anonymous function is called');
}();
 
// 或者这样
!function () {
  alert('ECMAScript');
}();
 
// 当然,还有其他很多方式
 
...

不过,括号是最通用也是最优雅的方式。

顺便提下,组操作符既可以包含没有调用括号的函数,又可以包含有调用括号的函数,这两者都是合法的FE:

(function () {})();
(function () {}());

实现扩展: 函数语句


看如下代码,符合标准的解释器都无法解释这样的代码:

if (true) {
 
  function foo() {
    alert(0);
  }
 
} else {
 
  function foo() {
    alert(1);
  }
 
}
 
foo(); // 1 还是 0 ? 在不同引擎中测试

这里有必要提下:根据标准,上述代码结构是不合法的,因为,此前我们就介绍过,函数声明是不能出现在代码块中的(这里if和else就包含代码块)。 此前提到的,函数声明只能出现在两个位置: 程序级别或者另外一个函数的函数体中。

为什么这种结构是错误的呢?因为在代码块中只允许语句。函数要想在这个位置出现的唯一可能就是要成为表达式语句。 但是,根据定义表达式语句又不能以左大括号开始(这样会与代码块冲突)也不能以function关键字开始(这样又会和FD冲突)。

然而,在错误处理部分,标准允许实现对程序语法进行扩展。而上述例子就是其中一种扩展。目前,所有的实现中都不会对上述情况抛出错误,都会以各自的方式进行处理。

因此根据标准,上述if-else中应当需要FE。然而,绝大多数实现中都在进入上下文的时候在这里简单地创建了FD,并且使用了最后一次的声明。 最后“foo”函数显示了1,尽管理论上else中的代码根本不会被执行到。

而SpiderMonkey(TraceMonkey也是)实现中,会将上述情况以两种方式来处理: 一方面它不会将这样的函数以函数声明来处理(也就意味着函数会在执行代码阶段才会创建出来), 然而,另外一方面,它们又不属于真正的函数表达式,因为在没有括号的情况是不能作函数调用的(同样会有解析错误——和FD冲突),它们还是存储在变量对象中。

我认为SpiderMonkey单独引入了自己的中间函数类型——(FE+FD),这样的做法是正确的。这样的函数会根据时间和对应的条件正确创建出来,不像FE。 和FD有点类似,可以在外部对其进行访问。SpiderMonkey将这种语法扩展命名为函数语句(Function Statement)(简称FS);这部分理论在MDC中有具体的介绍。 JavaScript的发明者 Brendan Eich也提到过这类函数类型。

有名字的函数表达式的特性(NFE)


当FE有名字之后(named function expression,简称:NFE),就产生了一个重要的特性。 正如在定义中提到的,函数表达式是不会影响上下文的变量对象的(这就意味着不论是在定义前还是在定义后,都是不可能通过名字来进行调用的)。 然而,FE可以通过自己的名字进行递归调用:

(function foo(bar) {
 
  if (bar) {
    return;
  }
 
  foo(true); // "foo" name is available
 
})();
 
// but from the outside, correctly, is not
 
foo(); // "foo" is not defined

这里“foo”这个名字究竟保存在哪里呢?在foo的活跃对象中吗?非也,因为在foo函数中根本就没有定义任何“foo”。 那么是在上层上下文的变量对象中吗?也不是,因为根据定义——FE是不会影响VO的——正如我们在外层对其调用的结果所看到的那样。 那么,它究竟保存在哪里了呢?

不卖关子了,马上来揭晓。当解释器在执行代码阶段看到了有名字的FE之后,它会在创建FE之前,创建一个辅助型的特殊对象,并把它添加到当前的作用域链中。 然后,再创建FE,在这个时候(根据第四章-作用域链描述的),函数拥有了[[Scope]]属性 —— 创建函数所在上下文的作用域链(这个时候,在[[Scope]]就有了那个特殊对象)。 之后,特殊对象中唯一的属性 —— FE的名字添加到了该对象中;其值就是对FE的引用。在最后,当前上下文退出的时候,就会把该特殊对象移除。 用伪代码来描述此算法就如下所示:

specialObject = {};
 
Scope = specialObject + Scope;
 
foo = FunctionExpression;
foo.[[Scope]] = Scope;
specialObject.foo = foo; // {DontDelete}, {ReadOnly}
 
delete Scope[0]; // 从作用域链的最前面移除specialObject

这就是为什么在函数外是无法通过名字访问到该函数的(因为它并不在上层作用域中存在),而在函数内部却可以访问到。

而这里要注意的一点是: 在某些实现中,比如Rhino,FE的名字并不是保存在特殊对象中的,而是保存在FE的活跃对象中。 再比如微软的实现 —— JScript,则完全破坏了FE的规则,直接将该名字保存在上层作用域的变量对象中了,这样在外部也可以访问到。

NFE和SpiderMonkey


说到实现,部分版本的SpiderMonkey有一个与上述提到的特殊对象相关的特性,这个特性也可以看作是个bug(既然所有的实现都是严格遵循标准的,那么这个就是标准的问题了)。 此特性和标识符处理相关: 作用域链的分析是二维的,在标识符查询的时候,还要考虑作用域链中每个对象的原型链。

当在Object.prototype对象上定义一个属性,并将该属性值指向一个“根本不存在”的变量时,就能够体现该特性。 比如,如下例子中的变量“x”,在查询过程中,通过作用域链,一直到全局对象也是找不到“x”的。 然而,在SpiderMonkey中,全局对象继承自Object.prototype,于是,对应的值就在该对象中找到了:

Object.prototype.x = 10;
 
(function () {
  alert(x); // 10
})();

活跃对象是没有原型一说的。可以通过内部函数还证明。 如果在定义一个局部变量“x”并声明一个内部函数(FD或者匿名的FE),然后,在内部函数中引用变量“x”,这个时候该变量会在上层函数上下文中查询到(理应如此),而不是在Object.prototype中:

Object.prototype.x = 10;
 
function foo() {
 
  var x = 20;
 
  // function declaration 
 
  function bar() {
    alert(x);
  }
 
  bar(); // 20, from AO(foo)
 
  // the same with anonymous FE
 
  (function () {
    alert(x); // 20, also from AO(foo)
  })();
 
}
 
foo();

在有些实现中,存在这样的异常:它们会在活跃对象设置原型。比方说,在Blackberry的实现中,上述例子中变量“x”值就会变成10。 因为,“x”从Object.prototype中就找到了:

AO(bar FD or anonymous FE) -> no ->
AO(bar FD or anonymous FE).[[Prototype]] -> yes - 10

当出现有名字的FE的特殊对象的时候,在SpiderMonkey中也是有同样的异常。该特殊对象是常见对象 —— “和通过new Object()表达式产生的一样”。 相应地,它也应当继承自Object.prototype,上述描述只针对SpiderMonkey(1.7版本)。其他的实现(包括新的TraceMonkey)是不会给这个特殊对象设置原型的:

function foo() {
 
  var x = 10;
 
  (function bar() {
 
    alert(x); // 20, but not 10, as don't reach AO(foo)
 
    // "x" is resolved by the chain:
    // AO(bar) - no -> __specialObject(bar) -> no
    // __specialObject(bar).[[Prototype]] - yes: 20
 
  })();
}
 
Object.prototype.x = 20;
 
foo();

NFE和JScript


微软的实现——JScript,是IE的JS引擎(截至本文撰写时最新是JScript5.8——IE8),该引擎与NFE相关的bug有很多。每个bug基本上都和ECMA-262-3rd标准是完全违背的。 有些甚至会引发严重的错误。

第一,针对上述这样的情况,JScript完全破坏了FE的规则:不应当将函数名字保存在变量对象中的。 另外,FE的名字应当保存在特殊对象中,并且只有在函数自身内部才可以访问(其他地方均不可以)。而JScript却将其直接保存在上层上下文的变量对象中。 并且,JScript居然还将FE以FD的方式处理,在进入上下文的时候就将其创建出来,并在定义之前就可以访问到:

// FE 保存在变量对象中
// 和FD一样,在定义前就可以通过名字访问到
testNFE();
 
(function testNFE() {
  alert('testNFE');
});
 
// 同样的,在定义之后也可以通过名字访问到
testNFE();

正如大家所见,完全破坏了FE的规则。

第二,在声明同时,将NFE赋值给一个变量的时候,JScript会创建两个不同的函数对象。 这种行为感觉完全不符合逻辑(特别是考虑到在NFE外层,其名字根本是无法访问到的):

var foo = function bar() {
  alert('foo');
};
 
alert(typeof bar); // "function", NFE 有在VO中了 – 这里就错了
 
// 然后,还有更有趣的
alert(foo === bar); // false!
 
foo.x = 10;
alert(bar.x); // undefined
 
// 然而,两个函数完全做的是同样的事情
 
foo(); // "foo"
bar(); // "foo"

然而,要注意的是: 当将NFE和赋值给变量这两件事情分开的话(比如,通过组操作符),在定义好后,再进行变量赋值,这样,两个对象就相同了,返回true:

(function bar() {});
 
var foo = bar;
 
alert(foo === bar); // true
 
foo.x = 10;
alert(bar.x); // 10

这个时候就好解释了。实施上,一开始的确创建了两个对象,不过之后就只剩下一个了。这里将NFE以FD的方式来处理,然后,当进入上下文的时候,FD bar就创建出来了。 在这之后,到了执行代码阶段,又创建出了第二个对象 —— FE bar,该对象不会进行保存。相应的,由于没有变量对其进行引用,随后FE bar对象就被移除了。 因此,这里就只剩下一个对象——FD bar对象,对该对象的引用就赋值给了foo变量。

第三,通过arguments.callee对一个函数进行间接引用,它引用的是和激活函数名一致的对象(事实上是——函数,因为有两个对象):

var foo = function bar() {
 
  alert([
    arguments.callee === foo,
    arguments.callee === bar
  ]);
 
};
 
foo(); // [true, false]
bar(); // [false, true]

第四,JScript会将NFE以FD来处理,但当遇到条件语句又不遵循此规则了。比如说,和FD那样,NFE会在进入上下文的时候就创建出来,这样最后一次定义的就会被使用:

var foo = function bar() {
  alert(1);
};
 
if (false) {
 
  foo = function bar() {
    alert(2);
  };
 
}
bar(); // 2
foo(); // 1

上述行为从逻辑上也是可以解释通的: 当进入上下文的时候,最后一次定义的FD bar被创建出来(有alert(2)的函数), 之后到了执行代码阶段又一个新的函数 —— FE bar被创建出来,对其引用赋值给了变量foo。因此(if代码块中由于判断条件是false,因此其代码块中的代码永远不会被执行到)foo函数的调用会打印出1。 尽管“逻辑上”是对的,但是这个仍然算是IE的bug。因为它明显就破坏了实现的规则,所以我这里用了引号“逻辑上”。

第五个JScript中NFE的bug和通过给一个未受限的标识符赋值(也就是说,没有var关键字)来创建全局对象的属性相关。 由于这里NFE会以FD的方式来处理,并相应地会保存在变量对象上,赋值给未受限的标识符(不是给变量而是给全局对象的一般属性), 当函数名和标识符名字相同的时候,该属性就不会是全局的了。

(function () {
 
  // 没有var,就不是局部变量,而是全局对象的属性
 
  foo = function foo() {};
 
})();
 
// 然而,在匿名函数的外层,foo又是不可访问的
 
alert(typeof foo); // undefined

这里从“逻辑上”又是可以解释通的: 进入上下文时,函数声明在匿名函数本地上下文的活跃对象中。 当进入执行代码阶段的时候,因为foo这个名字已经在AO中存在了(本地),相应地,赋值操作也只是简单的对AO中的foo进行更新而已。 并没有在全局对象上创建新的属性。

通过Function构造器创建的函数


这类函数有别于FD和FE,有自己的专属特性: 它们的[[Scope]]属性中只包含全局对象:

var x = 10;
 
function foo() {
 
  var x = 20;
  var y = 30;
 
  var bar = new Function('alert(x); alert(y);');
 
  bar(); // 10, "y" is not defined
 
}

我们看到bar函数的[[Scope]]属性并未包含foo上下文的AO —— 变量“y”是无法访问的,并且变量“x”是来自全局上下文。 顺便提下,这里要注意的是,Function构造器可以通过new关键字和省略new关键字两种用法。上述例子中,这两种用法都是一样的。

此类函数其他特性则和同类语法产生式以及联合对象有关。 该机制在标准中建议在作优化的时候采用(当然,具体的实现者也完全有权利不使用这类优化)。比方说,有100元素的数组,在循环数组过程中会给数组每个元素赋值(函数), 这个时候,实现的时候就可以采用联合对象的机制了。这样,最终所有的数组元素都会引用同一个函数(只有一个函数):

var a = [];
 
for (var k = 0; k < 100; k++) {
  a[k] = function () {}; // 这里就可以使用联合对象
}

但是,通过Function构造器创建的函数就无法使用联合对象了:

var a = [];
 
for (var k = 0; k $lt; 100; k++) {
  a[k] = Function(''); // 只能是100个不同的函数
}

下面是另外一个和联合对象相关的例子:

function foo() {
 
  function bar(z) {
    return z * z;
  }
 
  return bar;
}
 
var x = foo();
var y = foo();

上述例子,在实现过程中同样可以使用联合对象。来使得x和y引用同一个对象,因为函数(包括它们内部的[[Scope]]属性)物理上是不可分辨的。 因此,通过Function构造器创建的函数总是会占用更多内存资源。

函数创建的算法


如下所示使用伪代码表示的函数创建的算法(不包含联合对象的步骤)。有助于理解ECMAScript中的函数对象。此算法对所有函数类型都是一样的。

F = new NativeObject();
 
// 属性 [[Class]] is "Function"
F.[[Class]] = "Function"
 
// 函数对象的原型
F.[[Prototype]] = Function.prototype
 
// 对函数自身引用
// [[Call]] 在函数调用时F()激活
// 同时创建一个新的执行上下文
F.[[Call]] = <reference to function>
 
// 内置的构造器
// [[Construct]] 会在使用“new”关键字的时候激活
// 事实上,它会为新对象申请内存
// 然后调用 F.[[Call]]来初始化创建的对象,将this值设置为新创建的对象
F.[[Construct]] = internalConstructor
 
// 当前上下文(创建函数F的上下文)的作用域名链
F.[[Scope]] = activeContext.Scope
// 如果是通过new Function(...)来创建的,则
F.[[Scope]] = globalContext.Scope
 
// 形参的个数
F.length = countParameters
 
// 通过F创建出来的对象的原型
__objectPrototype = new Object();
__objectPrototype.constructor = F // {DontEnum}, 在遍历中不能枚举
F.prototype = __objectPrototype
 
return F

要注意的是,F.[[Prototype]]是函数(构造器)的原型,而F.prototype是通过该函数创建出来的对象的原型(因为通常对这两个概念都会混淆,在有些文章中会将F.prototype叫做“构造器的原型”,这是错误的)。

总结


本文介绍了很多关于函数的内容;不过在后面的关于对象和原型的文章中,还会提到函数作为构造器是如何工作的。

扩展阅读


ECMAScript标准:

另外一篇文章: