大家好,我是若川。持續組織了6個月源碼共讀活動,感興趣的可以點此加我微信 ruochuan02?參與,每周大家一起學習200行左右的源碼,共同進步。同時極力推薦訂閱我寫的《學習源碼整體架構系列》?包含20余篇源碼文章。歷史面試系列
esno我在我的很多源碼文章中提到過,但沒有寫文章,分享這篇好文章。
Node.js 并不支持直接執行 TS 文件,如果要執行 TS 文件的話,我們就可以借助 ts-node 這個庫。相信有些小伙伴在工作中也用過這個庫,關于 ts-node 這個庫的相關內容我就不展開介紹了,因為本文的主角是由 antfu 大佬開源的 esno 項目,接下來我將帶大家一起來揭開這個項目背后的秘密。
閱讀完本文后,你將了解 esno 項目是如何執行 TS 文件。此外,你還會了解如何劫持 Node.js 的 require 函數、如何為 ES Module 的 import 語句添加鉤子及如何自定義 https 加載器,以支持 import React from "https://esm.sh/react"
導入方式。
esno 是什么
esno 是基于 esbuild 的 TS/ESNext node 運行時。該庫會針對不同的模塊化標準,采用不同的方案:
esno
- Node in CJS mode - by esbuild-registeresmo
- Node in ESM mode - by esbuild-node-loader
使用 esno 的方式很簡單,你可以以全局或局部的方式來安裝它:
全局安裝
$?npm?i?-g?esno
在安裝成功后,你就可以通過以下方式來直接執行 TS 文件:
$?esno?index.ts
$?esmo?index.ts
局部安裝
$?npm?i?esno
而對于局部安裝的方式來說,一般情況下,我們會以 npm scripts 的方式來使用它:
{"scripts":?{"start":?"esno?index.ts"},"dependencies":?{"esno":?"0.14.0"}
}
esno 是如何工作的
在開始分析 esno 的工作原理之前,我們先來熟悉一下該項目:
├──?LICENSE
├──?README.md
├──?esmo.mjs
├──?esno.js
├──?package.json
├──?pnpm-lock.yaml
├──?publish.ts
└──?tsconfig.json
觀察以上的項目結構可知,該項目并不會復雜。在項目根目錄下的 package.json 文件中,我們看到了前面介紹的 esno 和 esmo 命令。
{"bin":?{"esno":?"esno.js","esmo":?"esmo.mjs"},
}
此外,在 package.json 的 scripts 字段中,我們發現了 release 命令。顧名思義,該命令用來發布版本。
{"scripts":?{"release":?"npx?bumpp?--tag?--commit?--push?&&?node?esmo.mjs?publish.ts"},
}
需要注意的是,在 publish.ts
文件中,使用到了 2021 年度 Github 上最耀眼的項目 zx,利用該項目我們可以輕松地編寫命令行腳本。寫作本文時,它的 Star 數已經高達 27.5K,強烈推薦感興趣的小伙伴關注一下該項目。
簡單介紹了 esno 項目之后,接下來我們來分析 esno.js
文件:
#!/usr/bin/env?nodeconst?spawn?=?require('cross-spawn')
const?spawnSync?=?spawn.syncconst?register?=?require.resolve('esbuild-register')const?argv?=?process.argv.slice(2)process.exit(spawnSync('node',?['-r',?register,?...argv],?{?stdio:?'inherit'?}).status)
由以上代碼可知,當執行 esno index.ts
命令后,會通過 spawnSync
來啟動 Node.js 程序執行腳本。需要注意的是,在執行時使用了 -r
選項,該選項的作用是預加載模塊:
-r,?--require?=?...?module?to?preload?(option?can?be?repeated)
這里預加載的模塊是 esbuild-register,該模塊就是 esno 命令執行 TS 文件的幕后英雄。
esbuild-register 是什么
esbuild-register 是一個基于 esbuild 來轉換 JSX、TS 和 esnext 特性的工具。你可以通過以下多種方式來安裝它:
$?npm?i?esbuild?esbuild-register?-D
#?Or?Yarn
$?yarn?add?esbuild?esbuild-register?--dev
#?Or?pnpm
$?pnpm?add?esbuild?esbuild-register?-D
在成功安裝該模塊之后,就可以在命令行中,直接通過 node
應用程序來執行 ts 文件:
$?node?-r?esbuild-register?file.ts
?-r, --require ?= ?... module to preload (option can be repeated)
-r
用于指定預加載的文件,即在執行file.ts
文件前,提前加載esbuild-register
模塊
它將會使用 tsconfig.json
中的 jsxFactory
, jsxFragmentFactory
和 target
配置項來執行轉換操作。
esbuild-register 不僅可以在命令行中使用,而且還可以通過 API 的方式進行使用:
const?{?register?}?=?require('esbuild-register/dist/node')const?{?unregister?}?=?register({//?...options
})//?Unregister?the?require?hook?if?you?don't?need?it?anymore
unregister()
了解完 esbuild-register 的基本使用之后,接下來我們來分析它內部是如何工作的。
esbuild-register 是如何工作的
esbuild-register 內部利用了 pirates 這個庫來劫持 Node.js 的 require
函數,從而讓你可以在命令行中,直接執行 ts
文件。下面我們來看一下 esbuild-register 模塊中定義的 register
函數:
//?esbuild-register/src/node.ts
import?{?transformSync,?TransformOptions?}?from?'esbuild'
import?{?addHook?}?from?'pirates'export?function?register(esbuildOptions:?RegisterOptions?=?{})?{const?{extensions?=?DEFAULT_EXTENSIONS,hookIgnoreNodeModules?=?true,hookMatcher,...overrides}?=?esbuildOptions//?利用?transformSync?const?compile:?COMPILE?=?function?compile(code,?filename,?format)?{const?dir?=?dirname(filename)const?options?=?getOptions(dir)format?=?format????inferPackageFormat(dir,?filename)const?{code:?js,warnings,map:?jsSourceMap,}?=?transformSync(code,?{sourcefile:?filename,sourcemap:?'both',loader:?getLoader(filename),target:?options.target,jsxFactory:?options.jsxFactory,jsxFragment:?options.jsxFragment,format,...overrides,})//?省略部分代碼}const?revert?=?addHook(compile,?{exts:?extensions,ignoreNodeModules:?hookIgnoreNodeModules,matcher:?hookMatcher,})return?{unregister()?{revert()},}
}
觀察以上的代碼可知,在 register
函數內部是利用 esbuild 模塊提供的 transformSync
API 來實現 ts -> js 代碼的轉換。其實最關鍵的環節,還是通過調用 pirates 這個庫提供的 addHook
函數來注冊編譯 ts 文件的鉤子。那么 addHook
函數內部到底做了哪些處理呢?下面我們來看一下它的實現:
//?pirates-4.0.5/src/index.js
export?function?addHook(hook,?opts?=?{})?{let?reverted?=?false;const?loaders?=?[];?//?存放新的loaderconst?oldLoaders?=?[];?//?存放舊的loaderlet?exts;const?originalJSLoader?=?Module._extensions['.js'];?//?原始的JS?Loader?//?省略部分代碼exts.forEach((ext)?=>?{//?獲取已注冊的loader,若未找到,則默認使用JS?Loaderconst?oldLoader?=?Module._extensions[ext]?||?originalJSLoader;oldLoaders[ext]?=?Module._extensions[ext];loaders[ext]?=?Module._extensions[ext]?=?function?newLoader(mod,?filename)?{let?compile;if?(!reverted)?{if?(shouldCompile(filename,?exts,?matcher,?ignoreNodeModules))?{compile?=?mod._compile;mod._compile?=?function?_compile(code)?{//?這里需要恢復成原來的_compile函數,否則會出現死循環mod._compile?=?compile;//?在編譯前先執行用戶自定義的hook函數const?newCode?=?hook(code,?filename);if?(typeof?newCode?!==?'string')?{throw?new?Error(HOOK_RETURNED_NOTHING_ERROR_MESSAGE);}return?mod._compile(newCode,?filename);};}}oldLoader(mod,?filename);};});
}
其實 addHook
函數的實現并不會復雜,該函數內部就是通過替換 mod._compile
方法來實現鉤子的功能。即在調用原始的 mod._compile
方法進行編譯前,會先調用 hook(code, filename)
函數來執行用戶自定義的 hook
函數,從而對代碼進行預處理。
而對于 esbuild-register 庫中的 register
函數來說,當 hook
函數執行時,就會調用該函數內部定義的 compile
函數來編譯 ts 代碼,然后再調用mod._compile
方法編譯生成的 js
代碼。
關于 esbuild-register 和 pirates 這兩個庫的內容就先介紹到這里,如果你想詳細了解 pirates 這個庫是如何工作的,可以閱讀 如何為 Node.js 的 require 函數添加鉤子? 這篇文章。
現在我們已經分析完 esno.js
文件,接下來我們來分析 esmo.mjs
文件。
esmo 是如何工作的
esmo 命令對應的是 esmo.mjs 文件:
#!/usr/bin/env?nodeimport?spawn?from?'cross-spawn'
import?{?resolve?}?from?'import-meta-resolve'
const?spawnSync?=?spawn.syncconst?argv?=?process.argv.slice(2)
resolve('esbuild-node-loader',?import.meta.url).then((path)?=>?{process.exit(spawnSync('node',?['--loader',?path,?...argv],?{?stdio:?'inherit'?}).status)
})
由以上代碼可知,當使用 node 應用程序執行 ES Module 文件時,會通過 --loader
選項來指定自定義的 ES Module 加載器。
--loader,?--experimental-loader?=?...?use?the?specified?module?as?a?custom?loader
需要注意的是,通過 --loader
選項指定的自定義加載器只適用于 ES Module 的 import 調用,并不適用于 CommonJS 的 require 調用。
那么自定義加載器有什么作用呢?在當前最新的 Node.js v17.4.0 版本中,還不支持以 https://
開頭的說明符。我們可以在自定義加載器中,利用 Node.js 提供的鉤子機制,讓 Node.js 可以使用 import
導入以 https://
協議開頭的 ES 模塊。
在分析如何自定義 https
資源加載器前,我們需要先介紹一下 import 說明符的概念。
import 說明符
import
語句的說明符是 from
關鍵字之后的字符串,例如 import { sep } from 'path'
中的 'path'
。說明符也用于 export from
語句,并作為 import()
表達式的參數。
有三種類型的說明符:
相對說明符,如
'./startup.js'
或'../config.mjs'
。它們指的是相對于導入文件位置的路徑。對于這種類型,文件擴展名是必須的。裸說明符,如
'some-package'
或'some-package/shuffle'
。它們可以通過包名來引用包的主入口點。當包沒有exports
字段的時候,才需要包含文件擴展名。絕對說明符,如
file:///opt/nodejs/config.js
。它們直接且明確地引用完整路徑。
裸說明符解析由 Node.js 模塊解析算法處理,所有其他說明符解析始終僅使用標準的相對URL 解析語義進行解析。
和 CommonJS 一樣,包內的模塊文件可以通過在包名上添加路徑來訪問,除非包的 package.json 包含一個 "exports " 字段,在這種情況下,包中的文件只能通過 "exports " 中定義的路徑訪問。
介紹完 import 說明符之后,接下來我們來看一下如何自定義 https 加載器。
自定義 https 加載器
resolve 鉤子
resolve
鉤子用于根據模塊的說明符和 parentURL
生成導入目標的絕對路徑,調用該鉤子后會返回一個包含 format
(可選) 和 url
屬性的對象。
//?https-loader.mjs
import?{?get?}?from?'https';export?function?resolve(specifier,?context,?defaultResolve)?{const?{?parentURL?=?null?}?=?context;if?(specifier.startsWith('https://'))?{return?{url:?specifier};}?else?if?(parentURL?&&?parentURL.startsWith('https://'))?{return?{url:?new?URL(specifier,?parentURL).href};}//?讓?Node.js?處理其它的說明符return?defaultResolve(specifier,?context,?defaultResolve);
}
在以上代碼中,會先判斷 specifier
字符串是否以 'https://'
開頭,如果條件滿足的話,該字符串的值直接作為 url
屬性的值,直接返回 { url: specifier }
對象。否則,會判斷 parentURL
是否以 'https://'
開頭,如果條件滿足的話,則會調用 URL 構造函數,創建 URL 對象。
parentURL
是從 context
對象上獲取的,那它什么時候會有值呢?假設在 ES 模塊 A 中,以相對路徑的形式導入 ES 模塊 B。在導入 ES 模塊 B 時,也會調用 resolve
鉤子,此時 context
對象上的 parentURL
就會有值。
load 鉤子
load
鉤子用于定義應該如何解釋、檢索和解析 URL 的方法,調用該方法后,會返回包含 format
和 source
屬性的對象。其中 format
屬性值只能是 'builtin'
、'commonjs'
、'json'
、'module'
和 'wasm'
中的一種。而 source
屬性值的類型可以為 string
、ArrayBuffer
或 TypedArray
。
import?{?get?}?from?'https';export?function?load(url,?context,?defaultLoad)?{if?(url.startsWith('https://'))?{return?new?Promise((resolve,?reject)?=>?{get(url,?(res)?=>?{let?data?=?'';res.on('data',?(chunk)?=>?data?+=?chunk);res.on('end',?()?=>?resolve({format:?'module',source:?data,}));}).on('error',?(err)?=>?reject(err));});}//?讓?Node.js?加載其它類型的文件return?defaultLoad(url,?context,?defaultLoad);
}
在以上代碼中,會通過 https
模塊中的 get
函數來加載 https://
協議的 ES 模塊。如果不是以 'https://'
開頭,則會使用默認的加載器來加載其它類型的文件。
創建完 https-loader
之后,我們來測試一下該加載器。首先創建一個 main.mjs
文件并輸入以下內容:
//?main.mjs
import?React?from?"https://esm.sh/react@17.0.2"console.dir(React);
然后在命令行輸入以下命令:
$?node?--experimental-loader?./https-loader.mjs?./main.mjs
當以上命令成功運行之后,控制臺會輸出以下內容:
{Fragment:?Symbol(react.fragment),StrictMode:?Symbol(react.strict_mode),Profiler:?Symbol(react.profiler),Suspense:?Symbol(react.suspense),...
}
了解完以上的內容后,我們回過頭來看一下 esmo.mjs
文件中所使用的 esbuild-node-loader 模塊。下面我們來簡單分析一下 load
鉤子:
//?loader.mjs(esbuild-node-loader?v0.6.4)
export?function?load(url,?context,?defaultLoad)?{if?(extensionsRegex.test(new?URL(url).pathname))?{const?{?format?}?=?context;let?filename?=?url;if?(!isWindows)?filename?=?fileURLToPath(url);const?rawSource?=?fs.readFileSync(new?URL(url),?{?encoding:?"utf8"?});const?{?js?}?=?esbuildTransformSync(rawSource,?filename,?url,?format);return?{format:?"module",source:?js,};}//?Let?Node.js?handle?all?other?format?/?sources.return?defaultLoad(url,?context,?defaultLoad);
}
通過觀察以上代碼,我們可知 load
鉤子的核心處理流程,可以分為兩個步驟:
步驟一:使用
fs.readFileSync
方法讀取文件資源的內容;步驟二:使用
esbuildTransformSync
函數對源代碼進行轉換。
而在 esbuildTransformSync
函數中,使用了 esbuild
模塊提供的 transformSync
函數來實現代碼的轉換。該函數的相關代碼如下所示:
//?loader.mjs(esbuild-node-loader?v0.6.4)
function?esbuildTransformSync(rawSource,?filename,?url,?format)?{const?{code:?js,warnings,map:?jsSourceMap,}?=?transformSync(rawSource.toString(),?{sourcefile:?filename,sourcemap:?"both",loader:?new?URL(url).pathname.match(extensionsRegex)[1],target:?`node${process.versions.node}`,?format:?format?===?"module"???"esm"?:?"cjs",});//?省略部分代碼return?{?js,?jsSourceMap?};
}
關于 transformSync
函數的使用方式,我就不展開介紹了。感興趣的小伙伴可以自行閱讀一下 esbuild 官網上的相關文檔。
好的,esno 這個項目就介紹到這里。如果你對 Node.js 平臺下的 require
和 import
hook 機制感興趣的話,可以詳細閱讀一下 pirates、esbuild-register 和 esbuild-node-loader 這幾個項目的源碼。若有遇到問題的話,可以跟阿寶哥交流喲。
參考資源
esbuild 官網
Node.js 官網 - ESM
如何為 Node.js 的 require 函數添加鉤子?
·················?若川簡介?·················
你好,我是若川,畢業于江西高校。現在是一名前端開發“工程師”。寫有《學習源碼整體架構系列》20余篇,在知乎、掘金收獲超百萬閱讀。
從2014年起,每年都會寫一篇年度總結,已經寫了7篇,點擊查看年度總結。
同時,最近組織了源碼共讀活動,幫助3000+前端人學會看源碼。公眾號愿景:幫助5年內前端人走向前列。
識別上方二維碼加我微信、拉你進源碼共讀群
今日話題
略。分享、收藏、點贊、在看我的文章就是對我最大的支持~