每日一题

120 阅读12分钟

2020/12/31 (3)

前端javaScript的面向对象的相关知识,说起面向对象,前端工作,说实话,在日常的"搬砖"工作中很少能够用到面向对象的概念,估计也就是如果多个使用相同方法,对这个方法进行封装,然后重复调用,再加上前端javaScript本身历史的原因,这门语言,本身刚刚开发的时候,估计开发者就没有想到这门语言到现在有那么火,当然为了顺应历史的潮流,加上这么语言在当今市场的活跃表现和大众的认可,所以呢,这门语言开始了往其他语言进行靠拢的操作,其中最重要的环节就是面向对象这个操作。说实话我为啥突然想写这个文章,还有个原因就是我感觉我自己掌握的不是很牢靠,想记录一下,他不像后台语言一样,平时工作也很少使用到,所以我就想认真的做这篇文章,让我也加深一下印象。

javaScript是一种基于原型的语言,它没有类的声明,比如C++或者java、php等一众后台语言,这有时会让我这个做过php开发的人,也很困扰,之前觉得php不严谨,自从做了前端就感觉javaScript就更严谨,瞬间感觉在不严谨的路上越走约深,言归正传,javaScript可用方法作为类。定义类跟定义一个函数一样简单,例如:

function Person() { }
// 或
var Person = function(){ }

类的实例化

我们可以使用new obj 实例创建一个对象,进行调用这个实例化对象中的函数和变量。这种实例化方式在其他后台语言中使用方式都一样。

function Person() { }
var person1 = new Person();
var person2 = new Person();

构造器

在实例化时构造器被调用 (也就是对象实例被创建时)。构造器是对象中的一个方法。 (其实通俗点讲就是,实例化时默认的对象,这个时候我们可以在构造器中,进行对值得初始化等等操作)。在JavaScript中函数就可以作为构造器使用,因此不需要特别地定义一个构造器方法,每个声明的函数都可以在实例化后被调用执行。

构造器常用于给对象的属性赋值或者为调用函数做准备。 在本文的后面描述了类中方法既可以在定义时添加,也可以在使用前添加。

function Person() {
  alert('Person instantiated');
}

var person1 = new Person();
var person2 = new Person();

属性

属性就是类中包含的变量,每一个对象实例有若干个属性,为了正面的继承,属性应该被定义在类的原型属性(函数)中

可以使用关键词this调用类中的属性,this是对当前对象的使用。从外部读取其属性的语法是new Ojbect.Property;这与其他的后台语言的用法是一样的,在类中的语法就是this.Property常用与set和get属性值

例如:

function Person(firstName) {
  this.firstName = firstName;
  alert('Person instantiated');
}

var person1 = new Person('Alice');
var person2 = new Person('Bob');

// Show the firstName properties of the objects
alert('person1 is ' + person1.firstName); // alerts "person1 is Alice"
alert('person2 is ' + person2.firstName); // alerts "person2 is Bob"

方法

方法和属性很相似,其实就是在实例中封装一个函数,调用方法很像存取一个属性, 不同的是add () 在方法名后面很可能带着参数. 为定义一个方法, 需要将一个函数赋值给类的 prototype 属性; 这个赋值给函数的名称就是用来给对象在外部调用它使用的。

例如

function Person(firstName) {
  this.firstName = firstName;
}

Person.prototype.sayHello = function() {
  alert("Hello, I'm " + this.firstName);
};

var person1 = new Person("Alice");
var person2 = new Person("Bob");

// call the Person sayHello method.
person1.sayHello(); // alerts "Hello, I'm Alice"
person2.sayHello(); // alerts "Hello, I'm Bob"

思考下面代码

function Person(firstName) {
  this.firstName = firstName;
}

Person.prototype.sayHello = function() {
  alert("Hello, I'm " + this.firstName);
};

var person1 = new Person("Alice");
var person2 = new Person("Bob");
var helloFunction = person1.sayHello;

person1.sayHello();                                 // alerts "Hello, I'm Alice"
person2.sayHello();                                 // alerts "Hello, I'm Bob"
helloFunction();                                    // alerts "Hello, I'm undefined" (or fails
                                                    // with a TypeError in strict mode)
console.log(helloFunction === person1.sayHello);          // logs true
console.log(helloFunction === Person.prototype.sayHello); // logs true
helloFunction.call(person1);                        // logs "Hello, I'm Alice"

如上例所示, 所有指向sayHello函数的引用 ,包括 person1, Person.prototype, 和 helloFunction 等, 均引用了相同的函数.

在调用函数的过程中,this的值取决于我们怎么样调用函数. 在通常情况下,我们通过一个表达式person1.sayHello()来调用函数:即从一个对象的属性中得到所调用的函数。此时this被设置为我们取得函数的对象(即person1)。这就是为什么person1.sayHello() 使用了姓名“Alice”而person2.sayHello()使用了姓名“bob”的原因。

然而我们使用不同的调用方法时, this的值也就不同了。当从变量 helloFunction()中调用的时候, this就被设置成了全局对象 (在浏览器中即window)。由于该对象 (非常可能地) 没有firstName 属性, 我们得到的结果便是"Hello, I'm undefined". (这是松散模式下的结果, 在 严格模式中,结果将不同(此时会产生一个error)。 但是为了避免混淆,我们在这里不涉及细节) 。另外,我们可以像上例末尾那样,使用Function#call (或者Function#apply)显式的设置this的值。

拓展

关于js改变this指向的问题

继承问题

创建一个或多个类的专门版本类方式称为继承(Javascript只支持单继承)。 创建的专门版本的类通常叫做子类,另外的类通常叫做父类。 在Javascript中,继承通过赋予子类一个父类的实例并专门化子类来实现。在现代浏览器中你可以使用 Object.create 实现继承.

在下面的例子中, 我们定义了 Student类作为 Person类的子类. 之后我们重定义了sayHello() 方法并添加了 sayGoodBye() 方法.

// 定义Person构造器
function Person(firstName) {
  this.firstName = firstName;
}

// 在Person.prototype中加入方法
Person.prototype.walk = function(){
  alert("I am walking!");
};
Person.prototype.sayHello = function(){
  alert("Hello, I'm " + this.firstName);
};

// 定义Student构造器
function Student(firstName, subject) {
  // 调用父类构造器, 确保(使用Function#call)"this" 在调用过程中设置正确
  Person.call(this, firstName);

  // 初始化Student类特有属性
  this.subject = subject;
};

// 建立一个由Person.prototype继承而来的Student.prototype对象.
// 注意: 常见的错误是使用 "new Person()"来建立Student.prototype.
// 这样做的错误之处有很多, 最重要的一点是我们在实例化时
// 不能赋予Person类任何的FirstName参数
// 调用Person的正确位置如下,我们从Student中来调用它
Student.prototype = Object.create(Person.prototype); // See note below

// 设置"constructor" 属性指向Student
Student.prototype.constructor = Student;

// 更换"sayHello" 方法
Student.prototype.sayHello = function(){
  console.log("Hello, I'm " + this.firstName + ". I'm studying " + this.subject + ".");
};

// 加入"sayGoodBye" 方法
Student.prototype.sayGoodBye = function(){
  console.log("Goodbye!");
};

// 测试实例:
var student1 = new Student("Janet", "Applied Physics");
student1.sayHello();   // "Hello, I'm Janet. I'm studying Applied Physics."
student1.walk();       // "I am walking!"
student1.sayGoodBye(); // "Goodbye!"

// Check that instanceof works correctly
console.log(student1 instanceof Person);  // true
console.log(student1 instanceof Student); // true

对于“Student.prototype = Object.create(Person.prototype);”这一行,在不支持 Object.create方法的老JavaScript引擎中,可以使用一个"polyfill"(又名"shim",查看文章链接),或者使用一个function来获得相同的返回值,就像下面:

function createObject(proto) {
    function ctor() { }
    ctor.prototype = proto;
    return new ctor();
}

// Usage:
Student.prototype = createObject(Person.prototype);

原型链继承

继承方式包括(原型链继承、借用构造函数继承)

原型链的缺点

单纯的原型链继承最大的一个缺点,在于对原型中引用类型值的误修改。

//父类:人
    function Person () {
      this.head = '脑袋瓜子';
    }
    //子类:学生,继承了“人”这个类
    function Student(studentID) {
      this.studentID = studentID;
    }
    Student.prototype = new Person();

    var stu1 = new Student(1001);
    console.log(stu1.head); //脑袋瓜子

    stu1.head = '聪明的脑袋瓜子';
    console.log(stu1.head); //聪明的脑袋瓜子
    
    var stu2 = new Student(1002);
    console.log(stu2.head); //脑袋瓜子

  以上例子,我们通过重写 Student.prototype 的值为 Person 类的一个实例,实现了 Student 类对 Person 类的继承。所以 ,stu1 能访问到父类 Person 上定义的 head 属性,打印值为“脑袋瓜子”。我们知道,所有的 Student 实例都共享着原型对象上的属性。那么,如果我在 stu1 上改变了 head 属性值,是不是会影响原型对象上的 head 值呢?看我上面的代码就知道,肯定是不会。stu1 的 head 值确实是改变了,但是我重新实例化的对象 stu2 的 head 值仍旧不变。

  这是因为,当实例中存在和原型对象上同名的属性时,会自动屏蔽原型对象上的同名属性。stu1.head = "聪明的脑袋瓜子" 实际上只是给 stu1 添加了一个本地属性 head 并设置了相关值。所以当我们打印 stu1.head 时,访问的是该实例的本地属性,而不是其原型对象上的 head 属性(它因和本地属性名同名已经被屏蔽了)。

  刚才我们讨论的这个 head 属性是一个基本类型的值,可如果它是一个引用类型呢?这其中又会有一堆小九九。

  其实原型对象上任何类型的值,都不会被实例所重写/覆盖。在实例上设置与原型对象上同名属性的值,只会在实例上创建一个同名的本地属性。

  但是,原型对象上引用类型的值可以通过实例进行修改,致使所有实例共享着的该引用类型的值也会随之改变。

  //父类:人
    function Person () {
      this.head = '脑袋瓜子';
      this.emotion = ['喜', '怒', '哀', '乐']; //人都有喜怒哀乐
    }
    //子类:学生,继承了“人”这个类
    function Student(studentID) {
      this.studentID = studentID;
    }
    Student.prototype = new Person();

    var stu1 = new Student(1001);
    console.log(stu1.emotion); //['喜', '怒', '哀', '乐']

    stu1.emotion.push('愁');
    console.log(stu1.emotion); //["喜", "怒", "哀", "乐", "愁"]
    
    var stu2 = new Student(1002);
    console.log(stu2.emotion); //["喜", "怒", "哀", "乐", "愁"]

  我们在刚才的 Person 类中又添加了一个 emotion 情绪属性,人都有喜怒哀乐嘛。尤其需要注意的是,这是一个引用类型的值。这时,stu1 认为他还很“愁”,所以就通过 stu1.emotion.push ( ) 方法在原来的基础上增加了一项情绪,嗯,打印出来“喜怒哀乐愁”,没毛病。可是 stu2 是个乐天派,他咋也跟着一起愁了呢?!肯定不对嘛~

  这就是单纯的原型链继承的缺点,如果一个实例不小心修改了原型对象上引用类型的值,会导致其它实例也跟着受影响。

  因此,我们得出结论,原型上任何类型的属性值都不会通过实例被重写,但是引用类型的属性值会受到实例的影响而修改。原型链不能实现子类向父类中传参。这里就不细说了。

构造函数继承

在解决原型对象中包含引用类型值所带来问题的过程中,开发人员开始使用一种叫做借用构造函数的技术。实现原理是,在子类的构造函数中,通过 apply ( ) 或 call ( )的形式,调用父类构造函数,以实现继承。

例如:

//父类:人
    function Person () {
      this.head = '脑袋瓜子';
      this.emotion = ['喜', '怒', '哀', '乐']; //人都有喜怒哀乐
    }
    //子类:学生,继承了“人”这个类
    function Student(studentID) {
      this.studentID = studentID;
      Person.call(this);
    }
    
    //Student.prototype = new Person();

    var stu1 = new Student(1001);
    console.log(stu1.emotion); //['喜', '怒', '哀', '乐']

    stu1.emotion.push('愁');
    console.log(stu1.emotion); //["喜", "怒", "哀", "乐", "愁"]
    
    var stu2 = new Student(1002);
    console.log(stu2.emotion); //["喜", "怒", "哀", "乐"]

细心的同学可能已经发现了,该例子与上面的例子非常相似,只是去掉了之前通过 prototype 继承的方法,而采用了 Person.call (this) 的形式实现继承。别忘了,函数只不过是一段可以在特定作用域执行代码的特殊对象,我们可以通过 call 方法指定函数的作用域。

  (题外话:也许有的同学对 this 的指向还不完全清楚,我是这么理解的:谁调用它,它就指向谁。)

  在 stu1 = new Student ( ) 构造函数时,是 stu1 调用 Student 方法,所以其内部 this 的值指向的是 stu1, 所以 Person.call ( this ) 就相当于Person.call ( stu1 ),就相当于 stu1.Person( )。最后,stu1 去调用 Person 方法时,Person 内部的 this 指向就指向了 stu1。那么Person 内部this 上的所有属性和方法,都被拷贝到了 stu1 上。stu2 也是同理,所以其实是,每个实例都具有自己的 emotion 属性副本。他们互不影响。说到这里,大家应该清楚一点点了吧。

  总之,在子类函数中,通过call ( ) 方法调用父类函数后,子类实例 stu1, 可以访问到 Student 构造函数和 Person 构造函数里的所有属性和方法。这样就实现了子类向父类的继承,而且还解决了原型对象上对引用类型值的误修改操作。

缺点

 这种形式的继承,每个子类实例都会拷贝一份父类构造函数中的方法,作为实例自己的方法,比如 eat()。这样做,有几个缺点:

  1. 每个实例都拷贝一份,占用内存大,尤其是方法过多的时候。(函数复用又无从谈起了,本来我们用 prototype 就是解决复用问题的)

  2. 方法都作为了实例自己的方法,当需求改变,要改动其中的一个方法时,之前所有的实例,他们的该方法都不能及时作出更新。只有后面的实例才能访问到新方法。

  //父类:人
    function Person () {
      this.head = '脑袋瓜子';
      this.emotion = ['喜', '怒', '哀', '乐']; //人都有喜怒哀乐
      this.eat = function () {
        console.log('吃吃喝喝');
      }
      this.sleep = function () {
        console.log('睡觉');
      }
      this.run = function () {
        console.log('快跑');
      }
    }

  所以,无论是单独使用原型链继承还是借用构造函数继承都有自己很大的缺点,最好的办法是,将两者结合一起使用,发挥各自的优势

结合使用两种继承模式

//父类:人
    function Person () {
      this.head = '脑袋瓜子';
      this.emotion = ['喜', '怒', '哀', '乐']; //人都有喜怒哀乐
    }
    //将 Person 类中需共享的方法放到 prototype 中,实现复用
    Person.prototype.eat = function () {
      console.log('吃吃喝喝');
    }
    Person.prototype.sleep = function () {
      console.log('睡觉');
    }
    Person.prototype.run = function () {
      console.log('快跑');
    }
    //子类:学生,继承了“人”这个类
    function Student(studentID) {
      this.studentID = studentID;
      Person.call(this);
    }
    
    Student.prototype = new Person();  //此时 Student.prototype 中的 constructor 被重写了,会导致 stu1.constructor === Person
    Student.prototype.constructor = Student;  //将 Student 原型对象的 constructor 指针重新指向 Student 本身

    var stu1 = new Student(1001);
    console.log(stu1.emotion); //['喜', '怒', '哀', '乐']

    stu1.emotion.push('愁');
    console.log(stu1.emotion); //["喜", "怒", "哀", "乐", "愁"]
    
    var stu2 = new Student(1002);
    console.log(stu2.emotion); //["喜", "怒", "哀", "乐"]

    stu1.eat(); //吃吃喝喝
    stu2.run(); //快跑
    console.log(stu1.constructor);  //Student

首先,我们将 Person 类中需要复用的方法提取到 Person.prototype 中,然后设置 Student 的原型对象为 Person 类的一个实例,这样 stu1 就能访问到 Person 原型对象上的属性和方法了。其次,为保证 stu1 和 stu2 拥有各自的父类属性副本,我们在 Student 构造函数中,还是使用了 Person.call ( this ) 方法。如此,结合原型链继承和借用构造函数继承,就完美地解决了之前这二者各自表现出来的缺点。