手写 CommonJS:require 加载与缓存实现
本节目标
- 亲手写一个迷你
require,真正理解 CJS 内部 - 覆盖「路径解析 + 执行 + 缓存」
- 体会「模块系统就是个加载器」
我们来手写一个极简 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,是「从会用模块化」到「懂模块化」的分水岭。我的观点:当你看到「模块系统无非是读文件+包函数+注入参数+缓存」,那些玄乎的概念(作用域隔离、缓存、导出)就全落地了。我强烈建议每个前端都手写一次——它把「黑盒工具」变成「我能造的东西」,以后遇到模块加载诡异问题,你能直接定位是「路径解析错」「缓存没清」还是「导出没挂对」。一句话:理解模块系统最好的方式,不是读文档,是写一遍加载器。