news 2026/9/17 2:53:12

Slate v2 大文档范围删除性能重构:replace_children 子区间替换操作的规划与落地

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Slate v2 大文档范围删除性能重构:replace_children 子区间替换操作的规划与落地

Slate v2 大文档范围删除性能重构:replace_children 子区间替换操作的规划与落地

【免费下载链接】plateRich-text editor with AI and shadcn/ui项目地址: https://gitcode.com/GitHub_Trending/pl/plate

本文是 plate 仓库中 Slate v2 开发线的技术规划与实现复盘,核心主题是为大文档场景下的剪切/删除(Issue #5992)引入一种新的核心操作原语replace_children:把"删除选中的整块顶层子节点"从逐个remove_node循环收敛为一次父子数组窗口替换,从而把操作成本从"与文档规模成正比"降为"与选中范围成正比"。读完本文,你将掌握这一操作契约的设计动机、候选方案取舍、内部运行时降级路径、回归证明矩阵、基准阈值,以及最终落地后的实测数据与 Issue 状态判定逻辑。

1. 问题背景:Issue #5992 与大文档剪切成本

Slate v2 开发线追踪的 Issue #5992 是一个经典的大文档编辑性能问题:在超大文档中使用剪切(cut)功能耗时严重。gitcrawl 实时台账 中的原始记录为:"In the case of large documents, using the cut function took a lot of time."(大文档下使用剪切功能非常耗时),归类为 bug,所属集群为 large-document-edit-performance。

该计划文档记录的最新技术状态:在 50,000 个块的文档中执行"选中两个节点后剪切"这一最小负载,copy-plus-delete(复制加删除)耗时621.26msedit-only(仅编辑删除)耗时511.47ms,共产生3个操作。当前证明已将其从早期"多秒级"的旧主人(owner)改善到毫秒级,但距离可接受的交互目标仍有差距,因此台账中 #5992 的状态是Improves而非Fixes(见 issue-coverage-matrix.md 第 315 行,以及 benchmark-candidate-map.md 的 #5992 小节)。

1.1 为什么逐个remove_node循环不是答案

当前删除路径的实现(计划文档引用 slate-v2 开发树中的transforms-text/delete-text.ts)在检测到"精确的整块顶层范围"后,会从endIndexstartIndex循环,对每一个被删除的顶层子节点单独发出一条remove_node操作。每个remove_node都要完整走一遍:

  • 父节点 children 数组的一次整体替换(见 operation.ts 中remove_node的类型定义:node+path);
  • selection / path 变换;
  • dirty path 分类;
  • 快照与提交(snapshot/commit)工作。

也就是说,删除 N 个块就产生 N 条操作、N 次完整的变换与提交管线。这正是"操作成本随文档规模线性放大"的根源——而实际上用户只改了 2 个块。计划文档的结论非常直接:3个操作比旧行为好,但"不要用当前remove_node循环来关闭 #5992"

1.2 为什么虚拟化不是答案

计划文档明确划出了边界:不要用虚拟化(virtualization)作为剪切/删除的修复手段。理由是该成本是"模型与操作成本"(model and operation cost),不是"DOM/渲染成本"(DOM/render cost)。虚拟化解决的是渲染层的问题,而 #5992 的瓶颈在数据模型层的操作放大,两者不能互相替代。这一判断也被列入第 17 节的硬性切割(Hard Cuts)清单。

2. 决策论证:候选方案与取舍

计划文档的决策章节(Decision Brief)给出五个候选方案并逐一裁决,核心决策原则有四条:

  1. Slate 操作仍然是对外的模型(external model);
  2. 一次用户剪切/删除不应变成许多条结构化操作;
  3. 操作负载应与"被改变的范围"成正比,而不是与整个文档成正比(除非是有意整篇替换);
  4. 协作(collaboration)与历史(history)需要确定性的逆操作(inverse)与重放(replay)。
候选方案裁决理由
保留当前remove_node循环拒绝(reject)每个被删子节点发一条remove_node,每条都经过子数组替换、selection/path 变换、dirty 分类、快照/提交工作
用根级replace_fragment做剪切/删除仅过渡(transitional only)只产生一条操作,但在大文档的小范围编辑中存储了完整的旧/新子数组,负载形状对 #5992 是错的
新增delete_fragment拒绝(reject)删除本质就是newChildren: []的替换;独立的删除操作会重复变换/逆操作逻辑
新增/泛化为replace_children硬化后选择(choose after hardening)符合 Slate 形状、按范围缩放、对逆操作友好、兼容粘贴,最接近 ProseMirror 的 replace-step 经验,且不照搬整数位置模型
在操作系统之外做可变根子数组拼接拒绝(reject)对 history/collaboration 隐藏真实变更,重新引入快照绕过风险

2.1replace_fragment的定位:正确的证明产物,错误的操作名

replace_fragment是此前"最佳粘贴策略"计划留下的证明产物(proof artifact),它证明了"单次操作完成顶层子数组替换"是可行的。但计划文档给出了三条否定性判断:

  • 命名是 paste 形状的fragment语义来自剪贴板/粘贴,而引擎真正需要的原语是"父节点的子区间拼接"(a parent child-range splice),不应以产品事件(paste)来命名模型机制;
  • 负载过宽:根级使用时携带完整childrennewChildren数组,对大文档中的小范围删除来说内存与复制成本都不合适;
  • 不宜冻结为最终操作:实现期间应把当前的语义用途迁移到replace_children,仅在需要安全落地时才保留临时内部桥接,发布前移除。

2.2 命名决策:为什么是replace_children而不是splice_children

计划文档的维护者反对意见账本记录了一个有代表性的争论:为什么不直接叫splice_children(JS 数组拼接语义)?结论是 Slate 的操作命名传统是描述模型动作(insert_noderemove_nodeset_nodesplit_nodemerge_node),而不是原始 JS API。replace_children对 undo/collab 负载更清晰,也更贴近tx.value.replace的既有命名习惯。同时delete_fragment因"过窄"被拒绝。

3. 目标操作契约:ReplaceChildrenOperation 详解

计划文档接受的核心实现目标是如下操作类型:

type ReplaceChildrenOperation<V extends Value = Value> = { type: "replace_children"; path: Path; index: number; children: DescendantIn<V>[]; newChildren: DescendantIn<V>[]; selection: Range | null; newSelection: Range | null; };

各字段语义:

  • path:父节点路径。path: []表示根节点自身(此时若范围覆盖全部子节点,即为整篇文档替换);
  • index:被替换子窗口的起始下标;
  • children:被移除的旧子节点窗口(仅限被移除的窗口,而不是整个父节点的全部子节点);
  • newChildren:替换后的新子节点窗口(删除场景下为[]);
  • selection/newSelection:操作前后选择范围,用于让选择修复内聚到操作契约中,而非事后修补。

该操作作为下述场景的统一实现基座:

  • 删除整块顶层子范围;
  • 粘贴时替换选中的顶层块;
  • path: []且范围覆盖全部子节点时的整篇文档替换;
  • 未来的 fragment 拟合(fragment fitting):替换父节点内部的一个子窗口。

对比当前仓库快照中 operation.ts 列出的经典操作族(insert_noderemove_nodeset_nodesplit_nodemerge_nodemove_nodeinsert_textremove_textset_selection),可以看到replace_children是首个"父子数组窗口级"的结构化操作:它把过去由多条insert_node/remove_node组合表达的变更压缩为一条自包含的操作,且负载与变更窗口成正比。

3.1 对应用层透明:事务用法保持不变

尽管新增了核心操作,应用层代码不需要任何改动:

editor.update((tx) => { tx.text.delete({ at: selection }); });

计划文档明确要求:除非最终操作表面(operation surface)被刻意设为公开,否则公共文档不应教用户手写构造该操作。普通应用代码保持editor.update形态不变——这一点与当前仓库中 editor-transforms.ts 等提供的变换层设计一脉相承。

3.2 内部降级(Internal Lowering)规则

  • 精确的子范围删除 → 降级为replace_children,其中newChildren: []
  • 顶层粘贴替换 → 降级为replace_children,携带插入的子节点;
  • 整篇文档替换 → 使用path: []index: 0replace_children

4. 内部运行时流程:从删除到单次子区间替换

计划文档给出了目标运行时流水线:

tx.text.delete({ at }) -> detect exact replaceable child window -> compute parent path + index + removed children + new children -> apply replace_children once -> map selection to newSelection -> classify dirty parent + changed child window -> history stores one inverse -> collab can lower one deterministic child splice

该操作被明确要求不得

  • 当只改变两个子节点时,用完整的根子数组重建操作负载;
  • 对 #5992 精确整子范围逐子节点发remove_node
  • 在操作应用之外直接变异根子数组;
  • 绕过 selection/path/ref 变换契约。

4.1 历史(History)侧:一条操作 = 一条逆操作

"一条逻辑操作 + 一条逆操作"的设计与当前仓库的历史机制完全吻合。在 with-history.ts 中,undo 的实现是取批次的全部操作、逐个OperationApi.inverse求逆后反转顺序再依次应用(见第 69–74 行),redo 则原序重放并恢复selectionAfter。因此操作数量越少、每一条操作的逆变换越确定,undo/redo 的语义就越稳定。replace_children的逆操作只需交换childrennewChildren两个窗口数组,天然可逆。

4.2 与现有 fragment 提取路径的关系

当前仓库中NodeApi.fragment提供"取 root 内某 range 对应的切片片段"能力(见 node.ts 第 113–115 行的接口文档与第 222–229 行的实现),getFragment.ts 进一步将其包装为编辑器级 API。计划文档中"整块顶层子 fragment 快速路径"正是这一能力的演进:提取与替换从"整文档切片"收敛为"受控子窗口",二者共用同一套路径/range 语义,保证复制与粘贴两侧形状一致。

5. 生态系统策略:从 Lexical / ProseMirror / Tiptap 借鉴

计划文档第 6 节给出了跨编辑器生态的策略综合,结论是"策略性偷师"而非照搬:

系统机制借鉴点拒绝点Slate 目标裁决
Lexicaleditor.update+ dirty leaves/elements + 生命周期标签dirty 运行时桶与更新标签(供 commit 消费者使用)class 节点与$helper API一条子区间操作 + dirty parent/range 元数据部分采纳
ProseMirror事务累积 steps 并映射 selectionreplace-step 纪律与映射后的 selection整数位置模型与 schema 优先的身份体系replace_children附带 path/index/range 变换语义认同
Tiptapcommand/chain 糖包裹单一事务扩展 DX 停留在事务引擎之上把 command chain 当作必需的 Slate API保留editor.update;产品糖可降级为单条操作部分采纳
Slate v2 现状replace_fragment证明 +remove_node删除循环复用证明表面与测试paste 形状的操作名 + 小范围大文档场景下的全数组负载泛化为replace_children修订

综合策略可表达为:

Lexical-style dirty metadata + ProseMirror-style range replacement + Slate paths/runtime ids + Tiptap-like extension sugar above the engine

相关的源研究文档在本仓库中可继续深入:ProseMirror 事务/视图/DOM 运行时、Lexical 读/更新扩展运行时、Tiptap 扩展命令与 React DX。

6. Plate 与 slate-yjs 的迁移骨架

6.1 Plate 侧:零产品 API 变化

Plate 应当看到的是:

  • 相同的editor.update写作形态;
  • 大范围删除/剪切时更少的操作数;
  • 对 history/collab 桥更干净的操作负载;
  • 没有新的 Plate 自有的粘贴/删除策略。

Plate不需要:围绕 #5992 的兼容包装、选择加入快速路径的产品命令、或用来掩盖核心删除成本的虚拟化。这是典型的"核心只做模型操作、产品层零感知"设计。

6.2 slate-yjs 侧:直接消费或单事务降级

已接受的协作骨架:

  • yjs/collab 适配器在能表达"父节点子拼接"时应直接消费replace_children
  • 若传输层无法原子表达,则适配器在一个远程事务内将其降级为 remove/insert 操作;
  • 本地 Slate 历史仍然只看到一条逻辑操作与一条逆操作;
  • 远程重放必须与本地重放通过相同的 path/index/window 语义收敛。

硬性门槛:在协作重放/降级有聚焦的契约测试之前(或发布说明显式标注首个切片不支持协作降级之前),replace_children不算发布就绪。这一门槛同样体现在第 11 节的"协作重放/降级契约测试或显式门禁"表述中。

7. 回归证明矩阵与浏览器压测门槛

7.1 遗留回归证明矩阵

任何变更都必须覆盖以下行为证明:

行为必需证明
删除一个选中的顶层块一条操作 + 正确的选择
在 50,000 块文档中删除两个选中顶层块达标的延迟目标、一条逻辑替换、子节点正确
剪切选中的顶层块复制 fragment 正确、删除后模型正确、一条历史记录
文档首/尾删除选择落在合法邻居或编辑器首/尾
删除整篇文档保留默认文档策略或显式替换行为
删除嵌套列表范围既有列表删除测试保持绿色或显式回退
删除 inline/void 范围既有 inline/void 行为保持绿色
撤销/重做逆操作恢复被删子节点与选择
path/point/range refs被删范围内的 refs 置空;范围之后的 refs 按 delta 平移
协作重放本地与远程重放收敛
dirty paths/runtime ids父节点与受影响的顶层范围失效,而非整个文档(除非必要)

7.2 浏览器压测命令与 #5992 阈值

在声明任何Fixes #5992之前,至少需要以下行(在 slate-v2 开发树中执行):

SLATE_CLIPBOARD_BENCH_HUGE_CUT_BLOCKS=50000 SLATE_CLIPBOARD_BENCH_ISSUE_TARGETS=1 bun ./scripts/benchmarks/slate/5945-large-plaintext-paste.mjs bun test ./packages/slate/test/delete-contract.ts bun test ./packages/slate/test/clipboard-contract.ts bun test ./packages/slate/test/operations-contract.ts PLAYWRIGHT_RETRIES=0 bunx playwright test playwright/stress/generated-editing.test.ts -g "paste-normalize-undo" --project=chromium

若基准推进到Fixes #5992,还需要新增浏览器剪切行,因为 Issue 描述的是"cut function"(剪切函数),不仅仅是纯模型删除。

已接受的 #5992 阈值:

  • 50,000 块双节点 edit-only 剪切:本地基准 lane 上 p50<150ms
  • 50,000 块双节点 copy-plus-delete:同 lane 上 p50<250ms
  • 操作数:一条replace_children结构化操作;仅当操作契约无法携带newSelection时才允许额外的显式选择操作;
  • 负载:旧/新子数组仅限被移除/插入的窗口,而非整个 50,000 子节点的根;
  • 若以上目标无实质改善,则停止归因于操作数,下一个归因点是快照/索引分配(snapshot/index allocation)。

8. 高风险预演(Pre-Mortem)与硬性切割

由于本变更涉及操作/数据模型行为,计划文档触发了高风险刻意模式预演,列出五个失败场景及对应证明路径:

  1. replace_children之后的 path/point/range 变换错误 → 被删范围之后的选择 refs 漂移;
  2. 历史逆操作恢复了子节点但未恢复选择 → 破坏剪切周边的 undo/redo;
  3. 协作适配器将其解释为全父节点替换 → 丢失意图或产生远程冲突;
  4. dirty-path 分类过宽 → 只是把 #5992 的成本搬进 React/运行时失效;
  5. 负载存储过多旧/新文档状态 → 大文档上的内存回归。

对应证明计划:单元级(op 校验、inverse、path/point/range 变换)、核心级(删除契约、粘贴替换契约、历史重放)、基准级(#5992 issue 规模行 + 已接受阈值)、浏览器级(推进到Fixes时的剪切/粘贴/撤销行)、协作级(重放/降级契约或显式门禁)。

硬性切割清单(明确不做):

  • 不用虚拟化修复模型删除成本;
  • 不提供公开的editor.cutFast或应用自选;
  • 不用全根replace_fragment负载作为小范围子节点删除的最终答案;
  • 不用多条remove_node循环关闭 #5992;
  • 不单凭操作数就宣称 #5992 已修复。

9. 维护者反对意见账本

计划文档第 18 节以账本形式预演了维护者最可能的三个反对意见,并给出钢化反驳(steelman antithesis)与取舍:

变更可能的反对裁决
新增/泛化replace_children操作"Slate 操作应当是小而原始的;小操作更容易变换与推理"保留。问题恰恰在于原始循环在规模化时变得病态;ProseMirror 式范围替换才是复合子变更的正确原语
替换或降级replace_fragment"你刚加了它,为什么又要变?"保留。证明是对的,但名称与负载对 delete/cut 过于 paste 化,最好在公开冻结前硬切割
不叫splice_children"splice 就是精确的数组原语"保留。Slate 操作名描述模型动作而非 JS API;replace_children对 undo/collab 负载更清晰

10. 实施阶段(TDD 路线)与快驱动门禁

计划文档第 22 节给出七个实施阶段,明确"此 ralplan 到达 done 之前不得执行":

  1. 红色证明(Red proof):新增replace_children操作契约测试;新增 #5992 edit-only 剪切基准目标行;断言旧行仍高于目标;
  2. 操作核心(Operation core):op 类型、校验、inverse、apply、dirty paths、runtime-index 失效、path/point/range 变换;
  3. 删除降级(Delete lowering):把精确整子范围删除降级为单条replace_children
  4. 粘贴迁移(Paste migration):将当前顶层replace_fragment用法迁移到replace_children
  5. 历史/协作(History/collab):证明 inverse、undo/redo、远程重放/降级;
  6. 基准/浏览器(Benchmark/browser):运行 #5992 issue 规模基准与聚焦的浏览器剪切/粘贴/撤销行;
  7. 文档/台账(Docs/ledgers):更新 issue 覆盖矩阵、fork 档案、PR 描述与计划状态。

执行期间的快驱动门禁(fast driver gates,在 slate-v2 开发树中运行):

bun test ./packages/slate/test/delete-contract.ts bun test ./packages/slate/test/clipboard-contract.ts bun test ./packages/slate/test/operations-contract.ts bun --filter slate typecheck SLATE_CLIPBOARD_BENCH_HUGE_CUT_BLOCKS=50000 SLATE_CLIPBOARD_BENCH_ISSUE_TARGETS=1 bun ./scripts/benchmarks/slate/5945-large-plaintext-paste.mjs

浏览器证明(Fixes #5992之前必须):

PLAYWRIGHT_RETRIES=0 bunx playwright test playwright/stress/generated-editing.test.ts -g "paste-normalize-undo" --project=chromium

11. Ralph 执行结果与最终基准

计划文档第 26 节记录了ralph执行的结果(状态:complete for the accepted execution lane)。已实现的清单:

  • replace_children操作类型、校验、inverse、apply、dirty paths、runtime-index 失效、path/point ref 变换;
  • 精确整块顶层子范围删除降级为单条replace_children
  • 粘贴快速路径从replace_fragment迁移到replace_children(root/block 子窗口替换);
  • DOM 纯文本粘贴回退路径迁移到replace_children
  • history/collab 重放证明;
  • 分离"冷快照分配"与"暖编辑器交互延迟"的基准目标行;
  • 大文档剪切的浏览器压力行 + 既有 paste/normalize/undo 行。

验证命令:

bun test ./packages/slate/test/operations-contract.ts ./packages/slate/test/delete-contract.ts ./packages/slate/test/collab-history-runtime-contract.ts ./packages/slate/test/clipboard-contract.ts bun --filter slate typecheck bun --filter slate-dom typecheck bun lint:fix SLATE_CLIPBOARD_BENCH_HUGE_CUT_BLOCKS=50000 SLATE_CLIPBOARD_BENCH_HUGE_CUT_ITERATIONS=3 SLATE_CLIPBOARD_BENCH_ISSUE_TARGETS=1 SLATE_CLIPBOARD_BENCH_ISSUE_ITERATIONS=1 bun ./scripts/benchmarks/slate/5945-large-plaintext-paste.mjs STRESS_FAMILIES=huge-document-cut,paste-normalize-undo PLAYWRIGHT_RETRIES=0 bunx playwright test playwright/stress/generated-editing.test.ts -g "huge-document-cut|paste-normalize-undo" --project=chromium

11.1 最新 #5992 基准(计划文档记录)

  • 暖编辑(warm edit)p50:9.95ms
  • 暖复制加删除(warm copy-plus-delete)p50:8.62ms
  • 操作数:1
  • 冷编辑(cold edit)p50(单独追踪):171.91ms

对照规划阶段的621.26ms(copy-plus-delete)与511.47ms(edit-only),暖路径的实测延迟下降了两个数量级,且操作数从 3 收敛到 1——这正是"负载与变更窗口成正比"的量化验证。注意:冷路径(cold snapshot allocation)仍被单独追踪,计划文档明确将"快照/索引分配"列为下一层可能的成本归属。

12. 结论:为什么 #5992 仍保持 Improves 而非 Fixes

尽管实测数据已大幅改善,计划文档与 issue-coverage-matrix.md 台账都坚持 #5992 维持Improves而非Fixes,原因有三:

  1. 接受标准是"维护者接受度":50,000 块基准 + 5,000 块浏览器压力行需要维护者认可其与原始复现路径("cut function")等价,这属于人为决策,不由实现方单方面裁定;
  2. 浏览器层证据要求:Issue 描述的是剪切函数而非纯模型删除,因此推进到Fixes必须追加浏览器剪切行;
  3. 历史路径证明未完成:台账中明确 "exact closure remains maintainer acceptance plus historical-path proof"(精确关闭仍需要维护者接受加历史路径证明)。

该判定逻辑与 benchmark-candidate-map.md 第 55–71 行的 #5992 条目一致:ready-with-minor-setup,基准接缝为 huge-document cut benchmark,主指标是"随文档规模增长的剪切延迟",次指标是"剪切路径中任何可见的选择或规范化放大"。

13. 相关 Issue 生态与后续方向

计划文档第 12 节的 issue 账本把这次变更放入更广的生态坐标系:#2288(operation-granularity-and-range-steps)被明确升级为范围能力操作的架构支撑——它要求"selectAll + delete 不应爆炸成大量操作";#6038、#5811、#3534、#3551、#4104、#5089、#5630 等分别从批处理引擎、规范化、历史选择状态、inline-void、多块粘贴形状、select-all 粘贴等角度被标记为Related(相关但不由本操作单独关闭)。后续的 CRDT/Yjs 专项计划将决定远程传输是直接消费replace_children,还是在单个远程事务内降级为 remove/insert——这对应计划文档第 2 节中唯一保留的"待用户决策点"的演进方向。

14. 总结

replace_children是 Slate v2 开发线对"大文档范围编辑性能"的一次核心模型级重构:它把剪切/删除/粘贴替换统一收敛为一条按变更窗口缩放的父子数组替换操作,通过历史逆操作、协作降级、ref 变换与 dirty-path 分类的协同契约,把 50,000 块文档中的双节点剪切从数百毫秒压到个位数毫秒(暖路径)。对于 Plate 与 slate-yjs 的使用者,这一变更完全透明——应用代码依旧是editor.update,产品粘贴策略依旧归 Plate/应用所有。本文所依据的完整规划与执行记录见 2026-05-06-slate-v2-range-delete-replace-children-ralplan.md,当前仓库中可继续对照的源码与台账包括 operation.ts(经典操作族)、with-history.ts(逆操作重放机制)、node.ts(fragment 提取接口)以及 issue-coverage-matrix.md(#5992 状态追踪)。

【免费下载链接】plateRich-text editor with AI and shadcn/ui项目地址: https://gitcode.com/GitHub_Trending/pl/plate

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

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

STM32F103显示JPG/PNG/GIF实战:解码、固化与驱动全链路

简介&#xff1a;本资源是一套面向嵌入式初学者与STM32开发者的图片显示实战工程&#xff0c;聚焦STM32F103在LCD/OLED屏上解码并显示JPG、PNG、GIF格式图像的核心技术难点。资源提供完整可运行的Keil MDK工程&#xff0c;涵盖JPEG/PNG/GIF软件解码逻辑、SPI/I2C显示屏驱动、RG…

作者头像 李华
网站建设 2026/9/17 2:52:51

2026年HelloGitHub年度盘点:最值得上手的开源项目与趋势指南

把HelloGitHub当睡前读物追了整年&#xff0c;这是我的个人习惯。每到月底等新一期月刊发布&#xff0c;花半小时翻一遍&#xff0c;遇到对胃口的就丢进收藏夹&#xff0c;周末再拿出来折腾。2026年一开年&#xff0c;我照例把这年的月刊重新扫了一遍&#xff0c;发现值得写的内…

作者头像 李华
网站建设 2026/9/17 2:52:32

5个IO驱动18颗LED显示“188”:查理复用原理与实战

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

作者头像 李华