二十一、使用模板文字和标记模板
- 21.1 消歧:“模板”
- 21.2 模板文字
- 21.3 标记模板
- 21.3.1 熟练与原始模板字符串(高级)(ch_template-literals.html#template-strings-cooked-vs-raw)
- 21.4 标记模板的示例(通过库提供)
- 21.4.1 标记函数库:lit-html
- 21.4.2 标记函数库:re-template-tag
- 21.4.3 标记函数库:graphql-tag
- 21.5 原始字符串文字
- 21.6 (高级)(ch_template-literals.html#advanced-3)
- 21.7 多行模板文字和缩进(ch_template-literals.html#multiline-template-literals-and-indentation)
- 21.7.1 修复:用于去除缩进的模板标记(ch_template-literals.html#fix-template-tag-for-dedenting)
- 21.7.2 修复:
.trim()
(ch_template-literals.html#fix-.trim)
- 21.8 通过模板文字进行简单模板化
- 21.8.1 更复杂的例子
- 21.8.2 简单的 HTML 转义
在我们深入研究模板文字和标记模板这两个功能之前,让我们先来看看术语模板的多重含义。
21.1 消歧:“模板”
尽管所有这些名称中都有模板,并且它们看起来都很相似,但以下三个事物在很大程度上是不同的:
- 文本模板是从数据到文本的函数。它经常用于 Web 开发,并经常通过文本文件定义。例如,以下文本定义了库Handlebars的模板:
<div class="entry"> <h1>{{title}}</h1> <div class="body"> {{body}} </div> </div>
- 这个模板有两个空白需要填写:
title
和body
。它的使用方式如下:
// First step: retrieve the template text, e.g. from a text file. const tmplFunc = Handlebars.compile(TMPL_TEXT); // compile string const data = {title: 'My page', body: 'Welcome to my page!'}; const html = tmplFunc(data);
- 模板文字类似于字符串文字,但具有附加功能-例如插值。它由反引号界定:
const num = 5; assert.equal(`Count: ${num}!`, 'Count: 5!');
- 在语法上,标记模板是一个遵循函数(或者更确切地说,求值为函数的表达式)的模板文字。这导致函数被调用。它的参数来自模板文字的内容。
const getArgs = (...args) => args; assert.deepEqual( getArgs`Count: ${5}!`, [['Count: ', '!'], 5] );
- 请注意,
getArgs()
接收文字模板的文本和通过${}
插值的数据。
21.2 模板文字
模板文字与普通字符串文字相比具有两个新功能。
首先,它支持字符串插值:如果我们将动态计算的值放在${}
中,它将被转换为字符串并插入到文字模板返回的字符串中。
const MAX = 100; function doSomeWork(x) { if (x > MAX) { throw new Error(`At most ${MAX} allowed: ${x}!`); } // ··· } assert.throws( () => doSomeWork(101), {message: 'At most 100 allowed: 101!'});
其次,模板文字可以跨越多行:
const str = `this is a text with multiple lines`;
模板文字始终产生字符串。
21.3 标记模板
A 行中的表达式是标记模板。它相当于使用 B 行中数组中列出的参数调用tagFunc()
。
function tagFunc(...args) { return args; } const setting = 'dark mode'; const value = true; assert.deepEqual( tagFunc`Setting ${setting} is ${value}!`, // (A) [['Setting ', ' is ', '!'], 'dark mode', true] // (B) );
第一个反引号之前的函数tagFunc
称为标记函数。它的参数是:
- 模板字符串(第一个参数):一个包含围绕插值
${}
的文本片段的数组。
- 在示例中:
['Setting ', ' is ', '!']
- 替换(剩余参数):插值的值。
- 在示例中:
'dark mode'
和true
文字模板的静态(固定)部分(模板字符串)与动态部分(替换)保持分开。
标记函数可以返回任意值。
21.3.1 熟练与原始模板字符串(高级)
到目前为止,我们只看到了模板字符串的cooked 解释。但是标签函数实际上有两种解释:
- cooked 解释,其中反斜杠具有特殊含义。例如,
\t
会产生一个制表符。模板字符串的这种解释存储为第一个参数中的数组。 - 原始解释,其中反斜杠没有特殊含义。例如,
\t
会产生两个字符 - 一个反斜杠和一个t
。模板字符串的这种解释存储在第一个参数(一个数组)的.raw
属性中。
原始解释通过String.raw
启用原始字符串文字(稍后描述)和类似的应用。
以下标签函数cookedRaw
使用了两种解释:
function cookedRaw(templateStrings, ...substitutions) { return { cooked: Array.from(templateStrings), // copy only Array elements raw: templateStrings.raw, substitutions, }; } assert.deepEqual( cookedRaw`\tab${'subst'}\newline\\`, { cooked: ['\tab', '\newline\\'], raw: ['\\tab', '\\newline\\\\'], substitutions: ['subst'], });
我们还可以在标记模板中使用 Unicode 代码点转义(\u{1F642}
),Unicode 代码单元转义(\u03A9
)和 ASCII 转义(\x52
):
assert.deepEqual( cookedRaw`\u{54}\u0065\x78t`, { cooked: ['Text'], raw: ['\\u{54}\\u0065\\x78t'], substitutions: [], });
如果其中一个转义的语法不正确,相应的 cooked 模板字符串是undefined
,而原始版本仍然是逐字的:
assert.deepEqual( cookedRaw`\uu\xx ${1} after`, { cooked: [undefined, ' after'], raw: ['\\uu\\xx ', ' after'], substitutions: [1], });
在模板文字和字符串文字中,不正确的转义会产生语法错误。在 ES2018 之前,它们甚至在标记模板中产生错误。为什么要改变呢?现在我们可以使用标记模板来处理以前非法的文本 - 例如:
windowsPath`C:\uuu\xxx\111` latex`\unicode`
21.4 标记模板的示例(通过库提供)
标记模板非常适合支持小型嵌入式语言(所谓的领域特定语言)。我们将继续举几个例子。
21.4.1 标签函数库:lit-html
lit-html是一个基于标记模板的模板库,被前端框架 Polymer使用:
import {html, render} from 'lit-html'; const template = (items) => html` <ul> ${ repeat(items, (item) => item.id, (item, index) => html`<li>${index}. ${item.name}</li>` ) } </ul> `;
repeat()
是一个用于循环的自定义函数。它的第二个参数为第三个参数返回的值生成唯一的键。请注意该参数使用的嵌套标记模板。
21.4.2 标签函数库:re-template-tag
re-template-tag是一个简单的库,用于组合正则表达式。使用re
标记的模板会产生正则表达式。主要好处是我们可以通过${}
(A 行)插入正则表达式和纯文本:
const RE_YEAR = re`(?<year>[0-9]{4})`; const RE_MONTH = re`(?<month>[0-9]{2})`; const RE_DAY = re`(?<day>[0-9]{2})`; const RE_DATE = re`/${RE_YEAR}-${RE_MONTH}-${RE_DAY}/u`; // (A) const match = RE_DATE.exec('2017-01-27'); assert.equal(match.groups.year, '2017');
21.4.3 标签函数库:graphql-tag
graphql-tag 库让我们可以通过标记模板创建 GraphQL 查询:
import gql from 'graphql-tag'; const query = gql` { user(id: 5) { firstName lastName } } `;
此外,还有用于在 Babel、TypeScript 等中预编译此类查询的插件。
21.5 原始字符串文字
原始字符串文字是通过标签函数String.raw
实现的。它们是字符串文字,其中反斜杠不会做任何特殊处理(例如转义字符等):
assert.equal(String.raw`\back`, '\\back');
这有助于在数据包含反斜杠时使用 - 例如,包含正则表达式的字符串:
const regex1 = /^\./; const regex2 = new RegExp('^\\.'); const regex3 = new RegExp(String.raw`^\.`);
所有三个正则表达式都是等效的。使用普通字符串文字时,我们必须将反斜杠写两次,以便为该文字转义。使用原始字符串文字时,我们不必这样做。
原始字符串文字也可用于指定 Windows 文件名路径:
const WIN_PATH = String.raw`C:\foo\bar`; assert.equal(WIN_PATH, 'C:\\foo\\bar');
21.6 (高级)
所有剩余的部分都是高级的
21.7 多行模板文字和缩进
如果我们在模板文字中放入多行文本,存在两个目标的冲突:一方面,模板文字应该缩进以适应源代码。另一方面,其内容的行应该从最左边的列开始。
例如:
function div(text) { return ` <div> ${text} </div> `; } console.log('Output:'); console.log( div('Hello!') // Replace spaces with mid-dots: .replace(/ /g, '·') // Replace \n with #\n: .replace(/\n/g, '#\n') );
由于缩进,模板文字很好地适应了源代码。遗憾的是,输出也被缩进了。我们不希望在开头有换行,结尾有换行加两个空格。
Output: # ····<div># ······Hello!# ····</div># ··
有两种方法可以解决这个问题:通过标记模板或修剪模板文字的结果。
21.7.1 修复:用于去除缩进的模板标签
第一个修复是使用自定义模板标签来移除不需要的空白。它使用初始换行后的第一行来确定文本从哪一列开始,并在所有地方缩短缩进。它还移除了开头的换行和结尾的缩进。一个这样的模板标签是Desmond Brand 的dedent
:
import dedent from 'dedent'; function divDedented(text) { return dedent` <div> ${text} </div> `.replace(/\n/g, '#\n'); } console.log('Output:'); console.log(divDedented('Hello!'));
这次,输出没有缩进:
Output: <div># Hello!# </div>
21.7.2 修复:.trim()
第二个修复更快,但也更脏:
function divDedented(text) { return ` <div> ${text} </div> `.trim().replace(/\n/g, '#\n'); } console.log('Output:'); console.log(divDedented('Hello!'));
字符串方法.trim()
会移除开头和结尾的多余空白,但内容本身必须从最左边的列开始。这种解决方案的优点是我们不需要自定义标签函数。缺点是它看起来很丑。
输出与dedent
相同:
Output: <div># Hello!# </div>
21.8 使用模板文字进行简单模板化
虽然模板文字看起来像文本模板,但如何使用它们进行(文本)模板化并不是立即明显的:文本模板从对象中获取数据,而模板文字从变量中获取数据。解决方案是在接收模板数据的函数的主体中使用模板文字,例如:
const tmpl = (data) => `Hello ${data.name}!`; assert.equal(tmpl({name: 'Jane'}), 'Hello Jane!');
21.8.1 一个更复杂的例子
作为一个更复杂的例子,我们想要取一个地址数组并生成一个 HTML 表格。这是数组:
const addresses = [ { first: '<Jane>', last: 'Bond' }, { first: 'Lars', last: '<Croft>' }, ];
生成 HTML 表格的函数tmpl()
如下所示:
const tmpl = (addrs) => ` <table> ${addrs.map( (addr) => ` <tr> <td>${escapeHtml(addr.first)}</td> <td>${escapeHtml(addr.last)}</td> </tr> `.trim() ).join('')} </table> `.trim();
此代码包含两个模板函数:
- 第一个函数(第 1 行)接受一个包含地址的数组
addrs
,并返回一个带有表格的字符串。 - 第二个函数(第 4 行)接受一个包含地址的对象
addr
,并返回一个带有表格行的字符串。注意末尾的.trim()
,它会移除不必要的空白。
第一个模板函数通过将一个包含在 HTML 中的数组包装成一个字符串来生成其结果(第 10 行)。该数组是通过将第二个模板函数映射到addrs
的每个元素(第 3 行)而生成的。因此,它包含带有表格行的字符串。
辅助函数escapeHtml()
用于转义特殊的 HTML 字符(第 6 行和第 7 行)。其实现在下一小节中显示。
让我们用地址调用tmpl()
并记录结果:
console.log(tmpl(addresses));
输出是:
<table> <tr> <td><Jane></td> <td>Bond</td> </tr><tr> <td>Lars</td> <td><Croft></td> </tr> </table>
21.8.2 简单的 HTML 转义
以下函数会转义纯文本,以便在 HTML 中原样显示:
function escapeHtml(str) { return str .replace(/&/g, '&') // first! .replace(/>/g, '>') .replace(/</g, '<') .replace(/"/g, '"') .replace(/'/g, ''') .replace(/`/g, '`') ; } assert.equal( escapeHtml('Rock & Roll'), 'Rock & Roll'); assert.equal( escapeHtml('<blank>'), '<blank>');
练习:HTML 模板化
练习和奖励挑战:exercises/template-literals/templating_test.mjs
测验
查看测验应用。
二十二、符号
原文:
exploringjs.com/impatient-js/ch_symbols.html
译者:飞龙
- 22.1 符号是原始值,也像对象
- 22.1.1 符号是原始值
- 22.1.2 符号也像对象
- 22.2 符号的描述
- 22.3 使用符号的用例
- 22.3.1 符号作为常量的值
- 22.3.2 符号作为唯一的属性键
- 22.4 公开已知的符号
- 22.5 转换符号
22.1 符号是原始值,也像对象
符号是通过工厂函数Symbol()
创建的原始值:
const mySymbol = Symbol('mySymbol');
参数是可选的,并提供一个描述,这对于调试非常有用。
22.1.1 符号是原始值
符号是原始值:
- 它们必须通过
typeof
进行分类:
const sym = Symbol(); assert.equal(typeof sym, 'symbol');
- 它们可以是对象中的属性键:
const obj = { [sym]: 123, };
22.1.2 符号也像对象
尽管符号是原始值,但它们也像对象,因为Symbol()
创建的每个值都是唯一的,不是按值比较的:
> Symbol() === Symbol() false
在引入符号之前,如果我们需要唯一的值(只等于自身),对象是最佳选择:
const string1 = 'abc'; const string2 = 'abc'; assert.equal( string1 === string2, true); // not unique const object1 = {}; const object2 = {}; assert.equal( object1 === object2, false); // unique const symbol1 = Symbol(); const symbol2 = Symbol(); assert.equal( symbol1 === symbol2, false); // unique
22.2 符号的描述
我们传递给符号工厂函数的参数为创建的符号提供了一个描述:
const mySymbol = Symbol('mySymbol');
描述可以通过两种方式访问。
首先,它是由.toString()
返回的字符串的一部分:
assert.equal(mySymbol.toString(), 'Symbol(mySymbol)');
其次,自 ES2019 以来,我们可以通过属性.description
检索描述:
assert.equal(mySymbol.description, 'mySymbol');
22.3 使用符号的用例
符号的主要用例是:
- 常量的值
- 唯一的属性键
22.3.1 符号作为常量的值
假设您想创建代表红色、橙色、黄色、绿色、蓝色和紫色的常量。一个简单的方法是使用字符串:
const COLOR_BLUE = 'Blue';
好处是,记录该常量会产生有用的输出。坏处是,有可能错误地将与颜色无关的值误认为是颜色,因为两个具有相同内容的字符串被认为是相等的:
const MOOD_BLUE = 'Blue'; assert.equal(COLOR_BLUE, MOOD_BLUE);
我们可以通过符号解决这个问题:
const COLOR_BLUE = Symbol('Blue'); const MOOD_BLUE = Symbol('Blue'); assert.notEqual(COLOR_BLUE, MOOD_BLUE);
让我们使用符号值常量来实现一个函数:
const COLOR_RED = Symbol('Red'); const COLOR_ORANGE = Symbol('Orange'); const COLOR_YELLOW = Symbol('Yellow'); const COLOR_GREEN = Symbol('Green'); const COLOR_BLUE = Symbol('Blue'); const COLOR_VIOLET = Symbol('Violet'); function getComplement(color) { switch (color) { case COLOR_RED: return COLOR_GREEN; case COLOR_ORANGE: return COLOR_BLUE; case COLOR_YELLOW: return COLOR_VIOLET; case COLOR_GREEN: return COLOR_RED; case COLOR_BLUE: return COLOR_ORANGE; case COLOR_VIOLET: return COLOR_YELLOW; default: throw new Exception('Unknown color: '+color); } } assert.equal(getComplement(COLOR_YELLOW), COLOR_VIOLET);
22.3.2 符号作为唯一的属性键
对象中属性(字段)的键在两个级别上使用:
- 程序在基本级别上运行。该级别的键反映了问题域- 程序解决问题的领域 - 例如:
- 如果程序管理员工,属性键可能是关于职称、薪水类别、部门 ID 等。
- 如果程序是一个国际象棋应用,属性键可能是有关国际象棋棋子、国际象棋棋盘、玩家颜色等的。
- ECMAScript 和许多库在元级别上操作。它们管理数据并提供不属于问题域的服务 - 例如:
- 标准方法
.toString()
在创建对象的字符串表示时由 ECMAScript 使用(A 行):
const point = { x: 7, y: 4, toString() { return `(${this.x}, ${this.y})`; }, }; assert.equal( String(point), '(7, 4)'); // (A)
.x
和.y
是基本级别的属性 - 它们用于解决计算点的问题。.toString()
是一个元级别的属性 - 它与问题域无关。- 标准的 ECMAScript 方法
.toJSON()
const point = { x: 7, y: 4, toJSON() { return [this.x, this.y]; }, }; assert.equal( JSON.stringify(point), '[7,4]');
.x
和.y
是基本级别的属性,.toJSON()
是一个元级别的属性。
程序的基本级别和元级别必须是独立的:基本级别的属性键不应与元级别的属性键冲突。
如果我们使用名称(字符串)作为属性键,我们将面临两个挑战:
- 当一种语言首次创建时,它可以使用任何元级别的名称。基本级别的代码被迫避免使用这些名称。然而,当大量基本级别的代码已经存在时,元级别的名称就不能再自由选择了。
- 我们可以引入命名规则来区分基本级别和元级别。例如,Python 用两个下划线括住元级别名称:
__init__
,__iter__
,__hash__
等。然而,语言的元级别名称和库的元级别名称仍然存在于同一个命名空间中,并且可能会发生冲突。
这是 JavaScript 中后者成为问题的两个例子:
- 在 2018 年 5 月,数组方法
.flatten()
不得不改名为.flat()
,因为前者的名称已经被库使用了(来源)。 - 在 2020 年 11 月,数组方法
.item()
不得不改名为.at()
,因为前者的名称已经被库使用了(来源)。
在这里,作为属性键使用的符号有助于我们:每个符号都是唯一的,符号键永远不会与任何其他字符串或符号键冲突。
22.3.2.1 示例:具有元级别方法的库
举个例子,假设我们正在编写一个库,如果对象实现了一个特殊方法,它会以不同的方式处理对象。这就是为这样一个方法定义属性键并为对象实现它的样子:
const specialMethod = Symbol('specialMethod'); const obj = { _id: 'kf12oi', [specialMethod]() { // (A) return this._id; } }; assert.equal(obj[specialMethod](), 'kf12oi');
A 行中的方括号使我们能够指定该方法必须具有键specialMethod
。更多细节在§28.7.2“对象文字中的计算键”中有解释。
22.4 公开已知符号
在 ECMAScript 中扮演特殊角色的符号称为公开已知符号。例如:
Symbol.iterator
: 使对象可迭代。它是返回迭代器的方法的键。有关此主题的更多信息,请参见§30“同步迭代”。Symbol.hasInstance
: 自定义instanceof
的工作方式。如果对象实现了具有该键的方法,则可以在该运算符的右侧使用它。例如:
const PrimitiveNull = { Symbol.hasInstance { return x === null; } }; assert.equal(null instanceof PrimitiveNull, true);
Symbol.toStringTag
: 影响默认的.toString()
方法。
> String({}) '[object Object]' > String({ [Symbol.toStringTag]: 'is no money' }) '[object is no money]'
- 注意:通常最好重写
.toString()
。
练习:公开已知符号
Symbol.toStringTag
:exercises/symbols/to_string_tag_test.mjs
Symbol.hasInstance
:exercises/symbols/has_instance_test.mjs
22.5 转换符号
如果我们将符号sym
转换为另一个原始类型会发生什么?Tbl. 15 中有答案。
表 15:将符号转换为其他原始类型的结果。
转换为 | 显式转换 | 强制转换(隐式转换) |
布尔值 | Boolean(sym) → OK |
!sym → OK |
数字 | Number(sym) → TypeError |
sym*2 → TypeError |
字符串 | String(sym) → OK |
''+sym → TypeError |
sym.toString() → OK |
${sym} → TypeError |
符号的一个关键陷阱是在将它们转换为其他类型时经常抛出异常。这背后的思想是什么?首先,将符号转换为数字是毫无意义的,应该提出警告。其次,将符号转换为字符串确实对诊断输出很有用。但是,警告意外地将符号转换为字符串也是有道理的(这是一种不同类型的属性键):
const obj = {}; const sym = Symbol(); assert.throws( () => { obj['__'+sym+'__'] = true }, { message: 'Cannot convert a Symbol value to a string' });
缺点是异常使得使用符号变得更加复杂。在通过加号运算符组装字符串时,您必须显式转换符号:
> const mySymbol = Symbol('mySymbol'); > 'Symbol I used: ' + mySymbol TypeError: Cannot convert a Symbol value to a string > 'Symbol I used: ' + String(mySymbol) 'Symbol I used: Symbol(mySymbol)'
测验
查看测验应用程序。
第五部分:控制流和数据流
原文:
exploringjs.com/impatient-js/pt_control-flow-data-flow.html
译者:飞龙
下一步:23 控制流语句
二十三、控制流语句
原文:
exploringjs.com/impatient-js/ch_control-flow.html
译者:飞龙
- 23.1 控制循环:
break
和continue
- 23.1.1
break
- 23.1.2
break
加标签:离开任何带标签的语句 - 23.1.3
continue
- 23.2 控制流语句的条件
- 23.3
if
语句 [ES1]
- 23.3.1
if
语句的语法
- 23.4
switch
语句 [ES3]
- 23.4.1
switch
语句的第一个例子 - 23.4.2 不要忘记
return
或break
! - 23.4.3 空的 case 子句
- 23.4.4 通过
default
子句检查非法值
- 23.5
while
循环 [ES1]
- 23.5.1
while
循环的例子
- 23.6
do-while
循环 [ES3] - 23.7
for
循环 [ES1]
- 23.7.1
for
循环的例子
- 23.8
for-of
循环 [ES6]
- 23.8.1
const
:for-of
vs.for
- 23.8.2 遍历可迭代对象
- 23.8.3 遍历数组的[index, element]对
- 23.9
for-await-of
循环 [ES2018] - 23.10
for-in
循环(避免使用)[ES1] - 23.11 循环的建议
本章涵盖了以下控制流语句:
if
语句 [ES1]switch
语句 [ES3]while
循环 [ES1]do-while
循环 [ES3]for
循环 [ES1]for-of
循环 [ES6]for-await-of
循环 [ES2018]for-in
循环 [ES1]
23.1 控制循环:break
和 continue
两个操作符 break
和 continue
可以用于控制循环和其他语句,当我们在其中时。
23.1.1 break
break
有两个版本:一个带操作数,一个不带操作数。后者的版本适用于以下语句:while
, do-while
, for
, for-of
, for-await-of
, for-in
和 switch
。它会立即离开当前语句:
for (const x of ['a', 'b', 'c']) { console.log(x); if (x === 'b') break; console.log('---') } // Output: // 'a' // '---' // 'b'
23.1.2 break
加标签:离开任何带标签的语句
带操作数的 break
可以在任何地方使用。它的操作数是一个标签。标签可以放在任何语句前面,包括块。break my_label
会离开标签为 my_label
的语句:
my_label: { // label if (condition) break my_label; // labeled break // ··· }
在以下示例中,搜索可以是:
- 失败:循环在没有找到
result
的情况下结束。这直接在循环之后处理(B 行)。 - 成功:在循环中,我们找到了一个
result
。然后我们使用break
加标签(A 行)来跳过处理失败的代码。
function findSuffix(stringArray, suffix) { let result; search_block: { for (const str of stringArray) { if (str.endsWith(suffix)) { // Success: result = str; break search_block; // (A) } } // for // Failure: result = '(Untitled)'; // (B) } // search_block return { suffix, result }; // Same as: {suffix: suffix, result: result} } assert.deepEqual( findSuffix(['notes.txt', 'index.html'], '.html'), { suffix: '.html', result: 'index.html' } ); assert.deepEqual( findSuffix(['notes.txt', 'index.html'], '.mjs'), { suffix: '.mjs', result: '(Untitled)' } );
23.1.3 continue
continue
只能在 while
, do-while
, for
, for-of
, for-await-of
, 和 for-in
中使用。它会立即离开当前循环迭代,并继续下一个迭代 - 例如:
const lines = [ 'Normal line', '# Comment', 'Another normal line', ]; for (const line of lines) { if (line.startsWith('#')) continue; console.log(line); } // Output: // 'Normal line' // 'Another normal line'
23.2 控制流语句的条件
if
, while
和 do-while
都有原则上是布尔值的条件。但是,条件只需要是真值(如果强制转换为布尔值则为true
)就可以被接受。换句话说,以下两个控制流语句是等价的:
if (value) {} if (Boolean(value) === true) {}
这是所有假值的列表:
undefined
,null
false
0
,NaN
0n
''
所有其他值都是真值。更多信息,请参见§15.2 “Falsy and truthy values”。
23.3 if
语句 [ES1]
这些都是两个简单的if
语句:一个只有一个“then”分支,另一个既有“then”分支又有“else”分支:
if (cond) { // then branch } if (cond) { // then branch } else { // else branch }
而不是块,else
也可以跟着另一个if
语句:
if (cond1) { // ··· } else if (cond2) { // ··· } if (cond1) { // ··· } else if (cond2) { // ··· } else { // ··· }
您可以使用更多的else if
继续这个链。
23.3.1 if
语句的语法
if
语句的一般语法是:
if (cond) «then_statement» else «else_statement»
到目前为止,then_statement
一直是一个块,但我们可以使用任何语句。该语句必须以分号结束:
if (true) console.log('Yes'); else console.log('No');
这意味着else if
不是自己的结构;它只是一个else_statement
是另一个if
语句的if
语句。
23.4 switch
语句 [ES3]
switch
语句的外观如下:
switch («switch_expression») { «switch_body» }
switch
的主体由零个或多个 case 子句组成:
case «case_expression»: «statements»
并且,可选地,一个默认子句:
default: «statements»
switch
的执行方式如下:
- 它评估 switch 表达式。
- 它跳转到第一个 case 子句,其表达式与 switch 表达式的结果相同。
- 否则,如果没有这样的子句,它就会跳转到默认子句。
- 否则,如果没有默认子句,它就什么也不做。
23.4.1 switch
语句的第一个示例
让我们看一个例子:以下函数将数字从 1-7 转换为工作日的名称。
function dayOfTheWeek(num) { switch (num) { case 1: return 'Monday'; case 2: return 'Tuesday'; case 3: return 'Wednesday'; case 4: return 'Thursday'; case 5: return 'Friday'; case 6: return 'Saturday'; case 7: return 'Sunday'; } } assert.equal(dayOfTheWeek(5), 'Friday');
23.4.2 不要忘记return
或break
!
在 case 子句的末尾,执行会继续下一个 case 子句,除非我们return
或break
- 例如:
function englishToFrench(english) { let french; switch (english) { case 'hello': french = 'bonjour'; case 'goodbye': french = 'au revoir'; } return french; } // The result should be 'bonjour'! assert.equal(englishToFrench('hello'), 'au revoir');
也就是说,我们的dayOfTheWeek()
的实现之所以有效,仅仅是因为我们使用了return
。我们可以通过使用break
来修复englishToFrench()
:
function englishToFrench(english) { let french; switch (english) { case 'hello': french = 'bonjour'; break; case 'goodbye': french = 'au revoir'; break; } return french; } assert.equal(englishToFrench('hello'), 'bonjour'); // ok
23.4.3 空 case 子句
case 子句的语句可以被省略,这实际上给了我们每个 case 子句多个 case 表达式:
function isWeekDay(name) { switch (name) { case 'Monday': case 'Tuesday': case 'Wednesday': case 'Thursday': case 'Friday': return true; case 'Saturday': case 'Sunday': return false; } } assert.equal(isWeekDay('Wednesday'), true); assert.equal(isWeekDay('Sunday'), false);
23.4.4 通过default
子句检查非法值
如果switch
表达式没有其他匹配项,就会跳转到default
子句。这使得它对错误检查很有用:
function isWeekDay(name) { switch (name) { case 'Monday': case 'Tuesday': case 'Wednesday': case 'Thursday': case 'Friday': return true; case 'Saturday': case 'Sunday': return false; default: throw new Error('Illegal value: '+name); } } assert.throws( () => isWeekDay('January'), {message: 'Illegal value: January'});
练习:switch
exercises/control-flow/number_to_month_test.mjs
- 奖励:
exercises/control-flow/is_object_via_switch_test.mjs
23.5 while
循环 [ES1]
while
循环有以下语法:
while («condition») { «statements» }
在每次循环迭代之前,while
评估condition
:
- 如果结果为假,循环就结束了。
- 如果结果为真,
while
体将再执行一次。
23.5.1 while
循环的示例
以下代码使用了while
循环。在每次循环迭代中,它通过.shift()
移除arr
的第一个元素并记录它。
const arr = ['a', 'b', 'c']; while (arr.length > 0) { const elem = arr.shift(); // remove first element console.log(elem); } // Output: // 'a' // 'b' // 'c'
如果条件总是评估为true
,那么while
就是一个无限循环:
while (true) { if (Math.random() === 0) break; }
23.6 do-while
循环 [ES3]
do-while
循环的工作方式与while
很像,但它在每次循环迭代之后检查条件,而不是之前。
let input; do { input = prompt('Enter text:'); console.log(input); } while (input !== ':q');
do-while
也可以被视为至少运行一次的while
循环。
prompt()
是一个全局函数,在 Web 浏览器中可用。它提示用户输入文本并返回它。
23.7 for
循环 [ES1]
for
循环有以下语法:
for («initialization»; «condition»; «post_iteration») { «statements» }
第一行是循环的head,控制body(循环的其余部分)的执行次数。它有三个部分,每个部分都是可选的:
initialization
:为循环设置变量等。此处通过let
或const
声明的变量仅在循环内部存在。condition
:在每次循环迭代之前检查此条件。如果它是假的,循环就会停止。post_iteration
:此代码在每次循环迭代之后执行。
因此,for
循环大致相当于以下while
循环:
«initialization» while («condition») { «statements» «post_iteration» }
23.7.1 for
循环的示例
例如,这是如何通过for
循环从零数到两的:
for (let i=0; i<3; i++) { console.log(i); } // Output: // 0 // 1 // 2
这是如何通过for
循环记录数组的内容:
const arr = ['a', 'b', 'c']; for (let i=0; i<arr.length; i++) { console.log(arr[i]); } // Output: // 'a' // 'b' // 'c'
如果我们省略头的所有三个部分,我们就得到了一个无限循环:
for (;;) { if (Math.random() === 0) break; }
23.8 for-of
循环 [ES6]
for-of
循环遍历任何可迭代 - 支持迭代协议的数据容器。每个迭代的值都存储在变量中,如头部中指定的那样:
for («iteration_variable» of «iterable») { «statements» }
迭代变量通常是通过变量声明创建的:
const iterable = ['hello', 'world']; for (const elem of iterable) { console.log(elem); } // Output: // 'hello' // 'world'
但我们也可以使用一个(可变的)已经存在的变量:
const iterable = ['hello', 'world']; let elem; for (elem of iterable) { console.log(elem); }
23.8.1 const
:for-of
vs. for
请注意,在for-of
循环中,我们可以使用const
。迭代变量仍然可以在每次迭代中不同(它只是在迭代期间不能更改)。可以将其视为在新的作用域中每次执行一个新的const
声明。
相比之下,在for
循环中,如果它们的值发生变化,我们必须通过let
或var
声明变量。
23.8.2 遍历可迭代对象
如前所述,for-of
适用于任何可迭代对象,而不仅仅是数组 - 例如,与 Set 一起使用:
const set = new Set(['hello', 'world']); for (const elem of set) { console.log(elem); }
23.8.3 遍历数组的[index, element]对
最后,我们还可以使用for-of
来遍历数组的[index, element]条目:
const arr = ['a', 'b', 'c']; for (const [index, elem] of arr.entries()) { console.log(`${index} -> ${elem}`); } // Output: // '0 -> a' // '1 -> b' // '2 -> c'
使用[index, element]
,我们正在使用解构来访问数组元素。
练习:for-of
exercises/control-flow/array_to_string_test.mjs
23.9 for-await-of
循环[ES2018]
for-await-of
类似于for-of
,但它适用于异步可迭代对象,而不是同步可迭代对象。它只能在异步函数和异步生成器内部使用。
for await (const item of asyncIterable) { // ··· }
for-await-of
在异步迭代章节中有详细描述。
23.10 for-in
循环(避免)[ES1]
for-in
循环访问对象的所有(自己的和继承的)可枚举属性键。在遍历数组时,这很少是一个好选择:
- 它访问属性键,而不是值。
- 作为属性键,数组元素的索引是字符串,而不是数字(有关数组元素工作原理的更多信息)。
- 它访问所有可枚举的属性键(自己的和继承的),而不仅仅是数组元素的属性键。
以下代码演示了这些要点:
const arr = ['a', 'b', 'c']; arr.propKey = 'property value'; for (const key in arr) { console.log(key); } // Output: // '0' // '1' // '2' // 'propKey'
23.11 循环建议
- 如果要循环遍历异步可迭代对象(在 ES2018+中),必须使用
for-await-of
。 - 要循环同步可迭代对象(在 ES6+中),必须使用
for-of
。请注意,数组是可迭代对象。 - 要循环遍历 ES5+中的数组,可以使用数组方法
.forEach()
。 - 在 ES5 之前,您可以使用普通的
for
循环来遍历数组。 - 不要使用
for-in
循环遍历数组。
测验
请参阅测验应用程序。
二十四、异常处理
原文:
exploringjs.com/impatient-js/ch_exception-handling.html
译者:飞龙
- 24.1 动机:抛出和捕获异常
- 24.2
throw
- 24.2.1 我们应该抛出什么值?
- 24.3
try
语句
- 24.3.1
try
块 - 24.3.2
catch
子句 - 24.3.3
finally
子句
- 24.4
Error
及其子类
- 24.4.1 类
Error
- 24.4.2
Error
的内置子类 - 24.4.3 子类化
Error
- 24.5 链接错误
- 24.5.1 为什么我们想要链接错误?
- 24.5.2 通过
error.cause
[ES2022]链接错误 - 24.5.3
.cause
的替代方案:自定义错误类
本章介绍了 JavaScript 如何处理异常。
为什么 JavaScript 不经常抛出异常?
直到 ES3,JavaScript 才支持异常。这就解释了为什么它们在语言和标准库中被稀少地使用。
24.1 动机:抛出和捕获异常
考虑以下代码。它从文件中读取存储的配置文件到一个具有Profile
类实例的数组中:
function readProfiles(filePaths) { const profiles = []; for (const filePath of filePaths) { try { const profile = readOneProfile(filePath); profiles.push(profile); } catch (err) { // (A) console.log('Error in: '+filePath, err); } } } function readOneProfile(filePath) { const profile = new Profile(); const file = openFile(filePath); // ··· (Read the data in `file` into `profile`) return profile; } function openFile(filePath) { if (!fs.existsSync(filePath)) { throw new Error('Could not find file '+filePath); // (B) } // ··· (Open the file whose path is `filePath`) }
让我们来看看 B 行发生了什么:发生了错误,但处理问题的最佳位置不是当前位置,而是 A 行。在那里,我们可以跳过当前文件并继续下一个文件。
因此:
- 在 B 行,我们使用
throw
语句表示出现了问题。 - 在 A 行,我们使用
try-catch
语句来处理问题。
当我们抛出时,以下结构是活动的:
readProfiles(···) for (const filePath of filePaths) try readOneProfile(···) openFile(···) if (!fs.existsSync(filePath)) throw
throw
逐个退出嵌套结构,直到遇到try
语句。执行将继续在该try
语句的catch
子句中。
24.2 throw
这是throw
语句的语法:
throw «value»;
24.2.1 我们应该抛出什么值?
在 JavaScript 中可以抛出任何值。但最好使用Error
的实例或子类,因为它们支持附加功能,如堆栈跟踪和错误链接(参见§24.4“Error
及其子类”)。
这给我们留下了以下选项:
- 直接使用
Error
类。这在 JavaScript 中比在更静态的语言中更不受限制,因为我们可以向实例添加自己的属性:
const err = new Error('Could not find the file'); err.filePath = filePath; throw err;
- 使用 Error 的子类之一。
Error
的子类化(更多细节在后面解释):
class MyError extends Error { } function func() { throw new MyError('Problem!'); } assert.throws( () => func(), MyError);
24.3 try
语句
try
语句的最大版本如下所示:
try { «try_statements» } catch (error) { «catch_statements» } finally { «finally_statements» }
我们可以将这些子句组合如下:
try-catch
try-finally
try-catch-finally
24.3.1 try
块
try
块可以被视为语句的主体。这是我们执行常规代码的地方。
24.3.2 catch
子句
如果异常到达try
块,则它被分配给catch
子句的参数,并且在该子句中的代码被执行。接下来,执行通常会在try
语句之后继续。如果:
- 在
catch
块内部有一个return
、break
或throw
。 - 有一个
finally
子句(在try
语句结束之前始终执行)。
以下代码演示了在 A 行抛出的值确实在 B 行被捕获。
const errorObject = new Error(); function func() { throw errorObject; // (A) } try { func(); } catch (err) { // (B) assert.equal(err, errorObject); }
24.3.2.1 省略catch
绑定[ES2019]
如果我们对抛出的值不感兴趣,我们可以省略catch
参数:
try { // ··· } catch { // ··· }
这可能偶尔会有用。例如,Node.js 有 API 函数assert.throws(func)
,用于检查func
内部是否抛出错误。可以这样实现。
function throws(func) { try { func(); } catch { return; // everything OK } throw new Error('Function didn’t throw an exception!'); }
然而,这个函数的更完整的实现将有一个catch
参数,并且,例如,检查其类型是否符合预期。
24.3.3 finally
子句
finally
子句中的代码总是在try
语句的末尾执行 - 无论try
块或catch
子句中发生了什么。
让我们看一个finally
的常见用例:我们创建了一个资源,并且希望在完成后始终销毁它,无论在处理它时发生了什么。我们可以这样实现:
const resource = createResource(); try { // Work with `resource`. Errors may be thrown. } finally { resource.destroy(); }
24.3.3.1 finally
总是被执行
finally
子句总是被执行,即使抛出错误(A 行):
let finallyWasExecuted = false; assert.throws( () => { try { throw new Error(); // (A) } finally { finallyWasExecuted = true; } }, Error ); assert.equal(finallyWasExecuted, true);
即使有return
语句(A 行):
let finallyWasExecuted = false; function func() { try { return; // (A) } finally { finallyWasExecuted = true; } } func(); assert.equal(finallyWasExecuted, true);
24.4 Error
及其子类
Error
是所有内置错误类的通用超类。
24.4.1 类Error
这是Error
的实例属性和构造函数的样子:
class Error { // Instance properties message: string; cause?: any; // ES2022 stack: string; // non-standard but widely supported constructor( message: string = '', options?: ErrorOptions // ES2022 ); } interface ErrorOptions { cause?: any; // ES2022 }
构造函数有两个参数:
message
指定了错误消息。options
在 ECMAScript 2022 中被引入。它包含一个对象,其中一个属性目前受支持:
.cause
指定了导致当前错误的异常(如果有)。
接下来的子节将更详细地解释实例属性.message
、.cause
和.stack
。
24.4.1.1 Error.prototype.name
每个内置错误类E
都有一个属性E.prototype.name
:
> Error.prototype.name 'Error' > RangeError.prototype.name 'RangeError'
因此,有两种方法可以获取内置错误对象的类名:
> new RangeError().name 'RangeError' > new RangeError().constructor.name 'RangeError'
24.4.1.2 错误实例属性.message
.message
只包含错误消息:
const err = new Error('Hello!'); assert.equal(String(err), 'Error: Hello!'); assert.equal(err.message, 'Hello!');
如果我们省略消息,那么空字符串将被用作默认值(从Error.prototype.message
继承):
如果我们省略消息,它就是空字符串:
assert.equal(new Error().message, '');
24.4.1.3 错误实例属性.stack
实例属性.stack
不是 ECMAScript 特性,但它被 JavaScript 引擎广泛支持。它通常是一个字符串,但其确切结构没有标准化,并且在引擎之间有所不同。
这是在 JavaScript 引擎 V8 上的样子:
const err = new Error('Hello!'); assert.equal( err.stack, ` Error: Hello! at file://ch_exception-handling.mjs:1:13 `.trim());
24.4.1.4 错误实例属性.cause
[ES2022]
实例属性.cause
是通过new Error()
的第二个参数中的选项对象创建的。它指定了哪个其他错误导致了当前错误。
const err = new Error('msg', {cause: 'the cause'}); assert.equal(err.cause, 'the cause');
有关如何使用此属性的信息,请参阅§24.5“链接错误”。
24.4.2 Error
的内置子类
Error
有以下子类 - 引用ECMAScript 规范:
AggregateError
[ES2021] 代表一次性表示多个错误。在标准库中,只有Promise.any()
使用它。RangeError
指示一个不在可允许值的集合或范围内的值。ReferenceError
指示检测到无效引用值。SyntaxError
指示发生了解析错误。TypeError
用于指示未成功操作,当其他NativeError对象都不适合表示失败原因时。URIError
指示全局 URI 处理函数之一的使用方式与其定义不兼容。
24.4.3 对Error
进行子类化
自 ECMAScript 2022 以来,Error
构造函数接受两个参数(参见前面的子节)。因此,在对其进行子类化时,我们有两种选择:要么在子类中省略构造函数,要么像这样调用super()
:
class MyCustomError extends Error { constructor(message, options) { super(message, options); // ··· } }
24.5 链接错误
24.5.1 为什么我们想要链接错误?
有时,我们捕获在更深层嵌套的函数调用期间抛出的错误,并希望附加更多信息:
function readFiles(filePaths) { return filePaths.map( (filePath) => { try { const text = readText(filePath); const json = JSON.parse(text); return processJson(json); } catch (error) { // (A) } }); }
try
子句中的语句可能会引发各种错误。在大多数情况下,错误不会意识到导致它的文件路径。这就是为什么我们想在 A 行附加该信息的原因。
24.5.2 通过error.cause
链接错误[ES2022]
自 ECMAScript 2022 以来,new Error()
让我们指定引起错误的原因:
function readFiles(filePaths) { return filePaths.map( (filePath) => { try { // ··· } catch (error) { throw new Error( `While processing ${filePath}`, {cause: error} ); } }); }
24.5.3 .cause
的替代方法:自定义错误类
以下自定义错误类支持链接。它与.cause
向前兼容。
/** * An error class that supports error chaining. * If there is built-in support for .cause, it uses it. * Otherwise, it creates this property itself. * * @see https://github.com/tc39/proposal-error-cause */ class CausedError extends Error { constructor(message, options) { super(message, options); if ( (isObject(options) && 'cause' in options) && !('cause' in this) ) { // .cause was specified but the superconstructor // did not create an instance property. const cause = options.cause; this.cause = cause; if ('stack' in cause) { this.stack = this.stack + '\nCAUSE: ' + cause.stack; } } } } function isObject(value) { return value !== null && typeof value === 'object'; }
练习:异常处理
exercises/exception-handling/call_function_test.mjs
测验
请参阅测验应用程序。
二十五、可调用值
原文:
exploringjs.com/impatient-js/ch_callables.html
译者:飞龙
- 25.1 函数的种类
- 25.2 普通函数
- 25.2.1 命名函数表达式(高级)
- 25.2.2 术语:函数定义和函数表达式
- 25.2.3 函数声明的部分
- 25.2.4 普通函数扮演的角色
- 25.2.5 术语:实体 vs. 语法 vs. 角色(高级)
- 25.3 专门的函数
- 25.3.1 专门的函数仍然是函数
- 25.3.2 箭头函数
- 25.3.3 方法、普通函数和箭头函数中的特殊变量
this
- 25.3.4 建议:优先使用专门的函数而不是普通函数
- 25.4 总结:可调用值的种类
- 25.5 从函数和方法返回值
- 25.6 参数处理
- 25.6.1 术语:参数 vs. 参数值
- 25.6.2 术语:回调
- 25.6.3 参数过多或不足
- 25.6.4 参数默认值
- 25.6.5 剩余参数
- 25.6.6 命名参数
- 25.6.7 模拟命名参数
- 25.6.8 展开(
...
)到函数调用中
- 25.7 函数的方法:
.call()
、.apply()
、.bind()
- 25.7.1 函数方法
.call()
- 25.7.2 函数方法
.apply()
- 25.7.3 函数方法
.bind()
在本章中,我们将研究可以调用的 JavaScript 值:函数、方法和类。
25.1 函数的种类
JavaScript 有两种类别的函数:
- 普通函数可以扮演多种角色:
- 真正的函数
- 方法
- 构造函数
- 专门的函数只能扮演这些角色中的一个- 例如:
- 箭头函数只能是真正的函数。
- 方法只能是方法。
- 类只能是构造函数。
- ECMAScript 6 中添加了专门的函数。
继续阅读以了解所有这些东西的含义。
25.2 普通函数
以下代码展示了创建普通函数的两种方式(大致相同):
// Function declaration (a statement) function ordinary1(a, b, c) { // ··· } // const plus anonymous (nameless) function expression const ordinary2 = function (a, b, c) { // ··· };
在作用域内,函数声明会提前激活(参见§11.8“声明:作用域和激活”),可以在声明之前调用。这有时很有用。
变量声明,比如ordinary2
的声明,不会提前激活。
25.2.1 命名函数表达式(高级)
到目前为止,我们只看到了匿名函数表达式-没有名称:
const anonFuncExpr = function (a, b, c) { // ··· };
但也有命名函数表达式:
const namedFuncExpr = function myName(a, b, c) { // `myName` is only accessible in here };
myName
只能在函数体内部访问。函数可以使用它来引用自身(用于自递归等)- 与其分配给哪个变量无关:
const func = function funcExpr() { return funcExpr }; assert.equal(func(), func); // The name `funcExpr` only exists inside the function body: assert.throws(() => funcExpr(), ReferenceError);
即使它们没有分配给变量,命名函数表达式也有名称(第 A 行):
function getNameOfCallback(callback) { return callback.name; } assert.equal( getNameOfCallback(function () {}), ''); // anonymous assert.equal( getNameOfCallback(function named() {}), 'named'); // (A)
请注意,通过函数声明或变量声明创建的函数始终具有名称:
function funcDecl() {} assert.equal( getNameOfCallback(funcDecl), 'funcDecl'); const funcExpr = function () {}; assert.equal( getNameOfCallback(funcExpr), 'funcExpr');
函数具有名称的一个好处是,这些名称会出现在 错误堆栈跟踪 中。
25.2.2 术语:函数定义和函数表达式
函数定义 是创建函数的语法:
- 函数声明(一个语句)
- 函数表达式
函数声明总是产生普通函数。函数表达式产生普通函数或专门的函数:
- 普通函数表达式(我们已经遇到过的):
- 匿名函数表达式
- 命名函数表达式
- 专门的函数表达式(稍后我们将看到):
- 箭头函数(它们总是表达式)
虽然在 JavaScript 中函数声明仍然很受欢迎,但在现代代码中,函数表达式几乎总是箭头函数。
25.2.3 函数声明的各个部分
让我们通过以下示例来检查函数声明的各个部分。大多数术语也适用于函数表达式。
function add(x, y) { return x + y; }
add
是函数声明的 名称。add(x, y)
是函数声明的 头部。x
和y
是 参数。- 花括号(
{
和}
)及其之间的所有内容是函数声明的 主体。 return
语句明确地从函数中返回一个值。
25.2.3.1 参数列表中的尾随逗号
JavaScript 一直允许并忽略数组文字中的尾随逗号。自 ES5 以来,它们也允许在对象文字中。自 ES2017 以来,我们可以在参数列表(声明和调用)中添加尾随逗号:
// Declaration function retrieveData( contentText, keyword, {unique, ignoreCase, pageSize}, // trailing comma ) { // ··· } // Invocation retrieveData( '', null, {ignoreCase: true, pageSize: 10}, // trailing comma );
25.2.4 普通函数扮演的角色
考虑前一节中的以下函数声明:
function add(x, y) { return x + y; }
这个函数声明创建了一个名为 add
的普通函数。作为普通函数,add()
可以扮演三种角色:
- 真实函数:通过函数调用调用。
assert.equal(add(2, 1), 3);
- 方法:存储在属性中,通过方法调用调用。
const obj = { addAsMethod: add }; assert.equal(obj.addAsMethod(2, 4), 6); // (A)
- 在 A 行,
obj
被称为方法调用的 接收者。 - 构造函数:通过
new
调用。
const inst = new add(); assert.equal(inst instanceof add, true);
- 顺便说一下,构造函数(包括类)的名称通常以大写字母开头。
25.2.5 术语:实体 vs. 语法 vs. 角色(高级)
语法、实体 和 角色 的概念之间的区别是微妙的,通常并不重要。但我想让你对此有更敏锐的观察力:
- 实体是 JavaScript 中的一个特性,因为它“存在”于 RAM 中。普通函数是一个实体。
- 实体包括:普通函数、箭头函数、方法和类。
- 语法是我们用来创建实体的代码。函数声明和匿名函数表达式是语法。它们都创建被称为普通函数的实体。
- 语法包括:函数声明和匿名函数表达式。产生箭头函数的语法也称为 箭头函数。对于方法和类也是如此。
- 角色描述了我们如何使用实体。实体普通函数可以扮演真实函数的角色,或者方法的角色,或者类的角色。实体箭头函数也可以扮演真实函数的角色。
- 函数的角色是:真实函数、方法和构造函数。
许多其他编程语言只有一个实体扮演 真实函数 的角色。然后它们可以将 函数 这个名称用于角色和实体。
25.3 专门的函数
专门的函数是普通函数的单一版本。它们中的每一个都专门从事一个角色:
- 箭头函数的目的是成为真实函数:
const arrow = () => { return 123; }; assert.equal(arrow(), 123);
- 方法 的目的是成为方法:
const obj = { myMethod() { return 'abc'; } }; assert.equal(obj.myMethod(), 'abc');
- 类 的目的是成为构造函数:
class MyClass { /* ··· */ } const inst = new MyClass();
除了更好的语法之外,每种专门的函数还支持新功能,使它们在其工作中比普通函数更好。
- 箭头函数很快就会解释。
- 方法在单个对象章节中有解释。
- 类在类章节中有解释。
Tbl. 16 列出了普通和专门函数的功能。
表 16:四种函数的功能。如果单元格值在括号中,那意味着某种限制。特殊变量this
在§25.3.3 “方法、普通函数和箭头函数中的特殊变量this
”中有解释。
函数调用 | 方法调用 | 构造函数调用 | |
普通函数 | (this === undefined ) |
✔ |
✔ |
箭头函数 | ✔ |
(词法this ) |
✘ |
方法 | (this === undefined ) |
✔ |
✘ |
类 | ✘ |
✘ |
✔ |
25.3.1 专门函数仍然是函数
重要的是要注意,箭头函数、方法和类仍然被归类为函数:
> (() => {}) instanceof Function true > ({ method() {} }.method) instanceof Function true > (class SomeClass {}) instanceof Function true
25.3.2 箭头函数
箭头函数被添加到 JavaScript 中有两个原因:
- 为了提供一种更简洁的创建函数的方式。
- 它们在方法内部作为真正的函数工作得更好:方法可以通过特殊变量
this
引用接收方法调用的对象。箭头函数可以访问周围方法的this
,普通函数不能(因为它们有自己的this
)。
我们首先将研究箭头函数的语法,然后再看各种函数中的this
是如何工作的。
25.3.2.1 箭头函数的语法
让我们回顾一下匿名函数表达式的语法:
const f = function (x, y, z) { return 123 };
箭头函数的(大致)等效形式如下。箭头函数是表达式。
const f = (x, y, z) => { return 123 };
这里,箭头函数的体是一个块。但它也可以是一个表达式。下面的箭头函数与前一个完全相同。
const f = (x, y, z) => 123;
如果箭头函数只有一个参数,并且该参数是一个标识符(不是解构模式),那么你可以省略参数周围的括号:
const id = x => x;
当将箭头函数作为参数传递给其他函数或方法时,这是很方便的:
> [1,2,3].map(x => x+1) [ 2, 3, 4 ]
前面的例子展示了箭头函数的一个好处 - 简洁性。如果我们用函数表达式执行相同的任务,我们的代码会更冗长:
[1,2,3].map(function (x) { return x+1 });
25.3.2.2 语法陷阱:从箭头函数返回对象字面量
如果你希望箭头函数的表达式体是一个对象字面量,你必须将字面量放在括号中:
const func1 = () => ({a: 1}); assert.deepEqual(func1(), { a: 1 });
如果不这样做,JavaScript 会认为箭头函数有一个块体(不返回任何东西):
const func2 = () => {a: 1}; assert.deepEqual(func2(), undefined);
{a: 1}
被解释为一个带有标签a:
和表达式语句1
的块。没有显式的return
语句,块体返回undefined
。
这个陷阱是由语法歧义引起的:对象字面量和代码块具有相同的语法。我们使用括号告诉 JavaScript,体是一个表达式(对象字面量),而不是一个语句(块)。
25.3.3 方法、普通函数和箭头函数中的特殊变量this
特殊变量this
是面向对象的特性
我们在这里快速看一下特殊变量this
,以便理解为什么箭头函数比普通函数更好。
但这个特性只在面向对象编程中才重要,并且在§28.5 “方法和特殊变量this
”中有更深入的介绍。因此,如果你还没有完全理解,不要担心。
在方法内部,特殊变量this
让我们可以访问接收者 - 接收方法调用的对象:
const obj = { myMethod() { assert.equal(this, obj); } }; obj.myMethod();
普通函数可以是方法,因此也有隐式参数this
:
const obj = { myMethod: function () { assert.equal(this, obj); } }; obj.myMethod();
当我们将普通函数用作真正的函数时,this
甚至是一个隐式参数。然后它的值是undefined
(如果严格模式激活,几乎总是激活的):
function ordinaryFunc() { assert.equal(this, undefined); } ordinaryFunc();
这意味着普通函数作为真正的函数时,无法访问周围方法的this
(A 行)。相反,箭头函数没有this
作为隐式参数。它们将其视为任何其他变量,因此可以访问周围方法的this
(B 行):
const jill = { name: 'Jill', someMethod() { function ordinaryFunc() { assert.throws( () => this.name, // (A) /^TypeError: Cannot read properties of undefined \(reading 'name'\)$/); } ordinaryFunc(); const arrowFunc = () => { assert.equal(this.name, 'Jill'); // (B) }; arrowFunc(); }, }; jill.someMethod();
在这段代码中,我们可以观察到处理this
的两种方式:
- 动态
this
:在 A 行,我们尝试从普通函数访问.someMethod()
的this
。在那里,它被函数自己的this
遮蔽,这是undefined
(由函数调用填充)。鉴于普通函数通过(动态)函数或方法调用接收它们的this
,它们的this
被称为动态。 - 词汇
this
:在 B 行,我们再次尝试访问.someMethod()
的this
。这次,我们成功了,因为箭头函数没有自己的this
。this
被词法解析,就像任何其他变量一样。这就是为什么箭头函数的this
被称为词法。
25.3.4 建议:优先选择专门的函数而不是普通函数
通常,您应该优先选择专门的函数而不是普通函数,特别是类和方法。
当涉及到真正的函数时,箭头函数和普通函数之间的选择并不那么明确:
- 对于匿名内联函数表达式,箭头函数是明显的赢家,因为它们的紧凑语法和它们不具有
this
作为隐式参数:
const twiceOrdinary = [1, 2, 3].map(function (x) {return x * 2}); const twiceArrow = [1, 2, 3].map(x => x * 2);
- 对于独立的命名函数声明,箭头函数仍然受益于词法
this
。但是函数声明(产生普通函数)具有良好的语法,早期激活也偶尔有用(参见§11.8“声明:范围和激活”)。如果普通函数的主体中没有出现this
,那么使用它作为真正的函数就没有任何缺点。在开发过程中,静态检查工具 ESLint 可以通过内置规则警告我们是否错误地这样做。
function timesOrdinary(x, y) { return x * y; } const timesArrow = (x, y) => { return x * y; };
25.4 总结:可调用值的种类
本节涉及即将到来的内容
本节主要作为当前和即将到来的章节的参考。如果您不理解一切,不要担心。
到目前为止,我们看到的所有(真正的)函数和方法都是:
- 单结果
- 同步
后续章节将涵盖其他编程模式:
- 迭代将对象视为数据容器(所谓的可迭代)并提供了一种标准化的方法来检索其中的内容。如果函数或方法返回可迭代对象,则会返回多个值。
- 异步编程处理长时间运行的计算。当计算完成时,会通知您,并且可以在其中间做其他事情。异步交付单个结果的标准模式称为Promise。
这些模式可以组合使用-例如,有同步可迭代和异步可迭代。
几种新的函数和方法有助于处理一些模式组合:
- 异步函数有助于实现返回 Promise 的函数。还有异步方法。
- 同步生成器函数有助于实现返回同步可迭代对象的函数。还有同步生成器方法。
- 异步生成器函数有助于实现返回异步可迭代对象的函数。还有异步生成器方法。
这使我们有 4 种(2×2)函数和方法:
- 同步与异步
- 生成器与单结果
Tbl. 17 提供了创建这 4 种函数和方法的语法概述。
表 17:创建函数和方法的语法。最后一列指定实体产生的值数量。
结果 | # | ||
同步函数 | 同步方法 | ||
function f() {} |
{ m() {} } |
值 | 1 |
f = function () {} |
|||
f = () => {} |
|||
同步生成器函数 | 同步生成器方法 | ||
function* f() {} |
{ * m() {} } |
可迭代 | 0+ |
f = function* () {} |
|||
异步函数 | 异步方法 | ||
async function f() {} |
{ async m() {} } |
Promise | 1 |
f = async function () {} |
|||
f = async () => {} |
|||
异步生成器函数 | 异步生成器方法 | ||
async function* f() {} |
{ async * m() {} } |
异步可迭代 | 0+ |
f = async function* () {} |
25.5 从函数和方法返回值
(本节提到的所有内容都适用于函数和方法。)
return
语句明确地从函数中返回一个值:
function func() { return 123; } assert.equal(func(), 123);
另一个例子:
function boolToYesNo(bool) { if (bool) { return 'Yes'; } else { return 'No'; } } assert.equal(boolToYesNo(true), 'Yes'); assert.equal(boolToYesNo(false), 'No');
如果在函数末尾没有显式返回任何内容,JavaScript 会为你返回undefined
:
function noReturn() { // No explicit return } assert.equal(noReturn(), undefined);
25.6 参数处理
再次强调,本节中只提到了函数,但所有内容也适用于方法。
25.6.1 术语:参数 vs. 参数
术语参数和参数基本上是指同一件事。如果你愿意,你可以做出以下区分:
- 参数是函数定义的一部分。它们也被称为形式参数和形式参数。
- 参数是函数调用的一部分。它们也被称为实际参数和实际参数。
25.6.2 术语:回调
回调或回调函数是作为函数或方法调用的参数的函数。
以下是一个回调函数的例子:
const myArray = ['a', 'b']; const callback = (x) => console.log(x); myArray.forEach(callback); // Output: // 'a' // 'b'
25.6.3 参数过多或不足
如果函数调用提供的参数数量与函数定义所期望的参数数量不同,JavaScript 不会报错:
- 额外的参数被忽略。
- 缺少的参数被设置为
undefined
。
例如:
function foo(x, y) { return [x, y]; } // Too many arguments: assert.deepEqual(foo('a', 'b', 'c'), ['a', 'b']); // The expected number of arguments: assert.deepEqual(foo('a', 'b'), ['a', 'b']); // Not enough arguments: assert.deepEqual(foo('a'), ['a', undefined]);
25.6.4 参数默认值
参数默认值指定如果未提供参数时要使用的值,例如:
function f(x, y=0) { return [x, y]; } assert.deepEqual(f(1), [1, 0]); assert.deepEqual(f(), [undefined, 0]);
undefined
也会触发默认值:
assert.deepEqual( f(undefined, undefined), [undefined, 0]);
25.6.5 Rest 参数
通过在标识符前加上三个点(...
)来声明 rest 参数。在函数或方法调用期间,它接收一个包含所有剩余参数的数组。如果末尾没有额外的参数,它就是一个空数组,例如:
function f(x, ...y) { return [x, y]; } assert.deepEqual( f('a', 'b', 'c'), ['a', ['b', 'c']] ); assert.deepEqual( f(), [undefined, []] );
有两个与我们如何使用 rest 参数相关的限制:
- 我们不能在一个函数定义中使用多个 rest 参数。
assert.throws( () => eval('function f(...x, ...y) {}'), /^SyntaxError: Rest parameter must be last formal parameter$/ );
- rest 参数必须始终位于最后。因此,我们无法像这样访问最后一个参数:
assert.throws( () => eval('function f(...restParams, lastParam) {}'), /^SyntaxError: Rest parameter must be last formal parameter$/ );
25.6.5.1 通过 rest 参数强制使用一定数量的参数
你可以使用 rest 参数来强制使用一定数量的参数。例如,考虑以下函数:
function createPoint(x, y) { return {x, y}; // same as {x: x, y: y} }
这是我们如何强制调用者始终提供两个参数的方法:
function createPoint(...args) { if (args.length !== 2) { throw new Error('Please provide exactly 2 arguments!'); } const [x, y] = args; // (A) return {x, y}; }
在 A 行,我们通过解构访问args
的元素。
25.6.6 命名参数
当有人调用一个函数时,调用者提供的参数被分配给被调用者接收的参数。执行映射的两种常见方式是:
- 位置参数:如果参数位置相同,则将参数分配给参数。只有位置参数的函数调用如下所示。
selectEntries(3, 20, 2)
- 命名参数:如果参数名称相同,则将参数分配给参数。JavaScript 没有命名参数,但你可以模拟它们。例如,这是一个只有(模拟)命名参数的函数调用:
selectEntries({start: 3, end: 20, step: 2})
命名参数有几个好处:
- 它们会导致更加自解释的代码,因为每个参数都有一个描述性标签。只需比较
selectEntries()
的两个版本:第二个版本更容易看出发生了什么。 - 参数的顺序不重要(只要名称正确)。
- 处理多个可选参数比较方便:调用者可以轻松地提供所有可选参数的任意子集,并且不必知道省略的参数(对于位置参数,您必须填写前面的可选参数,使用
undefined
)。
25.6.7 模拟命名参数
JavaScript 没有真正的命名参数。模拟它们的官方方式是通过对象字面量:
function selectEntries({start=0, end=-1, step=1}) { return {start, end, step}; }
这个函数使用解构来访问其单个参数的属性。它使用的模式是以下模式的缩写:
{start: start=0, end: end=-1, step: step=1}
这种解构模式适用于空对象字面量:
> selectEntries({}) { start: 0, end: -1, step: 1 }
但如果你在没有任何参数的情况下调用函数,它就不起作用:
> selectEntries() TypeError: Cannot read properties of undefined (reading 'start')
您可以通过为整个模式提供默认值来解决这个问题。这个默认值的工作方式与更简单的参数定义的默认值相同:如果参数缺失,则使用默认值。
function selectEntries({start=0, end=-1, step=1} = {}) { return {start, end, step}; } assert.deepEqual( selectEntries(), { start: 0, end: -1, step: 1 });
25.6.8 扩展(...
)到函数调用
如果在函数调用的参数前面加上三个点(...
),那么就会扩展它。这意味着参数必须是可迭代对象,并且迭代的值都成为参数。换句话说,一个参数被扩展为多个参数 - 例如:
function func(x, y) { console.log(x); console.log(y); } const someIterable = ['a', 'b']; func(...someIterable); // same as func('a', 'b') // Output: // 'a' // 'b'
扩展和剩余参数使用相同的语法(...
),但它们具有相反的目的:
- 剩余参数用于定义函数或方法时。它们将参数收集到数组中。
- 在调用函数或方法时使用扩展参数。它们将可迭代对象转换为参数。
25.6.8.1 示例:扩展到Math.max()
Math.max()
返回其零个或多个参数中的最大值。遗憾的是,它不能用于数组,但扩展给了我们一条出路:
> Math.max(-1, 5, 11, 3) 11 > Math.max(...[-1, 5, 11, 3]) 11 > Math.max(-1, ...[-5, 11], 3) 11
25.6.8.2 示例:扩展到Array.prototype.push()
同样,数组方法.push()
会将其零个或多个参数破坏性地添加到数组的末尾。JavaScript 没有一种方法可以将一个数组破坏性地附加到另一个数组上。我们再次通过扩展来解决这个问题:
const arr1 = ['a', 'b']; const arr2 = ['c', 'd']; arr1.push(...arr2); assert.deepEqual(arr1, ['a', 'b', 'c', 'd']);
练习:参数处理
- 位置参数:
exercises/callables/positional_parameters_test.mjs
- 命名参数:
exercises/callables/named_parameters_test.mjs
25.7 函数的方法:.call()
,.apply()
,.bind()
函数是对象,有方法。在本节中,我们将介绍其中三种方法:.call()
,.apply()
和.bind()
。
25.7.1 函数方法.call()
每个函数someFunc
都有以下方法:
someFunc.call(thisValue, arg1, arg2, arg3);
这种方法调用大致相当于以下函数调用:
someFunc(arg1, arg2, arg3);
然而,使用.call()
,我们也可以为隐式参数this
指定一个值。换句话说:.call()
使隐式参数this
变为显式参数。
以下代码演示了.call()
的使用:
function func(x, y) { return [this, x, y]; } assert.deepEqual( func.call('hello', 'a', 'b'), ['hello', 'a', 'b']);
正如我们之前所看到的,如果我们调用一个普通函数,它的this
是undefined
:
assert.deepEqual( func('a', 'b'), [undefined, 'a', 'b']);
因此,前面的函数调用等价于:
assert.deepEqual( func.call(undefined, 'a', 'b'), [undefined, 'a', 'b']);
在箭头函数中,通过.call()
(或其他方式)提供的this
值会被忽略。
25.7.2 函数方法.apply()
每个函数someFunc
都有以下方法:
someFunc.apply(thisValue, [arg1, arg2, arg3]);
这种方法调用大致相当于以下函数调用(使用扩展):
someFunc(...[arg1, arg2, arg3]);
然而,使用.apply()
,我们也可以为隐式参数this
指定一个值。
以下代码演示了.apply()
的使用:
function func(x, y) { return [this, x, y]; } const args = ['a', 'b']; assert.deepEqual( func.apply('hello', args), ['hello', 'a', 'b']);
25.7.3 函数方法.bind()
.bind()
是函数对象的另一种方法。该方法的调用方式如下:
const boundFunc = someFunc.bind(thisValue, arg1, arg2);
.bind()
返回一个新的函数boundFunc()
。调用该函数会将this
设置为thisValue
并传入这些参数:arg1
,arg2
,以及boundFunc()
的参数。
也就是说,以下两个函数调用是等价的:
boundFunc('a', 'b') someFunc.call(thisValue, arg1, arg2, 'a', 'b')
25.7.3.1 .bind()
的替代方法
另一种预填充this
和参数的方法是通过箭头函数:
const boundFunc2 = (...args) => someFunc.call(thisValue, arg1, arg2, ...args);
25.7.3.2 .bind()
的实现
考虑到前一节,.bind()
可以实现为一个真实函数,如下所示:
function bind(func, thisValue, ...boundArgs) { return (...args) => func.call(thisValue, ...boundArgs, ...args); }
25.7.3.3 示例:绑定一个真实函数
对于真实函数使用.bind()
有点不直观,因为我们必须为this
提供一个值。鉴于在函数调用期间它是undefined
,通常将其设置为undefined
或null
。
在下面的示例中,我们通过将add()
的第一个参数绑定到8
来创建一个只有一个参数的函数add8()
。
function add(x, y) { return x + y; } const add8 = add.bind(undefined, 8); assert.equal(add8(1), 9);
测验
请参阅测验应用程序。
二十六、动态评估代码:eval(),new Function()(高级)
原文:
exploringjs.com/impatient-js/ch_dynamic-code-evaluation.html
译者:飞龙
- 26.1
eval()
- 26.2
new Function()
- 26.3 Recommendations
在本章中,我们将看两种动态评估代码的方式:eval()
和 new Function()
。
26.1 eval()
给定一个包含 JavaScript 代码的字符串 str
,eval(str)
评估该代码并返回结果:
> eval('2 ** 4') 16
有两种调用 eval()
的方式:
- 直接,通过函数调用。然后在其参数中评估代码在当前范围内。
- 间接地,而不是通过函数调用。然后在全局范围内评估其代码。
“不通过函数调用”意味着“任何看起来不同于 eval(···)
”:
eval.call(undefined, '···')
(使用函数的.call()
方法)eval?.()
(使用可选链)(0, eval)('···')
(使用逗号运算符)globalThis.eval('···')
const e = eval; e('···')
- 等等。
以下代码说明了区别:
globalThis.myVariable = 'global'; function func() { const myVariable = 'local'; // Direct eval assert.equal(eval('myVariable'), 'local'); // Indirect eval assert.equal(eval.call(undefined, 'myVariable'), 'global'); }
在全局上下文中评估代码更安全,因为代码访问的内部更少。
26.2 new Function()
new Function()
创建一个函数对象,并按以下方式调用:
const func = new Function('«param_1»', ···, '«param_n»', '«func_body»');
前面的语句等同于下一条语句。请注意,«param_1»
等不再在字符串文字中。
const func = function («param_1», ···, «param_n») { «func_body» };
在下一个示例中,我们首先通过 new Function()
创建相同的函数,然后通过函数表达式创建:
const times1 = new Function('a', 'b', 'return a * b'); const times2 = function (a, b) { return a * b };
new Function()
创建非严格模式函数
默认情况下,通过 new Function()
创建的函数是松散模式。如果我们希望函数体处于严格模式,我们必须手动切换它。
26.3 推荐
尽量避免动态评估代码:
- 这是一个安全风险,因为它可能使攻击者能够以您代码的权限执行任意代码。
- 它可能会被关闭 - 例如,在浏览器中,通过内容安全策略。
通常,JavaScript 是动态的,因此您不需要 eval()
或类似的东西。在下面的示例中,我们使用 eval()
(行 A)可以很好地在没有它的情况下实现(行 B)。
const obj = {a: 1, b: 2}; const propKey = 'b'; assert.equal(eval('obj.' + propKey), 2); // (A) assert.equal(obj[propKey], 2); // (B)
如果您必须动态评估代码:
- 更喜欢
new Function()
而不是eval()
:它总是在全局上下文中执行其代码,并且函数为评估的代码提供了一个清晰的接口。 - 更喜欢间接的
eval
而不是直接的eval
:在全局上下文中评估代码更安全。
第六部分:模块化
原文:
exploringjs.com/impatient-js/pt_modularity.html
译者:飞龙
下一步:27 模块
二十七、模块
原文:
exploringjs.com/impatient-js/ch_modules.html
译者:飞龙
- 27.1 速查表:模块
- 27.1.1 导出
- 27.1.2 导入
- 27.2 JavaScript 源代码格式
- 27.2.1 内置模块之前的代码是用 ECMAScript 5 编写的
- 27.3 在我们有模块之前,我们有脚本
- 27.4 ES6 之前创建的模块系统
- 27.4.1 服务器端:CommonJS 模块
- 27.4.2 客户端:AMD(异步模块定义)模块
- 27.4.3 JavaScript 模块的特点
- 27.5 ECMAScript 模块
- 27.5.1 ES 模块:语法、语义、加载器 API
- 27.6 命名导出和导入
- 27.6.1 命名导出
- 27.6.2 命名导入
- 27.6.3 命名空间导入
- 27.6.4 命名导出风格:内联与子句(高级)
- 27.7 默认导出和导入
- 27.7.1 默认导出的两种风格
- 27.7.2 默认导出作为命名导出(高级)
- 27.8 导出和导入的更多细节
- 27.8.1 导入是对导出的只读视图
- 27.8.2 ESM 对循环导入的透明支持(高级)
- 27.9 npm 包
- 27.9.1 包安装在
node_modules/
目录中 - 27.9.2 npm 为什么可以用于安装前端库?
- 27.10 模块命名
- 27.11 模块规范
- 27.11.1 模块规范的类别
- 27.11.2 浏览器中的 ES 模块规范
- 27.11.3 Node.js 上的 ES 模块规范
- 27.12.1
import.meta.url
- 27.12.2
import.meta.url
和URL
类 - 27.12.3 Node.js 上的
import.meta.url
- 27.13.1 静态
import
语句的限制 - 27.13.2 通过
import()
运算符动态导入 - 27.13.3
import()
的用例
- 27.14 模块中的顶层
await
[ES2022](高级)
- 27.14.1 顶层
await
的用例 - 27.14.2 顶层
await
在内部是如何工作的? - 27.14.3 顶层
await
的利弊
- 27.15 Polyfills:模拟原生 Web 平台功能(高级)
- 27.15.1 本节的来源
27.1 速查表:模块
27.1.1 导出
// Named exports export function f() {} export const one = 1; export {foo, b as bar}; // Default exports export default function f() {} // declaration with optional name // Replacement for `const` (there must be exactly one value) export default 123; // Re-exporting from another module export {foo, b as bar} from './some-module.mjs'; export * from './some-module.mjs'; export * as ns from './some-module.mjs'; // ES2020
27.1.2 导入
// Named imports import {foo, bar as b} from './some-module.mjs'; // Namespace import import * as someModule from './some-module.mjs'; // Default import import someModule from './some-module.mjs'; // Combinations: import someModule, * as someModule from './some-module.mjs'; import someModule, {foo, bar as b} from './some-module.mjs'; // Empty import (for modules with side effects) import './some-module.mjs';
27.2 JavaScript 源代码格式
当前 JavaScript 模块的格局非常多样化:ES6 带来了内置模块,但在它们之前出现的源代码格式仍然存在。了解后者有助于了解前者,因此让我们进行调查。接下来的章节描述了以下传递 JavaScript 源代码的方式:
- 脚本是浏览器在全局范围内运行的代码片段。它们是模块的前身。
- CommonJS 模块是一种主要用于服务器的模块格式(例如,通过 Node.js)。
- AMD 模块是一种主要用于浏览器的模块格式。
- ECMAScript 模块是 JavaScript 的内置模块格式。它取代了所有以前的格式。
Tbl. 18 概述了这些代码格式。请注意,对于 CommonJS 模块和 ECMAScript 模块,通常使用两个文件扩展名。哪一个适合取决于我们想要如何使用文件。本章后面会详细介绍。
表 18:传递 JavaScript 源代码的方式。
运行在 | 加载 | 文件扩展名 | |
脚本 | 浏览器 | 异步 | .js |
CommonJS 模块 | 服务器 | 同步 | .js .cjs |
AMD 模块 | 浏览器 | 异步 | .js |
ECMAScript 模块 | 浏览器和服务器 | 异步 | .js .mjs |
27.2.1 在内置模块之前的代码是用 ECMAScript 5 编写的
在我们进入内置模块(在 ES6 中引入)之前,我们将看到的所有代码都将以 ES5 编写。其中包括:
- ES5 没有
const
和let
,只有var
。 - ES5 没有箭头函数,只有函数表达式。
27.3 在我们有模块之前,我们有脚本
最初,浏览器只有脚本 - 在全局范围内执行的代码片段。例如,考虑一个通过以下 HTML 加载脚本文件的 HTML 文件:
<script src="other-module1.js"></script> <script src="other-module2.js"></script> <script src="my-module.js"></script>
主文件是my-module.js
,在那里我们模拟一个模块:
var myModule = (function () { // Open IIFE // Imports (via global variables) var importedFunc1 = otherModule1.importedFunc1; var importedFunc2 = otherModule2.importedFunc2; // Body function internalFunc() { // ··· } function exportedFunc() { importedFunc1(); importedFunc2(); internalFunc(); } // Exports (assigned to global variable `myModule`) return { exportedFunc: exportedFunc, }; })(); // Close IIFE
myModule
是一个全局变量,它被赋予立即调用函数表达式的结果。函数表达式从第一行开始。它在最后一行被调用。
这种包装代码片段的方式称为立即调用函数表达式(IIFE,由 Ben Alman 创造)。我们从 IIFE 中获得了什么?var
不是块作用域(像const
和let
一样),它是函数作用域:为var
声明的变量创建新作用域的唯一方法是通过函数或方法(对于const
和let
,我们可以使用函数、方法或块{}
)。因此,示例中的 IIFE 隐藏了所有以下变量的全局作用域,并最小化了名称冲突:importedFunc1
,importedFunc2
,internalFunc
,exportedFunc
。
请注意,我们以特定方式使用 IIFE:最后,我们选择要导出的内容,并通过对象字面量返回它。这被称为揭示模块模式(由 Christian Heilmann 创造)。
这种模拟模块的方式有几个问题:
- 脚本文件中的库通过全局变量导出和导入功能,这会导致名称冲突。
- 依赖关系没有明确说明,并且脚本没有内置的方法来加载它所依赖的脚本。因此,网页不仅需要加载页面所需的脚本,还需要加载这些脚本的依赖项,依赖项的依赖项等。而且它必须按正确的顺序这样做!
27.4 ES6 之前创建的模块系统
在 ECMAScript 6 之前,JavaScript 没有内置模块。因此,语言的灵活语法被用于在语言内部实现自定义模块系统。其中两种流行的是:
- CommonJS(针对服务器端)
- AMD(针对客户端的异步模块定义)
27.4.1 服务器端:CommonJS 模块
模块的原始 CommonJS 标准是为服务器和桌面平台创建的。它是最初的 Node.js 模块系统的基础,在那里取得了巨大的流行。贡献于该流行的是 Node 的 npm 包管理器和工具,使得可以在客户端使用 Node 模块(browserify、webpack 等)。
从现在开始,CommonJS 模块指的是这个标准的 Node.js 版本(它还有一些额外的功能)。这是一个 CommonJS 模块的例子:
// Imports var importedFunc1 = require('./other-module1.js').importedFunc1; var importedFunc2 = require('./other-module2.js').importedFunc2; // Body function internalFunc() { // ··· } function exportedFunc() { importedFunc1(); importedFunc2(); internalFunc(); } // Exports module.exports = { exportedFunc: exportedFunc, };
CommonJS 可以被描述如下:
- 设计用于服务器。
- 模块被设计为同步加载(导入者等待导入的模块被加载和执行)。
- 紧凑的语法。
27.4.2 客户端:AMD(异步模块定义)模块
AMD 模块格式是为了在浏览器中比 CommonJS 格式更容易使用而创建的。它最流行的实现是RequireJS。以下是一个 AMD 模块的例子。
define(['./other-module1.js', './other-module2.js'], function (otherModule1, otherModule2) { var importedFunc1 = otherModule1.importedFunc1; var importedFunc2 = otherModule2.importedFunc2; function internalFunc() { // ··· } function exportedFunc() { importedFunc1(); importedFunc2(); internalFunc(); } return { exportedFunc: exportedFunc, }; });
AMD 可以被描述如下:
- 设计用于浏览器。
- 模块被设计为异步加载。这对于浏览器来说是一个至关重要的要求,因为代码不能等待模块下载完成。它必须在模块可用时得到通知。
- 语法稍微复杂一些。
好处是,AMD 模块可以直接执行。相比之下,CommonJS 模块必须在部署之前编译,或者必须生成和动态评估自定义源代码(考虑eval()
)。这在网络上并不总是被允许。
27.4.3 JavaScript 模块的特点
查看 CommonJS 和 AMD,JavaScript 模块系统之间的相似之处显现出来:
- 每个文件有一个模块。
- 这样的文件基本上是一段被执行的代码:
- 局部作用域:代码在局部的“模块作用域”中执行。因此,默认情况下,其中声明的所有变量、函数和类都是内部的,而不是全局的。
- 导出:如果我们希望导出任何声明的实体,必须明确将其标记为导出。
- 导入:每个模块可以从其他模块导入导出的实体。这些其他模块通过模块规范符(通常是路径,偶尔是完整 URL)来标识。
- 模块是单例的:即使一个模块被多次导入,它只存在一个“实例”。
- 不使用全局变量。相反,模块规范符充当全局 ID。
27.5 ECMAScript 模块
ECMAScript 模块(ES 模块或ESM)是在 ES6 中引入的。它延续了 JavaScript 模块的传统,并具有所有前述的特点。另外:
- 使用 CommonJS,ES 模块共享紧凑的语法和对循环依赖的支持。
- 使用 AMD,ES 模块共享被设计用于异步加载的特点。
ES 模块也有新的好处:
- 语法甚至比 CommonJS 更加紧凑。
- 模块具有静态结构(无法在运行时更改)。这有助于静态检查、优化导入的访问、死代码消除等。
- 对循环导入的支持是完全透明的。
这是一个 ES 模块语法的例子:
import {importedFunc1} from './other-module1.mjs'; import {importedFunc2} from './other-module2.mjs'; function internalFunc() { ··· } export function exportedFunc() { importedFunc1(); importedFunc2(); internalFunc(); }
从现在开始,“模块”指的是“ECMAScript 模块”。
27.5.1 ES 模块:语法、语义、加载器 API
ES 模块的完整标准包括以下部分:
- 语法(代码的编写方式):什么是模块?如何声明导入和导出?等等。
- 语义(代码的执行方式):变量绑定是如何导出的?导入与导出如何连接?等等。
- 用于配置模块加载的编程加载器 API。
部分 1 和 2 是在 ES6 中引入的。第 3 部分的工作正在进行中。
27.6 命名导出和导入
27.6.1 命名导出
每个模块可以有零个或多个命名导出。
例如,考虑以下两个文件:
lib/my-math.mjs main.mjs
模块my-math.mjs
有两个命名导出:square
和LIGHTSPEED
。
// Not exported, private to module function times(a, b) { return a * b; } export function square(x) { return times(x, x); } export const LIGHTSPEED = 299792458;
要导出某些东西,我们在声明前面放置关键字export
。未导出的实体对于模块是私有的,无法从外部访问。
27.6.2 命名导入
模块main.mjs
有一个命名导入,square
:
import {square} from './lib/my-math.mjs'; assert.equal(square(3), 9);
它还可以重命名其导入:
import {square as sq} from './lib/my-math.mjs'; assert.equal(sq(3), 9);
27.6.2.1 语法陷阱:命名导入不是解构
命名导入和解构看起来很相似:
import {foo} from './bar.mjs'; // import const {foo} = require('./bar.mjs'); // destructuring
但它们是非常不同的:
- 导入保持与其导出的连接。
- 我们可以在解构模式内再次解构,但是导入语句中的
{}
不能嵌套。 - 重命名的语法不同:
import {foo as f} from './bar.mjs'; // importing const {foo: f} = require('./bar.mjs'); // destructuring
- 原理:解构类似于对象文字(包括嵌套),而导入则唤起重命名的想法。
练习:命名导出
exercises/modules/export_named_test.mjs
27.6.3 命名空间导入
命名空间导入是命名导入的一种替代方法。如果我们对模块进行命名空间导入,它将成为一个对象,其属性是命名导出。如果我们使用命名空间导入,main.mjs
看起来像这样:
import * as myMath from './lib/my-math.mjs'; assert.equal(myMath.square(3), 9); assert.deepEqual( Object.keys(myMath), ['LIGHTSPEED', 'square']);
27.6.4 命名导出样式:内联与子句(高级)
到目前为止,我们看到的命名导出样式是内联的:我们通过在实体前面加上关键字export
来导出实体。
但我们也可以使用单独的导出子句。例如,这是带有导出子句的lib/my-math.mjs
的样子:
function times(a, b) { return a * b; } function square(x) { return times(x, x); } const LIGHTSPEED = 299792458; export { square, LIGHTSPEED }; // semicolon!
使用导出子句,我们可以在导出之前重命名并在内部使用不同的名称:
function times(a, b) { return a * b; } function sq(x) { return times(x, x); } const LS = 299792458; export { sq as square, LS as LIGHTSPEED, // trailing comma is optional };
27.7 默认导出和导入
每个模块最多可以有一个默认导出。这个想法是模块是默认导出的值。
避免混合命名导出和默认导出
模块既可以具有命名导出,也可以具有默认导出,但通常最好每个模块只使用一种导出样式。
作为默认导出的示例,请考虑以下两个文件:
my-func.mjs main.mjs
模块my-func.mjs
具有默认导出:
const GREETING = 'Hello!'; export default function () { return GREETING; }
模块main.mjs
默认导入导出的函数:
import myFunc from './my-func.mjs'; assert.equal(myFunc(), 'Hello!');
注意语法上的区别:命名导入周围的大括号表示我们正在进入模块,而默认导入是模块。
默认导出的用例是什么?
默认导出最常见的用例是包含单个函数或单个类的模块。
27.7.1 两种默认导出样式
有两种样式可以进行默认导出。
首先,我们可以使用export default
标记现有声明:
export default function myFunc() {} // no semicolon! export default class MyClass {} // no semicolon!
其次,我们可以直接默认导出值。这种export default
的样式很像一个声明。
export default myFunc; // defined elsewhere export default MyClass; // defined previously export default Math.sqrt(2); // result of invocation is default-exported export default 'abc' + 'def'; export default { no: false, yes: true };
27.7.1.1 为什么有两种默认导出样式?
原因是export default
不能用于标记const
:const
可以定义多个值,但export default
需要确切的一个值。考虑以下假设的代码:
// Not legal JavaScript! export default const foo = 1, bar = 2, baz = 3;
使用此代码,我们不知道三个值中的哪一个是默认导出。
练习:默认导出
exercises/modules/export_default_test.mjs
27.7.2 默认导出作为命名导出(高级)
在内部,默认导出只是一个名为default
的命名导出。例如,考虑具有默认导出的先前模块my-func.mjs
:
const GREETING = 'Hello!'; export default function () { return GREETING; }
以下模块my-func2.mjs
等效于该模块:
const GREETING = 'Hello!'; function greet() { return GREETING; } export { greet as default, };
对于导入,我们可以使用普通的默认导入:
import myFunc from './my-func2.mjs'; assert.equal(myFunc(), 'Hello!');
或者我们可以使用命名导入:
import {default as myFunc} from './my-func2.mjs'; assert.equal(myFunc(), 'Hello!');
默认导出也可以通过命名空间导入的属性.default
来使用:
import * as mf from './my-func2.mjs'; assert.equal(mf.default(), 'Hello!');
default
作为变量名是非法的吗?
default
不能是变量名,但它可以是导出名称,也可以是属性名称:
const obj = { default: 123, }; assert.equal(obj.default, 123);
27.8 导出和导入的更多细节
27.8.1 导入是对导出的只读视图
到目前为止,我们已经直触使用了导入和导出,并且一切似乎都按预期工作。但现在是时候更仔细地看一下导入和导出的真正关系了。
考虑以下两个模块:
counter.mjs main.mjs
counter.mjs
导出一个(可变的!)变量和一个函数:
export let counter = 3; export function incCounter() { counter++; }
main.mjs
名称导入了两个导出。当我们使用incCounter()
时,我们发现与counter
的连接是实时的-我们始终可以访问该变量的实时状态。
import { counter, incCounter } from './counter.mjs'; // The imported value `counter` is live assert.equal(counter, 3); incCounter(); assert.equal(counter, 4);
请注意,虽然连接是实时的,我们可以读取counter
,但我们不能更改这个变量(例如,通过counter++
)。
以这种方式处理导入有两个好处:
- 拆分模块变得更容易,因为以前共享的变量可以成为导出。
- 这种行为对支持透明的循环导入至关重要。继续阅读以获取更多信息。
27.8.2 ESM 对透明支持循环导入(高级)
ESM 支持透明的循环导入。要了解如何实现这一点,考虑以下示例:图 7 显示了一个模块导入其他模块的有向图。在这种情况下,P 导入 M 是循环。
图 7:模块导入模块的有向图:M 导入 N 和 O,N 导入 P 和 Q,等等。
解析后,这些模块分两个阶段设置:
- 实例化:访问每个模块并将其导入连接到其导出。在父级实例化之前,必须先实例化其所有子级。
- 评估:执行模块的主体。再次强调,必须在父级之前评估子级。
这种方法正确处理了循环导入,这是由于 ES 模块的两个特性:
- 由于 ES 模块的静态结构,解析后导出已经是已知的。这使得在其子级 M 之前实例化 P 成为可能:P 已经可以查找 M 的导出。
- 当 P 被评估时,M 尚未被评估。但是,P 中的实体可以已经提到了来自 M 的导入。它们只是还不能使用它们,因为导入的值稍后填充。例如,P 中的一个函数可以访问来自 M 的导入。唯一的限制是我们必须等到 M 的评估之后,才能调用该函数。
导入稍后填充是由它们成为对导出的“实时不可变视图”而启用的。
27.9 npm 包
npm 软件注册表是分发 JavaScript 库和 Node.js 和 Web 浏览器应用程序的主要方式。它通过npm 软件包管理器(简称npm)进行管理。软件以所谓的包的形式分发。包是一个包含任意文件和顶层描述该包的package.json
文件的目录。例如,当 npm 在目录my-package/
中创建一个空包时,我们得到了这个package.json
:
{ "name": "my-package", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "test": "echo \"Error: no test specified\" && exit 1" }, "keywords": [], "author": "", "license": "ISC" }
其中一些属性包含简单的元数据:
name
指定了这个包的名称。一旦上传到 npm 注册表,就可以通过npm install my-package
进行安装。version
用于版本管理,并遵循语义化版本,有三个数字:
- 主要版本:当进行不兼容的 API 更改时递增。
- 次要版本:在向后兼容的方式下添加功能时递增。
- 补丁版本:在向后兼容的方式下进行更改时递增。
description
,keywords
,author
使得更容易找到包。license
澄清了我们如何使用这个包。
其他属性使高级配置成为可能:
main
:指定“是”包的模块(本章后面会解释)。scripts
:是我们可以通过npm run
执行的命令。例如,可以通过npm run test
执行脚本test
。
有关package.json
的更多信息,请参阅npm 文档。
27.9.1 包被安装在目录node_modules/
内
npm 总是将包安装在node_modules
目录中。通常会有许多这样的目录。npm 使用哪一个取决于当前所在的目录。例如,如果我们在目录/tmp/a/b/
中,npm 会尝试在当前目录、其父目录、父目录的父目录等中找到node_modules
。换句话说,它会搜索以下位置的链:
/tmp/a/b/node_modules
/tmp/a/node_modules
/tmp/node_modules
当安装一个包some-pkg
时,npm 会使用最近的node_modules
。例如,如果我们在/tmp/a/b/
中,并且该目录中有一个node_modules
,那么 npm 会将包放在该目录中:
/tmp/a/b/node_modules/some-pkg/
在导入模块时,我们可以使用特殊的模块规范告诉 Node.js 我们想要从已安装的包中导入它。这是如何工作的,稍后会有解释。现在,考虑以下示例:
// /home/jane/proj/main.mjs import * as theModule from 'the-package/the-module.mjs';
寻找the-module.mjs
(Node.js 更喜欢使用文件扩展名.mjs
来表示 ES 模块),Node.js 会沿着node_module
链向上查找以下位置:
/home/jane/proj/node_modules/the-package/the-module.mjs
/home/jane/node_modules/the-package/the-module.mjs
/home/node_modules/the-package/the-module.mjs
27.9.2 为什么可以使用 npm 来安装前端库?
在node_modules
目录中查找已安装的模块只在 Node.js 上受支持。那么为什么我们也可以使用 npm 来为浏览器安装库呢?
这是通过打包工具(例如 webpack)启用的,它会在部署到线上之前编译和优化代码。在这个编译过程中,npm 包中的代码会被调整,以便在浏览器中运行。
27.10 命名模块
对于命名模块文件和导入到其中的变量,没有已建立的最佳实践。
在本章中,我使用以下命名风格:
- 模块文件的名称是破折号命名,并以小写字母开头:
./my-module.mjs ./some-func.mjs
- 命名空间导入的名称是小写和驼峰式命名:
import * as myModule from './my-module.mjs';
- 默认导入的名称是小写和驼峰式命名:
import someFunc from './some-func.mjs';
这种风格背后的原因是什么?
- npm 不允许包名中有大写字母(来源)。因此,我们避免驼峰命名,以使“本地”文件的名称与 npm 包的名称一致。仅使用小写字母也可以最小化大小写敏感和不敏感文件系统之间的冲突:前者区分具有相同字母但大小写不同的名称的文件;后者则不区分。
- 有明确的规则将破折号命名的文件名转换为驼峰式的 JavaScript 变量名。由于我们命名命名空间导入的方式,这些规则适用于命名空间导入和默认导入。
我也喜欢使用下划线命名的模块文件名,因为我们可以直接使用这些名称进行命名空间导入(无需任何转换):
import * as my_module from './my_module.mjs';
但是这种风格对于默认导入不起作用:我喜欢使用下划线命名空间对象,但对于函数等来说并不是一个好选择。
27.11 模块规范
模块规范是用于标识模块的字符串。它们在浏览器和 Node.js 中有略微不同的工作方式。在我们看到区别之前,我们需要了解不同类别的模块规范。
27.11.1 模块规范的类别
在 ES 模块中,我们区分以下类别的规范。这些类别起源于 CommonJS 模块。
- 相对路径:以点开头。例如:
'./some/other/module.mjs' '../../lib/counter.mjs'
- 绝对路径:以斜杠开头。例如:
'/home/jane/file-tools.mjs'
- URL:包括协议(从技术上讲,路径也是 URL)。例如:
'https://example.com/some-module.mjs' 'file:///home/john/tmp/main.mjs'
- 裸路径:不以点、斜杠或协议开头,由单个没有扩展名的文件名组成。例如:
'lodash' 'the-package'
- 深层导入路径:以裸路径开头,并且至少有一个斜杠。例如:
'the-package/dist/the-module.mjs'
27.11.2 浏览器中的 ES 模块规范
浏览器处理模块规范如下:
- 相对路径、绝对路径和 URL 都能按预期工作。它们都必须指向真实的文件(与 CommonJS 相反,它允许我们省略文件扩展名等)。
- 模块的文件名扩展名并不重要,只要它们以
text/javascript
的内容类型提供即可。 - 裸路径最终将如何处理尚不清楚。我们可能最终能够通过查找表将它们映射到其他规范。
请注意,打包工具(如 webpack)将模块合并为较少的文件,通常对规范要求不那么严格。这是因为它们在构建/编译时操作(而不是在运行时),并且可以通过遍历文件系统来搜索文件。
27.11.3 Node.js 上的 ES 模块规范
Node.js 处理模块规范如下:
- 相对路径会像在 Web 浏览器中一样解析 - 相对于当前模块的路径。
- 目前不支持绝对路径。作为一种解决方法,我们可以使用以
file:///
开头的 URL。我们可以通过url.pathToFileURL()
创建这样的 URL。 - 只支持
file:
作为 URL 规范的协议。 - 裸路径被解释为包名,并相对于最近的
node_modules
目录进行解析。应该加载哪个模块是通过查看包的package.json
的"main"
属性来确定的(类似于 CommonJS)。 - 深层导入路径也相对于最近的
node_modules
目录进行解析。它们包含文件名,因此始终清楚指的是哪个模块。
除了裸路径之外,所有的规范都必须指向实际的文件。也就是说,ESM 不支持以下 CommonJS 特性:
- CommonJS 会自动添加丢失的文件扩展名。
- 如果存在带有
"main"
属性的dir/package.json
,CommonJS 可以导入目录dir
。 - 如果存在
dir/index.js
模块,CommonJS 可以导入目录dir
。
所有内置的 Node.js 模块都可以通过裸路径访问,并且具有命名的 ESM 导出 - 例如:
import * as assert from 'assert/strict'; import * as path from 'path'; assert.equal( path.join('a/b/c', '../d'), 'a/b/d');
27.11.3.1 Node.js 上的文件扩展名
Node.js 支持以下默认的文件扩展名:
.mjs
用于 ES 模块.cjs
用于 CommonJS 模块
文件扩展名.js
代表 ESM 或 CommonJS。它是通过“最接近”的package.json
(在当前目录、父目录等)配置的。使用这种方式的package.json
与包是独立的。
在package.json
中,有一个"type"
属性,它有两个设置:
"commonjs"
(默认):具有扩展名.js
或没有扩展名的文件被解释为 CommonJS 模块。"module"
:具有扩展名.js
或没有扩展名的文件被解释为 ESM 模块。
27.11.3.2 将非文件源代码解释为 CommonJS 或 ESM
Node.js 执行的并非所有源代码都来自文件。我们也可以通过 stdin、--eval
和--print
向其发送代码。命令行选项--input-type
让我们指定如何解释这样的代码:
- 作为 CommonJS(默认):
--input-type=commonjs
- 作为 ESM:
--input-type=module
27.12 import.meta
- 当前模块的元数据[ES2020]
对象import.meta
保存了当前模块的元数据。
27.12.1 import.meta.url
import.meta
最重要的属性是.url
,其中包含当前模块文件的 URL 字符串 - 例如:
'https://example.com/code/main.mjs'
27.12.2 import.meta.url
和类URL
在浏览器和 Node.js 中,类URL
可以通过全局变量访问。我们可以在Node.js 文档中查找其完整功能。在使用import.meta.url
时,它的构造函数特别有用:
new URL(input: string, base?: string|URL)
参数input
包含要解析的 URL。如果提供了第二个参数base
,它可以是相对的。
换句话说,这个构造函数让我们根据基本 URL 解析相对路径:
> new URL('other.mjs', 'https://example.com/code/main.mjs').href 'https://example.com/code/other.mjs' > new URL('../other.mjs', 'https://example.com/code/main.mjs').href 'https://example.com/other.mjs'
这是我们如何获得一个指向与当前模块相邻的文件data.txt
的URL
实例:
const urlOfData = new URL('data.txt', import.meta.url);
27.12.3 Node.js 上的import.meta.url
在 Node.js 上,import.meta.url
始终是一个带有file:
URL 的字符串 - 例如:
'file:///Users/rauschma/my-module.mjs'
27.12.3.1 示例:读取模块的同级文件
许多 Node.js 文件系统操作接受路径字符串或URL
实例。这使我们能够读取当前模块的同级文件data.txt
:
import * as fs from 'fs'; function readData() { // data.txt sits next to current module const urlOfData = new URL('data.txt', import.meta.url); return fs.readFileSync(urlOfData, {encoding: 'UTF-8'}); }
27.12.3.2 模块fs
和 URL
对于模块fs
的大多数函数,我们可以通过以下方式引用文件:
- 路径 - 以字符串或
Buffer
实例的形式。 - URL - 在
URL
实例中(使用协议file:
)
有关此主题的更多信息,请参阅Node.js API 文档。
27.12.3.3 在file:
URL 和路径之间转换
Node.js 模块url
有两个函数用于在file:
URL 和路径之间转换:
fileURLToPath(url: URL|string): string
将file:
URL 转换为路径。pathToFileURL(path: string): URL
将路径转换为file:
URL。
如果我们需要一个可以在本地文件系统中使用的路径,那么URL
实例的.pathname
属性并不总是有效:
assert.equal( new URL('file:///tmp/with%20space.txt').pathname, '/tmp/with%20space.txt');
因此,最好使用fileURLToPath()
:
import * as url from 'url'; assert.equal( url.fileURLToPath('file:///tmp/with%20space.txt'), '/tmp/with space.txt'); // result on Unix
同样,pathToFileURL()
不仅仅是在绝对路径前面添加'file://'
。
27.13 通过import()
动态加载模块[ES2020](高级)
import()
操作符使用 Promises
Promises 是一种处理异步计算结果的技术(即不是立即计算的)。它们在§40“Promises for asynchronous programming [ES6]”中有解释。在理解它们之前,推迟阅读本节可能是有意义的。
27.13.1 静态import
语句的限制
到目前为止,导入模块的唯一方法是通过import
语句。该语句有几个限制:
- 我们必须在模块的顶层使用它。也就是说,我们不能在函数内部或
if
语句内部导入某些东西。 - 模块标识符是固定的。也就是说,我们不能根据条件改变导入的内容。我们也不能动态组装一个标识符。
27.13.2 通过import()
操作符动态导入
import()
操作符没有import
语句的限制。它看起来像这样:
import(moduleSpecifierStr) .then((namespaceObject) => { console.log(namespaceObject.namedExport); });
这个操作符像一个函数一样使用,接收一个带有模块标识符的字符串,并返回一个解析为命名空间对象的 Promise。该对象的属性是导入模块的导出。
通过await
来使用import()
更加方便:
const namespaceObject = await import(moduleSpecifierStr); console.log(namespaceObject.namedExport);
请注意,await
可以在模块的顶层使用(参见下一节)。
让我们看一个使用import()
的例子。
27.13.2.1 示例:动态加载模块
考虑以下文件:
lib/my-math.mjs main1.mjs main2.mjs
我们已经看到了模块my-math.mjs
:
// Not exported, private to module function times(a, b) { return a * b; } export function square(x) { return times(x, x); } export const LIGHTSPEED = 299792458;
我们可以使用import()
按需加载这个模块:
// main1.mjs const moduleSpecifier = './lib/my-math.mjs'; function mathOnDemand() { return import(moduleSpecifier) .then(myMath => { const result = myMath.LIGHTSPEED; assert.equal(result, 299792458); return result; }); } mathOnDemand() .then((result) => { assert.equal(result, 299792458); });
这段代码中有两件事是无法通过import
语句完成的:
- 我们在函数内部导入(而不是在顶层)。
- 模块标识符来自一个变量。
接下来,我们将实现与main1.mjs
中相同的功能,但通过一个称为async function或async/await的特性来实现,它为 Promises 提供了更好的语法。
// main2.mjs const moduleSpecifier = './lib/my-math.mjs'; async function mathOnDemand() { const myMath = await import(moduleSpecifier); const result = myMath.LIGHTSPEED; assert.equal(result, 299792458); return result; }
为什么import()
是一个操作符而不是一个函数?
import()
看起来像一个函数,但不能作为一个函数实现:
- 它需要知道当前模块的 URL 以解析相对模块标识符。
- 如果
import()
是一个函数,我们必须明确地将这些信息传递给它(例如通过参数)。 - 相比之下,操作符是一种核心语言构造,并且隐式访问更多数据,包括当前模块的 URL。
27.13.3 import()
的用例
27.13.3.1 按需加载代码
Web 应用程序的一些功能在启动时不必存在,可以按需加载。然后import()
有所帮助,因为我们可以将这样的功能放入模块中 - 例如:
button.addEventListener('click', event => { import('./dialogBox.mjs') .then(dialogBox => { dialogBox.open(); }) .catch(error => { /* Error handling */ }) });
27.13.3.2 模块的条件加载
我们可能希望根据条件是否为真来加载一个模块。例如,具有 Polyfill 的模块可以在旧平台上提供新功能:
if (isLegacyPlatform()) { import('./my-polyfill.mjs') .then(···); }
27.13.3.3 计算模块规范
对于国际化等应用程序,如果我们可以动态计算模块规范,这将有所帮助:
import(`messages_${getLocale()}.mjs`) .then(···);
27.14 模块中的顶层await
[ES2022](高级)
await
是异步函数的一个特性
await
在§41“异步函数”中有解释。在理解异步函数之前,推迟阅读本节可能是有意义的。
我们可以在模块的顶层使用await
运算符。如果这样做,模块将变成异步的,并且工作方式会有所不同。幸运的是,我们通常不会作为程序员看到这一点,因为语言会透明地处理它。
27.14.1 顶层await
的用例
为什么我们希望在模块的顶层使用await
运算符?它让我们可以使用异步加载的数据初始化模块。接下来的三个小节展示了这种用法的三个例子。
27.14.1.1 动态加载模块
const params = new URLSearchParams(location.search); const language = params.get('lang'); const messages = await import(`./messages-${language}.mjs`); // (A) console.log(messages.welcome);
在 A 行,我们动态导入一个模块。由于顶层await
,这几乎和使用普通的静态导入一样方便。
27.14.1.2 在模块加载失败时使用备用
let lodash; try { lodash = await import('https://primary.example.com/lodash'); } catch { lodash = await import('https://secondary.example.com/lodash'); }
27.14.1.3 使用加载最快的资源
const resource = await Promise.any([ fetch('http://example.com/first.txt') .then(response => response.text()), fetch('http://example.com/second.txt') .then(response => response.text()), ]);
由于Promise.any()
,变量resource
是通过任何首次完成的下载进行初始化。
27.14.2 顶层await
在底层是如何工作的?
考虑以下两个文件。
first.mjs
:
const response = await fetch('http://example.com/first.txt'); export const first = await response.text();
main.mjs
:
import {first} from './first.mjs'; import {second} from './second.mjs'; assert.equal(first, 'First!'); assert.equal(second, 'Second!');
两者大致等同于以下代码:
first.mjs
:
export let first; export const promise = (async () => { // (A) const response = await fetch('http://example.com/first.txt'); first = await response.text(); })();
main.mjs
:
import {promise as firstPromise, first} from './first.mjs'; import {promise as secondPromise, second} from './second.mjs'; export const promise = (async () => { // (B) await Promise.all([firstPromise, secondPromise]); // (C) assert.equal(first, 'First content!'); assert.equal(second, 'Second content!'); })();
如果:
- 它直接使用顶层
await
(first.mjs
)。 - 它导入一个或多个异步模块(
main.mjs
)。
每个异步模块都导出一个 Promise(A 行和 B 行),在其主体执行后实现。在这一点上,安全地访问该模块的导出。
在情况(2)中,导入模块会等待所有导入的异步模块的 Promise 被实现,然后再进入其主体(C 行)。同步模块则像通常一样处理。
等待的拒绝和同步异常的处理方式与异步函数中的处理方式相同。
27.14.3 顶层await
的利弊
顶层await
的两个最重要的好处是:
- 它确保模块在完全初始化之前不会访问异步导入。
- 它透明地处理异步性:导入者不需要知道导入的模块是异步的还是同步的。
另一方面,顶层await
延迟了导入模块的初始化。因此,最好是谨慎使用。需要更长时间的异步任务最好稍后执行。
然而,即使没有顶层await
的模块也可能阻止导入者(例如在顶层的无限循环中),因此阻塞本身并不是反对它的论点。
27.15 Polyfills:模拟原生 Web 平台功能(高级)
后端也有 Polyfills
本节是关于前端开发和 Web 浏览器,但类似的想法也适用于后端开发。
Polyfills有助于解决我们在 JavaScript 中开发 Web 应用程序时面临的冲突:
- 一方面,我们希望使用使应用程序更好和/或开发更容易的现代 Web 平台功能。
- 另一方面,应用程序应该在尽可能多的浏览器上运行。
考虑一个 Web 平台功能 X:
- X 的polyfill是一段代码。如果它在已经内置对 X 的支持的平台上执行,它什么也不做。否则,它会在平台上提供该功能。在后一种情况下,polyfilled 功能(大多数情况下)与本机实现几乎无法区分。为了实现这一点,polyfill 通常会进行全局更改。例如,它可能修改全局数据或配置全局模块加载器。Polyfills 通常打包为模块。
- 术语polyfill是由 Remy Sharp 创造的。
- 推测性 polyfill是针对提议的 Web 平台功能的 polyfill(尚未标准化)。
- 替代术语:prollyfill
- X 的复制品是一个在本地复制 X 的 API 和功能的库。这样的库独立于 X 的本机(和全局)实现。
- 复制品是本节中引入的一个新术语。替代术语:ponyfill
- 还有一个术语shim,但它没有一个普遍认可的定义。它通常意思大致相同于polyfill。
每次我们的 Web 应用程序启动时,它必须首先执行所有可能不是在所有地方都可用的功能的 polyfills。之后,我们可以确保这些功能在本地是可用的。
27.15.1 本节的来源
- “什么是 Polyfill?” by Remy Sharp
- 复制品这个术语的灵感来源:拉斯维加斯的埃菲尔铁塔
- 有用的澄清“polyfill”和相关术语:“Polfills and the evolution of the Web”。由 Andrew Betts 编辑。
测验
参见测验应用程序。