news 2026/9/16 4:16:22

前端导出Excel不卡顿:从SheetJS到Web Worker的进度条实战方案

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
前端导出Excel不卡顿:从SheetJS到Web Worker的进度条实战方案

做后台系统的前端,基本都逃不掉"导出Excel"这个需求。一开始大家都觉得轻松,丢个接口,拿个blob,下载完事。直到真实业务里遇到5万条、甚至20万条数据的导出,你会发现事情没那么简单:后端提前下班了让你自己拼数据,或者数据量一大浏览器直接卡死弹"无响应",又或者用户盯着屏幕等了几十秒,完全不知道到底在不在跑。这篇就聊聊我在实际项目里攒下来的方案,核心解决两件事:一是xlsx文件怎么在浏览器里正确生成和下载,二是导出过程中的进度条怎么做得既真实、又不伤害页面性能。适合后台管理系统、数据中台、报表平台这类场景的前端同学参考,也欢迎被"导出卡死"气到头秃的朋友来抄作业。

1. 用户感知的"卡死"和服务端的"不配合":这个需求真实长什么样

1.1 从一条真实需求说起

我接到过一个很典型的工单,业务方原话是:"导出报表,点完按钮没有任何反应,点了好几下也没反应,过了几分钟突然蹦出来下载。"

这个工单描述其实很准确,因为当时的实现确实什么都没做——前端调一个接口,等后端把文件流吐回来。问题是这张报表关联了十几张表,后端要按照筛选条件现算,跑一次要好几十秒。用户点完按钮之后,页面没有任何反馈,看起来就是"死掉了"。

后来我把这个工单拆开来看,里面其实藏着两个完全不同的需求点:

  • 导出这个动作本身要有"确定性反馈"——用户点了之后立刻知道"在跑",而且能看出跑动的进展。
  • 导出过程不能阻塞页面操作——用户等待的时候还能去干别的,至少页面不能卡成白屏。

这两个点,纯后端生成加一个loading转圈,解决不了;前端直接把几万条数据一次性塞进Excel,也解决不了。必须把导出流程拆成若干阶段,每个阶段有可观测的进度,再把计算压力从主线程挪走。

1.2 为什么进度条会成为导出功能的硬指标

很多产品经理提需求时只会说"加个进度条",但如果直接把一个<div>加上宽度动画糊弄上去,用户很快会发现:进度条卡在90%半天不动,或者瞬间从0跳到100%。这种"假进度"反而更让人焦虑。

真实工程里,进度条的本质是把导出流程中每一个耗时的环节显性化。一个完整的导出链路通常长这样:

  1. 收集筛选条件和表头配置。
  2. 向服务端请求数据(如果是分页接口,则需要循环拉取多页)。
  3. 对拿到的原始数据做清洗、映射、字段格式化。
  4. 组装成二维数组或JSON数组,写入xlsx。
  5. 把生成的二进制流触发浏览器下载。

任何一个环节超过1秒,都要有进度反馈。所以我的结论是:进度条不是UI问题,而是流程拆解和异步调度问题。先想清楚流程分几段,再谈进度条长什么样。

2. 技术方案取舍:前端生成、后端生成还是前后端配合

2.1 SheetJS、ExcelJS 与纯后端方案的适用边界

先说结论:没有银弹,不同数据量级走不同的路。

场景数据量推荐方案原因
前端已有完整数据,几千到几万行1万行以内前端SheetJS直接生成省一次接口往返,体验最快
后端分页接口,数据量中等1万到10万行前端循环拉取+分片生成后端改动少,进度可控
超大报表,几十万行以上10万行以上后端异步生成,前端轮询进度浏览器内存根本扛不住
需要复杂样式、合并单元格、多级表头任何量级ExcelJS或后端POI/AsposeSheetJS社区版写样式能力约等于零

SheetJS社区版(npm包名xlsx)是很多前端项目里最常见的选择,它的API极度简单,json_to_sheet一把梭,适合快速交付。但它有两个明显的短板:

  • 社区版不能写样式,字体、背景色、列宽这种东西统统别想。想加样式得买专业版,或者换ExcelJS。
  • 大数据量写入时会一次性占大量内存。10万行可能在你的本地开发机勉强能跑,在用户的老机器上直接就崩了。

ExcelJS的优势是样式能力和单元格颗粒度控制,缺点也很直接:包体积大、API繁琐、写入性能比SheetJS更慢。我之前试过用ExcelJS写8万行,内存峰值直接逼近1GB,只能在场景确实需要"花里胡哨"的Excel时才用它。

至于纯后端方案,最大的优势是利用服务器资源和Content-Disposition: attachment直接输出文件,干净利落。但问题在于:你没有中间态可观测。要么让后端在做接口时加一个任务队列,前端轮询任务状态;要么就得忍受几十秒的无反馈。所以现在很多中后台项目的导出架构,其实是"后端任务化+前端轮询进度条"的组合拳。

2.2 我是怎么快速判断用哪条路的

我在接需求时,第一句话不是问"你们要什么格式",而是先问"最大数据量多少条"。

  • 如果对方说"也就几千条",那前端SheetJS直接干,半天能交付。
  • 如果对方支支吾吾说"可能几万吧",我会去翻接口定义和数据表行数,估算极端情况。
  • 如果对方说"全量数据都要导",那劝你别在前端死磕,趁早拉后端一起改造,做成任务式导出。

另外还有一个非常容易被忽略的判断维度:导出数据是用户当前页面上已经加载的,还是需要重新从服务端查一遍

如果数据已经在前端内存里(比如当前表格绑定了全量数据),前端生成是最快的路,能省一个接口。如果数据不在前端,老老实实考虑拉接口,这时候进度条要和"请求进度"挂钩,而不是和"文件生成"挂钩。这两者搞混了,进度条就会显得很失真。

3. 前端生成本体:SheetJS 的完整用法与可选优化

3.1 最朴素的导出代码,先跑通

不管多复杂的方案,第一步永远是先把最简单的导出跑通。先装依赖:

npm install xlsx

然后在业务组件里引入:

import * as XLSX from 'xlsx'; function simpleExport(data, fileName = '导出数据.xlsx') { // 将 JSON 数组转换成工作表对象 const worksheet = XLSX.utils.json_to_sheet(data); // 创建 workbook 并追加 sheet const workbook = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(workbook, worksheet, '数据'); // 写入文件并触发下载 XLSX.writeFile(workbook, fileName); }

这段代码看起来是不是太简单了?确实,导出xlsx本身在API层面就是三行的事。但工程上真正的难点从来不在API,而在它周围的数据处理量和渲染帧调度

json_to_sheet时,每个对象的key会自动变成表头,value直接进单元格。如果字段顺序有要求,更好的做法是先映射成数组:

const rows = data.map((item) => ({ '姓名': item.name, '手机号': item.mobile, '创建时间': formatDate(item.createdAt), }));

因为json_to_sheet是按对象key的顺序来生成列的,如果你希望表头顺序稳定,又不信任后端字段顺序,最好在map阶段就手动构建好目标形态。这一步还可以顺手做字段格式化、状态码映射、过滤空值,比导出后再处理干净得多。

3.2 常见"卡界面"的根因

很多同学写完上面的代码,在数据量上来之后发现页面卡死,然后开始怀疑SheetJS的写入性能。但实际上,卡顿往往不发生在XLSX.writeFile这一步,而在前面的数据映射和JSON序列化

举个例子,你有5万行原始数据,每行有30个字段。map里做一个日期格式化、一个状态码映射。这一趟下来,JavaScript要创建5万个新对象,还要跑一堆字符串函数。这个过程的耗时可能比json_to_sheet本身还高,而且它发生在主线程,用户能感知到的就是"点击导出后页面冻住了"。

所以在写导出功能时,我把处理管线拆成了两段:

  • 第一段:清洗和映射,可能非常耗时。
  • 第二段:生成sheet并写文件,性能相对可控。

如果第一段耗时超过几百毫秒,就必须考虑分片。分片的核心思想是:把5万行数据切成50片,每片1000行,处理完一片之后把控制权还给浏览器,让它可以刷新进度条、响应点击事件,然后继续处理下一片。

4. 真实进度条的设计思路:分片、权重与UI刷新

4.1 进度条到底该"算"进度,而不是"感觉"进度

很多前端看到"进度条"第一反应是写一个setInterval,每100毫秒把宽度加一点,到99%停住,等导出完成再跳到100%。这在文件下载完成后确实够用,但如果导出过程可能失败、可能超时,一个假的百分比反而会把用户误导到"还差1%就成功了"的期望里,然后眼睁睁看着它卡死在99%。

真实的进度条,一定要绑定具体的可计算节点。我的习惯是把导出过程拆成这样:

  • 拉取数据阶段:权重60%。因为这一阶段通常是网络IO,最慢。
  • 数据清洗阶段:权重20%。纯计算,快慢取决于数据量。
  • 生成文件阶段:权重15%。底层写入。
  • 触发下载:权重5%。瞬时完成。

这样进度条就可以按阶段推进,每个阶段内部再做粒度更细的进度计算。比如拉取数据阶段如果后端给了分页接口,接口一共20页,每成功返回1页就加3%(60%除以20);如果后端是一个一次性接口,那这个阶段就只能有"请求中"和"请求完成"两种状态,无法细分——这种情况下我会把权重压缩,告诉用户"正在读取数据",而不是硬给一个数字。

4.2 分片处理让出主线程

解决了"进度怎么算"之后,下一个问题就是"怎么让进度条真正动起来"。答案是:不能一口气处理完所有数据再更新UI,必须分片处理,每片处理完停一下,让浏览器有机会渲染进度条

来看一个我实际封装过的分片处理示例:

async function processInChunks(rawList, chunkSize = 1000, onProgress) { const result = []; const total = rawList.length; for (let start = 0; start < total; start += chunkSize) { const end = Math.min(start + chunkSize, total); const chunk = rawList.slice(start, end); // 实际业务处理:字段映射、格式化、过滤 const mapped = chunk.map(normalizeRow); result.push(...mapped); const percent = Math.round((end / total) * 100); onProgress(percent); // 如果总数据量较大,主动让出主线程 if (total > 20000) { await new Promise((resolve) => setTimeout(resolve, 0)); } } return result; }

await new Promise(resolve => setTimeout(resolve, 0))这一行看起来像玄学,实际意义是:把当前任务排在浏览器渲染任务的后面。这样每次处理完一批数据,浏览器就能插空绘制一次,用户看到的进度条才会连续地动起来,而不是一卡一卡地跳。

这里有个性能细节需要提醒:setTimeout的执行间隔并不是0毫秒,浏览器通常会把嵌套的定时器最小间隔限制在4毫秒左右。如果分片数特别多(比如10万行分成了100片),光让出线程的时间就有400毫秒。所以分片大小要权衡,我一般取1000到2000行一片,既能保证UI有一定刷新率,又不至于因为频繁让出主线程拖慢整体速度。

4.3 进度UI更新的节流与避坑

进度条动起来之后,又会出现新问题:状态更新太频繁,React或Vue的setState往组件里塞了太多更新任务

我这个踩过坑。一开始我在onProgress回调里直接setProgress(percent),分片是1000行一片,进度数字一秒跳几十次,页面反而因为频繁渲染而卡顿。后来改成用requestAnimationFrame做节流:进度值先存到一个ref里,然后等浏览器下一帧再统一刷到界面上。

function createProgressUpdater(onUpdate) { let latestProgress = 0; let rafId = null; return function (percent) { latestProgress = percent; if (rafId !== null) return; rafId = requestAnimationFrame(() => { onUpdate(latestProgress); rafId = null; }); }; }

这样无论进度回调被调用多频繁,真正渲染到界面的频率始终和浏览器帧率对齐。这个方案在React 18和Vue 3里都适用,核心是"渲染交给浏览器调度,业务只负责上报最新值"。

另外一个容易踩的坑是:导出完成后进度条闪一下100%然后立刻消失。用户还没看清"导出成功"就没了,体验很差。我的处理是:到100%之后不马上隐藏,至少停留300到500毫秒,再用"下载已开始"的toast提示,给用户一个明确的收尾反馈。

5. 数据量再大一点:Web Worker 后台生成与进度回传

5.1 为什么数据量上来后主线程方案会失效

分片+让出主线程的方案,在5万行以内体验不错,但到了10万行以上就有些捉襟见肘了。

原因有两个:

  • 主线程内存压力。10万行数据经过映射之后,会产生几十万个JS对象,再加上SheetJS内部拷贝一次,内存峰值可能到几百MB。老一点电脑的浏览器进程会直接崩溃或弹"Aw, Snap!"。
  • 主线程任务再碎也还是主线程。用户如果去滚动页面、输入筛选条件,依然有明显的卡顿感,因为大部分时间还是被数据处理占着。

这个阶段,正确的姿势是把生成xlsx的整套逻辑塞进Web Worker

Worker里的逻辑帮我做完了这些事:

  • 在独立的线程里做数据清洗、分片、调用SheetJS生成文件。
  • 每处理完一片,通过postMessage向主线程回传进度。
  • 主线程只做两件事:更新进度条、接收最终的ArrayBuffer并触发下载。

这样一来,不管数据量多大,主线程都轻得像在度假——进度条丝滑,页面随便点,用户完全不会被导出任务绑架。

5.2 Worker 方案的完整骨架代码

先写worker文件,export-worker.js

// 在 Worker 里引入 SheetJS full 版本 importScripts('https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js'); self.onmessage = function (e) { const { rawList, chunkSize = 2000, fileName } = e.data; const total = rawList.length; const allRows = []; for (let start = 0; start < total; start += chunkSize) { const end = Math.min(start + chunkSize, total); const chunk = rawList.slice(start, end); const mapped = chunk.map(normalizeRow); allRows.push(...mapped); self.postMessage({ type: 'progress', percent: Math.round((end / total) * 100), }); } // 生成 sheet 和 workbook const worksheet = XLSX.utils.json_to_sheet(allRows); const workbook = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(workbook, worksheet, '数据'); // 生成二进制Buffer const buffer = XLSX.write(workbook, { bookType: 'xlsx', type: 'array' }); self.postMessage({ type: 'done', buffer, fileName, }); }; function normalizeRow(item) { // 这里的实际业务映射逻辑 return { '姓名': item.name, '手机号': item.mobile, '状态': item.status === 1 ? '启用' : '停用', }; }

主线程这边创建Worker并监听消息:

function exportWithWorker(rawList, fileName = '报表.xlsx') { return new Promise((resolve, reject) => { const worker = new Worker('/export-worker.js'); worker.onmessage = (e) => { const { type, percent, buffer, fileName: name } = e.data; if (type === 'progress') { updateProgress(percent); } else if (type === 'done') { // 接收 ArrayBuffer 并触发下载 const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = name; a.click(); URL.revokeObjectURL(url); worker.terminate(); resolve(); } }; worker.onerror = (err) => { worker.terminate(); reject(err); }; worker.postMessage({ rawList, fileName }); }); }

注意一个细节:我用XLSX.write而不是XLSX.writeFile,因为Worker线程里没有DOM,writeFile在Worker中无法触发浏览器的下载行为。正确做法是在Worker里生成ArrayBuffer,回传到主线程,再手动转成Blob并创建<a>标签下载。

importScripts引入CDN的xlsx包虽然方便,但生产环境还是建议把xlsx打成本地静态资源,避免CDN挂掉导致导出功能雪崩。而且要注意:importScripts是Worker全局函数,只能在Worker环境里用,别在主线程代码里写。

6. 实测踩坑:xlsx is not defined、科学计数法、文件损坏这些天坑

6.1 模块导入方式导致的 xlsx is not defined

"xlsx is not defined"是搜索热度很高的报错。我排查过几个项目,发现多数情况是模块引入姿势不对。

SheetJS这个包比较老,历史包袱重。在ES Module项目里正确写法是:

import * as XLSX from 'xlsx';

如果写成import XLSX from 'xlsx',在某些构建配置下也能用(因为包做了兼容导出),但在Vite + ESM环境下偶尔会拿到undefined默认导出,调用XLSX.utils时直接报错。

另一个常见场景是通过CDN的<script>标签引入:

<script src="https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js"></script>

这时候全局变量叫XLSX,但你如果在模块化代码里访问XLSX会得到undefined,因为ES模块的作用域隔离。解决办法是显式挂到window上:

const XLSX = window.XLSX;

或者索性就别用CDN,统一走npm包,少踩一个坑。

6.2 身份证号、长数字变科学计数法

这是导出功能里最经典的需求陷阱。Excel默认对超过11位的数字会显示成科学计数法,身份证号、交易流水号这类字段一旦直接用数字类型写进单元格,用户打开文件看到的是一串"8.2034E+17",直接把数据搞废了。

解决方案是在映射阶段就把这类字段转成字符串:

const rows = data.map((item) => ({ '身份证号': String(item.idCard), // 强制转字符串 '金额': Number(item.amount).toFixed(2), }));

不过这里有个让人血压升高的点:item.idCard如果是后端返回的Number类型,超过Number.MAX_SAFE_INTEGER(9007199254740991)的时候精度已经丢失了,前端String()救不回来。这种字段必须要求后端在接口返回时就用字符串类型。我在实际项目中遇到过数据库存的是int类型、接口返回数字、前端转字符串后发现末几位变成0的惨案,最终只能让后端改接口,前端再做一层兜底校验。

6.3 生成文件损坏或打不开

文件损坏的常见原因之一,是用老版本xlsx库生成文件时,单元格内容里包含非法字符。比如数据中带上了不可见的控制字符、特殊Unicode字符,或者某个字段的值是以=开头的字符串,Excel的CSV注入防护会让人误以为文件有问题。

我处理这类问题有两个习惯:

  • 对单元格文本做一次清洗,过滤掉\x00这类控制字符。
  • 对于以= + - @开头的文本字段,统一加一个前导空格或单引号,防止Excel执行公式注入。
function safeText(value) { if (typeof value !== 'string') return value; // 去掉控制字符,并防止公式注入 return value .replace(/[\u0000-\u001F]/g, '') .replace(/^([=+\-@])/, "'$1"); }

另一个文件打不开的原因,是XLSX.writetype参数设置错误。在浏览器环境用'array''binary'都行,但如果你在Node环境服务端生成,要用'buffer'。这个参数选错,生成的Blob可能字节不对,下载下来就是损坏文件。

6.4 大文件导出时的内存与下载姿势

最后一个坑是关于下载方式的。几万行的xlsx文件有十几MB很正常,用URL.createObjectURL生成下载链接没问题,但创建完<a>标签并click()之后,记得调用URL.revokeObjectURL释放对象URL。不释放的话,连续导出几次,浏览器的内存占用会肉眼可见地飙升。

另外提醒一下:有的浏览器对自动下载是有限制的。如果用户没有手动交互,多步操作后触发的a.click()可能被拦截。我遇到过在异步回调里创建下载链接被Chrome静默拦截,用户点了没反应。解决办法是在用户点击导出时先弹一个"正在生成"的模态框(这个模态框的关闭可以再触发一次下载),或者用window.open(url)让浏览器更"信任"这次下载。

还有一个我自己常用的兜底:导出前检查文件大小。如果Blob大于50MB,就弹个提示告诉用户改用分sheet或过滤条件导出。这不是偷懒,是因为超过这个量级的xlsx,用户打开Excel也会卡,体验并不好。

7. 一个更省心的变体:后端任务化 + 前端轮询进度

前面讲的主要是前端自己生成。但如果你的项目后端资源充足,我更推荐一种省心的架构:后端把导出变成异步任务,生成完文件后把地址存起来,前端轮询任务状态来更新进度条

这个方案的好处是:无论数据量多大、报表多复杂,前端都只做一件事——轮询接口,然后更新进度条。你甚至不需要知道后端用什么生成的,文件可能已经在服务器的临时目录或对象存储里躺好了。

实现上,前端只需要一个简单的轮询函数:

async function pollTask(taskId, interval = 2000) { while (true) { const res = await fetch(`/api/export/task/${taskId}`); const data = await res.json(); if (data.status === 'success') { return data.fileUrl; } if (data.status === 'failed') { throw new Error(data.message || '导出失败'); } updateProgress(data.progress); await new Promise((resolve) => setTimeout(resolve, interval)); } }

当然这个方案依赖后端愿意配合,加任务表、任务状态接口、文件清理都是工作量。我在实际项目中遇到的情况是:后端觉得"导出而已,你前端同步刷一下就行",直到我被卡死页面的工单淹没了,后端看了用户投诉才愿意改造。所以如果你有这个话语权,走任务化方案最稳。

如果后端实在改不动,那还是用前面Worker那套方案,至少能保住前端体验的底线。我个人现在的选择是:5万行以下前端Worker方案,5万行以上尽量推动后端任务化。这样既能在小项目里快速交付,又能在大数据量场景下不翻车。

最后再分享一个小技巧。无论用哪种方案,都应该在导出按钮旁边标注数据量级,或者干脆做一次前置校验:如果前端一次性要处理的条数超过某个阈值,提前弹确认框"当前导出约12万条数据,预计需要1-3分钟"。这个小小的交互改动,可以挡掉一大半"导出没反应"的投诉——因为用户被提前告知了,就不会以为是自己点错了。前端导出xlsx这件事,技术本身不复杂,复杂的是怎么让用户在整个过程中保持"有掌控感",进度条只是实现这个掌控感的一种载体。

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

Spring Boot+Vue养老院管理系统源码解析与实战

简介&#xff1a;基于springbootvue的养老院管理系统源码包&#xff0c;面向正在准备毕业设计或课程设计的计算机专业学生&#xff0c;也是一套适合Java学习者实战练习的完整项目。项目已通过导师指导&#xff0c;包含前端Vue页面、后端Spring Boot接口、数据库脚本及项目说明文…

作者头像 李华
网站建设 2026/9/16 4:13:48

Ubuntu Server + KVM 虚拟化实战:AMD Ryzen迷你主机运行Windows 10虚拟机

手头这台机器我折腾了一个多星期——AMD Ryzen AI Max 395 的迷你主机&#xff0c;装了 Ubuntu Server 24.04&#xff0c;跑 Windows 10 虚拟机。整个过程踩了不少坑&#xff0c;但最后稳定运行下来&#xff0c;说实话&#xff0c;这套组合比我想象中靠谱得多。如果你正打算用一…

作者头像 李华
网站建设 2026/9/16 4:12:35

端侧Agent实战:MiniCPM5-2B部署与Function Calling避坑指南

如果你手头只有一台普通配置的笔记本&#xff0c;或者一块 8GB 内存的树莓派&#xff0c;也想跑一个能自己查天气、调用计算器、甚至控制家里智能设备的 Agent&#xff0c;那 MiniCPM5-2B 应该是最值得先试的模型之一。这个 2B 参数规模的端侧模型&#xff0c;在工具调用上做了…

作者头像 李华
网站建设 2026/9/16 4:11:28

AI智能体实操路线图:4阶段8课时避开90%新手陷阱

1. 这不是又一门“AI速成课”&#xff0c;而是一张避开90%新手陷阱的实操路线图“AI智能体怎么学才不踩坑&#xff1f;”——这句话最近在技术社群、知识付费圈和职场学习群被反复刷屏。不是因为大家突然对“智能体”这个词产生了学术兴趣&#xff0c;而是真实需求压过来&#…

作者头像 李华
网站建设 2026/9/16 4:09:45

FPGA中DA分布式算法实现FIR滤波器,用查找表替代乘法器

简介&#xff1a;DA分布式FIR滤波器基于Verilog硬件描述语言实现&#xff0c;在Xilinx Vivado 2019.2中完成开发&#xff0c;采用纯Verilog设计、不依赖特定IP核&#xff0c;可方便移植到Quartus II或ISE环境&#xff0c;适合FPGA开发者与数字信号处理工程师学习高性能滤波器实…

作者头像 李华
网站建设 2026/9/16 4:09:28

嵌入式固件下载与OTA升级全链路故障排查指南

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

作者头像 李华