3. commonjs模块的实现原理&npm

377 阅读5分钟

模块

  • es6Module 静态导入 在编译的时候就可以知道使用了哪些变量 可以实现tree-shaking
  • commonjs 动态导入 不支持tree-shaking

commonjs规范

  • 想使用哪个模块就require谁 (后缀可以省略,默认会查找.js文件,没有js找.json)
  • 想被别人使用需要导出 module.exports
  • 在node中每一个js/json文件就是一个模块
  • 一个包中包含多个模块(每个包都必须配置一个package.json文件)

原理: 读取文件 => 包装自执行函数,设置参数 => 默认返回module.exports对象

运行流程分析

let a = require("./del");

  1. mod.require(path) -> Module.prototype.require
  2. Module._load 加载模块
  3. Module._resolveFilename 方法就是把路径变成绝对路径 添加后缀名
  4. 实现模块的缓存(根据绝对路径进行模块的缓存)
    • module.exports 会在第一次缓存起来(导出的结果如果是一个对象内部属性变了会跟着变, 普通值不会变)后续再去使用的话会取上次的返回值
    • es6模块使用export {} 导出的是一个变量,如果内部对应的值发生变化 导出的是会跟着变的
  5. 会尝试加载是不是一个原生模块,如果带相对路径或者绝对路径就不是核心模块
  6. 拿到绝对路径,创造一个模块 new Module。 几个重要的属性: this.id this.exports = {} this.path 父路径
  7. module.load 对模块进行加载
  8. 根据文件后缀 去做策略加载
  9. 用的是同步读取
  10. 增加一个函数的壳子,并让函数执行,让 module.exports 作为了 this
  11. 用户会默认拿到 module.exports 的返回值
  12. 最终返回的是 exports 对象

node 中 commonjs 规范的简单实现

const fs = require('fs');
const path = require('path');
const vm = require('vm');

function Module(id) {
  // 传入的id是文件的绝对路径
  this.id = id;
  this.exports = {};
}
Module._cache = {};
Module._extensions = {
  '.js'(module) {
    // 读取脚本
    let script = fs.readFileSync(module.id, 'utf8');
    // 包装函数,生成字符串
    let template = `(function(exports, module, require, __dirname, __filename){${script}})`;
    // 字符串变成函数
    let fn = vm.runInThisContext(template); // 相当于 new Function
    // 函数执行,将this指向module.exports
    let exports = module.exports;
    let thisValue = exports; // this = module.exports = exports
    let filename = module.id;
    let dirname = path.dirname(filename);
    // 函数执行,调用了模块,也就完成了 module.exports的赋值
    fn.call(thisValue, exports, module, req, dirname, filename);
  },
  '.json'(module) {
    // 获取文件内容
    let script = fs.readFileSync(module.id, 'utf8');
    // 导出
    module.exports = JSON.parse(script);
  },
};
// 返回require文件的绝对路径
Module._resolveFilename = function (id) {
  let filePath = path.resolve(__dirname, id);
  console.log(filePath, 'filePath');

  // 通过判断filePath这个路径存不存在来看传入的路径有没有加后缀
  let isExists = fs.existsSync(filePath);
  if (isExists) return filePath;
  // 不存在 则尝试添加后缀
  let keys = Reflect.ownKeys(Module._extensions); // 拿到所有的key
  for (let i = 0; i < keys.forEach.length; i++) {
    let newPath = filePath + keys[i];
    if (fs.existsSync(newPath)) return newPath;
  }
  throw new Error('module not found');
};
// 加载模块,让用户给module.exports赋值
Module.prototype.load = function () {
  // 不需要传任何参数,this就指向创建的module实例
  // 先取文件后缀名
  let ext = path.extname(this.id);
  // 根据后缀名,采用不同的策略去加载
  Module._extensions[ext](this);
};
/*
自实现的require方法
filename是传入的文件路径
*/
function req(filename) {
  // 返回绝对路径,并且加上后缀
  filename = Module._resolveFilename(filename);
  if (Module._cache[filename]) {
    return Module._cache[filename].exports;
  }
  // 创造一个模块
  const module = new Module(filename);
  // 增加缓存
  Module._cache[filename] = module;
  // 对模块进行加载
  module.load(); // 就是让用户给module.exports赋值
  // 最终导出module.exports
  return module.exports; // 默认是空对象
}
// let a = require("./del");
let a = req('./del');

console.log(a, '输出');

⚠️注意点

this = module.exports = exports 指向的是同一个引用地址 如果将exports更改了 module.exports不会变

  • 导出的时候 module.exports = "aaa"
  • 不能 exports = "aaa";
  • 但是可以这样导出 exports.a = "111" this.b = "222";(因为没有改变引用地址,和 module.exports 指向同样的引用地址)
  • 小结:最终用户使用的结果都是来自于module.exports; 不要同时使用exports和module.exports否则会以module.exports结果为基准

npm

  • 全局模块:安装在电脑的 npm 下,仅仅在命令行中使用,做一些工具

    全局安装的模块只能在命令行中使用 npm 默认在电脑的环境变量里,所以可以直接使用。安装的全局模块都在 npm 下生成了一个快捷方式,所以也可以直接用

    • 默认会安装在c盘下的npm/node_modules, npm root -g 可查看;npm i xxx -g安装完成后除了将文件放到全局目录中之外,还会默认生成软链(快捷命令)
    • 全局安装的模块包为什么要放到npm目录下:因为npm是添加在系统环境变量里的,所以当执行模块包的命令的时候,会去npm目录下查找

自实现一个全局模块

  • 包必须要初始化 npm init
  • 现在需要让这个包运行在当前的命令行内,需要在package.json中配置“bin”
// package.js
{
    "name": "zhuzhu",
    "version": "1.0.0",
    "description": "",
    "main": "index.js",
    "bin": "./1.js"
}
// 表示当在命令行执行zhuzhu这个命令,实际调用的就是"./1.js"这个文件
  • 希望把这个包放到全局安装npm目录下:在开发时可以临时将这个包放到全局下,通过npm link命令创建软链
  • 在bin指定的文件1.js头部加上#! /usr/bin/env node,这个话的含义是告诉在命令行执行“zhuzhu”命令的时候不是把这个文件打开,而是用系统的环境变量node来执行文件里的内容
  • (需要重新npm link(npm link --force)一下,才能生效)
  • 真正使用的时候会将包发布到npm上,就可以通过 npm i xxx -g来正常使用

nrm

  • 切换源
  • nrm use cnpm/taobao/npm

nvm

  • 切换node版本号

依赖

  • devDependencies
  • dependencies
  • peerDependencies 依赖的模块
  • optionalDependencies
  • bundleDependencies
    • npm pack 打包成压缩包
    • package.json 中配置:"bundleDependencies":["bootstrap"] 压缩打包的时候就会把 node_modules bootstrap 打包进去

命令执行问题 -- 将模块安装到项目里,该如何使用

  • 如webpack 一般安装本地 不安装全局,因为安装到全局每个人用到的webpack版本可能不一样,安装到本地可以锁定webpack的版本,所以是为了保证版本一致所以安装到本地
  • 在本地安装mime
    • 配置"script": {"mime":"mime a.js"}
    • npm run mime 可以执行 在命令行里直接 mime 却不可以
    • npm run 时把当前项目下的node_modules/.bin目录放到了环境变量里,所以就可以间接执行配置了的mime a.js 如果直接用 npm run script 方式默认在执行命令之前会将其添加到全局的环境变量,所以可以使用,但是命令执行完毕之后会删除掉对应的 path
  • npx 和 npm run 类似,可以 npx mime a.js 使用,但是一般用 npm run npx 的好处是: 如果模块不存在会先安装再使用,使用后自动删除掉 如果当前项目下有就直接复用,如果没有就先安装 好处:永远是最新的

版本号

  • 管理的方式 semver major.minor.patch
    • ^2.2.4 第一位只能是 2
    • ~2.2.4 只能是2.2,第三位比4大都可以
    • 特殊的
      • alpha 内部测试
      • beta 公测版本
      • rc 最终测试版本
      • 最后发布正式版本
  • 包的发布
    • 看包是否重名
    • 切换到本地 npm 源
    • 登录账号 发布

模块查找机制

  • 一般 require 都是相对路径
    • 会判断路径是不是核心模块/或者第三方模块,是核心模块就不做后面的事情了
    • 默认先查找同名文件,如果没找到尝试添加.js/.json 后缀查找文件
    • 如果没有,查找同名文件夹(当成一个包),查找 package.json(找设置的入口文件), 如果没有就找 index.js
    • 如果没有就报错
  • 第三方模块
    • 默认会沿着当前目录向上查找,查找 node_modules 下的同名文件夹,根据(package.json 中的 main)-> index.js 中查找
    • 如果没有找到向上查找 查找上级的 node_modules
    • 如果到根路径还没有找到就报错了