1. 三端同构到底在解决什么实际问题
第一次听到“三端同构”这个词,很多人会下意识觉得又是一个造出来的概念。但如果你真的经历过一个产品同时要出移动端 App、H5 页面和小程序三个版本,就会明白这件事有多痛。同一个商品详情页,三个团队各写一遍,UI 长得不一样,交互逻辑有偏差,改一个价格展示规则要同步改三处,测试要测三轮,上线时间还得对齐。这种重复劳动消耗的不只是人力,更是团队的耐心和产品的迭代速度。
所谓三端同构,核心思路是让同一套业务逻辑代码在移动端原生环境、浏览器环境和小程序容器里都能跑起来。注意这里说的是业务逻辑,不是 UI。UI 层因为各端渲染机制差异太大,强行统一反而会带来更多麻烦。真正值得同构的是那些与渲染无关的部分:数据请求、状态管理、业务规则计算、数据格式化、缓存策略、埋点上报逻辑等等。
React Native 本身已经解决了 iOS 和 Android 两端同构的问题,一套 JSX 代码通过原生桥接渲染成各自平台的组件。但当我们把 H5 和小程序也纳入进来时,情况就复杂了。React Native 用的是 React 的组件模型和运行时,H5 也是 React,但小程序有自己的组件体系和生命周期。这时候 TypeScript 的价值就体现出来了——它能在编译阶段帮你发现那些跨端不兼容的类型问题,而不是等到运行时才报错。
我见过不少团队尝试同构,最后做成了一锅粥:为了兼容小程序,把移动端的代码改得面目全非;为了迁就 H5 的样式,在原生端加了一堆条件判断。问题的根源在于没有提前划清边界,什么该同构、什么不该同构,心里没数。所以这篇文章不会给你一个“万能同构方案”,而是从实际项目出发,讲清楚哪些层可以同构、怎么用 TypeScript 把同构的边界管住、以及踩过的那些坑。
适合读这篇内容的人:正在做或准备做多端产品的前端开发、对 React Native 和 TypeScript 都有一定了解、希望减少重复代码但不想把项目搞成意大利面的工程师。如果你只是做单一端,这些经验可能暂时用不上,但了解跨端设计的思路对技术选型也有帮助。
2. 同构的边界划分:哪些代码该共享,哪些必须隔离
2.1 业务逻辑层与渲染层的切割原则
同构失败的项目,十有八九是因为边界没划清楚。我的经验是画三条线:纯逻辑层、适配层、渲染层。纯逻辑层是那些不依赖任何平台 API 的代码,比如金额计算、日期格式化、数据校验规则、状态机的转换逻辑。这部分可以 100% 共享,而且应该用 TypeScript 写死类型,保证三端调用时参数和返回值完全一致。
适配层是最容易被忽视但最关键的一层。它定义了一组接口,比如存储读写、网络请求、设备信息获取、导航跳转。每个端各自实现这套接口,业务代码只依赖接口而不依赖具体实现。这样做的好处是,当你在业务逻辑里需要读取本地缓存时,调用的是StorageAdapter.get(),而不是直接调AsyncStorage或localStorage。TypeScript 的接口定义在这里起到了契约的作用,任何一端实现不符合约定,编译阶段就会报错。
渲染层就是各端自己的 UI 代码,这部分不强求同构。移动端用 React Native 组件,H5 用 div 和 span,小程序用 view 和 text。但可以通过设计系统(Design Token)来统一视觉规范,比如颜色、间距、字号这些用同一份配置文件,各端渲染时读取相同的值。
2.2 TypeScript 接口在三端契约中的角色
TypeScript 在同构项目里不只是“给 JS 加类型”这么简单,它实际上是三端之间的通信协议。举个例子,用户信息的数据结构在三端必须完全一致,否则移动端传了userId,H5 期望的是id,这种问题在运行时才暴露出来会非常难查。
我会在项目里建一个shared/types目录,把所有跨端共享的类型定义放在这里。比如:
// shared/types/user.ts export interface UserProfile { userId: string; nickname: string; avatarUrl: string; memberLevel: number; registeredAt: number; } export interface ApiResponse<T> { code: number; message: string; data: T; }然后各端的 API 请求函数都返回ApiResponse<UserProfile>类型。这样当后端接口字段发生变化时,只需要改这一处类型定义,三端编译时都会提示哪里需要同步修改。这比靠文档和口头沟通靠谱得多。
还有一个技巧是用 TypeScript 的declare module来声明平台特定模块的类型。比如存储适配器,可以定义一个通用接口,然后各端用模块声明来告诉 TypeScript 自己实现了这个接口。这样业务代码里 import 的时候,类型提示是完整的,但实际运行时会加载对应端的实现。
2.3 平台差异的抽象策略与常见误区
抽象平台差异时,最容易犯的错误是抽象过度。我见过一个项目把导航也做了同构抽象,定义了一套自己的路由接口,结果三端的导航行为差异太大,抽象层里塞满了 if-else,最后比各端各写还难维护。
正确的做法是只抽象那些行为语义一致、实现方式不同的能力。比如“存储一个字符串”,三端语义完全一样,只是 API 不同,这种就值得抽象。“弹出一个模态框”这种就各端各写,因为交互差异太大,强行统一只会做出一个三端都不好用的东西。
另一个误区是过早抽象。项目刚开始的时候,你觉得某个功能三端都要用,赶紧抽出来。结果做着做着发现 H5 的需求和移动端完全不一样,抽象层改来改去,最后变成四不像。我的建议是:先让两端各写一遍,等第三端也要用的时候再抽象。这时候你对需求的理解已经足够深,抽象出来的接口才稳定。
3. 用 TypeScript 搭建跨端共享层的实操路径
3.1 项目目录结构与构建配置
一个清晰的三端同构项目,目录结构大概长这样:
project-root/ ├── shared/ # 三端共享代码 │ ├── types/ # 类型定义 │ ├── logic/ # 纯业务逻辑 │ ├── adapters/ # 适配器接口定义 │ └── constants/ # 共享常量 ├── mobile/ # React Native 端 │ ├── src/ │ │ ├── adapters/ # 移动端适配器实现 │ │ ├── screens/ │ │ └── components/ │ └── package.json ├── web/ # H5 端 │ ├── src/ │ │ ├── adapters/ │ │ ├── pages/ │ │ └── components/ │ └── package.json ├── miniapp/ # 小程序端 │ ├── src/ │ │ ├── adapters/ │ │ ├── pages/ │ │ └── components/ │ └── package.json └── tsconfig.base.json # 共享 TS 配置shared目录不包含任何平台特定的 import,它只依赖 TypeScript 标准库和纯 JS 工具库。构建时,各端通过路径别名(path alias)来引用 shared 里的代码。比如在tsconfig.json里配置:
{ "compilerOptions": { "baseUrl": ".", "paths": { "@shared/*": ["../shared/*"] } } }这样在移动端代码里写import { formatPrice } from '@shared/logic/format',构建工具会自动解析到共享目录。需要注意的是,React Native 的 Metro 打包器对 symlink 和路径别名的支持需要额外配置,通常要在metro.config.js里加watchFolders和resolver.alias。
3.2 适配器模式的具体实现与类型约束
适配器模式是同构项目的核心。以存储为例,先定义接口:
// shared/adapters/storage.ts export interface StorageAdapter { getItem(key: string): Promise<string | null>; setItem(key: string, value: string): Promise<void>; removeItem(key: string): Promise<void>; }移动端实现:
// mobile/src/adapters/storage.ts import AsyncStorage from '@react-native-async-storage/async-storage'; import { StorageAdapter } from '@shared/adapters/storage'; export const storageAdapter: StorageAdapter = { getItem: (key) => AsyncStorage.getItem(key), setItem: (key, value) => AsyncStorage.setItem(key, value), removeItem: (key) => AsyncStorage.removeItem(key), };H5 实现:
// web/src/adapters/storage.ts import { StorageAdapter } from '@shared/adapters/storage'; export const storageAdapter: StorageAdapter = { getItem: async (key) => localStorage.getItem(key), setItem: async (key, value) => { localStorage.setItem(key, value); }, removeItem: async (key) => { localStorage.removeItem(key); }, };小程序实现类似,用wx.getStorage系列 API 包装一下。关键在于,业务逻辑层只 import 接口类型,不 import 具体实现。具体实现通过依赖注入的方式传入,或者在入口文件里做一次绑定。
TypeScript 在这里的作用是:如果某一端的实现漏了某个方法,或者参数类型不对,编译时立刻报错。我试过故意把 H5 的setItem第二个参数改成number类型,TS 马上提示不匹配接口定义。这种保障在多人协作时特别有价值。
3.3 共享逻辑的单元测试与跨端验证
共享逻辑层因为不依赖平台 API,单元测试非常好写。用 Jest 直接跑,不需要模拟任何原生模块。我通常会给shared/logic下的每个模块写测试,覆盖率要求 90% 以上。因为这些代码三端都在用,一旦有 bug 影响面是全局的。
跨端验证方面,有一个实用技巧:在 CI 流程里加一步,分别用三端的 TypeScript 配置去编译 shared 目录。虽然 shared 本身不依赖平台,但不同端的 tsconfig 可能对lib和target设置不同。比如移动端可能用 ES2017,小程序可能只支持 ES5。提前发现这些差异,比等到打包时才报错要好。
另外,共享逻辑里要避免使用平台特有的全局对象。比如window、document、wx、global这些,在 shared 目录里绝对不要出现。可以用 ESLint 规则来强制检查,配置no-restricted-globals把这些全局变量列为禁止使用。
4. 三端渲染层的差异化处理与样式方案
4.1 React Native 与 H5 的组件映射策略
渲染层虽然不强求同构,但可以通过一些策略减少重复。最常见的是建立一套“逻辑组件”和“渲染组件”的分离。逻辑组件负责状态管理和业务逻辑,渲染组件负责展示。逻辑组件可以共享,渲染组件各端各写。
比如一个商品卡片,逻辑组件ProductCardLogic负责接收商品数据、处理点击事件、格式化价格。移动端的ProductCard.native.tsx和 H5 的ProductCard.web.tsx分别用各自的组件渲染。React Native 的打包器支持.native.tsx后缀自动解析,Webpack 可以配置.web.tsx优先。
样式方面,React Native 用 StyleSheet,H5 可以用 CSS Modules 或 styled-components。我的做法是定义一套 Design Token:
// shared/constants/design-tokens.ts export const colors = { primary: '#1677ff', danger: '#ff4d4f', textPrimary: '#1f1f1f', textSecondary: '#8c8c8c', } as const; export const spacing = { xs: 4, sm: 8, md: 16, lg: 24, xl: 32, } as const;移动端在 StyleSheet 里引用这些值,H5 在 CSS 变量或 styled-components 里引用。这样视觉规范统一了,但实现方式各端自由。
4.2 小程序端的特殊约束与兼容技巧
小程序是最特殊的一端。它不支持 DOM,也不支持 React Native 的组件体系,有自己的 WXML 和 WXSS。如果硬要把 React 组件编译成小程序组件,通常需要借助 Taro 或 Remax 这类框架。但这类框架有自己的学习成本和坑,不是所有项目都适合。
我的建议是:如果团队规模不大,小程序端可以独立写渲染层,只共享逻辑层和适配器层。这样虽然 UI 代码要写两遍(移动端+H5 一遍,小程序一遍),但业务逻辑只写一遍,已经省了很多事。而且小程序的 UI 交互和 App 差异本来就大,强行统一反而别扭。
如果一定要做 UI 同构,Taro 是目前比较成熟的方案。它支持用 React 语法写小程序,同时能编译到 H5 和 React Native。但要注意 Taro 的版本兼容性,不同版本对 React Native 的支持程度不一样。我踩过的坑是 Taro 3.x 编译到 RN 时,某些 CSS 属性不支持,需要写平台条件编译。
4.3 样式隔离与主题切换的跨端实现
主题切换在三端都要支持的话,共享层可以定义一个主题配置对象,各端读取后应用到自己的样式系统。移动端可以用 Context 传递主题,H5 用 CSS 变量,小程序用全局样式类。
// shared/logic/theme.ts export interface Theme { mode: 'light' | 'dark'; colors: Record<string, string>; } export function createTheme(mode: 'light' | 'dark'): Theme { return { mode, colors: mode === 'light' ? lightColors : darkColors, }; }各端在应用启动时调用createTheme,然后把结果注入到自己的样式系统。这样主题逻辑只写一遍,各端只负责应用。
样式隔离方面,H5 要注意 CSS 命名冲突,用 CSS Modules 或 BEM 命名规范。React Native 天然隔离,不用太担心。小程序也有样式隔离机制,但组件间的样式继承需要留意。
5. 工程化配套:构建、调试与发布
5.1 Monorepo 下的依赖管理与版本同步
三端同构项目强烈建议用 Monorepo 管理。Yarn Workspaces 或 pnpm Workspaces 都可以。好处是 shared 目录作为一个 workspace 包,各端通过workspace:*协议引用,版本永远同步。
// mobile/package.json { "dependencies": { "@project/shared": "workspace:*" } }这样改 shared 里的代码,三端立刻生效,不需要发版。但要注意 React Native 的 Metro 打包器对 workspace 的支持,需要在metro.config.js里配置watchFolders指向项目根目录,否则改 shared 代码不会触发热更新。
另一个坑是依赖版本冲突。比如移动端用 React 18,H5 用 React 17,shared 里如果 import 了 React,就会出问题。解决办法是 shared 里尽量不依赖 React,纯逻辑用普通函数写。如果确实需要 React hooks,把 React 作为 peerDependency,各端自己提供。
5.2 跨端调试工具链的搭建
调试三端同构项目,最头疼的是问题定位。一个 bug 可能在移动端出现,H5 正常,小程序又不一样。我的做法是在 shared 层加统一的日志工具:
// shared/logic/logger.ts export interface Logger { info(message: string, meta?: Record<string, unknown>): void; error(message: string, error?: Error): void; } let logger: Logger = { info: console.log, error: console.error, }; export function setLogger(customLogger: Logger) { logger = customLogger; } export function logInfo(message: string, meta?: Record<string, unknown>) { logger.info(`[Shared] ${message}`, meta); }各端在启动时注入自己的 logger 实现。移动端可以写到文件或上报到监控平台,H5 打到控制台,小程序用wx.getRealtimeLogManager。这样共享层的日志在三端都能被收集到,排查问题时能快速判断是共享逻辑的问题还是平台实现的问题。
5.3 持续集成中的多端构建流水线
CI 流水线要同时构建三端,但不需要每次都全量构建。我的策略是按变更范围触发:如果只改了 mobile 目录,只构建移动端;如果改了 shared 目录,三端都构建。
用 GitHub Actions 或类似的 CI 工具,可以配置路径过滤:
- name: Build Mobile if: contains(github.event.commits.*.modified, 'mobile/') || contains(github.event.commits.*.modified, 'shared/') run: cd mobile && yarn build构建产物分别上传到各自的发布渠道。移动端走应用商店或内部分发,H5 走静态资源服务器,小程序走微信开发者工具的上传流程。关键是 shared 层的类型检查要在所有构建之前跑一遍,确保三端引用的共享代码类型一致。
6. 实际项目中踩过的坑与应对经验
6.1 类型定义在三端不一致导致的运行时错误
有一次遇到一个诡异的问题:H5 端用户列表页正常,移动端崩溃。查了半天发现是 shared 里定义的用户类型有个字段是可选avatarUrl?: string,H5 端做了空值处理,移动端直接用了user.avatarUrl.startsWith('http'),当 avatarUrl 为 undefined 时就崩了。
TypeScript 的可选属性在三端行为一致,但各端开发者对空值的处理习惯不同。后来我们在 shared 层加了一个normalizeUser函数,统一把可选字段填上默认值,各端拿到的数据保证字段完整。这个教训是:共享类型定义只是第一步,共享数据规范化逻辑同样重要。
6.2 平台 API 差异引发的隐蔽 bug
存储适配器看起来简单,但三端行为有细微差异。AsyncStorage在移动端是异步的,localStorage在 H5 是同步的(虽然我们包装成了 async),小程序的wx.setStorage也是异步的。问题出在时序上:H5 端因为 localStorage 实际是同步执行,连续调用 setItem 再 getItem 能立刻拿到值。移动端因为异步,如果代码里没有 await,就可能拿到旧值。
这种 bug 在 H5 测试时完全发现不了,一到移动端就出问题。解决办法是在 shared 层的存储工具函数里强制 await,并且加 ESLint 规则禁止不 await 直接调用。另外在单元测试里模拟异步延迟,确保逻辑对时序不敏感。
6.3 共享代码膨胀后的维护策略
项目跑了半年后,shared 目录从最初的几个文件膨胀到上百个。问题开始出现:改一个函数,不确定影响哪些端;新人进来,不知道哪些能改哪些不能改。
我们的应对策略是给 shared 目录分三级:core(核心,改动需评审)、utils(工具,可自由修改)、experimental(实验性,随时可能删)。每个目录有 README 说明使用规范。另外加了 CODEOWNERS 文件,core 目录的改动必须由指定人员 review 才能合并。这样既保持了共享层的灵活性,又避免了随意改动引发的连锁问题。
6.4 性能优化:减少跨端桥接开销
React Native 的 JS 和原生通信是有开销的,如果 shared 层的逻辑频繁调用原生 API,性能会受影响。比如一个列表滚动时每帧都调用存储适配器读取配置,这种就要优化。
我的做法是在 shared 层加缓存机制。配置类数据读一次缓存在内存里,后续直接读内存。缓存失效策略各端可以自定义,但缓存逻辑本身是共享的。这样既减少了桥接调用,又保证了逻辑一致。
另一个优化点是批量操作。比如埋点上报,不要每产生一个事件就调一次原生 API,而是在 shared 层攒一批,定时或达到阈值时批量发送。各端的发送实现不同,但攒批逻辑共享。
7. 同构方案的演进方向与个人体会
三端同构不是一劳永逸的事,随着业务发展和技术栈更新,方案也要调整。我目前观察到的几个趋势:一是 React Native 的新架构(Fabric 和 TurboModules)会让 JS 和原生的通信更高效,同构的性能瓶颈会进一步降低;二是小程序容器技术逐渐标准化,未来可能有一套更通用的跨端组件规范;三是 TypeScript 的类型系统越来越强,模板字面量类型、条件类型这些特性可以用在跨端契约定义上,让类型约束更精确。
但不管技术怎么变,同构的核心原则不会变:共享稳定的业务逻辑,隔离易变的平台实现。我见过太多项目为了追求“一套代码跑三端”的口号,把不该共享的东西硬共享,最后维护成本比各写各的还高。同构的目的是减少重复劳动,不是消灭重复代码。有些重复是合理的,强行消除反而会引入不必要的抽象复杂度。
如果让我给正在考虑三端同构的团队一个建议,那就是:先从一个小模块试点,比如用户登录逻辑或者数据格式化工具。跑通之后评估收益和成本,再决定要不要扩大范围。不要一上来就搞大而全的同构架构,那样风险太高,一旦方向错了掉头都难。
还有一个实际体会是,同构项目对团队的 TypeScript 水平要求比较高。如果团队成员对泛型、接口、类型收窄这些概念不熟,写出来的共享代码质量会很差,类型定义形同虚设。所以在推行同构之前,先把团队的 TS 能力提上来,比选什么框架、用什么工具都重要。