最近做了一点环境模拟的工作,还是有点意思的,无意中发现自己其实已经在实现CommonJS规范了,那就正好去学习一下。
本来想上来就总结一下commonJS规范的概念,然后放一段完美的原理实现代码,perfect!其实最早也是按照这个模式去写了(一如既往的知识点搬来主义,哈哈哈),但是想了想还是从自己如何一步步实现并理解模块定义加载的角度来写,感觉这样印象更深更有助于理解。
问题一:实现以下两个函数
1 2 3 4
| define('hello.js', function(require, module, exports){ console.log('hello world'); }); require('hello.js');
|
解:简洁版的实现可以先不考虑define
第二个参数的参数。很明显define
是用来定义一个模块,require
用来引入模块,则需要一个全局对象记录模块名对应的模块。
1 2 3 4 5 6 7
| const modules = {}; const define = function(path, fn){ modules[path] = fn; } const require = function(path){ modules[path](require, {}, {}); }
|
至此,一个简洁版的模块定义加载就实现了。
问题二:模块导出
1 2 3 4 5 6 7 8
| define('hello.js', function(require, module, exports){ const des = 'hello world'; module.exports = { des }; }) const hello = require('hello.js'); console.log(hello.des);
|
解:require
返回的是module.exports
这个对象,module
可以认为是define定义的模块本身。
1 2 3 4 5 6 7 8 9 10 11
| const modules = {}; const define = function(path, fn){ modules[path] = fn; } const require = function(path){ const mod = modules[path]; if(!mod) throw new Error(`fail to require module ${path}`); if(!mod.exports) mod.exports = {}; mod(require, mod, mod.exports); return mod.exports; }
|
之前总是纠结怎么区分module.exports和exports,到这里就可以看出它们的区别了。在一个模块内,exports
指向module.exports
,由于exports
是对module.exports
的引用,在使用时需要注意,更高效的,可以完全不理解exports
直接使用module.exports
。
问题三:模块加载
1 2 3 4 5 6 7 8
| define('lib/hello.js', function(req, module, exports){ const hi = req('./hi'); console.log(hi); }) define('lib/hi.js', function(req, module, exports){ module.exports = 'hi'; }) req('lib/hello.js');
|
解:req
时可以省略后缀引入模块,还可以使用相对路径引入模块。这里实现比较困难的就是处理相对路径,如何在req
新模块时拿到当前模块的路径。因为在req
加载新模块时已经在一个模块内部,这时当前模块的路径只能看是通过什么方式绑到模块传进来的req
这个参数上。只要能拿到所在模块define
的路径就方便处理req
相对路径了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| const modules = {}; const resolve = function(path, basePath){ if(!/\.js$/.test(path)) path += '.js'; if(basePath) { const dir = basePath.replace(/[^\/]+\.js$/, ''); path = path.replace(/^\.\//, dir); path = path.replace(/(\.\.\/)+/, function(match, p1){ const times = match.length / p1.length; const realPath = dir.split('/').filter(p => !!p).slice(0, -times).join('/'); return realPath + '/'; }) } return path; } const req = function(path){ path = resolve(path, this.basePath); const mod = modules[path]; if(!mod) throw new Error(`fail to require module ${path}`); if(!mod.exports) mod.exports = {}; mod(mod, mod.exports); return mod.exports; } const define = function(path, fn){ modules[path] = fn.bind(null, req.bind({ basePath: path })) }
|
至此,模块规范基本已经实现。当然还有很多需要考虑的,比如模块循环加载、模块加载规则等等。
题外话。。。
偶然发现webpack打包后的文件中其实也实现了模块加载机制,摘出其中的代码放在下面,样例代码看这里。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| var installedModules = {}; function __webpack_require__(moduleId) { if(installedModules[moduleId]) { return installedModules[moduleId].exports; } var module = installedModules[moduleId] = { i: moduleId, l: false, exports: {} }; modules[moduleId].call(module.exports, module, module.exports, __webpack_require__); module.l = true; return module.exports; }
|
可以看出模块加载的实现和上面自己实现的基本差不多,不过有一处不解,为何调用模块需要用call
的形式把module.exports
作为上下文传入呢?
然后在Github上看到一篇博文,https://github.com/youngwind/blog/issues/98,文中让我感触比较深的并不是对模块加载的设计实现,而是其在开头处提到的”陷入了面向过程编程的误区”,我在上面列出的几个递进问题的实现其实就是面向过程的实现方式,专门去查了一下面向过程和面向对象的区别,再看看webpack中对require的封装,感觉明白了什么。以前可能以为面向对象就是给原型对象绑一些方法,然后需要new实例出来使用,看webpack中对require的实现给我的感觉用一个词来形容就是”行云流水”,实现思路还是得open起来啊,webpack中把方法或变量都绑给函数,直接调用函数的方式来做,其实也是符合面向对象的思想的。
是时候放CommonJS的思想了
commonJS规范的核心是模块化,而模块化的本质就是独立的作用域。模块内的变量、函数、类都是私有的,那么就需要global
这个全局对象来用于多模块变量(需要定义为global
对象的属性)的共享。
commonJS规范规定,由module对象表示模块,它的exports属性就是对外暴露的接口。之前一直纠结于exports
和module.exports
的区别,在理解了commonJS规范之后也就不再纠结了。
加载模块则使用require
。require
的功能就是读入并执行模块内容并拿到模块的module.exports
属性。加载文件规则后面提到。(require.main === module)
commonJS模块的特点:
- 所有代码都运行在模块作用域,不会污染全局作用域。
- 模块可以多次加载,但是只会在第一次加载时运行一次,然后运行结果就被缓存了,以后再加载,就直接读取缓存结果。要想让模块再次运行,必须清除缓存。
- 模块加载的顺序,按照其在代码中出现的顺序。
更详细的参考这里
Reference
实现requirejs
require() 源码解读
浏览器加载 CommonJS 模块的原理与实现
webpack源码学习系列之一:如何实现一个简单的webpack
commonJS规范
exports和module.exports的区别
分析webpack打包后的文件