深入浅出Electron:原理、工程与实践
上QQ阅读APP看书,第一时间看更新

2.2 Node.js原理

Node.js是一个集成项目,是一系列成熟项目的集合体,内置了V8、libuv、zlib、openssl等项目。Node.js的出现让JavaScript脱离了浏览器的束缚,使JavaScript程序员可以在服务端市场和客户端市场大展拳脚。

对于开发者来说,Node.js有以下两个主要特点。

1)Node.js解释并执行JavaScript脚本时是单线程执行的,也就是说,同一时刻只有一个用户线程用于JavaScript程序的执行。由于是单线程执行,所以开发者不需要考虑线程同步和线程间共享内存的问题,也不需要处理线程竞争访问资源的问题,这极大地减轻了开发者的负担,也使得运行在Node.js环境的项目更易于维护。

2)Node.js绝大多数接口都具备非阻塞I/O的特性,无论是读写文件、网络请求还是进程间通信,开发者都不必等待操作执行完成再执行下一步操作。所有的完成信号都是以事件或回调函数的形式暴露给开发者的。相对于其他编程语言或框架来说,为了在读写文件时不阻塞现有线程的执行,必须自己实现异步逻辑,但Node.js天生就是异步的,这更符合实际的需求,也为开发者减轻了不少负担。

正是Node.js的这两个特性,导致Node.js不适合执行CPU密集型任务(比如科学计算、音视频处理、仿真模拟等),因为它没有多线程模型来支持并行性(目前Node.js已经有worker_threads模块支持并行地执行JavaScript线程,但实际应用中其能力尚显不足),所以Node.js更适合I/O密集型的任务(比如处理网络请求、用户交互等)。

无论是Node.js的内部实现还是Node.js为上层JavaScript应用提供的支持都是高度模块化的,现在我们就从Node.js的模块化机制为切入点讲解Node.js的原理。

在Node.js中,模块主要可以分为以下几种类型。

  • 核心模块:是Node.js内置的,被编译到Node.js的可执行文件中,比如常用的http、fs等模块都属于核心模块。
  • 文件模块:开发者通过require的方式加载的指定文件,比如require('./util.js')。
  • 第三方模块:开发者通过npm安装到node_modules目录下的模块,其内部原理与文件模块类似。

由于文件模块和第三方模块都是开发者提供的,我们只关注Node.js内置的核心模块,大部分Node.js内置的核心模块都分为上、下两个部分,位于上层的部分是API部分,由JavaScript编写,目的是为开发者提供使用接口。

位于下层的部分是实现部分,是用C++语言编写的,是与操作系统交互的实现层。开发者一般不会直接访问核心模块的实现层(当然也有一些特例,只包含上层的包装层,不包含C++实现层)。图2-2是Node.js的一个简单的架构图。

031-1

图2-2 Node.js架构图

我们先简要介绍一下倒数第二层中各个模块的用途。

  • V8:高性能JavaScript的执行引擎,同时拥有解释执行和编译执行的能力,可以将JavaScript代码编译为底层机器码,Node.js通过V8引擎提供的C++ API使V8引擎解析并执行JavaScript代码,并且通过V8引擎公开的接口和类型把自己内置的C++模块和方法转换为可被JavaScript访问的形式。
  • libuv:高性能、跨平台事件驱动的I/O库,它提供了文件系统、网络、子进程、管道、信号处理、轮询和流的管控机制。它还包括一个线程池,用于某些不易于在操作系统级别完成的异步工作。
  • c-ares:异步DNS解析库。用于支持Node.js的DNS模块。
  • llhttp:一款由TypeScript和C语言编写的轻量级HTTP解析器,内存消耗非常小。
  • open-ssl:提供了经过严格测试的各种加密解密算法的实现,用于支持Node.js的crypto模块。
  • zlib:提供同步的、异步的或流式的压缩和解压缩能力,用于支持Node.js的zlib模块。

在所有支持Node.js运行的模块中,显然V8和libuv是最重要的,我们通过Node.js的启动过程来了解一下它们是怎么在Node.js中提供支持的。

当使用Node.js执行一个JavaScript脚本文件时,比如:

> node index.js

Node.js内部执行以下五项任务。

1)初始化自己的执行环境:在这个阶段Node.js会注册一系列的C++模块以备将来使用。

2)创建libuv的消息循环:这个消息循环会伴随着整个应用的生命周期,运行线程退出它才会退出。libuv模块内部持有一个非常复杂的结构体,当用户的代码开始读取文件或发起网络请求时,Node.js就会给这个结构体增加一个回调函数,libuv的消息循环会不断地遍历这个结构体上的回调函数,当读取文件或发起网络请求有数据可用时,就会执行用户的回调函数。

3)创建V8引擎的运行环境:这是一个拥有自己栈的隔离环境。

4)绑定底层模块:Node.js会使V8引擎执行一个JavaScript脚本(node_bootstrap.js),这是Node.js内置的一个脚本,这个脚本负责绑定Node.js注册的那一系列C++模块。

5)读取并执行index.js文件的内容:Node.js会把这个文件的内容交给V8引擎运行,并把运行结果返回给用户。

为了理解方便,这里省略了很多环节。

拓展:实际上libuv针对不同的操作,使用的是不同的异步模型,比如与网络相关的异步模型就是基于操作系统提供的事件驱动模块实现的,与文件系统相关的异步模型就是基于线程池的方案实现的(各操作系统的文件系统API差异较大,才导致libuv使用线程池方案的),其他的还有定时器相关的异步模型和进程控制相关的异步模型,这些都体现在libuv模块初始化时注册的那个复杂的结构体的定义上。

在第4)步中,绑定底层模块一般有三种方式:Binding、LinkedBinding和Internal-Binding。这三种方式都利用了V8公开的C++ API将原生方法转换成可被JavaScript代码使用的方法。

我们可以看一段演示性的代码:

// C++里定义
Handle<FunctionTemplate> Test = FunctionTemplate::New(cb); // cb是一个C++的方法指针
global->Set(String::New("yourCppFunction"), Test);

// js里使用
var test = new yourCppFunction();

上面的C++代码为JavaScript公开了一个名为yourCppFunction的方法,当JavaScript调用这个方法时,将执行C++内部的cb方法,我们在第16章还会进一步介绍相关的知识。

这三种绑定原生模块的方式中常用的只有后面两种:InternalBinding和LinkedBinding。

InternalBinding对应Node.js内部私有的C++绑定程序,用户无法访问。

LinkedBinding对应开发者自己实现的C++绑定程序,Electron内部也大量使用了这种绑定形式为用户提供API。