把网页版搬上手机,代码到底能复用多少
从网页到手机端的代码迁移
最近把 É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 流式。所以问题很具体:怎么最快做到手机端,同时最大化复用现有的代码。摆在面前的大概是三条路:
- Flutter。 跨平台,性能好,生态也成熟。但它跟我现有的 TypeScript 代码库是零复用,等于用 Dart 把所有东西重写一遍。这不是扩展,是另起炉灶。
- 原生(Swift + Kotlin)。 性能最好,平台集成也最好。但我一个人维护三套代码库,想想就头大。
- 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.ts、types/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 接口怎么设计、状态往哪个方向流。手机上有 CardPicker、RefImagePicker、StyleToggle、PhotoCountSlider,名字和职责都一样,渲染代码完全不同。
导航,大概 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 和样式层越接近从头重写
SSE 的坑
整个手机版开发里唯一卡了一整天的问题,就是 SSE。
ÉLAN 的生成流程是基于 SSE 的:服务端逐张发事件,started、photo_completed、photo_failed、completed。网页版用 fetch API 的 ReadableStream 去解析响应体,标准做法,哪儿都能跑。到了 React Native 上,fetch 返回的 Response,它的 body(也就是那个 ReadableStream)在 iOS 上直接是 null。不是报错,也不是哪里配置漏了,是运行时压根不支持流式响应体。这是 React Native 一个存在很多年的已知限制。
我试了三条路:
react-native-sse。 EventSource 的 polyfill 库。但我的 API 是 POST 加 JSON body,EventSource 规范只支持 GET,用它就得改后端协议。react-native-fetch-api。 支持流式的 fetch polyfill。依赖原生模块补丁,跟 Expo SDK 55 的新架构有兼容问题。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 事件
三步流程搬到手机上
网页版的三步流程——选张美照、选个灵感、光影创作——直接映射到手机的创作页,但每一步的交互方式都变了。
选照片。 网页是拖拽上传区;手机用 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-picker、expo-media-library、expo-sharing、expo-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.


