[ES6深度解析]15:模块 Module

JavaScript项目已经发展到令人瞠目结舌的规模,社区已经开发了用于大规模工作的工具。你需要的最基本的东西之一是一个模块系统,这是一种将你的工作分散到多个文件和目录的方法——但仍然要确保你的所有代码片段可以根据需要相互访问——而且还要能够有效地加载所有代码。所以很自然,JavaScript有一个模块系统。实际上,有不少模块系统。还有一些包管理器,用于安装所有这些软件和处理高级依赖关系的工具。你可能会认为,拥有新的模块语法的ES6有点晚了。

今天我们将看到ES6是否会在这些现有系统中添加任何东西,以及未来的标准和工具是否能够在它的基础上构建。但首先,让我们深入了解一下ES6模块是什么样子的。

Module 基础知识

ES6模块是一个包含JS代码的文件。没有特殊的module关键字;模块读起来就像脚本。有两个区别。

  • ES6模块是自动的严格模式代码,即使你没有写use strict
  • 可以在模块中使用importexport

让我们先谈谈export。默认情况下,在模块中声明的所有内容都是该模块的局部内容。如果你希望在模块中声明的某些特性是公共的,以便其他模块可以使用它,则必须export该特性。有几种方法可以做到这一点。最简单的方法是添加export关键字。

// kittydar.js - Find the locations of all the cats in an image.

export function detectCats(canvas, options) {
  var kittydar = new Kittydar(options);
  return kittydar.detectCats(canvas);
}

export class Kittydar {
  ... several methods doing image processing ...
}

// This helper function isn't exported.
function resizeCanvas() {
  ...
}

可以export任何顶级functionclassvarletconst

这就是编写模块所需要知道的全部内容!你不需要把所有的东西都放到IIFE回调中。去声明你需要的东西吧。由于代码是一个模块,而不是一个脚本,所以所有的声明都将作用域限定在该模块,而不是在所有脚本和模块中全局可见

除了export之外,模块中的代码基本上都是普通代码。它可以使用全局变量,如ObjectArray。如果您的模块在web浏览器中运行,它可以使用documentXMLHttpRequest

在一个单独的文件中,我们可以导入并使用detectCats()函数:

// demo.js - Kittydar demo program

import {detectCats} from "kittydar.js";

function go() {
    var canvas = document.getElementById("catpix");
    var cats = detectCats(canvas);
    drawRectangles(canvas, cats);
}

要从一个模块中导入多个名称,可以这样写:

import {detectCats, Kittydar} from "kittydar.js";

当你运行包含import声明的模块时,它首先加载所导入的模块,然后在依赖关系图的深度优先遍历中执行每个模块主体,通过跳过已经执行的任何内容来避免循环遍历。这些是模块的基础。这真的很简单。;-)

export 列表

你可以用花括号{}括起来,列出你想要导出的所有(方法,变量,类等)名称,而不是给每个导出的特性加上标签:

export {detectCats, Kittydar};

// no `export` keyword required here
function detectCats(canvas, options) { ... }
class Kittydar { ... }

export列表不一定是文件中的第一项内容;它可以出现在模块文件的顶级作用域中的任何地方。您可以有多个export列表,或者将export列表与其他export声明混合在一起,只要没有名称被多次重复导出。

重命名导入和导出

偶尔,导入的名称会与你需要使用的其他名称发生冲突。所以ES6允许你在导入时重命名:

// suburbia.js

// Both these modules export something named `flip`.
// To import them both, we must rename at least one.
import {flip as flipOmelet} from "eggs.js";
import {flip as flipHouse} from "real-estate.js";
...

类似地,您可以在导出时重命名它们。如果你想在两个不同的名称下导出相同的值,这是很方便的,这偶尔会发生:

// unlicensed_nuclear_accelerator.js - media streaming without drm
// (not a real library, but maybe it should be)

function v1() { ... }
function v2() { ... }

export {
  v1 as streamV1,
  v2 as streamV2,
  v2 as streamLatestVersion
};

默认导出

新标准旨在与现有的CommonJSAMD模块互操作。假设你有一个Node项目,你已经完成了npm install lodash。你的ES6代码可以从Lodash中导入单独的函数:

import {each, map} from "lodash";

each([3, 2, 1], x => console.log(x));

但也许你已经习惯看到_.each,而不是each,你仍然想要这样写。或者你把_作为函数使用,因为这在Lodash中很常用。为此,你可以使用稍微不同的语法:导入没有花括号的模块

import _ from "lodash";

这种简写等价于import {default as _} from "lodash"。所有的CommonJS和AMD模块在ES6中都有一个default export,这和你在require()函数中调用该模块时得到的是一样的,也就是exports对象

ES6模块被设计成允许你导出多个东西,但是对于现有的CommonJS模块,你只能得到默认的导出。例如,在撰写本文时,据我所知,著名的colors包没有任何特殊的ES6支持。它是CommonJS模块的集合,就像npm上的大多数包一样。但是你可以直接导入到你的ES6代码中。

// ES6 equivalent of `var colors = require("colors/safe");`
import colors from "colors/safe";

如果你想要自己的ES6模块有一个默认的导出,这很容易做到。默认导出没有什么魔力;它就像任何其他导出一样,除了它被命名为default。你可以使用我们已经讨论过的重命名语法:

let myObject = {
  field1: value1,
  field2: value2
};
export {myObject as default};

或者更好的做法是,使用以下简写:

export default {
  field1: value1,
  field2: value2
};

关键字export default后面可以跟任何值:函数、类、对象字面量。

模块对象

import * as cows from "cows";

当你import *时,所导入的是一个模块名称空间对象(module namespace object)。它的属性是模块的exports。因此,如果cows模块导出了一个名为moo()的函数,那么在以这种方式导入cows之后,你可以写:cows.moo()

聚合模块

有时候,一个包的主模块比导入包的所有其他模块并以统一的方式导出它们大不了多少。为了简化这类代码,有一种一体化的import-and-export简写:

// world-foods.js - good stuff from all over

// import "sri-lanka" and re-export some of its exports
export {Tea, Cinnamon} from "sri-lanka";

// import "equatorial-guinea" and re-export some of its exports
export {Coffee, Cocoa} from "equatorial-guinea";

// import "singapore" and export ALL of its exports
export * from "singapore";

每个export-from语句都类似于import-from语句后跟export语句。与真正的导入不同,它不会将重新导出的绑定添加到您的作用域。因此,如果你打算在world-food.js中编写一些利用Tea的代码,请不要使用这种简写。你会发现它并不存在。

如果singapore输出的任何名称与其他输出名称发生冲突,那将是一个错误,因此要小心使用export *

语法已经讲完了!现在说说有趣的部分。

import实际上是做什么的?

你会相信…什么都不没做吗?

哦,你没那么容易上当。你能相信标准并没有说import到底做了哪些事情吗?这是件好事吗?

ES6将模块加载的细节完全留给了实现。模块执行方式是详细指定的。粗略地说,当你告诉JS引擎运行一个模块时,它必须表现得像以下四个步骤正在发生:

  1. 解析(Parsing):
    实现读取模块的源代码并检查语法错误。

  2. 加载(Loading):
    实现加载所有导入的模块(递归地)。这部分还没有标准化。

  3. 链接(Linking):
    对于每个新加载的模块,实现创建一个模块作用域,并用该模块中声明的所有绑定填充它,包括从其他模块导入的内容。
    如果你试图import {cake} from "paleo",但是paleo模块实际上没有导出任何名为cake的东西,你会得到一个错误。这太糟糕了,因为你离真正运行一些JS代码已经很近了。

  4. 运行时(Runtime):
    最后,实现运行每个新加载模块的代码体中的语句。此时,导入处理已经完成,所以当执行到有import声明的代码行时……什么也没有发生!

看到了吗?我告诉过你答案是”import什么都没做”,关于编程语言,我没有撒谎。

现在我们来看看这个系统中有趣的部分。有一个很酷的技巧。因为系统不指定加载是如何工作的,因为你可以提前通过查看源代码import声明算出所有的依赖关系,一种加载的实现方式是在编译时完成所有的工作,你所有的模块打包成一个文件,并把它放在网络上传输!像webpack这样的工具可以做到这一点。

这是一件大事,因为通过网络加载脚本需要花费时间,而且每次获取一个脚本时,您可能会发现它包含需要加载几十个以上的导入声明。一个简单的加载器将需要大量的网络往返通讯。但是有了webpack,你现在不仅可以使用带有模块的ES6,还可以在不影响运行时性能的情况下获得所有的软件工程好处。

ES6中模块加载的详细规范最初是计划并构建的。它没有出现在最终标准中的一个原因是,对于如何实现这个捆绑特性没有达成共识。我希望有人能解决这个问题,因为我们将看到,模块加载确实应该标准化。捆绑销售太好了,不能放弃。

静态 vs 动态,或者:规则以及如何打破规则

作为一种动态语言,JavaScript让自己拥有了一个令人惊讶的静态模块系统。

  • 在一个模块中,所有类型的导入和导出都只允许在顶层。没有条件导入或导出,并且不能在函数内使用导入。
  • 所有导出的标识符必须在源代码中按名称显式导出。你无法通过编程方式遍历数组并以数据驱动的方式导出一组名称。
  • 模块对象被冻结(无法修改)。没有办法将一个新特性hack到一个模块对象中,polyfill风格。
  • 在任何模块代码运行之前,模块的所有依赖项都必须被加载、解析和链接。没有语法可以实现按需惰性加载的导入
  • 导入发生错误没有错误恢复。一个应用程序可能包含数百个模块,如果有任何模块无法加载或链接,就无法运行。不能把import包裹在try/catch块中。(这里的好处是,因为系统是静态的,所以webpack可以在编译时检测到这些错误。)
  • 没有钩子允许模块在依赖项加载之前运行一些代码。这意味着模块无法控制它们的依赖项是如何加载的。

只要你的需求是静态的,系统就相当不错。但你难免有时候需要做一点定制,对吧?

这就是为什么无论你使用什么模块加载系统,都会有一个编程API来配合ES6的静态import/export语法。例如,webpack包含一个API,你可以用它来“分割代码”,按需惰性加载一些模块包。同样的API可以帮助您打破上面列出的大多数规则。

ES6模块语法是非常静态的,这很好——它以强大的编译时工具的形式得到了回报。但是这种静态语法被设计为与丰富的动态、程序化加载器API一起工作。