JS 从看懂到看开

2021-04-20 10:26

阅读:422

标签:实现   函数   总结   原则   表达式   引用   但我   初始   引用类型   

 技术图片

  文章篇幅较长,知识点涵盖比较广泛,作为学习 JS 的一个总结。文章中仅涵盖 ES5 及之前的传统的知识点,未涵盖 ES6 及之后的新特性。

  JavaScript(简称“JS”) 是一种具有函数优先的轻量级,解释型或即时编译型的编程语言。虽然它是作为开发Web页面的脚本语言而出名的,但是它也被用到了很多非浏览器环境中,JavaScript 基于原型编程、多范式的动态脚本语言,并且支持面向对象、命令式和声明式(如函数式编程)风格。

  主要内容有:值传递与引用传递、深拷贝与浅拷贝、弱类型定义语言的特性、JS的“类”、JS中的this、call/apply/bind方法、原型链中的__proto__、原型链中的prototype、原型链中constructor、原型链总结、JS面向对象、JS实现继承的几种方式

值传递与引用传递

  JS 是弱类型语言,相比于强类型语言,其对函数式编程的支持更加丰富。对于 JS 来说,数据类型也分为基本数据类型和引用数据类型,基本数据类型包括:Number、String、Boolean、Null、 Undefined、Symbol(ES6),除上述六种类型外,其余类型均为引用类型,也就是我们所说的“类”。基本类型的传值为值传递,而引用类型的传值为引用传递,比如值传递的例子:

技术图片技术图片技术图片

   引用传递的例子:

技术图片 

深拷贝与浅拷贝

  说到值传递与引用传递,就不得不再说一下深拷贝与浅拷贝。对于一个对象来说,拷贝便是复制该对象来创建一个与该对象一模一样的副本。对象的成员也有基本数据类型成员及引用数据类型成员,在拷贝时针对引用数据类型成员,如果是仅将引用拷贝了一份则为浅拷贝,否则为深拷贝,比如一个浅拷贝的例子:

技术图片

   可以看到,对于 r 中的 sons 只是拷贝了一份 p 中 sons 的引用, r 与 p 共用同一个 sons 对象,r 对其的修改也会影响到 p。很多情况下我们希望 r 与 p 是完全独立的两个对象,那么对于 p 中的引用类型我们便需要也创建一份副本传递给 r 而不是仅仅给 r 传递一个引用。深拷贝我们可以使用递归实现:

技术图片

   深拷贝、浅拷贝代码如下:


function
copy(p){ var result={}; for(i in p){ result[i]=p[i]; } return result; }
function deepCopy(p,c){
    if(p==null||p==undefined){return p;}
    for(i in p){
        if(typeof p[i]===‘object‘){
            c[i]=(p[i].constructor===Array)?[]:{};
            deepCopy(p[i],c[i]);
        }else{
            c[i]=p[i];
        }
    }
    return c;
}

var p={
    name:p,
    sons:{
        son1:‘1‘,
        son2:‘2‘
    }
}

var r=deepCopy(p,{});
r.name=‘r‘;
r.sons.son1=‘rSon1‘;
console.log(‘p.name  :‘+p.name);
console.log(‘r.anme  :‘+r.name);
console.log(‘p.son.son1  :‘+p.sons.son1);
console.log(‘r.son.son1  :‘+r.sons.son1);
弱类型定义语言的特性
  JS 是一个弱类型定义语言,弱类型定义语言即为数据类型可以被忽略的语言。它与强类型定义语言相反, 一个变量可以赋不同数据类型的值。比如我们可以将字符串 ‘12‘ 和整数 3 进行连接得到字符串 ‘123‘,
然后可以把它看成整数 123,而不需要显示转换:

技术图片

   刚接触弱类型这个名词可能会与强类型、动态类型、静态类型的关系搞不清楚。弱类型/强类型,动态类型/静态类型是两个角度的分类方式。弱类型/强类型强调的是类型是否安全、而静态/动态强调的是在编译期还是运行期进行类型检查。定义如下图所示: 

技术图片

  JS中我们操作的是“变量”,但我们不关心变量属于什么类型,只要变量有我们操作变量是需要的行为即可。这便是 Has-a 与 Is-a 的问题,理解该问题对于理解JS的类型至关重要。  

  实际上JS对变量类型的宽容给编码带来了很大的灵活性,由于无需进行类型检测,开发者可以尝试调用任意对象的任意方法,而无须去考虑它原本是否被设计为拥有该方法。这一切都建立在鸭子类型(duck typing)的概念上。鸭子类型的通俗说法是:“如果它走起路来像鸭子,叫起来也是鸭子,那么它就是鸭子。”

  比如如果我们要的只是鸭子的叫声,这个声音的主人到底是一个鸡还是要鸭子并不重要。鸭子类型指导我们只关注对象的行为,而不关注对象本身,也就是关注HAS-A(拥有什么),而不是IS-A(是什么)。

  比如我们定义一个Duck类型和一个Dog类型:

function Duck(){
    this.name=‘duck‘;
}

function Dog(){
    this.name=‘dog‘;
}

Duck.prototype={
    say:function(){
        console.log(‘i am a ‘+this.name);
    }
}

Dog.prototype=Duck.prototype;

  我们创建一个 ducks 数组让 duck 们依次 say:

var ducks=[];
ducks[0]=new Duck();
ducks[1]=new Dog();
for(i in ducks){
    ducks[i].say();
}

  因为 Dog 类型也拥有 say() 方法,所以也可以无障碍的加入鸭子数组中。我们的处理代码并不在乎数组里是 duck 还是 dog,只在乎数组中的对象能不能 say()。

  在动态类型语言的面向对象设计中。鸭子类型的概念至关重要。利用鸭子类型的思想,我们不必借助超类型的帮助,就能轻松地在动态类型语言中实现一个原则:“面向接口编程,而不是面向实现编程”。无法面向接口,很难想象我们如何使用依赖倒置原则来设计健壮可重用的代码。

JS的“类”

   除了上面说的面向接口编程,面向对象编程也是 JS 的一项重要特性。

  JS 中是没有真正意义上 “类” 的概念的。我们定义一个类往往是通过定义一个构造函数来声明类的结构。而且在将对象 new 出来后,我们可以为对象新增任意属性,对象的结构并不受限于构造函数中声明的结构。例如我们可以在new一个对象后为该对象合法的添加构造函数中不存在的属性(这里是 run 属性):

技术图片

   new字是一个语法糖,一个来自 JS 之父的关怀,我们可以想象,不使用new,我们依然可以创建一个对象:

技术图片

  多写了几行代码。new关键字的内在运作机制也是这样的,私下里帮我们写了这些多出来的代码。 (其中的 call 方法会在下面 this 中说)

  new私下帮我们做了三件事:

  创建一个新对象:var car={};  

  将 this 指向新对象:this=car;(伪代码,这样写是不对的)

  执行 this.xxx=xxx,也就是在 car 中新增构造函数中的属性。

  这样便完成了一个对象的初始化,对比看看,JAVA 初始化对象是如何做的:

  在堆中开辟一片新的空间 -->  设置对象头(指向对象所属的类的指针、GC分代信息等等)--> 在该内存中设置成员变量,并设置初始化值(比如 int 是0,引用类型是null等)--> 执行构造函数,为成员变量赋值。

  对象创建的过程大同小异,核心都是 开辟空间-->设置成员 -->创建完成返回指向该对象的指针。

  只是 JS 中没有 “类” 的概念,我们用构造方法代劳实现模板模式。

JS中的this

  看了上面 new 关键字的运行机制,this指向的改变在创建新对象时至关重要。对 this 关键字的理解对于 JS 的学习也至关重要,下面我们看一下 this 到底指向哪里。

  当一个函数没有明确的调用对象的时候, 也就是单纯作为独立函数调用的时候, 将对函数的this使用默认绑定: 绑定到全局的window对象。比如:

技术图片

   或者:

技术图片

   所以在全局函数内定义的临时变量并不能通过 this 获取:

技术图片

   而将一个方法声明在一个对象内(非函数对象)时,this 被显式绑定到该对象上:

技术图片

  但要注意一种特殊情况,对一个在对象内的函数中声明的函数,其 this 指向 window 。比如:

技术图片

  因为按理说,其应该指向包含它的对象 fun ,但 fun 是一个函数对象,this 不能指向函数对象,所以指向了window。

  如果一个对象被作为另一个对象的属性,不影响 this 的指向:

技术图片

   this 的指向简单的说便是指向了调用该方法的对象(非函数对象,若函数包含函数则不再向上寻找调用对象,直接绑定window)。
  那么我们考虑一种情况,如果用外部引用调用对象中的方法,this 指向对象还是外部引用,如下:

技术图片

  可以看到,this 指向了外部引用所属的对象 outter。这说明this的指向不是由定义this决定的, 而是随脚本解析自动赋值的。所以分以下几种情况:

  1. 在全局作用域中,this指向window。

  2. 在函数作用域中,this指向调用该函数的对象(非函数对象)。其实全局作用域中便是在window中被调用,指向window合情合理。

  3. 对象中的方法中的 this 指向该方法所属的对象。

  4. 构造函数中指向新对象。如上一节所说,new 时改变了构造函数中this的指向。

  5. 在异步操作中指向window,比如定时器函数中:

技术图片

   6.在事件绑定时,指向绑定事件的元素。

   7. lamda表达式中this直接指向window而不是包含它的对象:

技术图片

    a. 箭头函数的this是在定义函数时绑定的, 不是在执行过程中绑定的

    b. 箭头函数中的this始终指向父级对象

    c. 所有 call() / apply() / bind() 方法对于箭头函数来说只是传入参数, 对它的 this 毫无影响。

技术图片

  向要改变 this 的指向,JS提供了三个方法。

call、apply、 bind方法

  每个函数对象都拥有上述三个方法,可以用来改变 this 的指向。

  call 函数可以将函数中 this 指向改为传入的对象,比如:

技术图片

  原本 this 指向 window ,call(aaa)时执行 fun 函数并将函数中的 this 指向了aaa。

  对于有入参的函数,在对象后依次放入参数即可:

技术图片

  apply() 与call() 非常相似, 不同之处在于提供参数的方式, apply() 使用参数数组, 而不是参数列表:

技术图片

   bind() 创建的是一个新的函数( 称为绑定函数), 与被调用函数有相同的函数体, 当目标函数被调用时this的值绑定到 bind() 的第一个参数上:

技术图片

   上述三个函数每个方法对象都可以使用,它们属于方法对象原型链顶端的 Function 的原型对象:

技术图片

 原型链中的__proto__

  上面我们说了 JS 中的类的定义及创建方式,下面我们来看一下 JS 中实现类的关联的原型链。

  在 JS 中,我们创建的对象都会包含一个默认属性:--proto--。该属性并非继承而来,而是对象天生自带的,比如我们创建一个空的对象a:

技术图片

  可以看到,即使 a 是一个空的对象,依然会被自动赋予一个属性 __proto__,指向了Object(函数对象指向 f())。--proto--属性便是对象的“原型”。

  每个对象维护着一个--proto--指针,指向了对象的原型。依靠该指针,我们可以实现对象间的继承关系。

  与 Java 中的继承类似,在 Java 中每个对象的对象头中维护着一个类型指针,当我们调用的方法在对象所属的类中不存在时,就会去对象的父类中寻找。

  而在 JS 中,继承是对象与对象之间的关系,而不是类与类。

  也就是说,在 JS 中,不仅方法会沿着继承链向上寻找,属性也会沿着继承链向上寻找,这与 Java 是不同的。因为 Java 的类型支持更加完备,对于属性,无论是父类的属性还是子类的属性,在创建对象时都会写入子类的对象中(private等私有属性除外),并且在创建子类对象时会同时执行父类的构造函数与子类的构造函数将父类的属性及子类的属性进行初始化。

  在 Java 中,属性是对象创建时便写在对象的结构中的,只有函数需要沿着继承链去寻找确认。在 Java 中,继承链是类与类之间连接起来的,属性的继承是在对象创建时完成的。

  而在 JS 中,原型链是由对象指向对象的,无论对象的属性还是方法,都需要我们沿着对象的原型链去寻找。当一个属性或方法子类没有时,便会沿着__proto__指针向上寻找,找到便直接使用父对象中对应的属性或方法,若找不到便继续沿着父类的__proto__指针向上寻找,知道在基类中还找不到,便会报undefined错误。

  所以,子类使用父类的属性时,其实是和父类共用了同一个属性,而不是在子类中真正继承了一个属于子类的属性。

  在改变子类的属性时,如果属性时一个基本数据类型,则为子类新增该属性,不影响父类中的属性,比如:

技术图片

  我们改变 son 中的 name,并没有影响 father中的 name,而是为 son 新增了一个 name 属性。

  引用类型也一样:

技术图片

  需要注意的是,既然当子类使用父类的属性时是子类与父类共用同一个属性,那么父类的属性修改时将会影响所有的子类:

技术图片

   可以看到,修改 father 的 name 时,son1 与 son2 的 name 都将修改。因为 son1 与 son2 都没有 name 属性,它们都在与 father 共用一个 name 属性。

原型链中的prototype

  在上一节我们看到,一个对象(非函数对象)在创建时会被自动添加一个__proto__属性,但并没有 prototype 属性。

  prototype 属性是属于函数对象的,而非普通对象,我们在创建一个函数对象时,函数对象会自带一个 prototype 属性,这个属性指向了该函数的“原型对象”。需要注意的是“原型对象”与上一节中说的“原型”是两个概念。

技术图片

   prototype 属性仅在函数在作为构造使用时才会发生作用,用于为构造出的对象添加“原型”,也就是添加父对象。当一个函数被作为构造函数使用时,new 出来的对象的__proto__指针将会指向该函数的 prototype 指针指向的对象。比如:

技术图片

   可以看到,对构造函数 Car 添加 prototype 可以达到为 car 对象添加 __proto__的效果,但这样做显然比每次 new 出 car 来都为其添加__proto__简洁的多。

原型链中constructor

  每个原型都会有一个 constructor 属性,指向了它的构造函数。例:

技术图片

   当我们在使用别人实例化好的对象时,如果我们想要为类添加属性,可以通过为对象的构造函数的 prototype 增加属性来实现。此时我们可以借助 instance.constructor.prototype

来拿到构造函数,进而拿到 prototype 对象。

  有一点值得注意的是,当我们修改一个对象的原型时,需要手动的修改原型的 constructor 属性,否则其指向还是原型的构造函数。比如:

function Father(){}
function Son(){}
var father=new Father();
console.log(father.constructor===Son);
Son.prototype=father;
console.log(father.constructor===Son);
console.log(Son.prototype.constructor===Son);

技术图片

   我们如果不手动修改构造函数的 prototype ,prototype 是一个自动生成的对象,其 constructorconstructor是指向构造函数的:

技术图片

   所以当我们手动修改构造函数的原型对象时,需要一并修改对象函数的 constructor 指向,使其指向该函数。防止使用 constructor 的场景出现错误。

原型链总结

  每个构造函数都有一个原型对象,原型对象都包含一个指向构造函数的指针,而实例都包含一个指向原型对象的内部指针。那么假如我们让原型对象等于另一个类型的实例,结果会怎样?显然,此时的原型对象将包含一个指向另一个原型的指针,相应地,另一个原型中也包含着一个指向另一个构造函数的指针。假如另一个原型又是另一个类型的实例,那么上述关系依然成立。如此层层递进,就构成了实例与原型的链条。这就是所谓的原型链的基本概念。

  狭义的说,沿着对象的__proto__指针构成的链表便是原型链。最终整个原型链的关系如下:

技术图片

JS面向对象

  JS 与 JAVA 在继承上本质的不同是,JAVA 的继承是类之间的继承;而 JS 之间的继承是对象之间的继承。

  因为 JAVA 有完备的类型支持,我们声名类和类与类之间的关系,并在此结构下创建对象。属性在创建对象时绑定到对象中,而函数顺着继承链向上查找。

  而 JS 的继承是在对象之间通过__proto__指针拉起原型链,对象共用原型链中祖先对象的属性。属性与函数均顺着继承链向上查找。

  因为一直在做Java,在很长一段时间内对 JS 的继承模式都觉着别扭。没有类和实例的概念,实例间通过指针连接起来共用祖先属性怎么想都不如 Java 中以类为模板并定义类之间的关系,对象只是特定场景下类的实例来的合理。知道看过阮一峰老师的解读后才恍然大悟,了解一个特性的由来还是需要了解其产生的目的和特定的历史背景,毕竟软件不一定一直都是在追求最合理,厚此薄彼、让步妥协的设计也极为广泛的存在着,甚至说为了什么设计而牺牲什么设计在每个软件工程中都是必然发生的。

  而对于 JS ,这种让步就体现在,面向对象对语言简洁性的让步。

  因为最初的 JS 被设计为实现用户与浏览器交互的简单语言,借此将服务器端的一部分压力分担到前端来,所以 JS 仅需要支持一些简单的功能即可。

  但是在 JS 诞生的时期,面向对象的概念正如日中天,开发者们希望 JS 也拥有面向对象的特性。但是如果像其它语言一样实现类和实例的概念的话将大大提升 JS 的复杂性和学习成本。

  JS 之父参照了其它语言(比如C++,Java)中创建对象的方式,关键字 new + 构造函数。将类的结构声明在构造函数中,借此模拟类的概念,实现面向对象。

  比如:

技术图片

   这样一来便实现了面向对象的概念,但是还存在一个问题,就向如上例子中,color 是每个实例特有的属性,各个实例间是不一样的。但是 name 在各个实例间是一样的,都是 car 。那么用如上方式创建的 car1 和 car2 中都存储着一份相同的 name ,这样及其浪费空间,而且也不符合面向对象中继承的特性,向上抽取。

  于是便有了我们上面说的原型链。将每个实例公共的属性抽取到原型中实现,并以此实现继承的概念:

技术图片

   这样一来,内存中只有原型中有一个name,以此为原型的对象共用它。换句话说,借此我们可以将对象间公共的属性抽取到原型中来,实现继承的概念。

JS实现继承的几种方式

  上面介绍了类、原型链以及 JS 的面向对象,下面我们看一下在 JS 中如何实现继承。

  原型继承  

  简单的说将便是为一个对象指定一个原型,通过该固定的原型构建原型链。

function Father(name,age){
    this.name=name;
    this.age=age;
}
function Son(){}
Son.prototype=new Father(‘myfather‘,‘150‘);
var son=new Son();

  这种方式有一个坏处便是无法给父类构造函数传参,因为原型链上是一个固定的实例化对象。就比如上面这个例子,所有 Son 的父对象都是 new Father(‘myfather‘,‘150‘),而不能实现每个Son对象的父类名字和年龄不一样,在实际生产中这种方式我们基本不用。

  调用父类构造函数

  为了解决上面的问题,在子类中调用父类的构造函数,即通过apply或者call改变父类构造函数中的 this 指向子类对象,为子类对象增加父类构造函数中定义的属性。

  这样每个子类中父类的属性都是与该子类绑定的,是每个子类特有的。该方法与原型链没有关系,子类的原型并不是父类,只是通过构造函数有了父类的属性。

function Father(name,age){
    this.name=name;
    this.age=age;
}
Father.prototype.company=‘inspur‘;
function Son(name,age){
    this.flag=‘from son‘;
    Father.apply(this,[name,age]);
}
var son=new Son(‘son1‘,‘18‘);
console.dir(son);
console.log(son.company);

技术图片

  可以看到,子类中自带父类的 age 和 name,不需要去原型链向上寻找。但是子类的 __proto__是 Object 而不是 Father ,那么弊端也显而易见,子类单纯的继承了父类,但没有与父类在一条继承链上。也就是说父类的祖先对象中的属性子类无法使用。

  组合继承

  为了解决子类与父类不在一条原型链的问题,出现了组合继承的方式。即我们结合 原型继承 和 调用父类构造函数继承 两种方式,既在子类构造函数中调用父类构造函数,又为子类添加一个父类原型使其构成一条完整的原型链。

function Father(name,age){
    this.name=name||‘default name‘;
    this.age=age;
}
Father.prototype.company=‘inspur‘;
function Son(name,age){
    this.flag=‘from son‘;
    Father.apply(this,[name,age]);
}
Son .prototype=new Father();
var son=new Son(‘son1‘,‘18‘);
console.dir(son);
console.log(son.company);

技术图片

   寄生组合继承

  ES5中最常用的方式,名字高大上,但跟 组合继承 差不多。只是组合继承中子类的__proto__指向父类对象,但寄生组合继承中子类的__proto__指向父类对象的原型。也就是说,寄生组合继承中子类对象拥有独立的父类构造方法中的属性,在原型链上与父类对象平级公用一个父类对象作为原型。

  为了下面理解顺畅,我们先看一个方法 Object.create(oto, propertiesObject)。create方法用于创建一个新对象,并将新对象的__proto__指向传入参数 oto,propertiesObject是可选参数,如果传入则将这些属性添加到新生成的对象中,比如:

function Father(){
    this.name=‘myFather‘;
}
var father=new Father();
var son=Object.create(father,{
    age: {
        value:"18",
        enumerable: true
      },
    say:{
        value:function(){
        console.log(‘i am son‘);
        },
        enumerable: true
      }
});
console.log(son.__proto__===father);
console.log(son.name);
console.dir(son);
son.say();

技术图片  

  可以看到,age 与 say 是son 对象自带的属性,而 son 对象的原型为 father,可以使用 father 的属性。而传进去的 propertiesObject 写法略显怪异,以为其牵扯到了数据属性与访问器属性,这个我们后面再说。

   这样创建对象可以更加简洁的为对象指定__proto__,其与 new 的不同在于 new 出的对象的__proto__指向了构造函数的prototype,而该方法的__proto__指向传入的第一个参数,该参数是可以为 null 的:

技术图片

   看完 Object.create 方法,我们再来看寄生组合继承,寄生组合继承与组合继承的不同是,寄生组合继承通过 Object.create 方法创建子类对象并将子类对象的__proto__指向父类的__proto__:

function Father(){
    this.name=‘father‘;
}
function Son(){
    Father.call(this);
}
Son.prototype=Object.create(Father.prototype);
var son=new Son();
console.dir(son);

技术图片

   注意上述代码中的:

function Father(){
    this.name=‘father‘;
}
function Son(){
    Father.call(this);
}
Son.prototype=Object.create(Father.prototype);
var son=new Son();
console.dir(son);

  可以看到虽然子类是有构造函数的,但其只是调用了父类的构造函数,产生的对象的 constructor 属性指向了父类的构造函数:

技术图片

   所以我们需要修改子类对象的 constructor 属性和构造函数原型对象的 constructor 属性。

 

JS 从看懂到看开

标签:实现   函数   总结   原则   表达式   引用   但我   初始   引用类型   

原文地址:https://www.cnblogs.com/niuyourou/p/12255837.html


评论


亲,登录后才可以留言!