news 2026/10/2 5:49:21

手写Canvas转盘抽奖组件:动态绘制、动画控制与概率分配

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
手写Canvas转盘抽奖组件:动态绘制、动画控制与概率分配

转盘抽奖算是H5活动页里最经典的互动玩法了,各种营销活动换个皮肤就能用。最近我接了一个偏运营向的项目,要求“每期奖品不同、样式跟着设计师走、中奖结果由后端决定”,简单翻了翻网上现成的Html5转盘插件,要么样式写死不好改,要么动画逻辑经不起推敲,索性用Canvas从零写了一个。这篇就把实现过程、几个关键决策点,以及上线前踩过的坑一并整理出来。如果你正打算自己写一个转盘组件,或者想把手头的旧代码重构掉,可以参考一下我的做法。

1. 转盘这种需求,我为什么绕开CSS和现成插件

先聊选型。转盘看起来简单,但“简单”只是对最终用户而言,放在代码层面要同时满足视觉还原、动态数据、动画可控三件事,方案就不是那么好选了。

1.1 三种实现方式的取舍

大多数新手第一反应是用切好的底图,配合CSS的transform: rotate旋转。这个方案在一次性落地页里确实最快,设计师出张图,前端把指针和按钮叠上去,transition给个时间就能转。但问题也明显:每期奖品变了要重新出图,奖品数量不同旋转角度就变了,图片在高清屏上还得额外准备2x图,而且想在中途加个“奖品动态下发”的功能,整个方案基本要推翻。

SVG方案比图片灵活一些,路径本身就是元素,可以动态增删扇区。但转盘上的文字是放射状分布的,逐个操作文字节点的旋转、对齐、换行,代码写起来非常啰嗦,动画过程中还要频繁更新DOM属性,掉帧风险高。

我最后选了Canvas,理由可以总结成四点:

  • 绘制逻辑全在JavaScript里,奖品数组怎么传,画布就怎么画,动态数据几乎零成本;
  • 每帧重绘即可完成动画,不需要操作DOM节点,性能更容易把控;
  • 高清屏适配很成熟,按设备像素比放大画布即可,不会发虚;
  • 视觉自由度最高,扇形颜色、文字样式、装饰元素都自己画,设计师怎么改都不怕。

表格对比更直观:

对比项图片+CSSSVGCanvas
静态页面开发速度快中慢
动态改奖品需要重出图操作DOM节点重新绘制即可
高清屏表现需要多倍图矢量清晰按dpr放大
动画控制能力transform实现依赖CSS/JS混合requestAnimationFrame完全可控
长期维护成本高中低

1.2 什么情况下不用自己写

必须说句公道话,如果只是个一次性活动页,下周一上线,奖品也不会中途改,用现成插件完全没问题,没必要从零造轮子。我当时选择手写,主要是因为项目后面至少还有三到四期运营活动,每期都会换奖品和配色,而且后端要控制中奖结果,我需要一个能“按指定奖品ID转到对应位置”的组件,这个需求很多现成插件并不支持,或者支持得很别扭。

另外提醒一句,如果用了第三方插件,务必确认它的旋转逻辑是基于CSS transform还是Canvas。CSS transform方案在连续多次旋转时,角度值会越积越大,虽然功能上不受影响,但时间长了浮点数的精度问题可能会冒出来。

2. 先画一个能用的转盘:扇形、文案与中心按钮

确定Canvas方案之后,第一步就是把静态转盘画出来。这个过程不复杂,但细节不少,尤其是文案的方向和高清屏的处理,我一个个说。

2.1 建立绘制框架

画转盘前先明确概念:Canvas的arc方法默认从三点钟方向开始算角度,顺时针递增。但我们视觉上转盘都是从十二点方向开始转的,所以我习惯给所有扇区的起始角度都减去Math.PI / 2,把零点偏移到顶部。

const startBase = -Math.PI / 2; const unit = (Math.PI * 2) / prizes.length; const start = startBase + index * unit; const end = start + unit;

prizes是奖品配置数组,每个元素至少包含id、label、color这些字段。实际项目中可能还会有icon、weight、textColor,后面用到再展开。

2.2 绘制扇形

扇区绘制本身很简单:先移动到圆心,画弧线,闭合路径,再填充颜色。

ctx.beginPath(); ctx.moveTo(cx, cy); ctx.arc(cx, cy, radius, start, end); ctx.closePath(); ctx.fillStyle = item.color; ctx.fill(); ctx.strokeStyle = '#ffffff'; ctx.lineWidth = 2; ctx.stroke();

这里有两个容易忽略的地方。第一,arc的角度是浮点数,多个扇区首尾相接时,理论上不会出现缝隙,但抗锯齿可能会导致扇区之间有一丝背景色透出来,所以给每个扇区加一条白色描边,反而能在视觉上让分界更干净。第二,如果奖品数量很多,扇区角度很小,文字很容易重叠,这个后面再细说。

关于底色搭配,我建议直接给每个扇区配好颜色,而不是用随机色。随机色生成的色板经常出现两种饱和度过高的颜色相邻,观感很差。我会让设计师一次性给一套“以品牌色为基调”的色板,然后放进配置里,前端不做任何颜色计算。

2.3 放射状文字的绘制

转盘上的文字不是水平排布的,而是沿着半径方向放射状排列,每个文字都要旋转到扇区中心线的方向。我一般这样写:

ctx.save(); ctx.translate(cx, cy); ctx.rotate(start + unit / 2); ctx.textAlign = 'right'; ctx.textBaseline = 'middle'; ctx.fillStyle = '#ffffff'; ctx.font = 'bold 14px sans-serif'; ctx.fillText(item.label, radius - 16, 0); ctx.restore();

解释一下这里的关键点。translate把坐标系原点移到圆心,然后rotate旋转当前坐标系,相当于把原来水平向右的x轴旋转到扇区中间方向。此时我在fillText里设置textAlign = 'right',文字就会从右向左绘制,写出来的效果是文字贴着外圈向圆心方向排列,方向呈现放射状。

还有一个小细节:当扇区很窄或者文字较长时,文字会溢出扇区。我加了一个简单的自适应字号逻辑:

let fontSize = 14; ctx.font = `bold ${fontSize}px sans-serif`; const maxWidth = radius * 0.55; while (ctx.measureText(item.label).width > maxWidth && fontSize > 10) { fontSize -= 1; ctx.font = `bold ${fontSize}px sans-serif`; }

measureText是Canvas提供的测量文本宽度的API,循环减小字号直到文字能装下。这个方法比固定用14px或者靠人工估算长度要可靠得多。

2.4 中心按钮和指针不画进Canvas

转盘中央的按钮我会用DOM元素实现,而不是画在Canvas里。原因很实在:按钮需要响应点击事件,DOM天然支持,如果在Canvas里画,还得自己写命中检测,纯粹是给自己找麻烦。指针同理,用CSS画一个三角形,绝对定位到Canvas上方居中即可。

<div class="wheel-box"> <canvas id="wheel"></canvas> <span class="pointer"></span> <button id="spinBtn">GO</button> </div>
.wheel-box { position: relative; width: 320px; height: 320px; margin: 0 auto; } #wheel { width: 100%; height: 100%; } .pointer { position: absolute; top: 0; left: 50%; transform: translateX(-50%); border-left: 12px solid transparent; border-right: 12px solid transparent; border-top: 20px solid #f59f00; z-index: 2; } #spinBtn { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 64px; height: 64px; border-radius: 50%; border: none; background: #fff; color: #f59f00; font-weight: 700; cursor: pointer; z-index: 3; }

用DOM做指针还有一个好处:动画过程中Canvas每帧都要重绘,如果指针也在Canvas里,等于每帧要重复绘制一堆静态图形,没必要。DOM元素由浏览器独立渲染,不参与Canvas重绘,性能更好。

3. 让转盘转起来:缓动动画、圈数与落点计算

静态转盘画完之后,核心问题就是旋转动画。这一节我会把动画逻辑拆成几个部分,每一步都说明为什么这么做。

3.1 动画引擎选择:requestAnimationFrame

动画本质上是一连串的重绘,最可靠的驱动方式是requestAnimationFrame。它有两个关键优点:浏览器会在下一帧绘制之前调用回调,动画帧率与屏幕刷新率对齐;页面切到后台时自动暂停,不会白白消耗CPU。

很多教程喜欢用setInterval控制旋转角度,我不推荐。setInterval无法保证执行频率稳定,移动端浏览器为了省电经常降低定时器精度,会导致转盘一顿一顿的,观感非常差。

3.2 用时间比例计算进度,而不是每帧加固定角度

新手写旋转动画经常这样:每帧给当前角度加一个固定值,比如currentAngle += 0.02。这种方式的问题在于不同设备的帧率不同,60Hz的设备一秒钟转60次,30Hz的设备只转30次,最终旋转的总圈数和时长完全不一样。

正确做法是记录动画开始时间,每帧根据当前时间与开始时间的比例计算进度:

let currentAngle = 0; let spinning = false; let rafId = 0; function spinTo(targetAngle, duration = 4000) { const startAngle = currentAngle; const delta = targetAngle - startAngle; const startTime = performance.now(); spinning = true; function frame(now) { const t = Math.min((now - startTime) / duration, 1); const ease = easeOutQuart(t); currentAngle = startAngle + delta * ease; drawWheel(prizes, currentAngle); if (t < 1) { rafId = requestAnimationFrame(frame); } else { spinning = false; onSpinFinished(); } } rafId = requestAnimationFrame(frame); }

performance.now()返回的是高精度时间戳,不受帧率波动影响。这样无论设备帧率是30还是60,动画总时长都锁定在4秒,视觉节奏稳定。

3.3 缓动函数决定“手感”

转盘的减速过程必须符合物理直觉:先快速旋转,然后慢慢停下来。这个过程用缓动函数实现。

我推荐easeOutQuart:

function easeOutQuart(t) { return 1 - Math.pow(1 - t, 4); }

这个函数的特性是开始阶段变化幅度大,速度递减非常快,最后阶段几乎静止,视觉上非常接近真实转盘的阻尼效果。如果产品经理希望转起来更“飘”一点,可以用easeOutQuint,指数越高,最后阶段越慢。我实测下来,easeOutQuart最不容易被反馈“转得太假”。

这部分有个常见误区:有人会用setTimeout在动画结束后直接弹出中奖结果。但实际上动画是异步执行的,弹窗逻辑应该放在onSpinFinished回调里,而不是点击事件之后立刻做。否则用户会在转盘还没停下来的时候就看到弹窗,非常出戏。

3.4 目标角度计算:让指定扇区落在指针处

这是整个转盘组件里最容易算错的地方。我们需要实现的能力是:给定一个奖品索引,让这个奖品的扇区中心在动画结束时恰好指向顶部的指针。

前面我设定过,第index个扇区的初始角度是:

- Math.PI / 2 + index * unit + unit / 2

要让这个中心角转到-Math.PI / 2,扇区需要再旋转的角度是:

-(index * unit + unit / 2)

但这个值可能是负数,我们不能让转盘逆着转。所以要先对角度做模运算,把它归一化到0 ~ 2π之间,然后再加上若干整圈,保证动画是从当前位置正向转过去的。

function getTargetAngle(prizeIndex, extraLoops = 5) { const unit = (Math.PI * 2) / prizes.length; const current = currentAngle; let diff = -(prizeIndex * unit + unit / 2) - current; diff = diff % (Math.PI * 2); if (diff < 0) diff += Math.PI * 2; return current + diff + extraLoops * Math.PI * 2; }

extraLoops是额外转的整圈数,一般在4到6之间。太少会显得很敷衍,太多用户等得不耐烦,我一般默认5圈。

再说一个实际经验:动画结束后,currentAngle会变得非常大,因为它累加了多圈角度,浮点数误差会累积。虽然短时间内不影响使用,但运行很长时间后可能影响计算精度。我通常在动画结束回调里把currentAngle做一次模运算:

currentAngle = currentAngle % (Math.PI * 2);

这样既能保持计算精度,又不会影响后续角度的正确性。

4. 概率控制:前端随机、加权随机与后端定夺

转盘动画只是个壳,真正决定用户“中什么奖”的逻辑才是业务核心。这一节讲概率控制的三种层次,从简单到复杂。

4.1 错误的做法:先随机索引再转

很多初学者会这么写:点击抽奖时用Math.random()随机一个奖品索引,然后调用getTargetAngle让转盘转到那个扇区。问题在于这种方案没有任何概率控制能力。打个比方,你画了10个扇区,但运营希望“谢谢参与”的实际概率是80%,“大奖”的实际概率只有0.1%。如果扇区画得一样大,纯随机就无法满足需求。

更严重的是,前端代码是公开可读的,结合控制台可以随意调用getTargetAngle,理论上可以做到“指哪打哪”。只要涉及真实利益,就绝对不能把中奖结果的决定权放在前端JS里。

4.2 加权随机:在纯前端里做到“看起来可控”

如果项目确实只是想做个玩法演示,奖品背后没有任何发奖逻辑,那可以用加权随机。每个奖品配置一个weight字段,数值越大中奖概率越高。

function weightedPickIndex(prizes) { const total = prizes.reduce((sum, p) => sum + p.weight, 0); let rand = Math.random() * total; for (let i = 0; i < prizes.length; i++) { rand -= prizes[i].weight; if (rand <= 0) return i; } return prizes.length - 1; }

原理是把所有权重加总成一个线段,然后在线上随机取一个点,落在哪段就中哪个。这个算法简单、可读性高,适合演示场景。

4.3 后端定结果:更严谨的抽奖流程

在正式运营项目里,正确的流程是:前端点击抽奖按钮之后,发起请求到后端,后端根据真随机算法或业务规则计算出中奖的奖品ID,返回给前端,前端再根据这个ID计算目标角度,让转盘转到对应的位置。

async function onSpinClick() { if (spinning) return; const prize = await fetchPrizeResult(); const index = prizes.findIndex((p) => p.id === prize.id); if (index === -1) { console.error('后端返回了未知奖品ID'); return; } const target = getTargetAngle(index, 5); spinTo(target, 4000, () => { showResult(prize); }); }

这种情况下,前端彻底变成了“展示层”,算法和概率都在服务端,用户无法篡改。这是任何真实发奖场景的底线。

4.4 接口延迟期间不能干等

后端接口通常不会瞬间返回,如果点击按钮后界面保持静止等接口,体验会很差。常用的优化方案是“先转起来,等接口返回后再对准”。

简单版本是这样:点击按钮后立刻调用spinTo到一个随机角度,接口返回后,重新计算目标角度并“续接”动画。实现起来有个难点:中途重新设置目标角度,要保证动画速度看起来依然是平滑减速的。

我自己用的是一种更务实的做法:把等待接口的过程放在动画启动之前,但接口期间给按钮一个loading态,配上“正在抽奖”的提示。因为绝大多数内网接口响应时间在200ms以内,用户基本感知不到延迟。如果接口偶尔慢,比如超过1秒,再考虑两段式动画。这个取舍要看你项目的实际情况,别一上来就搞复杂的续接逻辑。

4.5 扇区大小与实际概率无关,但别太离谱

运营经常会要求“把大奖的扇区画大一点,让用户看着很诱人”。这背后有一个常见误解:用户默认扇区面积等于中奖概率。前端确实可以把扇区画得很大,实际概率由后端权重控制,这样既满足视觉吸引力,又保证运营指标。但扇区面积和概率差距如果太悬殊,用户抽几次就会觉得“有黑幕”,影响口碑。这个度要结合业务背景把控,一般大奖扇区占比不超过整个转盘的三分之一比较安全。

5. 上线前容易被喷的四个细节

转盘能转起来只是完成了30%,剩下70%都在各种边界情况的处理上。这块内容是我踩坑踩出来的,逐条列出来。

5.1 防连点:抽奖进行中锁死按钮

用户手滑经常连续点好几次抽奖按钮,如果每次都发起请求,等于用户一次活动抽了好几次奖,运营成本不可控。最简单的方案是加一个spinning标志位,动画过程中直接拦截事件。

function onSpinClick() { if (spinning) return; // 抽奖逻辑 }

更稳妥的做法是把按钮的disabled属性也设置上,避免键盘或读屏器触发重复请求。注意,这个锁必须在发起请求之前就生效,而不是等接口返回后再锁,否则高并发场景下仍然可能重复提交。

5.2 高清屏适配:canvas.width重设会重置状态

这是Canvas开发里最经典的坑。如果直接把canvas.clientWidth赋给canvas.width,在Retina屏上绘制出来的图形会发虚。正确做法是让canvas.width等于CSS像素宽度乘以devicePixelRatio,再用ctx.scale(dpr, dpr)让绘图坐标系保持CSS像素。

function setupCanvas(canvas) { const dpr = window.devicePixelRatio || 1; const rect = canvas.getBoundingClientRect(); const width = Math.floor(rect.width * dpr); const height = Math.floor(rect.height * dpr); if (canvas.width !== width || canvas.height !== height) { canvas.width = width; canvas.height = height; const ctx = canvas.getContext('2d'); ctx.scale(dpr, dpr); } return canvas.getContext('2d'); }

注意一个隐藏问题:给canvas.width赋值会清空画布,同时重置所有canvas上下文状态,包括scale、fillStyle、strokeStyle等。所以setupCanvas不能放在每次绘制之前调用,否则每帧都会清空缩放状态。正确的做法是只在尺寸变化时重新设置,绘制过程中直接使用已经创建好的上下文。

另外,getBoundingClientRect()返回的尺寸可能带小数,和dpr相乘后可能出现非整数,最好用Math.floor向下取整,避免canvas内部内存分配出现非整数大小的报错。

5.3 异步图片加载:onload之后再重绘

如果奖品带icon图片,绘制顺序很重要。图片是异步加载的,如果用drawImage绘制尚未加载完成的图片,Canvas里就是一片空白。

我的做法是加载完所有图片后再触发首次绘制:

function loadAllImages(prizes) { const tasks = prizes.map((p) => { return new Promise((resolve) => { const img = new Image(); img.onload = () => resolve({ id: p.id, img }); img.onerror = () => resolve({ id: p.id, img: null }); img.src = p.icon; }); }); return Promise.all(tasks); }

使用Promise.all等所有图片加载完成,然后统一开始drawWheel。如果某张图片短时间加载不出来,onerror要返回一个默认占位图或者null,否则整个转盘会因为一张图挂掉。

5.4 页面生命周期:页面隐藏时暂停动画,组件销毁时清理任务

移动端用户随时可能切走页面,比如接电话、切微信。如果页面在后台时requestAnimationFrame被浏览器暂停,切回来后动画帧率会出现跳变,观感很奇怪。

针对这个问题,我习惯在封装时保留rafId,并在组件销毁时统一取消:

function destroy() { if (rafId) { cancelAnimationFrame(rafId); rafId = 0; } }

如果是React/Vue项目,在useEffect或onUnmounted里调用destroy即可。如果是纯原生页面,则需要在页面卸载事件里处理。

页面隐藏导致的另一个问题,是用户切回来发现动画已经结束,但中奖弹窗没有弹出来。这个其实是requestAnimationFrame暂停后,动画结束回调没有执行的连锁反应。比较省心的处理是监听visibilitychange事件,在页面重新可见时检查一下动画是否结束。

document.addEventListener('visibilitychange', () => { if (!document.hidden && spinning) { // 说明之前还在转,现在重新回到页面,视情况直接完成回调 } });

不过说实话,这个场景比较边缘,我实际项目中并不会特意处理,因为用户切走页面时,动画大概率已经因为performance.now()的逻辑结束了,只是回调还没触发。如果这个场景对你很重要,可以在visibilitychange触发时主动完成回调,保证弹窗不丢失。

6. 从开发到上线的实操顺序和调试技巧

最后分享一些开发流程上的心得,这部分是代码之外的,但能直接影响效率。

我第一次写转盘组件时,按“先静态、再动画、最后接接口”的顺序推进,整个过程很顺。静态阶段先把扇形、文字、指针、按钮这些视觉元素全部定稿,不发散;动画阶段只关心旋转逻辑,用写死的中奖索引去验证落点是否准确;等这两步都稳定了,再对接后端接口,把动态数据接进来。这样做的好处是每个阶段出问题时,问题边界都很清晰,不会出现“到底是图片没加载完还是角度算错”这种互相纠缠的排查局面。

调试落点是否准确时,有一个很实用的技巧:在URL后面加一个调试参数,比如?target=2,页面初始化时直接读取这个参数,把目标奖品索引强制设为2,然后自动触发一次动画。这样不用每次点按钮、等接口、拼概率才能看到某个特定扇区的落点效果。

const debugTarget = new URLSearchParams(location.search).get('target'); if (debugTarget !== null) { const index = Number(debugTarget); if (!Number.isNaN(index) && index >= 0 && index < prizes.length) { setTimeout(() => { const target = getTargetAngle(index, 5); spinTo(target, 4000); }, 500); } }

这个调试开关上线前不要删,留在代码里也没有任何副作用,但以后换文案、换奖品数量时,能帮你快速回归验证。

还有一个容易被人忽视的细节:中奖结果弹窗。转盘动画结束后的反馈一定要及时,最好配合一点音效或者文字弹层。如果动画停了1秒之后弹窗才出现,用户会觉得很卡。我把弹窗逻辑放在onSpinFinished回调里,紧跟动画最后一帧,实测在低端安卓机上也不会有明显的延迟感。

最后说一个踩过几次的坑:不要在requestAnimationFrame回调里同步弹出浏览器原生alert弹窗。原生弹窗会阻塞主线程,动画循环会直接卡死,明明转盘已经到位了,但页面没有任何反应。所有弹窗都要用自定义DOM弹层,且放在动画结束回调里执行,不要放在动画循环中。这个细节在我早期版本里踩得很惨,后来养成了习惯:整个组件内部禁止出现任何同步阻塞方法。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/2 5:49:18

vdexExtractor 实战:从 Vdex 到 Dex 的完整转换指南

1. 为什么需要把 Vdex 转换回 Dex做安卓应用分析和系统调试的朋友&#xff0c;应该都有过这种经历&#xff1a;从设备或者系统镜像里捞出一个.vdex后缀的文件&#xff0c;打开看一眼全是二进制乱码&#xff0c;用file命令一看&#xff0c;显示的是Android dex file或者干脆是未…

作者头像 李华
网站建设 2026/10/2 5:48:42

Codex 安装配置与 401 报错排查实战指南

1. 为什么 2026 年还有人在折腾 Codex 的安装Codex 这个工具从发布到现在&#xff0c;安装流程其实一直在变。2026 年 9 月这个时间点&#xff0c;官方把认证体系做了一次比较大的调整&#xff0c;以前那种直接填个 API Key 就能跑的日子已经过去了。现在你打开终端敲下codex命…

作者头像 李华
网站建设 2026/10/2 5:47:12

编译原理CP lab实验报告:词法、语法、语义分析全攻略

简介&#xff1a;面向编译原理课程实验的一份完整报告&#xff0c;依托 Engintime CP Lab 集成环境&#xff0c;覆盖从正则表达式到 NFA 的转换&#xff0c;以及使用 Lex 自动生成扫描程序两大核心任务&#xff0c;适合正在完成同类实验、需要理解实现原理或撰写实验报告的本科…

作者头像 李华
网站建设 2026/10/2 5:46:55

Umi-OCR离线文字提取实战:截图、批量图片与PDF识别全解析

图片里的文字提取这件事&#xff0c;说大不大&#xff0c;说小也不小。平时偶尔遇到一两张截图&#xff0c;手动敲几个字也就过去了&#xff1b;可一旦碰上几十页的扫描版PDF、成堆的发票照片、或者别人发来的资料截图&#xff0c;手动录入就变成了纯粹的体力活。我最早接触OCR…

作者头像 李华
网站建设 2026/10/2 5:46:25

Oracle ORA-00257 归档日志满故障处理与 RMAN 空间释放实战

简介&#xff1a;这份文档面向 Oracle 数据库运维与 DBA 人员&#xff0c;聚焦归档日志写满引发的 ORA-00257 报错&#xff0c;提供一套可落地的处理思路与操作记录。内容围绕 Archivelog 机制展开&#xff0c;先说明该错误产生的原理&#xff0c;再给出「删除物理文件 登录 R…

作者头像 李华
网站建设 2026/10/2 5:43:54

程序员必懂的NP问题实战指南:从验证快到求解难

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华