【译】JavaScript 模块:初学者指南

326 阅读15分钟

⚠️(译者注)
本文写于2016年,重心是介绍JavaScript的模块早期的一些实现,对于ES6模块介绍较少。
可当作是JavaScript模块的前期发展来阅读。

如果你是刚刚接触Javascript,你可能很快就被淹没在一些术语中,例如“module bundlers vs module loaders”, "Webpack vs. Browserify",还有“AMD vs. CommonJS”。

Javascript的模块系统可能是比较令人害怕,但是理解它对于web开发者来说,是至关重要的。

本文中,我将用平白的话语(和一些代码示例)解释这些时髦的名词。我希望你会觉得有用!

注意:为了简明扼要,这将会被分为两个小节:第一小节将被用来解释什么是模块和为什么我们要使用他们。第二小节将会讲述它对打包模块意味着什么,以及不同的打包方式。

Part1: 能有人再次解释下什么是模块吗?

好的作家会将他们的书籍拆分成章和节;好的程序员将他们的代码拆分成模块。 就像一本书的章节,模块就是文字的集合(或者是代码,视场景而定)

然而,好的模块都是功能独立且高度内聚的,能够允许自身被重组,删除,或是在必要时被添加,而不会破坏系统的整体性。

为什么要使用模块?

使用模块有很多的好处,对于一个支持广泛的、相互依赖的代码库是有利的。在我看来,最重要的一些是:

1)可维护性:根据定义,一个模块是内聚的。一个设计精良的模块旨在尽可能减少对代码库各部分额的依赖,从而使其能独立成长和改进。当模块与其他的代码解耦,更新这个模块会相对容易很多。

回到我们的书本的例子中,如果你想更新你书中的一个章节,如果一个章节中的一处小改动需要你将书的其他的每个章节也作出修改,这将会是一个噩梦。相反的,你会想以这样的方式去这样编写:一个章节的改动不会影响其他章节。

2) 命名空间:在Javascript中,一个顶层函数的变量的作用域是全局的(这意味着,每个人都可以获取到他们)。正是因为这个,经常会有“命名空间污染”的问题,完全没有任何关联的代码共享着全局的变量。

无关联代码之间共享全局变量是一个很大的禁忌

正如我们将在本文的后面看到的那样,模块通过为变量创建私有空间来避免命名空间污染。

3) 复用性:这里我们老实承认:我们会不时的将先前写的代码拷贝到新项目中。例如,想象下你会从之前的项目中拷贝一些你之前编写的工具方法代码到当前的项目中。

这都很好,但是你发现一个更好的方式去编写这部分的代码,则必须回过头来去找寻,同时在更新的时候,并记得在其他所有编写代码的地方进行更新。

这显然是时间的巨大浪费。如果有一个--可以等待-- 一个我们可以不断重复使用的模块,难道不是容易得多吗?

如何合并模块?

有很多的方式可以将模块合入到你的项目中。让我们来了解其中一些:

模块模式

模块模式是用来模拟classes的概念(既然Javascript不是先天支持classes的),从而我们能够将公共的和私有的方法和变量都存储在一个简单的对象中 -- 这跟classes是怎么在其他的编程语言例如Java或者Python中使用的类似。这允许我们为想对外暴露的方法创建一个公开的API,同时还是将私有的变量和方法保存在闭包中。

有几种方式可以实现模块模式。在第一个例子中,我将会使用一个匿名闭包。这将会通过将我们所有的代码都放在一个匿名函数中来实现我们的目标。(记住:在Javascript中,函数是唯一用来创建的新的作用域的方法。)

Example 1: 匿名闭包

(function () {
  // 我们将这些私有变量保存在闭包作用域内部
  
  var myGrades = [93, 95, 88, 0, 55, 91];
  
  var average = function() {
    var total = myGrades.reduce(function(accumulator, item) {
      return accumulator + item}, 0);
    
      return 'Your average grade is ' + total / myGrades.length + '.';
  }

  var failing = function(){
    var failingGrades = myGrades.filter(function(item) {
      return item < 70;});
      
    return 'You failed ' + failingGrades.length + ' times.';
  }

  console.log(failing());

}());

// ‘You failed 2 times.’

通过这个结构,我们的匿名函数有它自己的计算环境或是“封闭区域”,然后我们立即执行它。这让我们将变量从父(全局)的命名空间中实现隐藏。

这个方法的好处是你可以在函数中使用本地的变量,不用覆盖全局已经存在的变量,同时还可以获取全局变量,例如这样:

var global = 'Hello, I am a global variable :)';

(function () {
  // 我们将这些私有变量保存在闭包作用域内部
  
  var myGrades = [93, 95, 88, 0, 55, 91];
  
  var average = function() {
    var total = myGrades.reduce(function(accumulator, item) {
      return accumulator + item}, 0);
    
    return 'Your average grade is ' + total / myGrades.length + '.';
  }

  var failing = function(){
    var failingGrades = myGrades.filter(function(item) {
      return item < 70;});
      
    return 'You failed ' + failingGrades.length + ' times.';
  }

  console.log(failing());
  console.log(global);
}());

// 'You failed 2 times.'
// 'Hello, I am a global variable :)'

注意到匿名函数的外部的括号是需要的,因为以关键字 function 开头的语句都会被认为是函数声明(记住,在Javascript中不能有使用未命名的函数声明)。因此,用括号括起来就创建了一个函数表达式。如果你感到好奇,你可以在这里阅读更多

Example 2: 全局导入

jQuery之类的库采用的另一种流行的方式是全局的导入。除了我们现在在全局变量作为参数传入外,它跟我们刚才看的匿名闭包相似:

(function (globalVariable) {

// 将这些私有变量保存在闭包作用域内部
  var privateFunction = function() {
    console.log('Shhhh, this is private!');
  }

  //  将下面的方法通过全局的接口暴露,
  // 将方法的实现隐藏在function()的内部

  globalVariable.each = function(collection, iterator) {
    if (Array.isArray(collection)) {
      for (var i = 0; i < collection.length; i++) {
        iterator(collection[i], i, collection);
      }
    } else {
      for (var key in collection) {
        iterator(collection[key], key, collection);
      }
    }
  };

  globalVariable.filter = function(collection, test) {
    var filtered = [];
    globalVariable.each(collection, function(item) {
      if (test(item)) {
        filtered.push(item);
      }
    });
    return filtered;
  };

  globalVariable.map = function(collection, iterator) {
    var mapped = [];
    globalUtils.each(collection, function(value, key, collection) {
      mapped.push(iterator(value));
    });
    return mapped;
  };

  globalVariable.reduce = function(collection, iterator, accumulator) {
    var startingValueMissing = accumulator === undefined;

    globalVariable.each(collection, function(item) {
      if(startingValueMissing) {
        accumulator = item;
        startingValueMissing = false;
      } else {
        accumulator = iterator(accumulator, item);
      }
    });

    return accumulator;

  };

 }(globalVariable));

在这个例子中,globalVariable 是唯一的全局的变量。这种方式优于匿名闭包的点在于你预先声明了一个全局变量,这使得人们在阅读你代码时一目了然。

Example 3: 对象接口

另一个方式是使用自闭合对象接口创建模块,如下所示:

var myGradesCalculate = (function () {
    
  // 将这些私有变量保存在闭包作用域内部
  var myGrades = [93, 95, 88, 0, 55, 91];

  //  将下面的方法通过全局的接口暴露,
  // 在function()块的内部隐藏方法的使用

  return {
    average: function() {
      var total = myGrades.reduce(function(accumulator, item) {
        return accumulator + item;
        }, 0);
        
      return'Your average grade is ' + total / myGrades.length + '.';
    },

    failing: function() {
      var failingGrades = myGrades.filter(function(item) {
          return item < 70;
        });

      return 'You failed ' + failingGrades.length + ' times.';
    }
  }
})();

myGradesCalculate.failing(); // 'You failed 2 times.' 
myGradesCalculate.average(); // 'Your average grade is 70.33333333333333.'

正如你所见的,这个方法让我们决定那些变量/方法是我们想要保存为私有的(例如myGrades)和哪些变量/方法是我们想通过将他们放在返回的声明中来暴露的(例如:averagefailing)。

Example 4: 显式模块模式

这跟上面的方法非常类似,除了它会却确保所有的方法和变量在显式公开之前保持私有状态:

var myGradesCalculate = (function () {
    
  // 将这些私有变量保存在闭包作用域内部
  var myGrades = [93, 95, 88, 0, 55, 91];
  
  var average = function() {
    var total = myGrades.reduce(function(accumulator, item) {
      return accumulator + item;
      }, 0);
      
    return'Your average grade is ' + total / myGrades.length + '.';
  };

  var failing = function() {
    var failingGrades = myGrades.filter(function(item) {
        return item < 70;
      });

    return 'You failed ' + failingGrades.length + ' times.';
  };

  // Explicitly reveal public pointers to the private functions 
  // that we want to reveal publicly

  return {
    average: average,
    failing: failing
  }
})();

myGradesCalculate.failing(); // 'You failed 2 times.' 
myGradesCalculate.average(); // 'Your average grade is 70.33333333333333.'

这里看起来有很多需要消化的,但这对于模块模式只是冰山一角。这里有一些,我找到的我认为有用的资源:

CommonJS和AMD

上述的方法有一个共同的地方:使用一个简单的全局变量将代码包裹在一个函数中,因此通过使用闭包作用域,为自身创建一个私有的命名空间。

尽管每一个方法都以其自己的方式有效,但他们也有不足之处。

首先,对一个开发者来说,你需要清楚的知道依赖顺序以便将文件载入。举个例子,假设你在你的项目中使用Backbone,因此你在你的文件中包含了引用Backbone的源代码的脚本标签。

然而,既然Backbone对于Underscore.js有强依赖,Backbone文件的脚本标签不能放在Underscore.js文件的脚本标签之前。

作为一个开发者,管理这些依赖,并合理维持这些有时是比较令人头疼的。

另一个不足是他们仍然会导致命名空间混乱。例如,如果你的两个模块都有相同的名字呢?或者是如果你有一个模块的两个不同版本,并且你两个都需要呢?

因此你很可能想知道:我们能否设计一种使用模块的接口的方式,而不需要遍历全局作用域?

幸运的是,答案是yes。

这里有两种流行且实现良好的方式:CommonJS 和 AMD。

CommonJS

CommonJS是一个志愿者工作小组,负责设计和实现用于声明模块的JavaScript API。

一个CommonJS模块本质上是一段可重用的JavaScript,它可以导出特定的对象,使其能够为项目中的其他的模块所 require 。如果你已经使用过Node.js编程,你会对这种方式非常熟悉。

利用CommonJS,每一个Javascript文件都将模块存储在其自己唯一的模块的上下文中(就像将其包装在闭包中一样)。在这个作用域中,我们使用module.exports对象来暴露模块,使用require来导入他们。

当你正在定义一个CommonJS模块,它可能会看起来像这样:

function myModule() {
  this.hello = function() {
    return 'hello!';
  }

  this.goodbye = function() {
    return 'goodbye!';
  }
}

module.exports = myModule;

我们使用这个特殊的对象模块,并将我们函数的引用放入module.exports中。这使得CommonJS 模块系统知道我们想要暴露什么,以便其他的文件能够消费它。

然后当有人想要使用myModule,他们会在他们的文件中引入,像这样:

var myModule = require('myModule');

var myModuleInstance = new myModule();
myModuleInstance.hello(); // 'hello!'
myModuleInstance.goodbye(); // 'goodbye!'

这种方式相较于我们之前讨论的方式有两个明显的优点:

  1. 避免了全局命名空间的感染
  2. 让我们的依赖更加明确

而且,这种语法是非常简洁的,我个人非常喜欢。

另一个事情需要注意的是CommonJS是一个服务端优先的方法,并且是同步的加载模块。这很重要,因为如果我们有3个其他的模块需要require,它将会一个一个的加载他们。

现在,他在服务端工作的很好,但是不幸的是,这种同步加载使得它对于使用Javascript的浏览器端来说,使用比较困难。可以说,从web读取模块比从磁盘上读取所花费的时间要长得。只要是加载模块处于运行中,它将会阻塞浏览器运行其他的任务,直到加载完成。它会表现为这种形式是因为JavaScript线程将会停止执行直到代码被加载完全。(我将在我们讨论模块打包的Part2部分再来详细解释我们怎么应该怎么处理这个问题。现在,我们只需要知道即可)。

AMD

CommonJS很好,但如果我们想异步的加载模块呢?答案是异步模块定义(Asynchronous Module Definition),简称AMD。

使用AMD加载模块看起来像是这样:

define(['myModule', 'myOtherModule'], function(myModule, myOtherModule) {
  console.log(myModule.hello());
});

这里发生的是 define 函数将模块的依赖数组作为第一个参数。这些模块都是在背后加载的(以非阻塞的形式),并且一旦加载完成,define 将会调用给定的回调函数。

接下来,回调函数的参数是加载完成的依赖 -- 在我们的例子中是,myModulemyOtherModule -- 允许函数使用这些依赖。最终,还必须使用 define关键字定义这些依赖项本身。

举个例子,myModule 可能看起来是像这样的:


define([], function() {

 return {
   hello: function() {
     console.log('hello');
   },
   goodbye: function() {
     console.log('goodbye');
   }
 };
});

同样,与CommonJS不同的是,AMD采用的是浏览器优先的策略通过异步方式将完成工作。(注意,有许多人坚信在开始运行代码时动态地逐个加载文件是不可取的,这个我们将在下一个章节关于模块构的部分进行更多的探索)。

除了异步之外,AMD的另一个好处是你的模块可以是对象,函数,构造器,字符串,JSON和许多其他类型,相比之下CommonJS只支持对象作为模块。

话虽如此,但AMD不适用于io、文件系统和其他的在CommonJS中可得的服务端导向的特性,同时函数包裹的语法相比于简单的require来说也更加繁琐。

UMD

对于需要你同时支持AMD和CommonJS特性的项目,有另外的形式:通用的模块定义(UMD)。

UMD本质上创建了一种使用这两种方法之一的方式,同时还支持全局的变量定义。结果,UMD模块能够在客户端和服务端同时工作。

这里有一个快速的的尝试UMD是怎么工作的:

(function (root, factory) {
  if (typeof define === 'function' && define.amd) {
      // AMD
    define(['myModule', 'myOtherModule'], factory);
  } else if (typeof exports === 'object') {
      // CommonJS
    module.exports = factory(require('myModule'), require('myOtherModule'));
  } else {
    // 浏览器的全局变量 (注意: root 是window对象)
    root.returnExports = factory(root.myModule, root.myOtherModule);
  }
}(this, function (myModule, myOtherModule) {
  // 方法
  function notHelloOrGoodbye(){}; // 一个私有的方法
  function hello(){}; // 一个公共的方法,因为它被返回了 (往下看)
  function goodbye(){}; // 一个公共的方法,因为它被返回了 (往下看)

  // 暴露公共的方法
  return {
      hello: hello,
      goodbye: goodbye
  }
}));

获取更多UMD的形式,在GitHub上查阅这个仓库

原生的JS

喔!你还在吗?这里我没有使你遗失在困境中吧?太棒了!因为在我们结束之前,我们还有 另一个类型的模块要定义。

你很可能注意到,上述的模块没有是适用于原生的Javascript的。相反的,我们通过任一一种模块模式,CommonJS 或者是 AMD,创建出了模拟模块系统的方式。

幸运的是,在TC39的智者们(制定ECMAScript语法和语义的标准机构)已经在ECMAScript 6(ES6)引入了内建的模块系统。

ES6给导入和导出模块提供了许多的可能性,其他人则做了很好的解释 -- 这里有一些解释资源:

ES6模块相对于CommonJS或者AMD更伟大的地方在于它能够提供两全其美的优势:紧凑和声明式语法和异步的加载,再加上更好的支持循环依赖的附加好处。

我最喜欢ES6模块的特性可能是导入是导出的实时只读可见。(相比于CommonJS,导入是导出的复制,并且没有生命)。

这里有一个演示那是怎么工作的例子:

// lib/counter.js

var counter = 1;

function increment() {
  counter++;
}

function decrement() {
  counter--;
}

module.exports = {
  counter: counter,
  increment: increment,
  decrement: decrement
};


// src/main.js

var counter = require('../../lib/counter');

counter.increment();
console.log(counter.counter); // 1

在这个例子中,我们根本上是进行了模块的两遍复制,一遍是在我们导出它的时候,一遍是当我们require它的时候。

此外,在main.js中的副本已与原始模块断开连接。这就是为什么即使当我们增加我们的计数器,它仍然会返回1--因为我们导入的counter变量是一份跟那个模块中的counter变量的断开连接的副本。

因此,增加计数器会使模块中的计数器值增加,但是并不会增加你的复制的版本。修改计数器变量的复制版本的唯一方法是手动进行:

counter.counter++;
console.log(counter.counter); // 2

另一方面,ES6为导入的模块创建实时的只读视图:

// lib/counter.js
export let counter = 1;

export function increment() {
  counter++;
}

export function decrement() {
  counter--;
}


// src/main.js
import * as counter from '../../counter';

console.log(counter.counter); // 1
counter.increment();
console.log(counter.counter); // 2

非常酷,是吧?对于实时只读可见视图,我发现真正吸引人的地方是他们如何将你的模块拆分成更小的块,而又不损失功能。

然后你可以掉转身来,将他们合并,完全没问题。他就是这么“工作”的。

期待:打包模块

哇!时间都去哪儿了?这个是粗略的介绍,但我衷心的希望它能够帮你更好的理解Javascript模块。

在下一个章节中,我将会讲述模块打包,涵盖以下核心主题:

  • 为什么我们要打包模块
  • 不同的打包方式
  • ECMAScript模块加载API
  • 还有其他更多 :)

注意:为了更加精简,本文中跳过了一些具体的细节(例如:循环依赖)。如果我有遗漏任何重要的或是令人着迷的内容,请留言告知我!