第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); // 1true && function(x){console.log(x);
}(2); // 20, 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); // 7new function(x) {console.log(x);
}(8); // 8new function() {console.log(9); // 9
}
3.2 函数参数
3.2.1 形参和实参
- 形参(形式参数):在定义函数名称与函数体时使用 的参数,目的是用来接收调用该函数时传入的参数
- 实参(实际参数):在调用时传递给函数的参数,实 参可以是常量、变量、表达式、函数等类型
形参和实参区别:
- 形参出现在函数的定义中,只能在函数体内使用,一旦离开该函数则不能使 用;实参出现在主调函数中,进入被调函数后,实参也将不能被访问
- 在强类型语言中,定义的形参和实参在数量、数据类型和顺序上要保持严格一 致,否则会抛出“类型不匹配”的异常
- 在函数调用过程中,数据传输是单向的,即只能把实参的值传递给形参,而不 能把形参的值反向传递给实参。因此在函数执行时,形参的值可能会发生变化,但不会影响到实参中的值
- 当实参是基本数据类型的值时,实际是将实参的值复制一份传递给形参,在函 数运行结束时形参被释放,而实参中的值不会变化。当实参是引用类型的值时,实际是将实参的内存地址传递给形参,即实参和形参都指向相同的内存地址,此时形参可以修改实参的值,但是不能修改实参的内存地址
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]); // 4console.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); // 2arguments[0] = 11; console.log(a); // 11b = 12; console.log(arguments[1]); // 12arguments[2] = 3; console.log(c); // undefinedc = 13; console.log(arguments[2]); // undefinedconsole.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点:
- 构造函数的函数名的第一个字母通常会大写
- 在函数体内部使用this关键字,表示要生成的对象实例,构造函数并不会显式地返回任何值,而是默认返回“this”
- 作为构造函数调用时,必须与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步:
- 通过new操作符创建一个新的对象,在内存中创建一个新的地址
- 为构造函数中的this确定指向
- 执行构造函数代码,为实例添加属性
- 返回这个新创建的对象
使用构造函数的问题在于,每创建一个新的实例,都会新增一个属性,例如上面的sayName(),而且不同实例中的该属性并不相同。而事实上当我们在创建对象的实例时,对于相同的函数并不需要重复创建,而且由于this的存在,总是可以在实例中访问到它具有的属性。
更好的解决办法就是通过原型,这个会在第 4 章涉及。
3.4 变量提升与函数提升
JavaScript中会出现变量在定义之前就可以被访问到而不会抛出异常,以及函数在定义之前就可以被调用而不会抛出异常。
这是由于JavaScript存在变量提升和函数提升机制。
3.4.1 作用域
作用域:一个变量的定义与调用所在的固定的范围
作用域可以分为:
- 全局作用域
- 函数作用域
- 块级作用域(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 个很明显的特点:
- 函数拥有的外部变量的引用,在函数返回时,该变量仍然处于活跃状态
- 闭包作为一个函数返回时,其执行上下文环境不会被销毁,仍处于执行上下文环境中
在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保存thisvar _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绑定到回调函数以便继续使用实例。
此处之后再详细记录