深入浅出node.js总结-模块机制(1)
javasciprt 通过script标签引入代码的方式显得杂乱无章,语言自身毫无组织和约束能力。人们不得不用命名空间等方式人为地约束代码,以求达到安全和易用的目的。
- 为了让javascript能在服务端有市场,社区为javascript制定了相应的规范——CommonJS
CommonJS规范
CommonJs的出发点
javascript有以下缺点:
- ==没有模块系统==
- ==标准库较少==-比如没有文件系统、I/O流等标准的API
- ==没有标准接口==-几乎没有定义过web服务器或者数据库之类的标准统一接口
- ==缺乏包管理系统==-javascript没有自动加载和安装依赖的能力
CommonJS就是来弥补这些缺陷的
CommonJS大部分规范依旧是草案,但是为javascript开发大型应用程序指明了一条道路
- Node受到CommonJS的影响,CommonJS因Node表现优异而走入各个公司项目里,相互影响和促进
CommonJS的模块规范
分为模块引用、模块定义、模块标识
- ==模块引用== var math = require('math');
es6已更新一套支持模块引用的语法:import。在调用require时,可以把它放在某个判断条件下,但import不行;在打包编译时,如果require里的文件模块不存在,即便逻辑上不会进入其所在的判断条件,依旧会报错。可以使用try{}catch(e){}来处理
- ==模块定义==
- 提供了exports对象用于导出当前模块的方法或变量
export 是es6的规范,与import一同用,不要弄混了
- 一个模块里还有module对象,代表模块自身;exports是module的属性
- 在Node中,一个文件就是一个模块,将方法挂载在exports对象上作为属性即可定义导出的方式
// math.jsexports.add = function(a,b){ return a+b;}exports.sum = function(a,b){ return a+b;}复制代码// index.jsvar math = reqire('math');exports.add = function(val){ return math.add(val,1);}复制代码
- ==模块标识==
- 指的是传给require()方法的参数,必须是符合小驼峰命名的字符串,或者以.、..开头的相对路径,或者绝对路径
每个模块具有独立的空间,互不干扰,用户不必考虑变量污染
Node的模块实现
Node没有完全按照CommonJS规范实现,做了一定的取舍并加入自身需要的特性。
- ==路径分析==
- ==文件定位==
- ==编译执行==
- Node中模块分为两块:一类是Node提供的模块,称为==核心模块==;另一类是用户编写的模块,称为==文件模块==
- 核心模块在编译过程中,成为二进制执行文件;Node进程启动时,部分核心模块就直接加载进内存中
开发者引入这部分核心模块时,省略了文件定位、编译执行,且在路径分析中优先判断;故,加载速度最快
- 文件模块是在运行时动态加载,需要路径分析、文件定位、编译执行过程,速度比核心模块慢
先路径分析文件定位、最后再运行,所以前面require一个不存在的文件时,即便有判断条件,依然会报错
优先从缓存加载
- Node对引入过的模块都会进行缓存,以减少二次引入时的开销。==Node缓存的是编译和执行之后的对象==
所以,所有模块exports/export出的的东西,在内存中有且仅有一份,不受exports/export后的表达式/new之类的语句所影响
- require()方法/import语句对于相同模块的二次加载都一律采用缓存优先的方式,这是第一优先级的。核心模块的缓存检查优于文件模块的缓存检查
路径分析和文件定位
因标识符的不同形式,模块的查找和定位有不同程度的差异
- 模块标识符分析
- 标识符分为以下几类:
- ==核心模块==,如http、fs、path
- .或..开始的==相对路径文件模块==
- 以/开始的==绝对路径文件模块==
- ==非路径形式的文件模块==
- 核心模块
- 优先级仅次于缓存加载,加载速度最快
- 不能将自定义模块的标识符与核心模块标识符定义得一致,除非你换用路径的方式
- 路径形式的文件模块
- . .. /开始的标识符,均视为文件模块
- require将路径转为真实路径,以此做索引,编译执行后的结果放入缓存
- 加载速度比核心模块慢
- 自定义模块
- 特殊的文件模块,查找费时,加载速度最慢
- “模块路径”——Node定位文件模块时定制的查找策略。模块路径是一个路径组成的数组:
- [
- 当前文件目录下的xx目录,
- 父目录下的xx目录,
- 父目录下的父目录下的xx目录,
- 沿路径向上逐级递归,直到根目录下的xx目录
- ]
类似于js的原型链或作用域链的查找方式。也正因如此,一旦路径越深,查找耗时就越多,所以加载速度最慢
- 文件定位
有缓存的存在和前面的路径分析,文件定位相对比较简单。这里注意一些细节:
- 文件扩展名分析
- 有时标识符没有扩展名,CommonJS规范不允许不包含文件扩展名,但Node会按.js、.json、.node的顺序依次尝试
- 尝试时,会调用fs模块同步阻塞时判断文件是否存在。由于Node单线程,所以这里会引起性能问题——建议.node/.json文件加上扩展名
- 目录分析和包
- 有时没查找到文件,而是一个目录
- Node会先找当前目录下的package.json,解析出main属性指定的文件名进行定位;如果文件名没有扩展名,则会进入扩展名分析的步骤
解析方式就是JSON.parse()
- 如果main提供的文件名有误,或者没有package.json,则查index(.js、.json、.json)
- 还没定位成功,则按照自定义模块的模块路径 策略,去父目录查询;如果模块路径数组遍历完毕都没找到,则抛出查找失败的异常
模块编译
(这里指的都是文件模块,非核心模块) 当定位到具体文件后,Node会新建一个模块对象,将文件载入并编译。载入方法根据不同文件扩展名而区分
- .js文件:fs模块同步读取后编译执行
- .node文件:这是c/c++编写的扩展文件,通过dlopen()方法加载最后编译生成的文件
- .json文件:fs模块同步读取后用JSON.parse()解析返回结果
- 其余扩展名:当.js文件载入
编译成功的模块会缓存在Module._cache上,以文件路径作为索引
- javascript模块的编译
- 编译过程中,Node对获取的javascript的文件内容进行了头尾包装,头部添加(function(==exports, require, module, __filename, __dirname==){\n,尾部添加\n})
__filename 完整的文件路径;__dirname 文件目录
- 包装后的代码vm原生模块的runInThisContext()执行,返回一个具体的function对象
- 最后,将当前模块对象的exports、require()、module、文件路径和目录作为参数传入给function对象
- 执行后exports返回给调用方,其上的任何方法与属性均可被外部调用,但是模块中的其余变量或属性不可。==以此达到模块间的作用域隔离==
这就是Node对CommonJS模块规范的实现
- exports的误用 编写代码时,理论上只要这样写就行:
exports = function(){//My Class};复制代码
- 但是这样写是有问题的,我们来看看编译过程:
- 头尾包装:
(function(exports, require, module, __filename, __dirname){ exports = function(){//My Class};})复制代码
- 看,你把形参改了。。。但是exports最后是要返回给调用方然后被外部调用的,==改形参根本不能真正改变exports对象的内容==
- 所以,要不老老实实地写:exports.add = ...;或者写module.exports = ...
- c/c++模块的编译
- 事实上,.node模块不需要编译,因为它是c/c++模块之后编译生成。它只需要加载和执行
- 执行中exports对象与.node模块产生脸型,返回给调用者
- 优势:执行效率高;劣势:编写门槛高
- JSON文件的编译
- fs模块同步读取JSON文件
- JSON.parse()方法得到对象
- 赋给exports
- 如果.json文件作为配置文件(如package.json),则不必调用fs模块去读取和解析,直接reqire()引入即可。同理它也是享受缓存的便利,二次引入时没有性能影响
核心模块
核心模块分为c/c++编写的和javascript编写的两部分
js核心模块的编译过程
编译程序会将js模块文件编译成c/c++代码
- 转存为c/c++代码
- 将所有内置的js代码转换成c++的数组,此时js代码不可直接执行,启动Node进程后,js代码直接加载进内存,将来查找比文件模块要快得多
- 编译js核心模块
- 经历头尾包装、执行、导出exports对象。与文件模块区别地方在于:它从内存中加载;缓存执行结果的位置
核心模块在NativeModule._cache对象上,文件模块在Module._cache对象上
(未完待续~)