电子表格这个东西看起来简单做起来要命。我最早接触这类需求是在做一个内部数据看板的时候当时想的是不就是个表格嘛渲染一下单元格加个编辑功能就完事了。结果真正动手才发现单元格合并、公式计算、撤销重做、协同编辑、大数据量虚拟滚动……每一个单拎出来都够写一个月的。后来接触到 Univer 这个项目才意识到它做的事情远不止表格两个字能概括的——它本质上是一套通用的文档协作引擎表格只是它的一种文档形态。Univer 的定位是一个开源的、可嵌入的在线电子表格与文档协作 SDK支持在浏览器和 Node.js 环境中运行底层依赖 Canvas 做高性能渲染整体采用插件化架构来组织功能模块。它适合谁呢如果你正在做在线表格、数据填报、报表工具、协同文档类产品或者你单纯想研究一个成熟的 Canvas 渲染引擎和插件系统是怎么设计的那 Univer 值得花时间啃一啃。这篇文章我会从它的架构设计、Canvas 渲染机制、插件体系、Node.js 环境下的使用、以及实际集成中容易踩的坑这几个角度把我在使用过程中积累的经验完整地梳理一遍。1. 先搞清楚 Univer 到底在解决什么问题1.1 在线表格的技术难点在哪里很多人对在线表格的认知停留在Excel 的网页版但真正做过的人知道这里面的技术挑战是层层叠加的。最底层是渲染性能一个稍微像样的业务表格动辄几万行、几十列如果用 DOM 来渲染每个单元格浏览器直接卡死。所以必须走 Canvas 路线自己做虚拟滚动、脏区域重绘、图层管理。再往上是数据模型单元格的值、公式、格式、样式、批注、数据验证这些信息怎么组织、怎么索引、怎么高效更新直接决定了整个系统的上限。再往上一层是交互与协作多人同时编辑同一份表格时冲突怎么解决撤销重做怎么保证只回退自己的操作公式的依赖链怎么实时更新这些问题在单机环境下可能不明显一旦涉及协同复杂度指数级上升。Univer 的设计思路是把这些问题分层解决——渲染层用 Canvas 自绘数据层用独立的领域模型管理协作层通过插件机制接入各层之间通过事件总线和命令系统解耦。1.2 Univer 的分层架构拆解Univer 的架构可以粗略分为四层。最底层是核心层Core提供依赖注入容器、事件总线、命令系统、插件生命周期管理这些基础设施。第二层是文档模型层定义了工作簿Workbook、工作表Worksheet、单元格Cell、范围Range等领域对象以及它们之间的关联关系。第三层是渲染层基于 Canvas 实现表格的绘制包括网格线、单元格内容、选区高亮、滚动条等视觉元素。最上层是功能插件层公式引擎、条件格式、数据验证、筛选、排序、协同编辑等功能都以插件形式挂载。这种分层的好处是每一层可以独立演进。比如你想换一个渲染引擎只要保持渲染层对外的接口不变上层功能插件完全不需要改动。同样你想接入自己的协同后端也只需要实现协同插件定义的接口不用碰核心层和渲染层的代码。1.3 和主流方案对比Univer 的取舍市面上做在线表格的方案大致有几类一类是基于 DOM 的轻量方案比如 Handsontable 的早期版本优点是实现简单、兼容性好缺点是性能天花板低一类是基于 Canvas 自绘的方案比如 Google Sheets、飞书表格性能好但开发成本极高还有一类是基于 WebAssembly 把桌面端引擎编译到浏览器比如某些 Excel 兼容方案功能强大但包体积大、灵活性差。Univer 选择了 Canvas 自绘 插件化架构的路线本质上是在性能和灵活性之间找平衡。它不像 WebAssembly 方案那样追求极致的 Excel 兼容性而是把重点放在可扩展性上——你可以按需引入插件只打包你用到的功能。这一点对前端项目来说很关键毕竟没人愿意为了一个简单的数据展示表格引入几MB的运行时。2. Canvas 渲染引擎的核心机制2.1 为什么不用 DOM 而选择 Canvas这个问题我被问过很多次。简单算一笔账假设一个表格有 1000 行、20 列那就是 20000 个单元格。如果用 DOM 渲染每个单元格至少是一个 div 元素加上边框、内边距、事件监听浏览器需要维护的节点数量轻松超过十万。在这种量级下任何一次滚动或编辑操作都会触发大规模的样式重算和重排帧率直接掉到个位数。Canvas 的方案则完全不同。整个表格就是一张画布所有的单元格、文字、边框都通过绘图指令画上去。浏览器只需要维护一个 Canvas 元素节点数量从十万级降到个位数。滚动的时候只需要重新计算可视区域内的单元格擦除旧内容、绘制新内容整个过程在 GPU 加速下可以稳定在 60fps。当然Canvas 也有代价——你失去了 DOM 自带的事件冒泡、CSS 样式、无障碍支持这些都需要自己实现。Univer 通过命中检测Hit Testing来模拟事件系统通过自绘来实现样式通过额外的 DOM 层来补充输入框和辅助功能。2.2 虚拟滚动与脏区域重绘Univer 的渲染引擎做了两件关键的事情虚拟滚动和脏区域重绘。虚拟滚动是指只渲染可视区域内的单元格屏幕外的内容完全不画。这听起来简单但实现起来要考虑滚动惯性、预渲染缓冲区、行列高度动态变化等问题。Univer 的做法是维护一个可视区域的计算模型根据滚动偏移量反推出当前应该渲染的行列范围然后只对这个范围执行绘制。脏区域重绘则是另一个维度的优化。当用户编辑某个单元格时理论上只需要重绘那个单元格所在的区域而不是整张画布。Univer 通过维护一个脏矩形列表来追踪哪些区域需要更新在下一帧渲染时只清除并重绘这些区域。这个机制在协同编辑场景下尤其重要——当远端用户修改了某个单元格本地只需要重绘那一小块区域而不是整个表格。2.3 图层管理与渲染管线Univer 的 Canvas 渲染不是单层画布而是多层叠加的结构。底层负责绘制网格线和背景色中间层绘制单元格内容和格式顶层绘制选区、光标、拖拽指示器等交互元素。这种分层设计的好处是不同层可以独立重绘——比如用户只是移动了选区只需要重绘顶层底层的网格和内容完全不用动。渲染管线大致是这样的首先由渲染调度器收集本帧需要更新的脏区域然后按图层顺序依次执行绘制指令每个图层内部再按照单元格的 z-index 顺序绘制。绘制指令会被缓存和复用避免重复创建 Canvas 路径对象。整个管线是异步的通过 requestAnimationFrame 驱动确保渲染节奏和浏览器刷新率同步。// 简化的渲染调度逻辑示意 class RenderScheduler { constructor() { this.dirtyRegions []; this.isRendering false; } markDirty(region) { this.dirtyRegions.push(region); this.scheduleRender(); } scheduleRender() { if (this.isRendering) return; this.isRendering true; requestAnimationFrame(() { this.flush(); this.isRendering false; }); } flush() { // 合并脏区域按图层顺序重绘 const merged this.mergeRegions(this.dirtyRegions); this.layers.forEach(layer layer.repaint(merged)); this.dirtyRegions []; } }3. 插件架构的设计哲学与实操3.1 插件系统的依赖注入与生命周期Univer 的插件系统是整个项目最有价值的部分之一。它没有采用简单的注册-调用模式而是构建了一套完整的依赖注入容器和生命周期管理机制。每个插件在注册时需要声明自己的依赖项容器会负责按正确的顺序初始化和销毁插件。这解决了插件之间相互依赖时的初始化顺序问题——比如公式插件依赖数据模型插件数据模型插件又依赖核心服务容器会自动梳理出正确的启动顺序。插件的生命周期分为几个阶段注册register、初始化initialize、启动start、销毁dispose。在注册阶段插件向容器声明自己提供的服务和依赖的服务初始化阶段插件可以访问容器中的其他服务进行一些准备工作启动阶段插件开始监听事件、注册命令销毁阶段则负责清理资源、取消监听。这套机制保证了插件在动态加载和卸载时不会留下内存泄漏。3.2 命令系统与撤销重做的实现Univer 中所有的数据修改都必须通过命令系统来执行这是一个强制约束。为什么这么设计因为只有所有修改都走同一条路径才能统一实现撤销重做、协同同步、操作日志这些横切关注点。每个命令定义了执行execute和撤销undo两个操作命令执行后会生成一个变更集Mutation这个变更集可以被序列化、传输、回放。撤销重做的实现依赖于命令栈。每次执行命令命令对象会被压入撤销栈执行撤销时从栈中弹出命令并调用其 undo 方法同时将命令压入重做栈。在协同场景下远端传来的操作也会被包装成命令执行但不会进入本地的撤销栈——这样保证了你按 CtrlZ 只会撤销自己的操作而不会误撤销别人的修改。// 自定义命令的基本结构 const SetCellValueCommand { id: set-cell-value, execute(accessor, params) { const { unitId, row, col, value } params; const oldValue accessor.getCellValue(unitId, row, col); accessor.setCellValue(unitId, row, col, value); return { undo: () accessor.setCellValue(unitId, row, col, oldValue), redo: () accessor.setCellValue(unitId, row, col, value), }; }, };3.3 按需引入插件控制包体积Univer 的功能插件是独立发布的 npm 包你可以按需引入。比如你只需要一个只读的表格展示功能那就只引入核心包和渲染插件公式引擎、协同插件、条件格式这些统统不用装。这种设计对前端项目非常友好因为在线表格类应用的包体积往往是个敏感指标。我实测过一个最小化的只读表格场景引入univerjs/core、univerjs/sheets、univerjs/sheets-ui这几个包打包后的 gzip 体积大概在 300KB 左右。如果加上公式引擎和协同功能体积会翻倍。所以我的建议是先明确你的产品需要哪些功能然后只引入对应的插件包不要图省事直接全量引入。4. 在 Node.js 环境中跑 Univer4.1 服务端渲染表格的实际需求很多人觉得 Univer 是前端的东西跟 Node.js 没什么关系。但实际上服务端使用 Univer 的场景并不少。比如你需要批量生成报表并导出为图片或 PDF或者需要在服务端做公式计算和数据校验又或者你想在服务端预渲染表格的首屏内容来加速页面加载。这些场景下在 Node.js 中运行 Univer 就很有价值。Univer 的核心层和文档模型层是纯 TypeScript 实现的不依赖浏览器 API所以可以直接在 Node.js 中运行。但渲染层依赖 CanvasNode.js 环境没有原生的 Canvas 实现需要借助canvas这个 npm 包来提供兼容层。这个包底层是 node-canvas基于 Cairo 图形库支持大部分 Canvas 2D API。4.2 Node.js 环境准备与依赖安装在 Node.js 中使用 Univer 的渲染能力环境准备是最容易出问题的一步。首先 Node.js 版本建议用 18 LTS 或更高低版本可能在安装原生模块时遇到编译问题。安装canvas包时如果你的系统没有预装 Cairo、Pango、libjpeg、giflib 这些系统库npm 会尝试从源码编译这个过程在 Windows 上尤其容易失败。# 在 Ubuntu/Debian 上先装系统依赖 sudo apt-get install -y build-essential libcairo2-dev libpango1.0-dev \ libjpeg-dev libgif-dev librsvg2-dev # 然后再安装 canvas npm install canvas # 安装 Univer 核心包 npm install univerjs/core univerjs/sheets univerjs/sheets-formula在 macOS 上相对简单用 Homebrew 装一下依赖就行。Windows 上我建议直接用预编译的二进制包或者考虑在 WSL 里跑。如果你只是用 Univer 做公式计算和数据模型操作不涉及渲染那完全可以跳过canvas的安装直接用核心包就行。4.3 服务端公式计算的实践服务端做公式计算是我用得比较多的场景。比如用户提交了一份包含公式的表格数据你需要在服务端验证计算结果是否正确或者你需要根据模板批量计算生成报表。Univer 的公式引擎可以在 Node.js 中独立运行不需要渲染层。const { Univer, LocaleType } require(univerjs/core); const { UniverSheetsPlugin } require(univerjs/sheets); const { UniverSheetsFormulaPlugin } require(univerjs/sheets-formula); async function calculateSheet() { const univer new Univer({ locale: LocaleType.EN_US, }); univer.registerPlugin(UniverSheetsPlugin); univer.registerPlugin(UniverSheetsFormulaPlugin); const workbook univer.createUniverSheet({ id: workbook-1, sheetOrder: [sheet-1], sheets: { sheet-1: { id: sheet-1, name: Sheet1, rowCount: 100, columnCount: 20, cellData: { 0: { 0: { v: 10 }, 1: { v: 20 }, 2: { f: SUM(A1:B1) }, }, }, }, }, }); // 触发公式计算 const sheet workbook.getActiveSheet(); const value sheet.getRange(0, 2).getValue(); console.log(公式计算结果:, value); // 输出 30 }这段代码的关键点在于公式引擎需要显式注册插件后才会生效而且计算是异步的你需要等待计算完成后再读取结果。在实际项目中我建议把 Univer 实例做成单例复用避免每次计算都重新初始化那样开销太大。5. 集成过程中踩过的坑与排查思路5.1 Canvas 尺寸与设备像素比导致的模糊问题这是 Canvas 渲染最常见的问题没有之一。在高分屏devicePixelRatio 1上如果 Canvas 的 CSS 尺寸和实际像素尺寸不一致绘制出来的内容就会模糊。Univer 内部会处理这个问题但如果你自己在外层包裹了容器或者动态调整了容器尺寸就可能破坏它的计算逻辑。排查思路是这样的首先检查 Canvas 元素的width和height属性是否等于 CSS 尺寸乘以 devicePixelRatio。然后检查容器的尺寸变化是否触发了 Univer 的 resize 方法。我遇到过一次是因为容器用了 flex 布局初始化时容器宽度为 0Univer 按 0 宽度计算了 Canvas 尺寸后续容器撑开后没有触发重新计算。解决办法是在容器尺寸稳定后再初始化 Univer或者手动调用 resize。5.2 公式计算不生效的几种原因公式不生效这个问题我遇到过至少三种不同的原因。第一种是插件没注册公式引擎是独立插件不注册就不会计算。第二种是单元格的数据结构不对公式必须放在f字段里而不是v字段。第三种是依赖链没有触发重算比如你通过直接修改数据模型的方式改了 A1 的值但没有通过命令系统公式引擎不知道数据变了就不会重算 C1 的公式结果。记住一个原则在 Univer 中任何数据修改都应该通过命令系统执行。直接操作数据模型虽然能改数据但会绕过公式重算、协同同步、撤销重做等一系列机制后患无穷。5.3 协同场景下的冲突处理经验协同编辑的冲突处理是个深坑。Univer 的协同插件提供了 OTOperational Transformation和 CRDT 两种方案的接口具体用哪种取决于你的后端实现。我用的是 OT 方案核心思路是本地操作立即执行并发送到服务端服务端做冲突消解后广播给其他客户端其他客户端收到后执行变换后的操作。实际使用中最大的坑是操作的原子性。比如用户一次性粘贴了一大片数据这应该被视为一个原子操作要么全部成功要么全部失败。如果拆成多个命令逐个发送在网络抖动的情况下可能出现部分成功部分失败导致各端数据不一致。Univer 的命令系统支持把多个操作合并成一个事务这一点在协同场景下必须用好。6. 性能调优的实战经验6.1 大数据量下的渲染优化策略当表格数据量超过一万行时渲染性能会成为瓶颈。除了 Univer 自带的虚拟滚动你还可以做几件事来优化。第一是冻结行列把表头和数据区域分开渲染滚动时表头不动减少重绘范围。第二是按需计算公式引擎默认会计算所有公式如果表格里有大量复杂公式但你只需要展示部分区域可以配置延迟计算。第三是降低渲染精度在快速滚动时用简化的绘制指令滚动停止后再做精细渲染。我实测过一个五万行、三十列的表格在默认配置下滚动帧率大概在 40fps 左右开启冻结行列和延迟计算后能稳定在 55fps 以上。当然这跟具体的数据内容和浏览器环境有关仅供参考。6.2 内存占用与垃圾回收长时间运行的表格应用容易遇到内存持续增长的问题。主要来源有几个撤销栈无限增长、事件监听器未清理、Canvas 离屏缓存未释放。Univer 的撤销栈默认有上限但你可以根据业务需要调整。事件监听器方面插件销毁时一定要取消所有监听否则组件卸载后监听器还在导致内存泄漏。Canvas 的离屏缓存是另一个容易被忽视的点。Univer 为了加速渲染会缓存一些常用的绘制结果比如单元格的背景色块。如果表格数据频繁变化这些缓存会不断累积。我的做法是定期检查缓存命中率如果命中率低于某个阈值就主动清理缓存避免内存无限增长。6.3 首屏加载时间的压缩首屏加载时间直接影响用户体验。Univer 的包体积不算小如果全量引入首屏加载可能要好几秒。优化手段包括按需引入插件、用动态导入延迟加载非核心功能、把公式引擎的初始化放到 Web Worker 里避免阻塞主线程、预先把表格的初始数据序列化好直接注入避免初始化后再逐条写入。还有一个技巧是分阶段渲染先渲染表格的骨架网格线和表头让用户看到界面已经出来了然后再异步加载数据和渲染内容。这样用户感知到的加载时间会短很多虽然实际总时间没变但体验上好了不少。7. 我对 Univer 的一些个人判断用了一段时间 Univer 之后我最大的感受是它的架构设计确实下了功夫尤其是插件系统和命令系统这两块解决了很多在线表格开发中的经典难题。但它也不是银弹学习曲线比较陡文档虽然不算差但很多细节需要看源码才能搞清楚。如果你只是想做一个简单的数据展示表格用 Univer 可能有点杀鸡用牛刀但如果你要做的是一个功能完整、需要长期演进的在线表格产品那它的架构优势会逐渐体现出来。另外一点体会是Univer 的社区还在成长中遇到问题的时候GitHub 上的 issue 和讨论区往往比官方文档更有用。我在排查一个渲染闪烁的问题时就是在 issue 里找到了别人分享的解决方案。所以建议在使用过程中养成翻 issue 的习惯很多坑别人已经踩过了。
企业数字化 ERP 产品动态
相关推荐
测试用例设计:边界值分析与等价类划分实战 1. 测试用例设计基础概念解析 在软件测试领域,测试用例设计是最核心的技能之一。一个优秀的测试工程师不仅需要掌握各种测试方法,更需要理解如何将这些方法应用到实际项目中。边界值分析法和等价类划分法就像测试工程师的"左右手",… · 2026/11/3 15:51:45
小红书笔记详情API开发实战与优化策略 1. 项目背景与核心价值 在小红书平台内容生态快速发展的当下,第三方开发者对平台数据的合法获取与深度应用需求日益增长。笔记详情API作为连接平台内容与外部应用的关键通道,其技术实现与合规使用直接影响着数据应用的广度与深度。本文将基于实际项目经验… · 2026/11/3 15:51:45
华为OD机考:或门电路出错概率的位运算解法 1. 项目背景与需求解析 这个题目来自华为OD(Online Judge)机考中的双机位监控环境下的C卷考题,主要考察应聘者在受限环境下的逻辑思维能力和Java编程基本功。题目名为"出错的或电路",看似简单却暗藏玄机——它实际上是一… · 2026/11/3 15:51:45
C++中struct与class的本质区别与工程实践 1. 结构体与类的本质探析在C的语法体系中,struct和class就像一对孪生兄弟,表面相似却各有性格。作为从C语言继承而来的语法结构,struct在C中被赋予了全新的内涵。记得我刚接触C时,曾天真地认为struct只是C语言中那个简单的数据打包… · 2026/11/3 18:03:32
内网私有化部署DeepSeek:打造能严苛验证算法的智能音箱 去年年底我把家里的智能音箱从厂商云端切换到了内网私有化部署的 DeepSeek 上。不是简单换一个问答后台,而是做了一个能进行“严苛算法求证”的语音枢纽:语音识别、大模型推理、语音合成全部跑在家庭内网,断外网也能正常工作,数据… · 2026/11/3 18:03:32
2026 AI专业生存指南:Python与大模型应用开发全栈工具清单 2026年了,如果你正准备进入AI专业,或者已经在读大一大二,应该已经感觉到了:学校里教的东西和行业里真正用的东西,中间隔着的不是一两年,而是整整一个技术周期。我这个标题里列了Python和大模型应用… · 2026/11/3 18:03:32
深度学习在服装推荐系统中的应用与实践 1. 项目背景与核心价值服装推荐系统是近年来电商平台和时尚科技领域的热门研究方向。传统基于规则的推荐方式往往存在个性化程度低、更新滞后等问题。这个毕业设计项目通过引入深度学习技术,构建了一套能够自动学习用户偏好与服装特征的智能推荐系统。我在实际开发中… · 2026/11/3 18:03:32
拟贝叶斯方法与STFT结合的非平稳信号处理技术 1. 拟贝叶斯方法与STFT结合的背景与价值在信号处理领域,非平稳信号的分析一直是个棘手的问题。这类信号的特点是统计特性随时间变化,传统的傅里叶变换难以捕捉其瞬时特征。我在处理生物医学信号时就深有体会——心电图、脑电图这些信号往往包含多个随时间… · 2026/11/3 18:03:32
C语言数据结构选择与查找算法优化实践 1. 题目背景与需求解析这道PTA(程序设计能力考试)题目考察的是C语言基础编程能力,属于典型的"信息查询"类问题。题目要求我们根据输入的考生信息,在给定准考证号时快速输出对应的考试座位号。在实际考试系统中ÿ… · 2026/11/3 18:02:31
双极步进电机控制实战:DRV8818PWPR与PIC18F86K22驱动调试 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/2 13:10:28
STM32F207ZG + DRV8818PWPR 步进电机驱动实战解析 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/3 6:04:24
DRV8818PWPR+dsPIC30F4013工业级双极步进电机控制方案 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/3 5:52:46
STM32F411RE与MAX14431实现SPI隔离:工业现场抗共模干扰实战 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/3 1:01:59
ESP32+大模型AI硬件开发:从Demo到产品的8个工程问题 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/3 1:01:59
基于MAX14431BAEE+与PIC18F85J10的SPI隔离方案设计与实践 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/3 1:01:59
双极步进电机控制实战:DRV8818PWPR与PIC18F86K22驱动调试 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/2 13:10:28
STM32F207ZG + DRV8818PWPR 步进电机驱动实战解析 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/3 6:04:24
DRV8818PWPR+dsPIC30F4013工业级双极步进电机控制方案 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/3 5:52:46