2023 年 09 月(下)学习日志

143 阅读29分钟

前言

没办法,掘金这个编辑器文字多了就很卡,所以把一个月的学习内容分成上下吧。


往期回顾

2023 年 09 月学习日志(上)

2023 年 08 月学习日志


09-28 函数

函数表达式

理解函数声明与函数表达式之间的区别,关键是理解提升。比如,以下代码的执行结果可能会出乎意料:

// 千万别这样做!
if (condition) {
  function sayHi() {
    console.log('Hi!');
  }
} else {
  function sayHi() {
    console.log('Yo!');
  }
}

这段代码看起来很正常,就是如果 condition 为 true,则使用第一个 sayHi()定义;否则,就使用第二个。事实上,这种写法在 ECAMScript 中不是有效的语法。JavaScript 引擎会尝试将其纠正为适当的声明

问题在于浏览器纠正这个问题的方式并不一致。多数浏览器会忽略 condition 直接返回第二个声明。Firefox 会在 condition 为 true 时返回第一个声明。这种写法很危险,不要使用。不过,如果把上面的函数声明换成函数表达式就没问题了:

// 没问题
let sayHi;
if (condition) {
  sayHi = function () {
    console.log("Hi!");
  };
} else {
  sayHi = function () {
    console.log("Yo!");
  };
}

这个例子可以如预期一样,根据 condition 的值为变量 sayHi 赋予相应的函数。

创建函数并赋值给变量的能力也可以用于在一个函数中把另一个函数当作值返回:

function createComparisonFunction(propertyName) {
  return function () {
    // 做一些事
  };
}

这里的 createComparisonFunction()函数返回一个匿名函数,这个匿名函数要么被赋值给一个变量要么可以直接调用。但在 createComparisonFunction()内部,那个函数是匿名的。任何时候,只要函数被当作值来使用,它就是一个函数表达式。本章后面会介绍,这并不是使用函数表达式的唯一方式。

递归

递归函数通常的形式是一个函数通过名称调用自己,如下面的例子所示:

function factorial(num) {
  if (num <= 1) {
    return 1;
  } else {
    return num * factorial(num - 1);
  }
}

这是经典的递归阶乘函数。虽然这样写是可以的,但如果把这个函数赋值给其他变量,就会出问题:

let anotherFactorial = factorial;
factorial = null;
console.log(anotherFactorial(4)); // 报错

这里把 factorial()函数保存在了另一个变量 anotherFactorial 中,然后将 factorial 设置为 null,于是只保留了一个对原始函数的引用。而在调用 anotherFactorial()时,要递归调用factorial(),但因为它已经不是函数了,所以会出错。在写递归函数时使用 arguments.callee 可以避免这个问题。

arguments.callee 就是一个指向正在执行的函数的指针,因此可以在函数内部递归调用,如下所示:

function factorial(num) {
  if (num <= 1) {
    return 1;
  } else {
    return num * arguments.callee(num - 1);
  }
}

像这里加粗的这一行一样,把函数名称替换成 arguments.callee,可以确保无论通过什么变量调用这个函数都不会出问题。因此在编写递归函数时,arguments.callee 是引用当前函数的首选。

不过,在严格模式下运行的代码是不能访问 arguments.callee 的,因为访问会出错。此时,可以使用命名函数表达式(named function expression)达到目的。比如:

const factorial = (function f(num) {
  if (num <= 1) {
    return 1;
  } else {
    return num * f(num - 1);
  }
});

console.log(factorial(5))  // 120

这里创建了一个命名函数表达式 f(),然后将它赋值给了变量 factorial。即使把函数赋值给另一个变量,函数表达式的名称 f 也不变,因此递归调用不会有问题。这个模式在严格模式和非严格模式下都可以使用

尾调用优化

ECMAScript 6 规范新增了一项内存管理优化机制,让 JavaScript 引擎在满足条件时可以重用栈帧。具体来说,这项优化非常适合“尾调用”,即外部函数的返回值是一个内部函数的返回值。比如:

function outerFunction() {
  return innerFunction(); // 尾调用
}

在 ES6 优化之前,执行这个例子会在内存中发生如下操作。

  1. 执行到 outerFunction 函数体,第一个栈帧被推到栈上
  2. 执行 outerFunction 函数体,到 return 语句。计算返回值必须先计算 innerFunction。
  3. 执行到 innerFunction 函数体,第二个栈帧被推到栈上。
  4. 执行 innerFunction 函数体,计算其返回值。
  5. 将返回值传回 outerFunction,然后 outerFunction 再返回值。
  6. 将栈帧弹出栈外。

在 ES6 优化之后,执行这个例子会在内存中发生如下操作。

  1. 执行到 outerFunction 函数体,第一个栈帧被推到栈上。
  2. 执行 outerFunction 函数体,到达 return 语句。为求值返回语句,必须先求值 innerFunction。
  3. 引擎发现把第一个栈帧弹出栈外也没问题,因为 innerFunction 的返回值也是 outerFunction 的返回值。
  4. 弹出 outerFunction 的栈帧。
  5. 执行到 innerFunction 函数体,栈帧被推到栈上。
  6. 执行 innerFunction 函数体,计算其返回值。
  7. 将 innerFunction 的栈帧弹出栈外。

很明显,第一种情况下每多调用一次嵌套函数,就会多增加一个栈帧。而第二种情况下无论调用多少次嵌套函数,都只有一个栈帧。这就是 ES6 尾调用优化的关键:如果函数的逻辑允许基于尾调用将其销毁,则引擎就会那么做。

注意 现在还没有办法测试尾调用优化是否起作用。不过,因为这是 ES6 规范所规定的,兼容的浏览器实现都能保证在代码满足条件的情况下应用这个优化。

尾调用优化的条件

尾调用优化的条件就是确定外部栈帧真的没有必要存在了。涉及的条件如下:

  • 代码在严格模式下执行;
  • 外部函数的返回值是对尾调用函数的调用;
  • 尾调用函数返回后不需要执行额外的逻辑;
  • 尾调用函数不是引用外部函数作用域中自由变量的闭包。

下面展示了几个违反上述条件的函数,因此都不符号尾调用优化的要求:

"use strict";
// 无优化:尾调用没有返回
function outerFunction() {
  innerFunction();
}
// 无优化:尾调用没有直接返回
function outerFunction() {
  let innerFunctionResult = innerFunction();
  return innerFunctionResult;
}
// 无优化:尾调用返回后必须转型为字符串
function outerFunction() {
  return innerFunction().toString();
}
// 无优化:尾调用是一个闭包
function outerFunction() {
  let foo = 'bar';
  function innerFunction() { return foo; }
  return innerFunction();
}

下面是几个符合尾调用优化条件的例子:

"use strict";
// 有优化:栈帧销毁前执行参数计算
function outerFunction(a, b) {
  return innerFunction(a + b);
}
// 有优化:初始返回值不涉及栈帧
function outerFunction(a, b) {
  if (a < b) {
    return a;
  }
  return innerFunction(a + b);
}
// 有优化:两个内部函数都在尾部
function outerFunction(condition) {
  return condition ? innerFunctionA() : innerFunctionB();
}

差异化尾调用和递归尾调用是容易让人混淆的地方。无论是递归尾调用还是非递归尾调用,都可以应用优化。引擎并不区分尾调用中调用的是函数自身还是其他函数。不过,这个优化在递归场景下的效果是最明显的,因为递归代码最容易在栈内存中迅速产生大量栈帧。

注意 之所以要求严格模式,主要因为在非严格模式下函数调用中允许使用 f.arguments和 f.caller,而它们都会引用外部函数的栈帧。显然,这意味着不能应用优化了。因此尾调用优化要求必须在严格模式下有效,以防止引用这些属性。

尾调用优化的代码

可以通过把简单的递归函数转换为待优化的代码来加深对尾调用优化的理解。下面是一个通过递归计算斐波纳契数列的函数:

function fib(n) {
  if (n < 2) {
    return n;
  }
  return fib(n - 1) + fib(n - 2);
}
console.log(fib(0)); // 0
console.log(fib(1)); // 1
console.log(fib(2)); // 1
console.log(fib(3)); // 2
console.log(fib(4)); // 3
console.log(fib(5)); // 5
console.log(fib(6)); // 8

显然这个函数不符合尾调用优化的条件,因为返回语句中有一个相加的操作。结果,fib(n)的栈帧数的内存复杂度是 O(2n)。因此,即使这么一个简单的调用也可以给浏览器带来麻烦:

fib(1000);

当然,解决这个问题也有不同的策略,比如把递归改写成迭代循环形式。不过,也可以保持递归实现,但将其重构为满足优化条件的形式。为此可以使用两个嵌套的函数,外部函数作为基础框架,内部函数执行递归:

"use strict";
function fib_old(n) {
  if (n < 2) {
    return n;
  }
  return fib_old(n - 1) + fib_old(n - 2);
}

// 基础框架
function fib_new(n) {
  return fibImpl(0, 1, n);
}
// 执行递归
function fibImpl(a, b, n) {
  console.table(`a:>>${a}, b:>> ${b}, n:>> ${n}`)
  if (n === 0) {
    return a;
  }
  return fibImpl(b, a + b, n - 1);
}


const now_new = new Date()
console.log(fib_new(42));
console.log('花费_new:', (new Date() - now_new) / 1000 + 's')

const now_old = new Date()
console.log(fib_old(42));
console.log('花费_old:', (new Date() - now_old) / 1000 + 's')

image.png

JC3K631WD(RDR}}SCUBYV)L.jpg


这样重构之后,就可以满足尾调用优化的所有条件,再调用 fib(1000)就不会对浏览器造成威胁了。

09-27 函数:函数属性和方法

函数属性

每个函数都有两个属性:length 和 `prototype

function sayName(name) {
  console.log(name);
}
function sum(num1, num2) {
  return num1 + num2;
}
function sayHi() {
  console.log("hi");
}
console.log(sayName.length); // 1
console.log(sum.length); // 2
console.log(sayHi.length); // 0

可以看到 函数的 length 就是参数的个数

prototype 属性也许是 ECMAScript 核心中最有趣的部分。prototype 是保存引用类型所有实例方法的地方,这意味着 toString()、valueOf()等方法实际上都保存在 prototype 上,进而由所有实例共享。这个属性在自定义类型时特别重要。(相关内容已经在第 8 章详细介绍过了。)在 ECMAScript 5中,prototype 属性是不可枚举的,因此使用 for-in 循环不会返回这个属性。

函数方法

函数还有两个方法:apply()和 call()。这两个方法都会以指定的 this 值来调用函数,即会设置调用函数时函数体内 this 对象的值。apply()方法接收两个参数:函数内 this 的值和一个参数数组。第二个参数可以是 Array 的实例,但也可以是 arguments 对象。来看下面的例子:

function sum(num1, num2) {
  return num1 + num2;
}
function callSum1(num1, num2) {
  return sum.apply(this, arguments); // 传入 arguments 对象
}
function callSum2(num1, num2) {
  return sum.apply(this, [num1, num2]); // 传入数组
}
console.log(callSum1(10, 10)); // 20
console.log(callSum2(10, 10)); // 20

在这个例子中,callSum1()会调用 sum()函数,将 this 作为函数体内的 this 值(这里等于window,因为是在全局作用域中调用的)传入,同时还传入了 arguments 对象。callSum2()也会调用 sum()函数,但会传入参数的数组。这两个函数都会执行并返回正确的结果。

注意 在严格模式下,调用函数时如果没有指定上下文对象,则 this 值不会指向 window。除非使用 apply()call()把函数指定给一个对象,否则 this 的值会变成 undefined

call()方法与 apply()的作用一样,只是传参的形式不同。

apply()call()真正强大的地方并不是给函数传参,而是控制函数调用上下文即函数体内 this值的能力。考虑下面的例子:

window.color = 'red';
let o = {
  color: 'blue'
};
function sayColor() {
  console.log(this.color);
}
sayColor(); // red
sayColor.call(this); // red
sayColor.call(window); // red
sayColor.call(o); // blue

这个例子是在之前那个关于 this 对象的例子基础上修改而成的。同样,sayColor()是一个全局函数,如果在全局作用域中调用它,那么会显示"red"。这是因为 this.color 会求值为 window.color。如果在全局作用域中显式调用 sayColor.call(this)或者 sayColor.call(window),则同样都会显示"red"。而在使用 sayColor.call(o)把函数的执行上下文即 this 切换为对象 o 之后,结果就变成了显示"blue"了。

使用 call()apply()的好处是可以将任意对象设置为任意函数的作用域,这样对象可以不用关心方法。在前面例子最初的版本中,为切换上下文需要先把 sayColor()直接赋值为 o 的属性,然后再调用。而在这个修改后的版本中,就不需要这一步操作了。

ECMAScript 5 出于同样的目的定义了一个新方法:bind()。bind()方法会创建一个新的函数实例,其 this 值会被绑定到传给 bind()的对象。比如:

window.color = 'red';
var o = {
  color: 'blue'
};
function sayColor() {
  console.log(this.color);
}
let objectSayColor = sayColor.bind(o);
objectSayColor(); // blue

这里,在 sayColor()上调用 bind()并传入对象 o 创建了一个新函数 objectSayColor()。objectSayColor()中的 this 值被设置为 o,因此直接调用这个函数,即使是在全局作用域中调用,也会返回字符串"blue"。

对函数而言,继承的方法 toLocaleString()toString()始终返回函数的代码。返回代码的具体格式因浏览器而异。有的返回源代码,包含注释,而有的只返回代码的内部形式,会删除注释,甚至代码可能被解释器修改过。由于这些差异,因此不能在重要功能中依赖这些方法返回的值,而只应在调试中使用它们。继承的方法 valueOf()返回函数本身。


09-26 函数:函数声明与函数表达式、函数作为值,函数内部

函数声明与函数表达式

本章到现在一直没有把函数声明和函数表达式区分得很清楚。事实上,JavaScript 引擎在加载数据时对它们是区别对待的。JavaScript 引擎在任何代码执行之前,会先读取函数声明,并在执行上下文中生成函数定义。而函数表达式必须等到代码执行到它那一行,才会在执行上下文中生成函数定义。来看下面的例子:

// 没问题
console.log(sum(10, 10));
const fn = function sum(num1, num2) {
  return num1 + num2;
}

以上代码可以正常运行,因为函数声明会在任何代码执行之前先被读取并添加到执行上下文。这个过程叫作函数声明提升(function declaration hoisting)。在执行代码时,JavaScript 引擎会先执行一遍扫描,把发现的函数声明提升到源代码树的顶部。因此即使函数定义出现在调用它们的代码之后,引擎也会把函数声明提升到顶部。如果把前面代码中的函数声明改为等价的函数表达式,那么执行的时候就会出错:

// 会出错
console.log(sum(10, 10));
let sum = function (num1, num2) {
  return num1 + num2;
};

上面的代码之所以会出错,是因为这个函数定义包含在一个变量初始化语句中,而不是函数声明中。这意味着代码如果没有执行到加粗的那一行,那么执行上下文中就没有函数的定义,所以上面的代码会出错。这并不是因为使用 let 而导致的,使用 var 关键字也会碰到同样的问题:

image.png

函数作为值

因为函数名在 ECMAScript 中就是变量,所以函数可以用在任何可以使用变量的地方。这意味着不仅可以把函数作为参数传给另一个函数,而且还可以在一个函数中返回另一个函数。【不带括号的叫访问函数

函数内部

在 ECMAScript 5 中,函数内部存在两个特殊的对象:arguments 和 this。ECMAScript 6 又新增了 new.target 属性。

arguments

arguments 对象前面讨论过多次了,它是一个类数组对象,包含调用函数时传入的所有参数。这个对象只有以 function 关键字定义函数(相对于使用箭头语法创建函数)时才会有。虽然主要用于包含函数参数,但 arguments 对象其实还有一个 callee 属性,是一个指向 arguments 对象所在函数的指针。来看下面这个经典的阶乘函数:

function factorial(num) {
  if (num <= 1) {
    return 1;
  } else {
    return num * factorial(num - 1);
  }
}

阶乘函数一般定义成递归调用的,就像上面这个例子一样。只要给函数一个名称,而且这个名称不会变,这样定义就没有问题。但是,这个函数要正确执行就必须保证函数名是 factorial,从而导致了紧密耦合。使用 arguments.callee 就可以让函数逻辑与函数名解耦:

factorial = function () {
  return 0;
};
console.log(trueFactorial(5)); // 120
console.log(factorial(5)); // 0
function factorial(num) {
  if (num <= 1) {
    return 1;
  } else {
    return num * arguments.callee(num - 1);
  }
}

这个重写之后的 factorial()函数已经用 arguments.callee 代替了之前硬编码的 factorial。这意味着无论函数叫什么名称,都可以引用正确的函数。考虑下面的情况:

let trueFactorial = factorial;
factorial = function () {
  return 0;
};
console.log(trueFactorial(5)); // 120
console.log(factorial(5)); // 0

这里,trueFactorial 变量被赋值为 factorial,实际上把同一个函数的指针又保存到了另一个位置。然后,factorial 函数又被重写为一个返回 0 的函数。如果像 factorial()最初的版本那样不使用 arguments.callee,那么像上面这样调用 trueFactorial()就会返回 0。

不过,通过将函数与名称解耦,trueFactorial()就可以正确计算阶乘,而 factorial()则只能返回 0。

this

另一个特殊的对象是 this,它在标准函数和箭头函数中有不同的行为。

在标准函数中,this 引用的是把函数当成方法调用的上下文对象,这时候通常称其为 this 值(在网页的全局上下文中调用函数时,this 指向 windows)。来看下面的例子:

window.color = 'red';
let o = {
  color: 'blue'
};
function sayColor() {
  console.log(this.color);
}
sayColor(); // 'red'
o.sayColor = sayColor;
o.sayColor(); // 'blue'

定义在全局上下文中的函数 sayColor()引用了 this 对象。这个 this 到底引用哪个对象必须到函数被调用时才能确定。因此这个值在代码执行的过程中可能会变。如果在全局上下文中调用sayColor(),这结果会输出"red",因为 this 指向 window,而 this.color 相当于 window.color。而在把 sayColor()赋值给 o 之后再调用 o.sayColor(),this 会指向 o,即 this.color 相当于o.color,所以会显示"blue"。

在箭头函数中,this 引用的是定义箭头函数的上下文。下面的例子演示了这一点。在对sayColor()的两次调用中,this 引用的都是 window 对象,因为这个箭头函数是在 window 上下文中定义的:

window.color = 'red';
let o = {
  color: 'blue'
};
let sayColor = () => console.log(this.color);
sayColor(); // 'red'
o.sayColor = sayColor;
o.sayColor(); // 'red'

注意 函数名只是保存指针的变量。因此全局定义的 sayColor()函数和 o.sayColor() 是同一个函数,只不过执行的上下文不同。

有读者知道,在事件回调或定时回调中调用某个函数时,this 值指向的并非想要的对象。此时将回调函数写成箭头函数就可以解决问题。这是因为箭头函数中的 this 会保留定义该函数时的上下文

function Kunkun() {
  this.name = '坤坤';
  setTimeout(() => console.log(this.name), 1000);
}
function Jige() {
  this.name = '鸡哥';
  setTimeout(function () { console.log(this.name); }, 1000);
}
new Kunkun(); // 坤坤
new Jige(); // 

caller

ECMAScript 5 也会给函数对象上添加一个属性:caller。虽然 ECMAScript 3 中并没有定义,但所有浏览器除了早期版本的 Opera 都支持这个属性。这个属性引用的是调用当前函数的函数,或者如果是在全局作用域中调用的则为 null。比如:

function outer() {
  inner();
}
function inner() {
  console.log(inner.caller);
}
outer();

以上代码会显示 outer()函数的源代码。这是因为 ourter()调用了 inner()inner.caller 指向 outer()。如果要降低耦合度,则可以通过 arguments.callee.caller 来引用同样的值:

function outer() {
  inner();
}
function inner() {
  console.log(arguments.callee.caller);
}
outer();

在严格模式下访问 arguments.callee 会报错。ECMAScript 5 也定义了 arguments.caller,但在严格模式下问它会报错,在非严格模式下则始终是 undefined。这是为了分清 arguments.caller和函数的 caller 而故意为之的。而作为对这门语言的安全防护,这些改动也让第三方代码无法检测同一上下文中运行的其他代码。

严格模式下还有一个限制,就是不能给函数的 caller 属性赋值,否则会导致错误。

new.target

ECMAScript 中的函数始终可以作为构造函数实例化一个新对象,也可以作为普通函数被调用。

ECMAScript 6 新增了检测函数是否使用 new 关键字调用的 new.target 属性。如果函数是正常调用的,则 new.target 的值是 undefined;如果是使用 new 关键字调用的,则 new.target 将引用被调用的 构造函数。

function Jige() {
  if (!new.target) {
    throw '鸡哥 must be instantiated using "new"'
  }
  console.log('鸡哥 instantiated using "new"');
}
new Jige(); // 鸡哥 instantiated using "new"
Jige(); // Error: 鸡哥 must be instantiated using "new"

09-25 路由器以及光猫的关系,桥接

这几天刚回老家,心思把家里的网线给修一修,就没怎么看书

我家这个是移动的,移动发的光猫什么都能干,导致光猫都很热,所以我的思路就是,让光猫只处理光信号=>电信号的过程,其他的事情,比方说拨号上网,无线网络处理都交给性能够的路由器来解决。

具体会另写一篇文章来记录一下分析以及操作的过程。


09-22 函数

没有重载

ECMAScript 函数不能像传统编程那样重载。在其他语言比如 Java 中,一个函数可以有两个定义,只要签名(接收参数的类型和数量)不同就行。如前所述,ECMAScript 函数没有签名,因为参数是由包含零个或多个值的数组表示的。没有函数签名,自然也就没有重载。

一个函数签名 (类型签名,或方法签名)  定义了 函数 或 方法 的输入与输出。

默认参数作用与暂时性死区

因为在求值默认参数时可以定义对象,也可以动态调用函数,所以函数参数肯定是在某个作用域中求值的。

给多个参数定义默认值实际上跟使用 let 关键字顺序声明变量一样。

function makeKing(name = 'Henry', numerals = 'VIII') {
  return `King ${name} ${numerals}`;
}
console.log(makeKing()); // King Henry VIII

这里的默认参数会按照定义它们的顺序依次被初始化。可以依照如下示例想象一下这个过程:

function makeKing() {
  let name = 'Henry';
  let numerals = 'VIII';
  return `King ${name} ${numerals}`;
}

因为参数是按顺序初始化的,所以后定义默认值的参数可以引用先定义的参数。看下面这个例子:

function makeKun(name = '坤哥', numerals = name) {
  return `大家好我是 ${name} ${numerals}`;
}
console.log(makeKun());  // 大家好我是 坤哥 坤哥

参数初始化顺序遵循“暂时性死区”规则,即前面定义的参数不能引用后面定义的。像这样就会抛出错误:

function makeKun(name = name1, n2 = name) { 
  return `大家好我是 ${name}`;
}
console.log(makeKun());  // Uncaught ReferenceError: name1 is not defined

参数也存在于自己的作用域中,它们不能引用函数体的作用域:

// 调用时不传第二个参数会报错
function makeKing(name = 'Henry', numerals = defaultNumeral) {
  let defaultNumeral = 'VIII';
  return `King ${name} ${numerals}`;
}

参数扩展与收集

参数扩展

在 ECMAScript 6 中,可以通过扩展操作符极为简洁地实现这种操作。对可迭代对象应用扩展操作符,并将其作为一个参数传入,可以将可迭代对象拆分,并将迭代返回的每个值单独传入。

let values = [1, 2, 3, 4];
function countArguments() {
  console.log(arguments.length);
}
countArguments(-1, ...values); // 5
countArguments(...values, 5); // 5
countArguments(-1, ...values, 5); // 6
countArguments(...values, ...[5, 6, 7]); // 7

收集参数

在构思函数定义时,可以使用扩展操作符把不同长度的独立参数组合为一个数组。这有点类似 arguments 对象的构造机制,只不过收集参数的结果会得到一个 Array 实例。

收集参数的前面如果还有命名参数,则只会收集其余的参数;如果没有则会得到空数组。因为收集参数的结果可变,所以只能把它作为最后一个参数:

// 不可以
function getProduct(...values, lastValue) {}
// 可以
function ignoreFirst(firstValue, ...values) {
console.log(values);
}
ignoreFirst(); // []
ignoreFirst(1); // []
ignoreFirst(1,2); // [2]
ignoreFirst(1,2,3); // [2, 3]

箭头函数虽然不支持 arguments 对象,但支持收集参数的定义方式,因此也可以实现与使用 arguments 一样的逻辑:

let getSum = (...values) => {
return values.reduce((x, y) => x + y, 0);
}
console.log(getSum(1,2,3)); // 6

另外,使用收集参数并不影响 arguments 对象,它仍然反映调用时传给函数的参数:

function getSum(...values) {
  console.log(arguments.length); // 3
  console.log(arguments); // [1, 2, 3]
  console.log(values); // [1, 2, 3]
}
console.log(getSum(1, 2, 3));

09-20 & 21 函数

函数名

因为函数名就是指向函数的指针,所以它们跟其他包含对象指针的变量具有相同的行为。

ECMAScript 6 的所有函数对象都会暴露一个只读的 name 属性,其中包含关于函数的信息。多数情况下,这个属性中保存的就是一个函数标识符,或者说是一个字符串化的变量名。即使函数没有名称,也会如实显示成空字符串。如果它是使用 Function 构造函数创建的,则会标识成"anonymous"

function foo() {}
let bar = function() {};
let baz = () => {};
console.log(foo.name); // foo
console.log(bar.name); // bar
console.log(baz.name); // baz
console.log((() => {}).name); //(空字符串)
console.log((new Function()).name); // anonymous

如果函数是一个获取函数、设置函数,或者使用 bind()实例化,那么标识符前面会加上一个前缀:

function foo() {}
console.log(foo.bind(null).name); // bound foo
let dog = {
  years: 1,
  get age() {
    return this.years;
  },
  set age(newAge) {
    this.years = newAge;
  },
};
let propertyDescriptor = Object.getOwnPropertyDescriptor(dog, 'age');
console.log(propertyDescriptor.get.name); // get age
console.log(propertyDescriptor.set.name); // set age

理解参数

ECMAScript 函数的参数跟大多数其他语言不同。ECMAScript 函数既不关心传入的参数个数,也不关心这些参数的数据类型。定义函数时要接收两个参数,并不意味着调用时就传两个参数。你可以传一个、三个,甚至一个也不传,解释器都不会报错。

之所以会这样,主要是因为 ECMAScript 函数的参数在内部表现为一个数组。函数被调用时总会接收一个数组,但函数并不关心这个数组中包含什么。如果数组中什么也没有,那没问题;如果数组的元素超出了要求,那也没问题。事实上,在使用 function 关键字定义(非箭头)函数时,可以在函数内部访问 arguments 对象,从中取得传进来的每个参数值。

arguments 对象是一个类数组对象(但不是 Array 的实例),因此可以使用中括号语法访问其中的元素(第一个参数是 arguments[0],第二个参数是 arguments[1])。而要确定传进来多少个参数,可以访问 arguments.length 属性。

在下面的例子中,sayHi()函数的第一个参数叫 name:

function sayHi(name, message) {
  console.log('Hello ' + name + ', ' + message);
}

可以通过 arguments[0]取得相同的参数值。因此,把函数重写成不声明参数也可以:

function sayHi() {
  console.log('Hello ' + arguments[0] + ', ' + arguments[1]);
}

在重写后的代码中,没有命名参数。name 和 message 参数都不见了,但函数照样可以调用。这就表明,ECMAScript 函数的参数只是为了方便才写出来的,并不是必须写出来的。与其他语言不同,在ECMAScript 中的命名参数不会创建让之后的调用必须匹配的函数签名。这是因为根本不存在验证命名参数的机制。

也可以通过 arguments 对象的 length 属性检查传入的参数个数。

还有一个必须理解的重要方面,那就是 arguments 对象可以跟命名参数一起使用,比如:

function doAdd(num1, num2) {
  if (arguments.length === 1) {
    console.log(num1 + 10);
  } else if (arguments.length === 2) {
    console.log(arguments[0] + num2);
  }
}

在这个 doAdd()函数中,同时使用了两个命名参数和 arguments 对象。命名参数 num1 保存着与arugments[0]一样的值,因此使用谁都无所谓。(同样,num2 也保存着跟 arguments[1]一样的值。)

arguments 对象的另一个有意思的地方就是,它的值始终会与对应的命名参数同步。来看下面的例子:

function doAdd(num1, num2) {
  arguments[1] = 10;
  console.log(arguments[0] + num2);
}
doAdd(11, 22); // 21

这个 doAdd()函数把第二个参数的值重写为 10。因为 arguments 对象的值会自动同步到对应的命名参数,所以修改 arguments[1]也会修改 num2 的值,因此两者的值都是 10。但这并不意味着它们都访问同一个内存地址,它们在内存中还是分开的,只不过会保持同步而已。

另外还要记住一点:如果只传了一个参数,然后把 arguments[1]设置为某个值,那么这个值并不会反映到第二个命名参数。这是因为 arguments 对象的长度是根据传入的参数个数,而非定义函数时给出的命名参数个数确定的。

function doAdd(num1, num2) {
  arguments[1] = 10;
  console.log(arguments[0] + num2);
}
doAdd(11); // Nan

严格模式下,arguments 会有一些变化。首先,像前面那样给 arguments[1]赋值不会再影响 num2的值。就算把 arguments[1]设置为 10,num2 的值仍然还是传入的值。其次,在函数中尝试重写 arguments 对象会导致语法错误。(代码也不会执行。)

箭头函数中的参数

如果函数是使用箭头语法定义的,那么传给函数的参数将不能使用 arguments 关键字访问,而只能通过定义的命名参数访问。

function foo() {
  console.log(arguments[0]);
}
foo(5); // 5
let bar = () => {
  console.log(arguments[0]);
};
bar(5); // ReferenceError: arguments is not defined

虽然箭头函数中没有 arguments 对象,但可以在包装函数中把它提供给箭头函数:

function foo() {
  let bar = () => {
    console.log(arguments[0]); // 5
  };
  bar();
}
foo(5);

ECMAScript 中的所有参数都按值传递的。不可能按引用传递参数。如果把对象作为参数传递,那么传递的值就是这个对象的引用。


09-19 函数:箭头函数

函数是 ECMAScript中最有意思的部分之一,这主要是因为函数实际上是对象。每个函数都是 Function 类型的实例,而 Function 也有属性和方法,跟其他引用类型一样。因为函数是对象,所以函数名就是指向函数对象的指针,而且不一定与函数本身紧密绑定。函数通常以函数声明的方式定义,比如:

function sum (num1, num2) {
  return num1 + num2;
}

另一种定义函数的语法是函数表达式。函数表达式与函数声明几乎是等价的:

let sum = function(num1, num2) {
  return num1 + num2;
};

这里,代码定义了一个变量 sum 并将其初始化为一个函数。注意 function 关键字后面没有名称,因为不需要。这个函数可以通过变量 sum 来引用。

注意这里的函数末尾是有分号的,与任何变量初始化语句一样。

还有一种定义函数的方式与函数表达式很像,叫作“箭头函数”(arrow function),如下所示:

let sum = (num1, num2) => {
  return num1 + num2;
};

最后一种定义函数的方式是使用 Function 构造函数。这个构造函数接收任意多个字符串参数,最后一个参数始终会被当成函数体,而之前的参数都是新函数的参数。来看下面的例子:

let sum = new Function("num1", "num2", "return num1 + num2"); // 不推荐

我们不推荐使用这种语法来定义函数,因为这段代码会被解释两次

  1. 第一次是将它当作常规ECMAScript 代码
  2. 第二次是解释传给构造函数的字符串。这显然会影响性能。不过,把函数想象为对象,把函数名想象为指针是很重要的。而上面这种语法很好地诠释了这些概念。

这几种实例化函数对象的方式之间存在微妙但重要的差别,本章后面会讨论。无论如何,通过其中任何一种方式都可以创建函数。

箭头函数

ECMAScript 6 新增了使用胖箭头(=>)语法定义函数表达式的能力。很大程度上,箭头函数实例化的函数对象与正式的函数表达式创建的函数对象行为是相同的。任何可以使用函数表达式的地方,都可以使用箭头函数

箭头函数简洁的语法非常适合嵌入函数的场景:

let ints = [1, 2, 3];
console.log(ints.map(function(i) { return i + 1; })); // [2, 3, 4]
console.log(ints.map((i) => { return i + 1 })); // [2, 3, 4]

如果只有一个参数,那也可以不用括号。只有没有参数,或者多个参数的情况下,才需要使用括号

// 以下两种写法都有效
let double = (x) => { return 2 * x; };
let triple = x => { return 3 * x; };
// 没有参数需要括号
let getRandom = () => { return Math.random(); };
// 多个参数需要括号
let sum = (a, b) => { return a + b; };
// 无效的写法:
let multiply = a, b => { return a * b; };

箭头函数也可以不用大括号,但这样会改变函数的行为。

使用大括号就说明包含“函数体”,可以在一个函数中包含多条语句,跟常规的函数一样。如果不使用大括号,那么箭头后面就只能有一行代码,比如一个赋值操作,或者一个表达式。而且,省略大括号会隐式返回这行代码的值

// 以下两种写法都有效,而且返回相应的值
let double = (x) => { return 2 * x; };
let triple = (x) => 3 * x;
// 可以赋值
let value = {};
let setName = (x) => x.name = "Matt";
setName(value);
console.log(value.name); // "Matt"
// 无效的写法:
let multiply = (a, b) => return a * b;

箭头函数虽然语法简洁,但也有很多场合不适用。箭头函数不能使用 arguments、super 和new.target,也不能用作构造函数。此外,箭头函数也没有 prototype 属性


09-18 继承:类继承

前几节深入讲解了如何只使用 ECMAScript 5 的特性来模拟类似于类(class-like)的行为。不难看出,各种策略都有自己的问题,也有相应的妥协。正因为如此,实现继承的代码也显得非常冗长和混乱。

为解决这些问题,ECMAScript 6 新引入的 class 关键字具有正式定义类的能力。类(class)是ECMAScript 中新的基础性语法糖结构,因此刚开始接触时可能会不太习惯。虽然 ECMAScript 6 类表面上看起来可以支持正式的面向对象编程,但实际上它背后使用的仍然是原型和构造函数的概念

类定义

与函数类型相似,定义类也有两种主要方式:类声明和类表达式。这两种方式都使用 class 关键字加大括号:

// 类声明
class Person {}
// 类表达式
const Animal = class {};

与函数表达式类似,类表达式在它们被求值前也不能引用。不过,与函数定义不同的是,虽然函数声明可以提升,但类定义不能

console.log(FunctionExpression); // undefined
var FunctionExpression = function () {};
console.log(FunctionExpression); // function() {}

console.log(FunctionDeclaration); // FunctionDeclaration() {}
function FunctionDeclaration() {}
console.log(FunctionDeclaration); // FunctionDeclaration() {}

console.log(ClassExpression); // undefined
var ClassExpression = class {};
console.log(ClassExpression); // class {}

console.log(ClassDeclaration); // ReferenceError: ClassDeclaration is not defined
class ClassDeclaration {}
console.log(ClassDeclaration); // class ClassDeclaration {}

另一个跟函数声明不同的地方是,函数受函数作用域限制,而类受块作用域限制:

{
  function FunctionDeclaration() {}
  class ClassDeclaration {}
}
console.log(FunctionDeclaration); // FunctionDeclaration() {}
console.log(ClassDeclaration); // ReferenceError: ClassDeclaration is not defined

类的构成

类可以包含构造函数方法、实例方法、获取函数、设置函数和静态类方法,但这些都不是必需的。空的类定义照样有效。默认情况下,类定义中的代码都在严格模式下执行。

与函数构造函数一样,多数编程风格都建议类名的首字母要大写,以区别于通过它创建的实例(比如,通过 class Foo {}创建实例 foo):

// 空类定义,有效
class Foo {}

// 有构造函数的类,有效
class Bar {
  constructor() {}
}

// 有获取函数的类,有效
class Baz {
  get myBaz() {}
}

// 有静态方法的类,有效
class Qux {
  static myQux() {}
}

类表达式的名称是可选的。在把类表达式赋值给变量后,可以通过 name 属性取得类表达式的名称字符串。但不能在类表达式作用域外部访问这个标识符。

let Person = class PersonName {
  identify() {
    console.log(Person.name, PersonName.name);
  }
};
let p = new Person();
p.identify(); // PersonName PersonName
console.log(Person.name); // PersonName
console.log(PersonName); // ReferenceError: PersonName is not defined

类构造函数

constructor 关键字用于在类定义块内部创建类的构造函数。方法名 constructor 会告诉解释器在使用 new 操作符创建类的新实例时,应该调用这个函数。构造函数的定义不是必需的,不定义构造函数相当于将构造函数定义为空函数。

1. 实例化

使用 new 操作符实例化 Person 的操作等于使用 new 调用其构造函数。唯一可感知的不同之处就是,JavaScript 解释器知道使用 new 和类意味着应该使用 constructor 函数进行实例化。

使用 new 调用类的构造函数会执行如下操作:

  1. 在内存中创建一个新对象。
  2. 这个新对象内部的[[Prototype]]指针被赋值为构造函数的 prototype 属性。
  3. 构造函数内部的 this 被赋值为这个新对象(即 this 指向新对象)。
  4. 执行构造函数内部的代码(给新对象添加属性)。
  5. 如果构造函数返回非空对象,则返回该对象;否则,返回刚创建的新对象。

类实例化时传入的参数会用作构造函数的参数。如果不需要参数,则类名后面的括号也是可选的:

class Person {
  constructor(name) {
    console.log(arguments.length);
    this.name = name || null;
  }
}
let p1 = new Person(); // 0
console.log(p1.name); // null
let p2 = new Person(); // 0
console.log(p2.name); // null
let p3 = new Person('Jake'); // 1
console.log(p3.name); // Jake

类构造函数与构造函数的主要区别是,调用类构造函数必须使用 new 操作符。而普通构造函数如果不使用 new 调用,那么就会以全局的 this(通常是 window)作为内部对象。调用类构造函数时如果忘了使用 new 则会抛出错误:

function Person() {}
class Animal {}
// 把 window 作为 this 来构建实例
let p = Person();
let a = Animal();
// TypeError: class constructor Animal cannot be invoked without 'new'

类构造函数没有什么特殊之处,实例化之后,它会成为普通的实例方法(但作为类构造函数,仍然要使用 new 调用)。因此,实例化之后可以在实例上引用它:

class Person {}

// 使用类创建一个新实例
let p1 = new Person();

p1.constructor();
// TypeError: Class constructor Person cannot be invoked without 'new'

// 使用对类构造函数的引用创建一个新实例
let p2 = new p1.constructor();

2. 把类当成特殊函数

ECMAScript 中没有正式的类这个类型。从各方面来看,ECMAScript 类就是一种特殊函数。声明一个类之后,通过 typeof 操作符检测类标识符,表明它是一个函数:

class Person {}
console.log(Person); // class Person {}
console.log(typeof Person); // function

类标识符有 prototype 属性,而这个原型也有一个 constructor 属性指向类自身:

class Person{}

console.log(Person.prototype);                        // { constructor: f() }
console.log(Person === Person.prototype.constructor); // true

如前所述,类本身具有与普通构造函数一样的行为。在类的上下文中,类本身在使用 new 调用时就会被当成构造函数。重点在于,类中定义的 constructor 方法不会被当成构造函数,在对它使用instanceof 操作符时会返回 false。但是,如果在创建实例时直接将类构造函数当成普通构造函数来使用,那么 instanceof 操作符的返回值会反转:

class Person {}
let p1 = new Person();
console.log(p1.constructor === Person); // true
console.log(p1 instanceof Person); // true
console.log(p1 instanceof Person.constructor); // false

let p2 = new Person.constructor();
console.log(p2.constructor === Person); // false
console.log(p2 instanceof Person); // false
console.log(p2 instanceof Person.constructor); // true

类是 JavaScript 的一等公民,因此可以像其他对象或函数引用一样把类作为参数传递

// 类可以像函数一样在任何地方定义,比如在数组中
let classList = [
  class {
    constructor(id) {
      this.id_ = id;
      console.log(`instance ${this.id_}`);
    }
  },
];
function createInstance(classDefinition, id) {
  return new classDefinition(id);
}
let foo = createInstance(classList[0], 3141); // instance 3141

与立即调用函数表达式相似,类也可以立即实例化:

// 因为是一个类表达式,所以类名是可选的
let p = new (class Foo {
  constructor(x) {
    console.log(x);
  }
})('bar'); // bar
console.log(p); // Foo {}

实例、原型和类成员

类的语法可以非常方便地定义应该存在于实例上的成员、应该存在于原型上的成员,以及应该存在于类本身的成员。

1. 实例成员

每次通过new 调用类标识符时,都会执行类构造函数。在这个函数内部,可以为新创建的实例(this)添加“自有”属性。至于添加什么样的属性,则没有限制。另外,在构造函数执行完毕后,仍然可以给实例继续添加新成员。

每个实例都对应一个唯一的成员对象,这意味着所有成员都不会在原型上共享:

class Person {
  constructor() {
    // 这个例子先使用对象包装类型定义一个字符串
    // 为的是在下面测试两个对象的相等性
    this.name = new String('Jack');
    this.sayName = () => console.log(this.name);
    this.nicknames = ['Jake', 'J-Dog'];
  }
}

let p1 = new Person(),
  p2 = new Person();

p1.sayName(); // Jack
p2.sayName(); // Jack

console.log(p1.name === p2.name); // false
console.log(p1.sayName === p2.sayName); // false
console.log(p1.nicknames === p2.nicknames); // false

p1.name = p1.nicknames[0];
p2.name = p2.nicknames[1];

p1.sayName(); // Jake
p2.sayName(); // J-Dog

2. 原型方法与访问器

为了在实例间共享方法,类定义语法把在类块中定义的方法作为原型方法。

class Person {
  constructor() {
    // 添加到 this 的所有内容都会存在于不同的实例上
    this.locate = () => console.log('instance');
  }
  // 在类块中定义的所有内容都会定义在类的原型上
  locate() {
    console.log('prototype');
  }
}
let p = new Person();
p.locate(); // instance
Person.prototype.locate(); // prototype

类定义也支持获取和设置访问器。语法与行为跟普通对象一样:

class Person {
  set name(newName) {
    this.name_ = newName;
  }
  get name() {
    return this.name_;
  }
}
let p = new Person();
p.name = 'Jake';
console.log(p.name); // Jake

3. 静态类方法

可以在类上定义静态方法。这些方法通常用于执行不特定于实例的操作,也不要求存在类的实例。

与原型成员类似,静态成员每个类上只能有一个。

静态类成员在类定义中使用 static 关键字作为前缀。在静态成员中,this 引用类自身。其他所 有约定跟原型成员一样:

class Person {
  constructor() {
    // 添加到 this 的所有内容都会存在于不同的实例上
    this.locate = () => console.log('instance', this);
  }
  // 定义在类的原型对象上
  locate() {
    console.log('prototype', this);
  }
  // 定义在类本身上
  static locate() {
    console.log('class', this);
  }
}
let p = new Person();
p.locate(); // instance, Person {}
Person.prototype.locate(); // prototype, {constructor: ... }
Person.locate(); // class, class Person {}

静态类方法非常适合作为实例工厂:

class Person {
  constructor(age) {
    this.age_ = age;
  }
  sayAge() {
    console.log(this.age_);
  }
  static create() {
    // 使用随机年龄创建并返回一个 Person 实例
    return new Person(Math.floor(Math.random() * 100));
  }
}
console.log(Person.create()); // Person { age_: ... }

4. 非函数原型和类成员

虽然类定义并不显式支持在原型或类上添加成员数据,但在类定义外部,可以手动添加:

class Person {
  sayName() {
    console.log(`${Person.greeting} ${this.name}`);
  }
}
// 在类上定义数据成员
Person.greeting = 'My name is'; 
// 在原型上定义数据成员
Person.prototype.name = 'Jake';
let p = new Person();
p.sayName(); // My name is Jake

注意 类定义中之所以没有显式支持添加数据成员,是因为在共享目标(原型和类)上添加可变(可修改)数据成员是一种反模式。一般来说,对象实例应该独自拥有通过 this 引用的数据。

5. 迭代器与生成器方法

类定义语法支持在原型和类本身上定义生成器方法:

class Person {
  // 在原型上定义生成器方法
  *createNicknameIterator() {
    yield 'Jack';
    yield 'Jake';
    yield 'J-Dog';
  }
  // 在类上定义生成器方法
  static *createJobIterator() {
    yield 'Butcher';
    yield 'Baker';
    yield 'Candlestick maker';
  }
}

let jobIter = Person.createJobIterator();
console.log(jobIter.next().value); // Butcher
console.log(jobIter.next().value); // Baker
console.log(jobIter.next().value); // Candlestick maker

let p = new Person();
let nicknameIter = p.createNicknameIterator();
console.log(nicknameIter.next().value); // Jack
console.log(nicknameIter.next().value); // Jake
console.log(nicknameIter.next().value); // J-Dog

因为支持生成器方法,所以可以通过添加一个默认的迭代器,把类实例变成可迭代对象:

class Person {
  constructor() {
    this.nicknames = ['Jack', 'Jake', 'J-Dog'];
  }
  *[Symbol.iterator]() {
    yield* this.nicknames.entries();
  }
}
let p = new Person();
for (let [idx, nickname] of p) {
  console.log(nickname);
}
// Jack
// Jake
// J-Dog

也可以只返回迭代器实例:

class Person {
  constructor() {
    this.nicknames = ['Jack', 'Jake', 'J-Dog'];
  }
  [Symbol.iterator]() {
    return this.nicknames.entries();
  }
}
let p = new Person();
for (let [idx, nickname] of p) {
  console.log(nickname);
}
// Jack
// Jake
// J-Dog

继承

ECMAScript 6 新增特性中最出色的一个就是原生支持了类继承机制。虽然类继承使用的是新语法,但背后依旧使用的是原型链

1. 继承基础

ES6 类支持单继承。使用 extends 关键字,就可以继承任何拥有[[Construct]]和原型的对象。很大程度上,这意味着不仅可以继承一个类,也可以继承普通的构造函数(保持向后兼容):

class Vehicle {}

// 继承类
class Bus extends Vehicle {}
let b = new Bus();
console.log(b instanceof Bus); // true
console.log(b instanceof Vehicle); // true
function Person() {}

// 继承普通构造函数
class Engineer extends Person {}
let e = new Engineer();
console.log(e instanceof Engineer); // true
console.log(e instanceof Person); // true

派生类都会通过原型链访问到类和原型上定义的方法。this 的值会反映调用相应方法的实例或者类:

class Vehicle {
  identifyPrototype(id) {
    console.log(id, this);
  }
  static identifyClass(id) {
    console.log(id, this);
  }
}
class Bus extends Vehicle {}
let v = new Vehicle();
let b = new Bus();
b.identifyPrototype('bus'); // bus, Bus {}
v.identifyPrototype('vehicle'); // vehicle, Vehicle {}
Bus.identifyClass('bus'); // bus, class Bus {}
Vehicle.identifyClass('vehicle'); // vehicle, class Vehicle {}

extends 关键字也可以在类表达式中使用,因此 let Bar = class extends Foo {} 是有效的语法。

2. 构造函数、HomeObject 和 super()

派生类的方法可以通过 super 关键字引用它们的原型。这个关键字只能在派生类中使用,而且仅限于类构造函数、实例方法和静态方法内部。在类构造函数中使用 super 可以调用父类构造函数。

class Vehicle {
  constructor() {
    this.hasEngine = true;
  }
}
class Bus extends Vehicle {
  constructor() {
    // 不要在调用 super()之前引用 this,否则会抛出 ReferenceError
    super(); // 相当于 super.constructor()
    console.log(this instanceof Vehicle); // true
    console.log(this); // Bus { hasEngine: true }
  }
}
new Bus();

在静态方法中可以通过 super 调用继承的类上定义的静态方法:

class Vehicle {
  static identify() {
    console.log('vehicle');
  }
}
class Bus extends Vehicle {
  static identify() {
    super.identify();
  }
}
Bus.identify(); // vehicle

ES6 给类构造函数和静态方法添加了内部特性[[HomeObject]],这个特性是一个指针,指向定义该方法的对象。这个指针是自动赋值的,而且只能在 JavaScript 引擎内部访问。super 始终会定义为[[HomeObject]]的原型。

在使用 super 时要注意几个问题:

  1. super 只能在派生类构造函数和静态方法中使用。
  2. 不能单独引用 super 关键字,要么用它调用构造函数,要么用它引用静态方法。
  3. 调用 super()会调用父类构造函数,并将返回的实例赋值给 this
  4. super()的行为如同调用构造函数,如果需要给父类构造函数传参,则需要手动传入。
    class Vehicle {
      constructor(licensePlate) {
        this.licensePlate = licensePlate;
      }
    }
    class Bus extends Vehicle {
      constructor(licensePlate) {
        super(licensePlate);
      }
    }
    console.log(new Bus('1337H4X')); // Bus { licensePlate: '1337H4X' }
    
  5. 如果没有定义类构造函数,在实例化派生类时会调用 super(),而且会传入所有传给派生类的参数。
    class Vehicle {
      constructor(licensePlate) {
        this.licensePlate = licensePlate;
      }
    }
    class Bus extends Vehicle {}
    console.log(new Bus('1337H4X')); // Bus { licensePlate: '1337H4X' }
    
  6. 在类构造函数中,不能在调用 super()之前引用 this。
     class Vehicle {}
     class Bus extends Vehicle {
       constructor() {
         console.log(this);
       }
     }
     new Bus();
     // ReferenceError: Must call super constructor in derived class
     // before accessing 'this' or returning from derived constructor
    
  7. 如果在派生类中显式定义了构造函数,则要么必须在其中调用 super(),要么必须在其中返回一个对象。

抽象基类

有时候可能需要定义这样一个类,它可供其他类继承但本身不会被实例化。虽然 ECMAScript 没有专门支持这种类的语法 ,但通过 new.target 也很容易实现。new.target 保存通过 new 关键字调用的类或函数。通过在实例化时检测 new.target 是不是抽象基类,可以阻止对抽象基类的实例化:

// 抽象基类
class Vehicle {
  constructor() {
    console.log(new.target);
    if (new.target === Vehicle) {
      throw new Error('Vehicle cannot be directly instantiated');
    }
  }
}
// 派生类
class Bus extends Vehicle {}
new Bus(); // class Bus {}
new Vehicle(); // class Vehicle {}
// Error: Vehicle cannot be directly instantiated

另外,通过在抽象基类构造函数中进行检查,可以要求派生类必须定义某个方法。因为原型方法在调用类构造函数之前就已经存在了,所以可以通过 this 关键字来检查相应的方法:

// 抽象基类
class Vehicle {
  constructor() {
    if (new.target === Vehicle) {
      throw new Error('Vehicle cannot be directly instantiated');
    }
    if (!this.foo) {
      throw new Error('Inheriting class must define foo()');
    }
    console.log('success!');
  }
}
// 派生类
class Bus extends Vehicle {
  foo() {}
}
// 派生类
class Van extends Vehicle {}
new Bus(); // success!
new Van(); // Error: Inheriting class must define foo()

继承内置类型

ES6 类为继承内置引用类型提供了顺畅的机制,开发者可以方便地扩展内置类型:

class SuperArray extends Array {
  shuffle() {
    // 洗牌算法
    for (let i = this.length - 1; i > 0; i--) {
      const j = Math.floor(Math.random() * (i + 1));
      [this[i], this[j]] = [this[j], this[i]];
    }
  }
}
let a = new SuperArray(1, 2, 3, 4, 5);
console.log(a instanceof Array); // true
console.log(a instanceof SuperArray);
// trueconsole.log(a); // [1, 2, 3, 4, 5]
a.shuffle();
console.log(a); // [3, 1, 4, 5, 2]

类混入

把不同类的行为集中到一个类是一种常见的 JavaScript 模式。虽然 ES6 没有显式支持多类继承,但通过现有特性可以轻松地模拟这种行为。

Object.assign()方法是为了混入对象行为而设计的。只有在需要混入类的行为时才有必要自己实现混入表达式。如果只是需要混入多个对象的属性,那么使用Object.assign()就可以了。

在下面的代码片段中,extends 关键字后面是一个 JavaScript 表达式。任何可以解析为一个类或一个构造函数的表达式都是有效的。这个表达式会在求值类定义时被求值:

class Vehicle {}
function getParentClass() {
  console.log('evaluated expression');
  return Vehicle;
}
class Bus extends getParentClass() {}
// 可求值的表达式

混入模式可以通过在一个表达式中连缀多个混入元素来实现,这个表达式最终会解析为一个可以被继承的类。如果 Person 类需要组合 A、B、C,则需要某种机制实现 B 继承 A,C 继承 B,而 Person再继承 C,从而把 A、B、C 组合到这个超类中。实现这种模式有不同的策略。

一个策略是定义一组“可嵌套”的函数,每个函数分别接收一个超类作为参数,而将混入类定义为这个参数的子类,并返回这个类。这些组合函数可以连缀调用,最终组合成超类表达式:

class Vehicle {}
let FooMixin = (Superclass) =>
  class extends Superclass {
    foo() {
      console.log('foo');
    }
  };
let BarMixin = (Superclass) =>
  class extends Superclass {
    bar() {
      console.log('bar');
    }
  };
let BazMixin = (Superclass) =>
  class extends Superclass {
    baz() {
      console.log('baz');
    }
  };
class Bus extends FooMixin(BarMixin(BazMixin(Vehicle))) {}
let b = new Bus();
b.foo(); // foo
b.bar(); // bar
b.baz(); // baz

通过写一个辅助函数,可以把嵌套调用展开:

class Vehicle {}
let FooMixin = (Superclass) =>
  class extends Superclass {
    foo() {
      console.log('foo');
    }
  };
let BarMixin = (Superclass) =>
  class extends Superclass {
    bar() {
      console.log('bar');
    }
  };
let BazMixin = (Superclass) =>
  class extends Superclass {
    baz() {
      console.log('baz');
    }
  };
function mix(BaseClass, ...Mixins) {
  return Mixins.reduce((accumulator, current) => current(accumulator), BaseClass);
}
class Bus extends mix(Vehicle, FooMixin, BarMixin, BazMixin) {}
let b = new Bus();
b.foo(); // foo
b.bar(); // bar
b.baz(); // baz

很多 JavaScript 框架(特别是 React)已经抛弃混入模式,转向了组合模式(把方法提取到独立的类和辅助对象中,然后把它们组合起来,但不使用继承)。这反映了那个众所周知的软件设计原则:“组合胜过继承(composition over inheritance)。”这个设计原则被很多人遵循,在代码设计中能提供极大的灵活性。