前端——正儿八经的JS函数、原型、原型链必会向(七)

728 阅读16分钟

写在开头:本篇文章基本上属于基础科普向,不会涉及到ES6中的有关继承方面的问题,所以也就不会对函数及原型链进行太过详细的展开说明,也就不会有寄生组合继承这类较“高等”的编程思想。当然了,对于一些基础性的概念,肯定是在自我理解的基础上,讲的尽量透彻的,毕竟无论是一等公民:函数,还是原型链(JS又被人称为基于原型的语言),对于JS这门语言来说都显得至关重要。

JS中的函数(Function)

首先我们要明白一个根本性的概念,在JS这个万物皆对象的语言世界里面,函数也是一类对象,只是这类对象的地位要高出其他对象不少,即函数是可以被调用的对象,这类对象被称为Function对象。而在函数内部,也是有两种类别区分的,即自定义函数(个人所编写的函数)与系统函数(window自带的方法)。

对于系统函数常用的有window.encodeURIComponent、window.decodeURIComponent、window.atob、window.btoa、window.console.log、window.alert(其中window在代码编写过程中是可以省略的)等等。我相信这些函数或多或少都在代码中使用过,但可能并没有在意过这些细节。当然这些东西确实只用作了解就足够了。

但是对于自定义函数,我们还是应该要谨慎谨慎再谨慎的对待的,因为他们与系统函数不同,他们是我们所赋予和创造的,我们需要对他们“负责任”。

创造函数的三种写法

想要了解函数到底是怎么一回事,我们首先要知道他是通过什么方式被创造出来的,接下来我们来看一个🌰中的三段代码,这三段代码代表这创造函数的三种写法

demo1();
//其中第一种函数的声明(创造)方式是存在函数提升的情况的,所以调用函数的方法位置无限制
//第一种情况算是较为推荐的,比较函数提升可以算是JS的特性,也是为了解决相互递归问题而设计的
console.log(demo2);
//打印结果为undefined
function demo1() {
  console.log("demo1");
}
var demo2 = function() {
  console.log("demo2");
};
var demo3 = new Function("return console.log('demo3')");
demo2();
// 第二种算是函数表达式的方式声明(创造)的函数,本质有些依赖于第一种方式,可以算第一种的变种形式
// 当然了demo2是会被先当作是变量提升的,所以其调用位置需要跟在声明后面,用来覆盖其demo2的变量提升情况
demo3();
//第三种函数的声明方式其执行环境是在JS的VM(虚拟环境)当中的,并不建议使用
//况且“据说”Function构造函数使用字符串做为函数体,这会阻止JS引擎的优化并带来一些其它问题
//与第二种方式类似,也存在变量提升,调用位置需跟在声明后面。

好了,通过观察这三种函数的声明方式,我们可以通俗的总结一下,函数(自定义函数)的含义了。函数是一类特殊的对象,这类对象的价值即被调用时才能体现,而我们所调用的即函数内部的业务逻辑。通过调用一个又一个的函数,而将这些所调用的函数内部业务逻辑组合在一起,便实现了一个又一个的功能。

函数中的返回值情况

每每调用函数的时候,函数内部都是会生成一个默认返回值(undefined),而当我们在函数内部规定了返回值时,该返回值即覆盖默认返回值undefined。

现在我们再来看一个🌰,用于加深上面这句话的理解。

function demo1() {}
function demo2(a, b) {
  return a + b;
}
console.log(demo1());
// 打印结果为默认返回值undefined
console.log(demo2(1, 2));
// 在调用demo2函数时,传入参数1、2。通过函数内部逻辑,最后返回值覆盖默认返回值。打印结果为3。

函数当中的构造函数

通过上面的种种🌰,我们至少应该知晓两点。第一、函数的价值是通过函数的调用来体现的,第二、函数的调用本质即调用函数函数的内部业务逻辑。

现在通过这两点,我们再来较深入的了解函数中,构造函数的含义。对于OOP语言(面向对象语言),如同JAVA、C++语言来说都是由类这一概念的,至于为什么要有类这个概念,这里面涉及到了SOLID(面向对象设计)的概念。我们这里不展开说明,在这里我们只需要知道类即对象的单元模版。JAVA、C++语言中的对象就是类的实例化情况。

在JS语言世界里面,虽然经常被称为万物皆对象,但是这和面向对象还是有一定区别的。JS更像是一门基于对象的语言。所以在ES6之前,JS并没有类(class)这一概念,当然了哪怕是ES6中的类也只是一种语法糖而已(阮一峰老师ES6一书所认为的,其实两者间还是有区别的)。毕竟类这一概念虽然在ES6之前没有得到很好的体现,但是JS可是一门基于对象的语言,又怎么可能放过类的特性呢?JS函数当中的构造函数就具备该特性。

那么既然有了ES6中新的类的概念,我们还需要去了解构造函数吗?先要明确一点,ES6当中的class与构造函数当中有着细微区别,但是绝大部分的实际使用场景中,并不影响两者的使用情况。但是,哪怕我们不去深究两者间的细微差别。单单是两者的核心思想雷同这一点,去深入了解构造函数就是值得的。即了解了构造函数、基本也就掌握了ES6当中的class。

而且,对于构造函数的深入了解,有助于我们对函数的更深刻体会。让我们来一个🌰结合说明。

function Dog(name) {
  this.type = "animal";
  this.name = name;
  // 函数内部的this关键字其意义,即“构造”函数内部单元模版属性。
  // 一个不算是强制约束的规则,即为了区分一般函数与构造函数,构造函数的函数名首字母一般要大写。
  // 实际上每一个函数都可以被当作是构造函数,只是有些规则规范我们一定要默认遵守,毕竟代码除了要保证功能外,他更是用来给人看的。
}
var demo = new Dog("小强");
// 在执行dog构造函数,实例化成demo2对象时。我们应该都注意到了他与一般函数执行的不同之处,即多了一个new运算符
console.log(demo);
// 其打印结果为:dog {type: "animal", name: "小强"},即demo2对象且包含type、name属性。
// 对于构造函数来说,一般是不建议显示返回对象的,因为构造函数当中,this关键字就已经等于是在“构造”模版属性了,没必要多此一举。
// 甚至对于ts(typescript)来说,在语法解析上就直接限制了new运算符只能调用void函数(不能有显示返回:return出现)

根据上面的🌰我们现在再来分析一下new运算符(关键字)的内部逻辑:

  1. 创建一个demo对象,该对象继承自 Dog.prototype 的新对象被创建
  2. 使用相应的参数调用构造函数 Dog ,并将 this 绑定到新创建的对象,即将Dog构造函数实例化成demo对象。
  3. 执行Dog构造函数内部的逻辑代码。

由于new运算符涉及到了原型及原型链问题,其具体实现代码还是放在原型链当中再来详细讲解。甚至于构造函数本身的部分实现场景(构造函数继承其他构造函数)也涉及到原型链,不过我们可以先尝试用其他方式来暂时替代原型链情况,让我们再看一个🌰。

function Dog(name) {
  Animal.apply(this);
  this.name = name;
  // 嘿嘿,其实在看到this的时候,我们就可以联想到this的三基友(apply、call、bind)
  // 在我们暂时不考虑原型链的情况下,改变this的指向的对象,在形式上相当于原型的继承情况
}
function Animal(){
  this.type="animal"
}
var demo2 = new Dog("小强");
console.log(demo2);
// 打印结果与上述🌰类似,即内部constructor属性略有不同,其他基本完全相同。

函数中的回调函数

由于JS是单线程异步语言,所以在有些情况下,JS为了不阻塞线程是不会等待当前函数执行完成,才去执行其他函数的。例如常见的定时器函数(timer)就有专门的定时器线程来处理,而我们的ajax请求、前后端分离后的restful架构也都是异步处理的。

那么这里就会存在一个实际应用场景的问题,我之前有提到过:一个又一个的函数当中内部业务逻辑拼装到一起,用来实现各式各样的功能,那么在“拼装”的过程当中,拼装的顺序有时也会成为影响功能的一大因素。

再结合本身JS为单线程异步处理语言,回调函数为了解决这类需求痛点也就应运而生了(当然你如果想要同步回调也是没有问题的,但是个人认为一般情况下同步回调没有意义),到了ES6、ES7我们又拥有了更优雅的方式,例如Promise、async/await。其实这些在第二篇浏览器渲染有过讲解,我们在此处不去深究,通过🌰单纯的看一看被称作回调地狱的回调函数。

function demo(name, callback) {
  setTimeout(function demo1() {
    console.log("demo1");
    callback(name);
    // 虽然看上去有些不伦不类,但是回调函数的本质应该函数体现出来了的。
    // 简单来说,其实也是通过封装函数嵌套函数的方式,将本身该异步执行的情况,强行转换为自上而下同步执行
    // 打印结果依次是demo(setTimeout为异步处理,所以先执行主线程内的函数)、demo1、demo2。
    // 在ES6出来之后,回调函数出现的频率已经大幅度减少了,我们作为了解在实际开发当中已经完全够用了
  }, 0);
  console.log("demo");
}
demo("demo2", function(str) {
  console.log(str);
});

原型及原型链

先来明确一点,本人不画图、不画图、不画图,重要的事情说三遍。其实我自身对于那些较为复杂的图像理解并没有好感,因为每次看上去,都感觉脑袋是懵懵的。在这里我将通过文字的方式,逐步推进原型及原型链的本质。

在这里我们再次提出了之前的观点:JS是基于原型,万物皆对象的语言世界。这里面有两个关键词:原型、对象。既然他们两者算是JS语言的基石,那么两者之间肯定有着许多悄咪咪的“勾当”。当我们将他们之间的“勾当”了解清楚后,相信对于原型及原型链也就能彻底掌握了。

在OOP(面向对象)语言世界里,对象是通过不同的单元模版实例化出来的,然后再通过,对象与对象之间、对象与单元模版之间、单元模版与单元模版之间的复杂关系,衍生出了继承、多态、封装等等较为“高深”的设计理念。这些我们统统不用管,我们只需要明白在面向对象设计的语言当中,对象离不开单元模版。

原型对象

而单元模版这一理念,在JS当中也是存在的,只是他换了个名字存在罢了,即原型对象。好了我们现在要谨记的一点便是:原型对象就是单元模版。如果还有人不清楚单元模版与对象的关系,那么我再举一个形象的例子,对象与单元模版的关系就是模型与模具的关系,模型基于模具,但不一定仅限于模具

prototype

现在我们再来看一下原型对象是如何产生的。函数当中拥有着一种特殊的属性,该属性被命名为原型(prototype),虽然我们先不去讲与原型易混淆的__proto__,但是两者之间的一个区分点我们可以放在这里来说:即原型(prototype)是函数(一类较特殊对象)的特有属性,一般对象上并不存在该属性,而__proto__是对象(包含函数)外的一种“另类”,说他另类是因为该属性其实并不被官方所承认,但是目前还获得着部分浏览器的支持。至于两者具体的纠结关系,我们放到后面再谈。

回到原型对象,既然函数上有一特殊属性为原型(prototype),那么在函数“使用”该特殊属性的时候,便会“生成”一个原型对象。好了,现在我们已经搞清楚了对象、原型、原型对象之间的关系,来看一个小🌰加深一下印象。

function Demo() {
  this.x = "自身属性";
}
// 先简单的声明一个构造函数,该函数Demo上拥有自身的属性x
Demo.prototype.y = "原型上添加的属性";
// “使用”Demo函数的特殊属性原型(prototype),在“创建”原型对象的同时,向该原型对象里面添加y属性
var demo1 = new Demo();
// 执行构造函数,生成一个demo1对象
console.log(Demo.prototype);
// 打印结果为基于Demo函数的原型对象:{y: "原型上添加的属性", constructor: ƒ}
console.log(demo1);
// 打印结果为由Demo构造函数生成的对象:Demo {x: "自身属性"}
console.log(demo1.y);
// 通过上面的打印结果,在一般情况下,demo1应该是不存在y属性的,所以应该是会打印undefined的
// 但是由于其原型对象的缘故,不要忘了模型与模具的类比,所以打印时他先会去查询Demo对应的原型对象上的属性
// 所以打印结果为一串字符串:“原型上添加的属性”

constructor

根据上面的🌰,我们应该发现在打印Demo函数的时候,除了y属性之外,好像还多了一个默认的属性值constructor,那么这个constructor又是怎么一回事呢?

首先这里面打印出现的constructor指的是Object.prototype.constructor,并不是ES6当中的constructor。但是两者之间是有着应该是存在联系的。因为虽然class当中的继承是一种语法糖,但在实际应用场景当中,原型与constructor所遇到的需求问题,几乎都可以用ES6中的class解决。

当然对于constructor,我们还是应该有一些常识性的理解,用一个🌰来进行说明吧

function Demo() {
  this.x = "自身属性";
}
var demo=new Demo();
console.log(Demo.prototype.constructor);
// 打印值即为Demo构造函数本身
console.log(Demo.prototype.constructor===Demo);
// 甚至在JS渲染引擎当中,Demo函数与Demo.prototype.constructor是完全相等的。
// 所以Demo.prototype.constructor()与运行Demo函数也是完全相同的。
// 在基本业务场景当中,我们只需要知道这点目前来说也就足够了。

地位尴尬的__proto__

首先本人实名制,不建议使用__proto__的,先不说从最开始,JS官方就没有将其属性列为到标准当中,即便目前大部分浏览器都还是支持__proto__这一属性的。单单是实际应用场景都存在更便利的替代方案,这一点理由,我们就不需要去深究其内部含义了,当然了其基础理念还是应该掌握的。

废话不多说,直接上🌰

function Demo() {
  this.x = "自身属性";
}
var demo = new Demo();
Demo.prototype.y = "原型对象上的属性";
console.log(Demo.prototype);
console.log(demo.__proto__);
// 打印值都是Demo构造函数对应的原型函数:{y: "原型对象上的属性", constructor: ƒ}
console.log(Demo.prototype === demo.__proto__);
// Demo构造函数对应的原型对象与对象demo使用__proto__的属性时,在JS渲染引擎中是完全相同的
console.log(Demo.__proto__);
// 虽然函数也是一种对象,但是函数上的__proto__几乎没有任何意义,所以在typescript上,甚至会报一个语法解析错误

原型链

既然构造函数有相对应的原型对象,那么原型对象呢?他其实也有自己相对应的原型对象。甚至于他原型对象的原型对象都有可能是其他构造函数相对应的原型对象。是不是感觉稍微有点绕,那么再让我们来一个🌰,解释一下。

function Child() {
  this.name = "儿子级";
}
function Parent() {
  this.family = "家庭";
}
Parent.prototype.type = "家庭的原型属性";
// 我们在构造函数Parent相对应的原型对象上添加属性type
Child.prototype = new Parent();
// 我们将构造函数Child相对应的原型对象“指向”构造函数Parent。
var demo1 = new Child();
var demo2 = new Parent();
// 分别将构造函数Parent与Child进行实例化,生成对象demo1与demo2。
console.log(demo1);
console.log(demo2);
// 分别打印demo1与demo2,发现两者都只含有自身的属性。
console.log(demo2.type === demo1.type);
// 但是无论是demo2还是demo1都拥有type属性:“家庭的原型属性”,并且在JS渲染引擎当中,其两个对象中的属性是完全相同的
console.log(demo1.__proto__);
console.log(demo2);
// 两者的打印结果都指向构造函数Parent所实例化的对象demo2,但是这一次两者在JS渲染引擎当中却不是全等的。
// 当然这也可以理解,两者虽然值是完全相同的,但是作为复杂数据类型其引用地址并不相同,具体可以看第一篇基础实用向。
console.log(demo1.__proto__.__proto__);
console.log(demo2.__proto__);
// 两者的打印值皆为构造函数Parent原型对象,当然也是不全等的。其实这里的__proto__都可以被prototype所替代的
// 但是为了大家更形象的去理解__proto__,所以在这里都是以__proto__为🌰的。

通过上面的🌰,我们应该已经看到了一个关于原型对象的链式结构,这其实就是原型链。当我们想要寻找demo1对象上的某个属性时,其实也和作用域链一样,会有一个一层一层向上主动查找属性的行为。如果找到最外层也没有找到,就会返回一个undefined。

最后说两句:其实有了ES6之后,对于原型、原型链、函数的研究已经不是太过于必要了,但是其核心思想我们还是必须要理解的,毕竟ES6中的继承、还有class都是语法糖,仍然是基于原型、函数来设计的。我们可能一辈子都涉及不到造轮子的情况,但是每一丝编程思想的学习,都有助于我们解决问题能力的提升。大家共勉吧。