【译】JavaScript 模块:从立即执行函数 ( IIFEs ) 到 CommonJS 再到 ES6 模块

3,673 阅读22分钟

原文地址:JavaScript Modules: From IIFEs to CommonJS to ES6 Modules
原文作者:Tyler McGinnis
译者:FrankCheung

我教授 JavaScript 给很多不同的人很长一段时间了。这门语言普遍最难懂的概念就是模块系统。当然,这是有原因的,因为模块在 JavaScript 中有着一个奇怪的历史。在这篇文章中,我们将重温这段历史,你将学习到过去的模块化方式,以更好地理解如今 JavaScript 模块的工作原理。

在我们学习怎么在 JavaScript 中创建模块之前,我们首先必须明白什么是模块以及它们存在的意义。现在请你环顾四周,所有你看到的稍微复杂一点的物体,都可能是使用能够组合起来的、又相对独立的小零件拼装起来的。

下面我们以一块手表为例子。

一块简单的腕表由成千上万个内部零件组成。对于如何和其他零件进行协同,每一个小零件都有一个特定的用途和清晰作用范围。将所有的零件放到一起就可以组装出一块完整的手表。我不是一个手表工程师,但是上述方法的好处清晰可见。

可复用性 ( Reusability )

再看一下上面的图表,留意一块表上使用了多少相同的小零件。通过将十分聪明的设计思想融入到模块化中,在手表设计的不同层面都可以复用相同的零件。这种可以复用零件的能力简化了生产流程,与此同时,我猜想也增加了收益。

可组合性 ( Composability )

上述图表是可组合性的一个很好的阐释。通过划分清楚每个内部零件的作用范围,就可以将不同的微小的、功能单一的零件组合起来,制造出一只功能完整的手表。

杠杆作用 ( Leverage )

设想一下整个制造流程。这个公司并不是在制造手表,而是在制造个别的手表零件。他们既可以选择由自己公司来生产,也可以选择将这项工作外包出去,利用其他工厂进行生产,这都没有问题。不管零件在哪里生产,最关键的一点是每一个零件最后能够组合起来形成一块手表即可。

独立性 ( Isolation )

要明白整个系统是困难的,因为一块手表是由不同的功能单一的小零件组合而成的,每个小零件都可以被独立地设计、制造或者修理。这种独立性允许在制造或者修理手表过程中,多人同时独立工作,互不干扰。另外,如果手表的其中一个零件损坏了,你需要做的仅仅是换掉那个损坏的零件,而不是换掉整块手表。

可组织性 ( Organization )

可组织性是每个零件具有清晰的作用范围的副产品。在此基础上,可组织性是自然而然产生的。


我们已经看到模块化应用在我们日常生活中的事物,比如手表上的明显的好处,如果将模块化应用到软件上会怎么样呢?同样的方法将得到同样的好处,就像手表的设计一样,我们应该将软件设计成由不同的功能单一的有着特定用途和清晰的作用范围的小块组成。在软件中,这些小块被称为模块。在这一点上,一个模块听上去可能和一个函数或者一个 React 组件没有太大区别。那么,一个模块究竟包含了什么?

每个模块分为三个部分 —— 依赖(也称为导入 ( imports ) ) ( dependencies ), 代码 ( code ) , 导出 ( exports )

imports code exports

依赖( 导入 )

当一个模块需要另一个模块的时候,它可以 import 那个模块,将那个模块当作一个依赖。例如,当你想创建一个 React 组件时,你需要 import react 模块。如果你想使用一个库,如 lodash ,你需要 import lodash 模块。

代码

当引入了你的模块需要的依赖,接下来就是这个模块真正的代码。

导出

导出 ( exports ) 是一个模块的“接口”。不管你从这个模块中导出什么,对于该模块的导入者来说都是可以访问到的。


已经谈论足够多的上层概念了,下面让我们来深入一些具体的例子。

首先,让我们先看看 React Router 。十分方便,它们有一个模块文件夹,这个文件夹自然是充满了......模块。如此,在 React Router 中,什么是一个模块呢?大多数情况下,它们直接映射 React 组件到模块。这是可行的,并且模块化逻辑通常就是你在 React 项目中如何拆分组件的逻辑。这行得通,因为如果你重温上面关于手表的部分,并且用“组件 ( component ) ”替换所有“模块 ( module ) ”字眼,这个比喻仍然是成立的。

让我们来看一下 MemoryModule 的代码。注意现在不要过分关注里面的代码,而是更应该着眼于这个模块的结构。


// imports
import React from "react";
import { createMemoryHistory } from "history";
import Router from "./Router";

// code
class MemoryRouter extends React.Component {
  history = createMemoryHistory(this.props);
  render() {
    return (
      <Router
        history={this.history}
        children={this.props.children}
      />;
    )
  }
}

// exports
export default MemoryRouter;

你会注意到模块的顶部定义了所需要的引入,或者是使 MemoryRouter 正确运行的其他模块。接下来是这个模块实际的代码。在这里,它们创建了一个新的名叫 MemoryRouter 的 React 组件。然后在底部,它们定义了这个模块导出的内容 MemoryRouter 。这意味着无论何时其他人引入 MemoryRouter 模块,都将得到该 MemoryRouter 组件。


现在我们明白了什么是一个模块,让我们回顾一下手表设计的好处,并且看一下如何在遵循相似的模块化方法的情况下,让软件设计得到同样的好处。

可复用性

模块最大化了可复用性,因为一个模块可以被其他任何需要的模块导入并使用。除此以外,如果一个模块对其他应用程序有用,你还可以创建一个包 ( package )。一个包 ( package ) 可以包含一个或多个模块并且可以被上传至 NPM 供其他人下载。 react, lodash, 以及 jquery 都是 NPM 包很好的例子,因为他们可以通过NPM地址进行安装。

可组合性

因为模块明确定义了它们导入和导出的内容,所以它们可以很容易地被组合。不仅如此,优秀软件的一个标志就是可以轻松地被移除。模块化也提高了代码的“可移除性”( delete-ability )。

杠杆作用

NPM有着世界上最大的免费的可复用模块集合。这个优势是如果你需要某个特定的包,NPM都会有。

独立性

我们对手表独立性的描述在这里同样适用。“明白整个系统是困难的,因为(你的软件)是由不同的功能单一的(模块)组合而成的,每个(模块)都可以被独立地设计、创建或者修复。这种独立性允许在创建或者修复(程序)过程中,多人同时独立工作,互不干扰。另外,如果其中一个(模块)出问题了,你需要做的仅仅是换掉那个出问题的(模块),而不是换掉整个(程序)。”

可组织性

可能模块化对于软件来说最大的好处就是可组织性。模块提供了一个自然的分割点。由此,正如我们即将看到的那样,模块将能防止你污染全局命名空间,并且帮助你避免命名冲突。


此刻你知道了模块的好处并且了解了模块的结构,是时候开始创建它们了。我们的方法是十分详尽的,因为正如之前提到的,JavaScript 的模块有着奇怪的历史。尽管现在 JavaScript 有“更新”的方法创建模块,但一些旧的方法仍然存在并且你还将会不时看到它们。如果我们一下子跳到2018年的模块化方式,对于你理解模块化来说是不利的。因此,我们将回到2010年末,AngularJS 刚刚发布,jQuery 正盛行。公司最终还是使用 JavaScript 来创建复杂的网页应用,正因如此,产生了通过模块来管理复杂网页应用的需要。

你创建模块的第一直觉可能是通过创建不同文件来拆分代码。


// users.js
var users = ["Tyler", "Sarah", "Dan"]

function getUsers() {
  return users
}


// dom.js

function addUserToDOM(name) {
  const node = document.createElement("li")
  const text = document.createTextNode(name)
  node.appendChild(text)

  document.getElementById("users")
    .appendChild(node)
}

document.getElementById("submit")
  .addEventListener("click", function() {
    var input = document.getElementById("input")
    addUserToDOM(input.value)

    input.value = ""
})

var users = window.getUsers()
for (var i = 0; i < users.length; i++) {
  addUserToDOM(users[i])
}


<!-- index.html -->
<!DOCTYPE html>
<html>
  <head>
    <title>Users</title>
  </head>

  <body>
    <h1>Users</h1>
    <ul id="users"></ul>
    <input
      id="input"
      type="text"
      placeholder="New User">
    </input>
    <button id="submit">Submit</button>

    <script src="users.js"></script>
    <script src="dom.js"></script>
  </body>
</html>

完整代码见此处

好了,我们已经成功地将我们的应用代码拆分成不同的文件,但这是否意味着我们已经成功地实现了模块化呢?不,这完全不是模块化。从字面上来说,我们所做的只是将代码所在的位置进行了拆分。在 JavaScript 中创建一个新的作用域的唯一方法是使用一个函数。我们声明的所有不在函数体内的变量都是存在于全局对象上的。你可以通过在控制台上打印出 window 对象来验证这一说法。你会意识到我们可以访问,甚至更坏的情况是,改变 addUsers, users, getUsers, addUserToDOM 。这实质上就是整个应用程序。我们完全没有将代码拆分到模块里去,刚才所做的只是改变了代码物理上的存在位置。如果你是 JavaScript 初学者,这可能会让你大吃一惊,但这可能是你对于如何在 JavaScript 中实现模块化的第一直觉。

如果拆分文件并没有实现模块化,那该怎么做?还记得模块的优点 —— 可复用性、可组合性、杠杆作用、独立性、可组织性。JavaScript 是否有一个天然的特性可供我们用于创建“模块”,并且带来上述的好处?通过一个普通的函数如何?想一下函数的好处你会发现,它们可以很好地与模块的好处对应上。那么该怎么实现呢?与其让整个应用存在于全局命名空间上,我们不如暴露一个单独的对象,可以称之为 APP 。可以将所有应用程序运行需要的方法放入这个 APP 对象中,防止污染全局名命空间。然后可以将所有东西用一个函数包裹,让它们相对于应用程序的其他空间是封闭的。


// App.js
var APP = {}


// users.js
function usersWrapper () {
  var users = ["Tyler", "Sarah", "Dan"]

  function getUsers() {
    return users
  }

  APP.getUsers = getUsers
}

usersWrapper()


// dom.js

function domWrapper() {
  function addUserToDOM(name) {
    const node = document.createElement("li")
    const text = document.createTextNode(name)
    node.appendChild(text)

    document.getElementById("users")
      .appendChild(node)
  }

  document.getElementById("submit")
    .addEventListener("click", function() {
      var input = document.getElementById("input")
      addUserToDOM(input.value)

      input.value = ""
  })

  var users = APP.getUsers()
  for (var i = 0; i < users.length; i++) {
    addUserToDOM(users[i])
  }
}

domWrapper()


<!-- index.html -->
<!DOCTYPE html>
<html>
  <head>
    <title>Users</title>
  </head>

  <body>
    <h1>Users</h1>
    <ul id="users"></ul>
    <input
      id="input"
      type="text"
      placeholder="New User">
    </input>
    <button id="submit">Submit</button>

    <script src="app.js"></script>
    <script src="users.js"></script>
    <script src="dom.js"></script>
  </body>
</html>

完整代码见此处

现在如果你查看 window 对象,之前它拥有应用程序所有重要的部分,现在它只拥有 APP 以及包裹函数 ( wrapper functions ),usersWrapperdomWrapper 。更重要的是,我们重要的代码(例如 users )不能被随意修改了,因为它们并不存在于全局命名空间上。

让我们看看是否能够更进一步,是否有方法可以避免使用包裹函数?注意我们定义了包裹函数并且马上调用了它们,我们赋予包裹函数一个名字的原因只是为了能调用它们。是否有方法可以立即调用一个匿名函数,这样我们就不需要赋予它们名字了?确实有这样的方法,并且这个方法还有一个很好的名字 —— 立即执行函数表达式 ( Immediately Invoked Function Expression ) 或者缩写为 IIFE

IIFE

下面是 IIFE 的大概样式


(function () {
  console.log('Pronounced IF-EE')
})()

注意下面只是一个包裹在小括号中的匿名函数表达式。


(function () {
  console.log('Pronounced IF-EE')
})

然后,就像其他函数一样,为了调用它,我们在其后面添加了一对小括号。


(function () {
  console.log('Pronounced IF-EE')
})()

现在为了避免使用包裹函数,并且让全局名命空间变干净,让我们使用 IIFEs 的相关知识改造我们的代码。


// users.js

(function () {
  var users = ["Tyler", "Sarah", "Dan"]

  function getUsers() {
    return users
  }

  APP.getUsers = getUsers
})()


// dom.js

(function () {
  function addUserToDOM(name) {
    const node = document.createElement("li")
    const text = document.createTextNode(name)
    node.appendChild(text)

    document.getElementById("users")
      .appendChild(node)
  }

  document.getElementById("submit")
    .addEventListener("click", function() {
      var input = document.getElementById("input")
      addUserToDOM(input.value)

      input.value = ""
  })

  var users = APP.getUsers()
  for (var i = 0; i < users.length; i++) {
    addUserToDOM(users[i])
  }
})()

完整代码见此处

完美!现在如果你查看 window 对象,你将会看到只添加了 APP 到其上,作为该应用程序运行所需的所有方法的命名空间。

我们可以称这个模式为 IIFE Module Pattern

IIFE Module Pattern 的好处是什么呢?首先并且最重要的是,避免了将所有东西都放置到全局命名空间上,这将有助于减少变量冲突以及让代码更私有化。这种模式是否有不足之处?当然,我们仍在全局命名空间上创建了一个变量, APP 。如果碰巧另一个库也使用相同的命名空间就会很麻烦。其二,index.html<script> 标签的顺序影响代码执行,如果不保持现有顺序,那么整个应用程序将会崩溃。

尽管上述解决方法并非完美,但仍然是进步的。现在我们明白了 IIFE module pattern 的优缺点,如果让我们制定创建和管理模块的标准,会需要什么样的功能呢?

之前我们将代码拆分为模块的第一直觉,是每个文件都是一个新的模块。尽管在 JavaScript 中这并不起作用,但我认为这是一个明显的模块分割点。每个文件就是一个独立的模块。基于此,还需要一个功能,就是让每个文件定义明确的导入(或者说是依赖),以及对于导入模块可用的明确的导出


Our Module Standard

1) File based
2) Explicit imports
3) Explicit exports

现在知道了我们制定的模块标准需要的功能,下面可以来看一下 API 。唯一真实的我们需要定义的 API 是导入和导出的实现。从导出开始,尽量保持简单,任何关于模块的信息可以放置于在 module 对象中。然后我们可以将想导出的内容添加到 module.exports 。跟下面的代码类似:


var users = ["Tyler", "Sarah", "Dan"]

function getUsers() {
  return users
}

module.exports.getUsers = getUsers

这意味着我们也可以用下面的写法:


var users = ["Tyler", "Sarah", "Dan"]

function getUsers() {
  return users
}

module.exports = {
  getUsers: getUsers
}

无论有多少方法,都可以将他们添加到 exports 对象上。


// users.js

var users = ["Tyler", "Sarah", "Dan"]

module.exports = {
  getUsers: function () {
    return users
  },
  sortUsers: function () {
    return users.sort()
  },
  firstUser: function () {
    return users[0]
  }
}

现在我们弄清楚了从一个模块导出内容是怎样的,下面需要弄清楚从模块导入内容的 API 是怎样的。简单而言,我们假设有一个名叫 require 的函数。它将接收字符串路径作为第一参数,然后将会返回从该路径导出的内容。继续使用 users.js 作为例子,引入模块将会类似下面的方式:


var users = require('./users')

users.getUsers() // ["Tyler", "Sarah", "Dan"]
users.sortUsers() // ["Dan", "Sarah", "Tyler"]
users.firstUser() // ["Tyler"]

非常顺手。使用我们假想的 module.exportsrequire 语法,我们保留了模块的所有好处并且避免了使用 IIFE Modules pattern 的两个缺点。

正如你目前为止所猜测的,这并不是一个虚构的标准。这个标准是真实存在的,它叫做 CommonJS 。

CommonJS 定义了一个模块格式,通过保证每个模块在其独自的命名空间内执行, 来解决 JavaScript 的作用域问题。这需要强制模块清晰地导出需要暴露给外界的变量,并且定义好代码正常工作需要引入的其他模块。 —— Webpack 文档

如果你之前使用过 Node ,CommonJS 看起来是相似的。这是因为为了实现模块化,Node (大多数情况下)使用了 CommonJS 的规范。因此,在 Node 中你使用之前看到过的,CommonJS 的 requiremodule.exports 语法来使用模块。然而,浏览器并不像 Node ,其并不支持 CommonJS 。事实上,不仅仅是浏览器不支持 CommonJS 的问题,而且对于浏览器来说, CommonJS 并不是一个好的模块化解决方案,因为它对于模块的加载是同步的。在浏览器环境中,异步加载才是王道。

总体而言,CommonJS 有两个问题。第一个问题是浏览器并不支持,第二个问题是它的模块加载是同步的, 这样在浏览器端的用户体验是极差的。如果能够解决上述两个问题,情况将会大为不同。那么如果CommonJS对于浏览器并不友好,我们花时间讨论它的意义何在?下面将介绍一种解决方案,它被称为 模块打包器 ( module bundler ) 。

模块打包器 ( Module Bundlers )

JavaScript 模块打包器会检查你整个代码库,找到所有的导入和导出,然后智能地将所有模块打包成一个浏览器能识别的单独的文件。不需要像以前一样在 index.html 中按顺序引入所有的 scripts ,现在你需要做的是引入那个打包好的文件 bundle.js 即可。


app.js ---> |         |
users.js -> | Bundler | -> bundle.js
dom.js ---> |         |

那么打包器实际上是如何工作的呢?这真是一个大问题,并且这个问题我也没有完全弄明白。但下面给出通过 Webpack,一个流行的模块打包器,打包后的我们的代码。

完整代码见此处 你需要下载这些代码,执行 "npm install" 指令,然后运行 "webpack" 指令。


(function(modules) { // webpackBootstrap
  // The module cache
  var installedModules = {};
  // The require function
  function __webpack_require__(moduleId) {
    // Check if module is in cache
    if(installedModules[moduleId]) {
      return installedModules[moduleId].exports;
    }
    // Create a new module (and put it into the cache)
    var module = installedModules[moduleId] = {
      i: moduleId,
      l: false,
      exports: {}
    };
    // Execute the module function
    modules[moduleId].call(
      module.exports,
      module,
      module.exports,
      __webpack_require__
    );
    // Flag the module as loaded
    module.l = true;
    // Return the exports of the module
    return module.exports;
  }
  // expose the modules object (__webpack_modules__)
  __webpack_require__.m = modules;
  // expose the module cache
  __webpack_require__.c = installedModules;
  // define getter function for harmony exports
  __webpack_require__.d = function(exports, name, getter) {
    if(!__webpack_require__.o(exports, name)) {
      Object.defineProperty(
        exports,
        name,
        { enumerable: true, get: getter }
      );
    }
  };
  // define __esModule on exports
  __webpack_require__.r = function(exports) {
    if(typeof Symbol !== 'undefined' && Symbol.toStringTag) {
      Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
    }
    Object.defineProperty(exports, '__esModule', { value: true });
  };
  // create a fake namespace object
  // mode & 1: value is a module id, require it
  // mode & 2: merge all properties of value into the ns
  // mode & 4: return value when already ns object
  // mode & 8|1: behave like require
  __webpack_require__.t = function(value, mode) {
    if(mode & 1) value = __webpack_require__(value);
    if(mode & 8) return value;
    if((mode & 4) && typeof value === 'object' && value && value.__esModule) return value;
    var ns = Object.create(null);
    __webpack_require__.r(ns);
    Object.defineProperty(ns, 'default', { enumerable: true, value: value });
    if(mode & 2 && typeof value != 'string')
      for(var key in value)
        __webpack_require__.d(ns, key, function(key) {
          return value[key];
        }.bind(null, key));
    return ns;
  };
  // getDefaultExport function for compatibility with non-harmony modules
  __webpack_require__.n = function(module) {
    var getter = module && module.__esModule ?
      function getDefault() { return module['default']; } :
      function getModuleExports() { return module; };
    __webpack_require__.d(getter, 'a', getter);
    return getter;
  };
  // Object.prototype.hasOwnProperty.call
  __webpack_require__.o = function(object, property) {
      return Object.prototype.hasOwnProperty.call(object, property);
  };
  // __webpack_public_path__
  __webpack_require__.p = "";
  // Load entry module and return exports
  return __webpack_require__(__webpack_require__.s = "./dom.js");
})
/************************************************************************/
({

/***/ "./dom.js":
/*!****************!*\
  !*** ./dom.js ***!
  \****************/
/*! no static exports found */
/***/ (function(module, exports, __webpack_require__) {

eval(`
  var getUsers = __webpack_require__(/*! ./users */ \"./users.js\").getUsers\n\n
  function addUserToDOM(name) {\n
    const node = document.createElement(\"li\")\n
    const text = document.createTextNode(name)\n
    node.appendChild(text)\n\n
    document.getElementById(\"users\")\n
      .appendChild(node)\n}\n\n
    document.getElementById(\"submit\")\n
      .addEventListener(\"click\", function() {\n
        var input = document.getElementById(\"input\")\n
        addUserToDOM(input.value)\n\n
        input.value = \"\"\n})\n\n
        var users = getUsers()\n
        for (var i = 0; i < users.length; i++) {\n
          addUserToDOM(users[i])\n
        }\n\n\n//# sourceURL=webpack:///./dom.js?`
);}),

/***/ "./users.js":
/*!******************!*\
  !*** ./users.js ***!
  \******************/
/*! no static exports found */
/***/ (function(module, exports) {

eval(`
  var users = [\"Tyler\", \"Sarah\", \"Dan\"]\n\n
  function getUsers() {\n
    return users\n}\n\nmodule.exports = {\n
      getUsers: getUsers\n
    }\n\n//# sourceURL=webpack:///./users.js?`);})
});

你将留意到这里有大量魔术般的代码(如果你想弄明白究竟发生了什么,可以阅读注释),但有趣的是所有的代码被包裹在一个大的 IIFE 中了。这样一来,通过简单地利用原来的 IIFE Module Pattern,他们找到了一个可以得到一个优秀模块系统所有优点的同时,避免上文提到的缺点的方法。


未来真正证明了 JavaScript 是一门活的语言。 TC-39 ,JavaScript 标准委员会,一年讨论几次关于这门语言的潜在优化可能性。与此同时,可以清晰看到,模块化对于创建可扩展、可维护的 JavaScript 代码来说是一个重要的功能。在2013年以前(也可能很久以前),JavaScript 很明显需要一个标准化的、内置的解决方案来处理模块。这开始了原生 JavaScript 实现模块化的进程。

基于你现在所知道的,如果你被赋予一项任务是为 JavaScript 创设一个模块系统,你设想会是怎样的? CommonJS 大部分实现是正确的。就像 CommonJS ,每个文件是一个新的模块并且能清晰定义导入和导出的内容。—— 很明显,这是最重要的一点。CommonJS 的一个问题是它加载模块是同步的,这对服务器来说是好的,但是对于浏览器来说则恰恰相反。其中一个可以做出的改变是支持模块异步加载,另一个可以做出的改变是定义新的关键字,而不是使用一个 require 函数调用,因为我们需要的是让这门语言原生支持该功能。下面让我们从 importexport 开始。

没有与上述我们“假设的标准”相距太远,当TC-39 委员会创造出 "ES Modules"(目前在 JavaScript 中创建模块的标准方法)的时候,他们想到了这个完全相同的设计思路。让我们来看一下这个语法。

ES Modules

正如上文提到的,为了指定需要从模块导出的内容,需要使用 export 关键字。


// utils.js

// Not exported
function once(fn, context) {
	var result
	return function() {
		if(fn) {
			result = fn.apply(context || this, arguments)
			fn = null
		}
		return result
	}
}

// Exported
export function first (arr) {
  return arr[0]
}

// Exported
export function last (arr) {
  return arr[arr.length - 1]
}

现在要导入 firstlast ,你有几个不同的选择。其中一个是导入所有从 utils.js 中导出的东西。


import * as utils from './utils'

utils.first([1,2,3]) // 1
utils.last([1,2,3]) // 3

但如果我们并不想导入所有该模块导出的东西呢?具体到这个例子而言,如果我们仅仅想导入 first 但不想导入 last 呢?这里可以使用 名命导入 ( named imports ) (看起来像解构但其实并不是)


import { first } from './utils'

first([1,2,3]) // 1

ES Modules 很酷的地方不仅仅是可以指定多个普通导出,而且也可以指定一个默认导出 ( default export )


// leftpad.js

export default function leftpad (str, len, ch) {
  var pad = '';
  while (true) {
    if (len & 1) pad += ch;
    len >>= 1;
    else break;
  }
  return pad + str;
}

当你使用默认导出时,这将改变你引入该模块的方式。不用像之前一样使用 * 语法或者名命导入,默认导出只需要使用 import name from './path 进行导入。


import leftpad from './leftpad'

如果有一个模块既有默认导出,也有其他常规导出呢?没错,你可以用你期待的方式来进行导入。


// utils.js

function once(fn, context) {
	var result
	return function() {
		if(fn) {
			result = fn.apply(context || this, arguments)
			fn = null
		}
		return result
	}
}

// regular export
export function first (arr) {
  return arr[0]
}

// regular export
export function last (arr) {
  return arr[arr.length - 1]
}

// default export
export default function leftpad (str, len, ch) {
  var pad = '';
  while (true) {
    if (len & 1) pad += ch;
    len >>= 1;
    else break;
  }
  return pad + str;
}

这样的话,导入语法是什么样的呢?在这个例子中,同样,可以以你期望的方式导入。


import leftpad, { first, last } from './utils'

非常顺手,对吧? leftpad 是默认导出, firstlast 是常规导出。

ES Modules 有趣的地方在于,因为是 JavaScript 的原生语法,现代浏览器不需要使用打包器就可以支持。看看教程一开始的简单的 Users 的例子使用 ES Modules后会是什么样子的。

完整代码见此处


// users.js

var users = ["Tyler", "Sarah", "Dan"]

export default function getUsers() {
  return users
}


// dom.js

import getUsers from './users.js'

function addUserToDOM(name) {
  const node = document.createElement("li")
  const text = document.createTextNode(name)
  node.appendChild(text)

  document.getElementById("users")
    .appendChild(node)
}

document.getElementById("submit")
  .addEventListener("click", function() {
    var input = document.getElementById("input")
    addUserToDOM(input.value)

    input.value = ""
})

var users = getUsers()
for (var i = 0; i < users.length; i++) {
  addUserToDOM(users[i])
}

这就是 ES Modules 神奇的地方。 使用 IIFE pattern, 仍然需要通过 script 标签引入每一个 JS 文件(并且要按顺序)。使用 CommonJS 需要一个打包器,如 Webpack ,然后通过一个 script 标签引入 bundle.js 文件。使用 ES Modules, 在现代浏览器中,需要做的只是引入主文件(这个例子中的 dom.js ) 并且在 script 标签上添加 type='module' 属性即可。


!DOCTYPE html>
<html>
  <head>
    <title>Users</title>
  </head>

  <body>
    <h1>Users</h1>
    <ul id="users">
    </ul>
    <input id="input" type="text" placeholder="New User"></input>
    <button id="submit">Submit</button>

    <script type=module src='dom.js'></script>
  </body>
</html>

Tree Shaking

CommonJS 和 ES Modules 之间还有一个不同点上文没有提及。

使用 CommonJS ,你可以在任何地方 require 一个模块,甚至是有条件地引入。


if (pastTheFold === true) {
  require('./parallax')
}

因为 ES Modules 是静态的,导入声明必须位于模块的顶层。有条件地引入是不可以的。


if (pastTheFold === true) {
  import './parallax' // "import' and 'export' may only appear at the top level"
}

这样一个设计思路的原因是,通过强制为静态模块,加载器可以静态分析模块树,找出实际被使用的代码,并且从代码束中丢弃没有被使用的代码。这是一个很大的话题,用另外一种说法就是,因为 ES Modules 强制在模块顶层写导入声明,打包器可以快速了解代码的依赖树,据此检查哪些代码没有被使用并将他们从代码束中移除。这就叫做 Tree Shaking or Dead Code Elimination

这是一个 stage 3 proposal 关于 动态导入 ( dynamic imports ) 的介绍,这个语法允许有条件地使用 import() 导入模块。


我希望通过深入 JavaScript 模块的历史,不仅可以帮助你更好地理解 ES Modules ,还可以帮你更好理解它们的设计思路。