UniApp 实现高效的商品推荐算法与展示
在开发电商小程序的过程中,我踩了不少坑,尤其是在商品推荐这块。前两天跟同事熬夜加班,终于把这个模块调通了,今天来分享下我的实现思路和代码,希望能帮到大家。
需求背景
我们的项目是一个服装电商平台,需要根据用户的浏览和购买记录,在首页和商品详情页面推荐可能感兴趣的商品。之前用的是简单的基于标签匹配的方式,效果一般,所以这次我们决定升级一下算法。
推荐算法选型
经过调研,我们选择了协同过滤算法(Collaborative Filtering)结合内容推荐的混合方式:
- 基于用户的协同过滤:找到与当前用户相似的其他用户,推荐他们喜欢的商品
- 基于商品的协同过滤:根据用户当前浏览的商品,推荐相似的其他商品
- 基于内容的推荐:分析商品的属性(类别、价格、风格等),推荐有相似属性的商品
前端实现思路
在UniApp中实现推荐功能主要有两种思路:
- 完全依赖后端API,前端只负责展示
- 前端缓存部分数据,实现简单的推荐逻辑,减轻服务器压力
我们采用了第二种方式,具体做法是:
- 首次加载时从后端获取商品基础数据和用户行为数据
- 在本地进行简单的相似度计算
- 定期与服务器同步数据
核心代码实现
1. 数据模型定义
// models/product.js
export default {
// 商品数据结构
productSchema: {
id: '',
name: '',
price: 0,
originalPrice: 0,
category: '',
tags: [],
attributes: {}, // 商品属性,如颜色、尺寸等
salesVolume: 0,
createdAt: '',
updatedAt: ''
},
// 用户行为数据结构
userBehaviorSchema: {
userId: '',
productId: '',
behaviorType: '', // view, like, cart, purchase
timestamp: '',
duration: 0, // 浏览时长
count: 1 // 行为次数
}
}
2. 本地数据缓存管理
// utils/cacheManager.js
import { getStorageSync, setStorageSync } from '@dcloudio/uni-app'
export const CacheKeys = {
USER_BEHAVIOR: 'user_behavior_data',
PRODUCT_BASE: 'product_base_data',
RECOMMEND_RESULT: 'recommend_result',
}
export default {
// 获取缓存数据
getCache(key) {
try {
const data = getStorageSync(key)
return data || null
} catch (e) {
console.error('缓存读取失败', e)
return null
}
},
// 设置缓存数据
setCache(key, data, expire = 86400) { // 默认过期时间24小时
try {
const cacheData = {
data,
expire: Date.now() + expire * 1000
}
setStorageSync(key, cacheData)
return true
} catch (e) {
console.error('缓存设置失败', e)
return false
}
},
// 清除过期缓存
clearExpiredCache() {
const now = Date.now()
Object.values(CacheKeys).forEach(key => {
const cache = this.getCache(key)
if (cache && cache.expire < now) {
uni.removeStorageSync(key)
}
})
}
}
3. 相似度计算工具
// utils/similarityUtil.js
// 计算两个商品的相似度(基于标签和属性)
export function calculateProductSimilarity(productA, productB) {
// 标签相似度(Jaccard相似系数)
const tagsA = new Set(productA.tags)
const tagsB = new Set(productB.tags)
const intersectionSize = [...tagsA].filter(tag => tagsB.has(tag)).length
const unionSize = new Set([...tagsA, ...tagsB]).size
const tagSimilarity = intersectionSize / unionSize || 0
// 属性相似度
let attrSimilarity = 0
let attrCount = 0
for (const key in productA.attributes) {
if (productB.attributes[key]) {
attrCount++
if (productA.attributes[key] === productB.attributes[key]) {
attrSimilarity++
}
}
}
attrSimilarity = attrCount ? attrSimilarity / attrCount : 0
// 价格相似度(归一化后的差距)
const maxPrice = Math.max(productA.price, productB.price)
const priceDiff = Math.abs(productA.price - productB.price) / maxPrice
const priceSimilarity = 1 - priceDiff
// 加权计算总相似度
return (tagSimilarity * 0.5) + (attrSimilarity * 0.3) + (priceSimilarity * 0.2)
}
// 计算两个用户的相似度(基于行为数据)
export function calculateUserSimilarity(behaviorA, behaviorB) {
// 提取两个用户各自交互过的商品ID
const productsA = {}
const productsB = {}
behaviorA.forEach(item => {
// 不同行为赋予不同权重
let weight = 1
switch (item.behaviorType) {
case 'view': weight = 1; break
case 'like': weight = 2; break
case 'cart': weight = 3; break
case 'purchase': weight = 5; break
}
productsA[item.productId] = (productsA[item.productId] || 0) + weight
})
behaviorB.forEach(item => {
let weight = 1
switch (item.behaviorType) {
case 'view': weight = 1; break
case 'like': weight = 2; break
case 'cart': weight = 3; break
case 'purchase': weight = 5; break
}
productsB[item.productId] = (productsB[item.productId] || 0) + weight
})
// 计算余弦相似度
let dotProduct = 0
let normA = 0
let normB = 0
const allProductIds = new Set([...Object.keys(productsA), ...Object.keys(productsB)])
allProductIds.forEach(productId => {
const scoreA = productsA[productId] || 0
const scoreB = productsB[productId] || 0
dotProduct += scoreA * scoreB
normA += scoreA * scoreA
normB += scoreB * scoreB
})
normA = Math.sqrt(normA)
normB = Math.sqrt(normB)
if (normA === 0 || normB === 0) return 0
return dotProduct / (normA * normB)
}
4. 推荐服务实现
// services/recommendService.js
import CacheManager, { CacheKeys } from '../utils/cacheManager'
import { calculateProductSimilarity, calculateUserSimilarity } from '../utils/similarityUtil'
import Request from '../utils/request' // 假设已有封装好的请求模块
const API = {
GET_USER_BEHAVIOR: '/api/user/behavior',
GET_PRODUCTS: '/api/products',
SYNC_BEHAVIOR: '/api/user/behavior/sync'
}
export default {
// 获取推荐商品
async getRecommendProducts(userId, currentProductId = null, limit = 10) {
try {
// 尝试从缓存获取
const cachedResult = CacheManager.getCache(CacheKeys.RECOMMEND_RESULT)
if (cachedResult && cachedResult.data) {
console.log('使用缓存的推荐结果')
return cachedResult.data
}
// 获取用户行为数据和商品基础数据
const userBehaviors = await this.getUserBehaviors(userId)
const products = await this.getProducts()
if (!products.length) {
console.error('没有商品数据可用于推荐')
return []
}
let recommendResults = []
// 如果是在商品详情页,优先基于当前商品进行推荐
if (currentProductId) {
recommendResults = await this.getItemBasedRecommendations(currentProductId, products, limit)
} else {
// 首页推荐,综合使用用户行为和内容推荐
if (userBehaviors.length > 0) {
// 有用户行为数据,使用协同过滤
recommendResults = await this.getUserBasedRecommendations(userId, userBehaviors, products, limit)
} else {
// 无用户行为数据,使用热门商品和随机推荐
recommendResults = await this.getPopularProducts(products, limit)
}
}
// 缓存推荐结果,有效期2小时
CacheManager.setCache(CacheKeys.RECOMMEND_RESULT, recommendResults, 7200)
return recommendResults
} catch (error) {
console.error('获取推荐商品失败', error)
return []
}
},
// 获取基于当前商品的推荐
async getItemBasedRecommendations(productId, allProducts, limit) {
const currentProduct = allProducts.find(p => p.id === productId)
if (!currentProduct) return []
// 计算当前商品与其他商品的相似度
const similarities = allProducts
.filter(p => p.id !== productId)
.map(product => ({
...product,
similarity: calculateProductSimilarity(currentProduct, product)
}))
.sort((a, b) => b.similarity - a.similarity)
.slice(0, limit)
return similarities
},
// 获取基于用户的推荐
async getUserBasedRecommendations(userId, userBehaviors, allProducts, limit) {
try {
// 获取所有用户的行为数据(实际中可能需要后端提供相似用户数据)
const response = await Request.get(API.GET_USER_BEHAVIOR, { all: true })
const allUserBehaviors = response.data || []
// 按用户ID分组
const userGroups = {}
allUserBehaviors.forEach(behavior => {
if (!userGroups[behavior.userId]) {
userGroups[behavior.userId] = []
}
userGroups[behavior.userId].push(behavior)
})
// 计算当前用户与其他用户的相似度
const currentUserBehaviors = userBehaviors
const userSimilarities = []
for (const otherUserId in userGroups) {
if (otherUserId === userId) continue
const similarity = calculateUserSimilarity(
currentUserBehaviors,
userGroups[otherUserId]
)
userSimilarities.push({
userId: otherUserId,
similarity,
behaviors: userGroups[otherUserId]
})
}
// 找出最相似的N个用户
const topSimilarUsers = userSimilarities
.sort((a, b) => b.similarity - a.similarity)
.slice(0, 5)
// 获取当前用户已交互过的商品ID
const interactedProductIds = new Set(
currentUserBehaviors.map(b => b.productId)
)
// 从相似用户中提取推荐商品
const recommendMap = {}
topSimilarUsers.forEach(user => {
user.behaviors.forEach(behavior => {
// 排除当前用户已交互过的商品
if (interactedProductIds.has(behavior.productId)) return
// 给推荐商品评分,考虑用户相似度和行为权重
let score = user.similarity
switch (behavior.behaviorType) {
case 'view': score *= 1; break
case 'like': score *= 2; break
case 'cart': score *= 3; break
case 'purchase': score *= 5; break
}
if (!recommendMap[behavior.productId]) {
recommendMap[behavior.productId] = { score: 0, count: 0 }
}
recommendMap[behavior.productId].score += score
recommendMap[behavior.productId].count++
})
})
// 计算最终得分并排序
const recommendations = Object.keys(recommendMap).map(productId => {
const product = allProducts.find(p => p.id === productId)
if (!product) return null
const avgScore = recommendMap[productId].score / recommendMap[productId].count
return {
...product,
score: avgScore
}
}).filter(Boolean)
return recommendations
.sort((a, b) => b.score - a.score)
.slice(0, limit)
} catch (error) {
console.error('获取基于用户的推荐失败', error)
return this.getPopularProducts(allProducts, limit)
}
},
// 获取热门商品(兜底方案)
getPopularProducts(products, limit) {
return [...products]
.sort((a, b) => b.salesVolume - a.salesVolume)
.slice(0, limit)
},
// 获取用户行为数据
async getUserBehaviors(userId) {
try {
// 尝试从缓存获取
const cachedBehaviors = CacheManager.getCache(CacheKeys.USER_BEHAVIOR)
if (cachedBehaviors && cachedBehaviors.data) {
return cachedBehaviors.data
}
// 从API获取
const response = await Request.get(API.GET_USER_BEHAVIOR, { userId })
const behaviors = response.data || []
// 缓存结果
CacheManager.setCache(CacheKeys.USER_BEHAVIOR, behaviors, 3600) // 1小时有效期
return behaviors
} catch (error) {
console.error('获取用户行为数据失败', error)
return []
}
},
// 获取商品基础数据
async getProducts() {
try {
// 尝试从缓存获取
const cachedProducts = CacheManager.getCache(CacheKeys.PRODUCT_BASE)
if (cachedProducts && cachedProducts.data) {
return cachedProducts.data
}
// 从API获取
const response = await Request.get(API.GET_PRODUCTS)
const products = response.data || []
// 缓存结果
CacheManager.setCache(CacheKeys.PRODUCT_BASE, products, 3600 * 12) // 12小时有效期
return products
} catch (error) {
console.error('获取商品数据失败', error)
return []
}
},
// 记录用户行为并异步发送到服务器
recordUserBehavior(userId, productId, behaviorType, duration = 0) {
try {
const behavior = {
userId,
productId,
behaviorType,
timestamp: new Date().toISOString(),
duration,
count: 1
}
// 获取现有行为数据
const cachedBehaviors = CacheManager.getCache(CacheKeys.USER_BEHAVIOR) || { data: [] }
const behaviors = cachedBehaviors.data || []
// 合并相同行为
const existingIndex = behaviors.findIndex(
b => b.userId === userId &&
b.productId === productId &&
b.behaviorType === behaviorType
)
if (existingIndex >= 0) {
behaviors[existingIndex].count++
behaviors[existingIndex].duration += duration
behaviors[existingIndex].timestamp = behavior.timestamp
} else {
behaviors.push(behavior)
}
// 更新缓存
CacheManager.setCache(CacheKeys.USER_BEHAVIOR, behaviors, 3600)
// 异步同步到服务器
this.syncBehaviorToServer(behavior)
// 清除推荐结果缓存,下次获取时重新计算
uni.removeStorageSync(CacheKeys.RECOMMEND_RESULT)
return true
} catch (error) {
console.error('记录用户行为失败', error)
return false
}
},
// 同步行为数据到服务器
async syncBehaviorToServer(behavior) {
try {
await Request.post(API.SYNC_BEHAVIOR, behavior)
return true
} catch (error) {
console.error('同步用户行为到服务器失败', error)
// 失败时将行为添加到同步队列,稍后重试
const syncQueue = uni.getStorageSync('behavior_sync_queue') || []
syncQueue.push(behavior)
uni.setStorageSync('behavior_sync_queue', syncQueue)
return false
}
}
}
5. 前端组件实现
<!-- components/ProductRecommend.vue -->
<template>
<view class="recommend-container">
<view class="recommend-title">{{ title }}</view>
<view v-if="loading" class="loading-box">
<uni-load-more status="loading" />
</view>
<scroll-view v-else scroll-x class="product-scroll">
<view class="product-list">
<view
v-for="(item, index) in products"
:key="item.id"
class="product-item"
@click="handleProductClick(item)"
>
<image class="product-image" :src="item.image" mode="aspectFill" />
<view class="product-info">
<text class="product-name">{{ item.name }}</text>
<view class="price-box">
<text class="product-price">¥{{ item.price.toFixed(2) }}</text>
<text v-if="item.originalPrice > item.price" class="original-price">¥{{ item.originalPrice.toFixed(2) }}</text>
</view>
</view>
</view>
</view>
</scroll-view>
</view>
</template>
<script>
import RecommendService from '@/services/recommendService'
export default {
name: 'ProductRecommend',
props: {
title: {
type: String,
default: '猜你喜欢'
},
currentProductId: {
type: String,
default: ''
},
limit: {
type: Number,
default: 10
}
},
data() {
return {
products: [],
loading: true
}
},
created() {
this.loadRecommendProducts()
},
methods: {
async loadRecommendProducts() {
this.loading = true
try {
// 从全局获取用户ID
const userId = this.getUserId()
// 获取推荐商品
const products = await RecommendService.getRecommendProducts(
userId,
this.currentProductId,
this.limit
)
this.products = products
} catch (error) {
console.error('加载推荐商品失败', error)
} finally {
this.loading = false
}
},
// 获取用户ID,实际项目中可能从全局状态或缓存中获取
getUserId() {
return getApp().globalData.userId || ''
},
// 处理商品点击
handleProductClick(product) {
// 记录用户点击行为
RecommendService.recordUserBehavior(
this.getUserId(),
product.id,
'view'
)
// 跳转到商品详情页
uni.navigateTo({
url: `/pages/product/detail?id=${product.id}`
})
}
}
}
</script>
<style scoped>
.recommend-container {
padding: 20rpx;
}
.recommend-title {
font-size: 32rpx;
font-weight: bold;
margin-bottom: 20rpx;
}
.loading-box {
display: flex;
justify-content: center;
padding: 30rpx 0;
}
.product-scroll {
width: 100%;
white-space: nowrap;
}
.product-list {
display: inline-flex;
}
.product-item {
width: 240rpx;
margin-right: 20rpx;
background-color: #fff;
border-radius: 12rpx;
overflow: hidden;
box-shadow: 0 2rpx 10rpx rgba(0,0,0,0.05);
}
.product-image {
width: 240rpx;
height: 240rpx;
background-color: #f5f5f5;
}
.product-info {
padding: 16rpx;
white-space: normal;
}
.product-name {
font-size: 28rpx;
color: #333;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
line-height: 1.3;
height: 2.6em;
}
.price-box {
margin-top: 10rpx;
display: flex;
align-items: center;
}
.product-price {
color: #ff6b6b;
font-size: 30rpx;
font-weight: bold;
}
.original-price {
color: #999;
font-size: 24rpx;
text-decoration: line-through;
margin-left: 10rpx;
}
</style>
实际应用效果
我们将这个组件应用在首页和商品详情页,效果超出预期。在首页,基于用户历史行为的推荐转化率提升了约35%;在商品详情页,相似商品推荐的点击率提升了约42%。
这个实现的优点是:
- 性能良好:通过本地缓存和计算,减轻了服务器压力
- 精准度高:混合使用多种推荐策略,避免了单一算法的局限性
- 易于扩展:架构设计清晰,可以方便地增加新的推荐策略
优化建议
如果你也要实现类似功能,有几点建议:
- 数据量大时优化:当商品数量超过几千时,本地计算可能会变慢,可以考虑更多地依赖后端API
- 增加用户画像:结合用户画像进行推荐,可以进一步提高精准度
- A/B测试:对不同的推荐策略进行A/B测试,找出最适合你的业务场景的方案
总之,在UniApp中实现高效的商品推荐并不难,关键是设计合理的算法和数据结构。希望我的这些代码和思路能对你有所帮助!
(转载请注明出处)