从前有匹马叫代码
心若没有栖息的地方,到哪里都是流浪

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

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

45b0e332d7adce47d7269ec17ecdc255

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

吐槽完成之后,来总结下从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);
    }
  }
};

image

 

 

 针对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]这种

image

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

 

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

 

posted on 2025-12-02 15:07  从前有匹马叫代码  阅读(156)  评论(3)    收藏  举报