
做前端这行的朋友应该都有过类似的经历平时写了那么多页面真到自己从零开始反倒连一个点赞按钮都做不利索。需求表面上是“用户点一下红心跳一下数字加一再点一下取消”可真要上线跑起来背后涉及的交互状态、事件绑定、接口同步、竞态处理、失败回滚每一层都能写出东西来。这也是为什么我会把“点赞 / 取消点赞”当成新手练手案例里的必练项——它小到你可以在半天内写完又大到能覆盖前端日常开发里最常见的一整套链路。这篇文章从最基础的前端实现讲起逐步拆到对接后端接口、优化体验、处理边界异常最后把我在实际项目里踩过的坑一并列出来。刚学完 JavaScript 基础语法、想找个真实案例练习的朋友或者做简单活动页需要现成方案的朋友都可以直接参考里面的代码和思路。代码我会给完整版但更重要的是把每一步“为什么这么写”说清楚这样你换一套业务场景也能自己改。1. 动手前先把“点赞”这件事想透交互模型比代码更重要很多人一上来就写onclick这在功能演示里完全没问题但一旦真实用户开始操作你就会发现“点击”这个动作背后并不是一个简单的布尔值切换。点赞按钮的完整状态其实是四种未点赞、已点赞、请求中、请求失败待恢复。忽略后面两种状态是绝大多数点赞功能出 bug 的根本原因。1.1 点赞按钮到底要承载多少状态先看最基础的两种视觉状态这也是你最终在页面上能看到的东西未点赞空心图标灰色点击后图标变红并填充已点赞实心图标红色点击后图标恢复空心并变灰基础的classList.toggle就能完成这两种视觉状态的切换这没任何难度。但真实场景里用户点击之后接口返回需要几百毫秒如果在这几百毫秒里用户又点了第二次你还能不能保证界面的状态和后端数据是一致的这就引出了“请求中”这个中间态。一个成熟的点赞按钮在请求期间应该禁用点击或者至少短暂锁住防止重复提交。所以我建议你在写第一版代码之前先在注释里把状态定义写清楚。我一般这样做// 按钮状态 // like-default未点赞 // like-active已点赞视觉高亮 // like-loading请求中禁止再次点击 // like-error请求失败需要回滚并提示代码里的class不一定这么命名但心里必须有数。状态想清楚了代码自然好写不想清楚后面全是补丁。1.2 接口模型设计点赞/取消是两句话还是一个开关对接后端之前你得先搞清楚接口风格。目前主流有两种风格做法优点缺点REST 风格POST /like表示点赞DELETE /like表示取消语义清晰方便统计前端要维护两种状态分支Toggle 风格一个接口POST /like/toggle后端自己判断当前状态并翻转前端逻辑简单数据不一致时状态可能被“翻错”后端需幂等设计大多数项目里尤其以内容社区为主的后端更喜欢第一种 REST 风格。原因很直接日志审计、数据分析、消息通知都需要知道用户到底点了赞还是取消赞如果只有一个 toggle后端虽然也能记录但很容易产生“两边状态不同步”的历史包袱。不过做活动页或临时需求时toggle 接口写起来是真快前端一个请求搞定出错概率也低。我的建议是如果是你同时负责前后端选 toggle 没问题轻量如果是和后端同事配合直接问清楚接口是“两个动作”还是“一个开关”并且把接口返回的数据约好。这里有个细节特别容易被忽略接口返回时最好把最新的点赞总数一起返回而不是让前端自己本地加一减一。后面我会专门讲为什么。1.3 用户体验细节甩手式点击与请求竞态还有个很实际的问题用户连续点击一个按钮的速度比你想象中快得多。他们不会乖乖等接口返回再点第二次。像“双击点赞再双击取消”这种操作我实际测试过一秒钟内可以发生 4~6 次点击。如果每一次点击都发一次请求你的后端得挨多少无效调用的打用户那边的数字又会来回跳多少次所以在动手写代码之前一定要想好“竞态”怎么处理。最简单的方案是加一个“门闩”mutex也就是我前面说的like-loading状态。当第一次请求发出后立刻把按钮锁住等响应回来后解除锁并更新 UI。如果用户连续点击很快只有第一次点击真正生效后面的点击要么被直接忽略要么在提示层里显示“操作过于频繁”。别小看这一步设计它直接决定了你的功能在真实用户手里是“顺滑自然”还是“疯狂抖动”。2. 第一版代码纯前端模拟实现附完整代码讲完了理论咱们先不看后端单纯用 JavaScript 在前端把交互做出来。这个版本的核心目标是让你看清“事件绑定 状态切换”这条主线。作为新手练手我推荐自己先照着写一遍不要复制粘贴跑通了就完事而是试着改改参数观察效果。2.1 HTML 结构从语义化到一个合理的 DOM 设计先用最语义化的方式搭结构。点赞按钮本质上是“按钮”所以必须用button而不是div原因有两个一是鼠标点击之外键盘用户可以通过 Tab 定位并用回车触发二是无障碍屏幕阅读器能正确播报“按钮”角色。这是很多教程会忽略的细节但真实项目里很关键。button classlike-btn idlikeBtn typebutton aria-pressedfalse svg classlike-icon viewBox0 0 24 24 width18 height18 aria-hiddentrue path dM12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z/ /svg span classlike-count idlikeCount0/span /buttonaria-pressed这个属性很值得强调它专门用来表示“切换按钮”的当前状态值为true或false。视觉上用户能看到红色还是灰色屏幕阅读器用户则需要这个属性才能知道按钮当前是不是处于“已点赞”状态。后面 JS 里每切一次状态都要同步更新它。2.2 CSS 交互反馈按下有响应松开有反馈纯视觉状态之外按钮的“手感”也很重要。点赞这种高频交互用户对反馈极其敏感。你可以给按钮加一个:active状态下的缩放效果再加一个点赞成功时的弹跳动画。比例不用大稍微意思一下就能让整个交互显得很有“活性”。.like-btn { display: inline-flex; align-items: center; gap: 6px; padding: 8px 14px; border: 1px solid #e0e0e0; border-radius: 999px; background: #fff; color: #666; font-size: 14px; cursor: pointer; transition: all 0.2s ease; user-select: none; } .like-btn:hover { border-color: #ff6b6b; color: #ff6b6b; } .like-btn:active { transform: scale(0.92); } .like-btn.liked { border-color: #ff6b6b; background: #fff5f5; color: #ff6b6b; } .like-btn.liked .like-icon { fill: #ff6b6b; stroke: #ff6b6b; } .like-btn .like-icon { fill: transparent; stroke: currentColor; stroke-width: 2; transition: transform 0.2s ease; } .like-btn.liked .like-icon { animation: likeBounce 0.3s ease; } keyframes likeBounce { 0% { transform: scale(1); } 40% { transform: scale(1.3); } 100% { transform: scale(1); } }有几个点需要注意。user-select: none是防止用户快速连点时出现文本选中这个在按钮场景下很影响体验。:active的缩放用的是transform尽量别去改width或padding避免触发重排影响性能。动画用 GPU 友好的transform属性也能明显减少动效卡顿感。2.3 JavaScript 逻辑事件绑定与状态切换接下来是重头戏。我们用一个对象来装点赞相关的 DOM 引用再写一个toggleLike函数做状态切换。注意一个关键点状态不能只存成“一个布尔值”而是要有一个当前状态的变量作为唯一数据源。const likeModule { btn: document.getElementById(likeBtn), countEl: document.getElementById(likeCount), liked: false, count: 0, init() { // 我把初始状态从 HTML 里读出来而不是写死 this.liked this.btn.getAttribute(aria-pressed) true; this.count parseInt(this.countEl.textContent, 10) || 0; this.updateUI(); this.btn.addEventListener(click, this.handleClick.bind(this)); }, handleClick() { // 演示版本先做本地切换接口章节再改动这里 this.toggleState(); }, toggleState() { this.liked !this.liked; this.count this.liked ? this.count 1 : this.count - 1; this.updateUI(); }, updateUI() { this.btn.classList.toggle(liked, this.liked); this.btn.setAttribute(aria-pressed, String(this.liked)); this.countEl.textContent this.count; } }; likeModule.init();这段代码里有一个值得养成的习惯UI 永远跟着数据状态走而不是在事件回调里直接改 class。toggleState只负责更新liked和count这两个数据updateUI专门负责把数据渲染到界面。数据驱动视图新手越早理解这个思想后面学 Vue、React 这类框架就越轻松。2.4 数字自增自减与边界保护上面代码里count的逻辑是点赞 1取消 -1。但你有没有想过如果用户在某一次操作时本地 count 已经由于某种原因显示为 0他点一下取消赞count 就变成了 -1这明显不符合预期。这个 bug 在纯前端演示阶段不容易出现因为toggleState每次都做了取反逻辑上是自洽的。但碰到从后端拿数据、多个页面共享状态的时候本地count就可能和真实数据不一致。所以无论哪个版本我建议都在自减时加一层保护toggleState() { if (!this.liked this.count 0) { console.warn(当前数量已经是 0前端不再继续减); return; } this.liked !this.liked; this.count this.liked ? this.count 1 : this.count - 1; this.updateUI(); }严格说这个保护是治标不治本的真正可靠的做法是让后端在接口返回值里带上最新的count前端直接渲染。你把这里当成一个“底线防御”理解就行——不是用来替代正确方案的而是防止意外数据把它带崩。3. 对接真实后端Fetch请求与接口状态同步本地模拟跑通之后下一步就是把数据源换成真实接口。因为没有一个固定的后端地址这一部分我会用fetch写一个通用策略用POST表示点赞DELETE表示取消请求成功以后以后端返回的计数为准渲染 UI。3.1 从模拟到真实改造数据来源先把业务 URL 抽出来方便后续切换接口。我习惯在代码里保留一个配置块const API { like: /api/post/like, // POST unlike: /api/post/unlike // DELETE };真正的请求逻辑我写在requestLike方法里。它接收action参数like或unlike然后根据 action 选不同请求方式和地址。async requestLike(action) { const url action like ? API.like : API.unlike; const method action like ? POST : DELETE; const res await fetch(url, { method, headers: { Content-Type: application/json }, credentials: include // 带上 cookie登录态相关 }); if (!res.ok) { throw new Error(Request failed: ${res.status}); } return res.json(); }关于credentials: include我专门解释一下。很多点赞接口需要判断当前登录用户是谁有的是用 Cookie 方式有的是用 Authorization 头。如果你用 Cookie 存登录态fetch默认不会携带 Cookie必须显式加这个配置。如果你用的是 token那就改成headers: { Authorization: Bearer xxx }。这个细节特别容易让人在接口联调时一脸懵明明 Postman 里能通代码里却总是 401。3.2 请求中的 Loading 状态处理接口请求发出到返回之间有一个时间差这个时间段里必须把按钮锁住。我在第一节讲到的“门闩”这里终于派上用场。改造一下handleClickasync handleClick() { if (this.loading) return; // 请求中直接忽略 if (!this.liked this.count 0) return; this.loading true; this.btn.disabled true; const action this.liked ? unlike : like; const prevLiked this.liked; const prevCount this.count; try { const data await this.requestLike(action); // 以后端返回的数据为准 this.liked data.liked ?? !prevLiked; this.count data.count ?? prevCount (action like ? 1 : -1); this.updateUI(); } catch (err) { this.handleError(err, prevLiked, prevCount); } finally { this.loading false; this.btn.disabled false; } }注意这里disabled的用法在请求期间禁用整个按钮。但有一个体验细节禁用按钮会触发浏览器默认的置灰样式而且会使鼠标手势变成不允许状态观感上比较“生硬”。我实际项目里更喜欢不用disabled而是用一个.loading类配合 CSS 来降低透明度并禁止点击。两全其美的方式是写一个节流锁逻辑像这样async handleClick() { if (this.loading) return; // 用变量锁代替 disabled this.loading true; this.btn.classList.add(loading); try { // ...同上 } finally { this.loading false; this.btn.classList.remove(loading); } }CSS 方面给.loading加pointer-events: none; opacity: 0.6;就行。这样视觉上还是“活的按钮”但不会触发二次点击。3.3 请求失败后的回滚机制真实网络环境不可能永远顺风顺水。断网、超时、后端 500这些情况一旦发生你刚才把 UI 改成“已点赞”的操作就成了一次“欺骗”。所以请求失败一定要回滚。我在代码里先保存了prevLiked和prevCount这个动作就是为回滚准备的。handleError方法负责把状态还原handleError(err, prevLiked, prevCount) { console.error(点赞操作失败, err); this.liked prevLiked; this.count prevCount; this.updateUI(); // 这里是提示用户实际项目中可以接弹窗或 toast alert(操作失败请检查网络后重试); }这里有一个设计上的权衡回滚和提示哪个在前哪个在后我建议回滚在提示之前先让界面恢复成用户熟悉的样子再弹出提示。如果先弹窗用户看到错误提示的同时按钮还保持高亮点赞状态很容易产生“我到底点没点上”的困惑。当然提示本身最好也别用alert我在真实项目里一般是接自己项目里的 Toast 组件但作为示例代码alert最直观。3.4 登录态与未登录用户引导点赞这个动作绝大多数产品都要求“登录后即可点赞”。未登录用户点击按钮不能直接发请求而是应该被引导到登录页或弹出登录框。这个逻辑可以在handleClick最前面判断。为了贴近真实项目我一般用一个isLogin变量或函数来表示登录状态具体实现根据项目来async handleClick() { if (this.loading) return; if (!this.isLogin()) { this.showLoginModal(); // 或者跳转 return; } // 继续原有逻辑 }这里要特别小心一个细节未登录判断要在“请求中”判断之后还是之前我见过有人把顺序写反导致未登录用户疯狂点击时弹窗弹好几层。正确顺序应该是先判断loading再判断登录态最后才发请求。当然如果项目里要求“未登录点击也要带动效”那可以先把视觉状态切过去再弹窗但一旦弹窗关闭要把状态回滚。我更推荐不做这种“假反馈”用户预期值得不到满足反而会产生反感。如果对接的是接口的 401 返回那还有一种情况用户看起来登录了前端 token 没过期但接口返回未授权。这种情况就不能只靠前端isLogin()判断了你需要在requestLike里对 401 做统一处理if (!res.ok) { if (res.status 401) { // 统一跳登录 this.redirectToLogin(); return; } throw new Error(Request failed: ${res.status}); }4. 让点赞在真实项目中跑得更稳性能与体验优化基础功能做完页面能运转了但这离“上线”还有一段距离。点赞往往是高频率的交互一旦用户数量上来你就能见识到各种千奇百怪的卡顿和数据不一致。这部分聊几个真正能提升稳定性的手段。4.1 防抖与节流连续点击时后端能扛住吗新手最容易犯的错是每次点击都把请求发出去了。前面用loading锁能挡掉“请求未返回时的重复点击”但如果用户点击间隔刚好和请求耗时一致呢比如请求耗时 500ms用户在 600ms 后又点了第二次第一次已经结束了这次点击会发新请求。整个流程没有阻断问题可后端会被高频请求骚扰得很无奈。对付这种情况通常有两种思路防抖debounce和节流throttle。点赞场景我推荐用开关锁其实也就是第一节说的loading标志。防抖适合“连续操作只要最后一下”的场景比如搜索框联动节流适合“每秒最多执行一次”的场景比如滚动事件。点赞是“每点击一次都要生效”的动作不能把中间几次点击吞掉所以节流锁更合适。// 节流锁最短间隔内忽略后续点击 let lastClickTime 0; const THROTTLE_DELAY 300; if (Date.now() - lastClickTime THROTTLE_DELAY) return; lastClickTime Date.now();把这段逻辑放在handleClick开头和loading锁配合使用。注意lastClickTime要在第一次点击时立刻记录否则“点击瞬间”和“请求完成时”之间会有空隙。简单说loading解决的是“请求未完成”节流锁解决的是“时间间隔太短”。两个加起来后端压力能小一大截。4.2 本地缓存与乐观UI让用户觉得“快”说“快”很多人第一反应是后端优化实际上前端交互层面也能制造出“快”的错觉。这就是乐观 UIOptimistic UI悲观更新先等接口返回再更新界面。用户会感受到明显延迟。乐观更新先改界面用户立刻看到反馈再发请求失败时回滚。点赞这种轻量、低频、易于回滚的场景非常适合乐观更新。你可以直接把 3.2 的代码改成“先切 UI再发请求”这样用户点击的瞬间按钮就变红了体验爽感立刻不同。乐观更新的核心代价是“失败补偿机制”。一旦接口报错界面要恢复到点击前的状态同时给用户明确提示。如果网络不好用户可能看到“按钮红了又被弹回去了”这个闪烁感确实存在但绝大多数情况下都比让用户等 300ms 再看到反馈来得舒服。还有一个小技巧把上一次成功同步的数据缓存到localStorage下次进入页面时可以先用缓存展示初始状态再向服务器拉最新数据。尤其适合文章详情页用户刷新后看到的点赞状态不会闪一下才回来。这个方案也有坑就是缓存会过期所以只能作为“过渡展示”不能作为最终数据源。4.3 列表页通用方案事件委托与自定义事件前面讲的都是一个点赞按钮对应一个文章。实际上真实项目里更多是一个列表页同时存在几十篇文章。如果每一篇都给自己绑定一个click事件那内存里就是几十个函数性能虽然不至于崩但完全是没必要的浪费。列表页的通用做法是事件委托把 click 监听绑定到它们的共同父容器上然后通过e.target.closest(.like-btn)来判断具体是哪一个按钮触发的。document.getElementById(postList).addEventListener(click, async (e) { const btn e.target.closest(.like-btn); if (!btn) return; const postId btn.dataset.postId; const currentLiked btn.classList.contains(liked); // 接下来通过 postId 去请求对应接口 });这里>button classlike-btn>async handleClick() { if (this.loading) return; if (!this.isLogin()) { /* 登录引导 */ return; } this.loading true; const prevLiked this.liked; const prevCount this.count; this.liked !this.liked; // 先乐观更新 this.count this.liked ? this.count 1 : this.count - 1; this.updateUI(); const action this.liked ? like : unlike; try { const data await this.requestLike(action); // 以服务端返回为准 } catch (err) { this.liked prevLiked; this.count prevCount; this.updateUI(); // 提示用户 } finally { this.loading false; } }这个版本其实才是真实项目里最常见的写法乐观 UI 入口锁 失败回滚。你拿它直接改业务就够用。5.2 切换页面回来状态不对我遇到过一种情况在文章详情页点赞返回列表页列表上同一篇文章却还是“未点赞”状态。原因很简单列表页的按钮状态是页面加载时从接口拿的详情页里的状态变更并没有同步到列表页。解决方案有两种。一种是列表页在shown/focus事件里重新拉取接口数据适合数据不频繁变更的场景另一种是自己维护一份全局状态比如一个MappostId, bool详情页操作时更新列表页渲染时读取。如果没有引入状态管理库第二种方法也不难直接在模块里导出一个对象就行。还有一种更省事的方案页面不可见时刷新。用visibilitychange事件监听页面重新可见时静默同步一次数据。用户切后台再回来看到的点赞状态就是最新的。这个方案在移动端 H5 里很常见。5.3 数字大数溢出与格式化正常一个内容点赞量破万时界面会显示“1.2万”而不是“12000”。新手容易忽略这个问题看到数字一长串界面布局直接被撑破。我先给一个最简单的格式化函数function formatCount(num) { if (num 10000) { return (num / 10000).toFixed(1).replace(/\.0$/, ) 万; } return String(num); }但要注意格式化只应该应用在“展示”层面你内部维护的count还是应该保持真实的数字否则“1.2万”加“1”就莫名变成“1.200001万”了。以下是比较稳的写法// 更新数字时 this.count data.count; // 存真实数字 this.countEl.textContent formatCount(this.count); // 显示格式化另外一个隐蔽的坑如果count是从接口读出来的 JSON而 JSON 里用了数字类型超过Number.MAX_SAFE_INTEGER时会丢精度。点赞数一般到不了这个量级但如果后台返回的是字符串类型前端parseInt时也要注意别把大数解析成不准确的值。真碰到超大数量级的业务就要考虑用字符串或 BigInt 来处理了。5.4 按钮可访问性键盘操作与ARIA点赞按钮是一个纯前端交互控件但它不应该只对鼠标用户可见。键盘用户通过 Tab 键聚焦后按回车或空格也应该能正常触发点赞。原生button天然支持这些行为所以我在最开始强调别用div模拟。除了键盘还要处理 ARIA 状态属性。前面代码里一直维护的aria-pressed就是给无障碍工具看的。很多教程里不写这个只是把视觉样式切换好但如果你服务的产品要对更广泛的用户友好这属性就得加上。在实际开发中我建议每次更新状态时把aria-pressed和视觉 class 放在同一个updateUI函数里避免二者不同步。如果有测试同学在检查可访问性这一步基本是必查项。最后提醒一个很容易被忽略的细节点赞图标里的 SVGpath要加上aria-hiddentrue因为辅助工具不需要把图标路径读出来它只需要播报“按钮已选中/未选中”就够了。别让屏幕阅读器去读一团毫无意义的路径数据。我在实际项目里做点赞功能时最大的体会就是小功能不小。一个点赞按钮从无到有能把事件绑定、状态管理、异步请求、错误处理、性能优化、可访问性全部串一遍对新手来说是性价比极高的练手项目。建议你拿到代码后别急着收工试着把 4.2 的乐观 UI、4.3 的事件委托、5.1 的入口锁这三个改造分别加上去再模拟一次断网请求失败看看状态是不是能正确回滚。等你把这些坑都趟过一遍再做其他类似的“小而美”交互相比如收藏、关注、踩一下基本就是手到擒来的事了。