Skip to content
雲里
里雾

Metro Hermes 与 JSI

mindgym 开发 更新于 2026/4/12

pnpm start 到屏幕出现 UI,中间经历了 Metro 三阶段流水线(Resolution → Transformation → Serialization)、Babel 插件链、以及 Hermes 字节码执行。理解这条链路,就理解了 metro.config.jsbabel.config.jstailwind.config.js 每个配置字段存在的理由。


概述:为什么 RN 不用 Webpack?

你敲下 pnpm start,几秒后手机屏幕上出现了 MindGym 的 UI。如果换成 Web 项目,你大概会说:“Webpack(或 Vite)把源码打成 bundle,浏览器加载并执行。“但 RN 没有浏览器,也不用 Webpack。

RN 的处理链复杂得多:

  1. 运行时不是浏览器——JS 跑在独立的 JS 引擎(Hermes)里,和原生 UI 层通信
  2. 文件解析需要平台感知——同一个 import,iOS 可能加载 Button.ios.tsx,Android 加载 Button.android.tsx
  3. 开发体验要求极高的 HMR——你改一行代码,手机要在 200ms 内反映,不能全量刷新
  4. 生产包需要预编译字节码——应用冷启动时不能现场 parse JS,要提前编译成 Hermes 字节码(.hbc)

这些需求加在一起,催生了 Metro。


历史背景:Metro 是怎么来的

Metro 是 Facebook 内部工具演化而来的。2013 年 RN 刚诞生时,Facebook 的工程师直接用了已有的 react-packager,一个非常简陋的开发服务器。随着 RN 的规模化,这个工具逐渐成为瓶颈。

2017 年,Facebook 将其重写并命名为 Metro(“Metro Bundler”),目标是:

Metro 从不试图替代 Webpack 或 Rollup,它只做 RN 需要的那一部分——开发服务器 + 打包,不做 tree-shaking、不做 code splitting,专注做好 RN 的事。

为什么不直接用 Webpack?

需求WebpackMetro
平台感知后缀(.ios/.android)需要额外插件,不是一等公民内置,resolver 原生支持
HMR(改一行,手机刷新)刷新浏览器 Tab,状态丢失Fast Refresh:保留组件状态,只更新改动的组件
手机连接开发服务器不支持(为浏览器设计)内置 WebSocket server,手机扫码连接
Hermes 字节码输出不支持生产构建直接输出 .hbc
超大 monorepo内存占用高增量缓存,文件级 transform 缓存

工作方式

Metro 三阶段流水线

Metro 处理一个 bundle 请求分三个阶段,理解这三个阶段就理解了 metro.config.js 的所有配置项。

阶段一:Resolution(依赖解析)

做什么:从入口文件出发,递归找到所有 import/require 指向的文件。

resolver 的工作:当你写 import { Button } from '@/shared/ui/Button',resolver 需要决定这个字符串对应哪个物理文件。它按以下优先级查找:

  1. tsconfig.jsonpaths 配置(@/src/
  2. 平台后缀:先找 Button.ios.tsx,没有再找 Button.tsx
  3. package.jsonmain/react-native 字段(用于 node_modules)
  4. index 文件(目录导入 fallback)

Resolution 阶段的输出是一张模块依赖图(Module Graph),记录每个文件和它依赖的文件列表。这张图是增量更新的——你改一个文件,只有这个文件及其依赖图的”上游”需要重新处理。

阶段二:Transformation(代码转换)

做什么:对依赖图里的每个文件,调用 Babel 把 TSX/JSX/TS 转成能在 Hermes 上跑的纯 JS。这是 NativeWind 介入的阶段。

Babel 读取 babel.config.js,按配置的 preset 链处理每个文件。Transformation 是逐文件并行的,Metro 会把多个文件分发到 worker 线程(maxWorkers 配置项控制线程数),充分利用多核。

缓存机制:每次 transform 的结果会被缓存到磁盘(默认在 $TMPDIR/metro-cache)。缓存 key 是文件内容 hash + babel.config.js hash + 平台标识的组合。所以你改了 babel.config.js,所有缓存自动失效。这也是为什么有时需要 pnpm start --reset-cache——当你安装了新的 Babel 插件但缓存没自动失效时。

阶段三:Serialization(序列化输出)

做什么:把依赖图里的所有模块拼成一个(或多个)bundle 文件。

每个模块被包裹在 __d() 函数里:

// Metro 序列化后的格式(简化)
__d(function(global, require, _importDefault, _importStar, module, exports, _dependencyMap) {
  // 你的代码,import 已被转为 require()
}, 42 /* 模块 ID */, [1, 7, 23] /* 依赖的模块 ID 列表 */);

开发模式 vs 生产模式的区别

开发模式(pnpm start生产模式(eas build
输出按需发给手机,不落盘写入 bundle 文件
压缩不压缩,保留变量名和行号(方便调试)Terser 压缩,变量名混淆
Source map实时生成,发给手机的调试工具生成并上传到 Sentry(如果配了)
Hermes 字节码不编译(直接跑 JS)编译成 .hbc,冷启动快约 30%
Dead code 剔除不做(开发快比体积重要)基于 __DEV__ 标志剔除调试代码

配置文件逐行解析

metro.config.js

// metro.config.js(完整代码)
const { getDefaultConfig } = require('expo/metro-config')
const { withNativeWind } = require('nativewind/metro')

const config = getDefaultConfig(__dirname)

module.exports = withNativeWind(config, {
  input: './global.css',
})

babel.config.js

// babel.config.js(完整代码)
module.exports = function (api) {
  api.cache(true)
  return {
    presets: [
      ['babel-preset-expo', { jsxImportSource: 'nativewind' }],
      'nativewind/babel',
    ],
  }
}

tailwind.config.js

// tailwind.config.js(完整代码,关键部分)
module.exports = {
  content: [
    './app/**/*.{ts,tsx}',
    './features/**/*.{ts,tsx}',
    './shared/**/*.{ts,tsx}',
  ],
  presets: [require('nativewind/preset')],
  theme: { extend: {} },
  plugins: [],
}

完整配置调用链

pnpm start
  └─ Expo CLI 读取 app.json
       ├─ newArchEnabled: true → 告诉原生构建系统启用新架构(Fabric + JSI)
       ├─ plugins: ["expo-router"] → 生成 Expo Router 的入口文件
       └─ 启动 Metro Dev Server
            └─ Metro 读取 metro.config.js
                 ├─ getDefaultConfig() → Expo 默认配置(TS支持等)
                 └─ withNativeWind() → 注入 NativeWind transformer
                      └─ 每个文件 transform 时调用 Babel
                           └─ Babel 读取 babel.config.js
                                ├─ nativewind/babel → 注册 className 处理插件
                                └─ babel-preset-expo (jsxImportSource: 'nativewind')
                                     └─ NativeWind 读取 tailwind.config.js
                                          └─ content 扫描 → 按需生成样式表

HMR(Fast Refresh):改一行代码手机为什么能立刻刷新

传统 HMR(Webpack 的那种)是模块级替换:把改动的模块重新发给浏览器,浏览器重新 evaluate 这个模块。副作用是整个应用状态可能丢失。

Metro 的 Fast Refresh 更聪明:

  1. Metro 监听文件系统变化(watchFolders 配置)
  2. 发现文件改动 → 只对这一个文件重新走 Transformation 阶段
  3. 通过 WebSocket 把新模块代码推送给手机上的 Metro client
  4. Metro client 在 JS 线程里用新模块热替换旧模块,同时保留 React 组件树的状态

关键在第 4 步:Fast Refresh 利用 React 的 reconciler,能在不卸载组件的情况下替换组件的实现(函数体),useState 里的值不会丢失。这是 Web HMR 做不到的,因为 React DOM 的 reconciler 没有 Metro 这样的深度集成。

Hermes:专为 RN 设计的 JS 引擎

V8 是 Google 为 Chrome 和 Node.js 设计的 JS 引擎,JSC(JavaScriptCore)是 Apple 为 Safari 设计的。两者都针对长期运行的 Web 应用优化:JIT 编译、复杂的垃圾回收、REPL 环境。

RN 的场景不同:移动应用的冷启动时间极其敏感,用户不会等你 JIT 预热。

Hermes 的设计目标就是解决这个问题:

开发模式 vs 生产模式的关键差异

pnpm start 时,Hermes 运行的是纯 JS 文本(Metro 实时发过来的),不是字节码。字节码预编译只在 eas build 生产构建时发生。这意味着开发模式下你感受到的启动速度和用户实际感受的不是一回事——生产包会快很多。


横向对比

MetroWebpackViteesbuild
定位RN 专用打包 + Dev Server通用 Web 打包Web Dev Server + 构建极速 JS 打包(工具链底层)
HMRFast Refresh(状态保留)模块热替换(状态可能丢失)基于 ES Module HMR不内置 HMR
平台感知原生支持(.ios/.android)需要 plugin需要 plugin不支持
Tree-shaking不做
编译速度中等(Babel 是瓶颈)慢(大项目)快(esbuild 做预打包)极快(Go 实现)

Vite 快是因为开发模式下它利用浏览器原生 ES Module,不打包直接让浏览器按需请求文件。这个策略在 RN 里行不通——手机上的 JS 引擎没有原生 ES Module 加载机制,必须打成 bundle。


代码示例:从代码到样式的完整闭环

假设你在 src/features/schulte/SchulteScreen.tsx 写了:

<View className="bg-white p-4 rounded-xl">

发生的完整过程:

  1. tailwind.config.jscontent 扫描 ./features/**/*.{ts,tsx},发现 bg-white p-4 rounded-xl 被使用,生成对应样式规则并存入 NativeWind 的内部样式表
  2. Metro 处理 SchulteScreen.tsx 时,调用 Babel(读 babel.config.js
  3. jsxImportSource: 'nativewind' 把 JSX 转换为调用 NativeWind 的 jsx() 工厂函数
  4. 运行时,NativeWind 的 jsx() 查询内部样式表,把 className 转成 style={[{ backgroundColor: '#fff', padding: 16, borderRadius: 12 }]}
  5. RN Fabric Renderer(新架构)接收这个 style prop,通过 JSI 同步传给原生 layout 引擎

面试视角

Q: Metro 和 Webpack 的区别是什么?

不要只说”Metro 是给 RN 用的”,要讲到需求差异:RN 需要平台感知(.ios/.android 后缀处理是一等公民)、需要 WebSocket Dev Server 推送代码给手机、需要 Fast Refresh 保留组件状态、生产构建需要输出 Hermes 字节码。Webpack 是为浏览器设计的,这些需求都要额外 plugin 或根本支持不了。

Q: Hermes 是什么?为什么 RN 不用 V8?

Hermes 是 Meta 为 RN 专门设计的 JS 引擎,核心目标是解决移动应用冷启动慢的问题。V8/JSC 针对长期运行的 Web 应用优化,内置 JIT 编译,启动快需要预热时间。Hermes 选择 AOT 字节码预编译(在构建时把 JS 编译成 .hbc 字节码打进 App 包),牺牲了运行时的 JIT 优化换取更快的启动和更低的内存峰值。具体数字:冷启动快约 30%,运行时二进制约 1.5 MB vs V8 约 7 MB,但计算密集型任务的峰值性能低于 V8。

Q: Metro 的三阶段流水线是什么?

Resolution(依赖解析)→ Transformation(Babel 转换)→ Serialization(序列化输出)。Resolution 建立模块依赖图,Transformation 对每个文件调用 Babel(并行,有磁盘缓存),Serialization 把模块图拼成 bundle,用 __d() 包裹每个模块。增量构建的关键在 Resolution 阶段的依赖图——改一个文件只需要重新 transform 这个文件及其上游,不需要全量重建。

Q: NativeWind 的 className 在底层怎么工作?

分两层:编译期 + 运行时。编译期,Babel 的 jsxImportSource: 'nativewind' 配置把所有 JSX 的工厂函数替换为 NativeWind 自己的版本,同时 NativeWind 的 Metro transformer(withNativeWind 注入)在 Tailwind 扫描阶段生成样式表。运行时,NativeWind 的 JSX 工厂函数拦截 className prop,查询预生成的样式表,把 class 字符串转成 RN 的 style 对象再传给原生组件。RN 本身从头到尾都不知道 className 的存在——这完全是 NativeWind 在编译期和运行时两个层面的拦截。

Q: 为什么有时候要 pnpm start --reset-cache

Metro 对每个 transform 结果做磁盘缓存,缓存 key 是文件内容 + babel.config.js 内容的 hash 组合。正常情况下改了文件 Metro 会自动失效对应缓存。但有些情况下缓存 key 的计算不能覆盖所有变更(比如某个 Babel 插件的内部逻辑变了但包版本号没变,或者 Metro 的 FileStore 本身出现元数据不一致),这时候 --reset-cache 是最直接的修复手段。不要作为日常命令使用,它会让下次启动变慢(需要重新 transform 所有文件)。


版本说明

本页基于 MindGym M0 阶段(Expo SDK 54, RN 0.81.5, 2026-04-13)。

参见

参考