把网页版搬上手机,代码到底能复用多少
从网页到手机端的代码迁移
最近把 É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,它只给 API 发一个 museCardId 和一个 outputStyle,剩下全是服务器的事。整个 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 没法直接用,所以手机版同时发 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.


