Zijue/blog

4.node 模块化

Opened this issue · 0 comments

Zijue commented

为什么需要模块化

模块本质上是封装,把模块内可访问的和不可以访问的区分得清清楚楚。同时为了解决冲突,实现高内聚低耦合

node 的模块化机制

node 遵循了 CommonJS 的模块规范来隔离每个模块的作用域。与 ES6 模块规范对比

  • CommonJS 依赖于 node 的特性,可以按需依赖,无法实现 tree-shaking
  • ES6 模块只能静态依赖,可以实现 tree-shaking

CommonJS 规范

  • 每一个文件都是一个模块
  • 需要通过 module.exports 导出需要给别人使用的值
  • 通过 require 拿到需要的结果

CommonJS 规范中分三种模块

  • 内置模块和核心模块:node 中自带的不需要安装,引用的时候不需要添加路径
  • 第三方模块
  • 自定义模块、文件模块

下面介绍三个内置模块便于后面学习 CommonJS 规范实现原理

const fs = require('fs');

let r1 = fs.readFileSync('./1.js', 'utf8');  // 同步读取文件内容
let r2 = fs.existsSync('./1.js');  // 同步判断是否存在的方法
const path = require('path');

console.log(path.resolve(__dirname, '1.js')); // 解析出一个绝对路径,默认以 process.cwd() 解析
console.log(path.join(__dirname, 'a', 'b')); // join 和 resolve 可以互换使用,但是‘/’不能使用 resolve,会跑到根路径下
console.log(path.resolve(__dirname, 'a', '/', 'b')); // 输出结果为‘/b’
console.log(path.join(__dirname, 'a', '/', 'b')); // 输出结果为‘xx/xx/xx/a/b’

console.log(path.extname('a.min.js')); // .js 获取文件扩展名
console.log(path.relative('a/', 'a/b/1.js')); // b/1.js 相减取差异的部分
console.log(path.dirname('a/b.js')); // a 获取目录名
const vm = require('vm');

let a = 1;
vm.runInThisContext('console.log(a)'); // ReferenceError: a is not defined

a = 1; // 等同于 global.a = 1;
vm.runInThisContext('console.log(a)'); // 1

// vm 运行方式同 new Function,但是不需要把字符串包装成函数。Function 与 eval 不同的是,Function 创建的函数只能在全局作用域中运行

使用 vscode 调试 node 代码

点击 debugger 按钮先建 node 调试文件 launch.json

// launch.json

{
    // 使用 IntelliSense 了解相关属性。 
    // 悬停以查看现有属性的描述。
    // 欲了解更多信息,请访问: https://go.microsoft.com/fwlink/?linkid=830387
    "version": "0.2.0",
    "configurations": [
        {
            "type": "node",
            "request": "launch",
            "name": "启动程序",
            "skipFiles": [
                "<node_internals>/**" // 该项表示跳过 node 源代码,需要查看源代码时应删除
            ],
            "program": "${workspaceFolder}/temp.js"
        }
    ]
}

CommonJS 规范实现原理

通过调试源码梳理 require 模块引入代码执行顺序如下

  1. require 是一个 Module 原型上的方法 Module.prototype.require
  2. Module._load 加载方法(模块加载)
  3. Module._resolveFilename(解析文件名变成绝对路径并且带有后缀)
  4. new Module 创建一个模块 (id, exports) require 方法获取到的是 module.exports 属性
  5. Module.prototype.load 进行模块的加载
  6. js 模块 json 模块 根据不同的文件扩展名使用不同的策略去进行模块的加载
  7. fs.readFileSync 读取文件的内容
  8. module._compile 将内容包裹进一个函数
const wrapper = [
  '(function (exports, require, module, __filename, __dirname) { ',
  '\n});'
];
  1. 让文件执行,用户会给 exports 赋值
  2. 最终获取到的是 module.exports 的结果

核心原理流程

  • 读取文件
  • 创建 exports 空对象,并传给用户
  • 用户赋值后返回

手写 require 模块原理实现

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, 'utf-8'); // 读取文件内容
        let code = `(function(exports, require, module, __filename, __dirname){
            ${script}
        })`;
        let func = vm.runInThisContext(code);
        let exports = module.exports;
        let thisVal = exports;
        let dirname = path.dirname(module.id);
        func.call(thisVal, exports, req, module, module.id, dirname);
    },
    '.json'(){}
}

Module._resolveFilename = function(id){
    let filepath = path.resolve(__dirname, id);
    // 查看该文件是否存在,如果不存在尝试添加后缀
    let isExists = fs.existsSync(filepath);
    if (isExists) return filepath; // 文件存在则直接返回
    let keys = Object.keys(Module._extensions)
    for (let i = 0; i < keys.length; i++) {
        let newFilepath = filepath +  keys[i];
        if(fs.existsSync(newFilepath)) return newFilepath
    }
    // 循环结束仍未返回文件路径,说明文件不存在
    throw new Error('模块文件不存在')
}

Module.prototype.load = function(){
    // 核心的加载方法,根据文件不同的后缀名进行加载
    let extname = path.extname(this.id);
    Module._extensions[extname](this);
}

Module._load = function(id){
    let filename = Module._resolveFilename(id); // 就是将用户的路径变成绝对路径
    let module = new Module(filename);
    module.load(); // 内部会读取文件,用户会给 exports 对象赋值
    return module.exports
}
// 自定义 require 方法
function req(id){ // 根据用户名加载模块
    return Module._load(id)
}

const r = req('./1');
console.log(r);  // > zijue
<--------------------------------->
// 1.js
module.exports = 'zijue' 

给模块导入加缓存

Module._cache = {}
Module._load = function(id){
    let filename = Module._resolveFilename(id); // 就是将用户的路径变成绝对路径
    if(Module._cache[filename]){
        return Module._cache[filename].exports // 如果有缓存直接将上次缓存的结果返回
    }
    let module = new Module(filename);
    Module._cache[filename] = module;
    module.load(); // 内部会读取文件,用户会给 exports 对象赋值
    console.log('no cache')
    return module.exports
}

添加 .json 导入方法

Module._extensions = {
    '.js'(module){
        ...
    },
    '.json'(module){
        let script = fs.readFileSync(module.id, 'utf-8'); // 读取文件内容
        module.exports = JSON.parse(script);  // 文件中没有 module.exports,直接手动赋值
    }
}

module.exports 与 exports 的关系

在源码 let exports = module.exports 中 module.exports 和 exports 指向同一个引用类型 {},故 exports = [newVal] 并不会改变 module.exports 的空间

// 1.js
exports = 'zijue'

// test.js
...
const r = req('./1');
console.log(r);  // > {}

// <------------------------>
exports.a = 'zijue'

// test.js
...
const r = req('./1');
console.log(r);  // > { a: 'zijue' }

// <------------------------>
// module.exports 和 exports 同时写
module.exports = 'zijue 1'
exports = 'zijue 2'

// test.js
...
const r = req('./1');
console.log(r);  // > zijue 1
// module.exports = exports = {} ==> module.exports = 'zijue 1' ==> exports = 'zijue 2'

由此引出另一个问题:所有相关模块中的 global 都是同一个,尽量不要使用 global,可能会污染全局变量;但是有些例外,比如:数据库连接属性 conn

ES6 中既可以使用 export default 也可以使用 exports;ES6中使用exports {a}方式导出的是一个变量a,当使用import {a}时取值。