news 2026/10/14 1:34:59

Delphi 插件框架实战:Tangram 模块化架构与热插拔设计

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Delphi 插件框架实战:Tangram 模块化架构与热插拔设计

简介:Tangram Plugin Framework 是一套面向 Delphi 开发者的模块化插件框架源码包,适合希望为 Object Pascal 项目引入可插拔架构的中高级开发者,用于解决单一静态代码结构难以灵活扩展与定制的问题。压缩包共 404 个文件,约 1.2MB,以 140 个 pas 单元、50 个 dfm 窗体、30 个 dproj 工程文件为主,并包含 dpr、dpk、groupproj、bdsproj 等工程与包定义文件,以及 res、cfg、ini、xml 等配置资源,另有少量 exe、dll、chm 与 mdb 示例数据,覆盖框架核心、数据库支持、权限控制、配置工具等多个子模块。资源围绕插件注册与发现、插件间通信、版本兼容、动态加载与卸载、资源管理等关键机制展开,读者可借此研究插件生命周期管理、接口规范设计与主程序解耦思路,并参考其目录组织方式搭建自己的插件化应用。目前已有 744 人学习下载。

1. 拆开一个 Delphi 插件框架压缩包,先想清楚它到底解决什么问题

拿到一个名为 Tangram Plugin FrameWork 的压缩包,很多人的第一反应是解压、找 Demo、双击运行。但如果你做过几年 Delphi 桌面项目,就会知道真正值钱的不是那几个窗体文件,而是它背后那套「主程序不动、功能靠插件拼」的架构思路。Tangram 这个词本身就暗示了拼图式组合:宿主程序提供画布和基础服务,每个插件是一块独立拼图,运行时动态加载、按需卸载。它解决的核心痛点是 Delphi 项目越做越大之后,编译一次要几分钟、改一个模块影响全系统、交付时只能整包替换。适合谁?适合手里有多个中小型 Delphi 桌面系统、需要按客户定制功能、又不想维护多套分支的团队。这一章先把「模块化插件框架」这件事讲透,后面再动手。

2. Tangram 的插件契约怎么定:接口、生命周期与通信总线

2.1 为什么先定接口而不是先写加载器

很多 Delphi 插件方案翻车,不是因为加载器写错了,而是因为接口没定好。常见做法是让插件直接引用主程序的单元,编译期耦合,结果插件一多,主程序单元成了公共厕所,谁都能改。Tangram 这类框架的正确姿势是:主程序和插件之间只通过一个「契约单元」通信,这个单元只包含接口定义、常量、基础类型,不包含任何窗体或业务逻辑。插件编译时只引用契约单元,主程序运行时也只认接口,不认具体类。

我一般会把契约单元命名为Tangram.Contract.pas,里面至少定义三样东西:插件元信息接口、插件生命周期接口、宿主服务接口。元信息告诉宿主「我是谁、版本多少、依赖什么」,生命周期负责Initialize和Finalize,宿主服务则让插件能反向调用主程序提供的日志、配置、权限等能力。

// Tangram.Contract.pas —— 只放接口,不放实现 unit Tangram.Contract; interface type // 插件元信息:宿主靠它做展示和依赖检查 IPluginInfo = interface ['{A1B2C3D4-0001-4000-9000-000000000001}'] function GetName: string; function GetVersion: string; function GetAuthor: string; function GetDependencies: TArray<string>; // 依赖的其他插件名 end; // 宿主服务:插件反向调用主程序能力 IHostServices = interface ['{A1B2C3D4-0002-4000-9000-000000000002}'] procedure Log(const Msg: string); function GetConfig(const Key: string): string; procedure RegisterMenuItem(const Caption: string; const Action: TProc); end; // 插件生命周期:宿主只调这两个方法 IPlugin = interface ['{A1B2C3D4-0003-4000-9000-000000000003}'] function GetInfo: IPluginInfo; procedure Initialize(const Host: IHostServices); procedure Finalize; end; implementation end.

这段代码的关键在于:接口全部带 GUID,这是 Delphi 里跨模块传递接口而不依赖具体实现的基础。GetDependencies返回字符串数组,宿主在加载前先做拓扑排序,避免插件 A 依赖插件 B 但 B 还没加载的情况。IHostServices里的RegisterMenuItem用匿名方法TProc,这样插件不需要引用主程序的菜单单元,主程序也不用知道插件想加什么菜单。

参数说明:GUID 必须唯一,可以用 Delphi 自带的Ctrl+Shift+G生成;TArray<string>需要System.Generics.Collections或直接使用动态数组;匿名方法在 Delphi 2009 之后才支持,如果团队还在用更老的版本,需要改成接口回调。

2.2 插件 DLL 的导出函数与加载时机

契约定好之后,每个插件编译成一个 DLL。DLL 必须导出至少一个标准函数,宿主通过GetProcAddress拿到它,再拿到IPlugin接口。常见做法是导出两个函数:CreatePlugin和DestroyPlugin,或者只导出一个返回接口的函数,由接口自身管理生命周期。

// PluginDemo.Main.pas —— 一个最小插件的实现 unit PluginDemo.Main; interface uses Tangram.Contract; type TPluginInfo = class(TInterfacedObject, IPluginInfo) public function GetName: string; function GetVersion: string; function GetAuthor: string; function GetDependencies: TArray<string>; end; TPluginDemo = class(TInterfacedObject, IPlugin) private FHost: IHostServices; public function GetInfo: IPluginInfo; procedure Initialize(const Host: IHostServices); procedure Finalize; end; // 唯一导出函数,宿主用 GetProcAddress 获取 function CreatePlugin: IPlugin; stdcall; implementation function TPluginInfo.GetName: string; begin Result := 'DemoPlugin'; end; function TPluginInfo.GetVersion: string; begin Result := '1.0.0'; end; function TPluginInfo.GetAuthor: string; begin Result := '某开发者'; end; function TPluginInfo.GetDependencies: TArray<string>; begin Result := nil; // 无依赖 end; function TPluginDemo.GetInfo: IPluginInfo; begin Result := TPluginInfo.Create; end; procedure TPluginDemo.Initialize(const Host: IHostServices); begin FHost := Host; FHost.Log('DemoPlugin 初始化'); FHost.RegisterMenuItem('演示功能', procedure begin FHost.Log('演示功能被点击'); end); end; procedure TPluginDemo.Finalize; begin FHost := nil; end; function CreatePlugin: IPlugin; stdcall; begin Result := TPluginDemo.Create; end; exports CreatePlugin; end.

逻辑说明:TPluginDemo同时实现了IPlugin,内部持有IHostServices引用。Initialize里通过宿主服务注册菜单项,匿名方法捕获了FHost,所以点击菜单时能继续调用宿主日志。exports段只导出CreatePlugin,宿主加载 DLL 后调用它拿到接口,后续全部走接口调用,不需要再导出销毁函数,因为接口引用计数归零时对象自动释放。

参数说明:stdcall是 Windows 下 DLL 导出的标准调用约定,Delphi 默认是register,跨模块必须显式写stdcall。TInterfacedObject提供了引用计数实现,如果自己实现IInterface的三个方法,要小心循环引用导致内存泄漏。

2.3 宿主加载器:从目录扫描到依赖排序

宿主端的加载器负责三件事:扫描插件目录、读取元信息、按依赖顺序加载。我一般会先加载所有 DLL 拿到IPluginInfo,构建依赖图,再做拓扑排序,最后依次调用Initialize。这样即使插件 B 依赖插件 A,也能保证 A 先初始化。

// Tangram.Loader.pas —— 宿主加载器核心逻辑 unit Tangram.Loader; interface uses System.SysUtils, System.Classes, System.Generics.Collections, Tangram.Contract; type TPluginLoader = class private FPlugins: TDictionary<string, IPlugin>; FHost: IHostServices; function LoadDLL(const FileName: string): IPlugin; procedure SortByDependencies(List: TList<IPlugin>); public constructor Create(const Host: IHostServices); destructor Destroy; override; procedure LoadFromDir(const Dir: string); procedure UnloadAll; end; implementation constructor TPluginLoader.Create(const Host: IHostServices); begin inherited Create; FHost := Host; FPlugins := TDictionary<string, IPlugin>.Create; end; destructor TPluginLoader.Destroy; begin UnloadAll; FPlugins.Free; inherited; end; function TPluginLoader.LoadDLL(const FileName: string): IPlugin; var Handle: THandle; Proc: function: IPlugin; stdcall; begin Handle := LoadLibrary(PChar(FileName)); if Handle = 0 then raise Exception.CreateFmt('加载 %s 失败,错误码 %d', [FileName, GetLastError]); try @Proc := GetProcAddress(Handle, 'CreatePlugin'); if not Assigned(@Proc) then raise Exception.CreateFmt('%s 未导出 CreatePlugin', [FileName]); Result := Proc; except FreeLibrary(Handle); raise; end; end; procedure TPluginLoader.LoadFromDir(const Dir: string); var SR: TSearchRec; List: TList<IPlugin>; Plugin: IPlugin; begin List := TList<IPlugin>.Create; try if FindFirst(IncludeTrailingPathDelimiter(Dir) + '*.dll', faAnyFile, SR) = 0 then begin repeat if (SR.Attr and faDirectory) = 0 then begin Plugin := LoadDLL(IncludeTrailingPathDelimiter(Dir) + SR.Name); List.Add(Plugin); end; until FindNext(SR) <> 0; FindClose(SR); end; SortByDependencies(List); for Plugin in List do begin FPlugins.Add(Plugin.GetInfo.GetName, Plugin); Plugin.Initialize(FHost); end; finally List.Free; end; end; procedure TPluginLoader.SortByDependencies(List: TList<IPlugin>); begin // 简化版:按依赖名排序,实际项目建议用拓扑排序 List.Sort(TComparer<IPlugin>.Construct( function(const L, R: IPlugin): Integer begin Result := Length(L.GetInfo.GetDependencies) - Length(R.GetInfo.GetDependencies); end)); end; procedure TPluginLoader.UnloadAll; var Pair: TPair<string, IPlugin>; begin for Pair in FPlugins do Pair.Value.Finalize; FPlugins.Clear; end; end.

逻辑说明:LoadDLL用LoadLibrary加载 DLL,GetProcAddress取CreatePlugin地址,转成函数指针后调用。LoadFromDir遍历目录下所有 DLL,先全部加载到列表,再排序,最后依次Initialize。SortByDependencies这里用了简化版按依赖数量排序,真实项目里应该用拓扑排序,否则依赖链一深就会乱序。

参数说明:LoadLibrary的路径建议用绝对路径,避免当前工作目录变化导致找不到 DLL。GetLastError在LoadLibrary失败后立即调用,否则可能被其他 API 覆盖。FPlugins用TDictionary按插件名索引,方便后续按名查找或卸载。

3. 把 Tangram 跑起来:从零搭建宿主与第一个插件

3.1 宿主工程的最小骨架

宿主工程不需要复杂,一个 VCL 窗体程序就够。窗体上放一个TMemo显示日志,一个TButton触发加载。关键是宿主必须实现IHostServices,并把自身实例传给加载器。

// HostMain.pas —— 宿主主窗体 unit HostMain; interface uses Winapi.Windows, Winapi.Messages, System.SysUtils, System.Variants, System.Classes, Vcl.Graphics, Vcl.Controls, Vcl.Forms, Vcl.Dialogs, Vcl.StdCtrls, Tangram.Contract, Tangram.Loader; type TMainForm = class(TForm, IHostServices) MemoLog: TMemo; BtnLoad: TButton; procedure BtnLoadClick(Sender: TObject); private FLoader: TPluginLoader; public procedure Log(const Msg: string); function GetConfig(const Key: string): string; procedure RegisterMenuItem(const Caption: string; const Action: TProc); end; var MainForm: TMainForm; implementation {$R *.dfm} procedure TMainForm.Log(const Msg: string); begin MemoLog.Lines.Add(Format('[%s] %s', [TimeToStr(Now), Msg])); end; function TMainForm.GetConfig(const Key: string): string; begin Result := ''; // 实际项目从 ini 或注册表读取 end; procedure TMainForm.RegisterMenuItem(const Caption: string; const Action: TProc); begin Log('插件注册菜单:' + Caption); // 实际项目动态创建 TMenuItem 并绑定 Action end; procedure TMainForm.BtnLoadClick(Sender: TObject); begin if not Assigned(FLoader) then FLoader := TPluginLoader.Create(Self); FLoader.LoadFromDir(ExtractFilePath(Application.ExeName) + 'Plugins'); Log('插件加载完成'); end; end.

逻辑说明:TMainForm同时继承TForm并实现IHostServices,这样Self可以直接传给TPluginLoader.Create。BtnLoadClick里从 exe 同级目录的Plugins文件夹加载所有 DLL。RegisterMenuItem目前只打日志,实际项目里应该动态创建菜单项并绑定Action。

参数说明:ExtractFilePath(Application.ExeName)拿到 exe 所在目录,拼接Plugins子目录。如果插件放在其他位置,改成配置项读取。FLoader延迟创建,避免窗体创建时就加载插件导致启动慢。

3.2 编译插件 DLL 的目录与输出设置

插件工程编译时,输出路径要指向宿主的Plugins目录,或者编译后手动拷贝。Delphi 项目里在Project Options的Delphi Compiler下设置Output directory为..\Host\Plugins。同时要确保插件工程引用了Tangram.Contract.pas,但不要引用宿主工程的其他单元。

# 命令行编译插件(假设使用 dcc32) dcc32 -B PluginDemo.dpr -E..\Host\Plugins -NSSystem;Vcl;Tangram

逻辑说明:-B强制重新编译,-E指定输出目录,-NSSystem;Vcl;Tangram指定单元搜索路径。实际项目里更推荐用.dproj配合 MSBuild,但命令行方式适合 CI 环境。

参数说明:-E后面的路径要存在,否则编译失败。-NS里的Tangram是契约单元所在目录,如果契约单元在独立文件夹,要写全路径。

3.3 验证插件是否真的被加载

加载完成后,在宿主日志里应该能看到插件Initialize里打的日志。如果没看到,按以下顺序排查:DLL 是否在Plugins目录、是否导出了CreatePlugin、LoadLibrary是否返回 0、GetProcAddress是否拿到地址。我一般会在LoadDLL里加详细日志,把每个 DLL 的加载结果都打出来。

// 在 LoadDLL 里加日志 FHost.Log(Format('尝试加载:%s', [FileName])); Handle := LoadLibrary(PChar(FileName)); if Handle = 0 then begin FHost.Log(Format('加载失败,错误码:%d', [GetLastError])); Exit(nil); end; FHost.Log('加载成功,查找 CreatePlugin');

逻辑说明:日志要打在关键分支上,这样即使插件没加载成功,也能从日志看出卡在哪一步。GetLastError返回 0 表示成功,但LoadLibrary失败时返回非零,常见错误码 126 表示找不到模块,193 表示不是有效的 Win32 程序。

参数说明:如果插件是 64 位而宿主是 32 位,LoadLibrary会返回 193。Delphi 项目要确保宿主和插件的目标平台一致,要么都是 32 位,要么都是 64 位。

4. 避坑与排查:Delphi 插件框架最容易翻车的五个地方

4.1 接口跨模块传递导致内存泄漏

现象:插件卸载后,宿主内存不降反升,反复加载卸载几次后内存持续增长。原因:Delphi 的接口引用计数在跨 DLL 边界时,如果 DLL 和 EXE 使用不同的内存管理器,_Release调用可能不会正确释放对象。解决:在插件 DLL 的initialization段调用IsMultiThread := True并确保使用ShareMem或者统一内存管理器。更稳妥的做法是插件对象由 DLL 内部管理,宿主只持有接口,DLL 卸载前先调用Finalize并释放所有接口引用。

4.2 插件依赖顺序错乱导致初始化失败

现象:插件 B 在Initialize里调用插件 A 提供的服务,但 A 还没初始化,报空指针。原因:加载器按文件名顺序加载,没有做依赖排序。解决:在IPluginInfo里返回依赖列表,加载器先构建依赖图,用拓扑排序确定加载顺序。如果依赖成环,直接报错并拒绝加载,不要试图自动解决。

4.3 宿主关闭时插件未正确卸载

现象:关闭宿主时弹出异常,提示某个 DLL 无法释放。原因:插件在Finalize里没有释放自己创建的窗体或线程,DLL 引用计数不为零。解决:在Finalize里显式释放所有资源,窗体用Free而不是Release,线程要Terminate后WaitFor。宿主在FormClose里先调用FLoader.UnloadAll,再释放加载器。

4.4 调试插件时断点不生效

现象:在插件代码里下断点,运行时断点变空心,提示无法加载符号。原因:宿主加载的是编译后的 DLL,而 IDE 调试的是插件工程,两者没有关联。解决:在宿主工程的Run参数里设置Host Application为宿主 exe,然后调试宿主;或者把插件工程和宿主工程放在同一个项目组里,用LoadLibrary的调试模式加载。我一般会在插件 DLL 的CreatePlugin里加OutputDebugString,用调试工具查看输出。

4.5 插件版本与宿主契约不匹配

现象:新插件在老宿主上加载后,调用某个接口方法时崩溃。原因:契约单元新增了方法,但老宿主的接口 GUID 没变,虚函数表偏移错位。解决:每次修改契约单元,必须重新生成接口 GUID,并在IPluginInfo里返回宿主最低版本要求。宿主加载插件前先检查版本,不匹配就拒绝加载并提示用户升级。

5. 进阶:让 Tangram 支持热插拔与插件间通信

5.1 热插拔的实现边界

热插拔不是真的「拔了再插」,而是「卸载旧版本、加载新版本」。Delphi 的 DLL 一旦LoadLibrary,在FreeLibrary之前无法替换文件。所以热插拔的正确流程是:先调用插件的Finalize,释放所有接口引用,再FreeLibrary,然后拷贝新 DLL 覆盖,最后重新LoadLibrary。这里有个坑:如果插件注册了菜单项或窗体,必须先反注册,否则宿主还持有插件的匿名方法引用,FreeLibrary会失败。

procedure TPluginLoader.ReloadPlugin(const Name, NewFile: string); var Old: IPlugin; begin if FPlugins.TryGetValue(Name, Old) then begin Old.Finalize; FPlugins.Remove(Name); Old := nil; // 引用计数归零,DLL 可释放 end; // 此处可拷贝 NewFile 覆盖旧 DLL LoadDLL(NewFile); end;

逻辑说明:Old := nil是关键,只有接口引用计数归零,DLL 才会真正卸载。如果宿主其他地方还持有这个接口,FreeLibrary不会立即生效,需要确保所有引用都释放。

参数说明:NewFile应该是新 DLL 的完整路径,覆盖前先备份旧文件,防止加载失败后无法回滚。

5.2 插件间通信:事件总线还是直接引用

插件 A 想调用插件 B 的功能,有两种做法:一是宿主提供事件总线,插件通过总线发布订阅;二是插件 B 把接口注册到宿主,插件 A 从宿主查询。我一般推荐事件总线,因为解耦更彻底。宿主实现一个简单的IEventBus,插件通过IHostServices获取总线实例,发布事件时带上事件类型和数据,订阅者按类型过滤。

type IEventBus = interface procedure Publish(const Topic: string; const Data: TObject); procedure Subscribe(const Topic: string; const Handler: TProc<TObject>); end;

逻辑说明:Publish和Subscribe都用字符串主题,避免插件之间引用具体类型。Data用TObject基类,实际传递时可以用TJSONObject或自定义数据类。订阅者要记得在Finalize里取消订阅,否则宿主还持有匿名方法引用,导致插件无法卸载。

参数说明:主题命名建议用插件名.功能名格式,避免冲突。TProc<TObject>是泛型匿名方法,Delphi 2009 之后支持。

5.3 用配置文件控制插件加载

不是所有插件都需要每次加载。我一般会在宿主目录放一个plugins.ini,列出启用和禁用的插件名,加载器先读配置再决定加载哪些。

[Plugins] Enabled=DemoPlugin,ReportPlugin Disabled=DebugPlugin
// 读取配置 function TPluginLoader.IsEnabled(const Name: string): Boolean; var Ini: TIniFile; Enabled: string; begin Ini := TIniFile.Create(ExtractFilePath(ParamStr(0)) + 'plugins.ini'); try Enabled := Ini.ReadString('Plugins', 'Enabled', ''); Result := Pos(Name, Enabled) > 0; finally Ini.Free; end; end;

逻辑说明:TIniFile是 Delphi 自带的 ini 读写类,ReadString读取启用列表,Pos判断插件名是否在列表中。实际项目里可以用更严格的解析,比如按逗号分割后精确匹配,避免Demo匹配到DemoPlugin。

参数说明:ParamStr(0)是 exe 完整路径,ExtractFilePath取目录。ini 文件编码建议用 ANSI 或 UTF-8 with BOM,避免中文插件名乱码。

5.4 一个验证插件框架是否合格的检查表

检查项合格标准验证方法
接口隔离插件不引用宿主业务单元查看插件 uses 列表
依赖排序依赖插件先初始化构造 A 依赖 B 的场景
卸载干净卸载后内存回落反复加载卸载 10 次
版本校验不匹配拒绝加载改契约 GUID 后测试
异常隔离单插件崩溃不影响宿主插件 Initialize 里抛异常

这张表是我在多个 Delphi 项目里踩坑后总结的,每次新做插件框架都拿它过一遍。尤其是异常隔离,插件Initialize里抛异常时,加载器必须捕获并记录,不能让它冒泡到宿主主循环,否则整个程序直接挂掉。

5.5 我自己的习惯:先写契约测试再写插件

现在我做任何 Delphi 插件框架,第一件事不是写加载器,而是写一个契约测试工程:模拟宿主实现IHostServices,加载一个空插件,验证接口调用、引用计数、卸载流程全部正常。这个测试工程跑通之后,再写真实插件和宿主。这样做的代价是多花半天,但省下的是后面几天排查内存泄漏和加载失败的时间。血泪经验是:插件框架的 bug 往往不在插件本身,而在契约边界上,先把边界测透,后面就是体力活。希望帮到你。

本文还有配套的精品资源,点击获取

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

Matlab2019b信号峰值检测与提取:findpeaks参数调优与预处理实战

/* 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 1:34:51

金蝶云星空8.1升级实战:二开插件、接口签名与数据备份避坑指南

简介&#xff1a;金蝶云星空企业版8.1发版说明面向企业信息化负责人、财务与供应链业务骨干及金蝶产品实施顾问&#xff0c;用于系统了解V8.1在功能与平台层面的升级全貌。文档围绕产品特性、产品形态、云服务构成与主要适用范围展开&#xff0c;并逐项梳理员工服务、财务云、供…

作者头像 李华
网站建设 2026/10/14 1:34:48

STM32直接生成二维码:从编码原理到OLED/LCD显示实践

/* 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 1:34:32

DLT645电表接入OPC UA平台:VFBOX网关配置流程与调试经验

/* 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 1:34:09

Java并发volatile深度解析:可见性、重排机制与双重检查锁

/* 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 1:33:51

主驱逆变器实战笔记:车规芯片的硬门槛与长期回报

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

作者头像 李华