2025年12月的闲谈

2025年12月,接近年底,我准备把我最近一年的开发感悟总结一下

最近一年,我负责的项目主要以多端混合开发为主,以PC端管理系统与配套的H5生态为辅。这段时间中,我发现公司有些同事思考太远,经常会引起不必要的沟通与讨论,可能会持续浪费一个小时的工作时间。(PS备注:关于无效沟通讨论这种事,被大领导开KISS会议点名批评过某些同事,我不是空穴来风。)典型的案例就是我目前负责的最新项目,各种考虑深远,各种配套想实现,但现实却带来迎头痛击,小程序被下架。

2025年12月的闲谈

本来可以用最小化核心项目试验,非要搞出很多繁杂的设计步骤,操作过程来耗费多余的开发时间,在业务线试错的背景下,搞一套大而全的东西确实是本末倒置,所以在机会项目的前提下,考虑过于长远并非好的决策。

吐槽完成之后,来总结下从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';  // 很多代码
  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了,为什么选用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, ) {
  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);     }   } };

2025年12月的闲谈

 

 

 针对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年12月的闲谈

 要约束字符串类型,可以使用字符串字面量类型,也可以使用模板字符串类型等等

 

今天先写到这把,小程序会让人变得不幸。。。

 

发表评论

评论已关闭。

相关文章