曾经以为遥遥无期的2022年,转眼就到了。自大学本科毕业以来,从事前端开发行业也有五年了,对于日常工作中的业务需求开发基本都已游刃有余,但总感觉自己还存在很大的提升空间,也许是遇到大家口中常说的每隔三年五年就会面临的上升瓶颈了吧。
一个优秀的前端工程师,不仅能高效完成页面的开发,还能掌握和实践一系列前端工程化的技术,包括脚手架与项目脚本,测试体系、监控体系、项目规范、项目构建和打包、项目部署和运维等。不仅能做项目,而且有足够的经验和方案做好项目,具体可以是性能优化方面或者是技术方面,性能优化如长列表优化、加载性能优化、提升项目的可维护性等,技术方面如微前端、服务端渲染、跨端开发等。
当感到迷茫的时候,需要做的是及时调整好自己的心态,捋清思路,回过头来反思和沉淀一下自己的过去,以让自己的能力能得到进一步的提升。
过去在上下班碎片化时间阅读一些公众号推文的时候,经常感觉自己的基础知识不过关,掌握的前端开发知识未成体系,现在就趁着过年前没那么忙复习一下,计划接下来写一个深入理解JavaScript的系列文章,这篇博客是系列的第一篇,从 JavaScript 的原型与原型链说起。
JavaScript的面向对象
学习过Java的同学应该都知道,面向对象的语言有三大特性:封装性、继承性、多态性。但是JavaScript不是严格意义上的面向对象编程语言,它是一门基于原型的语言,通过原型可以实现继承的特性。
举个例子:
function Person(name, age) {
this.name = name
this.age = age
}
Person.prototype.language = 'chinese'
Person.prototype.sayName = function() {
console.log('My name is ' + this.name)
}
Person.prototype.sayAge = function() {
console.log('My age is ' + this.age)
}
Person.prototype.getLanguage = function() {
console.log(this.language)
}
let foo = new Person('foo', 25)
console.log(foo.sayName()) // My name is foo
console.log(foo.sayAge()) // My age is 25
console.log(foo.getLanguage()) // chinese
从这个例子我们可以看到,构造函数Person中没有sayName方法和sayAge方法,但是foo实例对象却成功调用了这两个方法,那为什么呢?这是因为它从原型对象中继承了这两个方法。通过这个例子我们也引出今天要讨论的话题,JavaScript中的原型、实例与构造函数三者之间有什么内在的联系呢?
原型prototype
每个函数都有一个 prototype 属性,它指向通过这个函数创建的实例对象的原型,实例对象都会从这个原型上继承属性,也就是说原型上的属性和方法会被所有实例对象共用。那什么是原型呢?原型可以理解为实例对象在被创建的时候,就会有一个与之相关联的对象,这对象就是我们经常说的原型。
构造函数与原型的关系可以使用下图来表示:
那实例跟原型是怎么联系起来的呢?
__proto__属性
每个实例对象都有 __proto__私有属性,它指向了构造函数的原型对象,也就是说实例对象是通过 __proto__属性与原型联系起来的。我们再完善一下实例、构造函数、原型之间的关系图:
需要注意的是,__proto__属性从来没有被包括在ECMAScript 的语言规范中,但是所有现代浏览器都实现了它。__proto__属性已在ECMAScript6语言规范中标准化,用于确保web浏览器的兼容性,因此它未来也将被支持,但它已不推荐使用,现在更推荐得是使用Object.getPrototypeOf/Reflect.getPrototypeOf和Object.setPrototypeOf/Reflect.setPrototypeOf。
__proto__属性是继承于Object.prototype 的一个访问器属性 ,暴露了一个对象的内部 [[Prototype]] 。如果一个对象设置了其他的.__proto__属性,那么将会覆盖原有的构造器原型对象,可以理解为如果改变了对象的 __proto__属性就会改变原型链。下文会讲原型链。
上文提到每个实例对象都会有 __proto__属性,使用不同方式创建的对象,它们的__proto__分别指向什么呢?
对象字面量创建对象
let person = {
name: 'tom',
age: 22
}
从控制台输出可以看到,通过对象字面量构造出的对象,其 __proto__指向 Object.prototype,这里也可以知道 Object 也是一个构造函数。
构造函数创建对象
function Person() {}
let p = new Person()
上面这种形式创建对象的方式就是通过构造函数创建对象,这里的构造函数是 Person 函数。上文也讲过了,通过构造函数创建的对象,其__proto__指向的是构造函数的prototype属性指向的对象,即构造函数的原型对象。
Object.create创建对象
let person = {
name: 'tom',
age: 22
}
const subPerson = Object.create(person);
可见通过Object.create 创建的对象subPerson 的 __proto__属性指向了person,看以下Object.create的 polyfill 代码:
if (typeof Object.create !== "function") {
Object.create = function (proto, propertiesObject) {
if (typeof proto !== 'object' && typeof proto !== 'function') {
throw new TypeError('Object prototype may only be an Object: ' + proto);
} else if (proto === null) {
throw new Error("This browser's implementation of Object.create is a shim and doesn't support 'null' as the first argument.");
}
if (typeof propertiesObject !== 'undefined') throw new Error("This browser's implementation of Object.create is a shim and doesn't support a second argument.");
function F() {}
F.prototype = proto;
return new F();
};
}
通过这段polyfill代码不难理解上述例子为什么 subPerson 的 __proto__属性指向了person。
既然实例对象有__proto__属性指向原型对象,构造函数有prototype属性指向原型对象,那么原型对象有没有属性指向实例和构造函数呢?指向构造函数的有,即 constructor,指向实例对象的没有,因为构造函数可以创建很多实例对象,自然没有与原型对象一对一关系的属性,但是每个实例对象都是从原型继承属性和方法。
constructor
因为原型对象可以通过constructor属性指向构造函数,事实上这个属性是通过原型链从 Object.prototype 继承而来的。进一步完善实例对象、构造函数与原型对象的关系图:
实例对象与原型
从文章开头的例子,我们就已经了解到实例对象会从它的构造函数的原型对象继承属性和方法。如 foo 对象调用 getLanguage 方法的时候,会获取对象的 language 属性并打印出来,但是 foo 对象上并没有设置 language 属性,所以会去与它关联的原型对象上查找,也就是 foo.__proto__ === Person.prototype 对象,这个对象上正好有这个属性,属性值为 "chinese",于是就被正确打印出来。但是如果实例对象的构造函数的原型上也没有这个属性呢?那么就会去原型的原型对象上查找,直到找到属性为止。
原型链
既然构造函数的原型也是一个对象,那么它是由哪个构造函数创建出来的呢?它的 __proto__属性又指向谁呢?从上文提到对象创建的三种方式中,对象字面量创建的对象由最原始的 Object 构造函数创建,其实构造函数的原型对象也是由它创建的,原型 的__proto__属性自然指向了 Object.prototype,我们进一步完善实例对象、构造函数与原型的关系图:
Object.prototype 也是一个对象,那么它的__proto__属性又指向谁呢?null,没错,就是 null。
通过 __proto__属性从将 foo 实例对象一直延伸到 null 的链状结构就是原型链。