上个月给公司做内部工具,需求是一个产品要同时上微信小程序、H5 和 App。时间紧任务重,我直接选了 uni-app,用 Vue3 + Vite 模板把项目搭起来,状态管理没有用 Vuex,直接用 Pinia。一天时间跑通了核心流程,三端同时上线。
最近 uni-app 社区里关于 Vue3 + Vite 编译速度的讨论挺热的,我今天也把这一套完整方案从项目初始化到跨端适配全部拆开讲。文章所有代码都能跑通,跟着敲一遍,你也能做出自己的跨端待办应用。
一、技术选型,为什么是这套组合
如果你的项目需要同时覆盖小程序、H5、App,团队主要技术栈又是 Vue,那 uni-app 可能是目前最靠谱的选择。这是业务倒逼出来的结论,不是吹。
Vue3 的 Composition API 写起来比 Options API 舒服太多,代码逻辑可以按功能聚合,而不是分散在 data、methods、computed 各个地方。Pinia 更是把状态管理简化到了一个相当轻量的程度,没有 mutation 的冗余步骤,store 就是一个普通的响应式对象,怎么写怎么顺手。
二、创建项目
用官方 Vite 预设模板来创建,可以减少一堆手工配置的麻烦。
npx degit dcloudio/uni-preset-vue#vite my-todo
cd my-todo
npm install
npm install pinia
安装完成后,项目目录结构大体是这样的:
my-todo/
├── src/
│ ├── pages/
│ │ └── index/
│ │ └── index.vue
│ ├── static/
│ ├── App.vue
│ ├── main.js
│ ├── manifest.json
│ └── pages.json
├── index.html
└── package.json
三、注册 Pinia:main.js 里的关键细节
打开 src/main.js,这里有一个需要注意的细节。
import { createSSRApp } from 'vue'
import * as Pinia from 'pinia'
import App from './App.vue'
export function createApp() {
const app = createSSRApp(App)
app.use(Pinia.createPinia())
return {
app,
Pinia
}
}
看到没有,我用的是 import * as Pinia from 'pinia',而不是 import { createPinia } from 'pinia'。这个坑折腾了我一下午——uni-app 编译到某些平台时,会对具名导入做 tree-shaking,把 Pinia 的核心逻辑摇掉了,导致运行时直接报错。用命名空间导入就稳了,建议大家都这么写。
四、定义 Todo Store:数据管理的核心
在 src 目录下新建 stores 文件夹,创建 todo.js。
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useTodoStore = defineStore('todo', () => {
const todos = ref([])
const completedCount = computed(() => {
return todos.value.filter(item => item.done).length
})
const activeCount = computed(() => {
return todos.value.filter(item => !item.done).length
})
function addTodo(text) {
if (!text || !text.trim()) return
todos.value.unshift({
id: Date.now().toString(36) + Math.random().toString(36).slice(2, 8),
text: text.trim(),
done: false
})
save()
}
function toggleTodo(id) {
const todo = todos.value.find(item => item.id === id)
if (todo) {
todo.done = !todo.done
save()
}
}
function removeTodo(id) {
todos.value = todos.value.filter(item => item.id !== id)
save()
}
function load() {
const data = uni.getStorageSync('todos')
if (data) {
todos.value = data
}
}
function save() {
uni.setStorageSync('todos', todos.value)
}
return { todos, completedCount, activeCount, addTodo, toggleTodo, removeTodo, load }
})
这个 store 用的 setup 语法,非常直观。每次修改数据后调用 save(),把最新数组同步到本地存储。load() 在页面加载时调用,从本地存储恢复之前的数据。
id 我用了时间戳的 36 进制加上随机字符串,避免快速操作时出现重复 id,这个细节在列表渲染中很重要,key 一旦重复,渲染就会出错。
五、编写待办页面:组合式 API 的完整实践
核心页面在 src/pages/index/index.vue,上完整代码。
<template>
<view class="page">
<view class="header">
<text class="title">我的待办</text>
<text class="date">{{ today }}</text>
</view>
<view class="input-wrap">
<input
v-model="inputText"
placeholder="有什么要做的?"
confirm-type="done"
@confirm="handleAdd"
/>
<button class="btn" @click="handleAdd">添加</button>
</view>
<view class="stats">
<text>全部 {{ todoStore.todos.length }}</text>
<text>待办 {{ todoStore.activeCount }}</text>
<text>完成 {{ todoStore.completedCount }}</text>
</view>
<view class="list">
<view
v-for="todo in todoStore.todos"
:key="todo.id"
class="item"
:class="{ done: todo.done }"
>
<view class="circle" @click="todoStore.toggleTodo(todo.id)">
<text v-if="todo.done">✓</text>
</view>
<text class="text">{{ todo.text }}</text>
<view class="del" @click="todoStore.removeTodo(todo.id)">
<text>删除</text>
</view>
</view>
</view>
<view v-if="todoStore.todos.length === 0" class="empty">
<text>暂无待办,给自己加一个目标吧</text>
</view>
</view>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useTodoStore } from '@/stores/todo'
const todoStore = useTodoStore()
const inputText = ref('')
const today = computed(() => {
const d = new Date()
return `${d.getMonth() + 1}月${d.getDate()}日`
})
function handleAdd() {
todoStore.addTodo(inputText.value)
inputText.value = ''
}
onMounted(() => {
todoStore.load()
})
</script>
页面加载时用 onMounted 调用 load(),把上次的数据恢复回来。输入框的 confirm-type=”done” 会把键盘右下角变成”完成”按钮,配合 @confirm 事件,用户在手机上不需要去点”添加”按钮也能直接录入,这个交互细节对移动端体验很重要。
记住,代码里没有展示样式部分,实际项目里样式需要自己补上,建议用 scoped。
六、跨端适配,这五个坑必须说
同样一段代码,在不同端跑起来行为可能会有细微差异。我在微信小程序、H5、iOS App 三个端都跑了一遍,这几个坑是优先需要处理的。
坑一:安全区域没有适配
iPhone 底部的 Home Indicator 会遮挡页面内容。在自定义导航栏的场景下,必须通过 uni.getSystemInfoSync() 获取状态栏高度,然后动态设置页面的 padding-top。
const info = uni.getSystemInfoSync()
const statusBarHeight = info.statusBarHeight || 20
H5 端没有状态栏,需要用条件编译单独处理这段逻辑。
坑二:点击态不一致
H5 上点击元素自带 :hover 效果,小程序上啥都没有。为了让两端体验一致,建议所有可点击元素都绑定 hover-class,然后设置 hover-stay-time=”70″。如果不想要点击态,在 H5 端页面上加上 -webkit-tap-highlight-color: transparent。
坑三:本地存储的容量限制
微信小程序的 setStorageSync 单条数据上限是 1MB,H5 的 localStorage 上限是 5MB。待办数据少的时候没啥感觉,但如果数据量很大,建议压缩存储或者使用异步版本。我这边本地存储只是缓存,后端还有接口,所以直接用同步版本没毛病。
坑四:flex 布局的兼容性
部分旧版本 iOS 上 justify-content: space-between 会表现异常,这就是为什么有些小程序页面要求使用 padding 和 margin 来布局。建议尽早约定好布局方案,避免临时改样式。
坑五:pages.json 的导航栏自定义
把 navigationStyle 设为 custom 后,默认导航栏消失了,但状态栏的占位区域也需要自行处理。如果不做处理,页面顶部会顶到状态栏,非常难看。
七、扩展:加一个分组筛选功能
为了演示 Pinia 的灵活性,我再加一个筛选功能:全部 / 待办 / 已完成 三个 tab 切换。
在 store 中加一个筛选状态和过滤后的列表:
const filter = ref('all') // all | active | done
const filteredTodos = computed(() => {
if (filter.value === 'active') {
return todos.value.filter(item => !item.done)
}
if (filter.value === 'done') {
return todos.value.filter(item => item.done)
}
return todos.value
})
然后页面上放三个 tab,点击切换 filter 的值。这个逻辑如果在 Options API 里,又要写三个 methods 三个 data 一个 computed,现在用 setup 风格就是一个 ref 加一个 computed,清爽得不行。
八、打包上线
不同端的打包命令 package.json 里都配好了,常用的是下面几个:
npm run dev:mp-weixin # 微信小程序
npm run dev:h5 # H5
npm run build:app # App 打包
小程序端把 dist/dev/mp-weixin 目录导入微信开发者工具就能预览。H5 端打包后丢到服务器上就行。App 端注意了,不能用 npm 命令直接生成安装包,还是得用 HBuilderX 打开项目,然后走云打包或者本地打包。
总结
这套组合用下来,我的感受是 Pinia 把状态管理变轻了,Composition API 把业务逻辑理顺了,uni-app 把多端打包的麻烦事扛了。三个配合起来,开发效率确实高。
不过 uni-app 的坑也是真的多,尤其是跨端兼容这块,建议项目一开始就统一好代码规范,条件编译的地方写清楚注释,不然后面维护代码的人真的会骂人。
今天这篇文章是用一个线上在跑的案例拆解的。代码都在上面,自己动手敲一遍,比看十遍文章有用。有问题评论区聊,看到会回。

