JavaScript-编程精解-Eloquent-第四版-二-

news/2025/3/13 11:33:39/文章来源:https://www.cnblogs.com/apachecn/p/18758536

JavaScript 编程精解(Eloquent)第四版(二)

译者:飞龙

协议:CC BY-NC-SA 4.0

第七章:对象的秘密生活

第四章介绍了JavaScript中的对象作为持有其他数据的容器。在编程文化中,面向对象编程是一套以对象为程序组织核心原则的技术。

尽管没有人真正同意它的确切定义,面向对象编程已经塑造了许多编程语言的设计,包括JavaScript。本章描述了这些思想如何应用于JavaScript。

抽象数据类型

面向对象编程的主要思想是将对象,或者更确切地说是对象类型,作为程序组织的单元。将程序设置为多个严格分离的对象类型提供了一种思考其结构的方式,从而实施某种纪律,防止所有内容交织在一起。

这样做的方法是将对象视为电动搅拌机或其他消费电器。设计和组装搅拌机的人需要进行专业工作,要求材料科学和电力知识。他们将所有这些都隐藏在光滑的塑料外壳中,这样只想搅拌煎饼面糊的人就不必担心这些——他们只需理解搅拌机可以操作的几个旋钮。

类似地,抽象数据类型对象类是一个子程序,可能包含任意复杂的代码,但暴露出一组有限的方法和属性,供与之合作的人使用。这允许大型程序由多个电器类型构建,限制了这些不同部分交织的程度,要求它们仅以特定方式相互作用。

如果在某个对象类中发现问题,通常可以修复或甚至完全重写,而不会影响程序的其他部分。更好的是,可能在多个不同的程序中使用对象类,避免了从头开始重建其功能的需要。你可以将JavaScript的内置数据结构,如数组和字符串,视为这样的可重用抽象数据类型。

每个抽象数据类型都有一个接口,这是外部代码可以对其执行的操作集合。超出该接口的任何细节都是封装的,视为该类型的内部内容,对程序的其他部分无关紧要。

甚至像数字这样的基本事物也可以被视为一个抽象数据类型,其接口允许我们对其进行加法、乘法、比较等操作。实际上,在经典的面向对象编程中,将单个对象作为主要组织单元的执着是有些不幸的,因为有用的功能片段通常涉及不同对象类紧密合作的情况。

方法

在JavaScript中,方法不过是持有函数值的属性。这是一个简单的方法:

function speak(line) {console.log(`The ${this.type} rabbit says '${line}'`);
}
let whiteRabbit = {type: "white", speak};
let hungryRabbit = {type: "hungry", speak};whiteRabbit.speak("Oh my fur and whiskers");
// → The white rabbit says 'Oh my fur and whiskers'
hungryRabbit.speak("Got any carrots?");
// → The hungry rabbit says 'Got any carrots?'

通常,一个方法需要对其被调用的对象执行某些操作。当一个函数被作为方法调用时——作为属性查找并立即调用,如object.method()——在其主体内被称为this的绑定自动指向其被调用的对象。

你可以把这看作是以不同于常规参数的方式传递给函数的额外参数。如果你想明确提供它,可以使用函数的call方法,该方法将this值作为第一个参数,并将后续参数视为常规参数。

speak.call(whiteRabbit, "Hurry");
// → The white rabbit says 'Hurry'

由于每个函数都有自己的this绑定,其值取决于调用的方式,因此在用function关键字定义的常规函数中,你无法引用包装作用域的this

箭头函数是不同的——它们不会绑定自己的this,但可以看到周围作用域的this绑定。因此,你可以执行如下代码,其中在局部函数内部引用了this

let finder = {find(array) {return array.some(v => v == this.value);},value: 5
};
console.log(finder.find([4, 5]));
// → true

在对象表达式中,像find(array)这样的属性是定义方法的一种简写方式。它创建了一个名为find的属性,并将一个函数作为其值。

如果我用function关键字为某个参数编写了代码,那么这段代码将无法工作。

原型

创建一个带有说话方法的兔子对象类型的一种方法是创建一个助手函数,该函数将兔子类型作为参数,并返回一个将其作为类型属性的对象,并在其说话属性中包含我们的说话函数。

所有兔子共享相同的方法。尤其对于具有多个方法的类型,如果能够以某种方式将类型的方法集中在一个地方,而不是逐个添加到每个对象中,那就太好了。

在JavaScript中,原型是实现这一点的方法。对象可以链接到其他对象,以神奇的方式获取其他对象所具有的所有属性。使用{}表示法创建的普通对象链接到一个名为Object.prototype的对象。

let empty = {};
console.log(empty.toString);
// → function toString(){...}
console.log(empty.toString());
// → [object Object]

看起来我们只是从一个空对象中提取了一个属性。但实际上,toString是存储在Object.prototype中的方法,意味着它在大多数对象中都是可用的。

当一个对象请求它没有的属性时,将会搜索其原型。如果原型中没有该属性,则会继续搜索原型的原型,依此类推,直到找到一个没有原型的对象(Object.prototype就是这样的对象)。

console.log(Object.getPrototypeOf({}) == Object.prototype);
// → true
console.log(Object.getPrototypeOf(Object.prototype));
// → null

正如你所猜的,Object.getPrototypeOf返回一个对象的原型。

许多对象并不直接以Object.prototype作为它们的原型,而是拥有另一个提供不同默认属性集的对象。函数源自Function.prototype,数组源自Array.prototype

console.log(Object.getPrototypeOf(Math.max) ==Function.prototype);
// → true
console.log(Object.getPrototypeOf([]) == Array.prototype);
// → true

这样的原型对象本身也将拥有一个原型,通常是Object.prototype,这样它仍然间接提供像toString这样的函数。

你可以使用Object.create来创建一个具有特定原型的对象。

let protoRabbit = {speak(line) {console.log(`The ${this.type} rabbit says '${line}'`);}
};
let blackRabbit = Object.create(protoRabbit);
blackRabbit.type = "black";
blackRabbit.speak("I am fear and darkness");
// → The black rabbit says 'I am fear and darkness'

“proto”兔子作为所有兔子共享属性的容器。个别兔子对象,如黑兔,包含仅适用于它自己的属性——在这种情况下是它的类型——并从其原型继承共享属性。

JavaScript的原型系统可以被解释为一种自由形式的抽象数据类型或类。定义了一种对象的形状——它具有哪些方法和属性。这样的对象称为该类的实例

原型对于定义所有类实例共享相同值的属性非常有用。每个实例不同的属性,例如我们的兔子的类型属性,需要直接存储在对象本身中。

要创建给定类的实例,你必须生成一个从适当原型派生的对象,但你需要确保该对象本身具有此类实例应该具备的属性。这就是构造函数的作用。

function makeRabbit(type) {let rabbit = Object.create(protoRabbit);rabbit.type = type;return rabbit;
}

JavaScript的类语法使定义这种类型的函数以及原型对象变得更容易。

class Rabbit {constructor(type) {this.type = type;}speak(line) {console.log(`The ${this.type} rabbit says '${line}'`);}
}

class关键字开始一个类声明,这使我们能够一起定义构造函数和一组方法。声明的花括号内可以编写任意数量的方法。这段代码的效果是定义一个名为Rabbit的绑定,持有一个运行构造函数代码的函数,并具有一个持有speak方法的原型属性。

这个函数不能像普通函数那样调用。在JavaScript中,构造函数通过在前面加上关键字new来调用。这样做会创建一个新的实例对象,其原型是来自函数的原型属性的对象,然后运行该函数,将this绑定到新对象,最后返回该对象。

let killerRabbit = new Rabbit("killer");

实际上,类是在2015年版JavaScript中引入的。任何函数都可以用作构造函数,而在2015年之前,定义类的方法是编写一个常规函数,然后操作其原型属性。

function ArchaicRabbit(type) {this.type = type;
}
ArchaicRabbit.prototype.speak = function(line) {console.log(`The ${this.type} rabbit says '${line}'`);
};
let oldSchoolRabbit = new ArchaicRabbit("old school");

因此,所有非箭头函数都以一个持有空对象的原型属性开头。

按照惯例,构造函数的名称首字母大写,以便能够与其他函数轻松区分。

理解原型与构造函数之间的关联方式(通过其原型属性)以及对象拥有原型的方式(可以通过Object.getPrototypeOf找到)之间的区别非常重要。构造函数的实际原型是Function.prototype,因为构造函数是函数。构造函数的原型属性保存通过它创建的实例所使用的原型。

console.log(Object.getPrototypeOf(Rabbit) ==Function.prototype);
// → true
console.log(Object.getPrototypeOf(killerRabbit) ==Rabbit.prototype);
// → true

构造函数通常会向此添加一些每个实例的属性。也可以在类声明中直接声明属性。与方法不同,这些属性是添加到实例对象中,而不是原型中。

class Particle {speed = 0;constructor(position) {this.position = position;}
}

类似于函数,类可以在语句和表达式中使用。当作为表达式使用时,它不会定义绑定,而只是将构造函数作为值生成。你可以在类表达式中省略类名。

let object = new class { getWord() { return "hello"; } };
console.log(object.getWord());
// → hello

私有属性

类通常会定义一些内部使用的属性和方法,这些不是它们接口的一部分。这些被称为私有属性,而与之相对的是公共属性,它们是对象外部接口的一部分。

要声明一个私有方法,在其名称前加一个#号。这样的函数只能在定义它们的类声明内部调用。

class SecretiveObject {#getSecret() {return "I ate all the plums";}interrogate() {let shallISayIt = this.#getSecret();return "never";}
}

当一个类没有声明构造函数时,它将自动获得一个空的构造函数。

如果你试图从类外调用#getSecret,会出现错误。它的存在完全隐藏在类声明内部。

要使用私有实例属性,必须先声明它们。常规属性可以通过简单的赋值创建,但私有属性必须在类声明中声明,才能被使用。

该类实现了一种获取小于给定最大数的随机整数的工具。它只有一个公共属性:getNumber

class RandomSource {#max;constructor(max) {this.#max = max;}getNumber() {return Math.floor(Math.random() * this.#max);}
}

重写派生属性

当你向一个对象添加属性时,无论它是否存在于原型中,该属性都会添加到对象本身。如果原型中已经有一个同名属性,那么这个属性将不再影响对象,因为它现在被对象自己的属性隐藏了。

Rabbit.prototype.teeth = "small";
console.log(killerRabbit.teeth);
// → small
killerRabbit.teeth = "long, sharp, and bloody";
console.log(killerRabbit.teeth);
// → long, sharp, and bloody
console.log((new Rabbit("basic")).teeth);
// → small
console.log(Rabbit.prototype.teeth);
// → small

以下图示描绘了这段代码运行后的情况。兔子和对象原型作为背景存在于killerRabbit后面,未在对象本身中找到的属性可以在这里查找。

https://github.com/OpenDocCN/geekdoc-js-zh/raw/master/docs/elqt-js-4e/img/f0102-01.jpg

重写存在于原型中的属性是一个有用的操作。正如兔子牙齿的例子所示,重写可以用来表达更通用对象类实例中的特殊属性,同时让非特殊对象从其原型中获取标准值。

重写还用于给标准函数和数组原型提供不同于基本对象原型的toString方法。

console.log(Array.prototype.toString ==Object.prototype.toString);
// → false
console.log([1, 2].toString());
// → 1,2

在数组上调用toString的结果类似于调用.join(","),它在数组中的值之间放置逗号。直接在数组上调用Object.prototype.toString会生成不同的字符串。该函数并不知道数组,因此它只是将单词object和类型名称放在方括号之间。

console.log(Object.prototype.toString.call([1, 2]));
// → [object Array]

映射

我们在前一章中看到单词映射用于通过将函数应用于元素来转换数据结构的操作。虽然令人困惑,在编程中同样的词用于一个相关但不同的概念。

映射(名词)是一种将值(键)与其他值关联的数据结构。例如,你可能想将名字映射到年龄。这是可以使用对象来实现的。

let ages = {Boris: 39,Liang: 22,Júlia: 62
};console.log(`Júlia is ${ages["Júlia"]}`);
// → Júlia is 62
console.log("Is Jack's age known?", "Jack" in ages);
// → Is Jack's age known? false
console.log("Is toString's age known?", "toString" in ages);
// → Is toString's age known? true

在这里,对象的属性名称是人名,属性值是他们的年龄。但我们确实没有在映射中列出任何名为toString的人。然而,由于普通对象继承自Object.prototype,看起来这个属性是存在的。

出于这个原因,使用普通对象作为映射是危险的。有几种可能的方法可以避免这个问题。首先,你可以创建没有原型的对象。如果你将null传递给Object.create,生成的对象将不继承自Object.prototype,可以安全地用作映射。

console.log("toString" in Object.create(null));
// → false

对象属性名称必须是字符串。如果你需要一个键无法轻易转换为字符串的映射——例如对象——你就无法使用对象作为映射。

幸运的是,JavaScript 提供了一个名为Map的类,正是为这个目的而编写。它存储一个映射,并允许任何类型的键。

let ages = new Map();
ages.set("Boris", 39);
ages.set("Liang", 22);
ages.set("Júlia", 62);console.log(`Júlia is ${ages.get("Júlia")}`);
// → Júlia is 62
console.log("Is Jack's age known?", ages.has("Jack"));
// → Is Jack's age known? false
console.log(ages.has("toString"));
// → false

setgethas方法是Map对象接口的一部分。编写一个可以快速更新和搜索大量值的数据结构并不容易,但我们不必担心这个。有人为我们做了这件事,我们可以通过这个简单的接口使用他们的工作。

如果你确实有一个普通对象,出于某种原因需要将其视为映射,知道Object.keys仅返回对象的自有键,而不返回原型中的键是很有用的。作为in运算符的替代,你可以使用Object.hasOwn函数,它会忽略对象的原型。

console.log(Object.hasOwn({x: 1}, "x"));
// → true
console.log(Object.hasOwn({x: 1}, "toString"));
// → false

多态

当你在对象上调用String函数(将值转换为字符串)时,它将调用该对象的toString方法以尝试从中创建一个有意义的字符串。我提到过,某些标准原型定义了自己的toString版本,以便创建一个包含比“[object Object]”更有用信息的字符串。你也可以自己做到这一点。

Rabbit.prototype.toString = function() {return `a ${this.type} rabbit`;
};console.log(String(killerRabbit));
// → a killer rabbit

这是一个强大理念的简单实例。当一段代码被编写用于处理具有特定接口的对象—在这种情况下是toString方法—任何恰好支持此接口的对象都可以插入代码中并能够与其一起工作。

这种技术被称为多态。多态代码可以与不同形状的值一起工作,只要它们支持它所期望的接口。

一个广泛使用的接口示例是数组类对象,它们具有一个包含数字的length属性和每个元素的编号属性。数组和字符串都支持这个接口,还有其他各种对象,其中一些我们将在关于浏览器的章节中看到。我们在第五章中的forEach实现可以在任何提供此接口的对象上工作。实际上,Array.prototype.forEach也是如此。

Array.prototype.forEach.call({length: 2,0: "A",1: "B"
}, elt => console.log(elt));
// → A
// → B

获取器、设置器和静态方法

接口通常包含普通属性,而不仅仅是方法。例如,Map对象有一个大小属性,它告诉你存储了多少个键。

这样的对象不必直接在实例中计算和存储这样的属性。即使是直接访问的属性也可能隐藏一个方法调用。这种方法称为getter,通过在对象表达式或类声明中的方法名前加上get来定义。

let varyingSize = {get size() {return Math.floor(Math.random() * 100);}
};console.log(varyingSize.size);
// → 73
console.log(varyingSize.size);
// → 49

每当有人读取这个对象的大小属性时,相关的方法就会被调用。当属性被写入时,你可以做类似的事情,使用一个setter

class Temperature {constructor(celsius) {this.celsius = celsius;}get fahrenheit() {return this.celsius * 1.8 + 32;}set fahrenheit(value) {this.celsius = (value - 32) / 1.8;}static fromFahrenheit(value) {return new Temperature((value - 32) / 1.8);}
}let temp = new Temperature(22);
console.log(temp.fahrenheit);
// → 71.6
temp.fahrenheit = 86;
console.log(temp.celsius);
// → 30

Temperature类允许你以摄氏度或华氏度读取和写入温度,但内部只存储摄氏度,并在华氏度的gettersetter中自动进行摄氏度之间的转换。

有时你希望将某些属性直接附加到构造函数上,而不是附加到原型上。这种方法将无法访问类实例,但可以例如用于提供创建实例的其他方式。

在类声明内部,方法或属性前面写有static的会存储在构造函数上。例如,Temperature类允许你使用Temperature.fromFahrenheit(100)来创建一个以华氏度表示的温度。

let boil = Temperature.fromFahrenheit(212);
console.log(boil.celsius);
// → 100

符号

我在第四章中提到过,for/of循环可以遍历几种数据结构。这是多态性的另一个例子——这样的循环期望数据结构暴露特定的接口,而数组和字符串做到了。我们也可以将这个接口添加到我们自己的对象上!但在我们做到这一点之前,我们需要简要了解一下符号类型。

多个接口可以为不同的事物使用相同的属性名称是可能的。例如,在类似数组的对象上,length指的是集合中元素的数量。但描述徒步路线的对象接口可以使用length来提供路线的米数。一个对象不可能同时符合这两个接口。

一个试图成为路由和类似数组的对象(也许是为了枚举它的途径点)有些牵强,而这种问题在实践中并不常见。不过,对于像迭代协议这样的内容,语言设计者需要一种真的不会与其他属性冲突的属性。因此,在 2015 年,符号被添加到语言中。

大多数属性,包括我们迄今为止看到的所有属性,都是用字符串命名的。但也可以使用符号作为属性名。符号是通过Symbol函数创建的值。与字符串不同,新创建的符号是唯一的——你不能创建同样的符号两次。

let sym = Symbol("name");
console.log(sym == Symbol("name"));
// → false
Rabbit.prototype[sym] = 55;
console.log(killerRabbit[sym]);
// → 55

传递给Symbol的字符串在转换为字符串时包含在内,并且可以在例如在控制台中显示时更容易识别符号。但除此之外没有其他意义——多个符号可能具有相同的名称。

符号既唯一又可用作属性名,使其适合定义可以与其他属性和平共存的接口,无论其他属性的名称是什么。

const length = Symbol("length");
Array.prototype[length] = 0;console.log([1, 2].length);
// → 2
console.log([1, 2][length]);
// → 0

通过在属性名称周围使用方括号,可以在对象表达式和类中包含符号属性。这会导致方括号之间的表达式被求值以生成属性名称,类似于方括号属性访问表示法。

let myTrip = {length: 2,0: "Lankwitz",1: "Babelsberg",[length]: 21500
};
console.log(myTrip[length], myTrip.length);
// → 21500 2

迭代器接口

提供给for/of循环的对象预期是可迭代的。这意味着它有一个以Symbol.iterator符号命名的方法(这是一个由语言定义的符号值,存储为Symbol函数的属性)。

当被调用时,该方法应该返回一个提供第二个接口的对象,迭代器。这实际上就是进行迭代的东西。它有一个next方法,返回下一个结果。该结果应该是一个具有value属性的对象,提供下一个值(如果有的话),以及一个done属性,当没有更多结果时为true,否则为false

请注意,nextvaluedone属性名是普通字符串,而不是符号。只有Symbol.iterator可能会被添加到许多不同对象中,是一个实际的符号。

我们可以直接使用这个接口。

let okIterator = "OK"[Symbol.iterator]();
console.log(okIterator.next());
// → {value: "O", done: false}
console.log(okIterator.next());
// → {value: "K", done: false}
console.log(okIterator.next());
// → {value: undefined, done: true}

让我们实现一个类似于第四章练习中链表的可迭代数据结构。这次我们将把列表写成一个类。

class List {constructor(value, rest) {this.value = value;this.rest = rest;}get length() {return 1 + (this.rest ? this.rest.length : 0);}static fromArray(array) {let result = null;for (let i = array.length - 1; i >= 0; i--) {result = new this(array[i], result);}return result;}
}

请注意,在静态方法中,这指向的是类的构造函数,而不是实例——在调用静态方法时没有实例存在。

遍历列表应该从头到尾返回所有列表元素。我们将为迭代器编写一个单独的类。

class ListIterator {constructor(list) {this.list = list;}next() {if (this.list == null) {return {done: true};}let value = this.list.value;this.list = this.list.rest;return {value, done: false};}
}

该类通过更新其列表属性来跟踪迭代列表的进度,以便在返回一个值时移动到下一个列表对象,并在该列表为空(null)时报告已完成。

让我们设置List类以便可以迭代。在本书中,我会偶尔使用事后原型操作来向类添加方法,以便各个代码片段保持小且自包含。在常规程序中,如果不需要将代码拆分成小片段,则可以直接在类中声明这些方法。

List.prototype[Symbol.iterator] = function() {return new ListIterator(this);
};

我们现在可以使用for/of循环遍历列表。

let list = List.fromArray([1, 2, 3]);
for (let element of list) {console.log(element);
}
// → 1
// → 2
// → 3

数组表示法和函数调用中的...语法同样适用于任何可迭代对象。例如,你可以使用[...value]来创建一个包含任意可迭代对象中元素的数组。

console.log([..."PCI"]);
// → ["P", "C", "I"]

继承

想象一下,我们需要一个与之前看到的List类非常相似的列表类型,但因为我们将一直请求它的长度,所以我们不希望每次都扫描它的其他部分。相反,我们希望在每个实例中存储长度以实现高效访问。

JavaScript的原型系统使得创建一个类成为可能,这个新类与旧类类似,但某些属性的定义不同。新类的原型源自旧原型,但为例如长度getter添加了新的定义。

在面向对象编程术语中,这被称为继承。新类从旧类继承属性和行为。

class LengthList extends List {#length;constructor(value, rest) {super(value, rest);this.#length = super.length;}get length() {return this.#length;}
}console.log(LengthList.fromArray([1, 2, 3]).length);
// → 3

使用extends这个词表明这个类不应该直接基于默认的Object原型,而应该基于其他类。这被称为超类。派生类是子类

要初始化一个LengthList实例,构造函数通过super关键字调用其超类的构造函数。这是必要的,因为如果这个新对象要(大致上)像一个List行为,它需要列表所具有的实例属性。

然后构造函数将列表的长度存储在一个私有属性中。如果我们在那里写this.length,类自己的getter将被调用,但这还不能

继承使我们能够在现有数据类型的基础上构建稍微不同的数据类型,工作量相对较小。它是面向对象传统的一个基本部分,与封装和多态并列。但虽然后两者现在通常被认为是很好的想法,继承则更具争议。

封装和多态可以用来分离代码片段,减少整个程序的复杂度,而继承则根本上将类联系在一起,造成更多的纠缠。当从一个类继承时,你通常需要了解它的工作原理,比简单使用它时知道的要多。继承可以是使某些类型的程序更简洁的有用工具,但它不应该是你首先使用的工具,而且你可能不应该主动寻找构建类层次结构(类的家族树)的机会。

instanceof操作符

有时了解一个对象是否是从特定类派生出来的很有用。为此,JavaScript提供了一个名为instanceof的二元操作符。

console.log(new LengthList(1, null) instanceof LengthList);
// → true
console.log(new LengthList(2, null) instanceof List);
// → true
console.log(new List(3, null) instanceof LengthList);
// → false
console.log([1] instanceof Array);
// → true

操作符能够透视继承类型,因此LengthListList的一个实例。该操作符也可以应用于像Array这样的标准构造函数。几乎每个对象都是Object的一个实例。

摘要

对象不仅仅是持有自己的属性。它们还有原型,原型是其他对象。只要它们的原型具有该属性,它们就会表现得像拥有那个属性一样。简单对象的原型是Object.prototype

构造函数是名称通常以大写字母开头的函数,可以与new操作符一起使用来创建新对象。新对象的原型将是构造函数的原型属性中找到的对象。您可以通过将所有给定类型的值共享的属性放入其原型来充分利用这一点。还有一种类的表示法,提供了一种清晰的方式来定义构造函数及其原型。

您可以定义getterssetters,在每次访问对象的属性时秘密调用方法。静态方法是存储在类的构造函数中的方法,而不是其原型中的方法。

instanceof操作符可以根据一个对象和一个构造函数,告诉您该对象是否是该构造函数的实例。

处理对象时一个有用的做法是为它们指定一个接口,并告诉大家应该只通过该接口与您的对象交互。构成您对象的其余细节现在被封装,隐藏在接口之后。您可以使用私有属性将对象的一部分隐藏于外部世界。

多于一种类型可以实现相同的接口。为使用接口编写的代码自动知道如何处理任何数量的提供该接口的不同对象。这被称为多态

在实现多个仅在某些细节上有所不同的类时,将新类作为现有类的子类编写,继承其部分行为可能会很有帮助。

练习

向量类型

编写一个类Vec,表示二维空间中的向量。它接受xy参数(数字),并将其保存到同名属性中。

Vec原型添加两个方法,plusminus,接受另一个向量作为参数,并返回一个新向量,该向量的xy值是两个向量(当前向量和参数向量)之和或之差。

向原型添加一个getter属性length,用于计算向量的长度——即点(x, y)到原点(0, 0)的距离。

标准JavaScript环境提供了另一种数据结构,称为Set。像Map的实例一样,Set保存一组值。与Map不同的是,它不将其他值与这些值关联——它仅跟踪哪些值是该集合的一部分。一个值在集合中只能出现一次——再次添加不会有任何效果。

编写一个名为Group的类(因为Set已经被占用了)。像Set一样,它具有adddeletehas方法。它的构造函数创建一个空的组,add方法将一个值添加到组中(但仅在该值不是成员时),delete方法从组中移除其参数(如果它是成员的话),而has方法返回一个布尔值,指示其参数是否是组的成员。

使用===运算符或其他等效的方法,例如indexOf,来判断两个值是否相同。

给这个类添加一个静态from方法,该方法以可迭代对象作为参数,并创建一个包含通过迭代该对象生成的所有值的组。

可迭代组

使前一个练习中的Group类可迭代。如果你对接口的确切形式不清楚,请参考第107页上的“迭代器接口”。

如果你使用数组来表示组的成员,不要仅仅返回通过调用数组的Symbol.iterator方法创建的迭代器。这虽然可行,但违背了本练习的目的。

如果你的迭代器在迭代过程中修改组时表现得很奇怪,也是可以的。

机器是否能够思考的问题与潜艇是否能够游泳的问题一样相关。

埃兹杰·迪克斯特拉计算机科学的威胁

https://github.com/OpenDocCN/geekdoc-js-zh/raw/master/docs/elqt-js-4e/img/f0114-01.jpg

第八章:项目:一个机器人

在“项目

本章的项目是构建一个自动机,一个在虚拟世界中执行任务的小程序。我们的自动机会是一个邮件投递机器人,负责拾取和投递包裹。

Meadowfield

Meadowfield村不大。它由11个地点和14条道路组成。可以用以下道路数组进行描述:

const roads = ["Alice's House-Bob's House",   "Alice's House-Cabin","Alice's House-Post Office",   "Bob's House-Town Hall","Daria's House-Ernie's House", "Daria's House-Town Hall","Ernie's House-Grete's House", "Grete's House-Farm","Grete's House-Shop",          "Marketplace-Farm","Marketplace-Post Office",     "Marketplace-Shop","Marketplace-Town Hall",       "Shop-Town Hall"
];

https://github.com/OpenDocCN/geekdoc-js-zh/raw/master/docs/elqt-js-4e/img/f0116-01.jpg

村庄中的道路网络形成一个是一个点(村庄中的地点)和它们之间的线(道路)的集合。这个将是我们的机器人移动的世界。

字符串数组的处理并不容易。我们感兴趣的是从给定地点可以到达的目的地。让我们将道路列表转换为一个数据结构,该结构为每个地点提供可以从那里到达的地点。

function buildGraph(edges) {let graph = Object.create(null);function addEdge(from, to) {if (from in graph) {graph[from].push(to);} else {graph[from] = [to];}}for (let [from, to] of edges.map(r => r.split("-"))) {addEdge(from, to);addEdge(to, from);}return graph;
}const roadGraph = buildGraph(roads);

给定一组边,buildGraph创建一个地图对象,为每个节点存储一个连接节点的数组。它使用split方法将道路字符串(形式为Start-End)转换为包含起始和结束的两个元素数组。

任务

我们的机器人将在村庄中移动。各处有包裹,每个包裹都寄往其他地方。当机器人遇到包裹时,它会拾取并在到达目的地时投递。

自动机必须在每个时刻决定下一步该去哪里。当所有包裹都送达后,它的任务就完成了。

为了能够模拟这个过程,我们必须定义一个能够描述它的虚拟世界。该模型告诉我们机器人在哪里,以及包裹在哪里。当机器人决定移动时,我们需要更新模型以反映新情况。

如果你在考虑面向对象编程,你的第一反应可能是为世界中的各种元素定义对象:为机器人定义一个类,为包裹定义一个类,可能还有一个为地点定义的类。这样可以持有描述其当前状态的属性,比如某个地点的包裹堆,这可以在更新世界时进行更改。

这是错误的。至少,通常是如此。某物听起来像对象并不意味着它应该在你的程序中成为对象。反射性地为应用程序中的每个概念编写类,往往会让你拥有一组相互关联的对象,每个对象都有其内部的、变化的状态。这类程序通常难以理解,因此容易出错。

相反,让我们将村庄的状态简化为定义它的最小值集合。这里包括机器人的当前位置和未投递包裹的集合,每个包裹都有当前位置和目的地地址。就这些。

在这个过程中,让我们确保在机器人移动时不改变这个状态,而是为移动后的情况计算一个状态。

class VillageState {constructor(place, parcels) {this.place = place;this.parcels = parcels;}move(destination) {if (!roadGraph[this.place].includes(destination)) {return this;} else {let parcels = this.parcels.map(p => {if (p.place != this.place) return p;return {place: destination, address: p.address};}).filter(p => p.place != p.address);return new VillageState(destination, parcels);}}
}

移动方法是动作发生的地方。它首先检查从当前位置到目的地是否有道路,如果没有,则返回旧状态,因为这不是一个有效的移动。

接下来,该方法创建一个新的状态,将目的地设为机器人的新位置。它还需要创建一组新的包裹——机器人携带的(位于机器人当前位置的)包裹需要移动到新位置。而寄往新位置的包裹需要被投递——也就是说,它们需要从未投递包裹的集合中移除。调用map处理移动,调用filter处理投递。

包裹对象在移动时不会被改变,而是被重新创建。移动方法给我们一个新的村庄状态,但完全保留了旧状态。

let first = new VillageState("Post Office",[{place: "Post Office", address: "Alice's House"}]
);
let next = first.move("Alice's House");console.log(next.place);
// → Alice's House
console.log(next.parcels);
// → []
console.log(first.place);
// → Post Office

这个移动使得包裹被投递,这在下一个状态中得以反映。但初始状态仍然描述了机器人位于邮局且包裹未投递的情况。

持久数据

不会改变的数据结构称为不可变持久。它们的行为与字符串和数字很相似,即它们始终保持原样,而不是在不同时间包含不同的内容。

JavaScript中,几乎所有东西都可以被改变,因此处理应保持不变的值需要一些克制。有一个名为Object.freeze的函数,可以改变一个对象,使对其属性的写入被忽略。如果你想小心的话,可以使用它来确保你的对象不会被改变。冻结确实需要计算机进行一些额外的工作,而被忽视的更新与执行错误的操作几乎同样可能会让人困惑。我通常更喜欢直接告诉人们某个对象不应该被修改,并希望他们能记住。

let object = Object.freeze({value: 5});
object.value = 10;
console.log(object.value);
// → 5

我为什么要特别避免改变对象,尽管语言显然期待我这么做?因为这有助于我理解我的程序。这再次涉及复杂性管理。当我系统中的对象是固定、稳定的事物时,我可以孤立地考虑对它们的操作——从给定的起始状态移动到爱丽丝的家总是产生相同的新状态。当对象随时间变化时,这会给这种推理增加全新的复杂性维度。

对于我们在本章构建的小型系统来说,我们可以处理这点额外的复杂性。但限制我们能构建什么样系统的最重要因素是我们能理解多少。任何使你的代码更容易理解的东西,都能让你构建出更具雄心的系统。

不幸的是,尽管理解建立在持久数据结构上的系统更容易,设计一个系统,尤其是在你的编程语言没有帮助的情况下,可能会更困难。我们将在本书中寻找使用持久数据结构的机会,但我们也会使用可变数据结构。

模拟

送货机器人观察世界并决定它想朝哪个方向移动。因此,我们可以说,机器人是一个接受VillageState对象并返回附近地点名称的函数。

因为我们希望机器人能够记住事物,以便它们可以制定和执行计划,所以我们也将它们的记忆传递给它们,并允许它们返回一个新的记忆。因此,机器人返回的东西是一个包含它想要移动的方向和一个记忆值的对象,该值将在下次调用时返回给它。

function runRobot(state, robot, memory) {for (let turn = 0;; turn++) {if (state.parcels.length == 0) {console.log(`Done in ${turn} turns`);break;}let action = robot(state, memory);state = state.move(action.direction);memory = action.memory;console.log(`Moved to ${action.direction}`);}
}

考虑一下机器人需要做些什么才能“解决”给定的状态。它必须通过访问每个有包裹的地点来收集所有包裹,然后通过访问每个包裹的地址来送递它们,但只能在收集完包裹后进行送递。

什么是可能有效的最笨拙策略?机器人可以在每次转弯时随机走一个方向。这意味着,它很可能最终会遇到所有的包裹,并在某个时刻到达包裹应该被送达的地方。

这可能看起来是这样的:

function randomPick(array) {let choice = Math.floor(Math.random() * array.length);return array[choice];
}function randomRobot(state) {return {direction: randomPick(roadGraph[state.place])};
}

请记住,Math.random()返回一个介于01之间的数字——但总是小于1。将这样的数字乘以数组的长度,然后应用Math.floor,便可以得到数组的随机索引。

由于这个机器人不需要记住任何东西,它忽略了第二个参数(记住,JavaScript函数可以在没有负面影响的情况下使用额外参数调用),并在返回的对象中省略了记忆属性。

要让这个复杂的机器人开始工作,我们首先需要一种方法来创建一个带有一些包裹的新状态。一个静态方法(在这里通过直接向构造函数添加属性来编写)是放置该功能的好地方。

VillageState.random = function(parcelCount = 5) {let parcels = [];for (let i = 0; i < parcelCount; i++) {let address = randomPick(Object.keys(roadGraph));let place;do {place = randomPick(Object.keys(roadGraph));} while (place == address);parcels.push({place, address});}return new VillageState("Post Office", parcels);
};

我们不希望包裹从它们被寄往的地方发送出去。因此,当获取到一个与地址相等的地方时,do循环会持续选择新的地点。

让我们启动一个虚拟世界。

runRobot(VillageState.random(), randomRobot);
// → Moved to Marketplace
// → Moved to Town Hall
// → ...
// → Done in 63 turns

机器人送递包裹需要经过很多次转弯,因为它的规划不够充分。我们会很快解决这个问题。

邮件卡车的路线

我们应该能够比随机机器人做得更好。一个简单的改进是借鉴现实世界邮递的工作方式。如果我们找到一条经过村庄所有地点的路线,机器人可以沿着这条路线运行两次,这样它就一定能完成任务。这是一条这样的路线(从邮局出发):

const mailRoute = ["Alice's House", "Cabin", "Alice's House", "Bob's House","Town Hall", "Daria's House", "Ernie's House","Grete's House", "Shop", "Grete's House", "Farm","Marketplace", "Post Office"
];

为了实现跟随路线的机器人,我们需要利用机器人的记忆。机器人将其路线的其余部分保存在记忆中,并在每次转弯时丢弃第一个元素。

function routeRobot(state, memory) {if (memory.length == 0) {memory = mailRoute;}return {direction: memory[0], memory: memory.slice(1)};
}

这个机器人已经快得多。它最多会经过26次转弯(两次13步的路线),但通常会更少。

寻路

不过,我并不认为盲目跟随固定路线是一种智能行为。如果机器人能根据实际需要做出的工作来调整自己的行为,它的工作效率将会更高。

为此,它必须能够有意识地朝着特定的包裹或需要投递包裹的地点移动。即使目标距离超过一步,这样做也需要某种寻路功能。

通过图找到一条路线的问题是一个典型的搜索问题。我们可以判断给定的解决方案(路线)是否有效,但我们不能像计算2 + 2那样直接计算出解决方案。相反,我们必须不断创建潜在的解决方案,直到找到一个有效的。

通过图的可能路线是无限的。但在从AB寻找路线时,我们只关注从A开始的路线。我们也不关心那些访问同一地点两次的路线——因为那些绝对不是任何地方的最高效路线。这就减少了寻路器需要考虑的路线数量。

事实上,由于我们主要关注最短路线,我们希望确保先查看短路线,再查看较长的路线。一个好的方法是“从起始点生长”路线,探索每一个尚未访问的可达地点,直到找到到达目标的路线。这样,我们只会探索那些潜在有趣的路线,而且我们知道找到的第一条路线是最短的路线(如果有多条路线,它就是其中之一)。

这里有一个实现这个功能的函数:

function findRoute(graph, from, to) {let work = [{at: from, route: []}];for (let i = 0; i < work.length; i++) {let {at, route} = work[i];for (let place of graph[at]) {if (place == to) return route.concat(place);if (!work.some(w => w.at == place)) {work.push({at: place, route: route.concat(place)});}}}
}

探索必须按正确的顺序进行——首先到达的地方必须首先被探索。我们不能在到达一个地方后立即探索,因为那意味着从那里到达的地方也会立即被探索,依此类推,尽管可能还有其他尚未探索的更短路径。

因此,该函数保持一个工作列表。这是一个包含接下来应探索地点的数组,以及到达那里的路线。它从起始位置和一个空路线开始。

搜索过程通过取出列表中的下一个项目并进行探索来进行,这意味着它查看从该地点出发的所有道路。如果其中一条是目标,则可以返回一条完成的路线。否则,如果我们之前没有查看过这个地点,就会将一个新项目添加到列表中。如果我们之前查看过,由于我们优先查看短路线,我们要么找到了一条更长的路线,要么找到了一条正好和现有路线一样长的路线,因此我们不需要再进行探索。

你可以将其可视化为从起始位置爬出的已知路线网络,均匀向各侧扩展(但绝不会回头缠绕在一起)。一旦第一条线程到达目标位置,该线程就会被追踪回起点,从而给我们提供路线。

我们的代码没有处理工作列表上没有更多工作项的情况,因为我们知道我们的图是连通的,这意味着每个位置都可以从所有其他位置到达。我们总是能够在两点之间找到一条路线,搜索不会失败。

function goalOrientedRobot({place, parcels}, route) {if (route.length == 0) {let parcel = parcels[0];if (parcel.place != place) {route = findRoute(roadGraph, place, parcel.place);} else {route = findRoute(roadGraph, place, parcel.address);}}return {direction: route[0], memory: route.slice(1)};
}

这个机器人使用它的内存值作为移动方向的列表,就像跟踪路线的机器人一样。每当这个列表为空时,它必须找出接下来该做什么。它取出未交付包裹中的第一个,如果那个包裹还没有被取走,就为其规划一条路线。如果包裹已经被取走,它仍然需要被交付,因此机器人会创建一条前往交付地址的路线。

这个机器人通常在大约16次回合内完成交付5个包裹的任务。这比routeRobot略好,但仍然显然不是最优的。我们将在接下来的练习中继续改进它。

练习

测量机器人

仅仅让机器人解决几个场景很难客观比较它们。也许某个机器人碰巧得到了一些较简单的任务或它擅长的任务,而另一个则没有。

编写一个compareRobots函数,接受两个机器人(及其初始内存)。它应该生成100个任务,并让这两个机器人解决每一个任务。完成后,它应输出每个机器人每个任务所用的平均步骤数。

为了公平起见,请确保将每个任务都交给两个机器人,而不是为每个机器人生成不同的任务。

机器人效率

你能写一个比目标导向机器人更快完成交付任务的机器人吗?如果你观察那个机器人的行为,它做了哪些显然愚蠢的事情?这些可以如何改进?

如果你解决了之前的练习,你可能想用你的compareRobots函数来验证你是否改善了机器人。

持久性组

在标准的JavaScript环境中,大多数数据结构并不太适合持久性使用。数组有sliceconcat方法,这使我们能够轻松创建新的数组而不损坏旧的数组。但例如,Set没有用于添加或移除项以创建新集合的方法。

编写一个新的类PGroup,类似于第六章中的Group类,存储一组值。像Group一样,它具有adddeletehas方法。然而,它的add方法应该返回一个新的 PGroup实例,添加给定成员,同时保留旧实例不变。同样,delete应该创建一个没有给定成员的新实例。

这个类应该适用于任何类型的值,而不仅仅是字符串。当处理大量值时,它不必高效。

构造函数不应该是类接口的一部分(尽管你肯定希望在内部使用它)。相反,有一个空实例PGroup.empty,可以用作起始值。

你为什么只需要一个PGroup.empty值,而不是每次都创建一个新的空映射的函数?

调试的难度是编写代码难度的两倍。因此,如果你尽可能聪明地编写代码,那么,从定义上讲,你就不够聪明去调试它。

—布赖恩·肯尼汉和P.J.普劳杰,程序设计风格元素

https://github.com/OpenDocCN/geekdoc-js-zh/raw/master/docs/elqt-js-4e/img/f0126-01.jpg

第九章:漏洞和错误

计算机程序中的缺陷通常被称为漏洞。程序员常常觉得将它们想象成一些偶然爬入我们工作的“小东西”会让人感觉良好。但实际上,当然是我们自己把它们放进去的。

如果程序是凝结的思维,我们可以大致将漏洞分为由于思维混乱引起的漏洞和在将思维转换为代码时引入的错误。前者通常比后者更难以诊断和修复。

语言

如果计算机对我们正在尝试做的事情了解得足够多,许多错误可以被自动指出。但在这里,JavaScript的宽松性反而成了障碍。它对绑定和属性的概念模糊到几乎无法在程序运行之前捕捉到拼写错误。即便如此,它仍然允许你在没有抱怨的情况下做一些显然无意义的事情,比如计算true * "monkey"

JavaScript会对一些事情进行抱怨。编写一个不符合语言语法的程序会立即导致计算机发出警告。其他事情,例如调用非函数的东西或在未定义值上查找属性,都会在程序尝试执行该操作时引发错误。

然而,通常情况下,你的无意义计算会仅仅产生NaN(不是一个数字)或未定义值,而程序则会愉快地继续运行,确信自己在做一些有意义的事情。这个错误只有在虚假值经过多个函数后才会显现出来。它可能根本不会触发错误,但会默默导致程序的输出错误。找到此类问题的源头可能会很困难。

查找程序中错误(漏洞)的过程称为调试

严格模式

JavaScript可以通过启用严格模式变得稍微严格。这可以通过在文件或函数体的顶部放置字符串"use strict"来实现。以下是一个示例:

function canYouSpotTheProblem() {"use strict";for (counter = 0; counter < 10; counter++) {console.log("Happy happy");}
}canYouSpotTheProblem();
// → ReferenceError: counter is not defined

模块中的代码(我们将在第十章中讨论)是自动严格的。旧的非严格行为仍然存在,只是因为某些旧代码可能依赖于此,语言设计者努力避免破坏任何现有程序。

通常情况下,当你忘记在绑定前加let时,比如示例中的counter,JavaScript会静默地创建一个全局绑定并使用它。在严格模式下,则会报告错误。这是非常有帮助的。但需要注意的是,当相关的绑定已经在作用域中存在时,这种方法将不起作用。在这种情况下,循环仍然会静默地覆盖绑定的值。

严格模式的另一个变化是,this绑定在未作为方法调用的函数中保持未定义值。当在非严格模式下进行这样的调用时,this引用全局作用域对象,该对象的属性是全局绑定。因此,如果你在严格模式下错误地调用了一个方法或构造函数,JavaScript会在尝试从this读取内容时产生错误,而不是高兴地写入全局作用域。

例如,考虑以下代码,它在没有new关键字的情况下调用构造函数,因此它的this引用新构造的对象:

function Person(name) { this.name = name; }
let ferdinand = Person("Ferdinand"); // Oops
console.log(name);
// → Ferdinand

Person的虚假调用成功了,但返回了未定义的值,并创建了全局绑定名称。在严格模式下,结果是不同的。

"use strict";
function Person(name) { this.name = name; }
let ferdinand = Person("Ferdinand"); // Forgot new
// → TypeError: Cannot set property 'name' of undefined

我们会立即被告知某些地方出了问题。这很有帮助。幸运的是,使用class语法创建的构造函数如果没有new被调用时总会抱怨,即使在非严格模式下,这也减少了问题的发生。

严格模式还做了一些其他事情。它不允许给函数多个相同名称的参数,并完全移除某些有问题的语言特性(例如with语句,由于错误严重而未在本书中进一步讨论)。

简而言之,在程序顶部放置"use strict"通常不会造成伤害,反而可能帮助你发现问题。

类型

一些语言希望在运行程序之前知道所有绑定和表达式的类型。当类型以不一致的方式使用时,它们会立即告诉你。JavaScript只在实际运行程序时考虑类型,即使在那时也常常尝试隐式地将值转换为它所期望的类型,因此并没有太大帮助。

尽管如此,类型提供了一个有用的框架来讨论程序。许多错误源于对输入或输出的值类型的混淆。如果你把这些信息写下来,你就不容易混淆。

你可以在上一章的findRoute函数之前添加如下注释,以描述它的类型:

// (graph: Object, from: string, to: string) => string[]
function findRoute(graph, from, to) {// ...
}

有多种不同的约定用于用类型注解JavaScript程序。

关于类型的一点是,它们需要引入自己的复杂性,以能够描述足够的代码以便有用。你认为返回数组中随机元素的randomPick函数的类型会是什么?你需要引入一个类型变量T,它可以代表任何类型,以便你可以为randomPick赋予类似(T[]) → T的类型(从一个T数组到一个T的函数)。

当程序的类型已知时,计算机可以为你检查这些类型,指出在程序运行前的错误。有几种JavaScript方言为语言添加了类型并进行检查。其中最流行的是TypeScript。如果你有兴趣为你的程序增加更多严谨性,我建议你试一试。

在本书中,我们将继续使用原始的、危险的、无类型的JavaScript代码。

测试

如果语言不会在很大程度上帮助我们找到错误,我们就必须通过运行程序

手动一次又一次地这样做是个很糟糕的主意。这不仅令人烦恼,而且往往效率低下,因为每次修改时要全面测试所有内容需要花费太多时间。

计算机擅长重复性任务,而测试就是理想的重复性任务。自动化测试是编写一个测试另一个程序的程序的过程。编写测试比手动测试需要多一点工作,但一旦你完成它,你就获得了一种超能力:你只需几秒钟就能验证你的程序在你编写测试的所有情况下仍然表现正常。当你破坏了某些东西时,你会立即注意到,而不是在之后的某个时刻偶然发现。

测试通常以小型标记程序的形式出现,用于验证代码的某些方面。例如,针对(标准的,可能已经被其他人测试过的)toUpperCase方法的一组测试可能如下所示:

function test(label, body) {if (!body()) console.log(`Failed: ${label}`);
}test("convert Latin text to uppercase", () => {return "hello".toUpperCase() == "HELLO";
});
test("convert Greek text to uppercase", () => {return "Χαίρετε".toUpperCase() == "ΧΑΙΡΕΤΕ";
});
test("don't convert case-less characters", () => {return "مرحبا".toUpperCase() == "مرحبا";
});

像这样编写测试往往会产生相当重复和笨拙的代码。幸运的是,有一些软件可以帮助你构建和运行测试集合(测试套件),通过提供一种适合表达测试的语言(以函数和方法的形式)以及在测试失败时输出有用信息。这些通常被称为测试运行器

一些代码比其他代码更容易测试。一般来说,代码与外部对象的交互越多,设置测试上下文就越困难。前一章中展示的编程风格,使用自包含的持久值而不是可变对象,往往更容易测试。

调试

一旦你注意到程序出现了问题,因为它表现不当或产生错误,下一步就是找出是什么问题。

有时这很明显。错误信息会指向程序的特定行,如果你查看错误描述和那行代码,通常能看到问题所在。

但并不总是如此。有时触发问题的行仅仅是一个地方,在那里一个不稳定的值以无效的方式被使用。如果你在前面的章节中解决过练习,你可能已经经历过这样的情况。

以下示例程序试图将一个整数转换为给定基数(十进制、二进制等)的字符串,通过反复提取最后一位数字,然后除以该数字以去掉这位数字。但它目前产生的奇怪输出表明它存在缺陷。

function numberToString(n, base = 10) {let result = "", sign = "";if (n < 0) {sign = "-";n = -n;}do {result = String(n % base) + result;n /= base;} while (n > 0);return sign + result;
}
console.log(numberToString(13, 10));
// → 1.5e-3231.3e-3221.3e-3211.3e-3201.3e-3191.3e-3181.3...

即使你已经看到问题,暂时假装你没有。我们知道我们的程序出现了故障,我们想找出原因。

在这里,你必须抵制随机更改代码的冲动,以看看这样是否会改善程序。相反,思考。分析发生了什么,并提出一个可能的理论来解释它。然后进行额外的观察以测试这个理论——或者,如果你还没有理论,进行额外的观察来帮助你形成一个。

在程序中放置一些战略性的console.log调用是获取程序正在做的事情的额外信息的好方法。在这种情况下,我们希望n依次取值131,然后0。让我们在循环开始时写出它的值。

13
1.3
0.13
0.013
...
1.5e-323

对的。将13除以10不会产生一个整数。我们实际上想要的是n = Math.floor(n / base),而不是n /= base,这样数字才能正确“向右移动”。

使用console.log来窥探程序行为的替代方法是使用浏览器的调试器功能。浏览器具有在代码的特定行上设置断点的能力。当程序执行到包含断点的行时,它会暂停,你可以检查此时绑定的值。我不会详细说明,因为不同浏览器的调试器各不相同,但可以查看浏览器的开发者工具或在网上搜索说明。

另一种设置断点的方法是在你的程序中包含一个调试器语句(仅由该关键字组成)。如果浏览器的开发者工具处于活动状态,程序将在遇到此类语句时暂停。

错误传播

不幸的是,并不是所有问题都能由程序员预防。如果你的程序以任何方式与外部世界通信,就有可能收到格式不正确的输入、过载工作量,或者网络失败。

如果你只是为自己编程,你可以选择忽略这些问题直到它们发生。但如果你构建的东西将被其他人使用,通常希望程序比单纯崩溃做得更好。有时正确的做法是坦然接受错误输入并继续运行。在其他情况下,最好是向用户报告发生了什么错误,然后放弃。在这两种情况下,程序必须积极响应问题。

假设你有一个函数promptNumber,它询问用户一个数字并返回它。如果用户输入“橙子”,它应该返回什么?

一种选择是让它返回一个特殊值。常见的选择包括nullundefined-1

function promptNumber(question) {let result = Number(prompt(question));if (Number.isNaN(result)) return null;else return result;
}console.log(promptNumber("How many trees do you see?"));

现在,任何调用promptNumber的代码都必须检查是否读取到了实际的数字,如果没有,则必须以某种方式进行恢复——也许是再次询问,或者填入一个默认值。或者它可以再次返回一个特殊值给的调用者,以指示它未能完成请求的操作。

在许多情况下,尤其是在错误常见且调用者应明确考虑这些错误时,返回一个特殊值是指示错误的好方法。然而,这也有其缺点。首先,如果函数已经可以返回每种可能的值怎么办?在这样的函数中,你需要做一些像将结果包装在对象中,以便能够区分成功和失败的事情,正如迭代器接口的下一个方法所做的那样。

function lastElement(array) {if (array.length == 0) {return {failed: true};} else {return {value: array[array.length - 1]};}
}

返回特殊值的第二个问题是,它可能导致尴尬的代码。如果一段代码调用promptNumber 10次,它必须检查10次是否返回了null。如果它发现null的反应只是简单地返回null本身,那么调用这个函数的代码也将必须进行检查,依此类推。

异常

当一个函数无法正常进行时,我们通常希望做的就是停止我们正在做的事情,并立即跳转到一个知道如何处理该问题的地方。这就是异常处理所做的。

异常是一种机制,使得在代码遇到问题时可以引发(或抛出)异常。异常可以是任何值。引发异常有点类似于函数的超级返回:它不仅跳出当前函数,还跳出所有调用它的函数,一直返回到开始当前执行的第一个调用。这被称为展开栈。你可能还记得在第三章提到的函数调用栈。异常在这个栈中快速向下移动,抛弃它遇到的所有调用上下文。

如果异常总是直接向下快速传递到栈底,它们就没有太大用处。它们只会提供一种新颖的方式来使你的程序崩溃。它们的强大在于你可以在栈上设置“障碍”,以捕获在下落过程中发生的异常。一旦你捕获了异常,就可以处理它以解决问题,然后继续运行程序。

这里有一个例子:

function promptDirection(question) {let result = prompt(question);if (result.toLowerCase() == "left") return "L";if (result.toLowerCase() == "right") return "R";throw new Error("Invalid direction: " + result);
}function look() {if (promptDirection("Which way?") == "L") {return "a house";} else {return "two angry bears";}
}try {console.log("You see", look());
} catch (error) {console.log("Something went wrong: " + error);
}

throw关键字用于引发异常。捕获异常是通过将一段代码包装在try块中,然后跟上关键字catch来实现的。当try块中的代码引发异常时,catch块将被评估,括号中的名称绑定到异常值上。在catch块完成后——或者如果try块没有问题地完成——程序将在整个try/catch语句下继续执行。

在这种情况下,我们使用Error构造函数创建我们的异常值。这是一个标准的 JavaScript 构造函数,用于创建一个具有消息属性的对象。Error的实例还收集了在创建异常时存在的调用栈的信息,所谓的栈追踪。这些信息存储在stack属性中,在尝试调试问题时非常有用:它告诉我们问题发生的函数以及哪些函数进行了失败的调用。

请注意,look函数完全忽略了prompt Direction可能出现错误的情况。这就是异常的重大优势:错误处理代码仅在错误发生的点和处理的点才是必要的。而中间的函数可以完全不再考虑这个问题。

好吧,几乎是这样……

异常后的清理

异常的效果是一种控制流。每个可能引发异常的动作,几乎每个函数调用和属性访问,都可能导致控制突然离开你的代码。

这意味着当代码有多个副作用时,即使其“常规”控制流看起来总会发生这些副作用,异常可能会阻止其中某些副作用的发生。

这里有一些非常糟糕的银行代码:

const accounts = {a: 100,b: 0,c: 20
};function getAccount() {let accountName = prompt("Enter an account name");if (!Object.hasOwn(accounts, accountName)) {throw new Error(`No such account: ${accountName}`);}return accountName;
}function transfer(from, amount) {if (accounts[from] < amount) return;accounts[from] -= amount;accounts[getAccount()] += amount;
}

转账函数将一笔资金从一个指定账户转移到另一个账户,并在此过程中询问另一个账户的名称。如果给出无效的账户名称,getAccount将抛出异常。

但是转账首先从账户中移走资金,然后调用getAccount,才将其添加到另一个账户。如果在这一点上被异常中断,资金就会消失。

这段代码本可以写得更聪明一些,例如在开始转移资金之前先调用getAccount。但这样的错误往往以更微妙的方式出现。即使是看似不会抛出异常的函数,在特殊情况下或因程序员的失误也可能会抛出异常。

解决这一问题的一种方法是减少副作用。同样,计算新值而不是更改现有数据的编程风格有助于减少问题。如果一段代码在创建新值的过程中中途停止运行,就不会破坏任何现有的数据结构,从而使恢复变得更容易。

由于这并不总是实际可行,try语句还有另一个特性:它们可以被finally块跟随,作为catch块的替代或补充。finally块表示“无论发生什么,在尝试运行try块中的代码后运行这段代码。”

function transfer(from, amount) {if (accounts[from] < amount) return;let progress = 0;try {accounts[from] -= amount;progress = 1;accounts[getAccount()] += amount;progress = 2;} finally {if (progress == 1) {accounts[from] += amount;}}
}

这个版本的函数跟踪其进度,如果在离开时发现它在创建不一致的程序状态时被中止,它会修复所造成的损害。

请注意,即使在try块中抛出异常时,finally代码仍会运行,但这并不会干扰异常。在finally块运行后,堆栈继续展开。

编写即使在意外情况下也能可靠运行的程序是很困难的。许多人根本不在意,因为异常通常是为特殊情况保留的,所以问题可能发生得非常少,以至于根本不会被注意到。这是好事还是坏事,取决于软件失败时造成的损害程度。

选择性捕获

当异常一路传递到底部而未被捕获时,它会被环境处理。这在不同环境中意味着不同的事情。在浏览器中,错误描述通常会写入 JavaScript 控制台(可以通过浏览器的工具或开发者菜单访问)。Node.js(我们将在第二十章中讨论的无浏览器 JavaScript 环境)对数据损坏更加谨慎。当发生未处理异常时,它会中止整个进程。

对于程序员的错误,通常允许错误通过是你能做的最好的选择。未处理异常是指示程序出现故障的一种合理方式,现代浏览器的 JavaScript 控制台会为你提供一些关于问题发生时调用栈上哪些函数的信息。

对于在日常使用中预期会发生的问题,崩溃并伴随未处理异常是一种糟糕的策略。

对语言的无效使用,例如引用一个不存在的绑定、在null上查找属性或调用非函数的东西,也会导致异常被抛出。这些异常也可以被捕获。

当进入catch体时,我们所知道的只是我们的try体中的某些东西导致了异常。但我们并不知道是什么造成了异常,或者是哪一个异常。

JavaScript(在一个相当明显的遗漏中)并未提供选择性捕获异常的直接支持:要么捕获所有异常,要么一个都不捕获。这使得假设你获得的异常正是你在编写catch块时所考虑的异常变得很诱人。

但这可能并非如此。某些其他假设可能被违反,或者你可能引入了导致异常的错误。以下是一个尝试持续调用promptDirection直到得到有效答案的示例:

for (;;) {try {let dir = promtDirection("Where?"); // ← Typo!console.log("You chose ", dir);break;} catch (e) {console.log("Not a valid direction. Try again.");}
}

for (;;)结构是一种故意创建不会自行终止的循环的方法。我们仅在给出有效方向时才会跳出循环。不幸的是,我们拼写错误了promptDirection,这将导致“未定义变量”错误。由于catch块完全忽略了其异常值(e),假设它知道问题出在哪里,因此错误地将绑定错误视为输入不正确。这不仅导致了无限循环,还“埋没”了关于拼写错误绑定的有用错误信息。

一般来说,除非是为了“路由”异常到某处(例如,通过网络告知另一个系统我们的程序崩溃了),否则不要随意捕获异常。即便如此,也要仔细考虑你可能隐藏的信息。

我们希望捕获特定类型的异常。我们可以通过在catch块中检查捕获的异常是否是我们感兴趣的类型,如果不是,就重新抛出它。但我们如何识别异常呢?

我们可以将其消息属性与我们预期的错误消息进行比较。但这是一种不可靠的写代码方式——我们将使用旨在供人类理解的信息(消息)来做出程序决策。一旦有人更改(或翻译)消息,代码将停止工作。

相反,让我们定义一个新的错误类型,并使用instanceof来识别它。

class InputError extends Error {}function promptDirection(question) {let result = prompt(question);if (result.toLowerCase() == "left") return "L";if (result.toLowerCase() == "right") return "R";throw new InputError("Invalid direction: " + result);
}

新的错误类扩展了Error。它没有定义自己的构造函数,这意味着它继承了Error的构造函数,该构造函数期望一个字符串消息作为参数。实际上,它什么都没有定义——这个类是空的。InputError对象的行为类似于Error对象,除了它们有一个不同的类,我们可以通过这个类来识别它们。

现在循环可以更仔细地捕捉这些错误。

for (;;) {try {let dir = promptDirection("Where?");console.log("You chose ", dir);break;} catch (e) {if (e instanceof InputError) {console.log("Not a valid direction. Try again.");} else {throw e;}}
}

这将仅捕获InputError的实例,并让不相关的异常通过。如果你重新引入拼写错误,将正确报告未定义绑定错误。

断言

断言是在程序内部进行的检查,用于验证某件事情是否如预期那样。它们的使用不是为了处理在正常操作中可能出现的情况,而是为了发现程序员的错误。

例如,如果firstElement被描述为一个不应在空数组上调用的函数,我们可能会这样写:

function firstElement(array) {if (array.length == 0) {throw new Error("firstElement called with []");}return array[0];
}

现在,当你错误使用它时,这将使你的程序立刻崩溃,而不是静默地返回undefined(当读取一个不存在的数组属性时得到的结果)。这降低了此类错误被忽视的可能性,并使得发生时更容易找到它们的原因。

我不建议尝试为每种可能的错误输入编写断言。那会是一项巨大的工作,并且会导致代码非常嘈杂。你应该将断言保留给那些容易犯的错误(或者是你发现自己经常犯的错误)。

概述

编程的重要部分是发现、诊断和修复错误。如果你有一个自动化测试套件或在程序中添加断言,问题可能会变得更容易被注意到。

由程序控制之外的因素引起的问题通常应该积极规划。有时,当问题可以在本地处理时,特殊返回值是跟踪它们的好方法。否则,异常可能更为合适。

抛出异常会导致调用栈被展开,直到下一个封闭的try/catch块或者栈底。异常值将被传递给捕获它的catch块,该块应该验证它实际上是预期的异常类型,然后对其进行处理。为了帮助解决异常引起的不可预测的控制流,可以使用finally块以确保在块结束时某段代码总是执行。

练习

重试

假设你有一个函数primitiveMultiply,在20%的情况下会乘以两个数字,而在其他80%的情况下会抛出类型为MultiplicatorUnitFailure的异常。编写一个函数来封装这个笨拙的函数,持续尝试直到调用成功,之后返回结果。

确保你只处理你试图处理的异常。

锁定的盒子

考虑以下(相当人为的)对象:

const box = new class {locked = true;#content = [];unlock() { this.locked = false; }lock() { this.locked = true; }get content() {if (this.locked) throw new Error("Locked!");return this.#content;}
};

这是一个带锁的盒子。盒子里有一个数组,但只有在盒子解锁时才能访问。

编写一个名为withBoxUnlocked的函数,接受一个函数值作为参数,解锁盒子,运行该函数,然后确保在返回之前盒子再次上锁,无论参数函数是正常返回还是抛出异常。

为了额外加分,确保当调用withBoxUnlocked时,如果盒子已经解锁,盒子保持解锁状态。

一些人在面对问题时,会想“我知道,我会使用正则表达式。”现在他们有两个问题。

—杰米·扎温斯基

https://github.com/OpenDocCN/geekdoc-js-zh/raw/master/docs/elqt-js-4e/img/f0140-01.jpg

第十章:正则表达式

编程工具和技术以一种混乱的进化方式生存和传播。并不是最佳或最聪明的工具获胜,而是那些在正确的细分市场中足够有效或恰好与其他成功技术整合的工具。

在本章中,我将讨论这样一个工具,正则表达式。正则表达式是一种描述字符串数据模式的方法。它们形成了一种小而独立的语言,属于 JavaScript 以及许多其他语言和系统。

正则表达式既十分笨拙又极其有用。它们的语法晦涩,而 JavaScript 为它们提供的编程接口又笨重。但它们是检查和处理字符串的强大工具。正确理解正则表达式将使你成为更有效的程序员。

创建正则表达式

正则表达式是一种对象。它可以通过RegExp构造函数构造,或通过用正斜杠(/)字符括起模式来作为文字值书写。

let re1 = new RegExp("abc");
let re2 = /abc/;

这两个正则表达式对象表示相同的模式:一个a字符后跟一个b,再后跟一个c

使用RegExp构造函数时,模式被写成普通字符串,因此反斜杠的通常规则适用。

第二种记法中,模式出现在斜杠字符之间,对反斜杠的处理

let aPlus = /A\+/;

匹配测试

正则表达式对象有许多方法。最简单的方法是test。如果你传递给它一个字符串,它将返回一个布尔值,告诉你该字符串是否包含模式的匹配。

console.log(/abc/.test("abcde"));
// → true
console.log(/abc/.test("abxde"));
// → false

仅由非特殊字符组成的正则表达式简单地表示该字符序列。如果abc出现在我们测试的字符串中的任何位置(不仅仅是在开始处),测试将返回true

字符集

检查一个字符串是否包含abc也可以通过调用indexOf来完成。正则表达式之所以有用,是因为它们允许我们描述更复杂的模式。

假设我们想匹配任何数字。在正则表达式中,将一组字符放在方括号之间,使得该部分表达式匹配方括号中的任何字符。

以下两个表达式匹配所有包含数字的字符串:

console.log(/[0123456789]/.test("in 1992"));
// → true
console.log(/[0-9]/.test("in 1992"));
// → true

在方括号内,两个字符之间的连字符(-)可以用来表示字符范围,其顺序由字符的 Unicode 编号决定。字符 0 到 9 在这个排序中彼此相邻(编码 48 到 57),因此[0-9]包含了所有数字,并且匹配任何数字。

一些常见的字符组有其自己的内置快捷方式。数字就是其中之一:\d的含义与[0-9]相同。

\d 任何数字字符
\w 一个字母数字字符(“单词字符”)
\s 任何空白字符(空格、制表符、换行符等)
\D 一个不是数字的字符
\W 一个非字母数字字符
\S 一个非空白字符
. 除换行符外的任何字符

你可以用以下表达式匹配日期和时间格式,如01-30-2003 15:20

let dateTime = /\d\d-\d\d-\d\d\d\d \d\d:\d\d/;
console.log(dateTime.test("01-30-2003 15:20"));
// → true
console.log(dateTime.test("30-jan-2003 15:20"));
// → false

那个正则表达式看起来完全糟糕,不是吗?其中一半是反斜杠,产生的背景噪声使得实际表达的模式很难被识别。稍后我们会看到这个表达式的稍微改进版本。

这些反斜杠代码也可以在方括号内使用。例如,[\d.]表示任何数字或句点字符。句点本身在方括号中失去了特殊含义。其他特殊字符也是如此,例如加号(+)

反转一组字符——即表达你想匹配任何除了该组中的字符外的字符——可以在开括号后写一个插入符号(^)。

let nonBinary = /[⁰¹]/;
console.log(nonBinary.test("1100100010100110"));
// → false
console.log(nonBinary.test("0111010112101001"));
// → true

国际字符

由于 JavaScript 最初的简单实现以及这种简单方法后来被视为标准行为,JavaScript 的正则表达式在处理不出现在英语中的字符时显得相当愚蠢。例如,在 JavaScript 的正则表达式看来,“单词字符”仅是拉丁字母表中的 26 个字符(大小写皆可)、十进制数字,以及出于某种原因的下划线字符。像éβ这样的字符,虽然绝对是单词字符,却不会匹配\w(而且会匹配大写的\W,即非单词类别)。

由于一个奇怪的历史偶然,\s(空白符)没有这个问题,它匹配 Unicode 标准认为的所有空白字符,包括不换行空格和蒙古元音分隔符等。

在正则表达式中可以使用\p来匹配 Unicode 标准赋予特定属性的所有字符。这使我们能够以更广泛的方式匹配字母。然而,由于与原始语言标准的兼容性,只有在正则表达式后面加上u字符(表示 Unicode)时,这些字符才能被识别。

\p{L} 任何字母
\p{N} 任何数字字符
\p{P} 任何标点符号字符
\P{L} 任何非字母(大写P表示反转)
\p{Script=Hangul} 给定脚本中的任何字符(参见 第五章)

使用\w进行文本处理,可能需要处理非英语文本(甚至包含借用词如cliché的英语文本)是一种风险,因为它不会将像é这样的字符视为字母。尽管它们通常更冗长,但\p属性组更加稳健。

console.log(/\p{L}/u.test("α"));
// → true
console.log(/\p{L}/u.test("!"));
// → false
console.log(/\p{Script=Greek}/u.test("α"));
// → true
console.log(/\p{Script=Arabic}/u.test("α"));
// → false

另一方面,如果你是为了对数字执行某些操作而匹配数字,通常确实需要\d来匹配数字,因为将任意数字字符转换为 JavaScript 数字并不是像Number这样的函数能为你做到的。

重复模式的部分

现在我们知道如何匹配单个数字。如果我们想匹配一个完整的数字——一个或多个数字的序列呢?

当你在正则表达式中在某个内容后加上加号(+)时,表示该元素可以重复多次。因此,/\d+/匹配一个或多个数字字符。

console.log(/'\d+'/.test("'123'"));
// → true
console.log(/'\d+'/.test("''"));
// → false
console.log(/'\d*'/.test("'123'"));
// → true
console.log(/'\d*'/.test("''"));
// → true

星号(*)的含义类似,但也允许模式零次匹配。后面带有星号的内容不会阻止模式匹配——如果找不到任何合适的文本匹配,它只会匹配零个实例。

问号(?)使模式的部分可选,意味着它可以出现零次或一次。在下面的示例中,u字符可以出现,但当它缺失时,模式仍然匹配:

let neighbor = /neighbou?r/;
console.log(neighbor.test("neighbour"));
// → true
console.log(neighbor.test("neighbor"));
// → true

要表示模式应该出现的精确次数,可以使用大括号。在元素后面加上{4},例如,要求它恰好出现四次。也可以通过这种方式指定范围:{2,4}意味着元素必须至少出现两次,最多四次。

这是日期和时间模式的另一个版本,允许单个和双个数字的天、月和小时。它也略微更易于解读。

let dateTime = /\d{1,2}-\d{1,2}-\d{4} \d{1,2}:\d{2}/;
console.log(dateTime.test("1-30-2003 8:45"));
// → true

使用大括号时,可以通过省略逗号后的数字来指定开放范围。例如,{5,}意味着五次或更多次。

子表达式分组

要在多个元素上同时使用*+这样的运算符,必须使用括号。被括号包围的正则表达式的一部分在后续运算符的考虑下算作一个单一元素。

let cartoonCrying = /boo+(hoo+)+/i;
console.log(cartoonCrying.test("Boohoooohoohooo"));
// → true

第一个和第二个+字符仅适用于boohoo中的第二个o。第三个+适用于整个组(hoo+),匹配一个或多个这样的序列。

示例中表达式末尾的i使该正则表达式对大小写不敏感,即使模式本身全部为小写,也允许匹配输入字符串中的大写B

匹配和分组

test方法是匹配正则表达式的最简单方式。它只告诉你是否匹配,而没有其他信息。正则表达式还有一个exec(执行)方法,如果未找到匹配项,则返回null,否则返回一个包含匹配信息的对象。

let match = /\d+/.exec("one two 100");
console.log(match);
// → ["100"]
console.log(match.index);
// → 8

exec返回的对象有一个index属性,告诉我们匹配在字符串中开始的位置。除此之外,该对象看起来(实际上也是)是一个字符串数组,其中第一个元素是匹配的字符串。在前面的例子中,这就是我们要寻找的数字序列。

字符串值有一个match方法,其行为类似。

console.log("one two 100".match(/\d+/));
// → ["100"]

当正则表达式包含用括号分组的子表达式时,匹配这些组的文本也会出现在数组中。整个匹配总是第一个元素。下一个元素是第一个组(即开括号在表达式中最先出现的那个)的匹配部分,然后是第二组,以此类推。

let quotedText = /'([^']*)'/;
console.log(quotedText.exec("she said 'hello'"));
// → ["'hello'", "hello"]

当一个组根本没有被匹配时(例如,后面跟着问号),其在输出数组中的位置将是undefined。当一个组被多次匹配时(例如,后面跟着+),只有最后一次匹配会出现在数组中。

console.log(/bad(ly)?/.exec("bad"));
// → ["bad", undefined]
console.log(/(\d)+/.exec("123"));
// → ["123", "3"]

如果你想单纯将括号用于分组,而不希望它们出现在匹配的数组中,可以在开括号后加上?:

console.log(/(?:na)+/.exec("banana"));
// → ["nana"]

组对于提取字符串的一部分是很有用的。如果我们不仅想验证一个字符串是否包含日期,还想提取它并构建一个表示它的对象,我们可以在数字模式周围加上括号,并直接从exec的结果中提取日期。

但首先我们将简要讨论 JavaScript 中表示日期和时间值的内置方法。

日期类

JavaScript 有一个标准的日期类用于表示日期,或者更准确地说,表示时间点。如果你仅仅使用new创建一个日期对象,你将获得当前的日期和时间。

console.log(new Date());
// → Fri Feb 02 2024 18:03:06 GMT+0100 (CET)

你也可以为特定时间创建一个对象。

console.log(new Date(2009, 11, 9));
// → Wed Dec 09 2009 00:00:00 GMT+0100 (CET)
console.log(new Date(2009, 11, 9, 12, 59, 59, 999));
// → Wed Dec 09 2009 12:59:59 GMT+0100 (CET)

JavaScript 使用一种约定,即月份编号从零开始(所以 12 月是 11),而日期编号从一开始。这让人感到困惑和愚蠢。请小心。

最后四个参数(小时、分钟、秒和毫秒)是可选的,未给定时默认为零。

时间戳以自 1970 年开始的毫秒数存储,使用 UTC 时区。这遵循了“Unix 时间”设定的约定,该约定大约在那个时候被发明。对于 1970 年之前的时间,可以使用负数。日期对象上的getTime方法返回这个数字。它的数值很大,可以想象。

console.log(new Date(2013, 11, 19).getTime());
// → 1387407600000
console.log(new Date(1387407600000));
// → Thu Dec 19 2013 00:00:00 GMT+0100 (CET)

如果你给Date构造函数一个单一的参数,那么该参数会被视为这样的毫秒计数。你可以通过创建一个新的Date对象并调用getTime来获取当前的毫秒计数,或者通过调用Date.now函数。

日期对象提供了getFullYeargetMonthgetDategetHoursgetMinutesgetSeconds等方法来提取它们的组成部分。除了getFullYear外,还有getYear,它返回的是年份减去 1900(如 98 或 125),这个方法大多无用。

将感兴趣的表达式部分用括号括起来后,我们现在可以从字符串创建日期对象。

function getDate(string) {let [_, month, day, year] =/(\d{1,2})-(\d{1,2})-(\d{4})/.exec(string);return new Date(year, month - 1, day);
}
console.log(getDate("1-30-2003"));
// → Thu Jan 30 2003 00:00:00 GMT+0100 (CET)

下划线(_)绑定被忽略,仅用于跳过exec返回的数组中的完整匹配元素。

边界和前瞻

不幸的是,getDate也会愉快地从字符串“100-1 -30000”中提取日期。匹配可以发生在字符串的任何地方,因此在这种情况下,它会从第二个字符开始,到倒数第二个字符结束。

如果我们想要强制匹配必须覆盖整个字符串,可以添加标记^$。插入符号匹配输入字符串的开头,而美元符号匹配结尾。因此,/^\d+$/匹配一个完全由一个或多个数字组成的字符串,/^!/匹配任何以感叹号开头的字符串,而/x^/则不匹配任何字符串(字符串开头不能有x)。

还有一个\b标记,匹配单词边界,即一侧是单词字符而另一侧是非单词字符的位置。不幸的是,这些标记与\w使用相同的简单概念,因此并不可靠。

注意,这些边界标记并不匹配任何实际字符。它们只是确保在出现位置满足特定条件。

前瞻测试做了类似的事情。它们提供一个模式,如果输入不匹配该模式,则使匹配失败,但实际上并不向前移动匹配位置。它们是在(?=)之间编写的。

console.log(/a(?=e)/.exec("braeburn"));
// → ["a"]
console.log(/a(?! )/.exec("a b"));
// → null

第一个示例中的e是匹配所必需的,但不是匹配字符串的一部分。(?! )符号表示负向前瞻。只有在括号内的模式匹配时,它才会匹配,从而导致第二个示例只匹配后面没有空格的字符。

选择模式

假设我们想知道一段文本是否不仅包含一个数字,还包含一个数字后面跟着单词pigcowchicken,或者它们的复数形式。

我们可以编写三个正则表达式并依次测试,但有一种更好的方法。管道字符(|)表示其左侧模式和右侧模式之间的选择。我们可以在这样的表达式中使用它:

let animalCount = /\d+ (pig|cow|chicken)s?/;
console.log(animalCount.test("15 pigs"));
// → true
console.log(animalCount.test("15 pugs"));
// → false

可以使用括号来限制管道操作符适用的模式部分,你可以将多个这样的操作符并排放置,以表达两种以上选项之间的选择。

匹配机制

从概念上讲,当你使用exectest时,正则表达式引擎会通过尝试首先从字符串的开头开始匹配表达式,然后从第二个字符开始,依此类推,直到找到匹配项或到达字符串的末尾。它将返回找到的第一个匹配项,或者根本找不到任何匹配。

为了进行实际的匹配,引擎将正则表达式视作一个流程图。这是前一个示例中牲畜表达式的图示:

https://github.com/OpenDocCN/geekdoc-js-zh/raw/master/docs/elqt-js-4e/img/f0150-01.jpg

如果我们能够找到一条从图左侧到右侧的路径,我们的表达式就匹配。我们在字符串中保持一个当前位置,每当我们穿过一个框时,我们会验证当前位置之后的字符串部分是否与该框匹配。

回溯

正则表达式/^([01]+b|[\da-f]+h|\d+)$/可以匹配一个后面跟着b的二进制数字、一个后面跟着h的十六进制数字(即基数 16,字母af表示数字 10 到 15),或者一个没有后缀字符的常规十进制数字。这是相应的图示:

https://github.com/OpenDocCN/geekdoc-js-zh/raw/master/docs/elqt-js-4e/img/f0150-02.jpg

在匹配这个表达式时,顶部的(二进制)分支通常会被进入,即使输入实际上并不包含二进制数字。例如,在匹配字符串“103”时,只有在字符 3 处才明确我们在错误的分支中。该字符串确实匹配表达式,只是我们当前所在的分支不匹配。

因此,匹配器回溯。进入一个分支时,它会记住当前的位置(在这种情况下,位于字符串的开始,刚好在图中的第一个边界框后面),以便在当前分支不成功时可以返回并尝试另一个分支。对于字符串“103”,在遇到字符 3 后,匹配器开始尝试十六进制数字的分支,但由于数字后没有h,因此再次失败。接着它尝试十进制数字的分支。这个分支匹配成功,最终报告了匹配结果。

匹配器在找到完整匹配后立即停止。这意味着如果多个分支可能匹配一个字符串,只有第一个(按分支在正则表达式中出现的顺序)会被使用。

回溯也发生在重复操作符如+*上。如果你用/^.*x/来匹配“abcxe”.*部分会首先尝试消耗整个字符串。引

可以编写会进行大量回溯的正则表达式。当一个模式可以以多种不同方式匹配输入的一部分时,就会出现这个问题。例如,如果在编写二进制数字正则表达式时感到困惑,可能会不小心写出类似/([01]+)+b/的东西。

https://github.com/OpenDocCN/geekdoc-js-zh/raw/master/docs/elqt-js-4e/img/f0151-01.jpg

如果

replace方法。

字符串值具有一个可以用来将字符串的一部分替换为另一个字符串的replace方法。

console.log("papa".replace("p", "m"));
// → mapa

第一个参数也可以是一个正则表达式,在这种情况下,正则表达式的第一次匹配将被替换。当在正则表达式后添加g选项(表示全局)时,字符串中的所有匹配项都会被替换,而不仅仅是第一个。

console.log("Borobudur".replace(/[ou]/, "a"));
// → Barobudur
console.log("Borobudur".replace(/[ou]/g, "a"));
// → Barabadar

使用正则表达式与replace结合的真正强大之处在于我们可以在替换字符串中引用匹配的组。例如,假设我们有一个包含人名的大字符串,每行一个名字,格式为Lastname, Firstname。如果我们想交换这些名字并移除逗号以获得Firstname Lastname格式,我们可以使用以下代码:

console.log("Liskov, Barbara\nMcCarthy, John\nMilner, Robin".replace(/(\p{L}+), (\p{L}+)/gu, "$2 $1"));
// → Barbara Liskov
//    John McCarthy
//    Robin Milner

替换字符串中的$1$2引用模式中的括号组。$1被替换为与第一个组匹配的文本,$2被替换为第二个,依此类推,直到$9。整个匹配可以通过$&引用。

可以将一个函数而不是字符串作为第二个参数传递给replace。对于每一个替换,函数将被调用,并传入匹配的组(以及整个匹配项)作为参数,其返回值将插入到新字符串中。

这是一个例子:

let stock = "1 lemon, 2 cabbages, and 101 eggs";
function minusOne(match, amount, unit) {amount = Number(amount) - 1;if (amount == 1) { // Only one left, remove the 's'unit = unit.slice(0, unit.length - 1);} else if (amount == 0) {amount = "no";}return amount + " " + unit;
}
console.log(stock.replace(/(\d+) (\p{L}+)/gu, minusOne));
// → no lemon, 1 cabbage, and 100 eggs

这段代码获取一个字符串,查找所有后跟一个字母数字单词的数字出现次数,并返回一个每个数量少一个的字符串。

(\d+)组最终作为函数的数量参数,(\p{L}+)组绑定到单位。函数将数量转换为数字——这总是有效的,因为它之前匹配了\d+——并进行一些调整,以防只有一个或零个剩余。

贪婪

我们可以使用replace来编写一个函数,从一段JavaScript代码中移除所有注释。这是第一次尝试:

function stripComments(code) {return code.replace(/\/\/.*|\/\*[^]*\*\//g, "");
}
console.log(stripComments("1 + /* 2 */3"));
// → 1 + 3
console.log(stripComments("x = 10;// ten!"));
// → x = 10;
console.log(stripComments("1 /* a */+/* b */ 1"));
// → 1  1

| 操作符前面的部分匹配两个斜杠字符后跟任意数量的非换行字符。多行注释的部分更复杂。我们使用[^](不在空字符集中的任何字符)作为匹配任意字符的方式。我们不能在这里简单地使用一个句点,因为块注释可能会在新行上继续,而句点字符无法匹配换行字符。

但是最后一行的输出似乎出错了。为什么?

表达式的[^]*部分,如我在回溯部分所述,首先会匹配尽可能多的内容。如果这导致模式的下一部分失败,匹配器会回退一个字符并从那里重新尝试。在这个例子中,匹配器首先尝试匹配字符串的其余部分,然后从那里回退。它会在回退四个字符后找到一个*/的出现,并进行匹配。这并不是我们想要的——我们的意图是匹配单个注释,而不是一直到代码的末尾去找到最后一个块注释的结束。

正因为这种行为,我们称重复运算符(+*?,和{})为贪婪的,意思是它们尽可能多地匹配,然后从那里回溯。如果在它们后面加上问号(+?*???{}?),它们就会变成非贪婪的,并尽量匹配尽可能少的内容,只有在剩余模式不适合较小匹配时才会匹配更多。

而这正是我们在这种情况下想要的。通过让星号匹配最小的字符范围,使我们到达一个*/,我们消耗了一个块注释,而没有更多。

function stripComments(code) {return code.replace(/\/\/.*|\/\*[^]*?\*\//g, "");
}
console.log(stripComments("1 /* a */+/* b */ 1"));
// → 1 + 1

正则表达式程序中的许多错误可以追溯到无意中使用了贪婪运算符,而非贪婪运算符会更好。当使用重复运算符时,优先选择非贪婪变体。

动态创建RegExp对象。

在某些情况下,当你编写代码时,可能不知道需要匹配的确切模式。比如说,你想在一段文本中测试用户的名字。你可以构建一个字符串,并在此基础上使用RegExp构造函数。

let name = "harry";
let regexp = new RegExp("(^|\\s)" + name + "($|\\s)", "gi");
console.log(regexp.test("Harry is a dodgy character."));
// → true

在创建字符串的\s部分时,我们必须使用两个反斜杠,因为我们是在正常字符串中编写它们,而不是在斜杠封闭的正则表达式中。RegExp构造函数的第二个参数包含正则表达式的选项——在这种情况下,“gi”表示全局匹配和不区分大小写。

但如果名字是“dea+hl[]rd”,因为我们的用户是一个书呆子青少年呢?这会导致一个毫无意义的正则表达式,实际上无法匹配用户的名字。

为了解决这个问题,我们可以在任何具有特殊含义的字符前添加反斜杠。

let name = "dea+hl[]rd";
let escaped = name.replace(/[\\[.+*?(){|^$]/g, "\\$&");
let regexp = new RegExp("(^|\\s)" + escaped + "($|\\s)", "gi");
let text = "This dea+hl[]rd guy is super annoying.";
console.log(regexp.test(text));
// → true

search方法。

虽然字符串的indexOf方法不能用正则表达式调用,但还有另一种方法search,它确实需要一个正则表达式。像indexOf一样,它返回表达式找到的第一个索引,或者在未找到时返回-1。

console.log("  word".search(/\S/));
// → 2
console.log("    ".search(/\S/));
// → -1

不幸的是,没有办法指示匹配应从给定偏移量开始(就像我们可以用indexOf的第二个参数一样),这在很多情况下会非常有用。

lastIndex属性。

exec方法同样没有提供从给定位置开始搜索的方便方式。但它提供了一种方便的方式。

正则表达式对象有属性。其中一个属性是source,它包含创建该表达式时使用的字符串。另一个属性是lastIndex,它在某些有限情况下控制下一个匹配的起始位置。

这些有限情况是正则表达式必须启用全局(g)或粘性(y)选项,并且匹配必须通过exec方法发生。再说一次,较少混淆的解决方案是允许将额外参数传递给exec,但混淆是JavaScript正则表达式接口的一个基本特征。

let pattern = /y/g;
pattern.lastIndex = 3;
let match = pattern.exec("xyzzy");
console.log(match.index);
// → 4
console.log(pattern.lastIndex);
// → 5

如果匹配成功,对exec的调用会自动更新lastIndex属性,使其指向匹配之后的位置。如果没有找到匹配,lastIndex会被重置为0,这也是新构建的正则表达式对象的初始值。

全局选项和粘性选项之间的区别在于,当启用粘性时,匹配只会在lastIndex直接开始时成功,而全局匹配则会向前搜索可以开始匹配的位置。

let global = /abc/g;
console.log(global.exec("xyz abc"));
// → ["abc"]
let sticky = /abc/y;
console.log(sticky.exec("xyz abc"));
// → null

在对多个exec调用使用共享的正则表达式值时,这些对lastIndex属性的自动更新可能会引发问题。你的正则表达式可能会意外地从上一次调用留下的索引开始。

let digit = /\d/g;
console.log(digit.exec("here it is: 1"));
// → ["1"]
console.log(digit.exec("and now: 1"));
// → null

全局选项的另一个有趣效果是它改变了字符串上match方法的工作方式。当使用全局表达式调用时,match不会返回与exec返回的数组类似的结果,而是会找到字符串中模式的所有匹配,并返回一个包含匹配字符串的数组。

console.log("Banana".match(/an/g));
// → ["an", "an"]

因此,请谨慎使用全局正则表达式。它们必要的情况——调用replace和希望显式使用lastIndex的地方——通常是你想使用它们的唯一情况。

常见的做法是查找字符串中正则表达式的所有匹配。我们可以通过使用matchAll方法来实现。

let input = "A string with 3 numbers in it... 42 and 88.";
let matches = input.matchAll(/\d+/g);
for (let match of matches) {console.log("Found", match[0], "at", match.index);
}
// → Found 3 at 14
//    Found 42 at 33
//    Found 88 at 40

该方法返回一个匹配数组的数组。传递给matchAll的正则表达式必须启用g选项。

解析INI文件。

为了结束这一章,我们将看一个需要正则表达式的问题。假设我们正在编写一个程序,以自动从互联网上收集有关我们敌人的信息。(我们在这里不会实际编写那个程序,只是读取配置文件的部分。抱歉。)配置文件看起来像这样:

searchengine=https://duckduckgo.com/?q=$1
spitefulness=9.7
; Comments are preceded by a semicolon...
; Each section concerns an individual enemy
[larry]
fullname=Larry Doe
type=kindergarten bully
website=http://www.geocities.com/CapeCanaveral/11451[davaeorn]
fullname=Davaeorn
type=evil wizard
outputdir=/home/marijn/enemies/davaeorn

这种格式的确切规则——它是一种广泛使用的文件格式,通常称为INI文件——如下所示:

  • 空行和以分号开头的行会被忽略。

  • []包裹的行开始一个新节。

  • 包含字母数字标识符后跟=字符的行会为当前节添加一个设置。

  • 其他任何情况都是无效的。

我们的任务是将像这样的字符串转换为一个对象,该对象的属性包含在第一个节标题之前写的设置字符串,而子对象则用于节,其中这些子对象包含该节的设置。

由于格式必须逐行处理,将文件拆分成单独的行是一个良好的开始。我们在第四章中看到了split方法。然而,一些操作系统不仅使用换行符来分隔行,还使用回车符后跟换行符(“\r\n”)。考虑到split方法也允许使用正则表达式作为参数,我们可以使用像/\r?\n/这样的正则表达式,以便在行之间支持“\n”和“\r\n”。

function parseINI(string) {// Start with an object to hold the top-level fieldslet result = {};let section = result;for (let line of string.split(/\r?\n/)) {let match;if (match = line.match(/^(\w+)=(.*)$/)) {section[match[1]] = match[2];} else if (match = line.match(/^\[(.*)\]$/)) {section = result[match[1]] = {};} else if (!/^\s*(;|$)/.test(line)) {throw new Error("Line '" + line + "' is not valid.");}};return result;
}console.log(parseINI(`
name=Vasilis
[address]
city=Tessaloniki`));
// → {name: "Vasilis", address: {city: "Tessaloniki"}}

代码遍历文件的行并构建一个对象。顶部的属性直接存储到该对象中,而在节中找到的属性则存储在单独的节对象中。节绑定指向当前节的对象。

有两种重要的行——节标题或属性行。当一行是常规属性时,它存储在当前节中。当它是节标题时,会创建一个新的节对象,并将节设置为指向它。

注意^$的重复使用,以确保表达式匹配整行,而不仅仅是部分内容。省略这些将导致代码在大多数情况下工作,但对于某些输入表现得很奇怪,这可能是一个难以追踪的错误。

模式if (match = *string*.match(...))利用赋值表达式(=)的值是被赋值的事实。你通常不能确定你的match调用是否会成功,因此你只能在测试此的if语句内部访问结果对象。为了不打破愉快的else if形式的链,我们将匹配结果分配给一个绑定,并立即将该赋值作为if语句的测试。

如果一行不是节标题或属性,函数会使用表达式/^\s*(;|$)/检查它是否是注释或空行,以匹配仅包含空白或空白后跟分号的行(使该行的其余部分成为注释)。当一行不匹配任何预期形式时,函数会抛出异常。

代码单元和字符

在JavaScript正则表达式中,一个被标准化的设计错误是,默认情况下,像.?这样的运算符是作用于代码单元(如第五章所讨论),而不是实际字符。这意味着由两个代码单元组成的字符表现得很奇怪。

console.log(/{3}/.test(""));
// → false
console.log(/<.>/.test("<>"));
// → false
console.log(/<.>/u.test("<>"));
// → true

问题是,第一行中的<https://github.com/OpenDocCN/geekdoc-js-zh/raw/master/docs/elqt-js-4e/img/apple.jpg>被视为两个代码单元,而{3}仅应用于第二个单元。类似地,点只匹配单个代码单元,而不是组成玫瑰表情的两个代码单元。

你必须在正则表达式中添加u(Unicode)选项,以使其正确处理此类字符。

console.log(/{3}/u.test(""));
// → true

摘要

正则表达式是表示字符串中模式的对象。它们使用自己的语言来表达这些模式。

/abc/ 一串字符
/[abc]/ 字符集中的任意字符
/[^abc]/ 不在字符集中的任意字符
/[0-9]/ 范围内的任意字符
/x+/ x模式的一个或多个出现
/x+?/ 一个或多个出现,非贪婪
/x*/ 零个或多个出现
/x?/ 零个或一个出现
/x{2,4}/ 两到四个出现
/(abc)/ 一个分组
`/a b
/\d/ 任意数字字符
/\w/ 一个字母数字字符(“单词字符”)
/\s/ 任意空白字符
/./ 除换行符外的任意字符
/\p{L}/u 任意字母字符
/^/ 输入的开始
$/ 输入的结束
/(?=a)/ 前瞻测试

正则表达式有一个test方法用于测试给定字符串是否与其匹配。还有一个exec方法,当找到匹配时返回一个包含所有匹配组的数组。这样的数组有一个index属性,指示匹配开始的位置。

字符串具有一个匹配方法,可以将其与正则表达式进行匹配,还有一个搜索方法用于查找匹配,只返回匹配的起始位置。它们的替换方法可以将模式的匹配替换为一个替换字符串或函数。

正则表达式可以有选项,这些选项在关闭斜杠后书写。i选项使匹配不区分大小写。g选项使表达式全局,这意味着替换方法将替换所有实例,而不仅仅是第一个。y选项使表达式具有粘性,这意味着在寻找匹配时不会向前搜索并跳过字符串的一部分。u选项启用Unicode模式,允许使用\p语法,并修复了一些关于处理占用两个代码单元的字符的问题。

正则表达式是一个锐利的工具,使用起来却有些笨拙。它们极大地简化了一些任务,但在应用于复杂问题时,很快就会变得难以管理。了解如何使用它们的一部分是抵制将无法清晰表达的内容强行塞入其中的冲动。

练习

在进行这些练习时,你几乎无法避免会对某些正则表达式的莫名行为感到困惑和沮丧。有时候,将你的表达式输入到一个像[www.debuggex.com`]()的在线工具中,可以帮助你查看其可视化效果是否与你的意图一致,并尝试它对各种输入字符串的响应。

正则表达式高尔夫

代码高尔夫是一个术语,用于描述尽可能少字符表达特定程序的游戏。同样,正则表达式高尔夫是一种实践,旨在写出尽可能小的正则表达式以匹配给定模式,并且匹配该模式。

对于以下每一项,写一个正则表达式来测试给定模式是否出现在字符串中。正则表达式应该只匹配包含该模式的字符串。当你的表达式工作时,看看能否让它更小。

  1. 流行道具

  2. 雪貂渡船法拉利

  3. 任何以ious结尾的单词

  4. 一个空白字符后跟一个句号、逗号、冒号或分号

  5. 一个超过六个字母的单词

  6. 一个不包含字母e(或E)的单词

参考章节总结中的表格以获得帮助。用几个测试字符串测试每个解决方案。

引号风格

想象一下,你写了一篇故事,并且在整个故事中使用单引号来标记对话的片段。现在你想将所有对话的引号替换为双引号,同时保留在缩写中使用的单引号,比如aren’t

想出一个模式来区分这两种引号的用法,并编写一个调用替换方法的代码来进行正确的替换。

数字再谈

写一个只匹配JavaScript风格数字的表达式。它必须支持在数字前的可选负号正号,十进制点,以及指数表示法——5e-31E10——同样在指数前也有可选符号。另外,请注意,点前后不需要有数字,但数字不能仅仅是一个点。也就是说,.55.是有效的JavaScript数字,但一个孤立的点不是。

编写易于删除的代码,而不是易于扩展的代码。

—Tef,编程是可怕的

<https://github.com/OpenDocCN/geekdoc-js-zh/raw/master/docs/elqt-js-4e/img/f0162-01.jpg>

第十一章:模块

理想情况下,程序应具有清晰、简单的结构。它的工作原理容易解释,每个部分都扮演着明确的角色。

在实践中,程序是有机增长的。随着程序员识别新的需求,功能块被逐步添加。保持这样的程序结构良好需要持续的关注和努力。这项工作只有在将来、下次有人处理该程序时才会得到回报,因此很容易忽视它,让程序的各个部分深度纠缠。

这造成了两个实际问题。首先,理解一个纠缠的系统很困难。如果一切都能相互触及,那么很难孤立地看待任何特定的部分。你被迫建立对整个事物的整体理解。其次,如果你想在另一种情况下使用这样的程序的任何功能,重写它可能比试图将其从上下文中解开要容易。

“大球泥土”这个短语常用来形容这样的庞大、无结构的程序。一切都粘在一起,当你试图挑出一部分时,整个东西就会散架,你最终只会搞得一团糟。

模块化程序

模块是试图避免这些问题的一个尝试。一个模块是一段程序,指定它依赖于哪些其他部分以及它为其他模块提供哪些功能(它的接口)。

模块接口与对象接口有很多相似之处,正如我们在第六章中看到的。它们将模块的一部分公开给外部世界,而将其余部分保留为私有。

但是,一个模块为其他模块提供的接口只是故事的一部分。一个好的模块系统还要求模块指定它们使用其他模块的哪些代码。这些关系称为依赖。如果模块A使用模块B的功能,就说模块A依赖于该模块。当这些在模块内部被明确指定时,可以用来确定使用特定模块所需的其他模块,并自动加载依赖项。

当模块之间的交互方式是明确的时,系统就更像乐高,组件通过明确定义的连接器相互作用,而不像泥土那样,所有东西都混在一起。

ES模块

原始的JavaScript语言没有模块的概念。所有脚本都在同一个作用域内运行,访问在另一个脚本中定义的函数是通过引用该脚本创建的全局绑定来完成的。这积极鼓励了代码的意外、难以察觉的纠缠,并导致了不同脚本尝试使用相同绑定名称等问题。

ECMAScript 2015以来,JavaScript支持两种不同类型的程序。脚本以旧的方式运行:它们的绑定在全局范围内定义,并且无法直接引用其他脚本。模块拥有自己的独立范围,并支持importexport关键字,这在脚本中不可用,以声明它们的依赖关系和接口。这个模块系统通常称为ES模块ES代表ECMAScript)。

一个模块化程序由多个这样的模块组成,通过它们的导入和导出连接在一起。

以下示例模块在日期名称和数字之间转换(如DategetDay方法返回的)。它定义了一个不属于其接口的常量,以及两个属于其接口的函数。它没有依赖关系。

const names = ["Sunday", "Monday", "Tuesday", "Wednesday","Thursday", "Friday", "Saturday"];export function dayName(number) {return names[number];
}
export function dayNumber(name) {return names.indexOf(name);
}

export关键字可以放在函数、类或绑定定义前,表示该绑定是模块接口的一部分。这使得其他模块能够通过导入该绑定来使用它。

import {dayName} from "./dayname.js";
let now = new Date();
console.log(`Today is ${dayName(now.getDay())}`);
// → Today is Monday

import关键字后跟花括号内的绑定名称列表,使来自另一个模块的绑定在当前模块中可用。模块由引号字符串标识。

这样的模块名称解析为实际程序的方式因平台而异。浏览器将它们视为网址,而Node.js将其解析为文件。当你运行一个模块时,它所依赖的所有其他模块——以及那些模块所依赖的模块——都会被加载,导出的绑定将对导入它们的模块可用。

importexport声明不能出现在函数、循环或其他块内。它们在模块加载时立即解析,无论模块中的代码如何执行。为了反映这一点,它们必须仅出现在外部模块体内。

模块的接口因此由一组命名绑定组成,其他依赖于该模块的模块可以访问这些绑定。导入的绑定可以通过在名称后使用as来重命名,从而赋予它们一个新的本地名称。

import {dayName as nomDeJour} from "./dayname.js";
console.log(nomDeJour(3));
// → Wednesday

模块也可以有一个名为default的特殊导出,通常用于仅导出单个绑定的模块。要定义默认导出,请在表达式、函数声明或类声明前写export default

export default ["Winter", "Spring", "Summer", "Autumn"];

这样的绑定通过省略名称周围的花括号来导入。

import seasonNames from "./seasonname.js";

要同时导入模块中的所有绑定,可以使用import *。你提供一个名称,这个名称将绑定到一个持有所有模块导出的对象上。当你使用很多不同的导出时,这非常有用。

import * as dayName from ".dayname.js";
console.log(dayName.dayName(3));
// → Wednesday

将程序构建为多个独立部分,并能够单独运行其中一些部分的一个优点是,你可能能够在不同程序中使用相同的部分。

那么,如何设置这个呢?假设我想在另一个程序中使用第九章中的parseINI函数。如果很清楚这个函数的依赖(在这种情况下,没有),我可以直接把该模块复制到我的新项目中并使用。但是,如果我在代码中发现了错误,我可能会在我当时正在工作的程序中修复它,却忘了在其他程序中也修复。

一旦你开始复制代码,你会迅速发现自己在浪费时间和精力来移动副本并保持它们的更新。这就是的用武之地。一个是一块可以分发(复制和安装)的代码。它可能包含一个或多个模块,并包含有关其依赖于其他的信息。通常还会附带文档,解释它的功能,以便那些没有编写它的人也能够使用。

当在一个中发现问题或添加新特性时,该会被更新。现在,依赖于它的程序(也可能是)可以复制新版本,以获得对代码所做改进的访问。

以这种方式工作需要基础设施。我们需要一个地方来存储和查找,以及一个方便的方式来安装和升级它们。在 JavaScript 世界中,这一基础设施由NPM提供([www.npmjs.com](https://www.npmjs.com))。

NPM有两个功能:一个是你可以下载(和上传)的在线服务,另一个是一个程序(与Node.js捆绑在一起),帮助你安装和管理这些

截至目前,NPM上有超过三百万个不同的。公平地说,其中大部分是无用的。但是几乎所有有用的、公开可用的 JavaScript 都可以在NPM上找到。例如,与我们在第九章中构建的INI文件解析器类似的一个解析器可以在包名为ini下找到。

第二十章将展示如何使用npm命令行程序在本地安装这些

拥有可供下载的高质量是非常有价值的。这意味着我们通常可以避免重新发明一个已经被 100 人写过的程序,并且可以在按下几下键的情况下获得一个稳固、经过良好测试的实现。

软件复制成本低,因此一旦有人编写了它,分发给其他人就是一个高效的过程。不过,最初编写它确实是一项工作,而回应那些发现代码问题或希望提出新特性的人则需要更多的工作。

默认情况下,你拥有自己编写代码的版权,其他人只能在获得你的许可后使用它。但是,因为有些人很友善,且发布好的软件可以让你在程序员中小有名气,许多都在允许其他人使用的许可证下发布。

NPM上的大多数代码以这种方式获得许可。一些许可证要求你在基于该构建的代码上也以相同许可证发布。其他许可证要求较少,仅要求在分发代码时保留许可证。JavaScript 社区主要使用后者类型的许可证。在使用其他人的时,请确保了解其许可证。

现在,我们可以使用NPM上的一个解析INI文件的模块,而不是自己编写一个。

import {parse} from "ini";console.log(parse("x = 10\ny = 20"));
// → {x: "10", y: "20"}

CommonJS模块

2015年之前,当 JavaScript 语言没有内置模块系统时,人们已经在 JavaScript 中构建了大型系统。为了使其可行,他们需要模块。

社区在语言之上设计了自己的即兴模块系统。这些使用函数为模块创建局部作用域,并使用常规对象来表示模块接口。

起初,人们只是手动将整个模块包裹在一个立即调用函数表达式中,以创建模块的作用域,并将接口对象分配给一个全局变量。

const weekDay = function() {const names = ["Sunday", "Monday", "Tuesday", "Wednesday","Thursday", "Friday", "Saturday"];return {name(number) { return names[number]; },number(name) { return names.indexOf(name); }};
}();console.log(weekDay.name(weekDay.number("Sunday")));
// → Sunday

这种模块风格提供了一定程度的隔离,但并未声明依赖关系。相反,它只是将其接口放入全局作用域,并期望其依赖关系(如果有的话)也这样做。这并不是理想的。

如果我们实现自己的模块加载器,我们可以做得更好。最广泛使用的附加 JavaScript模块方法称为CommonJS模块。Node.js从一开始就使用这个模块系统(虽然现在也知道如何加载 ES模块),这是许多NPM包使用的模块系统。

CommonJS模块看起来像一个常规脚本,但它可以访问两个绑定,以便与其他模块进行交互。第一个是一个名为require的函数。当你使用依赖模块的名称调用它时,它会确保模块被加载并返回其接口。第二个是一个名为exports的对象,这是模块的接口对象。它最开始是空的,你向其添加属性以定义导出的值。

这个CommonJS示例模块提供了一个日期格式化函数。它使用了来自NPM的两个包——ordinal将数字转换为"1st""2nd"等字符串,而date-names则获取工作日和月份的英文名称。它导出一个单一的函数formatDate,接受一个Date对象和一个模板字符串。

模板字符串可以包含指导格式的代码,例如YYYY表示完整年份,Do表示月份的序数日。你可以给它一个像"MMMM Do YYYY"的字符串,以获得类似于"2017 年 11 月 22 日"的输出。

const ordinal = require("ordinal");
const {days, months} = require("date-names");exports.formatDate = function(date, format) {return format.replace(/YYYY|M(MMM)?|Do?|dddd/g, tag => {if (tag == "YYYY") return date.getFullYear();if (tag == "M") return date.getMonth();if (tag == "MMMM") return months[date.getMonth()];if (tag == "D") return date.getDate();if (tag == "Do") return ordinal(date.getDate());if (tag == "dddd") return days[date.getDay()];});
};

ordinal的接口是一个单一的函数,而date-names导出的是一个包含多个内容的对象——天和月是名称的数组。解构在创建导入接口的绑定时非常方便。

模块将其接口函数添加到exports中,以便依赖于它的模块可以访问它。我们可以这样使用该模块

const {formatDate} = require("./format-date.js");console.log(formatDate(new Date(2017, 9, 13),"dddd the Do"));
// → Friday the 13th

CommonJS实现了一个模块加载器,当加载模块时,将其代码封装在一个函数中(为其提供自己的局部作用域),并将requireexports绑定作为参数传递给该函数。

如果我们假设有一个 readFile 函数,可以通过名称读取文件并返回其内容,我们可以像这样定义一个简化的 require 形式:

function require(name) {if (!(name in require.cache)) {let code = readFile(name);let exports = require.cache[name] = {};let wrapper = Function("require, exports", code);wrapper(require, exports);}return require.cache[name];
}
require.cache = Object.create(null);

Function 是一个内置的 JavaScript 函数,它接受一个以逗号分隔的字符串形式的参数列表和一个包含函数体的字符串,并返回一个带有这些参数和该函数体的函数值。这是一个有趣的概念——它允许程序从字符串数据中创建新的程序片段——但也是一个危险的概念,因为如果有人能欺骗你的程序将他们提供的字符串放入 Function 中,他们就能让程序执行任何他们想要的操作。

标准的 JavaScript 并没有提供像 readFile 这样的函数,但不同的 JavaScript 环境,如浏览器和 Node.js,提供了各自访问文件的方式。这个例子假装 readFile 存在。

为了避免多次加载相同的模块,require保持已加载模块的存储(缓存)。调用时,它首先检查请求的模块是否已加载,如果没有,则加载它。这涉及读取模块的代码,将其封装在一个函数中,并调用它。

通过将 requireexports 定义为生成的包装函数的参数(并在调用时传递适当的值),加载器确保这些绑定在模块的作用域中可用。

该系统与 ES 模块之间的一个重要区别是,ES 模块的导入在模块的脚本开始运行之前发生,而 require 是一个普通函数,在模块已经运行时调用。与导入声明不同,require 调用可以出现在函数内部,依赖项的名称可以是任何计算结果为字符串的表达式,而导入只允许普通的带引号字符串。

JavaScript 社区从 CommonJS 风格过渡到 ES 模块的过程比较缓慢且略显粗糙。幸运的是,现在大多数流行的 NPM 包都以 ES 模块的形式提供其代码,Node.js 也允许 ES 模块从 CommonJS 模块中导入。虽然 CommonJS 代码仍然会出现,但已经没有真正的理由再以这种风格编写新程序。

构建与打包

许多 JavaScript 包在技术上并不是用 JavaScript 编写的。诸如 TypeScript 之类的语言扩展,在第八章中提到的类型检查方言,被广泛使用。人们通常会在新语言特性被实际添加到运行 JavaScript 的平台之前,就开始使用计划中的新特性。为了实现这一点,他们会编译他们的代码,将其从所选的 JavaScript 方言转换为普通的 JavaScript,甚至是早期版本的 JavaScript,以便浏览器能够运行它。

在网页中包含由 200 个不同文件组成的模块化程序会产生自身的问题。如果从网络上获取一个文件需要 50 毫秒,那么加载整个程序需要 10 秒钟,或者如果你能够同时加载几个文件,可能会少一些。这是很多浪费的时间。因为获取一个大文件往往比获取许多小文件要快,网页程序员开始使用工具将他们精心拆分成模块的程序合并成一个大文件,然后再发布到网络上。这类工具被称为打包工具

我们可以更进一步。除了文件数量,文件的大小也决定了它们在网络上传输的速度。因此,JavaScript 社区发明了压缩工具。这些工具通过自动删除注释和空格、重命名绑定以及用占用更少空间的等效代码替换代码片段,使 JavaScript 程序变得更小。

在 NPM 包中或在网页上运行的代码经历过多次转换阶段是并不少见——从现代 JavaScript 转换为历史 JavaScript,将模块合并为一个文件,以及压缩代码。在本书中我们不会详细介绍这些工具,因为它们种类繁多,流行的工具也会定期变化。只需知道这些工具的存在,并在需要时查找它们。

模块设计

结构化程序是编程中更微妙的方面之一。任何非平凡的功能都可以以多种方式组织。

良好的程序设计是主观的——涉及权衡和品味的问题。学习结构良好的设计价值的最好方法是阅读或参与大量程序,并注意什么有效,什么无效。不要假设一个痛苦的混乱是“就是这样的”。通过更多思考,你可以改善几乎所有事物的结构。

模块设计的一个方面是易用性。如果你设计的东西是为了让多个人使用——或者即使是你自己,在三个月后当你不再记得你所做的具体事情时——那么如果你的接口简单且可预测,那将会很有帮助。

这可能意味着遵循现有的约定。一个好的例子是ini包。这个模块通过提供解析和字符串化(以写入 INI 文件)函数,模仿标准 JSON 对象,并且像 JSON 一样,在字符串和普通对象之间进行转换。接口小而熟悉,使用一次后,你可能会记住如何使用它。

即使没有标准函数或广泛使用的包可以模仿,你也可以通过使用简单的数据结构并专注于单一功能来保持模块的可预测性。比如,NPM上许多INI文件解析模块提供一个直接从硬盘读取并解析文件的函数。这使得在浏览器中使用这些模块变得不可能,因为我们没有直接的文件系统访问权限,并增加了复杂性,而这本可以通过组合文件读取功能来更好地解决。

这指出了模块设计的另一个有用方面——与其他代码组合的简便性。专注于计算值的模块适用于比执行复杂操作和副作用的大模块更广泛的程序场景。一个坚持从磁盘读取文件的INI文件读取器在文件内容来自其他来源的情况下毫无用处。

相关地,有状态对象有时是有用甚至必要的,但如果可以用函数完成的事情,就应该使用函数。NPM上几个INI文件读取器提供了一种接口风格,要求你首先创建一个对象,然后将文件加载到对象中,最后使用专门的方法获取结果。这种情况在面向对象传统中很常见,而且非常糟糕。你不得不进行将对象通过不同状态移动的仪式,而不是简单地调用一个函数并继续。而且,由于数据现在被封装在专门的对象类型中,所有与之交互的代码都必须了解该类型,造成不必要的相互依赖。

经常,定义新的数据结构是不可避免的——语言标准只提供少数基本结构,许多数据类型必须比数组或映射更复杂。但当数组足够时,就用数组。

一个稍微复杂的数据结构的例子是来自第七章的图。在JavaScript中,没有单一明显的方法来表示图。在那一章中,我们使用了一个对象,其属性持有字符串数组——可从该节点到达的其他节点。

NPM上有几个不同的路径查找包,但没有一个使用这种图格式。它们通常允许图的边具有权重,即与之相关的成本或距离。这在我们的表示中是不可行的。

例如,有dijkstrajs包。一种著名的路径查找方法,与我们的findRoute函数非常相似,称为迪杰斯特拉算法,以最早将其写下的Edsger Dijkstra命名。js后缀通常被添加到包名中,以表明它们是用JavaScript编写的。这个dijkstrajs包使用类似于我们的图格式,但它使用的是属性值为数字的对象——边的权重。

如果我们想使用那个包,就必须确保我们的图以它所期望的格式存储。所有边的权重相同,因为我们的简化模型将每条道路视为具有相同成本(一个转弯)。

const {find_path} = require("dijkstrajs");let graph = {};
for (let node of Object.keys(roadGraph)) {let edges = graph[node] = {};for (let dest of roadGraph[node]) {edges[dest] = 1;}
}console.log(find_path(graph, "Post Office", "Cabin"));
// → ["Post Office", "Alice's House", "Cabin"]

这可能成为组合的障碍——当各种包使用不同的数据结构描述相似事物时,组合它们会很困难。因此,如果你想设计可组合性,了解其他人使用的数据结构,并在可能的情况下遵循他们的示例。

为程序设计合适的模块结构可能很困难。在你仍在探索问题的阶段,尝试不同的方案以查看什么有效时,你可能不想太过担心这个,因为保持一切有序可能会带来很大的干扰。一旦你有了感觉稳固的东西,那就是退后一步进行整理的好时机。

摘要

模块通过将代码分离为具有清晰接口和依赖关系的片段,为更大的程序提供结构。接口是模块对其他模块可见的部分,依赖关系是它所使用的其他模块。

因为JavaScript历史上没有提供模块系统,所以在其之上构建了CommonJS系统。然后在某个时刻它确实得到了内置系统,现在与CommonJS系统并存,但关系并不融洽。

包是可以独立分发的代码块。NPM是一个JavaScript包的库。你可以从中下载各种有用(和无用)的包。

练习

一个模块化机器人

这是项目从第七章创建的绑定:

roads
buildGraph
roadGraph
VillageState
runRobot
randomPick
randomRobot
mailRoute
routeRobot
findRoute
goalOrientedRobot

如果你要把那个项目写成一个模块化程序,你会创建哪些模块?哪个模块依赖于哪个其他模块,它们的接口会是什么样子?

哪些部分可能在NPM上已有预写?你更愿意使用NPM包还是自己编写?

道路模块

基于第七章的示例编写一个ES模块,该模块包含道路数组并将表示它们的图数据结构导出为roadGraph。它依赖于一个导出函数buildGraph的模块./graph.js,该函数用于构建图。此函数期望一个由两个元素数组(道路的起点和终点)组成的数组。

循环依赖

循环依赖是一种情况,其中模块A依赖于B,而B也直接或间接依赖于A。许多模块系统简单地禁止这种情况,因为无论你选择哪种加载顺序,都无法确保在运行之前每个模块的依赖关系都已加载。

CommonJS模块允许有限形式的循环依赖。只要模块在加载完成之前不相互访问对方的接口,循环依赖是可以的。

本章前面给出的require函数支持这种类型的依赖循环。你能看出它是如何处理循环的吗?

谁能静静等待泥沙沉淀?谁能保持静止直到行动的时刻?

—老子,《道德经》

https://github.com/OpenDocCN/geekdoc-js-zh/raw/master/docs/elqt-js-4e/img/f0174-01.jpg

第十二章:异步编程

计算机的核心部分,即执行构成我们程序的各个步骤的部分,被称为处理器。到目前为止我们看到的程序将在它们完成工作之前一直占用处理器。像处理数字的循环那样的操作执行速度几乎完全依赖于计算机的处理器和内存的速度。

但许多程序与处理器外部的事物进行交互。例如,它们可能通过计算机网络进行通信,或请求硬盘上的数据——这比从内存获取数据要慢得多。

当这种情况发生时,让处理器闲置将是一个遗憾——在此期间可能还有其他工作可以完成。这部分由你的操作系统处理,它会在多个运行中的程序之间切换处理器。但当我们希望一个单一程序在等待网络请求时仍能进展时,这并没有帮助。

异步性

同步编程模型中,事情是一个接一个发生的。当你调用一个执行长时间运行的操作的函数时,它只有在操作完成并能返回结果时才会返回。这会在操作所需时间内停止你的程序。

异步模型允许多个事情同时发生。当你启动一个操作时,你的程序会继续运行。当操作完成时,程序会收到通知并访问结果(例如,从磁盘读取的数据)。

我们可以通过一个小例子来比较同步和异步编程:一个程序在网络上发出两个请求,然后合并结果。

在同步环境中,请求函数在完成工作之前不会返回,因此执行此任务的最简单方法是一个接一个地发出请求。这有一个缺点,即第二个请求只有在第一个请求完成后才会启动。总耗时至少是两个响应时间的总和。

在同步系统中,解决这个问题的方法是启动额外的控制线程。一个线程是另一个正在运行的程序,它的执行可能与操作系统中的其他程序交错进行——由于大多数现代计算机包含多个处理器,因此多个线程甚至可以在不同的处理器上同时运行。第二个线程可以启动第二个请求,然后两个线程等待结果返回,之后它们重新同步以合并结果。

在下图中,粗线代表程序正常运行所花费的时间,细线代表等待网络的时间。在同步模型中,网络所需的时间是特定控制线程的时间线的一部分。在异步模型中,启动网络操作允许程序继续运行,同时进行网络通信,并在完成时通知程序。

同步,单线程控制

https://github.com/OpenDocCN/geekdoc-js-zh/raw/master/docs/elqt-js-4e/img/f0176-01.jpg

同步,两条控制线程

https://github.com/OpenDocCN/geekdoc-js-zh/raw/master/docs/elqt-js-4e/img/f0176-02.jpg

异步

https://github.com/OpenDocCN/geekdoc-js-zh/raw/master/docs/elqt-js-4e/img/f0176-03.jpg

描述这种差异的另一种方式是,在同步模型中,等待操作完成是隐式的,而在异步模型中,它是显式的——在我们的控制之下。

异步性有双重作用。它使表达不符合直线控制模型的程序变得更容易,但它也可能使表达遵循直线的程序变得更为笨拙。我们将在本章稍后看到一些减少这种笨拙感的方法。

两大主要JavaScript编程平台——浏览器和Node.js——使可能需要一段时间的操作异步,而不是依赖线程。由于使用线程编程notoriously hard(理解一个程序的行为在它同时执行多个任务时更加困难),这通常被认为是一件好事。

回调

一种异步编程的方法是让需要等待某些事情的函数接受一个额外的参数,即回调函数。异步函数启动一个进程,设置好当进程完成时调用回调函数的条件,然后返回。

作为一个例子,setTimeout函数在Node.js和浏览器中都可用,它等待给定的毫秒数,然后调用一个函数。

setTimeout(() => console.log("Tick"), 500);

等待通常不是重要的工作,但当你需要安排某件事情在特定时间发生或检查某个操作是否比预期耗时更长时,这可以非常有用。

另一个常见异步操作的例子是从设备存储中读取文件。想象一下,你有一个函数readTextFile,它将文件的内容读取为字符串并传递给回调函数

readTextFile("shopping_list.txt", content => {console.log(`Shopping List:\n${content}`);
});
// → Shopping List:
// → Peanut butter
// → Bananas

readTextFile函数不是标准JavaScript的一部分。我们将在后面的章节中看到如何在浏览器和Node.js中读取文件。

使用回调在一系列异步操作中执行多个操作意味着你必须不断传递新的函数来处理在这些操作之后计算的继续。一个比较两个文件并生成一个布尔值,指示它们的内容是否相同的异步函数可能看起来像这样:

function compareFiles(fileA, fileB, callback) {readTextFile(fileA, contentA => {readTextFile(fileB, contentB => {callback(contentA == contentB);});});
}

这种编程风格是可行的,但每进行一次异步操作,缩进级别就会增加,因为你进入了另一个函数。进行更复杂的操作,比如将异步操作包装在循环中,可能会变得很尴尬。

从某种意义上说,异步性是具有传染性的。任何调用异步函数的函数本身必须是异步的,使用回调或类似机制来传递结果。调用回调比简单返回值更复杂且容易出错,因此需要以这种方式结构化程序的较大部分并不好。

承诺

构建异步程序的另一种稍微不同的方法是让异步函数返回一个表示其(未来)结果的对象,而不是传递回调函数。这样,这些函数实际上返回一些有意义的东西,程序的结构与同步程序更为相似。

这就是标准类Promise的用途。承诺是表示可能尚不可用的值的收据。它提供了一个then方法,允许你注册一个在它等待的操作完成时应被调用的函数。当承诺被解析时,即其值变得可用,这些函数(可能有多个)会被调用并传入结果值。可以在已经解析的承诺上调用then——你的函数仍然会被调用。

创建承诺的最简单方法是调用Promise.resolve。这个函数确保你提供的值被包装在一个承诺中。如果它已经是一个承诺,则直接返回。否则,你将得到一个新的承诺,它立即以你的值作为结果解析。

let fifteen = Promise.resolve(15);
fifteen.then(value => console.log(`Got ${value}`));
// → Got 15

要创建一个不会立即解析的承诺,可以使用Promise作为构造函数。它的接口有些奇怪:构造函数期望一个函数作为参数,并立即调用它,传递一个可以用来解析承诺的函数。

例如,这就是如何为readTextFile函数创建一个基于承诺的接口:

function textFile(filename) {return new Promise(resolve => {readTextFile(filename, text => resolve(text));});
}textFile("plans.txt").then(console.log);

注意,与回调风格的函数相比,这个异步函数返回了一个有意义的值——一个承诺,承诺在未来某个时刻提供文件的内容。

then方法的一个有用之处在于,它本身返回另一个承诺。这个承诺解析为回调函数返回的值,或者如果该返回值是一个承诺,则解析为该承诺所解析的值。因此,你可以将多个对then的调用“链式”连接在一起,以建立一系列异步操作。

这个函数读取一个包含文件名的文件,并返回该列表中随机文件的内容,展示了这种异步承诺管道:

function randomFile(listFile) {return textFile(listFile).then(content => content.trim().split("\n")).then(ls => ls[Math.floor(Math.random() * ls.length)]).then(filename => textFile(filename));
}

该函数返回这一系列then调用的结果。初始promise以字符串形式获取文件列表。第一次then调用将该字符串转换为行数组,从而产生一个新的promise。第二次then调用从中随机选择一行,产生一个返回单个文件名的第三个promise。最终的then调用读取这个文件,因此该函数的整体结果是一个返回随机文件内容的promise

在这段代码中,前两个then调用中使用的函数返回一个常规值,该值将在函数返回时立即传递给then返回的promise。最后一次then调用返回一个promisetextFile(filename)),使其成为一个实际的异步步骤。

也可以在单个then回调中执行所有这些步骤,因为实际上只有最后一步是异步的。但那种仅执行某些同步数据转换的then包装器通常是有用的,例如,当你想返回一个生成某些异步结果处理版本的promise时。

function jsonFile(filename) {return textFile(filename).then(JSON.parse);
}jsonFile("package.json").then(console.log);

通常,将promise视为一种设备是有益的,它使代码可以忽略值何时到达的问题。正常值必须在我们引用它之前实际存在。承诺的值是一个可能已经存在或者可能在未来某个时刻出现的值。通过将它们与then调用连接在一起定义的基于promise的计算,随着输入变得可用而异步执行。

失败

常规的JavaScript计算可能通过抛出异常而失败。异步计算通常需要类似的机制。网络请求可能失败,文件可能不存在,或者某个属于异步计算的代码可能抛出异常。

回调风格的异步编程面临的最紧迫问题之一是,它使得确保失败正确报告给回调变得极其困难。

一个常见的约定是使用回调的第一个参数来指示操作失败,第二个参数用来传递操作成功时产生的值。

someAsyncFunction((error, value) => {if (error) handleError(error);else processValue(value);
});

这样的回调函数必须始终检查是否收到异常,并确保它们引起的任何问题,包括它们调用的函数抛出的异常,都被捕获并传递给正确的函数。

Promises使这变得更简单。它们可以被解决(操作成功完成)或被拒绝(失败)。解决处理程序(如通过then注册的)仅在操作成功时调用,而拒绝会传播到then返回的新promise。当处理程序抛出异常时,这会自动导致其then调用生成的promise被拒绝。如果异步操作链中的任何元素失败,整个链的结果将被标记为拒绝,并且在失败点之后不会调用任何成功处理程序。

就像解析承诺提供一个值一样,拒绝承诺也提供一个值,通常称为拒绝的原因。当处理程序函数中的异常导致拒绝时,异常值被用作原因。同样,当处理程序返回一个被拒绝的承诺时,该拒绝会流入下一个承诺。有一个Promise.reject函数,可以创建一个新的、立即被拒绝的承诺。

为了显式处理这样的拒绝,承诺有一个catch方法,用于注册在承诺被拒绝时调用的处理程序,类似于then处理程序处理正常解析的方式。它也非常像then,因为它返回一个新的承诺,该承诺在原承诺正常解析时解析为原承诺的值,而在其他情况下解析为catch处理程序的结果。如果catch处理程序抛出错误,新的承诺也会被拒绝。

作为一种简写,then也接受一个拒绝处理程序作为第二个参数,因此你可以在一次方法调用中安装两种类型的处理程序:.then(acceptHandler, rejectHandler)

传递给Promise构造函数的函数接收第二个参数,除了resolve函数外,它可以用来拒绝新的承诺。

当我们的readTextFile函数遇到问题时,它将错误作为第二个参数传递给回调函数。我们的textFile包装器实际上应该检查该参数,以确保失败导致返回的承诺被拒绝。

function textFile(filename) {return new Promise((resolve, reject) => {readTextFile(filename, (text, error) => {if (error) reject(error);else resolve(text);});});
}

通过调用thencatch创建的承诺值链形成了一条管道,异步值或失败通过这条管道传递。由于这样的链是通过注册处理程序创建的,因此每个链接都有一个成功处理程序或拒绝处理程序(或两者都有)。不匹配结果类型(成功或失败)的处理程序会被忽略。匹配的处理程序会被调用,其结果决定了接下来是什么样的值——当它们返回非承诺值时为成功,当它们抛出异常时为拒绝,而当它们返回一个承诺时则为承诺的结果。

new Promise((_, reject) => reject(new Error("Fail"))).then(value => console.log("Handler 1:", value)).catch(reason => {console.log("Caught failure " + reason);return "nothing";}).then(value => console.log("Handler 2:", value));
// → Caught failure Error: Fail
// → Handler 2: nothing

第一个then处理程序函数没有被调用,因为在管道的那个点上,承诺持有一个拒绝。catch处理程序处理该拒绝并返回一个值,该值被传递给第二个then处理程序函数。

就像未捕获的异常由环境处理一样,JavaScript环境可以检测到承诺拒绝未被处理的情况,并将其报告为错误。

卡拉

在柏林是一个阳光明媚的日子。废弃机场的跑道上挤满了骑自行车和滑轮滑的运动员。在一个垃圾容器附近的草地上,一群乌鸦吵闹地聚在一起,试图说服一群游客放弃他们的三明治。

一只乌鸦十分显眼——一只毛发蓬乱的大雌鸟,右翅膀上有几根白色羽毛。她用一种技巧和自信吸引人们,似乎已经做了很长时间。当一位老年人被另一只乌鸦的antics分散注意力时,她悄然俯冲而下,从他手中抢走半个吃剩的面包,飞走了。

与其他看似乐于在这里消磨时间的鸟儿不同,这只大乌鸦显得目标明确。她带着战利品,径直飞向机库的屋顶,消失在通风口中。

在大楼内部,你可以听到一种奇怪的敲击声——柔和而持续。声音来自一个未完工楼梯间屋顶下的狭小空间。乌鸦坐在那里,周围是一堆偷来的零食,半打智能手机(其中几部已经开机),以及一堆电缆。她用嘴快速敲击其中一部手机的屏幕。字词正在上面出现。如果你不太了解,你可能会认为她在打字。

这只乌鸦在同伴中被称为“cāāw-krö”。但由于这些声音不适合人类的声带,我们就称她为卡拉。

卡拉是一只有些特别的乌鸦。年轻时,她对人类语言着迷,常常偷听人们的谈话,直到她很好地掌握了他们在说什么。后来,她的兴趣转向了人类技术,开始偷手机来研究。她目前的项目是学习编程。她在隐秘实验室中输入的文本实际上是一段异步JavaScript代码。

破门而入

卡拉喜欢上网。令人烦恼的是,她正在使用的手机即将耗尽预付数据。大楼内有无线网络,但需要密码才能访问。

幸运的是,大楼里的无线网络路由器已有 20 年历史,并且安全性差。经过一些研究,卡拉发现网络认证机制有一个她可以利用的漏洞。当加入网络时,设备必须发送正确的六位数密码。接入点会根据提供的密码是否正确来回复成功或失败的消息。然而,当发送部分密码(例如,仅三个数字)时,响应会根据这些数字是否为密码的正确开头而不同。发送错误的数字会立即返回失败消息。发送正确的数字时,接入点会等待更多的数字。

这使得大大加快猜测数字的速度成为可能。卡拉可以通过逐个尝试每个数字来找到第一个数字,直到找到一个不会立即返回失败的数字。得知一个数字后,她可以用同样的方法找到第二个数字,依此类推,直到她知道整个密码。

假设Carla有一个joinWifi函数。给定网络名称和密码(作为字符串),该函数尝试加入网络,返回一个如果成功则解析的Promise,如果身份验证失败则拒绝的Promise。她需要的第一件事是一个包装Promise的方法,以便在耗时过长后自动拒绝,这样如果接入点没有响应,程序就能迅速继续。

function withTimeout(promise, time) {return new Promise((resolve, reject) => {promise.then(resolve, reject);setTimeout(() => reject("Timed out"), time);});
}

这利用了Promise只能被解析或拒绝一次的事实。如果作为参数传入的Promise先解析或拒绝,那么该结果将是withTimeout返回的Promise的结果。另一方面,如果setTimeout先触发并拒绝了Promise,那么任何进一步的解析或拒绝调用都会被忽略。

为了找到整个密码,程序需要通过尝试每个数字来反复寻找下一个数字。如果身份验证成功,我们知道找到了我们要找的。如果立即失败,我们知道该数字是错误的,必须尝试下一个数字。如果请求超时,我们找到了另一个正确的数字,必须继续添加另一个数字。

因为你不能在for循环内等待一个Promise,卡拉使用一个递归函数来驱动这个过程。在每次调用中,这个函数获取当前已知的代码以及要尝试的下一个数字。根据发生的情况,它可能返回一个完成的代码,或者再次调用自己,开始破解代码的下一个位置,或用另一个数字重试。

function crackPasscode(networkID) {function nextDigit(code, digit) {let newCode = code + digit;return withTimeout(joinWifi(networkID, newCode), 50).then(() => newCode).catch(failure => {if (failure == "Timed out") {return nextDigit(newCode, 0);} else if (digit < 9) {return nextDigit(code, digit + 1);} else {throw failure;}});}return nextDigit("", 0);
}

接入点通常在大约20毫秒内响应错误的身份验证请求,因此为了安全起见,该函数在请求超时前等待50毫秒。

crackPasscode("HANGAR 2").then(console.log);
// → 555555

Carla侧着头叹气。如果代码再难一些,她会觉得更满意。

async函数

即使有了Promise,这种异步代码依然令人厌烦。Promise通常需要以冗长且看似任意的方式连接在一起。为了创建一个异步循环,Carla被迫引入了递归函数。

破解函数实际上做的事情是完全线性的——它总是等待上一个操作完成后再开始下一个。这在同步编程模型中会非常简单地表达。

好消息是JavaScript允许你编写伪同步代码来描述异步计算。async函数隐式返回一个Promise,并且可以在其主体中以看似同步的方式等待其他Promise

我们可以像这样重写crackPasscode

async function crackPasscode(networkID) {for (let code = "";;) {for (let digit = 0;; digit++) {let newCode = code + digit;try {await withTimeout(joinWifi(networkID, newCode), 50);return newCode;} catch (failure) {if (failure == "Timed out") {code = newCode;break;} else if (digit == 9) {throw failure;}}}}
}

这个版本更清楚地展示了函数的双重循环结构(内循环尝试数字09,外循环向密码中添加数字)。

一个异步函数通过在函数关键字前加上async来标记。方法也可以通过在其名称前加上async来变为异步。当这样的函数或方法被调用时,它返回一个promise。只要函数返回某个值,该promise就会被解决。如果函数体抛出异常,promise将被拒绝。

在异步函数内部,单词await可以放在一个表达式前面,以等待promise解决,然后再继续执行函数。如果promise被拒绝,则在await点会引发异常。

这样的函数不再像常规JavaScript函数那样从开始到结束一次性运行。相反,它可以在任何有await的点被冻结,并在稍后的时间恢复执行。

对于大多数异步代码,这种记法比直接使用promises更方便。你仍然需要理解promises,因为在许多情况下你仍会直接与它们交互。但在将它们组合在一起时,async函数通常比一连串的then调用更容易编写。

生成器

函数被暂停然后重新恢复的能力并不是异步函数所独有的。JavaScript还有一个称为生成器函数的特性。这些函数类似,但没有promises

当你用function*定义一个函数(在单词function后加上星号)时,它变成一个生成器。当你调用生成器时,它返回一个迭代器,这在第六章中我们已经看到了。

function* powers(n) {for (let current = n;; current *= n) {yield current;}
}for (let power of powers(3)) {if (power > 50) break;console.log(power);
}
// → 3
// → 9
// → 27

最初,当你调用powers时,函数在开始时被冻结。每次你在迭代器上调用next时,函数会运行直到遇到一个yield表达式,这会暂停它,并使得yield的值成为迭代器产生的下一个值。当函数返回时(示例中的函数从未返回),迭代器完成。

当你使用生成器函数时,编写迭代器通常要容易得多。Group类的迭代器(来自第六章的练习)可以用这个生成器来编写:

Group.prototype[Symbol.iterator] = function*() {for (let i = 0; i < this.members.length; i++) {yield this.members[i];}
};

不再需要创建一个对象来保存迭代状态——生成器会在每次yield时自动保存它们的局部状态。

这样的yield表达式只能直接出现在生成器函数本身,而不能在你在其中定义的内部函数中。生成器在yield时保存的状态只是它的局部环境和它yield的那个位置。

异步函数是一种特殊类型的生成器。它在被调用时产生一个promise,当它返回(完成)时,该promise被解决,当它抛出异常时,该promise被拒绝。每当它yieldawait)一个promise时,该promise的结果(值或抛出的异常)就是await表达式的结果。

一个科维德艺术项目

一天早上,卡拉被机库外面陌生的噪音吵醒。她跳到屋顶边缘,看到人们正在为某个活动做准备。周围有很多电缆,一个舞台,还有一些正在搭建的巨大黑墙。

作为一只好奇的乌鸦,卡拉仔细观察这面墙。它似乎由许多大型带玻璃前面的设备组成,并连接到电缆上。设备背面标示着LedTec SIG-5030

一次快速的网络搜索找到了这些设备的用户手册。它们似乎是交通标志,配有可编程的琥珀色LED灯矩阵。人类的意图可能是在事件期间在上面显示某种信息。有趣的是,这些屏幕可以通过无线网络进行编程。它们是否连接到了建筑的本地网络?

网络上的每个设备都有一个IP 地址,其他设备可以用它来向其发送消息。我们在第十三章中对此进行了更多讨论。卡拉注意到她自己的手机都获得了像10.0.0.2010.0.0.33这样的地址。尝试向所有这些地址发送消息,看看是否有一个响应手册中描述的接口,可能值得一试。

第十八章展示了如何在真实网络上发出真实请求。在这一章中,我们将使用一个名为request的简化虚拟函数进行网络通信。该函数接受两个参数——一个网络地址和一条消息,消息可以是任何可以作为JSON发送的内容——并返回一个承诺,要么解析为来自给定地址的机器的响应,要么在出现问题时拒绝。

根据手册,通过向SIG-5030标志发送内容为{"command": "display", "data": [0, 0, 3, ...]}的消息,可以改变显示的内容,其中数据为每个 LED 点提供一个数字,表示其亮度——0表示关闭,3表示最大亮度。每个标志宽50个灯,高30个灯,因此更新命令应该发送1,500个数字。

这段代码向本地网络上的所有地址发送显示更新消息,以查看哪个有效。IP 地址中的每个数字可以在0255之间变化。在它发送的数据中,激活与网络地址最后一个数字对应的多个灯光。

for (let addr = 1; addr < 256; addr++) {let data = [];for (let n = 0; n < 1500; n++) {data.push(n < addr ? 3 : 0);}let ip = `10.0.0.${addr}`;request(ip, {command: "display", data}).then(() => console.log(`Request to ${ip} accepted`)).catch(() => {});
}

由于大多数这些地址不存在或不接受此类消息,捕捉调用确保网络错误不会使程序崩溃。所有请求立即发送,而不等待其他请求完成,以免在某些机器未响应时浪费时间。

扫描网络后,卡拉回到外面查看结果。令她高兴的是,所有屏幕的左上角都显示了一条光带。它们确实在本地网络上,并且确实接受命令。她迅速记录下每个屏幕上显示的数字。有九个屏幕,排列成三行三列。它们的网络地址如下:

const screenAddresses = ["10.0.0.44", "10.0.0.45", "10.0.0.41","10.0.0.31", "10.0.0.40", "10.0.0.42","10.0.0.48", "10.0.0.47", "10.0.0.46"
];

现在这为各种捣蛋行为打开了可能性。她可以在墙上用巨大的字母展示“乌鸦统治,人类流口水”。但这感觉有点粗糙。相反,她计划在晚上展示一段飞翔的乌鸦视频,覆盖所有屏幕。

Carla找到了一段合适的视频剪辑,其中可以重复一秒半的镜头,以创建一个循环视频,展示乌鸦的翅膀拍打。为了适应九个屏幕(每个屏幕可以显示50*×*30像素),Carla裁剪并调整视频大小,得到一系列150*×*90的图像,每秒10帧。然后将这些图像切割成九个矩形,并进行处理,使视频中的黑暗区域(乌鸦所在处)显示明亮的光,而光亮区域(没有乌鸦)保持黑暗,这应该能产生乌鸦在黑色背景下飞翔的琥珀色效果。

她已设置clipImages变量,以保存一个帧的数组,其中每一帧由九组像素数组表示——每个屏幕一组——以所需的格式表示。

为了显示视频的单个帧,Carla需要同时向所有屏幕发送请求。但她还需要等待这些请求的结果,以便在当前帧正确发送之前不开始发送下一帧,并注意请求何时失败。

Promise有一个静态方法all,可以将一个promise数组转换为一个解析为结果数组的单一promise。这提供了一种方便的方式,使一些异步操作能够并行进行,等待它们全部完成,然后对它们的结果进行处理(或者至少等待它们以确保它们不会失败)。

function displayFrame(frame) {return Promise.all(frame.map((data, i) => {return request(screenAddresses[i], {command: "display",data});}));
}

这映射了帧中的图像(这是一个显示数据数组的数组),以创建一个请求promise的数组。然后它返回一个组合所有这些promisepromise

为了能够停止正在播放的视频,该过程被封装在一个类中。这个类有一个异步的播放方法,返回一个仅在通过停止方法再次停止播放时才会解析的promise

function wait(time) {return new Promise(accept => setTimeout(accept, time));
}class VideoPlayer {constructor(frames, frameTime) {this.frames = frames;this.frameTime = frameTime;this.stopped = true;}async play() {this.stopped = false;for (let i = 0; !this.stopped; i++) {let nextFrame = wait(this.frameTime);await displayFrame(this.frames[i % this.frames.length]);await nextFrame;}}stop() {this.stopped = true;}
}

wait函数将setTimeout包装在一个promise中,该promise在给定的毫秒数后解析。这对于控制播放速度非常有用。

let video = new VideoPlayer(clipImages, 100);
video.play().catch(e => {console.log("Playback failed: " + e);
});
setTimeout(() => video.stop(), 15000);

在屏幕墙存在的整个星期,每晚,当天黑时,一个巨大的发光橙色鸟神秘地出现在上面。

事件循环

一个异步程序通过运行其主脚本开始,这通常会设置回调以便稍后调用。该主脚本以及回调会以一整块完成,不会被打断。但它们之间,程序可能会处于闲置状态,等待某些事情发生。

因此,回调并不是由调度它们的代码直接调用。如果我在一个函数中调用setTimeout,那么在回调函数被调用时,该函数将已经返回。当回调返回时,控制权不会返回到调度它的函数。

异步行为发生在它自己空的函数调用栈上。这是没有promise时,跨异步代码管理异常如此困难的原因之一。由于每个回调开始时栈几乎是空的,当它们抛出异常时,你的catch处理程序不会在栈上。

try {setTimeout(() => {throw new Error("Woosh");}, 20);
} catch (e) {// This will not runconsole.log("Caught", e);
}

无论事件——如超时或传入请求——发生得多么紧密,JavaScript 环境一次只能运行一个程序。你可以把它看作是在你的程序周围运行一个大循环,称为事件循环。当没有事情可做时,该循环会暂停。但是随着事件的到来,它们会被添加到队列中,代码会一个接一个地执行。因为没有两个事情可以同时运行,运行缓慢的代码可能会延迟处理其他事件。

这个例子设置了一个超时,但随后拖延,直到超时预定的时间点之后,导致超时变得迟到。

let start = Date.now();
setTimeout(() => {console.log("Timeout ran at", Date.now() - start);
}, 20);
while (Date.now() < start + 50) {}
console.log("Wasted time until", Date.now() - start);
// → Wasted time until 50
// → Timeout ran at 55

Promise总是作为一个新事件解析或拒绝。即使一个promise已经被解析,等待它也会导致你的回调在当前脚本完成后运行,而不是立即运行。

Promise.resolve("Done").then(console.log);
console.log("Me first!");
// → Me first!
// → Done

在后面的章节中,我们将看到在事件循环上运行的各种其他类型的事件。

异步错误

当你的程序同步运行时,一次性完成,除了程序自身进行的状态变化之外,没有其他状态变化。对于异步程序来说,这种情况不同——它们在执行过程中可能会有空隙,其他代码可以在这些空隙中运行。

让我们看一个例子。这是一个尝试报告数组中每个文件大小的函数,确保同时读取它们,而不是按顺序读取。

async function fileSizes(files) {let list = "";await Promise.all(files.map(async fileName => {list += fileName + ": " +(await textFile(fileName)).length + "\n";}));return list;
}

async fileName =>部分展示了如何通过在箭头函数前面加上async关键字来使箭头函数也变为异步。

代码乍一看并没有什么可疑之处……它对名称数组映射异步箭头函数,创建一个promises数组,然后使用Promise.all等待所有这些,才返回它们构建的列表。

但这个程序完全有问题。它总是只返回一行输出,列出读取时间最长的文件。

你能找出原因吗?

问题出在+=操作符上,它在语句开始执行时取list当前值,然后在await完成时,将list绑定设置为该值加上添加的字符串。

但在语句开始执行和结束之间,有一个异步的空隙。映射表达式在列表中添加任何内容之前就运行,因此每个+=操作符都是从一个空字符串开始,最后在存储检索完成时,将list设置为将其行添加到空字符串的结果。

这本可以通过返回映射promises的行并在Promise.all的结果上调用join来轻松避免,而不是通过更改绑定来构建列表。像往常一样,计算新值比更改现有值更不易出错。

async function fileSizes(files) {let lines = files.map(async fileName => {return fileName + ": " +(await textFile(fileName)).length;});return (await Promise.all(lines)).join("\n");
}

像这样的错误很容易出现,特别是在使用await时,你应该意识到代码中的漏洞所在。JavaScript的显式异步性(无论是通过回调、Promise还是await)的一大优点是,发现这些漏洞相对简单。

摘要

异步编程使得在不冻结整个程序的情况下,表达对长时间运行操作的等待成为可能。JavaScript环境通常使用回调实现这种编程风格,即在操作完成时调用的函数。事件循环会调度这些回调在合适的时候依次调用,以确保它们的执行不会重叠。

Promise使得异步编程变得更简单,Promise是代表可能在未来完成的操作的对象,而异步函数则允许你像同步程序一样编写异步程序。

练习

安静的时光

卡拉实验室附近有一台通过运动传感器激活的监控摄像头。它连接到网络并在激活时开始发送视频流。因为她不想被发现,卡拉建立了一个系统,可以注意到这种无线网络流量,并在外面有活动时在她的巢穴中打开灯,以便她知道什么时候保持安静。

她还记录了摄像头被触发的时间一段时间,并希望利用这些信息来可视化一周内哪些时段通常比较安静,哪些时段则比较繁忙。日志存储在每行包含一个时间戳数字(由Date.now()返回)的文件中。

1695709940692
1695701068331
1695701189163

camera_logs.txt文件保存了日志文件的列表。编写一个异步函数activityTable(day),该函数为给定的星期几返回一个包含 24 个数字的数组,每个数字对应一天中的每个小时,表示该小时内观察到的摄像头网络流量。星期几通过数字标识,使用Date.getDay的方法,其中星期天是 0,星期六是 6。

activityGraph函数,由沙盒提供,将这样的表汇总为一个字符串。

要读取文件,请使用前面定义的textFile函数——给定一个文件名,它返回一个解析为文件内容的Promise。记住,new Date(*timestamp*)会为该时间创建一个Date对象,该对象有getDaygetHours方法,分别返回星期几和小时。

两种类型的文件——日志文件列表和日志文件本身——每一条数据都在自己的行上,通过换行符(\n)分隔。

真实的 Promise

重写之前练习中的函数,不使用async/await,使用普通的Promise方法。

在这种风格下,使用Promise.all会比尝试对日志文件建模的循环更方便。在异步函数中,简单地在循环中使用await更为简单。如果读取文件需要一些时间,哪种方法运行所需的时间最少?

如果文件列表中的某个文件有拼写错误,导致读取失败,这个失败是如何反映到你的函数返回的Promise对象中的?

构建 Promise.all

正如我们所看到的,给定一个承诺数组,Promise.all返回一个承诺,等待数组中所有承诺完成。它然后成功,返回一个结果值数组。如果数组中的一个承诺失败,所有的承诺返回的承诺也会失败,并传递失败承诺的失败原因。

自己实现类似的功能,作为一个名为Promise_all的常规函数。

请记住,在一个承诺成功或失败后,它无法再次成功或失败,对其解析的函数的进一步调用将被忽略。这可以简化你处理承诺失败的方式。

评估器,用于确定编程语言中表达式的含义,仅仅是另一个程序。

—哈尔·阿贝尔森和杰拉尔德·萨斯曼,《计算机程序的结构与解释》

https://github.com/OpenDocCN/geekdoc-js-zh/raw/master/docs/elqt-js-4e/img/f0194-01.jpg

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.hqwc.cn/news/895297.html

如若内容造成侵权/违法违规/事实不符,请联系编程知识网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

【学习笔记】连通性相关

Learning强连通分量定义 强连通是指在有向图中任意两节点 \(u,v\) 可相互到达,我们称 \(u,v\) 两点强连通。 强连通分量(Strongly Connected Compoments,SCC)是指极大的强连通子图。 如何求强连通分量算法一:Tarjan 最常用的就是 Tarjan 算法。 前置算法:DFS 生成树。 顾名…

[GDOUCTF 2023]doublegame wp

一个游戏为贪吃蛇,另一个游戏maze 在string里面能够直接看到迷宫点击查看代码 000000000000000000000 0 0 0 0 0 0 0 0 0 0 00000 00000 0 0 0 0 0 0 0 000 000 0 000 0 0 0 0 0 0 0 0 0 0 0 0 0 0 00000 000 000 0 0 0 0 0 0 0 0 000 …

React—12—ReactRouter

一、路由 ◼ 路由其实是网络工程中的一个术语:  在架构一个网络时,非常重要的两个设备就是路由器和交换机。  当然,目前在我们生活中路由器也是越来越被大家所熟知,因为我们生活中都会用到路由器:  事实上,路由器主要维护的是一个映射表;  映射表会决定数据的流…

如何将立创EDA/Altium Designer绘制的原理图导入/转换为Visio格式

相信各位写论文的学生在绘制论文电路图的时候都有苦恼,手绘太烦太累,在AD或立创EDA上已经绘制的又没有方便的导入软件 me too,直至今天找到一种非常方便的导入方式,除了拐角处有那么一丢丢缺陷其他感觉不错,下面予以介绍,顺便做个记录以防自己再忘了。 思路 PCB原理图的绘…

新版本将飞飞资源提取到unity中

旧版本方法:飞飞资源提取工具atools→3DMAX→unity 旧版文章链接:[Unity3D] 如何将飞飞游戏资源提取并加载到uinty3d中 - 伊凡晴天 - 博客园新版方法:飞飞资源提取工具atools→blender3.6→unity 新版方法视频:如何将飞飞游戏资源提取到unity中_哔哩哔哩_bilibili飞飞资源提取工…

电压转换模块

一、DCDC,LDO,电压基准的区别? DCDC电源转换电路可以承受大的压差,输出电流也比较大,带负载能力强,随随便便可以有几A的电流输出 DCDC即可以降压,还可以升压,而LDO只能降压。 LDO又叫线性稳压器,他的特点是输入电流和输出电流相当,这就造成了一个特别巨大的问题,就是…

多线程程序设计(二)——Immutable

本文摘要了《Java多线程设计模式》一书中提及的 Immutable 模式的适用场景,并针对书中例子(若干名称有微调)给出一份 C++ 参考实现及其 UML 逻辑图,也列出与之相关的模式。 ◆ 适用场景 多个线程在同时访问共享数据时,只需要读取数据而不必修改数据。 ◆ 解决方案 无需使用…

第三周第五天

所用时间:315分钟 代码量(行):197 博客量(篇):1 了解到的知识点: 1.完成了简单的安卓程序开发 通过springboot后端应用服务器将安卓程序插入到mysql数据库 程序页面如下:刚开始添加好一会儿都进不去,原来是服务器没弄好,服务器路径一定要搞好 我测试用的路径:url(&…

第二次作业-个人项目

第二次作业这个作业属于哪个课程 第二次作业这个作业要求在哪里 作业要求这个作业的目标 完成论文查重程序Github仓库地址 https://github.com/Simonysc-123/3123004761PSP2.1 Personal Software Process Stages 预估耗时(分钟 实际耗时(分钟)Planning 计划 10 15Estimate 估…

项目里如何引入阿里巴巴矢量图标库-iconfont

项目里如何引入阿里巴巴矢量图标库-iconfont 一、搜索或者直接选择自己想要的图标类型 二、选中想要的图标,加入购物车,可以选择多个 三、点击购物车可以将选择的图标加入原有项目,也可以新建项目 四、确定之后,选择下载至本地(下载后的图标是灰色的,没有颜色,若想有…