ES6 & ESNext 规范及编译工具简介

239 阅读28分钟

ES6 & ESNext 规范及编译工具简介

一. ES6 & ESNext

1.变量定义新形式

从 ES6 开始,JavaScript 引入了 letconst 关键字来定义变量,这是 ECMAScript 新增的两种声明变量的方式,相较于 var 关键字具有更多的优势。

Q: var 定义变量有什么问题?

  1. 变量提升;
  2. 无法形成词法作用域;
  3. 可以随意篡改变量值,重复声明;
1.1 let

let 关键字用于声明一个块级作用域的局部变量,可以将 let 声明的变量重新赋值。let 声明的变量只在代码块内部有效。

举个例子,如下:

if (true) {
    let i = 1;
    console.log(i); // 输出 1
}
console.log(i); // 报错,i 未定义

我们再来看一道 let,var 经典面试题:

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

很显然,我们最终打印的结果是5个5,因为退出循环时变量i已经被赋值为5,执行超时逻辑时,每个回调函数都是指向那个已经变成5的i变量。那么如何更改上面的代码使其挨个输出 0,1,2,3,4呢?

我们如果可以在循环的作用域中间单独存储 i 变量,就可以解决这个问题了!

解决方案1:闭包;

for (var i = 0; i < 5; i++) {
    (function (ii) {
        setTimeout(() => {
            console.log(ii);  // 0 1 2 3 4
        })
    })(i)
}

使用闭包形成独立的作用域链,为每一层循环都创建了一个函数作用域,并把每一层循环的 i 的值作为参数传入。

解决方案2:var变成let

for (let i = 0; i < 5; i++) {
    setTimeout(() => {
        console.log(i);      // 0 1 2 3 4
    })
}

使用 let 声明的变量 i 有块级作用域,在每一次循环中都会重新定义并赋值,避免了使用 var 声明变量可能导致的变量共享问题。

另一个特性是,let不允许在相同作用域内,重复声明同一个变量。

// 报错
function func() {
  let a = 10;
  var a = 1;
}

// 报错
function func() {
  let a = 10;
  let a = 1;
}

因此,不能在函数内部重新声明参数。

function func(arg) {
  let arg;
}

func()    // 报错

function func(arg) {
  {
    let arg; // 在函数内部的另一个代码块里可以重新声明
  }
}

func()    // 不报错
1.2 const

const 关键字用于声明一个块级作用域只读常量,跟 let 特性类似,只是 const多了一层常量处理;一旦 const 声明了某个变量,就不能使用赋值语句改变它的值。

举个例子,如下:

const PI = 3.1415926535;
PI = 3; // 报错,无法修改常量

正确的使用方式:

const PI = 3.1415926535;

如果在工作中,定义的变量后续基本不用改变,那就首选用 const 来声明。 还有,常量必须在声明时进行初始化,不能留到以后赋值。

const a;
a = 123;  // 报错,SyntaxError: Missing initializer in const declaration

对于const来说,只声明不赋值,就会报错,正确的使用方式:

const a = 123; 

使用const声明对象时,可以对属性的值进行修改:

const user = {
    name: "hll",
    age: 18,
    gender: "女"
};

user.name = "Mary";
console.log(user);   // { name: "Mary", age: 18, gender: "女" }

// 冻结对象
Object.freeze(user);

user.age = 20;  // 冻结对象后,age修改无效
console.log(user); // { name: "Mary", age: 18, gender: "女" }

使用 const 声明对象属性可以避免误修改,同时使用 Object.freeze() 方法可以将对象冻结,防止意外修改对象属性。

1.3 与 var 的对比

存储位置

  • var:变量存储在当前执行上下文中的全局变量存储区;
  • let:变量存储在词法环境(词法分析、语义分析、代码生成);

特性

  • var:变量提升;
  • let:存储在词法环境,不存在提升,形成暂时性死区(词法环境);
  • const:跟 let 特性类似,只是 const 多了一层常量处理;

顶层对象的属性

顶层对象,在浏览器环境指的是window对象,在 Node 指的是global对象。ES5 之中,顶层对象的属性与全局变量是等价的。

window.a = 1;
a   // 1

a = 2;
window.a   // 2

上面代码中,顶层对象的属性赋值与全局变量的赋值,是同一件事。

但是在ES6中规定,let命令、const命令、class命令声明的全局变量,不属于顶层对象的属性。也就是说,从 ES6 开始,全局变量将逐步与顶层对象的属性脱钩。

var a = 1;
// 如果在 Node 的 REPL 环境,可以写成 global.a
// 或者采用通用方法,写成 this.a
window.a // 1

let b = 1;
window.b    // undefined

上面代码中,全局变量avar命令声明,所以它是顶层对象的属性;全局变量blet命令声明,所以它不是顶层对象的属性,返回undefined

需要这些新增的变量定义形式主要原因如下:

  1. 更加安全

使用 letconst 可以有效地避免一些变量作用域混淆的问题。通过使用块级作用域,我们可以让变量只在指定代码块内部有效,避免了不必要的变量污染和冲突。

  1. 更加简洁

使用 letconst 可以大量减少代码量,并且更加易于维护。在使用 var 时,由于变量作用域问题,经常需要添加额外的语句进行变量定义、检查和清除等操作,而使用 letconst 可以直接在代码中进行定义和使用,更加简洁和高效。

  1. 更加规范

使用 letconst 可以使代码更加规范,让代码更好读懂、易于维护。随着 JavaScript 的逐渐发展,代码规范性和可读性越来越重要,letconst 关键字的引入正是为了更好地实现这一目标。

综上所述,letconst 关键字可以使 JavaScript 更加安全、简洁和规范,有效地解决了以往 JavaScript 变量定义存在的一些问题,例如变量作用域混乱,变量共享、变量易被修改等情况。使用 letconst 可以使代码更加健壮、可维护,提升开发效率和代码质量。

1.4 深入理解原理

底层实现上,letconst 的工作方式是通过JavaScript引擎来实现的。在 JavaScript 引擎中,每一个变量都会被封装在一个称为“变量对象”的容器中,这个对象包含了所有当前上下文中定义的变量与函数。变量对象类似于一个键/值对的容器,其中键是变量名,值是变量的值。在 JavaScript引擎中,使用 letconst 定义变量,实际上是将该变量定义在了一个块级作用域中,而块级作用域是由编译器在编译阶段中实现的。

{
    // 代码片段,在程序中,这就是一块儿作用域
}

image.png

具体过程如下:

whiteboard_exported_image.png

1.4.1 let 的底层实现过程
  1. 编译阶段

在代码编译阶段,编译器会扫描整个函数体(或全局作用域),查找所有使用 let 定义的变量,为这些变量生成一个词法环境(LexicalEnvironment)并将其保存在作用域链中。

  1. 进入执行上下文

当进入执行块级作用域(包括 for、if、while 和 switch 等语句块)后,会创建一个新的词法环境。如果执行块级作用域中包含 let 变量声明语句,这些变量将被添加到这个词法环境的环境记录中。

  1. 绑定变量值

运行到 let 定义的变量时,JavaScript 引擎会在当前词法环境中搜索该变量。首先在当前环境记录中找到这个变量,如果不存在变量,则向包含当前环境的外部环境记录搜索变量,直到全局作用域为止。如果变量值没有被绑定,JavaScript 引擎会将其绑定为 undefined,否则继续执行其他操作。

  1. 实现块级作用域

使用 let 定义变量时,在运行时不会在当前作用域之外创建单独的执行上下文,而是会创建子遮蔽(shadowing)新环境。在子遮蔽的词法环境中,变量的值只在最接近的块级作用域内有效。

为了让我们更容易地理解let的底层原理,我们来看下面这个例子:

let cname = 'test1';

function c() {
    console.log(cname);
    let cname = 'test2'; 

c()    // 报错:Uncaught ReferenceError: Cannot access 'cname' before initialization

上面代码中,存在全局变量cname,但是块级作用域内let又声明了一个局部变量cname,导致后者绑定这个块级作用域,所以在let声明变量前,打印cname的值会报错。

ES6 明确规定,如果区块中存在letconst命令,这个区块对这些命令声明的变量,从一开始就形成了封闭作用域。凡是在声明之前就使用这些变量,就会报错。

总之,在代码块内,使用let命令声明变量之前,该变量都是不可用的。这在语法上,称为暂时性死区。 在上面代码中,let声明变量canme之前,都属于变量cname死区

原理是编译器会预先扫描整个全局作用域查找所有使用 let 定义的变量,为这些变量(即c函数内部的cname)生成一个词法环境并将其保存在作用域链中,当我们在c函数内部访问cname时,由于编译器已经预先为cname开辟一个空间进行初始化,但却没有绑定值,所以浏览器会直接报错!!!

如果我们想访问到函数作用域外的cname,直接注释掉内部的let声明即可:

let cname = 'test1';

function c() {
    console.log(cname);
    // let cname = 'test2';    // 注释掉可以访问到外层cname,不注释的话会报错!
}

c()    // 'test1'

暂时性死区也意味着typeof不再是一个百分之百安全的操作。我们都知道typeof有一个安全机制,可以对未被定义变量兜底,但是一旦使用了let声明,typeof方法也会报错:

console.log(typeof cname);  // 报错
console.log(typeof cname1234567);  // undefined

let cname = 'test'

上面代码中,变量cname使用let命令声明,所以在声明之前,都属于cname死区,只要用到该变量就会报错。因此,typeof运行时就会抛出一个ReferenceError

作为比较,如果一个变量根本没有被声明,使用typeof反而不会报错。

总结:使用let声明变量时,只要变量在还没有声明前使用,就会报错。暂时性死区的本质就是,只要一进入当前作用域,所要使用的变量就已经存在了,但是不可获取,只有等到声明变量的那一行代码出现,才可以获取和使用该变量。

1.4.2 const 的底层实现过程

const具有与let相同的底层实现原理,区别在于const定义的变量被视为常量(在赋值之后无法更改),因此变量声明时必须初始化

此外应该注意的是,使用const声明的对象是可以修改属性的,在定义const对象时,实际上保证的,并不是变量的值不得改动,而是变量指向的那个内存地址所保存的数据不得改动。对象本身是常量,而不是对象的属性。只有对象本身不能被修改,而对象包含的属性可以任意修改。

const foo = {};

// 为 foo 添加一个属性是可以的
foo.prop = 123;

// 将 foo 指向另一个对象,就会报错
foo = {};   // TypeError: "foo" is read-only

2. 面向对象编程 - class 语法

在ES6中,class (类) 作为对象的模板被引入,可以通过 class 关键字定义

class的本质是 function。它可以看作是一个语法糖,它的绝大部分功能 ES5 都可以做到,新的class写法只是让对象原型的写法更加清晰、更像面向对象编程的语法而已。

2.1 类、构造函数

使用 class 关键字来定义类时,在内部会创建一个特殊的函数,称为构造函数(constructor) 。构造函数用于在创建对象时初始化对象的属性,类似于传统的基于原型的构造函数。

我们可以通过下面的代码来理解类的构造函数:

// es6写法如下:
class Person {
    // 属性写在constructor里面
    constructor(name, age) {
        this.name = name;
        this.age = age;
    }
    // 方法
    sayName() {
        return 'I am ' + this.name;
    }
}


let person = new Person('hll', 25);
console.log(person.name, person.age);

// es5写法如下:
function Person(name, age) {
    this.name = name;
    this.age = age;
    this.sayName = function () {
        return 'I am ' + this.name;
    }
}

class中定义的属性和方法分别定义在这个构造函数的 prototype 属性中,并且与原型方式不同的是,通过类的内部所有定义的方法,都是不可枚举的,因此无法使用 for...in 循环遍历类原型上的属性和方法。

class Person {
  constructor(name, age) {
    // ...
  }

  sayName() {
    // ...
  }
}

Object.keys(Person.prototype)    // []
Object.getOwnPropertyNames(Person.prototype) // ["constructor", "sayName"]

上面代码中,sayName方法是Person类内部定义的方法,它是不可枚举的,这一点与 ES5 的行为不一致。

如果代码采用 ES5 的写法,sayName方法就是可枚举的:

var Person = function (name, age) {
  // ...
};

Person.prototype.sayName = function () {
  // ...
};

Object.keys(Person.prototype)  // ["sayName"]
Object.getOwnPropertyNames(Person.prototype)  // ["constructor", "sayName"]
2.1.1 可枚举属性

在 JavaScript 中,对象的属性分为两种类型:可枚举属性不可枚举属性。可枚举属性是指那些可以通过 for...in 循环遍历到的属性,不可枚举属性则是指那些不能通过 for...in 循环遍历到的属性。

如果想要将某个属性设置为不可枚举属性,可以使用 Object.defineProperty() 方法或 Object.defineProperties() 方法,对属性的 enumerable 特征进行设置。示例如下:

const obj = {
    name: 'hll',
    age: 18,
    id: 0
}


Object.defineProperty(obj, 'id', {
    value: 1,
    enumerable: false
})

for (const objKey in obj) {
    // for...in...遍历不到id
    console.log(obj[objKey]);
}

在这个示例中,我们使用 Object.defineProperty() 方法将 obj 对象的 id 属性设置为不可枚举属性。最终,for...in 循环语句只会输出 obj 对象中的可枚举属性。

通常情况下,对象的所有普通属性和方法都是可枚举的,例如:

const obj = {
  prop1: 'value1',
  prop2: 'value2',
  func1: function() {}
};

for(let key in obj) {
  console.log(key);   // 'prop1', 'prop2', 'func1'
}

在这个示例中,prop1 和 prop2 是 obj 对象的可枚举属性,而 func1 是 obj 对象的可枚举方法。

需要注意的是,在 ES6 中,通过 class 定义的对象默认不可枚举,就算没有显式地设置 enumerable 属性。这与使用 Object.defineProperty() 方法在 ES5 中设置不可枚举属性的方式不同。如果需要将 class 中的某个属性或方法设置为可枚举属性,需要使用 Object.defineProperty() 方法来进行设置。

2.2 继承

在 JavaScript 中,继承是通过类的 prototype 属性实现的。在定义类时,可以使用 extends 关键字来继承其他的类:

class Student extends Person {
    constructor(name, age, grade) {
        super(name, age);
        this.grade = grade;
    }
}


let student = new Student('hll', 18, 1);
console.log(student.name, student.age, student.grade);   // hll 18 1

这段代码中,子类 Student 继承了父类 Person 的构造函数方法并添加了自己的属性和方法。super在这里表示父类的构造函数,用来新建一个父类的实例对象。

Object.getPrototypeOf()方法可以用来从子类上获取父类:

class Person { ... }

class Student extends Person { ... }

Object.getPrototypeOf(Student) === Person   // true

因此,可以使用这个方法判断一个类是否继承了另一个类。

2.2.1 super关键字

super这个关键字,既可以当作函数使用,也可以当作对象使用。在这两种情况下,它的用法完全不同。

  1. 当作函数使用

super作为函数调用时,代表父类的构造函数。ES6 要求,子类的构造函数必须执行一次super()函数。

class A {}

class B extends A {
  constructor() {
    // 调用父类的构造函数
    super();
    this.id = 0;
  }
}

调用super()的作用是形成子类的this对象,把父类的实例属性和方法放到这个this对象上面。子类在调用super()之前,是没有this对象的,任何对this的操作都要放在super()的后面。

  1. 当作对象使用 super作为对象时,在普通方法中,指向父类的原型对象;在静态方法中,指向父类。
class A {
  p() {
    return 0;
  }
}

class B extends A {
  constructor() {
    super();
    console.log(super.p());    // 0
  }
}

let b = new B();

上面代码中,子类B当中的super.p(),就是将super当作一个对象使用。这时,super在普通方法之中,指向A.prototype,所以super.p()就相当于A.prototype.p()

这里需要注意,由于super指向父类的原型对象,所以定义在父类实例上的方法或属性,是无法通过super调用的。

class A {
  constructor() {
    this.p = 2;
  }
}

class B extends A {
  get m() {
    return super.p;
  }
}

let b = new B();
b.m // undefined

上面代码中,p是父类A实例的属性,super.p就引用不到它。

如果属性定义在父类的原型对象上,super就可以取到。

class A {}
A.prototype.x = 2;

class B extends A {
  constructor() {
    super();
    console.log(super.x)   // 2
  }
}

let b = new B();

上面代码中,属性x是定义在A.prototype上面的,所以super.x可以取到它的值。

如果super作为对象,用在静态方法之中,这时super将指向父类,而不是父类的原型对象。

class Parent {
    static log(msg) {
        console.log('class', msg);
    }

    log(msg) {
        console.log('instance', msg);
    }
}

class Child extends Parent {
    static log(msg) {
        super.log(msg);
    }

    log(msg) {
        super.log(msg);
    }
}

// 静态方法,super 指向 Parent
Child.log(1); //   class 1

var child = new Child();
// 实例方法, super 指向父类的原型对象
child.log(2); //   instance 2

上面代码中,super在静态方法之中指向父类,在普通方法之中指向父类的原型对象。

注意,使用super的时候,必须显式指定是作为函数、还是作为对象使用,否则会报错。

class A {
}

class B extends A {
    constructor() {
        super()
        
        console.log(super); 
        // 报错:Uncaught SyntaxError: 'super' keyword unexpected here
    }
}
2.2.2 类的 prototype 属性和__proto__属性

大多数浏览器的 ES5 实现之中,每一个对象都有__proto__属性,指向对应的构造函数的prototype属性。Class 作为构造函数的语法糖,同时有prototype属性和__proto__属性,因此同时存在两条继承链。

  • 子类的__proto__属性,表示构造函数的继承,总是指向父类;

  • 子类prototype属性的__proto__属性,表示方法的继承,总是指向父类的prototype属性;

class A {
}

class B extends A {
}

B.__proto__ === A   // true
B.prototype.__proto__ === A.prototype   // true

上面代码中,子类B__proto__属性指向父类A,子类Bprototype属性的__proto__属性指向父类Aprototype属性。

那子类实例的__proto__属性呢?我们来看下面这个示例:

var a = new A();
var b = new B();

b.__proto__ === a.__proto__;   // false
b.__proto__.__proto__ === a.__proto__;   // true

子类实例的__proto__属性的__proto__属性,指向父类实例的__proto__属性。也就是说,子类的原型的原型,是父类的原型。

2.3 静态方法和属性

类相当于实例的原型,所有在类中定义的方法,都会被实例继承。如果在一个方法前,加上 static 关键字,就表示该方法不会被实例继承,而是直接通过类来调用,这就称为“静态方法”。

class Person {
    static species = "human";

    static saySpecies() {
        console.log(`We are ${this.species}.`);
    }
}

let person = new Person()
// 访问实例
console.log(person.species);   // undefined
person.saySpecies()  // 报错 TypeError: person.saySpecies is not a function

// 访问属性
console.log(Person.species);   // human
Person.saySpecies()  // We are human

这段代码中定义了一个静态方法和一个静态属性,可以通过类本身直接调用静态方法和属性。如果在实例上调用静态方法,会抛出一个错误,表示不存在该方法。

注意,如果静态方法包含this关键字,这个this指的是类,而不是实例。

class Foo {
  static bar() {
    this.baz();
  }
  static baz() {
    console.log('hello');
  }
  
  baz() {
    console.log('world');
  }
}

Foo.bar()      // hello
2.4 取值函数(getter)和 存值函数(setter)

与 ES5 一样,在“类”的内部可以使用getset关键字,对某个属性设置存值函数和取值函数,拦截该属性的存取行为。

class Person {
    constructor(name, age) {
        this._name = name;
        this._age = age;
    }

    get name() {
        return this._name.toUpperCase();
    }

    set name(val) {
        this._name = val;
    }

    set age(val) {
        if (val > 18 && val < 120) {
            this._age = val;
        } else {
            console.log("Invalid age value.");
        }
    }

    get age() {
        return this._age;
    }
}

let person = new Person('hll', 18)
console.log(person.name);   // hll
person.age = 121;          // Invalid age value.
console.log(person.age);   // 121

在类的实现中,gettersetter 其实是被定义在构造函数的 prototype 属性上,从而可以被该类的所有实例对象访问。

2.5 私有方法 和 私有属性

ES2022正式为class添加了私有属性,方法是在属性名之前使用#表示。

class Calc {
    // 定义私有属性
    #count = 0;

    get value() {
        console.log('Getting the current value!');
        return this.#count;
    }

    increment() {
        this.#count++;
        console.log(this.#count);  // 1
    }
}

let c = new Calc();
console.log(c.#counter);   
// 报错:Uncaught SyntaxError: Private field '#counter' must be declared in an enclosing class

c.increment();
// 1

上面示例中,在类的外部,读取或写入私有属性#count,都会报错。

另外,不管在类的内部或外部,读取一个不存在的私有属性,也都会报错。这跟公开属性的行为完全不同,如果读取一个不存在的公开属性,不会报错,只会返回undefined

class Calc {
    #count = 0;
    
    increment() {
        this.#count++;
        console.log(this.#Mycount);
    }
}

let c = new Calc();
c.increment();  
// 报错:Uncaught SyntaxError: Private field '#Mycount' must be declared in an enclosing class

上面示例中,#myCount是一个不存在的私有属性,不管在函数内部或外部,读取该属性都会导致报错。

这种写法不仅可以写私有属性,还可以用来写私有方法。

class Calc {
    #count1;
    #count2;

    constructor(count1, count2) {
        this.#count1 = count1;
        this.#count2 = count2;
    }

    #sum() {
        return this.#count1 + this.#count2;
    }

    printSum() {
        console.log(this.#sum());
    }
}

let c = new Calc(2, 3);
c.printSum();    // 5

另外,私有属性也可以设置 getter 和 setter 方法,也可以加上static关键字,表示这是一个静态的私有属性或私有方法。

2.6 class表达式

ES6 还引入了 class 表达式,可以通过这种表达式来创建函数对象。

const Person = class {
    constructor(name, age) {
        this.name = name;
        this.age = age;
    }

    sayHi() {
        console.log(`Hi, my name is ${this.name}, I'm ${this.age} years old.`);
    }
};

总体来说,JavaScript 的类本质上是一个函数,使用 class 关键字来声明类只是伪代码,这些代码最终都会被转成函数,并存在函数对象的属性和原型属性上,而 class 语法只是语法糖,是 JavaScript 引擎将 class 转化为了原型链实现的方式。

Class语法继承,最接近我们自己实现的哪种继承方式?
寄生+组合

3.模板字符串

JavaScript 模板字符串是 ES6 中新增的一种特殊的字符串语法,它允许嵌入表达式和变量,通过 ${} 拼接字符串和表达式,相比传统的字符串拼接来说,模板字符串更具可读性和可维护性。

3.1 模板字符串基础概念与用法

使用模板字符串时,需要使用反引号标识来定义字符串,并在字符串中使用 ${expression} 的方式来嵌入表达式和变量,可以使用多行方式来创建较长的字符串。

我们来看一下,通过使用模板字符串创建一个包括变量和表达式的字符串的实例:

let name = 'hll';
let age = 18;
let str = `My name is ${name}, I'm ${age} years old. I'm from China.`;
console.log(str);    // My name is hll, I'm 18 years old. I'm from China.
3.2 底层实现原理

模板字符串的实现原理,可以大致分为两个步骤:首先,JavaScript 引擎会将模板字符串解析成一个函数调用表达式;接着,这个表达式会被执行,并输出一个最终的字符串。

对于第一步,当 JavaScript 引擎解析模板字符串时,它会将特殊字符和变量值分割成多个参数,并将它们作为函数调用的参数传递给一个名为 Tagged Template 的函数。该函数的第一个参数是一个数组,其中包含原始模板字符串中的所有字符文字,除了所有插入字符。其余参数则是与模板字符串插值表达式相对应的插入值。

我们来看一下模版字符串的劈开机制:

function tagFn(...args) {
    console.log(args);
}

let a = 1;
let b = 2;

tagFn`hello${a}world${b}`    // [['hello',  'world',  '',]  1, 2]

也就是说,之前的示例可以被解析为如下调用:

tagFn`My name is ${name}, I'm ${age} years old. I'm from China.`
// [["My name is ",  ", I'm ",  " years old. I'm from China."],  name,  age]

那么如何模拟实现这个 tagFn 呢?

我们先看下 rest 参数的写法如下:

function tagFn(strings, value1, value2){
  // ...
}

// 等同于

function tagFn(strings, ...values){
  // ...
}

然后我们将各个参数按照原来的位置拼合回去:

function tagFn(strings, ...values) {
    // console.log(strings, values);
    let result = ''
    strings.forEach((item, i) => {
        result += item;
        if (i < values.length) {
            result += values[i]
        }
    })
    return result
}


let str1 = tagFn`hello${a}world${b}`
let str2 = tagFn`My name is ${name}, I'm ${age} years old. I'm from China.`
console.log(str1);    // hello1world2
console.log(str2);    // My name is hll, I'm 18 years old. I'm from China.

tagFn 是一个可被调用的函数,用于实现对模板字符串的自定义处理。我们可以通过这个函数对模板字符串和变量进行任意的处理和操作。也正是由于这种设计,模板字符串才能够像函数一样实现更加复杂的逻辑,比如计算、转换等操作。

3.3 tagged template

大家有使用或了解过 css-in-js 方案吗?

  • styled-components;
  • emotion(mui 使用的);
  • stitches:如果之前使用过 styled-components 的同学,我推荐大家去看看这个;
3.3.1 什么是 css-in-js

css-in-js是一种技术,而不是一个具体的库实现。简单来说css-in-js就是将应用的CSS样式写在JavaScript文件里面,而不是独立为一些css,scss 或 less之类的文件,这样你就可以在CSS中使用一些属于JS的诸如模块声明,变量定义,函数调用和条件判断等语言特性来提供灵活的可扩展的样式定义。css-in-js在React社区的热度是最高的,这是因为React本身不会管用户怎么去为组件定义样式,而Vue有属于自己的一套定义样式的方案。

styled-componentscss-in-js最热门的一个库,通过styled-components,你可以使用ES6的标签模板字符串语法,为需要styled的Component定义一系列CSS属性,当该组件的JS代码被解析执行的时候,styled-components会动态生成一个CSS选择器,并把对应的CSS样式通过style标签的形式插入到head标签里面。动态生成的CSS选择器会有一小段哈希值来保证全局唯一性来避免样式发生冲突。

如果没有了解或使用过的同学,可以看下面的这个案例:

// 给h1标签添加样式
const Tille = styled h1`
    font-size: 1.5em;
    test-align: center;
    color: red;
`

render(
    <Wrapper>
        <Title>
            Hello World!
        </Title>
    </Wrapper>
)
3.3.2 实现 css-in-js

那我们该如何封装一个这样的styled的方法呢?

const styled = {
    div: (args) => {
        const divDom = document.createElement('div');
        if(!args) {
            return divDom;
        }
        // 拆解传入参数
        let argArr = args[0].split(';');
        argArr.forEach(ele => {
            if(ele) {
                let [key,value] = ele.split(':');
                // console.log(key, value);
                // 对div设置样式
                divDom.style[key]=value;
            }
        })

        return divDom;
    },

    h1: (...args) => {
        console.log(args)
    }
}

const Div = styled.div`color: red;font-size: 20px;`
console.log(Div);  // <div style="color: red; font-size: 18px;"></div>

4. 解构语法

4.1 基础概念与原理

ES6 允许按照一定模式,从数组和对象中提取值,对变量进行赋值,这被称为解构(Destructuring)。

以前,为变量赋值,只能直接指定值。

let a = 1;
let b = 2;
let c = 3;

ES6 允许写成下面这样:

let [a, b, c] = [1, 2, 3];

上面代码表示,可以从数组中提取值,按照对应位置,对变量赋值。

本质上,这种写法属于“模式匹配”,只要等号两边的模式相同,左边的变量就会被赋予对应的值。

4.2 数组解构及底层原理

对数组解构的底层实现分为两个过程:

  • 第一步:使用取值函数(getter)读取数组中对应位置的值;
  • 第二步:将取得的值赋值给目标变量;

以上面的数组结构为例,JavaScript 引擎背后发生的事情如下:

const tempArray = [1, 2, 3];
const a = tempArray[0];
const b = tempArray[1];
const c = tempArray[2];
console.log(a, b, c);   // 1 2 3

对于数组解构而言,每个目标变量赋值的过程是独立的,它们并不会相互影响。

4.3 对象解构及底层原理

对象解构示例:

const {firstName: first, lastName: last, name: name} = {firstName: 'John', lastName: 'Doe'};
console.log(first);   // John
console.log(last);    // Doe
console.log(name);    // undefined

解构对象变量的过程与解构数组变量非常类似,JavaScript 引擎会遍历对象中的每一个属性,然后在解构表达式中查找同名的变量。

在上面例子中,解构对象并赋值给变量的过程可以理解为:

const tempObject = { firstname: 'John', lastname: 'Doe' };
const first = tempObject.firstname;
const last = tempObject.lastname;
console.log(first, last); // John Doe

对象的解构与数组有一个重要的不同。数组的元素是按次序排列的,变量的取值由它的位置决定;而对象的属性没有次序,变量必须与属性同名,才能取到正确的值。

let { bar, foo } = { foo: 'aaa', bar: 'bbb' };
console.log(foo)     // "aaa"
console.log(bar)     // "bbb"

let { baz } = { foo: 'aaa', bar: 'bbb' };
console.log(baz)     // undefined

上面代码的第一个例子,等号左边的两个变量的次序,与等号右边两个同名属性的次序不一致,但是对取值完全没有影响。第二个例子的变量没有对应的同名属性,导致取不到值,最后等于undefined

如果解构失败,变量的值等于undefined

let { foo } = { bar: 'baz' };
console.log(foo)    // undefined

上面代码中,等号右边的对象没有foo属性,所以变量foo取不到值,所以等于undefined

如果变量名与属性名不一致,必须写成下面这样:

let { foo: baz } = { foo: 'aaa', bar: 'bbb' };
console.log(baz) // "aaa"

let { first: f, last: l } = { first: 'hello', last: 'world' };
console.log(f)   // 'hello'
console.log(l)   // 'world'

这实际上说明,对象的解构赋值是下面形式的简写:

let { foo: foo, bar: bar } = { foo: 'aaa', bar: 'bbb' };

也就是说,对象的解构赋值的内部机制,是先找到同名属性,然后再赋给对应的变量。真正被赋值的是后者,而不是前者。

let { foo: baz } = { foo: 'aaa', bar: 'bbb' };
console.log(baz)    // "aaa"
console.log(foo)    // error: foo is not defined

上面代码中,foo是匹配的模式,baz才是变量。真正被赋值的是变量baz,而不是模式foo

4.4 嵌套解构及底层原理
4.4.1 数组嵌套解构

数组嵌套解构示例:

const [a, [b, [c]]] = [1, [2, [3]]];
console.log(a, b, c);   // 1 2 3

嵌套解构是指解构表达式中还包含其他的数组或对象解构。嵌套解构的底层实现和单层解构类似,操作每个元素或属性时都需要依次使用取值函数(getter)进行操作。

在上面例子中,解构过程类似于如下代码:

const tempArray = [1, [2, [3]]];
const a = tempArray[0];
const tempArray2 = tempArray[1];
const b = tempArray2[0];
const tempArray3= tempArray2[1];
const c = tempArray3[0];
console.log(a, b, c);    // 1 2 3

下面是一些使用嵌套数组进行解构的经典示例:

let [ , , third] = ["foo", "bar", "baz"];
console.log(third)    // "baz"

let [x, , y] = [1, 2, 3]; 
console.log(x)     // 1
console.log(y)     // 3

let [head, ...tail] = [1, 2, 3, 4];
console.log(head)   // 1
console.log(tail)   // [2, 3, 4]

let [x, y, ...z] = ['a'];
console.log(x)     // "a"
console.log(y)     // undefined
console.log(z)     // []

如果解构不成功,变量的值就等于undefined

let [foo] = [];
let [bar, foo] = [1];

以上两种情况都属于解构不成功,foo的值都会等于undefined

另一种情况是不完全解构,即等号左边的模式,只匹配一部分的等号右边的数组。这种情况下,解构依然可以成功。

let [a, [b], d] = [1, [2, 3], 4];
console.log(a)    // 1
console.log(b)    // 2
console.log(d)    // 4

如果等号的右边不是数组(或者严格地说,不是可遍历的结构),那么将会报错。

// 报错:Uncaught SyntaxError: Identifier 'foo' has already been declared
let [foo] = 1;
let [foo] = false;
let [foo] = NaN;
let [foo] = undefined;
let [foo] = null;
let [foo] = {};
4.4.2 对象嵌套解构

我们来看下面这个对象嵌套数组的解构案例:

let obj = {
    p: [
        'Hello',
        {y: 'World'}
    ]
};

let {p: [x, {y}]} = obj;
console.log(x);   // "Hello"
console.log(y);   // "World"

注意,这时p是模式,不是变量,因此不会被赋值。如果p也要作为变量赋值,可以写成下面这样:

let obj = {
  p: [
    'Hello',
    { y: 'World' }
  ]
};

let { p, p: [x, { y }] } = obj;
console.log(x)   // "Hello"
console.log(y)   // "World"
console.log(p)   // ["Hello", {y: "World"}]

如果解构模式是嵌套的对象,而且子对象所在的父属性不存在,那么将会报错:

let {foo: {bar}} = {baz: 'baz'};     // 报错

上面代码中,等号左边对象的foo属性,对应一个子对象。该子对象的bar属性,解构时会报错。原因很简单,因为foo这时等于undefined,再取子属性就会报错。

4.5 默认值

解构语法还支持给目标变量提供默认值,在无法解构出对应的值时会使用默认值。默认值可以是任何 JavaScript 表达式,包括函数调用、变量名、运算符等。

const [n = 1, m = 2] = [3, 4];
const [p = 1, q = 2] = [];
// 解构成功,使用赋值:
console.log(n, m);  // 3  4
// 解构未成功,使用默认值:
console.log(p, q);  // 1  2

在这个例子中,解构表达式[p = 1, q = 2]的值为空数组,因此解构无法成功。但由于设置了默认值,所以p 和 q 的值会分别设为 1 和 2。

注意,ES6 内部使用严格相等运算符(===),判断一个位置是否有值。所以,只有当一个数组成员严格等于undefined,默认值才会生效。

let [x = 1] = [undefined];
console.log(x)     // 1

let [x = 1] = [null];
console.log(x)     // null

上面代码中,如果一个数组成员是null,默认值就不会生效,因为null不严格等于undefined

4.6 字符串解构赋值
const [a, b, c, d, e] = 'hello';
console.log(a)    // "h"
console.log(b)    // "e"
console.log(c)    // "l"
console.log(d)    // "l"
console.log(e)    // "o"

类似数组的对象都有一个length属性,因此还可以对这个属性解构赋值。

let {length : len} = 'hello';
console.log(len)     // 5
4.7 数值和布尔值的解构赋值

解构赋值时,如果等号右边是数值和布尔值,则会先转为对象:

let {toString: s} = 123;
console.log(s === Number.prototype.toString)  // true

let {toString: s} = true;
console.log(s === Boolean.prototype.toString)  // true

上面代码中,数值和布尔值的包装对象都有toString属性,因此变量s都能取到值。

解构赋值的规则是,只要等号右边的值不是对象或数组,就先将其转为对象。由于undefinednull无法转为对象,所以对它们进行解构赋值,都会报错。

let { prop: x } = undefined;    // TypeError
let { prop: y } = null;         // TypeError
4.8 函数参数的解构赋值

函数的参数也可以使用解构赋值,使用方法如下所示:

function add([x, y]){
  return x + y;
}

console.log(add([1, 2]));    // 3

上面代码中,函数add的参数表面上是一个数组,但在传入参数的那一刻,数组参数就被解构成变量xy。对于函数内部的代码来说,它们能感受到的参数就是xy

函数参数的解构也可以使用默认值:

function move({x = 0, y = 0} = {}) {
  return [x, y];
}

console.log(move({x: 3, y: 8}));     // [3, 8]
console.log(move({x: 3}));           // [3, 0]
console.log(move({}));               // [0, 0]
console.log(move());                 // [0, 0]

上面代码中,函数move的参数是一个对象,通过对这个对象进行解构,得到变量xy的值。如果解构失败,xy等于默认值。

undefined就会触发函数参数的默认值:

[1, undefined, 3].map((x = 'yes') => x);
// [ 1, 'yes', 3 ]
4.9 圆括号问题

解构赋值虽然很方便,但是解析起来并不容易。对于编译器来说,一个式子到底是模式,还是表达式,没有办法从一开始就知道,必须解析到(或解析不到)等号才能知道。

由此带来的问题是,如果模式中出现圆括号怎么处理。ES6 的规则是,只要有可能导致解构的歧义,就不得使用圆括号。

4.9.1 不能使用圆括号的情况

以下三种解构赋值不得使用圆括号:

  • 变量声明语句:
// 全部报错
let [(a)] = [1];

let {x: (c)} = {};
let ({x: c}) = {};
let {(x: c)} = {};
let {(x): c} = {};

let { o: ({ p: p }) } = { o: { p: 2 } };

上面 6 个语句都会报错,因为它们都是变量声明语句,模式不能使用圆括号。

  • 函数参数: 函数参数也属于变量声明,因此不能带有圆括号:
// 报错
function f([(z)]) { return z; }
// 报错
function f([z, (x)]) { return x; }
  • 赋值语句的模式:
// 全部报错
({ p: a }) = { p: 42 };
([a]) = [5];

上面代码将整个模式放在圆括号之中,导致报错。

// 报错
[({ p: a }), { x: c }] = [{}, {}];

上面代码将一部分模式放在圆括号之中,导致报错。

4.9.2 可以使用圆括号的情况

可以使用圆括号的情况只有一种:赋值语句的非模式部分,可以使用圆括号。

[(b)] = [3];                // 正确
({ p: (d) } = {});          // 正确
[(parseInt.prop)] = [3];    // 正确

上面三行语句都可以正确执行,因为首先它们都是赋值语句,而不是声明语句;其次它们的圆括号都不属于模式的一部分。第一行语句中,模式是取数组的第一个成员,跟圆括号无关;第二行语句中,模式是p,而不是d;第三行语句与第一行语句的性质一致。

4.10 JavaScript 引擎对于解构语法实现的细节

在 JavaScript 引擎处理解构语法时,会执行以下步骤:

  1. 如果右边(要解构的对象)是一个具有 Iterator 接口的对象,则需要调用其 iterator 方法,为解构过程创建一个迭代器。迭代器可以让我们对要解构的对象进行遍历,并将其每个属性的取值传递给左边(解构的目标)中相应的变量;

  2. 对要解构的目标进行判断,如果为无法解构的值(如undefined),则抛出 TypeError;

  3. 如果解构语法中指定了默认值,则在对象无法解构到值(undefined)时使用默认值;

  4. 嵌套解构会在目标中继续求值以解构嵌套的变量;

  5. 解构对于数组和字符串元素时,会按照索引顺序进行赋值,而对于对象中的元素时,会按照属性名进行赋值;

  6. 对于对象的解构,会从对象中取出相应属性的值,然后复制到与解构表达式中相应的变量中。如果解构表达式中指定的变量名与属性名不同,需要使用 key: value 表示法进行定义;

  7. 解构表达式是完全可以包含剩余运算符的,这样即可匹配对象或数组中剩余的属性,将其赋值到相应的变量上;

  8. 如果解构表达式中不存在取值函数(getter)和设置函数(setter),则此时解构赋值可以在性能上比原生赋值语句快一些。

JavaScript 引擎在处理解构表达式时,会比使用常规的变量赋值语句多一些步骤。当我们使用解构表达式时,我们可以根据语法的特点编写更加简洁、易读的代码,JavaScript 引擎会自动为我们执行相应的处理过程。

5. 箭头函数的原理与使用场景

JavaScript中的箭头函数是新增的一种函数定义方式,它可以创建一个函数并赋值给变量,使用箭头语法=>。在箭头函数中,this 关键字的作用域与它周围代码作用域相同,因而有时也被称为词法作用域函数

5.1 基础概念与使用

箭头函数的语法非常简单,并且这种方法通常比函数表达式更好。

// 箭头函数:
const add = (a, b) => {
  return a + b;
};
// 或者更简洁的写法:
const add = (a,b) => a+b


// 常规的函数写法:
// const sum = function(a, b) { return a + b; }; 

console.log(add(2, 3));   // 5
5.2 箭头函数和普通函数的区别

箭头函数与普通函数有哪些不同?

  1. this指向不同,箭头函数 this 指向父级;
  2. 箭头函数不能作为构造函数,不能 new 实例化对象;
  3. 箭头函数无法使用 arguments 访问参数,访问到的是上一层的参数;
  4. 箭头函数无法使用 bind、apply、call改变this指向。
5.2.1 箭头函数的 this 指向

箭头函数的原理是基于JavaScript中的闭包、this和参数作用域。在箭头函数中,this关键字始终指向该函数所在的作用域指向的对象,而不是使用时所在的作用域指向的对象。

我们来看下面这个例子:

var name = 'window';

var A = {
    name: 'A',
    sayHello: function () {
        console.log(this.name)
    }
}
// this指向 A
A.sayHello();   // 输出'A'

var B = {
    name: 'B',
    sayHello: () => {
        console.log(this.name)
    }
}
// this指向 B 的上一级,全局作用域 window
B.sayHello()  // 输出'window'

由此我们可以看出,当使用了箭头函数,箭头函数内部的this并不会指向当前作用域B,而是指向了上一级的全局作用域window

当我们需要引用所在父级的this指针时,可以使用箭头函数,因为箭头函数中的this指向的不是函数调用时的上下文对象:

const obj = {
  name: 'Tom',
  age: 20,
  say: function() {
    console.log(`My name is ${this.name}, I'm ${this.age} years old`);
  }
};

setTimeout(obj.say, 1000);

在这个示例中,由于setTimeout中的this指针的问题,函数say中的name属性和age属性无法正常被引用,而使用箭头函数就能解决这个问题。

const obj = {
  name: 'Tom',
  age: 20,
  say: function() {
    setTimeout(() => {
      // 此时this 指向父级obj
      console.log(`My name is ${this.name}, I'm ${this.age} years old`);
    }, 1000);
  }
};

obj.say();
5.2.2 箭头函数不能用作构造函数

箭头函数没有自己的this指针,所以不能作为构造函数来使用。因此,不能使用new关键字来调用箭头函数来创建一个新对象。

const Person = (name, age) => {
  this.name = name;
  this.age = age;
};

let person1 = new Person('Tom', 20);  // 报错, Person is not a constructor
5.2.3 箭头函数不能使用 arguments 关键字

在箭头函数中,函数的参数为指定的参数,没有额外的 arguments 对象。如果需要使用 arguments 参数,必须使用常规的函数语法。

const func = () => {
 console.log(arguments);  
};

func1(1, 2, 3);  // 报错,arguments is not defined

如果需要使用 arguments,则需要使用function函数定义方式:

const func = function() {
 console.log(arguments);
};

func(1, 2, 3);   // 输出[1, 2, 3]
5.2.4 箭头函数不能通过 call、apply、bind 修改this指向

对于箭头函数,它的 this 指针指向词法作用域中的this值,无法通过call()、apply()、bind()方法来修改。

const obj = {
  name: 'Tom',
  age: 20,
  say: () => {
    console.log(`My name is ${this.name}, I'm ${this.age} years old`);
  }
};

obj.say.call({ name: 'Bill', age: 30 }); 
// 输出 My name is , I'm undefined years old

在这个示例中,虽然我们通过call()方法强制修改了say()的this值,但结果表明this值的实际结果并没有得到改变。这是因为箭头函数本身并没有自己的this值,在这里仍然使用的是上面全局对象的this值。

综上所述,尽管箭头函数在许多情况下都表现得非常优异,但仍然有一些限制场景需要我们关注。在处理这些限制场景时,我们可以使用传统的函数语法,或其他语法来满足我们的代码需求。

6.生成器 generator

JavaScript中的生成器(Generator)引入的一种函数类型,它与传统的函数不同之处在于,在生成器中,我们可以中途停止函数的执行,并保存相关的上下文信息,待下次继续执行时可以从保存的上下文信息处继续执行。

6.1 基本概念

生成器的定义与传统函数非常相似,不同之处在于生成器函数的关键字为function*(注意是带星号的function),并使用 yield 操作符来指定生成器函数的执行步骤。yield 操作符可以看做是一个暂停器,它可以和外部程序交换数据,并在函数执行停滞时暂停函数的操作,并记录下执行状态信息以备之后恢复时使用。

下面是一个简单的生成器示例:

function* generateSequence() {
  yield 1;
  yield 2;
  yield 3;
  
  return 4;
}

const sequence = generateSequence(); // 获取生成器实例
console.log(sequence.next().value); // 输出 1
console.log(sequence.next().value); // 输出 2
console.log(sequence.next().value); // 输出 3
console.log(sequence.next().value); // 输出 4

在这个示例中,我们定义了一个 generateSequence 的生成器函数,并在其中使用 yield 停止程序的执行,生成器函数执行到 yield 的时候,就会停止执行并且将 yield 后面的值返回作为值,并记录下当前运行的上下文信息,等待下一次调用 next() 方法时恢复上下文信息和yield后面的值,并继续执行,直到生成器函数执行结束。

生成器可以看做是一种特殊的迭代器,它与普通迭代器不同之处在于,它的 yield 关键字可以返回多个值,并且它具备暂停及恢复执行状态的功能。通常情况下,我们可以使用生成器来处理那些状态化的问题,在文件读写、网络请求、流式计算等处理方式中都可以使用生成器的特性来优化代码效率。

6.2 使用场景

生成器的使用场景主要是在需要处理大量异步操作并保持状态的情况下。生成器不仅可以使代码简洁易懂,还可以避免回调地狱Promise 降解的问题。

生成器可以通过 yield (产出)操作暂停函数的执行,并返回一个值,等待下一次调用 next (下一个)方法重新启动函数的执行,并继续执行到下一个 yield 操作或函数退出。这样就可以在暂停和恢复的过程中,保持函数的状态,避免了频繁的创建和销毁该函数的内部变量,提高了性能。

下面是一个使用生成器处理异步操作的示例:

function* myGenerator() {
  const result1 = yield asyncOperation1(); // 发起异步操作1
  const result2 = yield asyncOperation2(result1); // 发起异步操作2,并将异步操作1的结果作为参数
  return result2; // 返回异步操作2的结果
}

function asyncOperation1() {
  return new Promise(resolve => setTimeout(() => resolve('result1'), 1000));
}

function asyncOperation2(arg) {
  return new Promise(resolve => setTimeout(() => resolve(`result2-${arg}`), 1000));
}

const gen = myGenerator(); // 获取生成器实例
const p1 = gen.next(); // 启动异步操作1
p1.value.then(result1 => {
  const p2 = gen.next(result1); // 启动异步操作2,将异步操作1 的结果作为参数传递
  p2.value.then(result2 => {
    console.log(result2); // 输出 result2-result1
  });
});

在这个示例中,我们定义了 myGenerator 生成器函数,它使用 yield 操作暂停执行,并在异步操作完成后恢复执行,并传递相应的参数。我们利用 Promise 实例来完成异步操作,并使用then() 方法来获取异步操作的结果,并将结果作为参数传递给下一个yield操作。

在上面这个示例中,我们使用生成器函数来方便地完成了两个异步操作的串联和参数传递。这种方式的优点在于代码可读性和可维护性都得到了大大的提高。同时,生成器自身的状态保持特性,也使得代码的性能得到了提升。

7. 异步处理——callback、Promise、async & await

异步编程是一种处理事件循环,等待结果返回的方法,常见的实现方式有 callbackPromiseasync/await

7.1 callback

callback 是一种异步编程模式,通过回调函数的方式实现,通常用于处理一次性异步请求。callback 的好处在于实现起来简单,但由于回调函数嵌套层数容易过多,使得代码可读性和可维护性受到影响。

function fetchData(callback) {
  setTimeout(() => {
    const data = 'Hello World!';
    callback(data);
  }, 1000);
}

fetchData(data => {
  console.log(data);   // 输出 Hello World!
})
7.2 Promise

Promise 是 ES6 提供的一种处理异步操作的机制,用于解决 callback 回调函数嵌套过多的问题。Promise 可以链式调用,通过 then() 方法来处理返回值,同时还提供了 catch() 方法来处理错误。

function fetchData() {
  return new Promise(function(resolve) {
    setTimeout(() => {
      const data = 'Promise';
      resolve(data);
    }, 1000);
  });
}

fetchData().then(function(data){
  console.log(data);   // 输出 Promise
});
7.3 async/await

async/await 是 ES8 的新特性,是基于 Promise 的一种异步编程方式,它可以使异步代码看起来像同步代码,语法简单易懂,可读性较高。async 是用于定义一个异步函数,await 用于等待一个异步操作完成。async 函数返回一个 Promise 对象,await 关键字只能在 async 函数中使用。

async function fetchData() {
  return new Promise(resolve => {
    setTimeout(() => {
      const data = 'async/await';
      resolve(data);
    }, 1000);
  });
}

async function printData() {
  const data = await fetchData();
  console.log(data);   // 输出 async/await
}

printData();

总的来说,Promise 和 async/await 相对于 callback 函数来说更加强大和易用,能够有效避免回调地狱的问题。而 async/await 相比 Promise 的链式调用,则可以更好地表达异步操作的关系,让代码更易懂。

8. Reflect

8.1 Reflect 定义

Reflect 是一个内建的对象,用来提供方法去拦截 JavaScript 的操作。

Reflect 不是一个函数对象,所以它是不可构造的,也就是说它不是一个构造器,不能通过 new 操作符去新建或者将其作为一个函数去调用 Reflect 对象。

Reflect 的所有属性和方法都是静态的。

Reflect 内部封装了一系列对对象的底层操作。

Reflect 成员方法就是 proxy 处理对象的默认实现。

const proxy = new Proxy(obj, {
  get(target, property) {
    // 如果没有定义 get 方法,那么默认返回的就是 Reflect 的 get 方法
    return Reflect.get(target, property)
  }
})
8.2 Reflect API 汇总

Reflect 提供了一套用于操作对象的 API,我们之前操作对象可以用 object 上面的一些方法,也可以用in、delete这种操作符,使用 Reflect 就统一了操作方式。

handler 方法默认调用功能
getReflect.get()获取对象身上某个属性的值
setReflect.set()在对象上设置属性
hasReflect.has()判断一个对象是否存在某个属性
deletePropertyReflect.deleteProperty()删除对象上的属性
getPropertyReflect.getPrototypeOf()获取指定对象原型的函数
setPropertyReflect.setPrototypeOf()设置或改变对象原型的函数
isExtensibleReflect.isExtensible()判断一个对象是否可扩展(是否能够添加新的属性)
preventExtensionsReflect.preventExtensions()阻止新属性添加到对象
getOwnPropertyDescriptorReflect.getOwnPropertyDescriptor()获取给定属性的属性描符
definePropertyReflect.defineProperty()定义或修改一个对象的属性
ownKeysReflect.ownKeys()返回由目标对象自身的属性键组成的数组
applyReflect.apply()对一个函数进行调用操作,同时可以传入一个数组作为调用参数
constructReflect.construct()对构造函数进行 new 操作,实现创建类的实例
.preventExtensionsReflect.preventExtensions()阻止新属性添加到对象
8.3 2 代码示例

下面是一些反射 API 的常用示例:

  • 获取对象的属性名称列表
const obj = {a: 1, b: 2, c: 3};
console.log(Reflect.ownKeys(obj));   // 输出 ["a", "b", "c"]
  • 验证属性存在
const obj2 = {a: 1};
console.log(Reflect.has(obj2, 'a'));   // 输出 true
  • 获取对象的原型
const obj3 = {a: 1};
console.log(Reflect.getPrototypeOf(obj3));   // 输出 {}
  • 修改对象的原型
const obj4 = {a: 1};
const proto = {b: 2};
Reflect.setPrototypeOf(obj4, proto);
console.log(obj4.__proto__);   // 输出 {b: 2}
  • 代替call和apply方法
let foo = {
    value: 1
}

let bar = function () {
    console.log(this.value);
}

Reflect.apply(bar, foo, [])   // 输出1
  • 使用 set 函数和 get 函数来监视对象属性的读取和设置(重点)
// 反射 Reflect 和代理 Proxy 通常是同时出现的
const handler = {
    get(target, key, receiver) {
        console.log('get', key);
        
        // 值的获取就是我们所谓的依赖收集操作
        // 相当于return target[key]
        return Reflect.get(target, key, receiver)
    },

    set(target, key, value, receiver) {
        console.log('set', key, value);
        
        // 值的设置就是我们所谓的更新操作
        // 相当于target[key] = value;
        Reflect.set(target, key, value, receiver)
    }
};

// 目标对象
const target = {
    a: 1,
    b: 2
}

const proxy = new Proxy(target, handler);
proxy.a;    // 输出:get a
proxy.a = 2;   // 输出: set a 2
  • 查看 Reflect 具备的所有方法
console.log(Reflect);

企业微信截图_17491159537081.png

Reflect API 实现了对象的反射、代理等功能,它为我们提供了一些强大而便捷的工具,使得我们可以在运行时动态地查看、检查和修改对象的属性和行为。Reflect 反射在 JavaScript 中的应用非常广泛,可以用于类似响应式编程、面向对象编程等各种场景。

8.3 3 主要应用场景

在 Nest.js 中,注解和 reflect 是紧密相关的,它们常常一起使用来解决一些实际问题。下面是一些常见的使用场景:

  1. 定义控制器和路由

在 Nest.js 中,控制器和路由的定义通常使用注解进行描述,而 reflect API 可以用于存储和读取控制器和路由的元数据。例如,@Controller() 注解表示一个控制器,@Get()、@Post() 等注解表示控制器的路由和请求方法。而 Reflect.getMetadata()Reflect.defineMetadata() 则用于读取和存储它们的各种配置和元数据,例如路由地址、是否需要鉴权、权限等级等。

  1. 实现拦截器

Nest.js 中的拦截器通常使用注解和 reflect 进行实现。拦截器常用于实现请求的预处理和后处理,例如在请求发生之前,我们可以使用拦截器来检查用户是否已经登录,或者通过拦截器来记录请求生命周期中的各种日志等。而拦截器实际上就是一个装饰器,可以使用 @Injectable() 注解声明,并且可以实现某些拦截器特有的接口和方法。

  1. 应用全局过滤器

在 Nest.js 中,全局过滤器使用注解和 reflect 可以方便地实现请求和响应的统一过滤,例如过滤掉敏感信息、安全信息、非法请求等。全局过滤器使用 @UseFilters() 注解进行定义,可以通过 reflect API 来读写过滤器的元信息,例如过滤器的地址、请求头、状态码等。

总的来说,在 Nest.js 中,注解和 reflect 是紧密相关的,常常一起使用来实现控制器、路由、拦截器、过滤器等一些行为或功能。它们强调了代码的可维护性和可读性,可以使代码更加简单、灵活。

下面我会列举两份代码进行详细说明:

  1. 使用注解和 reflect 实现全局过滤器
import { Catch, ArgumentsHost } from '@nestjs/common';
import { BaseExceptionFilter } from '@nestjs/core';

@Catch()
export class MyGlobalFilter extends BaseExceptionFilter {
  catch(exception: unknown, host: ArgumentsHost) {
    console.log('MyGlobalFilter is running!');
    super.catch(exception, host);
  }
}

// 在 main.ts 中注册全局过滤器
app.useGlobalFilters(new MyGlobalFilter());

这个示例演示了如何使用注解和 reflect 实现全局过滤器。在这个示例中,我们定义了一个名为 MyGlobalFilter 的类,使用了 @Catch() 注解来标记这个类为一个过滤器类。这个类继承了 BaseExceptionFilter 类,实现了 catch() 方法。在 catch() 方法内部,我们调用了 super.catch() 方法来处理异常,并打印了一条日志。

当应用中发生任何异常时,都会自动触发 MyGlobalFilter 这个过滤器,让它来处理异常。这个过滤器会打印一条日志,然后调用基类 BaseExceptionFilter 中定义的处理方法进行异常处理。

  1. 使用注解和 reflect 实现请求日志记录拦截器
import { 
  Injectable,
  NestInterceptor,
  ExecutionContext,
  CallHandler 
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';

@Injectable()
export class RequestLoggingInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    console.log(`Request made to ${context.getClass().name}.${context.getHandler().name}`);
    return next.handle().pipe(
      tap(() => console.log(`Request response from ${context.getClass().name}`))
    );
  }
}

// 在控制器方法中使用 @UseInterceptors() 注解注入拦截器
@Controller()
@UseInterceptors(RequestLoggingInterceptor)
export class AppController {
  @Get()
  getData() {
    return 'Hello World!';
  }
}

这个示例演示了如何使用注解和 reflect 实现一个请求日志记录拦截器。在这个示例中,我们定义了一个名为 RequestLoggingInterceptor 的拦截器类,拦截器实现了 NestInterceptor 接口,它的主要作用是在每次请求发生时记录请求日志。

在 intercept() 方法内部,我们通过 ExecutionContextCallHandler 参数获取当前请求的类名和方法名,并打印了一条请求日志。同时,我们将 CallHandler 对象传递给 pipe 中的 tap() 方法,从而在请求响应过程中打印响应信息。

在控制器中,我们使用了 @UseInterceptors() 注解将拦截器注入到 getData() 方法中。当控制器的 getData() 方法被调用时,这个拦截器就会自动被触发,从而记录请求日志。

以上这两个示例都证明了在 Nest.js 中,可以使用注解和 reflect 实现一些实用的功能,例如全局过滤器、拦截器等。在实际编程中,我们可以根据需要自行实现更多的注解和 reflect 功能,来增强程序的灵活性和可维护性。

9. BigInt

如果处理过大数据的场景,想必知道 JavaScript Number 类型的限制是2^53 - 1

BigInt 是在 ES10 中引入的一种新类型,它可以用来表示任意大的整数,不受 JavaScript 中 Number 类型的 2^53 - 1 限制。

在 JavaScript 中,Number 类型使用 IEEE 754 标准表示,且占据 64 位内存。其中 1 位是符号位,11 位是指数位,剩余 52 位是有效数字位。因此,在 Number 类型中最大的安全整数为 2^53 - 1,超过这个值就会丢失精度。而 BigInt 类型则可以表示任意大的精度整数,其内存使用量要大于 Number 类型,但是比字符串表示更节省空间。

BigInt 类型的使用方法和 Number 类型相似,主要区别在于在数字后加 "n" 标志表示 Bigint 类型。BigInt 类型可以进行加、减、乘、除等基本数学运算,并且可以使用 BigInt() 构造方法将字符串或 Number 类型数据转换为 BigInt 类型。

以下是一个简单的 BigInt 示例:

// 打印 Number 类型的最大值:
console.log(Number.MAX_SAFE_INTEGER); // 输出 9007199254740991

// 打印大于 Number 类型的最大值的数值,输出结果丢失了精度
console.log(10000000100000001); // 输出 10000000100000000,
// 将 Number 类型的最大值转化为 BigInt 类型,再进行加法操作
const bigNum1 = BigInt(Number.MAX_SAFE_INTEGER);
console.log(bigNum1 + 1n); // 输出 9007199254740992n,避免了精度丢失

// 将超过 Number 类型的最大值的数值转化为 BigInt 类型,再进行输出
const bigNum2 = BigInt(10000000100000001);
console.log(bigNum2); // 输出 10000000100000001n,避免了精度丢失

为了与 Number 类型区别,BigInt 类型的数据必须添加后缀n

1234   // 普通整数
1234n  // BigInt

// BigInt 的运算
1n + 2n  // 3n

BigInt 同样可以使用各种进制表示,都要加上后缀n

0b1101n // 二进制
0o777n // 八进制
0xFFn // 十六进制

BigInt 与普通整数是两种值,它们之间并不相等:

42n === 42   // false

typeof运算符对于 BigInt 类型的数据返回bigint

typeof 123n   // 'bigint'

BigInt 可以使用负号(-),但是不能使用正号(+),因为会与 asm.js 冲突。

-42n // 正确
+42n // 报错

需要注意的是,BigInt 类型和 Number 类型不能进行混合运算。如果试图在 BigInt 和 Number 之间进行算术运算,会抛出类型错误。

BigInt 类型在一些算法、加密等领域应用广泛,同时在一些科学计算、数据计算等方面也具有广泛的应用前景。

image.png

如果你需要考虑更高的兼容性:big.js

二. Babel

babel 可将 JavaScript 源代码转换生成新的目标代码,最早期他的作用是:

  • 支持 ECMAScript 新特性;
  • 为平台兼容打 polyfill(core-js);

重点概念:babel preset、babel plugin、targets

1. 相关链接

babel官网

babel转义代码链接

2. 其他编译工具

  • ESBuild:基于 go 语言,很快,可以用来打包基础库等项目,vite 开发环境打包选择 ESBuild;

  • swc:基于 rust 语言编写,性能很强。