更新于 

第二章 模块机制

JavaScript的鸡汤时间
JS酱の不受重视的童年

JavaScript当初被仓促的创造出来,主要就是为了实现2个功能:

  • 表单校验
  • 网页特效
JS酱の进化

但随着B/S应用的兴起,JavaScript也越来越收到重视,
渐渐地就经历了一系列进化:

  • 工具(浏览器兼容)
  • 组件(功能模块)
  • 框架(功能模块组织)
  • 应用(业务模块组织)
后端の新星JS酱

但是JavaScript先天就缺乏模块功能,script标签引入代码的方式毫无章法,非常蛋疼,
于是就出现了 命名空间 的人为规范来约束代码。
开发者:还能不开发了咋地,加入命名空间凑合着写吧

最终经历了十多年的发展,CommonJS规范 终于被提出,
JavaScript的模块化功能也得以完善,最终成功破壁,进入后端开发领域。

CommonJS规范

CommonJS出发点

在CommonJS没有被提出之前,
JavaScript一直使用的是官方规范 ECMAScript
但是JavaScript在实际环境中,表现能力始终都取决于宿主环境对API的支持程度
在缓慢の进化过程中的JS酱,常常因为这些原因被后端开发拒之门外:

  • 没有模块系统
  • 标准库较少
  • 没有标准接口
  • 缺乏包管理系统

但是在引入CommonJS之后,JS酱终于具备了跨宿主环境执行的能力。
于是除了客户端应用,JavaScript此后还能开发以下应用:

  • 服务器端JavaScript应用程序
  • 命令行工具
  • 桌面图形界面应用程序
  • 混合应用
Node、浏览器、W3C、CommonJS、ECMAScript之间的关系
  • 浏览器: HTMLCSSJavaScript 的运行环境
  • W3C:BOMDOM 进行规范化
  • ECMAScript:JavaScript 进行规范化
  • CommonJS:JavaScript 进行脱离宿主环境规范化
  • Node: 同时兼容 ECMAScriptCommonJS 规范的js服务器端语言

CommonJS模块规范

模块的意义在于将类聚的方法和变量等限定在私有的作用域

模块定义的3个部分
模块引用

使用 require() 方法

1
let myModule = require('myModule');
模块定义

使用 module.exports 对象

1
2
3
4
5
6
7
8
9
10
11
// math.js
exports.add = function(){
let sum = 0,
i = 0,
args = arguments,
l = args.length;
while(i<l){
sum+=args[i++];
}
return sum;
};
1
2
3
4
5
// program.js
let math = require('math');
exports.increment = function(val){
return math.add(val,1);
}
模块标识
1
let myModule = require('模块标识')

模块标识就是 require() 方法接收的参数,
它可以遵从以下几种格式:

  • 符合小驼峰命名的字符串
  • 以...开头的相对路径
  • 绝对路径

Node的模块实现

Node中引入模块主要有3个步骤:

下面这行代码就好像叫你的下属 Node酱 给你找一份叫做 myModule 的东西出份材料:

1
let myModule = require('myModule')

和大多数被Boss搞蒙圈的社畜一样,Node酱一上来就是三个问题:

  1. 路径分析
    Node:我上哪去取这个东西呢?
  2. 文件定位
    Node:你说的这个myModule,它是个什么东西?
  3. 编译执行
    Node:我怎么用这个东西出材料呢?
Node的两类模块
  • 核心模块
    • Node提供
    • 静态内存加载
    • 速度快
  • 文件模块
    • 用户编写
    • 动态加载
    • 速度慢

文件加载优先级

Node的缓存策略

Node会对引入过的模块进行缓存,以减少二次引入时的开销

浏览器缓存策略也差不多:

  • 浏览器:缓存文件
  • Node:缓存编译和执行之后的对象
加载优先级

优先级遵循两个顺序:

  • 缓存优先
    • 教科书式废话,要不然干嘛引入缓存策略
  • 核心模块优先于文件模块

把这两个原则缝合一下就得出了完整的缓存优先级排序:

缓存核心模块 > 缓存文件模块 > 核心模块 > 文件模块

路径分析和文件定位

路径分析

Node:我上哪去取呢(挠头)?

httpfspath

特殊情况:自定义模块标识符=核心模块标识符

如果是路径文件模块的话撞名还好说,
但自定义模块没有路径标识,什么都不处理百分百会被核心模块覆盖
所以还是考虑换个模块名吧或者换个路径吧。

又划分为

  • 相对路径形式:以 ... 开头
  • 绝对路径形式:以 / 开头

路径首先会被转为 真实路径
之后如果二次加载该模块,就直接用这个 真实了路径 作为索引到指定位置取就好了。

编译成功的模块的文件路径作为索引缓存在 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
2
3
4
5
6
7
8
9
// 输出结果
[
'D:\\CS_Demo\\V_Demo\\NodeJs_Demo\\深入浅出NodeJS\\day1_module\\node_modules',
'D:\\CS_Demo\\V_Demo\\NodeJs_Demo\\深入浅出NodeJS\\node_modules',
'D:\\CS_Demo\\V_Demo\\NodeJs_Demo\\node_modules',
'D:\\CS_Demo\\V_Demo\\node_modules',
'D:\\CS_Demo\\node_modules',
'D:\\node_modules'
]

即从当前路径起,直到根目录\node_modules,依次向父级递归直到找到为止。

文件定位
两个细节
  • 文件扩展名分析
    • Node:你让我找的模块是个什么文件?别一会儿给你找来了又说不是你要的
  • 目录和包的处理
    • Node:你说的的那个模块我没找到,但我找到个同名目录,凑合着用吧

你和Node说:

1
2
// 帮我从食堂带份番茄炒鸡蛋
let dinner = require('./番茄炒鸡蛋')

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
2
3
4
5
6
7
8
9
10
11
12
function Module(id, parent){ 
this.id = id
this.exports = {}
this.parent = parent
if (parent && parent.children) {
// parent喜当爹
parent.children.push(this)
}
this.filename = null
this.loaded = false
this.children = []
}
模块编译

万物可对象(这话好像刚才说过),连编译载入的方法也被作为对象包装在require中:

不同文件的加载方式放在了 require.extensions

1
console.log(require.extensions);

输出结果:

1
2
3
4
5
{
'.js': [Function (anonymous)],
'.json': [Function (anonymous)],
'.node': [Function (anonymous)]
}

也可以通过对 require.extensions 的扩展来实现自定义扩展名文件的加载:

1
2
3
require.extensions['.duck'] = function(module, filename){
// 加载.duck文件
}

通过fs模块同步读取文件后编译执行

Step1 对js源代码进行头尾包装

1
2
3
4
5
6
(function (exports, require, module, __filename, __dirname){ //包装头
let math = require('math');
exports.area = function (radius){
return Math.PI * radius * radius;
}
}); // 包装尾

这样就实现了 作用域隔离

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.jssrc/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
2
3
4
5
6
7
8
9
10
function NativeModule(id){
this.filenmae = id + '.js'
this.id = id
this.exports = {}
this.loaded = false
}
// 取出源代码
NativeModule._source = process.binding('native')
// 缓存
NativeModule._cache = {}

C/C++核心模块的编译过程

Node核心代码的两种形式
  • 单一模式
    • 全部由C/C++编写
  • 复合模式
    • C++实现核心部分
    • JavaScript实现封装
    • Node提高性能的常用模式
内建模块

纯C/C++ 编写的部分又称为 内建模块

内建模块组织形式
1-内部结构
1
2
3
4
5
6
7
8
struct node_module_struct {
int version;
void *dso_handle;
const char *filename;
// 模块的具体初始化方法
void (*register_func) (v8::Handle<v8::Object> target);
const char *modname;
}
2-定义

模块被创建后就自然要被定义到 node命名空间 中,
需要通过 NODE_MODULE宏

1
2
3
4
5
6
7
8
9
#define NODE_MODULE(modename, regfunc)
extern "C" {
NODE_MODULE_EXPORT node::node_module_struct modname ## _module =
{
NODE_STANDARD_MODULE_STUFF,
regfunc,
NODE_STRINGIFY(modename)
}
}
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');

核心模块的引入流程

以os模块的引入为例

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)

编写核心模块

内建模块hello的实现

头文件node_hello.h存放到src目录下

1
2
3
4
5
6
7
8
#ifndef NODE_HELLO_H_
#define NODE_HELLO_H_
#include <v8.h>

namespace node{
// 预定义方法
v8::Handle<v8::Value> SayHello(const v8::Arguments& args);
}

将用于实现预定义方法的 node_hello.cc 存储到 src 目录下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <node.h>
#include <node_hello.h>
#include <v8.h>

namespace node{
using namespace v8;
// 实现预定义方法
Handle<Value> SayHello(const Arguments& args){
HandleScope scope;
return scope.Close(String::New("Hello world!"));
}
// 给传入的目标对象添加sayHello方法
void Init_Hello(Handle<Object> target){
target->Set(
String::NewSymbol("sayHello"),
FunctionTemplate::New(SayHello)
)
}
}
// 注册方法到内存中
NODE_MODULE(node_hello, node::Init_Hello)

需要修改 src/node_extensions.h
node_hello模块添加进node_module_list数组中

修改Node项目生成文件 node.gyp
然后编译整个Node项目

1
2
let hello = process.binding('hello')
hello.sayHello() // 'Hello World!'

C/C++扩展模块

C/C++模块引入的最佳场合是什么

答: 频繁出现 位运算 的场合,
转码编码 等过程。
这是因为JS酱的位运算很弱啦(傲娇)
C++:哎,拜托,弱哎,你很弱哎——
JavaScript只有 double类型 的数据类型,
位运算时需要转换为 int类型
因此效率不高。

C/C++扩展模块的跨平台问题

之前提到C/C++扩展模块会预先编译为 .node 文件,
这就存在一个问题,就是这个 .node 后缀只是个包装,
包装之下的内容实际如下:

  • windows系统下是 .dll
  • *nix系统下是 .so

dlopen方法实际上对两种文件分别进行处理。

前提条件

全称 Generate Your Projects
用于生成各个平台下的项目文件,
node-gyp是为Node提供的专有扩展构建工具

1
npm install -g node-gyp

V8引擎本身就是用 C++ 写成的,
因此能够实现 JavaScriptC++ 的相互调用

Node能够实现跨平台的诀窍,
它支持Node调用底层操作(事件循环、文件操作等)

Node自身提供的一些C++代码。

存在 deps目录 下。

C/C++扩展模块的编写

C/C++扩展模块的编译

C/C++扩展模块的加载

模块调用栈

模块之间的调用关系
模块之间的调用关系
  • 核心模块
    • C/C++内建模块:最底层的模块
    • JavaScript模块:
      • 作为内建模块的 封装层和桥阶层
      • 文件模块调用
  • 文件模块
    • C/C++扩展模块
    • JavaScript模块:纯粹功能模块

包与NPM

CommonJS的包规范

包和NPM就是将模块联系起来的一种机制,

就像是将不同生物联系到一起的食物链

CommonJS包规范规定包由两个部分组成:

  • 包描述文件
  • 包结构文件

包结构

这个概念可能过于抽象,
具体些说,实际就是一个存档文件(.zip/tar.gz)

完全符合CommonJS规范的包目录
  • package.json 包描述文件
  • lib 可执行二进制文件目录
  • bin JavaScript文件目录
  • doc 文档目录
  • test 单元测试用例目录

包描述文件与NPM

package.json

package.json位于包的根目录下,
CommonJS为其定义了一些描述字段:

必需字段
  • name 包名

    • 包名不能出现空格,可以包含如下字符:
      • 小写字母
      • 数字
      • .
      • _
    • “name”: “goodbye-world”

  • description 包简介

    • “description”: “world:why everybody has to say hello to me?”

  • version 版本号

  • keywords 关键词数组

    • 用于做分类搜索
    • “keywords”: [“jeusttest”, “dontdownload”, “goaway”]

  • maintainers 包维护者列表

    • 每个维护者包含
      • name
      • email
      • 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仓库。

如何搭建局域NPM仓库?

搭建镜像站方式几乎一样,
附录D待补充

原文作者提到局域NPM仓库搭建的一个用处,很有趣:

”不至于让各个小项目各自维护相同功能的模块,(重复劳动)
杜绝通过复制粘贴实现代码共享的行为。(无脑重复)“

怎样评价一个包的质量

NPM平台上有成千上万个包,对模块品质进行判断并不容易, 特别是:

  • 包质量
  • 安全问题

因此开发采纳包很多时候也是根据 口碑效应

模块口碑参考方式
包安全检查

安全检查的难点主要在于 C/C++扩展模块

对于模块安全的检查,Isaac Z. Schlueter 提出了一个有趣的词:Kwalitee

“Kwalitee” is something that looks like quality, sounds like quality,
but is not quite quality.

意思就是很多模块表面看上去很优质,但也不一定真正是高质量模块。

比较常用的考查点可以参考下面这些:

  • 覆盖率较高的测试模块
  • 文档
  • 编码规范