news 2026/9/13 6:33:31

fsnotify 跨平台文件系统通知库演进全解析:从 CHANGELOG 看 v0.1 到 v1.10 的架构演变与工程实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
fsnotify 跨平台文件系统通知库演进全解析:从 CHANGELOG 看 v0.1 到 v1.10 的架构演变与工程实践

fsnotify 跨平台文件系统通知库演进全解析:从 CHANGELOG 看 v0.1 到 v1.10 的架构演变与工程实践

【免费下载链接】lokiLike Prometheus, but for logs.项目地址: https://gitcode.com/GitHub_Trending/lok/loki

fsnotify 是 Go 生态中最常用的跨平台文件系统通知库,在 Windows、Linux、macOS、BSD 与 illumos 上提供统一的事件监听 API。本文以当前仓库 vendored 的 CHANGELOG.md 为骨架,结合 README.md 与核心源码(fsnotify.go、shared.go、backend_inotify.go),完整梳理其十年演进脉络、各平台后端实现原理与 API 设计取舍,读完即可理解"文件变化到底如何被监听到"以及版本升级背后修复了哪些关键缺陷。

一、库定位与在当前仓库中的角色

fsnotify 的官方定位非常清晰(见 README.md):

fsnotify is a Go library to provide cross-platform filesystem notifications on Windows, Linux, macOS, BSD, and illumos.

不直接封装内核事件,而是做一层统一抽象:把 Linux 的 inotify、BSD/macOS 的 kqueue、Windows 的 ReadDirectoryChangesW、illumos 的 FEN 四种完全不同的内核通知机制,收敛为同一个Watcher接口与Event/Op事件模型。

在当前仓库中,该库以 v1.10.1 版本被 vendored 到vendor/github.com/fsnotify/fsnotify/目录,并在根目录 go.mod 中声明为github.com/fsnotify/fsnotify v1.10.1 // indirect。也就是说它是作为传递依赖随项目引入的——这正是日志系统这类需要持续监控配置变更、规则文件热加载场景中常见的依赖形态。由于 CHANGELOG 恰好记录到 1.10.1,读者可以在本文中看到与仓库实际携带版本完全同步的演进历史。

二、平台后端架构:一个接口,四种内核机制

平台支持矩阵(摘自 README.md)决定了"能监听什么":

后端操作系统状态
inotifyLinux支持
kqueueBSD、macOS支持
ReadDirectoryChangesWWindows支持(不含Chmod操作)
FENillumos支持(1.7.0 起)
fanotifyLinux 5.9+尚未实现
FSEventsmacOS需要 x/sys/unix 支持
USN JournalsWindows需要 x/sys/windows 支持
Polling全部尚未实现

从源码结构看(backend_inotify.go、backend_kqueue.go、backend_windows.go、backend_fen.go),每个后端都实现同一个backend接口(定义于 fsnotify.go):

type backend interface { Add(string) error AddWith(string, ...addOpt) error Remove(string) error WatchList() []string Close() error xSupports(Op) bool }

而平台无关的公共状态(事件通道、错误通道、关闭信号)被抽取到 shared.go 中的shared结构体,各后端通过sendEvent/sendErrorselect+done通道的方式安全投递事件——这是"事件循环与关闭信号解耦"的经典 Go 并发模式。

各后端的本质差异

  • inotify(Linux):以"watch 描述符(wd)"为粒度。内部用wd map[uint32]*watchpath map[string]uint32两张表维护路径到描述符的映射(见 backend_inotify.go)。值得注意的是它用固定大小环形数组cookies [10]koekje存储 rename 事件的 cookie,以解决 MOVED_FROM 与 MOVED_TO 之间可能插入其他事件的问题,同时避免 map 泄漏。
  • kqueue(macOS/BSD)每个被监听的文件都要打开一个文件描述符,因此 watch 一个含 5 个文件的目录需要 6 个 fd,更容易触及"max open files"限制。
  • ReadDirectoryChangesW(Windows):基于目录句柄与 64K 缓冲区轮询式异步通知,事件溢出时会返回ErrEventOverflow
  • FEN(illumos):1.7.0 新增的后端,支持 illumos 与 Solaris。

三、核心 API 与事件模型

3.1 最小可用示例

README.md 给出的完整示例是理解 API 的最佳入口:

package main import ( "log" "github.com/fsnotify/fsnotify" ) func main() { // Create new watcher. watcher, err := fsnotify.NewWatcher() if err != nil { log.Fatal(err) } defer watcher.Close() // Start listening for events. go func() { for { select { case event, ok := <-watcher.Events: if !ok { return } log.Println("event:", event) if event.Has(fsnotify.Write) { log.Println("modified file:", event.Name) } case err, ok := <-watcher.Errors: if !ok { return } log.Println("error:", err) } } }() // Add a path. err = watcher.Add("/tmp") if err != nil { log.Fatal(err) } // Block main goroutine forever. <-make(chan struct{}) }

关键点:EventsErrors两个通道必须被并发读取(可用select在同一个 goroutine 中处理,无需开两个 goroutine),否则 watcher 内部会因通道阻塞而无法投递事件。

3.2 Watcher 方法与事件类型

核心类型与方法定义于 fsnotify.go:

  • NewWatcher():创建默认 watcher,事件通道容量为defaultBufferSize(默认缓冲);NewBufferedWatcher(sz uint):自 1.7.0 起提供,可指定用户态通道缓冲大小。
  • Add(path):开始监听路径;同一路径重复 Add 是 no-op 不报错;路径尚不存在时无法监听;返回ErrClosed(watcher 已关闭时)。
  • AddWith(path, opts...):1.7.0 起提供,可在 Add 的同时传入选项(如WithBufferSizewithOps)。
  • Remove(path):移除监听,目录总是非递归移除;移除未监听的路径返回ErrNonExistentWatch(1.6.0 起)。
  • Close():移除所有监听并关闭 Events 通道。
  • WatchList():返回所有显式 Add 且未移除的路径(1.5.2 起;1.8.0 修复了 Windows 上与其他平台行为不一致的问题)。

事件类型为位掩码,必须用Event.Has()/Op.Has()判断而非==比较,因为单个事件可能同时携带多个操作:

Op含义
Create新路径被创建,可能随后伴随一个或多个Write
Write文件被写入(不保证写入完成);truncate 也会触发;Windows/kqueue 上目录的 Write 表示目录内容变化
Remove路径被移除,其上的监听随之失效
Rename路径被重命名,旧路径作为Event.Name,同时以新名字发出一个Create(带RenamedFrom字段,仅当新旧两端都被监听时可靠)
Chmod属性被修改;Linux 上文件被 remove 时也会发 Chmod;kqueue 上文件被 truncate 时触发;Windows 上永不会发送

源码中还定义了xUnportableOpen/Read/CloseWrite/CloseRead四个不可移植操作(仅 Linux/FreeBSD 部分支持),名称以x开头表示不对外导出——这是 fsnotify 刻意保持公共 API 最小化的体现。

3.3 事件与操作的辅助方法

1.6.0 引入的Event.Has()Op.Has()极大简化了过滤逻辑。CHANGELOG 中给出了对比示例:

// 旧写法 if event.Op&Write == Write && !(event.Op&Remove == Remove) { } // 新写法(1.6.0+) if event.Has(Write) && !event.Has(Remove) { }

Op.String()(1.4.0 起)会将位掩码格式化为CREATE|WRITE这类可读字符串,便于日志输出。

四、版本演进全景:CHANGELOG 逐版解读

CHANGELOG 记录了从 2011 年 0.1.0 到 2026 年 1.10.1 的完整历史。以下按"重大能力演进"与"关键缺陷修复"两条线索组织,先看时间线:

版本日期核心主题
1.10.12026-05-04inotify/Windows 前缀共享 watch 修复
1.10.02026-04-30要求 Go 1.23;inotify/kqueue/Windows 多项修复
1.9.02024-04-04BufferedWatcher 恢复缓冲;并发与 symlink 竞态修复
1.8.02024-10-31FSNOTIFY_DEBUG调试开关
1.7.02023-10-22FEN 后端、NewBufferedWatcher、AddWith/WithBufferSize
1.6.02022-10-13Event.Has()/Op.Has()、cmd/fsnotify、非阻塞 inotify
1.5.x2021-2022WatchList、AddRaw(后回退)、最低 Go 版本提升
1.4.x2016-2020锁死锁修复、close-on-exec、IN_Q_OVERFLOW 处理
1.0-1.32014-2016API 定型(Add/Remove/Events/Errors)、arm64 支持
0.x2011-2014早期 kqueue/inotify 实现与 Windows 支持

4.1 1.10.x:近期维护与共享前缀 watch 的坑

1.10.1(2026-05-04)只包含两个高度聚焦的修复,均为"路径前缀共享"问题:

  • inotify:不再移除共享同一路径前缀的兄弟 watch(#754)。例如同时 watch/tmp/foo/tmp/foobar,此前移除其中一个可能误伤另一个。
  • inotify、Windows:不再重命名共享同一路径前缀的兄弟 watch(#755)。

这类 bug 之所以需要单独发版,是因为 fsnotify 的 watch 表(wd/path两张 map)以路径字符串为键,前缀匹配极易产生"波及相邻路径"的误操作,属于典型的目录监控边界问题。

1.10.0(2026-04-30)是该库的重要里程碑,要求 Go 1.23,并修复了三类问题:

  • inotify:改进初始化错误信息(#731);递归 watch 被重命名时发送 Rename 事件(#696);读取文件名时避免拷贝事件缓冲区(#741,性能优化)。
  • kqueuewatchDirectoryFiles跳过悬空 symlink(ENOENT),坏条目不再中止整个目录的Add(#748);Close()中直接释放 watch,修复 watcher 复用时的 fd 泄漏(#740)。
  • WindowsremWatch中修复空指针解引用(#736);对并发WatchList加锁保护 watch 字段更新,修复 v1.9.0 引入的竞态(#709、#749)。

4.2 1.9.0(2024-04-04):并发正确性集中修复

  • all:让BufferedWatcher恢复"缓冲"语义(#657)——此前缓冲能力退化的回归。
  • inotify:修复"监听路径正在被删除时,同时添加/移除 watch"的竞态(#678、#686);被监听路径卸载时不发送空事件(#655);同时监听 symlink 与其目标时不再注册重复 watch,此前会"半添加"且移除第二个时 panic(#679)。
  • kqueue:修复监听相对 symlink(#681);在 kqueue 上 watch 指向目录的链接时正确标记已存在条目(#682)。
  • illumos:处理事件期间被删文件不再发送错误(#678)。

这一版说明一个残酷现实:文件系统通知的并发正确性极难保证,尤其是"删除与监听并发"这一经典竞态。

4.3 1.8.0(2024-10-31):调试能力的里程碑

  • all:新增FSNOTIFY_DEBUG环境变量,设为"1"时向 stderr 打印调试日志(#619)。
  • WindowsWatchList()行为与其他平台对齐(#610)。
  • kqueue:忽略Ident=0的事件(#590);设置O_CLOEXEC防止 fd 泄漏给子进程(#617);watch symlink 时以真实路径/path/dir/file而非链接路径path/link/file发事件(#625)。
  • inotify:同时监听父目录时不再为IN_DELETE_SELF发送事件(#620);修复 goroutine 中调用Remove()的 panic(#650)。
  • fen:允许监听已监听目录的子目录(#621)。

FSNOTIFY_DEBUG的用法非常实用——当 fsnotify 作为间接依赖(正是当前仓库的场景)时,很难判断事件是否真的到达了库层,此时在 fsnotify.go 的包注释中可以查到其输出格式:

FSNOTIFY_DEBUG: 11:34:23.633087586 256:IN_CREATE → "/tmp/file-1" FSNOTIFY_DEBUG: 11:34:23.633202319 4:IN_ATTRIB → "/tmp/file-1" FSNOTIFY_DEBUG: 11:34:28.989728764 512:IN_DELETE → "/tmp/file-1"

数字为内核事件掩码,箭头右侧为受影响路径,时间戳精确到纳秒。

4.4 1.7.0(2023-10-22):API 扩张最集中的一版

这是新特性最密集的版本,要求 Go 1.17:

  1. illumos FEN 后端(#371):补齐 illumos/Solaris 支持,自此四大平台后端齐备。
  2. NewBufferedWatcher()(#550、#572):当无法控制内核缓冲区、事件以突发形式大量到达时,可用用户态缓冲通道承接。
  3. AddWith()(#521):Add 的选项化版本。
  4. WithBufferSize()(#521):仅 Windows 生效,可调大ReadDirectoryChangesW()的缓冲区;默认 64K 是所有平台都能工作的最大值,突发事件较多时可能溢出。

行为变更方面同样信息量巨大:

  • inotify:被监听路径重命名后直接移除 watcher(#518)——因为 inotify 无法可靠更新重命名后的名字(之前会出现空字符串名称),kqueue/FEN 早已如此;Windows 不受影响,仍保持监听。
  • Windows:不再监听文件属性变化(#520),因为FILE_ACTION_MODIFIED无法区分"写入"与"属性变更",此前会刷出大量无意义的 Write 事件;缓冲区满时返回ErrEventOverflow而非难以识别的 "short read"(#525)。
  • kqueue:移除被监听目录时确保所有文件事件以正确路径投递(#526,此前会带""".");不再为 symlink 发出虚假 Create(#524)。
  • all:watcher 已关闭时Add()返回ErrClosed(#516);给 no-op 后端(如 WASM、AIX)补上Errors/Events通道便于使用(#528);appengine构建标签下使用 no-op 后端(#528),因为 AppEngine 禁止 unsafe 包、inotify 后端无法编译。

4.5 1.6.0(2022-10-13):现代 API 定型

  • allEvent.Has()Op.Has()(#477);新增cmd/fsnotify命令行工具(#463),用于测试与示例。
  • inotify:不再对不存在的文件忽略事件(#260、#470)——此前会先os.Lstat()检查文件存在性,导致"快速删除再创建"时事件不一致,该检查是 2013 年为解决已不存在的内存泄漏而加的;用非阻塞 inotify 替换 epoll()(#434),大幅简化代码并提速,最低 Linux 版本从 2.6.27 提升到 2.6.32;Remove()未监听路径返回ErrNonExistentWatch(#460)。
  • kqueue取消每 100ms 轮询检查(#480),无事可做时彻底休眠,显著省电省 CPU;跳过不可读文件(#479,kqueue 需为目录中每个文件开 fd);监听文件失败时把路径名放进错误(#471)。
  • Windows:父目录也被监听时修复重命名被监听目录(#370);缓冲区从 4K 增大到 64K(#485);Remove()时关闭文件句柄(#288)。
  • macos:打开文件遇 EINTR 时重试(#475)。

4.6 1.5.x(2021-2022):Go 版本门槛与 API 试错

  • 1.5.4(2022-04-25):WindowsWatcher.WatchList补 defer(#447);修复 OpenBSD 编译(#443)。
  • 1.5.3(2022-04-22):已被撤回(retracted)——误发布了错误分支(#445),是 Go 生态 retract 机制的典型案例。
  • 1.5.2(2022-04-21):WatchList()特性(#374);修复 Windowsraw.FileNameLengthsyscall.MAX_PATH的潜在崩溃(#361);支持不支持的 GOOS 上构建(#424)。
  • 1.5.1(2021-08-24):回退 AddRaw 的"不跟随 symlink"行为(#394)。
  • 1.5.0(2021-08-20):最低 Go 版本提升到 1.12(#381);新增AddRaw不跟随 symlink(#289);Windows 默认跟随 symlink 与其他平台对齐(#289);CI 迁移到 GitHub Actions(#378 等);Go 1.14+ 修复 unsafe 指针转换(#325)。

4.7 1.0–1.4:API 定型期(2014–2020)

  • 1.4.x:1.4.9 把示例迁移到 README;1.4.8 大规模 CI/测试整理,Linux 上创建 epoll/pipe fd 与打开文件均加 close-on-exec(#219、#273),处理 inotify 的IN_Q_OVERFLOW(#334 对应修复,1.4.7);1.4.7 修复 kqueue/BSD/macOS 关闭 watcher 死锁、Linux Remove 死锁、LinuxWatch.Add竞态;1.4.2 用InotifyInit1+IN_CLOEXEC防止 fork/exec 时泄漏 fd(#178);1.4.0 为Event.Op增加String()(#165)。
  • 1.3.x:支持 linux/arm64,从 syscall 切换至 x/sys/unix(#135);Windows 根驱动器双反斜杠修复(#151)。
  • 1.2.x:kqueue CREATE/REMOVE 顺序逻辑修复(#111);Close 竞态修复;arm64 用epoll_create1(#100);kqueue 不监听命名管道(#98);symlink 循环防护(#101);1.2.0 inotify 用 epoll 唤醒 readEvents(#66),关闭 watcher 必停 goroutine(#63)。
  • 1.0.0(2014-08-15):移除 Windows 的AddWatch统一用Add,API 首次正式定稿。

4.8 0.x 与 dev 阶段(2011–2014):从雏形到 Go 标准库候选

0.x 时代的变更奠定了今天的 API 形状,几个关键决策值得注意:

  • 2014-06-12 的 dev 版本一次性完成 API 重命名:Watch()Add()RemoveWatch()Remove()、通道改复数Events/ErrorsFileEventEventIsCreate()等方法 →Op常量。
  • 移除WatchFlags(2014-05-23),理由是"不利用 OS 效率、过滤价值低、测试缺失、Windows 未完整实现"——这是"API 简洁优先"哲学的早期体现。
  • 2014-01-17(0.9.0)曾计划并入 Go 标准库(开发迁移至code.google.com/p/go.exp/fsnotify),虽未成行,但这段历史解释了 fsnotify 为何始终维护极小的公共 API 面。
  • 0.4.0(2012-03-30)引入 Windows 支持(winfsnotify);0.5.0 增加DELETE_SELF;0.7.0 增加 FSNotify flags 并把文件名加回事件路径。

五、实战要点:正确使用 fsnotify

5.1 监听目录而非单个文件

README 的 FAQ 明确警告:不推荐监听单个文件。多数编辑器采用原子写入(先写临时文件再 rename 覆盖),原文件的 watcher 会随原 inode 消失而失效。正确姿势是:

  1. 监听父目录;
  2. Event.Name过滤出感兴趣的文件;
  3. cmd/fsnotify/file.go中有现成示例(go run ./cmd/fsnotify可运行)。

5.2 高频事件的去重

编译大程序可能产生数百个 Write 事件(fsnotify.go 文档原话),且"目录内容变化"在 kqueue/Windows 上还会以目录的 Write 形式出现。两种常用策略:

  • 节流:等 Write 事件安静一段时间后再处理(去重示例见cmd/fsnotify);
  • 过滤:只关心文件内容时,过滤掉路径指向目录的 Write 事件(此语义在 Windows/kqueue 上成立,Linux inotify 不会发目录 Write)。

5.3 平台限制与内核参数

  • Linux:删除文件时,REMOVE 事件要等所有 fd 关闭后才发出,此前只发 CHMOD(inotify 内核行为);fs.inotify.max_user_watches决定单用户 watch 上限、fs.inotify.max_user_instances决定 inotify 实例上限,触顶报错表现为 "no space left on device" 或 "too many open files"。调优命令:
sysctl fs.inotify.max_user_watches=200000 sysctl fs.inotify.max_user_instances=256

持久化写入/etc/sysctl.conf(不同发行版路径有差异,可参考 README.md 平台说明)。

  • kqueue(macOS/BSD):每个文件一个 fd,watch 5 个文件的目录需 6 个 fd,更易触达kern.maxfiles/kern.maxfilesperproc限制。
  • Windows:默认ReadDirectoryChangesW缓冲区 64K,突发事件不足时可用WithBufferSize()调大,溢出时收到ErrEventOverflow;路径可用正斜杠C:/path/to/dir

5.4 已知限制(FAQ 要点)

  • 文件被移动到其他目录后,原监听不会跟随(除非目标目录也被监听);
  • 不递归监听子目录(递归 watcher 在路线图 #18;当前enableRecurse仅在测试中开启,见 fsnotify.go);
  • NFS、SMB、FUSE、/proc、/sys 等文件系统无通知支持,只能等轮询实现(#9);
  • 不要对 Chmod 事件做业务动作——macOS Spotlight、杀毒软件、备份程序可能大量触发。

六、调试建议

当 fsnotify 作为间接依赖(如当前仓库场景)出现"事件缺失/多余"时:

  1. 设置FSNOTIFY_DEBUG=1查看库层原始事件(fsnotify.go 包注释含输出样例);
  2. cmd/fsnotify在最小复现环境验证是否为应用层过滤逻辑问题;
  3. 检查是否触及内核 watch 上限(Linux 用cat /proc/sys/fs/inotify/max_user_watches);
  4. 判断是否为 NFS/虚拟文件系统(此类文件系统根本不产生通知)。

七、总结

回看 fsnotify 的 CHANGELOG,可以提炼出三条清晰的工程主线:

  1. API 极简主义:从 0.x 时代的频繁重命名,到 1.0 定稿后十余年公共 API 基本未变,新增能力(AddWithEvent.Has)都以向后兼容的方式叠加,这是它能成为 Go 事实标准文件监听库的关键。
  2. 并发与资源正确性:接近一半的修复属于竞态(Add/Remove 与删除并发)、fd 泄漏(kqueue Close、close-on-exec)、死锁与 panic——文件系统通知的难点从来不在"收到事件",而在"正确回收资源、优雅处理删除与重命名"。
  3. 平台差异的收敛与坦白:官方用 README 的 FAQ 与平台专属说明,把 inotify 不发目录 Write、Windows 不发 Chmod、kqueue 每个文件一个 fd 等差异如实记录,帮助使用者写出可移植的代码。

当前仓库 vendored 的 v1.10.1 已包含上述全部修复。理解这份 CHANGELOG,等于拿到了阅读任何使用 fsnotify 的项目(日志采集、配置热加载、开发工具等)底层行为的完整知识地图。

【免费下载链接】lokiLike Prometheus, but for logs.项目地址: https://gitcode.com/GitHub_Trending/lok/loki

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

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

通州小程序开发:市场现状与专业团队评估指南

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

作者头像 李华
网站建设 2026/9/13 6:33:05

信号完整性预测中的四大特征工程陷阱

1. 这不是模型不行&#xff0c;是特征在“装死”&#xff1a;一次Si预测实验的真实复盘做信号完整性&#xff08;Si&#xff09;相关预测任务时&#xff0c;我见过太多人一上来就猛调模型——换Transformer、加LSTM层数、堆注意力头、调学习率、试各种正则化……结果训练loss掉…

作者头像 李华
网站建设 2026/9/13 6:30:25

Computer Use:AI操作电脑的技术真相与落地指南

1. 这不是新模型发布&#xff0c;而是一次认知边界的集体误读 “GPT-6 Astra 发布”这个标题在社交平台刷屏时&#xff0c;我正坐在实验室里调试一个本地部署的Agent工作流。第一反应不是兴奋&#xff0c;而是皱眉——因为OpenAI官网、技术博客、arXiv最新提交记录、甚至GitHub…

作者头像 李华
网站建设 2026/9/13 6:30:19

金属低温烤漆技术:80℃实现高效节能涂装

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

作者头像 李华
网站建设 2026/9/13 6:29:38

pdf.js预览深度指南:disableAutoFetch、disableRange与disableStream实战解析

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

作者头像 李华
网站建设 2026/9/13 6:29:10

SQL中CASE表达式详解:原理、陷阱与高阶实战

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

作者头像 李华