Web Storage配额超限:从QuotaExceededError到完整存储治理方案
1. 从一次线上故障说起为什么浏览器存储会“爆仓”那天下午我正喝着咖啡突然收到一条紧急告警。一个核心的H5活动页面用户反馈点击按钮后页面直接白屏控制台一片猩红的错误。我赶紧打开开发者工具映入眼帘的是一个熟悉的错误QuotaExceededError: The quota has been exceeded.源头指向一个localStorage.setItem的调用。是的我们那个为了“优化”用户体验把所有用户行为日志、临时配置、甚至部分图片的Base64编码都往localStorage里塞的方案终于迎来了它的“容量审判日”。这绝不是个例。随着前端应用越来越复杂单页应用SPA大行其道localStorage和sessionStorage我们统称为 Web Storage因其简单易用的同步API成了前端开发者存储数据的“万能口袋”。从记住用户主题偏好到缓存接口数据再到保存未提交的表单草稿似乎什么都能往里装。但很多人包括曾经的我都忽略了一个根本问题这个口袋是有大小限制的。当它存满时会发生什么是默默失败还是引发灾难今天我们就来彻底拆解这个问题并分享一套从预防到治理的完整实战方案。2. 容量天花板Web Storage 的配额机制深度解析要理解存满的后果首先得清楚它的容量限制到底是怎么回事。这可不是一个简单的固定数字。2.1 标准限制与浏览器实现的差异根据 W3C 的 Web Storage 标准浏览器应为每个源origin即协议域名端口提供至少5MB的存储空间。注意这里是“至少”意味着浏览器可以提供更多但绝不能少于这个数。然而现实总是比标准复杂。不同浏览器甚至同一浏览器的不同模式实现都有差异桌面端 Chrome/Firefox/Edge通常严格遵循 5MB约 500万字符每个字符按2字节估算。你可以通过写入大量数据直到报错来实测一般就在 5MB 左右。移动端 Safari (iOS)这是一个著名的“坑点”。在私有浏览模式下localStorage虽然可用但容量极低可能只有几MB并且写入次数也有限制很容易触发配额错误。即使在普通模式下其行为也可能更保守。Internet Explorer老版本的IE如IE8甚至只有 2.5MB 左右。注意这里的 5MB 是针对字符串的。当你存入一个数字或对象时它们会被自动转换成字符串。例如JSON.stringify一个大型对象产生的字符串长度就是你要占用的空间。2.2localStorage与sessionStorage的配额是共享的吗这是一个关键问题。答案是对于同一个源localStorage和sessionStorage共享这大约 5MB 的配额上限。你可以这样理解浏览器为https://www.example.com这个源分配了一个大约 5MB 的“存储保险柜”。这个柜子里有两个隔间一个标签叫localStorage永久存储另一个标签叫sessionStorage会话存储。但这两个隔间加起来的总体积不能超过保险柜的总容量5MB。如果你在localStorage里存了 4MB 数据那么sessionStorage最多就只能再存约 1MB 数据反之亦然。2.3 超出配额时的精确行为抛出QuotaExceededError当你的代码尝试执行setItem(key, value)而此次操作会导致该源的 Web Storage 总使用量超过配额时浏览器会同步地抛出一个DOMException其name属性为QuotaExceededError。关键特性同步错误这是一个立即抛出的、会中断当前 JavaScript 执行流的错误。如果你不用try...catch包裹它就会导致未捕获的异常这就是我开头遇到的页面白屏的元凶。原子性整个setItem操作是原子的。如果超限不仅新数据存不进去原有存储的数据也不会发生任何改变。不会出现存了一半把旧数据挤掉一部分的混乱情况。错误对象你可以捕获到这个错误并进行处理。try { localStorage.setItem(myLargeKey, hugeDataString); } catch (e) { if (e.name QuotaExceededError) { console.error(存储空间已满, e.message); // 这里执行你的降级或清理逻辑 } else { // 其他类型的错误如 SecurityError禁用或跨域访问 throw e; } }3. 超越简单捕获一套完整的存储治理策略仅仅用try...catch包裹是不够的这是一种被动的、事后补救的策略。我们应该建立一套主动的、预防性的存储治理体系。3.1 策略一主动监控与预警在应用初始化或定期任务中实现一个存储健康度检查函数。/** * 检查当前源的Web Storage使用情况 * returns {Object} 包含使用量、百分比和健康状态 */ function checkStorageHealth() { const totalSpace 5 * 1024 * 1024; // 假设5MB let used 0; // 计算localStorage使用量 for (let i 0; i localStorage.length; i) { const key localStorage.key(i); const value localStorage.getItem(key); // 精确计算键和值的长度总和每个字符2字节UTF-16 used (key.length value.length) * 2; } // 计算sessionStorage使用量方法同上略 // for (let i 0; i sessionStorage.length; i) { ... } const usedPercentage (used / totalSpace) * 100; let status healthy; if (usedPercentage 90) status critical; else if (usedPercentage 70) status warning; return { usedBytes: used, totalBytes: totalSpace, usedPercentage: usedPercentage.toFixed(2), status: status }; } // 定期检查或在每次重大存储操作前检查 const health checkStorageHealth(); if (health.status critical) { console.warn(存储空间严重不足触发自动清理); triggerCleanupRoutine(); }3.2 策略二实现LRU最近最少使用自动清理机制对于缓存类数据如接口响应实现一个LRU缓存层是最高效的做法。这里提供一个简化版的LocalStorageCache类class LocalStorageCache { constructor(namespace cache, maxItems 50) { this.namespace namespace; this.maxItems maxItems; this._init(); } _init() { // 读取索引存储结构{ keys: [key1, key2], timestamps: { key1: 1648886400000 } } const meta this._getMeta(); if (!meta) { this._setMeta({ keys: [], timestamps: {} }); } } _getMeta() { const metaStr localStorage.getItem(${this.namespace}::meta); return metaStr ? JSON.parse(metaStr) : null; } _setMeta(meta) { localStorage.setItem(${this.namespace}::meta, JSON.stringify(meta)); } set(key, value, ttl 3600000) { // ttl默认1小时 const meta this._getMeta(); const fullKey ${this.namespace}::${key}; // 1. 如果key已存在更新其时间戳和位置 if (meta.timestamps[fullKey]) { meta.timestamps[fullKey] Date.now(); // 移到keys数组末尾表示最近使用 const keyIndex meta.keys.indexOf(fullKey); meta.keys.splice(keyIndex, 1); meta.keys.push(fullKey); } else { // 2. 如果key不存在检查是否超限 if (meta.keys.length this.maxItems) { // 找到最久未使用的key数组第一个 const lruKey meta.keys.shift(); localStorage.removeItem(lruKey); delete meta.timestamps[lruKey]; } // 添加新key meta.keys.push(fullKey); meta.timestamps[fullKey] Date.now(); } // 3. 存储数据和元数据 const dataToStore { value: value, expires: Date.now() ttl }; try { localStorage.setItem(fullKey, JSON.stringify(dataToStore)); this._setMeta(meta); } catch (e) { if (e.name QuotaExceededError) { // 即使有LRU也可能因单条数据过大而超限 this._forceCleanup(1); // 尝试再清理一个项目 localStorage.setItem(fullKey, JSON.stringify(dataToStore)); // 重试 this._setMeta(meta); } else { throw e; } } } get(key) { const fullKey ${this.namespace}::${key}; const itemStr localStorage.getItem(fullKey); if (!itemStr) return null; const item JSON.parse(itemStr); if (Date.now() item.expires) { this.delete(key); // 过期删除 return null; } // 更新访问时间 const meta this._getMeta(); if (meta.timestamps[fullKey]) { meta.timestamps[fullKey] Date.now(); const keyIndex meta.keys.indexOf(fullKey); meta.keys.splice(keyIndex, 1); meta.keys.push(fullKey); this._setMeta(meta); } return item.value; } _forceCleanup(count) { const meta this._getMeta(); for (let i 0; i count meta.keys.length 0; i) { const keyToRemove meta.keys.shift(); localStorage.removeItem(keyToRemove); delete meta.timestamps[keyToRemove]; } this._setMeta(meta); } }这个类实现了基于数量和时间的双重清理策略能有效防止存储空间被无效数据占满。3.3 策略三数据压缩与序列化优化在存之前问问自己数据是否最精简使用更高效的序列化对于纯数字数组JSON.stringify可能不是最优。考虑使用Array.prototype.join或更专业的序列化库如 MessagePack但要注意引入解压缩的复杂度可能得不偿失。压缩文本对于较长的文本数据如HTML片段、Markdown可以使用简单的压缩算法如lz-string这个库它能将文本压缩后存入localStorage通常能获得不错的压缩率。// 使用 lz-string import LZString from lz-string; const largeText ...非常长的文本...; const compressed LZString.compress(largeText); localStorage.setItem(compressedData, compressed); // 读取时解压 const compressedStr localStorage.getItem(compressedData); const originalText LZString.decompress(compressedStr);避免存储冗余数据检查不同键值对中是否存储了重复的信息。例如用户信息可能同时在userProfile和currentUser键中存了大部分相同字段。3.4 策略四降级方案与数据迁移当存储空间真的不够用时必须有优雅的降级方案。降级到内存对于非持久化需求的数据可以降级到内存变量中。虽然页面刷新会丢失但能保证应用功能不崩溃。class StorageWithFallback { setItem(key, value, persistent true) { if (persistent) { try { localStorage.setItem(key, value); } catch (e) { if (e.name QuotaExceededError) { console.warn(localStorage已满将数据 ${key} 降级存储至内存); this._memoryStore[key] value; // 可选触发异步清理任务 setTimeout(() this._cleanupOldPersistentData(), 0); } } } else { this._memoryStore[key] value; } } }迁移到 IndexedDB对于需要存储大量结构化数据或二进制数据如图片、文件的场景localStorage根本不适合。IndexedDB是浏览器提供的异步、事务型数据库存储空间大得多通常是硬盘空间的50%左右。你可以设计一个策略将localStorage作为热点数据缓存将历史或大型数据存到IndexedDB中。// 当localStorage存满时将不常用的数据迁移到IndexedDB async function migrateToIndexedDB(key, value) { const db await openDB(myAppStore, 1, { upgrade(db) { db.createObjectStore(overflowStore); } }); await db.put(overflowStore, value, key); localStorage.removeItem(key); // 腾出空间 console.log(数据 ${key} 已迁移至IndexedDB); }4. 实战排查当错误已经发生如何定位与清理假设你已经遇到了QuotaExceededError或者用户报告了类似问题你该如何快速定位是哪个“坏家伙”占用了大量空间4.1 使用开发者工具快速分析现代浏览器的开发者工具Application 或 Storage 面板提供了最直观的查看方式。你可以查看每个键值对的大小。按大小排序迅速找到最大的几个条目。直接编辑或删除某个键值对进行测试。4.2 编写诊断脚本如果问题在特定用户环境发生你可以让页面运行一个诊断脚本将结果上报。function diagnoseStorage() { const entries []; let total 0; for (let i 0; i localStorage.length; i) { const key localStorage.key(i); const value localStorage.getItem(key); const size (key.length value.length) * 2; total size; entries.push({ key: key, size: size, sizeKB: (size / 1024).toFixed(2) }); } // 按大小降序排序 entries.sort((a, b) b.size - a.size); console.table(entries.slice(0, 10)); // 打印前10大 console.log(总计使用: ${(total / 1024 / 1024).toFixed(2)} MB); // 可以将诊断结果通过接口上报 return { totalUsage: total, topEntries: entries.slice(0, 5) }; }4.3 针对性清理策略根据诊断结果制定清理策略按前缀清理如果你的应用使用了命名空间如myapp::userData,myapp::cache::api1可以按前缀批量清理过期或无用数据。function clearByPrefix(prefix) { const keysToRemove []; for (let i 0; i localStorage.length; i) { const key localStorage.key(i); if (key.startsWith(prefix)) { keysToRemove.push(key); } } keysToRemove.forEach(key localStorage.removeItem(key)); console.log(清理了 ${keysToRemove.length} 个以${prefix}开头的数据); }按时间戳清理在存储数据时额外存入一个时间戳。定期运行清理任务删除过期的数据。// 存储时 localStorage.setItem(dataKey, JSON.stringify({ value: actualData, _timestamp: Date.now() })); // 清理时 const now Date.now(); const maxAge 7 * 24 * 60 * 60 * 1000; // 一周 for (let i 0; i localStorage.length; i) { const key localStorage.key(i); const item JSON.parse(localStorage.getItem(key)); if (item item._timestamp (now - item._timestamp maxAge)) { localStorage.removeItem(key); } }5. 防患于未然架构设计与最佳实践要从根本上避免存储问题需要在项目架构初期就考虑好存储策略。5.1 建立清晰的存储分层规范在团队内制定一个前端存储规范文档明确不同类型数据的归宿数据类型推荐存储方案原因与注意事项用户身份令牌localStorage(需考虑XSS风险) 或httpOnly Cookie持久化但敏感信息需加密或考虑其他更安全方案。用户个性化设置localStorage数据量小需持久化。接口数据缓存localStorage(带LRU和TTL) 或内存缓存使用LRU缓存类管理设置合理的过期时间和最大条目数。表单草稿、多步骤状态sessionStorage会话级存储页面刷新不丢失标签页关闭后自动清理。大型数据如列表数据、文件IndexedDBlocalStorage容量和性能均不足IndexedDB是更合适的选择。临时状态、UI状态内存Vuex/Pinia, Redux无需持久化响应最快。5.2 封装统一的存储工具库不要直接在业务代码中调用localStorage.setItem。应该封装一个统一的存储工具库例如storage.js// storage.js import { LocalStorageCache } from ./LocalStorageCache; import { compress, decompress } from ./compressUtil; const cache new LocalStorageCache(app, 100); export const storage { // 安全设置带降级 set(key, value, options {}) { const { persistent true, compress false, ttl } options; let data value; if (compress) { data compressData(value); } if (persistent) { if (ttl) { // 使用缓存类 cache.set(key, data, ttl); } else { // 直接存储但包裹try-catch try { localStorage.setItem(key, JSON.stringify(data)); } catch (e) { this._handleQuotaError(key, data); } } } else { // 存到内存 this._memoryStore[key] data; } }, get(key, options {}) { const { decompress false } options; let data; // 先从内存找 if (this._memoryStore[key]) { data this._memoryStore[key]; } else { // 从localStorage找 const item cache.get(key) || JSON.parse(localStorage.getItem(key)); data item; } if (decompress data) { data decompressData(data); } return data; }, _handleQuotaError(key, value) { // 1. 尝试清理过期缓存 cache.cleanupExpired(); // 2. 如果还不行尝试迁移最旧的数据到IndexedDB // 3. 最后降级到内存并发出警告 console.error(存储空间不足数据 ${key} 已降级至内存); this._memoryStore[key] value; // 可以触发一个事件让上层UI提示用户 window.dispatchEvent(new CustomEvent(storageQuotaWarning)); }, _memoryStore: {} };这样所有业务代码都通过这个统一的接口访问存储底层策略的变更比如从localStorage切换到IndexedDB对业务透明。5.3 监控与告警集成将存储健康度检查集成到你的应用监控体系中。页面加载时检查在应用初始化时检查存储使用率如果超过阈值如80%可以在控制台输出警告甚至向监控系统发送一个低优先级事件。关键操作前检查在执行一个已知可能写入大量数据的操作如保存一篇长文章草稿之前主动检查剩余空间。错误边界捕获在React/Vue的全局错误边界或window.onerror中捕获QuotaExceededError并将其作为异常日志上报到你的错误监控平台如Sentry这样你就能知道有多少用户遇到了这个问题以及发生的频率。浏览器存储就像你家的储物间无节制地堆放杂物总有一天会连门都打不开。localStorage和sessionStorage的容量限制是一个明确的边界忽视它必然会导致运行时错误和糟糕的用户体验。通过主动监控、实现LRU清理、设计降级方案、制定架构规范我们可以将这个潜在的“故障点”转变为可预测、可管理的资源。记住好的开发者不仅要让功能跑起来更要让它在各种边界条件下依然稳健。下次当你下意识地写下localStorage.setItem时不妨先花一秒想想这数据真的需要持久化吗它有多大存多了怎么办

相关新闻

实地考察硅胶工厂,重点看什么?

实地考察硅胶工厂,重点看什么?

很多客户在确定合作之前,都会问一句:“能不能去你们工厂看看?”这是非常明智的做法。实地考察能看到宣传册上看不到的东西,也是判断一家工厂靠不靠谱的最直接方式-。但问题是:去了之后,重点看什么&#xff…

2026/10/14 13:51:14 阅读更多 →
OpenCore Legacy Patcher完整上手指南:5步让2007年旧Mac免费跑上新macOS

OpenCore Legacy Patcher完整上手指南:5步让2007年旧Mac免费跑上新macOS

OpenCore Legacy Patcher完整上手指南:5步让2007年旧Mac免费跑上新macOS 【免费下载链接】OpenCore-Legacy-Patcher Experience macOS just like before 项目地址: https://gitcode.com/GitHub_Trending/op/OpenCore-Legacy-Patcher 那个周六下午&#xff0c…

2026/10/11 1:14:59 阅读更多 →
AI编程助手Codex深度解析:从原理到实践,打造高效开发工作流

AI编程助手Codex深度解析:从原理到实践,打造高效开发工作流

如果你是一名开发者,最近一定在各种技术社区和社交平台上频繁看到“Codex”这个名字。它被描述为“最强AI助手”,号称能“22分钟速通”,还附带了“保姆级教程”和“安装包”。但当你真正想去了解时,却发现信息鱼龙混杂&#xff1a…

2026/10/11 14:43:23 阅读更多 →

最新新闻

Advanced Installer 15.2汉化完全指南:版本匹配、覆盖验证与避坑

Advanced Installer 15.2汉化完全指南:版本匹配、覆盖验证与避坑

简介:Advanced Installer(高级安装程序)15.2汉化版是一款面向Windows应用开发者和系统管理员的专业打包部署工具,基于MSI技术,可快速生成符合Windows Installer标准的安装包,并支持自定义安装向导界面、编写…

2026/10/14 13:52:00 阅读更多 →
光子晶体平带与BIC仿真:COMSOL高Q偏振可控光源建模全链路

光子晶体平带与BIC仿真:COMSOL高Q偏振可控光源建模全链路

简介:本资源是面向光学仿真初学者与光子晶体研究者的COMSOL实操复现包,聚焦光子晶体中平带合并与束缚态在连续态(BIC)的核心物理现象,解决二维/三维能带计算、品质因子提取及远场偏振分析等关键建模难题。压缩包共10个…

2026/10/14 13:52:00 阅读更多 →
4G EPC到5G核心网不是升级而是架构重建

4G EPC到5G核心网不是升级而是架构重建

1. 为什么“4G EPC 到 5G 5GC”不是升级,而是重建?你刚接触移动通信网络时,大概率会听到一句很顺口的话:“5G 就是把 4G 升级一下,加点带宽、快点速度。”这话听着合理,但从业十年、亲手部署过三套商用核心…

2026/10/14 13:52:00 阅读更多 →
毕业答辩PPT模板改造指南:母版、配色与图表重绘

毕业答辩PPT模板改造指南:母版、配色与图表重绘

简介:这是一份面向高校本科毕业生的毕业论文答辩PPT模板,适用于北京石油化工学院等院校的学位论文答辩场景,也可供其他专业学生参考套用。模板整体风格精美大气,结构完整,涵盖研究背景及意义、研究思路及方法、研究目的…

2026/10/14 13:52:00 阅读更多 →
OpenMontage实战:配置驱动的自动视频拼贴流水线解析

OpenMontage实战:配置驱动的自动视频拼贴流水线解析

1. 我为什么会盯上OpenMontage:从手动剪辑到批量拼贴最近一个需求让我对“自动生成视频蒙太奇”这条路彻底改观:每周末都要给社团的活动出一条30到60秒的回顾视频,素材倒是攒了上百条,可每次打开剪辑软件,光是把有用的…

2026/10/14 13:52:00 阅读更多 →
WebGL光栅化原理与性能优化实践

WebGL光栅化原理与性能优化实践

1. 光栅化基础概念与WebGL关系光栅化(Rasterization)是计算机图形学中将矢量图形转换为像素矩阵的核心技术流程。在WebGL的渲染管线中,光栅化阶段位于顶点着色器处理之后,负责把经过变换的3D图元(通常是三角形&#xf…

2026/10/14 13:50:59 阅读更多 →

日新闻

AnyPS5通用适配层设计:兼容性与性能的工程取舍

AnyPS5通用适配层设计:兼容性与性能的工程取舍

1. 从"AnyPS5"这个名字说起:它到底想解决什么问题第一次看到"AnyPS5"这个标题,我脑子里冒出来的第一个念头是:这大概率是一个围绕"跨平台运行"或者"通用适配"做文章的项目。名字里的"Any"…

2026/10/14 0:00:41 阅读更多 →
Flutter跨平台开发实战:二手交易应用与HarmonyOS适配

Flutter跨平台开发实战:二手交易应用与HarmonyOS适配

1. 项目概述:Flutter跨平台二手交易应用开发作为一名长期从事移动应用开发的工程师,我最近完成了一个基于Flutter框架的二手物品交易平台项目。这个项目最吸引我的地方在于它完美结合了Flutter的跨平台优势与HarmonyOS的生态特性,实现了"…

2026/10/14 0:00:41 阅读更多 →
DFS算法解决飞机降落调度问题实战

DFS算法解决飞机降落调度问题实战

1. 项目背景与核心思路飞机降落问题是一个经典的调度优化场景,也是算法竞赛和面试中的高频题目。这个题目要求我们合理安排多架飞机的降落顺序和时间,确保所有飞机都能安全着陆而不发生冲突。在实际航空管制中,这类问题直接关系到飞行安全和机…

2026/10/14 0:00:41 阅读更多 →

周新闻

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

简介:基于 ARIMA、LSTM、Transformer 等模型的流感时间序列预测 Python 源码,面向计算机相关专业课程设计与期末大作业学生,以及项目实战学习者。内容覆盖预处理、平稳性检验、定阶、残差分析、多模型对比预测的完整时序建模流程,…

2026/10/14 3:30:28 阅读更多 →
影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别 做影刀RPA自动化,十个新手有八个栽在"往输入框里填东西"这件事上:要么填不进去,要么填了一半,要么直接把原来内容追加在后面。这背后的根因&…

2026/10/13 16:22:04 阅读更多 →
影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容 1. 认识影刀:什么场景该用RPA采小说数据 起点中文网的页面结构相对稳定——分类榜单、书籍详情、章节内容三块独立页面,跳转链路清晰。这种场景非常适合影刀自动化&#x…

2026/10/14 3:29:58 阅读更多 →

月新闻

我发现了一个新思路:用 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/13 22:01:27 阅读更多 →
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/13 14:57:39 阅读更多 →
黑夜航拍船只数据集训练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/14 7:53:34 阅读更多 →