get/set 属性设计:类库封装中不可忽视的安全门卫
类库设计里最容易被低估的往往不是复杂算法反而是两个看起来不起眼的关键字get 和 set。我维护过几个被反复“封印”的内部类库打开源码排雷的时候十有八九是属性设计出了问题——要么直接 public 字段裸奔要么自动生成的 getter/setter 没有任何约束调用方想怎么改就怎么改。05-2 是我自己的类库设计笔记里的一节当时给一个模拟项目做封装被一个public List字段坑到加班才开始认真琢磨属性的门道。这次把 get set 单独拎出来聊因为它不是 IDE 点两下生成样板代码那么简单。在类库里get set 决定了 API 边界是否安全、哪些值能读、哪些值只能在内部改、哪些值必须经过校验才能进来。如果你正在写自己的类库或者想把领域对象的封装做得更讲究这篇文章就是给你准备的。1. 为什么说 get set 是类库设计的“门卫”1.1 一个字段引发的线上故障先讲一个我真实踩过的坑。之前做一个某跨平台系统端上同学反馈用户列表会随机变成空。查了半天定位到一个基础工具库的公共方法里直接clear()了一个List。问题源头是另一个模块拿走了这个List的引用在业务里做了一次removeAll结果底层类库的内部状态被连带清空。表面上是调用方用错了 API但根子出在类库自己身上内部集合直接用 public 字段暴露等于是把仓库钥匙复制给所有外部模块。字段是存储位外部拿到引用后可以绕过任何约束随意篡改。类库一旦发布你不可能守在每一个调用点旁边监督唯一的办法是让 API 结构本身就不允许违规操作。后来我把这个字段改成属性并且返回只读视图同类问题再没出现过。不要高估调用方的自觉性调用方总会用你想不到的方式去修改你的数据。门卫的意义不在抓坏人在让坏行为根本发生不了。1.2 属性不是语法糖而是两个方法新手最容易误解的一点属性好像只是“好看一点的字段”。实际上C# 里的属性会被编译成get_XXX和set_XXX两个方法Java 的 getter/setter 本来就是普通方法。所以你在类库代码里写一句obj.Score 100本质上是一次方法调用。既然是方法就可以在校验、转换、通知、懒加载上做文章普通字段做不到这一点。拿生活类比字段是把钱包直接放桌上谁路过都能抽两张属性是银行柜台业务员核对完再给你取钱。多一道流程是麻烦了点但能挡住大部分风险。类库面对的调用方基数大这道流程尤其值。理解了“属性 方法调用”后面所有的问题都好解释了get set 有性能开销是因为方法调用本身有栈帧开销setter 里抛出的异常调用方能感知到不是因为魔法是因为你就在方法体里抛的调试器会在 getter 上反复触发是因为调试器也要通过 getter 读值。这些都是方法的基本特性只是很多教程没点透。2. 类库里的 get set 到底该怎么写2.1 自动属性与 Java 传统写法先分清场景先看两种语言最“基础”的写法。C# 的自动属性public class Student { public string Name { get; set; } public int Score { get; set; } }Java 的传统写法public class Student { private String name; private int score; public String getName() { return name; } public void setName(String name) { this.name name; } public int getScore() { return score; } public void setScore(int score) { this.score score; } }C# 自动属性和 Java 的 IDE 生成 getter/setter本质上都只是“外部能读能写”的最低需求。它们没有把属性升级成有保护的通道只是省去了手写样板代码的麻烦。我的建议是纯数据容器比如 DTO、接口返回的 Json 模型、配置对象直接上自动属性或 IDE 生成的普通 getter/setter。这类对象不承载业务规则没有不变量要守护给自动属性最合适。反过来说如果这个类有业务逻辑比如Score不能超过 100、Name不能为空字符串那就要考虑下面的写法了。2.2 带校验的 setter别让脏数据流进库类库和普通应用程序不一样调用方可能来自不同团队、不同模块对数据规则的理解各不相同。如果你不在入口做校验脏数据就会流到类库内部。C# 完整属性写法public class Student { private int _score; public int Score { get _score; set { if (value 0 || value 100) { throw new ArgumentOutOfRangeException(nameof(value), 分数必须在 0 到 100 之间); } _score value; } } }Java 写法public class Student { private int score; public int getScore() { return score; } public void setScore(int score) { if (score 0 || score 100) { throw new IllegalArgumentException(分数必须在 0 到 100 之间); } this.score score; } }这里有个关键原则校验失败要抛异常不能静默忽略也不能返回false。静默丢弃会吞掉错误调用方以为写成功了等到读出来发现数据不对排查链路会拉得很长。尽早失败、快速失败问题才好定位。另一个要点是异常类型要明确。C# 用ArgumentOutOfRangeException或ArgumentExceptionJava 用IllegalArgumentException这属于调用方最容易理解的输入类异常。不要抛一个宽泛的Exception调用方没法针对处理。2.3 只读属性、私有 set 与不可变设计很多类库里的对象字段一旦构造完成就不该再被外部改变。比如一个订单的状态快照、一条埋点记录、一个配置项。这时最好的设计是“只可读不可改”。C# 写法public class Order { public string OrderId { get; private set; } public DateTime CreatedAt { get; private set; } public Order(string orderId, DateTime createdAt) { OrderId orderId; CreatedAt createdAt; } }Java 写法public class Order { private final String orderId; private final LocalDateTime createdAt; public Order(String orderId, LocalDateTime createdAt) { this.orderId orderId; this.createdAt createdAt; } public String getOrderId() { return orderId; } public LocalDateTime getCreatedAt() { return createdAt; } }不可变对象的好处是线程安全、天然适合缓存和哈希键。但有一个隐藏的坑很多反序列化框架需要无参构造和可写的 setter。private set或者只有 getter 的类可能在反序列化时直接失败。解决方式是在框架层面绕开比如 .NET 的[JsonConstructor]标记构造器参数Java 的 Jackson 用JsonProperty绑定构造器参数。这样既保持只读外观又能让序列化框架正常工作。注意这里不要为了迁就序列化框架而放弃不可变设计。保护不变量比框架方便性更重要框架总有办法适配。2.4 计算属性与延迟加载的适用边界属性不一定要对应一个私有字段也可以是根据其他字段算出来的结果。比如public class OrderItem { public decimal Price { get; set; } public int Count { get; set; } public decimal Total Price * Count; }C# 里这种只读计算属性很常见Java 里对应一个getTotal()方法。好处是每次读取都是最新值而且不会有字段被改坏的风险。延迟加载也经常用在 getter 里private LazyHeavyService _service new LazyHeavyService(() new HeavyService()); public HeavyService Service _service.Value;这样首次读取时才创建实例之后复用。但这里有一条非常容易踩的线不要在 getter 里做重活。我一再强调“属性是方法”所以 getter 里可以塞任何代码但请你想清楚后果。调试器会频繁调用 getter序列化框架也会调用 getter如果你在 getter 里访问数据库、做网络请求、写日志会让系统慢得无法接受甚至产生副作用。我判断 getter 是否适合做重活有一个简单标准你在这个 getter 里敢不敢放一行日志如果放了日志会导致系统卡顿或日志爆炸那这个操作就不应该放在 getter 里。3. 进阶集合属性、序列化与数据绑定的雷区3.1 集合属性永远别把内部列表直接交给调用方这是类库设计里最常见也是最贵的一课。public class Student { private readonly Liststring _tags new Liststring(); public Liststring Tags _tags; // 危险 }上面这段代码看起来没毛病但Tags返回的是内部_tags的引用调用方拿到后可以Clear()、Add()、Remove()完全绕过你的控制。比你预期更严重的是调用方可能只是读了一下比如foreach (var tag in student.Tags)然后在某个异步操作里顺手做了修改排查起来非常头疼。正确做法是返回只读视图public IReadOnlyListstring Tags _tags;Java 对应写法public ListString getTags() { return Collections.unmodifiableList(tags); }这样调用方可以遍历、可以读数量但没法修改集合内容。如果确实需要修改就提供明确的方法比如AddTag(string tag)、RemoveTag(string tag)。方法的好处是操作入口清晰你能在里面加校验、加日志、触发事件。注意返回只读视图不等于万事大吉。如果你的内部集合是ListT在多线程环境下仍然要保证内部写入是同步的只读视图只是解决了“外部篡改”的问题。3.2 序列化与反射get set 的隐形成本类库被其他模块引用之后属性名就是协议的一部分。对接方的 JSON 字段名、ORM 的列映射、反射框架的类型查找全部依赖属性名。一旦你改了属性名比如把Name改成FullName所有老调用方的序列化逻辑会直接崩掉或丢字段。如果确实要改名可以参考下面的兼容方案。C# 的System.Text.Json用[JsonPropertyName][JsonPropertyName(name)] public string FullName { get; set; }Java 的 Jackson 用JsonPropertyJsonProperty(name) private String fullName;另一个反射的性能坑动态调用 getter/setter 很慢不要在热路径里去GetProperty(Score)Invoke。正确做法是在启动时缓存PropertyInfo和MethodInfo或者直接构建委托表达式树。落到类库设计上就是如果某个属性要被反射框架高频调用一定要加上缓存层否则性能报表会很难看。3.3 数据绑定与通知setter 里的副作用怎么管很多 UI 类库依赖属性通知比如 C# 的INotifyPropertyChangedpublic class ViewModel : INotifyPropertyChanged { private string _name; public event PropertyChangedEventHandler PropertyChanged; public string Name { get _name; set { if (_name value) return; _name value; PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Name))); } } }这个写法核心在if (_name value) return;它的价值是只有值真的变化时才触发通知。否则大量无意义的赋值会反复触发绑定、刷新界面甚至造成 A 通知 B、B 又通知 A 的死循环。在类库设计里setter 里的隐藏动作越少越好。如果赋值操作会触发事件、写审计日志、发送消息那应该把语义讲清楚。我的评审习惯是一旦 setter 里有超过两层副作用就该提取成动词方法比如ChangeName、ApplyScore让调用方一眼就知道这次操作不简单。4. 实战一个成绩管理器的 get set 设计复盘4.1 核心类应该长什么样用一个成绩管理器的模拟场景把前面讲的原则落到一个完整的类上。C# 版本public class Student { private readonly Liststring _tags new Liststring(); private int _score; public string Name { get; private set; } public int Score { get _score; set { if (value 0 || value 100) { throw new ArgumentOutOfRangeException(nameof(value), 分数必须在 0 到 100 之间); } _score value; } } public IReadOnlyListstring Tags _tags; public Student(string name) { if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException(姓名不能为空, nameof(name)); Name name; } public void AddTag(string tag) { if (!string.IsNullOrWhiteSpace(tag) !_tags.Contains(tag)) { _tags.Add(tag); } } public void RemoveTag(string tag) { _tags.Remove(tag); } }Java 版本public class Student { private final ListString tags new ArrayList(); private final String name; private int score; public Student(String name) { if (name null || name.isBlank()) { throw new IllegalArgumentException(姓名不能为空); } this.name name; } public String getName() { return name; } public int getScore() { return score; } public void setScore(int score) { if (score 0 || score 100) { throw new IllegalArgumentException(分数必须在 0 到 100 之间); } this.score score; } public ListString getTags() { return Collections.unmodifiableList(tags); } public void addTag(String tag) { if (tag ! null !tag.isBlank() !tags.contains(tag)) { tags.add(tag); } } public void removeTag(String tag) { tags.remove(tag); } }这个设计的细节都在“为什么”上。Name用private set是因为学生姓名在创建后不应该被随意改动。如果业务上真的要允许改名可以单独提供ChangeName的方法在方法里做校验和事件通知而不是开放一个裸的 setter。Score用完整属性是因为分值有边界规则必须在入口校验。为什么不用方法SetScore(int)属性赋值写起来更自然也方便调试器直观查看。Tags返回IReadOnlyList/Collections.unmodifiableList彻底杜绝外部篡改内部集合的可能性。修改操作收敛到AddTag/RemoveTag这两个方法里可以做去重复、加日志、触发事件等逻辑。4.2 外部使用时的行为验证设计完了我会用下面的方式在脑子里过一遍使用场景。正常的读和写var student new Student(小鹿); student.Score 88; Console.WriteLine(student.Score); // 88非法赋值当场失败try { student.Score -10; } catch (ArgumentOutOfRangeException ex) { // 分数必须在 0 到 100 之间 }集合无法被外部修改// student.Tags.Clear() 编译错误IReadOnlyList 没有 Clear 方法 // 或者可以调用的接口上不存在 Clear有效防止误操作 student.AddTag(数学); student.AddTag(数学); // 重复的不会加进去这个场景在 Java 中行为更明显因为unmodifiableList的接口仍然是List调用clear()会在运行期抛UnsupportedOperationException——这也是好的失败方式因为它让问题在第一时间暴露出来。4.3 如果当初直接暴露字段会发生什么把这三种选择放到一起对比结论更清楚设计选择外部表现类库维护成本public 字段读写随意无校验集合引用可被改高任何地方都可能改动内部状态排查困难自动属性无校验读写随意但至少可以加日志、事件中数据合法性仍无保障带校验属性 只读视图读可以写受控集合只读低规则集中在类内部外部绕不过去结合自己的维护经验我会明确推荐第三种。它多写了几行代码但是把“谁在什么时候能改什么数据”这件事变成了类库自己说了算而不是靠人肉自觉。5. 高频问题排查这些年我踩过的 get set 坑5.1 高频问题速查表现象常见原因解决/规避JSON 反序列化后字段丢失类只有带参构造且没有无参构造或 setter 是 private补充无参构造 / 用构造器绑定 Json 属性集合属性被外部清空直接返回内部可变集合的引用改返回只读视图抽象修改入口调试器在 getter 上卡死getter 里做了 IO、重计算、死循环保持 getter 轻量重活放到方法属性赋值后值没变赋值的是旧值setter 里做了相等跳过确认是否是预期语义必要时加审计日志setter 抛异常导致线上报错校验太严格或调用方传入了不合规数据明确校验规则提前在 API 文档中说明边界反射调用 getter/setter 性能极低每次都重新查找 PropertyInfo 并 Invoke启动时缓存 Metadata或用编译期表达式树5.2 排查与验证的三个实用技巧第一个技巧想知道“谁改了我的字段”最快的办法是把它改成属性在 setter 里记调用栈。比如public string Name { get _name; set { if (_name value) return; _name value; System.Diagnostics.Debug.WriteLine(Environment.StackTrace); } }然后运行调试调用栈会直接告诉你是在哪个模块、哪个方法里改的。第二个技巧属性名引用要用nameof不要硬编码字符串。重命名属性的时候nameof可以跟随变更报错字符串不会。类库在重构期尤其吃这个红利。第三个技巧给类库的公开属性做契约测试。把 0、负数、最大值、空集合、重复值全部跑一遍确认校验没有丢。很多项目在早期写好了校验后来某个版本重构的时候不小心删了契约测试能防止这种回归。另外跨模块调用场景下尽量保持属性命名稳定。属性名一旦发布给外部就等价于永久 API。给老属性改名要像给接口方法改名一样慎重能用[JsonPropertyName]或别名兼容就优先兼容。做类库维护的时间越长我越觉得 get set 是你和所有调用方之间的一份契约。契约写得含糊后面就要靠人肉加班来填坑。它看着简单但值得花一个晚上把边界理清楚。最后分享一个我现在还在用的习惯新建类的时候把属性先都写成private set等真需要对外开放写操作时再放行。一开始收紧后面放开很容易一开始就放开等你想收紧的时候等着被调用方堵门投诉吧。如果你也在整理自己的类库笔记强烈建议把 get set 单独列成一小节。这个知识点不深但它是类库设计地基的一部分地基稳了上面盖的楼层才不会塌。

相关新闻

企业微信外部群主动推送:API群发与客服消息开发实战

企业微信外部群主动推送:API群发与客服消息开发实战

做企业微信外部群运营的同学,应该都体会过那种“想推个消息却推不出去”的憋屈感。群建好了,客户也拉进来了,活动海报、优惠券、直播链接,全靠人工在群里一条条发,发多了怕被群主踢,发少了又怕没转化。我这…

2026/10/12 7:49:32 阅读更多 →
给Claude Code接上“外脑”:Ace Data Cloud Google Search MCP实战指南

给Claude Code接上“外脑”:Ace Data Cloud Google Search MCP实战指南

Claude Code 用下来,最强的是它在本地上下文里的来回推敲,最痛的是它对外面正在发生的事一无所知。前几天我在旧项目上升级依赖,它凭记忆重构了三个接口,结果一编译全是废弃 API。我从报错里一步步追到原因:模型的知识…

2026/10/12 7:49:32 阅读更多 →
大厂移动端面试全解析:从语言基础到架构设计的考察体系

大厂移动端面试全解析:从语言基础到架构设计的考察体系

1. 移动端岗位面试考察体系全解析1.1 为什么大厂移动端面试越来越“卷”2020年前后,移动端开发岗位的招聘标准发生了一次非常明显的跃迁。早几年会写四大组件、能独立完成一个列表页和网络请求,基本就能拿到不错的offer。但从那一年开始,面试…

2026/10/12 7:49:32 阅读更多 →

最新新闻

gRPC从入门到实战:跨语言微服务通信与Python并发避坑指南

gRPC从入门到实战:跨语言微服务通信与Python并发避坑指南

我真的是太爱学习了——这种念头冒出来的时候,通常不是因为刚学会了一个妙招,而是因为刚被一个不算难的框架折腾到凌晨一点。最近这个对象就是 gRPC。很长一段时间里,我提到 gRPC 都是能躲就躲:要写 .proto、要装 protoc、跨语言还…

2026/10/12 8:44:35 阅读更多 →
从人耳到算法:MFCC梅尔倒谱系数原理与实战调参指南

从人耳到算法:MFCC梅尔倒谱系数原理与实战调参指南

1. 从人耳到算法:为什么需要梅尔倒谱系数做语音相关项目的人,迟早会撞上MFCC这个词。不管你是做语音识别、声纹比对、情感计算,还是只是想做个简单的关键词唤醒,翻开源码或者论文,MFCC几乎无处不在。但很多人第一次看到…

2026/10/12 8:44:35 阅读更多 →
2025制造业AI落地实战:视觉质检、预测性维护与排程优化

2025制造业AI落地实战:视觉质检、预测性维护与排程优化

1. 制造业AI落地的真实图景:从概念狂欢到车间实战2025年过半,我跑了十几个城市的制造企业,从年产值几十亿的汽车零部件龙头到只有百来号人的精密加工厂,聊下来最大的感受是:制造业对AI的态度已经彻底变了。前两年大家还…

2026/10/12 8:44:35 阅读更多 →
3个爆火Claude Code工具深度解析:TaoToken统一Key下如何选对赛道

3个爆火Claude Code工具深度解析: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/12 8:44:35 阅读更多 →
一站式接入Kimi K3:OpenAI协议兼容的零改造迁移方案

一站式接入Kimi K3:OpenAI协议兼容的零改造迁移方案

1. 项目概述:为什么“一站式接入Kimi K3”突然成了刚需?最近两周,我陆续收到七八位不同背景的朋友咨询——有做教育类SaaS产品的技术负责人,有独立开发AI助教工具的自由开发者,还有某高校实验室带学生做对话系统课题的…

2026/10/12 8:44:35 阅读更多 →
OpenAI DevDay 2026:dots、Spaces与GPT-6.1 Sol的工程化落地指南

OpenAI DevDay 2026:dots、Spaces与GPT-6.1 Sol的工程化落地指南

1. 这不是发布会速记,而是一份开发者视角的“技术落地可行性评估报告”OpenAI DevDay 2026 这个标题本身已经释放出强烈的信号:它不再是一场面向大众的炫技秀,而是一次明确指向工程实践边界的深度技术校准。我全程参与了线上直播与会后48小时…

2026/10/12 8:43:30 阅读更多 →

日新闻

复古胶片颗粒感噪点合成器: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 阅读更多 →