一周内从 React 网页版迁移到原生应用

一名 React Web 开发者用 Expo、Claude Code 和 Expo Skills 在一周内做出一个真实 iOS App 的完整历程。哪些经验能迁移,哪些不能。

中文
复制
App Logo and tools used with React

我是一个 React 开发者,每天写 Next.js、Tailwind、组件库和设计系统——这个世界对我来说再熟悉不过。原生开发则完全不是,多年来这道鸿沟显得无比巨大。

每次想到要做一个 iOS 应用,我脑海里浮现的都是 Xcode、Swift、配置文件,以及还没写到“Hello World”两周就已经没了。

直到我用一周时间做出了一个真正跑在我 iPhone 上的原生 iOS 应用——环境搭建、原生调试、真机测试、构建,端到端全部走通。

不是教程示例,不是计数器,也不是又一个待办清单。我做的是 Sun Buddy——一个原生 iOS 应用,有一只可爱的动画太阳吉祥物,基于我的位置实时追踪紫外线,触觉反馈,每日本地通知,持久化进度,以及太阳落山后进入睡眠的夜间状态。

Hero 图

我用 ExpoClaude CodeExpo Skills 把它做了出来。

环境一览: Expo SDK(最新版)、Claude Code(最新版)、通过 /plugin marketplace add expo/skills 然后 /plugin install expo 安装的 Expo Skills、最新版 macOS 和 Xcode,在 iOS 模拟器和一台真机 iPhone 上开发,最终通过 EAS 构建。

以下是我的端到端历程:哪些东西从 React 迁移了过来,哪些没有,Expo 在哪里让原生开发变得平易近人,以及平台现实又在哪里冒了出来。

简短版本:我不必先成为一个应用开发者才能做出这个应用。我需要学的,只是围绕我已有 React 技能的那些原生边缘。

想法:Sun Buddy

这个应用必须足够真实,才能真正测试原生开发。我不想做一个本质上只是伪装成应用的网页——我想触碰真正的设备能力:定位、触觉、通知、原生动画、本地存储,以及一个真正跑在我 iPhone 上的构建产物。

于是我选定了日照追踪。Sun Buddy 是一个日常习惯应用,帮你达成推荐的户外日照目标——根据你真实位置的紫外线指数来追踪。

想法很简单:你每天应该晒点太阳,而大多数人并没有。我想要一个叫 Sunny 的小吉祥物,根据你的进度做出情绪反应。

Sunny 有几种状态:

  • 电量 0% 时昏昏欲睡

  • 你刚开始时充满好奇

  • 完成一半时开心

  • 达成目标时光芒四射

  • 夜晚太阳落下后进入睡眠

有点怪,有点可爱。是那种我真会留在手机里的 app。

吉祥物阶段

Sun Buddy 刻意做得很小,而这正是重点。我想要一个范围收敛的 app,同时又能逼我走一遍真实的原生接口:定位权限、设备 API、触觉反馈、本地通知、持久化状态、动画、app 配置,以及一次真机构建。

这些模式并不专属于一个太阳吉祥物;你在健身 app、习惯打卡、外勤工具、旅行 app、内部仪表盘、配送流程,或任何需要在手机上显得自然的应用里,都会遇到同样的模式。

起步:先 Expo,后 Claude Code

这段经历最重要的部分不是我用了 AI,而是我能用自己已经掌握的 React 心智模型构建一个原生 app,而这正是 Expo 的关键所在。 Expo 给了我 app 结构、路由、设备 API、构建路径,以及从「我会 React」到「它跑在我手机上」的桥梁。

Claude Code 给了我结对循环,而 Expo Skills 让这个结对循环有用得多。

这个区别很重要。单靠 Claude Code 能帮你写代码,但原生开发有大量细节是通用建议覆盖不到的:权限、配置插件、EAS Build 配置文件、App Store 的限制、原生模块支持。这些恰恰是过时或含糊的指导最浪费时间的地方。

一个具体的例子: 早期,我刚加上 Reanimated,Sunny 的动画就崩了。通用的 AI 建议让我在版本不匹配和错误的 import 上折腾了半个小时。expo-dev-client Skill 立刻就知道要检查 Reanimated 的 Babel 插件是否已在 babel.config.js 中注册,以及 Metro 缓存是否已清除。两行配置,一个 --clear 标志,动画就正常了。这正是通用建议经常漏掉的那种原生专属上下文。

Expo Skills 把 Expo 相关的上下文带进了工作流。

最明显的差别出现在我问一些原生开发特有的问题时。通用的 AI 助手能告诉我怎么写一个组件,但那不是难点。真正难的是这类问题:

  • 这里该走普通的 Expo Go 流程,还是用 dev client

  • 这个权限需要在哪里配置?

  • 这是一个运行时库、一个 config plugin,还是两者都是?

  • 要真正构建出一个 iPhone 版本,正确的路径是什么?

  • 从模拟器测试转到 TestFlight,哪些东西会变?

Expo Skills 的价值就在这里——它为这个结对编程的循环提供了 Expo 专属的上下文,而不是靠对 React Native 的泛泛猜测。我在 Claude Code 里加上了这些 Skills:

/plugin marketplace add expo/skills
/plugin install expo

Expo Skills 是结构化的指令文件,可用于 Claude Code、Cursor、Codex 以及其他 agent。我用的是 expo plugin,它打包了三个仍在积极维护的 Expo Skills:

  • building-native-ui —— Expo Router 的模式、原生 UI 结构、Apple HIG 指南

  • expo-deployment —— EAS Build、TestFlight、App Store、凭据

  • expo-dev-client —— development build 与原生模块支持

这让 Claude Code 不再像一个通用的编码助手,更像一个懂 Expo 生态的协作者。

顺带一提: 我一开始用的是 Expo Go,几分钟内就让 React 外壳跑在了我的 iPhone 上。可当我给 Sunny 加上 Skia、给动画加上 Reanimated 之后,我就转到了 development build,因为这两个库都依赖 Expo Go 不包含的原生模块。Skills 让这次切换变得明确,而不是靠把应用搞崩了才发现。

意外之处:我的 React 技能几乎可以直接迁移

这是心态上最大的一次转变。组件代码就是 React——useStateuseEffect、自定义 hook、JSX、组件组合、条件渲染、获取数据、管理加载和错误状态。这些全都跟着我过来了。

基础组件变了:View 取代了 divText 取代了 pPressable 取代了 button。但心智模型一点也不陌生。我写 useUVIndex.tsuseSunTracking.ts 的方式,和给 Web 应用写 hook 时一模一样:取数据、管状态、把值返回给组件。布局模型也很熟悉——就是 flexbox。

这并不意味着 Web 上的一切都能完美迁移过来。但 React 的核心直觉,迁移过来的东西比我预想的多得多。

实际感受是这样的:

最后一行值得加个脚注:EAS 简化了流程,但 App Store 和 TestFlight 仍然涉及 Apple 特有的审核、代码签名、元数据和账号要求,这些在 Web 上没有真正的对应物。

这张表解释了为什么这件事感觉门槛不高。我并不是从零开始,而是在摸索一个新运行时的边界。

没能自动迁移的部分

真正让原生显得不同的,是外围那一层。不是组件模型,不是 React 那部分,而是原生的边缘地带。

比如:

  • 权限弹窗

  • 设备 API

  • 构建时配置

  • 原生模块

  • 模拟器状态

  • 开发构建

  • 代码签名

  • App Store 的限制

  • 在真机上测试

没有 Expo 和 Expo Skills,我就是在这些地方走弯路的。

举个例子,原生里的定位不是 navigator.geolocation。用 expo-location 时,我得先请求前台权限,检查结果,妥善处理被拒绝的情况,然后才能拿到设备位置。

逻辑的形状很熟悉,但平台的预期不一样——不是代码难写,而是你得知道正确的顺序。

const { status } = await Location.requestForegroundPermissionsAsync();

if (status !== "granted") {
  // Handle denial properly
  return;
}

const location = await Location.getCurrentPositionAsync({});

配置也是一样。在 Web 上,我几乎不会去想 app.json 这种东西。在 Expo 里,这个文件很重要。有些库不只是 JS 导入——它们还会修改原生工程配置。expo-location 需要一段 iOS 用途描述字符串,系统才知道权限弹窗里该显示什么。expo-notifications 会注册图标和通知渠道。expo-router 负责接上原生导航栈。

我的应用需要这样的 plugin 配置:

"plugins": [
  "expo-router",
  "expo-location",
  [
    "expo-notifications",
    {
      "icon": "./assets/notification-icon.png"
    }
  ]
]

这正是 Expo 专属上下文帮上忙的地方。没有它,我大概会写出一套完全合理的 React 代码,然后花时间调试那些跟我的组件毫无关系的原生行为。

我加上的原生能力

应用结构搭好之后,有意思的部分就是加一些真正有原生感的东西。

我用 expo-location 拿到手机的 GPS 坐标,再传给 Open-Meteo API 获取当前 UV 指数——不需要 API key,不需要后端,只要设备位置加一个公开天气 API。光这一点就让这个 app 和普通 web 项目不一样了。它不会让用户输入城市名,它本来就知道设备在哪。

然后我加了触觉反馈。这是最小的功能,带来的差别却最大。我用 expo-haptics 处理两个时刻:

  • 开始一次晒太阳时给轻触反馈

  • 达到 100% 时给成功反馈

代码很短:

await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);

还有:

await Haptics.notificationAsync(
  Haptics.NotificationFeedbackType.Success
);

但效果立竿见影。

点一下。弹一下。震一下。

它突然不再像一个假装成 app 的 React 页面,而像本来就属于手机的东西。

Web 开发者通常不会考虑触觉反馈,因为 web 上没有真正值得为之设计的对应物。在原生端,两行代码就能改变整个交互的手感。

我用 expo-notifications 做每日本地提醒,文案故意写得有点怪——在设备上调度,不是远程推送。(远程推送需要 APNs 凭证和付费的 Apple Developer Program,这个 app 两样都不需要。)

Sunny 在外面等你。大概吧。

这很符合这个吉祥物的性格。

通知

Sunny 用 @shopify/react-native-skia 构建,用 react-native-reanimated 做动画——弹跳、眨眼、情绪状态切换。如果你用过 Framer Motion,这套心智模型并不陌生,效果比我预期的顺滑得多。不是“demo 够用”的那种顺滑,是真的顺滑。

每日进度我用 @react-native-async-storage/async-storage 存,用起来就像异步版的 localStorage。字体用 expo-font 加载 Nunito,标签图标用 @expo/vector-icons。这些都没有让我觉得是在从零学移动开发,反而像是用 React 去够到 web 平时够不到的那部分设备能力。

实际出问题的地方

有些东西跑起来比我预想的快,也有些东西坏了。

Web 端的 Skia 崩溃

开发时我希望应用能同时在原生端和 Web 端运行。Sunny 的原生版本用的是 Skia,但在 Web 端,Skia 依赖 CanvasKit 和 WebAssembly。我在 CanvasKit 还没准备好时就调用了 Skia.Path.Make(),Web 构建立刻崩了。看到报错我就明白了这个 bug;我不知道的是 React Native 里惯用的修法。

修法是按平台拆分文件:

components/
  SunBuddy.native.tsx   # full Skia implementation
  SunBuddy.web.tsx      # SVG fallback

Metro 会自动为 iOS 解析 .native.tsx,为 Web 解析 .web.tsx

这个模式对我来说是新的。

这套约定值得练到形成肌肉记忆:不在运行时做分支判断,而是让 Metro 在打包时挑对文件,这样平台相关的代码永远不会被打进错误的目标。

在 Web 端,我大概会用一个运行时检查或者懒加载。而在 React Native 里,按平台拆文件是一等公民级别的约定。我的调试直觉能迁移过来,对原生模式的了解却不一定。Expo Skills 帮我补上了这块。

Metro 打包器缓存过期

加上 react-native-reanimated 之后,我打开模拟器,应用崩了。报错信息没什么帮助,我花了大约十五分钟,一直以为是自己哪里配置错了。真正的修法是清掉 Metro:

npx expo start --clear

旧的 bundle 还在被继续提供,Reanimated 的 Babel 插件并没有正确注册进去。

在 Expo 和 React Native 项目里,只要你加了一个带 Babel 插件的库、模拟器又开始表现异常,这就是那个标准的「先试这个」操作。值得记进肌肉记忆。

这不是我在 Web 开发里习惯遇到的问题。Vite 通常会自己重启、刷新,然后不挡路。原生开发在编辑器之外有更多状态。

我不喜欢这一点,但踩过一次、弄明白之后,它就变成了那种「行,现在我知道了」的时刻。

夜间 bug

这是我最喜欢的一个 bug,因为它其实不算技术问题。晚上 9 点,外面已经黑了,我在测 Sun Buddy,点了「start sun session」。Sunny 跳了起来。进度涨了。应用开开心心地记录着我根本晒不到的阳光。

有意思的地方在于,这个 app 完全按我设计的方式在运行——一个记录日照时长、累积进度的晒太阳追踪器。但我漏掉的产品逻辑,只有真正用起来才显得显而易见:一个晒太阳追踪器,大概应该先看看太阳在不在。这不是 AI 的失误,是我的产品思维问题。

工具执行的是你的意图,而你的职责是确保意图是对的。

于是我加上了基于 GPS 坐标的日出日落计算。到了晚上,Sunny 进入睡眠状态:半睁的眼睛、一个小小的“zzz”、耷拉下来的手臂,还有一个禁用的按钮,上面写着:

Sunny 正在睡觉。日出时见。

睡眠状态

就是这一个 bug 让 app 好了很多,也提醒了我:用 AI 做东西,并不能省掉真正去用自己做的这个东西。

代码签名

最“原生终究是原生”的时刻是代码签名。在 EAS 构建时,推送通知 capability 卡住了我免费 Apple Developer 账号的签名,因为远程推送是付费 Developer Program 的功能——这其实不算 Expo 的问题,只是 Apple 一贯如此。

我通过 Expo 配置去掉了推送通知 capability,而不是去 Xcode 里翻。Expo 原生的做法是在 app.json 里表达这个改动,让 Continuous Native Generation(CNG)在下次构建时重新生成原生工程——可以用 config plugin,也可以用 expo-build-properties——这样改动就留在源码里,而不是留在 Xcode UI 的状态里。本地通知仍然可用,因为它们不需要推送 capability,用户可见的行为也没有任何变化。

但我确实得弄明白到底发生了什么。

这是个有用的提醒:Expo 抹平了原生路径上的很多坑,但它抹不掉平台现实。Apple 依然有证书、capability、账号类型和规则。

Apple 的限制发生在几个不同的阶段,动手之前值得先搞清楚这级台阶:免费的 Apple ID 可以让你在自己的设备上跑构建(签名有效期七天)。TestFlight 和 App Store 分发需要付费的 Apple Developer Program。某些 capability——推送通知、应用内购买、Sign in with Apple——即使只是开发阶段也需要付费账号。

如果你要做 TestFlight 或 App Store 分发,就必须加入 Apple Developer Program,这一步绕不过去。

我首先推荐走 EAS

我试过在本地搭 iOS 环境,因为我想搞清楚底层到底发生了什么;但如果要我告诉另一个 React Web 开发者该怎么起步,我会让他早点去看 EAS

基本流程是:

eas build:configure
eas build --platform ios
eas submit --platform ios

具体用哪个 profile 取决于你在做什么。development 用于 dev client,preview 用于向小范围测试者做内部分发,production 面向 App Store / TestFlight。Profile 写在 eas.json 里,也在那里管理。

还有一点:eas submit 会把二进制上传到 App Store Connect,处理完成后在 TestFlight 里可见。它不会把应用发布到生产环境。你仍然需要准备 App Store Connect 的元数据、截图、隐私问卷、选择构建版本,并提交 App Review。

这样一来,你得到的是云端构建、凭据管理,以及一条通往 TestFlight 和 App Store 提交的更干净的路径——第一次正式构建时本地机器上的很多麻烦都没了。

Expo Skills 在这里也帮上了忙,因为部署方面的建议过时得很快。构建 profile、凭据、提交流程、内部分发、TestFlight——这些恰恰是最需要最新 Expo 专属上下文的地方。

我也试过本地环境:Xcode、CocoaPods、设备信任、签名。花了一个小时左右。不能说毫无阻力,但也不是我脑子里虚构出来的那种两周噩梦。

尝试的风险比看上去要小。

你不必一开始就成为 iOS 开发者、买齐所有工具、下定决心发布到 App Store。阻力最小的第一步是:npx create-expo-app,用 Expo Go 在 iOS Simulator 里打开,然后接上一个原生能力(定位或触觉反馈都是不错的选择)。这是一个一个晚上就能跑通的循环。等你用到 Expo Go 没有内置的库——Skia、Reanimated、自定义原生模块——再转向 development build。

如果真走到了那一步,EAS 会给你一条通往真实构建和分发的路。如果没有,你也只是花了一个周末,弄明白自己的 React 技能如何映射到另一个领域。

原生开发环境是真的麻烦,但这不是永远躲着原生走的理由。

那个瞬间

真正让这件事不再停留在理论层面的,是我在自己的 iPhone 上跑起 Sun Buddy 的那一刻。Sunny 弹了出来。Skia 进度环渲染出来了。UV 徽章显示的是从我的真实位置拉到的真实数值。我点了开始按钮,感受到了震动,又点了一下,接着又点了几下,因为这个循环手感太好了。

点击。动画。震动。进度。 不是浏览器标签页,不是响应式网页视图,不是假装成 App 的原型,而是我手机上真实存在的东西,用 React 写的。

电话

它就在我的主屏幕上。它知道我在哪。它对触摸的响应方式,是网页通常给不了的。

就是从那一刻起,原生不再像一条完全不同的职业道路,而开始像是我已有技能的另一块施展空间。

我会对另一个 React Web 开发者说的话

你的技能是可以迁移的。这是最关键的一点。组件模型、hooks、调试直觉——这些全都跟着你走。你不是从零开始。

但原生有它的棱角,你一定会撞上。权限流程。过期的 Metro bundle。config plugin。签名能力。大概率会有那么一刻,你心里想:这就是我不做原生应用的原因。 对我来说,这种时刻通常离解决只差一个 --clear 标志位或者一条配置项。

AI 那部分并没有让工作消失。我仍然得决定这个 App 该做什么。我仍然得注意到我的日照追踪器在晚上 9 点还在欢快地追踪阳光,这太荒唐了。产品思考是我的。判断也是我的。

如果你写 React,而且一直在躲着原生,别一上来就想着成为移动端专家。从一件网页给不了你的事情开始:定位、震动、相机、通知、手势。

围绕它做个小东西。在真机上跑起来。

你很快就能知道,它到底仍然像一个独立的世界,还是开始像你已有技能的另一块施展空间。

Sun Buddy 是用 ExpoClaude CodeExpo Skills 构建的。如果你是 React Web 开发者,正考虑转向原生开发,我会从这里入手:Expo for React web developers

来源: Expo Blog← 返回首页