node 中的模块分类
- 内置模块、核心模块(不需要安装直接能用,node 自带的)
- 文件模块(自己实现的模块,有相对或者绝对路径)
- 第三方模块(比如 npm 包)
es6module 和 commonJs 导入方式的区别
- es6 Module 静态导入(前置导入),在编译的时候就可以知道使用了哪些变量,可以实现 tree-shaking,而 commonJs 模块使用的时候动态导入(随用随导),所以它是不支持 tree-shaking
- commonJs 会缓存导入的模块,也就是说,模块内值类型的更改,模块使用者并不会感知,对象类型因为导出的是个指针,所以引用者可以拿到新值。
// es6 module 静态导入,编译时已经确定要导入哪些内容,这里不考虑 es7 import() 语法
import { a } from './constant.js';
// commonJs 动态导入,编译时无法确定导入哪些内容
function fn() {
var { a } = require('./constant.js');
}
commonJs 模块定义
commonJs 模块定义了自己的规范:
- 如果想要使用哪个模块,直接 require 即可,后缀可以省略,默认会查找 .js 文件,没有则查找 .json 文件。
- 如果这个模块需要被别人使用,需要通过 module.exports 导出具体的内容。
- 在 node 中,每个 js、json 都是一个模块。
- 一个包由多个模块组成(每个包都必须配置一个 package.json 文件)。
// a.js
module.exports = 1;
// index.js
var a = require('./a');
console.log(a); // 1s
commonJs 的理论实现
在浏览器环境中,js 如果想拿到某个第三方模块,不打包的情况下只能通过发送 http 请求,而 node 环境下 js 拥有文件读写的能力,所以 node 环境下引用模块使用的是同步文件读取的方式。
为什么 node 中引用模块是同步读取文件呢,想一下 node 引入其他模块的时候,没有写回调函数,而且下面立即可用~
我们知道,node 模块执行的时候,会在外部包裹一个自执行函数,并且接收五个参数,如果不知道的同学可以传送到我上一篇介绍 node 的博客,传送门
所以 index.js 实际的执行原理应该如下:
// @1 读取模块文件,读取到的是字符串哦
// @2 包装匿名函数,设置参数
// @3 默认返回 module.exports 对象
const a = function(module, exports, require, __dirname, __filename) {
// 文件读取来的 a 模块
module.exports = `module.exports=1`;
return module.exports;
}();
console.log(a);
Node 断点调试 require
chrome inspect 调试
> node --inspect-brk index.js // 执行 index.js 并在第一行断住
打开 chrome://inspect/#devices 进行代码调试,具体参考 node 调试指南
vscode 调试
- 编辑器最左侧选择运行和调试
- 点击创建 launch.json 文件,弹出的下拉列表选择 Node.js,修改配置项
修改 launch.json 文件:
// launch.json
{
"version": "0.2.0",
"configurations": [
{
"type": "pwa-node",
"request": "launch",
"name": "Launch Program",
// 跳过 node 源码,这里我们需要干掉
// "skipFiles": [
// "<node_internals>/**"
// ],
"program": "${workspaceFolder}/node/module/index.js"
}
]
}
点击左上角运行按钮,发现已经断到了文件,基本和浏览器调试一致~
ok,可以开始断点调试了。
断点分析 commonJS 实现原理
通过断点源码,我总结了如下几条 commonJS 实现的细节。
- 首先需要实现一个 require 方法,该方法接收一个路径
- _load 方法开始:执行 Module._load 方法,该方法返回 module.exports
- 全路径解析:执行 Module._resolveFilename 把文件路径解析成一个绝对路径
- 模块缓存:实现模块的缓存(根据绝对路径对模块进行缓存)
- 创建模块:根据文件绝对路径创建一个模块,一个模块必须要有三个属性, id、path、exports『后续文件导出的结果需要保存到这个变量上』
- 模块加载: module.load 根据创建的模块,进行模块加载
- 第三方模块路径解析:加载模块之前,会构建一个 paths 属性,这个属性就是第三方模块的查找路径数组(一层层的 node_modules),找到根目录为止。
- 调用加载模块的方法(策略模式):取出文件的后缀 Module._extensions,调用对应加载模块的策略方法。
- 读取文件: 策略方法中通过 fs.readFileSync 读取文件内容,并调用 module._compile 方法
- 函数包裹:_compile 方法中调用 Module.wrap 方法在文件内容外面包裹一个字符串形式的函数 wrapper,该方法接收 exports, require, module, __filename, __dirname 五个参数,并把模块内容塞进函数体中。
- 沙箱执行字符串:_compile 方法中中使用 vm.runInThisContext 使字符串函数 wrapper 变为可执行的函数 compiledWrapper,然后执行 compiledWrapper,传入 this, 传入五个参数,用户自己模块内设置的 module.exports = xxx 给 module.exports 赋值。
- 返回结果:最终 require 方法,会拿到 Module._load 的返回值,也就是 module.exports,完事儿
手动实现 commonJS 规范
// a.js
module.exports = 1;
// b.json
{
"name": "埃?就是玩"
}
myRequire 实现
const path = require('path');
const fs = require('fs');
const vm = require('vm'); // 可以创建独立沙箱去执行字符串
function Module(id) {
this.id = id; // 全路径
this.exports = {}; // 模块最终导出结果
}
// 策略模式,根据不同扩展名,调用不同加载模块的方法
Module._extensions = {
'.js'(module) {
let script = fs.readFileSync(module.id, 'utf8'); // 读取文件内容
let wrapper = `(function(exports, module, require, __filename, __dirname) { ${ script } })`;
// 把字符串函数转为 可执行的 js 函数
let compiledWrapper = vm.runInThisContext(wrapper);
let exports = module.exports; // 为了实现一个简写
// 模块执行中的 this,this = exports = module.exports = {};
let thisValue = exports;
let filename = module.id; // 模块的全路径
let dirname = path.dirname(filename);
// 凑齐五个参数,可以召唤神龙了,利用 ${ script } 修改 module.exports
compiledWrapper.call(thisValue, exports, module, myRequire, filename, dirname);
},
'.json'(module) {
let json = fs.readFileSync(module.id, 'utf8'); // 读取文件内容
// json 对象不需要写 module.exports 语法 需要我们手动更改
module.exports = JSON.parse(json);
}
}
// 静态方法 把文件路径解析成一个绝对路径 并补全后缀
Module._resolveFilename = function(filename) {
const filePath = path.resolve(__dirname, filename);
let exists = fs.existsSync(filePath); // 当前文件是否存在
if (exists) return filePath;
// 尝试添加 .js 或者 .json 后缀
let keys = Reflect.ownKeys(Module._extensions);
for (let i = 0; i < keys.length; i++) {
let newPath = filePath + keys[i];
if (fs.existsSync(newPath)) return newPath;
}
// 最后也没匹配到要引用的模块 则报错
throw new Error('module not found');
}
// 模块缓存
Module._cache = {};
// 模块加载
Module.prototype.load = function() {
// 根据要加载的文件扩展名,来决定使用哪个策略加载
let extension = path.extname(this.id);
// 加载模块
Module._extensions[extension](this);
}
/**
* @description 手动实现 commonJS require 方法
* @param { String } filename 文件路径, 可能不具备后缀哦
*/
function myRequire(filename) {
let fullPath = Module._resolveFilename(filename);
let cachedModule = Module._cache[fullPath]; // 缓存中是否存在
if (cachedModule) {
return cachedModule.exports;
}
// 创造一个模块
let module = new Module(fullPath);
Module._cache[fullPath] = module;
// 获取模块中的内容,包装函数,让函数执行,给 module.exports 赋值
module.load();
return module.exports;
}
let a = myRequire('../a');
console.log(a); // 1
// let b = myRequire('../b');
// console.log(b);
思考:为什么不能使用 expors = 1 导出变量
// a.js
exports = 1;
// index.js
let a = require('./a');
console.log(a); // {}
为什么取不到值呢?我们根据源码看一下
let exports = module.exports = {};
exports = 1;
// module.exports 仍然执行空对象的堆内存地址
// 如果 exports.a = 1; 或者 this.a = 1; 这时候 module.exports 就会变了~
共享 global
// a.js
global.a = 1;
// index.js
let a = require('./a');
console.log(global.a); // 1
这样也是可以拿到的,因为源码中会把 a.js 中的代码放到 index.js 中去执行(拿一个函数包裹),只要执行了,global 上就有 a 属性。不过并不建议这么玩,会污染全局变量哦。