Uniapp / Taro 跨端开发
跨端框架概述
跨端方案分类
跨端开发方案主要分为以下三类:
Web 容器方案
基于 WebView 渲染,通过 JSBridge 桥接原生能力。代表框架为 Cordova、PhoneGap。优势在于开发成本低、Web 技术栈复用度高;劣势是性能受限于 WebView,无法达到原生体验。
自渲染方案
通过自绘引擎绕过平台原生控件,直接渲染 UI。代表框架为 Flutter(Skia 引擎)、Qt。优势是渲染性能高、平台一致性好;劣势是生态相对独立,与 Web 技术栈有较大差异。
编译时 + 运行时方案
编写一套代码,编译时转换为各平台可运行的代码,运行时通过适配层抹平 API 差异。代表框架为 Uniapp、Taro。优势在于开发者可使用 Vue/React 等主流前端框架,学习成本低,生态丰富;劣势是平台差异需要条件编译处理,部分高级特性需平台原生插件。
主流框架选型对比
| 特性 | Uniapp | Taro | Flutter | React Native | Weex |
|---|---|---|---|---|---|
| 开发语言 | Vue / 小程序原生 | React / Vue | Dart | React | Vue |
| 渲染方式 | 编译时 + WebView(App) | 编译时 + 运行时 | 自渲染 Skia | 原生渲染 + JSI | 原生渲染 |
| 支持平台 | 微信/支付宝/百度/头条/QQ/H5/App | 微信/支付宝/百度/字节/H5/RN | iOS/Android/Web | iOS/Android | iOS/Android |
| 包体积 | 较小 | 中等 | 较大(含引擎) | 中等 | 中等 |
| 渲染性能 | 中(小程序)/ 可(App) | 中(小程序)/ 可(H5) | 优 | 良 | 中 |
| 开发效率 | 高(HBuilderX 生态) | 高(React 生态) | 中 | 中 | 低 |
| 社区活跃度 | 高(国内) | 高(国内) | 高(全球) | 高(全球) | 停滞 |
| 维护状态 | 活跃更新 | 活跃更新(v4) | 活跃更新 | 活跃更新 | 停止维护 |
Uniapp 框架
项目创建
使用 HBuilderX 创建
在 HBuilderX 中点击 文件 > 新建 > 项目,选择 uni-app 模板,填写项目名称和目录即可。
使用 CLI 创建
# 使用 vue-cli 创建(已支持 Vue 3 / Vite)
npx degit dcloudio/uni-preset/vue3 my-uni-app
# 进入项目目录
cd my-uni-app
# 安装依赖
npm install
# 运行到小程序
npm run dev:mp-weixin
# 运行到 H5
npm run dev:h5
# 运行到 App
npm run dev:app目录结构
my-uni-app/
├── pages/ # 页面目录
│ ├── index/
│ │ └── index.vue # 首页
│ └── user/
│ └── user.vue # 用户页
├── static/ # 静态资源(图片、字体等)
│ └── logo.png
├── components/ # 公共组件
│ └── nav-bar.vue
├── uni_modules/ # uni_modules 插件目录
│ └── uni-ui/
├── uni.scss # 全局样式变量
├── App.vue # 应用入口组件
├── main.js # Vue 入口
├── pages.json # 页面路由配置
├── manifest.json # 应用配置(各平台配置)
└── uniCloud-aliyun/ # uniCloud 云开发目录(可选)条件编译
Uniapp 通过条件编译处理各平台代码差异。以 #ifdef / #ifndef 为标记,支持平台:MP-WEIXIN、MP-ALIPAY、MP-BAIDU、MP-TOUTIAO、H5、APP-PLUS 等。
模板中条件编译
<!-- 仅微信小程序编译 -->
<!-- #ifdef MP-WEIXIN -->
<view class="weixin-only">微信小程序专属功能</view>
<!-- #endif -->
<!-- 非 H5 平台编译 -->
<!-- #ifndef H5 -->
<view class="native-only">原生平台渲染</view>
<!-- #endif -->
<!-- 多平台组合 -->
<!-- #ifdef MP-WEIXIN || H5 -->
<view class="common">微信和 H5 通用</view>
<!-- #endif -->样式条件编译
/* 微信小程序样式 */
/* #ifdef MP-WEIXIN */
.nav-bar {
height: 88rpx;
padding-top: var(--status-bar-height);
}
/* #endif */
/* H5 样式 */
/* #ifdef H5 */
.nav-bar {
height: 44px;
background: #fff;
}
/* #endif */Script 条件编译
// #ifdef H5
import { getLocation } from '@/api/h5/location'
// #endif
// #ifdef MP-WEIXIN
import { getLocation } from '@/api/weapp/location'
// #endif
export default {
methods: {
getCurrentLocation() {
// #ifdef H5
return getLocation()
// #endif
// #ifdef MP-WEIXIN
return new Promise((resolve, reject) => {
uni.getLocation({
success: resolve,
fail: reject
})
})
// #endif
}
}
}路由与页面管理
Uniapp 的路由通过 pages.json 集中配置:
{
"pages": [
{
"path": "pages/index/index",
"style": {
"navigationBarTitleText": "首页",
"navigationBarBackgroundColor": "#007aff",
"enablePullDownRefresh": true
}
},
{
"path": "pages/user/user",
"style": {
"navigationBarTitleText": "个人中心"
}
}
],
"globalStyle": {
"navigationBarTextStyle": "white",
"navigationBarTitleText": "跨端应用",
"navigationBarBackgroundColor": "#007aff",
"backgroundColor": "#f5f5f5"
},
"tabBar": {
"color": "#999",
"selectedColor": "#007aff",
"borderStyle": "black",
"list": [
{
"pagePath": "pages/index/index",
"iconPath": "static/tab/home.png",
"selectedIconPath": "static/tab/home-active.png",
"text": "首页"
},
{
"pagePath": "pages/user/user",
"iconPath": "static/tab/user.png",
"selectedIconPath": "static/tab/user-active.png",
"text": "我的"
}
]
}
}路由跳转使用 uni.navigateTo / uni.switchTab 等 API:
// 跳转页面(保留当前页面)
uni.navigateTo({
url: '/pages/detail/detail?id=1001'
})
// 跳转 tabBar 页面
uni.switchTab({
url: '/pages/user/user'
})
// 重定向(关闭当前页面)
uni.redirectTo({
url: '/pages/login/login'
})
// 获取页面栈
const pages = getCurrentPages()
console.log('当前页面栈深度:', pages.length)组件与 API
uni-ui 组件库
Uni-ui 是 DCloud 官方维护的组件库,基本覆盖常见 UI 需求:
<template>
<view class="container">
<!-- 轮播图 -->
<uni-swiper-dot :info="swiperList" :current="current">
<swiper :autoplay="true" :interval="3000" @change="onSwiperChange">
<swiper-item v-for="(item, index) in swiperList" :key="index">
<image :src="item.image" mode="aspectFill" />
</swiper-item>
</swiper>
</uni-swiper-dot>
<!-- 列表 -->
<uni-list>
<uni-list-item
v-for="item in list"
:key="item.id"
:title="item.title"
:note="item.note"
:show-arrow="true"
@click="onItemClick(item)"
/>
</uni-list>
<!-- 数据加载 -->
<uni-load-more :status="loadStatus" />
</view>
</template>uni- API 封装*
Uniapp 对平台 API 做了统一封装,调用方式在各平台保持一致:
// 数据缓存
uni.setStorageSync('token', 'xxx')
const token = uni.getStorageSync('token')
// 数据缓存(异步)
uni.setStorage({
key: 'userInfo',
data: { name: '张三' },
success() { console.log('写入成功') }
})
// 网络请求
uni.request({
url: 'https://api.example.com/v1/list',
method: 'GET',
data: { page: 1, size: 20 },
header: { 'Authorization': 'Bearer ' + token },
success(res) {
console.log('响应数据:', res.data)
},
fail(err) {
console.error('请求失败:', err)
}
})
// 交互反馈
uni.showToast({
title: '操作成功',
icon: 'success',
duration: 2000
})
uni.showLoading({
title: '加载中...'
})
// 选择图片
uni.chooseImage({
count: 9,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success(res) {
console.log('选中图片:', res.tempFilePaths)
}
})uniCloud 云开发
UniCloud 提供 Serverless 云开发能力,无需自建后端即可完成数据存储、云函数调用等功能:
// 云函数调用
uniCloud.callFunction({
name: 'getUserInfo',
data: { userId: '123' },
success(res) {
console.log('云函数返回:', res.result)
}
})
// 数据库操作
const db = uniCloud.database()
db.collection('articles')
.where({ status: 'published' })
.orderBy('createTime', 'desc')
.limit(20)
.get()
.then(res => {
console.log('文章列表:', res.data)
})Uniapp 平台差异化处理
各平台适配差异
| 平台 | 文件后缀 | 登录体系 | 支付体系 | 特殊限制 |
|---|---|---|---|---|
| 微信小程序 | .vue | wx.login | wx.requestPayment | 包体积 2MB,tab 最多 5 个 |
| 支付宝小程序 | .vue | my.getAuthCode | my.tradePay | 需要配合支付宝开放平台 |
| 百度小程序 | .vue | swan.login | swan.requestPolymerPayment | 包体积 4MB |
| 字节小程序 | .vue | tt.login | tt.requestPayment | 包体积 8MB |
| H5 | .vue | OAuth2 授权 | 微信 JS-SDK | 无包体积限制 |
| App (iOS/Android) | .vue | 各 SDK 独立 | 微信/支付宝 SDK | 需原生插件支持 |
支付接口统一封装示例
// services/payment.js
class PaymentService {
/**
* 统一下单
* @param {Object} params - { orderId, amount, subject }
*/
async createOrder(params) {
// 调用后端下单接口
const res = await uni.request({
url: 'https://api.example.com/v1/payment/create',
method: 'POST',
data: params
})
return res.data // 返回各平台支付所需参数
}
/**
* 发起支付
* @param {Object} paymentParams - 后端返回的支付参数
*/
async pay(paymentParams) {
// #ifdef MP-WEIXIN
return this._wechatPay(paymentParams)
// #endif
// #ifdef MP-ALIPAY
return this._alipayPay(paymentParams)
// #endif
// #ifdef H5
return this._h5Pay(paymentParams)
// #endif
// #ifdef APP-PLUS
return this._appPay(paymentParams)
// #endif
}
async _wechatPay(params) {
return new Promise((resolve, reject) => {
uni.requestPayment({
provider: 'wxpay',
timeStamp: params.timeStamp,
nonceStr: params.nonceStr,
package: params.package,
signType: params.signType,
paySign: params.paySign,
success: resolve,
fail: reject
})
})
}
async _alipayPay(params) {
return new Promise((resolve, reject) => {
uni.requestPayment({
provider: 'alipay',
orderInfo: params.orderInfo,
success: resolve,
fail: reject
})
})
}
async _h5Pay(params) {
// H5 端使用微信 JS-SDK
return new Promise((resolve, reject) => {
window.wx.chooseWXPay({
timestamp: params.timeStamp,
nonceStr: params.nonceStr,
package: params.package,
signType: params.signType,
paySign: params.paySign,
success: resolve,
fail: reject
})
})
}
async _appPay(params) {
// App 端使用 uni-app 内置支付
return new Promise((resolve, reject) => {
uni.requestPayment({
provider: params.provider, // 'wxpay' | 'alipay'
orderInfo: params.orderInfo,
success: resolve,
fail: reject
})
})
}
}
export const paymentService = new PaymentService()登录接口统一封装
// services/auth.js
class AuthService {
async login() {
// #ifdef MP-WEIXIN
return this._wechatLogin()
// #endif
// #ifdef MP-ALIPAY
return this._alipayLogin()
// #endif
// #ifdef H5
return this._h5Login()
// #endif
// #ifdef APP-PLUS
return this._appLogin()
// #endif
}
async _wechatLogin() {
const { code } = await uni.login({ provider: 'weixin' })
// 将 code 发送给后端换取 session + token
const res = await uni.request({
url: 'https://api.example.com/v1/auth/wechat',
method: 'POST',
data: { code }
})
return res.data
}
async _alipayLogin() {
const { authCode } = await uni.login({ provider: 'alipay' })
const res = await uni.request({
url: 'https://api.example.com/v1/auth/alipay',
method: 'POST',
data: { authCode }
})
return res.data
}
async _appLogin() {
// App 端支持多种登录方式
uni.login({
provider: 'weixin',
success: async (res) => {
const result = await uni.request({
url: 'https://api.example.com/v1/auth/app',
data: { code: res.code }
})
return result.data
}
})
}
}
export const authService = new AuthService()原生插件市场使用
Uni-app 提供原生插件市场(ext.dcloud.net.cn),用于扩展 App 端原生能力。
安装后在 manifest.json 中配置:
{
"app-plus": {
"modules": {
"Push": {},
"OAuth": {},
"Payment": {}
},
"distribute": {
"plugins": [
{
"id": "XXX-XXX-XXX",
"name": "custom-plugin",
"version": "1.0.0"
}
]
}
}
}通过 uni.requireNativePlugin 调用:
const plugin = uni.requireNativePlugin('PluginName')
plugin.methodName({
param1: 'value1',
param2: 'value2'
}, (result) => {
console.log('原生插件返回:', result)
})Taro 框架
项目创建
Taro v4 推荐使用 CLI 工具创建项目:
# 全局安装 Taro CLI
npm install -g @tarojs/cli
# 创建项目
taro init my-taro-app
# 进入交互式选择:
# ? 项目名称 my-taro-app
# ? 选择框架 React / Vue3
# ? 选择模板 默认模板
# ? 使用 TypeScript 是/否
# 安装依赖
cd my-taro-app
npm install
# 运行到微信小程序
npm run dev:weapp
# 运行到 H5
npm run dev:h5
# 运行到支付宝小程序
npm run dev:alipay
# 构建生产版本
npm run build:weapp目录结构
my-taro-app/
├── src/
│ ├── pages/
│ │ ├── index/
│ │ │ ├── index.tsx # 页面组件
│ │ │ └── index.scss # 页面样式
│ │ └── user/
│ │ ├── user.tsx
│ │ └── user.scss
│ ├── components/ # 公共组件
│ │ └── nav-bar/
│ │ ├── nav-bar.tsx
│ │ └── nav-bar.scss
│ ├── stores/ # 状态管理
│ │ └── counter.ts
│ ├── services/ # API 请求
│ │ └── api.ts
│ ├── utils/ # 工具函数
│ │ └── index.ts
│ ├── app.config.ts # 全局应用配置
│ ├── app.tsx # 应用入口
│ ├── app.scss # 全局样式
│ └── index.html # H5 入口模板
├── config/
│ ├── index.js # 公共编译配置
│ ├── dev.js # 开发环境配置
│ └── prod.js # 生产环境配置
├── package.json
└── tsconfig.jsonapp.config.ts 配置
// src/app.config.ts
export default defineAppConfig({
pages: [
'pages/index/index',
'pages/user/user',
'pages/detail/detail'
],
window: {
navigationBarTitleText: 'Taro 跨端应用',
navigationBarBackgroundColor: '#007aff',
navigationBarTextStyle: 'white',
backgroundColor: '#f5f5f5'
},
tabBar: {
color: '#999',
selectedColor: '#007aff',
list: [
{
pagePath: 'pages/index/index',
iconPath: 'static/tab/home.png',
selectedIconPath: 'static/tab/home-active.png',
text: '首页'
},
{
pagePath: 'pages/user/user',
iconPath: 'static/tab/user.png',
selectedIconPath: 'static/tab/user-active.png',
text: '我的'
}
]
}
})React / Vue 双 DSL 支持
Taro 同时支持 React 和 Vue3 两种 DSL。
React 示例(推荐)
// src/pages/index/index.tsx
import { FC, useEffect, useState } from 'react'
import { View, Text, Image, Button } from '@tarojs/components'
import { useLoad, usePullDownRefresh } from '@tarojs/taro'
import './index.scss'
const IndexPage: FC = () => {
const [list, setList] = useState<any[]>([])
// 页面加载钩子
useLoad(() => {
console.log('页面加载')
fetchList()
})
// 下拉刷新
usePullDownRefresh(() => {
fetchList().then(() => {
Taro.stopPullDownRefresh()
})
})
const fetchList = async () => {
const res = await Taro.request({
url: 'https://api.example.com/v1/list',
method: 'GET'
})
setList(res.data)
}
return (
<View className='index'>
<View className='header'>
<Text className='title'>跨端应用首页</Text>
<Image
className='logo'
src='https://example.com/logo.png'
mode='aspectFit'
/>
</View>
<View className='list'>
{list.map(item => (
<View
key={item.id}
className='list-item'
onClick={() => {
Taro.navigateTo({
url: `/pages/detail/detail?id=${item.id}`
})
}}
>
<Text className='item-title'>{item.title}</Text>
<Text className='item-desc'>{item.description}</Text>
</View>
))}
</View>
<Button
className='submit-btn'
type='primary'
onClick={() => {
Taro.showToast({ title: '提交成功', icon: 'success' })
}}
>
提交
</Button>
</View>
)
}
export default IndexPageVue3 示例
<!-- src/pages/index/index.vue -->
<template>
<view class="index">
<view class="header">
<text class="title">跨端应用首页</text>
</view>
<view class="list">
<view
v-for="item in list"
:key="item.id"
class="list-item"
@click="goDetail(item.id)"
>
<text class="item-title">{{ item.title }}</text>
<text class="item-desc">{{ item.description }}</text>
</view>
</view>
<button class="submit-btn" type="primary" @click="onSubmit">
提交
</button>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import Taro from '@tarojs/taro'
const list = ref<any[]>([])
Taro.useLoad(() => {
fetchList()
})
const fetchList = async () => {
const res = await Taro.request({
url: 'https://api.example.com/v1/list'
})
list.value = res.data
}
const goDetail = (id: number) => {
Taro.navigateTo({
url: `/pages/detail/detail?id=${id}`
})
}
const onSubmit = () => {
Taro.showToast({ title: '提交成功', icon: 'success' })
}
</script>编译配置(config/index.js)
// config/index.js
const path = require('path')
module.exports = {
projectName: 'my-taro-app',
date: '2025-01-01',
designWidth: 375,
deviceRatio: {
375: 2,
640: 2.34 / 2,
750: 1,
828: 1.81 / 2
},
sourceRoot: 'src',
outputRoot: 'dist',
plugins: [],
defineConstants: {},
alias: {
'@': path.resolve(__dirname, '..', 'src')
},
copy: {
patterns: [],
options: {}
},
// 各平台独立配置
mini: {
// 小程序端配置
postcss: {
pxtransform: {
enable: true,
config: {}
},
url: {
enable: true,
config: {
limit: 1024
}
}
},
// 小程序分包配置
webpackChain(chain) {
chain.merge({
optimization: {
splitChunks: {
cacheGroups: {
vendor: {
name: 'vendor',
test: /[\\/]node_modules[\\/]/,
priority: 10
}
}
}
}
})
}
},
h5: {
// H5 端配置
publicPath: '/',
staticDirectory: 'static',
router: {
mode: 'browser' // history 模式
},
devServer: {
port: 3000,
proxy: {
'/api': {
target: 'https://api.example.com',
changeOrigin: true
}
}
}
}
}多平台差异化配置可以在 config/dev.js 和 config/prod.js 中按环境覆盖:
// config/dev.js
module.exports = function(merge) {
const config = {
mini: {
// 开发环境小程序配置
},
h5: {
devServer: {
port: 3000
}
}
}
return merge({}, config)
}Taro UI 组件库
Taro UI 是 Taro 官方组件库,支持小程序和 H5:
import { FC, useState } from 'react'
import { View } from '@tarojs/components'
import {
AtButton,
AtList,
AtListItem,
AtForm,
AtInput,
AtToast
} from 'taro-ui'
const FormPage: FC = () => {
const [name, setName] = useState('')
const [phone, setPhone] = useState('')
const [showToast, setShowToast] = useState(false)
const handleSubmit = () => {
setShowToast(true)
}
return (
<View>
<AtForm onSubmit={handleSubmit}>
<AtInput
name='name'
title='姓名'
type='text'
placeholder='请输入姓名'
value={name}
onChange={(v) => setName(v)}
/>
<AtInput
name='phone'
title='手机号'
type='phone'
placeholder='请输入手机号'
value={phone}
onChange={(v) => setPhone(v)}
/>
<AtButton formType='submit' type='primary'>
提交
</AtButton>
</AtForm>
<AtToast
isOpened={showToast}
text='提交成功'
icon='check'
onClose={() => setShowToast(false)}
/>
</View>
)
}Taro 与 React 生态
Taro React 模式下完整支持 React Hooks 生态,可与社区的 Redux、Zustand 等状态管理库协同使用。
// 使用 React Hooks
import { FC, useState, useEffect, useCallback, useMemo } from 'react'
import Taro, { useLoad, useDidShow, usePullDownRefresh } from '@tarojs/taro'
const DemoPage: FC = () => {
const [count, setCount] = useState(0)
// 生命周期钩子
useLoad(() => {
console.log('页面加载')
})
useDidShow(() => {
console.log('页面显示')
})
// 计算属性
const doubleCount = useMemo(() => count * 2, [count])
// 事件回调
const increment = useCallback(() => {
setCount(prev => prev + 1)
}, [])
return (
<View>
<View>计数: {count}</View>
<View>双倍: {doubleCount}</View>
<View onClick={increment}>增加</View>
</View>
)
}跨端开发注意事项
样式兼容性
Flexbox 对齐差异
各平台对 Flexbox 的支持存在细微差异:
/* 安全写法 */
.container {
display: flex;
flex-direction: row;
justify-content: center; /* 微信/支付宝/头条/H5 均支持 */
align-items: center;
flex-wrap: wrap;
}
/* 避免使用的属性(某些端不支持) */
/* 微信小程序不支持 flex: 1 的缩写,需拆开 */
.item {
flex-grow: 1;
flex-shrink: 0;
flex-basis: 0%;
}
/* 避免使用 gap,各端支持不一致 */
/* 使用 margin 替代 */
.list {
display: flex;
flex-wrap: wrap;
}
.item {
margin-right: 10px;
margin-bottom: 10px;
}边框问题
/* 使用伪元素实现 1px 边框,各端兼容性更好 */
.border-bottom {
position: relative;
}
.border-bottom::after {
content: '';
position: absolute;
left: 0;
bottom: 0;
width: 100%;
height: 1px;
background: #e5e5e5;
/* 小程序端 retina 屏 1px 处理 */
/* #ifdef MP-WEIXIN */
transform: scaleY(0.5);
/* #endif */
}安全区域适配
/* 安全区域 - 适配 iPhone 刘海屏 / 底部白条 */
.safe-area-top {
/* #ifdef MP-WEIXIN || APP-PLUS */
padding-top: var(--status-bar-height);
/* #endif */
}
.safe-area-bottom {
/* #ifdef MP-WEIXIN || APP-PLUS */
padding-bottom: constant(safe-area-inset-bottom);
padding-bottom: env(safe-area-inset-bottom);
/* #endif */
}
/* 全屏页面安全区域 */
.full-page {
/* #ifdef MP-WEIXIN */
min-height: 100vh;
min-height: calc(100vh - var(--window-top) - var(--window-bottom));
/* #endif */
/* #ifdef H5 */
min-height: 100vh;
/* #endif */
}API 可用性
| API | 微信小程序 | H5 | App | 注意事项 |
|---|---|---|---|---|
uni.request | 支持 | 支持 | 支持 | 基础网络请求 |
uni.uploadFile | 支持 | 支持 | 支持 | App 端需配置权限 |
uni.downloadFile | 支持 | 支持 | 支持 | 文件存储路径各端不统一 |
uni.getLocation | 支持 | 需 HTTPS | 支持 | H5 需用户授权 |
uni.chooseImage | 支持 | 支持 | 支持 | 各端返回数据格式一致 |
uni.previewImage | 支持 | 支持 | 支持 | 图片预览 |
uni.getUserProfile | 支持(需授权) | 不支持 | 不支持 | 小程序专用 |
uni.getSystemInfo | 支持 | 支持 | 支持 | 获取设备信息 |
uni.makePhoneCall | 支持 | 不支持 | 支持 | H5 不支持 |
uni.setClipboardData | 支持 | 支持 | 支持 | 剪贴板操作 |
uni.startPullDownRefresh | 支持 | 支持 | 支持 | 下拉刷新 |
uni.createAnimation | 支持 | 支持 | 支持 | 动画 API |
wx.getShareInfo | 支持 | 不支持 | 不支持 | 平台特定 API |
处理不可用 API 的通用策略:
function safelyCallAPI(apiFn, params, fallback) {
// #ifdef H5
if (typeof apiFn !== 'function') {
console.warn('当前平台不支持此 API,使用降级方案')
return fallback ? fallback(params) : Promise.reject(new Error('API not supported'))
}
// #endif
return new Promise((resolve, reject) => {
apiFn({
...params,
success: resolve,
fail: reject
})
})
}性能优化
包体积优化
// 1. 分包加载(小程序)
// pages.json(Uniapp)
{
"pages": [/* 主包页面 */],
"subPackages": [
{
"root": "sub-package-a",
"pages": [
"pages/list/list",
"pages/detail/detail"
]
}
],
"preloadRule": {
"pages/index/index": {
"network": "all",
"packages": ["sub-package-a"]
}
}
}
// app.config.ts(Taro)
export default defineAppConfig({
pages: ['pages/index/index'],
subPackages: [
{
root: 'sub-package-a',
pages: ['list', 'detail']
}
]
})// 2. 图片懒加载
// 使用组件属性 lazy-load
<image
src="https://example.com/large-image.jpg"
mode="widthFix"
lazy-load // 仅小程序端生效
/>
// 3. 组件按需引入
// 避免全局注册组件,在页面中按需导入渲染性能优化
// 1. 减少 setData 体积(小程序优化关键)
// 不好的做法:一次性设置大量数据
this.setData({
list: hugeArray, // 可能包含 1000+ 条
userInfo: userData,
config: configData
})
// 好的做法:只更新必要的字段,使用局部更新
this.setData({
'list[0].status': 'completed'
})
// 2. 使用纯组件减少重渲染(Taro React)
import { memo } from 'react'
const ListItem = memo(({ item, onClick }) => {
return (
<View onClick={() => onClick(item.id)}>
<Text>{item.title}</Text>
</View>
)
})
// 3. 避免在渲染函数中执行复杂计算
// 不好的做法
<View>{items.filter(i => i.active).map(...)}</View>
// 好的做法:使用计算属性 / useMemo
const activeItems = useMemo(
() => items.filter(i => i.active),
[items]
)长列表优化
// 1. 使用虚拟列表(Uniapp)
// 使用 uni-ui 的 uni-list + uni-load-more
<template>
<view>
<uni-list>
<uni-list-item
v-for="(item, index) in visibleList"
:key="item.id"
:title="item.title"
/>
</uni-list>
<uni-load-more :status="loadMoreStatus" />
</view>
</template>
// 2. 使用虚拟列表(Taro)
// 使用 @tarojs/components 的 VirtualList(Taro v3.6+)
import { VirtualList } from '@tarojs/components'
<VirtualList
height={600}
itemCount={items.length}
itemSize={80}
itemData={items}
>
{(item, index) => (
<ListItem item={item} index={index} />
)}
</VirtualList>
// 3. 分页加载
const PAGE_SIZE = 20
async function loadMore() {
if (loading.value || !hasMore.value) return
loading.value = true
const res = await uni.request({
url: 'https://api.example.com/v1/list',
data: { page: pageNum.value, size: PAGE_SIZE }
})
list.value = list.value.concat(res.data.list)
hasMore.value = res.data.list.length === PAGE_SIZE
pageNum.value++
loading.value = false
}条件编译最佳实践
推荐的分层策略
将平台差异代码收敛到独立的 service 或 util 文件中,避免条件编译散落在业务代码各处。
// utils/platform.js - 平台工具函数
export function getPlatform() {
// #ifdef MP-WEIXIN
return 'weixin'
// #endif
// #ifdef MP-ALIPAY
return 'alipay'
// #endif
// #ifdef H5
return 'h5'
// #endif
// #ifdef APP-PLUS
return 'app'
// #endif
}
// 获取状态栏高度
export function getStatusBarHeight() {
const systemInfo = uni.getSystemInfoSync()
return systemInfo.statusBarHeight || 20
}
// 获取导航栏高度
export function getNavBarHeight() {
// #ifdef MP-WEIXIN
// 胶囊按钮位置计算
const menuButton = uni.getMenuButtonBoundingClientRect()
return menuButton.bottom + (menuButton.top - getStatusBarHeight())
// #endif
// #ifndef MP-WEIXIN
return 44 + getStatusBarHeight()
// #endif
}// services/storage.js - 统一存储封装
class StorageService {
get(key) {
// #ifdef H5
try {
return JSON.parse(localStorage.getItem(key))
} catch {
return null
}
// #endif
// #ifndef H5
return uni.getStorageSync(key)
// #endif
}
set(key, value) {
// #ifdef H5
localStorage.setItem(key, JSON.stringify(value))
// #endif
// #ifndef H5
uni.setStorageSync(key, value)
// #endif
}
remove(key) {
// #ifdef H5
localStorage.removeItem(key)
// #endif
// #ifndef H5
uni.removeStorageSync(key)
// #endif
}
}
export const storage = new StorageService()不推荐的做法:在业务组件中散落大量条件编译代码。
<!-- 不推荐 -->
<template>
<view>
<!-- #ifdef MP-WEIXIN -->
<button open-type="getPhoneNumber" @getphonenumber="onGetPhone">
<!-- #endif -->
<!-- #ifndef MP-WEIXIN -->
<button @click="inputPhone">
<!-- #endif -->
获取手机号
</button>
</view>
</template>推荐的做法:将平台差异封装到组件或服务中。
<!-- 推荐 -->
<template>
<view>
<phone-button @complete="onPhoneComplete" />
</view>
</template>
<script>
// components/phone-button.vue
export default {
methods: {
handleClick() {
this.$emit('complete', this.phoneNumber)
}
}
}
</script>跨端状态管理
Vuex / Pinia(Uniapp)
Pinia 示例(推荐,Uniapp Vue3)
// stores/counter.js
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', {
state: () => ({
count: 0,
userInfo: null,
token: ''
}),
getters: {
doubleCount: (state) => state.count * 2,
isLoggedIn: (state) => !!state.token
},
actions: {
increment() {
this.count++
},
async login(credentials) {
const res = await uni.request({
url: 'https://api.example.com/v1/auth/login',
method: 'POST',
data: credentials
})
this.token = res.data.token
this.userInfo = res.data.user
uni.setStorageSync('token', this.token)
},
logout() {
this.token = ''
this.userInfo = null
uni.removeStorageSync('token')
}
},
// 持久化插件配置
persist: {
key: 'counter-store',
storage: {
getItem: (key) => uni.getStorageSync(key),
setItem: (key, value) => uni.setStorageSync(key, value)
}
}
})// main.js
import { createPinia } from 'pinia'
import piniaPersist from 'pinia-plugin-persist'
const pinia = createPinia()
pinia.use(piniaPersist)
app.use(pinia)<!-- 页面中使用 -->
<script setup>
import { useCounterStore } from '@/stores/counter'
const counter = useCounterStore()
function handleLogin() {
counter.login({
username: 'admin',
password: '123456'
})
}
</script>
<template>
<view class="page">
<view v-if="counter.isLoggedIn">
<text>欢迎, {{ counter.userInfo?.name }}</text>
<text>计数: {{ counter.count }}</text>
<text>双倍: {{ counter.doubleCount }}</text>
<button @click="counter.increment">增加</button>
<button @click="counter.logout">退出</button>
</view>
<view v-else>
<button @click="handleLogin">登录</button>
</view>
</view>
</template>Vuex 示例(Uniapp Vue2 兼容)
// store/index.js
import Vue from 'vue'
import Vuex from 'vuex'
import user from './modules/user'
import cart from './modules/cart'
Vue.use(Vuex)
export default new Vuex.Store({
modules: {
user,
cart
}
})
// store/modules/user.js
export default {
namespaced: true,
state: {
token: '',
profile: null
},
mutations: {
SET_TOKEN(state, token) {
state.token = token
uni.setStorageSync('token', token)
},
SET_PROFILE(state, profile) {
state.profile = profile
}
},
actions: {
async login({ commit }, credentials) {
const res = await uni.request({
url: 'https://api.example.com/v1/auth/login',
data: credentials
})
commit('SET_TOKEN', res.data.token)
commit('SET_PROFILE', res.data.user)
return res.data
},
logout({ commit }) {
commit('SET_TOKEN', '')
commit('SET_PROFILE', null)
uni.removeStorageSync('token')
}
}
}Redux / Zustand(Taro)
Zustand 示例(推荐,轻量、TypeScript 友好)
// src/stores/counter.ts
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import Taro from '@tarojs/taro'
interface UserInfo {
name: string
avatar: string
}
interface AppState {
count: number
token: string
userInfo: UserInfo | null
increment: () => void
login: (credentials: { username: string; password: string }) => Promise<void>
logout: () => void
}
export const useAppStore = create<AppState>()(
persist(
(set) => ({
count: 0,
token: '',
userInfo: null,
increment: () => set((state) => ({ count: state.count + 1 })),
login: async (credentials) => {
const res = await Taro.request({
url: 'https://api.example.com/v1/auth/login',
method: 'POST',
data: credentials
})
set({
token: res.data.token,
userInfo: res.data.user
})
},
logout: () => {
set({ token: '', userInfo: null })
Taro.removeStorageSync('token')
}
}),
{
name: 'app-storage',
storage: {
getItem: (name) => {
const value = Taro.getStorageSync(name)
return value ? JSON.parse(value) : null
},
setItem: (name, value) => {
Taro.setStorageSync(name, JSON.stringify(value))
},
removeItem: (name) => Taro.removeStorageSync(name)
}
}
)
)// 页面中使用
import { FC } from 'react'
import { View, Text, Button } from '@tarojs/components'
import { useAppStore } from '@/stores/counter'
const HomePage: FC = () => {
const { count, userInfo, token, increment, login, logout } = useAppStore()
const isLoggedIn = !!token
const handleLogin = () => {
login({ username: 'admin', password: '123456' })
}
return (
<View>
{isLoggedIn ? (
<>
<Text>欢迎, {userInfo?.name}</Text>
<Text>计数: {count}</Text>
<Button onClick={increment}>增加</Button>
<Button onClick={logout}>退出</Button>
</>
) : (
<Button onClick={handleLogin}>登录</Button>
)}
</View>
)
}Redux Toolkit 示例
// src/stores/counterSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
interface CounterState {
value: number
loading: boolean
}
const initialState: CounterState = {
value: 0,
loading: false
}
export const counterSlice = createSlice({
name: 'counter',
initialState,
reducers: {
increment: (state) => {
state.value += 1
},
decrement: (state) => {
state.value -= 1
},
setLoading: (state, action: PayloadAction<boolean>) => {
state.loading = action.payload
}
}
})
export const { increment, decrement, setLoading } = counterSlice.actions
export default counterSlice.reducer// src/stores/index.ts
import { configureStore } from '@reduxjs/toolkit'
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'
import counterReducer from './counterSlice'
export const store = configureStore({
reducer: {
counter: counterReducer
}
})
export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch
export const useAppDispatch: () => AppDispatch = useDispatch
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector// 页面中使用 Redux
import { FC } from 'react'
import { View, Text, Button } from '@tarojs/components'
import { useAppSelector, useAppDispatch } from '@/stores'
import { increment } from '@/stores/counterSlice'
const CounterPage: FC = () => {
const count = useAppSelector((state) => state.counter.value)
const dispatch = useAppDispatch()
return (
<View>
<Text>计数: {count}</Text>
<Button onClick={() => dispatch(increment())}>增加</Button>
</View>
)
}跨端请求封装
提供一个统一的请求层,屏蔽各平台差异:
// services/request.js
/**
* 统一请求封装
* 适配 Uniapp(uni.request)和 Taro(Taro.request)的使用方式
*/
// #ifdef UNI_APP
const platformRequest = uni.request
const platformUpload = uni.uploadFile
// #endif
// #ifdef TARO
import Taro from '@tarojs/taro'
const platformRequest = Taro.request
const platformUpload = Taro.uploadFile
// #endif
class HttpClient {
constructor(baseURL) {
this.baseURL = baseURL
this.defaultHeaders = {
'Content-Type': 'application/json'
}
// 请求拦截器
this.requestInterceptors = []
// 响应拦截器
this.responseInterceptors = []
}
// 添加请求拦截器
useRequestInterceptor(fn) {
this.requestInterceptors.push(fn)
}
// 添加响应拦截器
useResponseInterceptor(fn) {
this.responseInterceptors.push(fn)
}
async request(config) {
let mergedConfig = {
url: this.baseURL + config.url,
method: config.method || 'GET',
data: config.data,
header: { ...this.defaultHeaders, ...config.headers },
timeout: config.timeout || 15000,
// Taro v4 中使用 dataType,Uniapp 使用 dataType
dataType: config.dataType || 'json'
}
// 执行请求拦截器
for (const interceptor of this.requestInterceptors) {
mergedConfig = interceptor(mergedConfig)
}
try {
const response = await platformRequest(mergedConfig)
let result = response
// 统一响应格式
if (response.data !== undefined) {
result = response.data
}
// 执行响应拦截器
for (const interceptor of this.responseInterceptors) {
result = interceptor(result)
}
return result
} catch (error) {
console.error('请求失败:', config.url, error)
throw error
}
}
get(url, params, config = {}) {
return this.request({ ...config, url, method: 'GET', data: params })
}
post(url, data, config = {}) {
return this.request({ ...config, url, method: 'POST', data })
}
put(url, data, config = {}) {
return this.request({ ...config, url, method: 'PUT', data })
}
delete(url, params, config = {}) {
return this.request({ ...config, url, method: 'DELETE', data: params })
}
async upload(url, filePath, formData = {}) {
return new Promise((resolve, reject) => {
platformUpload({
url: this.baseURL + url,
filePath,
name: 'file',
formData,
success: (res) => {
resolve(typeof res.data === 'string' ? JSON.parse(res.data) : res.data)
},
fail: reject
})
})
}
}
const http = new HttpClient(import.meta.env.VITE_API_BASE_URL || 'https://api.example.com')
// 请求拦截器:注入 token
http.useRequestInterceptor((config) => {
const token = uni.getStorageSync('token')
if (token) {
config.header['Authorization'] = `Bearer ${token}`
}
return config
})
// 响应拦截器:统一错误处理
http.useResponseInterceptor((response) => {
if (response.code === 401) {
// token 过期,跳转登录
uni.navigateTo({ url: '/pages/login/login' })
throw new Error('登录已过期')
}
if (response.code !== 0 && response.code !== 200) {
uni.showToast({
title: response.message || '请求异常',
icon: 'none'
})
throw new Error(response.message)
}
return response.data
})
export default http// services/api.js - 业务 API 定义
import http from './request'
export const userApi = {
login: (data) => http.post('/v1/auth/login', data),
logout: () => http.post('/v1/auth/logout'),
getProfile: () => http.get('/v1/user/profile'),
updateProfile: (data) => http.put('/v1/user/profile', data)
}
export const articleApi = {
getList: (params) => http.get('/v1/articles', params),
getDetail: (id) => http.get(`/v1/articles/${id}`),
create: (data) => http.post('/v1/articles', data)
}如果你更习惯 axios 的调用方式,也可以基于 axios 适配:
// 使用 axios 适配小程序环境(需安装 axios)
// npm install axios
import axios from 'axios'
const instance = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
timeout: 15000
})
// 适配小程序(Uniapp / Taro 均适用)
instance.defaults.adapter = function(config) {
return new Promise((resolve, reject) => {
uni.request({
url: config.url,
method: config.method?.toUpperCase() || 'GET',
data: config.data,
header: config.headers,
dataType: 'json',
success: (res) => {
resolve({
data: res.data,
status: res.statusCode,
statusText: res.errMsg,
headers: res.header,
config
})
},
fail: (err) => {
reject(err)
}
})
})
}
// 请求拦截器
instance.interceptors.request.use((config) => {
const token = uni.getStorageSync('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
// 响应拦截器
instance.interceptors.response.use(
(response) => {
if (response.data.code === 401) {
uni.navigateTo({ url: '/pages/login/login' })
return Promise.reject(new Error('登录已过期'))
}
return response.data
},
(error) => {
uni.showToast({ title: '网络异常', icon: 'none' })
return Promise.reject(error)
}
)
export default instance