标签 JavaScript 下的文章

关于 Node.js 测试

单元测试是大型 Web 项目必不可少的部分. 测试和其他部分一样, 从一开始就要设计好实现, 否则可能就可能不断给自己买埋坑. Node.js 的测试一般由测试框架和断言库两部分组成.

测试框架用的比较多的是 mocha, 我也用过一阵子的 ava. mocha 大家可能都比较熟悉, ava 可能实际使用的不多. 这两个其实差别也有点大, ava 的话是每个文件都开一个进程执行, 并且文件内的测试默认是并发执行, 听起来就很快. ava 的写法也比 mocha 简洁很多. ava 自带断言库, 并且支持异步断言(assert 通过插件也可以实现, 但没有 ava 简洁). 但 ava 对 typescript 支持不好, 需要 TypeScript 文件先编译成 JavaScript 文件后才能进行测试. 而 mocha 就不存在这个问题.

就如同 ava 官网介绍的, 面向未来的测试框架. 我个人还是非常看好 ava 的, 一旦它支持 TypeScript 了, 我立马就会转过来.

断言库的话一开始实习公司用的是 should. 我不知道为什么很多人喜欢用 should, api 长又难记. 我对它一直都很抗拒, 后面公司的断言库都是使用 assert(chai) 来进行. 除了 should 和 assert 之外还有一个比较有名的断言库就是 expect. 萝卜青菜各有所爱, 我个人偏爱是 assert, 现在主要是使用 power-assert, api 非常简洁, 而且错误提示做的一级棒.

// assert
assert.lengthOf(foo, 3)
// expect
expect(foo).to.have.length(3)
// should
foo.should.have.length(3)
// power-assert
assert(foo.length === 3)

正如 power-assert 官网说的, NO API is the best API. power-assert 简直不要太好, 特别是当断言错误时他的提醒更是极致. 就像下面这样:

    assert(types[index].name === bob.name)
           |    ||      |    |   |   |
           |    ||      |    |   |   "bob"
           |    ||      |    |   Person{name:"bob",age:5}
           |    ||      |    false
           |    |11     "alice"
           |    Person{name:"alice",age:3}
           ["string",98.6,true,false,null,undefined,#Array#,#Object#,NaN,Infinity,/^not/,#Person#]

    --- [string] bob.name
    +++ [string] types[index].name
    @@ -1,3 +1,5 @@
    -bob
    +alice

反观 assert, 原生的 assert 的问题在于功能太少. 所以一般会使用 chai. 而 expect 和 should, 总之我是写不下去.

因为我现在都是在写 TypeScript. 所以现在选定的测试套件是 mocha + power-assert.

测试遇到的问题

我个人参与了很多后端项目的开发, 特别是前期开发部分. 早期的测试文件也是由我来写, 我也认真思考过如何写一个比较清晰可维护的测试目录结构. 这过程也踩了不少坑, 这里简单介绍下.

测试前置数据

很多业务代码的测试是需要一些前置数据的, 一开始, 前置数据我们都是直接在该测试文件的 before 钩子完成, 然后在 after 钩子进行销毁. 但随着项目越来越大, 这种方式的弊端开始显现, 很多文件其实 before 部分是大体一致的, 这样对于代码修改起来就十分不便, 另外频繁的创建销毁也增加了开销.

很自然的就可以想到, 可以有一个全局的钩子, 批量创建测试需要的所有数据, 然后最后再统一销毁. 一开始没有了解到 mocha 的全局钩子, 我们是 mocha 运行一个文件, 该文件导入其他测试文件来进行的. 但其实 mocha 本身有全局钩子的功能, 利用全局钩子就可以干这种事情了. 这样就解决了上述的问题.

单进程测试应用奔溃

但其实还有一个一直被大多数人忽略的问题, 一般我们也是在测试里面启动项目, 然后对项目进行测试, 如果项目运行奔溃, 我们的测试进程也就跟着奔溃了. 结果就是 after 钩子无法被执行. 数据库有残余的测试数据插入. 对后面的再次测试也可能会产生影响.

要解决这个问题. 只能开多进程解决. 我们可以利用 cluster 来实现. mocha 自带延迟执行的功能, 我们需要延缓 mocha 的运行否则其全局钩子可能出问题.

测试覆盖率和持续集成

关于测试覆盖率报告, 我一直都是使用 nyc 来进行. nyc 的优点在于不需要进行额外配置, 兼容 babel, typescript. 测试覆盖统计也很准确, 使用起来非常简单. 并且也可以自定义测试覆盖率报告的输出格式. nyc 输出测试覆盖报告后我们可以再通过 codecov 来上传测试覆盖率报告.

持续集成方面, travis-ci 和 circle-ci 我都使用过. 简单的来说, travis-ci 用的比较多, 书写格式比较简洁, 用法比较简单, 文档也很容易看, 但测试速度贼慢. 而 circle-ci 支持多种格式, 支持 docker, 测试速度快, 但配置起来可能比较麻烦. 我目前基本都在使用 circle-ci, 因为 travis-ci 实在慢并且好像是要收费??

- 阅读剩余部分 -

Vue 双向绑定原理

以前写 AngularJS 的时候曾经分析过其双向绑定脏检查机制, 转向 Vue 开发也挺久了但一直没有去研究其源码, 也就前段时间去研究下了其 nextTick 的实现. 今天主要看了 Vue 双向绑定的机制, 感觉 Vue 的代码比起 AngularJS 要容易看很多了, 不过东西也挺多的, 代码质量不错, 写篇文章巩固下.

源码分析

Vue 实现双向绑定有一个非常重要的方法就是 defineReactive, 搞懂这个方法对理解 Vue 的双向绑定帮助非常大. 文章源码篇幅比较多, 但其实代码并不难理解.

defineReactive 大致过程如下:

(注: 结合我看的最新的源码, 图中观察整体 data 应该是用的 observe 方法而不是 defineProperty, 这两个方法区别在于是否需要劫持当前对象自身的 getter, setter.)

defineReactive

源码如下:

- 阅读剩余部分 -

JavaScript 准确判断数组

JavaScript 中判断数组看起来有很多方式:

[].constructor === Array
[].__proto__ === Array.prototype
Reflect.getPrototypeOf([]) === Array.prototype
Object.prototype.toString.call([]) === '[object Array]'
[].pop.toString() === 'function pop() { [native code] }'
[] instanceof Array
Array.isArray([])

正常情况下以上所有方法都是可以用来判断的, 但只有最后一种最准确. 其他方法都没法完全适用. 为什么这么说呢?

  1. 使用 constructor 或者 proto 或者 getPrototypeOf 方法

    实例化后的数组有一个 constructor 属性指向 Array. 这里稍微补充一个点, 在浏览器环境中, [].__proto__ === [].constructor.prototype === Array.prototype. 因此我们也可以通过 [].__proto__ === Array.prototype 来判断. 但 __proto__ 已经是一个已经被废弃的属性了, 不建议使用. 取而代之我们可以用 Reflect 来处理, 即 Reflect.getPrototypeOf([]) === Array.prototype. 它们是等效的. 那么用上述方法判断有什么问题呢?

    我们举个反例:

    const foo = Object.create(null)
    foo.constructor = Array
    console.log(foo.constructor === Array) // true
    foo.__proto__ = Array.prototype
    console.log(foo.__proto__ === Array.prototype) // true
    Reflect.setPrototypeOf(foo, Array.prototype)
    console.log(Reflect.getPrototypeof(foo) === Array.prototype) // true

    也就是说, 这个方法的问题在于 constructor__proto__(对象原型) 可以被重写. 虽然一般不会有人这样去写代码, 但是我们应该知道这些方法判断是有隐患的.

  2. 使用 Object.prototype.toString.call 方法

    JavaScript 内建对象一般都存在几个 Symbol 值, 这些在前面关于 JavaScript 元编程的文章已经有提及. 在调用 Object.prototype.toString() 方法时, 它会检查传入的参数是否拥有 [Symbol.toStringTag] 这个属性并且其为字符串, 如果满足的话, 则会被拼接返回, 下面的例子我们让他返回了跟数组一样的结果.

    class Bar {
     get [Symbol.toStringTag] () {
       return 'Array'
     }
    }
    console.log(Object.prototype.toString.call(new Bar()) === '[object Array]') // true

    因此使用 Object.prototype.toString.call 来判断数组也不是稳妥的, 但在 Array.isArray 不受支持的环境下, 这个方法还是被较多的用来判断数组的.

  3. 使用 [].pop.toString()

    这种方法主要是利用了内建函数隐藏的特点. 但这个是完全可以被伪造的. 但这种伪造就比较刁钻了.

    const foo = {
     pop: {
       toString () {
         return 'function pop() { [native code] }'
       }
     }
    }
    console.log(foo.pop.toString() === [].pop.toString())    // true
  4. instanceof 方法

    该方法实际上是不断的查找左值的原型, 如果找到和右值匹配的, 则返回 true. 该方法大多数情况是没问题的, 但是有一个情况例外:

    例子来源: instanceof_vs_isArray

    var iframe = document.createElement('iframe');
    document.body.appendChild(iframe);
    xArray = window.frames[window.frames.length-1].Array;
    var arr = new xArray(1,2,3); // [1,2,3]
    
    // Correctly checking for Array
    Array.isArray(arr);  // true
    // Considered harmful, because doesn't work though iframes
    arr instanceof Array; // false

    也就是说, 不同的 windows 下的 Array 是不相等的. 同样的, 以下代码判断也不行:

    const newWindow = window.open()
    const foo = new newWindow.window.Array()
    console.log(foo instanceof Array) // false

    相同的, 在 cluster, worker 中也一样. instanceof 有其局限性.

  5. Array.isArray 方法

    这个方法才是判断数组的最佳方法. 在不支持 Array.isArray 的情况下, 一般会使用 toString.call 来作为 Polyfill. Array.isArray 是 ES5 标准, 古董浏览器可能不支持.

- 阅读剩余部分 -

使用 TypeScript 开发后端应用

大多数人接触 TypeScript 开发会遇到的一个问题就是很多原先使用的库都是用 JavaScript 写的. 虽然在 TypeScript 项目中也可以使用他们, 但是有些库的用法还是面向过程形式的, 就和我们在 TypeScript 中用面向对象的写法有点格格不入. 有些库由于比较小众可能不存在声明定义文件, 这就导致了编辑器没法进行静态类型检查.

使用 TypeScript 应该抛弃原型链, 尽可能多的使用面向对象的方式来编程. 尽可能多的使用装饰器. 如果这么做显然就和我们传统的使用 JavaScript 编程有了差异. 那么, 怎样优雅的使用 TypeScript 呢?

TypeScript 生态不乏很多优秀的库, 例如关系型数据库 TypeORM, 依赖注入 inverisifyJS 和 TypeDI, 同时支持 Koa 和 Express 的 routing-controller, 参数校验 class-validator.

如下同时使用 routing-controller 和 class-validator.

// src/controller/ProblemController.ts
import { ProblemService } from 'app/service'
import { Problem } from 'app/entity'
import { Get, JsonController, Param, QueryParams } from 'routing-controllers'
import { Inject, Service } from 'typedi'

export class ProblemQuery { // 这是一个类, 也可以当接口使用, 这里使用的装饰器在下面的 QueryParams 中会被使用
  @IsNumberString() public limit: string = '20'
  @IsNumberString() public offset: string = '0'
  @IsIn(['ASC', 'DESC', 'asc', 'desc']) public order: 'ASC' | 'DESC' = 'ASC'
  @IsString() public sortby: string = 'id'
}

@Service()                          // 用于进行依赖注入
@JsonController('/v1/problems')     // 定义一个路由, 基址为 /v1/problems
export class ProblemsController {

  @Inject()
  private problemService: ProblemService    // 依赖注入 ProblemService

  @Get('/')                         // 匹配 Get /v1/problems 的请求
  public async index (@QueryParams() query: ProblemQuery): Promise<{ data: [Problem[], number]; }> {    // @QueryParams 也是依赖注入, 同时这里结合 class-validator 进行校验
    const data = await this.problemService.list(query.offset, query.limit, query.sortby, query.order)
    return {
      data
    }
  }
}

在这个文件中, 路由和控制器结合在一起, 非常简介. 如果我们要使用中间件的话, 也只需要再 @Get('/') 下面或者 @JsonController('/v1/problems') 下面再加上一个 @UseBefore(middleware) 就好了. 如果需要对某个路由进行限流, 也只是一个装饰器就搞定的事情.

上述的 ProblemService 文件如下:

// src/service/ProblemService.ts
import { Problem } from 'app/entity'
import { ProblemRepository } from 'app/repository'
import { Service } from 'typedi'
import { OrmCustomRepository } from 'typeorm-typedi-extensions'

@Service()  // 这里 ProblemService 可以被依赖注入的关键
export class ProblemService {

  @OrmCustomRepository(ProblemRepository)   // 同样可以依赖注入其他服务
  private problemRepository: ProblemRepository

  public async list (offset: number, limit: number, sortby: string, order: 'ASC' | 'DESC'): Promise<[Problem[], number]> {
    return this.problemRepository.getList(offset, limit, sortby, order)
  }
}

这个文件是一个 Service, 一般在 Service 里面, 我们会调用多种服务例如数据库增删查改, 各种外部 API 调用, 缓存处理等等. 当业务比较复杂时, 一般会把数据库这一层再进行一层抽象, 也就是有了 ProblemRepository 这个文件:

// src/problem/ProblemRepository.ts
import { Problem } from 'app/entity'
import 'reflect-metadata'
import { Service } from 'typedi'
import { EntityRepository, Repository } from 'typeorm'

@Service()
@EntityRepository(Problem)
export class ProblemRepository extends Repository<Problem> {

  public getList (offset: number, limit: number, sortby: string, order: string): Promise<[Problem[], number]> {
    return this.createQueryBuilder('problem')
               .orderBy(`problem.${sortby}`, order as 'ASC'|'DESC')
               .innerJoinAndSelect('problem.user', 'user')
               .setFirstResult(offset)
               .setMaxResults(limit)
               .getManyAndCount()
    }
}

这里就是使用 TypeORM 和数据库交互的代码了. Repository 这层在 Java 中用的很多. 如果不是一般的 CURD 系统的话建议都是加上这一层的.

在下一层就是 Entity 了, 也就是 Model.

import { Submission, User } from 'app/entity'
import { Column, Entity, JoinColumn, OneToMany, OneToOne, PrimaryGeneratedColumn } from 'typeorm'

@Entity()
export class Problem {
  @PrimaryGeneratedColumn()
  id: number

  @Column()
  title: string

  @OneToOne(type => User)
  @JoinColumn()
  user: User

  @OneToMany(type => Submission, submission => submission.problem)
  submissions: Submission[]
}

TypeORM 用装饰器来定义数据库模型, 这种写法非常清晰简洁, 个人非常喜欢这种写法.

当然你也可以继续用 Sequelize, 可以辅助使用 typescript-sequelize 这个库来像上面这样定义 Model. 使用 typescript-sequelize 定义的 Model 不需要单独写接口, 就好像上面 TypeORM 定义一样. 两者我都研究过, TypeORM 的静态类型检查强过 Sequelize 并且我不需要额外引入一个库来像上面这样类似的定义 Model 因此我选择了 TypeORM.

以上四个例子 controller -> service -> repository -> entity. 这应该是一个能够适应一定规模的分层结构.

可以看到无处不是装饰器和依赖注入, 非常简洁.

对于非关系型数据库例如 mongodb 目前我没有看到比较好的 ORM/ODM. TypeORM 的作者写了一个 TypeODM 但是作者说只有 TypeORM 稳定后才会去开发, 现在 TypeODM 功能还很不齐全.

我也在公司项目中使用了 mongoose + typescript 的组合进行开发. 没有使用第三方扩展, 直接用 mongoose 的后果是, 我定义 model 之后还得再单独写一个接口. 这就显得有点累赘. 希望后面有一些更好的解决方案出来.

- 阅读剩余部分 -

入坑 TypeScript

TypeScript 不同于 JavaScript 最大的一点就是添加了类型系统, 在此基础上进行了静态类型检查, 同时也带来了补全和提示. 可以在编译期检查错误, 避免运行时错误. 从而大大降低了出错的可能. 例如

@Service()
export class SubmissionService {
  public async create (userId: string, id: number, code: string, lang: string): Promise<number> {
    const problem = await Problem.findById<Problem>(id)
    if (!problem) {
      throw new BadRequestError('Problem 不存在')
    }
    const submission = await Submission.create<Submission>({
      problemId: id,
      userId,
      lang,
      code
    })
    await queue.submitCheckCodeTask(submission.id)

    return submission.id
  }
}

在这个服务中, 有一个 create 方法, 接收的参数有 userId, id, code, lang. 这里我都定义了参数类型. 在外部调用这个方法时, 如果我没有传入正确的参数类型那么编辑器会提醒同时编译将不通过.

findById 这个方法具有如下签名:

(method) Model<T>.findById<Problem>(identifier?: string | number, options?: IFindOptions): Bluebird<Problem>

一般这些声明文件会在单独的 d.ts 文件里面, 它是一个类型定义文件. TypeScript 一般会把 ts 文件编译成三部分, 一部分是 JS 代码, 一部分是 SourceMap, 再有就是 d.ts 文件包含了各种类型约束.

可以看到 findById 第一个参数接受一个 identifier , 这个 identifier 必须是字符串或者数字, 同时 ? 表示了他是可选参数. 后面的 options 也一样, 它是一个 IFindOptions 类型的. IFindOptions 是我们自己定义的一个接口, 他有点类似 C++ 中的 struct 结构.

这个 IFindOptions 定义如下:

export interface IFindOptions extends LoggingOptions, SearchPathOptions {
    where?: WhereOptions | Array<col | and | or | string> | col | and | or | string;
    attributes?: FindOptionsAttributesArray | {
        include?: FindOptionsAttributesArray;
        exclude?: Array<string>;
    };
    paranoid?: boolean;
    include?: Array<typeof Model | IIncludeOptions>;
    order?: string | col | literal | Array<string | number | typeof Model | {
        model: typeof Model;
        as?: string;
    }> | Array<string | col | literal | Array<string | number | typeof Model | {
        model: typeof Model;
        as?: string;
    }>>;
    limit?: number;
    offset?: number;
    lock?: string | {
        level: string;
        of: typeof Model;
    };
    raw?: boolean;
    having?: WhereOptions;
    group?: string | string[] | Object;
}

IFindOptions 内部所拥有的属性和方法是固定的, 如果你传入了不存在的属性, 或者没有正确传递类型, 那么都会提醒出错.

再看下这段代码:

const submission = await Submission.create<Submission>({
  problemId: id,
  userId,
  lang,
  code
})

这段代码创建了一个 submission 记录. 注意到 create<Submission> 这里, 接触过 C++ 的应该很容易联想到泛型. 如果不加上这个其实也是没问题的, 但编译器将不能正确识别 submissionSubmission 类型. Submission.create 本质上是 Model.create 方法, 它返回的是一个 Model 类型的, 但你可以通过 TypeScript 的泛型支持来让其返回特定 Model 类型.

用一个简单的例子来说明下泛型:

function identity<T>(arg: T): T {
    return arg;
}

如上是一个典型的泛型例子, 参数的类型是不限制的, 但如果我就这样设置为 any 类型, 然后在返回值类型的 data 也给设置为 any 类型, 就无法体现出返回值的 arg 和参数 arg 是同一个这点. 因此我可以通过泛型来声明他们是同一类型. 这样就不会丢失类型细节.

如上的 Submission.create 方法也同理. 也因为其本质是 Model.create 方法, 而该方法第一个参数为 value: any, 因此不能正确检测我们传入的属性和值是否正确匹配 Submission. 但我们可以这样做:

const submission = await Submission.create<Submission>({
  problemId: id,
  userId,
  lang,
  code
} as Submission)

我们在最后面加上了 as Submission, 这是一个类型断言, 也就是要求了前面那个对象要匹配这个结构. 如果我们再 Submission Model 并不存在 code 这个属性, 那么编译不能通过. 如果 Submission Model 上面的 code 是整型而我们传入的是一个字符串, 那么编译不能通过. 除了 as 的用法, 也可以直接在对象前面加上 <Submission> 达到同样目的.

在此之前其实我有使用了 TypeORM, 这是一个用 TypeScript 写的 ORM. 他就能够提示我传入的属性是否正确, 而且也可以提示我有什么属性可以传, 这点比 Sequelize 强大很多. 不过TypeORM 个人用了一段时间觉得很难用, 最后还是结合了 typescript-sequelize 这个库转向了 Sequelize

- 阅读剩余部分 -

浏览器和 Node.js 的事件循环机制

Libuv

Libuv 是 Node.js 关键的组成部分. 它包含了定时器, 非阻塞的网络 I/O, 异步文件系统访问, 子进程等功能. 它封装了 Libev, Libeio 以及 IOCP, 保证了跨平台的通用性. Libuv 的组成大致如下:

libuv_architecture

Node.js 事件循环

事件循环(event loop)是 Node.js 实现非阻塞 I/O 的关键. 尽管 JavaScript 的执行是单线程的, 但事件循环将操作尽可能的交由系统内核去执行. 而由于现代系统内核都是多线程的, 因此可以在后台同时处理多个操作. 当操作中的一个任务完成时, 系统内核告诉 Node.js 将回调添加到轮询队列中以等待执行.

Node.js 的事件循环可以用下图表示

   ┌───────────────────────┐
┌─>│        timers         │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │     I/O callbacks     │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │     idle, prepare     │
│  └──────────┬────────────┘      ┌───────────────┐
│  ┌──────────┴────────────┐      │   incoming:   │
│  │         poll          │<─────┤  connections, │
│  └──────────┬────────────┘      │   data, etc.  │
│  ┌──────────┴────────────┐      └───────────────┘
│  │        check          │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
└──┤    close callbacks    │
   └───────────────────────┘

上面的每一阶段都有一个先进先出的回调队列等待执行. 当事件循环进入该阶段时, 它将执行该阶段应该做的操作, 然后执行该阶段的队列中的回调, 直到队列耗尽或最大数量的回调执行. 之后事件循环将进入下一阶段.

长时间的回调的执行可能使得 poll 阶段的运行时间比 timers 的阀值更长.

  • timers

    this phase executes callbacks scheduled by setTimeout() and setInterval().

    该阶段会执行到期的定时器如 setTimeout 和 setInterval 的回调.

  • I/O callbacks

    executes almost all callbacks with the exception of close callbacks, the ones scheduled by timers, and setImmediate().

    该阶段执行某些系统操作的回调比如 TCP 错误. 当 TCP socket 连接出现 CONNREFUSED 错误时, *nix 系统会把它加入到 I/O callbacks 等待处理.

  • idle, prepare

    only used internally.

  • poll

    retrieve new I/O events; node will block here when appropriate.

    poll 阶段有两个重要任务:

    1. 执行到达阀值的计时器的回调
    2. 处理轮询队列(poll 队列)中的事件

    该阶段会首先检查计时器, 如果没有计时器需要被调度, 则

    • 如果轮询队列不为空, 事件循环将循环遍历轮询队列, 并同步的执行回调直到队列为空或者达到系统限制.
    • 如果轮询队列为空
    • 如果 immediateList 队列不为空, 则进入 check 阶段来执行 setImmediate 的任务
    • 如果 immediateList 队列为空, 则等待任务被加入到轮询队列然后执行, 一旦检测到则立即执行其回调

    当轮询队列为空时, 事件循环会去检查那些已经到达阀值的定时器, 如果有定时器已经准备好, 则事件循环将重新进入 timer 阶段去执行定时器回调.

  • check

    setImmediate() callbacks are invoked here.

    该阶段允许用户在 poll 阶段后立即执行一段回调. 如果 poll 阶段变为空闲并且脚本已经通过 setImmediate 排队, 那么事件循环会继续进入 check 阶段而不是继续等待

    setImmediate 实际上是一个在事件循环中的一个单独阶段运行的特殊定时器, 它使用了 libuv 的 API 来调度回调在轮询阶段完成后执行.

    一般的, 事件循环将进入 poll 阶段进行等待, 等待传入连接, 请求等等. 除非有任务通过 setImmediate 调度并且当前 poll 阶段为空闲, 事件循环将结束 poll 阶段进入 check 阶段.

  • close callbacks

    e.g. socket.on('close', ...).

    如果一个 socket 被突然关闭, 例如通过 socket.destroy(), 那么 close 事件将会在该阶段被触发. 否则它将通过 process.nextTick() 发出.

因此, 理解每个阶段的任务之后, 我们来理解 setTimeout, setImmediate 的执行时机就不难了.

  • setTimeout/setInterval 会在 timers 阶段执行, poll 阶段为空闲时且 immediateList 队列为空时. 它会检测 timer, 如果有准备好的 timer, 则切入 timers 阶段去执行. setTimeout 是被设计在超过阀值时后要运行的脚本.
  • setImmediate 只在 check 阶段执行, 通常是 poll 阶段为空闲时且 immediateList 不为空时切入 check 阶段执行. setImmediate 被设计用来在 poll 阶段执行完成后执行一段用户代码.

如果我们在非 I/O 回调中执行以下脚本, 那么这两个定时器(setImmediate 是是一个在事件循环中的一个单独阶段运行的特殊定时器)的执行顺序是不确定的. 因为它受到进程性能的限制.

// timeout_vs_immediate.js
setTimeout(() => {
  console.log('timeout');
}, 0);

setImmediate(() => {
  console.log('immediate');
});

但在 I/O 回调中他们的顺序就是确定的, setImmediate 总会先于 setTimeout 先执行. 因为 poll 阶段同步的执行 poll 队列的回调, 此时 setTimeout 和 setImmediate 各自注册了定时器, 之后当轮询队列为空时, 检查 immediateList 队列从而进入 check 阶段执行 immediateTask, 而后回到 poll 阶段之后才检测 timer 并进入 timers 阶段.

process.nextTick 是比较特殊的, 因为它并不是事件循环的一部分. 事实上, nextTickQueue 会在当前操作处理后执行, 无论它当前处在事件循环的哪一个阶段. process.nextTick 的问题在于他可能会导致其他阶段的任务被饿死, 并阻止事件循环进入到 poll 阶段. 既然如此, 为什么要设计这样一个 API 呢?

- 阅读剩余部分 -