重学Node.js及其框架(Express, Koa, egg.js) 之 Nodejs基础

总结自 Coderwhy的nodejs课程。

Express总结: juejin.cn/post/701653…

Koa总结:juejin.cn/post/701658…

nodejs官网:nodejs.org/dist/latest…

Nodejs是什么

Node.js是一个基于V8 JavaScript引擎的JavaScript运行时环境。

V8引擎原理

image.png

Parse模块会将JavaScript代码转换成AST(抽象语法树),这是因为解释器并不直接认识JavaScript代码;

  • 如果函数没有被调用,那么是不会被转换成AST的;
  • Parse的V8官方文档:v8.dev/blog/scanne…

Ignition是一个解释器,会将AST转换成ByteCode(字节码)

  • 同时会收集TurboFan优化所需要的信息(比如函数参数的类型信息,有了类型才能进行真实的运算);
  • 如果函数只调用一次,Ignition会执行解释执行ByteCode;
  • Ignition的V8官方文档:v8.dev/blog/igniti…

TurboFan是一个编译器,可以将字节码编译为CPU可以直接执行的机器码;

  • 如果一个函数被多次调用,那么就会被标记为热点函数,那么就会经过TurboFan转换成优化的机器码,提高代码的执行性能;
  • 但是,机器码实际上也会被还原为ByteCode,这是因为如果后续执行函数的过程中,类型发生了变化(比如sum函数原来执行的是number类型,后来执行变成了string类型),之前优化的机器码并不能正确的处理运算,就会逆向的转换成字节码;
  • TurboFan的V8官方文档:v8.dev/blog/turbof…

上面是JavaScript代码的执行过程,事实上V8的内存回收也是其强大的另外一个原因,不过这里暂时先不展开讨论:

  • Orinoco模块,负责垃圾回收,将程序中不需要的内存回收;
  • Orinoco的V8官方文档:v8.dev/blog/trash-…

Node.js架构

  • 我们编写的JavaScript代码会经过V8引擎,再通过Node.js的Bindings,将任务放到Libuv的事件循环中;
  • libuv(Unicorn Velociraptor—独角伶盗龙)是使用C语言编写的库;
  • libuv提供了事件循环、文件系统读写、网络IO、线程池等等内容;

image.png

Nodejs的全局对象

一些有用的全局对象。

  • __dirname:获取当前文件所在的文件夹的绝对路径
  • __filename:获取当前文件的绝对路径:
  • process对象:
    • cwd(): 获取当前终端运行的文件夹绝对路径。
    • argv:获取在终端执行时传入的参数。
    • env:获取当前程序的环境变量。

Node.js模块化

Node中对CommonJS进行了支持和实现,让我们在开发node的过程中可以方便的进行模块化开发:

  • 在Node中每一个js文件都是一个单独的模块;
  • 这个模块中包括CommonJS规范的核心变量:exports、module.exports、require
  • exportsmodule.exports可以负责对模块中的内容进行导出;
  • require函数可以帮助我们导入其他模块(自定义模块、系统模块、第三方库模块)中的内容;

下面我们将来介绍exports、module.exports、require的使用。

  • exports是一个对象,我们可以在这个对象中添加很多个属性,添加的属性会导出;
  • 我们也可以通过module.exports直接导出一个对象。
  • 我们通过require()函数导入一个文件。并且该文件导出的变量。

下面来详细介绍一个module.exports

  • CommonJS中是没有module.exports的概念的;
  • 但是为了实现模块的导出,Node中使用的是Module的类,每一个模块都是Module的一个实例,也就是module;
  • 所以在Node中真正用于导出的其实根本不是exports,而是module.exports;
  • 因为module才是导出的真正实现者;
  • 并且内部将exports赋值给module.exports

该方式的导入导出有一个特点。

具体请访问:juejin.cn/post/701299…

  • Node中的文件都运行在一个函数中。可以通过打印console.log(arguments.callee + "")来验证。
  • 导入导出是值的引用,如果导出的是一个基本数据类型值,那么导出文件改变该值,然后导入文件该变量的值也不会变。
  • 他是通过require 函数来导入的,只有在执行js代码才会知道模块的依赖关系。
  • 代码是同步执行的。
  • 模块多次引入,只会加载一次。每个module内部会存在一个loaded来确定是否被加载过
  • 代码循环引入的时候,深度优先来加载模块。然后再广度优先。

path模块

只介绍一些常用的API

由于不同操作系统可能使用不同的路径分隔符,所以join方法就非常有用。

  • resolve: 拼接路径。这个会根据传入的第一个路径前面是否有/,../,./来查找本地的完整目录,然后再拼接传入的其他路径。如果第一个传入的路径/开头的,那么将把路径拼接到当前执行文件的绝对路径后面。如果最后一个传入的路径通过/开头,那么我们将直接忽略前面传入的路径参数。
  • join:路径拼接。这个就是传入的是啥拼接的就是啥。傻瓜式拼接。但是也会根据../来拼接上一级
    const { resolve, join } = require("path")

    const first = '/zhang';
    const second = "./hao" || '../hao'
    const third = './llm' || '/llm'

    console.log(resolve( first, second, third)) //C:\zhang\hao\llm || C:\hao\llm || C:\llm
    console.log(join(first, second, third)) // \zhang\hao\llm || \hao\llm || \zhang\hao\llm
复制代码

image.png

  • dirname:获取文件的父文件夹;
  • basename:获取文件名;
  • extname:获取文件扩展名;
    const path = require("path")

    const url = "c:/zh/study/nodejs/index.js";

    console.log(path.dirname(url))// c:/zh/study/nodejs 
    console.log(path.basename(url))// index.js
    console.log(path.extname(url))//.js
复制代码

fs模块

fs模块大部分API的实现都有三种方法。

  • 方式一:同步操作文件:代码会被阻塞,不会继续执行;
  • 方式二:异步回调函数操作文件:代码不会被阻塞,需要传入回调函数,当获取到结果时,回调函数被执行;
  • 方式三:异步Promise操作文件:代码不会被阻塞,通过 fs.promises 调用方法操作,会返回一个Promise,可以通过then、catch进行处理;

方式一:同步操作文件

// 1.方式一: 同步读取文件
const state = fs.statSync('../foo.txt');
console.log(state);

console.log('后续代码执行');
复制代码

方式二:异步回调函数操作文件

// 2.方式二: 异步读取
fs.stat("../foo.txt", (err, state) => {
  if (err) {
    console.log(err);
    return;
  }
  console.log(state);
})
console.log("后续代码执行");
复制代码

方式三:异步Promise操作文件

// 3.方式三: Promise方式
fs.promises.stat("../foo.txt").then(state => {
  console.log(state);
}).catch(err => {
  console.log(err);
})
console.log("后续代码执行");
复制代码

获取文件描述信息

    const fs = require("fs");

    fs.stat("./01.hello.txt", (err, info) => {
      console.log(info)
      console.log(info.isFile())// 判断他是否为文件
      console.log(info.isDirectory())// 判断他是否为文件夹
    })
复制代码

image.png

文件描述符

在 POSIX 系统上,对于每个进程,内核都维护着一张当前打开着的文件和资源的表格。

每个打开的文件都分配了一个称为文件描述符的简单的数字标识符。

在系统层,所有文件系统操作都使用这些文件描述符来标识和跟踪每个特定的文件。

Windows 系统使用了一个虽然不同但概念上类似的机制来跟踪资源。

为了简化用户的工作,Node.js 抽象出操作系统之间的特定差异,并为所有打开的文件分配一个数字型的文件描述符

    const fs = require("fs");

    fs.open("./01.hello.txt", (err, fd) => {
      if (err) {
        console.log(err)
      } else {
        console.log(fd) // fd是一个数字。
        fs.promise.readFile(fd).then(res => {
          console.log(res)
        })
      }
    })
复制代码

文件读写

fs.readFile(path[, options], callback):读取文件的内容; fs.writeFile(file, data[, options], callback):在文件中写入内容;

    const fs = require("fs");


    fs.writeFile("./01.hello.txt", "加入文件", { encoding: 'utf-8', flag: 'a+' }, (err, res) => {
      if (err) {
        console.log(err)
      } else {
        console.log(res)
      }
    })
复制代码

调用读写API很简单就可以操作文件,下面我们来说明一些options选项。

encoding:指定编码格式。注意如果读取文件和写入文件的编码格式不同,将会乱码。一般都采用utf-8编码。如果文件读取不指定编码格式,那么它将返回Buffer数据。

flag: 用于指定文件写入的方式。带有+的flag都可以读写。除了r+文件不存在,读取和写入会报出异常,其他的+flag都可以自动创建。

image.png

文件夹操作

  • 使用fs.mkdir()或fs.mkdirSync()创建一个新文件夹。
    const dirname = './zh';
    if (!fs.existsSync(dirname)) { // 判断文件夹是否存在
      fs.mkdir(dirname, err => {
        console.log(err);
      });
    }
复制代码
  • fs.readdir(), fs.readdirSync()读取文件夹中的所有文件和文件夹
    fs.readdir(dirname, (err, files) => {
      console.log(files);
    });
复制代码
  • rename()重命名文件夹
    fs.rename("./zh", "./llm", err => {
      console.log(err);
    })
复制代码

兄弟们,熟练掌握文件操作,可以做好多骚操作的,写很多方便的脚本。

event模块

  • on,监听事件
  • emit,发送事件
  • off,移除事件。需要指定事件名和事件
  • once,只执行一次
  • removeAllListeners():删除全部事件,或者传入事件名来移除指定事件。
  • eventNames()获取全部事件名。
  • listenerCount(事件名)获取当前事件注册的个数。
  • listeners(事件名)获取当前事件数组。
    const EventEmitter = require("events");


    const events = new EventEmitter();

    // 监听事件
    events.addListener("zhanghao", (...args) => {
      console.log("addListener监听", ...args)
    })
    const listener1 = (...args) => {
      console.log("on监听", ...args)
    }
    events.addListener("llm", listener1)

    // events.off("llm", listener1)

    // 注册事件
    events.removeAllListeners("llm")
    events.emit("zhanghao", "我的")
    events.emit("llm", "他的")

    console.log(events.listenerCount('zhanghao'))
    console.log(events.listeners('llm'))
复制代码

Buffer

创建buffer的常用方式

  • from
    // 编码和解码的方式不同,解码会出现乱码。
    const buf = Buffer.from("中国加油", 'utf16le');

    // 对于utf-8编码的汉字而言,一个汉字等于3字节,对于utf16le编码的汉字而言,一个汉字等于2字节
    console.log(buf)

    console.log(buf.toString("utf16le"))// 指定编码,不然默认是utf-8编码,会乱码。
复制代码
  • alloc(指定创建多大的缓存区)

    // buf就相当于一个字节数组。没有给数组赋值时,他就相当于都是0000000...占位
    const buf = Buffer.alloc(10);

    console.log(buf) //<Buffer 00 00 00 00 00 00 00 00 00 00>


    // 对buffer赋值
    buf[0] = 0x90; // 为了方便展示,输出的都是16进制来表示二进制。4位等于一个16进制数。所以2个16进制数表示1字节

    console.log(buf)// <Buffer 90 00 00 00 00 00 00 00 00 00>
复制代码

读取二进制文件(视频,图片等非文本文件)并对其做一些事情

我们可以通过sharp库或者jimp库来对图片进行处理

github.com/lovell/shar…

www.npmjs.com/package/jim…

    // 将图片做简单的变化,然后输出
    const sharp = require("sharp");
    sharp('./sun.jpg')
      .resize(200, 200)
      .toFile("sunCopy.jpg")
复制代码

stream流

什么是流

程序中的流也是类似的含义,我们可以想象当我们从一个文件中读取数据时,文件的二进制(字节)数据会源源不断的被读取到我们程序中。而这个一连串的字节,就是我们程序中的流。所有的流都是EventEmitter的实例

在之前学习文件的读写时,我们可以直接通过 readFile或者 writeFile方式读写文件,为什么还需要流呢?

  • 直接读写文件的方式,虽然简单,但是无法控制一些细节的操作。比如从什么位置开始读、读到什么位置、一次性读取多少个字节;
  • 读到某个位置后,暂停读取,某个时刻恢复读取等等;
  • 或者这个文件非常大,比如一个视频文件,一次性全部读取并不合适;

Node.js中有四种基本流类型:

  • Writable:可以向其写入数据的流(例如 fs.createWriteStream())。
  • Readable:可以从中读取数据的流(例如 fs.createReadStream())。
  • Duplex:同时为Readable和的流Writable(例如 net.Socket)。
  • Transform:Duplex可以在写入和读取数据时修改或转换数据的流(例如zlib.createDeflate())。

下面我们来介绍一下可读流和可写流:

Readable可读流

以前通过fs.readFile()来读取文件,是一次性将一个文件中所有的内容都读取到程序(内存)中。并且不能指定读取位置。所以我们就需要通过可读流fs.createReadStream(path, options)来读取文件。读取完毕后文件自动关闭 其中options就是来对读取文件做约束的。

  • start:开始读取的位置(从0开始的)
  • end:结束的位置
  • highWaterMark:文件一次性读取多少字节。默认64kb
  • encoding: 编码格式
  • flags: 文件标识。默认为r
  • ...
    const fs = require("fs");

    const read = fs.createReadStream("./zh.txt", {
      // 位置是从0开始的
      start: 3,
      end: 8,
      highWaterMark: 2, //文件一次性读取多少字节。默认64kb
      encoding: 'utf-8'
    })

    // 可以通过pause(),resume()方法对文件读取进行暂停和恢复。
    read.on("data", (data) => {
      console.log(data)
    })
    read.on("open", () => {
      console.log("文件打开")
    })
    read.on("close", () => {
      console.log("文件关闭")
    })

    read.on("end", () => {
      console.log("文件读取完毕")
    })
复制代码

Writable可写流

以前通过fs.writeFile()来写入文件,而且只能是将源文件覆盖或者追加到源文件后面。所以我们就需要通过可读流fs.createWriteStream(path, options)来精确地写入文件。

其中options就是来对写入文件做约束的。

  • start:开始读取的位置(从0开始的)
  • encoding: 编码格式
  • flags: 文件标识。默认为w
    const fs = require("fs");

    const writeStream = fs.createWriteStream("./zh.txt", {
      flags: 'r+', // 这里可能会出现bug,应该需要指定为r+。
      encoding: 'utf-8',
      start: 4
    })

    // 写入时会覆盖原来位置的文字。
    writeStream.write("0000", (err) => {
      console.log(err)
    })
    
    // end方法既可以写入东西,并且也会自动关闭文件。
    writeStream.end("文件写入的内容")

    writeStream.on("open", () => {
      console.log("文件即将写入")
    })

    // writeStream.close()

    writeStream.on("close", () => {
      console.log("文件关闭")
    })
复制代码

pipe

连接可写流和可读流。

可读流 =====pipe=====> 可写流

    const fs = require('fs');


    const reader = fs.createReadStream("./zh.txt");
    const writer = fs.createWriteStream("./llm.txt");


    // pipe管道会自动关闭。不需要我们手动调用close事件。
    reader.pipe(writer)

    writer.on("close", () => {
      console.log("文件将要关闭")
    })
复制代码

http模块

什么是Web服务器?

当应用程序(客户端)需要某一个资源时,可以向一个台服务器,通过Http请求获取到这个资源;提供资源的这个服务器,就是一个Web服务器;

创建服务器

    const http = require("http");



    // 方式一
    const server = new http.Server((req, res) => {
      res.writeHead(200, {
        'content-type': 'text/html;charset:ASCII'
      })

      res.end("<h1>我是一个汉字,当编码格式是ascii时,看其乱不乱码</h1>")//会乱码。需要设置charset:utf-8格式。
    })


    server.listen(8080, () => {
      console.log("服务器创建成功")
    })


    // 方式二,这种方式底层其实也是调用Server类。
    http.createServer((req, res) => {
      // 指定body中数据的形式
      // req.setEncoding("utf-8")
      req.setEncoding("binary")
      req.on("data", (data) => {
        console.log("data", typeof data) //string
        res.end(data)
      })

      // res.end("返回数据")
    }).listen(3030, () => {
      console.log("服务器启动在3030端口")
    })
复制代码

Server通过listen方法来开启服务器,并且在某一个主机和端口上监听网络请求:

listen函数有三个参数:

  • 端口port: 可以不传, 系统会默认分配端, 可以通过server.address().port拿到端口号,后续项目中我们会写入到环境变量中;

  • 主机host: 通常可以传入localhost、ip地址127.0.0.1、或者ip地址0.0.0.0,默认是0.0.0.0;

    • localhost:本质上是一个域名,通常情况下会被解析成127.0.0.1;

    • 127.0.0.1:回环地址(Loop Back Address),表达的意思其实是我们主机自己发出去的包,直接被自己接收;正常的数据包经过应用层 - 传输层 - 网络层 - 数据链路层 - 物理层 ;而回环地址,是在网络层直接就被获取到了,是不会经常数据链路层和物理层的;比如我们监听 127.0.0.1时,在同一个网段下的主机中,通过主机ip地址是不能访问的;

    • 0.0.0.0:监听IPV4上所有的地址,再根据端口找到不同的应用程序;比如我们监听 0.0.0.0时,在同一个网段下的主机中,通过ip地址是可以访问的;回调函数:服务器启动成功时的回调函数;

解析query参数

我们可以通过url模块解析req.url取出query。然后在通过querystring模块解析query字段生成一个对象。

const url = require("url")
const queryString = require("querystring")
const {query} = url.parse(req.url);
const queryObj = queryString.parse(query);
复制代码

url解析

    const url = require("url");
    console.log(url.parse("http://127.0.0.1/user/1?name=zh&age=20#llm"))
复制代码

image.png

解析请求体中的参数

由于request是流。所以我们读取post请求传入的数据,我们需要通过监听data事件来读取,然后再将二进制流转为字符串。然后在通过JSON.parse()解析字符串。

      let dataBody = ""
      // 设置body的编码
      req.setEncoding("utf-8")
      req.on("data", (data) => {
        dataBody += data; // 这里拼接是为了当一次输入类型不能完全获取,需要多次拼接,才能获取完整数据。
      })
      req.on("close", () => {
        // 当文件关闭之前,解析参数
        const requestData = JSON.parse(dataBody)
        console.log(requestData)
      })
复制代码

请求和相应的一些字段

请求头字段

content-type: 指定传入的数据的类型。请求体类型

  • application/json表示是一个json类型;
  • text/plain表示是文本类型;
  • application/xml表示是xml类型;
  • multipart/form-data表示是上传文件;

content-length: 传递的数据长度。

accept-encoing:告诉服务器,客户端支持的文件压缩格式。

accept: 告诉服务器,客户端可以接受文件的格式类型。

响应response

只能通过res.end()方法来结束响应。并且返回响应。

Write方法:这种方式是直接写出数据,但是并没有关闭流。 设置状态码:

  • res.statusCode = “”。
  • res.writeHead("状态码", {响应头})

设置响应头

  • res.setHeader()

注意:text/html表示响应字段返回的是一个html格式的,可以直接渲染在页面中,但是application/html,表示下载html文件。

http状态码

HTTP状态码(HTTP Status Code),用以表示网页服务器超文本传输协议响应状态的3位数字代码。

http状态码的作用是服务器告诉客户端当前请求响应的状态,通过状态码就能判断和分析服务器的运行状态。

下面给出一些状态码的适用场景:

  • 100:客户端在发送POST数据给服务器前,征询服务器情况,看服务器是否处理POST的数据,如果不处理,客户端则不上传POST数据,如果处理,则POST上传数据。常用于POST大数据传输
  • 206:一般用来做断点续传,或者是视频文件等大文件的加载
  • 301:永久重定向会缓存。新域名替换旧域名,旧的域名不再使用时,用户访问旧域名时用301就重定向到新的域名
  • 302:临时重定向不会缓存,常用 于未登陆的用户访问用户中心重定向到登录页面
  • 304:协商缓存,告诉客户端有缓存,直接使用缓存中的数据,返回页面的只有头部信息,是没有内容部分
  • 400:参数有误,请求无法被服务器识别
  • 403:告诉客户端进制访问该站点或者资源,如在外网环境下,然后访问只有内网IP才能访问的时候则返回
  • 404:服务器找不到资源时,或者服务器拒绝请求又不想说明理由时
  • 503:服务器停机维护时,主动用503响应请求或 nginx 设置限速,超过限速,会返回503
  • 504:网关超时

分类

状态码第一位数字决定了不同的响应状态,有如下:

  • 1 表示消息
  • 2 表示成功
  • 3 表示重定向
  • 4 表示请求错误
  • 5 表示服务器错误
1xx

代表请求已被接受,需要继续处理。这类响应是临时响应,只包含状态行和某些可选的响应头信息,并以空行结束

常见的有:

  • 100(客户端继续发送请求,这是临时响应):这个临时响应是用来通知客户端它的部分请求已经被服务器接收,且仍未被拒绝。客户端应当继续发送请求的剩余部分,或者如果请求已经完成,忽略这个响应。服务器必须在请求完成后向客户端发送一个最终响应
  • 101:服务器根据客户端的请求切换协议,主要用于websocket或http2升级
2xx

代表请求已成功被服务器接收、理解、并接受

常见的有:

  • 200(成功):请求已成功,请求所希望的响应头或数据体将随此响应返回
  • 201(已创建):请求成功并且服务器创建了新的资源
  • 202(已创建):服务器已经接收请求,但尚未处理
  • 203(非授权信息):服务器已成功处理请求,但返回的信息可能来自另一来源
  • 204(无内容):服务器成功处理请求,但没有返回任何内容
  • 205(重置内容):服务器成功处理请求,但没有返回任何内容
  • 206(部分内容):服务器成功处理了部分请求
3xx

表示要完成请求,需要进一步操作。 通常,这些状态代码用来重定向

常见的有:

  • 300(多种选择):针对请求,服务器可执行多种操作。 服务器可根据请求者 (user agent) 选择一项操作,或提供操作列表供请求者选择
  • 301(永久移动):请求的网页已永久移动到新位置。 服务器返回此响应(对 GET 或 HEAD 请求的响应)时,会自动将请求者转到新位置
  • 302(临时移动): 服务器目前从不同位置的网页响应请求,但请求者应继续使用原有位置来进行以后的请求
  • 303(查看其他位置):请求者应当对不同的位置使用单独的 GET 请求来检索响应时,服务器返回此代码
  • 305 (使用代理): 请求者只能使用代理访问请求的网页。 如果服务器返回此响应,还表示请求者应使用代理
  • 307 (临时重定向): 服务器目前从不同位置的网页响应请求,但请求者应继续使用原有位置来进行以后的请求
4xx

代表了客户端看起来可能发生了错误,妨碍了服务器的处理

常见的有:

  • 400(错误请求): 服务器不理解请求的语法
  • 401(未授权): 请求要求身份验证。 对于需要登录的网页,服务器可能返回此响应。
  • 403(禁止): 服务器拒绝请求
  • 404(未找到): 服务器找不到请求的网页
  • 405(方法禁用): 禁用请求中指定的方法
  • 406(不接受): 无法使用请求的内容特性响应请求的网页
  • 407(需要代理授权): 此状态代码与 401(未授权)类似,但指定请求者应当授权使用代理
  • 408(请求超时): 服务器等候请求时发生超时
5xx

表示服务器无法完成明显有效的请求。这类状态码代表了服务器在处理请求的过程中有错误或者异常状态发生

常见的有:

  • 500(服务器内部错误):服务器遇到错误,无法完成请求
  • 501(尚未实施):服务器不具备完成请求的功能。 例如,服务器无法识别请求方法时可能会返回此代码
  • 502(错误网关): 服务器作为网关或代理,从上游服务器收到无效响应
  • 503(服务不可用): 服务器目前无法使用(由于超载或停机维护)
  • 504(网关超时): 服务器作为网关或代理,但是没有及时从上游服务器收到请求
  • 505(HTTP 版本不受支持): 服务器不支持请求中所用的 HTTP 协议版本

事件循环

什么是事件循环?

事实上我把事件循环理解成我们编写的JavaScript和浏览器或者Node之间的一个桥梁。

浏览器的事件循环是一个我们编写的JavaScript代码和浏览器API调用(setTimeout/AJAX/监听事件等)的一个桥梁,桥梁之间他们通过回调函数进行沟通。

Node的事件循环是一个我们编写的JavaScript代码和系统调用(file system、network等)之间的一个桥梁, 桥梁之间他们通过回调函数进行沟通的.

浏览器中的事件循环

主线程

宏任务队列:ajax、setTimeout、setInterval、DOM监听、UI Rendering等

微任务队列:Promise的then回调、 Mutation Observer API、queueMicrotask()等

队列中的回调函数,只有当满足条件的时候,他才会加入到队列中。

当主线程中的程序执行完毕后,就会去查看微任务队列中是否有事件,如果有则将它放在调用栈中。当微任务队列执行完毕,那么它将调用宏任务队列中的事件。在此期间,如果宏任务中的事件还会产生微任务,那么他将去执行微任务队列中的事件。

只要微任务队列中有事件,那么将不会执行宏任务队列中的事件。知道微任务队列中事件执行完毕。

Node.js中的事件循环

浏览器中的EventLoop是根据HTML5定义的规范来实现的,不同的浏览器可能会有不同的实现,而Node中是由libuv实现的。

libuv中主要维护了一个EventLoop和worker threads(线程池);

  • EventLoop负责调用系统的一些其他操作:文件的IO、Network、child-processes等

libuv是一个多平台的专注于异步IO的库,它最初是为Node开发的,但是现在也被使用到Luvit、Julia、pyuv等其他地方; image.png

阻塞IO和非阻塞IO

操作系统为我们提供了阻塞式调用和非阻塞式调用:

  • 阻塞式调用: 调用结果返回之前,当前线程处于阻塞态(阻塞态CPU是不会分配时间片的),调用线程只有在得到调用结果之后才会继续执行。
  • 非阻塞式调用: 调用执行之后,当前线程不会停止执行,只需要过一段时间来检查一下有没有结果返回即可。

所以我们开发中的很多耗时操作,都可以基于这样的 非阻塞式调用:

  • 比如网络请求本身使用了Socket通信,而Socket本身提供了select模型,可以进行非阻塞方式的工作;
  • 比如文件读写的IO操作,我们可以使用操作系统提供的基于事件的回调机制;

非阻塞IO存在的问题:

但是非阻塞IO也会存在一定的问题:我们并没有获取到需要读取(我们以读取为例)的结果 那么就意味着为了可以知道是否读取到了完整的数据,我们需要频繁的去确定读取到的数据是否是完整的;这个过程我们称之为轮训操作;

libuv提供了一个线程池(Thread Pool),线程池会负责所有相关的操作,并且会通过轮训等方式等待结果; 当获取到结果时,就可以将对应的回调放到事件循环(某一个事件队列)中; (这就好像浏览器中的时间回调,等到达一定条件,就会将事件放在相应的事件队列中) 事件循环就可以负责接管后续的回调工作,告知JavaScript应用程序执行对应的回调函数;

Node.js中事件循环阶段

  • 定时器(Timers):本阶段执行已经被 setTimeout() 和 setInterval() 的调度回调函数。
  • 待定回调(Pending Callback):对某些系统操作(如TCP错误类型)执行回调,比如TCP连接时接收到ECONNREFUSED。
  • idle, prepare:仅系统内部使用。
  • 轮询(Poll):检索新的 I/O 事件;执行与 I/O 相关的回调;
  • 检测:setImmediate() 回调函数在这里执行。
  • 关闭的回调函数:一些关闭的回调函数,如:socket.on('close', ...)。

Node.js中宏任务和微任务

从一次事件循环的Tick来说,Node的事件循环更复杂,它也分为微任务和宏任务:

  • 宏任务(macrotask):setTimeout、setInterval、IO事件、setImmediate、close事件;

  • 微任务(microtask):Promise的then回调、process.nextTick、queueMicrotask;

但是,Node中的事件循环不只是 微任务队列和 宏任务队列:

微任务队列:

  • next tick queue:process.nextTick;

  • other queue:Promise的then回调、queueMicrotask;

宏任务队列:

  • timer queue:setTimeout、setInterval;

  • poll queue:IO事件;

  • check queue:setImmediate;

  • close queue:close事件;

一道面试题

下面代码输出结果

    setTimeout(() => {
      console.log("setTimeout")
    }, 0)

    setImmediate(() => {
      console.log("setImmediate")
    })

    // 上面代码谁先输出。
    都有可能,因为libuv库在处理事件循环的时候,每次处理的时间不同。并且会在IO操作停留很长时间,
    如果在这次处理时,setTimeout函数未加入到timers队列中,那么就是setImmediate先执行,
    反之setTimeout先执行。
复制代码

猜你喜欢

转载自juejin.im/post/7016356622435876878