原生JS+Canvas手搓K线图:从数据映射到性能优化
简介这份资源面向需要在前端或移动端实现行情图表的开发者提供了一套基于原生 JavaScript 与 H5 Canvas 的 K 线图绘制方案可解决金融、数字货币等场景下的行情可视化需求。压缩包共 3 个文件包含 2 个 js 脚本与 1 个 html 页面整体约 11KB其中 kline.js 承担核心绘图与交互逻辑hammer-min.js 负责移动端手势识别html 文件作为可直接运行的演示入口。功能上支持左右滑动、手势缩放以及长按显示十字光标手势触摸事件集中在 kline.js 底部的 bindListener 方法内不熟悉 Hammer.js 的开发者可自行替换为原生事件。无需任何配置双击打开 kline.html 即可查看效果便于快速集成到自有项目中。目前已有 3019 人学习下载适合希望低成本掌握 Canvas 图表交互实现思路的前端开发者参考。1. 用 Canvas 手搓 K 线图为什么我不推荐直接上 ECharts前阵子帮一个做量化交易的朋友改他的行情面板他原本用某图表库渲染 K 线数据量一上到 8000 根就开始卡顿缩放拖拽像在拖水泥。我把他那套依赖全删了用原生 Canvas 重写了一遍同样的数据量帧率稳在 55 以上。这件事让我意识到很多人一提到「JavaScript 实现 K 线图」就条件反射去找现成库却从没想过 K 线图本质上就是一堆矩形加几条线自己画反而更可控。这份资源就是围绕「用原生 JS Canvas 从零实现 K 线图」展开的包含完整的绘制逻辑、坐标映射、交互处理和性能优化思路。它解决的不是「画一根蜡烛」这种玩具问题而是真实行情场景下的数据映射、视口裁剪、十字光标吸附、缩放平移这些硬骨头。适合两类人一是想彻底搞懂 K 线渲染原理的前端二是被图表库性能和定制性折磨过的交易系统开发者。如果你只想快速贴个图交差那直接用库没问题但如果你想掌控每一根蜡烛的像素往下看。2. 坐标系与数据映射K 线图的骨架怎么搭2.1 为什么 K 线图的核心是「映射」而不是「画图」新手写 K 线图最容易犯的错是拿到数据就开始ctx.fillRect画到一半发现价格 68000 和 0.00012 画出来一样高。K 线图的本质是两层映射第一层把「时间索引」映射到 X 轴像素第二层把「价格区间」映射到 Y 轴像素。这两层映射没搭好后面所有交互都是空中楼阁。先说 X 轴。K 线数据通常是等时间间隔的数组索引 0 到 N-1 对应从左到右的位置。但真实行情里会有停牌、休市导致的时间空洞如果你按时间戳线性映射K 线会稀稀拉拉。常见做法是按「数据点索引」映射而不是按真实时间戳这样每根蜡烛间距均匀视觉上更整齐。代价是时间轴不是严格线性的但对日内交易图来说可以接受。Y 轴更关键。价格映射需要先算出当前视口内所有可见 K 线的最高价和最低价然后留出上下各 5% 到 10% 的 padding再把[minPrice, maxPrice]线性映射到[canvasHeight, 0]注意 Y 轴是反的价格越高像素值越小。这里有个坑如果你用全局最高最低价缩放后价格波动会被压扁成一条直线所以必须用「当前视口可见数据」的动态极值。// 计算可见区域的 Y 轴映射参数 function computeYScale(visibleData, canvasHeight, paddingRatio 0.08) { let minPrice Infinity; let maxPrice -Infinity; // 只遍历可见数据这是性能关键 for (let i 0; i visibleData.length; i) { const bar visibleData[i]; // 注意影线的高低价也要纳入否则影线会被裁掉 if (bar.high maxPrice) maxPrice bar.high; if (bar.low minPrice) minPrice bar.low; } // 防止所有价格相同导致除以零 if (maxPrice minPrice) { maxPrice 1; minPrice - 1; } const padding (maxPrice - minPrice) * paddingRatio; const top maxPrice padding; const bottom minPrice - padding; const range top - bottom; // 返回一个把价格转成像素 Y 的函数 return { top, bottom, toY: (price) canvasHeight - ((price - bottom) / range) * canvasHeight, }; }这段代码里paddingRatio控制上下留白我一般设 0.08太小影线贴边太大蜡烛显得矮胖。toY返回的是像素坐标注意公式里用canvasHeight -是因为 Canvas 的 Y 轴向下增长而价格向上增长必须翻转。这个函数会被每根蜡烛调用 4 次开高低收所以它必须足够轻不要在里面做任何 DOM 操作或对象创建。2.2 从数据到蜡烛一根 K 线的绘制拆解一根标准 K 线由三部分组成实体开盘价到收盘价的矩形、上影线最高价到实体顶部、下影线实体底部到最低价。阳线收盘 开盘通常用红色或空心阴线用绿色或实心这是国内习惯欧美反过来所以颜色要可配置。绘制顺序有讲究先画影线再画实体否则影线会盖住实体边缘。影线用ctx.beginPathmoveTolineTostroke实体用fillRect。这里有个性能细节如果每根蜡烛都单独beginPath和stroke8000 根就是 8000 次路径操作非常慢。优化做法是把所有影线合并成一条路径一次性 stroke。function drawCandles(ctx, data, xScale, yScale, options) { const { candleWidth, upColor, downColor, wickColor } options; const halfWidth candleWidth / 2; // 第一遍合并所有影线为一条路径 ctx.beginPath(); ctx.strokeStyle wickColor; ctx.lineWidth 1; for (let i 0; i data.length; i) { const bar data[i]; const x xScale.toX(i) halfWidth; // 蜡烛中心 X const highY yScale.toY(bar.high); const lowY yScale.toY(bar.low); ctx.moveTo(x, highY); ctx.lineTo(x, lowY); } ctx.stroke(); // 一次性画完所有影线 // 第二遍逐根画实体 for (let i 0; i data.length; i) { const bar data[i]; const x xScale.toX(i); const openY yScale.toY(bar.open); const closeY yScale.toY(bar.close); // 实体高度至少 1px否则十字星会消失 const bodyTop Math.min(openY, closeY); const bodyHeight Math.max(Math.abs(closeY - openY), 1); ctx.fillStyle bar.close bar.open ? upColor : downColor; ctx.fillRect(x, bodyTop, candleWidth, bodyHeight); } }candleWidth需要根据缩放级别动态计算我一般用Math.max(1, Math.floor(viewportWidth / visibleCount) - 1)减 1 是留间隙。Math.max(..., 1)保证十字星开盘等于收盘不会因为高度为 0 而消失这是血泪经验曾经排查了半天为什么某些 K 线不见了。影线合并路径这个技巧在数据量大时能提升 3 到 5 倍渲染速度因为减少了 Canvas 状态切换和路径提交次数。3. 交互实现缩放、平移与十字光标3.1 缩放与平移视口管理的正确姿势K 线图的交互核心是「视口」概念你看到的永远只是全部数据的一个窗口缩放改变窗口大小平移改变窗口起点。视口用两个变量描述startIndex起始数据索引和visibleCount可见数量。所有绘制都基于这两个值裁剪数据。滚轮缩放时直觉是「以鼠标位置为中心缩放」也就是鼠标指向的那根 K 线在缩放后应该还在鼠标下方。实现方法是先算出鼠标对应的数据索引缩放后调整startIndex让这个索引仍然对齐鼠标 X 坐标。// 滚轮缩放以鼠标位置为锚点 function handleWheel(event, viewport, totalCount, canvasWidth) { event.preventDefault(); const rect event.target.getBoundingClientRect(); const mouseX event.clientX - rect.left; // 鼠标当前指向的数据索引 const anchorIndex viewport.startIndex (mouseX / canvasWidth) * viewport.visibleCount; // 缩放因子向上滚放大可见变少向下滚缩小 const zoomFactor event.deltaY 0 ? 0.9 : 1.1; let newCount Math.round(viewport.visibleCount * zoomFactor); // 边界限制最少 10 根最多全部数据 newCount Math.max(10, Math.min(totalCount, newCount)); // 反推新的 startIndex让 anchorIndex 仍在鼠标位置 const newStart Math.round(anchorIndex - (mouseX / canvasWidth) * newCount); // 防止越界 viewport.startIndex Math.max(0, Math.min(totalCount - newCount, newStart)); viewport.visibleCount newCount; }zoomFactor用 0.9 和 1.1 而不是 0.5 和 2是为了让缩放手感平滑一次滚轮不会跳太多。Math.max(10, ...)限制最少显示 10 根否则缩到 1 根时整个图会崩。Math.min(totalCount, ...)防止缩放到超过数据总量。平移用mousedownmousemove实现记录按下时的startX和startIndex移动时按像素差换算成索引差即可注意要Math.round避免累积浮点误差。3.2 十字光标与 Tooltip吸附逻辑与性能陷阱十字光标是 K 线图的灵魂功能但也是最容易写出性能问题的部分。常见错误是在mousemove里重绘整个图表导致鼠标一动就掉帧。正确做法是分层 Canvas主图层画 K 线交互层画十字光标和 Tooltip鼠标移动只重绘交互层。吸附逻辑是让十字光标的竖线自动对齐到最近的蜡烛中心而不是跟随鼠标像素。这样用户看到的永远是某根具体 K 线的数据。// 交互层绘制十字光标和 Tooltip function drawCrosshair(ctx, mouseX, mouseY, viewport, data, xScale, yScale) { ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); // 计算吸附的蜡烛索引 const rawIndex viewport.startIndex (mouseX / ctx.canvas.width) * viewport.visibleCount; const snapIndex Math.round(rawIndex); if (snapIndex 0 || snapIndex data.length) return; const bar data[snapIndex]; const snapX xScale.toX(snapIndex - viewport.startIndex) xScale.candleWidth / 2; // 竖线 ctx.beginPath(); ctx.strokeStyle #888; ctx.setLineDash([4, 4]); // 虚线 ctx.moveTo(snapX, 0); ctx.lineTo(snapX, ctx.canvas.height); ctx.stroke(); // 横线跟随鼠标 Y ctx.beginPath(); ctx.moveTo(0, mouseY); ctx.lineTo(ctx.canvas.width, mouseY); ctx.stroke(); ctx.setLineDash([]); // 恢复实线否则影响后续绘制 // Tooltip 显示 OHLC 数据 const price yScale.toPrice(mouseY); // 需要反向映射函数 ctx.fillStyle rgba(0,0,0,0.75); ctx.fillRect(snapX 10, mouseY - 40, 140, 70); ctx.fillStyle #fff; ctx.font 12px monospace; ctx.fillText(开: ${bar.open}, snapX 18, mouseY - 22); ctx.fillText(高: ${bar.high}, snapX 18, mouseY - 8); ctx.fillText(低: ${bar.low}, snapX 18, mouseY 6); ctx.fillText(收: ${bar.close}, snapX 18, mouseY 20); }setLineDash画完虚线后必须重置为[]否则后面所有线条都变虚线这个坑我踩过不止一次。yScale.toPrice是toY的反函数用于把鼠标 Y 坐标转回价格显示。Tooltip 的位置要做边界检测靠近右边缘时应该往左偏移否则会超出画布。交互层用独立的 Canvas 叠在主图层上方mousemove只清空交互层主图层完全不动这样即使 8000 根 K 线鼠标移动也是 60 帧满帧。4. 避坑与排查那些让我加班到凌晨的细节4.1 高 DPI 屏幕模糊devicePixelRatio 的坑现象在 MacBook 或 4K 显示器上K 线边缘发虚线条像蒙了一层雾。原因Canvas 的width和height属性是物理像素而 CSS 的width和height是逻辑像素高 DPI 屏幕下两者比例是 2:1 或 3:1不处理就会拉伸模糊。解决初始化时按devicePixelRatio放大 Canvas 物理尺寸再用ctx.scale缩放绘图坐标系。function setupHiDPICanvas(canvas, cssWidth, cssHeight) { const dpr window.devicePixelRatio || 1; canvas.width cssWidth * dpr; canvas.height cssHeight * dpr; canvas.style.width cssWidth px; canvas.style.height cssHeight px; const ctx canvas.getContext(2d); ctx.scale(dpr, dpr); // 之后所有绘图按逻辑像素走 return ctx; }注意ctx.scale会累积如果每次 resize 都调用而不重置变换矩阵缩放会翻倍。正确做法是 resize 时先ctx.setTransform(1, 0, 0, 1, 0, 0)重置再重新 scale。4.2 数据量大时卡顿别在 draw 里做排序和查找现象数据超过 5000 根后每次重绘要 200ms 以上。原因在绘制循环里做了data.filter、data.slice或者每次都重新计算极值。解决视口变化时才重新计算可见数据切片和极值绘制循环只做纯像素计算。另外for循环比forEach和map快得多绘制热路径上不要用高阶函数。4.3 缩放后 K 线错位浮点索引的累积误差现象反复缩放平移后K 线位置和十字光标对不上偏差越来越大。原因startIndex用浮点数存储每次加减都累积误差。解决startIndex始终用整数存储缩放时先算浮点结果再Math.round平移时按整数步进。如果确实需要亚像素平滑用单独的offsetX浮点变量做渲染偏移但索引本身保持整数。4.4 影线被裁掉极值计算漏了影线现象某些 K 线的上影线或下影线超出画布顶部或底部被切掉。原因计算 Y 轴极值时只用了开盘收盘价忘了最高最低价。解决极值遍历必须包含bar.high和bar.low这个错误在快速迭代时特别容易犯因为实体太显眼影线容易被忽略。4.5 内存泄漏事件监听没解绑现象页面切换几次后越来越卡内存占用持续上涨。原因mousemove、wheel、resize监听器绑定在window或document上组件销毁时没移除。解决把所有监听器引用存起来在销毁钩子里removeEventListener或者用AbortController统一取消。5. 进阶技巧用离屏 Canvas 把渲染压到 16ms 以内当数据量到万级、还要叠加均线、成交量、指标时单层 Canvas 会吃力。我的做法是三层结构底层用离屏 Canvas 缓存静态的 K 线主体只在视口变化时重绘中层画均线和指标顶层画十字光标。离屏 Canvas 的好处是它不参与页面合成绘制结果可以drawImage一次性贴到主画布。// 离屏缓存视口不变时复用 class CandleCache { constructor(width, height) { this.offscreen document.createElement(canvas); this.offscreen.width width; this.offscreen.height height; this.ctx this.offscreen.getContext(2d); this.lastKey ; // 缓存键视口 数据版本 } render(data, viewport, xScale, yScale, options) { // 缓存键变化才重绘 const key ${viewport.startIndex}-${viewport.visibleCount}-${data.version}; if (key this.lastKey) return this.offscreen; this.ctx.clearRect(0, 0, this.offscreen.width, this.offscreen.height); drawCandles(this.ctx, data.slice( viewport.startIndex, viewport.startIndex viewport.visibleCount ), xScale, yScale, options); this.lastKey key; return this.offscreen; } } // 主渲染循环 function render(mainCtx, cache, data, viewport, xScale, yScale, options) { const cached cache.render(data, viewport, xScale, yScale, options); mainCtx.clearRect(0, 0, mainCtx.canvas.width, mainCtx.canvas.height); mainCtx.drawImage(cached, 0, 0); // 一次性贴图极快 // 再画均线、十字光标等动态层 }data.version是数据更新时递增的版本号用来让缓存失效。drawImage贴离屏 Canvas 几乎不耗时因为不涉及路径计算。实测 10000 根 K 线视口不变时每帧渲染从 45ms 降到 3ms 以内。另一个技巧是用requestAnimationFrame节流mousemove把多次鼠标事件合并到一帧里处理避免高频重绘。验证渲染性能的方法很简单在render函数首尾打performance.now()连续跑 100 帧取平均。如果超过 16ms就要检查是不是在热路径里做了对象创建、数组操作或者字符串拼接。我习惯在开发阶段常驻一个帧率显示一旦掉到 50 以下就立刻排查。从那以后我每次写 Canvas 渲染都会先搭好离屏缓存和分层结构再填业务逻辑省下的调试时间远超前期投入。希望帮到你。本文还有配套的精品资源点击获取

相关新闻

不是新模型,不是新框架——AI 编程下半场,TaoToken 把基础设施做成了统一 Key

不是新模型,不是新框架——AI 编程下半场,TaoToken 把基础设施做成了统一 Key

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

2026/10/18 12:58:41 阅读更多 →
海康机器人工业相机SDK C#开发实战指南

海康机器人工业相机SDK C#开发实战指南

简介:本资源是一套基于C#开发的海康机器人工业相机SDK调用实践项目,面向自动化、机器视觉方向的初/中级开发者及高校相关专业学生,解决工业相机图像采集中的回调取图与软触发控制两大核心需求。压缩包共71个文件,包含15个C#源码文…

2026/10/18 12:58:41 阅读更多 →
Python寻宝游戏设计:状态机与可达性验证实战

Python寻宝游戏设计:状态机与可达性验证实战

简介:这是一份面向Python编程初学者的节日主题小游戏实践项目,聚焦Pygame库基础应用,帮助学习者通过动手开发掌握窗口创建、图像加载、事件响应与简单游戏逻辑实现等核心技能。资源包共7个文件,含6张JPG格式游戏素材(苹…

2026/10/18 12:58:41 阅读更多 →

最新新闻

飞凌 OK3576-C 实测:Debian 13 上的八核 CPU、eMMC、GPU 和 NPU,实际表现怎么样?

飞凌 OK3576-C 实测:Debian 13 上的八核 CPU、eMMC、GPU 和 NPU,实际表现怎么样?

拿到一块嵌入式开发板,我一般会先确认系统能否正常启动和使用,再考虑跑分。芯片参数很好查,但装上 Debian 以后,软件包是否齐全、编译是不是够快、eMMC 读写会不会拖后腿,以及 GPU 和 NPU 能否真正运行程序&#xff0c…

2026/10/18 13:46:06 阅读更多 →
机器人会走路之后,行业开始争“大脑“

机器人会走路之后,行业开始争“大脑“

2026年,人形机器人的"身体"差距正在快速缩小。关节电机国产化率超过八成,整机价格两年下降七成,头部厂商的产线已经能以分钟级节拍下线产品。但一个让全行业尴尬的事实始终没有改变:机器人在标定好的工位上成功率可以接…

2026/10/18 13:46:06 阅读更多 →
我给 Gemini 4 和 DeepSeek 搞了套「末位淘汰」,AI 干活会更猛吗?(Day 3)

我给 Gemini 4 和 DeepSeek 搞了套「末位淘汰」,AI 干活会更猛吗?(Day 3)

昨天定下做 Beyond Compare 的免费开源版 OpenCompare 后,评论区又有朋友提了 FTP 远端对比等新需求,我已经让 AI 员工收进需求池交给 Gemini 4 评估。 今天本来该直接让 Gemini 4 和 DeepSeek 开工写核心代码。但动手前,我突然好奇一件事&am…

2026/10/18 13:46:06 阅读更多 →
Spring Cloud Gateway实战:路由、断言、过滤器与跨域配置全解析

Spring Cloud Gateway实战:路由、断言、过滤器与跨域配置全解析

微服务做久了,几乎谁都绕不开这两个词:Spring Boot 和 Spring Cloud。我经常在面试里碰到候选人张口就能背“Spring Boot是基础框架,Spring Cloud是微服务全家桶”,但真让他落地一个服务网关的时候,却连路由怎么写、依…

2026/10/18 13:46:06 阅读更多 →
计算机组成原理|数据与文字的表示方法

计算机组成原理|数据与文字的表示方法

前言 在计算机硬件体系当中,所有信息都不能以我们人类熟悉的十进制数字、汉字、符号直接存在硬件里面。不管是数字计算、文本信息,最后全部要转换成为二进制比特串。第二章作为整本书承上启下的关键章节,一边衔接第一章计算机系统概述的硬件基…

2026/10/18 13:46:06 阅读更多 →
MySQL 定时 DDL 导致 mysqldump 备份失败:场景模拟、排查与优化建议

MySQL 定时 DDL 导致 mysqldump 备份失败:场景模拟、排查与优化建议

1. 问题背景生产环境中,MySQL 数据库每天凌晨 2 点执行定时 DDL 操作(如新增索引、修改表结构),随后触发 mysqldump 逻辑备份任务,但备份经常失败。本文通过模拟该场景,分析失败根因,并给出可落…

2026/10/18 13:45:06 阅读更多 →

日新闻

基于GMM的语音识别系统原理与MATLAB实现详解

基于GMM的语音识别系统原理与MATLAB实现详解

说到基于高斯混合模型(GMM)的语音识别系统,很多人第一反应就是一堆数学公式,觉得门槛很高。但如果你真的在MATLAB里把它落地跑通,会发现核心其实就是三件事:把声音变成特征向量,用GMM给每个类别…

2026/10/18 0:00:55 阅读更多 →
数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理这四个字听起来挺唬人,但落到具体操作上,无非就是让计算机按你的想法“摆弄”像素。我在一个模拟项目X里用了一套特别经典的基础处理流程:对图像做反白、改调色板、彩色转灰度,最后把像素数据导出成txt文件。单看每一…

2026/10/18 0:00:55 阅读更多 →
机器人大脑技术盘点:具身智能的感知、决策与数据闭环

机器人大脑技术盘点:具身智能的感知、决策与数据闭环

如果你的信息流最近也天天刷到“机器人大脑”这个词,你会发现一个很有意思的现象:每个人说的“大脑”根本不是同一个东西。有人指的是大语言模型,有人指的是运动控制系统,还有人指的是能理解物理世界的端到端模型。在2026年这个时…

2026/10/18 0:00:55 阅读更多 →

周新闻

基于GMM的语音识别系统原理与MATLAB实现详解

基于GMM的语音识别系统原理与MATLAB实现详解

说到基于高斯混合模型(GMM)的语音识别系统,很多人第一反应就是一堆数学公式,觉得门槛很高。但如果你真的在MATLAB里把它落地跑通,会发现核心其实就是三件事:把声音变成特征向量,用GMM给每个类别…

2026/10/18 0:00:55 阅读更多 →
数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理这四个字听起来挺唬人,但落到具体操作上,无非就是让计算机按你的想法“摆弄”像素。我在一个模拟项目X里用了一套特别经典的基础处理流程:对图像做反白、改调色板、彩色转灰度,最后把像素数据导出成txt文件。单看每一…

2026/10/18 0:00:55 阅读更多 →
机器人大脑技术盘点:具身智能的感知、决策与数据闭环

机器人大脑技术盘点:具身智能的感知、决策与数据闭环

如果你的信息流最近也天天刷到“机器人大脑”这个词,你会发现一个很有意思的现象:每个人说的“大脑”根本不是同一个东西。有人指的是大语言模型,有人指的是运动控制系统,还有人指的是能理解物理世界的端到端模型。在2026年这个时…

2026/10/18 0:00:55 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

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

2026/10/18 0:55:02 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

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

2026/10/17 17:22:31 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

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

2026/10/18 12:16:16 阅读更多 →