JavaScript基础知识学习总结

以下主要是个人 学习/复习 总结的要点,具体原理分析会比较少。如果需要深入学习原理,请看最后搬运文章

数据类型

ECMAScript标准规定了7种数据类型,其把这7种数据类型又分为两种:原始类型和对象类型。

原始类型

  • Null:只包含一个值:null
  • Undefined:只包含一个值:undefined
  • Boolean:包含两个值:truefalse
  • Number:整数或浮点数,还有一些特殊值(-Infinity+InfinityNaN
  • String:一串表示文本值的字符序列
  • Symbol:一种实例是唯一且不可改变的数据类型

(在es10中加入了第七种原始类型BigInt,现已被最新Chrome支持)

引用类型

  • Object:自己分一类丝毫不过分,除了常用的ObjectArrayFunction等都属于特殊的对象

判断JavaScript数据类型

  1. typeof

    typeof是一个操作符,其右侧跟一个一元表达式,并返回这个表达式的数据类型。返回的结果用该类型的字符串(全小写字母)形式表示,包括以下 7 种:number、boolean、symbol、string、object、undefined、function 等。

    typeof可以有效的辨认处原始类型,但对于引用类型不能够全部有效辨认

    • 对于基本类型,除 null 以外,均可以返回正确的结果。
    • 对于引用类型,除 function 以外,一律返回 object 类型。
    • 对于 null ,返回 object 类型。
    • 对于 function 返回 function 类型。
  2. instanceof

    instanceof 是用来判断 A 是否为 B 的实例,表达式为:A instanceof B,如果 A 是 B 的实例,则返回 true,否则返回 false。

    但是instanceof并不是那么准确:

    [] instanceof Array; // true
    {} instanceof Object;// true
    newDate() instanceof Date;// true
     
    function Person(){};
    newPerson() instanceof Person; //true
     
    [] instanceof Object; // true
    newDate() instanceof Object;// true
    newPerson instanceof Object;// true
    

    从 instanceof 能够判断出 [ ].proto 指向 Array.prototype,而 Array.prototype.proto 又指向了Object.prototype,最终 Object.prototype.proto 指向了null,标志着原型链的结束。因此,[]、Array、Object 就在内部形成了一条原型链:

    0D4B17EA-7B30-4155-B609-F66E3A40499E

    从原型链可以看出,[] 的 proto 直接指向Array.prototype,间接指向 Object.prototype,所以按照 instanceof 的判断规则,[] 就是Object的实例。依次类推,类似的 new Date()、new Person() 也会形成一条对应的原型链 。因此,instanceof 只能用来判断两个对象是否属于实例关系, 而不能判断一个对象实例具体属于哪种类型。

    不过针对数组问题,ES5提出了Array.isArray(),可以更方便的检测是否是数组对象

  3. constructor

    constructor可以很好的辨认出引用数据类型,但由于函数的 constructor 是不稳定的,这个主要体现在自定义对象上,当开发者重写 prototype 后,原有的 constructor 引用会丢失,constructor 会默认为 Object。

  4. toString

    toString() 是 Object 的原型方法,调用该方法,默认返回当前对象的 [[Class]] 。这是一个内部属性,其格式为 [object Xxx] ,其中 Xxx 就是对象的类型。

    对于 Object 对象,直接调用 toString() 就能返回 [object Object] 。而对于其他对象,则需要通过 call / apply 来调用才能返回正确的类型信息。

    Object.prototype.toString.call('') ;   // [object String]
    Object.prototype.toString.call(1) ;    // [object Number]
    Object.prototype.toString.call(true) ; // [object Boolean]
    Object.prototype.toString.call(Symbol()); //[object Symbol]
    Object.prototype.toString.call(undefined) ; // [object Undefined]
    Object.prototype.toString.call(null) ; // [object Null]
    Object.prototype.toString.call(newFunction()) ; // [object Function]
    Object.prototype.toString.call(newDate()) ; // [object Date]
    Object.prototype.toString.call([]) ; // [object Array]
    Object.prototype.toString.call(newRegExp()) ; // [object RegExp]
    Object.prototype.toString.call(newError()) ; // [object Error]
    Object.prototype.toString.call(document) ; // [object HTMLDocument]
    Object.prototype.toString.call(window) ; //[object global] window 是全局对象 global 的引用
    

null与undefined

null

表示被赋值过的对象,刻意把一个对象赋值为null,故意表示其为空,不应有值。

所以对象的某个属性值为null是正常的,null转换为数值时值为0

undefined

表示“缺少值”,即此处应有一个值,但还没有定义,

如果一个对象的某个属性值为undefined,这是不正常的,如obj.name=undefined,我们不应该这样写,应该直接delete obj.name

类型转换

image-20200812181815823

if语句和逻辑语句

if语句和逻辑语句中,如果只有单个变量,会先将变量转换为Boolean值,只有下面几种情况会转换成false,其余被转换成true

null
undefined
''
NaN
0
false
复制代码

各种运数学算符

我们在对各种非Number类型运用数学运算符(- * /)时,会先将非Number类型转换为Number类型;

1 - true // 0
1 - null //  1
1 * undefined //  NaN
2 * ['5'] //  10
复制代码

注意+是个例外,执行+操作符时:

  • 1.当一侧为String类型,被识别为字符串拼接,并会优先将另一侧转换为字符串类型。
  • 2.当一侧为Number类型,另一侧为原始类型,则将原始类型转换为Number类型。
  • 3.当一侧为Number类型,另一侧为引用类型,将引用类型和Number类型转换成字符串后拼接。
123 + '123' // 123123   (规则1)
123 + null  // 123    (规则2)
123 + true // 124    (规则2)
123 + {}  // 123[object Object]    (规则3)
复制代码

==

使用==时,若两侧类型相同,则比较结果和===相同,否则会发生隐式转换,使用==时发生的转换可以分为几种不同的情况(只考虑两侧类型不同):

  • 1.NaN

NaN和其他任何类型比较永远返回false(包括和他自己)。

NaN == NaN // false
复制代码
  • 2.Boolean

Boolean和其他任何类型比较,Boolean首先被转换为Number类型。

true == 1  // true 
true == '2'  // false
true == ['1']  // true
true == ['2']  // false
复制代码

这里注意一个可能会弄混的点:undefined、nullBoolean比较,虽然undefined、nullfalse都很容易被想象成假值,但是他们比较结果是false,原因是false首先被转换成0

undefined == false // false
null == false // false
复制代码
  • 3.String和Number

StringNumber比较,先将String转换为Number类型。

123 == '123' // true
'' == 0 // true
复制代码
  • 4.null和undefined

null == undefined比较结果是true,除此之外,null、undefined和其他任何结果的比较值都为false

null == undefined // true
null == '' // false
null == 0 // false
null == false // false
undefined == '' // false
undefined == 0 // false
undefined == false // false
复制代码
  • 5.原始类型和引用类型

当原始类型和引用类型做比较时,对象类型会依照ToPrimitive规则转换为原始类型:

  '[object Object]' == {} // true
  '1,2,3' == [1, 2, 3] // true
复制代码

来看看下面这个比较:

[] == ![] // true
复制代码

!的优先级高于==![]首先会被转换为false,然后根据上面第二点,false转换成Number类型0,左侧[]转换为0,两侧比较相等。

[null] == false // true
[undefined] == false // true
复制代码

根据数组的ToPrimitive规则,数组元素为nullundefined时,该元素被当做空字符串处理,所以[null]、[undefined]都会被转换为0

所以,说了这么多,推荐使用===来判断两个值是否相等...

原型到原型链

祭出经典老图:

335B2CFD-E610-4F0B-A1B8-404175C4688F

prototype

4.3.5 prototype

object that provides shared properties for other objects

在ES2019规范中,Prototype被定义为:给其他对象提供共享属性的对象。

也就是说,我们说Prototype对象描述的是两个对象之间的某种关系,其中一个,为另一个提供属性访问权限。

每个函数都有一个Prototype属性,也只有函数才会有Prototype属性

function Person() {

}
// 虽然写在注释里,但是你要注意:
// prototype是函数才会有的属性
Person.prototype.name = 'Kevin';
var person1 = new Person();
var person2 = new Person();
console.log(person1.name) // Kevin
console.log(person2.name) // Kevin

接下来我们看看构造函数和原型间的关系:

FCA7D6D3-BC47-4416-BF72-BE117675B0D6

_proto_

这个对象不同于prototype,这是每个JavaScript对象(除了null)都具有的一个属性,这个属性会指向该对象的原型。

我们可以通过代码证明:

function Person() {

}
var person = new Person();
console.log(person.__proto__ === Person.prototype); // true

于是我们更新了关系图:

24649F12-A718-4377-8D9F-4AD6542531AF

constructor

既然实例对象和构造函数可以指向原型,那么原型是否有属性指向构造函数呢?

constructor是由原型指向关联构造函数的原型属性。

function Person() {

}
console.log(Person === Person.prototype.constructor); // true

更新关系图:

B6AC0EB1-0F62-42F6-B109-6446EF5DA471

原型的原型

原型也是一个对象,原型对象也是通过Object构造函数生成的。所以原型的原型就是Object.prototype

同时,JS是单继承,Object.prototype是原型链的顶端,所有对象从它继承了包括toString等等方法和属性。

更新关系图:

79C5C318-0A95-4001-8B25-631352DA4F4F

原型链

原因是每个对象都有 __proto__ 属性,此属性指向该对象的构造函数的原型。

对象可以通过 __proto__与上游的构造函数的原型对象连接起来,而上游的原型对象也有一个__proto__,这样就形成了原型链。

56AB30E8-7D32-48E0-B71D-96681DAB6E64

词法作用域和动态作用域

作用域

作用域是指程序源代码中定义变量的区域。作用域规定了如何查找变量,也就是确定当前执行代码对变量的访问权限。

JavaScript是采用词法作用域(lexical scoping),也就是静态作用域

静态作用域和动态作用域

静态作用域:函数的作用域在定义的时候就已经决定了

动态作用域:函数的作用域在函数调用的时候才决定

var value = 1;

function foo() {
    console.log(value);
}

function bar() {
    var value = 2;
    foo();
}

bar();

// 结果是 ???

假设JavaScript采用静态作用域,让我们分析下执行过程:

执行 foo 函数,先从 foo 函数内部查找是否有局部变量 value,如果没有,就根据书写的位置,查找上面一层的代码,也就是 value 等于 1,所以结果会打印 1。

假设JavaScript采用动态作用域,让我们分析下执行过程:

执行 foo 函数,依然是从 foo 函数内部查找是否有局部变量 value。如果没有,就从调用函数的作用域,也就是 bar 函数内部查找 value 变量,所以结果会打印 2。

前面我们已经说了,JavaScript采用的是静态作用域,所以这个例子的结果是 1。

执行上下文

什么是执行上下文?

执行上下文就是当前 JavaScript 代码被解析和执行时所在环境的抽象概念, JavaScript 中运行任何的代码都是在执行上下文中运行。

执行上下文的类型

执行上下文总共有三种类型:

  • 全局执行上下文: 这是默认的、最基础的执行上下文。不在任何函数中的代码都位于全局执行上下文中。它做了两件事:1. 创建一个全局对象,在浏览器中这个全局对象就是 window 对象。2. 将 this 指针指向这个全局对象。一个程序中只能存在一个全局执行上下文。
  • 函数执行上下文: 每次调用函数时,都会为该函数创建一个新的执行上下文。每个函数都拥有自己的执行上下文,但是只有在函数被调用的时候才会被创建。一个程序中可以存在任意数量的函数执行上下文。每当一个新的执行上下文被创建,它都会按照特定的顺序执行一系列步骤,具体过程将在本文后面讨论。
  • Eval 函数执行上下文: 运行在 eval 函数中的代码也获得了自己的执行上下文,但由于 Javascript 开发人员不常用 eval 函数,所以在这里不再讨论。

执行上下文栈

执行栈,也就是在其它编程语言中所说的“调用栈”,是一种拥有 LIFO(后进先出)数据结构的栈,被用来存储代码运行时创建的所有执行上下文。

当 JavaScript 引擎第一次遇到你的脚本时,它会创建一个全局的执行上下文并且压入当前执行栈。每当引擎遇到一个函数调用,它会为该函数创建一个新的执行上下文并压入栈的顶部。

引擎会执行那些执行上下文位于栈顶的函数。当该函数执行结束时,执行上下文从栈中弹出,控制流程到达当前栈中的下一个上下文。

我们通过一段代码来理解:

let a = 'Hello World!';

function first() {
  console.log('Inside first function');
  second();
  console.log('Again inside first function');
}

function second() {
  console.log('Inside second function');
}

first();
console.log('Inside Global Execution Context');

1F93EEFA-7F58-4483-91C2-C55F18464C58

上述代码的执行上下文栈。

当上述代码在浏览器加载时,JavaScript 引擎创建了一个全局执行上下文并把它压入当前执行栈。当遇到 first() 函数调用时,JavaScript 引擎为该函数创建一个新的执行上下文并把它压入当前执行栈的顶部。

当从 first() 函数内部调用 second() 函数时,JavaScript 引擎为 second() 函数创建了一个新的执行上下文并把它压入当前执行栈的顶部。当 second() 函数执行完毕,它的执行上下文会从当前栈弹出,并且控制流程到达下一个执行上下文,即 first() 函数的执行上下文。

first() 执行完毕,它的执行上下文从栈弹出,控制流程到达全局执行上下文。一旦所有代码执行完毕,JavaScript 引擎从当前栈中移除全局执行上下文。

执行上下文

我们已经看到了 JavaScript 引擎如何管理执行上下文,现在就让我们来理解 JavaScript 引擎是如何创建执行上下文的。

执行上下文分两个阶段创建:1)创建阶段; 2)执行阶段

当函数被调用,但未执行任何其内部代码之前,会做以下三件事:

  • 创建变量环境:首先初始化函数的参数 arguments,提升函数声明和变量声明。下文会详细说明。
  • 创建词法环境:也就是作用域链(Scope Chain)在执行期上下文的创建阶段,作用域链是在变量对象之后创建的。作用域链本身包含变量对象。作用域链用于解析变量。当被要求解析变量时,JavaScript 始终从代码嵌套的最内层开始,如果最内层没有找到变量,就会跳转到上一层父作用域中查找,直到找到该变量。
  • 确定 this 指向:包括多种情况,下文会详细说明

这是执行上下文最重要的三个属性,接下来我们详细讲述。

变量对象

首先我们需要明白一点: JavaScript并不像大部分其他编程语言是根据顺序一句一句执行,而会有一个变量提升、函数提升的一个过程。

接下来我们通过一个案例来解释引擎是如何一段一段执行的。

showName()
console.log(myname) 
var myname = 'javascript' 
function showName() { 
  console.log('函数showName被执⾏'); 
}


//结果:
函数showName被执行
undefined

我们可以看到:

  1. 在⼀个变量定义之前使⽤它,不会出错,但是该变量的值会为undefifined,⽽不是定义时的值。
  2. 在⼀个函数定义之前使⽤它,不会出错,且函数能正确执⾏。

根据这种变量提升的现象,我们将变量分为两种情况:变量对象(VO)以及活动对象(AO)。未进入执行阶段之前,变量对象(VO)中的属性都不能访问!但是进入执行阶段之后,变量对象(VO)转变为了活动对象(AO),里面的属性都能被访问了,然后开始进行执行阶段的操作。但它们其实都是同一个对象,只是处于执行上下文的不同生命周期。

接下来,我们展示一个完整的变量对象的案例,从创建上下文-->执行阶段的变化

function foo(a) {
  var b = 2;
  function c() {}
  var d = function() {};

  b = 3;

}

foo(1);

在执行上下文后:

AO = {
    arguments: {
        0: 1,
        length: 1
    },
    a: 1,
    b: undefined,
    c: reference to function c(){},
    d: undefined				//虽然 d 是一个函数,但是根据规定函数可以得到提升,但是函数表达式并不会提升
}

代码执行:

AO = {
    arguments: {
        0: 1,
        length: 1
    },
    a: 1,
    b: 3,
    c: reference to function c(){},
    d: reference to FunctionExpression "d"
}

作用域链

JavaScript属于静态作用域,即声明的作用域是根据程序正文在编译时就确定的,有时也称为词法作用域。

其本质是JavaScript在执行过程中会创造可执行上下文,可执行上下文中的词法环境中含有外部词法环境的引用,我们可以通过这个引用获取外部词法环境的变量、声明等,这些引用串联起来一直指向全局的词法环境,因此形成了作用域链。

重点到了,那这个外部环境是什么呢?作用域链的外部环境其实是由词法作用域决定的,词法作用域在代码阶段就已经决定好了,和函数怎么调用是没有关系的。这其实就对应到第一问里的静态作用域

举个例子:

var a = 10
function fn() {
  var b = 20
  function bar() {
    console.log(a + b) //30
  }
  return bar
}
var x = fn(),
  b = 200
x() //bar()

函数bar调用了外部的fnb的值,然后又继续在fn的外部环境中找a的值,这样就构成了一个作用域链。

我们再使用一张图来解释词法作用域

我们可以看到整个词法作用域链:foo()函数域 --> bar()函数域 --> main()函数域 --> 全局作用域

所以作用域链与谁调用它并没有关系,只与词法作用域有关系,也就是我们上面讲的静态作用域

this 指向

由于上文提到的词法作用域的关系,JavaScript在对象内部调用对象属性这种需求情况下却无法得到满足。所有就有了this这套体系。先说结论:

  1. new调用:绑定到新创建的对象
  2. callapplybind调用:绑定到指定的对象
  3. 由上下文对象调用:绑定到上下文对象
  4. 默认:全局对象

this指向主要有以下

  1. 全局执行上下文this
  2. 函数执行上下文this
  3. new构造函数this
  4. 箭头函数this
  5. DOM事件绑定this
全局执行上下文this

全局执⾏上下⽂中的this是指向window对象的。这也是this和作⽤域链的唯⼀交点,作⽤域链的最底端包含了window对象,全局执⾏上下⽂中的this也是指向window对象。相较于函数执行上下文this的复杂情况,全局则较为简单。

函数执行上下文this

在一个函数上下文中,this由调用者提供,由调用函数的方式来决定。如果调用者函数,被某一个对象所拥有,那么该函数在调用时,内部的this指向该对象。如果函数独立调用,那么该函数内部的this,则指向undefined。但是在非严格模式中,当this指向undefined时,它会被自动指向全局对象。

但由于函数调用的不同情况还是很多的,有必要用一些例子来演示一下:

// 为了能够准确判断,我们在函数内部使用严格模式,因为非严格模式会自动指向全局
function fn() {
  'use strict';
  console.log(this);
}

fn();  // fn是调用者,独立调用
window.fn();  // fn是调用者,被window所拥有

在上面的简单例子中,fn()作为独立调用者,按照定义的理解,它内部的this指向就为undefined。而window.fn()则因为fn被window所拥有,内部的this就指向了window对象。

var a = 20;
var foo = {
  a: 10,
  getA: function () {
    return this.a;
  }
}
console.log(foo.getA()); // 10

var test = foo.getA;
console.log(test());  // 20

foo.getA()中,getA是调用者,他不是独立调用,被对象foo所拥有,因此它的this指向了foo。而test()作为调用者,尽管他与foo.getA的引用相同,但是它是独立调用的,因此this指向undefined,在非严格模式,自动转向全局window。

var a = 20;
function getA() {
  return this.a;
}
var foo = {
  a: 10,
  getA: getA
}
console.log(foo.getA());  // 10

由于getA()并不是独立调用,被对象foo所拥有,因此他的this只想foo

new构造函数的this
function Person(name, age) {

    // 这里的this指向了谁?
    this.name = name;
    this.age = age;   
}

Person.prototype.getName = function() {

    // 这里的this又指向了谁?
    return this.name;
}

// 上面的2个this,是同一个吗,他们是否指向了原型对象?

var p1 = new Person('Nick', 20);
p1.getName();

我们已经知道,this,是在函数调用过程中确定,因此,搞明白new的过程中到底发生了什么就变得十分重要。

通过new操作符调用构造函数,会经历以下4个阶段。

  • 创建一个临时对象
  • 给临时对象绑定原型
  • 给临时对象对应属性赋值
  • 将临时对象return

因此,当new操作符调用构造函数时,this其实指向的是这个新创建的对象,最后又将新的对象返回出来,被实例对象p1接收。因此,我们可以说,这个时候,构造函数的this,指向了新的实例对象:p1。

注:当new出来的对象时,构造函数返回对象,默认是返回自身,但如果手动返回一个对象时,则会按照返回的对象返回给实例而不是自身。

而原型方法上的this就好理解多了,根据上边对函数中this的定义,p1.getName()中的getName为调用者,他被p1所拥有,因此getName中的this,也是指向了p1。

例外的的this指向--箭头函数

先说结论:

箭头函数的 this 是一个普通变量,指向了父级函数的 this,且这个指向永远不会改变,也不能改变。

但是如果需要修改箭头函数的 this ,可以通过修改父级的 this 指针,来达到子箭头函数 this 的修改。(根本原因是箭头函数没有 this,而是在运行时使用父级的 this)。

举个例子:

function outer(){    
  var inner = function(){        
    var obj = {};       
    obj.getVal=()=>{           
      console.log("*******");          
      console.log(this);           
      console.log("*******");        
    }        
    return obj;    
  };   
  return inner; 
}outer()().getVal();
// 输出如下*******Window {parent: Window, opener: null, top: Window, length: 0, frames: Window, …}*******

getVal 函数是箭头函数,方法里面的 this 是跟着父级的 this。

在 outer() 执行后,返回闭包函数 inner

然后执行闭包函数 inner,而闭包函数的 inner 也是一个普通函数,仍然遵循 [谁调用,指向谁],这里没有直接调用对象,而是最外层的“省略的” window 调用的,所以 inner 的 this 是指向 window 的。

DOM事件绑定

onclick和addEventerListener中 this 默认指向绑定事件的元素。

IE比较奇异,使用attachEvent,里面的this默认指向window。

call apply 与 bind 手动改变this指向

在JavaScript中,callapplybindFunction对象自带的三个方法,先说结论:

  1. 三者都是用来改变函数的this指向

  2. 三者的第一个参数都是this指向的对象

  3. bind是返回一个绑定函数可稍后执行,callapply是立即调用

  4. 三者都可以给定参数传递

  5. call给定参数需要将参数全部列出,apply给定参数数组

call

call() 方法在使用一个指定的this值和若干个指定的参数值的前提下调用某个函数或方法。

当调用一个函数时,可以赋值一个不同的 this 对象。this 引用当前对象,即 call 方法的第一个参数。

语法 fun.call(thisArg, arg1, arg2, ...)

  • thisArg
    1. 不传,或者传nullundefined, 函数中的this指向window对象
    2. 为原始值(数字,字符串,布尔值)的this会指向该原始值的自动包装对象,如 StringNumberBoolean
    3. 传递一个对象,函数中的this指向这个对象
apply

语法与 call() 方法的语法几乎完全相同,唯一的区别在于,apply的第二个参数必须是一个包含多个参数的数组(或类数组对象)。apply的这个特性很重要.

语法:fun.apply(thisArg, [argsArray])

  • 用法-将类数组对象转化为数组

    function exam(a, b, c, d, e) {
    
      // 先看看函数的自带属性 arguments 什么是样子的
      console.log(arguments);
    
      // 使用call/apply将arguments转换为数组, 返回结果为数组,arguments自身不会改变
      var arg = [].slice.call(arguments);
    
      console.log(arg);
    }
    
    exam(2, 8, 9, 10, 3);
    
    // result:
    // { '0': 2, '1': 8, '2': 9, '3': 10, '4': 3 }
    // [ 2, 8, 9, 10, 3 ]
    //
    // 也常常使用该方法将DOM中的nodelist转换为数组
    // [].slice.call( document.getElementsByTagName('li') );
    
bind

可以看出,bind会创建一个新函数(称之为绑定函数),原函数的一个拷贝,也就是说不会像callapply那样立即执行。

当这个绑定函数被调用时,它的this值传递给bind的一个参数,执行的参数是传入bind的其它参数和执行绑定函数时传入的参数。

语法:fun.bind(thisArg, arg1, arg2, ...)

  • 与 call、apply 的区别

    当我们执行下面的代码时,我们希望可以正确地输出name,然后现实是残酷的

    function Person(name){
      this.name = name;
      this.say = function(){
        setTimeout(function(){
          console.log("hello " + this.name);
        },1000)
      }
    }
    var person = new Person("axuebin");
    person.say(); //hello undefined
    

    这里this运行时是指向window的,所以this.nameundefined,为什么会这样呢?看看MDN的解释:

    由setTimeout()调用的代码运行在与所在函数完全分离的执行环境上。这会导致,这些代码中包含的 this 关键字在非严格模式会指向 window。

    没错,这里我们就可以用到bind了:

    function Person(name){
      this.name = name;
      this.say = function(){
        setTimeout(function(){
          console.log("hello " + this.name);
        }.bind(this),1000)
      }
    }
    var person = new Person("axuebin");
    person.say(); //hello axuebin
    

JavaScript面向对象

实现继承

根据上面的学习,我们知道了原型链、applycallbind以及new的原理实现,懂得了这些,接下里的继承就相对简单些。

原型链继承

利用原型链原理,当找不到的属性会向上查找。直接让子类的原型对象指向父类实例,当子类实例找不到对应的属性和方法时,就会往它的原型对象,也就是父类实例上找,从而实现对父类的属性和方法的继承。

// 父类
function Parent() {
    this.name = '写代码像蔡徐抻'
}
// 父类的原型方法
Parent.prototype.getName = function() {
    return this.name
}
// 子类
function Child() {}

// 让子类的原型对象指向父类实例, 这样一来在Child实例中找不到的属性和方法就会到原型对象(父类实例)上寻找
Child.prototype = new Parent()
Child.prototype.constructor = Child // 根据原型链的规则,顺便绑定一下constructor, 这一步不影响继承, 只是在用到constructor时会需要

// 然后Child实例就能访问到父类及其原型上的name属性和getName()方法
const child = new Child()
child.name          // '写代码像蔡徐抻'
child.getName()     // '写代码像蔡徐抻'

缺点:

  1. 由于所有Child实例原型都指向同一个Parent实例, 因此对某个Child实例的父类引用类型变量修改会影响所有的Child实例
  2. 在创建子类实例时无法向父类构造传参, 即没有实现super()的功能
// 示例:
function Parent() {
    this.name = ['写代码像蔡徐抻'] 
}
Parent.prototype.getName = function() {
    return this.name
}
function Child() {}

Child.prototype = new Parent()
Child.prototype.constructor = Child 

// 测试
const child1 = new Child()
const child2 = new Child()
child1.name[0] = 'foo'
console.log(child1.name)          // ['foo']
console.log(child2.name)          // ['foo'] (预期是['写代码像蔡徐抻'], 对child1.name的修改引起了所有child实例的变化)

构造函数继承

构造函数继承,即在子类的构造函数中执行父类的构造函数,并为其绑定子类的this,让父类的构造函数把成员属性和方法都挂到子类的this上去,这样既能避免实例之间共享一个原型实例,又能向父类构造方法传参

function Parent(name) {
    this.name = [name]
}
Parent.prototype.getName = function() {
    return this.name
}
function Child() {
    Parent.call(this, 'zhangsan')   // 执行父类构造方法并绑定子类的this, 使得父类中的属性能够赋到子类的this上
}

//测试
const child1 = new Child()
const child2 = new Child()
child1.name[0] = 'foo'
console.log(child1.name)          // ['foo']
console.log(child2.name)          // ['zhangsan']
child2.getName()                  // 报错,找不到getName(), 构造函数继承的方式继承不到父类原型上的属性和方法

缺点:

  1. 继承不到父类原型上的属性和方法

组合式继承

既然原型链继承和构造函数继承各有互补的优缺点, 那么我们为什么不组合起来使用呢, 所以就有了综合二者的组合式继承

function Parent(name) {
    this.name = [name]
}
Parent.prototype.getName = function() {
    return this.name
}
function Child() {
    // 构造函数继承
    Parent.call(this, 'zhangsan') 
}
//原型链继承
Child.prototype = new Parent()
Child.prototype.constructor = Child

//测试
const child1 = new Child()
const child2 = new Child()
child1.name[0] = 'foo'
console.log(child1.name)          // ['foo']
console.log(child2.name)          // ['zhangsan']
child2.getName()                  // ['zhangsan']

缺点:

  1. 每次创建子类实例都执行了两次构造函数(Parent.call()new Parent()),虽然这并不影响对父类的继承,但子类创建实例时,原型中会存在两份相同的属性和方法,这并不优雅

寄生式组合继承

function Parent (name) {
    this.name = name;
    this.colors = ['red', 'blue', 'green'];
}

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

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

// 关键的三步
// var F = function () {};

// F.prototype = Parent.prototype;

// Child.prototype = new F();

var temp = Object.create(Parent.prototype)
temp.constructor = Child
Child.prototype = temp


var child1 = new Child('kevin', '18');

console.log(child1);

到这里我们就完成了ES5环境下的继承的实现,这种继承方式称为寄生组合式继承,是目前最成熟的继承方式,babel对ES6继承的转化也是使用了寄生组合式继承

闭包

闭包的概念

ECMAScript中,闭包指的是:

  1. 从理论角度:所有的函数。因为它们都在创建的时候就将上层上下文的数据保存起来了。哪怕是简单的全局变量也是如此,因为函数中访问全局变量就相当于是在访问自由变量,这个时候使用最外层的作用域。
  2. 从实践角度:以下函数才算是闭包:
    1. 即使创建它的上下文已经销毁,它仍然存在(比如,内部函数从父函数中返回)
    2. 在代码中引用了自由变量

闭包的分析

结论:根据代码的执行过程,执行上下文会维护一个作用域链,即使作用域链上的执行上下文被销毁,JavaScript 依然会将作用域链上的变量对象保存起来,其函数依然可以对其变量对象引用进行读写。

例子:

var scope = "global scope";
function checkscope(){
    var scope = "local scope";
    function f(){
        return scope;
    }
    return f;
}

var foo = checkscope();
foo();

这里直接给出简要的执行过程:

  1. 进入全局代码,创建全局执行上下文,全局执行上下文压入执行上下文栈
  2. 全局执行上下文初始化
  3. 执行 checkscope 函数,创建 checkscope 函数执行上下文,checkscope 执行上下文被压入执行上下文栈
  4. checkscope 执行上下文初始化,创建变量对象、作用域链、this等
  5. checkscope 函数执行完毕,checkscope 执行上下文从执行上下文栈中弹出
  6. 执行 f 函数,创建 f 函数执行上下文,f 执行上下文被压入执行上下文栈
  7. f 执行上下文初始化,创建变量对象、作用域链、this等
  8. f 函数执行完毕,f 函数上下文从执行上下文栈中弹出

当我们了解了具体的执行过程后,我们知道 f 执行上下文维护了一个作用域链:

fContext = {
    Scope: [AO, checkscopeContext.AO, globalContext.VO],
}

对的,就是因为这个作用域链,f 函数依然可以读取到 checkscopeContext.AO 的值,说明当 f 函数引用了 checkscopeContext.AO 中的值的时候,即使 checkscopeContext 被销毁了,但是 JavaScript 依然会让 checkscopeContext.AO 活在内存中,f 函数依然可以通过 f 函数的作用域链找到它,正是因为 JavaScript 做到了这一点,从而实现了闭包这个概念。

闭包的数据存储--栈与堆

JavaScript有8种数据类型,可以主要分为两大类:原始数据类型 和 引用数据类型

其中,原始类型的数据是存放在栈中,引⽤类型的数据是存放在堆中的。堆中的数据是通过引⽤和变量关联 起来的。也就是说,JavaScript的变量是没有数据类型的,值才有数据类型,变量可以随时持有任何类型的数据。

根据内存来分析闭包

我们上面讲过闭包,可能不能很好的理解闭包,这次我们从内存上来分析闭包是如何实现的。

先说结论:产⽣闭包的核⼼有两步:第⼀步是需要预扫描内部函数;第⼆步是把内部函数引⽤的外部变量保存到堆中。 当预扫描时,发现内部函数对外部函数有变量引用,则将变量存在堆中,保存变量,外部使用变量也只是引用地址,这样的话外部函数执行上下文被销毁,内部函数引用的变量也不会被销毁。

function foo() { 
  var myName = "极客时间" 
  let test1 = 1 
  const test2 = 2
  var innerBar = { 
    setName:function(newName){
      myName = newName },
    getName:function(){ 
      console.log(test1) 
      return myName
    } 
  }
  return innerBar 
}
var bar = foo() 
bar.setName("极客邦")
bar.getName() 
console.log(bar.getName())

我们站在内存模型的⻆度来分析这段代码的执⾏流程。

  1. 当JavaScript引擎执⾏到foo函数时,⾸先会编译,并创建⼀个空执⾏上下⽂。

  2. 在编译过程中,遇到内部函数setName,JavaScript引擎还要对内部函数做⼀次快速的词法扫描,发现 该内部函数引⽤了foo函数中的myName变量,由于是内部函数引⽤了外部函数的变量,所以JavaScript 引擎判断这是⼀个闭包,于是在堆空间创建换⼀个“closure(foo)”的对象(这是⼀个内部对象, JavaScript是⽆法访问的),⽤来保存myName变量。

  3. 接着继续扫描到getName⽅法时,发现该函数内部还引⽤变量test1,于是JavaScript引擎⼜将test1添加 到“closure(foo)”对象中。这时候堆中的“closure(foo)”对象中就包含了myName和test1两个变量了。

  4. 由于test2并没有被内部函数引⽤,所以test2依然保存在调⽤栈中。

闭包的作用

闭包最大的作用就是隐藏变量,闭包的一大特性就是内部函数总是可以访问其所在的外部函数中声明的参数和变量,即使在其外部函数被返回(寿命终结)了之后而外部变量无法访问内部变量。

基于此特性,JavaScript可以实现私有变量、特权变量、储存变量等

我们就以私有变量举例,私有变量的实现方法很多,有靠约定的(变量名前加_),有靠Proxy代理的,也有靠Symbol这种新数据类型的。

但是真正广泛流行的其实是使用闭包。

function Person(){
    var name = 'cxk';
    this.getName = function(){
        return name;
    }
    this.setName = function(value){
        name = value;
    }
}

const cxk = new Person()

console.log(cxk.getName()) //cxk
cxk.setName('jntm')
console.log(cxk.getName()) //jntm
console.log(name) //name is not defined

函数体内的var name = 'cxk'只有getNamesetName两个函数可以访问,外部无法访问,相对于将变量私有化。

Event Loop

在 JavaScript 运行的时候,JavaScript Engine 会创建和维护相应的堆(Heap)和栈(Stack),同时通过 JavaScript Runtime 提供的一系列 API(例如 setTimeout、XMLHttpRequest 等)来完成各种各样的任务。

事件循环(Event Loop) 是让 JavaScript 做到既是单线程,又绝对不会阻塞的核心机制。JavaScript是单线程的,同一时间只能运行一个任务。同时,浏览器提供了Web API包括DOM API定时器HTTP请求等特性,帮助我们实现了异步、非阻塞的行为。

同步任务和异步任务

Javascript单线程任务被分为同步任务异步任务,同步任务会在调用栈中按照顺序等待主线程依次执行。

而异步任务则会经历Event Loop机制进行等待调用栈中所有同步任务执行完后再执行异步任务。

宏任务和微任务

异步任务又会分为 宏任务和微任务

  • 宏任务:渲染事件、用户交互事件、JS脚本执行、网络请求,文件读写完成事件等

  • 微任务:Process.nextTick(Node独有)Promise.thenObject.observe(废弃)MutationObserver

Event Loop执行机制

执行栈在执行完同步任务后,查看执行栈是否为空,如果执行栈为空,就会去检查微任务(microTask)队列是否为空,如果为空的话,就执行Task(宏任务),否则就一次性执行完所有微任务。
每次单个宏任务执行完毕后,检查微任务(microTask)队列是否为空,如果不为空的话,会按照先入先出的规则全部执行完微任务(microTask)后,设置微任务(microTask)队列为null,然后再执行宏任务,如此循环。

举个例子
console.log('start');
setTimeout(() => {
  console.log('timeout');
});
Promise.resolve().then(() => {
  console.log('resolve');
});
console.log('end');

start
end
resolve
timeout

我们来分析一下:

  1. 刚开始整个脚本作为一个宏任务来执行,对于同步代码直接压入执行栈中进行执行
  2. setTimeout 作为一个宏任务放入宏任务队列
  3. Promise.then作为一个为微任务放入到微任务队列
  4. 当执行栈中为空时,检查微任务队列,发现Promise.then执行。
  5. 然后执行setTimeout

Promie

什么是Promise

Promise是一种异步编程的解决方案。从语法上来讲,Promise是一个对象,他可以获取异步操作的的消息。从本意上来讲,Promise是一个承诺,承诺过一段时间后给你一个结果。Promise有三种状态:pending(等待),fulfiled(成功),rejected(失败),状态一旦改变,就不会再变。

Promise将回调嵌套改为链式调用,解决了“回调地狱”增加可读性和可维护性。

Javascript事件机制

JavaScript 是一个事件驱动(Event-driven) 的语言,当浏览器载入网页开始读取后,虽然马上会读取JavaScript 事件相关的代码,但是必须要等到「事件」被触发(如使用者点击、按下键盘等)后,才会再进行对应代码段的执行。

DOM事件等级

DOM有4次版本更新,与DOM版本变更,形成了3种DOM事件:DOM0级事件、DOM2级事件、DOM3级事件,由于 DOM1级事件并没有与事件有关的内容,所以没有DOM1级事件。

image-20200830220622932

DOM0级事件

在DOM0级事件以前,我们处理 HTML事件处理程序是通过这种方式:

<button type="button" onclick="fn" id="btn">点我试试</button>

<script>
    function fn() {
        alert('Hello World');
    }
</script>

这种方式并不合适,因为一旦我们修改fn的名字,我们还需要更改HTML里的内容,这种方式太过强耦合,我们可以更低耦合的方式去绑定事件:

<button id="btn" type="button"></button>

<script>
    var btn = document.getElementById('btn');
    
    btn.onclick = function() {
        alert('Hello World');
    }
    
    // btn.onclick = null; 解绑事件 
</script>

这种方式也就是DOM0级事件虽然更加低耦合,但我们同时发现,一个事件只能绑定一个函数

DOM2级事件

DOM2级事件就是为了解决一个事件只能绑定一个函数的缺点,允许给一个处理程序添加多个处理函数。

  1. Dom 2级事件是通过 addEventListener 绑定的事件

  2. 同一个元素的同种事件可以绑定多个函数,按照绑定顺序执行

  3. 解绑Dom 2级事件时,使用 removeEventListener

<button type="button" id="btn">点我试试</button>

<script>
    var btn = document.getElementById('btn');

    function fn() {
        alert('Hello World');
    }
    btn.addEventListener('click', fn, false);
    // 解绑事件,代码如下
    // btn.removeEventListener('click', fn, false);  
</script>

addEventLIstener有三个值需要传递:

  1. 第一个值是 事件名
  2. 第二个值是 事件处理程序
  3. 第三个值 true的话表示在捕获阶段调用,为false的话表示在冒泡阶段调用(后面我会讲捕获和冒泡阶段)

DOM3级事件

DOM3级事件在DOM2级事件的基础上添加了更多的事件类型,全部类型如下:

  1. UI事件,当用户与页面上的元素交互时触发,如:load、scroll
  2. 焦点事件,当元素获得或失去焦点时触发,如:blur、focus
  3. 鼠标事件,当用户通过鼠标在页面执行操作时触发如:dbclick、mouseup
  4. 滚轮事件,当使用鼠标滚轮或类似设备时触发,如:mousewheel
  5. 文本事件,当在文档中输入文本时触发,如:textInput
  6. 键盘事件,当用户通过键盘在页面上执行操作时触发,如:keydown、keypress
  7. 合成事件,当为IME(输入法编辑器)输入字符时触发,如:compositionstart
  8. 变动事件,当底层DOM结构发生变化时触发,如:DOMsubtreeModified

同时DOM3级事件也允许使用者自定义一些事件

DOM事件模型

DOM事件模型分为捕获和冒泡。一个事件发生后,会在子元素和父元素之间传播(propagation)。这种传播分成三个阶段。

  1. 捕获阶段:事件从window对象自上而下向目标节点传播的阶段;

  2. 目标阶段:真正的目标节点正在处理事件的阶段;

  3. 冒泡阶段:事件从目标节点自下而上向window对象传播的阶段。

事件捕获

捕获是从上至下的捕获。即document -> HTML -> body -> 直到触发事件元素

事件冒泡

冒泡则与捕获相反,由触发事件元素 -> body -> HTML -> document

这两个概念还是很好理解的,所以不做过多解释,那么实际操作呢,是执行哪种机制呢?

答案是:两种都执行

image-20200830233014761

事件代理(事件委托)

事件冒泡阶段会向上传播到父节点,因此可以把子节点的监听函数放在父节点上。由父节点的监听函数统一处理多个元素的事件。这种方法叫事件委托

我们可以利用事件委托,对页面进行一些性能优化以及功能实现:

  1. 减少DOM节点的直接操作,减少事件处理程序的挂载在DOM节点上。
  2. 当有节点删除添加操作时,可以通过事件委托动态的添加时间。

事件委托的实现

<ul id="list">
    <li>1</li>
    <li>2</li>
    <li>3</li>
    <li>4</li>
</ul>

<script>
// 给父层元素绑定事件
document.getElementById('list').addEventListener('click', function (e) {
    //事件返回对象Event有一个target属性,代表当前操作的DOM节点
  	// 兼容性处理
    var event = e || window.event;
    var target = event.target || event.srcElement;
    // 判断是否匹配目标元素
    if (target.nodeName.toLocaleLowerCase() === 'li') {
        console.log('the content is: ', target.innerHTML);
    }
});
</script>

搬运文章

深入理解JavaScript

从_proto_和prototype来深入理解js对象和原型链

[译]理解JavaScript中的执行上下文和执行栈

ES6系列之let和const

冴羽的博客

深入理解JavaScript执行上下文与执行上下文栈

前端基础进阶(七):全方位解读this

回味JS基础:call apply 与 bind

对阮一峰《ES6 入门》中箭头函数 this 描述的探究

2万字 | 前端基础拾遗90问

[译] 图解Event Loop

深入理解 JavaScript Event Loop

判断JS数据类型的四种方法

【JS 进阶】你真的掌握变量和类型了吗

深入理解DOM机制

JavaScript事件三部曲之事件机制的原理

posted @ 2020-08-01 17:31  ChunCode  阅读(179)  评论(0编辑  收藏  举报