深度解读 @expo/metro-runtime:Expo 生态中驱动高级 Metro 打包特性的运行时基石
@expo/metro-runtime 是 Expo 官方维护的一个"运行时注入"型包,它自身不负责打包,而是为应用启动时的 JS 运行时注入那些 Metro 高级打包特性(如异步分块加载、Server Components / RSC 边界、window.location 与相对 URL fetch 兼容、开发期错误上报)所必需的补丁与基础设施。它通常不需要开发者手动安装,而是由 expo-router 与 expo/metro-config 自动携带,但对于希望了解 Expo 应用在启动瞬间发生了什么、以及想排查"为什么原生端可以发相对路径请求""为什么 Promise 未处理异常会出现在 LogBox"等问题的工程师来说,读懂它的 变更日志 与源码是最高效的切入点。读完本文,你将掌握该包的安装方式、入口执行顺序、四大运行时能力背后的实现位置,以及从 3.x 到 57.x 的版本演进脉络所揭示的 Expo 架构迁移方向。
一、这个包是做什么的:给运行时"打补丁"而非"提供 API"
包自身的 package.json 给出的描述是"Tools for making advanced Metro bundler features work"(让高级 Metro 打包特性得以工作的工具),版本为 57.0.8,授权 MIT,作者为 650 Industries(Expo 团队)。其 README 说得更直接:
Injects runtime code required for advanced Metro bundling features in the Expo ecosystem.
也就是说,它提供的不是 import { something } 这样的业务 API,而是副作用注入:只要在首屏 bundle 里被导入一次,它就会在运行时环境中安装各类 polyfill 和钩子。这一点在源码入口 src/index.ts 中体现得淋漓尽致——该文件没有任何导出符号,只有三件事:
import './location/install':安装window.locationpolyfill(原生平台)并为相对 URL 请求包装全局fetch;import '@expo/metro-runtime/rsc/runtime':接入 RSC / 异步 bundle 分块的运行时加载钩子;- 在
__DEV__下分别启用 Metro 终端日志的堆栈捕获(metroServerLogs)、Hermes Promise 拒绝追踪,并把@expo/log-box的clearAllLogs挂到globalThis.__expo_dev_resetErrors。
从 package.json 的依赖可以看出它的技术栈:@expo/log-box(错误覆盖层)、anser 与 stacktrace-parser(ANSI 颜色剥离与堆栈解析)、pretty-format(格式化非 Error 的 rejection 值)、whatwg-fetch(fetch 标准库补丁)。它在 Node 侧通过 build/index.js 暴露,同时提供 expo-source 条件导出指向 src/index.ts,配合 workspace 协议管理 monorepo 内的依赖版本。
二、接入方式:谁负责把它放进你的 bundle
README 给出了唯一的使用姿势:
yarn add @expo/metro-runtime
然后保证它在首屏 bundle 的某个位置被导入(例如 App.js 顶部):
import '@expo/metro-runtime';
这里有两个非常关键的工程细节:
expo-router用户无需安装——它已经被依赖链带上了(README 原文:"expo-routerusers do not need to install this package, it is already included.")。- 导入顺序会被自动纠正:README 明确说明
expo/metro-config会自动把这个 import 移动到 bundle 的最前面。之所以强调"最前",是因为它要做的安装动作(初始化 RN 核心全局、安装fetch/URL/window.location)必须在任何应用代码执行前完成,否则后续模块就会拿到"残缺的运行时"。
同时,包内还存在面向未来 Server 运行时的专用入口 ./rsc/runtime(指向 rsc/runtime.js),这也解释了变更日志中多次出现的"entry point 迁移"类条目。
三、四大运行时能力:从变更日志到源码的对照
包的当前形态是由一次次的版本演进堆叠出来的。下面把 CHANGELOG.md 中最重要的功能条目与其在源码中的落点逐一对照。
3.1 window.location polyfill 与相对 URL fetch(原生端也能用 /api/...)
变更日志证据链:4.0.0-preview.0(2024-10-22)"Enable relative fetch requests by default"(默认启用相对 fetch 请求);同一版本的 "Use src directory for source code";4.0.1 的 "Use window.location polyfill for server requests"(#32099);4.0.0-preview.0 还包含 "Support location.reload() in native production builds"(#29572)。
源码落点:原生实现位于 src/location/install.native.ts。它的顶部注释非常诚实地说出了一段"依赖地狱"的解决顺序:
// 确保 react-native 核心全局先初始化,而不是依赖不稳定的 getModulesRunBeforeMainModule
import 'react-native/Libraries/Core/InitializeCore';
// 先装 fetch,保证 Headers / Request 全局可用
import 'whatwg-fetch';
// 必须导入 expo,确保 URL 已被安装
import 'expo';
随后它会依据来源解析出 origin:
- 开发模式:优先读取开发服务器 bundle origin(通过
expo/internal/bundle-origin的getBundleOrigin(),这也正是 Unpublished 版本中 "Read the development server URL fromexpo/internal/bundle-origininstead of duplicating its accessor"(#48278)所对应的重构); - 生产模式 / 无 bundle origin:回退到
Constants.expoConfig.extra.router.origin,若为空再取 release 构建时自动写入的extra.router.generatedOrigin。
只要 extra.router.origin !== false,它就会:
- 在原生端
window上安装locationgetter(若尚未存在),并用解析到的 origin 调用setLocationHref(url); - 用
wrapFetchWithWindowLocation包一层全局fetch,把所有以/开头的相对 URL(无论是字符串形式还是{ url }对象形式)解析成绝对 URL 后再发出请求。
对外的 Location 类(src/location/Location.native.ts)借鉴了 Deno 的 Location 实现,只暴露只读属性:hash、host、hostname、href、origin、pathname、port、protocol、search 全部只可读;对 location.hash = ...、location.assign() 等写入行为统一抛出 NotSupportedError。唯一的例外是 reload():开发模式下调用 RN 的 DevSettings.reload(),生产模式下若 globalThis.expo.reloadAppAsync 存在(Expo SDK 51+)则调用它实现应用内重载——这正是"native production builds 支持 location.reload()"那条功能条目的直接产物。
而在 web 端,这些文件有对应的空实现(src/location/install.ts 与 src/location/Location.ts),因为浏览器本身就提供了这些标准能力,无需 polyfill——这也体现了该包"按平台裁剪、尽量少干扰"的设计哲学。
测试佐证:src/location/tests/wrapFetchWithWindowLocation.test.native.ts 用 4 个用例锁定了 fetch 包装器的行为契约:
- 相对 URL 会基于 location origin 解析(
'/api/route'→'http://proxy.test:4443/api/route'); - request-like 对象中的
url同样会被解析; - 绝对 URL 保持原样;
- 当没有可解析的 location 时,相对 URL 原样放行,让请求"以自己的方式失败",而不是在这里抛出
Invalid URL。
3.2 Metro 终端日志:把错误栈与组件栈送回开发服务器
变更日志证据链:6.1.0(2025-08-19)"Pass errors, synthetic and owners stacks to Metro Dev Server terminal"(#38871);6.1.1(2025-08-26)"Avoid sending compilation errors back to Metro terminal from the application runtime"(#39142);Unpublished 中还有 "Update logbox imports"(#46640)。
源码落点:开发模式入口通过 captureStackForServerLogs()(原生端实现 src/metroServerLogs.native.ts)替换 react-native 的 HMRClient.log,只拦截 error 级别日志(warn 被有意排除在捕获范围外)。其核心逻辑是:
- 检测
preventSymbolication标记:若存在则直接丢弃,避免编译错误被重复打印(一次来自 Metro 自身、一次来自应用运行时); - 对携带
stack字段的错误,把原始堆栈显式 push 进 data(否则会被pretty-format吞掉); - 对没有错误栈的日志,合成一个
syntheticStack(以空名错误捕获当前调用栈),并提醒开发者"向上查找真正的报错源头"; - 通过 React 19 的
captureOwnerStack()捕获组件 owner 栈,并附带检测新老两种组件栈格式(in/at/@...\n)以决定是否补充组件栈。
这些细节解释了为什么开发者在 Expo CLI 终端里能看到比原生报错更完整、可 symbolicate 的调用栈信息。web 端对应实现(src/metroServerLogs.ts)为空函数,说明终端日志回传主要服务于原生平台。
3.3 Promise 拒绝追踪:让"未处理 rejection"现身 LogBox
变更日志证据链:6.0.1(2025-08-15)"Show console.error and LogBox for unhandled promise rejection in development"(#38834)。
源码落点:src/promiseRejectionTracking.native.ts 在 HermesInternal.hasPromise() 与 enablePromiseRejectionTracker 均存在时启用 Hermes 原生追踪器,并传入:
global.HermesInternal.enablePromiseRejectionTracker({
allRejections: true,
onUnhandled: (id, rejection) => { /* 组装并抛出 rejectionError */ },
onHandled: (id) => { /* console.warn 提醒可忽略先前的提示 */ },
});
实现细节值得一提:对非 Error 的 rejection 值,优先用 pretty-format 格式化,失败再退化为 JSON.stringify;拼装出的错误统一交给 src/ExceptionsManager 的 handleException 处理,从而进入 Expo / LogBox 的既有错误管线。同时保留了原始 rejection 的堆栈(若有),并给每条错误加上 Uncaught (in promise, id: N) 前缀,便于开发者把"稍后出现的一条 handled 警告"与之前的未处理异常对应起来。由于它只在 __DEV__ 下被 require(见入口 index.ts),生产包不会携带这份额外的追踪开销。
3.4 RSC / 异步 bundle 加载的运行时接线
变更日志证据链:这一块是 4.x 版本最密集的功能区。4.0.0-preview.0 一口气引入了 "Always enable async bundle loading on native"(原生端始终启用异步 bundle 加载)、"Add server HMR for native"、"Add streaming fetch polyfill"、"Add support for CSS in server components"、"Add initial version of DOM Components"、"Enable relative fetch requests by default";3.0.2 则先在 web 端为生产模式开启了异步代码加载。
源码落点:rsc/runtime.js 是这段历史沉淀下来的"接线层"。它以 3 行为核心:
globalThis.__webpack_chunk_load__ = (id) => global`${__METRO_GLOBAL_PREFIX__}__loadBundleAsync`;
globalThis.__webpack_require__ = (id) => global`${__METRO_GLOBAL_PREFIX__}__r`;
即把 Metro/Webpack 语义的 chunk 加载与模块执行分别桥接到 Expo 运行时的 __loadBundleAsync 与 __r 上,让 RSC 的虚拟客户端边界(virtual client boundary)能够在原生端按需拉取分块代码。同时它针对生产原生构建做了一处"防御性"修正:React Native 的缺失模块错误处理会导致生产崩溃,而这里期望的效果是把错误抛给最近的 error boundary 展示给用户,因此会临时把 ErrorUtils.reportFatalError 替换为 throw err 并在模块执行后恢复(注释中还给出了可测试方式:在无虚拟客户端边界的生产 iOS 构建上运行,即可看到所有 split chunk 缺失并抛错)。变更日志中随后的 5.0.4 "Move virtual RSC client boundary entry point to expo"(#36408)则表明这一接线层的入口职责已随架构演进移交给了 expo 包本体。
四、错误呈现能力的迁移史:从自带覆盖层到 @expo/log-box
阅读变更日志时会发现一条清晰的"减法"主线——Expo 正在把错误 UI、日志覆盖层逐步从 @expo/metro-runtime 中抽出:
55.0.0(2026-01-21):"Move error overlay UI to@expo/log-boxpackage"(#39958),正式把错误覆盖层 UI 迁出;6.1.2(2025-09-12):把@expo/log-box调整为 peer dependency(#39603),团队认为 peer 依赖更能准确表达二者之间的边界意图;5.0.4(2025-04-28):移除/symbolicate导入(#36409),并同步把 RSC 客户端边界入口点迁入expo;4.0.0-preview.0的多个修复:"Fix logbox usage on native for router errors"、"PreventLogBoxStateSubscriptioncallingsetStatewhile rendering a<Suspense />boundary"(#32047)等。
即便如此,@expo/metro-runtime 与 LogBox 之间仍保留了一层薄薄的耦合:入口 index.ts 中
globalThis.__expo_dev_resetErrors = require('@expo/log-box/LogBox').default.clearAllLogs;
把"清空既有错误日志"的能力暴露成运行时全局,供刷新逻辑复用(代码注释明确标注这是过渡期的 TODO)。当前版本依赖中 @expo/log-box: workspace:^ 正是这条迁移链的终点形态。
五、能力边界的持续"上移":Fast Refresh 与 async-require 的归属变化
除了向 @expo/log-box 迁移,变更日志还记录了一条把通用能力移交给 expo 包本身的路径:
6.0.0(2025-08-13,Breaking):"Move async-require and fast refresh toexpo"(#36405),作为破坏性变更,@expo/metro-runtime不再直接承担 async-require 与 Fast Refresh 的运行时;同版本还通过 noop 掉 native 上未使用的代码来抑制react-native导入警告(#38495);5.0.3:移除web-streams-polyfill,改为依赖expo自身的支持(#36407);5.0.0(Breaking):移除对已废弃的setImmediate的全局 polyfill(#35373)——一个典型的"砍掉历史包袱"式清理;同时修复了 Windows 上把 Babel code frame 解析为语法错误的问题(#34017)以及异步导入被破坏的回归(#34824);3.2.0:为 React 客户端组件标记"use client"指令(#27300);3.1.2:为 split chunks 增加 Metro 构建错误的错误处理(#26609)。
可以把它理解为一种持续进行的"架构收敛":那些需要在任何 Expo 应用中无条件生效的基础运行时交给 expo 本体,而 @expo/metro-runtime 只保留与 Metro 构建管线强绑定、且主要服务于 dev 体验的部分。
六、版本节奏解读:55/56/57 与 SDK、React Native 版本的同步
细看 CHANGELOG.md 会发现明显的版本断层与节奏变化:
- 2023-2024 年间版本停留在 3.x → 4.0.0-preview.x → 5.x → 6.x,采用独立小步快跑;
- 进入 2026 年后版本号跳升至 55/56/57 并紧密跟随 Expo SDK 版本,且绝大多数补丁版本标注为 This version does not introduce any user-facing changes.(如
57.0.1~57.0.8、56.0.1~56.0.15、55.0.1~55.0.11),说明该阶段主要是 SDK 发布流水线驱动的同步发布; - 与 React Native 主版本对齐的"Notices"条目值得关注:
55.0.0声明支持 React Native 0.82.x(#39678),56.0.0声明支持 React Native 0.84.x(#43018)——这解释了为何在某些版本(如56.0.0)没有任何用户可见变化时依然会触发 major 版本号变更; - 每个版本条目使用统一的分类规范:
🛠 Breaking changes/🎉 New features/🐛 Bug fixes/💡 Others/⚠️ Notices,其中 "Others" 大量承载内部重构(如依赖解耦、导入路径调整、平台空实现),是观察架构演进的第一手材料。
而文档顶部 Unpublished 区块(当前 57.0.8 之后的待发布内容)预告了接下来两个内部改动方向:"Update logbox imports"(#46640)与"Read the development server URL from expo/internal/bundle-origin"(#48278)——后者正对应本文 3.1 节里从 Constants.expoConfig.extra 读取 origin 改为复用 expo 内部工具的重构。
七、源码导航:一份可继续深入的地图
若想亲自验证上文结论,可以按下面的路径继续阅读当前仓库:
| 关注点 | 推荐文件 |
|---|---|
| 包入口与副作用装配顺序 | src/index.ts |
window.location polyfill 原生实现(只读 Location、reload 行为) |
src/location/Location.native.ts |
| origin 解析、location/fetch 的安装逻辑 | src/location/install.native.ts |
| 相对 URL fetch 包装器的行为契约测试 | src/location/tests/wrapFetchWithWindowLocation.test.native.ts |
| Metro 终端错误栈 / 组件栈捕获 | src/metroServerLogs.native.ts |
| Hermes Promise 拒绝追踪 | src/promiseRejectionTracking.native.ts |
| 异常统一处理出口 | src/ExceptionsManager |
| RSC 分块加载与错误边界兜底 | rsc/runtime.js |
| 包元数据、导出与依赖策略 | package.json |
| 安装与使用说明 | README.md |
| 版本演进全记录(本文的事实依据) | CHANGELOG.md |
提示:文中多处提到的 web 平台空实现(如 src/metroServerLogs.ts、src/location/install.ts)是一个值得留意的阅读技巧——同一文件名带
.native后缀的版本才是原生平台真正执行的逻辑,不带后缀的通用版本往往只做平台占位,这是 Expo monorepo 中"按平台裁剪运行时"的惯用实现模式。
八、开发者视角:何时需要关注这个包
总结起来,理解 @expo/metro-runtime 能在三类场景里真正帮到你:
- 排查 dev 体验问题:当你发现终端里错误栈缺失、错误被重复打印,或 LogBox 无法展示某条未处理 Promise rejection 时,排查起点应是 src/metroServerLogs.native.ts 与 src/promiseRejectionTracking.native.ts 这两份源码,而非
react-native内部; - 搭建使用 RSC / 异步分块的 Expo 应用:需要理解代码是如何通过 rsc/runtime.js 桥接到
__loadBundleAsync的,以及为什么生产原生构建中缺失 chunk 会表现为"被 error boundary 捕获的错误"而不是直接崩溃; - 评估升级风险:阅读 CHANGELOG.md 时,重点看
🛠 Breaking changes(例如5.0.0移除setImmediatepolyfill、6.0.0把 async-require/Fast Refresh 移入expo)以及⚠️ Notices(React Native 版本支持声明),它们决定了一次 Expo SDK 升级是否会影响你的自定义 Metro 配置或运行时假设。
需要再次强调的是:除非你脱离 expo-router 与默认 expo/metro-config 手动搭建原生 + Metro 链路,否则无需也通常不应该直接安装这个包——它的存在意义恰恰是"在你不感知的情况下,让高级 Metro 特性正常工作"。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0629
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python07
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00