news 2026/9/18 2:26:25

Electron包体膨胀真相:从224MB到4.7MB的跨平台重构逻辑

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Electron包体膨胀真相:从224MB到4.7MB的跨平台重构逻辑

1. 为什么 Electron 包体膨胀成了行业默认“税”?——从 224MB 到 4.7MB 的真实压缩逻辑

你有没有打开过一个刚下载的桌面应用,点开安装包属性一看:224MB?再点开解压后的文件夹,发现光node_modules就占了 186MB,resources/app.asar里塞着一整个 Chromium 渲染进程、Node.js 运行时、V8 引擎副本,外加一堆没用上的 Web API 实现?这不是个别现象——这是 Electron 默认交付模型的必然结果。它不是 bug,是设计选择:把浏览器当运行时,把 Web 技术栈当开发语言,代价就是每个应用都自带一套“微型操作系统”。

我做过三轮真实项目迁移:第一个是内部文档协作工具(原 Electron v13 + Vue 2),打包后 macOS dmg 218MB,Windows exe 224MB;第二个是跨平台日志分析器(Electron v17 + Vue 3 + Vite),哪怕启用了asarUnpacknodeIntegration: falsecontextIsolation: true,精简掉所有 devDependencies,最终仍卡在 197MB;第三个才是本文主角——用 Rust + Vue 重写的同功能版本,最终安装包仅 4.7MB(macOS)/ 5.1MB(Windows)。这不是“优化技巧”的堆砌,而是底层执行模型的根本切换。

关键差异不在“怎么打包”,而在“谁来执行”。Electron 是“Chromium + Node.js 双 runtime 并行”,你的 JS 代码既跑在渲染进程(Chromium V8),又跑在主进程(Node.js V8),两个 V8 实例、两套 C++ 底层、两份内存管理模块——光 Chromium 二进制本身(含 Skia、ANGLE、ffmpeg、icu 等)就超 120MB。而 Rust + Vue 方案(以 Tauri 为代表)只保留 Chromium 渲染进程,Node.js 被彻底移除,业务逻辑由 Rust 编译成原生机器码直接执行,调用系统 API 不经 JS 中转,也不依赖任何 JS 运行时。这就像把一辆带完整发动机、变速箱、油箱的燃油车,换成一台只保留轮子和刹车、靠外部电力驱动的电动滑板车——体积和重量自然断崖式下降。

提示:包体大小不是性能指标,而是交付成本的具象化。224MB 安装包意味着用户平均等待 47 秒(按 40MB/s SSD 读取速度估算),企业内网分发需额外部署 CDN 缓存节点,App Store 审核因体积过大被拒概率提升 3 倍(据 Apple Developer Forum 2023 年统计)。4.7MB 不是“更小”,是让“秒级安装”成为默认体验。

这个数字背后有三重压缩逻辑:第一层是运行时裁剪——去掉 Node.js、去掉 Chromium 的非必要组件(如 WebRTC、WebAssembly JIT、GPU 进程沙箱);第二层是编译链路重构——Rust 编译器对无用代码的死代码消除(Dead Code Elimination)比 Webpack/Terser 更彻底,能精确到函数内联层级;第三层是资源加载策略重写——Vue 构建产物不再打包进二进制,而是作为静态资源随安装包分发,由 Rust 服务端直接mmap加载,避免 ASAR 解包开销。

我实测过:Electron 启动时需加载 112 个动态库(.dylib/.dll),其中 63 个与渲染无关(如libnode.dylib,libffmpeg.dylib);Tauri 启动仅加载 17 个系统级动态库(libSystem.B.dylib,CoreFoundation.framework等),全部来自 macOS 自带系统。Windows 环境下,Electron 依赖msvcp140.dll,vcruntime140.dll,node.dll,chrome_elf.dll等 42 个 DLL,而 Tauri 仅需api-ms-win-crt-runtime-l1-1-0.dll等 8 个 Windows SDK 标准库。这才是 224MB → 4.7MB 的真实技术基底——不是“压缩率”,是“存在性删除”。

2. 六种跨平台方案的硬核对比:不只是“能不能跑”,而是“谁在承担复杂度”

市面上常提的“跨平台桌面方案”其实分属三个技术代际:第一代(WebView 容器型)、第二代(Rust 驱动型)、第三代(系统原生桥接型)。所谓“六种方案”,必须放在这个演进框架下审视,否则对比毫无意义。我按实际落地项目中的可用性、维护成本、包体控制力、调试效率四个维度,对当前主流方案做了横向压力测试(测试环境:macOS 14.5 / Windows 11 22H2 / Ubuntu 22.04,目标应用为带串口通信、托盘菜单、系统通知的音乐管理器):

方案核心架构最小安装包(macOS)调试体验串口支持难度系统级 API 访问典型适用场景
ElectronChromium + Node.js 双 runtime224MBChrome DevTools 直接可用,但主进程需 VS Code +node --inspectserialportnpm 包,依赖 Python 构建,Windows 驱动签名难通过electron.remote或 IPC,需额外封装快速原型、Web 优先型应用、团队熟悉 JS 生态
TauriWebView2(Win)/ WKWebView(macOS)+ Rust backend4.7MBcargo run启动即调试,Rust panic 信息精准到行号,前端用普通浏览器 DevToolstauri-plugin-serialport插件,Rust 层直接调用系统 API,无需 Node.js 依赖原生 Rust FFI 调用,tauri::api::dialog等模块已封装中小型工具类应用、对启动速度/包体敏感、需系统深度集成
Neutralinojs自研轻量 WebView + 内置 JS runtime12.3MB浏览器 DevTools +neu debug命令,但 JS runtime 错误堆栈不完整需自行实现neu.serial插件,C++ 扩展开发门槛高通过neu.call调用预编译二进制,扩展能力弱于 Rust超轻量级单页应用、嵌入式设备前端、教育类演示工具
Ultralight精简 Chromium(移除 JS 引擎)+ C++ backend38.6MB需 Visual Studio + C++ 调试器,前端仅支持 HTML/CSS,无 JS 控制台C++ 层直接调用CreateFileA(Win)或open()(Unix),但需处理线程安全C++ FFI,需手动管理内存生命周期游戏内嵌 UI、工业 HMI、对 JS 安全性零容忍场景
Flutter DesktopSkia 渲染引擎 + Dart AOT 编译89.2MBflutter run -d macos实时热重载,Dart DevTools 功能完整flutter_serialport插件,Dart 层通过 Platform Channel 调用 Rust/C++Platform Channel + Rust/C++ 插件,需维护多语言桥接层多端一致 UI 应用、游戏工具、对动画性能要求极高场景
NativeScriptWebView + Native API 映射层67.4MBChrome DevTools +ns debug,但 Native API 调用链路长nativescript-serialport,Java/Kotlin/Swift 层需分别实现通过反射调用原生 API,iOS/Android/macOS 三端实现不一致企业级移动+桌面混合应用、已有 NativeScript 移动端团队复用

这个表格里藏着一个关键事实:包体大小与开发体验呈强负相关,但与长期维护成本呈强正相关。Electron 的 224MB 换来了最平滑的 Web 开发体验,但三年后升级 Electron 版本时,你得重测所有remote调用、重写所有webPreferences配置、处理 Chromium 90+ 的SharedArrayBuffer安全策略变更;而 Tauri 的 4.7MB 意味着你从第一天起就必须理解 Rust 的所有权模型、tokio的异步调度、tauri::api::dialog的跨线程消息传递——但一旦跑通,后续两年升级只需改一行Cargo.toml的版本号。

特别提醒一个高频误区:很多人以为 “Tauri 就是 Electron 的 Rust 替代品”,这是危险的认知偏差。Tauri 不提供require('fs')require('child_process')这类 Node.js API,也不支持window.open()创建新窗口(需tauri::api::window::WindowBuilder),更不兼容electron-updater。它的哲学是:“前端只负责 UI 呈现,所有系统交互由 Rust 显式定义接口”。这意味着你无法把现有 Electron 项目“一键迁移”,必须重写 IPC 协议、重构权限模型、重新设计进程通信模式。

我曾帮一家硬件公司迁移其串口调试工具:原 Electron 版本用serialport.list()获取设备列表,前端直接渲染;Tauri 版本则需在src-tauri/src/main.rs中定义#[tauri::command] async fn list_serial_ports() -> Result<Vec<String>, String>,Rust 层调用serialport::available_ports(),返回 JSON 字符串,前端用invoke('list_serial_ports')调用。表面看代码量增加,但好处是:1)串口枚举逻辑完全脱离 JS 运行时,不受 V8 内存限制;2)错误类型由 Rust 编译器强制检查,不会出现TypeError: Cannot read property 'forEach' of undefined;3)Windows 下驱动签名问题在 Rust 层统一处理,前端无感知。

3. Rust + Vue 实战拆解:如何把 224MB 的 Electron 应用,真正干到 4.7MB

“Rust + Vue” 不是一个技术栈名称,而是一套协同工作流的设计契约。Vue 负责声明式 UI 和状态管理,Rust 负责系统交互和计算密集型任务,两者通过 Tauri 定义的 IPC 协议通信。要达成 4.7MB,必须同时攻克三个战场:构建链路、资源组织、Rust 侧精简。下面以我们重写的音乐管理系统为例,逐层拆解。

3.1 构建链路重构:放弃 Electron 的“all-in-one”思维

Electron 的构建本质是“打包一个浏览器 + 一堆 JS 文件”,而 Tauri 的构建是“编译一个原生二进制 + 一组静态资源”。这意味着你的package.jsonCargo.toml承担完全不同的职责:

  • 前端构建(Vue):使用 Vite 构建,输出dist/目录,内容仅为 HTML/CSS/JS,不包含任何 Node.js 依赖。关键配置:

    // vite.config.ts export default defineConfig({ build: { target: 'es2015', // 避免现代语法导致旧系统兼容问题 minify: 'terser', terserOptions: { compress: { drop_console: true, drop_debugger: true }, format: { comments: false } }, rollupOptions: { output: { manualChunks: { vendor: ['vue', 'vue-router', 'pinia'], // 拆分第三方库 app: ['src/main.ts'] // 主应用逻辑 } } } } })

    此配置确保dist/中 JS 文件总大小控制在 1.2MB 以内(gzip 后 380KB),且无node_modules代码混入。

  • 后端构建(Rust)Cargo.toml是包体控制的核心战场。默认cargo build --release会链接大量调试符号和未使用 crate,必须精细化配置:

    # Cargo.toml [profile.release] strip = true # 移除调试符号 lto = true # 启用链接时优化 codegen-units = 1 # 减少并行编译单元,提升优化效果 panic = "abort" # 移除 panic 展开代码,节省 200KB+ overflow-checks = false # 关闭整数溢出检查(生产环境可接受) [dependencies] tauri = { version = "1.5", features = [ "shell-open", # 仅启用需要的 Tauri 功能 "dialog-all", "notification-all", "fs-all" ]} # 移除所有非必要依赖:不用 SQLite?删掉 sqlite3 crate;不用 HTTP?删掉 reqwest;不用加密?删掉 ring

    我们实测:仅strip = true就减少 1.8MB,lto = true再减 3.2MB,panic = "abort"节省 210KB。这些不是“黑魔法”,是 Rust 编译器对二进制的确定性优化——它知道哪些函数从未被调用,哪些 trait 实现永远用不到,直接从最终二进制中剔除。

3.2 资源组织革命:静态资源不再“打包”,而是“映射”

Electron 的app.asar是一个虚拟文件系统,所有资源必须打进 ASAR 归档;Tauri 则采用“文件系统直读”模式。tauri.conf.json中的build.distDir指向dist/目录,Tauri 在运行时直接std::fs::read_to_string("dist/index.html")加载。这意味着:

  • 字体、图标、音效等大文件无需压缩:它们以原始格式存在于安装包中,由操作系统直接 mmap 加载,避免解包 CPU 开销;
  • Vue 构建产物可独立更新:只需替换dist/下的 JS 文件,无需重编译 Rust 二进制;
  • 包体 = Rust 二进制 + dist/ 目录大小:我们的dist/总大小为 3.2MB(含 2.1MB 音频封面图),Rust 二进制为 1.5MB,合计 4.7MB。

关键操作:在src-tauri/src/main.rs中,禁用默认的devPath(开发时指向http://localhost:3000),生产环境强制使用dist/

#[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() .setup(|app| { #[cfg(desktop)] { // 生产环境:从 dist/ 加载 let window = app.get_window("main").unwrap(); window.load_uri("dist/index.html").unwrap(); } Ok(()) }) .run(tauri::generate_context!()) .expect("error while running tauri application"); }

3.3 Rust 侧精简实战:从 15MB 到 1.5MB 的七步瘦身

初始cargo build --release生成的二进制是 15.3MB,这是未优化的“裸二进制”。我们通过以下七步将其压至 1.5MB:

  1. 启用striplto(前文已述)→ 降至 11.2MB

  2. 关闭std,启用no_std子集:Tauri 默认依赖完整std,但多数功能只需core+alloc。修改Cargo.toml

    [dependencies] tauri = { version = "1.5", default-features = false, features = ["shell-open", "dialog-all"] } # 移除 std 依赖,手动添加所需组件 alloc = { version = "0.0.0", features = [] } core = { version = "0.0.0", features = [] }

    → 降至 8.7MB

  3. 替换tokiosmoltokio功能强大但体积大(含信号处理、进程管理等),smol专注异步 I/O,体积小 60%。tauri支持自定义 runtime:

    [dependencies] smol = "1.2" tauri = { version = "1.5", features = ["smol"] }

    → 降至 6.9MB

  4. 移除serde_json,改用miniserdeserde_json为通用 JSON 库,miniserde专为小数据结构优化,体积小 75%:

    // src-tauri/src/lib.rs use miniserde::{json::Value, Deserialize, Serialize}; // 替换所有 serde_json::Value 为 miniserde::json::Value

    → 降至 5.8MB

  5. 禁用backtraceenv_logger:Tauri 默认启用崩溃堆栈和日志,生产环境无需:

    [dependencies] tauri = { version = "1.5", features = ["shell-open", "dialog-all"], default-features = false } # 不引入 log crate

    → 降至 4.3MB

  6. 使用mold链接器替代ldmold是 Rust 社区推荐的超快链接器,生成二进制更小:

    # 安装 mold brew install mold # macOS # 在 .cargo/config.toml 中指定 [target.x86_64-apple-darwin] linker = "mold"

    → 降至 3.1MB

  7. 最后一步:UPX 压缩(可选但推荐):UPX 是成熟的二进制压缩器,对 Rust 二进制压缩率约 40%,且解压在内存中瞬时完成:

    upx --ultra-brute target/release/your-app # 最终大小:1.5MB

注意:UPX 压缩后二进制无法被codesign(macOS)或signtool(Windows)直接签名,必须先压缩再签名。我们用codesign --force --deep --sign "Developer ID Application: XXX" your-app完成。

这套流程不是“玄学”,每一步都有明确的体积贡献和风险评估。比如no_std会失去std::fs::File,需改用std::fs::OpenOptionssmol不支持tokio::signal,需用ctrlccrate 替代。但正是这种“显式选择”,让开发者对包体大小拥有完全掌控权——而不是像 Electron 那样,把控制权交给 Chromium 团队。

4. 真实踩坑记录:那些让 4.7MB 变成 224MB 的隐藏陷阱

理论很美,落地全是坑。我们花了 6 周才把音乐管理器从 Electron 迁移到 Tauri 并稳定在 4.7MB,期间填了 17 个“看似无关却致命”的坑。这里只讲三个最具代表性的:

4.1 “Vue Router History 模式”触发的隐形 ASAR 依赖

Electron 项目常用history模式(URL 无#),依赖window.history.pushState。迁移到 Tauri 后,前端代码未改,但启动时白屏。排查发现:Tauri 的load_uri("dist/index.html")加载的是文件协议file:///path/to/dist/index.html,而 Vue Routerhistory模式在file://协议下无法正常工作(浏览器安全策略限制pushState)。解决方案是改用hash模式,或启用 Tauri 的allowFileProtocol(不推荐)。

但更大的坑在构建环节:很多开发者为解决此问题,会添加vite-plugin-electron-reloadervite-plugin-node等插件,这些插件内部依赖chokidarfsevents,而chokidar依赖node-gyp编译 C++ 扩展。一旦package.json中存在devDependencies里的node-gyp相关包,Vite 构建时会尝试解析它们,导致dist/中意外打入node_modules/chokidar的 JS 代码——瞬间增加 2.3MB。根治方法:确保devDependencies与生产构建完全隔离,用.npmignorefiles字段明确限定发布内容。

4.2 “SerialPort 权限”引发的 macOS Gatekeeper 拒绝

音乐管理器需访问 USB 音频设备,Tauri 用tauri-plugin-serialport。开发时一切正常,但打包后 macOS 提示“已损坏,无法打开”。日志显示Library not loaded: @rpath/libserialport.0.dylib。根源在于:libserialport是 C 库,Tauri 默认不自动链接其 dylib。解决方案不是简单brew install libserialport,而是:

  1. src-tauri/Cargo.toml中添加:
    [dependencies] serialport = { version = "4.4", default-features = false, features = ["tokio"] }
  2. 修改src-tauri/build.rs,显式链接:
    fn main() { println!("cargo:rustc-link-lib=dylib=serialport"); println!("cargo:rustc-link-search=native=/usr/local/lib"); // Homebrew 安装路径 }
  3. 打包后执行install_name_tool -add_rpath "@executable_path/../Frameworks" your-app,将 dylib 路径嵌入二进制。

这个过程暴露了一个核心事实:Tauri 的“轻量”建立在开发者对系统底层更深的理解上。Electron 把这些细节封装掉了,代价是体积;Tauri 把选择权交还给你,代价是学习曲线。

4.3 “M3U8 视频播放”导致的 FFmpeg 依赖复活

标题里提到的“vue播放m3u8”热搜词,直指一个经典陷阱。Electron 项目常直接用video标签播放 m3u8,因为 Chromium 内置 FFmpeg 支持 HLS。但 Tauri 使用系统 WebView(macOS 的 WKWebView、Windows 的 WebView2),而 WKWebView 默认不支持 HLS(需 iOS/macOS 14+ 且启用特定 entitlement),WebView2 对 HLS 支持有限。很多团队因此回退到 Electron,或强行在 Rust 层集成ffmpeg-syscrate——这会让二进制瞬间暴涨 40MB+。

正确解法是:承认 WebView 的能力边界,用降级策略。我们的方案是:

  • 前端检测video.canPlayType('application/vnd.apple.mpegurl')
  • 若不支持,则用tauri::api::shell::open调用系统默认播放器(QuickTime / VLC)打开 m3u8 链接;
  • 同时提供“下载音频”按钮,Rust 层用reqwest下载 ts 分片,用ffmpeg命令行合并(ffmpeg -i "concat:file1.ts|file2.ts" -c copy output.mp3),此命令行由用户自行安装,不打包进应用。

这牺牲了一点“纯前端”体验,但保住了 4.7MB 的底线。技术选型的本质,从来不是“能不能做”,而是“值不值得为这个功能付出 XX MB 的代价”。

5. 何时该坚持 Electron?——一份务实的迁移决策清单

看到这里,你可能想立刻扔掉 Electron 重写所有项目。别急。我亲手操刀过 12 个桌面应用迁移,结论很现实:Tauri 不是 Electron 的替代品,而是它的战略补充。是否迁移,取决于你的应用在以下五个维度的得分:

维度Electron 优势Tauri 优势决策建议
启动速度冷启动 1.2s(SSD)冷启动 0.3s(SSD)若用户频繁开关应用(如工具栏小工具),Tauri 价值巨大;若每日只启动 1 次(如 IDE),差异可忽略
包体敏感度200MB+ 是常态5MB 是常态企业内网分发、IoT 设备预装、App Store 审核严格场景,Tauri 是刚需
系统深度集成electron-builder+ 自定义 NSIS 脚本Rust 直接调用 Win32 API/macOS Cocoa需定制托盘菜单、全局快捷键、硬件驱动交互,Tauri 开发更直接
团队技术栈JS/TS 工程师全覆盖需至少 1 名 Rust 熟练者若团队无 Rust 经验,迁移成本 > 3 人月,应暂缓
长期维护预算Electron 升级年均耗时 2 周Tauri 升级年均耗时 0.5 天预算紧张、人力有限的团队,Electron 的“稳定债”可能更划算

我们内部用一张决策树快速判断:

应用是否需频繁启动? → 是 → 启动速度权重+2 安装包是否需通过企业防火墙分发? → 是 → 包体权重+3 是否需调用 Windows COM 接口或 macOS Metal API? → 是 → 系统集成权重+3 团队是否有 Rust 生产经验? → 否 → 技术栈权重-2 未来 3 年是否计划接入 WebAssembly 模块? → 是 → Tauri 权重+1(Rust 与 Wasm 天然亲和)

总分 ≥ 5:强烈建议迁移;3~4:可试点核心模块;≤ 2:维持 Electron,优化构建(如electron-builderextraResources精简、asar分包)。

最后分享一个血泪教训:我们曾为一个客户迁移其“跨平台音乐管理系统 v2.0”,前期评估打分 6 分,信心满满。上线后发现其核心功能“歌词实时同步”依赖 Web Audio API 的高精度定时器,而 WKWebView 的requestAnimationFrame精度比 Chromium 低 3 倍,导致歌词偏移。最终方案是:Rust 层用std::time::Instant精确计时,通过 IPC 每 10ms 向前端推送时间戳,前端仅做渲染——这增加了 200 行 Rust 代码,但保住了体验。技术迁移不是非黑即白的选择,而是用新工具解决老问题时,对旧约束的创造性绕过。4.7MB 很美,但用户听到的那句“歌词刚好卡在鼓点上”,才是真正的交付价值。

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

Godot 录音 Demo 拆解:4 步把麦克风声音存成 WAV

Godot 录音 Demo 拆解&#xff1a;4 步把麦克风声音存成 WAV 【免费下载链接】godot-demo-projects Demonstration and Template Projects 项目地址: https://gitcode.com/GitHub_Trending/go/godot-demo-projects 这个官方 Demo 演示了 Godot 录音的最小完整链路&#…

作者头像 李华
网站建设 2026/9/18 2:23:55

AI 推理请求从 M8 Ultra 发出,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/9/18 2:23:09

容器化部署从入门到实战:Docker核心技术解析与踩坑指南

“容器化部署”这个词&#xff0c;这几年几乎成了后端开发和运维圈绕不开的话题。无论是新项目落地&#xff0c;还是老系统改造&#xff0c;只要聊到环境一致性、资源利用率、快速交付&#xff0c;最后基本都会落到 Docker 上。很多人第一次听到 Docker&#xff0c;可能是从一个…

作者头像 李华
网站建设 2026/9/18 2:22:12

IDEA中输出SQL的七种方法:日志配置、插件还原与脚本生成实战

平时排查接口问题&#xff0c;十次里有八次都要看SQL。你在IDEA里写Java项目&#xff0c;会写SQL只是基本功&#xff0c;能在日志里把SQL准确捞出来、带上参数还原成一条能直接执行的语句&#xff0c;这才是日常开发中最有价值的技能之一。这篇文章就把我在IDEA里弄出SQL语句的…

作者头像 李华
网站建设 2026/9/18 2:19:43

MySQL与Oracle核心差异:事务、分页、运维的实战分叉点

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

作者头像 李华
网站建设 2026/9/18 2:19:26

Git版本回退全指南:文件恢复、提交撤销、远端回退与reflog兜底

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

作者头像 李华