这个也不会,回家种田去吧你
顺便我们来谈谈delete的用法
几个礼拜前, 我有了个机会去翻阅Stoyan Stefanov的 Object-Oriented Javascript 一书. 这本书在亚马逊上拥有很高的评价(12篇评论, 5颗星), 所以我很好奇地想看看它到底是不是那么值得推荐的一本书, 于是我开始阅读函数的那章. 我非常欣赏这本书解释事物的方式, 例子们被以一种非常漂亮, 渐进的方式被组织起来, 看起来即便是初学者也能够轻松掌握这些知识. 然而, 几乎是立刻, 我就发现了一个贯穿整个章节的有趣的误解――删除功能函数. 另外还有一些其它错误(例如函数声明与函数表达式的区别), 但是我们目前将不去讨论它们.
这本书声称:
"函数被作为像一般变量一样对待-它可以被复制到不同的变量中, 甚至被删除". 在这个解释后面附加了这样一段示例:
忽略掉一些漏掉的分号, 你能看出这几句代码的错误在哪么? 显然, 错误在于删除sum这个变量的操作是不会成功的. delete表达式不应该返回true, 并且 typeof sum也不应该返回"undefined". 这一切都因为在JavaScript中删除变量是不可能的. 至少, 在这种声明方式下是不可能的.
所以, 在这个例子中到底发生了什么? 它是一个错误么? 抑或是一个特殊用法? 大概不是这样的. 这一段代码事实上是Firebug控制台中的真实输出, Stoyan一定是使用了它作为快速测试的工具. 这几乎就好像是Firebug遵守了其它一些delete的规则一样. 是Firebug导致了Stoyan误入歧途! 所以, 这儿到底发生了什么?
在回答这个问题之前, 我们首先需要理解delete运算符到底在JavaScript中是如何工作的: 到底什么能够被删除, 什么不能够被删除? 今天, 我将尝试着详细解释这个问题. 我们将看看Firebug的"奇怪"行为并且意识到它其实并不是那么奇怪. 我们将深入了解在声明变量, 函数, 给属性赋值和删除它们的这些场景背后到底隐藏了什么. 我们将看看浏览器的兼容性和一些最臭名昭著的bug. 我们还将讨论ES5的严格模式, 和它如何改变delete操作符的行为.
我将交换着使用JavaScript和ECMAScript, 它们都意味着ECMAScript(除非明显地谈论Mozilla的JavaScript实现)
不出所料, 在网络上, 对delete的解释是相当稀缺的. MDC article大概是最好理解的资源了, 但是, 不幸的是, 它缺失了这个主题的一些有趣的细节. 奇怪的是, 其中一个被遗忘的东西就是Firebug的奇怪表现的原因. 而MSDN reference在这些方面几乎是无用处的.
Theory
那么, 为什么我们能够删除对象的属性:
却不能删除这样声明的对象:
或者函数呢:
注意: 当一个属性无法被删除时,delete操作符只会返回false
要理解这个, 我们首先需要掌握这些有关变量实例和属性特性的概念――这些概念很不幸地, 很少在JavaScript书中被提及. 我将试着在接下来的几个段落中简单地复习一下这些概念. 这些概念是很难理解的!如果你不在乎"为什么这些东西会以这种方式工作"的话,尽情跳过这一章节好了.
代码的类型:
在ECMAScript中, 有3种不同类型的可执行代码: 全局代码(Global code), 函数代码(Function code)和 Eval代码(Eval code). 这些类型从名称上来说或多或少是有自解释性的, 这里有一个简短的概述:
当一段源代码被看成程序(Program)时, 它将会在全局环境下被执行, 并且被认为是全局代码(Global code). 在一个浏览器环境中, 脚本元素的内容通常被解释为程序, 因此被作为全局代码来执行.
任何直接在一个函数中执行的代码显然被认为是函数代码(Function code). 在浏览器中, 事件属性的内容(如 <p onclick="....">)通常被解释成函数代码.
最后, 被应用到内置函数eval的代码文本被解释成Eval代码(Eval code). 很快我们会发现为什么这种类型是特殊的.
执行上下文(Execution context):
当ECMAScript代码执行时, 它通常会发生在特定的执行上下文中.执行上下文是一个有些抽象的实体概念, 它能帮助理解范围(Scope)和变量实例(Variable instantiation)是如何工作的. 对三种可执行代码的每一种, 都有一个执行上下文相对应. 当一个函数被执行的时候, 我们说"程序控制进入了函数代码的执行上下文"; 当一段全局代码被执行时, 程序控制进入了全局代码的执行上下文, 等等.
正如你所见, 执行上下文可以在逻辑上构成一个堆栈. 首先, 可能有一段全局代码和其自己的执行上下文, 然后这段代码可能会调用一个函数, 并带着它(函数)的执行上下文. 这段函数可以调用另外一个函数, 等等等等. 即使函数是递归调用的, 每次调用时被也会进入一个新的执行上下文.
活动对象(Activation object) / 变量对象(Variable Object):
每一个执行上下文都有一个跟其所关联的所谓变量对象(Variable Object). 类似于执行上下文, 变量对象是一个抽象实体, 一种用来描述变量实例的机制. 有趣之处在于, 在源代码中声明的变量和函数通常会被当做属性(properties)增加到这个变量对象上.
当程序控制进入全局代码的执行上下文时, 一个全局对象(Global object)被用来作为一个变量对象. 这正是为什么声明为全局的函数变量会变成全局对象属性的原因.
var foo = 1;
GLOBAL_OBJECT.foo; // 1
foo === GLOBAL_OBJECT.foo; // true
function bar(){}
typeof GLOBAL_OBJECT.bar; // "function"
GLOBAL_OBJECT.bar === bar; // true
好, 所以全局变量会变成全局对象的属性, 但是局部变量(那些在函数代码中定义的变量)会发生什么呢? 其实它们的行为也非常类似: 它们会变成变量对象(Variable object)的属性. 唯一的不同在于, 当在函数代码中时, 一个变量对象并不是全局对象, 而是所谓的活动对象(Activation object). 活动对象在会每次进入函数代码的执行上下文时被创建.
并不是只有在函数代码中声明的变量和函数会变成活动对象的属性; 这也会在每个函数参数(对应相应的形式参数的名称)和一个特殊的Arguments对象(以arguments为名称)上发生. 注意, 活动对象是一个内部描述机制, 在程序代码中并不能被访问.
属性的特性(property attributes)
我们几乎是已经在这了. 既然我们已经很清楚在变量上发生了什么(它们变成了属性), 唯一剩下的需要理解的概念就是属性的特性(property attributes)了. 每一个属性可以拥有0个或多个特性, 它们从以下集合中选取: ReadOnly, DontEnum, DontDelete和 Internal. 你可以把它们认为是flags ―― 一种特性可以在属性中存在, 也可以不存在. 对于我们今天的讨论来说, 我们只对DontDelete感兴趣.
当被声明的变量和函数成为变量对象(或者函数代码的活动对象, 或全局代码的全局对象)的属性时, 这些属性在创建时就带上了DontDelete的特性. 然而, 任何显式(或隐式)的属性赋值所建立的属性将不会被带上DontDelete特性. 这就是为什么我们能够删除一些属性, 但删除不了其它的.
内置对象和DontDelete
所以, 这就是有关它(DontDelete)的所有: 属性的一个特殊特性, 用来控制这个属性是否能够被删除. 注意, 有些内置对象的属性是指定含有DontDelete的, 所以无法被删除. 如特殊的arguments变量(或者, 正如我们现在所知道的, 一个活动对象的属性)拥有DontDelete. 函数实例的length属性也具有DontDelete属性.
函数参数所对应的属性也是从建立开始就拥有DontDelete特性的, 所以我们也无法删除它.
未声明的赋值:
你可能还记着, 未声明的赋值会在全局对象上建立一个属性, 除非这个属性已经在这个作用域链中全局对象之前的其它地方被找到. 并且, 现在我们知道属性赋值和变量声明的不同之处――后者会设置DontDelete属性, 但前者不会. 我们必须清楚, 为什么未声明的赋值会建立一个可删除的属性.
请注意: 特性是在属性被创建时被决定的, 之后的赋值不会修改已存在属性的特性. 理解这一点区别非常重要.
Firebug的困惑:
在Firebug中发生了什么? 为什么在console中声明的变量可以被删除, 这不是违背了我们之前所学到的知识么? 嗯, 就像我之前所说的那样, Eval代码在面对变量声明时会有特殊的表现. 在Eval中声明的变量实际上是作为不带DontDelete特性的属性被创建的.
同样, 类似的, 当在函数代码中调用时:
eval('var foo = 1;');
foo; // 1
delete foo; // true
typeof foo; // "undefined"
})();
这就是Firebug反常行为的依据. 在console中的所有文本都会被当做Eval代码来解析和执行, 而不是全局或函数代码. 显然, 这里声明的所有变量最后都会成为不带DontDelete特性的属性, 所以它们都能被轻松删除. 我们需要了解这个在全局代码和Firebug控制台之间的差异.
通过Eval来删除变量:
这个有趣的eval行为, 再加上ECMAScript的另一个方面, 可以在技术上允许我们删除"non-deletable"的属性. 有关函数声明的一点是, 它们能够覆盖相同执行上下文中同名的变量.
注意函数声明是如何获得优先权并且覆盖同名变量(或者, 换句话说, 在变量对象中的相同属性)的. 这是因为函数声明是在变量声明之后被实例化的, 并且被允许覆盖它们(变量声明). 函数声明不仅会替换掉一个属性的值, 它还会替换掉那个属性的特性. 如果我们通过eval来声明一个函数, 那个函数就应该会用它自己的特性来替换掉原有的(被替换的)属性的特性. 并且, 由于通过eval声明的变量会创建不带DontDelete特性的属性, 实例化这个新函数将会实际上从属性中删除已存在的DontDelete特性, 从而使得一个属性能够被删除(并且, 显然会将其值指向新创建的函数).
不幸的是, 这种"欺骗"在目前的任何实现中都不起作用. 也许我在这漏掉了什么, 或者是这种行为只是太晦涩了以至于实现者都没有注意到它.
浏览器兼容性:
在理论上了解事物是如何工作的是有用的, 但是实践却是最重要的. 当面对变量/属性的创建/删除时, 浏览器有遵循标准么? 答案是: 在大多数情况下, 是的.
我写了一个简单的测试集来测试浏览器对于delete操作符的兼容性, 包括在全局代码, 函数代码和Eval代码下的测试. 测试集检查了delete操作符的返回值和属性值是否(像它们应当表现的一样)真的被删除了. delete的返回值并不像它的真实结果一样重要. 如果delete返回true而不是false, 这其实并不重要, 重要的是那些拥有DontDelete特性的属性没有被删除,反之亦然.
现代浏览器大致上来说是相当兼容的. 除去了我之前提到的eval特点, 如下的浏览器通过了全部的测试集: Opera 7.54+, Firefox 1.0+, Safari 3.1.2+, Chrome 4+.
Safari 2.x 和 3.0.4在删除函数参数时有问题; 这些属性看起来是不带DontDelete被创建的, 所以可以删除它们. Safari 2.x有更多的问题――删除非引用类型变量(如: delete 1)会抛出异常; 函数声明会创建可删除的属性(但是, 奇怪的是, 变量声明却不会); eval中的变量声明会变成不可删除的(但是函数声明是可删除的).
跟Safari类似, Konqueror(3.5, 不是4.3)会在删除非引用类型时抛出异常(如: delete 1), 并且错误地让函数变量变为可删除的.
译者注:
我测试了最新版本的chrome和firefox以及IE, 基本还是保留在除23,24会fail其它均pass的情况. 同时测试了UC和一些手机浏览器, 除了诺基亚E72的自带浏览器还会Fail 15,16之外, 其余的自带浏览器大都与桌面浏览器效果一样. 但值得一提的是, Blackberry Curve 8310/8900的自带浏览器可以pass测试23, 令我很惊讶.
Gecko DontDelete bug:
Gecko 1.8.x 浏览器 ―― Firefox 2.x, Camino 1.x, Seamonkey 1.x等等. ―― 表现出了一个非常有趣的bug, 对属性的显式赋值会删除它的DontDelete特性, 即使这个属性是通过变量声明或函数声明创造的.
令人吃惊的是, Internet Explorer 5.5 - 8 通过了完整的测试集, 除了删除非引用类型(如: delete 1)会抛出异常(就像旧的Safari一样). 但是在IE下有更严重的bugs, 它不是那么明显. 这些bugs跟Global object有关.
IE bugs:
这整章都在说Internet Explorer的bugs? 哇! 真是令人吃惊!
在IE中(至少是IE 6-8), 以下表达式会抛出异常(当在全局代码中执行时):
this.x = 1;
delete x; // TypeError: Object doesn't support this action
这一个也会, 但是会抛出不同的异常, 这使得事情更有趣了:
var x = 1;
delete this.x; // TypeError: Cannot delete 'this.x'
这看上去好像是在IE中, 全局代码中的变量声明没有在全局对象上创建属性. 通过赋值来创建属性(this.x = 1)和之后通过delete x来删除它会抛出错误. 通过声明来创建属性(var x = 1)并且在之后通过delete this.x来删除它会抛出另一个错误.
但这还不是全部. 通过显式赋值来创建属性事实上总会引起在删除时的抛出异常. 这里不止有错误, 而且所创建的属性似乎会拥有DontDelete特性, 而这当然是不应该具有的.
this.x = 1;
delete this.x; // TypeError: Object doesn't support this action
typeof x; // "number" (still exists, wasn't deleted as it should have been!)
delete x; // TypeError: Object doesn't support this action
typeof x; // "number" (wasn't deleted again)
现在, 我们会认为 在IE下, 未声明的赋值(应当在全局对象上创建属性)确实会创建可删除的属性.
x = 1;
delete x; // true
typeof x; // "undefined"
但是, 如果你是同通过全局代码中的this引用来删除这个属性的话(delete this.x), 就会弹出一个类似的错误.
x = 1;
delete this.x; // TypeError: Cannot delete 'this.x'
如果我们想要归纳一下这种行为的话, 看起来是从全局代码中使用delete this.x来删除变量从来不可能成功. 当问题中的属性通过显式的赋值(this.x = 1)来创建时,delete抛出了一个错误; 当属性是通过未声明的赋值(x = 1)或通过声明(var x = 1)来创建时, delete抛出另外一个错误.
delete x, 另一方面来说, 应当只在属性是通过显式赋值来创建时抛出错误 ――this.x = 1.如果一个属性是通过声明来创建的(var x = 1), 删除操作从来不会发生, 并且删除操作会正确地返回false. 如果一个属性是通过未声明的赋值来创建的(x = 1), 删除操作会像期望地一样工作.
我这9月份又思考了一下这个问题, Garrett Smith建议说在IE下,
"全局变量对象(The global variable object)是实现为一个JScript对象的, 并且全局对象是由host来实现的".
Garrett使用了Eric Lippert's blog entry作为参考.
我们多多少少可以通过实施一些测试来确认这个理论. 注意到this和window看起来是应当指向同一个对象的(如果我们能够信任===操作符的话), 但是变量对象(函数声明所在的那个对象)却与this所指向的不同.
getBase() === this.getBase(); // false
this.getBase() === this.getBase(); // true
window.getBase() === this.getBase(); // true
window.getBase() === getBase(); // false
误解:
理解事物为何以那种方式工作的美是不可低估的. 我在网络上见过一些有关delete操作符的误解. 例如, 这个Stackoverflow上的答案(拥有令人吃惊的高rating), 自信地解释道
"当目标操作数不是一个对象属性时, delete应当是无操作的".
现在既然我们已经理解了delete操作行为的核心, 这个答案的错误也就变得显而易见了. delete并不会去区分因为变量和属性(事实上, 对于delete来说, 它们都是引用类型)并且事实上只关心DontDelete特性(和属性本身是否存在).
看到各种误解互相反驳也是非常有趣的, 在一个相同的话题中一个人首先建议只delete变量(这将不会有效果, 除非它是在eval中声明的), 而另一个人提供了一个错误的纠正说明delete是如何在全局代码中用于删除变量, 而在函数代码中却不行.
对于网络上JavaScript的解释要格外小心, 理想的方法是总去理解问题的本质. ;)
delete和宿主对象(Host Object):
delete的算法大概是这样的:
如果操作数不是引用类型, 则返回true
如果对象没有这个名字的直接属性, 返回true(正如我们所知, 对象可以是活动对象或者全局对象)
如果属性存在但是有DontDelete特性, 返回false
其它情况, 删除属性并且返回true
然而, delete操作符在宿主对象上的行为是难以预测的. 并且这种行为实际上并没有错: (根据标准), 宿主对象是被允许对于像read(内部[[Get]]方法), write(内部[[Put]]方法)和delete(内部[[Delete]]方法)其中几个操作符实现任何行为的. 这种对自定义[[Delete]]行为的宽限就是将宿主对象变得如此混乱的原因.
我们已经见过了一些IE的怪癖, 删除特定的对象(显然是指被实现为宿主对象的)会抛出错误. Firefox的一些版本在删除window.location的时候会抛出. 当操作数是宿主对象时, 你不可以信任delete的返回值. 让我们来看看在Firefox中发生了什么:
delete window.alert; // true
typeof window.alert; // "function"
删除window.alert返回true, 即使这个属性完全没有任何应该导致这样的结果的理由. 它将解析为一个引用(所以不会在第一步就返回true). 这是个window对象的直接属性(所以不会在第二步返回true). 所以delete唯一能够返回true的情况就是到达第四步并且真正删除那个属性. 然而, 这个属性从未被删除.
这个故事的寓意是: 永远不要相信宿主对象.
ES5 严格模式:
所以, 严格模式的ECMAScript5给我们带来了什么呢? 它介绍了很少的一些限制. 当delete操作符的表达式是一个变量的直接引用, 函数参数或者函数标示符时, 语法错误将会被抛出. 另外, 如果属性具有内部特性[[Configurable]] == false, 则一个类型错误将会被抛出.
另外, 删除未声明的变量(或者说未解析的引用)将也会抛出语法错误:
"use strict";
delete i_dont_exist; // SyntaxError
未声明的赋值跟严格模式下未声明的变量所表现的行为类似(除了这次是引发引用错误而不是语法错误):
"use strict";
i_dont_exist = 1; // ReferenceError
正如你现在所明白的, 所有的限制多多少少是有道理的, 因为删除变量, 函数声明和 参数会导致这么多混乱. 与其静默地忽略删除操作, 严格模式采用了一种更加激进和更具描述性的措施.
总结:
这篇博文最后变得相当的长, 所以我不准备再去谈论类似于用delete删除数组对象或它的含义是什么等. 你可以参考MDC文章对其专门的解释(或者阅读标准和自己做实验).
这里有一份对于JavaScript中删除操作是如何工作的简短的总结:
变量和函数声明是活动对象或者全局对象的属性
属性拥有一些特性, 这其中的DontDelete是决定这个属性能否被删除的那个特性.
在全局或者函数代码中的变量和函数声明总是创建带有DontDelete特性的属性.
函数参数总是活动对象的属性, 并且带有DontDelete.
在Eval代码中声明的变量和函数总是创建不带DontDelete的属性.
新的属性在建立时是没有特性的(当然也没有DontDelete).
宿主对象被允许自己决定如何对delete操作做出反应.
如果你想要对这里所描述的东西更加熟悉的话, 请参阅 ECMA-262 3rd edition specification.
我希望你能够享受这篇文章, 并且学到一些新的东西. 欢迎提出任何问题, 建议或者纠正.
新闻热点
疑难解答