AVATAR

Life is live.

316 说说 72 文章 35 相册 4992留言

王较瘦的个人主页 运行 6451 天

老王正在 休息

运动
今日行走 0 步

当地时间GMT+8 00:00:00

Nuxt 项目中实现基于 Redis 的点赞功能 4/5/2026王叔书10

自上次将微信步数改为redis存储后 ~/note/6025 ,我就一直在想,让他一人独占redis是不是太孤单了,在折腾点什么呢。

正好前后端分离后开启了进阶玩法,技术也进步不少,那就整个点赞功能吧,虽然没什么卵用,重在折腾嘛,先理一下思路,结合最近学到的知识大概苏理出:后端逻辑 、API 仓库扩展 和 首页组件 三个部分。

那有了思路打开ai开干,现在的ai真强大,以前不敢想的现在变成只有你想不到的。

第一步:

安装redis就跳过,前期已部署完成,在 .env 中添加 Redis 地址:

REDIS_URL=redis://localhost:6379

第二步:服务端逻辑

1.创建 Redis 工具类 server/utils/redis.ts

    // server/utils/redis.ts
import Redis from 'ioredis'

let redis: Redis | null = null

export const useRedis = () => {
  if (!redis) {
    const config = useRuntimeConfig()
    // 优先读取 runtimeConfig 中的配置,如果没有则取环境变量
    const url = config.redisUrl || process.env.REDIS_URL
    
    if (!url) {
      console.error('Redis URL 未配置!请检查 .env 文件。')
    }

    redis = new Redis(url)

    // 监听连接错误(方便排查密码错误)
    redis.on('error', (err) => {
      console.error('Redis 连接失败:', err.message)
    })
  }
  return redis
}
  1. 创建点赞/取消点赞接口 server/api/likes/toggle.post.ts
    export default defineEventHandler(async (event) => {
  const { cid } = await readBody(event)
  if (!cid) throw createError({ statusCode: 400, message: 'Missing CID' })

  const redis = useRedis()
  // 使用 IP 作为用户标识(如果没有登录系统)
  const ip = getRequestIP(event, { xForwardedFor: true }) || 'anonymous'
  const key = `post:likes:${cid}`

  const hasLiked = await redis.sismember(key, ip)
  
  if (hasLiked) {
    await redis.srem(key, ip)
    return { liked: false, count: await redis.scard(key) }
  } else {
    await redis.sadd(key, ip)
    return { liked: true, count: await redis.scard(key) }
  }
})
  1. 创建批量获取点赞数接口 server/api/likes/batch.get.ts 用于首页一次性获取当前页所有文章的点赞状态。
  export default defineEventHandler(async (event) => {
  const query = getQuery(event)
  const cids = (query.cids as string || '').split(',')

  if (!cids.length || cids[0] === '') return {}
  const redis = useRedis()
  const pipeline = redis.pipeline()

  cids.forEach(cid => pipeline.scard(`post:likes:${cid}`))  

  const results = await pipeline.exec()

  const likesMap: Record<string, number> = {}  

  cids.forEach((cid, index) => {
    likesMap[cid] = results?.[index]?.[1] || 0
  })
  return likesMap
})

第三步:修改 API 仓库和 Composable

  1. 修改 repositories/modules/posts.ts 增加点赞相关方法
    // 在 PostsRepository 类中添加
async toggleLike(cid: string | number) {
  return await this.fetcher('/api/likes/toggle', {
    method: 'POST',
    body: { cid }
  })
}

async getBatchLikes(cids: (string | number)[]) {
  return await this.fetcher('/api/likes/batch', {
    method: 'GET',
    params: { cids: cids.join(',') }
  })
}

第四步:首页组件集成 (pages/index.vue)

    // 新增
    const likesMap = ref({}) // 存储 { cid: { count: 0, liked: false } }

// 获取点赞数并同步 Cookie 状态
watch(postList, async (newList) => {
  if (newList && newList.length > 0) {
    const cids = newList.map(p => p.cid)
    try {
      const data = await postsRepo.getBatchLikes(cids)
      const formattedData = {}

      cids.forEach(cid => {
        const cookieName = `liked_${cid}`
        const hasLocalCookie = document.cookie.split(';').some(c => c.trim().startsWith(cookieName + '='))
        
        const serverData = data[cid]
        
        formattedData[cid] = {
          count: typeof serverData === 'object' ? serverData.count : (Number(serverData) || 0),
          liked: hasLocalCookie || (typeof serverData === 'object' ? !!serverData.liked : false)
        }
      })
      
      likesMap.value = { ...likesMap.value, ...formattedData }
      
    } catch (e) {
      console.error('获取点赞失败', e)
    }
  }
}, { immediate: true })
    // 4. 点赞动作(含 Cookie 处理)
const handleLike = async (cid) => {
  if (!likesMap.value[cid]) {
    likesMap.value[cid] = { count: 0, liked: false }
  }

  const item = likesMap.value[cid]
  const isLiking = !item.liked 
  const cookieName = `liked_${cid}`

  item.liked = isLiking
  item.count += isLiking ? 1 : -1

  try {
    const result = await postsRepo.toggleLike(cid)
    
    if (isLiking) {
      document.cookie = `${cookieName}=true; path=/; max-age=${30*24*60*60}`
    } else {
      document.cookie = `${cookieName}=; path=/; expires=Thu, 01 Jan 1970 00:00:01 GMT`
    }

    if (typeof result === 'object') {
      item.count = result.count
    } else if (!isNaN(result)) {
      item.count = Number(result)
    }

  } catch (e) {
    console.error('点赞请求失败', e)
    item.liked = !isLiking
    item.count += isLiking ? -1 : 1
  }
}
   <!-- <template>区域合适位置插入点赞按钮 -->
   <span :class="['i-like', likesMap[post.cid]?.liked ? 'liked' : '']" @click.stop="handleLike(post.cid)">
       {{ likesMap[post.cid]?.count || 0 }}
   </span>

然后经过无数次调试,完美收工!!最终样子如图,还算满意👍这一周干了2026年最最最伟大的事情。

从无到有完成了前后端分离,增加了页面过渡动画、点赞、足迹地图等功能,同时做了大量优化,坚持能用原生方法实现的绝不加载第三方库,新主题虽外表无变化,但内在却有质的飞跃,感觉前几年的折腾简直太小儿科了,现在的自已强得可怕😱。

1364x286-3716319744.png