JavaScript 的语言语法由 ECMAScript 定义,但 fetch、文件系统、模块解析和事件循环并不属于 ECMAScript。它们由宿主运行时提供。因此,同一段 JavaScript 是否可运行、如何加载依赖、何时执行回调,取决于它运行在浏览器、Node.js 还是 Bun。

先区分语言与运行时

ECMAScript 规定了 classPromise、模块语法、MapArray 等语言能力。浏览器、Node.js 与 Bun 都实现了这套标准,日常业务代码的语法差异很小。

运行时在语言之外补齐执行引擎、全局对象、I/O、模块加载器、事件循环和安全模型。document 是浏览器 API,fs 是 Node.js API,Bun.file() 是 Bun API;它们都不是 JavaScript 标准的一部分。

维度浏览器Node.jsBun
主要目标渲染和交互网页服务端、脚本和工具链服务端与高性能工具链
JavaScript 引擎V8、JavaScriptCore 等V8JavaScriptCore
I/O 边界Web API 与用户授权操作系统能力操作系统能力
模块入口URL 和 import包解析、ESM、CommonJS包解析、ESM、CommonJS 兼容
DOM原生提供默认不提供默认不提供
内置服务能力无监听端口 APInode:httpnode:netBun.serve()、Node 兼容 API

不能根据文件扩展名判断运行时。.js 既可以由浏览器加载,也可以由 nodebun 执行;决定行为的是启动环境和构建配置。

全局对象与平台 API

浏览器:围绕页面与用户

浏览器的全局环境通常是 window,同时以 globalThis 提供统一入口。核心能力是 DOM、CSSOM、事件、存储、网络请求、计时器和渲染生命周期。

1
2
3
4
5
6
7
const button = document.querySelector('button');

button?.addEventListener('click', async () => {
const response = await fetch('/api/profile');
const profile = await response.json();
localStorage.setItem('profile', JSON.stringify(profile));
});

页面脚本不能任意读取本机路径、监听 TCP 端口或启动子进程。浏览器以同源策略、CORS、内容安全策略和权限提示限制能力。fetch('https://api.example.com') 能否读取响应,还受目标服务器的 CORS 响应头约束。

浏览器提供的 FileBlobFileSystemHandle 面向用户选择或授权后的文件,而不是 Node.js 风格的任意路径文件系统。不要把它们当作 fs 的替代品。

Node.js:围绕进程与操作系统

Node.js 使用 V8 执行 JavaScript,通过 C++ 绑定和 libuv 提供文件、网络、子进程、DNS、信号和线程池等能力。顶层全局对象是 global,跨运行时代码应优先使用 globalThis

1
2
3
4
5
6
import { readFile } from 'node:fs/promises';
import process from 'node:process';

const path = process.env.CONFIG_PATH ?? './config.json';
const text = await readFile(path, 'utf8');
const config = JSON.parse(text);

现代 Node.js 已实现许多 Web API,例如 fetchRequestResponse、Web Streams、URL 和 Web Crypto。但它们不意味着 Node.js 具有浏览器环境:没有 DOM、布局、windowlocalStorage 或页面生命周期。

Node.js 进程默认拥有启动用户可访问的操作系统权限。服务端程序必须自行约束路径、网络目标、子进程参数和不可信输入;浏览器的同源策略不会替你保护服务端。

Bun:以 Web API 为表面,兼容 Node.js 生态

Bun 使用 JavaScriptCore,而不是 V8。它直接提供大量 Web API,并实现 Node.js 的 processBuffer 和多数 node: 内置模块,以便复用 npm 包。兼容并不等价于完全相同:原生扩展、边缘 API、诊断输出和性能特征都应在 Bun 中实际验证。

1
2
3
4
5
6
7
8
9
10
11
const file = Bun.file('./message.txt');
const message = await file.text();

const server = Bun.serve({
port: 3000,
fetch() {
return new Response(message);
}
});

console.log(`listening on ${server.url}`);

Bun 还将文件、子进程、SQLite、测试、打包和包管理器纳入运行时接口,例如 Bun.write()Bun.spawn()bun:sqlite。这些 API 适合明确选择 Bun 的应用,不应放入宣称兼容浏览器或 Node.js 的共享库。

模块加载的差异

浏览器按 URL 加载 ESM

浏览器原生支持 ES Modules。模块说明符必须能解析为 URL,通常是相对路径、绝对路径、完整 URL 或由 import map 映射的裸说明符。

1
<script type="module" src="/assets/main.js"></script>
1
2
import { formatDate } from './format-date.js';
import { client } from '/assets/client.js';

原生浏览器不会遍历 node_modules,也不能直接理解 TypeScript、JSX 或大多数 npm 包的源代码。Vite、Webpack、Rspack 等构建工具负责解析包、转换语法、打包或开发时按需转换。

Node.js 同时支持 ESM 和 CommonJS

Node.js 支持 ESM 的 import,也保留 CommonJS 的 require()。一个文件采用哪种模式受扩展名和最近的 package.jsontype 字段影响:.mjs 固定为 ESM,.cjs 固定为 CommonJS,.js 则由 type 决定。

1
2
3
4
5
6
{
"type": "module",
"exports": {
".": "./src/index.js"
}
}

Node.js 会按 node_modulespackage.jsonexports 和条件导出解析包。ESM 中应显式写出相对文件扩展名;不要期待浏览器和 Node.js 对 ./util 做同样的猜测。

Bun 原生加载更多文件类型

Bun 能直接执行 TypeScript、JSX、JSON 等常见项目文件,并支持 ESM 与 CommonJS。它同样解析 npm 包和 package.json 导出,因此常可直接运行 Node.js 项目。

这减少了开发期转译配置,不会消除发布边界。若库的消费者包含浏览器、Node.js 和 Bun,应发布已构建的 JavaScript,并用条件导出给不同环境提供明确入口。

1
2
3
4
5
6
7
8
9
{
"exports": {
".": {
"browser": "./dist/browser.js",
"node": "./dist/node.js",
"default": "./dist/browser.js"
}
}
}

条件的键顺序有语义:解析器会选择第一个满足的条件。将通用 default 放在末尾,避免更具体的运行时入口被提前遮蔽。

事件循环与异步 I/O

三者都有 Promise、微任务和计时器,但调度器的实现与优先级不完全相同。依赖某个回调的精确先后顺序,尤其混用 setTimeout()、I/O 回调、queueMicrotask() 和运行时私有 API 时,容易产生跨运行时错误。

浏览器的事件循环还要在任务之间协调渲染。长时间同步计算会阻塞点击、动画和绘制;应拆分任务、使用 Web Worker,或将计算移到服务端。

Node.js 的事件循环处理 I/O 阶段,部分文件系统、DNS 和加密任务会使用 libuv 线程池。process.nextTick() 是 Node.js 特有的高优先级队列;递归调度它可能饿死 I/O,因此跨平台库优先用 queueMicrotask()

1
2
3
4
5
6
7
queueMicrotask(() => {
console.log('runs before the next task');
});

setTimeout(() => {
console.log('runs in a later task');
}, 0);

Bun 的事件循环与 I/O 实现不同于 Node.js。把 Node.js 的时序细节、内存占用或吞吐量结论直接迁移到 Bun 没有依据;性能敏感路径必须用目标运行时、目标操作系统和真实负载测量。

网络、流与二进制数据

浏览器的网络编程以 fetchWebSocketEventSource 和 WebRTC 为主,通常以 ReadableStreamBlobArrayBufferUint8Array 传递数据。请求受同源策略、CORS 和混合内容策略约束。

Node.js 同时提供 Web 风格 API 和传统的 node:streamnode:httpnode:net API。两套流模型可以互转,但接口、背压处理和错误传播不同;在一个数据管道中选定一种模型,避免无意义的适配与复制。

Bun 也重视 Web 标准的 RequestResponseReadableStream,并兼容许多 Node.js 网络接口。对外暴露 HTTP 服务时,优先以标准 RequestResponse 设计边界;仅在需要 Node.js 中间件兼容性时引入 Node 专用接口。

编写可移植代码

共享代码应只依赖 ECMAScript 和三个运行时都稳定实现的 Web 标准,例如 URLURLSearchParamsTextEncodercrypto.subtlefetch。即使 API 名称相同,也要确认目标版本和语义满足需求。

平台能力放在应用边缘,再通过小接口注入共享逻辑。下面的函数不关心数据来自浏览器缓存、Node.js 文件还是 Bun 数据库。

1
2
3
4
export async function loadProfile(readText) {
const text = await readText();
return JSON.parse(text);
}

浏览器入口可传入 () => localStorage.getItem('profile') ?? '{}';Node.js 或 Bun 入口则传入读取文件或数据库的函数。这样,平台差异被限制在入口层,业务逻辑不需要检测运行时。

若确实需要分支,使用能力检测而不是用户代理或版本字符串。能力检测表达真正前提,也能兼容 polyfill 和未来运行时。

1
2
3
4
5
6
7
8
export function hasDOM() {
return typeof document !== 'undefined';
}

export function canReadWithBun() {
const isBun = typeof Bun !== 'undefined';
return isBun && typeof Bun.file === 'function';
}

不要在同一模块顶层同时导入浏览器和 Node.js 专用模块。打包器或浏览器可能在执行分支前就解析失败。应将平台实现拆成独立入口,再用条件导出、构建别名或依赖注入选择它们。

选择运行时

前端页面选择浏览器;需要 DOM 的测试也应使用真实浏览器或专门的 DOM 模拟环境。传统服务端、CLI、需要成熟原生扩展或依赖 Node.js 诊断生态的项目,选择 Node.js 最稳妥。

需要快速启动、内置工具链,且依赖已经在目标平台验证过的服务端或工具项目,可以选择 Bun。它不是浏览器,也不只是“更快的 Node.js”;它是带有 Node.js 兼容层和 Bun 专有 API 的独立运行时。

最终应将运行时写入项目的版本约束、CI 矩阵和发布产物,而不是只依赖开发者本机的默认命令。跨环境代码的正确性来自清晰的边界和目标环境验证,而不是偶然通过的本地运行。