适配层里按平台拆组件好过按逻辑写 if-else——我们给 Taro 和 uni-app 各自抽了一层壳
我们在一个同时维护微信原生、Taro、uni-app 三套小程序代码的项目里吃过大亏:最早是按逻辑写 if-else 做适配,结果每个文件都像补丁堆起来的,改一处逻辑要同步改三个地方。后来彻底重构,按平台拆组件、各自抽壳,适配逻辑从业务代码里消失,开发和维护成本直接降了一个数量级。
这篇文章想把我们踩过的坑和最终落地的方案讲清楚。如果你也在维护多套小程序代码,或者正在纠结 Taro 和 uni-app 的适配层怎么设计,这套思路应该能帮你少走很多弯路。
按逻辑写 if-else 为什么一定会炸
先给结论:在小程序适配场景里,按逻辑写 if-else 本质上是把平台差异耦合进了业务逻辑,随着需求迭代,这种耦合会指数级膨胀,最终谁都改不动。
我们最早的做法很直观——哪里不一样就在哪里加判断。比如一个商品详情页,Taro 用 <Image> 组件,uni-app 用 <image>,原生用 <image> 但 mode 属性值不同。于是代码里到处是:
// 反例:逻辑 if-else 散落在业务代码里
if (platform === 'taro') {
return <Image src={url} mode="aspectFill" />
} else if (platform === 'uni-app') {
return <image src={url} mode="aspectFill" />
} else {
return <image src={url} mode="aspectFill" />
}
看着没什么问题对吧?问题在三个月后爆发了。商品详情页迭代了 7 个版本,每个版本都在不同平台上有细微差异:Taro 的分享弹窗要用 Taro.showShareMenu,uni-app 用 uni.showShareMenu,原生用 wx.showShareMenu,但参数结构还有差别;图片预览组件在三个平台上的长按识别二维码行为不一致,需要针对性地加 workaround;甚至同一个 API getSystemInfo 在三个平台的返回字段都有差异,Taro 返回 Taro.getSystemInfoSync(),uni-app 返回 uni.getSystemInfoSync(),原生返回 wx.getSystemInfoSync(),且 safeArea 的字段名在 Taro 3.6.2 和 uni-app 2.0 上还不一样。
当这些差异点超过 20 个的时候,每个业务文件都变成了 if-else 的海洋。更致命的是,修改一个通用逻辑——比如商品价格计算——需要确认三个分支都没漏改,测试也要在三套小程序上回归。有一次我们在 Taro 上改了价格格式化逻辑,忘了同步到 uni-app 分支,上线后两边价格显示不一致,用户投诉才发现。
问题根源在于:if-else 是按照「逻辑维度」拆分的,而平台差异天然是「物理维度」的东西。按逻辑写 if-else,等于让一个函数或组件同时承担「业务逻辑」和「平台适配」两个职责,这违反了单一职责原则。正确的做法是把平台适配提升到架构层,让业务代码只关心业务逻辑。
按平台拆组件的核心思路:壳层隔离
我们的解法很直接:为每个平台各抽一层壳,壳只负责封装平台差异,对外暴露完全一致的接口。业务代码不感知当前运行在哪个平台,只和壳打交道。
具体落地分两个层面:组件层和 API 层。
组件层的壳
以商品详情页的图片组件为例,我们定义了一个业务层组件 ProductImage,它在三个平台上的壳分别是:
src/components/ProductImage/
├── index.tsx # 业务逻辑,平台无关
├── taro.tsx # Taro 壳
├── uniapp.vue # uni-app 壳
└── native.wxml # 原生壳(配合同名 .js/.wxss)
业务层的 index.tsx 只写业务逻辑,比如图片点击事件、加载状态处理、长按菜单等。它不 import 任何平台特定的组件或 API,而是通过一个统一的接口调用壳层:
// index.tsx - 业务层,平台无关
import { ImageShell } from './shell'
export const ProductImage: React.FC<Props> = ({ src, onPress, onLongPress }) => {
const [loading, setLoading] = useState(true)
return (
<ImageShell
src={src}
mode="aspectFill"
loading={loading}
onLoad={() => setLoading(false)}
onClick={onPress}
onLongPress={onLongPress}
/>
)
}
关键在于 ImageShell 的导入路径。我们不是写 if (platform === 'taro') 来动态选择,而是利用构建工具在编译期决定引入哪个壳。具体做法是:
- Taro 项目:在
tsconfig.json里配 path alias,把./shell映射到./taro - uni-app 项目:用 uni-app 的
easycom或自定义vite插件做同样的映射 - 原生项目:直接用微信开发者工具的路径解析
这样每个平台编译出来的是不同的壳代码,但业务层的 index.tsx 完全一样——一个 if-else 都没有。
壳层的实现就很简单粗暴了,Taro 壳长这样:
// taro.tsx
import { Image } from '@tarojs/components'
import { ImageShellProps } from './types'
export const ImageShell: React.FC<ImageShellProps> = (props) => {
return <Image {...props} />
}
uni-app 壳(Vue 3):
<!-- uniapp.vue -->
<template>
<image :src="src" :mode="mode" @load="onLoad" @click="onClick" />
</template>
原生壳:
<!-- native.wxml -->
<image src="{{src}}" mode="{{mode}}" bindload="onLoad" bindtap="onClick" />
这个壳层极薄,几乎不做任何业务逻辑,唯一的职责就是把业务层定义的接口翻译成各平台的组件调用。即使未来某个平台的组件 API 发生变化,只需要改对应的壳文件,业务代码纹丝不动。
API 层的壳
组件层的壳解决了 UI 差异,但还有大量 API 层面的差异——分享、支付、定位、存储等等。我们同样抽了一层 API 壳,结构类似:
src/api/
├── share.ts # 业务层,定义统一的分享接口
├── share.taro.ts # Taro 实现
├── share.uniapp.ts # uni-app 实现
└── share.native.ts # 原生实现
业务层的 share.ts 只定义接口签名:
// share.ts
export interface ShareConfig {
title: string
path: string
imageUrl?: string
query?: Record<string, string>
}
export const showShareMenu: (config: ShareConfig) => Promise<void>
各平台的壳实现细节完全不同。Taro 3.6+ 的分享 API 是:
// share.taro.ts
import Taro from '@tarojs/taro'
export const showShareMenu = async (config: ShareConfig) => {
await Taro.showShareMenu({
withShareTicket: true,
menus: ['shareAppMessage', 'shareTimeline']
})
// Taro 需要在页面配置里设置 onShareAppMessage
const pages = Taro.getCurrentPages()
const currentPage = pages[pages.length - 1]
currentPage.onShareAppMessage = () => ({
title: config.title,
path: `${config.path}?${new URLSearchParams(config.query).toString()}`,
imageUrl: config.imageUrl
})
}
uni-app 的实现完全不同:
// share.uniapp.ts
export const showShareMenu = async (config: ShareConfig) => {
// uni-app 通过 uni.showShareMenu 和页面的 onShareAppMessage 钩子
// 具体实现与 Taro 有差异,比如参数名不同、时机不同
await uni.showShareMenu({
withShareTicket: true,
menus: ['shareAppMessage', 'shareTimeline']
})
// ...
}
原生实现又有细微差别——wx.showShareMenu 在基础库 2.11.3 之后才支持 menus 参数,需要做兼容。
如果不抽壳,这些差异会全部散落在业务代码里。抽壳之后,业务代码调用 showShareMenu({ title: '商品详情', path: '/pages/detail' }) 就完事了,完全不需要知道底下是 Taro 还是 uni-app 还是原生。
为什么抽壳优于其他方案
有人可能会问:用条件编译(#ifdef)不也能解决吗?或者写一个适配器类,在运行时动态判断平台?
这两种方案我们都试过,结论是:条件编译在跨平台框架内部有用,但跨框架(Taro vs uni-app vs 原生)时完全失效;运行时适配器能工作,但会把平台差异推迟到运行时暴露,而且增加包体积。
先说条件编译。uni-app 的 #ifdef MP-WEIXIN、#ifndef H5 这套机制在 uni-app 生态内确实好用,但前提是你只维护一套 uni-app 代码。我们的场景是同时维护 Taro、uni-app、原生三套代码,条件编译管不了跨框架的差异——Taro 代码里的 #ifdef uni-app 根本不认识,反之亦然。
再说运行时适配器。写一个 PlatformAdapter 类,在初始化时检测平台,然后调用不同的实现:
class PlatformAdapter {
private platform: 'taro' | 'uniapp' | 'native'
share(config: ShareConfig) {
if (this.platform === 'taro') {
// Taro 实现
} else if (this.platform === 'uniapp') {
// uni-app 实现
}
}
}
这本质上还是 if-else,只是把 if-else 从业务代码挪到了一个适配器类里。问题在于:
- 所有平台的代码都会打进最终的包里,Taro 项目里会包含 uni-app 和原生的 API 调用代码,虽然不会执行,但占用包体积;
- 运行时判断有性能开销,虽然很小,但每个 API 调用都要走一遍判断;
- 最关键的是,它不解决组件层的差异——你没法在运行时动态切换 React 组件和 Vue 组件。
抽壳方案利用编译期静态选择,每个平台只打包自己需要的壳代码,零运行时开销,而且组件层的差异也天然解决——Taro 编译出来的是 React 组件壳,uni-app 编译出来的是 Vue 组件壳,原生编译出来的是 wxml 壳,互不干扰。
实际落地中的几个关键细节
方案说起来简单,落地时有一些坑必须处理好,否则壳层会变成新的维护噩梦。
第一,接口定义要极度严格。 壳层的价值完全建立在「对外接口一致」这个前提上。如果 Taro 壳的 onClick 回调参数是 event,uni-app 壳的 onClick 回调参数是 { detail },那业务层还是得写判断。我们的做法是:所有壳的接口统一在 types.ts 里定义,写单元测试验证每个壳的实现都严格遵循接口契约。比如 ImageShellProps 的 onClick 统一定义为 () => void,壳内部自己处理参数的转换:
// taro.tsx
<Image onClick={(e) => props.onClick(e)} /> // Taro 的事件对象直接透传
// uniapp.vue
<image @click="handleClick" />
// handleClick 里做转换:props.onClick()
第二,壳层不能写业务逻辑。 这是铁律。一旦在壳里写了业务判断,很快就会退化成升级版的 if-else。我们有一个反面案例:最初在 Taro 壳的图片组件里加了「如果是 GIF 就用 Image 组件,否则用 Canvas 渲染」的逻辑,后来 uni-app 那边也要改,结果两边壳的实现开始分叉,最终不得不重构。正确的做法是:壳只做平台 API 的翻译,任何业务判断都提升到业务层,通过 props 传进来。
第三,原生小程序的壳层需要额外处理。 Taro 和 uni-app 的壳可以用 JSX/Vue SFC 写,但原生小程序的壳是 wxml/js/wxss 三件套,维护成本略高。我们的做法是尽量保持原生壳的模板极简——只写最基础的组件映射,样式用原子类或全局样式,不写复杂逻辑。原生壳的 js 文件也只做事件转发:
// native.js
Component({
properties: {
src: String,
mode: String,
loading: Boolean
},
methods: {
onLoad() {
this.triggerEvent('load')
},
onClick() {
this.triggerEvent('click')
}
}
})
第四,目录结构要能支撑多平台并行开发。 我们的最终结构是这样:
src/
├── components/
│ └── ProductImage/
│ ├── index.tsx # 业务逻辑(共享)
│ ├── types.ts # 接口定义
│ ├── taro.tsx # Taro 壳
│ ├── uniapp.vue # uni-app 壳
│ └── native/ # 原生壳(目录)
│ ├── index.wxml
│ ├── index.js
│ └── index.wxss
├── api/
│ ├── share.ts
│ ├── share.taro.ts
│ ├── share.uniapp.ts
│ └── share.native.ts
└── utils/
├── storage.ts
├── storage.taro.ts
├── storage.uniapp.ts
└── storage.native.ts
每个模块的业务文件(如 index.tsx、share.ts)不包含任何平台判断,壳文件以平台名作为后缀或目录。构建时通过 alias 或条件编译选择对应的壳。
这套结构在团队协作时有额外好处:负责 Taro 的同事只需要关注 *.taro.tsx 和 *.taro.ts 文件,负责 uni-app 的同事只关注 *.uniapp.vue 和 *.uniapp.ts,互不干扰。而业务逻辑的改动(index.tsx、share.ts)由所有人共同维护,改完之后两边自动生效——因为壳的接口是统一的。
这套方案的代价
不回避问题,抽壳方案也有代价,需要提前评估是否能接受。
代码文件数量翻倍。 原来一个组件 1 个文件,现在变成 1 个业务文件 + 3 个壳文件(或更多,如果有 H5、支付宝等)。对于小项目来说这可能是过度设计——如果总共就 10 个组件、5 个 API,写 if-else 完全够用。我们是在组件数量超过 50、API 调用超过 30 之后才下定决心重构的。
壳层的维护需要纪律。 前面提到壳不能写业务逻辑,但这需要 Code Review 持续把关。新同事容易图省事在壳里加判断,必须靠流程约束。
原生壳的调试体验差一些。 Taro 和 uni-app 的壳可以直接在 IDE 里打断点调试,但原生壳的 wxml/js 分离,事件流转需要借助微信开发者工具的调试面板追踪,效率略低。
接口变更时需要同步改多个壳文件。 比如 ProductImage 的 props 新增一个 lazyLoad 属性,Taro 壳、uni-app 壳、原生壳都要改。不过这个成本比改 if-else 低得多——因为每个壳的改动是机械的、无脑的翻译工作,不需要理解业务逻辑,而且单元测试能立刻发现漏改。
权衡下来,对于中大型的多平台项目,这个代价完全值得。我们重构之后,业务代码的行数减少了约 40%(主要是去掉了大量的 if-else),新增一个平台的适配成本从「每个文件都要改」变成了「只写壳文件」,bug 率也明显下降——因为平台差异被严格限制在壳层,不会污染业务逻辑。
常见问题
这套方案和 Taro 的条件编译、uni-app 的 #ifdef 有什么区别?
条件编译只能在同一个框架内区分平台(比如 uni-app 里区分微信和支付宝),但管不了跨框架的差异。我们的场景是同时维护 Taro、uni-app、原生三套代码,条件编译完全用不上。抽壳方案是编译期静态选择整个实现文件,可以跨越 React/Vue/原生三套技术栈。
如果只有两个平台(比如 Taro + 原生),需要这么搞吗?
看规模。如果组件和 API 数量少(比如不到 20 个组件、10 个 API),写 if-else 或简单适配器就够了。但一旦超过这个量级,或者预期未来会持续迭代,建议尽早抽壳——因为重构的成本会随着代码量增长而急剧上升,我们就是拖到了 50 个组件才重构,吃了大亏。
壳文件的粒度怎么控制?是按组件拆还是按模块拆?
按组件拆。每个需要适配的组件或 API 模块各抽一层壳,不要试图写一个「万能壳」把所有的平台差异都封装进去——那会变成一个巨大的 God Object,比 if-else 更难维护。壳的价值就在于「小且专一」,每个壳只做一件事。
多个壳文件里会有重复代码吗?
会有,但这是故意的。Taro 壳和 uni-app 壳里可能都有类似的参数转换逻辑,但它们是独立维护的,互不影响。如果试图提取公共逻辑反而会引入耦合——一旦某个平台的 API 更新,公共逻辑就得兼容两个平台,又回到了 if-else 的老路。重复在这里是合理的,因为它代表了「两个平台的两个独立实现」。