戰(zhàn)指南:讓 ES6 代碼無需編譯直接運(yùn)行)
寫 ES6 代碼的時(shí)候最煩的不是語法本身而是寫了一行 import 卻跑不起來的那種憋屈。我見過太多人在本地寫 demo 時(shí)卡在這一步代碼邏輯全對結(jié)果 node 直接甩你一臉SyntaxError: Cannot use import statement outside a module。更煩的是好不容易裝了一整套編譯工具鏈每次改完代碼還得手動編譯一遍改一行、編一次、跑一下循環(huán)往復(fù)時(shí)間全耗在等待上了。babel-node 就是干這個(gè)的。它是 Babel 官方提供的一個(gè) Node 運(yùn)行包裝器裝好之后直接用babel-node xxx.js就能運(yùn)行帶 import/export、async/await、裝飾器等 ES6 語法的腳本不用提前編譯也不用在每次改動之后重新生成 dist 文件。對本地開發(fā)、CLI 工具、臨時(shí)腳本、測試環(huán)境啟動入口這些場景來說它幾乎是零成本上手。這篇文章會從原理講到配置再把我實(shí)際踩過的坑一并整理出來把 babel-node 這套東西徹底捋清楚。內(nèi)容適合所有被ES6 代碼沒法直接跑困擾的人。不管你是剛接觸前端工程化不久還是想優(yōu)化本地開發(fā)工作流的老手照著這篇文章操作十分鐘內(nèi)就能把 babel-node 真正用起來。1. 為什么需要它手動編譯這條路有多痛1.1 直接運(yùn)行 ES6 的報(bào)錯現(xiàn)場先說最常見的情況。你寫了一個(gè)很簡單的腳本// src/demo.js import fs from fs; import path from path; const configPath path.resolve(__dirname, ../config.json); const config JSON.parse(fs.readFileSync(configPath, utf8)); console.log(當(dāng)前配置, config);然后你像以前運(yùn)行普通 JS 一樣執(zhí)行node src/demo.js終端里幾乎是立刻彈出SyntaxError: Cannot use import statement outside a module原因其實(shí)不復(fù)雜。Node 本身對 ES Module 的支持是有的但默認(rèn)情況下.js文件走的是 CommonJS 模塊體系。你直接寫 import 關(guān)鍵字Node 的解析器根本不知道該怎么處理。過去有一段時(shí)間你需要把文件后綴改成.mjs才能用原生 ESM或者加上--experimental-modules這個(gè)實(shí)驗(yàn)性 flag。這些方案要么依賴不穩(wěn)定的版本行為要么改動文件結(jié)構(gòu)在日常開發(fā)里根本沒法讓人放心用。這里有個(gè)很容易混淆的地方我單獨(dú)說一句ES Module 和 CommonJS 是兩套完全不同的模塊規(guī)范。CommonJS 是 Node 默認(rèn)的那套用require()引入、module.exports導(dǎo)出ESM 是 ECMAScript 官方標(biāo)準(zhǔn)用import和export關(guān)鍵字。Babel 做的事情就是把你寫的 ESM 語法翻譯成 Node 能直接執(zhí)行的 CommonJS 語法。理解了這個(gè)本質(zhì)后面看 babel-node 的所有行為就都不糊涂了。1.2 手動編譯工作流的三種代價(jià)既然直接跑不了過去大家最普遍的做法是裝 babel-cli寫 .babelrc把 ES6 編譯成 ES5 輸出到一個(gè)目錄然后運(yùn)行編譯后的文件。表面看沒問題實(shí)際用起來全是摩擦。第一個(gè)代價(jià)是改一行編譯一次的循環(huán)。你修了個(gè)日志輸出也得重新編一遍項(xiàng)目大一點(diǎn)Babel 全量編譯一次就是好幾秒。積少成多一天浪費(fèi)的等待時(shí)間非??捎^。就算開--watch模式Babel 的監(jiān)聽重編譯仍然要花時(shí)間而且 watch 模式在某些編輯器組合下還不穩(wěn)定動不動就失效。第二個(gè)代價(jià)是目錄結(jié)構(gòu)的撕裂。源碼在 src 下編譯產(chǎn)物在 lib 或 dist 下一個(gè)項(xiàng)目同時(shí)維護(hù)兩套代碼。調(diào)試的時(shí)候還得開著 source map 跳來跳去報(bào)錯堆棧里全是編譯后的行號定位問題全靠猜。第三個(gè)代價(jià)是改了源碼忘了編譯這種低級又致命的錯誤。我自己就栽過這種跟頭。某次調(diào)一個(gè)定時(shí)任務(wù)改了 src 下面的時(shí)間參數(shù)忘記編譯程序直接按舊邏輯跑了一個(gè)小時(shí)翻日志才發(fā)現(xiàn)問題。從那以后我就一直在想開發(fā)階段能不能干脆不編譯直接跑源碼babel-node 給出的答案是可以而且做得很干凈。2. babel-node 的原理與基礎(chǔ)搭建2.1 它不是魔法只是把編譯過程挪到了內(nèi)存里babel-node 說白了是 Node CLI 的一個(gè)薄包裝。它的底層依賴 babel-register 的 require 鉤子機(jī)制當(dāng)你require一個(gè)模塊時(shí)鉤子會攔截這個(gè)模塊先交給 Babel 做一次內(nèi)存中的轉(zhuǎn)譯再把轉(zhuǎn)譯后的代碼交給 Node 執(zhí)行。所有轉(zhuǎn)譯產(chǎn)物只存在于內(nèi)存不會落盤所以你既看不到編譯輸出目錄也不需要清理緩存文件。打一個(gè)生活化的比方會覺得很好理解手動編譯像是你要先把菜做好、裝盤、再端上桌babel-node 則像是請了個(gè)廚師站在你旁邊你點(diǎn)一道菜他現(xiàn)做一道吃完不用處理任何剩菜。它的定位就是開發(fā)階段的隨點(diǎn)隨做不是生產(chǎn)環(huán)境的大廚。順帶提一個(gè)概念上的區(qū)分。babel-register 本身是個(gè)編程式接口你在項(xiàng)目入口最頂上寫一行require(babel/register);就能讓整個(gè)進(jìn)程后面的 require 都走 Babel 轉(zhuǎn)譯。而 babel-node 是把這層邏輯封裝成了命令行工具省得你在每個(gè)入口文件里手動掛鉤子。兩者底層機(jī)制是一樣的只是使用姿勢不同。2.2 最小化安裝與配置既然要用 babel-node我們得先建一個(gè)干凈的項(xiàng)目。我習(xí)慣從零開始直接建目錄并初始化mkdir babel-node-demo cd babel-node-demo npm init -y然后安裝核心依賴。注意babel-node 并不是單獨(dú)存在的東西它依賴 babel/core 做真正的轉(zhuǎn)譯依賴預(yù)設(shè)或插件來提供語法解析規(guī)則。所以一次裝全npm install --save-dev babel/core babel/node babel/preset-env裝完之后在項(xiàng)目根目錄創(chuàng)建 babel.config.json也可以叫 .babelrcBabel 7 對兩種寫法都支持內(nèi)容如下{ presets: [ [babel/preset-env, { targets: { node: current } }] ] }這里有兩個(gè)細(xì)節(jié)值得展開。第一babel.config.json 和 .babelrc 的適用場景略有差異。babel.config.json 是項(xiàng)目級配置對整個(gè)項(xiàng)目生效包括 node_modules 里的依賴引用.babelrc 是文件級配置主要針對項(xiàng)目內(nèi)具體目錄經(jīng)常配合 monorepo 各子包獨(dú)立配置。普通單包項(xiàng)目兩個(gè)選一個(gè)用都行我的習(xí)慣是用 babel.config.json因?yàn)樗诰庉嬈骱蜆?gòu)建工具里的識別更穩(wěn)定。第二配置里那個(gè)targets一定要重視。babel/preset-env 本質(zhì)是一套語法插件的組合它根據(jù)你聲明的目標(biāo)環(huán)境決定哪些語法需要轉(zhuǎn)譯、哪些不需要。設(shè)成node: current意思是按當(dāng)前本機(jī) Node 版本的能力來轉(zhuǎn)譯如果當(dāng)前 Node 已經(jīng)原生支持某個(gè)語法Babel 就放過去不轉(zhuǎn)譯如果不支持Babel 才出手。這樣轉(zhuǎn)譯開銷最小、執(zhí)行速度最快不會做無用功。反過來如果你在這欄寫了某個(gè)舊瀏覽器版本Babel 會拼了命地把 async/await 編譯成 regenerator 那一大坨本地跑個(gè)腳本根本沒必要。安裝配置完成之后本文開頭那個(gè)讀配置文件的腳本就能直接跑了npx babel-node src/demo.js輸出結(jié)果跟預(yù)期一致沒有任何報(bào)錯也不需要先生成編譯目錄。我第一次跑通這個(gè)命令的時(shí)候確實(shí)有一種解放了的感覺。3. 核心玩法腳本運(yùn)行、自動重啟與調(diào)試3.1 傳參規(guī)則與 npm 腳本集成先澄清一個(gè)容易搞混的點(diǎn)babel-node 后面的參數(shù)哪些是給 babel-node 自身的哪些是給腳本的看這條命令npx babel-node src/demo.js --port 8080babel-node 會去加載 src/demo.js把--port 8080傳給腳本內(nèi)的 process.argv。也就是說如果腳本里做了命令行參數(shù)解析這些參數(shù)可以正常通過args[2]、args[3]拿到。而 babel-node 自己的選項(xiàng)比如--presets則要放在腳本路徑之前npx babel-node --presets babel/preset-env src/demo.js這種臨時(shí)覆蓋的寫法平時(shí)不太會用到因?yàn)榕渲靡呀?jīng)寫在配置文件里了。但如果你臨時(shí)想用一套跟配置文件不同的預(yù)設(shè)組合這個(gè)能力是真實(shí)存在的。實(shí)際項(xiàng)目中我?guī)缀鯊牟辉诮K端里手敲 babel-node 的完整命令而是把它揉進(jìn) package.json 的 scripts 里{ scripts: { dev: babel-node src/index.js, start: NODE_ENVproduction babel-node src/index.js } }這樣團(tuán)隊(duì)里任何人 clone 完項(xiàng)目執(zhí)行npm install之后一句npm run dev就能跑起來誰也不用關(guān)心底層的 babel-node 到底怎么調(diào)用。這一條建議對所有工具類 CLI 都適用本地開發(fā)命令盡量固化到 scripts 里別讓大家靠記憶輸入。3.2 配合 nodemon 實(shí)現(xiàn)保存即重啟光能跑還不夠開發(fā)時(shí)最順滑的體驗(yàn)是改完保存程序自動重啟。我之前折騰過不少自動重啟方案最后發(fā)現(xiàn) nodemon 配 babel-node 最省心。安裝方式很簡單npm install --save-dev nodemon然后把啟動腳本改成這樣{ scripts: { dev: nodemon --exec babel-node src/index.js } }原理是 nodemon 監(jiān)聽文件變化一旦發(fā)現(xiàn) src 目錄下有文件被修改就自動殺掉舊進(jìn)程再用 babel-node 重新拉起來。由于 babel-node 的編譯過程在內(nèi)存里完成重啟速度比nodemon 編譯產(chǎn)物的方案快得多。配合 ESLint 的--fix自動修復(fù)整個(gè)開發(fā)流程基本可以做到保存即生效。這里有一個(gè)小細(xì)節(jié)值得講nodemon 默認(rèn)監(jiān)聽的是當(dāng)前目錄下所有文件如果你不想讓某個(gè)無關(guān)配置文件也觸發(fā)重啟可以加上監(jiān)聽范圍的限制{ scripts: { dev: nodemon --watch src --exec babel-node src/index.js } }--watch src表示只盯著 src 目錄其他目錄里的任何動靜都不會觸發(fā)重啟。我見過有人在項(xiàng)目里寫了日志文件也被 nodemon 當(dāng)成變更觸發(fā)進(jìn)程反復(fù)重啟還以為是 babel-node 出 bug 了排查半天才發(fā)現(xiàn)是監(jiān)聽范圍的問題。3.3 用 babel-node 跑 REPL 驗(yàn)證語法除了運(yùn)行腳本babel-node 還有個(gè)容易被忽略的用法直接進(jìn)入交互式 REPL。在終端里敲npx babel-node你會進(jìn)入一個(gè)帶 Babel 轉(zhuǎn)譯能力的 Node REPL在那里可以直接寫 const fn (x, y 10) x y; undefined fn(5) 15因?yàn)?REPL 的代碼會經(jīng)過 Babel 轉(zhuǎn)譯箭頭函數(shù)、默認(rèn)參數(shù)、對象解構(gòu)這些語法都能直接使用。我經(jīng)常拿它來快速驗(yàn)證一個(gè)算法片段是否可用或者測試某個(gè) Babel 插件到底有沒有生效比開一整個(gè)項(xiàng)目要輕快得多。另外如果你在寫一個(gè)需要標(biāo)準(zhǔn)輸入讀取的交互式命令行腳本babel-node 跑起來后的 stdin 行為跟 node 基本一致不容易踩兼容性的坑。3.4 調(diào)試支持與 IDE 集成再聊一個(gè)容易被忽略但非常關(guān)鍵的點(diǎn)調(diào)試。很多人在本地用 babel-node 跑通之后發(fā)現(xiàn)斷點(diǎn)打不上只能靠 console.log 猜問題體驗(yàn)非常原始。babel-node 的調(diào)試配置其實(shí)不復(fù)雜。Node 6.3 之后調(diào)試協(xié)議走的是--inspect參數(shù)npx babel-node --inspect src/index.js啟動后在 Chrome 地址欄打開chrome://inspect或者在 VS Code 里配置好 launch.json{ type: node, request: launch, name: Babel Debug, runtimeExecutable: npx, runtimeArgs: [babel-node], program: ${workspaceFolder}/src/index.js, sourceMaps: true }配好之后在源碼里打上紅點(diǎn)啟動調(diào)試斷點(diǎn)是可以直接命中的。原因在于 Babel 轉(zhuǎn)譯時(shí)默認(rèn)會生成 source map調(diào)試器通過 map 文件把編譯后的代碼位置映射回原始源碼位置。這一點(diǎn)很關(guān)鍵千萬不要為了提速關(guān)閉 source map否則調(diào)試體驗(yàn)會直接退化到比手動編譯時(shí)代還差。4. 常見問題排查與性能調(diào)優(yōu)實(shí)錄4.1 典型報(bào)錯速查用 babel-node 挺長一段時(shí)間我把反復(fù)出現(xiàn)的坑整理成了速查表遇到問題照著查基本都能解決報(bào)錯信息根本原因解決方案Cannot use import statement outside a module沒通過 babel-node 啟動直接用了 node確認(rèn)命令是npx babel-node而不是nodeRequires Babel ^7.0.0-0, but was loaded with 6.x全局殘留舊版本 Babel 6 實(shí)例Node 解析到了全局包項(xiàng)目內(nèi)顯式安裝 babel/core并卸載全局舊版Cannot find module babel/preset-env只裝了 babel/node沒裝配套預(yù)設(shè)補(bǔ)裝 babel/preset-env并在配置里聲明啟動越來越慢Babel 把 node_modules 里的第三方庫也轉(zhuǎn)譯了一遍配置 ignore 規(guī)則讓 Babel 跳過 node_modules斷點(diǎn)不生效source map 被關(guān)閉或配置缺失開啟 source map 相關(guān)選項(xiàng)檢查 launch.json 配置改了文件后不重啟nodemon 監(jiān)聽范圍過窄或過寬調(diào)整--watch src參數(shù)確認(rèn)事件觸發(fā)第二條是我實(shí)際踩過最痛的案例。我電腦上為了一個(gè)老項(xiàng)目全局裝過 Babel 6后來做新項(xiàng)目時(shí)babel-node 在解析依賴時(shí)命中了全局那個(gè)舊版本拋出了一堆看似莫名其妙的版本沖突提示。最后我把全局的 Babel 卸掉問題立刻消失。從那次之后我給自己立了一條規(guī)矩任何 Babel 相關(guān)依賴一律裝在項(xiàng)目內(nèi)絕不用全局包。這個(gè)原則也適用于大多數(shù)前端構(gòu)建工具全局環(huán)境不可控項(xiàng)目內(nèi)安裝才是可復(fù)現(xiàn)的。再說啟動速度變慢的問題。默認(rèn)情況下babel-node 轉(zhuǎn)譯時(shí)會處理被 require 的模塊。如果你的代碼 import 了 node_modules 里的第三方庫這些庫也會被 Babel 過一遍依賴一多啟動速度就會變得非常難看。解決辦法是在 Babel 配置文件里加 ignore 規(guī)則{ ignore: [node_modules], presets: [ [babel/preset-env, { targets: { node: current } }] ] }加完這一行之后node_modules 里的代碼原樣交給 Node 處理Babel 只對業(yè)務(wù)源碼動手啟動速度提升非常明顯。這個(gè)優(yōu)化我建議在項(xiàng)目初始化第一天就加上而不是等慢到受不了才想起來。4.2 內(nèi)存轉(zhuǎn)譯的代價(jià)與緩存策略babel-node 的內(nèi)存轉(zhuǎn)譯機(jī)制前面夸了很多但它也有代價(jià)。最直接的問題是每次啟動進(jìn)程所有被引用的模塊都要重新轉(zhuǎn)譯一遍沒有持久化的緩存。大型項(xiàng)目里這會表現(xiàn)為啟動一次要等好幾秒比直接跑編譯產(chǎn)物慢不少。如果想要一點(diǎn)緩存能力可以把 Babel 的 cache 目錄利用起來。Babel 7 在轉(zhuǎn)譯時(shí)會把結(jié)果緩存在node_modules/.cache下面只要你沒有頻繁改動 Babel 配置增量啟動時(shí)部分模塊是可以命中緩存的。另外如果你用的是 webpack 或 vite 這類構(gòu)建工具做日常開發(fā)它們的持久化緩存策略比 babel-node 更完善這也是為什么大型項(xiàng)目更傾向構(gòu)建工具 dev server而不是 babel-node 的原因之一。我的取舍標(biāo)準(zhǔn)是腳本文件數(shù)量在幾十個(gè)以內(nèi)babel-node 的啟動速度完全可接受一旦項(xiàng)目膨脹到幾百個(gè)模塊就應(yīng)該認(rèn)真考慮把運(yùn)行入口換成構(gòu)建產(chǎn)物或者給項(xiàng)目引入真正意義上的工程化構(gòu)建。babel-node 不背這個(gè)鍋它是給輕量場景準(zhǔn)備的。4.3 babel-node 與 babel-register 的選型差異前面稍微提到過 babel-register這里再放一個(gè)明確的對比方便你按場景選特性babel-nodebabel-register使用方式命令行工具編程式require(babel/register)覆蓋范圍當(dāng)前的整個(gè) Node 進(jìn)程從 require 那行之后的模塊加載是否輸出產(chǎn)物否純內(nèi)存轉(zhuǎn)譯否純內(nèi)存轉(zhuǎn)譯適用場景CLI 啟動入口、臨時(shí)腳本工具庫內(nèi)部需要跑帶新語法的測試或腳本簡單說你有明確的入口腳本選 babel-node你寫的是一個(gè)工具庫希望在庫內(nèi)部某個(gè)入口上掛載轉(zhuǎn)譯能力選 babel-register。兩者的配置體系完全一樣都是讀取 babel.config.json 或 .babelrc。5. 生產(chǎn)環(huán)境方案與替代工具對比5.1 生產(chǎn)環(huán)境為什么不能繼續(xù)用 babel-node前面反復(fù)強(qiáng)調(diào) babel-node 適合開發(fā)階段那生產(chǎn)環(huán)境呢我的建議是不要用。有三個(gè)層面的原因。第一是性能babel-node 啟動時(shí)要在進(jìn)程內(nèi)完成所有模塊的實(shí)時(shí)轉(zhuǎn)譯比直接執(zhí)行編譯產(chǎn)物多了不少 CPU 開銷和啟動延遲這對服務(wù)端應(yīng)用是實(shí)打?qū)嵉拈_銷。第二是可靠性一旦源文件在運(yùn)行時(shí)被改動哪怕是誤操作babel-node 也可能重新轉(zhuǎn)譯行為變得不可預(yù)測而編譯產(chǎn)物是靜態(tài)的部署之后內(nèi)容固定出了問題好回溯。第三是資源占用內(nèi)存中轉(zhuǎn)譯器常駐進(jìn)程的內(nèi)存占用會比純 Node 高出一截在容器化部署場景里這個(gè)差距會被放大。所以生產(chǎn)環(huán)境的正確做法是回到編譯后執(zhí)行的路子上來但這次不是手動編譯而是交給構(gòu)建命令npx babel src --out-dir dist node dist/index.js把這一步寫進(jìn) CI 流程或者部署腳本里源碼保持整潔產(chǎn)物是可復(fù)現(xiàn)的。開發(fā)用 babel-node 追求爽快生產(chǎn)用編譯產(chǎn)物追求穩(wěn)定兩者各管一段并不沖突。5.2 橫向?qū)Ρ萾s-node、tsx 與 Node 原生 ESM聊完 babel-node 的問題解決再把它放到整個(gè)工具生態(tài)里橫向看一眼。如果你的項(xiàng)目用的是 TypeScriptts-node 是更順手的替代品。它本質(zhì)上和 babel-node 同一套思路只是在轉(zhuǎn)譯之外補(bǔ)了類型檢查相關(guān)的能力。如果你的項(xiàng)目對啟動速度有極致要求可以看看基于 esbuild 的方案比如 tsx它把轉(zhuǎn)譯交給 Go 寫的高性能構(gòu)建器啟動速度能比 babel-node 快出一個(gè)量級。至于 Node 原生 ESM現(xiàn)在還在能力追趕的階段新版本對 import 的支持越來越完整但很多語法特性和生態(tài)兼容問題依然存在。我把幾個(gè)常用方案的關(guān)鍵差異列成一張表方案語言范圍啟動速度配置復(fù)雜度適合場景babel-nodeJavaScript中等低已有 Babel 工具鏈的 JS 項(xiàng)目ts-nodeTypeScript中等中TypeScript 項(xiàng)目tsxTS/JS快低對速度敏感的 Node 服務(wù)Node 原生 ESMJavaScript最快最低純新項(xiàng)目、無歷史包袱我的觀點(diǎn)是工具沒有銀彈只有匹配。babel-node 適合項(xiàng)目里既有 Babel 工具鏈又不想額外引入構(gòu)建復(fù)雜度的場景它和 Babel 生態(tài)天然契合配置文件還是同一份心智負(fù)擔(dān)最小。如果項(xiàng)目里準(zhǔn)備全面擁抱 TypeScript那一步到位用 ts-node 更合理。別被新技術(shù)一定更好的思路帶著走選工具看的是當(dāng)前項(xiàng)目的具體約束。5.3 一個(gè)完整的日常使用范式最后把整套開發(fā)范式做一個(gè)總結(jié)。一個(gè)同時(shí)用了 babel-node 的 Node 服務(wù)項(xiàng)目我常用的腳本長這樣{ scripts: { dev: nodemon --watch src --exec babel-node src/index.js, debug: nodemon --watch src --exec \babel-node --inspect src/index.js\, build: babel src --out-dir dist, start: node dist/index.js } }日常開發(fā)敲npm run dev需要調(diào)試敲npm run debug要上線了先npm run build再npm start。一整套流程清晰明確團(tuán)隊(duì)協(xié)作時(shí)也好理解。在我看來babel-node 最大的價(jià)值不是省掉了幾次編譯命令而是它把跑代碼這個(gè)動作的摩擦力降到了幾乎為零。開發(fā)者的注意力應(yīng)該集中在業(yè)務(wù)邏輯上而不是被編譯了嗎、跑的是不是最新代碼這種瑣事反復(fù)打斷。我用了很久每次用 npx babel-node 跑通一段帶裝飾器語法的腳本時(shí)還是會感慨要是早點(diǎn)接觸這套工具能少熬多少夜。如果你現(xiàn)在還被手動編譯折騰著我的建議很簡單把今天這套配置順手抄下來裝完依賴跑一遍然后親身體驗(yàn)一下改完代碼直接跑的流暢感。工具這種事兒用過了才知道真正的差距在哪里。另外再提醒一句容易被忽略的細(xì)節(jié)升級 Node 大版本之后記得重新跑一遍項(xiàng)目測試確認(rèn) babel/core、babel/node 的版本和當(dāng)前 Node 兼容。Babel 對 Node 版本是有要求范圍的版本跨度太大的時(shí)候babel-node 偶爾會出現(xiàn)啟動時(shí)崩潰別到時(shí)候一臉懵。