通俗完整解释JavaScript基于原型的面向对象机制

引言

本文参考:

重新介绍 JavaScript(JS 教程) - JavaScript | MDN (mozilla.org)

通过本文,你能了解到:

  1. this的指向问题
  2. newthis的原理
  3. prototype__proto__constructor的作用
  4. 构造函数的原理,即其背后发生的事情
  5. 原型链思想(简单引入),帮助你更好理解原型链
  6. call()函数的简单使用与作用体现

本篇文章是看了很多博客和视频最后进行的一个小总结,可能有一些啰嗦,但是希望通过这种方式把JS的核心——基于原型的对象机制进行完整梳理,力图讲清楚其前因后果。多多少少会受到之前传统面向对象编程语言的影响,导致我对JS的面向对象存在一定的”误解“,因此专门作此总结,提醒自己,也希望能帮助到大家哈哈。

文章中如有错误欢迎大家批评指正。

在经典的面向对象语言中,对象是指数据和在这些数据上进行的操作的集合。与 C++ 和 Java 不同,JavaScript 是一种基于原型的编程语言,并没有 class 语句(虽然后面es6引入了class关键字,但是本质是一种语法糖,并不是我们理解常规的类,实质还是原型),而是把函数用作类

怎么理解呢?我们从一个简单的需求开始讲起:

我们需要定义一个人名对象,这个对象包括人的姓和名两个属性。名字的方法有两种表示:

“名 姓(First Last)”或“姓,名(Last, First)”。

1.朴素做法

在我们无法创建类的情况下,一个常规思路如下:

function makePerson(first, last) {
    return {
        first: first,
        last: last
    };
}
function personFullName(person) {
    return person.first + ' ' + person.last;
}
function personFullNameReversed(person) {
    return person.last + ', ' + person.first;
}

var s = makePerson('Simon', 'Willison');
personFullName(s); // "Simon Willison"
personFullNameReversed(s); // "Willison, Simon"

缺陷:我们可以很容易发现,对象的属性和方法分离,而且很麻烦繁杂,需要在全局命名空间写很多函数。

但是注意:在JS中,一切皆是对象(除了null和undefined),包括函数。既然函数也是对象,如果需要使一个函数隶属于一个对象,不难得到如下改进:

2.继承属性与方法

进行改进:

// 定义一个makePerson方法,返回值是一个对象,这个对象有first、last属性,还有两个类型为方法的fullName属性和fullNameReversed属性
function makePerson(first, last) {
    return {
        first: first,
        last: last,
        fullName: function() {
            return this.first + ' ' + this.last;
        },
        fullNameReversed: function() {
            return this.last + ', ' + this.first;
        }
    }
}
s = makePerson("Simon", "Willison");
s.fullName(); // "Simon Willison"
s.fullNameReversed(); // Willison, Simon

说明:注意到将方法加入后,出现了this关键字。和最初的朴素做法对比不难发现,在这里this指向的是调用该方法的对象。

this的指向:如果在一个上使用点或者方括号来访问属性或者方法,这个对象就称了this 。如果没有使用这两种方法调用某个对象,那么this将指向全局对象。

例如:

// 示例一:
function Stu(name){
    return {
        sname: name,
        greeting: function() {console.log(`hello,${this.sname}`);}
    }
}
let s = new Stu("orange");
s.greeting();// 此时this.sname 的 this即为调用该方法的实例对象s

// 示例二:
let greetingFunc = s.greeting;// 将s的greeting方法赋值给全局变量
/*
下面的greetingFunc()等价于this.greetingFunc()等价于window.greetingFunc()
因为全局对象window没有sname属性,因此是undefined
*/
greetingFunc();// 控制台输出:hello,undefined。

3.新的伙伴:new关键字

为了更加简洁,我们引入一个new关键字进行改造:

// 构造函数Person,但是没有返回值
function Person(first, last) {
    this.first = first;
    this.last = last;
    this.fullName = function() {
        return this.first + ' ' + this.last;
    }
    this.fullNameReversed = function() {
        return this.last + ', ' + this.first;
    }
}
// new关键字可以调用构造函数
var s = new Person("Simon", "Willison");

newthis密切相关。

作用是创建一个崭新的空对象,然后使用指向那个对象的this调用特定的函数。

注意,含有 this 的特定函数不会返回任何值,只会修改 this 对象本身。

**new 关键字将生成的 this 对象返回给调用方,而被 new 调用的函数称为构造函数。**习惯的做法是将这些函数的首字母大写,这样用 new 调用他们的时候就容易识别了。

解释:含有 this 的特定函数不会返回任何值,只会修改 this 对象本身。

这句话是MDN里面看到的,按照我的理解,特定函数在这里特指被new调用的构造函数,而含有this的构造函数不会返回任何值。具体来说如下例子:

// 例子来源于:http://t.csdn.cn/6p8kE
function Person(){

    this.name="monster1935";
    this.age='24';
    this.sex="male";

    return "monster1935";

}
console.log(Person());  //monster1935
console.log(new Person());//Person {name: "monster1935", age: "24", sex: "male"}

也就是说,如果未使用new关键字调用构造函数,那么构造函数就是普通的函数,所以第一条打印输出为return的值是没有问题的。

但是如果使用new关键字,根据new关键字的作用,它会修改this对象本身,将生成的this对象返回给调用方,此时也就是第二条打印语句输出的结果。而非返回return里面的值。


简单来说,

new调用的构造函数是不会返回return里面的值的,只是返回new关键字生成的this对象给调用方。

本质上构造函数的只干了一件事情,创建了一个空对象,那么由谁指向这个空对象,则是通过new来决定的。new关键字会生成this对象,将其返回给调用方。

【上面这个不理解没关系,下文会详细解释执行构造函数发生的事情】

4.继续优化:共享属性(原型链思想先导)

虽然代码是清晰简单了不少,但是有一些不太好的地方。每次我们创建一个 Person 对象的时候,我们都在其中创建了两个新的函数对象——如果这个代码可以共享不是更好吗?【创建一个对象,就要再新创建两个函数,有点占用资源,如果这个资源可以共享最好了】

因此进行如下改进:

function personFullName() {
    return this.first + ' ' + this.last;
}
function personFullNameReversed() {
    return this.last + ', ' + this.first;
}
function Person(first, last) {
    this.first = first;
    this.last = last;
    this.fullName = personFullName;
    this.fullNameReversed = personFullNameReversed;
}

但是还是感觉有一种割裂感,代码一点儿也不优雅,因为personFullNamepersonFullNameReversed并不完全属于我们的Person对象,只是两个全局的函数。

实际上构造函数有一个属性:prototype,而这个属性就是为了解决构造函数的对象实例之间无法共享属性的痛点而生的。我们先看改进:

function Person(first, last) {
    this.first = first;
    this.last = last;
}
Person.prototype.fullName = function() {
    return this.first + ' ' + this.last;
}
Person.prototype.fullNameReversed = function() {
    return this.last + ', ' + this.first;
}

说明

Person.prototype:是Person构造函数的prototype属性,它是一个对象,这个对象里面存储着所有Person构造出来实例的共享属性和函数,而我们把这个对象称为原型对象。

我们把这俩个需要共享的方法存入原型对象Person.prototype。当实例对象需要访问这个方法时,只要去找Person.prototype索要就可以。

当我们访问这个函数时:

// 调用方法:fullName
let p = new Person("Simon", "Willison");
console.log(p.fullName());// Simon Willison

问题:当我们在控制台输入p时,可以发现压根没有fullName方法,只有first、last和[[Prototype]]三个属性。

image-20230326191939026

那么它是如何调用fullName()方法呢?[[Prototype]]又是什么呢?

分析

一个直观的感受就是[[Prototype]]里面存在fullName(),不难猜想,在p中找不到这个方法 ,JS解释器就选择从[[Prototype]]中去找有没有fullName(),恭喜你,你已经有原型链的思想了。

我们点开[[Prototype]]查看:

image-20230326192327349

不出所料,确实有我们需要的函数(两个共享的方法都在)。不出意料的话,这个p.[[Prototype]]就是我们的原型对象。我们通过p.[[Prototype]]拿到了需要的共享方法。

而在定义的时候,我们则是将这俩个共享方法存入Person.prototype

这侧面说明一点,Person.prototype===p.[[Prototype]]

但是呢,事实上p是无法通过点方法访问[[Prototype]]的。

image-20230326193221802

我们是通过__proto__来访问这个属性。

image-20230326193348872

那么只要证明Person.prototype===p.__proto__在控制台打印输出为true即我们的猜想正确。

image-20230326193500855

如上图,事实证明猜想没问题。

结论

  1. 实例对象=new 构造函数()

  2. 我们通过构造函数.prototype存储实例对象所共享的属性和方法,即构造函数.prototype.共享属性=xxx;

    构造函数.prototype为其实例的原型对象

  3. 构造函数实例的原型对象===构造函数.prototype === 实例对象.__proto__

  4. 实例对象.共享方法()的完整过程:

    • (1)实例对象中没有找到共享方法,去实例对象的原型对象实例对象.__proto__寻找
    • (2)在原型对象中找到共享方法,成功

拓展:注意到__proto__有一个属性:constructor,这个属性指向原构造函数。


看了知乎的一篇解释挺清楚的:https://zhuanlan.zhihu.com/p/92894937

  1. 万物皆对象:记住,方法是对象,方法的原型(Function.prototype)也是对象。因此他们都会具有对象共有的特点(如都含有属性)。

    • 对象具有属性__proto__,称为隐式原型(一个对象的隐式原型指向构造该对象的构造函数的原型)
  2. 方法

    方法这个特殊对象,除了拥有隐式原型属性,还有一个特有属性——显示原型属性(prototype)。这个属性是一个指针,指向一个对象,这个对象的用途就是包含所有实例共享的属性和方法(我们把这个对象叫做原型对象)。

    原型对象也有一个属性,叫做constructor,这个属性包含了一个指针,指回原构造函数。

    如下图:

img

理解上面的内容,对于原型链的理解就会更加容易了,本文对于原型链不进行详细展开,有时间再写。

执行构造函数时发生的事情

参考:js构造函数详解 - 长安城下翩翩少年 - 博客园 (cnblogs.com)

回过头来,我们再仔细讨论讨论执行构造函数时到底发生了什么事情?

let f = new Foo();为例:

// 1.定义一个构造函数
function Foo(name, age, sex){
    this.name = name;
    this.age = age;
    this.sex = sex;
}
// 2.在原型对象中定义一个共享属性,belief
Foo.prototype.belief = function(){
    console.log("Hello");
}
// 3.执行构造函数
let f = new Foo('orange', 20, '女');
// -----------------华丽的分割线-----------------------
// 模拟执行构造函数的过程
let f={};// 创建一个空对象
f.__proto__ = Foo.prototype;// 将该空对象继承自Foo.prototype
Foo.call(f, 'orange', 20, '女');// 执行Foo构造函数,将name,age,sex参数传入执行。
/*【关于call函数发生的事情,如下有解释】
  Foo.call(f, "orange", 20, "女");
  等价于:
  f.Foo = Foo;
  f.Foo("orange", 20, "女");
  delete(f.Foo);// 将挂载的方法删除。
  本质上就是把Foo的this指针替换为f,然后传入参数调用。
*/

// 4.原型链思想
f.belief();// Hello
// -----------------华丽的分割线-----------------------
// 模拟调用过程
f.belief();// 无法执行,f没有这个属性belief,顺着原型链去找
f.__proto__.belief();// 在f.__proto__找到belief,调用该方法输出"Hello"
/* 分析步骤 f.__proto__.belief();
 f.__proto__ === Foo.prototype ,也就是f的__proto__找到了Foo构造函数的属性,那么在上方可知Foo.prototype有定义一个belief()函数,因此f顺着__proto__这个节点找到了这个属性。
 __proto__就是原型链上的一个节点。
*/

一般call方法多用于改变this指针的指向。

call方法的实现:

Function.prototype.MyCall = function (obj) {
  let newObj = obj || window;// 若传入的obj存在,则新对象等于obj,否则等于window
  newObj.fn = this;
  let params = [...arguments].slice(1);// 第一个参数是obj,后面都是fn需要的参数。即obj.fn(params);
  let result = newObj.fn(...params);
  delete newObj.fn;
  return result;
}

测试:

function test(){
	console.log(this);
}
test();// Window {window: Window,  …}
test.MyCall([1, 2, 3]);// [1, 2, 3, fn: ƒ]

例题:filter手动实现

问题:

filter 过滤,filter()使用指定的回调函数测试所有元素,并创建一个包含所有通过测试的元素的新数组。

let arr=[2,4,6,8];
let arr=arr.filter(item=>item>5);
console.log(arr); //[6,8]

​ 请你手动实现一个myFilter方法,该方法可以和filter达到同样的效果。

Array.prototype.myFilter = function (callback) {
  // TODO:待补充代码
};

分析:

arr.filter(callback(item));
上面这一个步骤的内部流程是这样的:
1.遍历取出arr里的每一个元素item;
2.调用回调函数callback,并且该callback的返回值为boolean【过滤】
3.返回值为true的结果加入到新数组中
4.遍历结束后,返回新的数组【即过滤后的结果】

解决:

Array.prototype.myFilter = function (callback) {
  let newArr = [];
  this.forEach(item=>{
      if(callback(item)){
          newArr.push(item);
      }
  });
  return newArr;
};

解释:

this的指向其实就是调用myFilter方法的数组。注意到这个方法是定义在Array.prototype中的。

这里如何理解呢?其实就是原型链的思想。我们来分析一下这个过程:

// 调用
arr.myFilter(item=>item>5);
// 分解过程:
1.数据类型为数组的arr去寻找myFilter方法,没有找到,顺着原型链去找;
2.访问__proto__,发现myFilter方法,即可调用。
之前讲原型链的文章里面解释过,如果在一个上使用点或者方括号来访问属性或者方法,这个对象就成了this3.传入的匿名函数就是callback方法,即
function callback(item){
    return item>5;
}
4.正常执行myFilter中的方法即可。
  • 2
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值