浅谈 Node 中的模块化
非模块化开发的问题
- 命名冲突
- 文件依赖高
- 可扩展性低
- 可重用性低
- 等等......
一、模块化的演变过程
从最简单的加减乘除运算来举例说明,为了方便理解这里都没有采用ES6的语法。
- 全局函数的方式——最原始的写法
// 早期的开发过程中就是将重复使用的代码封装到函数中 // 再将一系列的函数放到一个文件中,称之为模块 // 缺点:存在命名冲突,可维护性也不高的问题 // 仅仅从代码角度来说:没有任何模块的概念 function convertor(a) { return parseFloat(a); } function add(a, b) { return convertor(a) + convertor(b); }
- 封装对象的方式
// 有了传统编程语言中的命名空间 // 从代码层面就已经有了模块的感觉 // 避免了多处全局污染 // 缺点:没有私有空间,没有抽离私有成员 var calculator = { add: function (a, b) { return this.convertor(a) + this.convertor(b); }, convertor:function(a){ return parseFloat(a) } };
- 私有空间的划分
// 这里形成一个单独的私有的空间 // 高内聚,低耦合:模块内部相关性强,模块之间没有过多相互牵连,如convertor和add // 缺点:可扩展性低 var calculator = (function () { // 将一个成员私有化,外部无法访问和修改 function convertor(a) { return parseFloat(a); } // 抽象公共方法(其他成员中都会用到的) function add(a, b) { return convertor(a) + convertor(b); } return { add:add } })();
- 模块的扩展
// calc_v2016.js (function (window,calculator) { function convert(input) { return parseFloat(input); } calculator = { add: function (a, b) { return convert(a) + convert(b); } } window.calculator = calculator; })(window, {}); // 新增需求 remain // calc_v2017.js // 开闭原则:对新增开放,对修改关闭 (function (calculator) { function convert(input) { return parseInt(input); } // calculator 如果存在的话,我就是扩展,不存在我就是新加 calculator.remain = function (a, b) { return convert(a) % convert(b); } window.calculator = calculator; })(window.calculator || {});
- 第三方依赖
// calc_v2016.js (function (window,calculator) { //对全局产生依赖,不能这样用 console.log(document); function convert(input) { return parseFloat(input); } calculator = { add: function (a, b) { return convert(a) + convert(b); } } window.calculator = calculator; })(window, {}); // 新增需求 // calc_v2017.js (function (calculator,document) { // 依赖函数的参数,是属于模块内部 console.log(document); function convert(input) { return parseInt(input); } calculator.remain = function (a, b) { return convert(a) % convert(b); } window.calculator = calculator; })(window.calculator || {},document);
以上通过一些简短的代码介绍了模块化发展大致情况。
二、模块化规范
- 服务器端规范
CommonJS---nodejs - 浏览器端规范
AMD---RequireJS
CMD---SeaJS - ES6的module规范
ES6 在语言标准的层面上,实现了模块功能,而且实现得相当简单,旨在成为浏览器和服务器通用的模块解决方案。其模块功能主要由两个命令构成:export
和import
。export
命令用于规定模块的对外接口,import
命令用于输入其他模块提供的功能。在这里就不具体展示每种规范的具体写法了,详情请点击阮一峰《ES6 入门教程》。
三、CommonJS 模块规范
-
Node 采用的模块化结构是按照 CommonJS 规范
- 模块与文件是一一对应关系,即加载一个模块,就是加载对应的一个模块文件。
- CommonJS 就是一套约定标准,不是技术; 用于约定我们的代码应该是怎样的一种结构。
-
CommonJS 模块的特点
- 所有代码都运行在模块作用域,不会污染全局作用域。
- 模块可以多次加载,但是只会在第一次加载时运行一次,然后运行结果就被缓存了,以后再加载就直接读取缓存结果;
module.exports
不会再次执行该模块。
- 模块加载的顺序按照其在代码中出现的顺序。
-
模块的分类
- 自定义模块:就是我们自己写的功能模块文件。
- 核心模块:Node 平台自带的一套基本的功能模块。
- 第三方模块:社区或第三方开发好的功能模块,可以直接拿回来用。
-
模块的定义
- Node 内部提供一个 Module 构建函数。所有模块都是 Module 的实例,属性如下:
module.id
模块的识别符,通常是带有绝对路径的模块文件名。module.filename
模块定义的文件的绝对路径。module.loaded
返回一个布尔值,表示模块是否已经完成加载。module.parent
返回一个对象,表示调用该模块的模块。module.children
返回一个数组,表示该模块要用到的其他模块。module.exports
表示模块对外输出的值。
- 载入一个模块就是构建一个 Module 实例,一个新的 JS 文件就是一个模块
// 导出方式,`module.exports` 和 `exports` exports.name = value; module.exports = { name: value };
module.exports
是用于为模块导出成员的接口;
exports
是指向module.exports
的别名,相当于在模块开始的时候执行:var exports = module.exports
。 - Node 内部提供一个 Module 构建函数。所有模块都是 Module 的实例,属性如下:
-
用Node手写一个简单的 require
function $require(files) { const fs = require('fs'); const path = require('path'); // 注意,这里实现的缓存不是Node的缓存机制 const filename = path.join(__dirname, files); $require.cache=$require.cache||{}; if($require.cache[filename]) return $require.cache[filename].exports; const dirname=path.dirname(filename); const file = fs.readFileSync(filename); const module = { id:filname, exports: {} }; // 保存module.exports重新赋值前的值 const { exports } = module; const code = ` (function (module,exports,__dirname,__filename) { ${file} })(module,exports,dirname,filename) `; eval(code); $require.cache[filename]=module; return module.exports; }
从以上代码我们可以知道:
- 模块只执行一次 之后调用获取的
module.exports
都是缓存哪怕这个js
还没执行完毕(因为先加入缓存后执行模块)。 - 模块导出就是
return
这个变量的其实跟a = b
赋值一样, 基本类型导出的是值, 引用类型导出的是指针(内存地址)。 exports
和module.exports
持有相同引用,因为最后导出的是module.exports
,所以对exports
进行赋值会导致exports
操作的不再是module.exports
的引用。
- 模块只执行一次 之后调用获取的
四、require 加载文件规则
Node 使用 CommonJS 模块规范,内置的 require 函数用于加载模块文件。require 的基本功能是,读入并执行一个 javascript 文件,然后返回该模块的 exports 对象。 如果没有发现指定模块,会报错。
require 加载文件规则如下:
- require 加载 js 文件时可以省略扩展名,也可以直接加载 json 文件
- 通过 ./ 或 ../ 开头:则按照相对路径从当前文件所在文件夹开始寻找模块
require('../file.js'); // 上级目录下找 file.js 文件
require('./file.js'); // 同级目录找 file.js 文件
require('file.js'); // 同级目录找 file.js 文件
- 通过 / 开头:则以系统根目录开始寻找模块
require('/vue-template/src/main.js'); // 以绝对路径的方式找
- 如果 require 传入的是一个目录的路径,会自动查看该目录的 package.json 文件,然后加载 main 字段指定的入口文件
- 如果 package.json 文件没有main字段,或者根本就没有 package.json 文件,则默认找目录下的 index.js 文件作为模块
- 如果参数字符串不以
../
或./
或/
开头,则表示加载的是一个默认提供的核心模块(位于 Node 的系统安装目录 node_modules)中 - 缓存文件的加载优先级最高,同名的系统模块要比自定义模块优先级高
- Node 在加载系统模块的时候,如果当前文件夹里面没有 node_modules 文件夹就会去逐层向上查找至项目根目录直到找到为止,如果没有就会报错。
五、ES6 模块
- 区别 CommonJS 模块
- CommonJS 模块输出的是一个值的拷贝,ES6 模块输出的是值的引用
- CommonJS 模块输出的是值的拷贝,也就是说一旦输出一个值,模块内部的变化就影响不到这个值。
// lib.js const counter = 3; const incCounter = () => { counter++; }; module.exports = { counter, incCounter, }; // main.js const mod = require('./lib'); console.log(mod.counter); // 3 mod.incCounter(); console.log(mod.counter); // 3
- ES6 模块是动态引用,并且不会缓存值,模块里面的变量绑定其所在的模块。
JS 引擎对脚本静态分析的时候,遇到模块加载命令// lib.js export let counter = 3; export const incCounter = () => { counter++; }; // main.js import { counter, incCounter } from './lib'; console.log(counter); // 3 incCounter(); console.log(counter); // 4
import
,就会生成一个只读引用。等到脚本真正执行时,再根据这个只读引用,到被加载的那个模块里面去取值。换句话说,原始值变了,import
加载的值也会跟着变。再举一例子:
上面代码表明,ES6 模块不会缓存运行结果,而是动态地去被加载的模块取值,并且变量总是绑定其所在的模块。// m1.js export var foo = 'bar'; setTimeout(() => foo = 'baz', 500); // m2.js import {foo} from './m1.js'; console.log(foo); // bar setTimeout(() => console.log(foo), 500); // baz
- CommonJS 模块输出的是值的拷贝,也就是说一旦输出一个值,模块内部的变化就影响不到这个值。
- CommonJS 模块是运行时加载,ES6 模块是编译时输出接口
- CommonJS 模块加载的是一个对象(即
module.exports
属性),该对象只有在脚本运行完才会生成,然后再从这个对象上面读取相关方法,这种加载称为运行时加载。 - ES6 模块不是对象,而是通过
export
命令显式指定输出的代码,import
时采用静态命令的形式,即在代码静态解析阶段就会生成,而不是加载整个模块,这种加载称为编译时输出。这也是ES6 模块非常轻松的实现Tree Shaking
的重要因素。
- CommonJS 模块加载的是一个对象(即
- 使用
import
被导入的变量是只读的,不能被重新赋值。 import
会自动提升到代码的顶层,以下代码都会报错:const num = 100; import xxx from 'xxx-module';
因为,CommonJS 模块是动态语法可以写在判断里,ES6 模块静态语法只能写在顶层。// if for while ... if(boolExp){ import xxx from 'xxx-module'; }
- CommonJS 模块的顶层作用域里
this
指向当前模块,ES6 模块的顶层作用域里this
指向undefined
。 - CommonJS 模块的循环引用
运行此段代码结合上面的// a.js module.exports.a = 1; var b = require('./b'); console.log(b); module.exports.a = 2; // b.js module.exports.b = 11; var a = require('./a'); console.log(a); module.exports.b = 22; //main.js var a = require('./a'); console.log(a);
require demo
,分析每一步过程:执行 node main.js -> 第一行 require(a.js)
,(node
执行也可以理解为调用了require方法,我们省略require(main.js)
内容)进入 require(a)方法: 判断缓存(无) -> 初始化一个 module -> 将 module 加入缓存 -> 执行模块 a.js 内容
,(需要注意 是先缓存, 后执行模块内容)a.js: 第一行导出 a = 1 -> 第二行 require(b.js)
(a 只执行了第一行)进入 require(b) 内 同 1 -> 执行模块 b.js 内容
b.js: 第一行 b = 11 -> 第二行 require(a.js)
require(a) 此时 a.js 是第二次调用 require -> 判断缓存(有)-> cachedModule.exports -> 回到 b.js
(因为js
对象引用问题 此时的cachedModule.exports = { a: 1 }
)b.js:第三行 输出 { a: 1 } -> 第四行 修改 b = 22 -> 执行完毕回到 a.js
a.js:第二行 require 完毕 获取到 b -> 第三行 输出 { b: 22 } -> 第四行 导出 a = 2 -> 执行完毕回到 main.js
main.js:获取 a -> 第二行 输出 { a: 2 } -> 执行完毕
。
- CommonJS 模块的循环引用
// bar.js import { foo } from './foo'; console.log(foo); export const bar = 'bar'; // foo.js import { bar } from './bar'; console.log(bar); export const foo = 'foo'; // main.js import { bar } from './bar'; console.log(bar);
执行 main.js -> 导入 bar.js
bar.js -> 导入 foo.js
foo.js -> 导入 bar.js -> bar.js 已经执行过(它认为这个接口已经存在了,就不会再去执行) -> 输出 bar -> bar is not defined, bar 未定义报错
。
我们可以使用function
的方式解决:
这是因为函数具有提升作用,在执行// bar.js import { foo } from './foo'; console.log(foo()); export function bar(){ return 'bar'; } // foo.js import { bar } from './bar'; console.log(bar()); export function foo(){ return 'foo'; } // main.js import { bar } from './bar'; console.log(bar());
import { foo } from './foo'
时,函数bar
就已经有定义了,所以foo.js
加载的时候不会报错。这也意味着,如果把函数foo
改写成函数表达式,也会报错。
- CommonJS 模块输出的是一个值的拷贝,ES6 模块输出的是值的引用
六、Node 加载
Node 对 ES6 模块的处理比较麻烦,因为它有自己的 CommonJS 模块格式,与 ES6 模块格式是不兼容的。目前的解决方案是,将两者分开,ES6 模块 和 CommonJS 模块采用各自的加载方案。从 v13.2 版本开始,Node 已经默认打开了 ES6 模块支持。在此版本之前,想要在 Node 中使用 ES6 模块,需要添加--experimental-modules
,如:node --experimental-modules ./index.mjs
。
Node 要求使用 ES6 模块需要采用.mjs
后缀文件名。也就是说,Node 遇到.mjs
文件,就认为它是ES6 模块,默认启用严格模式,不必在每个模块文件顶部指定"use strict"
。
如果不希望将后缀名改成.mjs
,可以在项目的package.json
文件中,指定type
字段为module
。如果不希望将后缀名改成.mjs
,可以在项目的package.json
文件中,指定type
字段为module
。一旦设置了以后,该目录里面的 JS 脚本,就被解释用 ES6 Module。
{
"type": "module" // 开启 ES6 Module 模式
}
如果这时还要使用 CommonJS 模块,那么需要将 CommonJS 模块脚本的后缀名都改成.cjs
。如果没有type
字段,或者type
字段为commonjs
,则.js
脚本会被解释成 CommonJS 模块。
1、main 和 exports 字段
package.json
文件有两个字段可以指定模块的入口文件:main
和exports
。
main
字段
比较简单的模块,可以只使用main
字段,指定模块加载的入口文件。
上面代码指定项目的入口脚本为{ "type": "module", "main": "./index.js" }
./index.js
,它的格式为 ES6 模块。如果没有type
字段,index.js
就会被解释为 CommonJS 模块。- exports 字段
exports
字段的优先级高于main
字段。它有多种用法。
(1)子目录别名
package.json
文件的exports
字段可以指定脚本或子目录的别名。
(2)main 字段的别名// package.json { "exports": { "./xxx-file-name": "./xxx-dir/xxx.js", // 指定脚本别名 "./xxx-dir-name/": "./xxx-dir/", // 指定子目录别名 } } // 模块引入 import module1 from 'project-name/xxx-file-name'; import module2 from 'project-name/xxx-dir-name/xxx.js';
exports
字段的别名如果是.
,就代表模块的主入口,优先级高于main
字段,并且可以直接简写成exports
字段的值。
由于{ "exports": { ".": "./main.js" } } // 等同于 { "exports": "./main.js" }
exports
字段只有支持 ES6 的 Node 才认识,所以可以用来兼容旧版本的 Node。
(3)条件加载{ "main": "./old-version.js", "exports": { ".": "./new-version.js" } }
利用.
这个别名,可以为 ES6 Module 和 CommonJS 模块指定不同的入口。目前,这个功能需要在 Node 运行的时候,打开--experimental-conditional-exports
标志。
上面可以简写如下:{ "type": "module", "exports": { ".": { "require": "./main.cjs", // require 规定 CommonJS 模块的入口 "default": "./main.js" // default 规定 `default` 条件指定其他情况的入口,即 ES6 模块的入口 } } }
如果有别名则只能如下:{ "exports": { "require": "./main.cjs", "default": "./main.js" } }
{ "exports": { ".": { "./xxx-file-name": "./xxx-dir/xxx.js", "require": "./main.cjs", "default": "./main.js" } } }
2、ES6 模块加载 CommonJS 模块
有了上一节的条件加载以后,Node 本身就可以同时处理两种模块。
{
"type": "module",
"main": "./index.cjs",
"exports": {
"require": "./index.cjs",
"default": "./wrapper.mjs"
}
}
注意,import
命令加载 CommonJS 模块,只能整体加载,不能只加载单一的输出项。
// 正确
import packageMain from 'commonjs-package';
// 报错
import { method } from 'commonjs-package';
3、CommonJS 模块加载 ES6 模块
CommonJS 模块的require
命令不能加载ES6 模块,会报错,只能使用import()
这个方法加载。
4、Node 的内置模块
Node 的内置模块可以整体加载,也可以加载指定的输出项。
七、总结
以上就是我对模块化加载的理解,当然这里没有去过多的写案例,目的是为了以后回来查阅方便,方便自己记忆。