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

本来可以用最小化核心项目试验,非要搞出很多繁杂的设计步骤,操作过程来耗费多余的开发时间,在业务线试错的背景下,搞一套大而全的东西确实是本末倒置,所以在机会项目的前提下,考虑过于长远并非好的决策。
吐槽完成之后,来总结下从2025年到如今我在开发中的一些经验。
在做小程序项目中,永远不要相信产品经理乃至领导"只做这一端"的鬼话,在我负责第一个跨端项目的时候,领导刚开始说只做微信小程序,然后随着业务的进展,领导又说支付宝小程序有前景,过了一段时间又说,抖音小程序是个趋势,然后又过了一段时间,运营想要小红书小程序...
所以在做技术选型的时候,一定要充分的考虑考虑再考虑,能优先考虑多端技术统一的技术栈就优先考虑,在此,我推荐使用uniapp,网上虽然很多人骂uniapp这不好那不好,但是实际上uniapp在国内的中小企业开发环境下,实在是一个比较好的选择,搭配针对Uniapp的脚手架,uni-helper 或者 uni-best 等上层框架,开发体验会好很多。
再来说一下架构设计这一方面,由于使用了Uniapp这个底层框架,大部分情况下,我们不需要去考虑偏底层的设计,如页面路由怎么选啊,request请求库怎么选啊,数据缓存怎么做啊,身份鉴权怎么做啊等等,由于小程序端的天然限制,大部分都有平台提供的API可以使用,我们只需要针对这些API,做恰到好处的架构设计就可以了,最典型的例子就是:"登录与用户数据获取",这个需要考虑的就比较多,比如登录之后,用户数据怎么同步,未登录的时候,怎么针对用户进行登录,由于产品经理的设计,用户未登录不影响用户查阅数据,而不是跳转到一个专门的登录页面(只针对C端),这里我的解决方案是:pinia / mitt / 无渲染组件 / 跨平台Login逻辑,这里来说明一下:
-
pinia 是为了全局存储用户数据,相信大家都明白,一页项目可能在非常多的页面都要使用用户信息数据,所以这里存全局;
-
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';
// 很多代码
export const subscribeUserUpdate = (fn: () => void) => {
emitter.on(USER_UPDATE_KEY, fn);
};
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了
emitter.emit(USER_UPDATE_KEY);
- 无渲染组件,因为有很多功能是需要用户登录才可以使用的,但是也不能给所有的功能都写一个<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做绝对定位覆盖在插槽上
- 跨平台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,
) {
await loginAndGetToken();
// 微信端的实现
}
// #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]这种

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