JS中函数(三)(闭包、IIFE、私有变量等)

JS中函数(三)(闭包🐬IIFE🦐私有变量等)

  这是 JS 函数章节中的第三部分内容,也是最后一部分,最重要的一部分内容,其中的闭包无论在面试过程中还是理解 JS 其他重要知识点中都尤为重要。本部分除了闭包,还讲到立即执行函数,私有变量,静态私有变量,模块模式及模块增强模式。前两部分内容链接如下:
JS中函数(一):箭头函数、函数参数、扩展操作符
JS中函数(二):arguments、this、call、apply、bind、TCP

10.14、闭包

  闭包指的是那些引用了另一个函数作用域中变量的函数,闭包让你可以在一个内层函数中访问到其外层函数的作用域。通常是在嵌套函数中实现的。比如前文中写过的 createComparisonFunction() 函数。

// 创建一个根据属性值比较大小的函数
function createComparisonFun(propertyName) {
  return function(object1, object2) {
    let value1 = object1[propertyName];
    let value2 = object2[propertyName];
    if (value1 < value2) {
      return -1;
    } else if (value1 > value2) {
      return 1;
    } else {
      return 0;
	} 
  };
}

  上述函数的内部匿名函数的第 4、5 行代码中均引用了外部函数的变量 propertyName 。在这个内部函数被返回并在其他地方被使用后,它仍然引用着那个变量。这是因为内部函数的作用域链包含 createComparisonFun() 函数的作用域。要理解为什么会这样,可以想想第一次调用这个函数时会发生什么。

  注意: 以下内容读起来可能会有点吃力,请相信我,暂时的看不明白不要紧,因为很多知识是有联系的,看到后面就明白了。坚持住~

  理解作用域链创建和使用的细节对理解闭包非常重要。在调用一个函数时,会为这个函数调用创建一个执行上下文,并创建相应的作用域链。然后用 arguments 和其他命名参数来初始化这个函数的活动对象。外部函数的活动对象是内部函数作用域链上的第二个对象。这个作用域链一直向外串起了所有包含函数的活动对象,直到全局执行上下文才终止。

  先从一个简单的函数看起:

function compare(value1, value2) {
   if (value1 < value2) {
     	return -1;
   } else if (value1 > value2) {
		return 1;
   } else {
        return 0;
   } 
}
let result = compare(5, 10);

  当在这段代码执行前, JS 引擎会在内存中构建如下的情况:

  进入代码就会创建全局执行上下文,会对代码进行一系列的预处理,包括变量、函数的提升、登记等内容。这里注意,全局执行上下文的文本环境也就是登记变量、函数、类名的地方称之为全局变量对象,其实就是一块存储区域。在这块区域内又分成了全局对象区域和全局scope区,分别登记不同类型的变量名。这块不是很理解的建议去看看: JS中执行上下文与作用域【⭐️重点理解】 里面有更加详细讲解和配图。

  函数的定义会创建函数对象,上图中黄色区域,里面的 [[environment]] 记录了创建函数对象时的当前作用域链,在全局下创建的,即指向全局文本环境,即全局变量对象。同时函数名也被记录在全局对象上,并以创建的函数对象的地址赋值,let 声明的变量 result 登记在全局 scope 上,但没有初始化。let 是 ES6 中新的声明变量的方式,与 var 有一定的区别。可以参考:ES6中var let const三种变量(一文弄清楚)

  接下来执行代码第10行 result = compare(5, 10); 碰到函数调用,会创建该函数的执行上下文并会赋值创建函数对象时保存的作用域链。然后创建自己存储变量名,函数名的地方,称之为活动对象(这里的对象不是平时所研究的对象,只是一片区域,如下图小的蓝色框),将其插到作用域链的前端。所以在这个环境下通过作用域链既可以访问到自己的活动对象里的变量,也可以访问到全局变量对象的内容。

函数属性描述

  再来看看上面列举的创建根据属性值排序的函数。更复杂一点的情况:

function cCF(propName) {		// 有函数提升
  return function(object1, object2) {
    let value1 = object1[propName];
    let value2 = object2[propName];
    if (value1 < value2) {
      return -1;
    } else if (value1 > value2) {
      return 1;
    } else {
      return 0;
	} 
  };
}
let comp = cCF('name');				// 有 comp 变量提升
let result = comp({ name: 'a' }, { name: 'b' });	// 有 result 变量提升

  当执行代码前会是下面情况:

函数属性描述

  执行第14行 cCF() 函数调用,进入 cCF() 函数后会赋值创建函数定义时的作用域链,然后创建自己的活动对象用于保存自己变量,并插入作用域链前端。如下图。

函数属性描述

  接下来执行函数,是返回一个匿名函数的定义,碰到函数的定义会创建函数对象,并赋值当前的作用域链,保存与函数对象内。这时候这个函数对象的地址 xxx2 就被返回,cCF 函数执行结束,执行上下文退出栈,因为此时 cCF 函数的活动对象(小蓝色框)有被匿名函数引用,所以不销毁,这里就是形成闭包的关键。接下来就是将 cCF 函数返回的结果 xxx2 赋值给 comp 变量。运行结束后内存中结构图如下:

函数属性描述

  接下来进入第15行代码 result = comp({ name: 'a' }, { name: 'b' }); 调用 comp() 函数,会赋值该函数对象体内保存的作用域链,然后创建自己的活动对象,插入作用域前端。注意:下图中执行上下文栈顶应该是(comp函数执行上下文,不是全局执行上下文)画图的小错误~懒得改了 😂😂😂

函数属性描述

  如上,在当前执行上下文上,有三个作用域(蓝色框)通过作用域链链接,都是可以访问到的。在 comp 函数中是可以通过作用域链找到 cCF 函数的活动对象的,虽然 cCF 函数已经执行完了,但其活动对象还在作用域链上。于是就可以在 comp 函数中访问 cCF 活动对象中的 propName 变量。执行函数内部代码计算返回值为 -1返回,退出执行栈,并因为自己的 comp 函数活动对象没有被任何其内容引用,因此可以直接销毁。回复当前执行上下文中的指向。并将结果保存到 result 中。

函数属性描述

  相信如果有一定基础,看了上面这几个例子的详细分析,对闭包应该会有更加深刻的理解了。

10.14.1 this 对象

  在闭包中使用 this 会让代码变复杂。如果内部函数没有使用箭头函数(前面内容讲到过:箭头函数的 this 引用的是定义箭头函数的上下文对象,即箭头函数在定义时就确定了内部的 this 指向问题。)普通函数的 this 对象会在运行时绑定到执行函数的上下文。如果在全局函数中调用,则 this 在非严格模式下等于 window,在严格模式下等于 undefined。如果作为某个对象的方法调用,则 this 等于这个对象。匿名函数在这种情况下不会绑定到某个对象,这就意味着 this 会指向 window,除非在严格模式下 this 是 undefined。这就是让人头疼的 this 指向问题。由于闭包的写法所致,这个事实有时候更加没有那么容易看出来。如下代码:

window.identity = 'The Window';
let object = {
  identity: 'My Object',
  getIdentityFunc() {
    return function() {
      return this.identity;
	}; 
  }
};
console.log(object.getIdentityFunc()()); // 'The Window'

  这里 object 对象调用了自己的方法 getIdentityFunc() 所以在该方法内 this 是指向 object 的,但是该方法返回了一个函数,那么 object.getIdentityFunc() 就相当于一个函数,如下:

object.getIdentityFunc() == function () {return this.identity;}

  object.getIdentityFunc() 在这里就相当于一个函数名,假设记为 fun,那么 object.getIdentityFunc()() 就相当于执行了这个函数 fun()。fun() 的执行是直接调用的,那么它里面的 this 就指向 调用时的当前环境对象 window。

  记住:每个函数在调用时会自动的创建两个特殊变量:this 和 arguments。内部函数永远不可能直接访问外部函数的这两个变量。上述返回的闭包函数内部之所以不能访问到外部的 this,是因为它自己内部也有同名的 this。但是,如果把外部的 this 保存到闭包内部就可以通过传入的 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'

  以下是几种调用 object.getIdentity() 的方式及返回值:

object.getIdentity();                         // 'My Object'
(object.getIdentity)();                       // 'My Object'
(object.getIdentity = object.getIdentity)();  // 'The Window'

  **第一行:**调用 object.getIdentity() 是正常调用,会返回 “My Object” ,因为 this.identity 就是 object.identity。

  第二行: object.getIdentity 和 (object.getIdentity) 是相等的。虽然加了括号之后看起来是对一个函数的引用,但 this 值并没有变。

  第三行: 执行了一次赋值,然后再调用赋值后的结果。因为赋值表达式的值是函数本身,this 值不再与任何对象绑定,所以返回的是 “The Window” 。

10.14.2 内存泄漏

  IE 在 IE9 之前对 JScript 对象和 COM 对象使用了不同的垃圾回收机制(还没整理出来)。在这些版本的 IE 中,把 HTML 元素保存在某个闭包的作用域中,就相当于宣布该元素不能被销毁。来看下面的例子:

function assignHandler(){
	let element = document.getElementById("someElement");
    element.onclick = () => console.log(element.id);
}

  以上代码创建了一个闭包,即 element 元素的事件处理程序中又引用 assignHandler() 的活动对象,阻止了对 element 的引用计数归零。只要这个匿名函数存在,element 的引用计数就至少等于 1。也就是说,内存不会被回收。其实只要这个例子稍加修改,就可以避免这种情况,比如:

function assignHandler() {
  let element = document.getElementById('someElement');
  let id = element.id;
  element.onclick = () => console.log(id);
  element = null;
}

  闭包改为引用一个保存着 element.id 的变量 id,从而消除了循环引用。但这样还不足以解决内存问题。 因为闭包还是会引用包含函数的活动对象,而其中包含 element。即使闭包没有直接引用 element,包含函数的活动对象上还是保存着对它的引用。因此,必须再把 element设置为 null。这样就解除了对这个 COM 对象的引用,其引用计数也会减少,从而确保其内存可以在适当的时候被回收。

10.15、立即调用的函数表达式(IIFE)

  立即调用的匿名函数又被称作立即调用的函数表达式,形如下面代码。紧跟在第一组括号后面的第二组括号会立即调用前面的函数表达式。

(function() { // 块级作用域
})();

  使用 IIFE 可以模拟块级作用域,即在一个函数表达式内部声明变量,然后立即调用这个函数。ES5 不支持块级作用域,就可以使用 IIFE 模拟块级作用域。

// IIFE
(function () {
  for (var i = 0; i < count; i++) {
    console.log(i);
  } 
})();
console.log(i); 	// 抛出错误

  上述代码中的变量 i 是在 IIFE 内部定义的,在外部访问不到。IIFE 在 ES5.1 以前可以防止变量定义外泄。IIFE 也不会导致闭包产生的内存问题,因为不存在对这个匿名函数的引用。只要函数执行完毕,其作用域链就可以被销毁。

  ES6 之后有了块级作用域,立即调用的函数表达式就没有那么重要了。

  立即调用的函数表达式还有锁定参数值的功能:例如我们要在页面上点击哪个 div 就显示它的索引号:

<!-- html 代码 -->
<div>第一个div</div>
<div>第二个div</div>
<div>第三个div</div>
// javascript 代码
let divs = document.querySelectorAll("div");
for (var i = 0; i < divs.length; i++){
    divs[i].addEventListener("click",function(){
        console.log(i);;
    });
}

  这里使用 var 关键字声明了循环迭代变量 i,但这个变量并不会被限制在 for 循环的块级作用域内。因此,渲染到页面上之后,点击每个 <div> 都会弹出元素总数。这是因为在执行单击处理程序时,迭代变量的值是循环结束时的最终值,即元素的个数。而且,这个变量 i 存在于循环体外部,随时可以访问。

  ES6 以前,为了实现点击第几个 <div> 就显示相应的索引值,需要借助 IIFE 来执行一个函数表达式,传入每次循环的当前索引,从而 “锁定” 点击时应该显示的索引值:

let divs = document.querySelectorAll("div");
for (var i = 0; i < divs.length; i++){
    divs[i].addEventListener("click",(function (a){
        return function() {
            console.log(a);
        }
    })(i));
}

  ES6 之后就可以直接使用let 关键字声明块级作用域的变量来解决这个问题:

let divs = document.querySelectorAll('div');
for (let i = 0; i < divs.length; ++i) {
  divs[i].addEventListener('click', function() {
    console.log(i);
  });
}

  因为在 ES6 中,如果对 for 循环使用块级作用域变量关键字,在这里就是 let,那么循环就会为每个循环创建独立的变量,从而让每个单击处理程序都能引用特定的索引。如果把变量声明拿到 for 循环外部,那就不行了。

10.16、私有变量

  严格来讲,JavaScript 没有私有成员的概念,所有对象属性都是公有的。但是有私有变量这个概念。

  私有变量: 任何定义在函数或块中的变量,都可以认为是私有的,因为在这个函数或块的外部无法访问其中的变量。(包括函数参数局部变量以及函数内部定义的其他函数)。

function add (num1,num2){
    let sum = num1+num2;
    return sum;
}

  上述函数 add() 有3个私有变量:num1、num2 和 sum。它们只能在函数内部使用,不能在函数外部访问。如果这个函数中创建了一个闭包(就是一个函数),则这个闭包能通过其作用域链访问其外部的这 3 个变量。基于这一点,就可以创建出能够访问私有变量的公有方法。

  • this.publicMethod:特权方法

  特权方法(privileged method)是能够访问函数私有变量(及私有函数)的公有方法。在对象上有两种方式创建特权方法。特权方法就是作为函数对象的属性方法。

(1) 在构造函数中实现

function MyObject() {
	let privateVariable = 10;			// 私有变量
	function privateFunction() {		// 私有函数
    	return false;
	}
	this.publicMethod = function() {	// 特权方法
    	privateVariable++;
    	return privateFunction();
  	};
}

  定义在构造函数中的特权方法其实是一个闭包(因为它引用了外部函数内的变量),它具有访问构造函数中定义的所有变量和函数的能力。

  在 MyObject 函数外部,变量 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("jack");
console.log(person.getName());  // jack
person.setName("ancy");
console.log(person.getName());  // ancy

  这段代码中的构造函数定义了两个特权(属性)方法:getName() 和 setName()。 每个方法都可以在构造函数外部通过实例对象或 Person 构造函数调用,并通过它们来读写私有的 name 变量。因为两个方法都定义在构造函数内部,所以它们都是能够通过作用域链访问 name 的闭包。私有变量 name 对每个 Person 实例而言都是独一无二的,因为每次调用构造函数都会重新创建一套变量和方法。不过这样也有个问题:必须通过构造函数来实现这种隔离。正如第 8 章所讨论过的,构造函数模式的缺点 是每个实例都会重新创建一遍新方法。使用静态私有变量实现特权方法可以避免这个问题。

10.16.1 静态私有变量

  特权方法也可以通过使用私有作用域定义私有变量和函数来实现。这个模式如下所示:

(function(){
	let privateVariable = 10;
	function privateFunction(){
		return false;
	}
	MyObject = function(){};	// 定义一个私有函数
	MyObject.prototype.publicMethod = function(){	// 在私有函数的原型上添加特权方法
		privateVariable++;
		return privateFunction();
	};
})();

  在这个模式中,匿名函数表达式中定义的是私有变量和私有函数,然后又定义了构造函数和公有方法。公有方法定义在构造函数的原型上,与典型的原型模式一样。注意,这个模式定义的构造函数没有使用函数声明,使用的是函数表达式。函数声明会创建内部函数,在这里并不是必需的。基于同样的原因(但操作相反),这里声明 MyObject 并没有使用任何关键字。因为不使用关键字声明的变量会创建在全局作用域中,所以 MyObject 变成了全局变量,可以在这个私有作用域外部被访问。注意在严格模式下给未声明的变量赋值会导致错误。

  这个模式与前一个模式的主要区别就是,私有变量和私有函数是由实例共享的。因为特权方法定义在原型上,所以同样是由实例共享的。特权方法作为一个闭包,始终引用着包含它的作用域。来看下面的例子:

(function() {
  let name = '';
  Person = function(value) {		// 构造函数,没有用关键字声明,会成为全局构造函数
    name = value;
  };
  Person.prototype.getName = function() {	// 特权方法 getName 写在构造函数的原型对象上
    return name;
  };
  Person.prototype.setName = function(value) {	// 特权方法 setName 写在构造函数的原型对象上
    name = value;
  }; 
})();
let person1 = new Person('Nicholas');
console.log(person1.getName());  // 'Nicholas'
person1.setName('Matt');
console.log(person1.getName());  // 'Matt'
let person2 = new Person('Michael');
console.log(person1.getName());  // 'Michael'
console.log(person2.getName());  // 'Michael'

  这里的 Person 构造函数可以访问私有变量 name,跟 getName() 和 setName() 方法一样。使用这种模式,name 变成了静态变量,可供所有实例使用。这意味着在任何实例上调用setName() 修改这个变量都会影响其他实例。调用 setName() 或创建新的 Person 实例都要把name 变量设置为一个新值。而所有实例都会返回相同的值。

  像这样创建静态私有变量可以利用原型更好地重用代码,只是每个实例没有了自己的私有变量。最终,到底是把私有变量放在实例中,还是作为静态私有变量,都需要根据自己的需求来确定。
  注意:使用闭包和私有变量会导致作用域链变长,作用域链越长,则查找变量所需的时间也越多。

10.16.2 模块模式

  前面的模式通过自定义类型创建了私有变量和特权方法

  模块模式:在一个单例对象上实现了相同的隔离和封装。

  单例对象(singleton):就是只有一个实例的对象。JS 是通过对象字面量来创建单例对象的。

let singleton = {
	name: value,
	method(){
		// 方法的代码
	}
}

  模块模式是在单例对象基础上加以扩展,使其通过作用域链来关联私有变量和特权方法。例如:

let singleton = function(){
	let privateVariable = 10;		// 私有变量
	function privateFunction(){		// 私有函数
		return false;
	} 
	return {					// 特权/公有方法和属性
		publicProperty: true,
		publicMethod(){
			privateVariable++;
			return privateFunction();
		}
	};
}();

  上述的模块模式使用立即执行的匿名函数返回一个对象,在匿名函数内部,首先定义私有变量和私有函数。之后,创建一个要通过匿名函数返回的对象字面量。这个对象字面量中只包含可以公开访问的属性和方法。因为这个对象定义在匿名函数内部,所以它的所有公有方法都可以访问同一个作用域的私有变量和私有函数。本质上,对象字面量定义了单例对象的公共接口。如果单例对象需要进行某种初始化,并且需要访问私有变量时,那就可以采用这个模式:

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);
			} 
		};
    }
}();

  上面这个简单的例子创建了一个 application 对象,假设它用于管理组件。在创建这个对象之后,内部就创建一个私有的数组 components,然后将一个 BaseComponent 组件的新实例添加到数组中。(BaseComponent组件的代码并不重要,在这里用它只是为了说明模块模式的用法)。返回的对象字面量中定义的 getComponentCount() 和 registerComponent() 方法都是可以访问 components 私有数组的特权方法。前一个方法返回注册组件的数量,后一个方法负责注册新组件。

  在模块模式中,单例对象作为一个模块,经过初始化可以包含某些私有的数据,而这些数据又可以通过其暴露的公共方法来访问。以这种方式创建的每个单例对象都是 Object 的实例,因为最终单例都由一个对象字面量来表示。不过这无关紧要,因为单例对象通常是可以全局访问的, 而不是作为参数传给函数的,所以可以避免使用 instanceof 操作符确定参数是不是对象类型的需求。

10.16.3 模块增强模式

  另一个利用模块模式的做法是在返回对象之前先对其进行增强。这适合单例对象需要是某个特定类型的实例,但又必须给它添加额外属性或方法的场景。

let singleton = function(){
	let privateVariable = 10;		// 私有变量
	function privateFunction(){		// 私有函数
		return false;
	}
	let object = new CustomType();	// 创建 CustomType 类型的对象
	object.publicProperty = true;	// 在对象上添加共有属性
	object.publicMethod = function(){	// 在对象上添加共有方法
		privateVariable++;
		return privateFunction();
	};
	return object;
}();

  这样在返回对象时,返回的对象有自己的类型,并且也添加了需要的属性和方法用于访问私有变量和函数。

10.17、小结

  函数是 JavaScript 编程中最有用也最通用的工具。ECMAScript6 新增了更加强大的语法特性,从而让开发者可以更有效地使用函数。这里再简要的总结函数这一章节的重要知识点:

  • 函数表达式与函数声明是不一样的。函数声明要求写出函数名称, 而函数表达式并不需要。没有名称的函数表达式也被称为匿名函数。函数声明有提升,函数表达式没有。
  • ES6 新增了类似于函数表达式的箭头函数语法,但两者也有一些重要区别。
  • JavaScript 函数定义与调用时的参数极其灵活。arguments 对象, 以及 ES6 新增的扩展操作符,可以实现函数定义和调用的完全动态化。
  • 函数内部也暴露了很多对象和引用,涵盖了函数被谁调用(callee)、使用什么调用(caller),以及调用时传入了什么参数等信息。
  • JavaScript引擎可以优化符合尾调用条件的函数,以节省栈空间。
  • 闭包的作用域链中包含自己的一个变量对象,然后是包含函数的变量对象,直到全局上下文的变量对象。
  • 通常,函数作用域及其中的所有变量在函数执行完毕后都会被销毁。
  • 闭包在被函数返回之后,其作用域会一直保存在内存中,直到闭包被销毁。
  • 函数可以在创建之后立即调用,执行其中代码之后却不留下对函数的引用。
  • 立即调用的函数表达式如果不在包含作用域中将返回值赋给一个变 量,则其包含的所有变量都会被销毁。
  • 虽然 JavaScript 没有私有对象属性的概念,但可以使用闭包实现公共方法,访问位于包含作用域中定义的变量。
  • 可以访问私有变量的公共方法叫作特权方法。
  • 特权方法可以使用构造函数或原型模式通过自定义类型中实现,也可以使用模块模式或模块增强模式在单例对象上实现。
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

ItDaChuang

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值