從 is-promise 事件咱們能夠學到什麼?

齊雲雷,微醫雲服務團隊前端工程師。專一於 Node.js 基礎生態建設以及在 Web 應用中的方案沉澱。javascript

前言

4 月 25 日,NPM 社區又一次因更新事故引燃技術圈的討論,導火索便來自名爲 is-promise 的包。前端

網上盛傳一個單行代碼的包影響到了谷歌、FaceBook、亞馬遜等衆多大咖的知名項目,也有人揚言它使幾乎整個 JavaScript 生態陷入了混亂。java

不過「雪崩」之時,我和身邊人都沒有體會到震感,不由疑惑,平時不多有場景須要判斷某個值是否爲 Promise,如此名聲不顯、功能又不重要的 NPM 包,真的有這麼大的影響和破壞力嗎?node

既是好奇心的驅使,也是不認同部分誇張的言辭,我決定向前一探究竟。react

is-promise 簡介

先解讀一下事故發生以前,is-promise 2.1.0 版本的完整代碼。git

module.exports = isPromise;
 function isPromise(obj) {  return !!obj && (typeof obj === 'object' || typeof obj === 'function') && typeof obj.then === 'function'; } 複製代碼

這是一個比較寬鬆的 Promise Like 檢查函數,雖然包名叫 is-promise,其實更像 is-thenable。別看只有一行的邏輯,須要不淺的功力才能準確寫出。github

例如,前置的 typeof 能有效過濾 String.prototype.then = function () {} 這樣不合規範的 thenable 字符串。web

咱們能夠不使用,但不應貶低這個包的價值。Promise/A+ 是一個自由的規範,而非語言特性,長久以來有着衆多版本實現,採起這種具備包容性的判斷方式是合情合理的。npm

相似的 NPM 包還有 Sindre Sorhus 的 p-is-promise,它增長了 catch 方法的檢查。json

回顧

讓咱們一塊兒回到那個週末,從新審視整個事件的始末。

時間線

is-promise 做者 Forbes Lindesay 回顧了當時的主要歷程:

  • 2020–04–25T15:03:25Z — 發佈存在問題的 2.2.0
  • 2020–04–25T17:16:00Z — Ryan Zimmerman 提交了修復 PR
  • 2020–04–25T17:48:00Z — 在社交軟件上收到告警
  • 2020–04–25T17:54:00Z — 合併 Ryan 的 PR,發佈 2.2.1
  • 2020–04–25T17:57:00Z — 閱讀並關閉 BUG 相關的 issues,從新開了一帖以便集中 溝通
  • 2020–04–25T18:06:00Z — Jordan Harband 提到 "exports" 字段仍然存在 問題
  • 2020–04–25T18:08:08Z — 從 package.json 中移除 "exports" 字段,發佈 2.2.2
  • 2020–04–25T19:20:00Z — 撤銷 2.2.0 和 2.2.1

可見,做者收到告警信息後的反應是很是迅速的,但撤銷操做滯後的問題仍須要指責。

接下來,咱們逐個分析 2.2.x 版本的更迭。

2.2.0

  • 添加 Typescript 聲明文件
  • 支持 ES Module 風格的 import

站在上帝視角,咱們明確知道問題出在這裏,做者在 package.json 中新增了兩個字段

{
 "type": "module",  "exports": {  "import": "index.mjs",  "require": "index.js"  } } 複製代碼

很快,就有人反饋 BUG,一共有兩類報錯

錯誤一:exports 的文件路徑遺漏了 './',在 Node.js 中

Error [ERR_INVALID_PACKAGE_TARGET]: Invalid "exports" main target "index.js" defined in the package config /xxx/node_modules/is-promise/package.json; targets must start with "./"
複製代碼

錯誤二:添加了 type: module,致使 require 被禁用,必須使用 import 才能引入。

Error [ERR_REQUIRE_ESM]: Must use import to load ES Module: /xxx/node_modules/is-promise/index.js
複製代碼

以及被隱藏的錯誤三:沒有更新 package.json 中的 files 字段,致使 index.mjs、index.d.ts 沒有一塊兒打包發佈。

2.2.1

  • 修復錯誤的 ESM 用法

改動後的 package.json 包含以下

{
 "exports": {  "import": "./index.mjs",  "require": "./index.js"  } } 複製代碼

然而,若是使用 require('is-promise/package.json') 引入模塊下其餘文件,則會拋出

Error [ERR_PACKAGE_PATH_NOT_EXPORTED]: Package subpath './package.json' is not defined by "exports" in /Users/claude/Workspace/test/is-p/node_modules/is-promise/package.json
複製代碼

甚至不容許引用 'is-promise/index' 和 'is-promise/index.js'。

2.2.2

  • 從 package.json 刪除 exports 字段

爲了完全解決 2.2.0 帶來的 Breaking Change,終於在 2.2.2 刪掉了 exports 字段。

問題字段解析

本次事故源於兩個少見的 package.json 字段,咱們已經見識到了其反作用,但還沒搞明白爲何會被做者引入,不妨進一步明確它們的概念。

官網文檔在 12.x 及以上版本都包含這些字段的描述,可是並不表明 12.x 用戶必定享受到了這個特性。

type

它決定當前 package.json 層級目錄內文件遵循哪一種規範,包含兩種值,默認爲 commonjs。

  • commonjs: js 和 cjs 文件遵循 CommonJS 規範,mjs 文件遵循 ESM 規範
  • module: js 和 mjs 文件遵循 ESM 規範,cjs 文件遵循 CommonJS 規範

要正常使用這個特性,在 Node.js v12.x 的早期版本,必須主動開啓 --experimental-modules。可是從 v12.16.0 之後就有些混亂,不開啓選項的狀況下錯誤使用該字段會當即拋出異常。直到了 v13.2.0 正式引入,取消了實驗特性的標識,纔算恢復正常。

is-promise 將 type 顯式指定爲 module,顯然會影響到特定版本的 CommonJS 用戶。

exports

type 是相對較老的特性,exports 則是鮮有人知。

功能來自 proposal-pkg-exports 提案,以實驗特性 --experimental-exports 加入 v12.7.0,於 v12.16.0 正式引入。具體時間線能夠經過這個 PR 追溯。

下面看它的具體做用。

一般,咱們用 main 字段指定包的入口文件,但也僅限於指定惟一的入口文件。

exports 字段是 main 的補充,支持定製不一樣運行環境、不一樣引入方式下的入口文件,也支持導出其餘文件,看下面的例子便知。

{
 "main": "./main.js",  "exports": {  ".": "./main.js",  "./feature": {  "browser": "./feature-browser.js",  "default": "./feature.js"  }  } } 複製代碼

但值得注意的是,在支持 exports 的 Node.js 版本中,exports 會覆蓋 main.js。

exports 一旦被指定,只能引用 exports 中顯示導出的文件。

用下面這種特殊寫法,才能容許項目內全部文件被導出(未通過充分測試)。 但缺點是沒法使用 import isPromise from 'is-promise/index’,而必須帶上文件後綴 import isPromise from 'is-promise/index.mjs'

{
 "exports": {  ".": ".",  "./": "./",  } } 複製代碼

此外,做者想固然覺得 exports 和 main 字段同樣,支持省略 "./",這在文檔中並無交代。

做者覆盤

過後,做者發佈了一篇 《is-promise post mortem》,他公開說明了上述的一部分錯誤,還總結了導致犯錯的幾個因素

  • 習慣於本地發佈,不通過 CI 驗證
  • 使用新特性,CI 卻沒有添加支持新特性的 Node 版本
  • 只驗證了代碼,沒有驗證明際發佈到 NPM 的包
  • 本人不在,其餘維護者沒有途徑發佈修復補丁

總結下來就兩點,測試不充分,流程不規範。

再談影響

我翻找了相關 ISSUES,發現 create-react-app@angular/clifirebase-tools 等項目的確受到影響,具體表現則爲安裝、構建失敗。

再回看 NPM 生態,is-promise 周下載量在千萬級,存在直接引用關係的就有 766 個包(現只剩 561,受事故影響,許多包取消了引用),GitHub 顯示依賴它的項目更是有 3.5m 之衆。

從問題版本 2.2.0 發佈,到 2.2.2 修復,歷時約 3 個小時,考慮到 NPM 的緩存機制,實際影響時間會被拉長。

所以,它的影響範圍的確很廣,但實際沒有那麼誇張。

一方面,Node.js 12.16.0 之前的 LST 和更早版本纔是主流,這些運行時可被認定爲安全。

另外一方面,遭到輻射的項目(大多爲 CLI 工具)並不具有整個生態的表明性,也不會危及生產環境。

旁觀者的思考

看過了問題,也藉此反思一下如何避免悲劇發生在本身身上吧。

鎖定版本

加鎖能夠 100% 避免本次意外,尤爲面向應用開發者,這是一直在呼籲的工做,卻不多真正落地。

不要吐槽 package-lock.json 會本身變,由於只有一個 lock 文件是不成氣候的,若是 package.json 沒有鎖定版本,NPM 會使用浮動的版本覆蓋 package-lock.json。

但對於 NPM 包的開發者,除非是對穩定性有所要求的工具鏈、產品,仍是不建議濫用版本鎖定。若是全部的 NPM 包都這麼作,必定會加大 node_modules 的混亂程度,也不利於及時享受到相關依賴的修復補丁,反而提升了維護難度。

單元測試

測試的重要性無須多言。

is-promise 的新增更改根本沒有獲得測試覆蓋,甚至連 require 引入都會報錯。除了開發者要完善 CI,NPM 是否也有提供內置檢測服務的義務呢?

該不應使用小型代碼庫

小型庫背後是衆多開源人士的努力貢獻,優質的文檔、測試用例遠超代碼的原始價值。

is-promise 的問題不在於它有幾行代碼,而且代碼邏輯沒有變動。

我的認爲,NPM 包開發者有必要減小依賴數量,應用開發者則能夠自由決定。引用也好,套用也罷,但至少請給這些代碼的做者和協議應有的尊重。

文檔不濟

2.2.0 這個版本號的使用是否得當,若是隻從功能上看,它是向下兼容 2.1.0 的一次更新嗎?

看過上面 exports 字段的介紹能夠得知,它固然屬於 Breaking Change,但 Node.js 文檔的描寫是模糊的,讓 is-promise 的做者認爲 exports 是無害的。

官網通篇沒有一個警告字樣,若是沒有此次事故後才提交的 PR,恐怕會有更多的人掉入坑中。

Yarn or NPM

曾經有很多人傾向於 Yarn 的機制,時至今日,Yarn 和 NPM 的差距已經大大收縮,二者都是不錯的選擇,我惟一建議是不要混合使用。

Yarn 的速度已經沒有特別大的優點

還有像 PNPM 這類致力於改進 NPM 生態的努力,值得咱們持續關注。

總結

當前仍在批判 NPM 生態的人羣,大部分不會參與 JS 社區的建設,願改善現狀而貢獻的更是百裏挑一。

各位 NPM 用戶無須危言聳聽,人有失手,馬有失蹄,只要規範流程,可以有效下降負面影響。

逆耳未必是忠言,但願更多有價值的聲音能被髮出。

相關文章
相關標籤/搜索