概览
最近在重新看js基础,索性就将继承、闭包、原型链这三个原生js中比较重要的点写篇文章总结一下。自己明白理解是一回事,写了文章让别人看明白是另外一回事,通过讲述,自己也能进步。
原型
背景
JS
的作者Brendan Eich
在设计这门编程语言时,只是为了让这门语言作为浏览器与网页互动的工具。他觉得这门语言只需要能完成一些简单操作就够了,比如判断用户是否填写了表单。
基于简易语言的设计初衷,作者觉得JS
不需要有类似java
等面向对象语言所拥有的“继承”机制。但是考虑到JS
中一切皆对象(所有的数据类型都可以用对象来表示),必须有一种机制,把所有的对象联系起来,实现类似的“继承”机制。
不同于大部分面向对象语言,ES6
之前并没有引入类(class
)的概念,JS
并非通过类而是通过构造函数来创建实例,使用prototype
原型模型来实现“继承”。
构造函数
在 JavaScript
里,构造函数通常是用来实现实例的,JavaScript
没有类的概念,但是有特殊的构造函数。构造函数本质上是个普通函数,充当类的角色,主要用来创建实例,并初始化实例,即为实例成员变量赋初始值。
构造函数和普通函数的区别在于,构造函数应该遵循以下几点规范:
- 在命名上,构造函数首字母需要大写;
- 调用方式不同,普通函数是直接调用,而构造函数需要使用
new
关键字来进行调用; - 在构造函数内部,
this
指向的是新创建的实例; - 构造函数中没有显示的
return
表达式,一般情况下,会隐式地返回this
,也就是新创建的对象,如果想要使用显式的返回值,则显式的返回值必须是对象,否则依然返回实例。
原型的规则
构造函数是用来创建实例的
// 步骤1:新建构造函数
function Person(name) {
this.name = name;
this.sayName = function() {
console.log(this.name);
}
}
// 步骤2:创建实例
var person = new Person('yang');
此时,如下图所示,针对步骤1,当构造函数被创建时,会在内存空间新建一个对象,构造函数内有一个属性 prototype
会指向这个对象的存储空间,这个对象称为构造函数的原型对象。
针对步骤2,如下图所示,person
是通过 Person
构造函数创建的实例,在 person
内部将包含一个指针(内部属性),指向构造函数的原型对象,这个指针称为 [[prototype]]
。
目前,大部分浏览器都支持 __proto__
这个属性来访问构造函数的原型对象,就像这里,person.__proto__
指向 Person.prototype
的对象存储空间。
由上面示例图知道,实例 person
如果访问原型对象,需要使用 __proto__
这个属性。
事实上,__proto__
是一个访问器属性(由一个 getter
函数和一个 setter
函数构成),但作为访问 [[prototype]]
的属性,它是一个不被推荐的属性, JavaScript
规范中规定,这个属性仅在浏览器环境下才能使用。[[prototype]]
是内部的而且是隐藏的,当需要访问内部 [[prototype]]
时,可以使用以下现代方法:
// 返回对象 `obj` 的 `[[prototype]]`。
Object.getPrototypeOf(obj);
// 将对象 `obj` 的 `[[prototype]]` 设置为 `proto`。
Object.setPrototypeOf(obj, proto)
// 利用给定的 `proto` 作为 `[[prototype]]` 和属性描述符(可选)来创建一个空对象。
Object.create(proto[, descriptors])
在默认情况下,所有的原型对象都会自动获得一个 constructor
的属性,这个属性包含一个指向 prototype
所在函数的指针,即 constructor
属性会指向构造函数本身。
此外,Person.prototype
指向的位置是一个对象,也包含有内部 [[prototype]]
指针,这个指针指向的是 Object.prototype
,是一个对象。这个关系表示,Person.prototype
是由 Object
作为构造函数创建的。
需要注意的是,原型是可以被改写的。但是 JavaScript
中对其做了规定,只可以被改写成对象,如果改写成其他值(空值 null
也不行),会自动被忽略,会让原型链下一级来替换这个被改写的原型。
原型的作用
- 属性公用化:原型可以存储一些默认属性和方法,并且在各个不同的实例中可以共享使用;
- 继承:在子类构造函数中借用父类构造函数,再通过原型来继承父类的原型属性和方法,模拟继承的效果;
- 节省存储空间:结合第1点,公用的属性和方法多了,对应需要的存储空间也减少了。
// 第一步 新建构造函数
function Person(name) {
this.name = name;
this.age = 18;
this.sayName = function() {
console.log(this.name);
}
}
// 第二步 创建实例 1
var person1 = new Person('1号');
// 第三步 创建实例2
var person2 = new Person('2号');
// 结果均为 true
person1.__proto__ === Person.prototype;
person2.__proto__ === Person.prototype;
// 1号 2号
console.log(person1.name, person2.name);
// 18 18
console.log(person1.age, person2.age);
原型链
JavaScript
中,万物皆对象(所有的数据类型都可以用对象来表示),对象与对象之间存在关系,并不是孤立存在的,对象之间的继承关系,在JavaScript
中实例对象通过内部属性[[prototype]]
指向父类对象的原型空间,直到指向浏览器实现的内部对象Object
为止,Object
的内部属性[[prototype]]
为null
,这样就形成了一个原型指向的链条,这个链条称为原型链。
当访问对象的属性时,会先在对象自身属性中查找,如果有则直接返回使用,如果没有则会顺着原型链指向继续寻找(不断查找内部属性 [[prototype]]),直到寻找浏览器内置对象的原型,如果依然没有找到,则返回 undefined。
需要注意的是,原型链中访问器属性和数据属性在读写上是有区别的(点击了解访问器属性和数据属性)。如果在原型链上某一级设置了访问器属性(假设为 age
),则读取 age
时,直接按访问器属性设置的值返回;写入时也是以访问器属性为最优先级。在数据属性的读写上,读取时,会按照原型链属性查找进行查找;写入时,直接写入当前对象,若原型链中有相同属性,会被覆盖。
可以结合以下代码来对原型链进行分析:
// 第一步 新建构造函数
function Person(name) {
this.name = name;
this.age = 18;
this.sayName = function() {
console.log(this.name);
}
}
// 第二步 创建实例
var person = new Person('person');
复制代码
根据以上代码,可以得到下面的图示:
第一步中,新建 Person
的构造函数,此时原型空间被创建;第二步中,通过 new
构造函数生成实例 person
,person
的 [[prototype]]
会指向原型空间。
很多人容易忽视的是浏览器对于下面的处理,这里 Person.prototype.__proto__
指向内置对象,因为 Person.prototype
是个对象,默认是由 Object
函数作为类创建的,而 Object.prototype
为内置对象。
而 Person.__proto__
指向内置匿名函数 anonymous
,因为 Person
是个函数对象,默认由 Function
作为类创建,而 Function.prototype
为内置匿名函数 anonymous
。
这里还需要注意一个点,Function.prototype
和 Function.__proto__
同时指向内置匿名函数 anonymous
,这样原型链的终点就是 null
,而不用担心原型链查找会陷入死循环中。
继承
- 概念:通过某种方式,可以让某个对象访问到其他对象中的属性、方法,这种方式称之为继承。
- 背景:有些对象会有方法,而这些方法都是函数(函数也是对象),如果把这些方法都放在构造函数中声明,则会产生内存浪费
- 注意:js的继承都是建立在:方法在原型上创建、属性在实例上创建的前提下
实现继承的方式
1、 借助call
function Parents(age, live) {
this.name = '借助call方式实现继承'
this.age = age
this.live = live
}
function Child() {
Parents.call(this, ...arguments)
}
let child = new Child(18, true)
console.log('child: ', child)
缺点:这样写的时候子类虽然能够拿到父类的属性值, 但是问题是父类原型对象中一旦存在方法那么子类无法继承。
2、借助原型链
function Parents1(age) {
this.name = "借助原型链实现继承"
this.age = age
}
function Child1() {
this.type = 'Child1'
}
Child1.prototype = new Parents1()
let child1 = new Child1()
console.log("child1: ", child1.name)
缺点:改变实例的属性会影响到父类的属性,因为共用一个原型对象(引用类型)
3、 将前两中组合(组合式继承)
function Parents2(age) {
this.name = '借助组合式实现继承'
this.age = age
this.arr = [1, 2, 3]
}
function Child2() {
this.type = 'Child2'
Parents2.call(this, ...arguments)
}
Child2.prototype = new Parents2()
let child2 = new Child2(12)
let anthorChild2 = new Child2(13)
child2.arr.push(4)
console.log('child2: ', child2)
console.log('anthorChild2: ', anthorChild2)
缺点:这种继承的问题 那就是Parent2的构造函数会多执行了一次(Child2.prototype = new Parent2();)
4、组合继承的优化
function Parents3(age) {
this.age = age
this.name = '组合继承的优化1'
}
function Child3() {
Parents.call(this, ...arguments)
this.type = 'Child3'
}
// 这里让将父类原型对象直接给到子类,父类构造函数只执行一次,
// 而且父类属性和方法均能访问
Child2.prototype = Parents3.prototype
缺点:子类实例的构造函数是Parent3,显然这是不对的,应该是Child3。
5、寄生组合式继承
function Parents4(age) {
this.age = age
this.name = '寄生组合式继承'
}
function Child4() {
Parents.apply(this, [...arguments])
this.type = 'Child4'
}
Child4.prototype = Object.create(Parents4.prototype)
Child4.prototype.constructor = Child4
这是最推荐的一种方式, 接近完美的继承, 它的名字也叫做寄生组合继承。
6、ES6的extends
它用的就是寄生组合式继承,但是加了一个Object.setPrototypeOf(subClass, superClass)
是用来继承父类的静态方法。这也是原来的继承方式疏忽掉的地方。
扩展:面向对象继承的问题,无法决定继承哪些属性, 所有属性都得继承。
- 一方面父类是无法描述所有子类的细节情况的,为了不同的子类特性去增加不同的父类,代码势必会大量重复。
- 另一方面一旦子类有所变动,父类也要进行相应的更新,代码的耦合性太高,维护性不好。
- 用组合, 这也是当今编程语法发展的趋势, 比如golang完全采用的是面向组合的设计方式。
- 面向组合就是先设计一系列零件, 然后将这些零件进行拼装, 来形成不同的实例或者类。
例如:不同的车有不同的功能
function drive(){
console.log("发动");
}
function music() {
console.log("音乐")
}
function addOil() {
console.log("加油")
}
// compose是一个组合各种方法的方法
// 普通汽车
let car = compose(drive, music, addOil);
// 新能源
let newEnergyCar = compose(drive, music);
闭包
闭包是指有权访问另外一个函数作用域中的变量的函数(红宝书)
闭包是指那些能够访问自由变量的函数。(MDN)其中自由变量, 指在函数中使用的, 但既不是函数参数arguments也不是函数的局部变量的变量,其实就是另外一个函数作用域中的变量。)
作用域
说起闭包,就必须要说说作用域,ES5种只存在两种作用域:1、函数作用域。2、全局作用域
当访问一个变量时, 解释器会首先在当前作用域查找标示符,如果没有找到, 就去父作用域找, 直到找到该变量的标示符或者不在父作用域中, 这就是作用域链,每一个子函数都会拷贝上级的作用域, 形成一个作用域的链条。
let a = 1;
function f1() {
var a = 2
function f2() {
var a = 3;
console.log(a); //3
}
}
在这段代码中, f1的作用域指向有全局作用域(window) 和它本身,而f2的作用域指向全局作用域(window)、 f1和它本身。而且作用域是从最底层向上找, 直到找到全局作用域window为止,如果全局还没有的话就会报错。闭包产生的本质就是, 当前环境中存在指向父级作用域的引用。
function f2() {
var a = 2
function f3() {
console.log(a); //2
}
return f3;
}
var x = f2();
x();
这里x会拿到父级作用域中的变量, 输出2。 因为在当前环境中,含有对f3的引用, f3恰恰引用了window、 f3和f3的作用域。 因此f3可以访问到f2的作用域的变量。那是不是只有返回函数才算是产生了闭包呢?回到闭包的本质,只需要让父级作用域的引用存在即可。
var f4;
function f5() {
var a = 2
f4 = function () {
console.log(a);
}
}
f5();
f4();
让f5执行,给f4赋值后,等于说现在f4拥有了window、f5和f4本身这几个作用域的访问权,还是自底向上查找,最近是在f5中找到了a,因此输出2。在这里是外面的变量f4存在着父级作用域的引用, 因此产生了闭包,形式变了,本质没有改变。
场景
- 返回一个函数。
- 作为函数参数传递。
- 在定时器、 事件监听、 Ajax请求、 跨窗口通信、 Web Workers或者任何异步中,只要使用了回调函数, 实际上就是在使用闭包。
- IIFE(立即执行函数表达式) 创建闭包, 保存了全局作用域window和当前函数的作用域。
var b = 1;
function foo() {
var b = 2;
function baz() {
console.log(b);
}
bar(baz);
}
function bar(fn) {
// 这就是闭包
fn();
}
// 输出2,而不是1
foo();
// 以下的闭包保存的仅仅是window和当前作用域。
// 定时器
setTimeout(function timeHandler() {
console.log('111');
}, 100)
// 事件监听
// document.body.click(function () {
// console.log('DOM Listener');
// })
// 立即执行函数
var c = 2;
(function IIFE() {
// 输出2
console.log(c);
})();
经典的一道题
for (var i = 1; i <= 5; i++) {
setTimeout(function timer() {
console.log(i)
}, 0)
} // 6 6 6 6 6 6
// 为什么会全部输出6? 如何改进, 让它输出1, 2, 3, 4, 5?
解析:
- 因为setTimeout为宏任务, 由于JS中单线程eventLoop机制, 在主线程同步任务执行完后才去执行宏任务。
- 因此循环结束后setTimeout中的回调才依次执行, 但输出i的时候当前作用域没有。
- 往上一级再找,发现了i,此时循环已经结束,i变成了6,因此会全部输出6。
// 1、利用IIFE(立即执行函数表达式)当每次for循环时,把此时的i变量传递到定时器中
for (var i = 0; i < 5; i++) {
(function (j) {
setTimeout(() => {
console.log(j)
}, 1000);
})(i)
}
// 2、给定时器传入第三个参数, 作为timer函数的第一个函数参数
for (var i = 0; i < 5; i++) {
setTimeout(function (j) {
console.log(j)
}, 1000, i);
}
// 3、使用ES6中的let
// let使JS发生革命性的变化, 让JS有函数作用域变为了块级作用域,
// 用let后作用域链不复存在。 代码的作用域以块级为单位,
for (let i = 1; i <= 5; i++) {
setTimeout(function timer() {
console.log(i)
}, 2000)
}
说明
以上部分内容来源与自己复习时的网络查找,也主要用于个人学习,相当于记事本的存在,暂不列举链接文章。如果有作者看到,可以联系我将原文链接贴出。