JavaScript的面向对象编程思想与原型继承

1.面向对象编程思想

我们从多态说起,也就是常说的“鸭子模型”,JavaScript所谓的“鸭子模型”,是指一种动态类型语言的编程风格,形容在运行时,只要一个对象具有某些方法或属性,就可以认为它是某种类型的实例,不要求严格的类型一致性,只要相似就可以认为是同一类。

在JavaScript中,由于语言的动态性,对象的类型是在运行时决定的,而不是在编译时静态决定的,因此可以用同样的方式对待不同形状的对象。

我们无需强制一个对象必须是某个特定类的实例,而是可以根据形状和行为来组织代码。例如在写函数是接收任意类型的参数,或者实现面向对象的继承时允许多态性。

例如检查一个对象是否有某个方法:

	function print() {
      console.log('jsvascript多态性')
    }

    function hasPrint(obj) {
      // 在运行时检测是否存在print方法,如果存在就执行对应的操作
      return typeof obj === 'object' && typeof obj.print === 'function'
    }

    console.log(hasPrint({ print: print }))//true

然后我们可以传入任何参数,只要它具有print方法,就可以通过函数检查

	const obj = {print: () => {return 'print'}}
    const per = {print: () =>{console.log('name')}}
    const stu = {otherMethod: () => {console.log('age')}}
    console.log(hasPrint(obj))//true
    console.log(hasPrint(per))//true
    console.log(hasPrint(stu))//false

虽然这些对象的类型不同,但只要包含了print方法,就可以认为它属于某个特定类型(如上为“可以打印”类型)。这就是JavaScript的鸭子模型体现。

1.1 面向对象编程的重要特性

  • 封装
  • 继承
  • 多态性

在JavaScript中封装、继承和多态是面向对象编程的核心概念。

1.1.1 封装(Encapsulation)

封装是指将对象的状态(即属性)和行为(即方法)包装在一起,对外隐藏对象的部分属性和方法,只保留对外公开的接口。这样可以保护对象内部的状态不受干扰,从而更好的控制对象的访问和修改。

在以前会采用闭包等形式实现封装处理。例如:

	function Counter() {
      let count = 0;
      
      this.increment = function() {
        count++;
      };

      this.get = function() {
        return count
      };
    }
    
    let counter = new Counter;
    console.log(counter.get());//0
    counter.increment();
    console.log(counter.get());//1

在上面的代码中,Counter函数使用闭包封装了count变量,使其只能通过get和increment函数进行访问和修改,这样可以保护count变量的安全性。

1.1.2 继承(Inheritance)

继承是子类继承父类的属性和方法,并可以在此基础上添加新的属性和方法,继承使得代码的复用和扩展变得更加容易。

在JavaScript中使用原型链实现继承,一个对象的原型对象可以被指定为另一个对象这样可以实现属性和方法的继承。例如:

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

    Person.prototype.sayHello = function(name) {
      console.log(this)
      console.log('hello my name is' + this.name);
    }

    function Student(name, age) {
      Person.call(this, name);
      this.age = age;
    }

    Student.prototype = Object.create(Person.prototype);
    Student.prototype.constructor = Student;

    Student.prototype.sayAge = function() {
      console.log('my age is' + this.age)
    } 
    console.log(Student.prototype)//如下图

在上面的代码中,Student构造函数调用了Person构造函数,并使用Object.create()方法将Person.prototype设置为自己的原型,实现对Person属性和方法的继承。Object.create可以通过Object.create(null)来创建一个没有原型的对象。

在这里插入图片描述

1.1.3 多态

多态是指相同的操作作用于不同的对象,可以产生不同的结果。在面向对象编程中,多态使用的最重要的便是重写(方法的名字相同,但参数和实现不同)以及重载(方法的名字相同,但参数不同)。使用多态可以使代码更加灵活和可扩展。

在JavaScript中,由于语言的动态特性和“鸭子类型”的支持,多态实现的更加自然。一个对象的方法可以被任何对象调用,只要对象具有相同的方法名和参数即可。

function speak(animal) {
      if(animal && typeof animal.speak === 'function') {
        animal.speak()
      }
    }

    let cat = {
      speak: function() {
        console.log('meow~')
      }
    }

    let dog = {
      speak: function() {
        console.log('woof')
      }
    }

    let cow = {}
    speak(cat);//meow
    speak(dog);//woof
    speak(cow);//什么也不输出

在上面的代码中,speak可以接受任何对象作为参数,只要这个对象有speak方法,就可以调用该方法。

2. 原型继承

在早期,JavaScript中实现继承的方式是原型继承。

2.1 基本概念

当我们创建JavaScript对象时,JavaScript解释器会自动为这个对象添加一个特殊的属性和方法,他们是proto和prototype。

2.1.1 __proto__

proto是每个JavaScript对象都有的属性,它是指向该对象原型的指针,每个JavaScript对象都有一个原型,也就是该对象从其父对象继承属性和方法,在创建对象时,__proto__会被初始化为其构造函数的原型对象,它可以指向另一个对象,也可以指向null。

例如以下代码创建了一个Person对象,它的原型指向Object.prototype:

	let Person = {};
    console.log(Person.__proto__ === Object.prototype)//true  

proto这个方式是演变来的,所以在用的时候需要注意细节,可能会影响代码效率问题,在实际开发中,应优先使用现代的方式访问原型。

2.1.2 prototype

prototype是函数对象中的属性,每个函数默认拥有一个prototype对象(除了Function.prototype.bind())这个特殊函数,其实例对象可以继承这个prototype对象上的属性和方法。

我们可以使用构造函数和prototype创建自定义函数,并通过new实例化一个对象。例如:以下代码创建一个骑车Car类,并在其prototype上定义了一个drive方法:

    function Car() {}
    Car.prototype.drive = function() {
      console.log('drive')
    }
    let myCar = new Car();
    myCar.drive();//frive

在这个例子中,实例对象myCar继承了通过prototype定义的drive方法。

可以看出__proto__是一个指向内部[[prototype]]属性指针的引用,而prototype是函数对象的一个属性,指向一个对象,它被称作为这个函数的原型对象。简而言之,__proto__是访问对象原型链上一层的对象,prototype是在定义对象构造函数时设置它的原型,以解决继承问题。

2.2 原型继承的实现方式

原型继承可以让我们通过继承父对象的属性和方法来创建一个新对象。JavaScript中所有对象都有一个原型,而原型也可以拥有自己的原型,形成了一个原型链,下面介绍几种实现原型继承的方式。

2.2.1 原型链继承

原型链继承是最常见的一种实现方式,其基本思想是通过原型链来实现继承。

	function Parent() {
      this.name = 'parent'
    }

    Parent.prototype.sayName = function() {
      console.log(this.name)
    }

    function Child() {
      this.name = 'child'
    }

    Child.prototype = new Parent();

    let child = new Child();
    child.sayName()//child

在这个例子中,我们定义了父构造函数Parent和一个子构造函数Child,通过将child.prototype指向一个父对象的实例,实现子对象继承父对象的属性和方法。

但上述方法有缺点:

缺点1:在运用原型进行继承时,有用构造器借用、公用原型等等方法实现,如果公用原型,就会导致属性发生改变后影响所有子类。

缺点2:不能给父构造器传参,无法实现super()功能。

2.2.2 构造函数借用

构造函数借用也是一种原型继承的方式,该方式的基本思想是通过调用父构造函数并将子构造函数作为上下文来初始化属性。

	function Parent(name) {
      this.name = name || "default name";
    }

    Parent.prototype.sayName = function() {
      console.log(this.name);
    }

    function Child(name) {
      Parent.call(this, name);
    }

    let child1 = new Child("child1");
    console.log(child1.name); // "child1"

在这个例子中,定义了一个父构造函数Parent和一个子构造函数Child。在创建子构造函数实例时,我们使用了call()函数来调用父构造函数并将其子构造函数作为上下文来初始化子对象的属性。

2.2.3 组合继承

组合继承也称为伪经典继承,是基于以上两种的混合方式,其基本思想是组合使用原型链继承和借用构造函数。

	function Parent(name) {
      this.name = name || "default name";
    }

    Parent.prototype.sayName = function(){
      console.log(this.name);
    }

    function Child(name) {
      Parent.call(this, name);
    }

    Child.prototype = new Parent();

    let child1 = new Child("child1");
    console.log(child1.name); // "child1"
    child1.sayName();// "child1"

在这个例子中,我们定义了一个父构造函数Parent和一个子构造函数Child。在创建子构造函数的实例时,我们使用call()函数来调用父构造函数并将其子构造函数作为上下文来初始化子对象的属性,然后将子构造函数的原型等于一个父对象的实例。

2.3 实现继承的技巧

  • 直接使用Object.creat()方法,它可以创建一个新的对象,将原型指向指定的对象。
  • 使用构造函数配合prototype实现继承。
  • 使用ES6 class 关键字实现继承,它本质上也是基于原型的继承。
  • 使用混入方式实现继承,这种方式可以保持对象独立性的同时,将多个对象合并成一个对象。可以使用Object.assign()方法来实现。
  • 使用组合方式实现继承,即将几个对象进行组合,从而达到继承的效果。这种方式需要借助自定义函数或封装类实现。

3.面向对象编程进阶

提到面向对象,我们提的更多的是对象,工作中我们会发现很多时候都在处理对象内容,比如在react中,我们的状态是对象类型,我们需要将该数据渲染到页面中。

3.1 对象相关方法

JavaScript中Object类型内置有很多有用的办法,这里列举一些常用的方法:
1.Object.keys(obj):返回一个包含所有可枚举属性名称的数组(不包括从原型继承来的属性)。
2.Object.values(obj):返回一个包含所有可枚举属性值的数组。
3.Object.entries(obj):返回一个包含所有可枚举属性名称和属性值的二维数组。
4.Object.hasOwnProperty(prop):检查一个对象是否含有指定名称的属性(不包括从原型继承而来的属性)。
5.Object.defineProperty(object, key, option):为对象定义属性。
6.Object.freeze(obj) :冻结一个对象,不能向这个对象添加新的属性,不能删除已有属性,不能修改该对象已有属性的可枚举型、可配置性、可写性,以及不能修改已有属性的值。此外冻结一个,象后该对象的原型也不能被修改。
7.Object.seal(obj):密封一个对象,使其不能添加、删除属性,但可以修改属性的值和特性。
8.Object.assign(target, s1, s2):将一个或多个源对象的所有可枚举属性复制到目标对象。

其中Object.keys()、Object.values()、Object.entries()都是ES6新增的方法,在旧版浏览器中不一定支持。

3.2 代理对象(Proxy)

JavaScript中还有Object类型的一个非常有用的特性——代理对象(Proxy)。代理对象允许你拦截对目标对象的操作,并定义自己的行为,他的语法如下:

	let obj = {
	  name: 'John',
	  age: 30
	};
	
	let proxy = new Proxy(obj, {
	  get(target, prop, receiver) {
	    console.log('get ' + prop);
	    return target[prop];
	  },
	  set(target, prop, value, receiver) {
	    console.log('set ' + prop + ' to ' + value);
	    target[prop] = value;
	    return true;
	  }
	});
	
	console.log(proxy.name); // 输出 "get name" 和 "John"
	proxy.age = 31; // 输出 "set age to 31"
	console.log(proxy.age); // 输出 "get age" 和 "31"

上面的的代码创建了一个代理对象Proxy,然后通过get和set方法拦截了对目标对象obj的读写操作,并在控制台输出了相关信息。在实际使用中,代理对象的用途非常广泛,比如可以用来实现数据绑定、数据验证和缓存等,需要注意的是,代理对象不能拦截一些原生的操作,比如Object.defineProperty()方法。

3.3 代理对象实战(Proxy)

非常流行的库——immer,便运用了proxy这一特性,实现了immutable处理。

3.3.1 Immer基本概念

Immer是一款非常流行的JavaScript库,用于管理JavaScript对象的不可变状态。Immer使用ES6的Proxy对象来捕获对对象的修改,并返回一个可变的代理对象。在Immer库内部,这个代理对象被用于追踪每次对对象的修改,最终生成一个全新的不可变对象。

3.3.2 手写Immer核心源码

下面是immer库使用Proxy对象实现的核心源码:

// 定义一个生成递归代理对象的函数
	function createNextLevelProxy(base, proxy) {
	  return new Proxy(base, {
	    get(target, prop) {
	      // 如果读取的不是函数,且 prop 不是immer.proxies或immer.originals,则返回代理对象的相应属性
	      if (prop !== "immer_proxies" && prop !== "immer_original") return proxy[prop];
	      // 否则返回目标对象的相应属性
	      return target[prop];
	    },
	    set(target, prop, value) {
	      // 将之前代理的值存入 original 对象中,用于恢复状态
	      const original = target[prop];
	      // 在代理对象上运行修改操作,改变对象的状态
	      proxy[IMMER_PROXY_MARKER].mutations.push({
	        prop,
	        value,
	        original,
	      });
	      // 将新的值赋值到目标对象中
	      target[prop] = value;
	      // 返回新的代理对象
	      return true;
	    },
	  });
	}
	
	// 定义 createProxy 函数,用于创建递归代理对象
	function createProxy(base, parent) {
	  const target = Array.isArray(base) ? base.slice() : { ...base };
	  const proxy = Object.defineProperty(
	    {
	      [IMMER_PROXY_MARKER]: {
	        parent,
	        target,
	        drafted: false,
	        finalizing: false,
	        finalized: false,
	        mutations: [],
	      },
	    },
	    "immer_original",
	    {
	      value: base,
	      writable: true,
	      configurable: true,
	    }
	  );
	  for (let prop in target) {
	    if (isObject(target[prop])) {
	      // 递归代理子对象
	      target[prop] = createNextLevelProxy(target[prop], proxy);
	    }
	  }
	  return proxy;
	}

4. new创建对象原理详解

4.1 创建对象的过程

使用new关键字可以在JavaScript中创建对象。当我们使用new操作符时,它会执行以下操作:

1.在内存中创建一个新对象。

2.这个新对象内部的[[prototype]]特性被赋值为构造函数的prototype属性。

3.构造函数内部的this被赋值为这个新对象。(即this指向新对象)

4.执行构造函数内部的代码。(给新对象添加属性)

5.如果构造函数返回非空对象,则返回该对象;否则返回刚创建的新对象。

下面是一个实例:

	function Person(name, age) {
		this.name = name;
		this.age = age;
	}
	
	Person.prototype.sayHello = function() {
		console.log('hello , my name is ' + this.name + ' and I am ' + this.age + 'years old');
	}
	
	let person = new Person('lily' , 10);
	person.sayHello();//输出 "Hello, my name is lily, and I am 10 years old."

在创建person对象的过程中,new操作符执行了以下五个步骤:

1.创建了一个新对象person。

2.将person的[[prototype]]特性赋值为Person.prototype。

3.将构造函数Person的this对象设置为person。

4.执行构造函数Person内部的代码。

5.因为构造函数Person没有返回值,所以返回新创建的对象person。

4.2 手写一个new

1.创建一个空对象,并将其原型设置为构造函数的prototype属性。

2.将该空对象作为this关键字调用构造函数。

3.如果构造函数返回一个对象,则返回该对象,否则返回新创建的对象。

实例代码:

    function myNew(constructor, ...args) {
      const obj = Object.create(constructor.prototype);
      const res = constructor.apply(obj, args);
      return res instanceof Object ? res : obj;
    }
    function Person(name, age) {
      this.name = name;
      this.age = age;
    }

    Person.prototype.sayHello = function() {
      console.log('hello , my name is ' + this.name + ' and I am ' + this.age + 'years old');
    }

    let person = myNew(Person, 'lily', 10)
    // let person = new Person('lily' , 10);
    person.sayHello();//输出 "Hello, my name is lily, and I am 10 years old."

在myNew函数中,我们先通过Object.create()方法创建了一个空对象obj,并将其原型设置为构造函数的prototype属性。然后使用apply()方法将构造函数的this指向该对象,并传递了参数args,最后判断构造函数的返回值是否是一个对象,如果是返回该对象,否则返回新创建的对象。

需要注意的是,由于new操作符在使用时还会进行额外的处理,如设置该对象的constructor属性等,因此以上实现不完整,但以上实现方式可以帮助我们更好理解new操作符的原理和实现过程。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值