《你不知道的JavaScript(上卷)》读书笔记

《你不知道的JavaScript》大概是去年这个时候看的,上卷主要讲解作用域和闭包、this和对象原型这两个部分,阅读之后有很大收获,相当于对于JavaScript这门语言有了新的认识。

最近在复习JavaScript,决定重新阅读,并补充之前遗漏的读书笔记。

<!--more-->

1. 作用域

1.1. 作用域的概念

作用域是一套规则,用于确定在何处以及如何查找变量(标识符)。

从下面这行代码开始

var a = 2;

引擎认为这里有两个完全不同的声明,一个由编译器在编译时处理,另一个由引擎在运行时处理。换句话说,变量的赋值操作会执行两个操作

  • 首先编译器会在当前作用域中声明一个变量
  • 然后在运行时引擎会在作用域中查找该变量,如果能找到则对齐进行复制

引擎执行的查找有两种形式:LHSRHS

  • 当变量出现在赋值操作的左侧时进行LHS查询(即查找的目的是为变量进行赋值),如a=2
  • 当变量作为赋值操作的源头时进行RHS查找(即查找的目的是获取变量的值),如console.log(a)

当一个块或函数嵌套在另一个块或函数中时,就发生了作用域的嵌套。因此在当前作用域中无法找到某个变量时,引擎就会在外层嵌套的作用域继续查找,知道找到该变量或者抵达全局作用域为止。

对于LHSRHS,上面的查找过程均适用,他们的区别在于当变量还没有声明时(即查询在所有嵌套的作用域中都找不到所需的变量),这两种查找的行为是不一样的

  • 如果RHS,则引擎会抛出ReferenceError的错误
  • 如果LHS
    • 非严格模式下,全局作用域会创建一个具有该名称的变量,并将其返回给引擎
    • 严格模式下会抛出ReferenceError错误

1.2. 词法作用域

作用域可分为词法作用域和动态作用域,

  • 词法作用域就是定义在词法阶段的作用域。换句话说,词法作用域是由你在写 代码时将变量和块作用域写在哪里来决定的,因此当词法分析器处理代码时会保持作用域 不变(大部分情况下是这样的)。
  • 动态作用域在程序运行时确定变量的值,换句话说,作用域链是基于调用栈的,而不是代码中的作用域嵌套。

JavaScript采用的是词法作用域。

function foo(a) {
    var b = a * 2
    function bar(c){
        console.log(a, b, c)
    }
    bar(b * 3)
}
foo(2)

上面代码存在3个作用域:

  • 全局作用域,其中包含foo一个标识符
  • 包含着foo函数所创建的作用域,其中有a,b,bar三个标识符
  • 包含着bar函数所创建的作用域,其中包含c一个标识符

作用域查找会在找到第一个匹配的标识符停止,内部作用域的标识符遮蔽了外部作用域的标识符。

词法作用域只会查找一级标识符,也就是说,如果查找foo.bar.baz,词法作用域只会视图查找foo标识符,找到这个变量以后,对象属性访问规则则会分别接管对bar和baz属性的访问。

无论函数在哪里被调用,也无论它何时被调用,它的词法作用域都只由函数被声明时所处的位置决定。

在JavaScript中可以通过witheval等方式欺骗词法,在运行时修改或创建新的作用域,以此来欺骗其他在书写时定义的词法作用域。

但是由于JavaScript引擎会在编译阶段对作用域查找进行性能优化,如果使用上述手段欺骗作用域,则可能导致程序运行效率的下降,因此不建议使用。

1.3. 函数作用域与块作用域

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

通过函数作用域,可以隐藏内部的实现,最小限度地暴露接口的细节。在ES5及之前的版本,JS中只支持函数作用域。

ES6改变了现状,引入了let关键字,它为其声明的变量隐式地附加到所在的块作用域。块级作用域有以下几个技巧

  • 由于闭包函数的存在,引擎可能会保存父级作用域中的大数据(即使闭包函数没有使用这个变量),使用块级作用域可以消除这种顾虑
  • 在for循环中,let实际上将计数器重新绑定到了循环的每一个迭代中,确保使用上一个循环迭代结束时的值重新进行赋值

此外const关键字也可以用来创建块级作用域,区别在于其声明的变量值是固定的,之后任何试图修改值的操作都会引起错误。

需要注意的是letconst声明的变量都不会进行声明提升,这是非常有趣的一点。

有些人认为块作用域不应该完全作为函数作用域的替代方案。两种功能应该同时存在,开 发者可以并且也应该根据需要选择使用何种作用域,创造可读、可维护的优良代码。

1.4. 提升

包含变量和函数在内的所有声明都会在任何代码执行前首先被处理。下面的代码

var a = 2

等价于

var a // 定义声明在编译阶段进行
a = 2 // 赋值声明会被留在原地,等待执行阶段

这个过程就好像变量和函数声明从他们在代码中出现的位置被移动到了最上面,这个过程被称为提升

每个作用域都会进行提升操作。需要注意的下面几点

函数声明会提升,但函数表达式不会

// 正常执行
foo()
function foo(){//...}

// 报 TypeError 错误
bar()
var bar = function(){//...}

函数声明提升优先级大于变量声明提升

函数声明和变量声明都会被提升,但是是函数会首先被提升,然后才是变量。

foo() // 1

var foo

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

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

这里的先提升的含义,相当于函数声明提升会覆盖同名的变量声明提升。

如果存在多个提升,则后提升的函数声明会覆盖先提升的函数声明,变量亦然。建议不要在同一个作用域中进行重复声明

2. 闭包

2.1. 作用域闭包

当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行。换句话说,闭包使得函数可以继续访问定义时的 词法作用域。

闭包函数对于其内部的变量查找遵循词法作用域的查找规则(即该闭包函数被声明时所处的位置决定),即使当闭包在其定义的词法作用域以外的地方执行时(比如闭包作为函数的返回值,或者闭包以函数参数的形式传入),这个查找规则也是生效的。

正常情况下当函数执行完毕,函数的整个内部作用域都被销毁,垃圾回收机制会回收不被使用的内存空间。

但是如果在函数执行时产生了闭包,情况就有所不同了。即使定义闭包的函数已经执行完毕,由于闭包仍旧保持对其词法作用域的引用,因此只要闭包在其他地方调用,都可以观察到闭包(即访问定义时的外部变量),下面列举了常见的三种形式

以函数返回值形式调用闭包

function foo(a) {
    var b = a * 2
    function bar() {
        console.log(b)
    }
    return bar 
}

let baz = foo(2) // 以函数返回值的形式调用闭包
baz()

以回调函数形式调用闭包

function foo(fn){
    var b = 4
    function bar() {
        console.log(b)
    }
    fn(bar)
}

function baz(fn){
    fn()
}
foo(baz)

以外部变量形式调用闭包

var baz
function foo(a) {
    var b = a * 2
    function bar() {
        console.log(b)
    }
    baz = bar
}
foo(2)
baz()

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

2.2. 循环和闭包

下面是一个比较常见的面试题

for(var i = 0; i < 5; ++i){
    setTimeout(() => {
        console.log(i)
    }, i*100);
}

上面的代码本意是每隔100ms输出不同的i值,最后的输出结果却是5个相同的值:5。

如果从JavaScript事件队列的机制来解释,即先执行同步代码,再执行异步代码,定时器生效时访问到的i变量的值已经变成了5,因此最后输出的都是5。

但是究竟是什么导致代码的行为与语义上所暗示的不一样呢?因为我们试图假设循环中每个迭代在运行时都会给自己“捕获”一个i的副本,但根据作用域的原理,i变量会进行声明提升,因此实际上只有一个i,且被封闭在与for同级的全局作用域中。这样,即使定时器使用了5个闭包函数,他们共享的是同一个i变量,所以最后输出的值相同。

换句话说,要解决上面的问题,我们需要在每次循环中都生成一个新的闭包作用域,然后在作用域中保存每次循环不同的i值,这样保证每个闭包访问到不同的变量。

可以使用下面几种方式

通过IIFE来创建新的作用域

因为变量提升只会在提升到当前作用域的顶部,因此可以使用一个变量来保存每次循环中的值

for (var i = 0; i < 5; ++i) {
    (function () {
        var j = i
        setTimeout(() => {
            console.log(j)
        }, j * 100);
    })()
}

也可以换成下面这种简写方式

for(var i = 0; i < 5; ++i){
    (function(i){
        setTimeout(() => {
            console.log(i)
        }, i * 100);
    })(i)
}

通过let在每次循环时重新声明i

既然上面的问题是由于变量声明提升导致共享一个变量导致的,使用let块级作用域中保存每次循环的值更加简单

for(let i = 0; i < 5; ++i){
    setTimeout(() => {
        console.log(i)
    }, i*100);
}

块级作用域和闭包联手可以解决很多问题哦!!

2.3. 闭包的应用

通过函数作用域封装内部变量和数据,然后通过闭包暴露相关接口,闭包可以访问内部变量,而其他地方无法访问,这样就可以封装模块

模块有两个主要特征:

  • 为创建内部作用域而调用了一个包装函数;
  • 包装函数的返回 值必须至少包括一个对内部函数的引用,这样就会创建涵盖整个包装函数内部作用域的闭包

通过闭包,访问私有变量,封装模块,这是闭包很常用的使用场景。

3. this

显式传递上下文对象参数会让代码变得混乱,而让函数自动引用合适的上下文对象就变得十分重要。

this是一个很特别的关键字,它被自动定义在所有函数的作用域中。this是在运行时绑定的,而不是在编写时绑定的,它的上下文取决于函数调用时的各种条件。换句话说,this的绑定和函数声明的位置没有任何关系,只取决于函数的调用方式。

学习 this 的第一步是明白 this 既不指向函数自身也不指向函数的词法作用域

第二步就是寻找函数的调用位置,主要是分析调用栈(就是为了到达当前执行位置所调用的所有函数)

第三步就是了解在函数执行过程中调用位置决定this的绑定对象

3.1. 默认规则

如果是是直接使用不带任何修饰的函数引用进行调用的,因此只能使用 默认绑定,无法应用其他规则。

function foo(){
    console.log(this.a) // 100
}
var a = 100

foo()

默认规则下,

  • 非严格模式中,this指向全局对象
  • 严格模式下,this为undefined

3.2. 隐式绑定

当函数引用有上下文对象时,隐式绑定规则会把函数调用中的this绑定到这个上下文对象

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

var obj = {
    a: 1,
    foo
}

obj.foo() // 1,foo作为对象的方法调用

比较常见问题是被隐式绑定的函数丢失其绑定对象

var bar = obj.foo()
bar() // undefined

将对象的方法通过函数表达式赋值,然后以普通函数的形式调用时,函数就会丢失其绑定对象

上面这种函数表达式的形式还很明显,在某些使用回调函数的情形下,容易忽略这个问题

setTimeout(obj.foo, 1000) // 回调函数已经丢失了其绑定对象,这里是obj

在jQuery中的事件注册中,会在其内部实现中将传入的事件处理函数绑定到当前DOM节点上,需要理解其中的区别。

3.3. 显式绑定

那么如果我们不想在对象内部包含函数引用,而想在某个对象上强制调用函数,可以使用函数的callapply两个方法来实现。

这两个方法接受的第一个参数是一个对象,他们会把这个对象绑定到this,然后在函数调用时指定这个this

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

var obj = {
    a: 1,
    foo
}

foo.call(obj) // 1

call和apply的区别在于他们处理函数参数的形式

  • call第二个参数及后面多余的参数,当做函数的参数列表
  • apply第二个参数接受一个数组,当做函数的参数列表

硬绑定

硬绑定可以解决函数的this绑定对象丢失的问题

var bar = function(){
    foo.call(obj)
}

无论bar以何种形式调用,都不可能改变foo显式绑定this的对象。

硬绑定是一种十分常用的模式,ES5提供了Function.prototype.bind方法,该方法返回一个硬编码的新函数,他会把参数设置为this的上下文,并调用原始函数。

忽略this

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

3.4. new 绑定

JavaScript中的new机制实际上和面向类的语言完全不同。实际上并不存在所谓的“构造函数”,只有对函数的“构造调用”

先来看看这段代码

function foo(a){
    this.a = a // 现在研究的是这里的this指向
}

var obj = new foo(2)
console.log(obj.a) // 2

使用 new 来调用函数,或者说发生构造函数调用时,会构造一个新对象并把他绑定到foo调用中的this上,这种绑定被称为new 绑定

3.5. 四种规则的优先级

为了判断函数中的this,我们需要做的就是找到函数的调用位置并判断应当应用哪条规则。

首先,默认规则的优先级最低。接下来了解剩下三种规则的比较

1.显示绑定优先级大于隐式绑定

可以用下面代码简单检测

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

var obj = {
    a: 1,
    foo
}
var obj2 = {
    a: 100
}
obj.foo.call(obj2) // 100

2.new绑定优先级大于显式绑定

先来看看这段代码

function foo(a){
    // console.log(this.a)
    this.a = a
}

var obj = {}
var bar = foo.bind(obj) // bar函数的this硬绑定到obj上
bar(2) // 修改obj.a = 2
console.log(obj.a) // 2

var baz = new bar(3) // 对函数的构造调用,返回新对象baz.a = 3
console.log(obj.a) // 2,并没有修改obj.a的值
console.log(baz.a) // 3,//新对象的属性值

可以看见new绑定的优先级高于显式绑定

总结

现在可以根据下面的规则进行this的判断了

  1. 函数是否在 new 中调用(new 绑定)?如果是的话 this 绑定的是新创建的对象。 var bar = new foo()
  2. 函数是否通过 call、apply(显式绑定)或者硬绑定调用?如果是的话,this 绑定的是 指定的对象。 var bar = foo.call(obj2)
  3. 函数是否在某个上下文对象中调用(隐式绑定)?如果是的话,this 绑定的是那个上 下文对象。 var bar = obj1.foo()
  4. 如果都不是的话,使用默认绑定。如果在严格模式下,就绑定到 undefined,否则绑定到 全局对象。var bar = foo()

3.6. 箭头函数

ES6新增了一种无法使用上述规则的特殊函数类型:箭头函数

箭头函数的this会捕获调用其父函数的this(全局作用域下就是全局对象),箭头函数的绑定无法被修改,(包括new绑定也不行)

function foo(){
    return ()=>{
        console.log(this.a)
    }
}

等价于

function foo() {
    var self = this
    return function(){
        console.log(self.a)
    }
}

可以理解为,箭头函数用更常见的词法作用域取代了传统的this机制。

4. 原型与原型链

4.1. 对象

对象可以通过声明形式和构造形式进行定义。

有一种常见的错误说法是“JavaScript 中万物皆是对象”,这显然是错误的。简单基本类型本身并不是对象。

基本类型的字面量是一个不可变的值,如果要在这个字面上上执行一些操作,比如获取长度、方位其中某个字符等,语言会自动把其转换成一个包装对象

访问对象的属性可以使用.操作符(属性访问)或[]操作符(键访问)

  • 属性访问要求属性名满足标识符的命名规范
  • 键访问可以接受任意utf-8/Unicode字符串作为属性名,可以传入动态获取对象属性

在JavaScript中,函数也是对象,因此,从技术角度上来说,函数永远不会“属于”一个对象,所以把对象内部引用的函数称为“方法”似乎有点不妥。最保险的说法可能是,“函数”和”方法“在JavaScript中是可以互换的。

ES6定义了Object.assign方法来实现浅复制。

4.2. 对象描述符

对象属性的特性可以通过属性描述符来控制

属性描述符

之前学习Vue的时候整理过属性描述符的相关知识:对象描述符与响应式数据,这里再简单过一遍。

通过Object.getOwnPropertyDescriptor可以获取对象的某个属性描述符,描述符看起来很像是某个对象的属性。

  • value数据描述符,用来指代这个属性所包含的数据值的
  • configurable,决定是否可以修改对象某个属性的描述符
  • enumerable,决定某个属性是否在遍历中出现
  • writeable,决定是否可以修改属性的值,即是否允许对该属性进行赋值

    我们还可以使用defineProperty来修改某个属性的描述符。

访问描述符

语言规范中,对象属性访问的实现实际上是[[get]],该操作有点类似于函数调用,即首先在对象中根据指定的表示式或字符串常量查找对应的同名属性,如果找到就返回该属性值,如果不存在则会在原型链上实现委托查询(这个后面马上会详细讲解)。

既然属性访问实际上是通过[[get]]实现的,那么肯定存在对属性进行赋值的操作[[put]]。实际上,[[put]]操作的触发并不仅仅是对属性进行赋值这么简单,而是取决于许多因素:

  • 属性的writable是否为true
  • 属性是否是访问描述符(接下来就会提到)
  • 如果都不会,则会将该值设置为该属性的值
var obj = {
    _a: 100,
    get a(){
        return this._a
    },
    set a(num){
        this._a = num * 2
    }
}

console.log(obj.a) // 100
obj.a = 200
console.log(obj.a) // 400

注意

当为某个属性(这里是msg)定义一个gettersetter时(或两者都存在),该属性就会被定义为访问描述符

对于访问描述符而言,JavaScript会忽略他们的valuewritable特性,而关注getset

4.3. 类

一个类就是一张蓝图。为了获得真正可以交互的对象,我们必须按照类来建造(也可以说 实例化)一个东西,这个东西通常被称为实例,有需要的话,我们可以直接在实例上调用 方法并访问其所有公有数据属性。

实例对象就是类中描述的所有特性的一份副本。类意味着复制。

继承

定义好一个子类之后,相对于父类来说它就是一个独立并且完全不同的类。子类会 包含父类行为的原始副本,但是也可以重写所有继承的行为甚至定义新行为。

多态

任何方法都可以引用继承层次中高层的方法(无论高层的方法名和当前方法名是否相同)。这个技术被称为多态。

多态的另一个方面是,在继承链的不同层次中一个方法名可以被多次定义,当调用方法时会自动选择合适的定义。

JavaScript中的类

事实上JavaScript中只有对象,并不存在可以被实例化的”类“,一个对象并不会被复制到其他对象,他们会被关联起来

在JavaScript中模拟类是得不尝试的,可能会埋下更多的隐患。

4.4. 原型

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

对于默认的[[get]]操作来说,如果无法在对象本身找到需要的属性,就会继续访问对象的[[Prototype]]引用的对象B。

如果在对象B上也无法找到对应的属性,就会继续访问对象B的的[[Prototype]]引用的对象C,如此持续直到找到匹配的属性名(返回对应的属性值)或者查找完整条[[Prototype]]链(返回undefined)。

属性的设置和屏蔽

在于原型链上层时myObject.foo = "bar"会出现的三种情况。

  1. 如果在 [[Prototype]] 链上层存在名为 foo 的普通数据访问属性,并且没 有被标记为只读(writable:false),那就会直接在 myObject 中添加一个名为 foo 的新 属性,它是屏蔽属性。
  2. 如果在 [[Prototype]] 链上层存在 foo,但是它被标记为只读(writable:false),那么 无法修改已有属性或者在 myObject 上创建屏蔽属性。如果运行在严格模式下,代码会 抛出一个错误。否则,这条赋值语句会被忽略。总之,不会发生屏蔽。
  3. 如果在 [[Prototype]] 链上层存在 foo 并且它是一个 setter,那就一定会 调用这个 setter。foo 不会被添加到(或者说屏蔽于)myObject,也不会重新定义 foo 这 个 setter。

换句话说,并不是只要赋值就会在当前对象上添加属性并屏蔽原型链上的属性(只有上面第一种情形会出现)

构造函数的本质

JavaScript中的所有函数默认都会拥有一个名为prototype的属性,他会指向另外一个对象。

通过对函数Foo进行构造调用时(new调用),会创建一个新的对象,并将该对象内部的[[prototype]]连接,关联到Foo.prototype指向的那个对象。

Foo.prototype.constructor = Foo

可以通过在Foo.prototype上添加公共的属性和方法,然后实例对象都可以访问这些属性和方法了。(我们可以直接将Foo.prototype修改为另外一个对象,这样就会丢失constructor方法)

但是需要注意的是,在JavaScript中,我们并不会将一个对象(”类“)复制到另一个对象(”实例“),而只是将新对象与函数的原型对象关联起来。这个机制被称为原型继承

”继承“这个词语难免让人疑惑,因为继承意味着复制操作,JavaScript(默认)并不会复制对象属性。相反,JavaScript 会在两 个对象之间创建一个关联,这样一个对象就可以通过委托访问另一个对象的属性和函数。

委托这个术语可以更加准确地描述 JavaScript 中对象的关联机制。

再次强调, JavaScript 中的继承机制有一个核心区别,那就是不会进行复制,对象之间是通过内部的 [[Prototype]] 链关联的。

4.5. 原型链

参考Object.create文档

Object.create()方法创建一个新对象,使用现有的对象来提供新创建的对象的__proto__

也就是说,通过create接口,我们可以关联某个对象的[[prototype]]到另一个对象上,从而在执行[[get]]委托原型对象进行查询。

现在我们知道了,[[Prototype]]机制就是存在于对象中的一个内部链接,它会引用其他对象。

通常来说,这个链接的作用是:如果在对象上没有找到需要的属性或者方法引用,引擎就 会继续在 [[Prototype]] 关联的对象上进行查找。同理,如果在后者中也没有找到需要的 引用就会继续查找它的 [[Prototype]],以此类推。

这一系列对象的链接被称为原型链。原型链的本质也就是对象之间的关联关系。

委托行为意味着某些对象在找不到属性或者方法引用时会把这个请求委托给另一 个对象。

5. 小结

这本书剖析了JavaScript中几个最重要的基础问题:作用域、闭包、this指向和原型链。

周末两天时间重新阅读了这本书,对其中不少知识点有了更深刻的认识,不单单是为了面试和刷题,对于写JavaScript代码也是极有好处的哈哈。

一个web项目的总结 《网络是怎样连接的》读书笔记