这篇聊前端。技术栈是 uni-app(Vue 3 ),目标平台是微信小程序。26 个页面,写了挺久的,有些东西值得记一记。
为什么选 uni-app
其实一开始纠结过要不要直接用微信原生。原生的文档很完善,生态也成熟,但写法和 Vue 差异比较大,组件复用的方式也比较笨拙。最后还是选了 uni-app,主要是几个理由:
1. 用 Vue 3 的 Composition API 写,逻辑可以抽成 composable,复用性好很多
2. uni-ui 这个组件库开箱即用easycom 配置一下<uni-xxx> 组件不用手动 import
3. 如果以后要适配 H5 或者其他小程序,理论上改动量很小
当然代价也有——uni-app 的 bug 确实比原生多,有时候某个样式在模拟器上好的,真机上就飘了;有些微信特有的 API 需要加条件编译,代码里偶尔会出现 // #ifdef MP-WEIXIN 这种注释。整体还是利大于弊。
工程配置:pages.json 是核心
uni-app 没有 React Router 那种东西,所有路由都在 pages.json 里声明。每新建一个页面就要加一条记录,这个文件到最后有 26 个 pages 条目。
有几个配置我觉得值得一提:
{
"lazyCodeLoading": "requiredComponents",
"easycom": {
"autoscan": true,
"custom": {
"^uni-(.*)": "@dcloudio/uni-ui/lib/uni-$1/uni-$1.vue"
}
}
}
lazyCodeLoading 开启后,小程序启动时不会把所有页面代码全加载进来,按需加载,启动包体积小了不少easycom 是 uni-app 的自动导入机制,配置了 uni-* 的映射规则之后,在模板里直接用 <uni-icons> 就行,不需要每个页面都手动 import,省了很多重复代码。
请求封装:request.js
这个文件是整个前端最重要的基础设施,所有页面的数据请求都经过它。核心要解决几个问题:
1. 用户 Token 和管理员 Token 分开管理
小程序同时有普通用户界面和管理员后台,两套权限用的是不同的 Token(后端也是分开颁发的)。请求时需要根据 URL 自动判断用哪个:
const getTokenForUrl = (url) => {
if (url && url.includes('/admin')) {
return uni.getStorageSync('adminToken') || ''
}
return uni.getStorageSync('token') || ''
}简单粗暴,但够用。只要接口路径规范——用户接口在 /user//market/ 等下面,管理员接口统一在 /admin/ 下——这个逻辑就不会出问题。
2. Token 过期的自动重登
用户 Token 过期时(后端返回 401),不应该直接跳到登录页,而是应该静默地用微信 Code 换一个新 Token,然后重试原来的请求,对用户无感知。
这里有个并发的问题:如果用户进入页面时同时发了 3 个请求,它们可能会同时收到 401,然后同时触发 3 次微信重登——这是不行的。
解决方案是用一个 Promise 变量做单例锁:
let reloginPromise = null
const reloginOnce = () => {
// 如果已经有一个重登在进行中,直接返回同一个 Promise
if (reloginPromise) return reloginPromise
reloginPromise = new Promise((resolve, reject) => {
uni.login({
provider: 'weixin',
success: (loginRes) => {
uni.request({
url: ${baseUrl}/user/wx-login,
method: 'POST',
data: { code: loginRes.code },
success: (res) => {
if (res.data.code === 200) {
uni.setStorageSync('token', res.data.data.token)
resolve(res.data.data.token)
} else {
reject(new Error('重登失败'))
}
},
complete: () => {
reloginPromise = null // 释放锁
}
})
}
})
})
return reloginPromise
}三个请求都收到 401 → 都调 reloginOnce() → 第一个创建 Promise,后两个拿到同一个 Promise → 微信只被调用一次 → 新 Token 存好之后三个请求各自重试。
3. 管理员 401 不自动重登
管理员的 Token 过期了就该手动重新登录,不能静默续期(因为没有微信身份可以对应):
if (isAdminApi) {
uni.removeStorageSync('adminToken')
uni.redirectTo({ url: '/pages/admin/admin-login' })
return
}这里用 redirectTo 而不是 navigateTo,是为了清掉页面栈,防止用户返回到需要鉴权的管理页面。
4. 环境切换
开发调试时后端跑在本地,真机测试时要打到服务器,两个地址经常要切换。与其每次都改代码重新编译,我直接把 baseUrl 存到 storage 里,可以运行时动态修改:
const DEFAULT_BASE_URL = 'https://api.jlszxzyzxh.top/api'
const getBaseUrl = () => {
const stored = uni.getStorageSync('baseUrl')
return stored || DEFAULT_BASE_URL
}API 组织:按业务域分组
api/index.js 把所有接口方法按业务模块分组导出:
export const homeApi = {
getCarousel: () => request({ url: '/home/carousel' }),
getNews: (page, pageSize) => request({ url: '/home/news', data: { page, pageSize } })
}
export const marketApi = {
getGoods: (page, pageSize) => request({ url: '/market/goods', data: { page, pageSize } }),
exchangeGoods: (goodsId) => request({ url: '/market/exchange', method: 'POST', data: { goodsId } }),
donateItem: (data) => request({ url: '/market/donate-item', method: 'POST', data })
}页面里用的时候:
import { marketApi } from '@/api/index.js'
const goods = await marketApi.getGoods(1, 10)这样的好处是接口路径只在一个地方定义,如果后端改了路径,只需要改 api/index.js 一个地方,不用去每个页面里搜。
adminApi 里还专门加了一些语义化的别名:
banUser: (id) => adminApi.updateUserStatus(id, 'disabled'),
unbanUser: (id) => adminApi.updateUserStatus(id, 'active'),
approveWish: (id) => adminApi.updateWishStatus(id, 'pending', ''),管理页面调 adminApi.banUser(userId) 比调 adminApi.updateUserStatus(userId, 'disabled') 可读性强很多,写管理页的时候不需要去记参数枚举值。
设计系统:从 Claymorphism 到 Apple 极简
这个项目的 UI 风格经历了一次比较大的调整。1.0 用的是 Claymorphism(黏土拟物风),大圆角、厚重阴影、饱和度很高的色块,视觉冲击力强,但在信息密度高的管理页面里显得很拥挤。新版本AI给了我很多的想法和建议。也确实很实用,很多的CSS样式也是AI做出来的,我简单介绍下
2.0 重新做了设计系统,风格向 Apple 的极简靠拢——减少装饰性元素,用排版和空白来建立层次感。设计令牌全部整理到 design-system.css 里:
page {
--color-primary: #3B82F6;
--color-primary-dark: #2563EB;
--color-cta: #F59E0B;
--color-text-primary: #1E293B;
--color-text-secondary: #64748B;
--color-text-muted: #94A3B8;
--radius-sm: 12rpx;
--radius-md: 16rpx;
--radius-lg: 24rpx;
--radius-full: 999rpx;
--shadow-card: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
--shadow-modal: 0 20rpx 60rpx rgba(0, 0, 0, 0.12);
}所有页面引用这些变量,不写硬编码的颜色值。这样如果哪天要换主色调,改一个地方就够了。
有几个细节处理得比较用心:
卡片点击反馈——微信小程序的默认点击态是 opacity: 0.7,整个卡片会变透明,看起来很廉价。改成 transform: scale(0.98) 会有一种被「按下去」的触感:
.card-hover:active {
transform: scale(0.98);
transition: transform 0.1s ease;
}毛玻璃蒙层——弹窗背景不用纯黑透明,而是用 backdrop-filter 做模糊:
.modal-mask {
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
background: rgba(0, 0, 0, 0.4);
}在高端机型上效果很好,低端机会有性能问题,所以只在弹窗这种不常出现的场景用,不用在列表里。
页面入场动画——页面数据加载完成后,内容区域用一个 fade + slide-up 的动画出现,避免内容突然蹦出来的割裂感:
@keyframes fadeIn {
from { opacity: 0; transform: translateY(20rpx); }
to { opacity: 1; transform: translateY(0); }
}
.animate-fade-in {
animation: fadeIn 0.3s ease-out;
}写 26 个页面的感受
写到后期其实有点麻木。同样的模式——加载数据、显示列表、点击跳详情、弹 Modal 确认操作——在管理端的七个页面里各来了一遍。
有一个做法比较好用:把通用的弹窗操作(确认框、Toast 提示、图片预览)抽成全局的 composable,页面里直接调用。
export const useConfirm = () => {
const showConfirm = (content, title = '确认操作') => {
return new Promise((resolve) => {
uni.showModal({
title,
content,
success: (res) => resolve(res.confirm)
})
})
}
return { showConfirm }
}页面里:
const { showConfirm } = useConfirm()
const confirmed = await showConfirm('确认封禁该用户?')
if (confirmed) { /* ... */ }比每次都写 uni.showModal({ ... success: ... }) 要简洁很多。
以上就是我对于助学云前端页面的一些小感受,第三篇后端也在路上,距离正式发布也不远了,我们还在做相关的测试,关于后端呢,AI的编码占得更多,现在也不得不承认,只要你有个好想法,好思路,告诉给AI之后,他也能帮你完成很多的任务。下一篇见。