zoukankan      html  css  js  c++  java
  • 深入浅出JS的封装与继承

      JS虽然是一个面向对象的语言,但是不是典型的面向对象语言。Java/C++的面向对象是object - class的关系,而JS是object - object的关系,中间通过原型prototype连接,父类和子类形成一条原型链。本文通过分析JS的对象的封装,再探讨正确实现继承的方式,然后讨论几个问题,最后再对ES6新引入的类class关键字作一个简单的说明。

        JS的类其实是一个函数function,由于不是典型的OOP的类,因此也叫伪类。理解JS的类,需要对JS里的function有一个比较好的认识。首先,function本身就是一个object,可以当作函数的参数,也可以当作返回值,跟普通的object无异。然后function可以当作一个类来使用,例如要实现一个String类

    复制代码
    1 var MyString = function(str){
    2     this.content = str;
    3 };
    4 
    5 var name = new MyString("hanMeimei");
    6 var addr = new MyString("China");
    7 console.log(name.content + " live in " + addr.content);
    复制代码

        第一行声明了一个MyString的函数,得到一个MyString类,同时这个函数也是MyString的构造函数。第5行new一个对象,会去执行构造函数,this指向新产生的对象,第2行给这个对象添加一个content的属性,然后将新对象的地址赋值给name。第6行又去新建一object,注意这里的this指向了新的对象,因此新产生的content和前面是不一样的。

        上面的代码在浏览器运行有一点问题,因为这段代码是在全局作用域下运行,定义的name变量也是全局的,因此实际上执行var name = new MyString("")等同于window.name = new MyString(""),由于name是window已经存在的一个变量,作为window.open的第二个参数,可用来跨域的时候传数据。但由于window.name不支持设置成自定义函数的实例,因此设置无效,还是保持默认值:值为"[object Object]"的String。解决办法是把代码的运行环境改成局部的,也就是说用一个function包起来:

    复制代码
    (function(){
        var name = new MyString("hanMeimei");
        console.log(name.content); //正确,输出hanMeimei
    })(); 
    复制代码

        所以从此处看到,代码用一个function包起来,不去污染全局作用域,还是挺有必要的。接下来,回到正题。

        JS里的每一个function都有一个prototype属性,这个属性指向一个普通的object,即存放了这个object的地址。这个function new出来的每个实例都会被带上一个指针(通常为__proto__)指向prototype指向的那个object。其过程类似于:

    var name = new MyString();             //产生一个对象,执行构造函数
    name.__proto__ = MyString.prototype;   //添加一个__proto__属性,指向类的prototype(这行代码仅为说明)

        如下图所示,name和addr的__proto__指向MyString的prototype对象:

        可以看出在JS里,将类的方法放在function的prototype里面,它的每个实例都将获得类的方法。       

        现在为MyString添加一个toString的方法:

    MyString.prototype.toString = function(){
        return this.content;
    };

       MyString的prototype对象(object)将会添加一个新的属性。

       这个时候实例name和addr就拥有了这个方法,调用这个方法:

    console.log(name.toString()); //输出hanMeimei
    console.log(name + " lives in " + addr); //“+”连接字符时,自动调用toString,输出hanMeimei lives in China

        这样就实现了基本的封装——类的属性在构造函数里定义,如MyString的content;而类的方法在函数的prototype里添加,如MyString的toString方法。

        这个时候,考虑一个基础的问题,为什么在原型上添加的方法就可以被类的对象引用到呢?因为JS首先会在该对象上查找该方法,如果没有找到就会去它的原型上查找。例如执行name.toString(),第一步name这个object本身没有toString(只有一个content属性),于是向name的原型对象查找,即__proto__指向的那个object,发现有toString这个属性,因此就找到了。

        要是没有为MyString添加toString方法呢?由于MyString实际上是一个Function对象,上面定义MyString语法作用等效于:

    //只是为了示例,应避免使用这种语法形式,因为会导致两次编译,影响效率
    var MyString = new Function("str", "this.content = str");

        通过比较MyString和Function的__proto__,可以从侧面看出MyString其实是Function的一个实例:

    console.log(MyString.__proto__); //输出[Function: Empty]
    console.log(Function.__proto__); //输出[Function: Empty]

        MyString的__proto__的指针,指向Function的prototype,通过浏览器的调试功能,可以看到,这个原型就是Object的原型,如下图所示:

     

        因为Object是JS里面的根类,所有其它的类都继承于它,这个根类提供了toString、valueOf等6个方法。

        因此,找到了Object原型的toString方法,查找完成并执行:

    console.log(name.toString()); //输出{ content: 'hanMeimei' }

        到这里可以看到,JS里的继承就是让function(如MyString)的原型的__proto__指向另一个function(如Object)的原型。基于此,写一个自定义的类UnicodeString继承于MyString

    var UString = function(){ };

        实现继承:

    UString.prototype = MyString.prototype; //错误实现

        注意上面的继承方法是错误的,这样只是简单的将UString的原型指向了MyString的原型,即UString和MyString使用了相同的原型,子类UString增删改原型的方法,MyString也会相应地变化,另外一个继承MyString如AsciiString的类也会相应地变化。依照上文分析,应该是让UString的原型里的的__proto__属性指向MyString的原型,而不是让UString的原型指向MyString。也就是说,得让UString有自己的独立的原型,在它的原型上添加一个指针指向父类的原型:

    UString.prototype.__proto__ = MyString.prototype;  //不是正确的实现

        因为__proto__不是一个标准的语法,在有些浏览器上是不可见的,如果在Firefox上运行上面这段代码,Firefox会给出警告:

    mutating the [[Prototype]] of an object will cause your code to run very slowly; instead create the object with the correct initial [[Prototype]] value using Object.create

        合理的做法应该是让prototype等于一个object,这个object的__proto__指向父类的原型,因此这个object须要是一个function的实例,而这个function的prototype指向父类的原型,所以得出以下实现:

    复制代码
    1 Object.create = function(o){
    2     var F = function(){};
    3     F.prototype = o;
    4     return new F();
    5 };
    6 
    7 UString.prototype = Object.create(MyString.prototype);
    复制代码

       代码第2行,定义一个临时的function,第3行让这个function的原型指向父类的原型,第4行返回一个实例,这个实例的__proto__就指向了父类的prototype,第7行再把这个实例赋值给子类的prototype。继承的实现到这里基本上就完成了。

       但是还有一个小问题。正常的prototype里面会有一个constructor指向构造函数function本身,例如上面的MyString:

        这个constructor的作用就在于,可在原型里面调用构造函数,例如给MyString类增加一个copy拷贝函数:

    复制代码
    1 MyString.prototype.copy = function(){
    2 //  return MyString(this.content);              //这样实现有问题,下面再作分析
    3     return new this.constructor(this.content);  //正确实现
    4 };
    5 
    6 var anotherName = name.copy();
    7 console.log(anotherName.toString());            //输出hanMeimei
    8 console.log(anotherName instanceof MyString);   //输出true
    复制代码

       问题就于:Object.create的那段代码里第7行,完全覆盖掉了UString的prototype,取代的是一个新的object,这个object的__proto__指向父类即MyString的原型,因此UString.prototype.constructor在查找的时候,UString.prototype没有constructor这个属性,于是向它指向的__proto__查找,找到了MyString的constructor,因此UString的constructor实际上是MyString的constuctor,如下所示,ustr2实际上是MyString的实例,而不是期望的UString。而不用constructor,直接使用名字进行调用(上面代码第2行)也会有这个问题。

    复制代码
    var ustr = new UString();
    var ustr2 = ustr.copy();
    console.log(ustr  instanceof UString); //输出true
    console.log(ustr2 instanceof UString); //输出false
    console.log(ustr2 instanceof Mystring); //输出true
    复制代码

        所以实现继承后需要加多一步操作,将子类UString原型里的constructor指回它自己:

    UString.prototype.constructor = UString;

        在执行copy函数里的this.constructor()时,实际上就是UString()。这时候再做instanseof判断就正常了:

    console.log(ustr2 instanceof Ustring); //输出true

        可以把相关操作封装成一个函数,方便复用。

        基本的继承核心的地方到这里就结束了,接下来还有几个问题需要考虑。

        第一个是子类构造函数里如何调用父类的构造函数,直接把父类的构造函数当作一个普通的函数用,同时传一个子类的this指针:

    复制代码
    1 var UString = function(str){
    2 // MyString(str);   //不正确的实现
    3     MyString.call(this, str);
    4 };
    5 
    6 var ustr = new UString("hanMeimei");
    7 console.log(ustr + "");  //输出hanMeimei
    复制代码

        注意第3行传了一个this指针,在调用MyString的时候,这个this就指向了新产生的UString对象,如果直接使用第2行,那么执行的上下文是window,this将会指向window,this.content = str等价于window.content = str。

        第二个问题是私有属性的实现,在最开始的构造函数里定义的变量,其实例是公有的,可以直接访问,如下:

    复制代码
    var MyString = function(str){
        this.content = str;
    };
    
    var str = new MyString("hello");
    console.log(str.content);        //直接访问,输出hello
    复制代码

        但是典型的面向对象编程里,属性应该是私有的,操作属性应该通过类提供的方法/接口进行访问,这样才能达到封装的目的。在JS里面要实现私有,得借助function的作用域:

    复制代码
    var MyString = function(str){
        this.sayHi = function(){
            return "hi " + str;
        }
    };
    
    var str = new MyString("hanMeimei");
    console.log(str.sayHi());            //输出hi, hanMeimei
    复制代码

         但是这样的一个问题是,必须将函数的定义放在构造函数里,而不是之前讨论的原型,导致每生成一个实例,就会给这个实例添加一个一模一样的函数,造成内存空间的浪费。所以这样的实现是内存为代价的。如果产生很多实例,内存空间会大幅增加,这个问题是不可忽略的,因此在JS里面实现属性私有不太现实,即使在ES6的class语法也没有实现。但是可以给类添加静态的私有成员变量,这个私有的变量为类的所有实例所共享,如下面的案例:

    复制代码
    var Worker;
    (function(){
        var id = 1000;
        Worker = function(){
            id++;
        };
        Worker.prototype.getId = function(){
            return id;
        };
    })();
    
    var worker1 = new Worker();
    console.log(worker1.getId());   //输出1001
    var worker2 = new Worker();
    console.log(worker2.getId());   //输出1002
    复制代码

        上面的例子使用了类的静态变量,给每个worker产生唯一的id。同时这个id是不允许worker实例直接修改的。

        第三个问题是虚函数,在JS里面讨论虚函数是没有太大的意义的。虚函数的一个很大的作用是实现运行时的动态,这个运行时的动态是根据子类的类型决定的,但是JS是一种弱类型的语言,子类的类型都是var,只要子类有相应的方法,就可以传参“多态”运行了。比强类型的语言如C++/Java作了很大的简化。

        最后再简单说下ES6新引入的class关键字

    复制代码
     1 //需要在strict模式运行
     2 'use strict';
     3 class MyString{
     4     constructor(str){
     5         this.content = str;
     6     }
     7     toString(){
     8         return this.content;
     9     }
    10     //添加了static静态函数关键字
    11     static concat(str1, str2){
    12         return str1 + str2;
    13     }
    14 }
    15 
    16 //extends继承关键字
    17 class UString extends MyString{
    18     constructor(str){
    19     //使用super调用父类的方法
    20         super(str);
    21     }
    22 }
    23 
    24 var str1 = new MyString("hello"),
    25     str2 = new MyString(" world");
    26 console.log(str1);                       //输出MyString {content: "hello"}
    27 console.log(str1.content);               //输出hello
    28 console.log(str1.toString());            //输出hello
    29 console.log(MyString.concat(str1, str2));//输出hello world
    30 31 var ustr = new UString("ustring"); 32 console.log(ustr); //输出MyString {content: "ustring"} 33 console.log(ustr.toString()); //输出ustring
    复制代码

        从输出的结果来看,新的class还是没有实现属性私有的功能,见第27行。并且从第26行看出,所谓的class其实就是编译器帮我们实现了上面复杂的过程,其本质是一样的,但是让代码变得更加简化明了。一个不同点是,多了static关键字,直接用类名调用类的函数。ES6的支持度还不高,最新的chrome和safari已经支持class,firefox的支持性还不太好。

        最后,虽然一般的网页的JS很多都是小工程,看似不需要封装、继承这些技术,但是如果如果能够用面向对象的思想编写代码,不管工程大小,只要应用得当,甚至结合一些设计模式的思想,会让代码的可维护性和扩展性更高。所以平时可以尝试着这样写。

  • 相关阅读:
    POJ1486 Sorting Slides 二分图or贪心
    POJ2060 Taxi Cab Scheme 最小路径覆盖
    POJ3083 Children of the Candy Corn 解题报告
    以前的文章
    POJ2449 Remmarguts' Date K短路经典题
    这一年的acm路
    POJ3014 Asteroids 最小点覆盖
    POJ2594 Treasure Exploration 最小路径覆盖
    POJ3009 Curling 2.0 解题报告
    POJ2226 Muddy Fields 最小点集覆盖
  • 原文地址:https://www.cnblogs.com/tangshiguang/p/6746181.html
Copyright © 2011-2022 走看看