UniApp 实战:从零打造企业级电商商品模块(列表 + 详情 + 搜索筛选)
前言
在电商类跨端应用中,商品模块是整个项目的核心,也是最考验开发功底的部分:既要保证多端渲染一致性,又要兼顾长列表性能,还要处理好复杂的交互逻辑。很多初学者写的商品页面往往存在代码冗余、复用性差、卡顿严重等问题。
本文将以企业级标准,带你从零搭建一套高可用的电商商品模块。你将学到:
- 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 设计要点解析
- 瀑布流布局:采用双列等宽布局,通过 CSS Flex 实现,简单高效。如果需要更精准的高度计算,可以在 JS 层根据图片高度动态分配左右列。
- 分页逻辑:使用
page+pageSize标准分页,通过hasMore标记是否还有更多数据,避免无效请求。 - 下拉刷新:使用 scroll-view 原生的 refresher 能力,多端一致性好。
- 防抖优化:实际项目中建议对搜索输入加防抖处理,避免频繁请求。
五、商品详情页完整实现
详情页是转化的关键,包含轮播图、商品信息、规格选择、底部操作栏等核心模块。
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 小程序端特殊优化
- 分包配置:商品详情页等非首页页面放入分包,减少主包体积
- 图片懒加载:
<image>组件开启lazy-load属性 - setData 优化:Vue3 已做了 diff 优化,但仍要避免高频更新大数组
七、性能优化要点
7.1 列表性能优化
- 分页加载:每次只加载一页数据,避免一次性渲染大量节点
- 图片懒加载:长列表图片开启懒加载,减少首屏内存占用
- 避免 v-for 中复杂计算:所有格式化提前在数据层处理好
- 使用 v-show 替代频繁切换的 v-if:减少 DOM 销毁重建
7.2 包体积优化
- 静态图片尽量使用 CDN 地址,不打入本地包
- 小图标使用字体图标或 SVG
- 合理使用分包加载,主包只放 TabBar 页面和公共资源
- 移除未使用的组件和依赖
7.3 渲染优化
- 减少嵌套层级,避免过深的 DOM 树
- 非首屏内容延迟渲染(如商品详情图文)
- 弹窗类组件使用
v-if按需创建,不要一开始就渲染
八、常见踩坑与解决方案
8.1 样式相关坑
- 小程序不支持通配符选择器:不要写
* { margin: 0 },改用page选择器 - 背景图不能用本地路径:小程序端
background-image只能用网络地址或 base64 - 高度百分比不生效:父元素必须有明确高度,子元素百分比才生效
- scroll-view 高度问题:必须给 scroll-view 设置明确高度,滚动才正常
8.2 交互相关坑
- 点击穿透:弹窗遮罩层要加
@click.stop防止事件穿透 - iPhone 底部安全区:底部固定栏要加
padding-bottom: env(safe-area-inset-bottom) - 页面栈限制:小程序最多 10 层页面栈,深跳转用
redirectTo
8.3 数据相关坑
- URL 传参长度限制:复杂数据不要通过 URL 传递,使用全局状态或本地缓存
- rich-text 图片溢出:要给 rich-text 内的 img 设置
max-width: 100% - 数字精度问题:价格计算注意小数精度,建议使用整数分存储
九、总结
本文从零实现了一套完整的电商商品模块,涵盖了从架构设计、组件封装到页面实现、性能优化的全流程。核心收获:
- 分层设计思想:API 层、组件层、页面层分离,职责清晰,易维护易扩展
- 组件化思维:将可复用的 UI 和逻辑抽离成组件,大幅提升开发效率
- 类型安全:TypeScript 类型定义让代码更健壮,重构更有底气
- 性能意识:长列表、图片、渲染时机都需要考虑性能,尤其是小程序端
- 跨端思维:理解各端差异,合理使用条件编译,在复用性和平台特性间取得平衡
这套代码架构不仅适用于商品模块,也可以推广到整个 UniApp 项目。在此基础上,你还可以继续扩展购物车、订单、支付、个人中心等模块,逐步搭建起完整的电商应用。
更多推荐


所有评论(0)