第二章 模块机制
JavaScript当初被仓促的创造出来,主要就是为了实现2个功能:
- 表单校验
- 网页特效
但随着B/S应用的兴起,JavaScript也越来越收到重视,
渐渐地就经历了一系列进化:
- 工具(浏览器兼容)
- 组件(功能模块)
- 框架(功能模块组织)
- 应用(业务模块组织)
但是JavaScript先天就缺乏模块功能,script标签引入代码的方式毫无章法,非常蛋疼,
于是就出现了 命名空间 的人为规范来约束代码。开发者:还能不开发了咋地,加入命名空间凑合着写吧
最终经历了十多年的发展,CommonJS规范 终于被提出,
JavaScript的模块化功能也得以完善,最终成功破壁,进入后端开发领域。
CommonJS规范
CommonJS出发点
在CommonJS没有被提出之前,
JavaScript一直使用的是官方规范 ECMAScript,
但是JavaScript在实际环境中,表现能力始终都取决于宿主环境对API的支持程度
在缓慢の进化过程中的JS酱,常常因为这些原因被后端开发拒之门外:
- 没有模块系统
- 标准库较少
- 没有标准接口
- 缺乏包管理系统
但是在引入CommonJS之后,JS酱终于具备了跨宿主环境执行的能力。
于是除了客户端应用,JavaScript此后还能开发以下应用:
- 服务器端JavaScript应用程序
- 命令行工具
- 桌面图形界面应用程序
- 混合应用
- 浏览器: HTML、 CSS 、 JavaScript 的运行环境
- W3C: 对 BOM 和 DOM 进行规范化
- ECMAScript: 对 JavaScript 进行规范化
- CommonJS: 对 JavaScript 进行脱离宿主环境规范化
- Node: 同时兼容 ECMAScript 和 CommonJS 规范的js服务器端语言
CommonJS模块规范
模块的意义在于将类聚的方法和变量等限定在私有的作用域中
模块引用
使用 require() 方法
1 | let myModule = require('myModule'); |
模块定义
使用 module.exports 对象
1 | // math.js |
1 | // program.js |
模块标识
1 | let myModule = require('模块标识') |
模块标识就是 require() 方法接收的参数,
它可以遵从以下几种格式:
- 符合小驼峰命名的字符串
- 以...开头的相对路径
- 绝对路径
Node的模块实现
下面这行代码就好像叫你的下属 Node酱 给你找一份叫做 myModule 的东西出份材料:
1 | let myModule = require('myModule') |
和大多数被Boss搞蒙圈的社畜一样,Node酱一上来就是三个问题:
- 路径分析
Node:我上哪去取这个东西呢? - 文件定位
Node:你说的这个myModule,它是个什么东西? - 编译执行
Node:我怎么用这个东西出材料呢?
- 核心模块
- Node提供
- 静态内存加载
- 速度快
- 文件模块
- 用户编写
- 动态加载
- 速度慢
文件加载优先级
Node会对引入过的模块进行缓存,以减少二次引入时的开销
浏览器缓存策略也差不多:
- 浏览器:缓存文件
- Node:缓存编译和执行之后的对象
优先级遵循两个顺序:
- 缓存优先
教科书式废话,要不然干嘛引入缓存策略
- 核心模块优先于文件模块
把这两个原则缝合一下就得出了完整的缓存优先级排序:
缓存核心模块 > 缓存文件模块 > 核心模块 > 文件模块
路径分析和文件定位
Node:我上哪去取呢(挠头)?
如 http、fs、path等
如果是路径文件模块的话撞名还好说,
但自定义模块没有路径标识,什么都不处理百分百会被核心模块覆盖,
所以还是考虑换个模块名吧或者换个路径吧。
又划分为
- 相对路径形式:以 ... 开头
- 绝对路径形式:以 / 开头
路径首先会被转为 真实路径,
之后如果二次加载该模块,就直接用这个 真实了路径 作为索引到指定位置取就好了。
编译成功的模块的文件路径作为索引缓存在 Module._cache对象上
比如:
1 | let myModule = require('./myModule') |
Node首先会 ./myModule 转化为绝对路径 path:
D:\CS_Demo\V_Demo\NodeJs_Demo\深入浅出NodeJS\day1_module\myModule.js
然后将path作为索引,下一次我再用到myModule模块,就直接会找到path对应的文件
也就是 自定义模块,
这类文件模块由于 文件路径一般比较深,
因此查找起来比较费时。
模块路径
Node将文件定位策略涉及到的查找路径放在了一个数组中,
即 module.paths
1 | console.log(module.path) //输出路径数组 |
1 | // 输出结果 |
即从当前路径起,直到根目录\node_modules,依次向父级递归直到找到为止。
- 文件扩展名分析
- Node:你让我找的模块是个什么文件?别一会儿给你找来了又说不是你要的
- 目录和包的处理
- Node:你说的的那个模块我没找到,但我找到个同名目录,凑合着用吧
你和Node说:
1 | // 帮我从食堂带份番茄炒鸡蛋 |
Node一共就知道食堂有三个窗口卖番茄炒鸡蛋:
- js窗口
- json窗口
- node窗口
然后Node就开始一个窗口一个窗口的找,看还有没有卖番茄炒鸡蛋的。
这就是Node中文件扩展名分析的策略,当然也可以一开始就指定好你要的文件的类型,
注意这里Node跑窗口是要花时间的,
需要 调用fs模块同步阻塞式判断 文件是否存在,
所以指定json和node类型模块时,和Node一开始说清楚你要的模块会省去扩展名分析花费的时间。
你叫Node帮你去一家叫做摩卡的咖啡店买杯摩卡:
1 | let dinner = require('./Mocha') |
结果Node一去发现店里没有卖摩卡的,结果把整家店都给买下来了(不愧是你)
这里需要了解Node分析目录的一些规则:
首先,在目录下寻找 package.json 文件
- Node找到咖啡厅的餐厅手册——package.json
- Node开始翻看起来——JSON.parse()
- Node在手册的main菜单查看是否有卖摩卡的
package.json:CommonJS包规范定义的包描述文件
package.json被解析后,其中的 main属性 会被提取出来作为文件名进行定位,
当main属性的路径错误或根本没指定时…(Node:你丫玩我是吧?白解析了)
- Node发现main菜单里没写卖摩卡
- Node心想那就买杯index美式凑合一下吧
Node会把 index 作为默认文件名,挨个查找:
- index.js
- index.node
- index.json
如果还是没有找到的话…
- 结果Node一问店员,什么?连index美式都没有?
- Node震惊了,但是店员又和Node推荐了一家更大的父级咖啡厅,让它去那买
还记得 module.path 吗?
没错,顺着路径找到父级目录再重复一遍上面的操作。
模块编译
万物可对象,每个文件模块也是作为对象包装的:
1 | function Module(id, parent){ |
万物可对象(这话好像刚才说过),连编译载入的方法也被作为对象包装在require中:
不同文件的加载方式放在了 require.extensions 中
1 | console.log(require.extensions); |
输出结果:
1 | { |
也可以通过对 require.extensions 的扩展来实现自定义扩展名文件的加载:
1 | require.extensions['.duck'] = function(module, filename){ |
通过fs模块同步读取文件后编译执行
Step1 对js源代码进行头尾包装
1 | (function (exports, require, module, __filename, __dirname){ //包装头 |
这样就实现了 作用域隔离
Step2 使用包装后的代码创建一个具体的function对象
使用的是vm原生模块的 runInThisContext()
返回一个具体function对象
Step3 传参执行
Step1中包装成的函数参数主要如下:
- exports 当前模块对象的exports
- require 引入方法
- module 当前模块对象自身
- __filename 文件定位的完整路径
- __dirname 文件目录
.node是用 C/C++ 编译后生成的模块文件,
加载和执行方法:process.dlopen()
总之就是在 dlopen() 中让exports和.node产生联系
(别问怎么产生的联系,问就是量子纠缠)
通过fs模块同步读取文件后编译执行
解析方法:JSON.parse()
解析后直接赋给模块对象的exports
核心模块
- C/C++文件
- 放在src目录下
- JavaScript文件
- 放在lib目录下(lib/*.js)
- src目录下的node.js(src/node.js)
JavaScript核心模块的编译过程
怎么把核心代码中的js部分加载到内存中呢?
0分答案: 第一步,取出js代码,第二步:放入内存你搁这搁这呢
实际上是需要将js转存为C/C++,然后再加载到内存之中。
问题又来了:如何将js转化为C/C++呢?
答: Node采用的是V8的 js2c.py。
Step1 转换成C++数组
这里需要把js代码以字符串形式存储在node命名空间中
Step2 生成node_natives.h头文件
存储在node命名空间中,无法直接执行
Step3 加载进内存中
在启动Node之后,js代码会被加载进内存中
核心模块和文件模块的缓存位置不同:
- 核心模块 NativeModule._cache
- 文件模块 Module._cache
1 | function NativeModule(id){ |
C/C++核心模块的编译过程
- 单一模式
- 全部由C/C++编写
- 复合模式
- C++实现核心部分
- JavaScript实现封装
- Node提高性能的常用模式
由 纯C/C++ 编写的部分又称为 内建模块,
1-内部结构
1 | struct node_module_struct { |
2-定义
模块被创建后就自然要被定义到 node命名空间 中,
需要通过 NODE_MODULE宏
1 |
|
3-取出
Node提供了 get_builtin_module() 从 node_module_list 数组取出这些模块
Node使用 Binding() 方法实现内建模块的加载,
具体步骤如下:
step1-创建一个exports空对象
1 | exports = Object::New(); |
step2-取出内建模块对象
1 | modp = get_builtin_module(*module_v); |
step3-填充exports对象
1 | modp->register_func(exports); |
step4-按模块名缓存exports对象
1 | binding_cache->Set(module, exports); |
step5-核心模块编译内容的取出
使用到Node提供的全局变量process
1 | NativeModule._source = process.binding('natives'); |
核心模块的引入流程
Step1-用户请求模块
1 | require("os") |
Step2-分析确定是核心模块的请求
1 | NativeModule.require("os") |
Step3-需要取出模块为字符串数组并转化为字符串
1 | process.binding("os") |
Step4-需要取出模块对象
1 | get_builtin_module("node_os") |
Step5-需要定义模块对象
1 | NODE_MODULE(node_os, reg_func) |
编写核心模块
头文件node_hello.h存放到src目录下
1 |
|
将用于实现预定义方法的 node_hello.cc 存储到 src 目录下
1 |
|
需要修改 src/node_extensions.h ,
将node_hello模块添加进node_module_list数组中
修改Node项目生成文件 node.gyp,
然后编译整个Node项目
1 | let hello = process.binding('hello') |
C/C++扩展模块
C/C++模块引入的最佳场合是什么
答: 频繁出现 位运算 的场合,
如 转码、 编码 等过程。
这是因为JS酱的位运算很弱啦(傲娇)C++:哎,拜托,弱哎,你很弱哎——
JavaScript只有 double类型 的数据类型,
位运算时需要转换为 int类型,
因此效率不高。
之前提到C/C++扩展模块会预先编译为 .node 文件,
这就存在一个问题,就是这个 .node 后缀只是个包装,
包装之下的内容实际如下:
- windows系统下是 .dll
- *nix系统下是 .so
dlopen方法实际上对两种文件分别进行处理。
前提条件
全称 Generate Your Projects,
用于生成各个平台下的项目文件,
node-gyp是为Node提供的专有扩展构建工具
1 | npm install -g node-gyp |
V8引擎本身就是用 C++ 写成的,
因此能够实现 JavaScript 和 C++ 的相互调用
Node能够实现跨平台的诀窍,
它支持Node调用底层操作(事件循环、文件操作等)
Node自身提供的一些C++代码。
存在 deps目录 下。
C/C++扩展模块的编写
C/C++扩展模块的编译
C/C++扩展模块的加载
模块调用栈
- 核心模块
- C/C++内建模块:最底层的模块
- JavaScript模块:
- 作为内建模块的 封装层和桥阶层
- 供文件模块调用
- 文件模块
- C/C++扩展模块
- JavaScript模块:纯粹功能模块
包与NPM
包和NPM就是将模块联系起来的一种机制,
就像是将不同生物联系到一起的食物链
CommonJS包规范规定包由两个部分组成:
- 包描述文件
- 包结构文件
包结构
包这个概念可能过于抽象,
具体些说,包实际就是一个存档文件(.zip/tar.gz)
- package.json 包描述文件
- lib 可执行二进制文件目录
- bin JavaScript文件目录
- doc 文档目录
- test 单元测试用例目录
包描述文件与NPM
package.json位于包的根目录下,
CommonJS为其定义了一些描述字段:
必需字段
name 包名
- 包名不能出现空格,可以包含如下字符:
- 小写字母
- 数字
- .
- _
“name”: “goodbye-world”
- 包名不能出现空格,可以包含如下字符:
description 包简介
“description”: “world:why everybody has to say hello to me?”
version 版本号
“version”: “1.0.0”
keywords 关键词数组
- 用于做分类搜索
“keywords”: [“jeusttest”, “dontdownload”, “goaway”]
maintainers 包维护者列表
- 每个维护者包含
- name
- web
"maintainers": [{"name":"God", "email":"highestgod@heaven.com","web":"www.notwelcomemotals.com"}]
- 每个维护者包含
用于权限认证的字段
- contributors 贡献者列表
- bugs 一个可以反馈bug的地址
- licenses 许可证列表
- repositories 托管源代码位置
- dependencies 依赖包列表
可选字段
- hoempage 包的网站地址
- os 操作系统支持列表
- 包括 aix、freebsd、linux、macos、solaris、vxworks、windows 等
- cpu CPU架构支持列表
- 包括 arm、mips、ppc、sparc、x86、x86_64 等
- engine 支持的JavaScript引擎列表
- 包括 v8、ejs、flusspferd、gpsee、jsc、spidermonkey、narwhal 等
- builtin 底层内建模块的标志
- directories 包目录说明
- implements 实现规范列表
- scripts 脚本说明对象
NPM实际需要的字段
- author 包作者
- bin 用于添加到执行路径在命令行工具中调用的脚本
- main 指定模块入口
- DevDependencies 只在开发时需要的依赖
NPM最初是由 Isaac Z. Schlueter 单独创建的,
但在v0.6.3版本时被集成进Node作为 默认包管理器
局域NPM
考虑到模块保密性,企业一般搭建自己的NPM仓库。
与搭建镜像站方式几乎一样,
附录D待补充
原文作者提到局域NPM仓库搭建的一个用处,很有趣:
”不至于让各个小项目各自维护相同功能的模块,(重复劳动)
杜绝通过复制粘贴实现代码共享的行为。(无脑重复)“
怎样评价一个包的质量
NPM平台上有成千上万个包,对模块品质进行判断并不容易, 特别是:
- 包质量
- 安全问题
因此开发采纳包很多时候也是根据 口碑效应。
模块口碑参考方式
- NPM模块首页依赖榜
- GitHub
- 观察者数量
- 流行度
- 包结构
- 测试用例
- 文档状况
安全检查的难点主要在于 C/C++扩展模块,
对于模块安全的检查,Isaac Z. Schlueter 提出了一个有趣的词:Kwalitee
“Kwalitee” is something that looks like quality, sounds like quality,
but is not quite quality.
意思就是很多模块表面看上去很优质,但也不一定真正是高质量模块。
比较常用的考查点可以参考下面这些:
- 覆盖率较高的测试模块
- 文档
- 编码规范