从 package.json 和 package-lock.json 浅谈 npm 解析依赖出错问题

引子

在 Node.js 的世界中,人们几乎每天都在使用着 npm install 命令。这个命令会根据 package.json 的内容,将项目所依赖的库安装到本地的 node_modules 文件中,然后产生一个新文件 package-lock.json

许多人都知道,后者的作用是保存 npm 按照 package.json 的内容经过计算而构建的依赖树,对应本地的 node_modules 的文件结构。并且,之后执行的 npm install 都将围绕这棵依赖树进行。从某种意义上来说,package.json 就像是构建本地依赖树的蓝图。

关于 package-lock.json 有一种广泛流传的观点是:在共享给他人的项目中,应该提供它来确保其他人通过 npm install 所获得的的 node_modules 和原作者的保持一致,从而确保不会出现依赖的版本出错而导致配置本地开发环境失败。

然而有些时候,我们仍然会在使用 npm install 之后出乎预料地遇到了依赖版本出错的问题;或是在团队开发中偶然发现自己执行 npm install 之后, package-lock.json 的内容竟然发生了改变,旋即犹豫不决:为什么发生这种问题呢?要不要恢复到 HEAD 中的版本呢?

别着急,本篇文章就来讲讲 package.jsonpackage-lock.json 的关系,为你解决上述问题提供思路!

语义化版本

在讲述它们的关系之前,我们有必要先来理解一下 Sematic Versioning (简称 SemVer,中文名为“语义化版本”) 规范。它被选为 npm 社区的首选版本规范,npm 上绝大多数的依赖库都采用了这种版本规范。

以语义化版本号 X.Y.Z 为例:

  • X 是 Major version,表示一次不向后兼容的变更,例如 API 的变更。
  • Y 是 Minor version,表示一次向后兼容的变更,通常是添加一些新功能。
  • Z 是 Patch version,表示一次向后兼容的 Bug 修复,或者是文档的修改。

我们再来看看 package.json 中对语义化版本的使用:

1
2
3
4
5
"dependencies": {
"@angular/common": "^9.1.0",
"rxjs": "~6.5.4",
"@nrwl/angular": "9.4.5",
}

上面的例子中:

  • ^ 在 npm 眼里表示“安装这个库最新的 Minor version 或 Patch version,但 Major version 保持不变。”。所以对于 ^9.1.0来说,这份 package.json 的用户如果运行 npm install,是完全可能在实际上安装了 9.2.0 版本的,这会体现在他的 package-lock.json 中。
  • ~ 在 npm 眼里表示“安装最新的 Patch version,但其他必须不变”。与上面类似,~6.5.4 说明只允许安装 6.5.46.5.6 这样的版本。
  • 如果版本号前既没有 ^ 也没有 ~,则说明 npm 必须安装完全一致的版本。

在开发者使用 npm install <dep_name> 来安装依赖时,npm 默认会在 package.json 中写入前面带 ^ 的版本号。因为 npm 认为,既然 SemVer 被选为了首选的版本规范,那么这些库的开发者们都应该严格地遵循它,不应该在 Minor version 和 Patch version 中引入不向后兼容的变化,默认使用 ^ 还能在最大程度上保证使用者安装的依赖的版本是最新的。基于以上原因,大多数的 npm 项目的 package.json 里的版本号都是 ^X.Y.Z 形式的。

然而,npm 这样的假设在实际中是存在隐患的。我们先按下不表。

package.json 与 package-lock.json

基于上述的假设,npm install 会同时参考 package.jsonpackage-lock.json,并确保两者表示的依赖的版本能够符合上述的规则。下面用一个例子来阐释 npm install 的行为:

假设你 clone 了 A 项目,A 项目的 package.json 的依赖部分如下:

1
2
3
4
5
"dependencies": {
"a": "^1.1.0",
"b": "~2.2.0",
"c": "3.3.0",
}

在 A 项目提供的 package-lock.json 中,三个依赖的版本解析结果分别为 1.2.42.2.43.3.0。那么你在运行 npm install 后将会得到一模一样的解析结果。这里 npm 遵循了 package-lock.json 的结果。

然而,如果 A 项目的作者通过 npm install xxx 安装新的依赖,而忘记 commit 更新后的 package-lock.json,那么这会导致提供给用户的 package-lock.json 存在偏差,比如 a 依赖的版本不是 1.2.4 而是 1.0.4。你在运行 npm install 后,package-lock.json 将会被修改,其中的 1.0.4 所在的节点会被修改为你的 npm 的解析结果,即最新的、可以满足 ^1.1.0 规则的 1.2.4。这里 npm 遵循了 package.json 的规定。

更新 package-lock.json 的隐患

如上文所说,npm install 可能会自动更新本地已经存在的 package-json.json。同样使用依赖 A 的例子,这里的隐患是:如果这个依赖 A 没有很好地遵守 SemVer,或者是因为代码规模太大而出现疏忽(这非常常见),导致 1.3.1 中出现了不兼容 1.2.0 的代码部分,会如何呢?对,你将无法正常运行 clone 下来的项目。因此如果你在运行 npm install 之后,package-lock.json 发生了改变,你应该警觉这一个可能性。

npm 为了缓解这个问题,实际上专门引入了一个特殊版本的 install,叫做 clean-install,简写 ci。它在进行 npm install 所做的工作时,会检查一些条件,如果解析 package.json 的结果与已存在的 package-lock.json 不符,那么它将报错退出,同时若本地已经存在 node_modules,它将事先删除。

npm ci 并没有彻底解决这个问题,它只是防止产生可能有误的 package-lock.json。如果我们的项目是发布到各大 registry 上被人们使用 npm install xxx 安装的,那么我们的用户的 npm 是不会遵守我们的 package-lock.json的(这是设定,用户的 npm 只会遵守它的项目本身的 package-lock.json), 那么我们又该如何去从避免用户的 npm 解析出不同的依赖树呢?

有人提出:不要使用 ^,甚至是 ~ 也不要使用,定死尽可能多的依赖的版本号,这样就能在最大程度上杜绝上述隐患。定死版本号虽然能够缓解隐患,但也带来了这样的问题:用户再也不能通过 npm install 来自动更新那些进行了 bug 修复的依赖,使得你必须及时更新使用了新依赖的新版本,但依然造成了不便。

解决思路

笔者综合一些参考资料,认为:我们不应该一律采用 ^ 规则,而应该根据实际情况,灵活地定义版本规则。我们来看看 nx workspace 给开发者自动生成的 package.json(略去了一些项目):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
"dependencies": {
"@angular/animations": "^9.1.0",
"@angular/common": "^9.1.0",
"@nrwl/angular": "9.4.5",
"rxjs": "~6.5.4",
"zone.js": "^0.10.2"
},
"devDependencies": {
"@angular/cli": "9.1.0",
"@nrwl/workspace": "9.4.5",
"@types/node": "~8.9.4",
"dotenv": "6.2.0",
"ts-node": "~7.0.0",
"tslint": "~6.0.0",
"typescript": "~3.8.3",
"@angular/compiler-cli": "^9.1.0",
"@angular-devkit/build-angular": "0.901.0",
"codelyzer": "~5.0.1",
"@types/jest": "25.1.4",
"ts-jest": "25.2.1"
}

nx workspace 会给用户生成这样的 package.json,然后调用用户的 npm 进行 npm install 产生 package-lock.json。这一过程和上面讨论的用户使用 npm install xxx 安装你的库几乎一致。

我们可以看到,上面的片段混用了 ^~ 规则和固定版本号。这样做有以下的好处:

  1. 能够兼容那些不遵从 SemVer 的依赖。例如 TypeScript,它特别喜欢把 SemVer 中的 Minor version 当做 Major version 来使用,经常会出现 3.x.y 不兼容 3.(x-1).z 的问题。所以 nx workspace 就限定了 ~3.8.3,因为 TypeScript 暂时还保留些许克制,不会在 Patch version 中引入不向后兼容的改变。
  2. 缩小用户进行 npm install 后产生错误的依赖树的排错范围。我们能够知道哪些依赖严格遵从 SemVer,所以能够套用 ^~ 规则;也能知道哪些项目规模庞大,大到哪怕是 Patch version 的更新都可能会无意中导致不兼容的改变,从而使用固定版本号······总之我们可以根据自己的需求,灵活地套用规则。这样我们就能够将可能发生错误的隐患限制到了一个非常小的范围,易于排查。

当然,对于规模尚小的项目而言,全部采用 ^ 规则几乎不会出现差错,但是当你的用户向你发出 Issue 时,你就是时候要检查一下自己的 package.jsonpackage-lock.json 了。

参考资料

浅谈 JavaScript 异步编程(四):JS 异步编程的另一种思路

什么是响应式编程

在 JavaScript 还在被 Callback Hell 的阴影笼罩时,来自 ReactiveX 组织的仁人志士,为 JavaScript 世界带来了被广泛使用的响应式编程。响应式编程和我们熟悉的命令式编程有很大的不同。它和声明式编程很想象,但强调使用可观察对象构建异步数据流。下面让我们通过一个例子来体会一下它们之间的区别:

拉取体系与推送体系

在继续介绍 RxJS 之前,我们有必要弄清楚所谓拉取体系(Pull System)和推送体系(Push System)间的区别。其中,后者是响应式编程的根基。

“拉取和推送是两种不同的协议,用来描述数据生产者 (Producer)如何与数据消费者 (Consumer)进行通信的”,RxJS 官方文档如此描述。在拉取中,消费者占主动地位,而生产者占被动地位,消费者自行决定什么时候接受数据,而生产者并不知道何时发出数据。在推送中,以上关系发生颠倒,由生产者决定何时发出数据,而消费者只能被动接受。

拉取体系与推送体系

在 JavaScript 中,拉取体系的典型例子是 Iterator,其中的消费者就是 iterator.next()。推送体系的典例是 Promise,其中的消费者是 promise.then()

利用 Generator 实现的求斐波那契数列的函数

从上图的代码中可以看到,只要 iterator.next() 不被调用,也就是消费者不去拉取数据,那么 fib() 内的代码就会被挂起。因此,在拉取体系中,我们主动向生产者索要数据。

我们回想一下先前的 fetch().then()fetch() 是生产者,then() 中的函数是消费者。此时并不存在我们拉取数据的说法,fetch() 自动启动,我们能做的只能被动接受它产生的数据,而且时机是不确定的。故在推送体系中,我们只能被动接受。

Observable 与异步数据流

由之前的叙述我们知道,JavaScript 本身存在着应用响应式编程的空间。然而,其中存在一道缺口:对于推送体系中的产生多个值的数据源,JavaScript 本身并没有很好的支持。先前提到的 Async Iteration 用途场景较为受限,很难完全填补这里的空白。至此,我们总算要开始介绍 RxJS 的核心概念——Observable 与异步数据流

RxJS 为了填补这道缺口,提出了 Observable 对象,代表能够产生若干个值的异步数据源。同时,采用观察者模式,让数据消费者能够通过订阅数据源来被动消费数据。

异步数据流则强调 Observable 产生的数据仿佛溪流一般,消费者既能改变流向,又能在溪流的任意位置获取数据。

下图是一个简单的例子,它以按钮被点击的事件作为 Observable,我们的消费者就是第二行中订阅函数的参数。注意这个 Observable 是一个典型的异步数据源:它可能产生若干个值,并且存在一定时间跨度。我们可以定义自己的 Observable,目前 RxJS 原生支持将 DOM Event、setInterval、Promise 等转换为异步数据源。

为了增强异步数据流给编写异步代码的舒适性,新版本的 RxJS 引入了 Piped Operators,使得我们可以顺着异步数据流的“溪流”,在数据流淌的过程中对数据做出修改,并让修改后的“溪流”最终流向我们的目标(消费者)。例如,使用 map 可以利用纯函数来修改输入的数据,并让修改后的数据继续沿着“溪流”流向下一个 Operator 或消费者。

实例

这是笔者参与维护的学院的课程平台。平台上的这个页面显示着从服务器获取的未结束的课程,并且支持使用搜索框筛选显示的课程。我们不妨想想,要如何使用 RxJS 实现这个页面的功能呢?

显然,卡片容器是数据消费者,它需要接受一个表示课程信息的数组。而从服务器获取的所有未结束的课程列表,以及搜索框的内容会是我们的两个数据源。因此我们的目标就是整合这两个数据源,并让搜索框能够发挥它的作用。下面来看看实际的代码以及概念图:

P1、P2、F1、F2 是几个不同的 Operator,用于对数据流做特殊的修改,这里暂不探究启具体作用。

可以发现,我们的这些代码几乎就像一篇操作指南一样,告诉了大家我们对数据流做了什么,这就是响应式编程的美妙之处;并且,异步数据流的概念也很符合我们对数据处理的思考,先干这个再干哪个,中间没有任何东西打断我们的思路。

小结

RxJS 的世界远远比先前的介绍更加复杂,但也更加有趣,这里由于篇幅原因只能介绍这些。总而言之,RxJS 代表的响应式编程是人们对 JavaScript 异步编程的一项伟大的补充,它解决了多数据异步数据流的处理问题,并且带来了许多理念先进且使用便捷的 Operators。

链接

浅谈 JavaScript 异步编程(三):JS 异步编程的发展

混乱的 Callback

正如先前所述,JS 运行环境在处理收到的任务之后,主要是通过 Callback 来通知 JS 这个“大老板”的。Callback 本质上是一个接受若干参数的函数。

在 Promise 还未出现的年代,人们只是在“要有 Callback”上达成了广泛共识,并没有在“Callback 应该需要什么参数”、“Callback 应该什么时候调用、怎么调用”这类细节问题上形成统一的标准。并且,从形式上看,Callback 是一个作为参数的函数,如果出现复杂的异步任务,人们很容易写出嵌套的 Callback,既不美观,又影响了程序员的思维。同时,Callback Hell 的产生又让人们伤透了脑筋。

对于一些复杂的异步任务,人们很容易写出像上图这样糟糕的代码。这种层层嵌套的 Callback 又被成为 Callback Hell。

Callback Hell 不仅难以阅读,更为严重的是它能够打乱程序员对于复杂的异步任务的思维流。Callback 天生就不符合人脑的“顺序思考”特性,而 Callback Hell 则有过之而无不及,大大增加了人在这段代码中犯下低级错误的可能性,为系统的稳定带来风险。

与此同时,几乎每一个广泛使用的库或者其他 API 对自己异步操作的 Callback 都有着不同的设计。这些各自不同的设计让当时的人们很是痛苦,每用一个 API 就要去查一查参数、异常处理等细节的文档。看看下面这几个常见的异步 API 到底使用了多少种不同的设计方案:

缺乏细节而又容易产生问题的几个例子:

  1. 异步任务会存在哪些状态?
  2. 什么时候调用错误处理 Callback?调用几次?什么参数?
  3. 在操作成功后是不是马上调用 Callback?
  4. 传入 Callback 的参数会不会因情况而异?

在这个例子中,我们的程序要在运货 API 检查到缺货并抛出错误时,给已经付款的用户退款 1000 美元;在检查到货品充足时给用户显示成功信息。

可是,我们没有想到的是,运货 API 检查缺货的过程中会同时进行很多小步骤,而每一个小步骤出错都可以导致这个 Callback 被调用,也就是说这个 Callback 可能会被用异常对象调用多次,你会给用户多退款好几千美元!

像这样的问题被称为信赖问题。在这种问题中,你很难保证 Callback 会 100%按照你设想的方式被调用。因为这些多样的 API 对于如何对待你的异步操作并不存在一种统一的规范。

正规军 Promise

Callback 标准的混乱问题源于当时 JS 的标准并没有在这一领域带来某种官方认可的规范,于是第三方各自为战。Callback 自身的缺陷使得它无法适应正在变得越来越复杂的异步任务,最终导致了 Callback Hell 的肆虐。

事情终于在 2015 年迎来转机。JS 的标准制定者们综合了全世界范围内的反馈后,终于在 ES6 标准中正式推出了官方的异步编程 API——Promise API。

Promise 是异步操作的抽象封装,它对这些异步操作进行了合理的限制,在很大程度上缓解了信任问题,因为所有的行为变得可预测、可控制起来。Promise 正如其名,是 JavaScript 官方给与我们的异步编程的“承诺”。

让我们来好好地认识一下这个 JS 异步编程的“正规军”有什么特点吧:

  • 一个 Promise 实例一旦被创建,异步操作就开始进行,不能中止。

  • Promise 在创建后的任意时间点上只能存在以下三种状态的其中一种:

    1. Pending(进行中)
    2. Fulfilled(操作成功)
    3. Rejected(操作失败)

    并且,这三种状态间的转换关系是单向的,不可逆的。

当 Promise 处于 Fulfilled 或 Rejected 状态时,我们说这个 Promise 已经决议(resolved),而且一旦决议则永不改变。

现在我们知道:Promise 代表的异步操作要么顺利完成,要么因为错误而失败,绝不存在模棱两可的状态。而且,状态的不可逆,又使得 Promise 摆脱了修复、重试动作带来的复杂性。若要在一个 Promise 失败后重试异步操作,只能重新构造一个 Promise 实例。

这样的设计很好地缓解了异步 API 的信赖问题,因为当这些 API 都采用 Promise API 之后,我们在处理这些 Promise 的方式上就能获得统一性,从而让我们得以将宝贵的注意力集中在我们真正需要关心的部分——具体的业务数据上。

Promise Chain

Promise API 除了 Promise 的理念外,还引入了功能强大的 Promise Chain。下图就是先前的 Callback Hell 例子用 Promise 改写后的版本(假定 fs 库支持 Promise)。这里,每一个 then 函数的参数都是一个接受上一个 Promise 的结果,产生下一个 Promise 的函数。也就是说,我们能通过 Promise Chain 将一系列复杂的异步操作用 Promise 表达,并将它们串联起来构成一个可读性极佳的整体,成功地摆脱了 Callback Hell!

Promise Chain 的例子

then 函数也接受第二个参数用于处理上一个 Promise 的错误,默认抛给下一个 then 函数。catch 函数是一个特殊的 then 函数,它只接受处理错误的函数。

受到广泛应用的 Promise API

自从 Promise API 正式推出以后,各大异步 API 以及浏览器的许多 API 都跟进了对 Promise 的支持。在 2020 年的今天,我们能接触到的绝大多数与异步操作相关的 API 都提供了对 Promise API 的支持(除了 Node.js 的官方库之外)。

浏览器中新推出的用以取代老旧的 XMLHttpRequest API 的 Fetch API

ES2020 标准中引入的 Dynamic Import

Promise API 的不足

Promise API 并非完美无瑕。虽然 Promise Chain 让复杂的异步操作代码的可读性大大提高,但其实并没有从本质上解决 Callback 模式的一大核心痛点:不符合人脑的“同步思维”。人脑习惯于以时间先后顺序连贯地思考一系列复杂的操作,但无论是 Callback 还是 Promise Chain,都在某种程度上打断了思维的连贯性。

在“先”与“后”之间始终存在着阻隔思维流的代码

后起之秀 async/await

我们看来还是习惯于平常编写的同步式的代码。要算一个平方并输出,我就先 let square,再 square = getXXX(),然后 console.log(square) 就好了,这很符合我的思维习惯。于是有人利用 ES6 标准中引入的 Generator,实现了用同步式的代码去表达 Promise Chain!

function 后的星号表示这是一个 Generator,而 yield 表示产出后面的 Promise。Generator 在执行到 yield 并产出值之后就会暂停执行,直到外部调用 next() 才能恢复执行,直到下一个 yield,由此往复直到末尾。

要运行这个 Generator,需要借助一个特殊的函数的帮助:它会自动处理通过 yield 产出的 Promise,并将 Promise 的结果用 next() 塞回去,然后 Generator 继续运行到下一个 yield xxx,直至末尾。

通过和特殊函数的“一唱一和”,我们顺利实现了用同步式的代码表达 Promise Chain。不过最终的代码看上去很别扭,而且还得额外写出这个特殊的函数,还是有些麻烦。

好消息是,这种创造性的用法马上就被列入到了 ES7 标准当中,那个特殊的函数已经被纳入了 JavaScript 的引擎之中,不必再额外写上。并且,为了改善可读性,ES7 还专门增加了两个关键字:asyncawait。让我们用 async/await 重构上面的例子(仅是示例,fs api 至今不支持 async/await):

如今 async/await 在 JavaScript 的各大运行环境中都有了广泛的支持。

展望未来

人们对于 JavaScript 异步编程的探索仍在继续。自 async/await 被列入标准后,人们陆续又提出了诸如 Async Iteration 这些让编写异步代码更加方便的概念。

一个 Async Iteration 的用例,需要实现 Async Iterator 或 Async Generator

小结

JavaScript 异步编程在经历了混沌的 Callback 时代后终于迎来了 Promise API 的曙光,现如今,我们几乎能在 JS 的一切使用场景中看到 Promise 的身影,如 then() 等字眼。

变得越来越强大的 JavaScript 也在这段时期促进了前端的工程化、前后端的语言统一以及伟大的前后端分离战略等,其中当然也少不了这些异步 API 的功劳。

接下来我们来研究一下 JS 异步编程的另一种思路——响应式编程。

链接

浅谈 JavaScript 异步编程(二):JS 异步编程的基石

引擎与运行环境的“角逐”

我们再来看看刚才的这段代码,Node.js 在读取文件结束后将 Callback 交给 JS 引擎执行,但这其中有一个问题:运行环境交还 Callback 的时机如何确定?

如果运行环境在任务结束时立刻将 Callback 交还引擎,而此时引擎很可能正在执行一个函数的中途,那么强行插入执行的 Callback 很可能会导致一些难以预料的后果。

例如这段代码,setTimeout 是运行环境提供的一个定时器,我们这里将定时器设为 0,也就是说运行环境得到这个任务后可以立即完成。假如运行环境完成后立即塞回引擎执行,那么counter is 0既可能被打印一次,也可能被打印两次。

JS 作为一门单线程语言竟然会出现多线程并发问题!

事件循环与任务队列

幸运的是,上述的情况是不可能在 JS 中发生的。为了让引擎和运行环境在恰当的时间点交互,运行环境都采用了一套基于事件循环与任务队列的设计。

上图为浏览器中的事件循环与任务队列模型,它最初由 HTML5 标准制定,后来 ES6 标准又作出了修改。

此模型除了引入事件循环和任务队列的概念,还规定了一个基本原则:每当引擎的 stack 被清空后才运行事件循环清空任务队列,由此往复。
先前的代码在 for 中执行时 stack 始终不为空,所以运行结果一定是:counter is 0只被打印了一次。

下面我们通过一个具体的例子来理解这个模型:

小结

事件循环与任务队列的引入为 JS 执行异步代码提供了一个安全的、稳定的、可预测的环境,成为了 JS 异步编程的基石。

另外,ES6 标准中提出了宏任务与微任务的概念,并对运行环境提出了进一步的要求,例如不完成所有微任务就不能进入下一次事件循环等,让 JS 在拓展异步编程的功能时有了更为安全可靠的保障。

接下来让我们继续前进,探索 JS 异步编程的发展轨迹。

链接

浅谈 JavaScript 异步编程(一):JS 异步编程的含义

异步与同步

在介绍 JS 异步编程以前,我们首先得理清一下什么是异步,以及什么是并发。异步与同步是消息通信机制中两个相对的概念。

  • 同步:发出调用后,在得到结果之前,调用不返回。
  • 异步:发出调用后,调用直接返回,此时并没有获得结果。

让我们设想一个场景:有一天中午,我从教学楼里走出来,正想着去吃饭,突然一时兴起在图书馆里找一本书,于是拿出手机向图书管理员打电话。

高颜值的我与图书管理员在线对谈

接下来,我们分别用同步和异步去理解这个过程:

同步:

  1. 我打电话给图书管理员,提出请求(调用)。
  2. 我在电话里等图书管理员找书(等待结果)。
  3. 图书管理员完成找书,在电话里告诉我结果之后,我挂断电话(调用返回)。

异步:

  1. 我打电话给图书管理员,提出请求(调用)。
  2. 我挂断电话,骑车吃饭去了(调用返回)。
  3. 图书管理员完成找书,主动打电话告诉我结果(获得结果)。

这两个过程最显著的区别在于:我有没有干等着“找书”任务出结果

并发

并发是指一个系统支持两个或以上的任务同时存在。如果能够同时执行,这种并发也叫并行。不幸的是 JavaScript 本身是一门单线程的脚本语言,同一时间只能执行一个任务。单个 JavaScript 引擎实例无法做到并行。

不过,这并不意味着 JavaScript 不能借助外力来同时处理多个任务。还记得前面的漫画吗?我挂断电话后可以去食堂吃饭,但是此时找书的任务仍然在进行中。因为我将这个任务甩给了图书管理员,而他向我保证会在之后将结果告诉我。于是,我通过打电话给图书管理员这一异步操作,做到了一边找书一边吃饭。这种并发叫分时并发。

一边找书一边恰饭

JS 里的异步并发例子

类似地,在这段 JavaScript 代码中,我们要同时处理这两个任务:

  • 读取一个文件
  • 打印 do something else

我们的代码将读取文件的任务交给了 Node.js,然后打印了 do something else,最后 Node.js 读取文件完成,将最终结果输入 Callback 并交给 JS 引擎执行。

以上这个例子向我们粗略地阐述了 JS 中的异步与并发的概念。

为什么 JS 长于异步并发

今天,如果我们去翻开 JavaScript 的维基百科,上面会说它是一个“单线程非阻塞异步并发脚本语言”,那么为什么 JS 会以“异步并发”为特色呢?

历史原因

JS 自 1995 年诞生开始就是一门单线程的脚本语言,起初的设计目的只是辅助浏览器完成诸如验证表单这类简单的任务。并且,直到 Node.js 爆红之前,JS 的主要运行环境都是浏览器,所以它和浏览器之间存在着非常深的耦合关系。

在人们对提升 JS 性能的呼声越来越高时,浏览器支撑的庞大的 Web 体系不可能让 JavaScript 骤变变成一门支持多线程的语言来提高性能,所以 JavaScript 只能在单线程的道路上追求性能的极致。

环境原因

事实上在浏览器中,运行环境包括了 DOM、BOM 等浏览器自身暴露给 JavaScript 的 API,这些 API 通常由 C++编写,并且由浏览器执行,JS 只负责调用它们,也就是通知浏览器执行任务。

这一事实让人们找到了突破口:用支持多线程的运行环境为单线程的 JS 提供并发能力。于是,JS 变成了“大老板”,运行环境里的各种 C++线程成为了“打工仔”,专门为它服务。

JS 向运行环境发出若干个任务的指令,任务由运行环境完成并通知 JS,这一过程正是所谓的“异步并发”, 由此我们不难理解为什么“异步并发”是 JS 的一大特色了。

JS: like a boss

偏科的 JS

由此可见,JS 的设计使得它非常适合用于 I/O 密集型场合,因为这个“老板”能够高效地对若干个 C++线程发号施令,使得整个程序有条不紊地快速调度大量的 I/O 任务。

不过,它并不适合计算密集型场合,因为它只是一个单线程的“老板”,如果单看干活的多少,它显然不可能干得比两个人多。因此像 bcrypt 这样的加密库不得不像 I/O 相关的 API 那样借助多线程的 C++的力量来执行任务。

Node.js 在 Web 服务端领域占领的份额,其实主要集中在中间件等典型的高并发 I/O 密集型设施,其中一大关键原因就是因为 JS 的上述“偏科”缺陷。

另外,JS 单线程的设计也导致它难以充分利用多核 CPU 的性能,虽然 Node.js 能够通过 child_process 等方式实现这一点,但在 Web 服务端中的计算领域,相比其他成熟的方案(Java、C++等)优势并不明显,因此 Node.js 直到今天也难以撼动这些方案的地位。

面对Java无能狂怒的JS

链接

浅谈 JavaScript 异步编程:前言

2009 年,Ryan Dahl 正式发布服务端 JavaScript 解释器 Node.js,以其极高的性能震惊世人。在发布后的短短几年之间,Node.js 攻城略池,在 Web 服务端领域中占据了相当大的市场份额,并在之后的几年为伟大的前后端分离策略的诞生提供了物质基础。

Node.js 的成功也让 JavaScript 有了十足的改变。从 Node.js 广泛采用 Callback 开始,JS 开始了对异步编程的进一步探索。从 Callback 到 Promise、从 ES5 到今天的 ES2020, JS 异步编程逐步走向成熟,造福了成千上万的程序员,也不断地提升着 JS 的性能。

本系列文章取自笔者在学校的俱乐部的技术宣讲的 PPT,将简要地介绍 JS 异步编程的含义、基础与发展,希望能给大家带来一些灵感。如果存在疑问或发现有表述不妥之处,欢迎在评论区中指出并友善交流!

链接