涉及到JavaScript高级的知识,永远都躲不过this
、原型、原型链、作用域和作用域链。但是拗口的概念又经常让人描述得不准确,在此做个记录。
1、this
代表函数调用相关联的对象,通常也称之为执行上下文
1.1、作为函数直接调用,非严格模式下,this
指向window
,严格模式下,this
指向undefined
;
// 非严格模式
function foo() {
console.log(this)
}
foo() // window
// 严格模式
"use strict"
function foo() {
console.log(this)
}
foo() // undefined
1.2、作为某对象的方法调用,this
通常指向调用的对象。
let foo = {
bar: function() {
console.log(this)
}
}
foo.bar() // foo
1.3.、使用apply、call、bind
可以绑定this的指向(不管给函数 bind
几次,函数中的this
永远由第一次 bind
决定)。
let a = {}
let fn = function () { console.log(this) }
fn.bind(a)() // a
let a = {}
let fn = function () { console.log(this) }
fn.bind().bind(a)() // window
1.4、 在构造函数中,this
指向新创建的对象
function Foo() {
this.name = 'hi';
console.log(this)
}
new Foo() // Foo {name: "hi"}
1.5、 箭头函数没有单独的this
值,this
在箭头函数创建时确定,它与声明所在的上下文相同。
let a = {
b: function() {
console.log(this)
},
c: () => {
console.log(this)
}
}
a.b() // a
a.c() // window
this判断 下面输出为多少?
var name1 = 1;
function test() {
let name1 = 'kin';
let a = {
name1: 'jack',
fn: () => {
var name1 = 'black'
console.log(this.name1)
}
}
return a;
}
test().fn() // ?
答案: 输出1
因为fn
处绑定的是箭头函数,箭头函数并不创建this
,它只会从自己的作用域链的上一层继承this
。这里它的上一层是test()
,非严格模式下test
中this
值为window
。
- 如果在绑定fn的时候使用了
function
,那么答案会是jack
- 如果第一行的
var
改为了let
,那么答案会是undefind
, 因为let
不会挂到window
上
再来一题:
var num = 1;
var myObject = {
num: 2,
add: function() {
this.num = 3;
(function() {
console.log("第1个出现的console:" + this.num);
this.num = 4;
})();
console.log("第2个出现的console:" + this.num);
},
sub: function() {
console.log("第3个出现的console:" + this.num);
}
};
myObject.add();
console.log("第4个出现的console:" + myObject.num);
console.log("第5个出现的console:" + num);
var sub = myObject.sub;
sub();
下面来看正确答案:
第1个出现的console:1
第2个出现的console:3
第4个出现的console:3
第5个出现的console:4
第3个出现的console:4
1.2、作为某对象的方法调用,
this
通常指向调用的对象。
myObject.add()
里,第1个出现的console
在立即执行函数里,根据上面提到过的,那么这个1.2所说,立即执行函数在myObject.add()
里,this
应该指向myObject
。然而,立即执行函数中的this
指向window
,因为立即执行函数是window
调用的,所以,第1个出现的console
的值为1。第1个出现的console
执行完以后,this.num = 4
,改变的是window
中的值。所以第5个出现的console
的值为4。var sub = myObject.sub;
,此时sub
的环境也是window
,所以第3个出现的console
的值也是4。
一个小小的吐槽 ~ 之前以为自己把 this
搞懂了,一个立即执行函数 IEFF
让我遭遇了 this
滑铁卢。
多个this规则出现时,this最终指向哪里?
首先,new
的方式优先级最高,接下来是 bind
这些函数,然后是 obj.foo()
这种调用方式,最后是 foo
这种调用方式,同时,箭头函数的 this
一旦被绑定,就不会再被任何方式所改变。
2、原型与原型链
2.1、原型对象
每一个JavaScript对象(null
除外)都和另一个对象相关联,“另一个”对象就是我们熟知的原型,每一个对象都是从原型继承属性
每一个函数都有一个
prototype
(原型)属性,这个属性指向函数的原型对象,而这个对象的用途是包含可以由特定类型的所有实例共享的属性和方法。
- 《JavaScript高级程序设计》(第3版) 中提到:
prototype
就是通过调用构造函数而创建的那个对象实例的原型对象。 - 《JavaScript权威指南》(第6版) 中也提到:通过
new
关键字和构造函数调用创建的对象的原型就是构造函数的prototype
属性的值。
function Person(){};
Person.prototype.name = "wood";
Person.prototype.job = "engineer";
Person.prototype.sayName = function(){
console.log(this.name);
}
var person1 = new Person();
person1.sayName(); // wood
var person2 = new Person();
person2.sayName(); // wood
console.log(person1.sayName == person2.sayName); // true
所以通过上述例子可知:Person.prototype
就是实例person1
和实例person2
的原型对象。
在默认情况下,所有原型对象都会自动获得一个
constructor
(构造函数)属性,这是一个指向prototype
属性所在函数的指针。
也就是说每个原型都有都有一个 constructor
属性,指向了原型所在的函数,在上面例子来说,Person.prototype.constructor == Person
。
有细心的小伙伴会注意到,为什么实例person1
和person2
之中是[[prototype]]
?
—— 当调用构造函数创建一个新实例后,该实例的内部将包含一个指针(内部属性),指向构造函数的原型对象。ECMA-262第5版中管这个指针叫[[prototype]]
。虽然在脚本中没有标准的方式访问[[prototype]]
,但Firfox、Safari和Chrome在每个对象上都支持一个__proto__
的属性(来自-《JavaScript高级程序设计》(第三版))。
所以在有的地方会直接说,实例person1
和person2
的__proto__
属性,指向Person.prototype
。
注意:我们无法直接访问到[[prototype]]
或__proto__
,可以通过 isPrototypeOf()
方法判断某个原型和对象实例是否存在关系,或者,也可以使用 Object.getPrototypeOf()
获取一个对象实例 __proto__
属性的值。
console.log(Person.prototype.isPrototypeOf(person1)); // true
console.log(Object.getPrototypeOf(person1) == Person.prototype); // true
更简单的原型语法:
前面例子中,每添加一个属性和方法就要敲一次Person.prototype
,更常见的做法是用一个包含所有属性和方法的对象字面量来重写整个原型对象,如下所示:
function Person(){};
Person.prototype = {
name : "wood",
job: "engineer",
sayName : function () {
console.log(this.name);
}
};
重写后的代码与原代码最终结果相同。但是有一个例外:constructor
属性不再指向Person
了。我们在前面提到过,每创建一个函数,就会同时创建它的prototype
对象,这个对象也自动获得constructor
属性。而我们这样重写,本质上完全重写了默认的prototype
对象,因此constructor
属性也就变成了新对象的constructor
属性,不再指向Person
函数了。此时,尽管instanceof
操作符还能返回正确结果,但是通过constructor
已经无法确定对象的类型了,结果如下所示:
var friend = new Person();
console.log(friend instanceof Object); // true
console.log(friend instanceof Person); // true
console.log(friend.constructor == Person); // false
console.log(friend.constructor == Object); // true
从上可见,constructor
属性等于Object
而不是等于Person
了。如果constructor
的值真的很重要,可以像下面这样特意将它设置回适当的值:
function Person(){};
Person.prototype = {
constructor : Person,
name : "wood",
job: "engineer",
sayName : function () {
console.log(this.name);
}
};
以上代码特意包含了一个constructor
属性,并将它的值设置为Person
,从而确保了通过该属性能够访问到适当的值。
但是,重设constructor
属性后会导致它的[[Enumerable]]
特性被设置为true
。默认情况下,原生的constructor
属性是不可枚举的。因此,可通过Object.defineProperty()
来重设构造函数,如下代码所示:
function Person(){};
Person.prototype = {
name : "wood",
job: "engineer",
sayName : function () {
console.log(this.name);
}
};
Object.defineProperty(Person.prototype, "constructor", {
enumerable: false;
value: Person
})
2.2、原型链
每个构造函数都有一个原型对象,原型对象都包含一个指向构造函数的指针,而实例都包含一个指向原型对象的内部指针。
而每一个原型对象都是个普通对象,普通对象都具有原型。所有的内置构造函数以及大部分自定义的构造函数都具有一个继承自Object.prototype
的原型(所有函数的默认原型都是Object
的实例)。例如(关系示意见下图),Date.prototype
的属性继承自Object.prototype
,因此由new Date()
创建的Date对象
的属性同时继承自Date.prototype
和Object.prototype
。这一系列链接的原型对象就是所谓的“原型链”。
当以读取模式访问一个实例属性时,首先会在实例中搜索该属性。如果没有找到该属性,则会继续搜索实例的原型。在通过原型链实现继承的情况下,搜索过程就得以沿着原型链继续向上。
可以用instanceof
操作符和isPrototypeOf()
方法来确定原型和实例的关系。
谨慎地定义方法
子类型有时候需要覆盖超类型中的某个方法,或者需要添加超类型中不存在的某个方法。但不管怎样,给原型添加方法的代码一定要放在替换原型的语句之后。
function SuperType(){
this.property = true;
}
SuperType.prototype.getSuperValue = function(){
return this.property;
};
function SubType(){
this.subproperty = false;
}
// 继承了 SuperType
SubType.prototype = new SuperType();
// 添加新方法
SubType.prototype.getSubValue = function (){
return this.subproperty;
};
// 重写超类型中的方法
SubType.prototype.getSuperValue = function (){
return false;
};
var instance = new SubType();
alert(instance.getSuperValue()); //false
还有一点需要注意的是,通过原型链实现继承时,不能使用对象字面量来创建原型,这样做会重写原型链。如下所示:
function SuperType(){
this.property = true;
}
SuperType.prototype.getSuperValue = function(){
return this.property;
};
function SubType(){
this.subproperty = false;
}
// 继承了 SuperType
SubType.prototype = new SuperType();
// 使用字面量添加新方法,会导致上一行代码无效
SubType.prototype = {
getSubValue : function (){
return this.subproperty;
},
someOtherMethod : function (){
return false;
}
};
var instance = new SubType();
alert(instance.getSuperValue()); //error!
-
原型链的问题
原型链最主要的问题来自包含引用类型值的原型。包含引用类型值的原型属性会被所有实例共享;所以要在构造函数中定义属性,而不是在原型对象中定义。
在通过原型实现继承时,原型实际上会变成另一个类型的实例。于是,原先的实例属性也就变成了现在的原型属性了。
function SuperType(){
this.colors = ["red", "blue", "green"];
}
function SubType(){
}
// 继承了 SuperType
SubType.prototype = new SuperType();
var instance1 = new SubType();
instance1.colors.push("black");
alert(instance1.colors); //"red,blue,green,black"
var instance2 = new SubType();
alert(instance2.colors); //"red,blue,green,black"
上面例子中,SuperType
构造函数定义了一个colors
属性,SuperType
的每个实例都会有各自包含自己数组的colors
属性。当SubType
通过原型链继承了SuperType
之后,SubType.prototype
就变成了SuperType
的一个实例,因此它也拥有了一个它自己的colors
属性 —— 就跟专门创建了一个SubType.prototype.colors
属性一样。结果SubType
的所有实例都会共享这一个colors
属性。
原型链的第二个问题是:在创建子类型的实例时,不能向超类型的构造函数中传递参数。实际上,应该说是没有办法在不影响所有对象实例的情况下,给超类型的构造函数传递参数。
鉴于这两个问题,实践中很少会单独使用原型链来实现继承。
3、作用域与作用域链
3.1、作用域
一个变量的作用域是程序源代码中定义这个变量的区域。
在之前,JavaScript是没有块级作用域的,并且还会出现变量提升的问题,导致内层变量可能会覆盖外层变量和用来计数的循环变量泄露为全局变量等问题,并且还出现了闭包的问题。
但是……但是!ES6 新增了let
命令,let
声明的变量,只在代码块内有效,并且也不存在变量提升。块级作用域的出现,实际上使得获得广泛应用的匿名立即执行函数(匿名 IIFE)不再必要了。
变量的作用域有全局作用域和函数作用域,还有ES6新增的块级作用域。
- 全局作用域:全局变量拥有全局作用域,在任何地方都是有定义的。
// 全局作用域
var b = 10;
function a(){
console.log('函数内的b:' + b); // 函数内的b:10
}
a();
console.log('函数外的b:' + b); // 函数外的b:10
- 函数作用域:变量在声明他们的函数体以及这个函数体嵌套的任意函数体内都是有定义的。
// 函数作用域
function a(){
var b = 10;
console.log(b); // 10
}
a();
console.log(b); // Uncaught ReferenceError: b is not defined
// 函数作用域,变量声明提升
function a(){
console.log(b); // undefined
var b = 10;
}
a();
console.log(b); // Uncaught ReferenceError: b is not defined
// 相当于执行了以下代码
function a(){
var b;
console.log(b); // undefined
b = 10;
}
a();
console.log(b); // Uncaught ReferenceError: b is not defined
- 块级作用域:花括号内的变量有其自己的作用域,而且变量在声明它们的代码段之外是不可见的。
// 块级作用域
function a(){
let b = 10;
if (true) {
let b = 20;
console.log(b); // 20
}
console.log(b); // 10
}
a();
在以上代码中,分别用let
在不同的花括号内声明了b
,但是最终外层代码块不受内层代码块的影响。如果两次都使用var
定义变量,则两个输出的值都是20
。
3.2、作用域链
当代码在一个环境中执行时,会创建变量对象的一个作用域链。作用域链的用途是保证对执行环节有权访问的所有变量和函数的有序访问。作用域链的前端,始终都是当前执行的代码所在环境的变量对象。如果这个环境是函数,则将其活动对象作为变量对象。活动对象在最开始的时候只包含一个变量,即
arguments
对象(这个对象在全局环境中是不存在的)。作用域链中的下一个变量对象来自包含(外部)环境,而再下一个变量对象则来自下一个包含环境。这样,一直延续到全局执行环境;全局执行环境的变量对象始终都是作用域链中的最后一个对象。——《JavaScript高级程序设计》(第3版)
var color = "blue";
function changeColor(){
if (color === "blue"){
color = "red";
} else {
color = "blue";
}
}
changeColor();
console.log("Color is now " + color); // Color is now red
在上面例子中,函数changeColor()
的作用域包含两个对象:它自己的变量对象(其中定义着arguments
对象) 和全局环境的变量对象。最终输出为red,可见可以在函数内部访问变量color
,就是因为可以在这个作用域链中找到它。
此外,在局部作用域中定义的变量可以在局部环境中与全局变量互换使用,见以下例子:
var color = "blue";
function changeColor(){
var anotherColor = "red";
function swapColors(){
var tempColor = anotherColor;
anotherColor = color;
color = tempColor;
// 这里可以访问color、anotherColor 和t empColor
}
// 这里可以访问color and anotherColor,但不能访问 tempColor
swapColors();
}
changeColor();
// 这里可以访问color,但不能访问anotherColor 和 tempColor
console.log("Color is now " + color); // Color is now red
以上代码共涉及3个执行环境:全局环境、changeColor()
的局部环境和swapColors()
的局部环境。全局环境中有一个变量color
和一个函数changeColor()
。changeColor()
的局部环境中有一个名为anotherColor
的变量和一个名为swapColors()
的函数,但它也可以访问全局环境中的变量color
。swapColors()
的局部环境中有一个变量tempColor
,该变量只能在这个环境中访问到。无论全局环境还是changeColor()
的局部环境都无权访问tempColor
。然而,在swapColors()
内部则可以访问其他两个环境中的所有变量,因为那两个环境是它的父执行环境。
上图中的矩形表示特定的执行环境。其中,内部环境可以通过作用域链访问所有的外部环境,但外部环境不能访问内部环境中的任何变量和函数。这些环境之间的联系是线性、有次序的。每个环境都可以向上搜索作用域链,以查询变量和函数名;但任何环境都不能通过向下搜索作用域链而进入另一个执行环境。对于这个例子中的swapColors()
而言,其作用域链包括3个对象:swapColors()
的变量对象、changeColor()
的变量对象和全局变量对象。swapColors()
的局部环境开始时会现在自己的变量对象中搜索变量名和函数名,如果搜索不到则再搜索上一级作用域链。changeColor()
的作用域链中指包含两个对象:它自己的变量对象和全局变量对象。也就是说,它不能访问swapColors()
的环境。