使用 npm 和 ES6 模块进行前端开发
2015年12月20日

伴随着 JavaScript 世界日新月异的变化,我们和我们的网站以及应用中的各种依赖文件打交道的方式也在发生着变化。

这篇文章适合于那些目前仍然用多个 script 标签加载 Javascript,并发现随着他们的网页数量或应用规模扩大,依赖管理变得麻烦的开发人员。

如果想要更深入地了解 ES6 模块规范的内容,以及它与 CommonJS 和 AMD 之间的区别,请查看 Axel Rauschmayer 著作的书籍 Exploring ES6 ,尤其是第十七章的内容。

什么是 JavaScript 模块?

JavaScript 模块让我们可以把项目代码拆分成一个个孤立的文件,或者通过 npm 工具安装开源模块。按照模块化规范编写代码,有助于我们组织、维护、测试代码,最重要的是,有利于管理依赖关系。

当我们编写 JavaScript 代码的时候,如果我们可以让每一个模块只做一件事并把这件事做好,这样是最理想的。这种分离可以让我们仅在有需求的时候才会下载各种各样的模块。模块代表了 npm 工具背后的核心理念,当我们需要特定功能的时候,我们可以安装需要的模块并将它们加载到我们的应用中。

随着时间的推移,我们将看到越来越少的功能完善的大框架,而是会看到越来越多的具备良好单一功能的小模块横空出世。

例如,我们许多人都学过 jQuery。它包含了可以完成从 CSS 操作到 ajax 调用的一切事情的方法。现在,许多人正迁移到类似 React 的 javascript 库,使用这些库,我们经常需要下载其它的软件包来完成 ajax 或路由的工作。

这篇文章将看一下如何使用 npm 和 ES6 模块。虽然还有其它的工具(如,Bower)和模块加载器(如,CommonJS 和 AMD),但是已经有大量的文章介绍它们了。

不管你是做 Node 开发还是前端开发,我相信 ES6 模块和 npm 组合是未来的方向。放眼望去,今天任意一个流行的开源项目,比如说 React 或者 lodash,你会发现它们已经采用了 ES6 模块 + npm 这对搭档。

当前工作流

许多 JavaScript 工作流看起来像这样:

  1. 找到一个你需要的插件或库并把它从 GitHub 上下载下来
  2. 通过一个 script 标签把它加载到你的网站中
  3. 借助一个全局变量或作为一个 jQuery 插件访问它

多年以来,这种工作流运作的很棒,但并不是没有问题:

  1. 必须手动更新这些插件 —— 导致很难知道有严重的 bug 修复或者有可用的新功能

  2. 混乱的源码版本控制历史 —— 所有依赖文件都需要加到源码控制当中,当库文件更新的时候,一切变得乱七八糟

  3. 几乎无依赖管理 —— 许多脚本功能重复。但是如果采用一个 js 模块,其实就可以很容易地实现功能共享

  4. 全局命名空间中的命名污染和冲突

编写 Javascript 模块的想法并不新鲜,但随着 ES6 的带来和业内认定 npm 为 Javascript 首选的包管理工具,我们刚开始看到许多开发者远离上述老的工作流,而开始致力于 ES6 和 npm 的标准化。

等等,npm 不是专门服务 Node 的吗?

Many moons ago, npm began as the package manager for Node.js, but it has since evolved to become the package manager for JavaScript and front end dev in general. Nowadays, we can cut the whole song and dance for installing libraries down to 2 steps:

多年以前,npm 是作为 Node.js 的包管理器诞生的,但是,目前它已经逐渐发展成为 Javascript 和前端开发的首选软件包管理器。如今,我们可以把安装软件包的过程缩减为两个步骤:

  1. 通过 npm 安装依赖软件包,例如:npm install lodash --save

  2. 导入依赖到需要的文件,例如:

import _ from 'lodash';

关于工作流还有很多东西需要我们研究,也大量的关于导入和导出模块的知识需要学习,所以就让我们一起来探究一下吧。

模块背后的理念

我们使用 import 和 export 语句在文件之间共享资源(变量、函数、数据等),而不是像原来那样把所有东西都加载到全局命名空间。每个模块将导入自己需要的依赖,并导出其它文件需要导入的资源。

让所有代码能在当前浏览器中顺利运行起来需要一个打包的步骤。稍后我们会讨论这个话题,但现在我们重点关注一下 Javascript 模块背后的核心理念。

创建你自己的模块

比方说我们将建立一个线上商店应用,我们需要一个文件托管所有的 helper 函数。这样我们就可以创建一个名为 helper.js 的模块文件,其中包含了若干个 helper 函数 —— formatPrice(price)、addTax(price) 和 discountPrice(price, percentage),以及一些针对线上商店本身的变量。

我们的 helper.js 文件看起来是这样:

const taxRate = 0.13;

const couponCodes = ['BLACKFRIDAY', 'FREESHIP', 'HOHOHO'];

function formatPrice(price) {
    // .. do some formatting
    return formattedPrice;
}

function addTax(price) {
    return price * (1 + taxRate);
}

function discountPrice(price, percentage) {
    return price * (1 - percentage);
}

现在,每个文件都包含自己的本地函数和变量,除非显式地导出这些函数和变量,要不然他们决不会流入到其他文件的作用域。在上面的例子中,我们可能不需要 taxRate 变量用于其它模块,但是我们需要在这个模块中使用这个变量。

我们怎样让上面的函数和变量可供其它模块使用呢?我们需要导出它们。在 ES6 中有两种导出途径 —— 命名导出和单一默认导出。因为我们要导出多个函数和 couponCodes 变量,我们采用命名导出。稍后有更多解释。

从模块中导出数据,最简单也最直接了当的方式是在前面添加一个 export 关键字,如下所示:

const taxRate = 0.13;

export const couponCodes = ['BLACKFRIDAY', 'FREESHIP', 'HOHOHO'];

export function formatPrice(price) {
    // .. do some formatting
    return formattedPrice;
}

我们也可以这样在后面导出它们:

export couponCodes;
export formatPrice;
export addTax;
export discountPrice;

或者一次全部导出:

export { couponCodes, formatPrice, addTax, discountPrice };

还有其它几个导出方法,当你使用的导出方式出现问题的时候,请查看 MDN 文档

默认导出

正如之前我们所提到的,有两种途径可以从模块中导出数据 —— 命名(named)或默认(default)。上面都是命名导出方式的实例。为了导入数据到其它模块,我们必须知道要导入变量或函数的名字 —— 演示实例马上就到。使用命名导出方式的好处是你可以从一个模块文件中导出多个条目。

另一种导出方式是默认导出(default export)。当模块需要导出多个变量/函数的时候,使用命名导出;当模块只需要导出一个变量/函数的时候,使用默认导出。虽然你可以在一个模块文件中同时使用默认导出和命名导出,但是我建议你每个模块文件只用一种导出方式。

默认导出的例子可能是一个名为 StorePicker 的 React 组件或者是一个数组。例如,如果我们需要把下面一个数组导出给其它组件,我们采用默认导出方式。

// people.js
const fullNames = ['Drew Minns', 'Heather Payne', 'Kristen Spencer', 'Wes Bos', 'Ryan Christiani'];

const firstNames = fullNames.map(name => name.split(' ').shift());

export default firstNames; // ["Drew", "Heather", "Kristen", "Wes", "Ryan"]

正如上面,你可以在要导出的函数前面加上 export default 字样:

export default function yell(name) { return `HEY ${name.toUpperCase()}!!` }

导入你自己的模块

现在我们已经把代码分割成了一个个的小模块并导出了所需的模块,接下来我们要在应用中的其它部分导入这些模块。

为了导入自己代码库中的模块,我们使用一个 import 语句,然后指向一个文件路径,相对于当前模块所在路径 —— 就像你使用任何 HTML 源文件或 CSS 背景图像的路径一样。你会注意到我们去掉了 .js 文件扩展名,因为它不是必需的。

需要注意的是,我们没有一次导入所有的模块供整个应用使用,而是当一个模块依赖于另一个模块的时候 —— 比如上述代码需要一个 lodash 方法 —— 我们才导入这个方法到需要它的模块。如果有5个模块需要同样的 lodash 函数,我们就导入它5次。这有利于维持一个清晰的作用域,也使得模块具有非常好的可移植性和可重用性。

导入 named exports (命名导出)

首先我们要导出我们的 helper 模块。这里我们使用了命名导出,所以我们有多种方法导出它们:

// 把所有内容作为一个对象的方法或者属性导入:
import * as h from './helpers';
// 使用它们
const displayTotal = h.formatPrice(5000);


//导入所有内容到当前模块作用域:
import * from './helpers';
const displayTotal = addTax(1000);
// I'd recommend against this style because it's less explicit
// and could lead to code that's harder to maintain


// 或者只是挑出你想要的:
import { couponCodes, discountPrice } from './helpers';
const discount = discountPrice(500, 0.33);

导入 default exports (默认导出)

如果你记得,我们也从 people.js 文件导出了一个姓名数组,这是唯一需要从该模块导出的数据。

默认导出方式可以把数据导出为任意名称 —— 所以没有必要知道要导出的变量、函数或类的名字。

import firstNames from './people';
// 或者
import names from './people';
// 或者
import elephants from './people';
// 上面这些方式都可以正确导入

// 你也可以这样来获取默认导出:
import * as stuff from './people'
const theNames = stuff.default

导入 npm 模块

我们用到的许多模块来自 npm 仓库。不管我们需要一个类似 jQuery 的功能完备的库 ,还是一些由 lodash 提供的实用函数,或者执行 Ajax 请求类似 superagent 的库,我们都可以使用 npm 安装它们。

npm install jquery --save
npm install lodash --save
npm install superagent --save
// 或者一次全搞定:
npm i jquery lodash superagent -S

一旦这些软件包安装到 node_modules/ 目录下,我们就可以把它们导入到我们的代码中。默认情况下,Babel 会把 ES6 导入语句转译为 CommonJS。因此,通过使用一个明白该模块语法的打包工具(如 webpack 或 browserify),你可以很好的利用起 node_modules/ 目录。所以我们的导出语句只需要包含 Node 模块的名字。其它的打包工具可能需要插件或配置一下,才能使用 node_modules/ 目录的模块。

// 导入整个库或者插件
import $ from 'jquery';
// 这样就可以按照我们想要的方式来使用它们了:
$('.cta').on('click',function() {
    alert('Ya clicked it!');
});

上述代码能够工作是因为 jQuery 被作为一个 CommonJS 模块导出来了,并且 Babel 转译我们的 ES6 导出语句,让其与 jQuery 的 CommonJS 导出配合工作。

让我们再试试 superagent。和 jQuery 一样,superagent 使用 CommonJS 按照默认导出的方式导出了整个库,所以我们可以把它导出为任意我们喜欢的变量名 —— 通常称之为 request。

// 把模块导入到我们自己的模块中
import request from 'superagent';
// 现在就来用一下:
request
    .get('https://api.github.com/users/wesbos')
    .end(function(err, res){
        console.log(res.body);
    });

只导入需要的片段

一个我最喜欢的 ES6 模块的功能是许多库让你可以有选择的只去导入你需要的功能。lodash 是一个奇妙的工具库,它提供了很多有用的 Javascript 方法。

We can load the entire library into the _ variable since lodash exports the entire library as a the main module export (again, Babel transpiles our import to treat it as if lodash is using export default):

我们可以加载整个库到 _ 变量中,因为 lodash 模块的主导出是把整个库导出来(依旧,Babel 可以转译我们的导入,把它看作就像 lodash 使用了 export default)

// 把整个库都导入到 _ 变量中
import _ from 'lodash';
const dogs = [
  { 'name': 'snickers', 'age': 2, breed : 'King Charles'},
  { 'name': 'prudence', 'age': 5, breed : 'Poodle'}
];

_.findWhere(dogs, { 'breed': 'King Charles' }); // snickers object

然而,很多时候你只需要一个或两个的 lodash 方法而不是整个 lodash 库。因为 lodash 已经把每一个方法作为单独的模块导出,所以我们可以只选择需要的方法。由于 Babel 可以转译你的导入语句,所以一起都不成问题。

import { throttle } from 'lodash';
$('.click-me').on('click', throttle(function() {
  console.count('ouch!');
}, 1000));

打包过程

因为浏览器还不能理解 ES6 模块,所以我们需要借助工具让 ES6 模块生效。一个 JavaScript 打包工具会把我们的模块编译成一个 JavaScript 文件或者多个包,以供我们应用的不同部分使用。

未来,我们将不再需要运行 bundler,HTTP/2 可以一次请求所有的 import 语句。

目前,流行的打包工具有几个,其中多数依赖于 Babel 把 ES6 模块转译为 CommmonJS 模块。

你应该选用哪个打包工具呢?哪个工具最适合你呢?我最钟爱 Browserify,因为它容易上手使用,和 webpack,因为它配合 React 使用很方便。编写 ES6 模块的好处就在于你不是在编写 Browserify 或 webpack 模块 —— 你可以随时更换你的打包工具。关于使用什么打包工具这个话题,网上有太多的看法,快速搜索一下,你会发现针对每个工具都有大量的争论。

如果你已经在使用 gulp、Grunt 或 npm tasks 构建任务,运行你的 JavaScript 和 CSS 代码,把模块集成到你的项目中是相当简单的事情。

有许多不同的方式可以实现一个 bundler —— 你可以写一个 gulp 任务,配置一下 webpack,写一个 npm 脚本,或者直接执行命令

我创建了一个仓库,详细说明了 webpack 和 Browserify 如何配合使用,还有一些样本模块供你把玩。

导入非模块化的代码

如果你正在把项目代码分割成一个个模块,但是又不能一次完成,这时你就可以通过 import "filename" 语句,加载未模块化的代码。这确实不是 ES6 的功能,是你使用的打包器( bundler )提供的功能。

This concept is no different than running concatenation on multiple .js files except that the code you import will be scoped to the importing module. 其实就跟直接合并这些 .js 文件没什么不同, 导入的代码的作用域就是在导入该代码的模块中,

需要全局变量的代码

一些软件库,例如 jQuery 插件,不符合 JavaScript 模块系统。整个 jQuery 插件生态系统假定有一个名为 window.jQuery 全局变量,每一个插件都可以挂载到它上面。然而,我们刚刚知道 ES6 模块中没有全局数据。一切数据都限制在自身所在的模块当中,除非你显式地设置全局数据。

为了解决这个问题,首先问问你自己是否真的需要那个插件,是否可以自己编码实现这个功能。为了脱离对 jQuery 的依赖,许多 JavaScript 插件正在被重新编写为独立的 JavaScript 模块。

如果不能自己编码实现,你需要借助项目构建工具来帮助解决这个问题。可以参考 Browserify 的 Browserify Shimwebpack 的一些文档。

陷阱

当导出一个函数的时候,不要在函数的末尾添加分号。虽然许多打包工具仍然允许这种写法,但是一个良好的编程习惯是不要在函数声明的末尾添加分号,这样做是为了,当你转换打包工具的时候,不会遇到出乎意料的情况。

// 错误:
export function yell(name) { return `HEY ${name}`; };
// 正确:
export function yell(name) { return `HEY ${name}`; }

进一步阅读

希望这是一篇介绍使用 npm 和 ES6 模块的好文章。显然还有很多关于 npm 和 ES6 模块的知识点需要我们去学习。在我看来,最好的学习方式是开始在你的下一个项目中动手使用它们。下面我列举了一些很优秀的资源,可以帮助你学习: