手写 CommonJS:require 加载与缓存实现

本节目标

我们来手写一个极简 CJS 加载器(Node 自带的不用,纯演示原理)。

// 运行环境:Node.js 16+(保存为 .cjs 运行)
const fs = require('fs')
const path = require('path')
const vm = require('vm')

const cache = {} // 模拟 require.cache
function myRequire(modulePath) {
  const abs = path.resolve(modulePath) // 解析绝对路径
  if (cache[abs]) return cache[abs].exports // 命中缓存直接返回
  const code = fs.readFileSync(abs, 'utf8')
  const module = { exports: {} }
  cache[abs] = module
  // 用 vm 在「带 require/module/exports」的自定义沙箱上下文里执行模块代码
  const sandbox = {
    require: myRequire,
    module,
    exports: module.exports,
    console,                       // 让模块内部也能 console.log
    __filename: abs,               // 模块里可读取自己的路径
    __dirname: path.dirname(abs),
  }
  vm.runInNewContext(code, sandbox) // 沙箱里的顶层 var 不会泄漏成全局,实现模块隔离
  return module.exports
}

// 使用示例:
// math.cjs 内容:module.exports = { add:(a,b)=>a+b }
const math = myRequire(path.resolve(__dirname, 'math.cjs'))
console.log(math.add(1, 2)) // 3

关键点:require 不过是「读文件 → 放进 vm 沙箱上下文(注入 require/module/exports 作为全局)→ 执行 → 缓存 exports」。这就是 Node CJS 的全部秘密。

名词解释

手写模块加载器:自己实现一个 require,用来「看透」模块系统的本质。核心三步永恒不变:① 路径解析(相对→绝对);② 给模块代码注入 require/module/exports 后执行;③ 把 module.exports 缓存。理解了这三点,任何模块系统(CJS/AMD)你都能自己造。

vm(虚拟机)模块:Node 的 vm 能在「自定义沙箱上下文」里执行字符串代码。手写加载器用 vm.runInNewContext(code, sandbox):把模块源码和 sandbox(里面挂了 require/module/exports/console/__filename/__dirname)一起丢进一个隔离的全局环境跑。模块顶层的 var 只活在沙箱里、不会泄漏成真正的全局——这就是「模块隔离」的真实原理。它比 new Function 更贴近 Node 跑 CJS 文件的底层手法。

课后练习

练习 1:手写 require 里 cache[abs] 这一行解决了什么问题?

答案:模块只执行一次(单例 + 性能)。第二次 myRequire 同一文件直接返回缓存的 exports,不重新读文件、不重新执行。这就是 require.cache 机制的迷你版,对应 Node 的真实行为。

练习 2:为什么模块代码要「包成函数再执行」,而不是直接 eval?

答案:包成函数能「给每个模块独立的作用域(参数就是 require/module/exports)」,模块顶层的 var 不会泄漏成全局;eval 则在当前作用域执行,变量互相污染。函数作用域才是模块隔离的正确实现,这也是为什么真实 CJS 也用函数包裹。

总结

手写一遍 require,是「从会用模块化」到「懂模块化」的分水岭。我的观点:当你看到「模块系统无非是读文件+包函数+注入参数+缓存」,那些玄乎的概念(作用域隔离、缓存、导出)就全落地了。我强烈建议每个前端都手写一次——它把「黑盒工具」变成「我能造的东西」,以后遇到模块加载诡异问题,你能直接定位是「路径解析错」「缓存没清」还是「导出没挂对」。一句话:理解模块系统最好的方式,不是读文档,是写一遍加载器。