阅读 18

《你不知道的JS》上篇整理

读完《你不知道的js》已经过去很久了,正好顺着春招的气息,整理一波,复习一下JS基础,不得不说这本书刷新了我对JS得认知,看完红色的那本JS经典之后再来看这本你不知道的js简直就是爽爆了,如果能认认真真得啃完这两本书,理解深层次得js基础,那么js水准虽说距离阮一峰老师还有很大一截但至少能在中上游站稳脚跟。春招加油!!!努力进字节!!!

作用域

理解作用域首先来理解三个概念

  • 引擎:从头到尾负责整个JS程序得编译及执行过程

  • 编译器:负责语法分析及代码生成等脏活累活

  • 作用域:负责收集并维护由所有声明得标识符(变量)组成得一系列查询,并实施一套非严格得规则,确定当前执行的代码对这些标识符得访问权限。

知道了这三个概念之后,我们来理解一下var a = 2这句代码的含义。这句代码在js底层会分为一下几步

  1. 遇到var a,编译器会询问作用域是否已经有一个该名称的变量存在于同一个作用域得集合中,若有,编译器会跳过这条声明,继续编译。若没有,编译器会要求作用域在当前作用域得集合中生命一个变量并将其命名为 a

  2. 接下来编译器会为引擎生成运行时所需要的代码,来处理 a = 2 这条赋值语句,引擎会询问当前作用域,是否存在一个变量 a,若有,引擎会使用这个变量,若没有,引擎会继续查找该变量(也就是后面说到的作用域连)

  3. 如果找到了,那么将 2 赋值给该变量,若没有,引擎将会抛出一个异常

LHS RHS 查询

上面说到引擎会去查找使用存在变量 a,这个查询称为LHS查询,另一种查询称为RHS查询

从字面意思上可以大致猜出一个是‘左’查询,一个是‘右’查询,简单的理解就是当变量出现在赋值操作得左侧的时候就是LHS,反之,出现在右侧得时候就是RHS,这两种查询方式很好理解。

在通俗一点得讲就是,RHS查询是查找变量对应的值,而LHS查询是查找变量的容器本身,从而可以对其赋值。从这个角度讲,RHS并不是真正意义上的“赋值操作得右侧”,更准确得讲是“非左侧”

function foo(a) {
    console.log(a);
}
foo(a);
复制代码

观察上方一段代码,一个简单的打印形参a得函数。我们来找一下其中的变量查询方式。

  • 第 1 行,当调用foo函数得时候,会发生一个隐士得赋值操作就是a = 2,将传递得实参传递给形参,这边是对a得LHS查询
  • 第 2 行,调用console对象重的log方法,是对console变量得RHS查询
  • 第 2 行,打印a变量,是对a变量得RHS查询
  • 第 4 行,调用了foo函数,是对foo变量的RHS查询

作用域嵌套

当一个块或者一个函数嵌套在另一个块或者另一个函数时,就发生了作用域嵌套。

在es5之前,只有全局作用域和函数作用域 es6 出来之后,let const 语句产生了块作用域

function foo(a) {
    console.log(a + b);
}

var b = 2;

foo(2); // 4
复制代码

这段代码中,foo函数内部对变量b做了一个RHS查询,但是foo函数内部并没有变量b得声明,于是引擎会向上一级作用域中查找,当前代码中也就是全局作用域,找到了var b = 2,完成了对变量b得查询。

像这样一级一级得嵌套就产生了作用域链的概念。

进行RHS查询时,引擎会在当前作用域中查找,若在当前作用域中没有找到该变量,就会向上一级作用中进行查找,一直到最外层的全局作用域,若还是没有找到所需变量,引擎会抛出ReferenceError

而LHS查询,未找到变量的话,就会在全局作用域中隐式的创建一个具有该名称的变量,但是在ES5中引入了严格模式,严格模式下有很多不同的行为,其中一个行为就是不允许自动或隐式的创建全局变量

词法作用域

作用域有两种工作模型,第一种是最为普遍的,被大多数编程语言采用的词法作用域,我们的JS也是采用的这种工作模型,另一种称为动态作用域,比如说Bash脚本,Perl中的一些模式等。后面提到的this的工作机制有一点像动态作用域但也只是有一点像而已,JS里是没有动态作用域的只有词法作用域。

function foo(a) {
    var b = a * 2;
    function bar(c) {
        console.log(a, b, c);
    }
    bar(b * 3);
}
foo(2); // 2, 4, 12
复制代码

来分析一下这一段代码

  • 全局作用域下包含一个标识符foo

  • foo函数作用域下包含三个标识符a b bar

  • bar函数作用域下包含一个标识符 c

当函数bar在对b进行RHS查询的时候,返现bar函数作用域内没有标识符b,那么引擎就会向上一级作用域也就是foo函数作用域,找到其中的标识符b

作用域查找会在找到第一个匹配的标识符时停止。在多层的嵌套作用域中可以定义同名的标识符,这叫做 遮蔽效应(内部的标识符遮蔽了外部的标识符),作用域查找始终从运行时所处的最内部的作用域开始,逐渐向外或者向上进行,直到遇见第一个匹配的标识符停止。

无论函数在哪被调用,也无论函数如何被调用,他的词法作用域都只由函数被声明时所处的位置决定(这是理解词法作用域最重要的一点,理解了这一点后面的闭包才能更好的理解)

考虑如下代码:

function foo() {
    console.log(a); // 2
}

function bar() {
    var a = 3;
    foo();
}

var a = 2;

foo();
复制代码

这边foo函数最终打印出来的值是2,而不是3,那是因为调用foo函数的时候,foo函数内部没有变量a,那么就会上一级作用域查找,而foo函数是定义在全局作用域的,因此上一级作用域是全局作用域,拿到全局的变量a。要谨记,js中只有词法作用域,作用域只由函数声明的位置来决定

eval with 欺骗词法作用域

JS有两种可以实现欺骗词法作用域的方法,这两种方法被各大开发者禁止使用,建议大家直接eslint禁止掉这两个方法的使用,欺骗了词法作用域会导致性能下降。谨记:不要使用这两个方法

eval

考虑一下代码

function foo(str, a) {
    eval(str);
    console.log(a, b);
}

var b = 2;

foo("var b = 3", 1) // 1, 3
复制代码

这段代码不会向预期的那样拿到全局作用域下的b,因为eval方法,导致var b = 3这段代码就好像本来就在foo函数里一样,事实上,引擎的确会在foo函数内部创建一个标识符b,并赋值为 3, 这样就屏蔽了全局作用域,达到了欺骗词法作用域的目的。

在严格模式下,eval方法有其自己的词法作用域,意味着其中的声明无法影响外部的作用域

function foo(str) {
    "use strict"
    eval(str);
    console.log(a); // ReferenceError: a is not defined
}

foo("var a = 3")
复制代码

with

这个就不说了

函数作用域

函数作用域的定义是:属于这个函数的全部变量都可以在整个函数的范围内使用及复用(事实上在嵌套的作用域中可以使用)

首先来理解两个概念

  • 函数声明:以 function 关键词开始,形如function foo() {}的被称为函数声明

  • 函数表达式:以 (function 开始,形如 (function() {})的被称为函数表达式

区分这两种的方法很简单,就是看function是否出现在整个声明的第一个词,如果是的话那就是函数声明,若不是,那么就是函数表达式

下面来考虑这一段代码

var a = 2;
function foo() {
    var a = 3;
    console.log(a); // 3
}
foo();
console.log(a); // 2
复制代码

这段代码使用foo函数包裹了一段代码段,避免了函数内部的a影响到外部的标识符a,但是foo这个名称本身“污染”了所在作用域(这个例子也就是全局作用域),理想状态是函数不需要名称这样就不会影响所在作用域,并可以可以自动运行,这是最理想的状态

考虑这段代码

var a = 2;
(function() {
    var a = 3;
    console.log(a); // 3
    
})();
console.log(a); // 2
复制代码

使用函数表达式的形式,可以避免给函数起一个名字,同时直接调用,在代码运行的同时他就会立即调用,这种表达式成为IIFE(立即执行表达式)

匿名/具名

具名很好理解就是一个函数有一个确切的名字,我们可以通过名字()的形式来调用。

匿名出现最多的场景就是回调函数

考虑一下代码

setTimeout(function() {
    console.log('I waited 1 second');
}, 1000);
复制代码

这叫做匿名函数表达式,因为function() {...}没有具体的名字。函数表达式可以匿名,但是函数声明必须有一个具体的名字。

匿名函数在开发中被大量使用,但是它具有一下几个小缺点

  • 匿名函数在栈追踪中不会显示出来有意义的函数名,不方便调试

  • 如果没有函数名,当函数需要引用自身的话,需要使用已经过期的arguments.callee来使用,比如在递归中,再比如在事件监听器触发后需要解绑自身(这是一个非常令人不舒服的地方,所以我一般都是直接修改window.onkeydown类似属性)

  • 匿名函数省略了具有一个描述性的函数名,对代码的可读性造成了些许的影响。

块作用域

考虑一下代码

var foo = true;
if (foo) {
    var bar = foo * 2;
    bar = something(bar);
    console.log(bar);
}
复制代码

标识符bar仅在 if 声明的上下文中使用,但是但使用 var 关键字声明时,它写在哪里都一样,因为他都属于外部作用域,这个现象也就是后面会说的变量提升

var a = 1;

if (a) {
    var b = a * 2;
}

console.log(b); // 2
复制代码

我们的理想状态是标识符b只在if声明的上下文中存在。

在看一个例子

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

标识符i只在for循环中使用,我们为什么要让它污染了我们整个所在的作用域呢?

with

严格模式下,用width从对象中创建的作用域仅在with声明中而非外部作用域中有效,这是块作用域的一种形式。

try/catch

这是一个很有意思的事情,我也是在看这本书的时候才了解到,try/catch语句中的catch代码块会创建一个块级作用域。

例子:

try {
    undefined(); // 强行制造异常
} catch (error) {
    console.log(error); // 正常打印
}
console.log(error); // ReferenceError: error is not defined
复制代码

标识符error只存在与catch语句内部

let/const

在ES6出来之后,我们有了另外两种声明变量的关键字let const,这是JS开发者的福音。

const 声明的变量是常量,声明时必须赋值,并且之后不可在修改

let关键字可以将变量绑定到任意作用域,(通常是{...}内部),并且**let const声明的变量不具有变量提升的特性**

考虑以下代码

if(true) {
    let a = 1;
}
console.log(a); // ReferenceError: a is not defined
复制代码

很明显a只存在于if上下文的内部。

考虑一下代码

if(true) {
    {
        // 块及作用域 - 作用域A
        let a = 1;
    }
    console.log(a); // ReferenceError: a is not defined
}
复制代码

这段代码很好的解释了,let声明的变量被绑定到了if内部的作用域A内部了,通常被绑定到{...}内部,因为在作用域A外部无法拿到标识符a

同样的for循环

for (let i = 0; i < 10; i++) {
    console.log(i);
}
console.log(i); // ReferenceError: i is not defined
复制代码

我们通过babel来看一下转换之后的代码

// 转换前
if (true) {
    let a = 1;
    console.log(a);
}
console.log(a);

// 转换后
"use strict";

if (true) {
  var _a = 1;
  console.log(_a);
}

console.log(a);
复制代码

猜测babel监测到了{} 同时内部使用了let关键字,然后就将使用了let关键子换了个名字

变量提升

变量提升在这本书中仅仅讲了三页纸不到,这是一个很简单的问题,我把它理解成一个现象,虽说简单,但是在开发过程中很多刚接触JS新手经常会发生这样一种疑问“为什么能拿到,为什么拿到的是undefined”

直觉上会认为JS代码执行时是由上到下一行一行运行的,但实际上并不完全正确,有一种特殊情况会导致这个假设是错误的。

考虑以下代码

console.log(a); // undefined
var a = 1;
复制代码

产生这一现象的原因: 编译阶段中的一部分工作就是找到所有的声明,并用合适的作用域将它们关联起来

因此,变量提升的含义可以理解为:包括变量和函数在内的所有声明都会在任何代码被执行前首先被处理。

当你看到var a = 2的时候,编译器会将这一条语句理解为两段代码var aa = 2,第一段代码会在编译时运行,而第二段代码会留在原地等待执行阶段。

因此,我们上方的代码实例可以转换为:

var a;
console.log(a);
a = 1;
复制代码

考虑一下代码

foo(); // is raise
function foo() {
    console.log('is raise');
}
复制代码

从这个例子我们可以看出来,不仅是var声明的变量会被提升,函数声明也会被提升,并且函数提升时,会带着其整个函数一起提升,以上代码可以理解为:

function foo() {
    console.log('is raise');
}
foo(); // is raise
复制代码

考虑以下代码:

foo(); // TypeError
var foo = function() {
    console.log('is raise');
}
复制代码

变量标识符foo会被提升,但是赋值操作还留在原地等待执行阶段。上方代码可以理解为:

var foo;
foo(); // TypeError
foo = function() {
    console.log('is raise');
}
复制代码

可以看出函数声明会被提升,而函数表达式却不会被提升。

函数声明和var声明的变量都会提升,那么提升的优先级呢,顺序不一样将会导致不一样的代码。

结论是函数提升优先于变量提升

函数提升优先于变量提升

考虑以下代码:

foo(); // 1

var foo;

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

foo = function() {
    console.log(2);
}
复制代码

以上代码可以理解为:

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

foo(); // 1

foo = function() {
    console.log(2);
}
复制代码

从上面的实例可以看出,函数声明的提升时优先于变量提升的,而且 var foo这条语句会被引擎给省略掉,因为在当前作用域中,已经有一个名称为foo的标识符了,重复声明会被引擎给跳过。这一点在第一节说到了。

那么如果多个重名的函数声明呢,提升的优先级呢?

考虑以下代码:

foo(); // 3

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

var foo = function() {
    console.log(2);
}

function foo() {
    console.log(3);
}
复制代码

以上代码可以理解为:

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

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

foo(); // 3
复制代码

可以看出,多个重名的函数声明,会按照书写的顺序进行提升,后者可以覆盖前者,因此这里会打印出 3

闭包

闭包在开发过程中很常见,你只需要知道去认识它,并知道你写的这一段代码就是闭包,而不应该去为了闭包而闭包

闭包是基于词法作用域书写代码所产生的自然结果。

闭包的概念可以定义为:函数在当前词法作用域之外被调用。

首先我们来看一下经典闭包代码:

function foo() {
    var a = 2;

    function bar() {
        console.log(a);
    }

    return bar;
}

var baz = foo();
baz();
复制代码

函数foo内部有一个函数bar并将整个函数返回出来,然后调用这个返回值,这是这段代码的字面意思。事实上标识符baz还是指向的bar函数,但是整个函数却在foo函数外部被调用,也就是说bar函数在自己的词法作用域之外被调用。这一段代码是一段经典闭包代码。

foo函数被执行之后,通常会期待foo函数内部整个作用域都被销毁,因为JS有一个垃圾回收机制,会释放不再使用的内存,但是在这边,闭包打断了整个回收,因为bar函数本身仍在使用这个作用域,bar函数依然持有该作用域的引用,这个引用就叫做闭包。

考虑下面这段代码,你会对闭包有着更加深刻的认知。

function foo() {
    var a = 2;

    function baz() {
        console.log(a);
    }

    bar(baz);
}

function bar(fn) {
    fn() // 这就是闭包
}

foo();
复制代码

函数baz被当作参数传递给函数bar的行参fn,之后调这个fn,实质就是调用整个baz,调用位置不再函数baz的词法作用域,而是在bar函数的内部调用,这就是闭包。

无论通过何种手段将内部函数传递到所在的词法作用域以外,它都会持有对原始定义作用域的引用,无论在何处执行这个函数都会使用闭包。

考虑以下代码

function wait(message) {
    setTimeout(function timer() {
        console.log(message);
    }, 1000)
}

wait('hello 闭包');
复制代码

这是一段大家都很熟悉的代码,函数timersetTimeout的回调,但是timer函数内部使用了外部的变量message,涵盖了wait函数的作用域,当wait调用时,其内部的作用域并不会被销毁,因为过一秒后执行的回调timer依然保留着wait的作用域。

循环 + 闭包

我们来看一段经典的代码

for (var i = 0; i < 5; i++) {
    setTimeout(function timer() {
        console.log(i);
    }, i * 1000);
}
复制代码

理想状态下是每隔一秒打印出对应的数字,但是实际情况每隔一秒打印一个 5

我们来分析一下产生这个问题的原因,

  1. 首先我们知道setTimeout定时器是一个异步任务,会在所用的同步任务执行完成之后再去执行异步任务,这是JS的EventLoop事件循环机制(其中还细分微任务,宏任务两个概念)

  2. 其次我们发现,内部的回调函数timer是一个闭包,它仍旧引用着外部的变量i

  3. 然后我们回过头来发现,当for循环整个同步执行完成之后,变量i会自增到5,从而退出循环,而这个时候,没有同步任务了,就开始执行异步任务,异步任务时每隔一秒钟打印一下这个i,而现在变量i就是5,因此会产生这种现象。

那么定位到了问题在哪那就好办了,我们可以给每个timer函数一个变量i的副本,也可以理解为一个瞬时值。那么如何给它一个瞬时值呢,答案就是给他创建一个独立的作用域,作用域内部的i会屏蔽掉for循环的游标i,这正是我们一开始说到的屏蔽效应

因此我们的代码改写为:

for (var i = 0; i < 5; i++) {
    (function moment(i) {
        setTimeout(function timer() {
            console.log(i);
        }, i * 1000);
    })(i);
}
复制代码
  1. 首先还是来一下这个timer函数,依旧是一个闭包,不过这次保留的不是for循环中的i而是moment函数的形参i,而timer保留的也是moment函数内部的作用域。

  2. 然后我们来看一下moment函数,这是一个IIFE,接受一个形参i,这个形参用来屏蔽循环中的i,这样就可以达到瞬时值的目的。

那么除了这种方法,还有一种更为简单的就是使用ES6的let关键字声明变量i,因为我们只要达到一个块级作用域的目的就可以了。

代码修改为:

for (let i = 0; i < 5; i++) {
    setTimeout(function timer() {
        console.log(i);
    }, i * 1000);
}
复制代码

模块

模块正是利用了闭包的强大威力。

考虑如下代码:

function CalModule() {
    var something = 'cool';

    var another = [1, 2, 3];

    function doSomething() {
        console.log(something);
    }

    function doAnother() {
        console.log(another);
    }

    return {
        doSomething: doSomething,
        doAnother: doAnother
    }
}

var foo = CalModule();

foo.doSomething(); // cool
foo.doAnother(); // [1, 2, 3]
复制代码

这个模式被称为模块

函数CalModule返回一个对象,对象中包含了两个方法,用于在其他的地方进行调用,但是函数内部的变量something another不会被直接修改, 只能通过函数CalModule暴露出来的方法来进行修改数据,这就是模块的魅力,利用了闭包的强大威力。

模块必须具有两个条件:

  • 必须有外部的封闭函数,该函数必须至少被调用一次(每次调用都会产生一个新的模块实例)

  • 封闭函数必须返回至少一个内部函数,这样内部函数才能在私有作用域中形成闭包,并且可以访问或者修改私有的状态。

单例模式

每次调用封闭函数的时候都会返回一个新的模块实例,那么当我们只需要一个实例的时候,我们可以使用单例模式

var foo = (function CalModule() {
    var something = 'cool';

    var another = [1, 2, 3];

    function doSomething() {
        console.log(something);
    }

    function doAnother() {
        console.log(another);
    }

    return {
        doSomething: doSomething,
        doAnother: doAnother
    }
})();

foo.doSomething();
复制代码

使用IIFE表达式,得到一个模块实例,这样每次使用的都是同一个实例。

this

this一直都是一个非常神奇的东西,在使用函数的时候经常会用到this,我们首先得搞明白为什么需要this,其次需要理解JS中this的执行机制是什么,只有了解了执行机制我们就能灵活的运用this。

this提供了一种优雅的方式来隐式的传递执行上下文,可以将API设计的更加简洁并易于复用。

this是在运行时进行绑定的,并不是在编写时绑定的,它的上下文取决于函数调用时的各种条件。this和函数的声明没有任何关系,只取决于函数的调用方式

默认绑定

当函数不加任何修饰直接调用时,或者不匹配其他规则时都会引用默认绑定规则,默认绑定规则下,this 将指向全局,在浏览器环境中也就是window

但是在严格模式下,this 会绑定到 undefined

考虑如下代码:

function foo() {
    console.log(this.a);
}

var a = 2;

foo(); // 2
复制代码

函数foo不加任何修饰直接调用。

考虑如下代码:

var a = 2;
var obj = {
    foo: function() {
        console.log(this.a);
    }
};
var bar = obj.foo;
bar(); // 2
复制代码

即使函数barobj.foo的一个引用,但是bar函数是直接调用的,所以this指向全局。谨记:this 指向是运行时确定的,和函数声明的位置无关,只与函数的调用方式有关

考虑如下代码:

function foo() {
    console.log(this.a);
}
function doFoo(fn) {
    fn(); // 不加任何修饰直接调用,指向全局
}
var obj = {
    a: 2,
    foo: foo
}
var a = 'ops';

doFoo(obj.foo); // ops
复制代码

隐式绑定

隐式绑定指的是函数有明确的调用者,这种绑定规则下,this 指向调用者。

考虑如下代码:

function foo() {
    console.log(this.a);
}
var obj = {
    a: 2,
    foo: foo
};
obj.foo(); // 2
复制代码

函数foo并不是不加任何修饰的调用了,他的前面有了一个调用者obj,这个时候函数的this指向是指向的obj调用者了。

考虑如下代码:

function foo() {
    console.log(this.a);
}
var obj2 = {
    a: 42,
    foo: foo
};
var obj = {
    a: 2,
    obj2: obj2
};
obj.obj2.foo(); // 42
复制代码

函数foo的前面有了多个调用者,这个时候,foo函数只看最后一层调用,这边也就是对象obj2

显示的绑定

call/apply

使用call函数可以修改一个函数的this指向。

考虑如下代码:

var a = 'window';
var obj = {
    a: 'obj'
};
function foo() {
    console.log(this.a);
}
foo.call(obj); // obj
复制代码

foo函数在调用时通过call方法修改了this指向,这是显示的绑定。与之类似的是apply,两者的第一个参数都是新的this指向,唯一的区别在于参数传递的方式不一样

call传递的是一个参数列表 apply传递的是一个数组

考虑如下代码:

var a = 'window';
var obj = {
    a: 'call'
};
var obj2 = {
    a: 'apply'
};
function foo(param1, param2) {
    console.log(this.a, param1, param2);
}
foo.call(obj, 'param1', 'params2'); //call param1 params2
foo.apply(obj2, ['param1', 'params2']); // apply param1 params2
复制代码

bind

bind绑定是call apply绑定得一个变种,是一种显示得强制绑定,被称为“硬绑定”。bind函数返回一个新的函数,这个新的函数不能再被call apply等二次修改this指向。

使用方式如下:

var obj = {
    a: 'bind'
};
var obj2 = {
    a: 'second change'
};
function foo(param1, param2) {
    console.log(this.a, arguments);
}
var newFoo = foo.bind(obj, 'pram1', 'param2');
newFoo.call(obj2); // bind ['param1', 'param2']
newFoo('param3', 'param4'); // bind ['param1', 'param2', 'param3', 'param4']
复制代码
  • 首先第 10 行,我们使用bind方法,返回一个新的函数,第一个参数为新函数的this指向,之后会传递一个参数列表作为函数的参数。最终的foo函数的得到的参数是bind时传递的参数列表加上调用newFoo新函数传递的参数组合起来得一个参数列表

  • 第 11 行,可以发现,通过bind修饰后的新函数不能再被call apply等二次修改 this 指向。

new 绑定

该绑定规则适用于 new 绑定关键字,该规则this指向new出来的那个实例。

当我们执行new操作的时候,实际上发生了如下步骤:

  1. 创建一个全新的对象

  2. 这个新对象执行[[Prototype]]绑定

  3. 这个新的对象将会绑定到函数调用得this

  4. 如果函数没有返回对象,那么new表达式中的函数调用会自动返回这个新对象。

考虑如下代码:

function foo(a) {
    this.a = a;
}

var bar = new foo(2);
console.log(bar.a); // 2
复制代码

优先级

尤高到底分别为:

  1. new 绑定

  2. bind 绑定

  3. call, apply绑定

  4. 隐式绑定

  5. 默认绑定

当你把 null 或者 undefined 作为 this 得绑定对象传入 call, apply, bind,这些值在调用时会被忽略,实际应用的是默认绑定规则

箭头函数

箭头函数是ES6新增一个函数写法,写法非常cool,也非常简洁,至少我本人非常喜欢这个。唯一要注意的是箭头函数得this指向不应用上方介绍的任何一种规则,而是根据外层(函数或者全局)作用域来决定this,这个时候得看箭头函数的词法作用域,并且箭头函数得this不可以经过二次修改。

总结为以下几点:

  • 箭头函数的this是定义时确定的,继承至父级的(函数或者全局)作用域

  • 箭头函数得this不可二次修改

function foo() {
    return (a) => {
        console.log(this.a);
    }
}
var obj1 = {
    a: 'obj1'
}
var obj2 = {
    a: 'obj2'
}
var bar = foo.call(obj1);
bar.call(obj2); // obj1
复制代码

函数foo返回一个箭头函数,根据我们总结得第一点,我们得箭头函数中的this是父级作用域也就是foo函数内部,这个时候通过call方法修改foo函数得this,使其指向了obj1,也就是说这个箭头函数的this已经定死了执行obj1(除非修改foo函数的this指向), 之后第 13 行再次使用call试图修改箭头函数的指向,结果证明并没有成功,这也证实了我们的第二点。

JS是一门面向对象的语言,而ES6带来了Class语法,但是这并不意味着JS中实际上有类得,看到babel对类解析之后得代码的话就知道,Class 最终还是会被转换为普通的函数,只不过这些函数得函数名首字母大写了,我们称之为“构造函数”,但是没有任何规范要求你构造函数的首字母大写。

Class 关键字可以使你创建一个类,然后这个类中使用new关键字来构造一个实例,JS中得类同样满足封装、继承、多态。

但是每次new出一个实例的时候,这个实例都是一个单独得实例,它不和其他实例有什么关联,唯一的联系是它们都属于同一个类,所以无法实现实例间得数据共享。而使用原生函数加上原型链我们可以轻松的实现数据共享且构造简单。

对象

JS中的object是以key-value形式存在的,那么这边要介绍的点是对象上的两种属性描述符:数据描述符和访问描述符

我们可以通过 Object.defineProperty来给一个对象添加一个 key 并且为这个 key 设置属性描述符。

考虑如下代码:

var obj = {};
Object.defineProperty(obj, 'foo', {
    configurable: true,
    enumerable: true,
    writable: true,
    value: 'obj'
});
console.log(obj); // { foo: 'obj' }
复制代码

方法接受三个参数,第一个参数为要操作的对象,第二个参数为添加的 key 值,第三个参数为属性描述符。

属性描述符可能的参数如下:

  • configurable:boolean,当且仅当该属性的 configurable 为 true 时,该属性描述符才能够被改变,同时该属性也能从对应的对象上被删除。默认为 false。

  • enumerable:boolean,当且仅当该属性的enumerable为true时,该属性才能够出现在对象的枚举属性中。默认为 false。

数据描述符同时具有以下可选键值:

  • writable:boolean,当且仅当该属性的writable为true时,value才能被赋值运算符改变。默认为 false。

  • value:该属性对应的值。可以是任何有效的 JavaScript 值(数值,对象,函数等)。默认为 undefined。

访问描述符同时具有以下可选键值:

  • get:一个给属性提供 getter 的方法,如果没有 getter 则为 undefined。当访问该属性时,该方法会被执行,方法执行时没有参数传入,但是会传入this对象(由于继承关系,这里的this并不一定是定义该属性的对象)。

  • set:一个给属性提供 setter 的方法,如果没有 setter 则为 undefined。当属性值修改时,触发执行该方法。该方法将接受唯一参数,即该属性新的参数值。

数据描述符和访问描述符不可同时存在。

考虑如下代码:

var obj = {};
Object.defineProperty(obj, 'foo', {
    configurable: true,
    enumerable: true,
    set: function(val) {
        this._foo_ = val;
    },
    get: function() {
        return this._foo_
    }
});
obj.foo = 'obj';
console.log(obj); // { foo: 'obj' }
复制代码

这边注意的是需要设置名为一个属性,一般以_开始,表示内部元素,如果写成this.foo = val的话,那么会陷入死循环,因为赋值操作就是在调用set方法,方法内部还是在对该属性赋值,所以会陷入死循环,get同理。

原型

JS中的对象有一个特殊的名为[[Prototype]]的内置属性,其实就是对其他对象的引用。

考虑如下代码:

var obj = {
    a: '1'
}

obj.a // 1
复制代码

当获取obj.a的时候,首先会检测对象obj中是否具有a属性,如果有的话会触发该属性的[[GET]]方法,若没有的话,就需要用到原型链了,引擎会继续在原型链上继续查找是否具有a这个key值。

原型链的尽头

所有普通的原型链最终都会指向Object.prototype,它包含了很多方法,比如说toString, valueOf, hasOwnProperty

属性设置和屏蔽

这一点是大多数开发者经常会犯的错误,它是一个非常细小的操作,只是一个简单的赋值操作但是产生的结果往往会导致代码运行失败。

考虑如下代码:

var obj = {
    foo: 'obj'
}
var bar = Object.create(obj);
console.log(bar); // {}
console.log(bar.foo); // 1
bar.foo = 'bar'
console.log(bar); // { foo: 'bar' }
console.log(Object.getPrototypeOf(bar)); // { foo: 'obj' }
复制代码

通过Object.create方法将bar的原型指向obj,但是我们现在的bar是一空对象,我们没有设置任何的 key 值,所以直接打印这个 bar 得到的是一个空对象。

当我们试图打印bar.foo�的时候,引擎首先会检查 bar 中是否具有这个 ke y找不到的话会继续查找其原型链,而原型链上是有一个名为 foo 的key值,他的 value 是 1, 所以打印1

当我们试图执行bar.foo = 'bar'的时候,我们首先看一下打印出来的结果 以及打印 bar 原型链的结果,结果表明这个foo添加在了 bar 上,而不是我们预期的修改原型链上的 foo 属性。这个 foo 属性变成了 bar 对象上的一个私有属性,它屏蔽掉了原型链上的 foo 属性。

事实上,如果 foo 不存在于 bar 中,而是存在于原型链上时执行 bar.foo = 'something' 赋值操作时,会发生三种情况:

  • 如果原型链上存在 foo 属性,并且没有被标记为只读(writable: false),那么会直接在bar对象上添加一个foo属性

  • 如果原型链上存在 foo 属性,并且被标记为只读,那么无法修改已有属性或者在 bar 上创建屏蔽属性,如果运行在严格模式下,代码会抛出一个异常,否则,这条赋值语句会被忽略

  • 如果原型链上存在并且是一个 setter(访问描述符),那么会调用这个 setter 方法,foo 属性不会添加到 bar 上,也不会重新定义 foo 这个 setter 方法。

构造函数

考虑如下代码;

function Foo() {
    //...
}
Foo.prototype.constructor === Foo // true
var a = new Foo();
a.constructor === Foo // true
复制代码

当我们执行 new 操作的时候,有一个步骤是原型关联,将a.prototype指向Foo.prototype,而Foo.prototype中有一个公有并且不可枚举的属性constructor,这个属性引用的是对象关联的函数,在这里也就是Foo函数。

那么Foo函数就是我们所说的构造函数,事实上也只是一个普通的函数,只不过首字母大写了而已,为什么大写呢,类名首字母大写,所以。。。大写。这显然不是将它称之为构造函数的理由,唯一的理由是,这个函数被 new 操作符调用。

实际上,new 操作会劫持所有的普通函数并且构造对象的形式来调用它。换句话说,在JS 中,对于“构造函数”最准确的解释是,所有带 new 的函数调用

函数不是构造函数,但是当且仅当使用new时,函数调用会变成“构造函数调用”

数据共享

之前说过 class 语法每次new 出来的实例都是一个新的实例,每个实例的数据并没有实现共享。

考虑如下代码:

class C {
    constructor() {
        this.count = 0;
    }
}
var c1 = new C();
var c2 = new C();
c1.count++;
console.log(c1, c2); // { count: 1 } { count: 0 }
复制代码

你会发现c1 c2都有一个独立的count,那么如果向要实现一个共享的 count 就必须使用原型链,代码修改为

class C {
    constructor() {
        C.prototype.count++;
    }
}
C.prototype.count = 0;
var c1 = new C();
var c2 = new C();
console.log(c1.count, c2.count); // 2 2
复制代码

但是这不是 class 的本意,class 就是不想把 prototype 暴露出来。所以我们可以使用Object.create我已经爱上了这个方法

var parent = {
    count: 0,

    addCount: function() {
        parent.count++;
    }
}

var child = Object.create(parent);
child.addCount();
var child2 = Object.create(parent);
console.log(child, child2);
复制代码

image_1e1154j1f5qu1m4ft2dglag8b9.png-24.5kB

唯一的缺点是我们需要创建一个对象出来。