你不知道的 JavaScript

作者: shaokang 时间: November 8, 2023字数:7286

引言

在我三年前阅读《你不知道的 JavaScript》这本书的时候,有些知识点就像刻在石头上一样,深深地印在了我的脑海中。然而,时间的流逝使得我逐渐忘记了部分内容,现在我决定重新阅读这本书,并在这个过程中留下这份读书笔记。

通过这个读书笔记,我希望能够记录下自己的理解和感悟,以及在温故知新的过程中产生的新认识。

开始这次重拾知识的旅程~

作用域和闭包

简而言之:存储变量的规则称为作用域。

编译原理

传统的编译分为三个阶段:

  • 分词/词法分析
    将字符串组成的字符串分解成有意义的代码块(token)
  • 解析/语法分析
    将词法单元流转换成一个由元素逐级嵌套所组成的代码程序语法结构的树(AST)。
  • 代码生成 将 AST 转换为可执行代码的过程。

而 JS 引擎编译过程远比这三个步骤复杂。

作用域

理解作用域

先来认识这三种角色:

  • 引擎:负责整个 JS 程序的编译及执行过程
  • 编译器:负责语法分析及代码生产
  • 作用域:负责收集并维护所有声明的标识符组成的一些列查询,确认当前执行代码对这些标识符的访问权限

LHS 和 RHS 的含义:

  • LHS:”赋值操作的目标是谁”。一般对变量赋值,这个变量是左侧查询。
  • RHS: “谁是赋值操作的源头”。一般定义的变量,这个变量需要进行右侧查询。

简而言之,对变量进行赋值,会使用 LHS 查询,获取变量的值,就会使用 RHS 查询

一个例子:

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

最后一行 foo(..) 函数的调用需要对 foo 进行 RHS 引用;
代码中隐式的 a = 2 操作,给参数 a(隐式地)分配值,需要进行一次 LHS 查询;
对 a 进行的 RHS 引用,并且将得到的值传给了 console.log(..);

作用域嵌套

LHS 和 RHS 查询都会在当前执行作用域中开始,没有找到的话,就会向上级作用域继续查找目标标识符,这样每次上升一级作用域,最后抵达全局作用域(顶层),无论找到或没找到都将停止。

不成功的 RHS 引用会导致抛出 ReferenceError 异常。不成功的 LHS 引用会导致自动隐式 地创建一个全局变量(非严格模式下),该变量使用 LHS 引用的目标作为标识符,或者抛出 ReferenceError 异常(严格模式下)。

词法作用域

词法作用域就是定义在词法阶段的作用域。换句话说,词法作用域是由你在写代码时将变量和块作用域写在哪里来决定的。

作用域查找

作用域查找始终从运行时所处的最内部作用域开始,逐级向外或者说向上进行,直到遇见第一个匹配的标识符为止。无论函数在哪里被调用,也无论它如何被调用,它的词法作用域都只由函数被声明时所处的位置决定。

作用域是在一开始声明时就决定好的,这个过程在编译时就完成了,在执行代码过程时只需要去拿作用域。

欺骗词法

上面说了词法作用域完全由写代码期间函数所声明的位置来定义,怎样才能在运行时来“修改”(也可以说欺骗)词法作用域呢?– eval 和 with。

欺骗词法作用域往往会导致性能下降。

eval

eval(..) 可以在运行期修改书写期的词法作用域。

function foo(str, a) {
    eval(str); // 欺骗!
    console.log(a, b);
}
var b = 2;
foo('var b = 3;', 1); // 1, 3

严格模式的程序中,eval(..) 在运行时有其自己的词法作用域,意味着其中的声明无法修改所在的作用域。

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

with

with 接收一个对象,并对其作用域进行查询。

function foo(obj) {
    with (obj) {
        a = 2;
    }
}
var o1 = {
    a: 3,
};
var o2 = {
    b: 3,
};
foo(o1);
console.log(o1.a); // 2 ——找到 o1.a 属性
foo(o2);
console.log(o2.a); // undefined ——没找到 o2.a 属性
console.log(a); // 2 ——不好,a 被泄漏到全局作用域上了!

注意到一个奇怪的副作用,找不到 o2.a 属性,对 a = 2 赋值操作会创建了一个全局的变量 a。这 是怎么回事?
with 可以将一个没有或有多个属性的对象处理为一个完全隔离的词法作用域,因此这个对象的属性也会被处理为定义在这个作用域中的词法标识符。

性能

JavaScript 引擎会在编译阶段进行数项的性能优化。其中有些优化依赖于能够根据代码的词法进行静态分析,并预先确定所有变量和函数的定义位置,才能在执行过程中快速找到标识符。

而 eval(..) 和 with 会在运行时修改或创建新的作用域,所有的优化可能都是无意义的。

函数作用域和块作用域

函数作用域

函数作用域的含义是指,属于这个函数的全部变量都可以在整个函数的范围内使用及复用(事实上在嵌套的作用域中也可以使用)。这种设计方案是非常有用的,能充分利用 JavaScript 变量可以根据需要改变值类型的“动态”特性。

函数作用域可以隐藏变量、规避冲突.

块作用域

块作用域指的是变量和函数不仅可以属于所处的作用域,也可以属于某个代码块(通常指 { .. } 内部)。

从 ES3 开始,try/catch 结构在 catch 分句中具有块作用域。

let: 不会在块作用域中进行提升。 const:声明的变量不可修改。

变量提升

变量声明 var a = 2,JS 引擎会将其看作两个单独声明 var a 和 a = 2,第一个是编译阶段的任务,第二个是执行阶段的任务。

  • 函数声明和变量声明会被提升,而函数会首先被提升。
  • 函数表达式不会被提升。
  • 重复的变量声明会被忽略掉,但出现在后面的函数声明还是可以覆盖前面的。

作用域闭包

当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数在当前词法作用域之外执行。这个函数持有对词法作用域的引用,这个引用就叫闭包。

模块

闭包可以用来实现模块。

function CoolModule() {
    var something = 'cool';
    var another = [1, 2, 3];
    function doSomething() {
        console.log(something);
    }
    function doAnother() {
        console.log(another.join(' ! '));
    }
    return {
        doSomething: doSomething,
        doAnother: doAnother,
    };
}

这个模式被称为模块。

模块有两个主要特征:(1)为创建内部作用域而调用了一个包装函数;(2)包装函数的返回值必须至少包括一个对内部函数的引用,这样就会创建涵盖整个包装函数内部作用域的闭包。

动态作用域

动态作用域并不关心函数和作用域是如何声明以及在何处声明的,只关心它们从何处调用。词法作用域是在写代码或者说定义时确定的,而动态作用域是在运行时确定的。

记住 JavaScript 中的作用域就是词法作用域。

this

this 提供了一种更优雅的方式来隐式“传递”一个对象引用,因此可以将 API 设计得更加简洁并且易于复用。随着你的使用模式越来越复杂,显式传递上下文对象会让代码变得越来越混乱,使用 this 则不会这样。

含义

当一个函数被调用时,会创建一个活动记录(有时候也称为执行上下文)。这个记录会包含函数在哪里被调用(调用栈)、函数的调用方法、传入的参数等信息。this 就是记录的其中一个属性,会在函数执行的过程中用到。

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

绑定规则

优先级: new 绑定 > 显式绑定 > 隐式绑定 > 默认绑定

默认绑定

独立函数调用时,this 绑定的是全局对象。

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

隐式绑定

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

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

隐式丢失:被隐式绑定的函数会丢失绑定对象,从而把 this 绑定到全局对象或者 undefined 上。

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

var obj = {
    a: 2,
    foo: foo,
};

var bar = obj.foo; // 函数别名
var a = 'oops, global'; // 全局变量
bar(); // "oops, global"

显式绑定

通过 call 和 apply 方法,我们可以在函数调用时强制指定 this 绑定的对象。

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

var obj = {
    a: 2,
};
foo.call(obj); // 2

硬绑定:通过 bind 方法,我们可以创建一个函数,使它隐式绑定到某个对象。

new 绑定

new 绑定:当一个函数用 new 来调用时,this 绑定的是新创建的对象。

空绑定

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

// 显式绑定到 null 的应用
// 使用 apply(..) 来展开一个数组,并当做参数传入一个函数

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

foo.apply(null, [2, 3]); // a : 2, b : 3

// 使用 bind(..) 对参数进行柯里化(预先设置一些参数)

var bar = foo.bind(null, 2);

bar(3); // a : 2, b : 3

更安全的做法 — 创建一个空对象进行绑定:

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

var EMPTY = Object.create(null);

foo.apply(EMPTY, [2, 3]); // a : 2, b : 3

var bar = foo.bind(EMPTY, 2);
bar(3); // a : 2, b : 3

箭头函数

箭头函数不使用 this 的四种标准规则,而是根据外层(函数或者全局)作用域来决定 this。

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

var bar = foo.call(obj1);
bar.call(obj2); // 2,不是 3!

foo() 内部创建的箭头函数会捕获调用时 foo() 的 this。由于 foo() 的 this 绑定到 obj1,bar(引用箭头函数)的 this 也会绑定到 obj1,箭头函数的绑定无法被修改。(new 也不行!)。

总之,箭头函数会继承外层函数调用的 this 绑定,且箭头函数的绑定无法被修改。

对象

语法

  • 声明形式。文字自变量的形式 {key: value}。
  • 构造形式。new Object()。

类型

基本类型:string、boolean、number、null、undefined、symbol、bigint。

内置对象:Sting、Boolean、Number、Object、Array、Function、Date、RegExp、Error。

属性

在对象中,属性名永远都是字符串

  • 数据属性

    • configurable:能否通过 delete 删除属性而重新定义属性;能否修改属性的特性;能否把属性修改为访问器属性。默认为 true
    • enumerable:能否通过 for-in 循环返回这个属性。默认为 true
    • writable:能否修改这个属性的数据值。默认为 true
    • value:属性的数据值。默认为 undefined
  • 访问器属性:包含一对 getter 和 setter 函数。有如下 4 个特性:

    • configurable:能否通过 delete 删除属性而重新定义属性;能否修改属性的特性;能否把属性修改为数据属性。默认为 true
    • enumerable::能否通过 for-in 循环返回这个属性。默认为 true
    • get:在读取属性时调用的函数。默认为 undefined
    • set:在写入属性时调用的函数。默认为 undefined

修改属性

Object.defineProperty()

添加一个属性或者修改一个已有属性(如果它是 configurable):

var myObj = {};

Object.defineProperty(myObj, 'a', {
    value: 2,
    writable: false, // 只读
    configurable: true,
    enumerable: true,
});

myObj.a; // 2

只有 configurabletrue,就可以使用 defineProperty(..)方法来修改属性描述符。 当configurablefalse时,无法使用defineProperty,且无法删除该属性

在调用 Object.defineProperty() 时,如果不指定,configurable, enumerable, writable 默认值都是 false

configurable 修改为 false 是单向操作,无法撤销

例外:即使属性为 configurable: false,我们还是可以把 writable 的状态由 true 改为 false,但无法由 false 改为 true

复制对象

浅复制 Object.assign(..)

第一个参数是目标对象,之后可以跟一个或多个源对象。它会遍历一个或多个源对象的所有可枚举(enumerable)的自有键(owned key),并把它们复制到目标对象,最后返回目标对象:

不变性

  • 对象常量

不可修改、重定义或者删除

writable: false

configurable: false

  • 禁止扩展

禁止一个对象添加新属性

Object.preventExtensions(obj);

var myObj = {
    a: 2,
};

Object.preventExtensions(myObj);
myObj.b = 3;
myObj.b; // undefined
  • 密封

禁止扩展,也不能重新配置或者删除任何现有属性(但可以修改属性的值)

Object.seal(...)

相当于 Object.preventExtensions(obj) + configurable: false

  • 冻结

密封一个对象,并把所有“数据访问”属性标记为 writable: false

Object.freeze(..)

相当于 Object.seal(...) + writable: false

存在性

判断对象中是否存在某个属性:

  • in: 检查对象与 Prototype 原型链,无所谓 enumerable
  • for..in: 检查对象与 Prototype 原型链,且enumerable = true
  • obj.hasOwnProperty(..): 只检查对象
  • obj.propertyIsEnumerable(..): 只检查对象,且 enumerable = true
  • Object.keys(obj): 只检查对象,返回一个数组,包含所有可枚举属性
  • Object.getOwnPropertyName(obj): 只查找对象直接包含的属性,返回一个数组,无论是否可枚举

遍历

  • forEach(..)

遍历数组中的所有值并忽略回调函数的返回值

  • every(..)

一直运行到回调函数返回 false

  • some(..)

一直运行到回调函数返回 true

  • for..in..

无法直接获取属性值,因为它遍历的是对象中所有可枚举属性,需要手动获取属性值

  • for..of..

遍历数组和有迭代器的对象

const myArray = [1, 2, 3];
for (const v of myArray) console.log(v);

const myObj = {
    a: 1,
    b: 2,
};
for (const key in myObj) console.log(key);