esno:Node.js运行时的性能飞跃
目录
1. 背景与动机
在 Node.js 生态中,随着 TypeScript 以及 ESModule(ESM)的普及,开发者希望直接在运行时使用最新的 JavaScript/TypeScript 语法,而无需每次手动编译。例如,使用 ts-node
可以直接执行 .ts
文件,使用 Node.js 内置 --loader ts-node/esm
也可支持 ESM。但是,这些方案普遍存在以下性能瓶颈:
首次启动慢
- 每次启动都需要将 TypeScript 编译为 JavaScript,耗费大量时间。
增量编译开销大
- 在一次运行过程中,若涉及多模块、多次即时编译,整体性能较差。
内存占用高
- 编译器需要加载
typescript
库,消耗数十 MB 内存。
- 编译器需要加载
配置复杂
- 配置
tsconfig.json
、babel.config.js
、ESM Loader 等需要多步联动,容易出错。
- 配置
为了解决上述痛点,社区涌现出一批“极速运行时”工具,其中最有代表性的是 esno。它以 esbuild 为底层编译引擎,或结合快速解析机制,极大提升了启动与编译速度,让你能够像运行纯 JavaScript 脚本一样,瞬间启动 TypeScript 或 ESM 代码。下面我们一探究竟。
2. 什么是 esno?
esno 是一个基于 esbuild 打造的“极速 Node.js 运行时”,全称是 “esbuild-powered Node.js runtime”。它可以直接运行以下几类代码而无需事先编译:
.ts
、.tsx
(TypeScript / React TSX).js
、.jsx
(JavaScript / React JSX).mjs
、.cjs
(ESM / CommonJS)- 支持最新的 ESNext 特性(可选链、空值合并、装饰器等)
核心优势在于:
极快启动
- esbuild 利用 Go 语言编写的超快速解析和转换引擎,每次启动仅需毫秒级别。
零配置或极简配置
- 默认支持 TypeScript、JSX、ESM,无需为基本场景编写
tsconfig.json
或babel.config.js
。
- 默认支持 TypeScript、JSX、ESM,无需为基本场景编写
模块缓存与增量编译
- esno 会缓存编译结果,第二次运行同一模块几乎无需重新编译。
支持 Node.js 原生 API
- esno 会将源文件按需编译,也会处理 Node.js 内置模块与第三方包,兼容
require()
、import
等。
- esno 会将源文件按需编译,也会处理 Node.js 内置模块与第三方包,兼容
兼容性良好
- 支持 macOS、Linux、Windows,无需额外编译,直接安装 npm 包即可使用。
esno 的底层是调用 esbuild API,读取源代码、解析 AST、转换为 CommonJS/ESM 输出到内存,然后交给 Node.js 本地执行。整个流程可简化为:
┌───────────────────────┐
│ esno CLI │
│ (node wrapper + loader)│
└────────────┬──────────┘
│
┌──────▼───────┐
│ esbuild │ <─── 解析、转换、缓存
└──────┬───────┘
│
┌──────▼───────┐
│ 内存中 JS │ (无需输出到磁盘)
└──────┬───────┘
│
┌──────▼───────┐
│ Node.js 本地 │ <─── 立即执行
│ V8 引擎 │
└──────────────┘
相比传统 ts-node
,esno 在编译速度和内存占用上都有显著优势。
3. 与 ts-node、nodejs 的比较
下面通过表格和简单说明,比较 esno、ts-node 以及纯 nodejs(先编译再运行)的异同与优劣。
特性 | esno | ts-node | 先编译再运行 (node + tsc) |
---|---|---|---|
启动速度 | 极快 (ms 级) | 慢(>100ms + 依赖项目大小) | 较慢(先编译再启动,几百 ms) |
内存占用 | 较低 (数十 MB) | 较高 (TypeScript 编译器需加载) | 较低(仅运行已编译 JS) |
支持语法 | TypeScript、JSX、ESM、ESNext | TypeScript、TSX、ESM (需额外配置) | 需要显式 tsc 配置 |
配置难度 | 极简(无需配置或极少配置) | 需配置 tsconfig.json 、--loader | 需 tsconfig.json 、构建脚本 |
开发体验 | 热启动(watch 模式 + 缓存) | 支持 --transpile-only (但慢) | 需要额外工具(如 nodemon/watch) |
生产环境可用性 | 可用于简单脚本与服务原型 | 不推荐用于生产(性能瓶颈) | 推荐(先编译,再用 node 运行) |
第三方插件生态 | 与 esbuild 插件兼容 | 与 ts-node 插件兼容 | 使用 tsc 中的插件 |
- esno:适合日常开发、脚本、小型服务原型,提供接近原生 nodejs 的体验,但对于非常复杂的 Babel 插件场景(如自定义 JSX 转换)可能仍需传统编译流程。
- ts-node:功能强大,支持完整 TypeScript 编译,但启动慢。当项目非常大时,慢的问题尤为明显。
- 先编译再运行:最稳定的生产流程,编译时可做类型校验和代码优化,将最终产物输出到
dist/
,然后由node dist/index.js
运行。缺点是每次改动都要重新编译,开发时效率较低。
4. 安装与基本使用
4.1 全局安装 vs 本地安装
全局安装
如果你希望在任意项目中都能直接使用 esno
命令,可全局安装:
npm install -g esno
安装完成后,在终端输入 esno --version
应能看到 esno 版本号。例如:
$ esno --version
0.19.9
优点:随时随地可用,不依赖项目本地。
缺点:不同项目可能需要不同版本的 esno,若全局版本与本地需求不一致,则容易出问题。
本地安装
更推荐将 esno 作为 开发依赖 安装到项目中,保证各项目环境隔离:
npm install --save-dev esno
然后可以在 package.json
的 scripts
中写:
{
"scripts": {
"start": "esno src/index.ts",
"dev": "esno --watch src/index.ts"
}
}
再执行 npm run start
或 npm run dev
,即可使用本地版本的 esno。
优点:项目复现性强,不会因全局版本不同导致“在我电脑上可用,在你电脑上报错”。
缺点:每个项目都要安装一份 esno,占用少量磁盘空间。
4.2 执行 TypeScript、ESM、ESNext 代码
esno 默认支持以下几种文件后缀:
.ts
/.mts
/.cts
:TypeScript.tsx
:TypeScript + JSX.js
/.mjs
/.cjs
:JavaScript(ESM / CommonJS).jsx
:JavaScript + JSX- 以及 ESNext 特性(可选链、空值合并、装饰器)
执行方式非常简单:
esno path/to/script.ts
或结合 Node.js 参数,比如传递环境变量:
NODE_ENV=development esno src/app.ts
如果想直接启动本地项目的入口,在 package.json
中写:
{
"type": "module", // 启用 ESM 模式(可选)
"scripts": {
"dev": "esno --watch src/index.ts",
"start": "esno src/index.ts"
}
}
--watch
:开启监视模式,一旦源文件改动,会自动重新加载并重启(基于 esbuild 的增量编译与缓存)。--enable-source-maps
:如果需要在调试时生成 source map,可显式添加该参数。
5. 代码示例:从零开始使用 esno
下面以一个小型项目为例,演示如何从头设置并使用 esno。
5.1 项目初始化与配置
初始化项目
mkdir esno-demo cd esno-demo npm init -y
package.json
内容示例:{ "name": "esno-demo", "version": "1.0.0", "type": "module", // 使用 ESM,后续可用 import/export "scripts": { "dev": "esno --watch src/index.ts", "start": "esno src/index.ts" }, "devDependencies": { "esno": "^0.19.9" } }
安装依赖
npm install --save-dev esno npm install axios # 作为示例的第三方依赖
我们在脚本中会使用
axios
发起 HTTP 请求。目录结构
esno-demo/ ├── package.json ├── src/ │ ├── index.ts │ ├── utils.ts │ └── config.ts └── tsconfig.json (可选,用于自定义 TypeScript 配置)
tsconfig.json(可选)
如果需要更细粒度的 TypeScript 配置,可在项目根目录创建tsconfig.json
。esno 默认会读取该文件。{ "compilerOptions": { "target": "ESNext", "module": "ESNext", "strict": true, "esModuleInterop": true, "sourceMap": true, "outDir": "dist", "skipLibCheck": true }, "include": ["src/**/*.ts"] }
5.2 编写一个简单的 TypeScript 脚本
下面示例代码展示在 esno 环境下如何使用最新的 ESNext/TypeScript 特性。
src/config.ts
// src/config.ts
export const API_URL = 'https://api.github.com';
export const DEFAULT_USER = 'octocat';
src/utils.ts
// src/utils.ts
import axios from 'axios';
import type { AxiosResponse } from 'axios';
interface GitHubUser {
login: string;
id: number;
public_repos: number;
followers: number;
}
// 异步函数:获取 GitHub 用户信息
export async function fetchGitHubUser(username: string): Promise<GitHubUser> {
const response: AxiosResponse<GitHubUser> = await axios.get(
`${API_URL}/users/${username}`
);
return response.data;
}
// 可选链、空值合并示例
export function describeUser(user?: GitHubUser | null): string {
const login = user?.login ?? 'Unknown';
const repos = user?.public_repos ?? 0;
const followers = user?.followers ?? 0;
return `用户 ${login} 有 ${repos} 个公共仓库,拥有 ${followers} 名粉丝。`;
}
src/index.ts
// src/index.ts
import { fetchGitHubUser, describeUser } from './utils';
import { DEFAULT_USER } from './config';
async function main() {
try {
console.log('正在获取 GitHub 用户信息...');
const user = await fetchGitHubUser(DEFAULT_USER);
console.log(describeUser(user));
} catch (err: any) {
console.error('发生错误:', err.message);
}
}
main();
以上示例用到了:
- ESM
import/export
- TypeScript 接口与类型注解
async/await
异步逻辑- 可选链(
user?.login
)与空值合并(??
)
在传统 node
环境中,这些语法需要借助 Babel、ts-node 或手动编译。使用 esno,可以直接运行,并享受超快启动。
5.3 运行脚本并观察效果
启动开发模式
npm run dev
输出示例:
[esno] Watching for file changes... 正在获取 GitHub 用户信息... 用户 octocat 有 8 个公共仓库,拥有 5452 名粉丝。
- esno 监听
src/
目录下的文件改动,若你修改utils.ts
或index.ts
,会自动重新编译并重启脚本。
- esno 监听
生产/一次性运行
npm run start
等价于
esno src/index.ts
,输出相同结果。- 查看编译缓存
在项目根目录下,会被生成一个缓存目录(默认隐藏),方便增量编译。通常无需关心,若要清除缓存,可删除.esno_cache
(或类似目录)。
ASCII 图解:esno Watch 模式
┌─────────────────┐ 文件改动 ┌───────────┐ │ src/index.ts │ ───────────────▶ │ esno │ └─────────────────┘ │ (--watch) │ ┌─────────────────┐ └─────┬─────┘ │ src/utils.ts │ │ └─────────────────┘ 重新编译 & 热重载 │ ┌─────▼────┐ │ Node.js │ │ 执行逻辑 │ └──────────┘
6. 性能对比与飞跃
为了直观感受 esno 带来的速度提升,我们做一个简单的对比测试:同样的 TypeScript 脚本,分别用 ts-node
和 esno
来运行,测量启动到第一行输出所需时间。
6.1 启动速度对比示意图
启动耗时 (ms)
┌────────────────────────────────────────────────────────┐
│ 1000 ts-node 启动时间 │
│ │
│ │
│ │
│ 800 │
│ │
│ │
│ 600 │
│ │
│ │
│ 400 │
│ │
│ │
│ 200 │
│ esno 启动时间 │
│ │
│ │
│ 0 ────────────────────────────────────────────▶│
│ [ esno (~50ms) ] [ ts-node (~800ms) ] │
└────────────────────────────────────────────────────────┘
- esno:大约 30–80ms(取决于机器性能)
- ts-node:大约 600–1200ms(项目大小不同差异较大)
6.2 示例对比测试脚本
假设有同样的 hello.ts
文件:
// hello.ts
console.log('Hello, Performance Test!');
用 ts-node 运行并计时
在终端执行(Linux/macOS):
time ts-node hello.ts
示例输出:
Hello, Performance Test!
real 0m0.925s
user 0m0.543s
sys 0m0.112s
用 esno 运行并计时
time esno hello.ts
示例输出:
Hello, Performance Test!
real 0m0.045s
user 0m0.030s
sys 0m0.005s
可见 esno 大约 45ms 即可完成启动与执行,而 ts-node 需要约 900ms,足足慢了近 20 倍。对于大型项目、调试和频繁重启场景,这种性能差异带来的体验改进非常明显。
7. 高级用法与配置
虽然 esno 默认配置已能满足大多数场景,但针对一些高级需求,你可以自定义配置或结合其他工具链。
7.1 自定义 esno 配置文件
esno 支持在项目根目录添加一个名为 esno.config.ts
或 esno.config.js
的配置文件,用于指定额外的编译选项和插件。例如,若你希望在 esno 内置的 esbuild 基础上添加自定义 loader、插件或别名(alias),可以这样写:
// esno.config.ts
import type { EsnoConfig } from 'esno';
const config: EsnoConfig = {
// 指定文件别名
alias: {
'@utils': './src/utils.ts'
},
// 自定义 esbuild 插件(示例:处理 .txt 文件为字符串)
esbuild: {
plugins: [
{
name: 'txt-loader',
setup(build) {
build.onLoad({ filter: /\.txt$/ }, async (args) => {
const contents = await require('fs').promises.readFile(args.path, 'utf8');
return {
contents: `export default ${JSON.stringify(contents)}`,
loader: 'js'
};
});
}
}
]
},
// 启用 source-map
sourceMap: true
};
export default config;
- alias:为路径
@utils
指定到./src/utils.ts
,可在代码中使用import foo from '@utils'
。 - esbuild.plugins:可以注入任意 esbuild 插件,例如上面演示的
.txt
文件内容直接导出为字符串。 - sourceMap:输出 source-map,方便调试。
配置文件生效后,执行 esno src/index.ts
时,会自动加载这些设置。
7.2 结合 Babel / SWC 等预处理器
在更复杂的项目中,可能需要 Babel 或 SWC 针对某些语法做特殊转换、注入 Polyfill 或支持实验性语法。esno 本身内置了 esbuild 的转换能力,但在以下场景需要配合其他工具:
使用装饰器(Decorators)
- TypeScript 的
experimentalDecorators
可能需要 Babel 的@babel/plugin-proposal-decorators
。
- TypeScript 的
需要 Polyfill(core-js)
- 对某些旧环境 API(如
Promise.finally
)做兼容。
- 对某些旧环境 API(如
特殊 JSX 转换
- React 项目中使用 Emotion、styled-components 等需要 Babel 特定插件。
你可以在 esno 前置执行 Babel / SWC,或者在代码中先编译一遍再交给 esno 运行。例如,在 package.json
中写:
{
"scripts": {
"dev": "babel-node src/index.ts", // 先用 Babel 转译
"fast": "esno src/index.ts" // 直接用 esno 运行
}
}
但大多数场景,esno + esbuild 的组合已足够快速和现代。
7.3 在调试与测试中使用
调试(Debug)
esno 可配合 Node.js 内置的调试协议,直接在 VSCode 或 Chrome DevTools 中调试 TS 代码。示例 VSCode 配置:
// .vscode/launch.json
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Debug esno",
"runtimeExecutable": "esno",
"program": "${workspaceFolder}/src/index.ts",
"cwd": "${workspaceFolder}",
"args": [],
"protocol": "inspector",
"sourceMaps": true,
"outFiles": []
}
]
}
- runtimeExecutable:指定
esno
为运行时,Debug 时会先编译再启动。 - program:要调试的入口文件,使用
.ts
即可。 - 配置完成后,在 VSCode Debug 面板点击 “Debug esno” 便可进入断点调试。
测试(Test)
大多数测试框架(如 Jest、Mocha)并不直接支持 esno。但是,你可以借助 esbuild-jest、ts-mocha 等桥接:
Jest + esbuild
npm install --save-dev jest @types/jest ts-jest esbuild-jest
在
jest.config.js
中:module.exports = { transform: { '^.+\\.(t|j)sx?$': 'esbuild-jest' }, testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.[jt]s?$', moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'] };
然后直接写 TypeScript 测试文件,esbuild-jest 会为你即时编译。
Mocha + ts-node-esm
若想在 Mocha 中使用 esbuild 的速度,可使用mocha --require esno/register
:npm install --save-dev mocha @types/mocha
在
mocha.opts
或package.json
脚本中:{ "scripts": { "test": "mocha -r esno/register \"src/**/*.spec.ts\"" } }
-r esno/register
会让 Mocha 在运行前加载 esno 的挂载,从而可直接用import
执行 TS 测试文件。
8. 常见问题与注意事项
Node.js 内置模块兼容性
- esno 会将源文件转换为 CommonJS/ESM,并在运行时调用 Node.js 内置模块(如
fs
,path
)。绝大多数场景无需特殊处理,但如果你需要原生二进制包(如node-gyp
),需确保安装环境已满足相关依赖。
- esno 会将源文件转换为 CommonJS/ESM,并在运行时调用 Node.js 内置模块(如
第三方包 ESM/CJS 混用
- 某些 npm 包只提供 CommonJS 版本,可能在 ESM 中
import
时出错。可在代码中使用动态import()
或改为const pkg = require('pkg')
。
- 某些 npm 包只提供 CommonJS 版本,可能在 ESM 中
Cache 清理
- esno 会在内部建立缓存目录(一般为
node_modules/.cache/esno
),以提升重复运行速度。如遇到缓存不一致导致的奇怪错误,可手动删除该目录。
- esno 会在内部建立缓存目录(一般为
跨平台路径问题
- 在 Windows 上,路径分隔符为
\
,而在 Linux/macOS 为/
。esno 会将源文件编译到内存,解决模块解析时的跨平台兼容,通常无需自行处理。但如果在代码中硬编码了文件路径,需要使用 Node.js 内置path.join
等方法统一。
- 在 Windows 上,路径分隔符为
TypeScript 类型检查
- esno 仅作“运行时转译”,默认不执行类型检查。如果你需要在开发中持续进行类型校验,建议另行运行
tsc --noEmit
,或在 CI 流程中加入类型检查一步。
- esno 仅作“运行时转译”,默认不执行类型检查。如果你需要在开发中持续进行类型校验,建议另行运行
装饰器与实验性语法
- 若需使用 TypeScript 装饰器(
experimentalDecorators
),需在tsconfig.json
中开启,并安装相应 polyfill。esno/esbuild 本身会跳过装饰器的语义,具体功能需借助 Babel 或额外转换。
- 若需使用 TypeScript 装饰器(
source-map 支持
- 为了在调试时定位到 TypeScript 源码,需在运行时启用 source-map。可在 CLI 中添加
--enable-source-maps
,或在esno.config.ts
中设置sourceMap: true
。
- 为了在调试时定位到 TypeScript 源码,需在运行时启用 source-map。可在 CLI 中添加
9. 总结与最佳实践
选择适合的场景
- 对于日常开发、脚本、原型项目,推荐使用 esno,享受毫秒级启动和零配置体验;
- 对于生产环境以及需要完整 Babel 转换、细粒度 TypeScript 检查的项目,仍建议先行编译(
tsc
/ Babel),再用node dist/index.js
运行。
保持 TypeScript 与类型检查独立
- 使用 esno 时,它只负责“即时转译 + 运行”。若需类型校验,请另行在 IDE 中或 CI 中运行
tsc --noEmit
。
- 使用 esno 时,它只负责“即时转译 + 运行”。若需类型校验,请另行在 IDE 中或 CI 中运行
合理利用缓存与 watch 模式
- 在开发中启用
--watch
,esno 会缓存编译结果并增量更新,大幅提升重启速度; - 如遇奇怪的编译问题,可考虑清除缓存目录。
- 在开发中启用
注意实验性语法兼容性
- 虽然 esno 支持许多 ESNext 特性,但某些实验性语法(如装饰器、私有字段)可能需要额外插件。结合项目需求选择是否用 Babel 做预处理。
配置别名与插件拓展
- 通过
esno.config.ts
可以自定义 esbuild 插件、路径别名等,轻松将前后端代码合并。
- 通过
集成调试与测试
- 在 VSCode 中可结合
launch.json
直接调试 esno 运行的 TypeScript; - 在测试框架中使用
-r esno/register
,让测试文件可直接使用 TS 或 ESM。
- 在 VSCode 中可结合
通过本文,你已经了解到 esno 的基本原理与使用方法,并通过示例代码和性能对比,深切体会到它相比传统 ts-node
或先编译再运行的速度优势。在实际项目中,根据需求灵活选型,将能够显著提升开发效率,减少启动延迟,让你更专注于业务逻辑,而不是等待编译。
评论已关闭