这些JS设计模式的基础知识点你都会了吗?

367 阅读10分钟

最近在学习关于设计模式相关内容,设计模式是面向对象的,而JavaScript是一门动态语言,没有类型约束,实际上是没有类这个概念,但是基于前端行业的发展,ECMAScript标准以及TypeScript的出现,JavaScript也变得逐渐规范起来,因此程序开发的设计模式便可运用到前端项目当中。想要弄清楚设计模式,首先就是要掌握JS中对于继承、多态的实现。逐步向一名优秀的软件工程师靠近。

笔者对于JavaScript中如何实现继承、多态的知识一直比较零散,遂尽可能地全面由表及里地梳理相关内容。

希望大家通过阅读本文都能厘清概念,有所收获!

一、 this指向

1.1 函数中的this

函数在创建的时候会创建两个变量:thisarguments

  • arguments:是实参类数组对象的引用
  • this:指向函数的执行上下文,推荐阅读《JavaScript 的 this 原理》从内存角度理解 this 的指向原理
// 数组
var arr = [1, 2, 3, 4];
// 类数组对象
var arrObj = {0: 1, 1: 2, 2: 3, 3: 4, length: 4 };

简单看个例子:

function a() {
    console.log(this);
}

var obj = {
    b: a,
    c: {
        d: a,
    },
};

a();       // 等同于Window.a(), this指向全局
obj.b();   // this指向obj
obj.c.d(); // this指向obj.c

在Chrome浏览器下运行的结果

1.2 call()、apply()和bind()

在JavaScript中函数的原型链上还定义了call()apply()bind()方法用于更改this的指向

1.2.1 Function.prototype.call()

call()方法,可理解为“调用”,其作用就是将函数内的运行时this值指向指定的对象

其语法:

function.call(thisArg, [, arg1[, arg2[, ...argN]]])

例如:

var sex = 'Girl';
var obj = { sex: 'Boy' }
function getSex() { console.log(this.sex); }

getSex();         // output: Girl
getSex.call(obj); // output: Boy

最后一行使用 call() 方法将 getSex 函数的 this 指向了 obj 对象,因此 this.sex === obj.sex

1.2.2 Function.prototype.apply()

apply()call() 方法的作用相同,可以理解为“应用”,不同点在于语法,其第二个参数是接受参数数组

function.apply(thisArg, [argsArray])

看一个实例:

var sex = 'Girl';
var obj = { sex: 'Boy' }
function getInfo(name, age) { console.log('name:', name, ' age:', age, ' sex:', this.sex); }

getInfo('DYBOY', 18);               // output: name: DYBOY  age: 18  sex: Girl
getInfo.apply(obj, ['DYBOY', 18]);  // output: name: DYBOY  age: 18  sex: Boy

1.2.3 Function.prorotype.bind()

bind() 函数与call()类似,但不会像 call() 那样立即执行,而是返回一个改变了 this 指向的新的函数

其语法:

let boundFunc = func.bind(thisArg[, arg1[, arg2[, ...argN]]])

示例:

var obj = {
    name: 'DYBOY',
    getName: function() {
        'use strict'
        console.log('output: ', this);
    }
}

var unboundGetName = obj.getName;
var boundGetName = obj.getName.bind(obj);

unboundGetName();  // output:  undefined
boundGetName();    // output:  {name: "DYBOY", getName: ƒ}

1.3 this指向总结

综上所述,可以简单总结this的指向规则:

  1. 作为对象的方法调用this 指向该对象
  2. 作为普通函数调用this 指向全局对象(globalThis —— MDN),浏览器中一般是 WindowECMAScript5 严格模式下 this 指向 undefined
  3. 构造函数调用:通常 this 指向类的实例化对象
  4. Function 原型上定义的call()apply()bind() 函数可以改变 this 指向指定的对象,call()bind() 除第一个参数均接受参数列表,apply() 第二个参数接受参数数组

二、原型对象(Prototype)&原型链(Prototype Chain)

2.1 原型对象

  • 原型对象:在定义函数时被创建,该函数的 prototype 属性指向的就是原型对象

同时定义了两种读写原型的属性:

  • 隐式原型(proto):所有引用类型(函数、数组、对象)都有 __proto__ 属性,例如 arr.__proto__,该属性
  • 显式原型(prototype):所有函数拥有 prototype 属性,例如:func.prototype

2.2 原型链

“JavaScript中一切皆对象” —— 网友

“皆对象”这一特性,实际就是JavaScript中的原型链规则所形成的一种类型链式关联关系的表达。

在JavaScript中,实例对象与原型之间的链接,叫做原型链。其基本思想是利用原型让一个引用类型继承另一个引用类型的属性和方法。然后层层递进,就构成了实例与原型的链条,这就是所谓原型链的基本概念。

而原型链的规则也可以通过下图弄清楚,感兴趣的同学不妨动手推敲下面这张图的关系:

一图看懂原型链

Javascript 中访问对象的属性或方法,如果当前对象不存在就会在当前对象所在的原型链上逐级向上寻找,直到 null,如果最后还是不存在就会返回 undefined

欲完全掌握原型链,笔者推荐阅读《一文搞懂JavaScript原型链

三、new操作符

new 操作符创建一个用户定义的对象类型的实例或具有构造函数的内置对象的实例。

其语法:

new constructor[([arguments])]

3.1 构造函数(constructor)

通过语法可知 new 操作符后面紧跟一个函数,这里我们把这个函数称作:构造函数

示例:

const Person = function(name, sex, age) {
	this.name = name;
	this.sex = sex;
	this.age = age;
}

const p1 = new Person('DYBOY', 'male', 18);
const p2 = new Person('DYGIRL', 'female', 19);

console.log(p1.name, p1.sex, p1.age);  // output: DYBOY male 18
console.log(p2.name, p2.sex, p2.age);  // output: DYGIRL female 19

通过 new 操作符能够借助构造函数创建多个对象实例,JavaScript 语言因为其特殊性,想要弄明白如何实现对象继承,就得首先搞清楚对象的实例是如何生成的,也就是 new 操作符的执行过程中到底做了什么。

3.2 拆解new操作符

大家可能会有一个疑问,为啥 new 一个函数,就会返回一个对象实例?

在MDN上有解释 new 操作符的执行过程:

  1. 创建一个空的新对象
  2. 给新对象的隐式原型(__proto__)指向构造函数的原型对象(Function.prototype
  3. constructor(构造)函数中的 this 指向新对象,并执行构造函数代码
  4. 返回新对象

那么根据上面的步骤,我们就可以不通过 new 操作符,来实例化一个p3

const p3 = (function() {
	// 1. 创建新对象
	const newObj = {};
	// 2. __proto__指向构造函数的原型对象
	newObj.__proto__ = Person.prototype;
	// 3. 更改构造函数this指向新对象并执行
	Person.call(newObj, '卢本伟', '男', 999);
	// 4. 返回新对象
	return newObj;
})();

console.log(p3.name, p3.sex, p3.age);
// output: 卢本伟 男 999

在Chrome浏览器控制台执行对比发现生成的 p1p3 对象实例其结构都是一致的,因此可以验证 new 操作符过程如上没有问题。

在看 new 操作符号的实现过程中,聪明的读者已经发现第2步似乎并没有什么用,这是因为示例对象过于简单。

有了上面扎实的基础知识,接下来一起来看看 JavaScript 中的继承吧!

四、继承

4.1 类式继承

// 父类
function Parent(name) {
    this.name = name;
    this.list = [1,2,3];
}
// 父类原型链上绑定函数,可以继承
Parent.prototype.getName = function(){
    return this.name;
}
// 子类
function Child(name) {
    this.subName = name;
}
// 子类原型指向一个Parent实例,就可以继承Parent的方法
Child.prototype = new Parent();


const child1 = new Child('DYBOY');
console.log(child1.list, child1.subName, child1.name);

上面的“类式继承”最终输出是:

[1, 2, 3]  DYBOY undefined

为啥this.name的值会是一个undefiend

仔细分析类式继承这个过程:

// 子类原型指向一个Parent实例,就可以继承Parent的方法
Child.prototype = new Parent();

因为Parent的实例没有传name参数,所以this.name是一个undefined,那么Child.prototype的值如下

Child.prototype = {
    name: undefiend,
    list: [1, 2, 3],
    __proto__: {
        getName: fn,
        constructor: ...
    }
}

Child实例化一个child1时,因为只有subName赋值,而没有更改name值的方法,所以child1.name的返回值会是一个undefined

child1的结构如下:

类式继承的child1实例

由此,可以看到类式继承的缺点:

  1. 不支持父构造函数带参数
  2. 父构造函数中的属性和方法都会变成公有

4.2 构造函数继承

因为类式继承的缺点,尝试使用改变构造函数指向的方式来实现继承

// 父类
function Parent(name) {
    this.name = name;
    this.list = [1,2,3];
}
// 父类原型链上绑定函数,可以继承
Parent.prototype.getName = function(){
    return this.name;
}

// 子类
function Child(firstName, lastName) {
    Parent.call(this, firstName)
    this.subName = lastName;
}

const child1 = new Child('DY', 'BOY');
console.log(child1.list, child1.subName, child1.name);

相较于类式继承,在“构造函数继承”中的子类“Child”构造函数中,使用Parent.call(this, firstName) 来使得父构造函数的this指向子构造函数。

则实例化的child1的结构如下:

构造函数继承的child1实例

从上面的结构我们又发现一个问题,父级的prototype上的getName()函数没有被继承。换句话说,构造函数继承方式无法继承父构造函数的原型方法和属性。

4.3 组合式继承

如果将类式继承和构造函数继承方式结合,是否就可以解决上述遇到的继承问题呐?

// 父类
function Parent(name) {
    this.name = name;
    this.list = [1,2,3];
}
// 父类原型链上绑定函数,可以继承
Parent.prototype.getName = function(){
    return this.name;
}

// 子类
function Child(firstName, lastName) {
    Parent.call(this, firstName)
    this.subName = lastName;
}
// 子类原型指向一个Parent实例,就可以继承Parent的方法
Child.prototype = new Parent();

const child1 = new Child('DY', 'BOY');
console.log(child1.list, child1.subName, child1.name, child1.getName());

此时的child1实例就完全具有父级的方法和属性

组合继承的过程可以拆解为如下步骤:

// 1. 首先执行 Child.prototype = new Parent();
Child.prototype = new Parent() = {
    name: undefined,
    list: [1, 2, 3],
    __proto__: {
        getName: fn,
        constructor: ...
    }
}

// 2. 执行 const child1 = new Child('DY', 'BOY');
// Child构造函数中call绑定this指向并立即执行
const child1 = new Child('DY', 'BOY') = {
    name: DY,
    list: [1, 2, 3],
    subName: BOY,
    __proto__: 指向第1步得到的Child.prototype
}

在浏览器中执行后得到实例child1和上述分析一致:

组合式继承的child1实例

但是可以发现child1实例的结构比较复杂,因此暴露出“组合继承”方式的两个问题:

  1. __proto__中的属性值没有用,原型链规则会首先访问最近节点的属性或方法
  2. 父构造函数执行了两次

4.4 寄生组合式继承

在组合式继承中,其第一步实例化一个父实例其实是不必要执行的,其方法过于暴力,一股脑地塞进了子构造函数中,因此我们需要从第一步Child.prototype = new Parent() 着手改造。

// 将 Child.prototype = new Parent(); 改造为如下代码

function inheritPrototype(parentClass, subClass) {
    const F = function() {};
    F.prototype = parentClass.prototype;
    subClass.prototype = new F();
    subClass.prototype.constructor = subClass; // more perfect
}

inheritPrototype(Parent, Child);

上述过程也非常好理解,在子类绑定父构造函数时候,我们用一个空属性的函数,这样在new操作时候就不会有参数赋值操作。

如此实例化生成的child1实例的结构就会非常干净

寄生组合继承的child1实例结构

其实到这里JavaScript的继承原理就已经差不多啦,弄清楚原理,很多内容就可以举一反三,如果还想要了解更多关于继承的姿势(ES6的extends语法等),推荐阅读《JavaScript常用八种继承方案

五、多态

多态特征:不同对象调用相同方法返回不同结果。

// 多态
function Base() {};
Base.prototype.initial = function() {
    this.init();
}

function SubA() {
    this.init = function() {
        console.log('init A');
    }
}

function SubB() {
    this.init = function() {
        console.log('init B');
    }
}

// 由于Base构造函数没有属性,这里简单使用类式继承
SubA.prototype = new Base();
SubB.prototype = new Base();

const a = new SubA();
const b = new SubB();

a.initial();  // output: init A
b.initial();  // output: init B

在JavaScript中多态和继承是一样没有定义概念,但是我们可以将多态和继承的概念在JavaScript中实现,以便于在“程序设计模式”中,能够殊途同归地运用程序设计思想。

六、总结

本篇文章,总结了this指向、更改this指向的三种原型方法(apply()call()bind())、继承和多态,这些知识非常基础,但通过由表及里的分析,能够加深在使用JavaScript开发应用的理解,以及提升代码阅读的效率。

这些知识点也是作为学习“程序设计模式”的垫脚石,觉得不错,不妨点赞收藏一波?

笔者将针对JS设计模式做一个系列,欢迎大家关注微信公众号:DYBOY,茶花前端,内推大厂!