初步了解js高级

76 阅读15分钟

 本文是关于js高级的一些知识整理,包含以下内容:

  • this
  • 原型和原型链
  • 执行上下文
  • 作用域和作用域链
  • 闭包

this

this 实际上是在函数被调用时发生的绑定,它指向什么完全取决于函数在哪里被调用,

关于this我们经常会产生两个误区

误区一:误以为this指向自身

function test(i) {
    
   this.a++;
}

//  这里的test虽然是一个函数,但是在js语言里一切皆对象,
//  所以语法是没有错误的
test.a  = 0

for(let i = 0; i < 10; i++) {
    // 这里函数确实执行了十次
    test(i)
}

console.log(test.a)  // 0
console.log(this.a)  // NaN

这里的test函数虽然确实循环了十次,但是test.a始终为0,这是因为函数对象test内部的this指向问题,this并未指向test,实际上这个a是在无意中向全局增加了一个变量a,值为NaN。

小拓展: 为什么会产生这样的操作,这是因为this是指向window的,而window里面并没有a这个属性,js语言对于对象.属性名的访问方式就是有则输出,没有的话输出undefined,由于test函数里又执行了一步自增操作,所以此时的window.a的值为NaN

var obj = {}  // 新建一个对象,里面并无任何属性
console.log(obj.a)  // undefined
console.log(obj.a++)  // NaN

其实这里有好多解决方法,最简单的方法就是把test函数里面的this.a++改为test.a++你可以创建一个对象,添加属性a,属性值为0,然后test里的代码块变成,对象名.a++

function test(i) {

    data.a++
}

var data = {

    a: 0
}

当然,这里并没有用到this,可是也同样解决了问题,但是最好的解决方法是改变this指向,指向到函数自身

//  改变循环内的代码块
for(let i = 0; i < 10; i ++) {
    //  这里用call改变this指向,你也可以用apply或者bind
    //  都可以起到改变this指向的作用
    test.call(test, i)
}

误区二:this的作用域

this指向函数的作用域

function f() {
    var a = 1;
    this.bar()

}

function bar() {

    console.log(this.a)
}


f()  // 会报错,报错信息为a未定义

这里试图通过bar函数访问f函数内部的a,这是不可能实现的,你不能通过this引用一个词法作用域内部的东西(词法作用域是由你在写代码时将变量和块作用域写在哪里来决定的)

this的绑定规则

  1. 独立函数调用 --- 默认绑定
function test() {
    console.log(this.a)
}

var a = 0;

test();  // 0

this的默认绑定规则,test函数调用位置没有添加任何修饰符,因此只能使用默认绑定规则

如果使用严格模式,那么this就使用不了默认规则,就会出现错误,因此此时的this是undefined

function test() {
    "use strict";
    console.log(this); // undefined
    console.log(this.a);  //  a未定义
}

var a = 0;

test();  // Uncaught TypeError: Cannot read properties of undefined (reading 'a')

  1. 如果被某个对象拥有或者包含,或者说是调用位置是否有上下文对象(这里可以了解一下js的执行上下文)就会出现隐式绑定规则
function test() {
    console.log(this)
    console.log(this.a, 'this.a')
}

var obj = {
  a: 2,
  test: test  // 这里也可以写直接写一个test,是js的语法糖,属性名和属性值一致的时候可以省略
}
console.log(obj.test(), 'obj.test()')

/* 
依次输出内容:
    
    {a: 2, test: ƒ}
    2 'this.a'
    undefined 'obj.test()'
 */

小拓展:细心的小伙伴已经发现了输出了函数内的两条语句之后,第三条语句输出的是undefined,大家也可以测试下,这里是因为函数内默认执行的一条return语句,js语言的函数里的return在执行完毕后,如果不指定return的内容的话,会默认返回undefined

这里的undefined也不独函数里面有,就像浏览器的开发者工具里声明一个变量,之后会立马出现一个undefined,也是如此

​编辑

//  小测验

function test() {
    console.log(this)
    console.log(this.a, 'this.a')
    return this
}

var obj = {
  a: 2,
  test: test  // 这里也可以写直接写一个test,是js的语法糖,属性名和属性值一致的时候可以省略
}
console.log(obj.test(), 'obj.test()')

/* 
依次输出内容:
    
    {a: 2, test: ƒ}
    2 'this.a'
    {a: 2, test: ƒ}  'obj.test()'   // 这时会输出obj对象,如果只写一条return,那么输出结果和上方一致
 */

可以利用这条规则简单实现链式调用,如下方代码所示:

function test() {
   console.log(this)
   console.log(this.a, 'this.a')
    // 如果执行return this语句,那么obj.test().a  就会输出2
   // return this
}

var obj = {
  a: 2,
  test: test
}
console.log(obj.test().a, 'obj.test().a')

/*
    {a: 2, test: ƒ}

    2 'this.a'

    2 'obj.test().a'
*/

  1. 显式绑定

隐式绑定必须在一个对象内部包含一个指向函数的属性,通过对象间接引用函数,但如果不想再对象内声明此属性,又想调用函数该如何做?那就要用到call()和apply()了

function test() {
   console.log(this.a)
}

var obj = {
    a: 2
}

let againCall = function() {

     return test.call(obj)
}

againCall()

againCall.call(window)

/*
输出内容:
    2
    2

*/

这里用了call方法改变this指向,把test里的this指向到obj对象中去,此时输出结果就是obj对象的a属性值了,定义了一个方法againCall()来执行这条语句,并想把这个方法内的this指向到window,但是发现结果并没有输出window,而是obj对象中的a的值。这就是显示绑定中的强绑定,无论之后怎么调用againCall方法,都会手动的在obj上调用test,由于硬绑定比较常用,所以后续es5中推出了bind方法 --- Function.prototype.bind

js中也常用apply,call, bind三个方法来改变this指向

  1. 构造函数中的this --- new绑定

   举个栗子:

     function Animal(name, move) {
        this.name = name;
        this.move = move;
      }

      let dog = new Animal()
      console.log(dog)
      let cat = new Animal('猫', '睡觉')
      console.log(cat)

/*
    输出内容:
    Animal {name: undefined, move: undefined}
    Animal {name: '猫', move: '睡觉'}
*/

此时this指向函数创建的新对象,如果不传值会默认为undefined

使用new来调用函数,或者说发生构造函数调用时,会自动执行以下操作:

  1. 创建一个全新的对象
  2. 新对象绑定到函数调用的this
  3. 自动返回这个新对象(在没有指定返回其他对象的时候)

5.箭头函数中的this

     function fun() {
        // 返回一个箭头函数
        return (a) => {
          //this 继承自 fun()
          console.log( this.a );
        };
      }
      var obj1 = {
        a:2
      };
      var obj2 = {
        a:3
      };

       //  fun中的this又被绑定到obj1
      var bar = fun.call( obj1 );
      
      bar.call( obj2 ); // 2, 不是 3 !

fun函数中返回了一个箭头函数并输出a,下面fun函数中的this又显示绑定到obj1对象,所以此时的this是指向obj1对象,所以输出a的值是2,由于显示绑定后面不能再做修改,所以再绑定到obj2是不能实现的,输出依然是2.

简单说明箭头函数声明规则:不用function关键字声明,使用‘=>’操作符声明;如果只有一个参数,可以省略括号;return可省略等规则

箭头函数并不遵循别的绑定规则,而是根据外层函数或者全局作用域来决定this

小结:

  •  由new调用时,绑定到新创建的对象
  • 由call,apply,bind调用时,绑定到指定的对象
  • 由上下文对象调用则绑定到上下文对象
  • 默认情况下,如果不采用严格模式,则绑定到全局,否则绑定到undefined
  • 箭头函数会继承外层函数调用的this绑定

注:这里的上下文对象理解为被一个对象拥有或者包含,不要跟js执行上下文对象混淆

原型和原型链

  • 原型 --- [ [ Prototype ] ]

        原型是function对象的一个属性,它定义了构造函数制造出的对象的公共祖先。

        每个对象拥有一个原型对象,对象以其原型为模板、从原型继承方法和属性,

任何函数在创建的时候,都会默认给该函数添加 prototype 属性.

        任何对象都有原型,但null和undefined没有原型

        原型也是对象

       // Children的原型对象
      // 此书写方式等同于 
      // Children.prototype.LastName = "Jin"
      // Children.prototype.School = "syxx"
       // 虽然效果一样,但是实际Children.prototype却换了一个新对象,所以构造器自然也就不是原来的Children了
      // 此时若指定constructor的属性值为Children
      // 那么Children.prototype.constructor === Children 执行结果就是true
      // 如果不指定则是false, 则Children.prototype.constructor === Object 为true
      Children.prototype = {
        constructor: Children,
        LastName: "Jin",
        School: "syxx"
      }

      function Children(age, sex) {
        this.age = age
        this.sex = sex
      }

      var children1 = new Children(18, 'male')
      var children2 = new Children(20, 'female')

        输出内容如下图:

 ​编辑

         看出children1,children2各自有各自的age和sex属性,但是他们又同时继承了Children构造函数的原型上的属性

小拓展:

为什么给Children的原型增加属性的时候,Children.prototype.constructor === Children这条语句的执行结果为真?Children.prototype =  {},这样书写就为false了呢?

****那是因为我们对constructor存在理解上的偏差,Children.prototype.constructor === Children这条语句执行结果为真,并不意味着示例对象children里确实有一个constructor属性,实际上.constructor属性被委托给了Children.prototype, 而Children.prototype.constructor默认指向Children,实例对象children.constructor只是通过prototype委托指向Children

        就像我们后来给Children.prototype替换了一个新对象,那么新对象就不会自动获取.constructor属性,你需要重新指定constructor的值为Children。如果没有指定的话那Children.prototype.constructor === Children这条语句的执行结果就会为false,Children.prototype.constructor === Object这条语句执行结果就会为真,至于为什么这条语句执行结果为真,是因为.constructor属性会继续往上委托,这次会委托给Object.prototype如下图:

​编辑

第一条语句为重新赋值新对象后,第二条语句为默认

继续拓展: 我们并不希望指定给原型的新对象里直接添加constructor属性并赋值,因为默认的原型对象里constructor属性是不可枚举的,for...in...,Obeject.values()和Object.keys()等都不能遍历,如需指定constructor属性,可以像下面代码示例这样进行设置

Object.defineProperty( Children.prototype, "constructor" , {
    enumerable: false,  // 不可枚举
    writable: true,    // 可写
    configurable: true,    // 可设置
    value: Children // 让 .constructor 指向 Children
} );

 要想查看对象中的所有属性可以通过Object.getOwnPropertyNames(...) 来查看,可以返回自身的可枚举型和不可枚举型的所有键名

​编辑

 如果想查看对象中的那些属性可枚举,哪些不可枚举,可以通过Object.prototype.propertyIsEnumerable() 来判断,返回布尔值,true表示可枚举,false表示不可枚举

end...


        对象查看原型 --- proto

作用: 实例对象在初始化的时候系统会给你一个__proto__属性,指向构造函数的原型对象,当你访问对象没有的属性的时候,对象就会通过__proto__找到父类对象的原型上有没有要访问的属性,如果没有就再接着找父类的父类有没有,以此类推,直到找到null;如果实例对象内有用户要访问的属性,那就返回自身的属性

****构造函数的实例对象的__proto__指向构造函数的原型

console.log(children1.__proto__ === Children.prototype)  // true

       

      var obj = {
        a: 2
      }

      Object.defineProperty(Object.prototype, '__proto__', {
        get() {
          console.log("is get")
        },
        set() {

        }
      })

      console.log(obj.__proto__)  // is get

访问对象的 obj.proto 属性,默认走的是 Object.prototype 对象上 __proto__ 属性的 get/set 方法

对象查看对象的构造函数 ----  constructor

        构造函数构造出来的对象的构造器指向构造函数,构造函数自身的构造器指向自身

        children1.constructor输出是构造函数Children,children2同样也是

        Children.constructor的构造器指向自身

        构造函数的原型的构造器指向函数对象

console.log(Children.prototype.constructor === Children) // true

        非构造函数的原型指向Object

        Object的原型指向null

​编辑

小拓展:

  1. __proto__是对象的内置属性,prototype是函数的内置属性,又因为js内一切皆对象,所以函数也有__proto__属性
  2. constructor,prototype,__proto__都是可以被修改的,怎么修改可以看具体需求
  3. Object.create() 
    var obj = {
       toDo: function() {
        console.log("what should i do?")
       }
     }

     let test = Object.create(obj)

     test.toDo()  // what should i do?

Object.create()会创建一个新对象(test),并把它关联到我们指定的对象(obj),并且这个方法还可以创建一个空对象,Object.create(null),这个对象无法进行委托,因为null是没有原型的,也可以创建对象来关联到null,但是会出现没有原型的情况。

​编辑

详见用法:Object.create()

tips:

此链接文中最后提到Polyfill

​编辑

 这个术语用于表示根据新特性的定义,创建一段与之行为等价但能够在旧的 JavaScript 环境中运行的代码,并非所有的新特性都是兼容旧环境的。

值得一提的是语言中新增的语法是无法用plyfilling的,更好的方法是transpiling(transform -- 转换 +  compiling --- 编译),将新版代码等价的转换为旧版代码

  • 原型链 --- prototype chain

        在上面原型里也说到关于__proto__的概念,当我们在一个对象上查找属性的时候,如果自身没有就会通过这个属性,找到上级的原型里看看有无,如果没有再接着找上级,直到找到null,这个__proto__的关系链,就是原型链

优点:

原型链的优点在于父类方法可以复用,实现共享,这样可以提高性能,减少内存

缺点:

缺点也在于共享,该属性或者方法会被所有实例共享;

还有就是子类型实例不能给父类型构造函数传参(即不能向上传递)

hasOwnProperty -- Object.prototype.hasOwnProperty()

用来判断对象上面是否存在某个属性,返回布尔值

注意:原型链上存在并不能代表自身也存在,这里不会通过__proto__向原型链上查找

isPrototyeOf() -- Object.prototype.isPrototypeOf()

检查一个对象是否存在于另一个对象的原型链上,也是返回布尔值

instanceof  -- 检测构造函数的prototype属性是否出现在某个实例对象的原型链上

这里的实例对象的原型链是针对构造函数的原型进行检查的,而不是构造函数本身。

有兴趣的话还可以了解一下原型式继承,深入了解原型

执行上下文

execution context

执行上下文定义了变量或函数有权访问的其他数据,决定了它们各自的行为。每个执行环境都有与之关联的变量对象 --- Variable Object,简称VO,全局执行环境是最外层的执行环境,也就是window,有时也被称为Global Object,简称GO,所有的全局变量和对象都是作为window对象的属性和方法创建的。

说到起来可以顺便提一下js的预编译 --- 代码执行前会进行的操作,通常发生在代码执行前的几位秒甚至更短

预编译的时候var会进行变量提升,function整体提升

举一个简单的小栗子:

     function fun(a) {

        console.log(a)  // undefined  --> function a() {}

        var a = 111

        console.log(a)  // undefined --> function a() {} --> 111

        function a() {}

        console.log(b)  // undefined --> function b() {}
        
        function b() {}
      }

      fun(1)
      
      /*
        函数执行结果:
          function a() {}
          111
          function b() {}
      */



    

在js预编译环节,此函数会创建一个AO ( Activation Object ) 对象,这个AO对象就是我们理解的VO(变量对象),里面包含了变量和函数声明,首先将形参和实参相统一并放到AO对象里,再将变量声明放到对象里,初始值都为undefined,之后再找到函数声明,再进行最后的赋值。(代码片段中语句后面的注释为演变过程)

说到这里我们就对js的执行上下文有了初步的了解,其实全局的执行上下文就是保存到window对象里,也就是GO里,局部的执行上下文也就是保存到AO对象里。

每个函数都有自己的执行上下文,当函数执行完毕后,上下文会被销毁,并返回到之前的上下文环境,也就是全局环境或者上层环境。而最外层的全局环境直到应用程序退出(网页或者浏览器关闭)时才会被销毁。

tips:

既然函数声明和变量声明都会被提升,那么谁会被提升到最前面呢?

    test(); // 1

    var test

    function test() {
      console.log( 1111 );
    }
    
    test = function() {
      console.log( 2222 );
    };

从上面代码的执行结果中看出,函数优先级更高一点,虽然var声明在function声明之前,但是内部执行的时候会被忽略掉,因为function在它之前且跟function同名

所以在实际使用中,应避免重复声明,以免造成不必要的麻烦

注:声明本身会被提升,而包括函数表达式的赋值在内的赋值操作并不会提升(即函数声明和函数表达式是不一样的)

作用域与作用域链

scope && scope chain

作用域的理解:

比方说有一段代码var a = 111;很可能认为这是一段声明,其实这是声明 + 赋值的操作,一个是在编译时由编译器处理,一个是在运行时由引擎处理

当遇到var a这步的时候,编译器会查找作用域中是否有一个该名称的变量在当前作用域中,如果有的话则会忽略掉,继续往下进行,否则就会声明一个名称为a的变量在当前作用域中

编译的时候会把该提升的都提升,然后会为引擎生成运行时所需的代码(内部执行的代码跟所看到的代码可能不一样),引擎运行时会首先查找作用域,当前作用域中是否存在一个名称为a的变量,如果有的话,引擎就会使用这个变量,如果没有的话,引擎会往上级作用域继续查找,一直找到最外层的作用域(也就是全局作用域)为止。

如果最终找到了该名称的变量,就会把值赋给它,否则就会抛出异常。

换言之,作用域就是根据名称查找变量的一套规则。

书写代码时的静态作用域(我们看到的代码形式)并不是运行时的代码形式,前面提到的预编译会给出解释,所以运行时的代码会做出一些改变,在何处声明函数和变量并不重要,因为调用时候会改变作用域

作用域链是基于调用栈的

块作用域:

在js是不存在块级作用域的

为什么需要块级作用域?es6之前js只存在全局作用域和函数作用域,并没有块级作用域,会造成很多不合理的使用。

      for(var i = 0; i < 10; i ++) {
        
      }
      console.log(i)

    // 10

    if(true) {
        var l = 0
    }

    console.log(l)

    // 0

代码中可以看出,for循环语句里的变量i,在循环结束后,也依旧会存在与外部执行环境中。if语句中的变量l也会将变量添加到当前的执行环境中。在别的有块级作用域的语言中,for循环声明的变量只会存在于循环中,if语句中声明的变量会在执行完毕后被销毁,但是在js中是没有块级作用域的。

使用var声明的变量会自动添加到最近的环境中,在函数内部,最接近的环境就是函数的局部环境;在with语句中,最接近的环境是函数环境

with: 同样也是块作用域中的一种形式,用 with 从对象中创建出的作用域仅在 with 声明中而非外 部作用域中有效,但是饼补推荐使用with,在提升效率的同时会降低性能,而且在严格模式下会被禁止,有兴趣的可以研究一下。

try / catch: 在try/catch语句块中的catch语句中也会创建一个块级作用域,声明的变量只存在与catch中。外部输出会报错。

es6中新增了let和const两个关键字来声明变量。

let:用来声明变量。所声明的变量,只在let命令所在的代码块中有效

const:用来声明常量,一旦声明,就不能改变。

特点:

let和const不存在变量提升,就像for循环语句中的变量声明,在循环后还能使用的,这情况就可以用let代替var,if中同样也适用。

如果在区块中使用了let和const关键字,就会造成封闭作用域,在声明之前使用的话,就会报错。这在语法上称为暂时性死区。

并且不允许被重复声明,也会出现报错信息。

拓展: const内部实现是用指针实现的,变量的值并不是不得改动,而是变量指向的内存地址不得改动,对于简单数据来说等同于常量,但是对于数组和对象这种复合数据的话来说,就不能保证内部的数据变化了。

    const obj = {
        a: 1
      }

      obj.a = 2

      console.log(obj) // { a: 2 }


      const arr = [1, 2, 3]
      arr[0] = 4
      console.log(arr) // [4, 2, 3]

除去以上代码中的情况,还有一些api也是这样,数组或者对象内容改变,但是数组和对象的地址并未改变,所以也不会报错。

例如数组的api中,push,pop, splice,shift,unshift,sort,reverse等等

作用域链: 当代码在一个环境中运行时,会创建一个属于自己的作用域链,作用域链的顶端始终是当前执行的代码的作用域,作用域的用途是保证有权访问执行环境的所有变量和函数的有序访问。

作用域链的下一个来自于直接的外部包含环境,全局环境是作用域链的最末端。

这些环境之间是有次序的,每个环境之间都可以向上搜索作用域链,但是不能向下搜索

​编辑

图片中声明了一个名为a的函数,通过他的原型,我们能找到当前的[[Scopes]]属性,Scopes[0]为当前作用域链的顶端,包含了当前代码的变量对象 --- 也就是a函数(当前是函数环境,所以AO就是变量对象),Scopes[1]存的是Global对象,是最外层的执行环境,位于作用域链的末端,因为当前只声明了一个函数

AO在最开始的时候只有一个变量,那就是arguments对象

 ​编辑

闭包

闭包 --- closure,在了解了执行上下文和作用域与作用域链之后会更好的理解闭包的概念。

闭包就是通过嵌套函数的形式,缓存嵌套函数及其执行环境,等待下一次调用。换句话说就是一个不会销毁的栈环境(作用域链的调用栈)

创建闭包的常见方式就是在一个函数中创建另外一个函数

使用中常见的闭包的情况,使用原生js操作DOM点击事件

  <body>
    <div class="test">1</div>
    <div class="test">1</div>
    <div class="test">1</div>
    <div class="test">1</div>
    <div class="test">1</div>

    <script>
      var test = document.getElementsByClassName('test')
      for(var i = 0; i < test.length; i ++) {
        test[i].onclick = function() {
          console.log(i)  // 5
        }
      }

    </script>
  </body>

创建一组类名为test的div,循环test类数组,给每一个div添加点击事件,并输出当前的变量i的值,此时的输出结果是5,不管点击哪个div都会输出5.

解决这种情况有两种方法:

  1. 用let代替var来声明变量

  2. 用立即执行函数来解决

    var test = document.getElementsByClassName('test')
      for(var i = 0; i < test.length; i ++) {
        (function(j) {
            test[j].onclick = function() {
              console.log(j)
            }
        }(i))
        
      }

此时的输出结果就是正确的了 

解释:

这样的代码看似是正常的,符合逻辑的,但在实际执行中,i的值会是5,因为每一个事件函数引用的是同一个变量i,所以执行完毕后每一个输出都变成了5

常见的闭包情况有很多,比方说定时器实现循环内部函数等等,就不一一列举了。

普通函数的执行上下文、作用域链在执行完毕后会被销毁,但是在闭包中会有所不同

上面说过常见的闭包情况就是两个函数嵌套,外层函数在执行完毕后并不会立即销毁活动对象(AO),因为内层函数有可能还在引用活动对象,所以外层函数销毁的只是当前的作用域链,但它的活动对象还会继续留在内存中,直到内层函数被销毁后,才会被销毁。

内存泄漏:

闭包使用过多,内存就会占用过多,就会造成我们所说的内存泄露的问题,所以尽量减少闭包的使用。

闭包会引用包含函数的所有活动对象,就像上面的点击事件中,虽然内层事件函数中并没有直接引用当前的test[i],也就是当前的div,但是外层函数中仍然会存在一个引用,所以还是会存在内存泄漏的问题。至于怎么解决呢,可以每次在执行完点击事件后,给当前的div设置为null,也就是test[i] = null,这样就会解除占用,达到内存释放的作用,以解决内存泄漏问题。

私有化变量:

闭包也会产生私有化变量,因为js中并没有private关键字,不能像别的语言中,直接声明为私有化变量。

      function test() {
        var a = 0,
            b = 1,
            sum
        return sum = a + b
      }

      console.log(test())   // 1

上述代码中就创建了私有化变量sum, a, b,在函数内部可以访问这些变量,但是外部访问不了,这就实现了私有化变量。

本文章包含一些自己的理解,包含了看过一些书本的内容,旨在总结,可以与大家交流学习,共同提高,如果有什么不足的地方,希望在评论区中指出,我会加以改正,谢谢。

The end !!!