前言

在电商类跨端应用中,商品模块是整个项目的核心,也是最考验开发功底的部分:既要保证多端渲染一致性,又要兼顾长列表性能,还要处理好复杂的交互逻辑。很多初学者写的商品页面往往存在代码冗余、复用性差、卡顿严重等问题。

本文将以企业级标准,带你从零搭建一套高可用的电商商品模块。你将学到:

  • Vue3 + TS 环境下的 UniApp 工程化架构设计
  • 通用组件封装思路与最佳实践
  • 长列表分页加载与性能优化
  • 跨端样式兼容与条件编译技巧
  • 规格选择弹窗等复杂交互实现
  • 企业级项目的分层设计思想

最终实现效果:一套代码同时兼容微信小程序、H5、App 三端,包含搜索栏、分类筛选、下拉刷新、上拉加载、商品瀑布流、详情轮播、规格选择等完整功能。


一、项目初始化与架构设计

1.1 环境搭建

本文采用 Vite + Vue3 + TypeScript 方案,这是目前 UniApp 官方推荐的工程化方式,编译速度更快,类型支持更完善。

bash

运行

# 创建 Vite + TS 版 UniApp 项目
npx degit dcloudio/uni-preset-vue#vite-ts uni-shop-demo

# 进入项目
cd uni-shop-demo

# 安装依赖
npm install

运行命令:

bash

运行

# H5 开发
npm run dev:h5

# 微信小程序开发
npm run dev:mp-weixin

# App 开发
npm run dev:app

1.2 目录结构设计

好的目录结构是项目可维护性的基础。我们按照「分层设计、按业务模块化」的原则组织目录:

plaintext

src/
├── api/                  # 接口层:统一管理所有请求
│   └── goods.ts          # 商品相关接口
├── components/           # 公共组件层
│   ├── base/             # 基础通用组件
│   │   ├── search-bar/   # 搜索栏
│   │   ├── empty/        # 空状态
│   │   └── load-more/    # 加载更多
│   └── business/         # 业务组件
│       ├── goods-card/   # 商品卡片
│       └── spec-modal/   # 规格选择弹窗
├── pages/                # 页面层
│   ├── goods/
│   │   ├── list.vue      # 商品列表页
│   │   └── detail.vue    # 商品详情页
│   └── index/
├── styles/               # 全局样式
│   ├── variables.scss    # SCSS 变量
│   └── common.scss       # 公共样式类
├── utils/                # 工具函数层
│   ├── request.ts        # 请求封装
│   └── format.ts         # 格式化工具
├── types/                # TS 类型定义
│   └── goods.d.ts        # 商品相关类型
└── static/               # 静态资源

1.3 全局类型定义

先定义商品相关的 TypeScript 类型,为后续开发提供类型保障:

typescript

运行

// src/types/goods.d.ts
// 商品基础信息
export interface GoodsItem {
  id: number
  name: string
  price: number
  originalPrice: number
  cover: string
  sales: number
  tags: string[]
}

// 商品详情
export interface GoodsDetail extends GoodsItem {
  images: string[]
  description: string
  specs: SpecGroup[]
  skuList: SkuItem[]
}

// 规格组
export interface SpecGroup {
  name: string
  options: SpecOption[]
}

export interface SpecOption {
  id: number
  name: string
}

// SKU 项
export interface SkuItem {
  id: number
  price: number
  stock: number
  specs: Record<string, string>
}

// 分页参数
export interface PageParams {
  page: number
  pageSize: number
  categoryId?: number
  keyword?: string
}

// 分页结果
export interface PageResult<T> {
  list: T[]
  total: number
  hasMore: boolean
}

二、基础能力封装

2.1 网络请求封装(TS 版)

统一封装 uni.request,加入类型推导、请求拦截、响应拦截和错误处理:

typescript

运行

// src/utils/request.ts
const BASE_URL = import.meta.env.VITE_API_BASE_URL as string
const TIMEOUT = 10000

interface RequestConfig {
  url: string
  method?: 'GET' | 'POST' | 'PUT' | 'DELETE'
  data?: any
  header?: Record<string, string>
}

// 请求拦截
const requestInterceptor = (config: RequestConfig): RequestConfig => {
  const token = uni.getStorageSync('token')
  if (token) {
    config.header = {
      ...config.header,
      Authorization: `Bearer ${token}`
    }
  }
  return config
}

// 响应拦截
const responseInterceptor = <T>(response: UniApp.RequestSuccessCallbackResult): Promise<T> => {
  const { statusCode, data } = response
  
  if (statusCode === 200) {
    const res = data as any
    if (res.code === 200) {
      return Promise.resolve(res.data)
    } else if (res.code === 401) {
      uni.removeStorageSync('token')
      uni.redirectTo({ url: '/pages/login/index' })
      return Promise.reject(res)
    } else {
      uni.showToast({ title: res.msg || '请求失败', icon: 'none' })
      return Promise.reject(res)
    }
  }
  
  uni.showToast({ title: '网络错误', icon: 'none' })
  return Promise.reject(response)
}

// 核心请求方法
const request = <T>(config: RequestConfig): Promise<T> => {
  return new Promise((resolve, reject) => {
    const finalConfig = requestInterceptor({
      ...config,
      url: BASE_URL + config.url,
      header: config.header || {}
    })
    
    uni.request({
      ...finalConfig,
      timeout: TIMEOUT,
      success: (res) => {
        responseInterceptor<T>(res).then(resolve).catch(reject)
      },
      fail: (err) => {
        uni.showToast({ title: '网络连接失败', icon: 'none' })
        reject(err)
      }
    })
  })
}

// 导出通用方法
export const http = {
  get: <T>(url: string, data?: any) => request<T>({ url, method: 'GET', data }),
  post: <T>(url: string, data?: any) => request<T>({ url, method: 'POST', data })
}

2.2 商品接口层

将所有商品相关的接口统一管理,业务层不直接调用请求工具:

typescript

运行

// src/api/goods.ts
import { http } from '@/utils/request'
import type { GoodsItem, GoodsDetail, PageParams, PageResult } from '@/types/goods'

/**
 * 获取商品列表
 */
export const getGoodsList = (params: PageParams) => {
  return http.get<PageResult<GoodsItem>>('/api/goods/list', params)
}

/**
 * 获取商品详情
 */
export const getGoodsDetail = (id: number) => {
  return http.get<GoodsDetail>(`/api/goods/detail/${id}`)
}

/**
 * 获取分类列表
 */
export const getCategoryList = () => {
  return http.get<{ id: number; name: string }[]>('/api/category/list')
}

2.3 全局样式变量

styles/variables.scss 中统一定义设计变量,保证整体视觉一致性:

scss

// 主色调
$primary-color: #ff6b35;
$success-color: #52c41a;
$warning-color: #faad14;
$danger-color: #ff4d4f;

// 文本颜色
$text-primary: #333333;
$text-regular: #666666;
$text-secondary: #999999;
$text-placeholder: #cccccc;

// 背景色
$bg-color: #f5f5f5;
$card-bg: #ffffff;

// 间距
$spacing-xs: 10rpx;
$spacing-sm: 20rpx;
$spacing-md: 30rpx;
$spacing-lg: 40rpx;

// 圆角
$radius-sm: 8rpx;
$radius-md: 16rpx;
$radius-lg: 24rpx;

vite.config.ts 中配置全局注入,无需每个页面手动引入:

typescript

运行

export default defineConfig({
  css: {
    preprocessorOptions: {
      scss: {
        additionalData: `@import "@/styles/variables.scss";`
      }
    }
  }
})

三、核心业务组件封装

组件化是提升开发效率的关键。我们将商品模块中可复用的部分抽离成独立组件。

3.1 商品卡片组件

商品卡片是最高频复用的组件,封装后列表页、搜索页、推荐位都可以复用。

vue

<!-- src/components/business/goods-card/goods-card.vue -->
<template>
  <view class="goods-card" @click="handleClick">
    <image :src="goods.cover" mode="aspectFill" class="goods-cover" />
    <view class="goods-info">
      <text class="goods-name">{{ goods.name }}</text>
      <view class="tags" v-if="goods.tags?.length">
        <text class="tag" v-for="(tag, i) in goods.tags" :key="i">{{ tag }}</text>
      </view>
      <view class="bottom">
        <view class="price-box">
          <text class="price-symbol">¥</text>
          <text class="price">{{ goods.price }}</text>
          <text class="original-price" v-if="goods.originalPrice">
            ¥{{ goods.originalPrice }}
          </text>
        </view>
        <text class="sales">已售{{ goods.sales }}</text>
      </view>
    </view>
  </view>
</template>

<script setup lang="ts">
import type { GoodsItem } from '@/types/goods'

interface Props {
  goods: GoodsItem
}

const props = defineProps<Props>()
const emit = defineEmits<{
  click: [goods: GoodsItem]
}>()

const handleClick = () => {
  emit('click', props.goods)
}
</script>

<style lang="scss" scoped>
.goods-card {
  background: $card-bg;
  border-radius: $radius-md;
  overflow: hidden;
  margin-bottom: $spacing-sm;
  
  .goods-cover {
    width: 100%;
    height: 340rpx;
    display: block;
  }
  
  .goods-info {
    padding: $spacing-sm;
    
    .goods-name {
      font-size: 28rpx;
      color: $text-primary;
      line-height: 1.4;
      display: -webkit-box;
      -webkit-line-clamp: 2;
      -webkit-box-orient: vertical;
      overflow: hidden;
      min-height: 78rpx;
    }
    
    .tags {
      display: flex;
      gap: 10rpx;
      margin: 10rpx 0;
      
      .tag {
        font-size: 20rpx;
        color: $primary-color;
        padding: 4rpx 10rpx;
        background: rgba(255, 107, 53, 0.1);
        border-radius: 4rpx;
      }
    }
    
    .bottom {
      display: flex;
      justify-content: space-between;
      align-items: flex-end;
      margin-top: 10rpx;
      
      .price-box {
        display: flex;
        align-items: baseline;
        
        .price-symbol {
          font-size: 24rpx;
          color: $primary-color;
          font-weight: bold;
        }
        
        .price {
          font-size: 36rpx;
          color: $primary-color;
          font-weight: bold;
          margin-left: 2rpx;
        }
        
        .original-price {
          font-size: 22rpx;
          color: $text-placeholder;
          text-decoration: line-through;
          margin-left: 10rpx;
        }
      }
      
      .sales {
        font-size: 22rpx;
        color: $text-secondary;
      }
    }
  }
}
</style>

3.2 搜索栏组件

封装通用搜索栏,支持输入、搜索、取消等功能:

vue

<!-- src/components/base/search-bar/search-bar.vue -->
<template>
  <view class="search-bar">
    <view class="search-input-wrap">
      <text class="search-icon">🔍</text>
      <input
        class="search-input"
        type="text"
        :value="modelValue"
        :placeholder="placeholder"
        confirm-type="search"
        @input="handleInput"
        @confirm="handleSearch"
      />
      <text v-if="modelValue" class="clear-icon" @click="handleClear">✕</text>
    </view>
    <text v-if="showCancel" class="cancel-btn" @click="handleCancel">取消</text>
  </view>
</template>

<script setup lang="ts">
interface Props {
  modelValue: string
  placeholder?: string
  showCancel?: boolean
}

const props = withDefaults(defineProps<Props>(), {
  placeholder: '请输入搜索关键词',
  showCancel: false
})

const emit = defineEmits<{
  'update:modelValue': [value: string]
  search: [keyword: string]
  cancel: []
}>()

const handleInput = (e: any) => {
  emit('update:modelValue', e.detail.value)
}

const handleSearch = () => {
  emit('search', props.modelValue)
}

const handleClear = () => {
  emit('update:modelValue', '')
}

const handleCancel = () => {
  emit('cancel')
}
</script>

<style lang="scss" scoped>
.search-bar {
  display: flex;
  align-items: center;
  padding: 20rpx $spacing-md;
  background: #fff;
  gap: 20rpx;
  
  .search-input-wrap {
    flex: 1;
    display: flex;
    align-items: center;
    height: 72rpx;
    background: $bg-color;
    border-radius: 36rpx;
    padding: 0 24rpx;
    gap: 12rpx;
    
    .search-icon {
      font-size: 28rpx;
      color: $text-secondary;
    }
    
    .search-input {
      flex: 1;
      font-size: 28rpx;
      color: $text-primary;
    }
    
    .clear-icon {
      font-size: 28rpx;
      color: $text-placeholder;
      padding: 4rpx;
    }
  }
  
  .cancel-btn {
    font-size: 28rpx;
    color: $text-regular;
  }
}
</style>

3.3 加载更多组件

统一封装列表底部的加载状态提示:

vue

<!-- src/components/base/load-more/load-more.vue -->
<template>
  <view class="load-more">
    <text v-if="loading" class="text">加载中...</text>
    <text v-else-if="!hasMore" class="text no-more">没有更多了</text>
    <text v-else class="text" @click="$emit('load')">上拉加载更多</text>
  </view>
</template>

<script setup lang="ts">
interface Props {
  loading: boolean
  hasMore: boolean
}

defineProps<Props>()
defineEmits(['load'])
</script>

<style lang="scss" scoped>
.load-more {
  padding: 40rpx 0;
  text-align: center;
  
  .text {
    font-size: 24rpx;
    color: $text-secondary;
  }
  
  .no-more {
    color: $text-placeholder;
  }
}
</style>

四、商品列表页完整实现

商品列表页是电商应用的核心页面,集成了分类切换、搜索、下拉刷新、上拉加载、瀑布流布局等功能。

4.1 页面完整代码

vue

<!-- src/pages/goods/list.vue -->
<template>
  <view class="goods-list-page">
    <!-- 搜索栏 -->
    <search-bar 
      v-model="keyword" 
      placeholder="搜索商品"
      @search="handleSearch"
    />
    
    <!-- 分类标签栏 -->
    <scroll-view class="category-tabs" scroll-x enable-flex>
      <view 
        v-for="cat in categoryList" 
        :key="cat.id"
        class="tab-item"
        :class="{ active: currentCategoryId === cat.id }"
        @click="handleCategoryChange(cat.id)"
      >
        {{ cat.name }}
      </view>
    </scroll-view>
    
    <!-- 商品瀑布流 -->
    <scroll-view 
      class="goods-scroll"
      scroll-y
      refresher-enabled
      :refresher-triggered="refreshing"
      @refresherrefresh="onRefresh"
      @scrolltolower="onLoadMore"
    >
      <view class="goods-waterfall">
        <view class="waterfall-column">
          <goods-card
            v-for="item in leftList"
            :key="item.id"
            :goods="item"
            @click="goToDetail(item.id)"
          />
        </view>
        <view class="waterfall-column">
          <goods-card
            v-for="item in rightList"
            :key="item.id"
            :goods="item"
            @click="goToDetail(item.id)"
          />
        </view>
      </view>
      
      <!-- 加载更多 -->
      <load-more 
        :loading="loading" 
        :has-more="hasMore" 
        @load="onLoadMore"
      />
      
      <!-- 空状态 -->
      <empty v-if="!loading && goodsList.length === 0" text="暂无相关商品" />
    </scroll-view>
  </view>
</template>

<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import SearchBar from '@/components/base/search-bar/search-bar.vue'
import GoodsCard from '@/components/business/goods-card/goods-card.vue'
import LoadMore from '@/components/base/load-more/load-more.vue'
import Empty from '@/components/base/empty/empty.vue'
import { getGoodsList, getCategoryList } from '@/api/goods'
import type { GoodsItem } from '@/types/goods'

// 搜索相关
const keyword = ref('')

// 分类相关
const categoryList = ref<{ id: number; name: string }[]>([])
const currentCategoryId = ref(0)

// 列表数据
const goodsList = ref<GoodsItem[]>([])
const loading = ref(false)
const refreshing = ref(false)
const hasMore = ref(true)
const pageParams = ref({
  page: 1,
  pageSize: 10
})

// 瀑布流分列:奇数放左,偶数放右
const leftList = computed(() => {
  return goodsList.value.filter((_, i) => i % 2 === 0)
})

const rightList = computed(() => {
  return goodsList.value.filter((_, i) => i % 2 === 1)
})

// 加载分类
const loadCategories = async () => {
  try {
    const res = await getCategoryList()
    categoryList.value = [{ id: 0, name: '全部' }, ...res]
  } catch (e) {
    console.error('加载分类失败', e)
  }
}

// 加载商品列表
const loadGoodsList = async (isRefresh = false) => {
  if (loading.value) return
  if (!hasMore.value && !isRefresh) return
  
  loading.value = true
  
  if (isRefresh) {
    pageParams.value.page = 1
    hasMore.value = true
  }
  
  try {
    const res = await getGoodsList({
      ...pageParams.value,
      categoryId: currentCategoryId.value || undefined,
      keyword: keyword.value || undefined
    })
    
    if (isRefresh) {
      goodsList.value = res.list
    } else {
      goodsList.value = [...goodsList.value, ...res.list]
    }
    
    hasMore.value = res.hasMore
    pageParams.value.page++
  } catch (e) {
    console.error('加载商品失败', e)
  } finally {
    loading.value = false
    refreshing.value = false
  }
}

// 下拉刷新
const onRefresh = () => {
  refreshing.value = true
  loadGoodsList(true)
}

// 上拉加载
const onLoadMore = () => {
  if (!loading.value && hasMore.value) {
    loadGoodsList()
  }
}

// 分类切换
const handleCategoryChange = (id: number) => {
  if (currentCategoryId.value === id) return
  currentCategoryId.value = id
  loadGoodsList(true)
}

// 搜索
const handleSearch = () => {
  loadGoodsList(true)
}

// 跳转详情
const goToDetail = (id: number) => {
  uni.navigateTo({
    url: `/pages/goods/detail?id=${id}`
  })
}

onMounted(() => {
  loadCategories()
  loadGoodsList(true)
})
</script>

<style lang="scss" scoped>
.goods-list-page {
  height: 100vh;
  display: flex;
  flex-direction: column;
  background: $bg-color;
}

.category-tabs {
  background: #fff;
  white-space: nowrap;
  padding: 0 $spacing-md;
  border-bottom: 1rpx solid #eee;
  
  .tab-item {
    display: inline-block;
    padding: 24rpx 30rpx;
    font-size: 28rpx;
    color: $text-regular;
    position: relative;
    
    &.active {
      color: $primary-color;
      font-weight: bold;
      
      &::after {
        content: '';
        position: absolute;
        bottom: 0;
        left: 50%;
        transform: translateX(-50%);
        width: 40rpx;
        height: 6rpx;
        background: $primary-color;
        border-radius: 3rpx;
      }
    }
  }
}

.goods-scroll {
  flex: 1;
  padding: $spacing-sm;
  box-sizing: border-box;
}

.goods-waterfall {
  display: flex;
  gap: $spacing-sm;
  
  .waterfall-column {
    flex: 1;
  }
}
</style>

4.2 设计要点解析

  1. 瀑布流布局:采用双列等宽布局,通过 CSS Flex 实现,简单高效。如果需要更精准的高度计算,可以在 JS 层根据图片高度动态分配左右列。
  2. 分页逻辑:使用 page + pageSize 标准分页,通过 hasMore 标记是否还有更多数据,避免无效请求。
  3. 下拉刷新:使用 scroll-view 原生的 refresher 能力,多端一致性好。
  4. 防抖优化:实际项目中建议对搜索输入加防抖处理,避免频繁请求。

五、商品详情页完整实现

详情页是转化的关键,包含轮播图、商品信息、规格选择、底部操作栏等核心模块。

5.1 规格选择弹窗组件

规格选择是详情页最复杂的交互,单独封装成组件:

vue

<!-- src/components/business/spec-modal/spec-modal.vue -->
<template>
  <view class="spec-modal" v-if="visible" @click.self="handleClose">
    <view class="modal-content">
      <!-- 商品简要信息 -->
      <view class="goods-header">
        <image :src="cover" mode="aspectFill" class="goods-thumb" />
        <view class="goods-info">
          <text class="price">¥{{ currentPrice }}</text>
          <text class="stock">库存 {{ currentStock }} 件</text>
          <text class="selected">已选:{{ selectedSpecText }}</text>
        </view>
        <text class="close-btn" @click="handleClose">✕</text>
      </view>
      
      <!-- 规格组 -->
      <view class="spec-groups">
        <view class="spec-group" v-for="group in specGroups" :key="group.name">
          <text class="group-name">{{ group.name }}</text>
          <view class="spec-options">
            <view 
              v-for="option in group.options" 
              :key="option.id"
              class="spec-option"
              :class="{ 
                active: selectedSpecs[group.name] === option.name,
                disabled: !isOptionAvailable(group.name, option.name)
              }"
              @click="selectSpec(group.name, option)"
            >
              {{ option.name }}
            </view>
          </view>
        </view>
      </view>
      
      <!-- 数量选择 -->
      <view class="quantity-section">
        <text class="label">购买数量</text>
        <view class="quantity-control">
          <text class="btn" :class="{ disabled: quantity <= 1 }" @click="decrease">-</text>
          <text class="num">{{ quantity }}</text>
          <text class="btn" :class="{ disabled: quantity >= currentStock }" @click="increase">+</text>
        </view>
      </view>
      
      <!-- 确认按钮 -->
      <view class="footer">
        <button class="confirm-btn" @click="handleConfirm">确定</button>
      </view>
    </view>
  </view>
</template>

<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import type { SpecGroup, SkuItem } from '@/types/goods'

interface Props {
  visible: boolean
  cover: string
  specGroups: SpecGroup[]
  skuList: SkuItem[]
}

const props = defineProps<Props>()
const emit = defineEmits<{
  'update:visible': [value: boolean]
  confirm: [skuId: number, quantity: number]
}>()

const selectedSpecs = ref<Record<string, string>>({})
const quantity = ref(1)

// 已选规格文本
const selectedSpecText = computed(() => {
  const values = Object.values(selectedSpecs.value)
  return values.length ? values.join(' ') : '请选择规格'
})

// 当前选中的 SKU
const currentSku = computed(() => {
  if (Object.keys(selectedSpecs.value).length !== props.specGroups.length) {
    return null
  }
  return props.skuList.find(sku => {
    return Object.entries(selectedSpecs.value).every(
      ([key, value]) => sku.specs[key] === value
    )
  }) || null
})

const currentPrice = computed(() => currentSku.value?.price ?? 0)
const currentStock = computed(() => currentSku.value?.stock ?? 0)

// 选择规格
const selectSpec = (groupName: string, option: { id: number; name: string }) => {
  if (!isOptionAvailable(groupName, option.name)) return
  selectedSpecs.value[groupName] = option.name
}

// 判断规格选项是否可选(简单算法:假设其他规格已选时,该选项是否有对应库存)
const isOptionAvailable = (groupName: string, optionName: string): boolean => {
  const testSpecs = { ...selectedSpecs.value, [groupName]: optionName }
  
  return props.skuList.some(sku => {
    return Object.entries(testSpecs).every(([key, value]) => {
      if (!sku.specs[key]) return true
      return sku.specs[key] === value
    }) && sku.stock > 0
  })
}

const decrease = () => {
  if (quantity.value > 1) quantity.value--
}

const increase = () => {
  if (quantity.value < currentStock.value) quantity.value++
}

const handleClose = () => {
  emit('update:visible', false)
}

const handleConfirm = () => {
  if (!currentSku.value) {
    uni.showToast({ title: '请选择完整规格', icon: 'none' })
    return
  }
  emit('confirm', currentSku.value.id, quantity.value)
  handleClose()
}

// 打开弹窗时重置
watch(() => props.visible, (val) => {
  if (val) {
    selectedSpecs.value = {}
    quantity.value = 1
  }
})
</script>

<style lang="scss" scoped>
.spec-modal {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background: rgba(0, 0, 0, 0.5);
  z-index: 999;
  display: flex;
  align-items: flex-end;
  
  .modal-content {
    width: 100%;
    max-height: 80vh;
    background: #fff;
    border-radius: $radius-lg $radius-lg 0 0;
    padding: $spacing-md;
    box-sizing: border-box;
    animation: slideUp 0.3s ease;
  }
  
  @keyframes slideUp {
    from { transform: translateY(100%); }
    to { transform: translateY(0); }
  }
}

.goods-header {
  display: flex;
  gap: $spacing-sm;
  position: relative;
  padding-bottom: $spacing-md;
  border-bottom: 1rpx solid #eee;
  
  .goods-thumb {
    width: 160rpx;
    height: 160rpx;
    border-radius: $radius-sm;
    margin-top: -60rpx;
    border: 4rpx solid #fff;
  }
  
  .goods-info {
    flex: 1;
    display: flex;
    flex-direction: column;
    gap: 10rpx;
    
    .price {
      font-size: 36rpx;
      color: $primary-color;
      font-weight: bold;
    }
    
    .stock, .selected {
      font-size: 24rpx;
      color: $text-secondary;
    }
  }
  
  .close-btn {
    font-size: 32rpx;
    color: $text-placeholder;
    padding: 10rpx;
  }
}

.spec-groups {
  padding: $spacing-md 0;
  
  .spec-group {
    margin-bottom: $spacing-md;
    
    .group-name {
      font-size: 28rpx;
      color: $text-primary;
      font-weight: bold;
      margin-bottom: 20rpx;
      display: block;
    }
    
    .spec-options {
      display: flex;
      flex-wrap: wrap;
      gap: 20rpx;
      
      .spec-option {
        padding: 16rpx 32rpx;
        background: $bg-color;
        border-radius: $radius-sm;
        font-size: 26rpx;
        color: $text-regular;
        border: 2rpx solid transparent;
        
        &.active {
          color: $primary-color;
          border-color: $primary-color;
          background: rgba(255, 107, 53, 0.05);
        }
        
        &.disabled {
          color: $text-placeholder;
          text-decoration: line-through;
        }
      }
    }
  }
}

.quantity-section {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: $spacing-md 0;
  border-top: 1rpx solid #eee;
  
  .label {
    font-size: 28rpx;
    color: $text-primary;
  }
  
  .quantity-control {
    display: flex;
    align-items: center;
    
    .btn {
      width: 60rpx;
      height: 60rpx;
      display: flex;
      align-items: center;
      justify-content: center;
      background: $bg-color;
      border-radius: 8rpx;
      font-size: 32rpx;
      
      &.disabled {
        color: $text-placeholder;
      }
    }
    
    .num {
      width: 80rpx;
      text-align: center;
      font-size: 28rpx;
    }
  }
}

.footer {
  padding-top: $spacing-md;
  
  .confirm-btn {
    width: 100%;
    height: 88rpx;
    line-height: 88rpx;
    background: $primary-color;
    color: #fff;
    border-radius: 44rpx;
    font-size: 30rpx;
    border: none;
  }
}
</style>

5.2 详情页完整代码

vue

<!-- src/pages/goods/detail.vue -->
<template>
  <view class="goods-detail-page" v-if="goodsDetail">
    <!-- 轮播图 -->
    <swiper 
      class="detail-swiper" 
      indicator-dots 
      autoplay 
      circular
      indicator-color="rgba(255,255,255,0.5)"
      indicator-active-color="#fff"
    >
      <swiper-item v-for="(img, i) in goodsDetail.images" :key="i">
        <image 
          :src="img" 
          mode="aspectFill" 
          class="swiper-img"
          @click="previewImage(i)"
        />
      </swiper-item>
    </swiper>
    
    <!-- 商品信息 -->
    <view class="goods-info-card">
      <view class="price-row">
        <text class="current-price">¥{{ goodsDetail.price }}</text>
        <text class="original-price" v-if="goodsDetail.originalPrice">
          ¥{{ goodsDetail.originalPrice }}
        </text>
        <text class="sales">月销{{ goodsDetail.sales }}</text>
      </view>
      <text class="goods-name">{{ goodsDetail.name }}</text>
      <view class="tags" v-if="goodsDetail.tags?.length">
        <text class="tag" v-for="(tag, i) in goodsDetail.tags" :key="i">{{ tag }}</text>
      </view>
    </view>
    
    <!-- 规格选择入口 -->
    <view class="spec-entry" @click="showSpecModal = true">
      <text class="label">规格</text>
      <text class="value">{{ selectedSpecText || '请选择规格' }}</text>
      <text class="arrow">></text>
    </view>
    
    <!-- 商品详情图文 -->
    <view class="detail-content">
      <view class="section-title">商品详情</view>
      <rich-text :nodes="goodsDetail.description" class="rich-text"></rich-text>
    </view>
    
    <!-- 底部操作栏 -->
    <view class="bottom-bar">
      <view class="bar-item" @click="goToHome">
        <text class="icon">🏠</text>
        <text class="text">首页</text>
      </view>
      <view class="bar-item" @click="goToCart">
        <text class="icon">🛒</text>
        <text class="text">购物车</text>
      </view>
      <button class="btn add-cart" @click="openSpec('cart')">加入购物车</button>
      <button class="btn buy-now" @click="openSpec('buy')">立即购买</button>
    </view>
    
    <!-- 规格选择弹窗 -->
    <spec-modal
      v-model:visible="showSpecModal"
      :cover="goodsDetail.cover"
      :spec-groups="goodsDetail.specs"
      :sku-list="goodsDetail.skuList"
      @confirm="handleSpecConfirm"
    />
  </view>
</template>

<script setup lang="ts">
import { ref, computed, onLoad } from '@dcloudio/uni-app'
import SpecModal from '@/components/business/spec-modal/spec-modal.vue'
import { getGoodsDetail } from '@/api/goods'
import type { GoodsDetail } from '@/types/goods'

const goodsId = ref(0)
const goodsDetail = ref<GoodsDetail | null>(null)
const showSpecModal = ref(false)
const buyType = ref<'cart' | 'buy'>('cart')

const selectedSpecText = ref('')

// 加载商品详情
const loadDetail = async () => {
  if (!goodsId.value) return
  
  uni.showLoading({ title: '加载中' })
  try {
    const res = await getGoodsDetail(goodsId.value)
    goodsDetail.value = res
  } catch (e) {
    console.error('加载详情失败', e)
  } finally {
    uni.hideLoading()
  }
}

// 图片预览
const previewImage = (index: number) => {
  if (!goodsDetail.value) return
  uni.previewImage({
    current: index,
    urls: goodsDetail.value.images
  })
}

const openSpec = (type: 'cart' | 'buy') => {
  buyType.value = type
  showSpecModal.value = true
}

// 规格确认
const handleSpecConfirm = (skuId: number, quantity: number) => {
  if (buyType.value === 'cart') {
    uni.showToast({ title: '已加入购物车', icon: 'success' })
    // 实际项目调用加入购物车接口
  } else {
    uni.showToast({ title: '跳转结算页', icon: 'none' })
    // 实际项目跳转结算页
  }
}

const goToHome = () => {
  uni.switchTab({ url: '/pages/index/index' })
}

const goToCart = () => {
  uni.switchTab({ url: '/pages/cart/index' })
}

onLoad((options) => {
  if (options?.id) {
    goodsId.value = Number(options.id)
    loadDetail()
  }
})
</script>

<style lang="scss" scoped>
.goods-detail-page {
  padding-bottom: 120rpx;
  background: $bg-color;
}

.detail-swiper {
  width: 100%;
  height: 750rpx;
  
  .swiper-img {
    width: 100%;
    height: 100%;
  }
}

.goods-info-card {
  background: #fff;
  padding: $spacing-md;
  margin-bottom: 20rpx;
  
  .price-row {
    display: flex;
    align-items: baseline;
    gap: 20rpx;
    margin-bottom: 16rpx;
    
    .current-price {
      font-size: 48rpx;
      color: $primary-color;
      font-weight: bold;
    }
    
    .original-price {
      font-size: 26rpx;
      color: $text-placeholder;
      text-decoration: line-through;
    }
    
    .sales {
      margin-left: auto;
      font-size: 24rpx;
      color: $text-secondary;
    }
  }
  
  .goods-name {
    font-size: 32rpx;
    color: $text-primary;
    line-height: 1.5;
    font-weight: 500;
  }
  
  .tags {
    display: flex;
    gap: 16rpx;
    margin-top: 20rpx;
    
    .tag {
      font-size: 22rpx;
      color: $primary-color;
      padding: 6rpx 14rpx;
      background: rgba(255, 107, 53, 0.1);
      border-radius: 4rpx;
    }
  }
}

.spec-entry {
  background: #fff;
  padding: $spacing-md;
  display: flex;
  align-items: center;
  margin-bottom: 20rpx;
  
  .label {
    font-size: 28rpx;
    color: $text-regular;
    width: 120rpx;
  }
  
  .value {
    flex: 1;
    font-size: 28rpx;
    color: $text-primary;
  }
  
  .arrow {
    color: $text-placeholder;
    font-size: 28rpx;
  }
}

.detail-content {
  background: #fff;
  padding: $spacing-md;
  
  .section-title {
    font-size: 30rpx;
    font-weight: bold;
    color: $text-primary;
    margin-bottom: 20rpx;
    padding-bottom: 20rpx;
    border-bottom: 1rpx solid #eee;
  }
  
  .rich-text {
    img {
      max-width: 100%;
    }
  }
}

.bottom-bar {
  position: fixed;
  bottom: 0;
  left: 0;
  right: 0;
  height: 120rpx;
  background: #fff;
  display: flex;
  align-items: center;
  padding: 0 $spacing-sm;
  box-shadow: 0 -2rpx 10rpx rgba(0, 0, 0, 0.05);
  z-index: 100;
  
  .bar-item {
    display: flex;
    flex-direction: column;
    align-items: center;
    padding: 0 24rpx;
    
    .icon {
      font-size: 40rpx;
    }
    
    .text {
      font-size: 20rpx;
      color: $text-regular;
      margin-top: 4rpx;
    }
  }
  
  .btn {
    flex: 1;
    height: 80rpx;
    line-height: 80rpx;
    border-radius: 40rpx;
    font-size: 28rpx;
    border: none;
    margin-left: 16rpx;
  }
  
  .add-cart {
    background: #ffd8bf;
    color: $primary-color;
  }
  
  .buy-now {
    background: $primary-color;
    color: #fff;
  }
}
</style>

六、跨端适配与兼容处理

6.1 样式单位适配

  • 全项目统一使用 rpx 作为尺寸单位,750rpx = 屏幕宽度,自动适配不同尺寸屏幕
  • 字体大小也使用 rpx,保证多端视觉一致
  • 涉及固定物理尺寸的场景(如边框)使用 px

6.2 平台差异处理

使用条件编译处理各端特有能力:

vue

<template>
  <view>
    <!-- #ifdef MP-WEIXIN -->
    <button open-type="share" class="share-btn">分享给好友</button>
    <!-- #endif -->
    
    <!-- #ifdef H5 -->
    <button @click="h5Share" class="share-btn">分享链接</button>
    <!-- #endif -->
  </view>
</template>

<script setup lang="ts">
const h5Share = () => {
  // H5 端特有逻辑
  if (navigator.share) {
    navigator.share({
      title: '商品分享',
      url: location.href
    })
  }
}
</script>

<style lang="scss" scoped>
.share-btn {
  /* #ifdef H5 */
  cursor: pointer;
  /* #endif */
}
</style>

6.3 小程序端特殊优化

  1. 分包配置:商品详情页等非首页页面放入分包,减少主包体积
  2. 图片懒加载<image> 组件开启 lazy-load 属性
  3. setData 优化:Vue3 已做了 diff 优化,但仍要避免高频更新大数组

七、性能优化要点

7.1 列表性能优化

  1. 分页加载:每次只加载一页数据,避免一次性渲染大量节点
  2. 图片懒加载:长列表图片开启懒加载,减少首屏内存占用
  3. 避免 v-for 中复杂计算:所有格式化提前在数据层处理好
  4. 使用 v-show 替代频繁切换的 v-if:减少 DOM 销毁重建

7.2 包体积优化

  1. 静态图片尽量使用 CDN 地址,不打入本地包
  2. 小图标使用字体图标或 SVG
  3. 合理使用分包加载,主包只放 TabBar 页面和公共资源
  4. 移除未使用的组件和依赖

7.3 渲染优化

  1. 减少嵌套层级,避免过深的 DOM 树
  2. 非首屏内容延迟渲染(如商品详情图文)
  3. 弹窗类组件使用 v-if 按需创建,不要一开始就渲染

八、常见踩坑与解决方案

8.1 样式相关坑

  1. 小程序不支持通配符选择器:不要写 * { margin: 0 },改用 page 选择器
  2. 背景图不能用本地路径:小程序端 background-image 只能用网络地址或 base64
  3. 高度百分比不生效:父元素必须有明确高度,子元素百分比才生效
  4. scroll-view 高度问题:必须给 scroll-view 设置明确高度,滚动才正常

8.2 交互相关坑

  1. 点击穿透:弹窗遮罩层要加 @click.stop 防止事件穿透
  2. iPhone 底部安全区:底部固定栏要加 padding-bottom: env(safe-area-inset-bottom)
  3. 页面栈限制:小程序最多 10 层页面栈,深跳转用 redirectTo

8.3 数据相关坑

  1. URL 传参长度限制:复杂数据不要通过 URL 传递,使用全局状态或本地缓存
  2. rich-text 图片溢出:要给 rich-text 内的 img 设置 max-width: 100%
  3. 数字精度问题:价格计算注意小数精度,建议使用整数分存储

九、总结

本文从零实现了一套完整的电商商品模块,涵盖了从架构设计、组件封装到页面实现、性能优化的全流程。核心收获:

  1. 分层设计思想:API 层、组件层、页面层分离,职责清晰,易维护易扩展
  2. 组件化思维:将可复用的 UI 和逻辑抽离成组件,大幅提升开发效率
  3. 类型安全:TypeScript 类型定义让代码更健壮,重构更有底气
  4. 性能意识:长列表、图片、渲染时机都需要考虑性能,尤其是小程序端
  5. 跨端思维:理解各端差异,合理使用条件编译,在复用性和平台特性间取得平衡

这套代码架构不仅适用于商品模块,也可以推广到整个 UniApp 项目。在此基础上,你还可以继续扩展购物车、订单、支付、个人中心等模块,逐步搭建起完整的电商应用。

Logo

一站式 AI 云服务平台

更多推荐