从一个对象开始
不知你有没有想过,什么是对象?在Javascript中,对象是这样的
const animal = {
name: 'a',
getName() {
return this.name
}
}
复制代码
如果你还不清楚this的指向问题,看这里 this
因此,我们可以这么简单的描述,对象,是属性的集合,是一种数据结构,是一种代码的组织形式,是一种对现实世界物体的表示方法。
关于面向对象的不再多说,总之,我们就从这个对象为起点,开始讨论。
对象的模板
现在,假设我们需要3个对象,表示3只动物,如果直接把上面的代码复制3遍,那可是一个不好的习惯,要改掉哦,谨记DRY原则,Don’t Repeat Yourself。
最好呢,有一个模板,比如用函数:
function newAnimal(name) {
return {
name,
getName() {
return this.name
}
}
}
const animal1 = newAnimal('a')
const animal2 = newAnimal('b')
const animal3 = newAnimal('c')
复制代码
按下F12,讲这些代码复制进去运行看看,这样生成的对象没问题。
这样的写法,至少看着就不优雅了,而且继承也不好搞,关于继承先别急,后面会说。
这里介绍下Javascript的关键字:new,上面的代码可以这么写
function Animal(name) {
this.name = name,
this.getName = function() {
return this.name
}
}
const animal1 = new Animal('a')
const animal2 = new Animal('b')
const animal3 = new Animal('c')
复制代码
看着是不是就优雅了一些呢。
那么,这个new关键字干了啥呢?我们自己实现一个简单的函数来模拟new:
// new关键字当然不止干了这点事,其他的先不管
function newFunc(func, name) {
var obj = {} // 创建一个新的空对象
func.call(obj, name) // 调用函数,并将函数里面的this指向到obj
return obj // 返回这个对象
}
const animal4 = newFunc(Animal, 'd')
复制代码
运行起来,对比下animal3跟animal4。
animal3左边有个Animal,是函数Animal的名字,也既Animal.name,说明这个对象是Animal创建的。
对象的原型(prototype)
使用new来执行一个函数,是解决了模板的问题,但是还存在一个不可忽视的问题:每次创建一个对象的时候,对象里面的所有属性都要创建一次。
对于有些属性,比如函数getName,只需要一个,所有对象都使用这个函数就行了,多个相同的函数会浪费内存,显然可以优化。
而且,记住,Javascript是动态语言,运行过程中的属性是可以随意修改的,为了避免在运行中,修改函数的实现的需要找到所有对象一个个的改,也不能让每个对象都自带一个函数。
Javascript对此的解决方案,叫做原型(prototype),这么写:
function Animal(name) {
this.name = name
}
// 把getName放到了原型上
Animal.prototype.getName = function() {
return this.name
}
var animal = new Animal('a')
console.log(animal.getName())
复制代码
运行起来,getName能调用到。
prototype哪里来的
Javascript对每一个函数,自动给它加上prototype属性,它就是个对象。
实际上不是所有函数都有prototype的,下面会说到。
// 默认的原型对象
Animal.prototype = {
constructor: Animal // constructor是啥?先无视它
}
// 这只是个默认操作,如果我们用这样的写法
// 默认的对象就会被覆盖掉,constructor就没了
Animal.prototype = {
getName: function() {
return this.name
}
}
复制代码
运行代码,将两种写法运行下进行验证。
这样写有constructor
这样写没有
对象为什么可以直接调用prototype上的属性
new的时候,Javascript会给新建的对象设置一个__proto__属性,指向prototype。
animal.__proto__ = Animal.prototype
复制代码
然后,当在animal本身找不到属性时,会往__proto__里面找。
也既,animal.__proto__.getName 可以直接写成animal.getName
__proto__属性已被删除,你不可以在代码中使用它。MDN这里说了
基于原型的继承
想必继承的概念不必解释吧。假设我们现在需要写一个对象来表示狗,代码是这样的:
function Dog(name) {
this.name = name
}
Dog.prototype.getName = function() {
return this.name
}
Dog.prototype.bark = function() {
return 'wang'
}
复制代码
我们来分析下这段代码,先跟Animal对比下,问题就很明显,大部分代码跟Animal重复了。
所以,最好让Dog继承Animal。
首先,分析Animal,可以看到,Animal实例化后的对象属性分两种:
- 一种是对象本身的,写在类函数里面
- 一种是原型上的,写在prototype上面
对于第一种,可以像模拟new的实现一样,使用call函数把属性复制过来。
对于第二种,因为对象可以访问原型上的属性,让Dog的原型指向Animal的原型可以吗?
答案是,不可以。
如果Dog原型直接指向Animal原型,Dog跟Animal就共用一个原型对象了,在Dog里面加个属性,Animal里面就有了,假如我们再写个对象Cat继承Animal,那Cat里面岂不是也有了个bark属性?
第一次继承尝试
既然不能直接指向,我们先尝试复制一个Animal的原型对象,指向这个复制后的,代码如下
function Dog(name) {
// 运行Animal函数,将函数里面的this指向Dog的this,相当于给Dog添加属性
Animal.call(this, name)
}
// 复制Animal的原型对象,老规矩,当constructor不存在
Dog.prototype = Object.assign({}, Animal.prototype)
Dog.prototype.bark = function() {
return 'wang'
}
复制代码
Object.assign的介绍 看这里
运行代码,跟预期的结果相符
这样的方式,虽说可用,但也有限制。记住,Javascript是动态语言,如果在复制完Dog的属性之后,Animal的原型上增加了一个属性,Dog是没有的。
// 比如给Animal加个walk属性
Animal.prototype.walk = function() {
return 'walking'
}
复制代码
再运行代码,可以看到,walk属性dog里面并没有。
第二次继承尝试
我们再回顾下原型,JS在查找属性的时候,如果本身没找到,会去__proto__里面找。如果__proto__里面也没找到呢?
这里就要说下原型链了,简单的说,__proto__里面也可以继续嵌套__proto__的,属性查找就可以这样继续下去。
一个Dog的创建出来的对象如果能长这样子,不就能解决Animal原型上增加了属性Dog里面没有的问题了吗?
var dog = new Dog('dog')
// 这里只是示例,实际上并不相等
dog == {
name: 'dog',
__proto__: {
bark: function() {
return 'wang'
},
__proto__: {
getName: function() {
return this.name
}
}
}
}
复制代码
我们再回想之前讲到的:
- 第一,使用new创建对象时干了这么一件事
dog.__proto__ === Dog.prototype
复制代码
- 第二,原型的默认值是这样的,并且可以更改
Dog.prototype = {
constructor: Dog
}
复制代码
- 第三,这个上面没说过。
// 我们平时创建对象都习惯使用声明式语法
const prototype = {
constructor: Dog
}
// 其实还有个构造式语法
const prototype = new Object()
prototype.constructor = Dog
// 两者创建的对象时一样的。
复制代码
在构造式语法中,我们就可以轻易看出来,原型对象,它也是有__proto__的。
Dog.prototype.__proto__ === Object.prototype
复制代码
因此,只要Dog的原型不用默认的,用Animal的实例,像这样子
Dog.prototype = new Animal('ani')
复制代码
但是呢,这么写的话,Dog.prototype上就存在name属性了。
当然,这样问题也不大,根据属性查找规则,实例本身的name属性优先级是要高于原型上的,所以正常访问也不会访问出错。但是,如果把实例上的name属性删除了,dog.name还是会存在,也就是说这个属性怎么删也删不掉。
聪明的你,可能会想,那我把原型上的name删除不就完了吗?这。。。也行吧。
我们有更好的解决办法,既然我们想要的只是Animal的原型,那就可以这样
function T() {}
T.prototype = Animal.prototype
Dog.prototype = new T()
复制代码
到这里,我们就实现一个理想的继承了。
来看下JS的继承完整代码
function Animal(name) {
this.name = name
}
Animal.prototype.getName = function() {
return this.name
}
function Dog(name) {
Animal.call(this, name)
}
function T() {}
T.prototype = Animal.prototype
Dog.prototype = new T()
Dog.prototype.bark = function() {
return 'wang'
}
复制代码
看下运行效果
再看下这代码,别说优雅了,看都很难看懂好吧。
连封装成一个函数都不好封装,因为Dog里面为了得到Animal的属性,是必须要调用Animal的。
ES6的新写法
所幸,现在是2021年了,javascript的划时代版本,ES6,已经推出6年了。来看下ES6版本的代码
class Animal {
constructor(name) {
this.name = name
}
getName() {
return this.name
}
}
class Dog extends Animal {
constructor(name) {
super(name)
}
bark() {
return 'wang'
}
}
复制代码
是不是看着都舒服,但是这些class extends super都是啥啊?
类理论
说到这些关键字,就不得不提下类。如果你学过Java,就很熟悉类是什么,简单说一句,类就是一种设计模式,对继承思想的一种实现。
你可能从来没把类当成一种设计模式来看,但看看我们用Javascript实现继承,从头到尾都没提到类,不也照样实现了,只是代码好丑,所以Javascript在ES6版本就借用了类的语法。
要记住,继承,是一种编程思想;类,是一种实现方案。
Javascript另辟跷径的用原型也做到了,思路都是相通的。
关于类与继承的更多资料,推荐一本书,你不知道的Javascript,上卷第二部分第四章,深度好书,每一章都值得看。
再顺便看下Java的继承代码实现
public class Animal {
private String name = "";
public Animal(String name) {
this.name = name;
}
public String getName() {
return this.name;
}
}
public class Dog extends Animal {
public Dog(String name) {
super(name);
}
public String bark() {
return "wang";
}
}
复制代码
是不是看起来就挺像的。但是,Javascript中的类,就是语法糖,那些类语法的关键字class extends super啊,都是虚的,记住能这么写就行,实际运行时还是原型那一套。
对比Java的类以及Javascript的原型对继承的实现
Javascript的原型实现继承我们已经基本清楚了,那它跟基于类的继承有什么区别呢?
这里只是为了跟Javascript做个对比,会说的尽量简单,大概思路清楚就行。
如果想知道Java继承的详细实现,可以去这里看看
概念对应
首先理清两个概念跟Javascript的对应关系。
- 变量:指的是除了函数之外的数据类型
- 方法:就是函数
先来看下Java的class是干嘛用的。不去管public private这些权限修饰符。class里面有
- 静态方法
- 静态变量
- 实例方法
- 实例变量
- 构造函数
以Animal为例,静态属性跟静态方法的调用是这样的
Animal.a
Animal.b()
复制代码
在Javascript中,Animal首先是个对象,然后才是个函数,这也意味着,我们可以给Animal本身添加属性,这就模拟了Java中的静态变量、方法了。
函数跟对象的区别,看这里
再看下实例方法跟实例变量
Javascript写在函数里面的this.xxx就是实例属性,而且,比Java更强大的是,Javascript中函数是一等公民。
// Javascript可以把函数也当成变量
class A {
constructor() {
this.go = function() {
return 'gogogo'
}
}
}
复制代码
// Java中只能把函数当成方法
public class A {
public String go() {
return 'gogogo';
}
}
复制代码
不过在Java8之后引入了lambda表达式,这里就不管它了。
至于构造函数,Javascript中就是Animal本身了。
具体实现
先加载类
你可以这么理解,Java在加载类的时候,先把类变成一个对象,里面放着静态属性、静态方法、实例方法、构造函数,以及对父类的引用。
而在Javascript中,静态属性静态方法都是函数的属性,实例方法在原型上,构造函数就是函数本身,对父类的引用也在原型上。
再实例化对象
当Java执行new Animal的时候,就会调用类的构造函数,并逐级往上,调用所有父类的构造函数,将所有实例变量放到new出来的对象,并存放Animal类的引用。
以animal跟dog为例,大概长这样子
dog = {
'类Dog的引用': {
'父类Animal的引用': {
'实例方法getName': function() { return this.name }
}
'实例方法bark': function() { return 'wang' }
}
'父类Animal的实例变量': {
name: 'parent'
},
'类Dog的实例变量': {
name: 'parent'
}
}
复制代码
大概就是这样,所以,可以把类看成Javascript的原型,都是引用,不会把这些函数放在自己身上。
这里可以看出一个区别,Javascript继承时如果遇到子类的属性跟父类一样,子类会直接覆盖,而Java会两者都保留,而且通过多态可以切换访问父类与子类的实例变量。
对于方法的查找也跟Javascript差不多,都是逐级向上。
总的来说,基于原型跟基于类的实现,其实大同小异。
尴尬的constructor
constructor,就是构造函数。干嘛用的呢?先别急,看下Java,没办法,构造函数就是类理论的东西,Javascript老想着模拟类咱也没办法。
Java为什么需要构造函数
因为Java实例化一个对象的模板是类,而Javascript的模板是函数,函数可以直接执行,类怎么直接执行呢?
如果你非要问函数为什么可以直接执行,那就没完没了,不是一篇文章讲得完的。
因此,同样是 new Animal() 这么一句代码。
- Java的Animal是一个类,需要借助构造函数执行初始化操作
- Javascript的Animal是一个函数,直接就执行初始化操作了
constructor有啥用
我们再想想,Javascript,它需要再来个构造函数吗?明显不需要嘛。
那Javascript搞了这个constructor有啥用呢?
也不能说没用吧,至少面试的时候面试官会问下。开玩笑的。
不过,在ES6之前,constructor还真的没啥用,就规范里说了原型上要有个构造函数,指向函数本身,然后就没了,Javascript中没有任何地方使用到原型上的构造函数。
到了ES6,constructor终于被用上了,除此之外,有些第三方库的代码也会使用这个属性。
关于构造函数干嘛用,请看这里
只要记住原型上有个构造函数,指向函数本身,不能弄没了,有人会用。
prototype constructor __proto__的三角关系
大部分都理不清这些关系,我感觉是因为对原型不够了解造成的,特别是原型跟类混在一起就更乱了。
我们以ES6版本的Animal跟Dog为例,一步步来。
// new一条狗
const dog = new Dog('dog')
复制代码
把这条狗展开
再把Dog这个函数展开
有点尴尬,这个展不开,不管了。
dog.__proto__ === Dog.prototype
对比dog的展开,是不是明摆着一样的。再用全等号验证下
// 那么
dog.__proto__.__proto__ === Dog.prototype.__proto__
// 并且
Dog.prototype === new Animal()// 可以当作是Animal的实例。假设叫它animal
// 又因为
animal.__proto__ === Animal.prototype
// 综上所述
dog.__proto__.__proto__ === Animal.prototype
// 同理可得
dog.__proto__.__proto__.__proto__ === Object.prototype
// 不管继承了多少次,这么推导下去一定能一级一级的推导出来
复制代码
dog.constructor === Dog
记住,constructor只在原型中存在。
// 因为
Object.hasOwnProperty.call(dog, 'constructor') === false
// 所以
dog.constructor === dog.__proto__.constructor
// 又因为
dog.__proto__ === Dog.prototype
// 所以
dog.__proto__.constructor === Dog.prototype.constructor
// 所以
dog.constructor === Dog
// 再来个乱七八糟的
Dog.prototype.__proto__.constructor == Animal
// 自己推导试试
复制代码
Dog.constructor === Function
同对象一样,函数也有构造式写法
const Dog = new Function(name, 'Animal.call(this, name)')
复制代码
这样就可以看出来,Dog,是Function的一个实例。
来一些探索
请注意,下面涉及到Javascript本质的讨论,比较有难度,我也还没吃透,难免有些错误
Object哪来的
可能你会说,Object是Javascript内置的,是这么说没错,但我们来更进一步试试看。
先打印下Object
看不到Object的源代码代码。
[native code]是什么意思呢,可以看下别人怎么说 别人的回答
那么我们再试试这个。
哦,Object居然是一个函数。倒也是,new Object() 也很好的证明的这一点,因为new后面只能是函数。
如果new一个对象的时候,函数没有参数,不带括号也可以的哦,new Object也是正确语法
那么,我们就可以说,下面的等式也是成立的
// 自己推导,不解释
Object.__proto__ === Function.prototype
Object.constructor === Function
Object.prototype === ? // prototype是创建函数默认加上的,下面继续讨论
Object.prototype.__proto__ === null // 没了,原型链到此结束
复制代码
这里有个地方打破了我的认知,默认对象都是Object的实例。
那么Object.prototype是对象吗?当然是。
那么它是Object的实例吗?居然不是。
如果是,那么,Object.prototype.__proto__就应该等于Object.prototype。但是它等于null啊。
不过这也好理解,如果这样设计,原型链就没有终点了,这会导致属性的查询陷入死循环。
再看看Function
跟Object一样,打印出来也是[native code],也是一个函数。
那么问题来了,Function的constructor __proto__ prototype会是什么呢?
我看来看下浏览器的打印
说实话,看到这些玩意我人都傻了。
我一直以为prototype默认就是个new Object,可这是啥?函数?不过函数也是一个可执行的对象,这总没错吧,来试试看。
还好,这个是对的。
那么,所有函数都有prototype这个结论靠谱吗?经过一番查证,还真找到了答案,不靠谱
意不意外,函数不一定有prototype哦。
从上面的运行结果我们可以得出以下结论
Function.constructor === Function
Function.__proto__ === Function.prototype
复制代码
这说明了什么?Function自己创建了自己?对于这个答案,我是不满意的。
继续找答案,这种问题应该要找RFC规范吧,然而,我死活没找到,RFC就说了要这么干,没说为什么。
所幸,不止我一个人有疑问,答案在这,这就是个先有鸡还是先有蛋的问题。不想写了,有兴趣的自己去看吧。