本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

简介:本文详细讲解如何使用微信小程序技术实现一个高还原度的仿饿了么点餐界面,涵盖项目架构、页面设计与核心功能实现。项目基于小程序基础文件(app.js、app.json、app.wxss等)搭建整体结构,通过pages目录下的首页、商品详情页、购物车页和订单页实现完整点餐流程。结合utils工具函数处理数据逻辑,lib引入第三方组件提升UI表现,并利用本地Mock数据模拟后端接口,配合Iconfont图标库优化视觉体验。该项目适合希望掌握小程序开发、购物车逻辑实现及交互设计的开发者进行学习与实战。
小程序仿饿了么点餐界面

1. 小程序仿饿了么点餐界面的项目架构设计

项目整体架构设计与模块划分

本项目采用分层架构思想,围绕“页面层—逻辑层—数据层”构建高内聚、低耦合的小程序体系。通过 pages 承载视图展示, utils 封装通用逻辑, components 实现可复用自定义组件, mock api 分离模拟与真实请求,提升开发效率与维护性。目录结构清晰体现关注点分离原则,为后续迭代提供良好扩展基础。

2. 基础模块构建与工具函数封装

在小程序开发中,良好的项目基础架构是保障后续功能扩展和维护效率的核心。尤其是在仿饿了么这类涉及多页面交互、状态管理复杂、数据频繁请求的点餐类应用中,合理的全局配置与通用工具函数设计显得尤为重要。本章将深入剖析如何从零搭建一个高可维护性的基础框架,涵盖 app.json app.js app.wxss 等核心全局文件的配置策略,并系统性地实现一套高效、健壮的 utils 工具库。

通过科学的配置组织和模块化封装,不仅能够提升团队协作效率,还能显著降低代码冗余度,增强异常处理能力,为后续组件化开发和性能优化打下坚实基础。以下内容将逐层展开,结合实际场景中的典型问题,提供具备生产级质量的设计方案与代码实践。

2.1 小程序全局配置文件解析

微信小程序的运行依赖于一系列全局配置文件,这些文件共同定义了项目的结构、行为、样式及开发环境规范。其中最为关键的是 app.json app.js app.wxss project.config.json 四个文件。它们分别承担着路由管理、逻辑初始化、样式统一和工程配置等职责。合理配置这些文件,不仅能确保项目结构清晰,还能有效支持跨端兼容性和团队协同开发。

2.1.1 app.json 中页面路由与窗口样式配置

app.json 是整个小程序的“中枢神经”,它决定了页面注册顺序、导航栏外观、底部标签栏布局以及网络权限等全局设定。其核心字段包括 pages window tabBar permission 等。

以仿饿了么项目为例,首页、分类页、购物车页、个人中心页构成主流程,因此需在 pages 数组中按访问频率排序:

{
  "pages": [
    "pages/index/index",
    "pages/category/category",
    "pages/cart/cart",
    "pages/profile/profile"
  ],
  "window": {
    "navigationBarBackgroundColor": "#07c160",
    "navigationBarTextStyle": "white",
    "navigationBarTitleText": "饿了么精选",
    "backgroundColor": "#f8f8f8",
    "backgroundTextStyle": "light"
  },
  "tabBar": {
    "color": "#999",
    "selectedColor": "#07c160",
    "borderStyle": "black",
    "backgroundColor": "#fff",
    "list": [
      {
        "pagePath": "pages/index/index",
        "text": "首页",
        "iconPath": "assets/icons/home.png",
        "selectedIconPath": "assets/icons/home-active.png"
      },
      {
        "pagePath": "pages/category/category",
        "text": "分类",
        "iconPath": "assets/icons/category.png",
        "selectedIconPath": "assets/icons/category-active.png"
      },
      {
        "pagePath": "pages/cart/cart",
        "text": "购物车",
        "iconPath": "assets/icons/cart.png",
        "selectedIconPath": "assets/icons/cart-active.png"
      },
      {
        "pagePath": "pages/profile/profile",
        "text": "我的",
        "iconPath": "assets/icons/profile.png",
        "selectedIconPath": "assets/icons/profile-active.png"
      }
    ]
  },
  "style": "v2",
  "sitemapLocation": "sitemap.json"
}
参数说明:
  • "pages" :指定所有注册页面路径, 数组顺序影响编译优先级
  • "window" :控制每个页面顶部导航栏的颜色、标题文字风格等视觉属性。
  • "tabBar" :用于底部常驻菜单栏,提升用户体验一致性;图标建议使用 80x80px 的 PNG 格式图像。
  • "style": "v2" :启用新版组件样式,避免部分旧版渲染 bug。

此外,在真实项目中应根据业务需求动态调整 subPackages (分包加载)策略,例如将订单相关页面放入独立子包以减少主包体积:

"subPackages": [
  {
    "root": "packageOrder",
    "pages": [
      "pages/order-list",
      "pages/order-detail"
    ]
  }
]

分包机制可使首屏加载时间缩短 30% 以上,尤其适用于功能较多的外卖平台。

流程图:页面加载与路由跳转机制
graph TD
    A[启动小程序] --> B{检查 app.json}
    B --> C[读取 pages 列表]
    C --> D[注册所有页面]
    D --> E[设置 tabBar 导航]
    E --> F[显示首个页面 index]
    F --> G[用户点击 tabBar]
    G --> H[触发 wx.switchTab()]
    H --> I[跳转对应页面并保持栈底]
    F --> J[用户点击普通链接]
    J --> K[调用 wx.navigateTo()]
    K --> L[压入新页面至堆栈]

该流程体现了小程序基于堆栈的页面管理模式: switchTab 跳转不可携带参数且清空前序非 tabBar 页面,而 navigateTo 支持传参但不能跳转到 tabBar 页面。理解这一机制对后续页面通信至关重要。

2.1.2 app.js 中全局状态管理与生命周期初始化

app.js 是小程序的入口逻辑文件,负责全局数据定义、用户登录状态获取、API 基础配置初始化等工作。借助 getApp() 方法,各页面可安全访问共享状态。

典型的 app.js 实现如下:

// app.js
App({
  globalData: {
    userInfo: null,
    token: '',
    cartList: [], // 模拟购物车数据
    hasLogin: false
  },

  onLaunch() {
    const that = this;
    // 检查登录状态
    wx.getStorage({
      key: 'user_token',
      success(res) {
        that.globalData.token = res.data;
        that.checkUserValid(); // 验证 token 是否过期
      },
      fail() {
        console.log('未检测到登录信息');
      }
    });

    // 获取设备信息用于埋点
    wx.getSystemInfo({
      success(res) {
        that.globalData.systemInfo = res;
      }
    });
  },

  checkUserValid() {
    wx.request({
      url: 'https://api.example.com/v1/user/verify',
      method: 'POST',
      header: { 'Authorization': 'Bearer ' + this.globalData.token },
      success: (res) => {
        if (res.data.code === 200) {
          this.globalData.hasLogin = true;
          this.getUserInfo();
        } else {
          wx.removeStorageSync('user_token');
        }
      },
      fail: () => {
        wx.showToast({ title: '网络异常,请重试', icon: 'none' });
      }
    });
  },

  getUserInfo() {
    wx.request({
      url: 'https://api.example.com/v1/user/info',
      success: (res) => {
        this.globalData.userInfo = res.data.data;
      }
    });
  }
});
代码逐行分析:
行号 说明
3-9 定义 globalData 对象,存储跨页面共享的状态变量,如用户信息、购物车列表等
11 onLaunch 在小程序首次启动时执行一次,适合做初始化操作
15-22 从本地缓存读取 token,判断是否已登录;若存在则进行有效性验证
27-38 发起 HTTP 请求校验 token 合法性,成功则标记已登录并拉取用户详情
40-46 单独封装 getUserInfo 方法便于复用

值得注意的是,由于 App 实例在整个生命周期内唯一,因此 globalData 可作为轻量级状态容器使用。但对于大型项目,建议引入 Redux 或 MobX 类的状态管理方案。

同时, onShow onHide 生命周期可用于监听页面前后台切换,常用于统计停留时长或暂停视频播放:

onShow() {
  this.globalData.lastActiveTime = Date.now();
},
onHide() {
  const duration = Date.now() - this.globalData.lastActiveTime;
  console.log(`用户本次活跃时长:${duration}ms`);
}

2.1.3 app.wxss 全局样式定义与主题变量设置

为了实现 UI 风格统一,应在 app.wxss 中集中定义颜色、字体、间距等设计令牌(Design Tokens),并通过变量方式供各页面引用。

/* app.wxss */
/* 主题色 */
@theme-primary: #07c160;
@theme-warning: #ff9900;
@theme-danger: #fa5151;

/* 字体层级 */
@font-title: bold 36rpx/1.4 sans-serif;
@font-subtitle: 30rpx/1.5 "PingFang SC";
@font-body: 28rpx/1.6 system-ui;

/* 布局辅助 */
@mixin flex-center {
  display: flex;
  justify-content: center;
  align-items: center;
}

.container {
  padding: 0 20rpx;
  box-sizing: border-box;
}

.text-primary {
  color: @theme-primary;
}

.btn-primary {
  background-color: @theme-primary;
  color: white;
  padding: 24rpx 60rpx;
  border-radius: 48rpx;
  font-size: 30rpx;
  text-align: center;
}

虽然 WXML 不原生支持 Sass/Less,但可通过构建工具预编译引入变量机制。另一种做法是在 JS 中导出样式常量:

// utils/theme.js
export default {
  primaryColor: '#07c160',
  fontSizeLarge: '36rpx',
  borderRadius: '12rpx'
};

再在 WXML 中结合 style 绑定动态注入:

<view style="color: {{theme.primaryColor}}; font-size: {{theme.fontSizeLarge}}">
  欢迎光临
</view>
推荐样式命名规范(BEM 风格):
类名 含义
.btn 块(Block):按钮基础类
.btn--primary 修饰符(Modifier):主要按钮
.btn__text 元素(Element):按钮内部文本

这种命名方式有助于团队协作,减少样式冲突。

2.1.4 project.config.json 开发环境配置与团队协作规范

project.config.json 是开发者工具专用的本地配置文件,包含项目名称、appid、云开发设置、代码格式化规则等元信息。虽不参与运行,但在团队协作中极为重要。

示例配置片段:

{
  "description": "饿了么克隆项目",
  "packOptions": {
    "ignore": [
      { "type": "folder", "value": "dev-tools" },
      { "type": "file", "value": "README.md" }
    ]
  },
  "setting": {
    "urlCheck": true,
    "es6ToEs5": true,
    "postcss": true,
    "minified": true,
    "uglifyFileName": false,
    "autoAudits": false,
    "bundle": false,
    "compileHotReload": true,
    "useMultiFrameRuntime": true,
    "useStaticServer": true
  },
  "compileType": "miniprogram",
  "libVersion": "3.4.0",
  "appid": "wx1234567890abcdef",
  "projectname": "eleme-mini-program",
  "condition": {}
}
关键字段说明:
字段 作用
packOptions.ignore 打包时排除无关文件,减小上传体积
setting.es6ToEs5 开启 ES6 转 ES5,保证低版本兼容
setting.postcss 支持 autoprefixer 自动补全 CSS 前缀
compileHotReload 启用热更新,修改后无需重新编译
useStaticServer 使用本地静态服务器调试网络请求

团队开发中,建议将此文件纳入 Git 版本控制,并配合 .gitignore 忽略个人临时文件(如 mini_program_devtools/ )。此外,可结合 ESLint + Prettier 统一代码风格:

// .prettierrc
{
  "semi": false,
  "singleQuote": true,
  "tabWidth": 2,
  "trailingComma": "es5"
}

最终形成标准化开发流水线:编辑 → 格式化 → 编译 → 提交 → 审核发布。

2.2 utils 工具库的设计与实践

随着项目复杂度上升,重复的逻辑处理(如时间格式化、价格计算、缓存读写)会大量出现。为此必须建立一个独立的 utils 目录,封装高频使用的纯函数工具集,提升代码复用率与测试覆盖率。

2.2.1 数据处理函数:时间格式化、价格计算与字符串校验

常用的数据处理函数应具备无副作用、输入输出明确的特点。以下是几个典型实现:

时间格式化工具( date.js
// utils/date.js
export function formatDate(date, fmt = 'yyyy-MM-dd hh:mm') {
  if (!date) return '';
  const d = new Date(date);
  const o = {
    'M+': d.getMonth() + 1,
    'd+': d.getDate(),
    'h+': d.getHours(),
    'm+': d.getMinutes(),
    's+': d.getSeconds(),
    'q+': Math.floor((d.getMonth() + 3) / 3),
    'S': d.getMilliseconds()
  };
  for (let k in o) {
    if (new RegExp(`(${k})`).test(fmt)) {
      const str = o[k].toString();
      fmt = fmt.replace(RegExp.$1, RegExp.$1.length === 1 ? str : ('00' + str).substr(str.length));
    }
  }
  return fmt;
}

// 使用示例
console.log(formatDate(new Date(), 'yyyy年MM月dd日')); // 2025年04月05日

逻辑分析 :利用正则匹配模板中的占位符(如 yyyy ),替换为实际数值,并自动补零。支持毫秒级精度。

价格计算(防浮点误差)
// utils/number.js
export function add(a, b) {
  const factor = Math.pow(10, 10);
  return (Math.round(a * factor) + Math.round(b * factor)) / factor;
}

export function multiply(a, b) {
  const factor = 10 ** (decimalDigits(a) + decimalDigits(b));
  return Math.round(a * factor * b) / factor;
}

function decimalDigits(num) {
  return (num.toString().split('.')[1] || '').length;
}

浮点运算误差是前端常见陷阱,例如 0.1 + 0.2 !== 0.3 。上述方法通过放大倍数转换为整数运算规避问题。

字符串校验(手机号、邮箱)
// utils/validate.js
export const validators = {
  isPhone(str) {
    return /^1[3-9]\d{9}$/.test(str.trim());
  },
  isEmail(str) {
    return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(str.trim());
  },
  isNonEmpty(str) {
    return str && typeof str === 'string' && str.trim().length > 0;
  }
};

这些函数可在表单提交前统一调用,提高安全性。

2.2.2 网络请求封装:基于 Promise 的 HTTP 请求拦截与错误处理

原始 wx.request API 回调嵌套深、缺乏统一错误处理。应封装成 Promise 形式并加入拦截器机制。

// utils/request.js
const BASE_URL = 'https://api.example.com';

class HttpRequest {
  constructor() {
    this.interceptors = {
      request: [],
      response: []
    };
  }

  use(interceptor) {
    this.interceptors.request.push(interceptor);
  }

  async request(options) {
    let config = { ...options };

    // 请求拦截
    for (let fn of this.interceptors.request) {
      config = await fn(config);
    }

    return new Promise((resolve, reject) => {
      wx.request({
        ...config,
        url: BASE_URL + options.url,
        header: {
          'Content-Type': 'application/json',
          ...(options.header || {})
        },
        success: (res) => {
          if (res.statusCode >= 200 && res.statusCode < 300) {
            resolve(res.data);
          } else {
            wx.showToast({ title: '请求失败', icon: 'none' });
            reject(res);
          }
        },
        fail: (err) => {
          wx.showToast({ title: '网络错误', icon: 'none' });
          reject(err);
        }
      });
    });
  }

  get(url, data) {
    return this.request({ url, method: 'GET', data });
  }

  post(url, data) {
    return this.request({ url, method: 'POST', data });
  }
}

// 拦截器示例:添加 token
HttpRequest.prototype.use(async (config) => {
  const token = wx.getStorageSync('user_token');
  if (token) {
    config.header = { ...config.header, Authorization: `Bearer ${token}` };
  }
  return config;
});

export default new HttpRequest();

优势 :支持链式调用、统一异常提示、自动注入认证信息,极大简化业务层代码。

2.2.3 缓存操作工具:本地存储的读写封装与过期机制实现

微信 wx.setStorage 默认无过期机制,需自行实现 TTL 控制。

// utils/storage.js
export function setWithExpire(key, value, ttlMs) {
  const record = {
    value,
    expire: Date.now() + ttlMs
  };
  wx.setStorageSync(key, JSON.stringify(record));
}

export function getWithExpire(key) {
  const raw = wx.getStorageSync(key);
  if (!raw) return null;

  try {
    const record = JSON.parse(raw);
    if (record.expire > Date.now()) {
      return record.value;
    } else {
      wx.removeStorageSync(key); // 自动清理过期项
      return null;
    }
  } catch (e) {
    return null;
  }
}

示例:将商品列表缓存 5 分钟

setWithExpire('home_goods_list', goodsData, 5 * 60 * 1000);

2.2.4 日志输出与调试辅助函数在开发中的应用

生产环境下应屏蔽敏感日志,但开发阶段需要详细追踪。

// utils/logger.js
const ENV = 'development'; // 可通过构建变量注入

export function log(...args) {
  if (ENV === 'development') {
    console.log('[DEBUG]', new Date().toISOString(), ...args);
  }
}

export function warn(...args) {
  console.warn('[WARN]', ...args);
}

export function error(...args) {
  console.error('[ERROR]', ...args);
}

结合 wx.getLogManager() 还可上报远程日志用于监控。

表格:常用工具函数汇总
函数名 所属模块 功能描述 使用频率
formatDate date.js 时间格式化输出 ⭐⭐⭐⭐⭐
add/multiply number.js 安全数学运算 ⭐⭐⭐⭐☆
validators.isPhone validate.js 手机号校验 ⭐⭐⭐⭐☆
request.get/post request.js 封装 HTTP 请求 ⭐⭐⭐⭐⭐
setWithExpire storage.js 带过期时间的缓存 ⭐⭐⭐☆☆
log/warn/error logger.js 分级日志输出 ⭐⭐⭐⭐☆

通过以上系统化封装,项目的基础能力得到全面提升,为后续复杂业务逻辑提供了强有力的支撑。

3. 第三方库集成与自定义组件体系搭建

在现代小程序开发中,仅依赖原生能力已难以满足复杂业务场景下的性能、可维护性和开发效率需求。随着项目规模的扩大,如何高效引入第三方工具库并构建一套结构清晰、复用性强的自定义组件体系,成为决定项目成败的关键因素之一。本章节将深入探讨 lib 目录下第三方库的合理引入策略,并系统性地设计和实现一个高内聚、低耦合的组件开发模式。

通过科学选择与封装如 Iconfont、lodash 和 moment.js 等主流工具库,可以显著提升代码健壮性和时间处理精度;同时,基于微信小程序原生组件机制,结合 properties 属性传递、事件通信 triggerEvent 以及插槽 slot 的灵活运用,能够构建出高度可配置、可嵌套、可扩展的 UI 组件库。这不仅提升了团队协作效率,也为后续功能迭代提供了坚实的技术支撑。

更重要的是,在组件层级的设计过程中需充分考虑样式隔离、作用域边界、数据流控制等问题,避免出现“样式污染”或“状态混乱”的典型问题。通过对组件生命周期的理解与合理调度,配合 WXML 结构优化和 WXSS 样式管理策略,最终实现既美观又高效的用户界面交互体系。

3.1 lib 目录中第三方库的引入策略

在小程序工程化实践中, lib 目录常用于存放第三方 JavaScript 库或工具模块。由于小程序运行环境对包体积敏感且不支持 Node.js 模块系统(如 require/import),因此第三方库的引入必须经过严格筛选、裁剪与适配,以确保兼容性与性能最优。

3.1.1 引入 Iconfont 图标库并实现多端兼容的图标组件

阿里巴巴推出的 Iconfont 平台允许开发者将多个 SVG 图标合并为字体文件,便于在 Web 和小程序中统一调用。相较于使用图片资源,字体图标具备无损缩放、颜色可控、体积小巧等优势。

实现步骤:
  1. 注册账号并创建项目
    登录 Iconfont 官网,新建一个图标项目,搜索并添加常用图标(如购物车、定位、星评等)。
  2. 生成字体文件并下载
    在“项目设置”中选择“Unicode”引用方式,下载生成的字体文件( .ttf , .woff , .eot ),保留 .ttf 即可用于小程序。
  3. 导入至项目 lib 目录
    iconfont.ttf 放入 /lib/iconfont/ 目录下,并创建对应的 CSS 映射文件 iconfont.wxss
/* /lib/iconfont/iconfont.wxss */
@font-face {
  font-family: 'iconfont';
  src: url('./iconfont.ttf') format('truetype');
}

.iconfont {
  font-family: "iconfont" !important;
  font-size: 16px;
  font-style: normal;
  -webkit-font-smoothing: antialiased;
}
  1. 全局引入字体样式
    app.wxss 中导入该样式:
@import "./lib/iconfont/iconfont.wxss";
  1. 封装通用图标组件

创建 /components/icon/icon.wxml

<text class="icon {{className}}" style="font-size: {{size}}px; color: {{color}};">
  &#{icon};
</text>

对应 JS 文件 /components/icon/icon.js

Component({
  properties: {
    icon: { type: String, value: '' },       // Unicode 编码,如 'e600'
    size: { type: Number, value: 18 },
    color: { type: String, value: '#333' },
    className: { type: String, value: '' }
  }
});

📌 参数说明
- icon : 字符编码,来自 Iconfont 项目的 Unicode 值(例如 e600
- size : 图标大小(单位 px),动态绑定更灵活
- color : 可控颜色,增强主题适配能力
- className : 外部附加类名,用于进一步定制样式

使用示例:
<icon icon="e600" size="{{24}}" color="#ff6b00" />

此时页面即可渲染出指定图标。此方案实现了跨平台一致性,并可通过构建脚本自动化更新字体文件。

兼容性注意事项:
是否支持 TTF 字体 备注
微信小程序 推荐使用 base64 内联减少请求
H5 需额外处理跨域
App ❌(部分厂商限制) 建议降级为 image

建议将 .ttf 转换为 Base64 字符串内联到 wxss,避免网络请求失败导致图标缺失。

3.1.2 使用 lodash 进行深拷贝、防抖节流等高级操作优化性能

当小程序涉及复杂对象操作时,原生 JS 方法往往力不从心。例如浅拷贝无法应对嵌套对象变更,频繁触发的滚动/输入事件易造成卡顿。此时引入 lodash 可极大简化逻辑。

引入方式:

从小程序生态推荐的 CDN 或 npm 构建版本中提取所需方法:

npm install --save lodash.clonedeep lodash.debounce lodash.throttle

然后使用构建工具导出为单文件:

// /lib/lodash/index.js
export const cloneDeep = require('lodash.clonedeep');
export const debounce = require('lodash.debounce');
export const throttle = require('lodash.throttle');

在需要使用的页面或组件中引入:

const { cloneDeep, debounce } = require('../../lib/lodash');

Page({
  data: { list: [] },

  onLoad() {
    const raw = { a: { b: { c: 1 } } };
    const copied = cloneDeep(raw); // 安全深拷贝

    this.searchHandler = debounce(this.performSearch, 300);
  },

  onInput(e) {
    this.searchHandler(e.detail.value);
  },

  performSearch(query) {
    console.log('Searching:', query);
  }
});

🔍 逻辑分析
- cloneDeep : 解决对象引用带来的状态污染问题,尤其适用于购物车商品缓存备份
- debounce(fn, delay) : 延迟执行,连续触发只执行最后一次,适合搜索框防抖
- throttle(fn, interval) : 固定频率执行,防止 scroll/touchmove 过载

性能对比表格:
方法 执行次数(1s 内输入10次) 内存占用 适用场景
原生直接调用 10 快速反馈但易卡顿
debounce(300ms) 2~3 搜索框、表单验证
throttle(500ms) 2 极低 滚动监听、节流上报
流程图:防抖机制执行流程
graph TD
    A[用户开始输入] --> B{是否已有定时器?}
    B -- 是 --> C[清除旧定时器]
    B -- 否 --> D[启动新定时器]
    C --> D
    D --> E[等待300ms]
    E --> F{期间是否有新输入?}
    F -- 是 --> C
    F -- 否 --> G[执行实际函数]
    G --> H[完成搜索]

该模型有效控制了高频请求对后端的压力,是提升用户体验的重要手段。

3.1.3 集成 moment.js 处理订单时间相关逻辑

订单系统中常需格式化时间、计算倒计时、判断营业时段等,JavaScript 原生 Date API 功能有限且语法繁琐。moment.js 提供了强大而直观的时间处理接口。

引入与裁剪:

因完整版 moment.js 体积较大(约 200KB+),建议使用轻量替代品或按需打包:

npm install --save moment-mini

或使用 dayjs (推荐):

npm install --save dayjs

示例代码:

// /lib/time/index.js
const dayjs = require('dayjs');
require('dayjs/locale/zh-cn'); // 中文本地化
dayjs.locale('zh-cn');

module.exports = {
  format: (timestamp, fmt = 'YYYY-MM-DD HH:mm') => {
    return dayjs(timestamp).format(fmt);
  },
  fromNow: (timestamp) => {
    return dayjs(timestamp).fromNow(); // “3分钟前”
  },
  isWithinRange: (start, end) => {
    const now = dayjs();
    return now.isAfter(dayjs(start)) && now.isBefore(dayjs(end));
  }
};
应用场景示例:
const timeUtil = require('../../lib/time');

// 订单创建时间展示
const createTime = timeUtil.format(order.create_time);

// 判断商家是否正在营业
const isOpen = timeUtil.isWithinRange(
  '2025-04-05 09:00',
  '2025-04-05 22:00'
);

// 倒计时显示
const countdownText = dayjs(order.deadline).fromNow(); // “还剩1小时”

⚠️ 注意事项:
- 避免全局挂载过多实例,防止内存泄漏
- 若仅需基础格式化,可用正则 + Date 自实现,减小包体积
- 推荐上线前替换为 date-fns luxon 更现代的库进行 tree-shaking 优化

3.2 自定义组件开发模式

小程序组件化开发的核心在于解耦 UI 与逻辑,提升复用率与可测试性。通过合理的组件划分、属性传递、事件通信与插槽机制,可构建出类似 Vue/React 的声明式开发体验。

3.2.1 组件结构划分:button、card、quantity-selector 的抽象设计

良好的组件命名与职责划分是架构稳定的基础。以下三个典型组件代表不同交互类型:

组件名称 类型 主要功能 复用频率
button 基础组件 触发操作、状态反馈 极高
card 容器组件 包裹内容、提供视觉层次
quantity-selector 表单组件 控制商品数量增减
示例:quantity-selector 组件实现

WXML 结构 ( /components/quantity-selector/index.wxml ):

<view class="selector">
  <button bindtap="onMinus" disabled="{{count <= min}}">
    <text class="icon">-</text>
  </button>

  <input 
    value="{{count}}" 
    type="number"
    bindinput="onInputChange"
    class="input" />

  <button bindtap="onAdd" disabled="{{count >= max}}">
    <text class="icon">+</text>
  </button>
</view>

JS 逻辑 ( /components/quantity-selector/index.js ):

Component({
  properties: {
    value: { type: Number, value: 0 },
    min: { type: Number, value: 0 },
    max: { type: Number, value: 99 }
  },

  data: {
    count: 0
  },

  observers: {
    'value': function(val) {
      this.setData({ count: val });
    }
  },

  methods: {
    onAdd() {
      if (this.data.count < this.data.max) {
        const newVal = this.data.count + 1;
        this.setData({ count: newVal });
        this.triggerEvent('change', { value: newVal });
      }
    },

    onMinus() {
      if (this.data.count > this.data.min) {
        const newVal = this.data.count - 1;
        this.setData({ count: newVal });
        this.triggerEvent('change', { value: newVal });
      }
    },

    onInputChange(e) {
      const val = parseInt(e.detail.value) || 0;
      if (val >= this.data.min && val <= this.data.max) {
        this.setData({ count: val });
        this.triggerEvent('change', { value: val });
      }
    }
  }
});

🔎 逐行解读
- properties : 接收外部传入的初始值、最小最大限制
- observers : 监听 value 变化同步内部状态(支持父组件驱动)
- onAdd/onMinus : 点击加减按钮时检查边界并发出 change 事件
- onInputChange : 输入框手动修改时做合法性校验
- triggerEvent : 向父组件广播数值变化,形成双向数据流

样式文件(WXSS):
.selector {
  display: flex;
  align-items: center;
}

.input {
  width: 60rpx;
  text-align: center;
  margin: 0 10rpx;
  border: 1rpx solid #ddd;
  border-radius: 6rpx;
}

button[disabled] .icon {
  opacity: 0.4;
}
使用方式:
<quantity-selector 
  value="{{item.quantity}}" 
  bind:change="onQuantityChange" />

此组件可在商品列表、详情页、购物车等多个位置复用,极大减少重复代码。

3.2.2 组件通信机制:properties 属性传递与 triggerEvent 事件回调

组件间通信是小程序开发中最核心的问题之一。主要依赖两种机制:

  1. 向下传递 :通过 properties 接收父组件数据
  2. 向上通知 :通过 this.triggerEvent() 抛出事件
通信流程图:
graph LR
    A[父组件] -->|传入 props| B[子组件]
    B -->|触发 event| A
    A -->|更新 data| B
示例:card 组件接收标题与操作事件
// /components/card/card.js
Component({
  properties: {
    title: { type: String, value: '' },
    showMore: { type: Boolean, value: false }
  },

  methods: {
    onTapMore() {
      this.triggerEvent('moretap', {}, { bubbles: true });
    }
  }
});

父组件使用:

<card 
  title="热门推荐" 
  show-more="{{true}}" 
  bind:moretap="handleMoreClick" />

💡 参数说明:
- bubbles: true 表示事件可冒泡,适用于深层嵌套组件捕获
- 支持自定义事件名、携带数据 payload、设置非冒泡行为

这种单向数据流 + 事件通知的模式符合函数式编程思想,易于调试与维护。

3.2.3 插槽(slot)的应用:构建灵活可复用的布局容器

对于结构复杂的内容区域,静态 props 无法满足多样化布局需求,此时应使用 <slot> 实现内容分发。

示例:带标题栏的卡片容器

WXML:

<!-- /components/layout-card/index.wxml -->
<view class="layout-card">
  <view class="header">
    <slot name="title"></slot>
    <slot name="action"></slot>
  </view>
  <view class="body">
    <slot></slot>
  </view>
</view>

使用方式:

<layout-card>
  <view slot="title">我的订单</view>
  <view slot="action">
    <text class="link" bindtap="gotoHistory">查看全部</text>
  </view>
  <view>
    <order-item wx:for="{{orders}}" />
  </view>
</layout-card>

✅ 优势:
- 不再局限于固定字段渲染
- 支持富文本、组件、事件混合插入
- 提升模板自由度,适应多种业务形态

3.2.4 组件样式隔离与全局样式的协同管理

小程序默认开启 styleIsolation: 'apply-shared' ,意味着组件样式不会影响外部,但外部样式会影响组件。为防止意外覆盖,建议显式设置:

Component({
  options: {
    styleIsolation: 'scoped', // 类似 Vue scoped
    multipleSlots: true
  }
})
样式优先级对照表:
来源 优先级 是否受隔离影响
全局 app.wxss 是(scoped 下无效)
组件内 index.wxss
内联 style 最高
最佳实践:
  1. 全局定义主题变量 (colors, fonts, rpx基准)
  2. 组件内部使用相对单位 (rpx、%)
  3. 避免使用 ID 选择器或 !important
  4. 通过 behaviors 共享公共样式行为
// /behaviors/responsive.js
module.exports = Behavior({
  created() {
    this.data.deviceRatio = wx.getSystemInfoSync().pixelRatio / 2;
  }
});

通过以上策略,既能保证组件独立性,又能实现整体风格统一,真正达成“一次编写,处处可用”的组件化目标。

4. 首页布局实现与数据驱动渲染

在现代小程序开发中,首页作为用户进入应用的第一触点,承担着引导体验、展示核心功能和激发消费欲望的多重任务。对于仿饿了么类点餐应用而言,首页不仅是视觉呈现的核心界面,更是数据驱动架构的关键落地场景。该页面需要高效整合轮播图、品类分类、推荐商品列表、营销活动入口等多个模块,并通过结构化的WXML组织方式与动态数据绑定机制实现流畅的用户体验。

本章将深入剖析如何基于微信小程序原生能力完成首页的完整构建过程。重点聚焦于页面结构设计、组件化WXML组织策略、数据驱动渲染逻辑以及交互增强手段的应用。通过对 swiper 组件优化使用、网格布局动态生成、WXS脚本扩展、下拉刷新与分页加载等关键技术的实际编码实践,系统性地提升首页性能表现与可维护性。

4.1 页面结构设计与 WXML 架构组织

4.1.1 轮播图组件实现:swiper 与 image 懒加载策略

轮播图是外卖类小程序首页最显著的视觉元素之一,常用于展示平台优惠券、热门活动或品牌合作推广信息。为了保证良好的首屏加载速度与滑动流畅度,必须对 <swiper> 组件进行精细化配置并引入图片懒加载机制。

微信小程序提供的 <swiper> 组件支持自动播放、无限循环、指示器自定义等功能,但默认情况下会预加载所有图片资源,容易造成内存占用过高。因此,采用“按需加载”策略尤为重要——即仅当某张图片即将显示时才发起网络请求。

通过监听 bindchange 事件获取当前索引,在 data 中维护一个 visibleImages 数组,控制实际渲染的图像源:

<!-- index.wxml -->
<swiper 
  class="home-banner" 
  autoplay="{{true}}" 
  interval="5000" 
  duration="300" 
  circular="{{true}}" 
  bindchange="onSwiperChange">
  <block wx:for="{{banners}}" wx:key="id">
    <swiper-item>
      <image 
        src="{{item.visible ? item.url : ''}}" 
        mode="aspectFill" 
        lazy-load="{{true}}" 
        class="banner-image"/>
    </swiper-item>
  </block>
</swiper>

上述代码中, lazy-load 属性启用原生懒加载能力,而 src 绑定依赖于 visible 字段判断是否真正加载图片。初始状态下仅第一张图设置为可见。

对应JS逻辑如下:

// index.js
Page({
  data: {
    banners: [
      { id: 1, url: 'https://example.com/banner1.jpg', visible: true },
      { id: 2, url: 'https://example.com/banner2.jpg', visible: false },
      { id: 3, url: 'https://example.com/banner3.jpg', visible: false }
    ]
  },

  onSwiperChange(e) {
    const current = e.detail.current;
    const updatedBanners = this.data.banners.map((item, index) => ({
      ...item,
      visible: index === current || index === (current - 1 + 3) % 3 || index === (current + 1) % 3
    }));
    this.setData({ banners: updatedBanners });
  },

  onLoad() {
    // 预加载临近图片
    setTimeout(() => {
      this.onSwiperChange({ detail: { current: 0 } });
    }, 100);
  }
});

逻辑分析:

  • onSwiperChange 函数捕获滑动后的当前索引。
  • 使用 map 方法遍历原始 banners 数组,根据当前页码决定哪些图片应被标记为 visible (包括当前页及其前后一页),形成预加载窗口。
  • setData 更新状态触发视图重绘,只有 visible true 的图片才会发起真实请求。
  • 初始 onLoad 调用模拟一次变更以提前激活邻近项加载。
参数 类型 描述
autoplay Boolean 是否自动播放
interval Number(ms) 自动切换时间间隔
duration Number(ms) 动画持续时间
circular Boolean 是否可循环滑动
bindchange Event Handler 滑动后回调函数

此外,可通过CSS进一步优化加载体验:

/* index.wxss */
.home-banner {
  height: 200rpx;
  overflow: hidden;
}
.banner-image {
  width: 100%;
  height: 100%;
  background-color: #f5f5f5;
  transition: opacity 0.3s ease-in-out;
}

结合骨架屏技术,可在图片未加载完成前展示占位灰块,减少空白感。

Mermaid 流程图:轮播图懒加载执行流程
graph TD
    A[页面初始化] --> B{加载首页}
    B --> C[设置首张图片 visible=true]
    C --> D[渲染 swiper 组件]
    D --> E[用户滑动或自动切换]
    E --> F[触发 bindchange 事件]
    F --> G[计算当前及相邻索引]
    G --> H[更新 banners 数组中的 visible 字段]
    H --> I[setData 触发重新渲染]
    I --> J[仅 visible 图片发起 HTTP 请求]
    J --> K[图片加载完成后自然显示]

此流程确保资源按需加载,有效降低初始带宽消耗,尤其适用于移动端弱网环境。

4.1.2 网格分类布局:动态生成品类入口并绑定点击行为

首页通常包含多个品类图标入口,如“快餐便当”、“水果生鲜”、“奶茶小吃”等,构成九宫格或横向滚动菜单。这类布局要求高度灵活性,以便后台随时调整顺序或增减类别。

采用数据驱动方式定义分类列表,避免硬编码HTML结构:

<!-- index.wxml -->
<view class="category-grid">
  <scroll-view scroll-x="{{true}}" class="grid-scroll" show-scrollbar="{{false}}">
    <view class="grid-container">
      <block wx:for="{{categories}}" wx:key="id">
        <view class="grid-item" bindtap="onCategoryTap" data-id="{{item.id}}">
          <image src="{{item.icon}}" mode="aspectFit" class="icon"/>
          <text class="label">{{item.name}}</text>
        </view>
      </block>
    </view>
  </scroll-view>
</view>

配合JavaScript中动态数据注入:

// index.js
Page({
  data: {
    categories: [
      { id: 1, name: '快餐便当', icon: '/assets/icons/fastfood.png' },
      { id: 2, name: '水果生鲜', icon: '/assets/icons/fruit.png' },
      { id: 3, name: '奶茶饮品', icon: '/assets/icons/milktea.png' },
      { id: 4, name: '甜品蛋糕', icon: '/assets/icons/dessert.png' },
      { id: 5, name: '火锅烧烤', icon: '/assets/icons/hotpot.png' },
      { id: 6, name: '超市便利', icon: '/assets/icons/store.png' }
    ]
  },

  onCategoryTap(e) {
    const categoryId = e.currentTarget.dataset.id;
    wx.navigateTo({
      url: `/pages/category-detail/category-detail?id=${categoryId}`
    });
  }
});

逐行解读:

  • <scroll-view scroll-x> 实现水平滚动容器,适应屏幕宽度不足时的溢出处理。
  • wx:for 循环渲染每个分类项, wx:key 提升列表更新效率。
  • bindtap 绑定点击事件处理器 onCategoryTap ,并通过 data-id 传递唯一标识。
  • e.currentTarget.dataset.id 安全提取自定义属性值,避免直接访问DOM。
  • wx.navigateTo 导航至详情页,携带参数实现路由跳转。

样式部分采用Flex布局实现均分布局:

/* index.wxss */
.category-grid {
  padding: 20rpx 0;
  background-color: #fff;
}

.grid-scroll {
  white-space: nowrap;
}

.grid-container {
  display: flex;
  justify-content: flex-start;
  align-items: center;
}

.grid-item {
  display: inline-flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  width: 140rpx;
  height: 120rpx;
  margin-right: 30rpx;
}

.icon {
  width: 60rpx;
  height: 60rpx;
  margin-bottom: 8rpx;
}

.label {
  font-size: 24rpx;
  color: #333;
}
属性 说明
scroll-x true 启用水平滚动
show-scrollbar false 隐藏滚动条
white-space nowrap 防止换行
flex-direction column 图标在上文字在下
width/height 固定尺寸 控制每项大小一致
表格:分类网格响应式适配方案
设备类型 单行显示数量 grid-item 宽度 是否启用滚动
iPhone SE 4 个 160rpx
iPhone 13 5 个 144rpx
iPad 6+ 个 120rpx

注:rpx单位自动适配不同DPR屏幕,建议搭配 750rpx=100% 设计稿基准使用。

该设计方案具备高扩展性,后续可通过接口拉取远程分类数据,实现实时运营配置。同时支持手势滑动浏览更多品类,兼顾小屏与大屏设备的可用性。

4.2 数据绑定与 WXS 脚本扩展

4.2.1 利用 data 实现首页商品列表渲染

商品列表是首页信息密度最高的区域,通常包含商品名称、价格、销量、图片、配送费、距离等多项属性。传统做法是在 data 中存储完整商品数组,通过 wx:for 进行渲染。

假设从API获取如下格式的数据:

[
  {
    "id": 101,
    "name": "黄焖鸡米饭",
    "price": 22.5,
    "original_price": 28,
    "sales": 320,
    "distance": "1.2km",
    "delivery_fee": 3,
    "image": "/images/dish1.jpg",
    "rating": 4.8
  },
  ...
]

在页面 data 中初始化空数组:

data: {
  goodsList: [],
  loading: true,
  hasMore: true,
  page: 1
}

通过 onLoad 生命周期发起请求:

onLoad() {
  this.loadGoodsList();
}

async loadGoodsList() {
  if (!this.data.hasMore) return;

  this.setData({ loading: true });

  try {
    const res = await wx.request({
      url: 'https://api.example.com/goods',
      data: { page: this.data.page, size: 10 }
    });

    const newList = res.data.list;
    const total = res.data.total;

    this.setData({
      goodsList: [...this.data.goodsList, ...newList],
      hasMore: this.data.goodsList.length < total,
      page: this.data.page + 1
    });
  } catch (err) {
    wx.showToast({ title: '加载失败', icon: 'none' });
  } finally {
    this.setData({ loading: false });
  }
}

WXML模板渲染:

<view class="goods-list">
  <block wx:for="{{goodsList}}" wx:key="id">
    <view class="goods-item" bindtap="onGoodsClick" data-id="{{item.id}}">
      <image src="{{item.image}}" mode="aspectFill" class="goods-img"/>
      <view class="goods-info">
        <text class="title">{{item.name}}</text>
        <view class="meta">
          <text class="price">¥{{item.price}}</text>
          <text class="sales">月售{{item.sales}}份</text>
        </view>
        <view class="extra">
          <text class="distance">{{item.distance}}</text>
          <text class="fee">配送¥{{item.delivery_fee}}</text>
        </view>
      </view>
    </view>
  </block>

  <view class="loading-tips">
    <text>{{ loading ? '加载中...' : (hasMore ? '上拉加载更多' : '没有更多了') }}</text>
  </view>
</view>

该模式实现了典型的“数据→模板”的单向绑定机制, setData 是唯一的更新入口,保障了视图一致性。

4.2.2 使用 WXS 实现价格格式化和标签过滤功能

当涉及大量文本处理(如货币格式化、标签拼接)时,若全部放在JS主线程执行,可能导致渲染卡顿。WXS(WeiXin Script)是一种运行在视图层的脚本语言,可在WXML中直接调用,极大提升性能。

创建 utils.wxs 文件:

var formatPrice = function(price) {
  if (typeof price !== 'number') return '0.00';
  return price.toFixed(2);
};

var getDiscountTag = function(original, current) {
  if (original > current) {
    var discount = ((current / original) * 10).toFixed(1);
    return discount + '折';
  }
  return '';
};

module.exports = {
  formatPrice: formatPrice,
  getDiscountTag: getDiscountTag
};

在WXML中引入并使用:

<wxs src="../../utils/utils.wxs" module="filter"/>

<block wx:for="{{goodsList}}" wx:key="id">
  <view class="goods-item">
    <image src="{{item.image}}" class="goods-img"/>
    <view class="goods-info">
      <text class="title">{{item.name}}</text>
      <view class="meta">
        <text class="price">¥{{filter.formatPrice(item.price)}}</text>
        <text class="tag">{{filter.getDiscountTag(item.original_price, item.price)}}</text>
      </view>
    </view>
  </view>
</block>

优势分析:

  • 性能隔离 :WXS运行在视图线程,不阻塞JS逻辑。
  • 减少通信开销 :无需频繁调用 setData 来回传处理结果。
  • 复用性强 :同一WXS模块可在多个页面引用。
方法 执行位置 性能影响 适用场景
JS处理 逻辑层 高频调用易卡顿 复杂业务逻辑
WXS处理 渲染层 几乎无延迟 格式化、简单判断
Mermaid 流程图:WXS 与 setData 的渲染路径对比
graph LR
    subgraph JS主线程
        A[调用setData]
        A --> B[序列化数据]
        B --> C[发送到视图层]
        C --> D[反序列化并渲染]
    end

    subgraph 视图层独立执行
        E[WXS函数调用]
        E --> F[直接格式化输出]
        F --> G[插入DOM节点]
    end

    style A fill:#ffebee,stroke:#f44336
    style E fill:#e8f5e8,stroke:#4caf50

可见WXS绕过了跨线程通信瓶颈,更适合高频渲染场景。

4.3 触摸反馈与交互增强

4.3.1 添加 active 态样式提升用户点击感知

移动端缺乏物理按键反馈,需通过视觉变化增强操作确认感。为商品项添加 active 态样式是最基本的交互优化。

利用 bindtouchstart bindtouchend 事件控制类名切换:

<view 
  class="goods-item {{item.isPressed ? 'active' : ''}}" 
  bindtouchstart="onItemTouchStart" 
  bindtouchend="onItemTouchEnd"
  data-id="{{item.id}}">
  <!-- 内容 -->
</view>

JavaScript中记录按下状态:

onItemTouchStart(e) {
  const id = e.currentTarget.dataset.id;
  const list = this.data.goodsList.map(good => 
    good.id === id ? { ...good, isPressed: true } : good
  );
  this.setData({ goodsList: list });
}

onItemTouchEnd(e) {
  const id = e.currentTarget.dataset.id;
  const list = this.data.goodsList.map(good => 
    good.id === id ? { ...good, isPressed: false } : good
  );
  this.setData({ goodsList: list });

  // 可在此处延后跳转防止误触
  setTimeout(() => {
    wx.navigateTo({ url: `/pages/goods/goods?id=${id}` });
  }, 100);
}

CSS定义按下效果:

.goods-item.active {
  background-color: #f9f9f9;
  transform: scale(0.98);
  transition: all 0.1s ease;
}

这种微交互虽小,却显著提升了应用的专业感与可用性。

4.3.2 实现下拉刷新与上拉触底分页加载机制

微信小程序原生支持 onPullDownRefresh onReachBottom 生命周期钩子,用于实现常见的刷新与分页功能。

首先在 json 文件中开启下拉刷新:

{
  "enablePullDownRefresh": true,
  "backgroundTextStyle": "dark"
}

然后注册事件处理函数:

onPullDownRefresh() {
  console.log('开始刷新...');
  this.setData({ page: 1, goodsList: [], hasMore: true });
  this.loadGoodsList().finally(() => {
    wx.stopPullDownRefresh();
  });
},

onReachBottom() {
  if (this.data.hasMore && !this.data.loading) {
    this.loadGoodsList();
  }
}

其中 loadGoodsList() 已在前文定义,负责增量加载数据。 stopPullDownRefresh() 用于结束刷新动画。

该机制形成了完整的“加载 → 刷新 → 分页”闭环,符合用户直觉,广泛应用于各类内容流场景。

表格:分页加载关键参数配置建议
参数 推荐值 说明
每页数量 10 条 平衡加载速度与滚动频率
预加载阈值 100px 提前触发请求避免空白
错误重试次数 2 次 弱网环境下提升成功率
节流间隔 500ms 防止多次快速触发

最终形成的首页具备完整的数据驱动能力、高效的渲染机制与丰富的交互反馈,为后续购物车联动与订单转化打下坚实基础。

5. 商品详情页开发——信息展示与规格选择逻辑

在小程序仿饿了么点餐系统的整体流程中,商品详情页是用户从浏览走向下单的关键转化节点。该页面不仅承担着全面展示商品信息的职责,还需支持复杂的交互行为,如多规格选择、价格动态计算以及购物车添加控制等。因此,其设计需兼顾数据结构合理性、UI 响应灵敏性与用户体验流畅度。本章将围绕“信息展示”、“规格选择逻辑”和“加入购物车流程”三大核心模块展开深入分析,重点探讨如何通过组件化思维、递归算法建模及状态同步机制实现高可用的商品详情页。

5.1 商品信息结构化展示

商品信息的呈现直接影响用户的决策效率。一个清晰、层次分明的信息布局能够有效降低认知负荷,提升转化率。在仿饿了么项目中,商品详情页被划分为两个主要功能区域:基础信息区与商家信息模块。这两个部分分别承载商品本身属性与服务支持能力的数据表达。

5.1.1 基础信息区域:标题、评分、销量与图片预览

基础信息区域位于页面顶部,采用纵向排布方式依次展示商品主图、名称、描述、评分与月售数据。此部分内容通常来源于后端接口返回的 product 对象,结构如下所示:

{
  "id": 1001,
  "name": "经典珍珠奶茶",
  "description": "选用高山红茶搭配Q弹黑糖珍珠,甜而不腻",
  "image_url": "https://cdn.example.com/milktea.jpg",
  "rating": 4.8,
  "sales": 2345,
  "price": 15.00,
  "specifications": [
    {
      "name": "温度",
      "values": ["热", "常温", "冰"]
    },
    {
      "name": "甜度",
      "values": ["无糖", "少糖", "半糖", "全糖"]
    }
  ]
}

该数据结构具备良好的扩展性,支持未来新增字段(如标签、推荐指数)而无需重构模板。前端通过 WXML 的数据绑定机制进行渲染:

<!-- product-detail.wxml -->
<view class="product-header">
  <image src="{{product.image_url}}" mode="widthFix" class="product-image" />
  <view class="info-section">
    <text class="title">{{product.name}}</text>
    <text class="desc">{{product.description}}</text>
    <view class="meta">
      <text>评分 {{product.rating}}</text>
      <text>月售 {{product.sales}} 单</text>
    </view>
  </view>
</view>

配合 WXSS 样式定义,形成视觉层级清晰的卡片式布局:

/* product-detail.wxss */
.product-header {
  display: flex;
  padding: 20rpx;
  border-bottom: 1rpx solid #f0f0f0;
}

.product-image {
  width: 200rpx;
  height: 200rpx;
  margin-right: 20rpx;
  border-radius: 12rpx;
}

.info-section .title {
  font-size: 36rpx;
  font-weight: bold;
  color: #333;
}

.info-section .desc {
  font-size: 28rpx;
  color: #666;
  margin-top: 10rpx;
  display: block;
}

.meta {
  margin-top: 15rpx;
  font-size: 26rpx;
  color: #999;
}

上述代码实现了图文混排的基本结构。其中 mode="widthFix" 确保图片按原始比例缩放且宽度自适应容器;字体大小使用 rpx 实现响应式适配不同屏幕尺寸。此外,评分与销量信息合并为元数据行显示,符合移动端阅读习惯。

为了进一步优化加载体验,可结合懒加载策略对图片资源进行延迟加载。虽然微信原生 <image> 组件默认支持懒加载,但在复杂页面中建议显式设置 lazy-load 属性以确保性能稳定:

<image src="{{product.image_url}}" mode="widthFix" lazy-load />
图片预览功能增强:点击放大查看

考虑到用户可能希望查看高清大图,可在 JS 中绑定 tap 事件触发预览:

// product-detail.js
Page({
  data: {
    product: {}
  },
  previewImage() {
    wx.previewImage({
      urls: [this.data.product.image_url],
      current: this.data.product.image_url
    });
  }
});

并在 WXML 中为图片添加点击事件:

<image src="{{product.image_url}}" mode="widthFix" bindtap="previewImage" />

此操作调用微信 SDK 提供的 wx.previewImage 方法,开启内置图片查看器,支持手势缩放与滑动切换(尽管当前仅传入单张图片)。若后续支持多图轮播,则只需扩展 urls 数组即可。

属性 类型 描述
urls Array 要预览的图片 URL 列表
current String 当前显示图片的 URL,默认为首项

该方法无需引入额外依赖,轻量高效地提升了内容可访问性。

flowchart TD
    A[用户点击商品图片] --> B{是否启用预览?}
    B -->|是| C[调用 wx.previewImage]
    C --> D[展示全屏图片查看器]
    D --> E[支持缩放/滑动]
    B -->|否| F[无操作]

该流程图展示了图片预览的核心交互路径,体现了从小交互到完整体验的设计闭环。

5.1.2 商家信息模块:配送时间、起送价与服务标签

紧随基础信息之后的是商家服务信息模块,用于传达订单履约相关约束条件。这类信息虽不直接关联商品本身,但会影响用户是否下单的判断。典型字段包括:

  • 配送时间(如“预计30分钟内送达”)
  • 起送金额(如“满15元起送”)
  • 服务标签(如“支持开发票”、“可修改订单”)

此类信息可通过独立数据对象传入或由上级页面(如店铺页)共享至详情页上下文。示例结构如下:

merchantInfo: {
  deliveryTime: "30分钟",
  minOrderAmount: 15.00,
  serviceTags: ["支持发票", "可修改订单", "免配送费"]
}

WXML 渲染结构如下:

<view class="merchant-section">
  <text>配送时间:{{merchantInfo.deliveryTime}}</text>
  <text>起送价:¥{{merchantInfo.minOrderAmount}} 起</text>
  <view class="tags">
    <block wx:for="{{merchantInfo.serviceTags}}" wx:key="*this">
      <text class="tag">{{item}}</text>
    </block>
  </view>
</view>

样式方面,标签采用圆角背景突出显示:

.merchant-section {
  padding: 20rpx;
  background-color: #f9f9f9;
  border-radius: 16rpx;
  margin: 20rpx;
  font-size: 28rpx;
  color: #555;
}

.tag {
  display: inline-block;
  padding: 8rpx 16rpx;
  margin-right: 16rpx;
  background-color: #eef7ff;
  color: #007acc;
  font-size: 24rpx;
  border-radius: 8rpx;
}

值得注意的是,此类信息应保持只读状态,并避免频繁刷新干扰用户注意力。可通过页面 onLoad 时一次性获取并缓存,或利用全局状态管理工具(如 pinia 或简易 observe 模式)统一维护。

5.2 规格弹窗设计与选择逻辑

当商品存在多种可选配置时(如奶茶的温度、甜度、加料),必须提供直观的规格选择界面。由于组合数量呈指数增长,传统线性控件难以胜任,需引入弹窗形式集中处理。

5.2.1 多规格组合模型建模(如尺寸、温度、甜度)

多规格本质上是一个笛卡尔积问题。每个规格项(Specification Item)包含多个取值选项(Option),最终的有效 SKU 是所有维度选项的合法组合。例如:

specifications: [
  { name: "温度", values: ["热", "冰"] },
  { name: "甜度", values: ["半糖", "全糖"] },
  { name: "加料", values: ["波霸", "椰果", "无"] }
]

理论上共有 $2 \times 2 \times 3 = 12$ 种组合。然而并非所有组合都有效——某些加料可能不适用于热饮,或特定甜度不可选。因此需要建立“规格有效性映射表”来排除非法路径。

为此,我们构建如下数据结构表示 SKU 明细:

skus: [
  {
    id: "1001_1",
    specs: { 温度: "热", 甜度: "半糖", 加料: "波霸" },
    price: 16.00,
    stock: 50,
    disabled: false
  },
  {
    id: "1001_2",
    specs: { 温度: "冰", 甜度: "全糖", 加料: "椰果" },
    price: 17.00,
    stock: 30,
    disabled: false
  }
]

每个 SKU 明确标注其所属规格组合及其价格库存状态。前端据此生成可交互的选择面板。

5.2.2 使用递归算法生成所有有效规格组合路径

面对动态规格结构,手动枚举所有路径不可行。为此采用递归回溯法自动生成候选组合,并结合禁用规则过滤无效项。

以下为递归生成函数实现:

function generateCombinations(specList, skuMap, path = {}, index = 0) {
  // 递归终止条件:已遍历所有规格维度
  if (index === specList.length) {
    const key = JSON.stringify(path);
    const sku = skuMap[key];
    return [
      {
        ...path,
        price: sku ? sku.price : null,
        disabled: !sku || sku.stock <= 0
      }
    ];
  }

  const currentSpec = specList[index];
  let results = [];

  // 遍历当前维度的所有选项
  for (let value of currentSpec.values) {
    path[currentSpec.name] = value;
    const subResults = generateCombinations(specList, skuMap, { ...path }, index + 1);
    results = results.concat(subResults);
  }

  return results;
}

逐行逻辑分析:

  1. generateCombinations 接收四个参数:
    - specList : 规格维度数组
    - skuMap : 以规格组合为键的对象映射表(用于查价)
    - path : 当前递归路径中的选择记录
    - index : 当前处理的维度索引

  2. index 达到数组长度,说明已完成一次完整组合构造,进入结果收集阶段。

  3. 将当前 path 序列化为字符串作为键,查询 skuMap 获取对应价格与库存状态。

  4. 构造结果对象,包含完整规格路径、价格及是否禁用标志。

  5. 否则,遍历当前维度所有可选值,逐个尝试并递归进入下一维度。

  6. 使用 {...path} 实现浅拷贝,防止状态污染。

  7. 最终合并所有子路径结果返回。

假设 skuMap 定义如下:

const skuMap = {
  '{"温度":"热","甜度":"半糖","加料":"波霸"}': { price: 16, stock: 50 },
  '{"温度":"冰","甜度":"全糖","加料":"椰果"}': { price: 17, stock: 30 }
};

调用 generateCombinations(specifications, skuMap) 将输出全部 12 条路径,其中仅两条启用,其余标记为 disabled: true

该算法时间复杂度为 $O(n_1 \times n_2 \times … \times n_k)$,适用于中小规模规格组合(一般不超过 4 层嵌套)。对于超大规模场景,可考虑预计算+缓存策略。

graph TD
    A[开始生成组合] --> B{是否完成所有维度?}
    B -->|是| C[构造SKU路径对象]
    C --> D[查询价格库存]
    D --> E[添加到结果集]
    B -->|否| F[获取当前维度]
    F --> G[遍历每个选项]
    G --> H[将选项加入路径]
    H --> I[递归处理下一维度]
    I --> B

此流程图清晰描绘了递归生成过程的状态流转,有助于理解深层嵌套下的执行逻辑。

5.2.3 弹窗组件封装:支持遮罩层、滑动关闭与动画入场

规格选择需以弹窗形式呈现,兼顾视觉聚焦与交互便捷。使用自定义组件方式封装 spec-selector-popup 可实现高复用性。

组件结构如下:

<!-- components/spec-selector-popup.wxml -->
<view class="popup-container" hidden="{{!visible}}">
  <view class="mask" bindtap="onMaskTap"></view>
  <view class="panel" animation="{{animationData}}">
    <view class="header">
      <text>请选择规格</text>
      <icon type="clear" size="20" bindtap="onClose" />
    </view>
    <scroll-view scroll-y class="content">
      <block wx:for="{{specGroups}}" wx:key="name">
        <view class="group">
          <text class="label">{{item.name}}:</text>
          <view class="options">
            <block wx:for="{{item.values}}" wx:key="*this">
              <text
                class="option {{isSelected(item.name, itemValue) ? 'active' : ''}} {{isDisabled(item.name, itemValue) ? 'disabled' : ''}}"
                data-spec="{{item.name}}"
                data-value="{{itemValue}}"
                bindtap="onOptionSelect"
              >{{itemValue}}</text>
            </block>
          </view>
        </view>
      </block>
    </scroll-view>
    <view class="footer">
      <button type="primary" bindtap="onConfirm">确定</button>
    </view>
  </view>
</view>

JS 中管理动画与状态:

Component({
  properties: {
    specifications: Array,
    skus: Array,
    visible: Boolean
  },

  data: {
    selected: {},
    animationData: {}
  },

  methods: {
    show() {
      this.animatePanel('in');
      this.setData({ visible: true });
    },

    hide() {
      this.animatePanel('out', () => {
        this.setData({ visible: false });
      });
    },

    animatePanel(direction, callback) {
      const animation = wx.createAnimation({ duration: 300, timingFunction: 'ease' });
      if (direction === 'in') {
        animation.translateY(0).step();
      } else {
        animation.translateY('100%').step();
      }
      this.setData({ animationData: animation.export() }, callback);
    },

    onMaskTap() {
      this.hide();
    },

    onOptionSelect(e) {
      const { spec, value } = e.currentTarget.dataset;
      this.setData({
        [`selected.${spec}`]: value
      });
    },

    onConfirm() {
      this.triggerEvent('confirm', { selected: this.data.selected });
    },

    isSelected(specName, value) {
      return this.data.selected[specName] === value;
    },

    isDisabled(specName, value) {
      // 可集成组合有效性校验逻辑
      return false;
    }
  }
});

该组件实现了:
- 动画入场/退场( translateY 过渡)
- 遮罩层点击关闭
- 滚动内容区适应长列表
- 选项高亮与禁用状态控制
- 通过 triggerEvent 向父组件传递确认结果

5.3 加入购物车流程控制

5.3.1 用户未选择完整规格时的提示机制

在用户未完成必要规格选择前,禁止提交操作。可在“确定”按钮处添加校验逻辑:

onConfirm() {
  const requiredFields = ['温度', '甜度'];
  const missing = requiredFields.filter(field => !this.data.selected[field]);

  if (missing.length > 0) {
    wx.showToast({
      icon: 'none',
      title: `请先选择${missing.join('、')}`
    });
    return;
  }

  this.triggerEvent('confirm', { selected: this.data.selected });
}

同时,界面上可通过灰显按钮增强反馈:

<button type="primary" disabled="{{!canConfirm}}">确定</button>
computed: {
  canConfirm() {
    return this.data.selected.温度 && this.data.selected.甜度;
  }
}

5.3.2 规格确认后触发添加动作并同步更新购物车状态

确认选择后,根据所选规格查找对应 SKU 并添加至购物车:

// product-detail.js
onSpecConfirmed(e) {
  const selection = e.detail.selected;
  const key = JSON.stringify(selection);
  const matchedSku = this.getSkuByKey(key);

  if (matchedSku) {
    getApp().addToCart({
      productId: this.data.product.id,
      skuId: matchedSku.id,
      count: 1,
      price: matchedSku.price,
      specs: selection
    });

    wx.showToast({ title: '已加入购物车', icon: 'success' });
  }
}

此处调用全局 getApp().addToCart() 方法,确保跨页面购物车状态一致。该方法内部会触发事件通知首页更新角标数量,形成完整的闭环交互体系。

6. 购物车功能实现——状态管理与实时计算

在现代小程序开发中,购物车作为用户消费行为的核心枢纽,承担着商品选择、数量控制、价格汇总以及订单准备等关键职责。一个高效、稳定且具备良好用户体验的购物车系统,不仅需要精确的状态管理机制,还需支持跨页面的数据同步与持久化能力。本章将围绕“仿饿了么”点餐类小程序的实际需求,深入剖析购物车模块从数据结构设计到交互逻辑实现的全过程,涵盖状态组织、动态计算、选中联动及本地存储等多个维度。

购物车并非简单的商品列表展示,而是涉及多层级状态嵌套与实时响应式更新的复杂系统。尤其在多店铺并存的场景下(如平台型外卖应用),必须以店铺为单位进行商品聚合,并独立维护各店的选中状态与合计金额。同时,用户对加减操作的高频触发要求前端具备防抖处理和边界校验能力;而全选/单选之间的双向联动则需依赖精细化的状态监听策略。此外,由于小程序页面跳转不自动保留内存状态,如何通过本地缓存实现购物车内容的跨会话保留,也成为保障用户体验的关键环节。

6.1 购物车数据结构设计

购物车的数据结构设计是整个模块的基础,直接影响后续状态更新、价格计算与持久化的效率。合理的结构应当既能清晰表达业务语义,又能便于遍历、查找与修改。特别是在支持多商铺混购的场景下,传统的扁平化数组已无法满足需求,必须采用分层结构来组织数据。

6.1.1 基于店铺维度组织商品项与选中状态

在仿饿了么项目中,用户可同时从不同商家添加商品至同一购物车。因此,购物车应按“店铺 → 商品项”的两级结构组织。每个店铺作为一个独立结算单元,拥有自己的选中状态、总价与配送信息。这种设计既符合实际业务逻辑,也便于后续做拆单提交或优惠券匹配。

以下是一个典型的购物车数据结构示例:

{
  "shops": {
    "shop_001": {
      "name": "川味小馆",
      "selected": true,
      "items": [
        {
          "id": "food_001",
          "name": "水煮牛肉",
          "price": 38.5,
          "count": 2,
          "specs": "微辣,去葱"
        },
        {
          "id": "food_003",
          "name": "酸辣土豆丝",
          "price": 16.0,
          "count": 1,
          "specs": ""
        }
      ]
    },
    "shop_002": {
      "name": "甜心烘焙",
      "selected": false,
      "items": [
        {
          "id": "cake_005",
          "name": "草莓奶油蛋糕",
          "price": 45.0,
          "count": 1,
          "specs": "8寸,贺卡祝福:生日快乐"
        }
      ]
    }
  }
}

该结构使用 shops 对象作为外层容器,键名为店铺 ID,值为包含店铺名称、是否选中及其商品列表的对象。每件商品记录其基本信息与当前数量。此设计优势在于:
- 支持 O(1) 时间复杂度根据店铺 ID 快速定位;
- 明确区分各店结算状态,避免误算;
- 便于在 UI 层渲染时按店铺分组输出。

特性 描述
数据粒度 按店铺聚合商品
状态字段 selected 控制整店选中
可扩展性 可增加起送价、满减规则等字段
存储形式 JSON 对象,适合 wx.setStorageSync
graph TD
    A[购物车根对象] --> B[shops]
    B --> C[shop_001]
    B --> D[shop_002]
    C --> E[店铺元数据]
    C --> F[商品项数组]
    F --> G[商品1: id, name, price, count]
    F --> H[商品2: id, name, price, count]
    D --> I[店铺元数据]
    D --> J[商品项数组]

上述流程图展示了购物车数据的层级关系:顶层为 shops 容器,其下为各个店铺节点,每个店铺再包含元信息与商品项集合。这种树状结构天然适配多商户场景。

6.1.2 使用 Map 结构管理不同商铺的商品集合

虽然 JavaScript 的普通对象可以胜任基础的数据存储任务,但在频繁增删改查的场景下, Map 提供了更优的性能表现。尤其是在购物车这种高交互频率的功能中,推荐使用 Map 替代 Object 来管理店铺集合。

示例代码:使用 Map 构建购物车实例
// cart-manager.js
class CartManager {
  constructor() {
    this.shops = new Map(); // 使用 Map 存储店铺
  }

  addShop(shopId, shopName) {
    if (!this.shops.has(shopId)) {
      this.shops.set(shopId, {
        name: shopName,
        selected: true,
        items: []
      });
    }
  }

  addItem(shopId, foodItem) {
    const shop = this.shops.get(shopId);
    if (!shop) return;

    const exist = shop.items.find(item => item.id === foodItem.id);
    if (exist) {
      exist.count += 1; // 已存在则数量+1
    } else {
      shop.items.push({ ...foodItem, count: 1 });
    }
  }

  removeItem(shopId, itemId) {
    const shop = this.shops.get(shopId);
    if (!shop) return;

    const index = shop.items.findIndex(item => item.id === itemId);
    if (index > -1) {
      shop.items.splice(index, 1);
      if (shop.items.length === 0) {
        this.shops.delete(shopId); // 清空后删除店铺
      }
    }
  }

  getSelectedItems() {
    const result = [];
    for (let [_, shop] of this.shops) {
      if (shop.selected) {
        result.push(...shop.items);
      }
    }
    return result;
  }
}

export default new CartManager();
代码逻辑逐行解读分析:
  1. 构造函数初始化 Map
    this.shops = new Map() 创建一个键值映射结构,相比 {} 更适用于动态增删且无需遍历原型链。

  2. addShop 方法确保唯一性
    利用 has() 检查店铺是否存在,防止重复插入,提升数据一致性。

  3. addItem 实现增量添加
    先查找是否已有同 ID 商品,若有则 count++ ,否则推入新项。注意此处深拷贝原始 foodItem 避免引用污染。

  4. removeItem 处理边界情况
    删除商品后若店铺无商品,则调用 delete(shopId) 彻底清除空店铺,释放内存。

  5. getSelectedItems 遍历所有选中店铺
    使用 for...of 遍历 Map 所有条目,筛选出 selected === true 的商品合并返回。

参数说明:
  • shopId : 字符串类型,唯一标识店铺(通常来自后端)
  • foodItem : 包含 id , name , price 的商品对象
  • count : 数量字段,默认新增为 1

该封装方式使得购物车逻辑高度内聚,外部只需调用简洁 API 即可完成复杂操作,也为后续接入全局状态管理打下基础。

6.2 数量编辑与价格联动

购物车中最常见的交互即是对商品数量的调整。每一次点击“+”或“-”按钮都应立即反映在界面上的价格变化,并影响整体合计金额。这一过程涉及多个层次的联动更新,包括 DOM 渲染、数据模型变更与状态传播。

6.2.1 实现加减按钮控制商品数量并防止越界

在 WXML 中,通常为每个商品项绑定两个按钮用于数量调节:

<!-- cart-item.wxml -->
<view class="cart-item">
  <text>{{item.name}}</text>
  <view class="quantity-control">
    <button bindtap="onMinus" data-id="{{item.id}}" data-shop="{{shopId}}">-</button>
    <text>{{item.count}}</text>
    <button bindtap="onPlus" data-id="{{item.id}}" data-shop="{{shopId}}">+</button>
  </view>
  <text>¥{{item.price * item.count}}</text>
</view>

对应的事件处理函数如下:

// pages/cart/cart.js
Page({
  data: {
    cart: getApp().globalData.cartManager.getState()
  },

  onPlus(e) {
    const { id, shop } = e.currentTarget.dataset;
    const manager = getApp().globalData.cartManager;
    manager.addItem(shop, { id, price: getPriceById(id) }); // 简化示例
    this.updateCartView();
  },

  onMinus(e) {
    const { id, shop } = e.currentTarget.dataset;
    const manager = getApp().globalData.cartManager;

    const item = manager.getItem(shop, id);
    if (item && item.count <= 1) {
      wx.showModal({
        title: '提示',
        content: '确定要移除该商品吗?',
        success: (res) => {
          if (res.confirm) {
            manager.removeItem(shop, id);
            this.updateCartView();
          }
        }
      });
    } else {
      manager.decreaseItem(shop, id);
      this.updateCartView();
    }
  },

  updateCartView() {
    this.setData({
      cart: getApp().globalData.cartManager.exportJSON()
    });
  }
});
关键点解析:
  • data-* 属性传递上下文 :通过 data-id data-shop 将商品与店铺 ID 注入 DOM,供事件处理器提取。
  • 越界判断 :当数量 ≤1 时点击“-”,提示用户是否删除,增强交互安全性。
  • 异步确认框 :使用 wx.showModal 弹窗防止误删,提升 UX。
  • 强制视图刷新 :每次变更后调用 setData 更新视图。
表格:数量操作边界处理策略
操作 当前数量 处理逻辑
减少 >1 直接减1
减少 =1 弹窗确认是否删除
减少 =0 不允许操作(前端禁用按钮)
增加 任意 加1(可结合库存限制)

建议进一步优化:在 WXML 中绑定 disabled 属性,当 count=0 时禁用“-”按钮,减少无效点击。

6.2.2 实时计算单品小计、店铺合计与全场总价

价格计算是购物车的核心功能之一,需保证准确性和实时性。计算逻辑可分为三层:

  1. 单品小计 :单价 × 数量
  2. 店铺合计 :该店内所有选中商品的小计之和
  3. 全场总价 :所有选中店铺的合计金额总和
实现代码示例:
// utils/calculate.js
function calculateCart(cartData) {
  let totalAmount = 0;

  const shops = Object.values(cartData.shops || {});
  const result = shops.map(shop => {
    const selectedItems = shop.items.filter(item => item.selected !== false);
    const shopTotal = selectedItems.reduce((sum, item) => {
      return sum + (item.price * item.count);
    }, 0);

    if (shop.selected) {
      totalAmount += shopTotal;
    }

    return {
      ...shop,
      total: shopTotal.toFixed(2),
      itemCount: selectedItems.length
    };
  });

  return {
    shops: result,
    finalTotal: totalAmount.toFixed(2)
  };
}
逻辑分析:
  • 使用 Object.values() 获取所有店铺数组,便于统一处理。
  • filter 提取选中商品, reduce 累加金额。
  • toFixed(2) 格式化保留两位小数,避免浮点误差。
  • 返回结构包含更新后的店铺列表与最终总价,可用于 setData 更新视图。
在页面中的集成方式:
// cart.js
const calc = require('../../utils/calculate');

Page({
  updateTotals() {
    const calculated = calc.calculateCart(this.data.rawCart);
    this.setData({
      displayCart: calculated.shops,
      totalPrice: calculated.finalTotal
    });
  }
});

结合 WXS computed 字段可在模板中直接调用计算函数,减少 JS-WXML 通信开销。

6.3 全选与单选状态同步

全选功能极大提升了用户操作效率,但其实现需处理复杂的双向绑定问题:全选按钮的状态取决于所有商品是否全部选中,而任一商品状态变更又可能反过来影响全选状态。

6.3.1 双向绑定实现全选按钮与商品项的联动

理想状态下,我们希望达到以下效果:
- 当所有商品均被选中时,全选按钮为“已选”
- 若任意一项未选,则全选按钮变为“未选”
- 点击全选按钮可一键切换所有商品状态

为此,可在页面逻辑中维护一个 isAllSelected 标志位,并在每次状态变更后重新计算。

// cart.js
Page({
  data: {
    isAllSelected: false,
    cart: {}
  },

  toggleAllSelection() {
    const allSelected = !this.data.isAllSelected;
    const updatedCart = JSON.parse(JSON.stringify(this.data.cart));

    for (let shop of Object.values(updatedCart.shops)) {
      shop.selected = allSelected;
      for (let item of shop.items) {
        item.selected = allSelected;
      }
    }

    this.setData({
      cart: updatedCart,
      isAllSelected: allSelected
    });
  },

  onItemSelect(e) {
    const { shopId, itemId } = e.currentTarget.dataset;
    const cart = this.data.cart;
    const item = getItemFromCart(cart, shopId, itemId);
    item.selected = !item.selected;

    // 重新计算全选状态
    const allSelected = this.checkIfAllSelected(cart);
    this.setData({
      cart,
      isAllSelected: allSelected
    });
  },

  checkIfAllSelected(cart) {
    for (let shop of Object.values(cart.shops)) {
      if (!shop.selected) return false;
      for (let item of shop.items) {
        if (!item.selected) return false;
      }
    }
    return true;
  }
});
流程图表示状态同步机制:
stateDiagram-v2
    [*] --> 用户操作
    用户操作 --> 是否点击全选?
    是否点击全选? --> 是: 调用 toggleAllSelection
    是否点击全选? --> 否: 触发 onItemSelect
    toggleAllSelection --> 批量设置 selected=true/false
    onItemSelect --> 修改单项状态
    onItemSelect --> checkIfAllSelected
    checkIfAllSelected --> 更新 isAllSelected
    更新 isAllSelected --> 视图刷新

该机制确保无论哪种操作路径,都能正确反映当前选中状态。

6.3.2 使用 computed 字段简化状态判断逻辑

尽管手动监听可行,但随着状态增多,维护成本上升。可通过引入 computed 概念(类似 Vue)来自动派生状态。

自定义 observe 机制示例:
function createReactive(data, computed) {
  const handlers = {
    set(target, key, value) {
      target[key] = value;
      if (computed[key]) {
        computed[key].call(null, target);
      }
      return true;
    }
  };
  return new Proxy(data, handlers);
}

// 使用
const state = createReactive(
  { cart: initialCart },
  {
    isAllSelected(newCart) {
      console.log('全选状态已更新');
      // 自动触发 UI 更新
    }
  }
);

借助 Proxy Object.defineProperty ,可实现自动依赖追踪,进一步解耦视图与逻辑。

6.4 购物车持久化与跨页面通信

6.4.1 利用本地缓存保存购物车内容

为防止用户关闭小程序后丢失购物车数据,需将其持久化至本地。

// utils/storage.js
const CART_KEY = 'user_cart_v1';

function saveCart(cart) {
  try {
    wx.setStorageSync(CART_KEY, cart);
  } catch (e) {
    console.error('Failed to save cart', e);
  }
}

function loadCart() {
  try {
    return wx.getStorageSync(CART_KEY) || { shops: {} };
  } catch (e) {
    return { shops: {} };
  }
}

建议设置过期时间(如 7 天),避免无限占用空间。

6.4.2 首页角标更新:通过全局事件或页面栈回传触发刷新

当购物车内容变更时,首页 tabBar 应显示角标数量。可通过 wx.setTabBarBadge 实现:

function updateBadge(count) {
  if (count > 0) {
    wx.setTabBarBadge({ index: 2, text: count.toString() });
  } else {
    wx.removeTabBarBadge({ index: 2 });
  }
}

跨页面通信可通过发布订阅模式实现:

// event-bus.js
const events = {};

export function on(event, callback) {
  if (!events[event]) events[event] = [];
  events[event].push(callback);
}

export function emit(event, data) {
  if (events[event]) {
    events[event].forEach(cb => cb(data));
  }
}

// 在 cart.js 中 emit
emit('cart-updated', getTotalCount());

// 在 home.js 中监听
on('cart-updated', (count) => updateBadge(count));

综上,完整的购物车体系需兼顾结构合理性、交互流畅性与数据可靠性,方能支撑真实商业场景下的高可用需求。

7. 订单提交流程与用户体验优化

7.1 订单页核心功能构建

在小程序仿饿了么点餐项目中,订单提交页面是用户完成消费行为的最后关键环节。该页面需清晰展示收货信息、支付方式、商品清单,并确保数据不可篡改以保障交易安全。

7.1.1 收货地址选择与默认地址优先展示

为提升转化率,系统应在进入订单页时自动加载用户已保存的地址列表,并将“默认地址”置顶显示。地址数据结构如下表所示:

字段名 类型 说明
id Number 地址唯一标识
name String 收货人姓名
phone String 联系电话
province String 省份
city String 城市
district String 区/县
detail String 详细地址
isDefault Boolean 是否为默认地址

前端通过 wx.chooseAddress 或本地缓存获取地址数据后,使用排序逻辑将默认地址前置:

// utils/address.js
function sortAddresses(addressList) {
  return addressList.sort((a, b) => {
    if (a.isDefault && !b.isDefault) return -1;
    if (!a.isDefault && b.isDefault) return 1;
    return 0;
  });
}

页面 WXML 中绑定渲染:

<block wx:for="{{addresses}}" wx:key="id">
  <view class="address-item" bindtap="onSelectAddress" data-id="{{item.id}}">
    <text>{{item.name}} {{item.phone}}</text>
    <text>{{item.province}}{{item.city}}{{item.district}}{{item.detail}}</text>
  </view>
</block>

7.1.2 支付方式切换:微信支付与余额支付模拟实现

支付方式组件采用单选模式,支持两种支付类型:

[
  { "type": "wechat", "label": "微信支付", "icon": "/assets/icons/wechat.png" },
  { "type": "balance", "label": "余额支付", "icon": "/assets/icons/balance.png", "disabled": false }
]

WXML 实现单选交互:

<radio-group bindchange="onPaymentChange">
  <label wx:for="{{paymentMethods}}" wx:key="type" class="payment-item">
    <image src="{{item.icon}}" mode="aspectFit" />
    <text>{{item.label}}</text>
    <radio value="{{item.type}}" disabled="{{item.disabled}}" />
  </label>
</radio-group>

JS 中处理选择逻辑:

Page({
  data: {
    selectedPayment: 'wechat',
    paymentMethods: [
      { type: 'wechat', label: '微信支付', icon: '/assets/icons/wechat.png' },
      { type: 'balance', label: '余额支付', icon: '/assets/icons/balance.png' }
    ]
  },
  onPaymentChange(e) {
    const type = e.detail.value;
    this.setData({ selectedPayment: type });
    this.showToast(`已切换至${type === 'wechat' ? '微信支付' : '余额支付'}`);
  }
});

7.1.3 订单商品清单展示与不可修改状态锁定

订单页应冻结购物车状态,防止用户中途修改数量或删除商品。为此,在跳转前对购物车数据进行深拷贝并标记为只读。

使用 lodash 进行深拷贝:

const _ = require('../../lib/lodash');

Page({
  onLoad() {
    const cartItems = getApp().globalData.cartItems;
    const orderItems = _.cloneDeep(cartItems); // 隔离原始数据
    this.setData({ orderItems }, () => {
      console.log('订单商品已锁定');
    });
  }
});

WXML 中禁用所有编辑操作:

<block wx:for="{{orderItems}}" wx:key="shopId">
  <view class="shop-section">
    <text>店铺:{{item.shopName}}</text>
    <block wx:for="{{item.goods}}" wx:key="id">
      <view class="goods-item readonly">
        <image src="{{item.image}}" mode="aspectFill" />
        <view class="info">
          <text>{{item.name}}</text>
          <text>¥{{item.price}}</text>
          <text>x{{item.count}}</text>
        </view>
      </view>
    </block>
  </view>
</block>

CSS 添加视觉锁定样式:

.readonly .info {
  color: #999;
  pointer-events: none;
}

7.2 本地 Mock 数据与接口模拟方案

7.2.1 构造模拟商品、地址、订单数据结构

为了脱离后端依赖快速开发测试,需构造完整的 mock 数据集。

示例:模拟订单请求体(orderPayload)

{
  "orderId": "OD202405120001",
  "userId": "U10086",
  "address": {
    "name": "张三",
    "phone": "13800138000",
    "fullAddress": "北京市朝阳区建国路88号"
  },
  "items": [
    { "name": "宫保鸡丁", "price": 32.5, "count": 1, "shopId": "S001" },
    { "name": "米饭", "price": 2.0, "count": 2, "shopId": "S001" }
  ],
  "totalPrice": 36.5,
  "paymentMethod": "wechat",
  "createdAt": "2024-05-12T12:30:00Z"
}

7.2.2 使用 mock-wx-request 拦截请求返回假数据

引入轻量级拦截库 mock-wx-request 实现 API 模拟:

安装并初始化:

npm install mock-wx-request --save

创建 mocks/order.js:

const Mock = require('mock-wx-request');

Mock.mock('/api/order/submit', 'POST', (options) => {
  const data = JSON.parse(options.data);
  return {
    code: 200,
    message: '订单提交成功',
    data: {
      orderId: 'OD' + Date.now(),
      payUrl: data.paymentMethod === 'wechat' ? '/pay/wechat' : '/pay/balance'
    }
  };
});

App 启动时注册:

// app.js
onLaunch() {
  if (process.env.NODE_ENV === 'development') {
    require('./mocks/order'); // 注册订单 mock
  }
}

调用真实接口时自动被拦截:

wx.request({
  url: '/api/order/submit',
  method: 'POST',
  data: payload,
  success(res) {
    console.log(res.data); // 输出 mock 数据
  }
});

7.3 UI 风格还原与响应式细节打磨

7.3.1 仿饿了么色彩体系与字体层级设计

定义主题变量于 app.wxss

/* 主色调 */
.theme-color { color: #00C389; }
.bg-primary { background-color: #00C389; }

/* 辅助色 */
.text-secondary { color: #666; }
.border-light { border-bottom: 1rpx solid #eee; }

/* 字体层级 */
.font-title { font-size: 32rpx; font-weight: bold; }
.font-content { font-size: 28rpx; }
.font-small { font-size: 24rpx; color: #999; }

7.3.2 使用 rpx 单位实现多设备适配与布局弹性

rpx 是微信小程序推荐的响应式单位,1rpx = 屏幕宽度 / 750。

常用布局规则:
- 页面边距: padding: 20rpx
- 商品卡片圆角: border-radius: 12rpx
- 图标尺寸: width: 40rpx; height: 40rpx

Flex 布局示例:

.order-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 30rpx 20rpx;
  border-bottom: 1rpx solid #f0f0f0;
}

7.4 动画与交互体验深度优化

7.4.1 商品加入购物车的抛物线动画实现

使用 <animation> 组件结合贝塞尔曲线模拟抛物线轨迹:

// goods-detail.js
addToCartAnimation(targetX, targetY) {
  const animation = wx.createAnimation({
    duration: 600,
    timingFunction: 'cubic-bezier(0.4, 0, 0.4, 1)'
  });

  animation.translate(targetX, targetY).opacity(0).step();
  this.setData({ cartAnimation: animation.export() });
}

WXML 中绑定动画:

<view animation="{{cartAnimation}}" class="fly-ball"></view>

初始位置通过 createSelectorQuery 获取目标坐标:

const query = this.createSelectorQuery();
query.select('.cart-icon').boundingClientRect();
query.exec((res) => {
  const { left, top } = res[0];
  this.addToCartAnimation(left - startX, top - startY);
});

7.4.2 页面切换过渡动画与加载骨架屏设计

使用自定义 transition 动画提升流畅感:

// page.json
{
  "navigationStyle": "custom",
  "enablePullDownRefresh": false,
  "pageOrientation": "portrait",
  "style": "mpvue"
}

骨架屏组件 skeleton.wxml:

<view class="skeleton">
  <view class="skeleton-avatar"></view>
  <view class="skeleton-row"></view>
  <view class="skeleton-row short"></view>
</view>

控制显示时机:

this.setData({ showSkeleton: true });
setTimeout(() => {
  this.loadData().then(() => {
    this.setData({ showSkeleton: false });
  });
}, 200);

7.4.3 用户操作延迟反馈提示与异常场景友好提示

对于网络请求等异步操作,提供 loading 提示:

wx.showLoading({ title: '提交中...' });

wx.request({
  url: '/api/order/submit',
  success: () => {
    wx.hideLoading();
    wx.showToast({ title: '下单成功', icon: 'success' });
  },
  fail: () => {
    wx.hideLoading();
    wx.showModal({
      title: '提交失败',
      content: '网络不稳定,请检查连接后重试',
      showCancel: false
    });
  }
});

使用 Toast 提供短时反馈:

showToast(title, icon = 'none') {
  wx.showToast({ title, icon, duration: 2000 });
}

Mermaid 流程图展示订单提交流程:

graph TD
    A[进入订单页] --> B{是否有默认地址?}
    B -->|是| C[加载默认地址]
    B -->|否| D[提示添加地址]
    C --> E[展示商品清单]
    E --> F[选择支付方式]
    F --> G[点击提交订单]
    G --> H[显示loading]
    H --> I[调用API提交]
    I --> J{是否成功?}
    J -->|是| K[跳转支付页]
    J -->|否| L[弹出错误提示]
    L --> M[允许重试]

本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

简介:本文详细讲解如何使用微信小程序技术实现一个高还原度的仿饿了么点餐界面,涵盖项目架构、页面设计与核心功能实现。项目基于小程序基础文件(app.js、app.json、app.wxss等)搭建整体结构,通过pages目录下的首页、商品详情页、购物车页和订单页实现完整点餐流程。结合utils工具函数处理数据逻辑,lib引入第三方组件提升UI表现,并利用本地Mock数据模拟后端接口,配合Iconfont图标库优化视觉体验。该项目适合希望掌握小程序开发、购物车逻辑实现及交互设计的开发者进行学习与实战。


本文还有配套的精品资源,点击获取
menu-r.4af5f7ec.gif

Logo

火山引擎开发者社区是火山引擎打造的AI技术生态平台,聚焦Agent与大模型开发,提供豆包系列模型(图像/视频/视觉)、智能分析与会话工具,并配套评测集、动手实验室及行业案例库。社区通过技术沙龙、挑战赛等活动促进开发者成长,新用户可领50万Tokens权益,助力构建智能应用。

更多推荐