小程序开发实践

66 阅读16分钟

一、背景

正值创新的技术氛围下,团队获得了开发创意项目的契机。本项目聚焦于手游《三国:谋定天下》(简称“三谋”)。作为一款以三国为背景的深度策略游戏,“三谋”的核心机制高度依赖玩家间的协作,其中同盟系统是玩家参与资源争夺、城池攻防的核心单位,同盟的战斗力与成员协作效率直接决定了游戏体验的成败。然而,游戏现有的同盟管理功能存在显著限制:

  1. 成员战力评估缺失:盟主审核成员时仅能查看模糊数据(如征战天数、最高繁荣度及排名、最高武勋及排名),缺乏类似《王者荣耀》段位制的明确战力等级标识,导致难以精准判断申请人是否符合团队核心战力需求。
  2. 招贤信息不透明:招贤帖提供的信息过于简略,玩家在申请加入时无法充分展示自身能力,同盟方也缺乏足够依据进行筛选。
  3. 离线数据不同步:玩家处于未登录状态时,系统无法实时同步其同盟贡献值与个人积分数据,影响盟务管理的及时性与公平性。

二、目标

基于三国谋定天下同盟管理的核心痛点,本项目旨在开发一款平台型小程序,实现以下目标:

  • 构建跨游戏社区平台,开发轻量化小程序,为《三国:谋定天下》等热门游戏玩家提供统一社区入口。小程序无需下载安装,可快速触达用户,降低使用门槛;同时支持多游戏扩展,未来可接入同类游戏生态,形成玩家资源共享平台。

  • 针对性解决三谋同盟痛点

    • 个人战力深度分析:整合游戏内分散数据(如繁荣度、武勋值、风华值、征战天数等),通过算法生成可视化战力评估报告,帮助盟主精准识别成员能力短板与优势。
    • 合理化招人组队系统:设计双向筛选功能,允许玩家发布带详细战力标签的招贤帖,同盟可按需求(如战力阈值、活跃时段)定向招募,提升组队效率与匹配精度。
    • 小程序内等级制度:建立独立于游戏官方的玩家能力分级体系(如“万抽八阶”、“极一境”等),结合历史贡献与实时数据动态调整等级,为盟务分工提供清晰依据。
    • 强登录控制与数据同步:通过账号绑定与定时拉取机制,实现离线期间贡献值/积分的自动记录与同步,确保盟主随时掌握成员活跃度与贡献排名。(由于微信小程序限制,暂未开启该功能)

三、页面最终展示

先来看看,小程序最终的面貌,小程序的主要页面如下所示:

四、技术选型

框架语法体系多端支持优点缺点开发成本
Rax类 React全平台(阿里(支付宝、手淘、钉钉等)及微信、百度、字节跳动、快手)1. 公司内部有成熟项目经验,可复用组件 2. 类 React 语法,团队上手快 3. 构建部署流程轻量,迭代迅速1. 官方已停止维护 2. 第三方库适配差(如 ECharts 需手动封装) 3. 社区生态薄弱低(现有经验可复用)
Taro类 React/Vue全平台1. 社区活跃,文档完善 2. 插件生态丰富(含 ECharts 官方支持)1. 多端适配需额外配置 2. 复杂项目构建速度较慢中(需学习多端规范)
Uni-appVue 语法全平台1. 开发效率高,H5 转小程序无缝衔接 2. 官方维护积极,更新频繁1. Vue 2 语法为主,Vue 3 支持渐进 2. 深度定制能力受限中(依赖 Vue 技术栈)
原生开发各平台独立语法仅单一平台1. 性能最优,无兼容性问题 2. 官方工具链完整1. 多平台需重复开发 2. 人力与时间成本极高高(需要高学习成本)

因此选择Rax的核心考量基于三重现实因素:

  • 开发效率优先:团队具备70%框架复用率与React开发经验,可立即投入编码,能够快速完成MVP版本构建;
  • 成本控制明确:轻量化构建流程避免多端适配开销,部署速度较Taro/Uni-app提升40%;
  • 风险边界清晰:仅20%额外工时用于封装ECharts等组件,同时通过架构解耦预留Taro迁移路径。 选型本质是在时效性约束下,以可控技术债换取开发速度最大化。

五、调试工具

微信开发者工具

优势:

实时双端预览:同步显示模拟器与真机效果,修改代码秒级刷新;

深度调试能力;

云真机测试:直接扫描二维码连接实体设备,验证性能瓶颈

5.1 代码依赖分析功能:

部署时整体包大小不能超过2MB,从该功能能够清晰解析依赖关系,观察到现有包体积,便于给出分包策略。

5.2 开发者工具

  • 接口Timing

字段含义
Queueing请求文件顺序的排序。浏览器是有线程限制的,发请求也不能所有的请求同时发送,会将请求加入队列中(Chrome的最大并发连接数是6)。此参数表示从添加到待处理队列,到实际开始处理的时间间隔标示。
Stalled(阻塞)浏览器得到要发出这个请求的指令,到请求可以发出的等待时间,一般是代理协商、以及等待可复用的TCP连接释放的时间,不包括DNS查询、建立TCP连接等时间等。浏览器对同一个主机域名的并发连接数有限制,因此如果当前的连接数已经超过上限,那么其余请求就会被阻塞,等待新的可用连接;此外脚本也会阻塞其他组件的下载;
Proxy negotiation浏览器与代理服务器进行协商的过程
DNS Lookup请求某域名下的资源,浏览器需要先通过DNS解析器得到该域名服务器的IP地址。在DNS查找完成之前,浏览器不能从主机名那里下载到任何东西。DNS查询的时间,当本地DNS缓存没有的时候,这个时间可能是有一段长度的,但是比如你一旦在host中设置了DNS,或者第二次访问,由于浏览器的DNS缓存还在,这个时间就为0了
Initial connection建立TCP连接的时间,就相当于客户端从发请求开始到TCP握手结束这一段,包括DNS查询+Proxy时间+TCP握手时间。
SSL(包含于HTTPS连接中)http的四次握手(感兴趣的同学可以去看详细过程,这里不过多赘述)
Request sent(发送请求)发送HTTP请求的时间(从第一个字节发出前到最后一个字节发出后的时间)
Waiting(TTFB)请求发出后,到收到响应的第一个字节所花费的时间(Time To First Byte),发送请求完毕到接收请求开始的时间;通常是耗费时间最长的。从发送请求到收到服务器响应的第一字节之间的时间,受到线路、服务器距离等因素的影响。注意:网页重定向越多,TTFB越高,所以要减少重定向
Content Download(下载)收到响应的第一个字节,到接受完最后一个字节的时间,就是下载时间

在实际开发中,针对不同指标的耗时,会有不同的解决策略

  1. TTFB过大:使用CDN,将用户的访问指向距离最近的工作正常的缓存服务器上,由缓存服务器直接响应用户请求,提高响应速度;
  2. Content Download过大:先判断是否是返回结果内容过大,再确认返回的数据是否都能用上,是否需要对返回数据的字段进行优化,去掉一些不必要的数据
  • Performance

六、业务域方案

6.1 Mtop与域名切换

  1. @alife/lxg-request处理跨端mtop请求

    目前小程序的请求协议采用mtop,mtop请求使用 @alife/lxg-request ,该npm包底层抹平了多种请求能力调用差异,统一出参、错误回调。目前支持跨端mtop请求(基于universal-mtop),跨端ajax请求(基于@uni/request),支持扩展请求能力。

  2. 接口环境区分

    使用了微信提供的原生API wx.getAccountInfoSync(), 映射为 具体的dailypreprod三个值进行环境的判断,来请求不同环境下的接口。

  3. 预发环境下的环境切换

但这也下引发的一个问题,由于不同环境wx.getAccountInfoSync()提供的值是固定的,因此会导致预发环境下,无法请求到线上或者日常,本地开发时,可以手动修改request实例中的domain参数,来设置请求的环境(应业务方要求,需要在小程序体验版看一下线上数据)

解决方法:这里感谢红牛提供的调试面板来实现预发环境下的接口环境切换,因好奇看了下具体实现方式:

该工具的底层逻辑是通过@uni/env,判断当前的端类型(如,web、微信小程序、抖音小程序等),将切换环境挂在到全局,再将全局环境变量值加载到mtop中,调取相应接口。牛哥这边还贴心的提供了一个面板,点击即能切换,同时在线上隐藏了切换面板,也解决了分环境构建的问题(无需构建出两个版本,一个是体验版,一个是线上版本)。

6.2 埋点方案

AEM体验管理平台是爱橙科技面向阿里巴巴集团及生态公司提供的一款产品体验数据采集及分析工具,助力业务洞察体验问题,提升产品体验,提供三大能力:

  • 用户行为分析(UBA) —— 覆盖“基础流量分析、页面点击热力分析、单个用户行为细查,群体用户动线分析、表单操作效率分析”多种行为分析场景
  • 应用性能监测(APM) —— 产品稳定性、流畅性实时监控、告警与分析,助力定位产品性能卡点
  • 用户调研(Survey) —— 嵌入式轻量级问卷调研,在产品页面/流程中及时回收用户满意度与反馈,用户属性多维交叉分析,洞察满意度表现背后的原因。

实际上AEM比较偏向于中后台项目的埋点,这里采用的主要原因是如果用APlus,需要找BI捞数据,这里为了方便,直接用AEM进行了简单埋点!

6.2.1 AEM初始化 & 使用

初始化一个AEM实例,并挂载到小程序的全局对象上,保证每次进行数据上报使用同一个AEM实例。

初始化:

import { getGlobal } from '@ali/mini-app-debug';
export function initAem(uid: any, env?: any){
  console.log("初始化AEM的env", env)
  const aes = new AES({
    pid: 'xxx', // 项目id
    user_type: "101",
    uid,
    env,
    debug: true
  });
  const [pv, sendEvent, jserror, api] = aes.use([
    AESPluginPV,
    AESPluginEvent,
    AESPluginJSError,
    AESPluginAPI
  ]);

  const global = getGlobal();
  (global as any).aesTracker = {
    pv,
    sendEvent,
    jserror,
    api,
    aes
  };
}

AEM工具类

// 埋点工具函数
export const getTracker = () => {
  return (global as any).aesTracker;
};

// PV埋点
export const sendPV = () => {
  const tracker = getTracker();
  console.log("【pv埋点】", tracker);
  if (tracker?.pv) {
    tracker.pv.sendPV();
  }
};

// 自定义事件埋点
export const sendEvent = (eventId: string, params?: any) => {
  const tracker = getTracker();
  console.log("【自定义事件埋点】", tracker)
  if (tracker?.sendEvent) {
    tracker.sendEvent(eventId, {
      timestamp: Date.now(),
      ...params
    });
  }
};

// 错误埋点
export const sendError = (error: Error, params?: any) => {
  const tracker = getTracker();
  if (tracker?.jserror) {
    tracker.jserror.sendError(error, params);
  }
};

// API埋点
export const sendApi = (apiInfo: any) => {
  const tracker = getTracker();
  if (tracker?.api) {
    tracker.api.sendApi(apiInfo);
  }
};

// 设置用户ID(用于UV统计)
export const setUserId = (userId: string) => {
  const tracker = getTracker();
  if (tracker?.aes) {
    tracker.aes.setUID(userId);
  }
};

6.2.2 性能埋点方案

指标方案:

整体关注与启动性能和页面性能,来源于wx.getPerformance()(微信小程序性能数据)

指标说明
启动耗时小程序启动耗时,对应wx.performance中的appLaunch
脚本注入script
FR首次渲染耗时,performance中的firstRender
FP首次渲染时间(First Paint
FCP首次内容绘制(First Contentful Paint
LCP最大内容绘制(Largest Contentful Paint
TTI交互可用,用户体感,从页面点击到首页请求完成(用户实际与页面交互事件 - FCP startTime)

上报策略:在app.ts onLaunch 下使用wx小程序提供的getPerformance()API进行上报

import { runApp, IAppConfig } from 'rax-app';
import staticConfig from './app.json';
import {ENTRY_NAME, entryProcessors} from "@/const";
import {sendEvent} from "@/utils/aem";

const appConfig: IAppConfig = {
  app: {
    onLaunch() {
      console.log('app launch');
      wx.setStorageSync('bannerClosed', false);
      // wx.setStorageSync('appLaunchTime', Date.now());
      const performance = wx.getPerformance();
      const observer = performance.createObserver((entryList) => {
        console.log("observe的性能数据", entryList.getEntries())
        const entries = entryList.getEntries();
        let rdata: any = {};
        entries.forEach((item: any) => {
          const processor = entryProcessors[item?.name];
          if (processor) {
            Object.assign(rdata, processor(item));
          }
        })

        if(entries[0]?.name !== ENTRY_NAME.STUTTER){
          sendEvent('performance_analysis', {
            et: 'OTHERS',
            ext: rdata
          })
        }
      })
      observer.observe({ entryTypes: ['render', 'script', 'navigation'] })
    },
    onShow() {

    }
  },
};

runApp(appConfig, staticConfig);

6.3 分包策略

为应对微信小程序主包与分包均受限于 2MB 体积的约束,我采用了精细化的分包策略:将 tabBar 页面及其关联的跳转页面分别划分为独立分包,并启用 “分包公共模块抽取” 能力。通过该机制,被两个及以上分包共同依赖的公共模块(如组件、工具函数、基础库等)将自动提取至主包中,作为共享资源统一管理。此举显著减少了各分包的冗余代码,有效控制了单个分包体积,同时保障了加载性能与架构可维护性。

分包目录如下:

sanmou
├─ README.md
├─ README.png
├─ abc.json
├─ hyrule.mini.json
├─ package-lock.json
├─ package.json
├─ project.config.json
├─ public
├─ src
│  ├─ Layout
│  ├─ api
│  ├─ app.json
│  ├─ app.ts
│  ├─ components
│  ├─ const
│  ├─ env.ts
│  ├─ hooks
│  ├─ models
│  ├─ pages
│  │  ├─ Home
│  │  │  └─ index.tsx
│  │  ├─ Sanmou
│  │  ├─ Shilaike
│  │  └─ baxia
│  │     └─ webview
│  │        ├─ baxia.wxml
│  │        ├─ index.js
│  │        ├─ index.json
│  │        ├─ index.wxml
│  │        └─ index.wxss
│  ├─ store.ts
│  ├─ styles
│  │  └─ global.module.css
│  ├─ typings.d.ts
│  └─ utils
└─ tsconfig.json

6.4 webview加载H5

同时考虑到部分业务页面逻辑复杂度高、迭代频繁或跨端复用需求强等场景,我们在小程序架构中引入了 WebView 容器化方案:将特定高复杂度功能模块封装为独立的 H5 页面,由小程序通过 组件进行嵌入式加载。该设计不仅有效隔离了复杂业务逻辑对原生小程序包体积和主包性能的影响,还提升了前端资源的可维护性与跨平台复用能力(如 Web、H5、多端小程序共享同一套实现)。同时,通过标准化的通信协议(基于 postMessage 与 URL 参数)实现小程序与 H5 之间的安全、可控数据交互,兼顾灵活性与工程规范性。

下面是封装好的web-view组件,以及对其的简单使用。

import { createElement, useCallback, useEffect, useState } from 'rax';

/**
 * 简化版 Webview 基类 - 只保留通信能力
 * @param {object} pageConfig 页面配置信息
 * @param {string} pageConfig.targetUrl 目标地址
 * @param {string} pageConfig.webViewId webview唯一标识
 * @param {object} pageConfig.urlQueryParams URL查询参数
 * @param {object} pageConfig.customMethods 自定义方法映射
 */
function BaseWebview({ pageConfig }) {
  const { targetUrl, webViewId, urlQueryParams, customMethods = {} } = pageConfig;

  const [url, setUrl] = useState(targetUrl);
  console.log(998, url)
  const webViewContext = typeof my !== 'undefined' ? my.createWebViewContext(webViewId) : null;

  /**
   * 统一的方法调用处理
   */
  const callMethod = (postMsg, callback, methodMap = {}) => {
    const { eventType, eventId, data } = postMsg;

    if (typeof callback !== 'function') {
      callback = () => {};
    }

    console.log('BaseWebview callMethod:', { eventType, data, webViewId });

    if (typeof eventType !== 'string') {
      return callback({
        success: false,
        msg: 'METHOD_INVALID_PARAM',
        eventId
      });
    }

    // 优先检查自定义方法
    if (typeof methodMap[eventType] === 'function') {
      methodMap[eventType](data, callback, eventId);
    } else {
      // 默认方法处理
      handleDefaultMethods(eventType, data, callback, eventId);
    }
  };

  /**
   * 默认通信方法
   */
  const handleDefaultMethods = (eventType, data, callback, eventId) => {
    const defaultMethods = {
      // 页面跳转
      navigateTo: () => {
        const { url } = data;
        if (url && typeof my !== 'undefined') {
          my.navigateTo({ url });
          callback({ status: 'success', eventId });
        } else {
          callback({ status: 'fail', msg: 'Invalid URL', eventId });
        }
      },

      // 页面返回
      navigateBack: () => {
        if (typeof my !== 'undefined') {
          my.navigateBack();
          callback({ status: 'success', eventId });
        } else {
          callback({ status: 'fail', msg: 'Method not available', eventId });
        }
      },

      // 显示Toast
      showToast: () => {
        const { title, icon = 'none', duration = 1500 } = data;
        if (typeof my !== 'undefined') {
          my.showToast({ title, icon, duration });
          callback({ status: 'success', eventId });
        } else {
          callback({ status: 'fail', msg: 'Method not available', eventId });
        }
      },

      // 显示Loading
      showLoading: () => {
        const { title = '加载中...' } = data;
        if (typeof my !== 'undefined') {
          my.showLoading({ title });
          callback({ status: 'success', eventId });
        }
      },

      // 隐藏Loading
      hideLoading: () => {
        if (typeof my !== 'undefined') {
          my.hideLoading();
          callback({ status: 'success', eventId });
        }
      },

      // 获取系统信息
      getSystemInfo: () => {
        if (typeof my !== 'undefined') {
          my.getSystemInfo({
            success: (res) => {
              callback({
                status: 'success',
                eventId,
                data: res
              });
            },
            fail: (err) => {
              callback({
                status: 'fail',
                eventId,
                msg: err.errorMessage || 'Get system info failed'
              });
            }
          });
        } else {
          callback({ status: 'fail', msg: 'Method not available', eventId });
        }
      },

      // 设置导航栏标题
      setNavigationBarTitle: () => {
        const { title } = data;
        if (title && typeof my !== 'undefined') {
          my.setNavigationBarTitle({ title });
          callback({ status: 'success', eventId });
        } else {
          callback({ status: 'fail', msg: 'Invalid title', eventId });
        }
      },

      // 获取当前页面URL
      getCurrentUrl: () => {
        callback({
          status: 'success',
          eventId,
          data: {
            url,
            webViewId,
            urlQueryParams
          }
        });
      },

      // 更新当前URL
      updateUrl: () => {
        const { newUrl } = data;
        if (newUrl) {
          setUrl(newUrl);
          callback({ status: 'success', eventId });
        } else {
          callback({ status: 'fail', msg: 'Invalid URL', eventId });
        }
      }
    };

    if (typeof defaultMethods[eventType] === 'function') {
      defaultMethods[eventType]();
    } else {
      callback({
        status: 'fail',
        msg: 'METHOD_NOT_SUPPORT',
        eventId
      });
    }
  };

  /**
   * 处理H5发送的消息
   */
  const onMessage = useCallback((event) => {
    const messageData = event.detail || {};

    // 创建回调函数
    const postMessage = useCallback((params) => {
      if (webViewContext?.postMessage) {
        webViewContext.postMessage(params);
      }
    }, []);

    // 调用方法处理
    callMethod(messageData, postMessage, customMethods);
  }, [customMethods, webViewContext]);

  return (
    <web-view
      id={webViewId}
      src={url}
      onMessage={onMessage}
    />
  );
}

export default BaseWebview;
// import { config } from 'rax-app';
// import { getUrlQueryParams } from '@/utils/url';
import BaseWebview from '@/pages/Login/pages/Test';
import getUrlQuery, {buildUrlWithParams, getEnv} from "@/utils";
import {WEB_VIEW_URL} from "@/const";

function SimpleWebview(ctx) {
  const urlQueryParams = {
    // a: "test",
    // b: 123,
    // c: 'www'
  };

  const env = getEnv();
  const url = buildUrlWithParams('xxx', urlQueryParams);

  // 配置webview参数
  // ctx.pageConfig.urlQueryParams = {a: 'test'};
  ctx.pageConfig.targetUrl = url;
  ctx.pageConfig.webViewId = 'simple_webview';

  return BaseWebview(ctx);
}

export default SimpleWebview;

7 遇到的坑

7.1 canvas渲染层级过高

问题描述:随屏幕滚动,canvas始终处于固定位置(不随屏幕滚动而滚动)

解决思路:google上 关于小程序canvas层级过高 的的解决方案,大致分为两种:

  1. 将canvas标签替换为图片
  2. 使用cover-view(小程序层级最高的标签)去覆盖canvas标签:
    关于cover-view的小程序官方文档

两种方案的不足之处:

方案一:需要等待canvas渲染完成后再生成图片去替换,且canvas标签不能使用display:none;或 opacity: 0;去隐藏,所以这会导致在canvas渲染完成后仍然会闪动一下再切换成图片显示。

方案二:要修改被覆盖的标签,将其层级提到最高。在阅读官方文档后,我就把页面底部的tab栏的view标签替换成了cover-view,结果导致icon消失了…这又是一个bug?

“所以这就结束了?哦,不!,乔治,我们不能就这样放弃!”

“有时候,生活会出其不意地带给我们灵感” ——鲁迅

最终方案:将canvas标签挪到用户不可见的区域,在渲染时用loading动画代替,渲染完成后再将loading动画替换成图片!(具体可以参考代码)

{/* Canvas标签移到视口外,只在没有图片时显示 */}
      {!canvasImg && (
        <canvas
          id={canvasId}
          canvas-id={canvasId}
          style={{
            width: '100%',
            height: '100%',
            opacity: 0,
            position: 'absolute',
            left: '-1000rpx',
          }}
        />
      )}
setTimeout(() => {
      // 修复wx类型问题,使用类型断言
      (wx as any).canvasToTempFilePath({
        x: 0,
        y: 0,
        width: 150,
        height: 230,
        canvasId: canvasId,
        success: (res) => {
          setCanvasImg(res.tempFilePath);
          setIsLoading(false);
          console.log('canvasImg', res.tempFilePath);
        },
        fail: (error) => {
          console.error('生成图片失败:', error);
          setIsLoading(false);
        }
      });
    }, 1000);
  };