TypeScript 08:产品实战 —— 移动应用(Capacitor + Ionic Vue / uni-app)
用 TS + Vue 也能做手机 App,主要有三条路线:
| 方案 | 原理 | 产出 | 适合 |
|---|---|---|---|
| PWA | 网页 + Service Worker | 可"添加到主屏幕"的网页 | 最轻量,不上应用商店(见 06 篇练习) |
| Capacitor + Ionic Vue | WebView 中运行 Vue,通过插件调用相机、通知等原生能力 | iOS / Android 安装包 | 已有 Vue Web 项目,想快速出 App |
| uni-app(DCloud) | Vue 语法,编译到各平台 | 微信/支付宝小程序、H5、App | 国内产品,小程序是刚需时 |
| React Native / Expo | JS 驱动原生控件 | iOS / Android | 对性能和原生体验要求高(需学 React) |
| Tauri 2 mobile | 系统 WebView + Rust | iOS / Android | 已用 Tauri 做桌面版 |
| (对照)Flutter / Kotlin / Swift | 非 TS | — | 纯原生团队 |
本篇以 Capacitor + Ionic Vue 为主线,做一个手机记账 App:拍小票、离线记账联网自动同步、每晚提醒;
再用 uni-app 演示同一功能如何发布为微信小程序;最后用 WebdriverIO + Appium(TS) 做移动端自动化。
1. Capacitor + Ionic Vue
1.1 创建项目
npm install -g @ionic/cli
ionic start accounting-mobile tabs --type=vue --capacitor
cd accounting-mobile
ionic serve # 先在浏览器里开发调试,和普通 Vue 项目一样
npm install @capacitor/camera @capacitor/preferences @capacitor/network @capacitor/local-notifications
npx cap add android # 需要 Android Studio
npx cap add ios # 需要 Xcode(仅 macOS)
日常流程:
ionic build && npx cap sync # 把 Vue 构建产物和插件同步到原生工程
npx cap open android # 用 Android Studio 打开,运行到模拟器/真机
ionic cap run android -l --external # 真机热更新调试
调试 WebView:Android 用 Chrome 打开
chrome://inspect;iOS 用 Safari「开发」菜单。
和调试网页一样能看 DOM、Console、Network。
1.2 关键差异:手机上没有"同源后端"
浏览器里可以 fetch("/api/records"),但 App 里页面来自本地文件,必须写完整地址,后端需开启 CORS:
// src/api/client.ts
const BASE_URL = import.meta.env.VITE_API_BASE ?? "https://accounting.example.com";
export async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
const token = await getToken();
const res = await fetch(BASE_URL + path, {
...init,
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
...init.headers,
},
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return (res.status === 204 ? null : await res.json()) as T;
}
1.3 本地存储(≈ 浏览器的 localStorage,但更可靠)
// src/services/storage.ts
import { Preferences } from "@capacitor/preferences";
export async function getToken(): Promise<string | null> {
return (await Preferences.get({ key: "token" })).value;
}
export async function setToken(token: string): Promise<void> {
await Preferences.set({ key: "token", value: token });
}
export async function getJson<T>(key: string, fallback: T): Promise<T> {
const { value } = await Preferences.get({ key });
return value ? (JSON.parse(value) as T) : fallback;
}
export async function setJson(key: string, data: unknown): Promise<void> {
await Preferences.set({ key, value: JSON.stringify(data) });
}
1.4 离线优先:没网也能记账,联网自动同步
这是移动产品和网页最大的区别之一。
// src/services/syncQueue.ts
import { Network } from "@capacitor/network";
import { getJson, setJson } from "./storage";
import { request } from "../api/client";
export interface PendingRecord {
localId: string;
type: "income" | "expense"; category: string; amount: number; date: string;
receiptPath?: string;
}
const KEY = "pending-records";
export async function addRecordOfflineFirst(r: Omit<PendingRecord, "localId">): Promise<void> {
const queue = await getJson<PendingRecord[]>(KEY, []);
queue.push({ ...r, localId: crypto.randomUUID() });
await setJson(KEY, queue);
await trySync();
}
let syncing = false;
export async function trySync(): Promise<number> {
if (syncing || !(await Network.getStatus()).connected) return 0;
syncing = true;
let synced = 0;
try {
const queue = await getJson<PendingRecord[]>(KEY, []);
const failed: PendingRecord[] = [];
for (const item of queue) {
try {
const { localId, receiptPath, ...data } = item;
await request("/api/records", { method: "POST", body: JSON.stringify(data) });
synced++;
} catch {
failed.push(item); // 失败的保留,下次再试
}
}
await setJson(KEY, failed);
} finally {
syncing = false;
}
return synced;
}
// 在 main.ts 中调用一次:网络恢复时自动同步
export function startAutoSync(): void {
Network.addListener("networkStatusChange", (status) => {
if (status.connected) trySync();
});
}
1.5 原生能力:拍小票、定时提醒
// src/services/native.ts
import { Camera, CameraResultType, CameraSource } from "@capacitor/camera";
import { LocalNotifications } from "@capacitor/local-notifications";
import { Capacitor } from "@capacitor/core";
export async function takeReceiptPhoto(): Promise<string | undefined> {
const photo = await Camera.getPhoto({
quality: 70,
resultType: CameraResultType.Uri,
source: CameraSource.Prompt, // 让用户选择 拍照 / 相册
});
return photo.webPath; // 可直接作为 <img :src> 显示
}
export async function scheduleDailyReminder(): Promise<void> {
if (!Capacitor.isNativePlatform()) return; // 浏览器调试时跳过
const { display } = await LocalNotifications.requestPermissions();
if (display !== "granted") return;
await LocalNotifications.schedule({
notifications: [{
id: 1,
title: "记账提醒",
body: "今天的账记了吗?",
schedule: { on: { hour: 21, minute: 0 }, allowWhileIdle: true },
}],
});
}
使用相机需要声明权限:iOS 在
ios/App/App/Info.plist添加NSCameraUsageDescription、NSPhotoLibraryUsageDescription;
Android 一般由插件自动处理。权限被拒绝是移动端必测场景。
1.6 页面(Ionic 组件 = 移动端风格的 Element Plus)
<!-- src/views/AddRecordPage.vue -->
<script setup lang="ts">
import { reactive, ref } from "vue";
import {
IonPage, IonHeader, IonToolbar, IonTitle, IonContent, IonList, IonItem,
IonInput, IonSelect, IonSelectOption, IonButton, toastController,
} from "@ionic/vue";
import { takeReceiptPhoto } from "@/services/native";
import { addRecordOfflineFirst } from "@/services/syncQueue";
const form = reactive({
type: "expense" as "income" | "expense",
category: "",
amount: 0,
date: new Date().toISOString().slice(0, 10),
});
const receipt = ref<string>();
async function snap() {
receipt.value = await takeReceiptPhoto();
}
async function save() {
await addRecordOfflineFirst({ ...form, receiptPath: receipt.value });
const toast = await toastController.create({ message: "已保存", duration: 1500 });
await toast.present();
}
</script>
<template>
<ion-page>
<ion-header><ion-toolbar><ion-title>记一笔</ion-title></ion-toolbar></ion-header>
<ion-content class="ion-padding">
<ion-list>
<ion-item>
<ion-select v-model="form.type" label="类型">
<ion-select-option value="expense">支出</ion-select-option>
<ion-select-option value="income">收入</ion-select-option>
</ion-select>
</ion-item>
<ion-item><ion-input v-model="form.category" label="分类" data-testid="category" /></ion-item>
<ion-item><ion-input v-model.number="form.amount" type="number" label="金额" /></ion-item>
<ion-item><ion-input v-model="form.date" type="date" label="日期" /></ion-item>
</ion-list>
<img v-if="receipt" :src="receipt" alt="小票" style="max-width: 100%" />
<ion-button expand="block" fill="outline" @click="snap">拍小票</ion-button>
<ion-button expand="block" @click="save">保存</ion-button>
</ion-content>
</ion-page>
</template>
1.7 发布
- Android:Android Studio → Build → Generate Signed Bundle(
.aab上传 Google Play,国内各应用市场用.apk)。 - iOS:Xcode → Archive → 上传 App Store Connect(需苹果开发者账号)。
- 热更新(不重新提交商店即可更新 Web 部分):Capgo / Appflow 等服务。
2. uni-app:一份 Vue 代码发布微信小程序
国内产品经常要求"App + 小程序 + H5"同时上线,uni-app 是主流选择。语法就是 Vue 3,但标签和 API 按小程序规范:
| Web Vue | uni-app |
|---|---|
<div> / <span> |
<view> / <text> |
<img> |
<image> |
fetch / axios |
uni.request |
localStorage |
uni.setStorageSync |
| vue-router | pages.json 配置 + uni.navigateTo |
npx degit dcloudio/uni-preset-vue#vite-ts accounting-uni
cd accounting-uni && npm install
npm run dev:h5 # 浏览器
npm run dev:mp-weixin # 生成到 dist/dev/mp-weixin,用微信开发者工具打开
// src/api/request.ts —— 把 uni.request 封装成带类型的 Promise
export function request<T>(url: string, options: Partial<UniApp.RequestOptions> = {}): Promise<T> {
return new Promise((resolve, reject) => {
uni.request({
url: "https://accounting.example.com" + url,
header: { Authorization: `Bearer ${uni.getStorageSync("token")}` },
...options,
success: (res) => (res.statusCode < 300 ? resolve(res.data as T) : reject(res)),
fail: reject,
});
});
}
<!-- src/pages/index/index.vue -->
<script setup lang="ts">
import { ref } from "vue";
import { onShow, onPullDownRefresh } from "@dcloudio/uni-app";
import { request } from "@/api/request";
interface AccountRecord { id: number; type: string; category: string; amount: number; date: string }
const records = ref<AccountRecord[]>([]);
async function load() {
records.value = await request<AccountRecord[]>(`/api/records?month=${new Date().toISOString().slice(0, 7)}`);
}
onShow(load); // 小程序生命周期:页面每次显示
onPullDownRefresh(async () => { await load(); uni.stopPullDownRefresh(); });
const goAdd = () => uni.navigateTo({ url: "/pages/add/add" });
</script>
<template>
<view class="list">
<view v-for="r in records" :key="r.id" class="item">
<text>{{ r.date }} {{ r.category }}</text>
<text>{{ r.type === "income" ? "+" : "-" }}{{ r.amount }}</text>
</view>
<button @click="goAdd">记一笔</button>
</view>
</template>
小程序上线前需在微信公众平台配置request 合法域名(必须 HTTPS),这也是常见的"测试环境正常、线上不通"原因。
3. 移动端自动化:WebdriverIO + Appium(TS 版)
你已有 Python + Appium 经验,TS 版本概念完全一致:
npm init wdio@latest ./mobile-e2e # 选择 Appium、Mocha、TypeScript
npm install -g appium && appium driver install uiautomator2
// test/specs/record.e2e.ts
describe("记账 App", () => {
it("离线新增记录后显示保存成功", async () => {
// Ionic/Capacitor 是混合应用:界面在 WebView 中,先切换上下文
await driver.waitUntil(async () => (await driver.getContexts()).length > 1);
const contexts = (await driver.getContexts()) as string[];
await driver.switchContext(contexts.find(c => c.startsWith("WEBVIEW"))!);
await $('[data-testid="category"] input').setValue("food");
await $("ion-button=保存").click();
await expect($("ion-toast")).toBeDisplayed();
// 切回原生上下文,处理系统权限弹窗等原生元素
await driver.switchContext("NATIVE_APP");
});
});
| Python Appium | WebdriverIO (TS) |
|---|---|
driver.find_element(AppiumBy.ACCESSIBILITY_ID, "x") |
$("~x") |
driver.contexts / driver.switch_to.context() |
driver.getContexts() / driver.switchContext() |
WebDriverWait |
waitForDisplayed() / waitUntil() |
| pytest fixture | Mochabefore / beforeEach,wdio.conf.ts 的 hooks |
移动端测试清单
- 权限:首次拒绝 / 永久拒绝 / 设置中重新开启
- 网络:飞行模式记账 → 恢复网络 → 自动同步,不重复、不丢失
- 生命周期:切后台再回来、来电打断、杀进程后数据是否保留
- 适配:刘海屏、小屏、深色模式、系统字体放大
- 升级:旧版本数据升级到新版本后是否正常
4. 练习
- 用 Ionic Vue 完成"手机记账":列表页 + 新增页 + 拍小票 + 离线同步,后端用 06 篇的 Nuxt 项目(改为 Bearer Token 认证)。
- 用 uni-app 把列表页发布到微信开发者工具中运行。
- 用 WebdriverIO 写一个混合应用用例,覆盖"无网络新增 → 恢复网络 → 服务端出现该记录"。
文章评论 (0)