这是我参与更文挑战的第 19 天,活动详情查看:更文挑战
首先了解一下为什么代码需要模块化。将代码模块化的目的,是为了能够将不同来源的代码模块组装成大型程序。各个模块都封装和隐藏了自身的实现细节,模块与模块之间互不影响,不存在耦合。
通过模块化的编程方式,开发者们只需要关心自己所负责模块的实现,使用其它模块时只需要关心它们所暴露的接口方法,能够极大地提升编程效率和项目的维护成本,同时提高代码的可读性和可复用性。
在 ES6 之前,JavaScript 都没有内置对模块的支持,实践中常用的模块化形式是基于闭包实现。之后,Node 编程环境提供了基于 require() 的模块实现,但没有被 JavaScript 官方采用。直到 ES6,使用了 export、import 和 import() 的模块。
基于闭包的模块实现
我们知道函数中声明的变量和函数都只在函数的作用域内,属于函数私有,外部无法影响,这也是闭包的原理。通过闭包的方式,能够将代码的实现细节进行封装,只返回需要暴露给外部的公共 API,从而实现代码的模块化。
下面这部分代码通过闭包的方式实现一个统计模块,只暴露了 mean() 和 stddev() 函数供外部使用,对外隐藏实现细节。
const stats = (function() {
// 模块私有的辅助函数
const sum = (x, y) => x + y;
const square = x => x * x;
// 要导出的公共 API
function mean(data) {
return data.reduce(sum)/data.length;
}
function stddev(data) {
let m = mean(data);
return Math.sqrt(
data.map(x => x - m)
.map(square)
.reduce(sum)/(data.length-1)
);
}
// 将公共 API 作为一个对象属性导出
return { mean, stddev }
}())
// 使用 stats 模块,不需要关注实现细节
stats.mean([1, 3, 5, 7, 9]) // => 5
stats.stddev([1, 3, 5, 7, 9]) // => 3.1622776601683795
Node 中的模块
Node 中的模块,也叫 CommonJS 模块,基于 CommonJS 规范。使用 exports 或 module.exports 进行模块 API 的导出,使用 require() 进行模块的导入。
导出
在 Node 中,每个文件就是一个模块,内部定义了一个全局的 Module 对象,以及一个 exports 变量。可以输出看一下:
console.log(module); // 试试看
console.log(module.exports); // {}
console.log(exports === module.exports); // true
编写一个 stats 模块,通过 exports 导出。
// stats.js
const sum = (x, y) => x + y;
const square = x => x * x;
exports.mean = data => data.reduce(sum)/data.length;
exports.stddev = function(d) {
let m = exports.mean(d);
return Math.sqrt(d.map(x => x - m).map(square).reduce(sum)/(d.length-1));
};
也可以通过 module.exports 导出。
module.exports = {
mean,
stddev
}
导入
Node 使用 require() 方法进行模块的导入。如果是内置的模块或者是已安装的模块,直接通过名字导入,如果是自己代码的模块,则通过文件相对路径导入。
// index.js
const fs = require('fs'); // 内置的文件系统模块
const express = require('express'); // 已安装的模块
const stats = require('./stats.js'); // 项目代码模块
输出当前文件下的 module 看看。如果在前文的时候你有输出过 module,那你现在会发现 children 数组里多了几个 module 对象。required()方法就是将导入的模块存到了当前module对象的children数组下。
其基本工作原理跟下面这段代码类似:
const modules = {};
function require(moduleName) { return modules[moduleName]; }
modules['stats.js'] = (function() {
const exports = {};
// stats.js 文件的内容
const sum = (x, y) => x + y;
const square = x => x * x;
exports.mean = function(data) {...};
exports.stddev = function(d) {...};
return exports;
}());
ES6 中的模块
ES6 为 JavaScript 添加了 import 和 export 关键字,用于模块的导入和导出。
导出
要从 ES6 模块导出常量、变量、函数或类,使用 export 关键字即可。
export const PI = Math.PI;
export function degreesToRadians(d) { return d * PI / 180; }
// 或者导出花括号,注意,这不是对象
export { PI, degreesToRadians }
// 默认导出
export {
mean,
stddev
}
export 只对有名字的声明有效,使用 export default 的默认导出可以导出任意表达式。他们两个一般不混用,但没有禁止这种使用方式。
导入
导入模块使用 import 关键字实现。
// 导入默认导出,需要给它命名
import stats from './stats.js'
// 非默认导出有名字,只能通过名字引用它们
import { PI, degressToRadians } from './stats.js';
// 可以改名字
import { PI as pi } from './stats.js';
// 也可以都赋值到一个对象上
import * as stats from './stats.js';
// 如果既有默认导出,也有非默认导出,可以这样引入
import stats, { PI, degressToRadians } from './stats.js';
// 导入没有任何导出的模块
import './analytics.js';
导入没有任何导出的模块,该模块的代码会在首次导入时运行一次,之后导入就什么也不做。用于一些特殊情况。(比如希望执行该段代码但是不需要用到导出值)。
此外,ES2020 引入了动态导入 import()。传给import() 一个模块标识符,会返回一个 promise,如下所示:
import('./stats.js').then(stats => {
let average = stats.mean(data);
})
// 或者使用 async 函数
async analyzeData(data) {
let stats = await import('./stats.js');
return {
average: stats.mean(data),
stddev: stats.stddev(data)
}
}
需要注意的是,import()看起来像函数调用,但它其实是一个操作符,圆括号表示操作符语法必须的部分。
Node 模块与 ES6 模块的对比
分别了解了 Node 模块跟 ES6 模块以后,我们对它们进行更加深入的分析与对比。通过上文知道它们的语法是不一样的,使用的 API 不同。
Node 模块导出是一个对象,而 ES6 模块是多个 API 导出。
Node 模块是动态导入,可以写在任何地方。ES6 模块是静态导入,只能写在顶层(import 会被变量提升)。
ES6 模块使用 import 被导入的变量是只读的,不能被赋值,其次被导入的变量是引用传递,与原变量是绑定的,如果被改变了,其它导入该模块的地方也会受到影响。而 Node 模块导入的是值的浅拷贝。