前端学习记录~2023.7.26~JavaScript重难点实例精讲~第3章 函数

第2章 引用数据类型

  • 前言
  • 3.1 函数的定义与调用
    • 3.3.1 函数的定义
      • (1)函数声明
      • (2)函数表达式
      • (3)Function()构造函数
      • (4)函数表达式的应用场景
      • (5)函数声明与函数表达式的区别
        • a. 函数名称
        • b. 函数提升
    • 3.1.2 函数的调用
      • (1)函数调用模式
      • (2)方法调用模式
      • (3)构造器调用模式
      • (4)call()函数、apply()函数调用模式
      • (5)匿名函数调用模式
    • 3.1.3 自执行函数
  • 3.2 函数参数
    • 3.2.1 形参和实参
    • 3.2.2 arguments对象的性质
      • (1)函数外部无法访问
      • (2)可通过索引访问
      • (3)由实参决定
      • (4)特殊的arguments.callee属性
    • 3.2.3 arguments对象的应用
      • (1)实参的个数判断
      • (2)任意个数的参数处理
      • (3)模拟函数重载
  • 3.3 构造函数
  • 3.4 变量提升与函数提升
    • 3.4.1 作用域
    • 3.4.2 变量提升
    • 3.4.3 函数提升
  • 3.5 闭包
    • 3.5.1 执行上下文环境
    • 3.5.2 闭包的概念
    • 3.5.3 闭包的用途
      • (1)结果缓存
      • (2)封装
      • (3)一些和闭包相关的例题
        • a. ul中有若干个li,每次单击li,输出li的索引值
        • b. 定时器问题
        • c. 作用域链问题
        • d. 多个相同函数名问题
    • 3.5.4 小结
      • (1)闭包的优点
      • (2)闭包的缺点
  • 3.6 this使用详解
    • (1)this指向全局对象
    • (2)this指向所属对象
    • (3)this指向对象实例
    • (4)this指向call()函数、apply()函数、bind()函数调用后重新绑定的对象
    • (5)闭包中的this
    • 额外的帮助理解this的小题
  • 3.7 call()函数、apply()函数、bind()函数的使用与区别
    • 3.7.1 call()函数的基本使用
    • 3.7.2 apply()函数的基本使用
    • 3.7.3 bind()函数的基本使用
    • 3.7.4 call()函数、apply()函数、bind()函数的比较
    • 3.7.5 call()函数、apply()函数、bind()函数的巧妙用法
      • (1)求数组中的最大项和最小项
      • (2)类数组对象转换为数组对象
      • (3)用于继承
      • (4)执行匿名函数
      • (5)bind()函数配合setTimeout


前言

本章是第三章函数相关的内容。函数包括了作用域、原型链、闭包等核心知识点,非常关键。

在学完后,希望掌握下面知识点:

  • 函数的定义与调用
  • 函数参数
  • 构造函数
  • 变量提升与函数提升
  • 闭包
  • this使用详解
  • call()函数、apply()函数、bind()函数的使用与区别

3.1 函数的定义与调用

在JavaScript中,函数实际也是一种对象,每个函数都是Function类型的实例,能够定义不同类型的属性方法

函数的定义大致可以分为 3 种:

  • 函数声明
  • 函数表达式
  • Function 构造函数

3.3.1 函数的定义

(1)函数声明

function关键字+函数名+形参+函数体

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

(2)函数表达式

函数表达式的形式类似于普通变量的初始化,只不过这个变量初始化的值是一个函数:

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

这个函数表达式没有名称,属于匿名函数表达式

但是也可以像上面函数声明一样定义一个函数名,但是这样它只是函数内部的一个局部变量,在函数外部无法直接调用:

var sum = function foo(num1, num2) { 
	return num1 + num2;
};
console.log(foo(1,3));	//ReferenceError: foo is not defined

如果想正常还是需要使用前面的sum

(3)Function()构造函数

使用new操作符,调用Function()构造函数,传入对应的参数:

var add = new Function("a","b","return a + b");

只有最后一个参数是执行的函数体,其他都是函数的形参。

一般用的少,主要有下面两个缺点:

  • 每次执行该构造函数都会创建一个新函数对象,因此需要频繁执行时效率很低
  • 使用Function()构造函数创建的函数并不遵循典型的作用域,它将会一直作为顶级函数执行。所以在一个函数A内部调用Function()构造函数时,其中的函数体并不能访问到函数A中的局部变量,而只能访问到全局变量

对于第二点,可以参考下面代码:

var y = 'global'; // 全局环境定义的y值 
function constructFunction() { 
	var y = 'local'; // 局部环境定义的y值 
	return new Function('return y'); // 无法获取局部环境定义的值
}
console.log(constructFunction()()); // 输出'global'

(4)函数表达式的应用场景

  • 函数递归
  • 代码模块化
  • 等等

(5)函数声明与函数表达式的区别

JavaScript解释器在处理两者时有一定区别。

a. 函数名称

  • 使用函数声明时,必须设置函数名称,这个函数名称相当于一个变量,后面函数调用就会通过这个变量进行
  • 使用函数表达式时,函数名称可选,可以定义一个匿名函数表达式,并赋给一个变量,然后通过这个变量进行函数的调用

b. 函数提升

  • 对于函数声明,存在函数提升,所以即使函数的调用在函数的声明之前,仍然可以正常执行
  • 对于函数表达式,不存在函数提升,函数在定义之前不能对其进行调用,否则会抛出异常

3.1.2 函数的调用

函数的调用大致分为 5 种模式:

  • 函数调用模式
  • 方法调用模式
  • 构造器调用模式
  • call()函数、 apply()函数调用模式
  • 匿名函数调用模式

(1)函数调用模式

通过函数声明或者函数表达式的方式定义函数,然后直接通过函数名调用的模式

// 函数声明 
function add(a1, a2) { 
	return a1 + a2;
} 
// 函数表达式 
var sub = function (a1, a2) { 
	return a1 - a2;
};
add(1, 3);
sub(4, 1);

(2)方法调用模式

优先定义一个对象obj,然后在对象内部定义值为函数的属性property,通过对象obj.property()来进行函数的调用

//定义对象
var obj = {
	name:"kingx",
	//定义getName属性,值为一个函数
	getName:function(){
		return this.name;
	}
};
obj.getName();	//通过对象进行调用

函数还可以通过中括号来调用,即 对象名['函数名'],那么上面的实例代码,我们还可以改写成如下代码

obj["getName"]();

如果在某个方法中返回的是函数对象本身this,那么可以利用链式调用原理进行连续的函数调用

var obj2 = { 
	name: 'kingx', 
	getName: function () { 
		console.log(this.name);
	}, 
	setName: function (name) { 
		this.name = name; 
		return this; // 在函数内部返回函数对象本身
	}
};
obj2.setName('kingx2').getName(); // 链式函数调用

(3)构造器调用模式

定义一个函数,在函数中定义实例属性,在原型上定义函数,然后通过new操作符生成函数的实例,再通过实例调用原型上定义的函数。

// 定义函数对象
function Person(name) {
	this.name = name
}
// 原型上定义函数
Person.prototype.getName = function(){
	return this.name;
};
//通过new操作符生成实例
var p = new Person("kingx");
//通过实例进行函数的调用
p.getName();

(4)call()函数、apply()函数调用模式

通过call()函数或者apply()函数可以改变函数执行的主体,使得某些不具有特定函数的对象可以直接调用该特定函数。

// 定义一个函数 
function sum(num1, num2) { 
	return num1 + num2;
}
// 定义一个对象 
var person = {}; 
// 通过call()函数与apply()函数调用sum()函数 
sum.call(person, 1, 2);
sum.apply(person, [1, 2]);

通过call()函数与apply()函数,使得没有sum()函数的person对象也可以直接调用 sum()函数。这两个函数具体相关的内容会在本章后面的部分涉及到。

(5)匿名函数调用模式

匿名函数,顾名思义就是没有函数名称的函数。

匿名函数的调用有 2 种方式:

  • 一种是通过函数表达式定义函数,并赋给变量,通过变量进行调用
  • 另一种是使用小括号()将匿名函数括起来,然后在后面使用小括号(),传递对应的参数,进行调用
// 方式一
// 通过函数表达式定义匿名函数,并赋给变量sum
var sum = function(num1, num2){ 
	return num1 + num2;
}; 
// 通过sum()函数进行匿名函数调用
sum(1, 2);

// 方式二
(function (num1, num2) { 
	return num1 + num2;
})(1, 2); // 3

上述方式中,使用小括号括住的函数声明实际上是一个函数表达式,紧随其后的小括号表示会立即调用这个函数

函数必须要用小括号括起来,不然会出问题。

3.1.3 自执行函数

自执行函数即函数定义函数调用的行为先后连续产生。它需要以一个函数表达式的身份进行函数调用,上面的匿名函数调用也属于自执行函数的一种。

下面是自执行函数的多种表现形式:

function (x) { 
	alert(x);
}(5); // 抛出异常,Uncaught SyntaxError: Unexpected token 

var aa = function(x) {
	console.log(x);
}(1);	// 1

true && function(x){
	console.log(x);
}(2);	// 2

0, function(x) {
	console.log(x);
}(3);	// 3

!function(x) {
	console.log(x);
}(4);	// 4

~function(x) {
	console.log(5);
}(5);	// 5

-function(x) {
	console.log(x);
}(6);	// 6

+function(x) {
	console.log(x);
}(7);	// 7

new function(x) {
	console.log(x);
}(8);	// 8

new function() {
	console.log(9);	// 9
}

3.2 函数参数

3.2.1 形参和实参

  • 形参(形式参数):在定义函数名称与函数体时使用 的参数,目的是用来接收调用该函数时传入的参数
  • 实参(实际参数):在调用时传递给函数的参数,实 参可以是常量、变量、表达式、函数等类型

形参和实参区别:

  1. 形参出现在函数的定义中,只能在函数体内使用,一旦离开该函数则不能使 用;实参出现在主调函数中,进入被调函数后,实参也将不能被访问
  2. 在强类型语言中,定义的形参和实参在数量、数据类型和顺序上要保持严格一 致,否则会抛出“类型不匹配”的异常
  3. 在函数调用过程中,数据传输是单向的,即只能把实参的值传递给形参,而不 能把形参的值反向传递给实参。因此在函数执行时,形参的值可能会发生变化,但不会影响到实参中的值
  4. 当实参是基本数据类型的值时,实际是将实参的值复制一份传递给形参,在函 数运行结束时形参被释放,而实参中的值不会变化。当实参是引用类型的值时,实际是将实参的内存地址传递给形参,即实参和形参都指向相同的内存地址,此时形参可以修改实参的值,但是不能修改实参的内存地址

JavaScript是弱类型语言,函数参数在上述规则外还有一些特性:

  • 函数可以不用定义形参,可以在函数体中通过arguments对象获取传递的实参并进行处理
  • 在函数定义了形参的情况下,传递的实参与形参的个数并不需要相同,实参与形参会从前到后匹配,未匹配到的形参被当作undefined处理
  • 实参并不需要与形参的数据类型一致,因为形参的数据类型只有在执行期间才能确定,并且还存在隐式数据类型的转换

3.2.2 arguments对象的性质

arguments对象是所有函数都具有的一个内置局部变量,表示的是函数实际接收的参数,是一个类数组结构(除了具有length属性外,不 具有数组的一些常用方法)

(1)函数外部无法访问

(2)可通过索引访问

arguments对象是一个类数组结构,可以通过索引访问,每一项表示对应传递的实参值,如果该项索引值不存在,则会返回undefined

function sum(num1, num2) { 
	console.log(arguments[0]); // 3 
	console.log(arguments[1]); // 4
	console.log(arguments[2]); // undefined
} 
sum(3, 4);

(3)由实参决定

arguments对象的值由实参决定,而不是由定义的形参决定,形参与arguments对象占用独立的内存空间。

arguments对象与形参之间的关系:

  • arguments对象的length属性在函数调用的时候就已经确定,不会随着函数的处理而改变
  • 指定的形参在传递实参的情况下,arguments对象与形参值相同,并且可以相互改变
  • 指定的形参在未传递实参的情况下,arguments对象对应索引值返回undefined
  • 指定的形参在未传递实参的情况下,arguments对象与形参值不能相互改变
function foo(a, b, c) { 
	console.log(arguments.length); // 2
	
	arguments[0] = 11; 
	console.log(a); // 11
	
	b = 12; 
	console.log(arguments[1]); // 12
	
	arguments[2] = 3; 
	console.log(c); // undefined
	
	c = 13; 
	console.log(arguments[2]); // undefined
	
	console.log(arguments.length); // 2
}
foo(1, 2);

(4)特殊的arguments.callee属性

arguments对象有一个很特殊的属性callee,表示的是当前正在执行的函数,在比较时是严格相等的。

在匿名函数的递归中非常有用,因为匿名函数没有名称,所以只能通过arguments.callee属性来获取函数自身,同时传递参数进行函数调用

function create() { 
	return function (n) {
		if (n <= 1) 
			return 1;
		return n * arguments.callee(n - 1); 
	}
}
var result = creat()(5);	// 120 (5*4*3*2*1)

但并不推荐广泛使用,因为这个属性会改变函数内部的 this 值

所以如果需要在函数内部进行递归调用,推荐使用函数声明或者使用函数表达式,给函数一个明确的函数名

3.2.3 arguments对象的应用

(1)实参的个数判断

比如定义函数时,明确要求在调用时只能传递正好3个参数,否则都抛出异常

function f(x, y, z) {
	// 检查传递的参数个数是否正确 
	if (arguments.length !== 3) {
		throw new Error("期望传递的参数个数为3,实际传递个数为" + arguments.length); 
	}
	// ...do something 
}
f(1, 2); // Uncaught Error: 期望传递的参数个数为3,实际传递个数为2

(2)任意个数的参数处理

定义一个函数,该函数只会特定处理传递的前几个参数,对于后面的参数不论传递多少个都会统一处理,这种场景下我们可以使用arguments对象。

例如,定义一个函数,需要将多个字符串使用分隔符相连,并返回一个结果字符串。 此时第一个参数表示的是分隔符,而后面的所有参数表示待相连的字符串,我们并不关心后面待连接的字符串有多少个,通过arguments对象统一处理即可。

function joinStr(seperator) {
	// arguments对象是一个类数组结构,可以通过call()函数间接调用slice()函数,得到一个数组 var 
	strArr = Array.prototype.slice.call(arguments, 1);
	// strArr数组直接调用join()函数
	return strArr.join(seperator);
}
joinStr('-', 'orange', 'apple', 'banana'); // orange-apple-banana
joinStr(',', 'orange', 'apple', 'banana'); // orange,apple,banana

(3)模拟函数重载

函数重载表示的是在函数名相同的情况下,通过函数形参不同参数类型或者不同参数个数来定义不同的函数。

JavaScript中是没有函数重载的,原因是:

  • JavaScript是一门弱类型的语言,变量只有在使用时才能确定数据类型,通过形参是无法确定数据类型的
  • 无法通过函数的参数个数来指定调用不同的函数,函数的参数个数是在函数调用时才确定下来的
  • 用函数声明定义的具有相同名称的函数,后者会覆盖前者

通过模拟函数重载可以完成类似任务,比如想写出一个通用函数,来实现任意个数字的加法运算求和:

// 通用求和函数 
function sum() {
	// 通过call()函数间接调用数组的slice()函数得到函数参数的数组 
	var arr = Array.prototype.slice.call(arguments);
	// 调用数组的reduce()函数进行多个值的求和
	return arr.reduce(function (pre, cur) {
		return pre + cur; 
	}, 0)
}
sum(1, 2); // 3 
sum(1, 2, 3); // 6 
sum(1, 2, 3, 4); // 10

3.3 构造函数

当我们创建对象的实例时,通常会使用到构造函数,例如对象和数组的实例化可以通过相应的构造函数Object()和Array()完成。

构造函数与普通函数在语法的定义上没有任何区别,主要的区别体现在以下3点:

  1. 构造函数的函数名的第一个字母通常会大写
  2. 在函数体内部使用this关键字,表示要生成的对象实例,构造函数并不会显式地返回任何值,而是默认返回“this”
  3. 作为构造函数调用时,必须与new操作符配合使用。一个函数在当作构造函数使用时,能通过new操作符创建对象的实例,并通过实例调用对应的函数
// 对于第 2 条
function Person(name) { 
	this.name = name;
}
var p = new Person('kingx'); 
console.log(p); // Person {name: "kingx"}

//对于第 3 条
function Person(name, age) {
	this.name = name;
	this.age = age; 
	this.sayName = function () {
		alert(this.name); 
	};
}
var person = new Person('kingx', '12');
person.sayName(); // 'kingx'

一个函数在当作普通函数使用时,函数内部的this会指向window

Person('kingx', '12'); 
window.sayName(); // 'kingx'

使用构造函数可以在任何时候创建我们想要的对象实例,构造函数在执行时会执行以下4步:

  1. 通过new操作符创建一个新的对象,在内存中创建一个新的地址
  2. 为构造函数中的this确定指向
  3. 执行构造函数代码,为实例添加属性
  4. 返回这个新创建的对象

使用构造函数的问题在于,每创建一个新的实例,都会新增一个属性,例如上面的sayName(),而且不同实例中的该属性并不相同。而事实上当我们在创建对象的实例时,对于相同的函数并不需要重复创建,而且由于this的存在,总是可以在实例中访问到它具有的属性。

更好的解决办法就是通过原型,这个会在第 4 章涉及。


3.4 变量提升与函数提升

JavaScript中会出现变量在定义之前就可以被访问到而不会抛出异常,以及函数在定义之前就可以被调用而不会抛出异常。

这是由于JavaScript存在变量提升和函数提升机制。

3.4.1 作用域

作用域:一个变量的定义与调用所在的固定的范围

作用域可以分为:

  1. 全局作用域
  2. 函数作用域
  3. 块级作用域(ES6 新增,需使用 let 或 const)

3.4.2 变量提升

变量提升:在函数作用域中,会出现的现象。将变量的声明提升到函数顶部的位置,而变量的赋值不会被提升

会产生提升的变量必须是通过var关键字定义的,而不通过var关键字定义的全局变量是不会产生变量提升的。

比如下面的例子:

var v = 'Hello World'; 
(function () {
	console.log(v);
	var v = 'Hello JavaScript'; 
})();	// undefined

这是因为出现了变量提升,在函数内部,变量 v 的定义会提升到函数顶部,而赋值并不会提升,这样实际执行的代码其实是如下所示:

var v = 'Hello World';
(function () {
	var v; // 变量的声明得到提升
	console.log(v);
	v = 'Hello JavaScript'; // 变量的赋值并未提升
})();

在window上定义了一个变量v,赋值为Hello World,而且在立即执行函数的内部同样定义了一个变量v,但是赋值语句并未提升,因此v为undefined。在输出时,会优先在函数内部作用域中寻找变量,而变量已经在内部作用域中定义,因此直接输出“undefined”。

3.4.3 函数提升

除了通过var定义的变量会出现提升,使用函数声明方式定义的函数也会出现提升

例如:

// 函数提升 
foo(); // 我来自 foo 
function foo() {
	console.log("我来自 foo"); 
}

在上面的代码中,foo()函数的声明在调用之后,但是却可以调用成功,因为foo()函数被提升至作用域顶部。

需要注意的是函数提升会将整个函数体一起进行提升,包括里面的执行逻辑

对于函数表达式,是不会进行函数提升的。

下面的例子展示了同时使用函数声明和函数表达式的情况:

show(); // 你好 
var show;

// 函数声明,会被提升 
function show() {
	console.log('你好'); 
}
              
// 函数表达式,不会被提升 
show = function () {
	console.log('hello'); 
};

由于函数声明会被提升,因此最后输出的结果为“你好”


3.5 闭包

一般定义一个函数就会产生一个函数作用域,在函数体中的局部变量会在这个函数作用域中使用,一旦函数执行完成,函数所占空间就会被回收,存在于函数体中的局部变量同样会被回收,回收后将不能被访问到。但是闭包可以实现在函数执行完成后,函数中的局部变量仍然可以被访问到。

3.5.1 执行上下文环境

每段代码的执行都会存在于一个执行上下文环境中,每个执行上下文环境又都会存在于整体的执行上下文环境中。根据栈先进后出的特点,全局环境产生的执行上下文环境会最先压入栈中,存在于栈底。当新的函数进行调用时,会产生的新的执行上下文环境,也会压入栈中。当函数调用完成后,这个上下文环境及其中的数据都会被销毁,并弹出栈,从而进入之前的执行上下文环境中。

需要注意的是,处于活跃状态的执行上下文环境只能同时有一个。

1 var a = 10; // 1.进入全局执行上下文环境 
2 var fn = function (x) {
3		var c = 10;
4		console.log(c + x);
5};
6 var bar = function(y) {
7		var b = 5;
8		fn(y + b);	// 3.进入fn()函数执行上下文环境 
9 };
10 bar(20); // 2.进入bar()函数执行上下文环境

像上面这种代码执行完毕,执行上下文环境就会被销毁的场景,是一种比较理想的情
况。

有另外一种情况,虽然代码执行完毕,但执行上下文环境却被无法干净地销毁,这就
闭包

3.5.2 闭包的概念

闭包:一个拥有许多变量和绑定了这些变量执行上下文环境的表达式,通常是一个函数。

闭包有 2 个很明显的特点:

  1. 函数拥有的外部变量的引用,在函数返回时,该变量仍然处于活跃状态
  2. 闭包作为一个函数返回时,其执行上下文环境不会被销毁,仍处于执行上下文环境中

在JavaScript中存在一种内部函数,即函数声明和函数表达式可以位于另一个函数的函数体内,在内部函数中可以访问外部函数声明的变量,当这个内部函数在包含它们的外部函数之外被调用时,就会形成闭包。

例如

1 function fn() {
2 	var max = 10;
3	return function bar(x){
4		if (x > max) { 
5			console.log(x);
6		}
7	};
8 }
9 var f1 = fn();
10 f1(11); // 11
  • 代码开始执行后,生成全局上下文环境,并将其压入栈中
  • 代码执行到第9行时,进入fn()函数中,生成fn()函数执行上下文环境,并将其压入栈中
  • fn()函数返回一个bar()函数,并将其赋给变量f1
  • 当代码执行到第10行时,调用f1()函数,注意此时是一个关键的节点,因为f1()函数中包含了对max变量的引用,而max变量是存在于外部函数fn()中的,此时fn()函数执行上下文环境并不会被直接销毁,依然存在于执行上下文环境中
  • 等到第10行代码执行结束后,bar()函数执行完毕,bar()函数执行上下文环境才会被销毁,同时因为max变量引用会被释放,fn()函数执行上下文环境也一同被销毁
  • 最后全局上下文环境执行完毕,栈被清空,流程执行结束

从上面可以看出闭包所存在的最大的一个问题就是消耗内存,如果闭包使用越来越多,内存消耗将越来越大。

3.5.3 闭包的用途

(1)结果缓存

闭包不会释放外部变量的引用,所以能将外部变量值缓存在内存中。

所以在下面的场景中应用闭包:对于一个处理很耗时的函数对象,为了避免每次都非常耗时地调用,可以将其结果缓存起来,这样如果内存中有就直接返回,没有的话再调用函数进行计算,更新缓存并返回结果。

var cachedBox = (function () { 
	// 缓存的容器
	var cache = {};
	return {
		searchBox: function (id) {
			// 如果在内存中,则直接返回 
			if(id in cache) {
				return '查找的结果为:' + cache[id]; 
			}
			// 经过一段很耗时的dealFn()函数处理 
			var result = dealFn(id);
			// 更新缓存的结果
			cache[id] = result;
			// 返回计算的结果
			return '查找的结果为:' + result; 
		}
	}; 
})();
// 处理很耗时的函数 
function dealFn(id) {
	console.log('这是一段很耗时的操作');
	return id; 
}
// 两次调用searchBox()函数 
console.log(cachedBox.searchBox(1));	//这是一段很耗时的操作 查找的结果为:1
console.log(cachedBox.searchBox(1));	//查找的结果为:1

而第二次执行searchBox(1)函数时,由于第一次已经将结果更新到cache对象中,并且该对象引用并未被回收,因此会直接从内存的cache对象中读取

(2)封装

在JavaScript中提倡的模块化思想是希望将具有一定特征的属性封装到一起,只需要对外暴露对应的函数,并不关心内部逻辑的实现。

比如借助数组实现一个栈,只对外暴露出表示入栈和出栈的push()函数和 pop()函数,以及表示栈长度的size()函数

var stack = (function () { 
	// 使用数组模仿栈的实现 
	var arr = [];
	// 栈
	return {
		push: function (value) {
			arr.push(value); 
		},
        pop: function () { 
        	return arr.pop();
		},
		size: function () {
			return arr.length; 
		}
	};
})();
stack.push('abc'); 
stack.push('def'); 
console.log(stack.size()); // 2 
stack.pop(); 
console.log(stack.size()); // 1

上面的代码中存在一个立即执行函数,在函数内部会产生一个执行上下文环境,最后返回一个表示栈的对象并赋给stack变量。在匿名函数执行完毕后,其执行上下文环境并不会被销毁,因为在对象的push()、pop()、size()等函数中包含了对arr变量的引用,arr 变量会继续存在于内存中,所以后面几次对stack变量的操作会使stack变量的长度产生变化。

(3)一些和闭包相关的例题

这一部分的感受就是了解后能更加理解闭包,感觉很有必要好好看下来帮助翻过三座大山中的一座。

a. ul中有若干个li,每次单击li,输出li的索引值

比较容易想到的就是下面的代码

var lis = document.getElementsByTagName('ul')[0].children;
for (var i = 0; i < lis.length; i++) { 
	lis[i].onclick = function () {
		console.log(i); 
	};
}

目标是依次输出索引值0,1,2,3,4(假如有五个列表项),实际上上面代码会输出5个5。

这里涉及到了同步异步任务的问题。

原因是:执行顺序从上到下,遇到for循环,它是同步任务先执行,遇到绑定点击事件也是同步任务对其一一进行绑定。再执行遇到function函数,这是一个异步任务,当点击时将会进入队列,等待同步任务执行完毕再执行,因此同步任务执行完毕也就是for循环完成,此时的i=5,再执行function函数体内的console.log()打印都是i=5。

此处参考了这篇博客

通过闭包就能解决这样的问题:

var lis = document.getElementsByTagName('ul')[0].children; 
for (var i = 0; i < lis.length; i++) {
	(function (index) { 
		lis[index].onclick = function () {
			console.log(index); 
		};
	})(i); 
}

在每一轮的for循环中,我们将索引值 i 传入一个匿名立即执行函数中,在该匿名函数中存在对外部变量lis的引用,因此会形成一个闭包。而闭包中的变量index,即外部传入的 i 值会继续存在于内存中,所以当单击 li 时,就会输出对应的索引index值。

b. 定时器问题

定时器setTimeout()函数和for循环在一起使用,经常容易出问题。

var arr = ['one', 'two', 'three'];
for(var i = 0; i < arr.length; i++) { 
	setTimeout(function () {
		console.log(arr[i]); 
	}, i * 1000);
}

本题是希望通过定时器从第一个元素开始往后,每隔一秒输出arr数组中的一个元素。但是运行过后,我们却会发现结果是每隔一秒输出一个“undefined”。

其实问题大致和上面一题相同,setTimeout()函数与for循环在调用时会产生两个独立执行上下文环境,当 setTimeout()函数内部的函数执行时,for循环已经执行结束,而for循环结束的条件是最后一次i++执行完毕,此时i的值为3,所以实际上setTimeout()函数每次执行时,都会输出arr[3]的值。而因为arr数组最大索引值为2,所以会间隔一秒输出“undefined”

通过闭包可以解决问题

var arr = ['one', 'two', 'three']; 
for(var i = 0; i < arr.length; i++) {
	(function (time) { 
		setTimeout(function () {
			console.log(arr[time]); 
		}, time * 1000);
	})(i); 
}

通过立即执行函数将索引i作为参数传入,在立即函数执行完成后,由于 setTimeout() 函数中有对 arr 变量的引用,其执行上下文环境不会被销毁,因此对应的 i 值都会存在内存中。所以每次执行 setTimeout() 函数时,i都会是数组对应的索引值0、1、 2,从而间隔一秒输出“one”“two”“three”。

c. 作用域链问题

闭包往往会涉及作用域链问题,尤其是包含this属性时

var name = 'outer';
var obj = {
	name: 'inner', 
	method: function () {
		return function () { 
			return this.name;
		} 
	}
};
console.log(obj.method()()); // outer

在调用obj.method()函数时,会返回一个匿名函数,而该匿名函数中返回的是 this.name,因为引用到了this属性,在匿名函数中,this相当于一个外部变量,所以会形成一个闭包。在JavaScript中,this指向的永远是函数的调用实体,而匿名函数的实体是全局对象 window,因此会输出全局变量name的值“outer”。

如果想要输出obj对象自身的name属性,就要改变this的指向,将其指向obj对象本身:

var name = 'outer'; 
var obj = {
	name: 'inner', 
	method: function () {
		// 用_this保存obj中的this 
		var _this = this;
		return function () {
			return _this.name; 
		}
	} 
};
console.log(obj.method()()); // inner

在method()函数中利用_this变量保存obj对象中的this,在匿名函数的返回值中再去 调用_this.name,此时_this就指向obj对象了,因此会输出“inner”

d. 多个相同函数名问题

// 第一个foo()函数 
function foo(a, b) {
	console.log(b); 
	return {
		// 第二个foo()函数 
		foo: function (c) {
			// 第三个foo()函数
			return foo(c, a); 
		}
	} 
}
var x = foo(0); x.foo(1); x.foo(2); x.foo(3); // undefined,0,0,0
var y = foo(0).foo(1).foo(2).foo(3); // undefined,0,1,2
var z = foo(0).foo(1); z.foo(2); z.foo(3); // undefined,0,1,1

在上面的代码中,出现了3个具有相同函数名的foo()函数,返回的第三个foo()函数中包含了对第一个foo()函数参数a的引用,因此会形成一个闭包。

这道题的关键就是理解三个foo()函数的指向。具体步骤就不说了,按照前面讲过的那些思路应该能想明白,参考最后三个x,y,z的输出来验证即可

3.5.4 小结

闭包如果使用合理,在一定程度上能提高代码执行效率;如果使用不合理,则会造成内存浪费,性能下降。

(1)闭包的优点

  • 保护函数内变量的安全,实现封装,防止变量流入其他环境发生命名冲突,造成环境污染
  • 在适当的时候,可以在内存中维护变量并缓存,提高执行效率

(2)闭包的缺点

  • 消耗内存:通常来说,函数的活动对象会随着执行上下文环境一起被销毁,但是,由于闭包引用的是外部函数的活动对象,因此这个活动对象无法被销毁,这意味着,闭包比一般的函数需要消耗更多的内存
  • 泄漏内存:在IE9之前,如果闭包的作用域链中存在DOM对象,则意味着该DOM对象无法被销毁,会造成内存泄漏

3.6 this使用详解

当我们想要创建一个构造函数的实例时,需要使用new操作符,函数执行完成后,函数体中的this就指向了这个实例,通过下面这个实例可以访问到绑定在this上的属性。

假如我们将Person()函数当作一个普通的函数执行,其中的this则会直接指向window对象。

总的来说,可以概括为,在JavaScript中,this指向的永远是函数的调用者

下面就是在不同场景中 this 的指向问题。

(1)this指向全局对象

当函数没有所属对象而直接调用时,this指向的是全局对象

var value = 10; 
var obj = {
	value: 100,
	method: function () {
		var foo = function () { 
			console.log(this.value); // 10 
			console.log(this); // Window对象
		};
		foo();
		return this.value;
	} 
};
obj.method();

当我们调用obj.method()函数时,foo()函数被执行,但是此时foo()函数的执行是没有所属对象的,因此this会指向全局的window对象,在输出this.value时,实际是输出window.value,因此输出“10”。

(2)this指向所属对象

同样沿用场景1中的代码,我们修改最后一行代码,输出obj.method()函数的返回值。

console.log(obj.method()); // 100

obj.method()函数的返回值是this.value,method()函数的调用体是obj对象,此时this就指向obj对象,而obj.value = 100,因此会输出“100”。

(3)this指向对象实例

当通过new操作符调用构造函数生成对象的实例时,this指向该实例。

// 全局变量 
var number = 10; 
function Person() {
	// 复写全局变量 
	number = 20;
	// 实例变量 
	this.number = 30;
}
// 原型函数
Person.prototype.getNumber = function () {
	return this.number; 
};
// 通过new操作符获取对象的实例 
var p = new Person(); 
console.log(p.getNumber()); // 30

在上面这段代码中,我们定义了全局变量number和实例变量number,通过new操作符生成Person对象的实例p后,在调用getNumber()操作时,其中的this就指向该实例 p,而实例p在初始化的时候被赋予number值为30,因此最后会输出“30”。

(4)this指向call()函数、apply()函数、bind()函数调用后重新绑定的对象

通过call()函数、apply()函数、bind()函数可以改变函数执行的主体,如果函数中存在this关键字,则this也将会指向call()函数、apply()函数、bind()函数处理后的对象

// 全局变量 
var value = 10;
var obj = {
      value: 20 
};
// 全局函数
var method = function () {
	console.log(this.value); 
};
method(); // 10 
method.call(obj); // 20 
method.apply(obj); // 20
var newMethod = method.bind(obj); 
newMethod(); // 20

(5)闭包中的this

函数的this变量只能被自身访问,其内部函数无法访问。因此在遇到闭包时,闭包内 部的this关键字无法访问到外部函数的this变量

varuser={ 
	sport: 'basketball', 
	data: [
		{name: "kingx1", age: 11},
		{name: "kingx2", age: 12} 
	],
	clickHandler: function () {
		// 此时的this指向的是user对象
		this.data.forEach(function (person) {
			console.log(this); // [object Window] 
			console.log(person.name + ' is playing ' + this.sport);
		}) 
	}
};

这样最终会输出下面的结果。具体步骤和原因应该不需要多解释了。

kingx1 is playing undefined 
kingx2 is playing undefined

那么如果我们希望forEach循环结果输出的sport值为“basketball”,应该怎么做呢?可以使用临时变量将clickHandler()函数的this提前进行存储,对其使用user对象,而在匿名函数中,使用临时变量访问sport属性,而不是直接用this访问。

var user = { 
	sport: 'basketball', 
	data: [
		{name: "kingx1", age: 11},
		{name: "kingx2", age: 12} 
	],
	clickHandler: function () {
		// 使用临时变量_this保存this
		var _this = this; 
		this.data.forEach(function (person) {
			// 通过_this访问sport属性
			console.log(person.name + ' is playing ' + _this.sport); 
		})
	} 
};
user.clickHandler();

这样修改后的结果就是下面这样了

kingx1 is playing basketball 
kingx2 is playing basketball

额外的帮助理解this的小题

function f(k) { 
	this.m = k; 
	return this;
}
var m = f(1); 
var n = f(2);
console.log(m.m); 
console.log(n.m);

代码很短但是理解起来有一定难度。

在执行f(1)的时候,因为f()函数的调用没有所属对象,所以this指向window,然后this.m=k语句执行后,相当于window.m = 1。通过return语句返回“window”,而又将返回值“window”赋值给全局变量m,因此变成了window.m = window,覆盖前面的window.m = 1。

在执行f(2)的时候,this同样指向window,此时window.m已经变成2,即 window.m = 2,覆盖了window.m = window。通过return语句将window对象返回并赋值给n,此时window.n=window。

先看m.m的输出,m.m=(window.m).m,实际为2.m,2是一个数值型常量,并不存在m属性,因此返回“undefined”。再看n.m的输出,n.m=(window.n).m=window.m=2,因此输出“2”。


3.7 call()函数、apply()函数、bind()函数的使用与区别

在JavaScript中,每个函数都包含两个非继承而来的函数apply()和call(),这两个函数的作用是一样的,都是为了改变函数运行时的上下文而存在的,实际就是改变函数体内this的指向

bind()函数也可以达到这个目的,但是在处理方式上与call()函数和apply()函数有一定的区别。

3.7.1 call()函数的基本使用

call()函数调用一个函数时,会将该函数的执行对象上下文改变为另一个对象,语法为:

function.call(thisArg, arg1, arg2, ...)
  • function为需要调用的函数
  • thisArg表示的是新的对象上下文,函数中的this将指向thisArg,如果thisArg为null或者undefined,则this会指向全局对象
  • arg1,arg2,…表示的是函数所接收的参数列表
// 定义一个add()函数 
function add(x, y) {
	return x + y; 
}
// 通过call()函数进行add()函数的调用 
function myAddCall(x, y) {
	// 调用add()函数的call()函数
	return add.call(this, x, y); 
}
console.log(myAddCall(10, 20)); //输出“30”

3.7.2 apply()函数的基本使用

apply()函数的作用域与call()函数是一致的,只是在传递参数的形式上存在差别,语法为:

function.apply(thisArg, [argsArray])
  • function为需要调用的函数
  • [argsArray]表示的是参数会通过数组的形式进行传递,如果argsArray不是一个有效的数组或者arguments对象,则会抛出一个TypeError异常

同样是上面call()的例子

//定义一个add()函数 
function add(x, y) {
	return x + y; 
}
// 通过apply()函数进行add()函数的调用 
function myAddApply(x, y) {
	// 调用add()函数的apply()函数
	return add.apply(this, [x, y]); 
}
console.log(myAddApply(10, 20)); //输出“30”

3.7.3 bind()函数的基本使用

bind()函数创建一个新的函数,在调用时设置this关键字为提供的值,在执行新函数时,将给定的参数列表作为原函数的参数序列,从前往后匹配。语法为:

function.bind(thisArg, arg1, arg2, ...)

事实上,bind()函数与call()函数接收的参数是一样的。其返回值是原函数的副本,并拥有指定的this值和初始参数。

同样是上面call()和apply()的例子

//定义一个add()函数 
function add(x, y) {
	return x + y; 
}
// 通过bind()函数进行add()函数的调用 
function myAddBind(x, y) {
	// 通过bind()函数得到一个新的函数 
	var bindAddFn = add.bind(this, x, y); 
	// 执行新的函数
	return bindAddFn();
}
console.log(myAddBind(10, 20)); //输出“30”

3.7.4 call()函数、apply()函数、bind()函数的比较

相同点:

  • 都会改变函数调用的执行主体,修改this的指向

不同点:

  • 第一点是关于函数立即执行,call()函数与apply()函数在执行后会立即调用前面的函数,而bind()函数不会立即调用,它会返回一个新的函数,可以在任何时候进行调用
  • 第二点是关于参数传递,call()函数与bind()函数接收的参数相同,第一个参数表示
    将要改变的函数执行主体,即this的指向,从第二个参数开始到最后一个参数表示的是函数接收的参数;而对于apply()函数,第一个参数与call()函数、bind()函数相同,第二个参数是一个数组,表示的是接收的所有参数,如果第二个参数不是一个有效的数组或者 arguments对象,则会抛出一个TypeError异常

3.7.5 call()函数、apply()函数、bind()函数的巧妙用法

本节涉及到三个函数可以应用的一些具体算法或场景

(1)求数组中的最大项和最小项

Array数组本身是没有max()函数和min()函数的,所以可以使用apply()函数来改变 Math.max()函数和Math.min()函数的执行主体,然后将数组作为参数传递给Math.max()函数和Math.min()函数

var arr = [3, 5, 7, 2, 9, 11];
// 求数组中的最大值 
console.log(Math.max.apply(null, arr)); // 11 
// 求数组中的最小值 
console.log(Math.min.apply(null, arr)); // 2
  • apply()函数的第一个参数为null,这是因为没有对象去调用这个函数,我们只需要这个函数帮助我们运算,得到返回结果
  • 第二个参数是数组本身,就是需要参与max()函数和min()函数运算的数据,运算结束后得到返回值,表示数组的最大值和最小值

(2)类数组对象转换为数组对象

函数的参数对象arguments是一个类数组对象,自身不能直接调用数组的方法,但是我们可以借助call()函数,让arguments对象调用数组的slice()函数,从而得到一个真实的数组,后面就能调用数组的函数。

var arr = Array.prototype.slice.call(arguments);

(3)用于继承

下一章就会涉及到继承,其中的构造继承就会用到call()函数

(4)执行匿名函数

假如存在这样一个场景,有一个数组,数组中的每个元素是一个对象,对象是由不同的属性构成,现在我们想要调用一个函数,输出每个对象的各个属性值。

我们可以通过一个匿名函数,在匿名函数的作用域内添加print()函数用于输出对象的各个属性值,然后通过call()函数将该print()函数的执行主体改变为数组元素,这样就可以达到目的了。

var animals = [
	{species: 'Lion', name: 'King'}, 
	{species: 'Whale', name: 'Fail'}
];
for (var i = 0; i < animals.length; i++) {
	(function (i) {
		this.print = function () {
			console.log('#' + i + ' ' + this.species + ': ' + this.name); };
			this.print(); 
	}).call(animals[i], i);
}

在上面的代码中,在call()函数中传入animals[i],这样匿名函数内部的this就指向animals[i],在调用print()函数时,this也会指向animals[i],从而能输出speices属性和name属性。

(5)bind()函数配合setTimeout

在默认情况下,使用setTimeout()函数时,this关键字会指向全局对象window。当使用类的函数时,需要this引用类的实例,我们可能需要显式地把this绑定到回调函数以便继续使用实例。

此处之后再详细记录

你可能感兴趣的:(前端学习记录,前端~JavaScript,前端,学习,javascript)