你不知道的JavaScript——this全面解析(下)
混入
JavaScript和传统的面向对象编程语言有很大差异,实际上它没有类,只有对象,那么自然也就不存在继承、多态这些东西。
混入是在JS中实现传统面向对象模式的一个办法。
function mixin(parentObj,childObj){
for(var key in parentObj){
if(!(key in childObj))
childObj[key] = parentObj[key];
}
return childObj;
}
如上,混入就是赋予子元素所有在它身上没有的他爹身上的属性,这不就是继承嘛。
看个例子:
var Vehicle = {
engines: 1,
ignition: function() {
console.log("Turning on my engine.");
},
drive: function() {
this.ignition();
console.log("Steering and moving forward");
}
};
var Car = mixin(Vehicle,{
wheels: 4,
drive: function(){
Vehicle.drive.call(this);
console.log("Rolling on all "+this.wheels+" wheels!");
}
});
Car.drive();
/*
Turning on my engine.
Steering and moving forward
Rolling on all 4 wheels!
*/
分析,Car通过mixin
,获得了Vehicle
的engine
属性(值)和ignition
方法(引用),而drive
则保留自己的,实现了子类重写父类方法,并且子类新增了一个wheels
属性。
Vehicle.drive.call(this)
就是一个多态模式,因为父类和子类都有drive
的定义,这里是使用了父类的drive
方法,并将自己作为上下文绑定进去,如果不使用call而是直接调用,那就是在Vehicle
对象的上下文中调用。显然对这个示例没什么影响,但是这不是我们所期望的。
寄生继承
寄生继承更像是委托设计模式。
// 定义Vehicle对象
function Vehicle(){
this.engines = 1;
}
Vehicle.prototype.ignition = function(){
console.log("Turning on my engine.");
}
Vehicle.prototype.drive = function(){
this.ignition();
console.log("Steering and moving forward!");
}
// 定义Car对象
function Car(){
// Car是一个Vehicle
var car = new Vehicle();
// 新增属性
car.wheels = 4;
// 保留父类方法
var vehDrive = car.drive;
// 重写父类方法
car.drive = function(){
// 调用父类方法
vehDrive.call(this);
console.log("Rolling on all " + this.wheels + " wheels!");
}
// 返回对象
return car;
}
var myCar = new Car();
myCar.drive();
只是个人觉得这样写挺丑。
原型
前面提到了JS中没有类,只有对象,也介绍了两种用来在JS中描述类之间结构关系的办法,那JS有没有啥自带的办法??
好像每个JS对象都有toString
方法,valueOf
方法......这是典型的继承操作嘛,所以JS肯定有类似的东西,那就是Prototype
——原型。
JS中的每个对象都有一个__proto__
属性,它也是一个对象(后文称它proto对象),一般这个对象不会为空,这个对象描述了JS中对象之间的继承关系。
通过Object.create(obj)
可以显式的把一个对象obj
作为一个新对象的proto对象。
var parent = { a: 10 };
var child = Object.create(parent);
当我们查看这个child
对象时,你会清楚的看到parent
对象成为了这个对象的proto对象,这时你就可以访问child.a
了,当访问一个对象中并不存在的属性时,就会去检查proto对象中是否有相应的属性。
而且child
的proto对象中也有一个proto对象,这是再上一级的父对象,这一层一层的proto对象就构成了JS中的原型链,原型链的末尾是Object.prototype
,也就是这里定义了toString
、valueOf
这些方法。访问属性时会沿着原型链向上查找。当使用for in
、key in value
时也会检查原型链。
显然这个例子中再上一级的proto对象就是Object.prototype
了,因为没有其他的继承关系了。
引用!引用!
注意,原型链并不复制对象,而是直接保存引用。
可以看到,child.__proto__
与parent
根本就是同一个对象。
再看赋值操作
obj.a = 10;
- 先检查obj中是否有a这个属性,如果有直接修改赋值。
- 检查obj的原型链中是否有a这个属性,如果有且
writable == true
的话,在obj中创建a属性并赋值 - 如果obj的原型链中有这个属性,并且
writable == false
的话,不会在obj中创建新属性,如果运行在非严格模式,忽略该条语句,如果在严格模式,TypeError - 如果obj的原型链中这个a属性有setter的话,调用setter,忽略是否可写
- 都不满足,说明在obj中和它的原型链中都不存在a属性,那么在obj中创建a属性并赋值
我们把它和传统的面向对象做一个类比。
- 相当于子类中特有的属性
- 相当于父类中有这个属性,但子类要覆盖或重写
- 相当于父类中有这个属性,但父类已经为这个属性设定了一个很高的访问级别(即不允许写),那么子类自然无法低于这个级别
- 没什么可以类比的,如果非要类比,可以想成,父类对一个属性的实际存储方式已经规划好了,所有的子类必须要遵守这个规定,所以要调用原型链当中的
setter
- 也相当于子类中特有的属性
隐式屏蔽
var parent = { a: 10 };
var child = Object.create(parent);
child.a++;
console.log(child);
console.log(child.__proto__);
该代码有个坑,child.a
应该读取到的是parent
中的a
属性,那么对它进行自增,应该操作的是parent
里面的a
,预期的输出结果应该是:
{}
{ a: 11 }
但实际的输出结果是:
{ a: 11 }
{ a: 10 }
因为自增操作实际上是会被转换成如下代码:
child.a = child.a + 1;
所以,根据对象赋值的规则,会在child中新建一个a
,并且把11赋给它,而parent中的a
并未受影响。
跳出面向对象的模式
上面运用了大量的面向对象的类比来介绍原型链,其实不过是方便理解罢了,但实际上JS中并没有类似继承、类、多态这些概念,它只有对象。同时,本书的作者也对社区中的一些通用的过于面向对象风
的术语嗤之以鼻。
再次重申,JS中没有类,只有对象,刚刚你看到的不过是通过一系列对象的组合引用,使用一种类似委托的技术创造出了原型链模式,来方便的在对象间复用逻辑,也就是定义一个对象时,只考虑它特有的属性和方法,其他的属性和方法留空,然后再使用别的对象填满。后面我们会尽量少的使用传统面向对象的术语。
类函数
下面是被滥用了很多年的用JS中的函数模拟传统面向对象的“类”特性的写法,至今还能在一些成熟的框架中看到影子。
function People(name){
this.name = name;
}
People.prototype.eat = function(){
console.log(this.name + ",eating...");
}
People.prototype.drink = function(){
console.log(this.name + ",drinking...");
}
var p1 = new People("张三");
p1.eat();
p1.drink();
前面说了,JS中对象都有一个proto属性,而函数在JS中也是一个特殊的对象,它自然也有proto属性,并且当你使用new
关键字时,会返回一个对象(见上一篇博文),JS会自动将这个函数的proto属性作为返回对象的proto属性。所以p1.__proto__ === People.prototype
是成立的。
这里已经完全是在模仿传统面向对象中的类的思路了,提供一套模板方法和属性,每当实例化一个对象时,就将这些模板方法和属性复制进去。虽然这里没有发生复制,但是整体的效果已经一致了。
原型继承
你也看到了,在JS中如果你想实现传统面向对象当中的继承,直接让它的proto属性指向要继承的对象就好了。而使用的方法就是Object.create
。
下面展示的是一个例子:
function Foo(name){
this.name = name;
}
Foo.prototype.myName = function(){
return this.name;
}
function Bar(name,label){
Foo.call(this,name);
this.label = label;
}
Bar.prototype = Object.create(Foo.prototype);
Bar.prototype.myLabel = function(){
return this.label;
}
var a = new Bar('a','obj a');
console.log(a.myName()); // a
console.log(a.myLabel()); // obj a
关键代码就是Bar.prototype = Object.create(Foo.prototype)
这行代码执行完毕,Bar
默认的proto属性会被替换,替换成如下形式:
Bar.prototype == {
__proto__: {
myName: function(){...}
}
}
相当于它继承了Foo
的所有属性和方法,并且我们可以通过Bar.prototype
随意添加和覆盖其中的方法。
最终的Bar.prototype
是这样
{
myLabel: function(){...},
__proto__: {
myName: function(){...}
}
}
最终的对象a
是这样:
a == {
label: "obj a",
name: "a",
__proto__: {
myLabel: function(){...},
__proto__: {
myName: function(){...}
}
}
}
注意,上面的办法和如下两种办法是完全不同的,并且下面的办法不推荐使用:
Bar.prototype = Foo.prototype;
这种办法的问题在于Bar.prototype
被直接指向了Foo.prototype
,它俩变成了同一个对象,虽然确实能借此访问到父对象中的方法和属性,但是你对Bar.prototype
的修改也都会应用到Foo.prototype
,也就是说你放弃了所有重写和覆盖父对象中方法和属性的权力。
Bar.prototype = new Foo();
之前说过,new
关键字会创建一个新的对象,并把Foo.prototype
设置为这个新对象的proto属性并返回,所以a
对象实际的结构是这样的:
a == {
label: "obj a",
name: "a",
__proto__: {
name: undefined, // [+] 多了这行
myLabel: function(){...},
__proto__: {
myName: function(){...}
}
}
}
也就是说,Foo
函数中对this
进行的操作都会反映在原型链中,如果此处有些破坏性的操作也都会反映到最终对象的原型链中。
但是Bar.prototype = Object.create(Foo.prototype)
有一个问题就是,原本的Bar.prototype
貌似丢失了,被一个新创建的对象替换掉了,虽然也没啥问题,它给垃圾处理器造成的压力几乎可以忽略不计,但是感觉还是不是内回事儿。
ES6中新增了一个方法规避了这个问题。
Object.setPrototypeOf( Bar.prototype, Foo.prototype );
判断是否是父对象
JS中有instanceof
关键字,但是它只能判断一个对象和一个函数的关系,实际上它是在判断这个对象的原型链中是否包含这个函数的prototype
属性,它无法应对判断一个对象是否是另一个对象的原型(也就是是不是另一个的父对象)。
b.isPrototype(c) // 返回b是否是c的原型
也可以将上面的代码解释为查看b是否在c的原型链中出现过
__proto__
我们几乎用了一整篇这个属性,但是很遗憾,它并非标准的,而且原书中也几乎没用,只是简单的介绍了一下,直到刚刚我才知道它是非标准的。
标准的获取原型对象的办法是Object.getPrototypeOf
,设置原型对象的则是Object.setPrototypeOf
。
而__proto__
只被部分的浏览器支持,它的实现大致如下:
Object.defineProperty( Object.prototype, "__proto__", {
get: function() {
return Object.getPrototypeOf( this );
},
set: function(o) {
// ES6 中的 setPrototypeOf(..)
Object.setPrototypeOf( this, o );
return o;
}
} );
行为委托
现在开始忘掉传统面向对象中那些面向类的模式。
JS中的模式被称为面向委托的模式,在JS中,一个对象不再有一个父类,因为压根就没有类,所以我们再也看不到一个纵深的树状的继承结构了。
在JS中,你如果要描述动物和猫的关系,你要做的不是创建一个Animal
,描述动物都有的属性和行为,再创建一个Cat
作为Animal
的子类,描述一只猫特有的属性和行为。你要做的是创建Animal
和Cat
,分别描述它俩所特有的行为,然后Cat
自己干不了活,它要依赖它的哥们儿Animal
才能干活,所以要把Animal
委托给它。
这是委托和传统面向类的模式思想上的不同。
var Animal = function(typeName){
return {
typeName: typeName,
run: function(){
console.log(this.typeName+' is running...');
},
hunt: function(){
console.log(this.typeName+' is hunting...');
}
}
}
// 委托Animal类
var Cat = Object.create(Animal('猫'));
Cat.catchMouse = function(){
this.hunt();
console.log('Finding mouses...');
}
Cat.washFace = function(){
console.log('The cat is washing her face now.');
}
Cat.catchMouse();
Cat.washFace();
上面的代码就是使用委托风格写的代码,其中你可以看到,Cat
提供了一个catchMouse
方法,其中委托了Animal的hunt
进行处理。在传统的面向对象的中,这里应该使用方法重写,所以catchMouse
理应该被改名为hunt
,但上面的代码中如果改名了,当执行到this.hunt
时就会造成循环引用,不会去找原型链中的hunt
,因为此处已经有一个了。这里你就要去想办法找到Animal
中的hunt
,并通过call
绑定上下文调用它,要么就取一个新的名字。
委托风格推荐你取新名字,而不是进行掩盖,然后再手动去找要委托的方法。
我实话实说,上面的代码还有点面向类,我们再改改。
var Animal = {
setTypeName: function(typeName){
this.typeName = typeName;
},
run: function(){
console.log(this.typeName+' is running...');
},
hunt: function(){
console.log(this.typeName+' is hunting...');
}
}
var Cat = Object.create(Animal);
Cat.setTypeName('猫');
Cat.catchMouse = function(){
this.hunt();
console.log('Finding mouses...');
}
Cat.washFace = function(){
console.log('The cat is washing her face now.');
}
Cat.catchMouse();
Cat.washFace()
这里改了Animal
,使用对象文法进行声明,提供一个setTypeName
,而不是像传统的面向对象中的使用类似构造器的语法传递进去。这和刚刚有啥区别呢?注意,如果你使用Cat.setTypeName
,那么这个属性是被设置到Cat
上的,而刚刚你使用那种类构造器的语法,那么那个属性会被设置到Animal
上,委托模式推荐你使用上面的办法将所有属性设置到委托者身上,而不是委托目标。
至少在JS中,委托模式比面向类的模式要简洁清晰很多,因为JS本来就是为此而设计的,但开发者们非要在这个模式下去模仿传统的面向类设计,那肯定会更加繁杂啊。