ECMAScript 6规范新增了一项内存管理优化机制,让JavaScript引擎在满足条件时可以重用栈帧。
具体来说,这项优化非常适合“尾调用”,即外部函数的返回值是一个内部函数的返回值。
比如:
function outerFunc() {return innerFunc(); // 尾调用}
在ES6优化之前,执行这个例子会在内存中发生如下操作:
(1)执行到outerFunction函数体,第一个栈帧被推到栈上。
(2)执行outerFunction函数体,到return语句。计算返回值必须先计算innerFunction。
(3)执行到innerFunction函数体,第二个栈帧被推到栈上。
(4)执行innerFunction函数体,计算其返回值。
(5)将返回值传回outerFunction,然后outerFunction再返回值。
(6)将栈帧弹出栈外。
在ES6优化之后,执行这个例子会在内存中发生如下操作。
(1)执行到outerFunction函数体,第一个栈帧被推到栈上。
(2)执行outerFunction函数体,到达return语句。为求值返回语句,必须先求值innerFunction。
(3)引擎发现把第一个栈帧弹出栈外也没问题,因为innerFunction的返回值也是outerFunction的返回值。
(4)弹出outerFunction的栈帧。
(5)执行到innerFunction函数体,栈帧被推到栈上。
(6)执行innerFunction函数体,计算其返回值。
(7)将innerFunction的栈帧弹出栈外。
第一种情况下每多调用一次嵌套函数,就会多增加一个栈帧。
第二种情况下无论调用多少次嵌套函数,都只有一个栈帧。
这就是ES6尾调用优化的关键:如果函数的逻辑允许基于尾调用将其销毁,则引擎就会那么做。
注:现在还没有办法测试尾调用优化是否起作用。不过,因为这是ES6规范所规定的,兼容的浏览器实现都能保证在代码满足条件的情况下应用这个优化。
10.13.1 尾调用优化的条件
尾调用优化的条件就是确定外部栈帧真的没有必要存在了。
涉及的条件如下:
❑ 代码在严格模式下执行;
❑ 外部函数的返回值是对尾调用函数的调用;
❑ 尾调用函数返回后不需要执行额外的逻辑;
❑ 尾调用函数不是引用外部函数作用域中自由变量的闭包。
下面展示了几个违反上述条件的函数,因此都不符号尾调用优化的要求:
"use strict";// 无优化:尾调用,没有返回function outerFunc() {innerFunc();}// 无优化:尾调用没有直接返回function outerFunc() {let innerFuncResult = innerFunc();return innerFuncResult;}// 无优化:尾调用返回后必须转型为字符串function outerFunc() {return innerFunc().toString();}// 无优化:尾调用是一个闭包function outerFunc() {let foo = 'bar';function innerFunc() { return foo };return innerFunc();}
下面是几个符合尾调用优化条件的例子:
"use strict";// 有优化:栈帧销毁前执行参数计算function outerFunc(a,b) {return innerFunc(a+b);}// 有优化:初始返回值不涉及栈帧function outerFunc(a,b) {if (a < b) {return a;}return innerFunc(a+b);}// 无优化:两个内部函数都在尾部function outerFunc(condition) {return condition ? innerFuncA() : innerFuncB();}
无论是递归尾调用还是非递归尾调用,都可以应用优化。不过,这个优化在递归场景下的效果是最明显的,因为递归代码最容易在栈内存中迅速产生大量栈帧。
注:之所以要求严格模式,主要因为在非严格模式下函数调用中允许使用f.arguments和f.caller,而它们都会引用外部函数的栈帧。显然,这意味着不能应用优化了。因此尾调用优化要求必须在严格模式下有效,以防止引用这些属性。
10.13.2 尾调用优化的代码
可以通过把简单的递归函数转换为,待优化的代码,来加深对尾调用优化的理解。
下面是一个通过递归计算斐波纳契数列的函数:
function fib(n) {if (n < 2) {return n;}return fib(n-1) + fib(n-2);}console.log(fib(0)); // 0console.log(fib(1)); // 1console.log(fib(2)); // 1console.log(fib(3)); // 2console.log(fib(4)); // 3console.log(fib(5)); // 5console.log(fib(6)); // 8
显然这个函数不符合尾调用优化的条件,因为返回语句中有一个相加的操作。
结果,fib(n)的栈帧数的内存复杂度是O(2n)。
解决这个问题也有不同的策略,比如把递归改写成迭代循环形式。
不过,也可以保持递归实现,但将其重构为满足优化条件的形式。
为此可以使用两个嵌套的函数,外部函数作为基础框架,内部函数执行递归:
"use strict";// 基础框架function fib(n) {return fibImp(0, 1, n);}// 执行递归function fibImp(a, b, n) {if (n === 0) {return a;}return fibImp(b, a+b, n-1);}// fib(4)// fibImp(b, a+b, 3)// fibImp(1, 1, 2)// fibImp(1, 2, 1)// fibImp(2, 3, 0)// 这样一个数列:0、1、1、2、3、5、8、13、21、34、……
这样重构之后,就可以满足尾调用优化的所有条件,再调用fib(1000)就不会对浏览器造成威胁了。
10.14 闭包
匿名函数经常被人误认为是闭包(closure)。
闭包指的是那些引用了另一个函数作用域中变量的函数,通常是在嵌套函数中实现的。
理解作用域链创建和使用的细节对理解闭包非常重要。
在调用一个函数时,会为这个函数调用创建一个执行上下文,并创建一个作用域链。
然后用arguments和其他命名参数来初始化这个函数的活动对象。
外部函数的活动对象是内部函数作用域链上的第二个对象。
这个作用域链一直向外串起了所有包含函数的活动对象,直到全局执行上下文才终止。
在函数执行时,要从作用域链中查找变量,以便读、写值。
来看下面的代码:
function compare(val1, val2) {if (val1 < val2) {return -1;} else if (val1 > val2) {return 1;} else {return 0;}}let result = compare(5, 10);
这里定义的compare()函数是在全局上下文中调用的。
第一次调用compare()时,会为它创建一个包含arguments、value1和value2的活动对象,这个对象是其作用域链上的第一个对象。
而全局上下文的变量对象则是compare()作用域链上的第二个对象,其中包含this、result和compare
函数执行时,每个执行上下文中都会有一个包含其中变量的对象。
全局上下文中的叫变量对象,它会在代码执行期间始终存在。
而函数局部上下文中的叫活动对象,只在函数执行期间存在。
在定义compare()函数时,就会为它创建作用域链,预装载全局变量对象,并保存在内部的[[Scope]]中。
在调用这个函数时,会创建相应的执行上下文,然后通过复制函数的[[Scope]]来创建其作用域链。
接着会创建函数的活动对象(用作变量对象)并将其推入作用域链的前端。
在这个例子中,这意味着compare()函数执行上下文的作用域链中有两个变量对象:局部变量对象和全局变量对象。作用域链其实是一个包含指针的列表,每个指针分别指向一个变量对象,但物理上并不会包含相应的对象。
函数内部的代码在访问变量时,就会使用给定的名称从作用域链中查找变量。
函数执行完毕后,局部活动对象会被销毁,内存中就只剩下全局作用域。
不过,闭包就不一样了。
在一个函数内部定义的函数,会把其包含函数的活动对象添加到自己的作用域链中。
注:因为闭包会保留它们包含函数的作用域,所以比其他函数更占用内存。
过度使用闭包可能导致内存过度占用,因此建议仅在十分必要时使用。
V8等优化的JavaScript引擎会努力回收被闭包困住的内存,不过我们还是建议在使用闭包时要谨慎。
10.14.1 this对象
在闭包中使用this会让代码变复杂。
如果内部函数没有使用箭头函数定义,则this对象会在运行时绑定到执行函数的上下文。
如果在全局函数中调用,则this在非严格模式下等于window,在严格模式下等于undefined。
如果作为某个对象的方法调用,则this等于这个对象。
匿名函数在这种情况下不会绑定到某个对象,这就意味着this会指向window,除非在严格模式下this是undefined。不过,由于闭包的写法所致,这个事实有时候没有那么容易看出来。
来看下面的例子:
window.identity = 'the window';let object = {identity: 'my object',getIdentityFunc() {return function() {return this.identity;}}}console.log(object.getIdentityFunc()()); // the window
先创建了一个全局变量identity,之后又创建一个包含identity属性的对象。这个对象还包含一个getIdentityFunc()方法,返回一个匿名函数。这个匿名函数返回this.identity。
因为getIdentityFunc()返回函数,所以object.getIdentityFunc()()会立即调用这个返回的函数,从而得到一个字符串。可是,此时返回的字符串是”The Winodw”,即全局变量identity的值。
为什么匿名函数没有使用其包含作用域(getIdentityFunc())的this对象呢?
每个函数在被调用时都会自动创建两个特殊变量:this和arguments。
内部函数永远不可能直接访问外部函数的这两个变量。但是,如果把this保存到闭包可以访问的另一个变量中,则是行得通的。
比如:
window.identity = 'the window';let object = {identity: 'my object',getIdentityFunc() {let that = this;return function() {return that.identity;}}}console.log(object.getIdentityFunc()()); // my object
在定义匿名函数之前,先把外部函数的this保存到变量that中。
然后在定义闭包时,就可以让它访问that
因为这是包含函数中名称没有任何冲突的一个变量。即使在外部函数返回之后,that仍然指向object,所以调用object.getIdentityFunc()()就会返回”MyObject”。
注:this和arguments都是不能直接在内部函数中访问的。如果想访问包含作用域中的arguments对象,则同样需要将其引用先保存到闭包能访问的另一个变量中。
在一些特殊情况下,this值可能并不是我们所期待的值。
比如下面这个修改后的例子:
window.identity = 'the window';let object = {identity: 'my object',getIdentity() {return this.identity;}}console.log(object.getIdentity()); // my objectconsole.log((object.getIdentity)()); // my objectconsole.log((object.getIdentity = object.getIdentity)()) // the window
第一行调用object.getIdentity()是正常调用,会返回”My Object”,因为this.identity就是object.identity。
第二行在调用时把object.getIdentity放在了括号里。虽然加了括号之后看起来是对一个函数的引用,但this值并没有变。这是因为按照规范,object.getIdentity和(object.getIdentity)是相等的。
第三行执行了一次赋值,然后再调用赋值后的结果。因为赋值表达式的值是函数本身,this值不再与任何对象绑定,所以返回的是”The Window”。
一般情况下,不大可能像第二行和第三行这样调用对象上的方法。但通过这个例子,我们可以知道,即使语法稍有不同,也可能影响this的值。
10.14.2 内存泄漏
由于IE在IE9之前对JScript对象和COM对象使用了不同的垃圾回收机制(第4章讨论过),所以闭包在这些旧版本IE中可能会导致问题。在这些版本的IE中,把HTML元素保存在某个闭包的作用域中,就相当于宣布该元素不能被销毁。
来看下面的例子:
function assignHandler() {let element = document.getElementById('someElement');element.onclick = () => console.log(element.id);}
创建了一个闭包,即element元素的事件处理程序(事件处理程序将在第13章讨论)。而这个处理程序又创建了一个循环引用。匿名函数引用着assignHandler()的活动对象,阻止了对element的引用计数归零。只要这个匿名函数存在,element的引用计数就至少等于1。也就是说,内存不会被回收。
其实只要这个例子稍加修改,就可以避免这种情况
比如:
function assignHandler() {let element = document.getElementById('someElement');let id = element.id;element.onclick = () => console.log(element.id);element = null;}
在这个修改后的版本中,闭包改为引用一个保存着element.id的变量id,从而消除了循环引用。
不过,光有这一步还不足以解决内存问题。
因为闭包还是会引用包含函数的活动对象,而其中包含element。即使闭包没有直接引用element,包含函数的活动对象上还是保存着对它的引用。
因此,必须再把element设置为null。这样就解除了对这个COM对象的引用,其引用计数也会减少,从而确保其内存可以在适当的时候被回收。
10.15 立即调用的函数表达式
立即调用的匿名函数又被称作立即调用的函数表达式(IIFE, ImmediatelyInvoked Function Expression)。它类似于函数声明,但由于被包含在括号中,所以会被解释为函数表达式。紧跟在第一组括号后面的第二组括号会立即调用前面的函数表达式。
下面是一个简单的例子:
(function() {// 块级作用域})()
使用IIFE可以模拟块级作用域,即在一个函数表达式内部声明变量,然后立即调用这个函数。
(function() {for( var i = 0; i < count; i++) {console.log(i);}})();console.log(i); //抛出错误
在ECMAScript 5.1及以前,为了防止变量定义外泄,IIFE是个非常有效的方式。这样也不会导致闭包相关的内存问题,因为不存在对这个匿名函数的引用。为此,只要函数执行完毕,其作用域链就可以被销毁。
在ECMAScript 6以后,IIFE就没有那么必要了,因为块级作用域中的变量无须IIFE就可以实现同样的隔离。
下面展示了两种不同的块级作用域形式
// 内嵌块级作用域{let i;for (i = 0; i < count; i++) {console.log(i);}}console.log(i); // 抛出错误// 循环的块级作用域for( let i = 0; i < count; i++) {console.log(i);}console.log(i); // 抛出错误
说明IIFE用途的一个实际的例子,就是可以用它锁定参数值。
比如:
let divs = document.querySelectorAll('div');// 达不到目的for (var i = 0; i < divs.length; i++) {divs[i].addEventListener('click', function() {console.log(i);});}
这里使用var关键字声明了循环迭代变量i,但这个变量并不会被限制在for循环的块级作用域内。
因此,渲染到页面上之后,点击每个
这是因为在执行单击处理程序时,迭代变量的值是循环结束时的最终值,即元素的个数。
而且,这个变量i存在于循环体外部,随时可以访问。
以前,为了实现点击第几个
let divs = document.querySelectorAll('div');for (var i = 0; i < divs.length; i++) {divs[i].addEventListener('click', function(frozenCounter) {return function() {console.log(i);}})(i);}
而使用ECMAScript块级作用域变量,就不用这么大动干戈了:
let divs = document.querySelectorAll('div');for (let i = 0; i < divs.length; i++) {divs[i].addEventListener('click', function(frozenCounter) {console.log(i);});}
这样就可以让每次点击都显示正确的索引了。
这里,事件处理程序执行时,就会引用for循环块级作用域中的索引值。
这是因为在ECMAScript 6中,如果对for循环使用块级作用域变量关键字,在这里就是let,那么循环就会为每个循环创建独立的变量,从而让每个单击处理程序都能引用特定的索引。
但要注意,如果把变量声明拿到for循环外部,那就不行了。
下面这种写法会碰到跟在循环中使用var i = 0同样的问题:
let divs = document.querySelectorAll('div');let i;for (i = 0; i < divs.length; i++) {divs[i].addEventListener('click', function(frozenCounter) {console.log(i);});}
10.16 私有变量
严格来讲,JavaScript没有私有成员的概念,所有对象属性都公有的。不过,倒是有私有变量的概念。
任何定义在函数或块中的变量,都可以认为是私有的,因为在这个函数或块的外部无法访问其中的变量。
私有变量包括函数参数、局部变量,以及函数内部定义的其他函数。
来看下面的例子:
function add(num1, num2) {let sum = num1 + num2;return sum;}
函数add()有3个私有变量:num1、num2和sum。这几个变量只能在函数内部使用,不能在函数外部访问。如果这个函数中创建了一个闭包,则这个闭包能通过其作用域链访问其外部的这3个变量。基于这一点,就可以创建出能够访问私有变量的公有方法。
特权方法(privileged method)是能够访问函数私有变量(及私有函数)的公有方法。
在对象上有两种方式创建特权方法。
第一种是在构造函数中实现
比如:
function MyObject() {// 私有变量和私有函数let privateVar = 10;function privateFunc() {return false;}// 特权方法this.publicMethod = function() {privateVar ++ ;return privateFunc();};}
这个模式是,把所有私有变量和私有函数都定义在构造函数中。
然后,再创建一个能够访问这些私有成员的特权方法。
这样做之所以可行,是因为,定义在构造函数中的特权方法其实是一个闭包,它具有访问构造函数中定义的所有变量和函数的能力。
在这个例子中,变量privateVariable和函数privateFunction()只能通过publicMethod()方法来访问。在创建MyObject的实例后,没有办法直接访问privateVariable和privateFunction(),唯一的办法是使用publicMethod()。
如下面的例子所示,可以定义私有变量和特权方法,以隐藏不能被直接修改的数据:
function Person(name) {this.getName = function() {return name;};this.setName = function(value) {name = value;};}let person = new Person('Amy');console.log(person.getName()); // Amyperson.setName('Bill');console.log(person.getName()); // Bill
这段代码中的构造函数定义了两个特权方法:getName()和setName()。
每个方法都可以构造函数外部调用,并通过它们来读写私有的name变量。在Person构造函数外部,没有别的办法访问name。
因为两个方法都定义在构造函数内部,所以它们都是能够通过作用域链访问name的闭包。
私有变量name对每个Person实例而言都是独一无二的,因为每次调用构造函数都会重新创建一套变量和方法。
不过这样也有个问题:必须通过构造函数来实现这种隔离。
正如第8章所讨论的,构造函数模式的缺点是每个实例都会重新创建一遍新方法。
使用静态私有变量实现特权方法可以避免这个问题。
10.16.1 静态私有变量
特权方法也可以通过,使用私有作用域定义私有变量和函数来实现。
这个模式如下所示:
(function() {// 私有变量和私有函数let privateVar = 10;function privateFunc() {return false;}// 构造函数MyObject = function() {};// 公有和特权方法MyObject.prototype.publicMethod = function() {privateVar ++ ;return privateFunc();};})();
在这个模式中,匿名函数表达式,创建了一个包含构造函数及其方法的私有作用域。
首先定义的是私有变量和私有函数,然后又定义了构造函数和公有方法。
公有方法定义在构造函数的原型上,与典型的原型模式一样。
注意,这个模式定义的构造函数没有使用函数声明,使用的是函数表达式。
函数声明会创建内部函数,在这里并不是必需的。
基于同样的原因(但操作相反),这里声明MyObject并没有使用任何关键字。因为不使用关键字声明的变量会创建在全局作用域中,所以MyObject变成了全局变量,可以在这个私有作用域外部被访问。
注意在严格模式下给未声明的变量赋值会导致错误。
这个模式与前一个模式的主要区别就是,私有变量和私有函数是由实例共享的。
因为特权方法定义在原型上,所以同样是由实例共享的。特权方法作为一个闭包,始终引用着包含它的作用域。
来看下面的例子:
(function() {let name = '';Person = function(value) {name = value;};Person.prototype.getName = function() {return name;}Person.prototype.setName = function(value) {name = value;};})();let person1 = new Person('Amy');console.log(person1.getName()); // Amyperson1.setName('Bill');console.log(person1.getName()); // Billlet person2 = new Person('Cindy');console.log(person1.getName()); // Cindyconsole.log(person2.getName()); // Cindy
这里的Person构造函数可以访问私有变量name,跟getName()和setName()方法一样。
使用这种模式,name变成了静态变量,可供所有实例使用。
这意味着在任何实例上调用setName()修改这个变量,都会影响其他实例。
调用setName()或创建新的Person实例,都要把name变量设置为一个新值。
而所有实例都会返回相同的值。
像这样创建静态私有变量可以利用原型更好地重用代码,只是每个实例没有了自己的私有变量。最终,到底是把私有变量放在实例中,还是作为静态私有变量,都需要根据自己的需求来确定。
注:使用闭包和私有变量会导致作用域链变长,作用域链越长,则查找变量所需的时间也越多。
10.16.2 模块模式
前面的模式通过自定义类型创建了私有变量和特权方法。模块模式,则在一个单例对象上实现了相同的隔离和封装。单例对象(singleton)就是只有一个实例的对象。
按照惯例,JavaScript是通过对象字面量来创建单例对象的,如下面的例子所示:
let singleton = {name: value,method() {// 方法的代码}}
模块模式是在单例对象基础上加以扩展,使其通过作用域链来关联私有变量和特权方法。
模块模式的样板代码如下:
let singleton = function() {// 私有变量和私有函数let privateVar = 10;function privateFunc() {return false;}// 特权/公有方法和属性return {publicProperty: true,publicMethod() {privateVar ++ ;return privateFunc();}};}();
模块模式使用了匿名函数返回一个对象。
在匿名函数内部,首先定义私有变量和私有函数。
之后,创建一个要通过匿名函数返回的对象字面量。
这个对象字面量中只包含可以公开访问的属性和方法。
因为这个对象定义在匿名函数内部,所以它的所有公有方法都可以访问同一个作用域的私有变量和私有函数。
本质上,对象字面量定义了单例对象的公共接口。
如果单例对象需要进行某种初始化,并且需要访问私有变量时,那就可以采用这个模式:
let application = function() {// 私有变量和私有函数let components = new Array();// 初始化components.push(new BaseComponent());// 公共接口return {getComponentCount() {return components.length;},registerComponent(component) {if (typeof component == 'object') {components.push(component);}}};}();
在Web开发中,经常需要使用单例对象管理应用程序级的信息。
上面这个简单的例子创建了一个application对象用于管理组件。
在创建这个对象之后,内部就会创建一个私有的数组components,然后将一个BaseComponent组件的新实例添加到数组中。(BaseComponent组件的代码并不重要,在这里用它只是为了说明模块模式的用法。)对象字面量中定义的getComponentCount()和register-Component()方法都是可以访问components私有数组的特权方法。前一个方法返回注册组件的数量,后一个方法负责注册新组件。
在模块模式中,单例对象作为一个模块,经过初始化可以包含某些私有的数据,而这些数据又可以通过其暴露的公共方法来访问。
以这种方式创建的每个单例对象都是Object的实例,因为最终单例都由一个对象字面量来表示。
不过这无关紧要,因为单例对象通常是可以全局访问的,而不是作为参数传给函数的,所以可以避免使用instanceof操作符确定参数是不是对象类型的需求。
10.16.3 模块增强模式
另一个利用模块模式的做法是:在返回对象之前先对其进行增强。
这适合单例对象需要是某个特定类型的实例,但又必须给它添加额外属性或方法的场景。
来看下面的例子:
let singleton = function() {// 私有变量和私有函数let privateVar = 10;function privateFunc() {return false;}// 创建对象let object = new CustomType();// 添加特权/公有方法和属性object.publicProperty = true;object.publicMethod = function() {privateVar ++ ;return privateFunc();}// 返回对象return object;}();
