ENZH
和 AI 讨论这篇文章
ChatGPTClaude

把网页版搬上手机,代码到底能复用多少

📊 幻灯片

从网页到手机端的代码迁移从网页到手机端的代码迁移

最近把 ÉLAN 从网页版搬到了手机上,Next.js 搬到 React Native (Expo)。这篇把过程记一下:路线是怎么选的,代码到底复用了多少,还有一个 SSE 的坑是怎么填的。起因很简单,系列上线之后有朋友问手机上能不能用——她说的不是拿手机浏览器打开网页,她要的是正经的 app 体验:点桌面图标,从相册选张照片,生成完直接分享到朋友圈,全程不用跳出去。网页版在手机浏览器里确实能打开,但这跟她要的不是一个东西。

为什么要做手机版

这个事我其实一直知道。第一篇里写过,ÉLAN 的目标用户是 18-35 岁、想要零成本美照的女性,她们的整个流程都在手机上:拍照在手机上,修图在手机上,发小红书发朋友圈也都在手机上。网页版做得再好,对她们来说也是绕路——先打开浏览器,找到网址,上传照片(iOS Safari 的上传体验大家应该都懂),生成完了长按保存,再切到微信去发。每多一步,用户就掉一批。所以手机版是肯定要做的,拖到现在才做纯粹是我自己的问题。

PWA 在 iOS 上基本是废的

先说为什么不走 PWA。PWA 对内容消费类的产品是够用的,但只要你的产品要碰相册、要调系统分享、要本地存储,它在 iOS 上就基本是废的。ÉLAN 的核心循环是四步:从相册选照片,生成结果,存回相册,分享到微信。这四步 PWA 每一步都比原生差:相册访问受限,存图只能长按保存,分享菜单直接不存在,推送通知勉强能用。不是完全不能用,是体验差到用户会直接放弃,或者说,差到你自己都不好意思推给用户。

三条路是怎么选的

当时手上已经有一个 Next.js 的网页版,Tailwind 做样式,Zustand 管状态,图片生成走 SSE 流式。所以问题很具体:怎么最快做到手机端,同时最大化复用现有的代码。摆在面前的大概是三条路:

  1. Flutter。 跨平台,性能好,生态也成熟。但它跟我现有的 TypeScript 代码库是零复用,等于用 Dart 把所有东西重写一遍。这不是扩展,是另起炉灶。
  2. 原生(Swift + Kotlin)。 性能最好,平台集成也最好。但我一个人维护三套代码库,想想就头大。
  3. React Native + Expo。 同一个语言(TypeScript),同一个状态管理库(Zustand),同样的组件思维。Expo SDK 55 已经很成熟了,文件系统、图片选择、相册访问、分享这些全是内置的。赌的就是业务逻辑能复用,只重写 UI 层。

选了第三条。

monorepo 评估过,没拆

项目结构上我评估过标准的 monorepo 拆法——packages/logic/packages/presets/packages/types/ 这种。最后放弃了,原因很简单:目前只有一个消费者,就是手机 app 调网页版的 API。为了一个消费者去维护跨包导入、tsconfig alias、workspace 配置,投入产出不成比例。

实际的结构就很直接:

ai-daipai/
  src/              ← Next.js 网页版
    stores/         ← Zustand 状态(Web)
    types/          ← TypeScript 类型(Web)
    lib/            ← 业务逻辑、灵感卡、预设
  mobile/           ← Expo 手机版(独立目录)
    src/
      app/          ← Expo Router 页面
      stores/       ← Zustand 状态(Mobile,适配版)
      lib/          ← API 客户端、类型、主题、图片缓存
      components/   ← React Native 组件
      hooks/        ← 自定义 hooks

手机版通过 API 跟网页版通信,不直接 import src/ 里的代码。类型定义和 store 的模式是手动复制过来的——不优雅,但实用。等真的有第二个移动端平台,或者要出 SDK 的时候,再拆也不迟。

到底复用了多少

这个数字我想认真讲一下。复用怎么算,差别其实很大——复制粘贴过来的类型定义算不算复用?所以我这边按层分开算,每一层单独给个数。

prompt 构造和灵感卡数据,100%。 因为这部分全在服务端。手机 app 根本不构造 prompt,它只发一个 museCardId 和一个 outputStyle 给 API,剩下全是服务器的事。整个 prompt 流水线、VANITY_DESIGN_INSTRUCTIONS、卡片定义,全在服务端,手机端等于白嫖。

API 请求/响应接口,大概 95%。 手机的 lib/types.ts 就是网页版 types/generation.tstypes/upload.ts 的镜像,接口、字段名、枚举值都一模一样。花了大概半小时仔细复制过来,顺手加了更严格的 readonly 标注。

Zustand store 的模式,大概 85%。 网页有 creation-store.ts,手机有 generation-store.ts,概念是一样的——步骤追踪、参考图管理、生成状态。但实现有差异:手机版的 store 内部集成了「上传完再生成」的流程,网页版是把上传委托给一个独立组件。状态形状相似,action 也相似,但代码没法直接复制粘贴。

UI 组件,大概 40%。 网页用 <div><button>className、CSS Grid、Tailwind 工具类;手机用 <View><Pressable>StyleSheet.create()、纯 Flexbox。一行 JSX 都共享不了。能复用的是结构:组件怎么拆、props 接口怎么设计、状态往哪个方向流。手机上有 CardPickerRefImagePickerStyleTogglePhotoCountSlider,名字和职责都一样,渲染代码完全不同。

导航,大概 30%。 网页用 Next.js 的路由,手机用 Expo Router 的文件路由加 Tab 布局。概念能对上——创作页、结果页、个人页——机制完全不同。

样式,0%。 网页是 Tailwind,手机是 StyleSheet.create() 加一套自定义主题系统。考虑过 NativeWind(React Native 版的 Tailwind),最终没用:手机端要精确控制触摸目标尺寸(44px 底线)、安全区域适配、平台差异的阴影,用显式的 StyleSheet 比工具类抽象更可控。

登录流程,0%。 网页用 httpOnly cookie,服务端设置。React Native 没法直接用 httpOnly cookie,所以手机版同时发 Cookie header 和 x-invite-code header,后端两个都检查。改动不大,但不是一眼能看出来的坑。

网页搬到手机时,代码复用率随所处的层递减:服务端逻辑几乎全复用,越往 UI 和样式层越接近从头重写网页搬到手机时,代码复用率随所处的层递减:服务端逻辑几乎全复用,越往 UI 和样式层越接近从头重写

SSE 的坑

整个手机版开发里唯一卡了一整天的问题,就是 SSE。

ÉLAN 的生成流程是基于 SSE 的:服务端逐张发事件,startedphoto_completedphoto_failedcompleted。网页版用 fetch API 的 ReadableStream 去解析响应体,标准做法,哪儿都能跑。到了 React Native 上,fetch 返回的 Response,它的 body(也就是那个 ReadableStream)在 iOS 上直接是 null。不是报错,也不是哪里配置漏了,是运行时压根不支持流式响应体。这是 React Native 一个存在很多年的已知限制。

我试了三条路:

  1. react-native-sse EventSource 的 polyfill 库。但我的 API 是 POST 加 JSON body,EventSource 规范只支持 GET,用它就得改后端协议。
  2. react-native-fetch-api 支持流式的 fetch polyfill。依赖原生模块补丁,跟 Expo SDK 55 的新架构有兼容问题。
  3. XMLHttpRequest 全世界最老的 API。它有个特性:onprogress 事件会随着数据到达增量触发,xhr.responseText 会一直累积完整响应。靠这个渐进式的文本累积,就能拿到流式的效果。

最后用的是 XHR,自己写了一个 SSE 解析器:

xhr.onprogress = () => {
  const newData = xhr.responseText.substring(lastIndex);
  lastIndex = xhr.responseText.length;
  const events = parser.feed(newData);
  for (const event of events) {
    onEvent(event);
  }
};

解析器的核心逻辑:按双换行符(SSE 协议的消息分隔符)切分,只处理完整的消息,不完整的留在缓冲区,等下一次 onprogress 进来再拼。这个方案挺老派的,但真的靠谱:不依赖原生模块,不需要 polyfill,iOS 和 Android 都能跑。超时设了 10 分钟,够生成 8-9 张高清照片。

React Native 上流式响应体为 null,改用 XHR 的 onprogress 事件让 responseText 不断累积,再按双换行切成 SSE 事件React Native 上流式响应体为 null,改用 XHR 的 onprogress 事件让 responseText 不断累积,再按双换行切成 SSE 事件

三步流程搬到手机上

网页版的三步流程——选张美照、选个灵感、光影创作——直接映射到手机的创作页,但每一步的交互方式都变了。

选照片。 网页是拖拽上传区;手机用 expo-image-picker 调相册,一行紧凑的预览显示正脸照和可选的身材照位。

选灵感卡。 网页是响应式网格加 hover 预览;手机是可滚动的卡片网格,点击选择,长按会从底部滑出一个预览弹窗,把桌面上 hover 能看到的信息——完整的场景描述、服饰、氛围、示例图——都放进去。

灵感匹配。 两个平台都有这个功能:上传一张照片(比如小红书截图),AI 分析风格,自动匹配最合适的灵感卡,给出匹配百分比。手机上匹配完会自动选中卡片,卡片选择器折叠成一行摘要,比网页还少一次点击。

结果页。 网页是照片网格加下载按钮;手机是双列网格加骨架屏 shimmer 动画(用 react-native-reanimated),生成中轮换显示等待文案——「好照片值得等一等」「光正在寻找最好的角度」「你的光,刚刚好」。生成完可以保存到相册、走系统分享菜单,或者点「换灵感」回去重新生成,参考照片还保留着。

图片持久化。 这是网页版没有的东西。手机上生成完成后,照片会自动缓存到设备本地的文件系统。服务器 2 小时后就把 blob URL 删了,本地副本还在。个人页有缓存管理,显示总大小,可以一键清除。

暗色模式

暗色模式我没刻意做。Expo SDK 55 的 userInterfaceStyle: "automatic",加上 React Navigation 的 ThemeProvider,基本就是白送的。

主题系统就是一张查找表:亮色模式是暖奶油底色加暖炭灰文字,暗色模式是深棕底色加浅奶油文字。香槟金的强调色两个模式都一样,品牌的锚点不能变。每个组件从 useTheme() 这个 hook 里读颜色,直接贴到 style 对象上,不用条件 className,也不用 CSS 变量。整个暗色模式大概花了 2 小时。反倒是网页版当初用 Tailwind 的 dark: 前缀做暗色模式,花的时间比这个还多。

剩下几个零碎的点

SSE 是最大的坑,上面写过了。剩下几个零碎的点也顺手记一下:

  • Expo 的原生 API 很省事。 expo-image-pickerexpo-media-libraryexpo-sharingexpo-file-system,装上就能用。不用 link 原生模块,不用调试 pod install,不用碰 Xcode 工程文件。从零做到「生成的 AI 照片存进相册、再分享到微信」,一个下午搞定。
  • Zustand 在 React Native 上不需要任何适配器。 API 完全一样。store 的形状不一样——手机有 local URI 和 blob URL 的区别,还要追踪上传阶段这些特有的问题——但 Zustand 的 create() 模式原封不动就能用。
  • EAS Build 也省心。 Expo Application Services 在云端帮你构建 iOS 和 Android:推代码,跑 eas build,出 .ipa.apk,全程没碰过 Xcode 和 Android Studio。第一次构建 15 分钟,后面更快。
  • 触摸目标挺烦的。 Apple HIG 要求可点击区域最小 44px,网页组件的 py-1.5 大概只有 28px 高。所以每个可交互元素都要调一遍:文案风格选择器、平台切换标签、分类过滤器。不难,就是烦,每个组件都得过一遍。
  • 安全区域也是。 iPhone 的 Home 指示条、状态栏、刘海,都会吃掉你的布局。每个页面都要套 SafeAreaView,或者手动用 useSafeAreaInsets() 加 padding。结果页底部的操作栏就要 paddingBottom: insets.bottom,不然按钮会被 Home 指示条挡住。都是小事,但忘一次就是一个 bug。

几个真实数字

这是造灵颜系列的技术日志,照例放一些真实数据:

  • 网页代码量:大概 12,000 行(Next.js + Tailwind + API 路由)
  • 手机代码量:大概 4,200 行(Expo + React Native)
  • 手机大概是网页的 35%
  • 手机 MVP 用时:2 周,包括 SSE 踩坑的那一天
  • 直接引用的复用:全部服务端逻辑(prompt、卡片数据、生成流水线、文案生成)
  • 模式层面的复用:Zustand store、API 契约、类型定义
  • 从头重写:UI 组件、导航、样式、登录流程

35% 这个比例说明的事情很直接:手机端本身很简单,所有复杂度都在服务端。手机 app 就是一个薄客户端,上传照片,选个卡,看结果。prompt 构造、Gemini API 调用、人脸漂移质检、成本追踪,全在服务端,手机端一行都不用写。

重来的话会怎么做

两件事我会换个做法。

第一,先做手机版。当时先做网页版是因为迭代快,但手机版才是更好的产品——相册集成、系统分享、自动缓存、原生体验,全在手机这边。今天要是从头开始,我会先做 Expo,再加一个网页的管理后台。

第二,第一天就把共享类型拆成包。src/types/mobile/src/lib/types.ts 之间的手动复制,在现在这个规模(6 个类型文件)还能忍,但每次 API 一改就要改两个地方。packages/types/ 这种 workspace 拆分,大概第二周就能回本。

反正这一趟从网页搬到手机走完,我最大的体会就是逻辑要尽量都放在服务端。新功能上线的时候,网页和手机是同时生效的,因为改的全是后端,前端根本不用动。


This post is also available in English.

和 AI 讨论这篇文章
ChatGPTClaude

订阅更新

新文章发布时发到你的邮箱,不发别的。


© Xingfan Xia 2024 - 2026 · CC BY-NC 4.0