前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >Node理论笔记:模块实现

Node理论笔记:模块实现

原创
作者头像
Ashen
修改2020-06-01 14:40:35
6910
修改2020-06-01 14:40:35
举报
文章被收录于专栏:Ashenの前端技术

这个笔记是基于《深入浅出nodeJs》的,这本书出版较早是基于v0.6.0版本的,而现在node已经更新到v10的版本了,所以很多东西可能在新的版本都已经不适用了,但这本书偏理论居多,这些思想应该不会变的,所以do it吧。

一、node简介

node诞生于2009年基于谷歌V8引擎,它并不是一个框架,而是一个JavaScript运行环境。

1.1 node的特点

1、异步I/O

在node中绝大部分的操作都是以异步方式进行调用的,如网络请求、文件写入/读取等。

2、事件与回调函数

node是基于事件驱动的。

3、单线程

JavaScript是单线程的,单线程有一些弱点:

  1. 无法利用多核CPU
  2. 错误会引起整个应用退出,应用的健壮性值得考研
  3. 大量计算占用CPU导致无法继续异步调用I/O

node采用了child_process子线程来解决这些问题,类似于浏览器端的web worker。

4、跨平台

node与底层操作系统之间有一层libuv,libuv在操作系统与node上层模块之间构建了一层平台架构,得益于这层架构node可以轻松实现跨平台。

1.2 node应用场景

1、I/O密集型

从单线程的角度来讲,node处理I/O的能力是非常强的,I/O密集的优势主要在于node利用事件循环的能力,而不是启动每一个线程为每一个请求服务,资源暂用较少。

2、是否不擅长CPU密集型业务

首先V8执行JavaScript的效率是非常高的。由于JavaScript是单线程,如果有长时间运行的计算将会导致CPU时间片不能释放,使得后续I/O无法发起。但是适当调整和分解大型运算任务为多个小任务,使得运算能适时释放,不阻塞I/O调用的发起,这样既可以享受并行异步I/O的好处,又能充分利用CPU。

1.3 node使用者

  1. 前后端编程语言环境统一
  2. 高性能I/O用于实时应用
  3. 并行I/O高效利用分布式环境
  4. 云计算平台的node支持
  5. 游戏开发
  6. 工具类应用

当前普遍环境下,工具类应用普遍较为广泛,如:webpack、babel、grunt等。

二、模块机制

2.1 CommonJS规范

CommonJS的美好愿景:希望JavaScript能运行在任何地方。

2.1.1 CommonJS的出发点

早期JavaScript主要有几个大问题:

  1. 没有模块系统。只能约定俗成通过命名空间的方式组织多个模块。
  2. 标准库较少。文件系统和I/O流就没有。
  3. 没有标准接口。如没有web服务器或数据库之类的统一标准接口。
  4. 缺乏包管理系统。

CommonJS的出现,致力于让JavaScript能够编写以下应用:

  1. 服务器端JavaScript应用程序
  2. 命令行工具
  3. 桌面图形界面应用程序
  4. 混合应用

CommonJS规范已经涵盖了模块、二进制、Buffer、字符集编码、I/O流、进程环境、文件系统、套接字、单元测试、web服务器网关接口、包管理等。

2.1.2 CommonJS的模块规范

主要分为模块引用、模块定义、模块标识3个部分。

1、模块引用

调用require()方法来引入一个模块。如:

代码语言:javascript
复制
const fs = require("fs");

2、模块定义

对于引入的模块,上下文提供了exports对象用于导出当前模块的方法或变量,并且是唯一导出的出口。在模块中,存在一个module对象代表模块自身,而exports是module的属性。在node中,一个文件就是一个模块,将方法或属性挂载在exports对象上作为属性即可定义导出的方式。

代码语言:javascript
复制
exports.sayHello = function(){
  console.log("hello world");
};

3、模块标识

模块标识就是传递给require()的参数,必须是符合小驼峰命名的字符串,或者以..、.开头的相对路径,或者绝对路径。可以不包含文件名后缀.js。

2.2 node模块的实现

在node中引入模块需要经历3个步骤:

  1. 路径分析
  2. 文件定位
  3. 编译执行

在node中模块分为2类:一类是node提供的模块,称为核心模块;另一类是用户编写的模块,称为文件模块。

核心模块编译进了二进制执行文件,在node进程启动时,部分核心模块就被直接加载在内存中,所以这部分核心模块引入时,文件定位和编译执行这2个步骤可以省略,并且在路径分析中优先判断,所以加载速度是最快的。

文件模块在运行时动态加载,需要完整的路径分析、文件定位、编译执行过程,速度比核心模块慢。

2.2.1 优先缓存加载

node对引入过的模块都会进行缓存,以减少二次引入时的开销。不同于前端的文件缓存,node缓存的是编译和执行之后的对象。

无论是核心模块还是文件模块,对相同模块的二次加载一律采用缓存优先的方式,这是第一优先级,不同之处在于核心模块的缓存检查会先于为文件模块的缓存检查

2.2.2 路径分析和文件定位

标识符有几种形式,对于不同的标识符,模块的查找和定位有不同程度上的差异。

1、模块标识符分析

模块标识符在node中有这么几类:

  • 核心模块,如http、fs、path等
  • .或..开始的相对路径文件模块
  • 以/开始的绝对路径文件模块
  • 非路径形式的文件模块

1) 核心模块

核心模块的优先级仅次于缓存加载,在node源代码编译过程中已经编译为二进制代码,其加载过程最快。

试图加载一个与核心模块相同标识符的自定义模块是不会成功的。

2)路径形式的文件模块

以.或..开始的标识符都会当作文件模块来处理。分析文件模块时,require()方法会将路径转为真实路径,并以真实路径作为索引,将编译后的结果存放到缓存中,以使二次加载更快。

3)自定义模块

首先自定义模块是非核心模块,也不是路径形式的标识符。它是一种特殊的文件模块,可能是一个文件或包的形式,这类模块的查找是最慢的。

模块路径是node定位文件的制定的查找策略,表现为一个路径组成的数组

windows下:

代码语言:javascript
复制
exports.sayPath = function(){
  console.log(module.paths);
};
//打印结果
[ 
  'D:\\myProject\\node-project\\server\\node_modules',
  'D:\\myProject\\node-project\\node_modules',
  'D:\\myProject\\node_modules',
  'D:\\node_modules'
];

可以看出模块路径生成规则如下:

  • 当前目录下的node_modules
  • 父目录下的node_modules
  • 父目录的父目录下的node_modules
  • 沿路径向上逐级递归,直到根目录下的node_modules

文件路径越深,模块查找耗时越久,所以自定义模块的查找是最慢的。

2、文件定位

缓存加载的优化策略,使得二次加载不需要进行路径分析、文件定位和编译执行的过程,大大提高了再次加载模块时的效率。

文件的定位主要包括文件扩展名的分析、目录和包处理。

1)文件扩展名分析

标识符是可以不追加后缀名的,这种情况下,node会按.js、.json、.node的次序补足扩展名,依次尝试。

尝试的过程需要调用fs模块同步阻塞式的判断文件是否存在,这会导致略微的性能问题,所以对于.json、.node文件最好带上扩展名。

2)目录分析和包

分析标识符的过程中,可能没有找到对应的文件,但却得到一个目录,此时node会将这个目录当中包处理。

首先node会查找目录下的package.json文件,通过JSON.parse()解析包描述对象,从中取出main属性执行的文件名进行定位。如果文件名缺少扩展名,则进入扩展名分析的步骤。

如果main指定的文件名错误或压根没有package.json,node会将index当中默认文件名,依次查找index.js、index.json、index.node。

如果在目录分析的过程中没有定位到文任何文件,则自定义模块会进入下一个模块路径进行查找,如果路径数组都遍历完依然没有找到目标文件,则抛出查找失败的异常。

2.2.3 模块编译

以下提到的模块编译都是文件模块。

在node中,每个文件模块都是一个对象。编译和执行是引入文件模块的最后一个阶段。定位到具体的文件后,node会新建一个模块对象,然后根据路径载入并编译。对于不同的文件扩展名,载入的方法也有不同。

  • js文件。通过fs模块同步读取文件后编译执行
  • node文件。这是用C/C++编写的扩展文件,通过dlopen()方法加载最后编译生成的文件。
  • json文件。通过fs模块同步读取文件后,用JSON.parse()解析后返回结果。
  • 其余扩展名文件。都被当作js文件载入

每一个编译成功的模块都会将其文件路径作为索引缓存在Module._cache对象上,以提高二次引入的性能。

通过require.extensions可以知道系统中已有的扩展加载方式,如:

代码语言:javascript
复制
console.log(require.extensions);
//打印
{ '.js': [Function], '.json': [Function], '.node': [Function] }

1、JavaScript模块的编译

CommonJS模块规范中,每个模块文件中存在着require、exports、module这3个变量,同时每个模块还有__filename、__dirname这2个变量,模块中没有定义又是从何而来的呢?

这些变量不是全局定义的,实际上,在编译的过程中,node会对获取到的JavaScript文件内容进行头尾包装,所以一个正常的JavaScript文件被包裹后的样子:

代码语言:javascript
复制
(function(exports,require,module,__filename,__dirname){
  exports.sayHello = function(){
    console.log("hello world");
  }
});

这样每个文件之间都做了作用域隔离。包装之后的代码会通过vm原生模块的runInThisContext()方法执行(类似eval),返回一个具体的function对象。最后,将当前模块对象的exports属性、require()方法、module以及在文件定位中得到的完整文件路径和文件目录作为参数传递给这个function()执行。

执行之后,模块的exports属性被返回给了调用方,exports属性上的任何方法和属性都可以被外部调用。

exports对象是通过形参的形式传入的,直接赋值形参会改变形参的引用,所以以下写法是错误的:

代码语言:javascript
复制
exports = function(){
  
};

这样写则是正确的:

代码语言:javascript
复制
module.exports = function(){

};

2、C/C++模块的编译

node调用process.dlopen()方法进行加载和执行。node架构下,dlopen方法在windows和*nix平台下有不同的实现,通过liuv兼容层进行了封装。

事实上,.node文件并不需要编译,这些文件是编写C/C++模块之后编译产生的,所以这里只有加载和执行的结果。执行过程中,exports对象与.node模块产生联系,然后返回给调用者。

C/C++模块的运行效率更高,但编写门槛也比较高。

3、JSON文件的编译

node利用fs模块同步读取JSON文件的内容之后,调用JSON.parse()方法得到对象,然后将其赋值给模块对象的exports,以供外部引用。

JSON文件通常用作项目的配置文件,对于JSON文件的读取直接调用require()方法即可。

2.3 核心模块

node核心模块在编译成可执行文件的过程中被编译进了二进制文件。核心模块包括:C/C++编写的和JavaScript编写的两部分。

2.3.1 JavaScript核心模块的编译过程

编译所有C/C++文件之前,编译程序需要将所有的JavaScript模块文件编译成C/C++代码,但并没有将其直接编译成可执行代码。

1、转存为C/C++代码

node采用一些工具,将所有内置JavaScript代码转换成C++里的数组,这个过程中,JavaScript代码以字符串的形式存储在node命名空间中,是不可执行的。

启动node进程时,JavaScript代码直接加载到内存。在加载的过程中,JavaScript核心模块经历标识符分析后直接定位到内存中。

2、编译JavaScript核心模块

在引入核心模块的过程中,也经历了头尾包装的过程,然后才执行和导出了exports对象。与文件模块不同的是,核心模块是从内存中加载的。

核心模块编译成功的模块会缓存到NativeModule._cache上,文件模块则缓存到Module.__cache对象上。

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

核心模块中,有些模块全部由C/C++编写,有些模块由C/C++完成核心功能其它部分由JavaScript实现包装和对外导出。

静态语言的性能强于脚本语言,脚本语言的开发速度要优于静态语言。

整个过程:

  1. 组织内建模块
  2. 导出内建模块

2.3.3 核心模块的引入流程

  1. NODE_MODULE(node_os,reg_func)
  2. get_builtin_module(“node_os”)
  3. process.binding(“os”)
  4. NativeModule.require(“os”)
  5. require(“os”)

对于用户而言,直接调用require()即可。

2.4 包与NPM

CommonJS的包规范主要由2部分组成:包结构、包描述。

2.4.1 包结构

完全符合CommonJS规范的的包目录应该包含以下这些文件:

  • package.json:包描述文件
  • bin:用于存放可执行二进制文件的目录
  • lib:用于存放JavaScript代码的目录
  • doc:用于存放文档的目录
  • test:用于存放单元测试用例的代码

2.4.2 包描述文件

CommonJS为package.json定义了如下一些必要的字段:

  • name。包名,由小写字母和数字组成,可以包含.、_和-,但不允许出现空格。包名是必须的。
  • description。简介。
  • version。版本号,通常为major.minor.revision格式。(http://semver.org/
  • keywords。关键词数组,用来做分类搜索。
  • maintainers。包维护者列表,符合[{“name”:””,email:””,”web”:””}]格式。npm通过该属性进行权限认证。
  • bugs。一个反馈bug的网站或邮件地址。
  • licenses。许可证列表,符合[{type:””,url:””}]格式。
  • repositories。托管代码的位置列表。
  • dependencies。当前包的依赖列表。
  • devDependencies。开发时依赖的一些包。
  • homepage。当前包的网站地址。
  • os。操作系统支持列表。
  • cpu。cpu架构的支持列表。
  • engine。支持JavaScript的引擎列表。
  • builtin。标志当前包是否是内建在底层系统的标准组件。
  • directories。包目录说明。
  • implements。标志当前包实现了CommonJS的哪些规范。
  • scripts。脚本说明对象。主要用来管理包的安装、编译、测试和卸载等。
  • author。包作者。
  • bin。一些包可以作为命令行工具使用。配置好bin字段后,npm i package_name -g可以将脚本添加到执行路径中。
  • main。require()引入包时会优先检查这个字段。

包规范的定义可以帮助node解决依赖包安装的问题,npm正是基于该规范进行了实现。

通常一些npm包还包含了author、bin、main、scripts、

2.5 前后端共用模块

有很多模块是可以实现前后端共用的,但实际情况,前后端环境是略有差异的。

CommonJS规范并不适合于前端,所以AMD规范最终在前端应用场景中胜出。

2.5.1 AMD规范

AMD规范是CommonJS规范的一个延伸。定义如下:

代码语言:javascript
复制
define(id?,dependencies?,factory);

模块id和依赖是可选的,factory内容就是实际代码的内容。

代码语言:javascript
复制
define(function(){
  const exports = {};
  exports.sayHello = function(){
    console.log("hello world");
  };
  return exports;
});

AMD模块需要用define来明确定义一个模块,CommonJS则是隐式包装的,二者的目的都是为了进行作用域隔离。AMD规范的内容需要通过返回的方式实现导出。

2.5.2 CMD规范

与AMD规范的主要区别在于定义模块和依赖的引入部分。AMD需要在声明模块的时候指定所有依赖,并通过形参传递依赖到模块内容中:(Angular1.x和AMD规范很像)

代码语言:javascript
复制
define(["dep1","dep2"],function(dep1,dep2){
  const exports = {};
  exports.sayHello = function(){
    console.log("hello world");
  };
  return exports;
});

CMD模块更接近于node对CommonJS规范的定义,CMD支持动态引入:

代码语言:javascript
复制
define(function(require,exports,module){

});

require、exports、module通过形参传递给模块。

2.5.3 兼容多种模块规范

为了让一个模块可以运行在前后端,在写作中需要考虑环境问题。为了保持前后端的一致性,需要将代码包裹在一个闭包内:

代码语言:javascript
复制
(function(name,definition){
  const hasDefine = typeof define === "function";
  const hasExports = typeof module !== "undefined" && module.exports;
  if(hasDefine){
    //AMD或CMD规范
    define(definition);
  }else if(hasExports){
    //node模块
    module.exports = definition();
  }else{
    //挂在在window变量下
    this[name] = definition();
  }
})("hello",function(){
  return function(){
    console.log("hello world");
  }
});

三、总结

CommonJS提出的规范十分简单,但现实意义却十分强大。

node通过模块规范,组织了自身的原生模块,弥补了JavaScript弱结构性的问题,形成了稳定的结构,并向外提供服务。

npm通过对包规范的支持,有效组织了第三方模块,这使得项目开发中的依赖问题得到很好的解决。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 一、node简介
    • 1.1 node的特点
      • 1、异步I/O
      • 2、事件与回调函数
      • 3、单线程
      • 4、跨平台
    • 1.2 node应用场景
      • 1、I/O密集型
      • 2、是否不擅长CPU密集型业务
      • 1.3 node使用者
  • 二、模块机制
    • 2.1 CommonJS规范
      • 2.1.1 CommonJS的出发点
      • 2.1.2 CommonJS的模块规范
    • 2.2 node模块的实现
      • 2.2.1 优先缓存加载
      • 2.2.2 路径分析和文件定位
      • 2.2.3 模块编译
    • 2.3 核心模块
      • 2.3.1 JavaScript核心模块的编译过程
      • 2.3.2 C/C++核心模块的编译过程
      • 2.3.3 核心模块的引入流程
    • 2.4 包与NPM
      • 2.4.1 包结构
      • 2.4.2 包描述文件
    • 2.5 前后端共用模块
      • 2.5.1 AMD规范
      • 2.5.2 CMD规范
      • 2.5.3 兼容多种模块规范
  • 三、总结
相关产品与服务
命令行工具
腾讯云命令行工具 TCCLI 是管理腾讯云资源的统一工具。使用腾讯云命令行工具,您可以快速调用腾讯云 API 来管理您的腾讯云资源。此外,您还可以基于腾讯云的命令行工具来做自动化和脚本处理,以更多样的方式进行组合和重用。
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档