从 pnpm start 到屏幕出现 UI,中间经历了 Metro 三阶段流水线(Resolution → Transformation → Serialization)、Babel 插件链、以及 Hermes 字节码执行。理解这条链路,就理解了 metro.config.js、babel.config.js、tailwind.config.js 每个配置字段存在的理由。
概述:为什么 RN 不用 Webpack?
你敲下 pnpm start,几秒后手机屏幕上出现了 MindGym 的 UI。如果换成 Web 项目,你大概会说:“Webpack(或 Vite)把源码打成 bundle,浏览器加载并执行。“但 RN 没有浏览器,也不用 Webpack。
RN 的处理链复杂得多:
- 运行时不是浏览器——JS 跑在独立的 JS 引擎(Hermes)里,和原生 UI 层通信
- 文件解析需要平台感知——同一个 import,iOS 可能加载
Button.ios.tsx,Android 加载Button.android.tsx - 开发体验要求极高的 HMR——你改一行代码,手机要在 200ms 内反映,不能全量刷新
- 生产包需要预编译字节码——应用冷启动时不能现场 parse JS,要提前编译成 Hermes 字节码(.hbc)
这些需求加在一起,催生了 Metro。
历史背景:Metro 是怎么来的
Metro 是 Facebook 内部工具演化而来的。2013 年 RN 刚诞生时,Facebook 的工程师直接用了已有的 react-packager,一个非常简陋的开发服务器。随着 RN 的规模化,这个工具逐渐成为瓶颈。
2017 年,Facebook 将其重写并命名为 Metro(“Metro Bundler”),目标是:
- 增量构建:只重新处理改动的文件,不全量打包
- 并行化:多核利用,大项目启动快
- 平台感知:原生理解
.ios.tsx/.android.tsx后缀
Metro 从不试图替代 Webpack 或 Rollup,它只做 RN 需要的那一部分——开发服务器 + 打包,不做 tree-shaking、不做 code splitting,专注做好 RN 的事。
为什么不直接用 Webpack?
| 需求 | Webpack | Metro |
|---|---|---|
| 平台感知后缀(.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 需要决定这个字符串对应哪个物理文件。它按以下优先级查找:
tsconfig.json的paths配置(@/→src/)- 平台后缀:先找
Button.ios.tsx,没有再找Button.tsx package.json的main/react-native字段(用于 node_modules)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',
})
-
getDefaultConfig(__dirname):读取 Expo 的默认 Metro 配置。Expo SDK 54 的默认配置已经包含了 TypeScript 支持、SVG 支持、Hermes 集成等。__dirname是项目根目录路径,Metro 用它定位项目边界。 -
withNativeWind(config, { input: './global.css' }):这个函数做了两件事:- 往
config.transformer里注入 NativeWind 的自定义转换器,让 Babel Transformation 阶段能处理className - 告诉 NativeWind 去哪里找 Tailwind 的入口 CSS 文件(用来触发 Tailwind 的扫描和生成流程)
如果删掉这个
withNativeWind包装,NativeWind 的 Babel 插件虽然还在,但 Metro 的 transformer 不知道要调用它,className就会静默失效。 - 往
babel.config.js
// babel.config.js(完整代码)
module.exports = function (api) {
api.cache(true)
return {
presets: [
['babel-preset-expo', { jsxImportSource: 'nativewind' }],
'nativewind/babel',
],
}
}
-
api.cache(true):告诉 Babel 缓存这个配置文件的执行结果。如果用api.cache(false),每次 transform 都重新执行babel.config.js,严重拖慢启动速度。 -
preset 执行顺序是逆序的。
presets数组[A, B]的实际执行顺序是先 B 后 A。所以这里先执行nativewind/babel,再执行babel-preset-expo。nativewind/babel:注册 NativeWind 需要的 Babel 插件(处理classNameprop 的转换逻辑)babel-preset-expo:处理 TypeScript 类型剥离、JSX 转换、平台特定代码、模块别名(@/)等
-
jsxImportSource: 'nativewind':这是 NativeWind 工作的核心机制。正常情况下,Babel 把 JSX 编译成
React.createElement(View, props, children)。但className是 Web 的概念,RN 的View不认识它。加了
jsxImportSource: 'nativewind'之后,Babel 改为从nativewind包导入 JSX 工厂函数:// 编译前 <View className="bg-white p-4"> // 编译后(简化) import { jsx } from 'nativewind/jsx-runtime' jsx(View, { className: "bg-white p-4" })NativeWind 的
jsx函数会拦截classNameprop,查询预生成的样式表,把"bg-white p-4"转成{ backgroundColor: '#fff', padding: 16 },再传给真正的View。这就是className生效的全过程——不是 RN 原生支持 className,而是 Babel 在编译期替换了 JSX 工厂函数。
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: [],
}
-
content:Tailwind 是”按需生成”的——它不提前生成所有可能的样式类,而是扫描你的源文件,找到实际用到的 className,只生成那些样式。如果你新建了目录但没加进content,className 会静默失效——不报错,View 就是没有样式。 -
presets: [require('nativewind/preset')]:把 Tailwind 的 CSS 概念适配到 RN 的 StyleSheet 约束。例如text-blue-500在 Web 生成color: #3b82f6,在 RN 通过这个 preset 也能转成 RN 的color属性,而不是浏览器的 CSS 属性名。
完整配置调用链
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 更聪明:
- Metro 监听文件系统变化(
watchFolders配置) - 发现文件改动 → 只对这一个文件重新走 Transformation 阶段
- 通过 WebSocket 把新模块代码推送给手机上的 Metro client
- 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 的设计目标就是解决这个问题:
-
AOT 字节码预编译(.hbc 文件):在 EAS 构建服务器上,Metro 输出 bundle 后,Hermes 把这个 bundle 编译成 Hermes 字节码(.hbc)打进 APK/IPA。用户安装应用后,启动时直接执行字节码,跳过了 parse 和 compile 阶段,冷启动快约 30%。
-
没有 JIT:Hermes 只有解释器(Interpreter),不做运行时 JIT 优化。代价是计算密集型代码比 V8 慢,优点是启动快、内存用量更可预测(JIT 的代码缓存本身也消耗内存)。
-
更小的运行时体积:Hermes 的二进制体积约 1.5 MB,V8 嵌入版约 7 MB。
开发模式 vs 生产模式的关键差异:
pnpm start 时,Hermes 运行的是纯 JS 文本(Metro 实时发过来的),不是字节码。字节码预编译只在 eas build 生产构建时发生。这意味着开发模式下你感受到的启动速度和用户实际感受的不是一回事——生产包会快很多。
横向对比
| Metro | Webpack | Vite | esbuild | |
|---|---|---|---|---|
| 定位 | RN 专用打包 + Dev Server | 通用 Web 打包 | Web Dev Server + 构建 | 极速 JS 打包(工具链底层) |
| HMR | Fast 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">
发生的完整过程:
tailwind.config.js的content扫描./features/**/*.{ts,tsx},发现bg-white p-4 rounded-xl被使用,生成对应样式规则并存入 NativeWind 的内部样式表- Metro 处理
SchulteScreen.tsx时,调用 Babel(读babel.config.js) jsxImportSource: 'nativewind'把 JSX 转换为调用 NativeWind 的jsx()工厂函数- 运行时,NativeWind 的
jsx()查询内部样式表,把className转成style={[{ backgroundColor: '#fff', padding: 16, borderRadius: 12 }]} - RN Fabric Renderer(新架构)接收这个
styleprop,通过 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)。
参见
- AST
- Biome
- 2026-04-13-Expo vs 裸 RN
- 2026-04-13-React Native 新架构概览
- 2026-04-13-NativeWind 与 Tailwind 设计哲学
参考
- Metro 官方文档 — 重点看 Configuration 和 Concepts(Module Resolution 的详细规则)
- Hermes 引擎官网 — 重点看 “Hermes Bytecode” 一节,理解 .hbc 的格式和工具链
- React Native 官方文档:JavaScript Environment — 讲了 Hermes 和 JSC 的切换、调试工具兼容性、以及
__DEV__全局变量的工作原理 - NativeWind 架构文档 — v4 和 v3 的架构差异很大(v4 不再依赖 postcss CLI,改为 Metro transformer),值得仔细读