CommonJS 与 ES6 Module 之一

1,044 阅读8分钟

JavaScript发展到今天已经不再是当初那个只用10天设计出来的网页脚本语言。随着代码复杂度增加,模块化是目前接触JavaScript开发的必修课。

前言

随着JavaScript语言发展,曾经出现过各种模块标准。本文主要探讨目前主流CommonJS与ES6 Module的由来以及它们的区别。

CommonJS

CommonJS是JavaScript社区2009年提出的标准,并且首先在Node.js采用部分标准并实现,我们现在所说的CommonJS一般是指Node.js中的版本。在目前打包工具支持下,CommonJS模块也可以在浏览器环境中使用。

CommonJS中每个文件就是一个模块与常规把JavaScript脚本存到一个文件并通过script标签加载不同,CommonJS模块的作用域是私有的,所有变量和函数对外是不可见的。

// tom.js
var name = 'Tom';

// index.js
var name = 'Jeremy';
require('./tom.js');
console.log(name); // 打印出'Jeremy'

导出

模块内有一个module对象用于存放当前模块信息,可以理解为每个模块默认定义了以下对象:

var module = {...};
// 模块自身逻辑
module.exports = {...};

module.exports指定模块对外暴露内容,下面代码导出一个对象包含nameeat两个属性:

module.exports = {
    name: 'Tom',
    eat: function(){
    	console.log('eat something');
    };
};

上述代码也可以简写为下面这样:

exports.name = 'Tom';
exports.eat = function(){
    console.log('eat something');
};

原理是CommonJS模块内把exports指向了module.exports,可以简单理解为每个模块首部都有以下前置的代码:

var module= {
    exports: {},
};
var exports = module.exports;

exports上添加属性相当于把属性添加在modules.exports上。但是注意直接对exportsmodules.exports做赋值操作,会破坏了exportsmodules.exports的引用关系。

导入

使用require导入CommonJS模块

// tom.js
console.log('Tom is comming');
module.exports = {
    name: 'Tom',
    eat: function(){
    	console.log('eat something');
    };
};
// index.js
const tom = require('./tom.js');
tom.eat(); // 打印出'eat something'
const tonName = require('./tom.js').name;
console.log(tonName);
console.log('end')

控制台输入结果如下:

Tom is comming
eat something
Tom
end

从结果可以看出,尽管require了tom.js两次,但tom.js内部的代码只会执行一次。

  • require的模块第一次加载时候会被执行,然后导出执行结果module.exports
  • require的模块如果曾被加载过,再次加载时候模块内部代码不会再次被执行,直接导出首次执行的结果。

require函数是运行时执行的,所以require函数可以接收表达式,并且可以放在逻辑代码中执行。

const name = 'Tom';
const scriptName = 'tom.js';
if (name === 'Tom') {
    require('./' + scriptName);
}

ES6 Module

2015年6月,TC39标准委员会正式发布了ES6(ECMAScript6.0),从此JavaScript语言才具备了模块化这一特性。

使用ES6方式改写前面 tom.js 和 index.js。

// tom.js
export default {
    name: 'Tom',
    eat: function(){
    	console.log('eat something');
    };
}

// index.js
import Tom from './tom.js'
Tom.eat(); // 打印出 'eat something'
console.log(Tom.name) // 打印出 'Tom'

ES6 Module也是每个文件一个模块,每个模块有自身的作用域。不同点是导入导出的语句。importexport作为保留关键字在ES6版本中加入。

ES6 Module会强制采用严格模式,不管文件顶部是否有use strict。所以CommonJS模块改写成ES6 Module时候需要注意。

导出

ES6 Module使用export关键字导出模块,有两种形式:

命名导出

变量的声明和导出写在一行。

export const name = 'Tom';
export const eat = function(){ console.log('eat something'); };

先进行变量声明,然后再使用export一次导出。

const name = 'Tom';
const eat = function(){ console.log('eat something'); };
export { name, eat };

使用命名导出时,可以使用as关键字对变量重命名:

const name = 'Tom';
const eat = function(){ console.log('eat something'); };
export { name, eat as goToEat }; // 导入时候为 name 和 goToEat

默认导出

模块的默认导出只能有一个:

export default {
    name: 'Tom',
    eat: function(){ console.log('eat something'); };
}

模块的默认导出等同于命名导出一个default变量:

// 字符串导出
export default 'my name is Tom'
// 类导出
export default class {...}
// 函数导出
export default function() {...}

导入

ES6 Module使用import关键字导入模块

命名导入

// tom.js
const name = 'Tom';
const eat = function(){ console.log('eat something'); };
export { name, eat };

// index.js
import { name, eat } from './tom.js';
console.log(name);
eat();

import后使用大括号把导入的变量名包裹,变量名需要和导出变量名一致。导入变量相当于在当前模块作用域下声明这些变量( name 和 eat ),并且是只读的不能更改。

与命名导出类似,可以通过as关键字对导入变量名重命名:

// index.js
import { name, eat as goToEat } from './tom.js';
console.log(name);
goToEat();

可以使用import * as的方式整体导入多个变量:

// tom.js
const name = 'Tom';
const eat = function(){ console.log('eat something'); };
export { name, eat };

// index.js
import * as tom from './tom.js';
console.log(tom.name);  // 打印出 'Tom'
tom.eat(); // 打印出 'eat something'

默认导入

对于默认导出模块来说import后面直接跟变量名导入,改变量可以自由指定(下面代码里面使用了'tom'),它指向了tom.js的默认导出值。

// tom.js
const name = 'Tom';
const eat = function(){ console.log('eat something'); };
export default { name, eat };

// index.js
import tom from './tom.js';
console.log(tom.name);  // 打印出 'Tom'
tom.eat(); // 打印出 'eat something'

下面展示混合引用默认导出值和命名导出值:

// tom.js
const name = 'Tom';
const eat = function(){ console.log('eat something'); };
export const age = 10;
export default { name, eat };

// index.js
import tom, { age } from './tom.js';
console.log(tom.name);  // 打印出 'Tom'
console.log(age); // 打印出 10
tom.eat(); // 打印出 'eat something'

默认导出值tom的引用必须要写在命名导出值{ age }引用的前面。

复合写法

在开发之中,总模块的入口模块有可能会集合其他子模块并集合导出,这种时候可以导出和导出写在同一行之中:

// indexModule.js
export { TomName, TomEact } from './tom.js'
export { TimName, TimEact } from './tim.js'

CommonJS 与 ES6 Module 的区别

实际开发中,我们工程很打可能会从npm上下载并使用各种模块,这些模块有的是CommonJS,有的是ES6 Module。使用过程中我们需要注意什么,以及区分两者的区别?

动态与静态

细心的同学如果有仔细阅读前文,此时应该会清楚两者最大区别:CommonJS模块是动态引入的,模块依赖关系是发生在代码运行时;而ES6 Module模块是静态引入的,模块的依赖关系在编译时已经可以确立。

CommonJS例子:

// tom.js
module.exports = {
	name: 'Tom',
    eat: function(){
    	console.log('eat something');
    };
};
// index.js
const tom = require('./tom.js');
tom.eat(); // 打印出'eat something'

tom.js 在 index.js 首次引入时候会执行一次,随后导出执行结果module.exports作为require函数的返回值返回。 require函数可以在index.js任何地方使用,并且接受的路径参数也可以动态指定。因此,在CommonJS模块被执行前,是没有办法确定明确的依赖关系,模块的导入导出都发生在代码运行时(代码运行阶段)。

对比ES6 Module例子:

// tom.js
export default {
    name: 'Tom',
    eat: function(){
    	console.log('eat something');
    };
};
// index.js
import tom from './tom.js';
tom.eat(); // 打印出'eat something'

ES6 Module的导入、导出语句都是声明式的,它不支持模块路径使用表达式,并且也要求导入、导出语句位于模块的顶层作用域。因此ES6 Module是一种静态的模块结构,在ES6代码编译阶段就可以分析出模块的依赖关系。ES6 Module对比CommonJS有以下优势:

  • tree shaking。通过静态分析工具在编译时候检测哪些import进来的模块没有被实际使用过,以及模块中哪些变量、函数没有被使用,都可以在打包前先移除,减少打包体积。
  • 模块变量检查。JavaScript属于动态语言,不会在代码执行前检查类型错误。ES6 Module的静态模块结构有助于结合其他工具在开发或编译过程中去检查值类型是否正确。

值拷贝与动态引用

在导入模块时候,CommonJS是导出值的拷贝并缓存,而在ES6 Module中是值的动态引用。

CommonJS例子:

// tom.js
var name = 'Tom';
module.exports = {
	name: name,
    setName: function(otherName){
    	name = otherName;
    }
};

// index.js
var name = require('./tom.js').name;
var setName = require('./tom.js').setName;

console.log(name); // 打印出 'Tom',这里是tom.js执行结果name的拷贝,name的值为'Tom'
setName('Jeremy');
console.log(name); // 打印出 'Tom', tom.js中name的改变不会对这里造成影响

name = 'Tim';
console.log(name); // 打印出 'Tim',当前模块name的值是可以重新赋值的

index.js 中的 name 是对 tom.js 中 name 的一份拷贝,因此调用了setName方法时,虽然更改了 tom.js 中 name 的值,却不会对 index.js 中导入时候创建的拷贝副本造成任何影响。并且最后在 index.js 中对 name 再次赋值也不会影响到 tom.js 中 name 的值。

对应ES6 Module的例子:

// tom.js
var name = 'Tom';
export {
    name: name,
    setName: function(otherName){
    	name = otherName;
    }
};

// index.js
import { name, setName } from './tom.js';

console.log(name); // 打印出 'Tom',这里是tom.js中name变量的引用
setName('Jeremy');
console.log(name); // 打印出 'Jeremy', 引用反应出 tom.js中 name 值的变化

name = 'Tim'; // 会抛出 SyntaxError: "name" is read-only

上面例子中从ES6 Module中导入的变量其实是对原有模块值的动态引用。index.js 中的 name 是对 tom.js 中 name 值的实际反映。当在 index.js 中使用 setName 方法改变 tom.js 中 name 的值时,index.js 中 name 值也会一起变化。 并且ES6 Module中导入的变量是只读的,不允许更改。

*本文主要参考居玉皓老师的《Webpack实战 入门、进阶与调优》