Rust闭包捕获与内存管理:长期运行程序的避坑指南
Rust 的闭包捕获列表语法是我这几年在长期运行的服务类项目里反复踩坑又反复受益的地方。它看起来只是||和move ||的差别但决定了闭包到底握住了哪些内存、握多久、能不能安全逃出当前函数。长期运行的程序里只要有一个闭包把不该持有的数据多抓了几个月线上内存水位就会给你颜色看。如果你正在做后台任务、事件循环、定时任务这类常驻进程这篇文章应该能帮你避开不少坑。我下面会把闭包捕获机制、它与内存生命周期的关系、真实项目里的重构过程、以及排查内存问题的思路串起来讲。不搞教科书式罗列全是能落地到代码里的东西。1. 闭包捕获列表语法先分清“抓谁”和“抓多少”1.1 捕获类型与 Fn 三重奏Rust 闭包的核心机制是“捕获环境变量”而不是像普通函数那样只接收显式参数。捕获列表语法本身并不复杂默认情况下编译器会分析闭包体内部如何使用外部变量然后自动选择三种捕获方式之一。T不可变借用捕获对应Fntrait。mut T可变借用捕获对应FnMuttrait。T按值移动捕获对应FnOncetrait。很多刚接触 Rust 的人会有一个误解Fn只是“不修改状态”的闭包FnMut是“能修改状态”的闭包。这句话不算错但没说到根上。真正决定一个闭包属于哪个 trait 的是它捕获外部变量时的借用类别。如果你在闭包内部调用了vec.push(...)编译器就会让闭包以可变借用的方式捕获vec于是这个闭包只能是FnMut。如果你只是读取vec.len()那闭包以Vec捕获它可以实现Fn。我在实际项目里栽过的第一个跟头就是“我以为闭包是只读的但它其实悄悄捕获了整个 Vec 的所有权”。为什么因为我在闭包外面写了一句let owned vec;然后又在闭包里用了owned编译器判定这里必须移动捕获。闭包一旦 move 捕获它就拥有了owned对应的那块堆内存的所有权释放时机跟着闭包走。对于长期运行的程序这意味着闭包活多久那块内存就活多久。1.2 显式捕获列表与 move 的边界Rust 在 2021 edition 之后闭包捕获列表有一个明显变化move关键字被要求写在参数列表之前形成move |args| ...的显式形式。这个写法在旧版本其实也存在但语义容易被忽略。这里要强调一点move并不是“把变量移动进闭包”这么简单。move的准确语义是“让闭包获得捕获变量的所有权”。如果捕获的变量本身是一个引用类型那move捕获的是“引用本身”而不是引用指向的数据。比如let s String::from(hello); let borrow s; let c move || println!({}, borrow);这个闭包捕获的是borrow这个引用变量它移动的是引用本身一个指针宽度的值而不是 String。很多人以为move会把 String 的所有权也搬走不是的它只搬走你闭包里实际使用的那个层级的值。真正容易造成长期内存问题的是move捕获了一个大型容器。比如let big_cache: HashMapu64, Vecu8 build_cache(); let handler move |req| big_cache.get(req.id);这里big_cache整个被移进闭包只要handler被注册到某个全局注册表里缓存就永远活着即使业务上已经不需要它了。我在某个模拟项目里就见过类似写法一个定时任务闭包为了读取配置快照把整个配置结构 move 了进去结果每次配置刷新都会生成新的闭包旧的闭包还挂在定时器里内存里攒了几百份配置快照。这就是“闭包捕获列表语法”与“长期运行程序”最直白的交汇点语法上完全合法内存上完全失控。2. 长期运行程序的内存敏感点闭包持有态2.1 闭包即“一块活动内存”长期运行程序里闭包其实有两个身份一段可执行的逻辑以及一份被捕获的环境状态。你可以把闭包想象成一个“自带背包的函数”。普通函数跑完就释放栈帧闭包不一样如果它被放进堆上、被注册成回调、被存进任务队列那它的背包就跟着常驻。这个背包的大小取决于捕获列表抓了多少东西。默认捕获是“最小捕获原则”编译器会逐变量分析只捕获闭包体内确实用到的外部变量。但要注意“最小捕获”并不等于“捕获最少的内存”。比如你只用了结构体里的一个bool字段编译器在大部分情况下会选择捕获整个结构体因为捕获字段和捕获结构体在借用检查上的复杂程度差异很大。结果就是一个只需要一个标志位的闭包可能把跟这个标志位同结构的大缓冲全部拖进生命周期里。我后来养成了一个习惯写闭包之前先数一下“这个闭包真正需要哪些数据”然后把它们提取成小结构体或单独变量再传给闭包。这比事后优化内存要省事得多。2.2 引用捕获是悬垂风险主源长期运行的进程里引用捕获的最大敌人是生命周期。借用捕获的闭包有一个天然约束它不能活得比被借用者更久。这个约束是由编译器强制执行的所以它不会产生悬垂引用但它会带来另一个问题——借用检查器会强行把被借用者的生命周期拉长到闭包存活期间。举个例子。你要写一个周期性上报指标的组件fn start_reporter(state: mut State) { let closure || { state.collect_metrics(); state.reset(); }; spawn_repeating_task(closure); // 错误closure 可能活得比 state 更久 }编译会直接拒绝因为state的可变借用被闭包捕获而闭包要求static或至少与任务生命周期一致。此时如果你改成move捕获就必须把state的所有权交出去。如果是长期运行的任务这个state就永久归任务所有外部无法再访问。这个取舍在长期运行程序里非常关键你到底希望状态被“借用”还是被“拥有”借用意味着生命周期纠缠不清拥有意味着所有权转移后不可逆。真实项目的解法通常是引入Arc把所有权变成共享所有权后面会详细讲。2.3 泄漏来源的典型画像长期运行程序中闭包相关的内存泄漏一般不是“忘记释放”而是“不该被持有的东西被闭包持有”。我总结了几个高发画像日志型闭包为了打日志把整个请求上下文 move 进闭包日志写完闭包被存入滚动队列上下文残留。缓存型闭包闭包内部引用了缓存容器缓存容器永远不会清空因为闭包本身一直在。循环引用型闭包闭包被包在Rc里闭包又捕获了同一个Rc形成引用环析构永远不触发。注册表型闭包闭包被注册到全局事件总线业务侧以为注销了其实注销函数没调用闭包连同捕获数据一直挂在总线上。这些画像的共同特征都是没有认真想过“闭包捕获了什么”以及“闭包被谁持有”。我在下面实战部分会用一个具体案例演示怎么拆解。3. 长期服务中的闭包捕获与内存管理实战3.1 案例事件总线中的闭包处理器我参与过一个模拟项目核心是一个事件总线用来在进程内分发业务事件。总线支持注册回调回调统一类型为Boxdyn Fn(Event) Send Sync。事件类型里带有一个元数据字段和一个可选的载荷缓冲。第一版代码非常简单pub struct Bus { handlers: VecBoxdyn Fn(Event) Send Sync, } pub fn onF(mut self, handler: F) where F: Fn(Event) Send Sync static, { self.handlers.push(Box::new(handler)); }问题出在调用方。某个业务模块注册处理器时直接捕获了一个大的上下文对象bus.on(move |event| { let context ctx.snapshot(event.id); context.handle(event); });由于ctx被move捕获而闭包又满足static并被放进VecBox...它就会一直存活到总线销毁。业务侧以为每次事件来了才创建快照实际上ctx早就被闭包抓住快照函数里的临时数据也许能释放但ctx这个根部对象永远不释放。后来我们重构时做了一个很小的改动只把ctx内部真正需要的那部分引用传进去。具体做法是把上下文拆成“共享配置”和“可变会话”两个部分闭包里只捕获共享配置的Arc可变会话在事件处理内部新建用完即丢。内存水位立刻降了一个档次。3.2 定时任务注册器捕获列表的先后顺序定时任务是另一个闭包大本营。假设我们要维护一个延迟任务表每个任务是一个闭包。Rust 里一般会要求闭包实现Send static好在线程池上执行。这意味着几乎所有定时任务闭包都必须用move捕获。既然逃不掉唯一能做的就是控制捕获内容。我遇到过一个非常典型的场景每分钟执行一次任务任务需要读取用户列表中的 ID。第一版代码let user_ids get_all_user_ids(); schedule_repeat(Duration::from_secs(60), move || { for id in user_ids { process_user(*id); } });user_ids是一个大集合被闭包永久持有。如果用户列表每天变动这个闭包一直用的是旧列表业务上也是错的。正确做法是让定时任务每轮从共享状态里取最新数据let users: ArcRwLockVecu64 Arc::new(RwLock::new(get_all_user_ids())); let users_clone Arc::clone(users); schedule_repeat(Duration::from_secs(60), move || { let current users_clone.read().unwrap(); for id in current.iter() { process_user(*id); } });闭包捕获的只是一个Arc指向共享数据的引用计数数据本身可以动态更新。这就是长期运行程序里最关键的一个认知闭包捕获的不应该是数据本体而应该是访问数据的路径。3.3 状态共享与 Arc 闭包的配合在多线程长期运行的场景里Arc基本是闭包捕获的标配。但Arc不是银弹它本身有两个内存相关的坑。第一个坑是过度共享。一个闭包捕获了ArcMutexState每次执行都要拿锁。如果闭包执行频率很高锁竞争会让程序看起来像卡死。更隐蔽的是Arc会让状态“看起来可以被多处持有”于是开发者懒得清理状态越积越多。排查时你会看到堆上有大量Arc指向同一块内存但没人说得清谁应该负责释放。我的习惯是能用消息传递就用消息传递不要动不动把大状态Arc出去实在要共享就共享不可变数据可变部分用锁单独包一层。第二个坑是Arc强引用循环。如果闭包 A 捕获了包含闭包 A 的容器就会形成环。比如有一个RcRefCellVecBoxdyn Fn()的注册表你在某个闭包里又捕获了同一个注册表的Rc这个环基本不会被自动回收。多线程里对应的是Arc环。解法要么是主动提供clear方法去破环要么用Weak捕获让闭包不持有强引用。4. 内存问题排查与捕获范围收窄技巧4.1 循环引用与 Weak 的定位如果你怀疑长期运行进程里有闭包造成的循环引用第一件事不是改代码而是先确认循环是否存在。一个有效办法是在需要析构的对象里打印Drop日志impl Drop for Service { fn drop(mut self) { eprintln!(Service dropped); } }如果程序退出了但Service dropped迟迟不出现说明这个对象没有正常释放很可能是被闭包环抓住了。定位到具体环之后把其中一个方向的强引用改成Weak即可。这里有很实用的规律回调型的闭包注册端用强引用被注册端用弱引用。比如总线持有处理器列表处理器不需要反向引用总线如果处理器内部却拿着总线的强引用就特别容易成环。我还遇到过一种不那么明显的环闭包捕获了ArcMutexVecEvent同时这个VecEvent里的一条事件又包含一个闭包而那个闭包捕获了同一个Arc。这个环绕了两层不仔细看根本发现不了。排查时最好把“谁持有谁”画成简单的所有权图一眼就能看出哪里有环。4.2 堆分析里的闭包指纹堆分析工具对长期运行程序非常有用。当内存水位持续上升我会先抓一份堆快照然后按分配大小排序找那些“存活时间超过一个任务周期”的大对象。闭包的堆分配通常以Boxdyn Fn...或Arc...的形式出现它们的调用栈里往往能看到注册闭包的入口函数名。实际操作时我会在闭包被创建的地方临时加一行统计static CLOSURE_COUNT: AtomicUsize AtomicUsize::new(0); CLOSURE_COUNT.fetch_add(1, Ordering::Relaxed);配合定时打印计数就能发现闭包数量是不是只增不减。如果闭包数量一直涨基本可以确定是注册表、事件列表或延迟队列没有正确清理。统计比盲目看快照更快定位。4.3 把捕获粒度收到最小一次重构示范下面用一个简单但很典型的重构示范说明怎么把闭包捕获范围收到最小。原始代码struct ReportCtx { base_dir: PathBuf, filters: VecString, buffer: Vecu8, } fn register_reporter(ctx: RcReportCtx) { let ctx_clone Rc::clone(ctx); callback.register(move || { let path ctx_clone.base_dir.join(report.txt); write_report(path, ctx_clone.filters); }); }问题很明显ctx_clone是RcReportCtx的强引用闭包活着期间整个ReportCtx都活着其中buffer可能尽为写报告而分配占了不少内存。而且闭包只用了base_dir和filtersbuffer完全是被“连坐”的。重构后struct ReportCtx { base_dir: PathBuf, filters: VecString, // buffer 不再被闭包捕获 } fn register_reporter(ctx: RcReportCtx) { let base_dir ctx.base_dir.clone(); let filters ctx.filters.clone(); let ctx_weak Rc::downgrade(ctx); callback.register(move || { if let Some(strong) ctx_weak.upgrade() { let path base_dir.join(report.txt); write_report(path, filters); } }); }这里有两个关键动作一是只提取需要的字段让闭包不再背负整个结构二是把对上下文的强引用改成弱引用让上下文在业务不复用时可以被正常回收。闭包内部通过Weak::upgrade()临时获得强引用用完了立即释放。长期运行的程序里这个模式能有效避免“闭包无意中充当了对象的永久持有人”这个经典问题。5. 从实践沉淀下来的几条硬规矩5.1 设计时先定义捕获边界不要等代码写完了再回头优化闭包捕获。我的做法是在设计阶段就先回答三个问题这个闭包会被注册到什么地方生命周期是多久它需要捕获哪些数据能不能只捕获访问数据的句柄它执行完后捕获的数据还应该继续存活吗这三个问题一过闭包的捕获列表基本就能确定下来。如果答案是“闭包长期存活”我会主动把大对象排除在捕获范围外改用Arc、Weak或索引作为捕获目标。这样做的好处是闭包的定义本身就携带了内存策略不需要额外注释。5.2 定时任务里避免隐式长期引用定时任务闭包是最容易隐式捕获长期引用的地方。我见过不少人写每秒钟执行一次的任务时闭包里直接捕获了一个static的全局配置。后来配置变成了动态更新代码改成捕获一个ArcRwLockConfig但老的定时任务没注销新旧两份配置同时在内存里存在而且老任务还在执行。这个问题的根源不是“闭包捕获了配置”而是“定时任务没有可注销机制”。长期运行程序里任何“周期性执行”的任务都应该支持取消。闭包不一定非要static可以设计成持有JoinHandle或CancellationToken任务不再需要时先取消再清理。这样即使闭包捕获了较大的数据也会有明确的释放时机。5.3 用 debug_assert 验证生命周期假设最后分享一个小技巧。在闭包捕获了带有生命周期的引用时我经常用debug_assert辅助验证假设。比如我怀疑某个闭包捕获了Rc但这个Rc可能已经降级成Weak那可以在闭包入口处加一句断言debug_assert!(weak_ctx.upgrade().is_some(), ctx should still be alive);这种做法在开发阶段很有用它能帮你第一时间发现“闭包仍然活着但它捕获的环境已经不该再被使用”这种逻辑错误。注意只在debug_assert里做不要在生产环境引入额外开销。我这两年最深的体会是Rust 闭包捕获列表语法并不是一个“写着写着就会了”的知识点它跟内存管理是深度绑定的。尤其是长期运行的程序闭包的一个捕获决定可能要让内存多活几个月。每次写闭包前多想一下它捕获了什么、被谁持有、什么时候释放远比事后上堆分析工具更划算。

相关新闻

为不支持sse4.2命令集的老旧设备制作Win11 26H2系统镜像,2026-10-11。

为不支持sse4.2命令集的老旧设备制作Win11 26H2系统镜像,2026-10-11。

1.工具准备 主要为 POP4.2 项目 的 Python 脚本的运行做准本 1.1 安装Python 用华为的镜像站,下载速度比官方网站会快些。按照项目要求安装版本不低于 Python3.7 的版本进行安装即可。这里我用的是Python 3.10 1.2 安装git 这里我也用的是华为的开源镜像站下载的…

2026/10/12 4:52:52 阅读更多 →
读书拆解(二):《人类简史》《埃隆马斯克传》《红星照耀中国》,三本书里的想象、野心与信仰

读书拆解(二):《人类简史》《埃隆马斯克传》《红星照耀中国》,三本书里的想象、野心与信仰

本文是读《人类简史》《埃隆马斯克传》《红星照耀中国》的一些思考,由此问了两个一直困惑我的问题: 创新的必要条件是什么?人生的意义究竟是什么? 将自己的问题扔给AI帮我解答,觉得答案还不错,由此记录和分…

2026/10/12 4:52:52 阅读更多 →
在 Windows 11 或 10 中把 Visual Studio Code 加入系统 Path

在 Windows 11 或 10 中把 Visual Studio Code 加入系统 Path

一般来说,不需要手动把 Visual Studio Code(VS Code)加进 Windows 系统 Path,因为装完这个代码编辑器后系统会自动加上。把 VS Code 的可执行文件放进 Path 是必要的,只有这样 Windows 才能识别它,让你在命令提示符或 PowerShell 里直接跑 VS Code 命令,而不用先切到安装…

2026/10/12 4:52:52 阅读更多 →

最新新闻

mediamtx v1.21.2发布:UDP、JWT、RTSP、RTMP、HLS、WebRTC全面修复,稳定性与安全性再提升

mediamtx v1.21.2发布:UDP、JWT、RTSP、RTMP、HLS、WebRTC全面修复,稳定性与安全性再提升

2026年10月10日,mediamtx 发布 v1.21.2 最新版本。本次更新以“修复与改进”为主,覆盖通用逻辑、API、Media-Over-QUIC、RTSP、RTMP、HLS、WebRTC 以及依赖库升级等多个方向。 v1.21.2 没有引入新的功能模块,而是集中处理实际运行中可能出现的…

2026/10/12 5:43:21 阅读更多 →
哪个品牌密码锁最安全 高端市场占比领先全维安防更靠谱安心

哪个品牌密码锁最安全 高端市场占比领先全维安防更靠谱安心

在智能家居全面普及的今天,智能密码锁已经成为了家庭安全防护的核心入口。哪个品牌密码锁最安全,不仅关乎家庭财产安全,更影响着日常进出的便捷体验与全场景安防体验。2026年以来,国内智能门锁行业技术迭代加速,市场格…

2026/10/12 5:43:21 阅读更多 →
2026家用智能锁品牌推荐:德施曼热门产品深度解析

2026家用智能锁品牌推荐:德施曼热门产品深度解析

随着智能家居行业的快速发展,智能门锁已经成为了千家万户的入户安防首选。相较于传统机械锁,智能门锁不仅提供了更加便捷的多种解锁方式,还集成了猫眼可视、AI安防、远程对讲等功能,全方位提升家庭入户安全与使用体验。在2026年上…

2026/10/12 5:43:21 阅读更多 →
本地化企业知识库方案拆解:8 步把文档变成知识库

本地化企业知识库方案拆解:8 步把文档变成知识库

## 背景在项目复盘场景里,企业文档散落各处、找人问半天是效率的主要损耗点。## 核心能力- 全程本地运行,原始文档与知识数据不出电脑- 8 步流水线自动化:解析→结构化→质检→复核→分片→向量库→验收- 内置本地大模型,离线推理…

2026/10/12 5:43:21 阅读更多 →
81 极物科技 | KNX调试 - 个体地址过滤与报文隔离

81 极物科技 | KNX调试 - 个体地址过滤与报文隔离

极物科技 | KNX调试 - 个体地址过滤与报文隔离 前言 工程品质是 KNX 国际标准三十年立足全球的根基,而可观测性是品质的前提。 报文追踪把“看不见的总线”变成“看得见的证据”:每一次收发都有记录、每一次异常都有据可查。本文围绕报文追踪的接收链路、…

2026/10/12 5:43:21 阅读更多 →
百万级缺陷样本开源:工业视觉的「地基」被补上了

百万级缺陷样本开源:工业视觉的「地基」被补上了

1.工业质检的两道坎 ▍坎一:数据各管各的现成的工业缺陷数据集,几乎都窝在单一行当里。VisA、3CAD 盯着 3C 电子,PKU-GoodsAD 盯着包装,Real-IAD、MulSen-AD 盯着材料。覆盖面稍宽些的 VISION、MVTec AD、MMAD,又卡在…

2026/10/12 5:42:21 阅读更多 →

日新闻

复古胶片颗粒感噪点合成器:Canvas ImageData 像素高斯杂色注入算法

复古胶片颗粒感噪点合成器:Canvas ImageData 像素高斯杂色注入算法

在数码相机、高清显示屏与现代矢量图形技术高度发达的今天,画面可以做到绝对的锐利、平滑与无瑕。然而,当一张秋日手账插画或拍立得照片过于“平整无瑕”时,往往会散发出一种冰冷生硬的“数码塑料感(Digital Plasticity&#xff0…

2026/10/12 0:00:59 阅读更多 →
活字印刷古籍线装排版:Canvas 竖排文字与栏线自适应算法

活字印刷古籍线装排版:Canvas 竖排文字与栏线自适应算法

在现代网页与移动端设计中,横排(Horizontal Layout)早已经成为了绝对的主流。然而,当我们翻开泛黄的线装古籍、宋版木刻诗集,或是欣赏一张茶道雅集的手写便签时,那种**自上而下纵向书写、自右向左逐列铺展&…

2026/10/12 0:00:59 阅读更多 →
周日晚间的“精神松绑减震器”:无压力情绪倾倒箱与温和轻声陪伴

周日晚间的“精神松绑减震器”:无压力情绪倾倒箱与温和轻声陪伴

每到周日的晚上八点到十点,很多人心里都会悄悄亮起一盏警示灯。 在心理学上,这种现象有一个专门的称谓——“周日夜晚焦虑症(Sunday Scaries)”。明天又是周一,闹钟又要重新在七点响彻卧房;脑海里仿佛有一个…

2026/10/12 0:00:59 阅读更多 →

周新闻

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

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

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

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

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

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

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

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

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

2026/10/12 0:16:43 阅读更多 →

月新闻

我发现了一个新思路:用 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/11 10:45:37 阅读更多 →
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/11 14:36:53 阅读更多 →
黑夜航拍船只数据集训练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/11 14:36:54 阅读更多 →