近几年涌现了很多分而治之的程式码管理方案。长久以来,我们一直使用所谓的模组模式,即将程式码包装到自呼叫的函式表示式中。我们必须自己管理指令码的次序,以确保每个模组都在自己的依赖之后载入。
后来,RequireJS 库出现了。它提供了以程式设计方式定义每个模组依赖的机制,有了依赖图后,我们就不必再操心指令码的载入次序了。RequireJS 接受一个字串阵列,以明确依赖,同时将模组包装为一个函式呼叫,而这个函式接受前面的依赖作为引数。其实很多其他库都支援类似的功能,只不过API 不大一样。
还有其他的复杂依赖管理机制,如AngularJS 中的依赖注入机制。该机制需要我们用函式定义命名元件,并相应地指定其他命名元件依赖。AngularJS 会替我们管理依赖注入的载入,我们要做的只是命名元件和指定依赖。
取代RequireJS 的是CommonJS,随着Node.js 的火爆,CommonJS 迅速走红。本文将先介绍CommonJS,毕竟它在今天的应用还相当普遍。接下来还将介绍ES6 为JavaScript 引入的原生模组系统。最后,我们将探讨CommonJS 与原生JavaScript 模组(即人们常说的ECMAScript 模组)的互用性。
CommonJS
CommonJS 与其他模组化方案的不同之处是,它将每个档案都看成一个模组,其他方案则以程式设计方式宣告模组。CommonJS 模组拥有隐含的区域性作用域,全域性作用域必须通过global 显式地被访问。CommonJS 模组汇入依赖及汇出供外部呼叫的界面都是动态的,汇入依赖是通过require 函式呼叫实现的。这个函式呼叫是同步的,会返回所请求模组暴露的界面。不看程式码很难说清一种模组的定义语法。以下程式码展示了一个可重用的CommonJS 模组档案。其中的has 和union 函式都位于模组的区域性作用域内。在此之上,我们再将union 赋给module.exports,它就成了这个模组的公共API。
function has(list, item) {
return list.includes(item)
}
function union(list, item) {
if (has(list, item)) {
return list
}
return [...list, item]
}
module.exports = union
假设将以上档案储存为union.js,那我们就可以在另一个CommonJS 模组中呼叫它。比如,另一个档案是app.js,为了呼叫union.js,需要给require 传入一个相对路径。
const union = require(\'./union.js\')
console.log(union([1, 2], 3))
// console.log(union([1, 2], 2))
// 如果副档名是.js 或.json,则可以省略,但不鼓励这么做。
虽然副档名对require 语句来说是可选的,在使用node CLI 时最好还是养成新增副档名的习惯。浏览器对ESM 的实现不允许省略副档名,否则就会导致多一次到服务器的往返才能找到作为HTTP 资源的JavaScript 模组的正确端点。
在Node.js 环境下,我们可以通过CLI 执行app.js,如下所示。
» node app.js
# [1, 2, 3]
# [1, 2]
安装Node.js 后,就可以在终端命令列中使用node 程式。
正如其他JavaScript 函式那样,CommonJS 中的require 函式也可以被动态呼叫。我们可以利用这一特性实现通过一个界面动态获取不同的模组。举个例子,假设有一个模板目录,其中包含几个检视模板档案,每个档案都汇出一个函式。这些函式都接受一个模型引数,然后返回一个HTML 字串。
通过读取model 物件的属性,以下程式码中展示的模板函式构造并返回购物车中的一个商品。
// views/item.js
module.exports = model => `
${ model.amount }
x
${ model.name }
`
应用模组可以基于这个item.js 提供的检视模板打印一个
元素。// app.js
const renderItem = require(\'./views/item.js\')
const html = renderItem({
name: \'Banana bread\',
amount: 3
})
console.log(html)
图1 展示了这个小应用的执行结果。
图1:将模型渲染为HTML 就是向模板字面量中插值而已
接下来的这个模板用于渲染购物车中的所有商品。它接受一个商品阵列,并重用前面的item.js 模板来渲染每件商品。
// views/list.js
const renderItem = require(\'./item.js\')
module.exports = model => `
${ model.map(renderItem).join(\' \') }
`
我们可以像前面那样使用list.js 模板。但要注意,传给它的模型必须是一个商品阵列,而非单个商品物件。
// app.js
const renderList = require(\'./views/list.js\')
const html = renderList([{
name: \'Banana bread\',
amount: 3
}, {
name: \'Chocolate chip muffin\',
amount: 2
}])
console.log(html)
图2 展示了小应用的当前状况。
图2:基于模板字面量的复合模板同样是信手拈来
到目前为止,这个示例只写了几个小模组,每个模组只基于传入的模型物件和检视模板生成一种HTML 检视。简单的API 便于重用,因此我们才能轻松地将模型对映到item.js 模板函式,以渲染出多个商品,最后再通过换行符将它们连线起来。
既然两个检视的API 相似,都是接受一个模型并返回一段HTML 字串,那么就可以抽象一下。如果想要一个render 函式能够渲染任何模板,那么可以借助require 的动态特性来轻松实现。以下示例的核心是构建指向模板模组的路径。与前面程式码的重要不同点是,require 呼叫并没有出现在模组程式码的顶部。对require 的呼叫可以出现在任何地方,甚至可以巢状在其他函式中。
// render.js
module.exports = function render(template, model) {
return require(`./views/${ template }`.js)(model)
}
有了这样的API 后,就不用操心呼叫require 时传入的检视模板路径是否正确了,因为render.js 模组会正确拼接出路径。要想渲染模板,只要传入模板的名字和该模板所需要的模型即可,如以下程式码和图3 所示。
// app.js
const render = require(\'./render.js\')
console.log(render(\'item\', {
name: \'Banana bread\',
amount: 1
}))
console.log(render(\'list\', [{
name: \'Apple pie\',
amount: 2
}, {
name: \'Roasted almond\',
amount: 25
}]))
图3:模板字面量使得建立HTML 渲染应用变得易如反掌
接下来,我们会看到ES6 模组从某种程度上受到了CommonJS 的影响。接下来我们会讨论export 和import 语句,以及ESM 与CommonJS 有哪些相通之处。
JavaScript模组
在前面介绍CommonJS 模组时,我们已经知道其API 简单却非常灵活、强大。ES6 模组的API 甚至比它还要简单,虽然灵活性稍微差了点,但几乎是一样强大的。
严格模式
在ES6 模组系统中,严格模式预设是开启的。严格模式是一个特性,用于拒绝JavaScript中那些不好的特性,并让很多静默错误变成异常,从而被丢掷。在拒绝这些特性的基础上,编译器可以启用优化策略,让JavaScript 执行时更快、更安全。
• 变数必须被宣告。
• 函式引数的名字必须是唯一的。
• 禁止使用with 语句。
• 为只读属性赋值会导致丢掷错误。
• 00740 这样的八进位制数是语法错误。
• 用delete 删除不可删除的属性会丢掷错误。
• delete prop 是语法错误,delete global.prop 才是正确的。
• eval 不会为周围的作用域引入新变数。
• eval 和arguments 不能被系结或赋值。
• arguments 不会神奇地同步方法引数的变化。
• 不再支援arguments.callee,访问它会丢掷TypeError。
• 不再支援arguments.caller,访问它会丢掷TypeError。
• 方法呼叫中作为this 传递的上下文不会被“装箱”为Object。
• 不能再使用fn.caller 和fn.arguments 来访问JavaScript 栈。
• 保留字(如protected、static、interface 等)不能被系结。
接下来我们将深入探讨一下export 语句。
export语句
在CommonJS 模组中,要汇出的值必须赋给module.exports。可以汇出的内容包括任意型别的值、物件、阵列、函式,如下所示。
module.exports = \'hello\'
module.exports = { hello: \'world\' }
module.exports = [\'hello\', \'world\']
module.exports = function hello() {}
作为档案,ES6 模组是通过export 语句暴露API 的。ESM 中的宣告只在区域性作用域中有效,这一点与CommonJS 相同。模组中宣告的任何变数都必须作为该模组的API 汇入,并且在想要使用它们的模组中汇入才能访问。
1. 汇出预设系结
将前面CommonJS 程式码中的module.exports = 替换成export default 即可在ESM 中实现相同的效果。
export default \'hello\'
export default { hello: \'world\' }
export default [\'hello\', \'world\']
export default function hello() {}
在CommonJS 中,我们可以为module.exports 动态赋值。
function initialize() {
module.exports = \'hello!\'
}
initialize()
相较于CommonJS,ESM 中的export 语句只能出现在模组顶级。export 语句“只能出现在顶级”是一个很好的限制,因为根据方法呼叫来动态定义并暴露API 并不是非常必要。这一限制还有助于编译器及静态分析工具解析ES6 模组。
function initialize() {
export default \'hello!\' // SyntaxError
}
initialize()
除了export default 语句,ESM 还支援其他暴露API 的方式。
2. 命名汇出
在CommonJS 中,如果想要暴露多个值,不一定需要汇出一个包含这些值的物件。我们可以将这些值赋给隐含的module.exports 物件。这样汇出的仍然只是一个系结,其中包含module.exports 物件最终持有的所有属性。也就是说,虽然以下示例看似汇出了两个值,但实际上它们都是最终汇出物件的属性。
module.exports.counter = 0
module.exports.count = () => module.exports.counter++
在ESM 中,我们可以通过命名汇出语法复现这一行为。相较于CommonJS 为隐含的module.exports 物件新增属性,ES6 是直接宣告要汇出的系结,如下所示。
export let counter = 0
export const count = () => counter++
注意,前面的程式码不能将变数宣告提取为独立的语句,然后再作为命名汇出传给export,否则会导致语法错误。
let counter = 0
const count = () => counter++
export counter // SyntaxError
export count
ESM 这样严格限制模组中宣告的语法是为了方便静态分析,但代价是损失一些灵活性。要想提高灵活性,就必然会提高复杂性,这也是ESM 不提供灵活界面的正当理由。
3. 汇出列表
ES6 模组支援汇出顶级命名成员的列表,如下所示。这种汇出列表的语法很容易解析,同时也就前面提出的问题给出了一个解决方案。
let counter = 0
const count = () => counter++
export { counter, count }
要想重新命名汇出的系结,可以使用别名语法export { count as increment}。这样我们就可以将区域性作用域中的系结count 以别名increment 提供给外部,如下所示。
let counter = 0
const count = () => counter++
export { counter, count as increment }
最后,使用命名成员列表语法时还可以指定预设汇出。以下程式码使用as default 在汇出多个命名成员的同时定义了模组的预设汇出。
let counter = 0
const count = () => counter++
export { counter as default, count as increment }
虽然以下程式码长了点,但功能与上段程式码相同。
let counter = 0
const count = () => counter++
export default counter
export { count as increment }
需要特别注意的是,我们汇出的是系结,而不只是值。
4. 系结,而不是值
ES6 模组汇出系结,而不是值或引用。这意味着以下示例中的模组汇出的fungible 会系结到这个模组的fungible 变数,其值会随fungible 变数的变化而变化。被其他模组引用后再改变公共界面可能会导致困惑,但这一机制在某些情况下确实也很有用。
在以下程式码中,模组汇出的fungible 一开始系结的是一个物件,5 秒后又改成了一个数组。
export let fungible = { name: \'bound\' }
setTimeout(() => fungible = [0, 1, 2], 5000)
使用这个API 的模组在5 秒后也能看到fungible 值的变化。以下示例每隔2 秒会打印一次引入的系结。
import { fungible } from \'./fungible.js\'
console.log(fungible) // setInterval(() => console.log(fungible), 2000)
// // // // // 这种行为特别适合计数器和标记,但除非用途明确,最好不要使用。毕竟从使用者的角度来看,API 界面不确定很难理解。
JavaScript 的模组系统还提供了一个export..from 语法,用于暴露其他模组的界面。
5. 汇出另一个模组
向export 新增一个from 子句就可以汇出另一个模组的命名汇出。此时系结不会汇入到当前模组的作用域。换句话说,当前模组只是传递另一个模组的系结,并不能直接访问该系结。
export { increment } from \'./counter.js\'
increment()
// ReferenceError: increment is not defined
在传递系结时,我们可以为命名汇出起个别名。如果以下示例中的模组被命名为aliased,那么呼叫者可以通过import { add } from \'aliased.js\' 取得counter 模组中的increment的系结。
export { increment as add } from \'./counter.js\'
ESM 也支援用万用字元汇出另一个模组中的所有命名汇出,如下所示。但要注意,此时不会汇出counter 模组中的预设系结。
export * from \'./counter.js\'
要想汇出另一个模组的default 系结,必须使用汇出列表语法来新增别名。
export { default as counter } from \'./counter.js\'
我们将ES6 模组暴露API 的所有语法都过了一遍。接下来我们将探讨一下import 语句,看看如何通过它使用其他模组。
import语句
我们可以用import 语句在一个模组中载入另一个模组。载入模组的语法因实现而不同,也就是说,规范并未就此给出描述。如今,我们可以编写相容ES6 规范的程式码,而一些聪明的人已经找到了在浏览器中处理模组载入的办法。
Babel 这样的编译器可以基于CommonJS 等模组系统拼接模组。这意味着Babel 中的import 语句与CommonJS 中的require 具有相同的语义。
假设模组./counter.js 包含以下程式码。
let counter = 0
const increment = () => counter++
const decrement = () => counter--
export { counter as default, increment, decrement }
以下这行程式码可以将counter 模组载入到我们的app 模组中。这行程式码不会在app 模组的作用域中建立任何变数。但是这会导致counter 模组中的所有顶级程式码执行,包括该模组自己的import 语句。
import \'./counter.js\'
与export 语句类似,import 语句也只允许出现在模组程式码的顶级。这一限制有助于编译器简化自己的模组载入逻辑,同时有助于其他静态分析工具解析你的程式码。
1. 汇入预设汇出
CommonJS 模组通过require 语句汇入其他模组。如果需要引入某个模组的预设汇出,只要将该语句的结果赋给一个变数即可。
const counter = require(\'./counter.js\')
要想汇入ES6 模组汇出的预设系结,就必须给它起个名字。但语法和语义与宣告变数时有些不同,因为这里是汇入系结,而不只是将值赋给一个变数。这个区别有助于静态分析工具和编译器更轻松地解析我们的程式码。
import counter from \'./counter.js\'
console.log(counter)
// 除了预设汇出,我们也可以汇入命名汇出并给它们起别名。
2. 汇入命名汇出
以下程式码展示了如何从counter 模组汇入increment 方法。汇入命名汇出的语法是一对花括号,这让我们联想到了解构赋值。
import { increment } from \'./counter.js\'
为了汇入多个系结,系结之间以逗号分隔。
import { increment, decrement } from \'./counter.js\'
这里的语法和语义与解构有些不同。比如,解构通过冒号建立别名,而import 语句则使用as关键字,照搬了export 语句的语法。以下程式码在汇入increment 方法时将其重新命名为add。
import { increment as add } from \'./counter.js\'
以逗号作为分隔符,我们可以同时汇入预设汇出和命名汇出。
import counter, { increment } from \'./counter.js\'
我们也可以给default 系结命名,此时需要一个别名。
import { default as counter, increment } from \'./counter.js\'
以下的程式码示例展示了ESM 与CommonJS 汇入在语义上的区别。记住,ESM 中汇出和汇入的是系结,而不是引用。为方便理解,你可以将以下程式码中的系结counter 想象为一个属性的获取方法(getter),它可以访问counter 模组内部并返回其区域性变数counter 的值。
import counter, { increment } from \'./counter.js\'
console.log(counter) // increment()
console.log(counter) // increment()
console.log(counter) // 最后,我们将探讨名称空间汇入。
3. 万用字元汇入语句
我们可以用万用字元汇入一个模组的名称空间物件。相较于汇入命名汇出或预设汇出,这样可以一次性汇入所有汇出。注意,星号* 后面必须紧跟别名,汇入的所有系结都在这个别名名下。如果存在default 汇出,那么它也会被放到这个名称空间系结之下。
import * as counter from \'./counter.js\'
counter.increment()
counter.increment()
console.log(counter.default) // 动态import()
有人提出过有关动态import() 表示式的提案(阶段3)。与import 语句的静态分析和连结不同,import() 在执行时载入模组,并在获取、解析并执行请求的模组及其所有依赖后,返回一个包含该模组名称空间物件的Promise。
与import 语句类似,此时的模组说明符可以是任意字串。但与import 只允许静态的字串字面量作为模组说明符不同,import() 的模组说明符可以是模板字面量或任何能生成模组说明符字串的有效JavaScript 表示式。
假设我们正在国际化某个应用,需要根据使用者代理的语言偏好载入相应的语言包。我们可以先汇入localizationService,然后再通过import() 及根据插入navigator.language 的模板字面量构造的模组说明符来实现本地化资料的动态载入,如下所示。
import localizationService from \'./localizationService.js\'
import(`./localizations/${ navigator.language }.json`)
.then(module => localizationService.use(module))
注意,通常并不建议将程式码写成以上那样,原因如下。
• 对静态分析不友好,因为静态分析是在构建时执行的,所以几乎不可能推断出${ navigator.language } 这样插值的结果。
• JavaScript 打包工具也很难对其进行打包,结果很可能是应用载入完成后再异步载入这个模组。
• Rollup 等工具很难对其进行摇树优化,难以消除并未汇入(因而永远不会用到)的模组程式码,因此也就难以缩小包并提升效能。
• 不利于辅助检查模组汇入语句中要汇入的档案是否缺失的工具(如eslint-plugin-import)发挥作用。
与import 语句类似,规范也没有说明import() 获取模组的方式,因此就要看宿主环境了。
但提案说明了模组被成功解决后,Promise 应该获取解析后的名称空间物件。同时该提案指出,如果发生错误导致模组载入失败,那么Promise 应该被拒绝。
对于不那么重要的模组,我们可以在不阻塞页面载入的情况下进行异步载入,同时还可以在模组载入失败时恰当地处理,如下所示。
import(\'./vendor/jquery.js\')
.then($ => {
// 使用jQuery
})
.catch(() => {
// 载入jQuery失败
})
我们可以用Promise.all 异步载入多个模组。以下示例同时汇入了3 个模组,然后在.then子句中直接用解构获取了对它们的引用。
const specifiers = [
\'./vendor/jquery.js\',
\'./vendor/backbone.js\',
\'./lib/util.js\'
]
Promise
.all(specifiers.map(specifier => import(specifier)))
.then(([$, backbone, util]) => {
// 使用模组
})
同样,我们可以用同步循环或async/await 来载入模组,如下所示。
async function load() {
const { map } = await import(\'./vendor/jquery.js\')
const $ = await import(\'./vendor/jquery.js\')
const response = await fetch(\'/cats\')
const cats = await response.json()
$(\'\')
.addClass(\'container cats\')
.html(map(cats, cat => cat.htmlSnippet))
.appendTo(document.body)
}
load()
await import() 让动态汇入模组看起来像静态的import 语句。我们自己心里必须明白,这里其实是一个接一个地异步载入多个模组。
注意,虽然import() 有点像函式,但语义与常规函式不同。这里import 并非函式定义,不能进行扩充套件、不能给它新增属性,也不能对它使用解构语法。从这个意义上说,import()更像是类构造器中的super() 呼叫。
ES模组的实践考量
无论使用什么模组系统,我们都可以做到公开API 并同时隐藏资讯。这种完美的资讯隐藏正是以前的开发者梦寐以求的特性。那时候,要想实现同样的功能,必须非常熟悉JavaScript 的作用域规则,否则就得盲目地循环某种模式,如下所示。这个示例使用区域性作用域的calc 函式建立了一个random 模组,该函式负责生成一个区间为[0, n) 的随机数,而公共API 中包含range 方法,该方法可以计算一个[min, max] 范围内的随机数。
const random = (function() {
const calc = n => Math.floor(Math.random() * n)
const range = (max = 1, min = 0) => calc(max + 1 - min) + min
return { range }
})()
比较以上程式码与以下名为random 的ESM 模组中的程式码。你会发现,立即呼叫函式表示式(IIFE,immediately invoked function expression)的包装不见了,模组的名字也不见了。这里模组的名字已经变成了档名。我们又回到了以前在HTML 的 标签内编写原始JavaScript 程式码的日子。
const calc = n => Math.floor(Math.random() * n)
const range = (max = 1, min = 0) => calc(max + 1 - min) + min
export { range }
虽然没有用IIFE 来包装程式码的问题了,但如何定义、测试、说明和使用模组仍然需要认真思考。
决定模组中包含什么内容并不容易。需要考虑的因素非常多,以下列举了其中一部分。
• 过于复杂吗?
• 过大了吗?
• API 有没有明确的含义?
• API 有没有完善的文件?
• 为这个模组编写测试是否简单?
• 为这个模组增加新特性难不难?
• 删除模组中的特性困难吗?
相较于模组长度,复杂性是需要考量的首要指标。一个模组可能有几千行程式码但很简单,比如将档案说明符对映为特定语言字串的字典;模组也可能只有几十行程式码却非常难以理解,比如涉及域名验证和其他业务逻辑规则的资料模型。我们可以将程式码拆分成更小的模组以降低复杂性,每个模组只专注于解决问题的某一方面。只要不是过于复杂,大模组也不是什么大问题。
明确定义的API 同时配有完善的文件也是优秀模组化设计的关键。模组的API 应该聚焦,遵循资讯隐藏原理。换句话说,只对模组使用者暴露必要的东西。通过隐藏模组的内部实现,即使模组程式码缺乏注释和文件,或者将来再被修改,我们仍然可以从整体上保持界面简单,避免意外的调用出现。通过给公开的API 编写完善的文件,即使这些文件是写在程式码中的注释,抑或程式码本身就可以自解释,我们可以降低模组使用者的认知门槛。
测试应该只针对模组的公开界面来编写,模组的内部实现应该看作无关紧要的实现细节。测试要覆盖模组公开界面的不同方面,只要API 的输入和输出不变,对内部实现的修改就不应该影响测试覆盖率。
同样,为模组增加或减少特性的容易性也是需要考量的一个因素。
• 新增一个新特性有多难?
• 为实现某个逻辑是不是必须修改几个不同的模组?
• 这个流程是不是重复了很多次?或许我们可以将那些变化抽象到一个高层模组,以隐藏复杂性,也许这样做很大程度上只是引入了一个中间层,虽然有一些好处或改进,却导致程式码更难理解了。
• 从另一方面看,这个API 有多么不容易改动?
• 删除模组的一部分、完全删除,或用其他逻辑代替它是不是很容易?
• 如果模组之间的依赖度很高,那程式码年代越久远,改版次数越多,程式码量越大,修改就越困难。
浏览器实现的功能只是原生JavaScript 模组的一点皮毛。现在,有的浏览器已经实现了import 和export 语句。有的浏览器已经实现了,通过指定module指令码型别来使用模组。模组载入器规范还未最终制定完成,其最新进展参见https://github.com/whatwg/loader#implementation-status。
在此期间,Node.js 释出的新版本还没有包含JavaScript 模组系统的实现。考虑到JavaScript生态系统中的工具都依赖Node,到底能实现多大程度的相容还说不清楚。实现迟迟不能推出的原因是,目前还无法确定一个档案是CommonJS 模组还是ESM 模组。根据档案中至少存在一个import 或export 语句来判断它是否为ESM 模组的提案最终被废弃了。
目前的做法是准备为ESM 模组专门引入一个新副档名。鉴于执行Node.js 的平台及使用场景具有多样性,这里要考虑的细节非常庞杂。得到一个优雅、完美、正确的方案是非常难的。
——本文内容选自《深入理解JavaScript特性》。一本由JavaScript之父作序推荐,360资深前端精心翻译的著作!
它旨在让你轻松学习JavaScript的新进展,包括ES6及后续更新。
书中提供了大量实用示例,以循序渐进的方式讲解了异步函式、物件解构、动态汇入及异步生成器等内容,并从实践角度提供了许多建议,既能帮助广大前端开发者建立一个完整的知识体系,也能助其在工作中如虎添翼,开发出更好的Web应用。
书中不仅介绍了箭头函式、解构、模板字面量以及其他语法方面的新元素,还全面展示了ES6引入的流程控制机制,以及如何高效地简化自己的程式码。本书的讨论还涉及ES6内建的新集合型别、使用代理控制属性访问、ES6中内建API的改进、CommonJS与ECMAScript模组的互用性等方面。
“尼古拉斯写的东西特别实用……建议你好好读读,从中发现对自己有用的东西,进而真正拥抱JavaScript,致力于为所有人开发更好的Web应用。”——Brendan Eich,JavaScript之父
“本书全面介绍了ES6新特性的语法和语义,有助于你大幅度提升程式码的表达能力。作者把这些特性融入简单易懂的示例中,帮你快速上手。”——Kent C. Dodds,PayPal前端工程师,TC39成员
——
【图灵教育】
阅读改变世界,阅读塑造人生
让我们站在巨人的肩膀上,解锁更多IT技能!