您现在的位置是:首页 > 文章详情

01 面向对象的JavaScript

日期:2020-02-22点击:274

写在前面

这个系列的文章是通过对《JavaScript设计模式》一书的学习后总结而来,刚开始觉得学习的时候只需看书即可,不用再另外记录笔记了,但是后面发现书中有些内容理解起来并不是很容易,所以结合书中的描述就将自己的理解也梳理了一下并将它记录下来,希望和大家一起学习,文章中如果有我理解错的内容,请各位批评指正,大家共同进步~

目录

  • 动态类型语言和鸭子类型
  • 多态
  • 封装
  • 原型模式和基于原型继承的JS

内容

动态类型语言和鸭子类型

我们所接触的所有编程语言按照数据类型可以大致分为两类:静态类性语言和动态类型语言。
这两类语言可以通过下面简单的方法来进行区分:在编译时如果已经确定了变量的数据类型,那么它就是静态类性语言;如果变量的数据类型要等到程序运行的时候,给它具体赋予某个值才可以确定,那么它就是动态类型语言。
静态类性语言在程序编译时就已经确定了变量类型,所以它能提前检测出类型不匹配等常见的错误,大大降低了我们编码时的出错率。而且由于我们已经明确了变量的基本类型,所以编译器还能对整体的系统代码做进一步的优化工作,提高程序的执行效率。但是另一方面,静态类型语言要求编译时明确变量数据类型,所以我们编码时有很大一部分时间消耗在了变量定义和数据类型确定等方面,从而压缩了我们考虑业务逻辑方面的精力和时间,降低了开发效率,毕竟我们很大一部分人编码的最终目的就是按时交付产品。
动态类型语言避免了定义超多变量类型的局面,所以它的整体代码看起来简洁,而且业务流程代码清晰,专注于逻辑表达。虽然在很多情况下变量数据类型不明确会造成一定的阅读困扰,但整体而言代码量较少,使我们在编码时能专注于业务逻辑。同样地,跟静态类性语言相比,动态类型语言中变量数据类型不明确这种特性会在程序运行时可能会出现跟数据类型相关的错误。
动态类型语言的这种灵活性在编码时省去了类型检测这一环节,我们可以尝试调用任何对象的任何方法,而不用去关心它原本是否被设计为拥有该方法。这一切都是建立在“鸭子类型”的概念之上。
鸭子类型的通俗解释就是:如果一个东西走起路来像鸭子,叫起来也像鸭子,那么它就是鸭子。利用鸭子类型的思想,我们在动态类型语言中能很容易的实现“面向接口编程”的原则,例如:一个对象若有push和pop方法,并且都已实现此方法,就可以当做栈来使用;如果一个对象有length属性,可以通过下标存取属性,那这个对象就可以被当做数组使用。这种面向接口编程在静态类性语言中是很难实现的,所以动态类型语言的设计模式跟我们传统的静态类型语言设计模式有很大的不同。

多态

多态的含义就是同一操作作用在不同对象上时会出现不同的结果。比如说有一群动物,你对它们发出“叫”的指令,狗狗会发出“汪汪汪”的声音、鸡会发出“咯咯咯”的声音,而鸭子则会发出“嘎嘎嘎”的声音,同样是动物,同样可以发出叫声,但是根据你的一条指令,它们的叫声各不相同,这就是多态的思想。
多态背后的思想是将“做什么”和“谁去做以及怎样去做”分离开来,也就是把不变的部分隔离开来,把可变的部分封装起来,这样的程序看起来才是可生长的,程序才有可扩展能力。下面是两段程序的对比,我们详细的看一下刚才所说这句话的具体含义:
原始代码:

var makeSound = function (animal) {            if (animal instanceof Duck) {                console.log('嘎嘎嘎');           }else if (animal instanceof Chicken) {                console.log('咯咯咯');           }       }; ​        var Duck = function () {};        var Chicken = function () {}; ​        makeSound(new Duck());        makeSound(new Chicken()); 

优化后的代码:

//多态性优化后的代码        var makeSound = function (animal) {            animal.sound();       }; ​        var Duck = function () {};        Duck.prototype.sound = function () {            console.log('嘎嘎嘎');       }; ​        var Chicken = function () {};        Chicken.prototype.sound = function () {            console.log('咯咯咯');       }; ​        makeSound(new Duck());        makeSound(new Chicken()); 

上面两段代码其实都实现了多态,即:你对动物发出一个叫的指令,鸡和鸭都执行并得到了不同的结果。但是第一段代码的多态是我们无法接受的,比如说我们后期还要增加狗狗这个物种,那我们的makeSound函数要进行修改,再增加一条判断语句,然后我们整体代码还要增加狗狗这个对象的定义语句,这就相当于对我们整段代码进行了修改,程序代码修改的地方越多就越容易出错,所以第一段代码我们需要优化。
第一段代码优化完毕后的结果就是第二段代码,在这一段代码里,一切看上去都是那么完美,不管我们后期增加多少动物对象,我们只需要进行各自的对象定义和实现“叫”这个方法,对于makeSound函数就无需进行修改了,这也体现了我们之前所说的“将不变的隔离出来,将可变的封装起来”的原则。
很多有Java、C++、C#等开发经验的伙伴可能会对最后两句代码有疑惑,为什么makeSound函数参数里既可以传入Duck对象,又可以传入Chicken对象呢,这种写法要是放在Java、C++、C#等开发编码时早就报错了啊,这是因为Java、C++、C#这些语言都是静态类性语言,它们在编译时有类型检查这一环节,如果你在编码时设计makeSound函数的参数接受的是Duck类型的变量,那它只能接受Duck对象作为参数,如果传入Chicken对象作为参数就会报错,那么在这种静态类型语言的开发中就不能实现类似于上述的多态效果嘛,答案肯定是可以的,这需要我们用“向上转型”去做,也就是将 Duck 对象和 Chicken 对象的类型都被隐藏在超类型 Animal 身后, Duck 对象和 Chicken对象就能被交换使用,这就是让对象表现出多态性的必经之路。但是JavaScript是一门动态类型语言,它的变量类型在运行期间是可变的,所以说它省去了类型检查这一环节,即:JS的多态性是与生俱来的。
说了这么多,有人不经要问,多态在JS或者JS设计模式中到底有什么用呢?那大家只需要记住这句话即可,多态最根本的目的就是通过把过程化的条件分支语句转换为对象的多态性,从而清除这些条件分支语句。

封装

封装的最终目的是为了实现信息隐藏。一般情况下我们只讨论封装数据和封装实现,但在这里我们讨论一下更加广义的封装,包括封装数据、封装实现、封装类型、封装变化。
许多静态类性语言中的封装数据是由语法解析来完成的,它们也提供了public、private、protected等关键字来为变量赋予不同的访问权限。但是在JS中并没有这些关键字,我们只能通过变量作用域来控制变量的访问权限,一般情况下我们是通过函数来创建变量作用域,而且只能模拟public和private权限。但是这种情况正在改善,在ES6中,我们可以用let来创建作用域,同时,ES6还提供了Symbol来创建私有属性。
封装不仅仅是封装数据,它也可以封装实现。也就是说封装实现使得对象内部的变化对其他对象而言是不可见的,对象只对它内部的实现负责,只要对外暴露的API接口不变,我们就可以随意改动它内部的实现,而不会去影响到程序的其他功能。封装实现使得对象之间的耦合度降低。
封装类型是在静态类性语言中的一个重要实现,但在JS中这是没有能力做到的,因为JS本身就是一门类型模糊的语言,但是这种类型模糊在我看来并不是它的短板,而恰恰是它的优点,也可以说是它的一种解脱。
设计模式中有这样一段话“考虑你的设计中哪些地方可能变化,这种方式与关注会导致重新设计的原因相反”,这段文字总结起来就是“找到变化并封装之”。封装变化就是将系统中稳定不变和可能改变的内容隔离开来,所以在整个系统运行迭代中我们只需要替换那些可能改变的部分即可。

原型模式和基于原型继承的JS

许多编程语言创建对象时是通过类来创建的,也就是说对象是从类中而来,但在JS中对象未必需要通过类来创建,我们还可以通过克隆一个对象来创建另一个对象,这就是原型模式。
原型模式不仅仅是一种设计模式,它还是一种编程泛型。原型模式用于创建对象,它通过克隆一个对象来实现,所以如果我们需要一个跟某个对象一模一样的对象的话就可以运用原型模式。原型模式实现的关键是对象是否提供了clone方法,在ES5中我们可以通过Object.create方法来克隆对象,具体可以查看如下代码:

var Plane = function () {            this.name = 'testPlane';            this.color = 'blue';            this.number = '2282';       }; ​        var planeone = new Plane();        planeone.name = 'changename';        planeone.color = 'red';        planeone.number = '2383'; ​        //不支持Object.create方法的浏览器中,用以下写法        /*Object.create = Object.create || function (obj) {           var F = function () {};           F.prototype = obj; ​           return new F();       };*/ ​        var planetwo = Object.create(planeone);        console.log(planetwo);        console.log(planetwo.name);        console.log(planetwo.color);        console.log(planetwo.number); 

运行结果如下:
24

上述代码我们演示了如何通过原型模式来创建一个一模一样的对象,通过原型模式中的克隆对象,我们不必去关心对象的具体类型名称。并且在JS这种变量类型模糊的语言中,创建对象变得非常容易,我们不必去关心解耦这些复杂的操作。
上述讲了原型模式不仅仅是一种设计模式,更是一种编程泛型。在原型模式中我们创建对象就像上述代码一样,通过克隆可以创建很多很多个对象,但就像上述代码,这些许许多多的对象起初都是由一个原始对象通过克隆来创建的,那么这个原始对象它肯定不是通过克隆来创建的,而是通过正常的实例化一个类来创建,我们将这个原始对象称之为根对象。如果A对象是由B对象克隆而来,那我们称B对象就是A对象的原型。
我们来描述一个场景,如果有一个描述动物的根对象是animal,然后通过这个animal对象我们克隆一系列的对象,例如,从animal对象克隆出描述爬行类动物的reptile对象,再从reptile对象克隆出一个snake对象,如此可以得知,animal对象是reptile对象的原型,reptile对象又是snake对象的原型,这样一来就形成了一条简单的原型链,如果我们在某个场景中去调用snake对象的某个方法时,发现snake没有这个方法,那么snake对象就会把这个请求委托给它的原型reptile对象,如果reptile对象也没有这个方法,它同样地继续把这个请求委托给它的原型animal对象,如此继续下去,便会得到继承的效果。这个机制其实不算复杂,但是它却异常强大。
在JS里面,我们使用的就是这种原型继承的机制,所以JS遵守原型编程的基本规则,这些规则如下:

  • 所有的数据都是对象。
  • 要得到一个对象,不是通过实例化类,而是找到一个对象作为原型并克隆它。
  • 对象会记住它的原型。
  • 如果对象无法响应某个请求,它会把这个请求委托给它自己的原型。
    接下来我们简要介绍下以上四条规则。

在JS中有基本类型和对象类型两套类型机制,这是由于JS在当初设计时参考了Java的类型机制。JS的基本类型包括 undefined、number、boolean、string、function、object。其实按照JS当初设计的本意,除了undefined之外,在JS中一些皆对象,所以number、boolean、string这三种基本类型数据都可以通过包装类的方式将其变成对象类型来处理。刚才说了在JS中一切皆对象,那么肯定会有一个根对象的存在,在JS中这个根对象就是Object.prototype,这个对象是一个空对象,除此之外所有的对象都是通过克隆这个根对象而来,所以Object.prototype对象就是他们的原型。
在JS里面我们不用去关心如何克隆对象这件事情,因为这个是引擎考虑和应该做的事情,我们只需要显式的调用var object1 = new Object()或者var object2 = {}来创建对象,在创建对象时,引擎会自动地在Object.prototype对象上克隆出来一个对象,这个克隆出来的对象就是我们最终得到的对象。大家看到new Object()这行代码的时候可能会问,这个不就是在通过类来实例化一个对象嘛?那你肯定忘记了,在JS中函数既可以当做普通函数来调用,也可以当做构造器来调用,在这里,我们的Object其实是一个函数构造器,类似的代码如下:

function Person(name) {            this.name = name;       }; ​        Person.prototype.getName = function () {            return this.name;       }; ​        var person1 = new Person('mike'); ​        console.log(person1.name);        console.log(person1.getName());        console.log(Object.getPrototypeOf(person1) === Person.prototype); 

25

以上代码中的Proson并不是一个类,而是一个函数构造器,我们通过new运算符和函数构造器来创建了一个对象,在这过程中其实首先也是从Object.prototype对象上克隆下来一个对象,然后再进行一个额外的操作而已。
在JS中的要想实现原型继承的效果,就必须要求原型链上的每个节点都必须记住它们各自的原型,也就是说每个对象都应该记住它们各自的原型,但是这句话不太严谨,更加合理的说法是对象应该记住它们各自构造器的原型。因为严格意义上来说并不是对象有原型,而是对象的构造器有原型。JS给对象提供了一个名为“_proto_”的属性,这个属性默认会指向它的构造器的原型对象,所以_proto_属性就是对象和对象构造器原型连接的纽带。
之前说过的一句话“如果对象无法响应某个方法时,它会将这个请求委托给它的原型对象”,这句话现在看起来似乎不是那么准确,准确的来说应该是“如果对象无法响应某个方法时,它会将这个请求委托给它的构造器原型”,因为对象没有原型,对象的构造器有原型。到了这里大家可能会想,在JS中所有对象都是通过克隆Object.prototype对象而来的,那它们之间的继承关系很单一啊,就类似于以下这种:
26

由上图看出,所有对象都是通过克隆Object.prototype对象得到的,这样的对象系统是非常受限制的,我们更加希望的是下面这样的对象系统:
27

上图中,对象都是通过克隆Object.prototype对象而来,但它们各自的对象构造器原型并不局限在Object.prototype对象上,而是可以动态的改变,所以才出现了上图中所示的结构灵活的原型链。那这样的需求在JS能不能做呢,答案是肯定的。在JS中,如果对象C想要借用对象B的能力时,我们只需要将对象C的构造器原型指向对象B即可,从而达到继承效果,代码跟下面的类似:

A.prototype = B; 

最后,需要注意的是,JS中的原型链并不是无限延长的,如果按照原型链依次往上将请求委托给各自的构造器原型,那么请求最终会被传递到Object.prototype这个根对象,但是这个根对象是一个空对象,所以最后请求就会在此处打住,返回undefined。
设计模式其实往往会体现出语言的不足之处,它也是对语言不足之处的补充。JS语言是存在着很多不足之处,但它也在发展,或许未来的某一天我们发现某一个模式已经在JS中是一个天然存在的东西,就像Object.create()是原型模式在JS中的天然存在一样,那么我们应该感到高兴。但是话说回来,我们在平常开发时并不建议用Object.create()去创建对象,因为它的效率跟构造器创建来比的话真的太慢,但是我们却可以通过Object.create(null)来创建出一个没有原型的对象,哈哈,是不是很神奇,所以在今后介绍的各种模式中,只要能合理地运用在我们日常的开发中,它就是好的模式。

总结

本篇文章其实是后续所有文章开始之前的一个知识点回顾部分,我们简单地回顾了JS中面向对象部分的多态、封装以及基于原型的相关知识点,并且简单介绍了下已经在JS中现有的第一个设计模式——原型模式。接下来我们还会有一到两篇的文章会去回顾面向对象相关的知识点,等全部复习完之后,就开始介绍JS中的各种相关设计模式。

原文链接:https://yq.aliyun.com/articles/745873
关注公众号

低调大师中文资讯倾力打造互联网数据资讯、行业资源、电子商务、移动互联网、网络营销平台。

持续更新报道IT业界、互联网、市场资讯、驱动更新,是最及时权威的产业资讯及硬件资讯报道平台。

转载内容版权归作者及来源网站所有,本站原创内容转载请注明来源。

文章评论

共有0条评论来说两句吧...

文章二维码

扫描即可查看该文章

点击排行

推荐阅读

最新文章