JavaScript设计模式-基础知识

本系列是《JavaScript 设计模式与开发实践》一书学习记录

动态类型语言和鸭子类型

编程语言按照数据类型大体可分为两类 静态类型语言(Java)、动态类型语言(JavaScript)
静态类型语言在编译的时候便已经确定变量类型,而动态类型语言的变量类型要到运行时候,待变量被赋予某个值之后,才会具有某种类型。(其实微软的 typescript 可以让 JavaScript 实现静态类型)。

静态类型语言优点是可以在编译的时候就发现类型不匹配的错误,其次,如果在代码中明确规定了数据类型,编译器还可以进行相关优化提升速度。缺点首先是强迫程序员依照强契约来编写程序,为变量规定数据类型,其实只是一种辅助编写可靠性高的一种手段,而不是编写程序的目的。

动态类型语言优点是编写代码数量更少,可以把更多精力放在业务逻辑上,缺点是无法保证变量类型,比如一个函数接受一个数组参数,但是你传了一个字符串,就会导致函数错误,但是可能只有看过函数代码才知道。(此处说的是用别人的代码,当然大多数库都提供了 ts 声明文件,帮助编辑器来提示类型,如果没有的话,只能看文档了,如果文档都没写,只能看代码了。)

在 js 中声明一个变量不需要说明类型,赋值一个字符串他就是一个字符串变量,赋值一个数字他就是数字类型变量,而且可以重复赋值(ES6 新增了const来声明常量,不可重复赋值)。

这一切都建立在鸭子类型的概念上,鸭子类型的通俗说法是:如果他走路像鸭子,叫起来也像鸭子,那么他就是鸭子

多态

多态的实际含义是:同一操作用于不同对象上面,可以产生不同的解释和不同的执行结果,换句话说,给不同的对象发送他容一个消息的时候,这些对象会根据这个消息分别给出不同的反馈。

一段“多态”的 JavaScript 代码
instanceof 运算符,**instanceof** 运算符用来测试一个对象在其原型链中是否存在一个构造函数的 prototype 属性。

1
2
3
4
5
6
7
8
9
10
11
let makeSound = function(animal) {
if (animal instanceof Dock) {
console.log("嘎嘎嘎");
} else if (animal instanceof Chicken) {
console.log("咯咯咯");
}
};
const Dock = function() {};
const Chicken = function() {};
makeSound(new Dock()); // 嘎嘎嘎
makeSound(new Chicken()); // 咯咯咯

此段代码确实体现了多态性,当分别传入 Dock 和 Chicken 代码会返回不同的内容,但是这样的代码是不令人满意的,如果新增一个的话就需要更改 makeSound 来实现,代码修改的越多,出错的可能性也就越大。

多态背后的思想是将做什么谁去做以及怎么做分开,也就是说将不变的事物可能改变的事物分离开

对象的多态性

首先把不变的部分分离出来,就是所有动物都会叫。
以下代码可以在这里进行测试,记得打开控制台才能看到 console 出来的信息。

1
2
3
let makeSound = function(animal) {
animal.sound();
};

然后把可变的部分各自封装起来,刚才的多态性实际上是指对象的多态性:

1
2
3
4
5
6
7
8
9
10
11
12
class Duck {
sound() {
console.log("嘎嘎嘎");
}
}
class Chicken {
sound() {
console.log("咯咯咯");
}
}
makeSound(new Duck()); // 嘎嘎嘎
makeSound(new Chicken()); // 咯咯咯

类型检查和多态

类型检查是便显出对象多态性绕不开的话题,JavaScript 是一门动态语言类型,书上举了个例子 Java 的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
String str;
str = 'abc'; // 正常
str = 1; // 报错与声明不符

把上面的例子换成java的

public class Duck{
public void makeSound(){
System.out.println('嘎嘎嘎');
}
}
public class Chicken{
public void mackSounde(){
System.out.println('咯咯咯');
}
}
public class AnimalSound {
public void makeSound(Duck duck){
duck.makeSound()
}
}
public class Test {
public static void main(String args[]){
Duck duck = new Duck();
animalsound.makeSound(duck) // 输出 嘎嘎嘎
}
}

顺利输出嘎嘎嘎,但是如果让 Chicken 也打印出来,在 java 中并不容易实现

1
2
3
4
5
6
public class Test {
public static void main(String args[]){
AnimalSound animalSound = new AnimalSound();
animalsound.makeSound(duck) // 报错只接受duck类型参数
}
}

为解决这一问题,在静态类型的面向对象语言通常设计为可以向上转型:当给一个类变量赋值时,这个变量的类型既可以使用这个类本身,也可以使用这个类的超类,比如说天上有一只喜鹊在飞,如果忽略类型可以说成天上有只鸟。

使用继承得到多态效果

使用继承来获得多态效果,是让对象表现出多态性最常用的手段,继承通常报错实现继承和接口继承,这里先说实现继承。(在 JavaScript 中是没有类的,ES6 中的 class 知识一个语法糖,是基于原型链实现的,class 知识让原型的写法更加清晰,更像面向对象的语法而已)

Java 代码略

JavaScript 的多态

多态的思想实际上是把做什么谁去做分开,要实现这一点归根节点要消除类型之间的耦合关系。

在 JavaScript 中变量的类型在运行的时候是可变的,一个 JavaScript 对象既可以表示 Dock 类型又可以表示 Chicken 类型的对象,这意味着JavaScript 对象的多态性是与生俱来的

这种与生俱来的多态性并不难解释,JavaScript 作为一门动态类型语言,他在编译时没有类型检查过程,也没有检查创建的对象类型,又没有检查传递的参数类型。由此尅件某一种动物能否发声,只取决于他有没有 mackSounde 方法,而不取决于他是某种类型的对象,这里不存在任何程度上的类型耦合

多态在面上对象程序中的作用

多态最根本的左右就是通过把过程化的分支语句转化为对象的多态性,从而消除这些条件分支语句。
书上举了个例子

1
2
3
4
5
6
7
8
9
const googleMap = {
show: function(){
console.log('开始渲染谷歌地图')
};
};
const renderMap = function() {
googleMap.show();
};
renderMap(); // 输出开始渲染谷歌地图

出于一些原因要谷歌和百度地图交替使用,修改函数,实现效果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const googleMap = function(){
show: function(){
console.log('开始渲染谷歌地图')
}
}
const baiduMap = function(){
show: function(){
console.log('开始渲染百度地图')
}
}
const renderMap = function(type){
if(type === 'google'){
googleMap.show();
}else{
baiduMap.show();
}
}
renderMap('google')
renderMap('baidu')

这里就修改完成了,但是很麻烦,如果在添加一个腾讯地图的话先要增加一个 qqMap 对象然后更新 renderMap 函数,这样不断地修改导致函数越来越脆弱代码越来越多,也不利于代码维护。

优化一下首先把程序中相同的部分抽象出来,那就是显示地图

1
2
3
4
5
6
7
8
const renderMap = function(map) {
if (map.show instanceof Function) {
// 判断传入对象是否有show方法以及是否是函数
map.show();
}
};
renderMpp(googleMap);
renderMap(baiduMap);

将显示地图部分抽象出来之后无论新增什么地图,只需要新增一个对象,对象的 show 方法是调用地图即可。清晰简单。

封装

封装的目的是将信息隐藏,一般而言,讨论的封装是封装数据和封装实现。接下来将讨论更广义的耿庄,不仅包括封装数据和封装实现,还包括封装类型和封装变化。

封装数据

除了 ES6 中提供的 let 之外,一般通过函数来创建作用域

1
2
3
4
5
6
7
8
9
10
11
12
13
// 这里就不用const let了不然没意义了

var myObject = (function(){
var _name = 'sven'; // 私有化变量
return {
getName: function(){
return _name;
}
}
})()

这个函数是声明一个myObject变量,值是一个自执行函数的结果返回一个对象对象里面有一个getName方法这个方法返回私有化变量_name;其实也是个闭包,myObject变量一直保存着匿名函数的引用,除非主动声明myObject = null; 否则匿名函数会一直在内存中不会被释放。
console.log(myObject.getName()) // sven

封装实现

上面的封装是数据层面的封装,有时候人喜欢把封装等同于数据封装,但这是一种比较狭义的定义。

封装的目的是将信息隐藏,封装应该被视为任何形势的封装,也就是说封装不仅仅是隐藏数据,还包括隐藏实现细节,设计细节以及隐藏对象的类型等。

封装类型

封装类型是静态类型语言中一种重要的封装方式,一般而言,封装类型是通过抽象类和结构进行封装的,把对象的真正类型隐藏在抽象类或者接口之后,相比对象类型,用户更关心对象行为,在许多静态语言的设计模式中,想方设法的去隐藏对象的类型,也是促使这些模式诞生的原因之一,比如工厂模式,组合模式。
在 JavaScript 中,并没有对象类和接口的支持,JavaScript 本身也是一门类型模糊的语言(我估计 JavaScript 之父都没想到 JavaScript 会发展这么大),在封装类型方面,JavaScript 没有能力,也没有必要做的更多,对于 JavaScript 设计模式实现来说,不区分类型是一种失色,也可以说是一种解脱。

封装变化

从设计模式的角度出发,封装在更重要的层面体现为封装变化
《设计模式》一书中共归纳总结了 23 中设计模式,从意图上区分,23 种模式分别被划分为创建型模式、结构型模式和行为型模式。
拿创建型模式来说,要创建一个对象,是一种抽象行为,而具体的创建则是可以变化的,创建模式的目的就是封装创建的变化,而结构型模式封装的是对象之间的组合关系,行为模式封装的是对象的行为变化。

通过封装变化的方式,将代码中稳定不变部分和容易变化的部分隔离开来,在系统的演变过程中,只需要替换那些容易变化的部分,如果这部分是封装好的,替换起来也相对容易。这可以最他程度的保证程序的稳定性和可扩展性。

原型模式和原型继承的 JavaScript 对象系统

在 JavaScript 被发明的时候借鉴了 Self 和 Smalltalk 两门基于原型的语言,之说以选择基于原型的面向对象系统,是因为从一开始 JavaScript 中就没有打算加入类的概念。

使用克隆的原型模式

从设计模式的角度将,原型模式用于创建对象的一种模式,如果我们想要创建一个对象,一个方法是先指定它的类型,然后通过类来创建这个对象,原型模式选择了另一种方式,找到一个对象,然后通过克隆来创建一个一模一样的对象。

克隆是创建对象的手段

原型模式的真正目的并非在于需要得到一个一模一样的对象,而是提供了一种便捷的方式去穿件某个类型的对象,克隆知识创建这个对象的过程和手段。

在 JavaScript 这种类型模糊的语言中,创建对象非常容易,也不存在类型耦合的问题,从设计模式的角度来讲,原型模式的意义并不大,但 JavaScript 本事是一门基于原型的面向对象语言,它的对象系统就是使用原型链模式来搭建的,这里称之为原型编程也许更合适。

体验 lo 语言

原型编程规范的一些规则

原型编程范型至少包括以下基本准则:

  1. 所有的数据都是对象
  2. 要得到一个对象,并不是通过实例化,而是找打一个对象作为原型并克隆他。
  3. 对象会记住他的原型
  4. 如果对象无法响应某个请求,他会把这个请求委托给他自己的原型。

JavaScript 中的原型继承

JavaScript 也具有上面的基本规则,就不在重复了。
讨论一下 JavaScript 是如何在这些规则的基础上来构建它的对象系统。

  1. 所有数据都是对象

JavaScript 再设计的时候,模仿了 Java 引入了两套类型机制: 基本类型对象类型,其中基本类型包括了undefined number Boolean string function Object,从现在来看并不是一个好的想法。

根据设计者的本意,除了undefined之外,一切都应是对象,为了我实现这一目标,
number Boolean string这几种基本类型也可以通过包装类的方式编程对象数据来出处理。

不能说 JavaScript 中所有数据都是对象,但是可以说绝大部数据都是对象,那么相信 JavaScript 中也一定有一个根对象。

在 JavaScript 中根对象是 Object.prototype 对象 Object.prototype 对象是一个空白对象,我们在 JavaScript 中遇到的每个对象,实际上都是从 Object.prototype 对象克隆而来的,也就是说 Object.prototype 对象就是他们的原型

getPrototypeOf 这个方法返回指定对象的原型内部[[Prototype]]属性的值

1
2
3
4
5
const obj1 = new Object();
const obj2 = {};
console.log(Object.getPrototypeOf(obj1) === Object.prototype);
console.log(Object.getPrototypeOf(obj2) === Object.prototype);
两个都会返回true;
  1. 要得到一个对象,不是通过实例化类,而是找到一个对象作为原型并克隆它。

在 JavaScript 语言中并不需要关心克隆细节,这是引擎内部实现的,我们只需要显式的调用const obj = new Object()或者const obj1 = {}这个时候引擎会从 Object.prototype 上面克隆一个对象出来,我们最终得到的就是这个对象。

看看如何使用new 运算符从构造器中得到一个对象。

1
2
3
4
5
6
7
8
9
10
11
class Person {
constructor(name) {
this.name = name;
}
getName() {
return this.name;
}
}
const P = new Person("HXS");
p.name; // HXS
P.getName; // HXS

在 JavaScript 中并没有类的概念,这句话书中作者重复很多次了(在你不知道的 JavaScript 书中作者也是强调了很多次),但是刚刚不是new了 Person 吗

在这里 Person 并不是类,而函数构造器,JavaScript 中的函数既可以当做普通函数被调用,也可以作为构造函数被调用,当时用new运算符来调用函数时,此时的函数就是一个函数构造器new运算符来创建对象的过程,实际上也是先克隆Object.prototype对象,在进行一些其他的额外操作过程

在谷歌和火狐浏览器等浏览器中对外暴露了_proto_对象,可以从以下代码理解以下 new 运算过程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function Person(name) {
this.name = name;
}
Person.prototype.getName = function() {
return this.name;
};

var objectFactory = function() {
var obj = new Object(); // 从Object.prototype上面克隆一个空对象
Constructor = [].shift.call(arguments); // 取得外部传入的构造器,此例是Person
obj._proto_ = Constructor.protorype; // 指向正确的原型
var ret = Constructor.apply(obj, arguments); // 借用外部传入的构造器给obj设置属性
return typeof ret === "object" ? ret : obj; // 确保构造器总是会范湖一个对象
};
var a = objectFactory(Person, "sven");
a.name; // sven
a.getName(); // sven
  1. 对象会记住他的原型

如果请求可以在一个链条中一次向后传递,那么每个节点都必须知道他的下一个节点。
JavaScrip 给对象提供了一个名为_proto_的隐藏属性,某个对象的_proto_属性会默认指向他的构造器原型,在一些浏览器中_proto_被公开出来,实际上_proto_就是对象根对象构造器原型联系起来的纽带。

  1. 如果对象无法响应某个请求,它会把这个请求委托给他的构造器的原型

这条规则几十原型继承的精髓所在,当一个对象无法响应某个请求的时候,它会顺着原型链把请求传递下去,直到遇到一个可以处理该请求的对象为止(也有可能找到头也找不到,然后报错)

在 JavaScript 中每个对象都是从Object.prototype对象克隆而来,但对象构造器的原型并不仅限于Object.prototype上,而是可以动态指向其他对象,这样一来,当对象 a 需要借用对象 b 的能力时,可以有选择性的吧对象 a 的构造器的原型指向对象 b,从而达到继承的效果。

1
2
3
4
5
6
7
var obj = {
name: "sven"
};
var A = function() {};
A.prototype = obj;
var a = new A();
a.name; // 输出sven

执行这个代码引擎做了什么

首先,尝试遍历对象 a 中的所有属性,但是没有找到 name 这个属性。
查找 name 属性这个请求被委托给对象 a 的构造器原型,他被a_proto_记录着并指向了A.prototyptA.prototypt被设置为对象 obj。
在对象 obj 中找到了 name 属性,并返回它的值。

当我们期望得到一个“类”继承另外一个“类”的效果时,往往会使用下面的代码模拟实现:

1
2
3
4
5
6
7
var A = function() {};
A.prototype = { name: "sven" };

var B = function() {};
B.prototype = new A();
var b = new B();
console.log(b.name);

这段代码执行的时候,引擎又做了什么

首先,尝试遍历对象 b 中的所有属性,但是没有找到 name 这个属性
查找 name 属性的请求被委托给对象 b 的构造器原型,他被_proto_记录着并指向B.prototype,而在B.prototype被设置成通过new A()创造歘来的对象。
但是在该对象中依旧没有找到 name 这个属性,于是请求继续委托给这个对象构造器的原型A.prototype
最终在 A.prototype 中找到了 name 属性,并返回了它的值。

原型继承的未来

设计模式在很多时候其实都体现了语言的不足之处,Peter Norvig 曾经说过,设计模式是对语言不足测补充,如果要是用设计模式,不如去找一门更好的语言。这句话非常正确(书上写的。。)。不过作为 web 前端开发者,相信 JavaScript 在未来很长一段时间都是唯一的选择,虽然没有办法更换语言,但是语言本身也在发展,说不定某个模式在下一个版本会成为天然存在,不再需要拐弯抹角的去实现,比如Object.create就是原型模式的天然实现,使用Object.create来完成原型继承,看起来更能体现出原型模式的精髓。
另外 ECMAScript6 带来了新的 class 语法,让 JavaScript 看起来更像一门基于累的语言,但是其背后仍是通过原型极致来创建对象。