此文为翻译,原文地址在这儿:https://hacks.mozilla.org/2015/08/es6-in-depth-modules/

ES6 是 ECMAScript 第 6 版本的简称,这是新一代的 JavaScript 的标准。ES6 in Depth 是关于 ES6 的一系列新特性的介绍。

遥想 2007 年,笔者开始在 Mozilla 的 JavaScript 团队工作的时候,那个时候典型的 JavaScript 程序只有一行代码。

两年之后, Google Map 被发布。但是在那之前不久,JavaScript 的主要用途还是表单验证,当然啦,你的<input onchange=>处理器平均来说只有一行。

事过情迁,JavaScript 项目已经变得十分庞大,社区也发展出了一些有助于开发可扩展程序的工具。首先你需要的便是模块系统。模块系统让你得以将你的工作分散在不同的文件和目录中,让它们之前得以互相访问,并且可以非常有效地加载它们。自然而然地,JavaScript 发展出了模块系统,事实上是多个模块系统(AMD,CommonJS,CMD,译者注)。不仅如此,社区还提供了包管理工具(NPM,译者注),让你可以安装和拷贝高度依赖其他模块的软件。也许你会觉得,带有模块特性的 ES6,来得有些晚了。

模块基础

一个 ES6 的模块是一个包含了 JS 代码的文件。ES6 里没有所谓的 module 关键字。一个模块看起来就和一个普通的脚本文件一样,除了以下两个区别:

  • ES6 的模块自动开启严格模式,即使你没有写 'use strict'

  • 你可以在模块中使用 importexport

让我们先来看看 export。在模块中声明的任何东西都是默认私有的,如果你想对其他模块 Public,你必须 export 那部分代码。我们有几种实现方法,最简单的方式是添加一个 export 关键字。

// kittydar.js - Find the locations of all the cats in an image.
// (Heather Arthur wrote this library for real)
// (but she didn't use modules, because it was 2013)

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() {
  ...
}
...

你可以在 functionclassvarletconst 前添加 export

如果你想写一个模块,有这些就够了!再也不用把代码放在 IIFE 或者一个回调函数里了。既然你的代码是一个模块,而非脚本文件,那么你生命的一切都会被封装进模块的作用域,不再会有跨模块或跨文件的全局变量。你导出的声明部分则会成为这个模块的 Public API。

除此之外,模块里的代码和普通代码没啥大区别。它可以访问一些基本的全局变量,比如 ObjectArray。如果你的模块跑在浏览器里,它将可以访问 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 声明,只要没有变量名被重复导出即可。

重名命导出和导入

如果导入的变量名恰好和你模块中的变量名冲突了,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 或者看不见 _ 的话就浑身难受,当然这样使用 Lodash 也是不错的方式

这种情况下,你可以稍微改变一下你的 import 写法,不写花括号:

import _ from "lodash";

这个简写等价于 import {default as _} from "lodash";。所有 CommonJS 和 AMD 模块在被 ES6 代码使用的时候都已经有了默认的导出,这个导出和你在 CommonJS 中 require() 得到的东西是一样的,那就是 exports 对象。

ES6 的模块系统被设计成让你可以一次性引入多个变量。但对于已经存在的 CommonJS 模块来说,你能得到的只有默认导出。举个栗子,在撰写此文之时,据笔者所知,著名的 colors 模块并未特意支持 ES6。这是一个由多个 CommonJS 模块组成的模块,正如 npm 上的那些包。然而你依然可以直接将其引入到你的 ES6 代码中。

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

如果你想写自己的默认导出,那也很简单。这里面并没有什么高科技,它和普通的导出没什么两样,除了它的导出名是 default。你可以使用我们之前已经介绍过的语法:

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

这样更好:

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

export default 关键字后可以跟随任何值:函数,对象,对象字面量,任何你能说得出的东西。

模块对象

抱歉,这篇文章的内容有点多,但 JavaScript 已经算好的了:因为一些原因,所有语言的模块系统都有一大堆没什么卵用的特性。所幸的是,咱们只有一个话题要讨论了,呃,好吧,两个。

import * as cows from "cows";

当你 import *,被引入进来的是一个 module namespace object。它的属性是那个模块的导出,所以如果 “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 的表达式和后面跟了一个 exportimport-from 表达式类似。但和真正的导入不同,它并不会在你的作用域中加入二次导出的变量绑定。所以如果你打算在 world-foods.js 写用到了 Tea 的代码,就别使用这个简写形式。

如果 "singapore" 导出的某一个变量恰巧和其他的导出变量名冲突了,那么这里就会出现一个错误。所以你应该谨慎使用 export *

Whew!我们介绍完语法了,接下来进入有趣的环节。

import 到底干了啥

啥也没干,信不信由你。

噢,你好像看起来没那么好骗。好吧,那你相信标准几乎没有谈到 import 该做什么吗?你认为这是一件好事还是坏事呢?

ES6 将模块的加载细节完全交给了实现,其余的执行部分则规定得非常详细

大致来说,当 JS 引擎运行一个模块的时候,它的行为大致可归纳为以下四步:

  1. 解析:引擎实现会阅读模块的源码,并且检查是否有语法错误。

  2. 加载:引擎实现会(递归地)加载所有被引入的模块。这部分咱还没标准化。

  3. 链接:引擎实现会为每个新加载的模块创建一个作用域,并且将模块中的声明绑定填入其中,包括从其他模块中引入的。

当你尝试 import {cake} from "paleo" 但是 “paleo” 模块并没有导出叫 cake 的东西时候,你也会在此时得到错误。这很糟糕,因为你离执行 JS,品尝 cake 只差一步了!

  1. 执行:终于,JS 引擎开始执行刚加载进来的模块中的代码。到这个时候,import 的处理过程已经完成,因此当 JS 引擎执行到一行 import 声明的时候,它啥也不会干。

看到了不?我说了 import “啥也没干”,没骗你吧?有关编程语言的严肃话题,哥从不说谎。

不过,现在咱们可以介绍这个体系中有趣的部分了,这是一个非常酷的 trick。正因为这个体系并没有指定加载的细节,也因为你只需要看一眼源码中的 import 声明就可以在运行前搞清楚模块的依赖,某些 ES6 的实现甚至可以通过预处理就完成所有的工作,然后将模块全部打包成一个文件,最后通过网络分发。像 webpack 这样的工具就是做这个事情的。

这非常的了不起,因为通过网络加载资源是非常耗时的。假设你请求一个资源,接着发现里面有 import 声明,然后你又得请求更多的资源,这又会耗费更多的时间。一个 naive 的 loader 实现可能会发起许多次网络请求。但有了 webpack,你不仅可以在今天就开始使用 ES6,还可以得到一切模块化的好处并且不向运行时性能妥协。

原先我们计划过一个详细定义的 ES6 模块加载规范,而且我们做出来了。它没有成为最终标准的原因之一是它无法与打包这一特性调和。模块系统需要被标准化,打包也不应该被放弃,因为它太好了。

动态 VS 静态,或者说:规矩和如何打破规矩

作为一门动态编程语言,JavaScript 令人惊讶地拥有一个静态的模块系统。

  • importexport 只能写在顶级作用域中。你无法在条件语句中使用引入和导出,你也不能在你写的函数作用域中使用import

  • 所有的导出必须显示地指定一个变量名,你也无法通过一个循环动态地引入一堆变量。

  • 模块对象被封装起来了,我们无法通过 polyfill 去 hack 一个新 feature。

  • 在模块代码运行之前,所有的模块都必须经历加载,解析,链接的过程。没有可以延迟加载,惰性 import 的语法。

  • 对于 import 错误,你无法在运行时进行 recovery。一个应用可能包含了几百个模块,其中的任何一个加载失败或链接失败,这个应用就不会运行。你无法在 try/catch 语句中 import。(不过正因为 ES6 的模块系统是如此地静态,webpack 可以在预处理时就为你检测出这些错误)。

  • 你没办法 hook 一个模块,然后在它被加载之前运行你的一些代码。这意味着模块无法控制它的依赖是如何被加载的。

只要你的需求都是静态的话,这个模块系统还是很 nice 的。但你还是想 hack 一下,是吗?

这就是为啥你使用的模块加载系统可能会提供 API。举个栗子,webpack 有一个 API,允许你 “code splitting”,按照你的需求去惰性加载模块。这个 API 也能帮你打破上面列出的所有规矩。

ES6 的模块是非常静态的,这很好——许多强大的编译器工具因此收益。而且,静态的语法已经被设计成可以和动态的,可编程的 loader API 协同工作。

我何时能开始使用 ES6 模块?

如果你今天就要开始使用,你需要诸如 TraceurBabel 这样的预处理工具。这个系列专题之前也有文章介绍了如何使用 Babel 和 Broccoli 去生成可用于 Web 的 ES6 代码。那篇文章的栗子也被开源在了 GitHub 上。笔者的这篇文章也介绍了如何使用 Babel 和 webpack。

ES6 模块系统的主要设计者是 Dave Herman 和 Sam Tobin-Hochstadt,此二人不顾包括笔者在内的数位委员的反对,始终坚持如今你见到的 ES6 模块系统的静态部分,争论长达数年。Jon Coppeard 正在火狐浏览器上实现 ES6 的模块。之后包括 JavaScript Loader 规范在内的工作已经在进行中。HTML 中类似 <script type=module> 这样的东西之后也会和大家见面。

这便是 ES6 了。

欢迎大家对 ES6 进行吐槽,请期待下周 ES6 in Depth 系列的总结文章。

你可能感兴趣的文章

5 条评论

bububearAI 2015年10月06日

问博主如何运行ES6呢 我发现babel之后 import export 并不能用

+1 回复

欧雷 2016年04月25日

翻译用词错误的地方很多啊:「事过情迁」->「时过境迁」;「让它们之前得以互相访问」->「让它们之间得以互相访问」。

+1 回复

晓风well 2015年11月10日

翻译的非常不错

回复

PortWatcher 作者 2015年12月19日

你肯定哪里搞错了,脚手架的东西也没必要自己去写,clone 一个 starter 或者使用 yeoman 都行

回复

PortWatcher 作者 2015年12月19日

多谢

回复

载入中...
PortWatcher PortWatcher

1.8k 声望

发布于专栏

Jury Xiong's Blog

Know it, then hack it.

11 人关注

SegmentFault

一起探索更多未知

下载 App