2025年12月,接近年底,我准备把我最近一年的开发感悟总结一下
最近一年,我负责的项目主要以多端混合开发为主,以PC端管理系统与配套的H5生态为辅。这段时间中,我发现公司有些同事思考太远,经常会引起不必要的沟通与讨论,可能会持续一个小时。典型的案例就是我目前负责的最新项目,各种考虑深远,各种配套想实现,但现实却带来迎头痛击,小程序被下架。

本来可以用最小化核心项目试验,非要搞出很多繁杂的设计步骤,操作过程来耗费多余的开发时间,在业务线试错的背景下,搞一套大而全的东西确实是本末倒置,所以在机会项目的前提下,考虑过于长远并非好的决策。
吐槽完成之后,来总结下从2025年到如今我在开发中的一些经验。
在做小程序项目中,永远不要相信产品经理乃至领导“只做这一端”的鬼话,在我负责第一个跨端项目的时候,领导刚开始说只做微信小程序,然后随着业务的进展,领导又说支付宝小程序有前景,过了一段时间又说,抖音小程序是个趋势,然后又过了一段时间,运营想要小红书小程序...
所以在做技术选型的时候,一定要充分的考虑考虑再考虑,能优先考虑多端技术统一的技术栈就优先考虑,在此,我推荐使用uniapp,网上虽然很多人骂uniapp这不好那不好,但是实际上uniapp在国内的中小企业开发环境下,实在是一个比较好的选择,搭配针对Uniapp的脚手架,uni-helper 或者 uni-best 等上层框架,开发体验会好很多。
再来说一下架构设计这一方面,由于使用了Uniapp这个底层框架,大部分情况下,我们不需要去考虑偏底层的设计,如页面路由怎么选啊,request请求库怎么选啊,数据缓存怎么做啊,身份鉴权怎么做啊等等,由于小程序端的天然限制,大部分都有平台提供的API可以使用,我们只需要针对这些API,做恰到好处的架构设计就可以了,最典型的例子就是:“登录与用户数据获取”,这个需要考虑的就比较多,比如登录之后,用户数据怎么同步,未登录的时候,怎么针对用户进行登录,由于产品经理的设计,用户未登录不影响用户查阅数据,而不是跳转到一个专门的登录页面(只针对C端),这里我的解决方案是:pinia / mitt / 无渲染组件 / 跨平台Login逻辑,这里来说明一下:
1. pinia 是为了全局存储用户数据,相信大家都明白,一页项目可能在非常多的页面都要使用用户信息数据,所以这里存全局;
2. mitt 发布订阅模式是为了做针对用户数据的更新,这种方法是最简单的更新用户数据的地方,在一个地方处理数据,在任意地方发布事件,代码如下:
import { USER_UPDATE_KEY } from '@/global/key'; import emitter from './emitter'; import { getUserInfo } from '@/api/me'; import { useUserStore } from '@/stores/useUserStore'; import { cloneDeep, get } from 'lodash-es'; import { useGlobalStore } from '../stores/useGlobalStore'; // 很多代码
const getUser = async () => { const user = await getUserInfo(); if (user?.data?.code === 200) { const userStore = useUserStore(); userStore.setUser(user.data.data); } }; // 网络监听 export function onNetworkStatusChange() { uni.onNetworkStatusChange(res => { const store = useGlobalStore(); store.state.isConnected = res.isConnected; }); } export function boot() { subscribeUserUpdate(getUser); setCurrentLocation(); onNetworkStatusChange(); }
然后在App.vue生命周期中执行boot函数,就可以开启监听了
onLaunch(async () => {
platformUpdate();
boot();
await loginIfNotToken();
});
然后想要更新用户数据的时候,只需要发布一个事件就OK了,为什么选用mitt而不是pinia action,最大的区别就是我可以针对这个事件发布来做信息更新以外的事情,比如,用户信息更新了,可能要触发一个其他的日志统计接口,写到action中会让这些逻辑耦合在一起
emitter.emit(USER_UPDATE_KEY);
3. 无渲染组件,因为有很多功能是需要用户登录才可以使用的,但是也不能给所有的功能都写一个<button open-type="getphonenumber">这种,所以要封装一个通用的组件来自动处理这个功能。
<script lang="ts" setup> import { useUserStore } from '@/stores/useUserStore'; import { miniAppLogin } from '@/utils/auth'; import type { ButtonOnGetphonenumberEvent } from '@uni-helper/uni-types'; interface Props { isCustomAuthDoneNextProcess?: boolean; customNextFunction?: () => void; } const props = withDefaults(defineProps<Props>(), { isCustomAuthDoneNextProcess: false }); const { isCustomAuthDoneNextProcess } = toRefs(props); const userStore = useUserStore(); const isLogin = computed(() => userStore.user.userId); async function miniAppLoginDecorator(res: ButtonOnGetphonenumberEvent) { miniAppLogin( res, isCustomAuthDoneNextProcess.value ? props.customNextFunction : undefined ); } </script> <template> <view v-if="!isLogin" class="relative"> <slot /> <view class="absolute left-0 top-0 h-full w-full opacity-0"> <!-- #ifdef MP-WEIXIN --> <button open-type="getPhoneNumber" class="h-full w-full" @getphonenumber="miniAppLoginDecorator" > 登录 </button> <!-- #endif --> </view> </view> <slot v-else /> </template>
这便是我的做法,通过登录标识判断是否登录了,如果登录了之后则渲染原来的组件,否则给button做绝对定位覆盖在插槽上
4. 跨平台Login登录,在最开始的项目中因为要做微信/支付宝/ios(后来废弃)的登录,那么我就要统一入口,根据条件编译实现多平台的代码,代码如下:
import { login } from "@uni-helper/uni-promises"; import { get } from "lodash-es"; import { postMiniAppLogin, postMiniAppPhone } from "@/api/me"; import { useGlobalStore } from "@/stores/useGlobalStore"; import { alipayGetPhone, alipayLogin } from "@/api/login"; // #ifdef MP-WEIXIN export interface GetPhoneNumberArguments { detail: { [key in "iv" | "encryptedData" | "errMsg" | "code"]: string; }; } // #endif /**@description 后端为了兼容APP获取关注微信公众号获取用户手机号的逻辑,增加了一个备用字段 */ export async function loginAndGetToken(payload = {}) { // #ifdef MP-WEIXIN await loginAndGetTokenWeixin(payload); // #endif // #ifdef MP-ALIPAY await loginAndGetTokenAlipay(); // #endif } // #ifdef MP-WEIXIN async function loginAndGetTokenWeixin(payload = {}) { const globalStore = useGlobalStore(); const globalState = globalStore.state; const wxloginCode = await uni.login(); const miniAppRes = await postMiniAppLogin( wxloginCode.code, globalState.appId, payload, ); if (get(miniAppRes.data, "data.token")) uni.setStorageSync("TOKEN", miniAppRes.data.data.token); } // #endif // #ifdef MP-ALIPAY async function loginAndGetTokenAlipay() { const globalStore = useGlobalStore(); const globalState = globalStore.state; const aliloginCode = await login(); const res = await alipayLogin(globalState.appId, aliloginCode.code); if (get(res.data, "data.token")) uni.setStorageSync("TOKEN", res.data.data.token); } // #endif /** * * @param e 这里是只有微信小程序才会有回调函数 * @param next 这里是为了复用登录逻辑,但是想打断绑定手机号之后跳转其他页面的逻辑 */ export async function miniAppLogin(e?: AnyObject, next?: () => void) { // #ifdef MP-WEIXIN await miniAppLoginWeixin(e as GetPhoneNumberArguments, next); // #endif // #ifdef MP-ALIPAY await miniAppLoginAlipay(next); // #endif } // #ifdef MP-WEIXIN async function miniAppLoginWeixin( res?: GetPhoneNumberArguments, next?: () => void, ) {
// 微信端的实现 } // #endif // #ifdef MP-ALIPAY async function miniAppLoginAlipay(next?: () => void) {
await loginAndGetTokenAlipay();
// 支付宝端的实现
}
// #endif
说完用户登录与信息获取,再来说一下常用的场景,比如数据列表,做C端经常会遇到这种场景,那么要封装一个统一的组件来处理,因为在小程序中,写一套触底加载,下拉刷新,数据列表渲染真的很累,所以设计一个泛型组件来实现这个功能是非常合适的,我这里的实现方案如下:
<script lang="ts" setup generic="T"> import { loadingRequestDecorator } from '@/utils/common' import { cloneDeep, get } from 'lodash-es' interface TypeResponse { list: Array<T> total: number } interface Props { height: string scrollClassNames?: string immediate?: boolean load: (params: { page: number; size: number }) => Promise<TypeResponse> } const props = withDefaults(defineProps<Props>(), { immediate: true, }) const { height } = toRefs(props) const loading = ref(false) const currentPage = ref(1) const currentSize = ref(10) const hasNext = ref(true) const total = ref(0) const refreshing = ref(false) const data = shallowRef<TypeResponse['list']>([]) /** * @description 加载数据 */ async function loadData() { if (!loading.value) { // 这里是 如果是 列表没有数据的时候才会给他设置为true,分页加载数据的时候没必要展示骨架屏 loading.value = data.value.length === 0 loadingRequestDecorator(async () => { const list = await props.load({ page: currentPage.value, size: currentSize.value, }) // 表示刷新,则覆盖数据 if (refreshing.value) { data.value = list.list refreshing.value = false } else { data.value = [...data.value, ...list.list] } total.value = list.total hasNext.value = total.value > data.value.length setTimeout(() => { loading.value && (loading.value = false) }, 10) }, '加载失败') } } function onReachBottom() { if (hasNext.value) { currentPage.value += 1 loadData() } } async function onRefresh() { refreshing.value = true currentPage.value = 1 await loadData() } async function exposeReset() { currentPage.value = 1 currentSize.value = 10 data.value = [] refreshing.value = false total.value = 0 hasNext.value = true loading.value = false await loadData() // #ifdef MP-ALIPAY uni.stopPullDownRefresh() // #endif } function updateOne(callback: (item: AnyObject) => TypeResponse['list']) { const newDataList = callback(data.value) data.value = newDataList } /**@description 获取的是拷贝的数据,不会有响应式数据*/ function getUnRefList() { return cloneDeep(data.value) } /**@description 全量数据更新 */ async function onAllListUpdate() { try { const response = await props.load({ page: 1, size: data.value.length, }) const newTotal = get(response, 'total', 0) const list = get(response, 'list', []) const pageNewNum = Math.ceil(list.length / 10) // 向上取整 currentPage.value = pageNewNum total.value = newTotal data.value = list } catch (e) { console.warn('scrollLoadData:onAllListUpdate 更新接口失败!') console.log('error:', e) } } defineExpose({ reset: exposeReset, updateOne, getUnRefList, onAllListUpdate }) onMounted(() => { if (props.immediate) loadData() }) </script> <template> <scroll-view :class="scrollClassNames || ''" :refresher-enabled="true" :refresher-triggered="refreshing" :scroll-y="true" :style="{ height }" @refresherrefresh="onRefresh" @scrolltolower="onReachBottom" > <slot :data="data" :loading="loading" /> </scroll-view> </template>
因为支付宝小程序不支持scroll-view的下拉刷新,所以这里做兼容处理,支持自动、手动获取数据,单数据更新,重置等功能,使用起来也是非常简单,只需要提供一个load函数,与一些简单配置即可,使用示例:
<scroll-load-data ref="consumeRef" :load="getList" :height="scrollHeight"> <template #default="{ data }"> <div class="grid grid-gap-24rpx" v-if="data.length"> <currency-document v-for="i in data" :title="computedTitle(i.type)" :time="formatDate(i.createTime)" type="consume" :operateAmount="i.operateAmount" > <span>沟通求职者:{{ i.workerInfo.name }}</span> </currency-document> </div> <div v-else class="mt-60rpx"> <empty-state> 暂无数据 </empty-state> </div> </template> </scroll-load-data>
在做复杂条件判断的时候尽量使用策略模式来做,尤其是很多条件那种,这个例子是计算哪些日期在业务上是可拖动的逻辑,
// 根据入参的x坐标和y坐标,计算当前在x轴第几项与y轴第几项 const calculatePoint = () => { const touchOrder = getTouchOrder(lastTouchPoint.value); // 这里则代表该点是逻辑可选的,但是并不代表业务可选 if (touchOrder && touchOrder.isCanReceive) { const validatePipe = [ isOverRangeOrEndPointIfStartPointPass, isOverRangeOrEndPointIfEndPointPass ]; const isValid = validatePipe.every(validateFn => validateFn(touchOrder)); if (!isValid) { return; // return showToast({ title: '请选择一个可用的时间', icon: 'none' }); } // 校验通过后,更新当前选中的时间段 if (currentDragOrderIsStartTime.value) { // currentStartTime.value = touchOrder.time; emit('update:currentStartTime', touchOrder.time); } else { // currentEndTime.value = touchOrder.time; emit('update:currentEndTime', touchOrder.time); } } };

针对TS的一些经验,目前我在项目中针对Api接口等非vue文件中的类型定义,统一放在dto文件夹下,针对常用类型,比如ResponseBody写在dto/common.dto.ts文件下
export interface ResponseBody<T> { code: number; msg: string; data: T; } export interface OssDto { accessKeyId: string; policy: string; signature: string; dir: string; host: string; callback: string; expire: string; } export interface OssDtoData { code: number; msg: string; data: OssDto; } export interface Poi { address: string; city: string; cityCode: number; district: string; districtCode: number; lng: number; lat: number; province: string; title: string; } export interface Pager { page: number; size: number; }
类型的一些使用经验,要善于使用内置工具类型,如Pick,Partial,Record 等类型,好的类型定义让代码结构更清晰,取interface中的一个字段的类型,可以使用 User['name'] 这种方式,取数组元素可以使用 UserList[number]这种

要约束字符串类型,可以使用字符串字面量类型,也可以使用模板字符串类型等等
今天先写到这把,小程序会让人变得不幸。。。
2025年小程序开发实战总结
1214

被折叠的 条评论
为什么被折叠?



