perf(finder,orphan): 第一層掃描提速——category 記憶化(S3)+orphan 葉塊預計算(S2),保行為

下游效能訴求一(掃描疑 O(n²))之第一層:無狀態、保行為、無公開 API 變更之確定收益。

S3 category 記憶化:finder.category 以 categoryCache(WeakMap<Element, InlineCategory>)記憶化,砍
除 collectRuns/jiyaAdjacency/jinze/split 跨 pass 對同一元素之重複 el.matches()(pill/isolate/
avoid 選擇器)。安全:單次 render 內一元素之分類恆定(class/skip 屬性/nodeName 不因 render 變更;
jz-* 包裹按 nodeName 分類亦恆定),且 Finder 隨 makeCtx 每次 render 重建 → 快取僅存活單次 render、
無跨 render 失效(持久化屬 C1,另議)。亲驗(正確補 Element.prototype.matches):pill/isolate 密集
頁面 el.matches() 8204→5842(約降三成);暫禁快取則 computeCategory 2322 vs 1041(命中率 55%)。

S2 orphan 葉塊預計算:以一趟上攀預計算「非葉塊」Set 取代逐塊 block.querySelector(sel) 掃整棵子樹之
O(Σ塊子樹)(深塊嵌套下 O(n²));某塊為另一塊之最近塊祖先 ⟺ 含塊級後代,scope 無涉、與原
querySelector 同口徑,行為等價。sel 亦外提出迴圈。

測試 129→130(+1 鎖定 S2 非葉塊等價:含巢狀塊之容器跳過、僅內層葉塊綁定);全綠、輸出不變。
文檔:ARCHITECTURE §4.1(category 記憶化)+§6.5.1(orphan 葉塊預計算)。

新發現(待議、非本層):剩餘 el.matches() 之最大來源為 insidePill 之「逐文本節點沿祖先鏈 pillMatches」
(eachTextNode 每文本節點一趟、未記憶化)——可作下一項提速機會。
This commit is contained in:
2026-07-11 19:56:53 +08:00
parent 3fbb2904ef
commit 168e9e73f0
7 changed files with 138 additions and 6 deletions
+10
View File
@@ -257,6 +257,12 @@ Nvim peekClaude 模板等 v1 消費端之過渡期。**最後一批 v1 消費
`jiyaAdjacency``isTransparent`)皆 `switch(category)` 分派,`canSplit` `SPLITTABLE ∩ transparent`
——分類集中一處、優先序不再散落各原語各自短路(階段二後之清晰化)。
**效能(S3 記憶化)**:分類含至多三次 `el.matches()`pillisolateavoid 選擇器),且被上列各遍歷
原語逐元素、跨 pass 重算——`category``categoryCache``WeakMap<Element, InlineCategory>`)記憶化,
砍除跨 pass 重複(實測 pillisolate 密集之頁面 `el.matches()` 約降三成)。安全性:單次 render 內一元素
之分類恆定(classskip 屬性/nodeName 皆不因 render 變更;jz-* 包裹按 nodeName 分類亦恆定),且 Finder
`makeCtx` 每次 render 重建 → 快取僅存活單次 render、無跨 render 失效問題(持久化屬另議)。
判定**以元素為準、非標籤名**`isTransparent(el) category(el)==="transparent"`,前置排除 pill
isolateavoid
再查標籤集):KaTeX 全樹皆 `<span>`,舊版按標籤名判透明曾令 `canSplit``descend`
@@ -638,6 +644,10 @@ class 約定:`bd`(標點)、`bd-open/close/cop/stop/sep/liga`、`cjk`、`j
- **選項**`orphan?: boolean | { chars?: number }`feature gate**預設開 chars=2**per-block
經 `blocks``data-jz`;段落級,`data-jz-level="text"` 子樹跳過)。lang 無關。
- **邊界**:段落實義字 < N → 不綁。極窄容器(<N 字寬)→ 整體可能溢出(罕見,文檔提示)。
- **僅葉塊**:含塊級後代之容器(其實義字屬內層段落)跳過、另由內層處理。**效能(S2)**:舊版
逐塊 `block.querySelector(sel)` 掃整棵子樹判葉塊,累加 O(Σ塊子樹)、深塊嵌套下 O(n²);改以一趟
上攀預計算「非葉塊」Set(某塊為另一塊之最近塊祖先 ⟺ 含塊級後代),scope 無涉、與原 querySelector
同口徑,行為等價。
- **order=70**charify 20 後、layout pass 前;longWord 60 後)。
- **與禁則協同**:段末範圍可能含 jinze 已綁之 `jz-jinze`;置於 jinze(40) 後,將 jz-jinze 整體
納入 jz-orphan,並處理邊界字定位**跨 jz-jinze 攀爬**(實作細節)。免重複包裹。
+9
View File
@@ -31,6 +31,7 @@ export declare class Finder {
private readonly levelTextSelector;
private readonly featureCache;
private readonly levelCache;
private readonly categoryCache;
constructor(opts: ResolvedOptions);
/** 該元素是否匹配 pill 選擇器(三分類之「整體」:code/kbd/行內公式等)。 */
pillMatches(el: Element): boolean;
@@ -49,8 +50,16 @@ export declare class Finder {
* (自身排除最高)。**邏輯文本流之遍歷原語**collectRunsjiyaAdjacencyadjacentLogicalChar
* 之 isTransparent)皆據此**單一分類**分派;core/split 之 canSplit SPLITTABLE 白名單 ∩
* transparent(結構切分為獨立層,故更窄)。
*
* **記憶化**S3):分類含至多三次 el.matches()pillisolateavoid 選擇器),且被
* collectRunsjiyaAdjacencyjinzeorphansplit 各 pass 逐元素重算——以 categoryCache
* 記憶化砍除跨 pass 重複。安全性:單次 render 內一元素之分類**恆定**class/skip 屬性/
* nodeName 皆不因 render 變更;jz-* 包裹為新建元素、按 nodeName 分類亦恆定),且 Finder
* 隨 makeCtx 每次 render 重建 → 快取僅存活單次 render,無跨 render 失效問題(持久化屬 C1,
* 另議)。
*/
category(el: Element): InlineCategory;
private computeCategory;
/** category 之透明判定末步:styleInlines 標籤,或聚珍自身之 wrap 類 jz-*jz-hws 間隙、
* jz-jinze 禁則群組、**jz-charjz-inner 標點原子**)。前四步已排除 avoid/pill/isolate/block。
* jz-charjz-inner 必須列入:否則 adjacentLogicalChar 之 descend 遇標點原子既不進入亦不
+32 -2
View File
@@ -42,6 +42,7 @@ var Juzhen = (() => {
this.includeSelector = opts.scope.include;
this.featureCache = /* @__PURE__ */ new WeakMap();
this.levelCache = /* @__PURE__ */ new WeakMap();
this.categoryCache = /* @__PURE__ */ new WeakMap();
}
/** 該元素是否匹配 pill 選擇器(三分類之「整體」:code/kbd/行內公式等)。 */
pillMatches(el) {
@@ -73,8 +74,24 @@ var Juzhen = (() => {
* (自身排除最高)。**邏輯文本流之遍歷原語**collectRunsjiyaAdjacencyadjacentLogicalChar
* 之 isTransparent)皆據此**單一分類**分派;core/split 之 canSplit SPLITTABLE 白名單 ∩
* transparent(結構切分為獨立層,故更窄)。
*
* **記憶化**S3):分類含至多三次 el.matches()pillisolateavoid 選擇器),且被
* collectRunsjiyaAdjacencyjinzeorphansplit 各 pass 逐元素重算——以 categoryCache
* 記憶化砍除跨 pass 重複。安全性:單次 render 內一元素之分類**恆定**class/skip 屬性/
* nodeName 皆不因 render 變更;jz-* 包裹為新建元素、按 nodeName 分類亦恆定),且 Finder
* 隨 makeCtx 每次 render 重建 → 快取僅存活單次 render,無跨 render 失效問題(持久化屬 C1,
* 另議)。
*/
category(el) {
const cached = this.categoryCache.get(el);
if (cached) {
return cached;
}
const result = this.computeCategory(el);
this.categoryCache.set(el, result);
return result;
}
computeCategory(el) {
if (this.avoidsSelf(el)) {
return "avoid";
}
@@ -1692,6 +1709,20 @@ var Juzhen = (() => {
const anyCjk = new RegExp("[" + options.ruleset.cjk + "]");
const biaodian = options.ruleset.biaodian;
const chars = options.orphan.chars;
const sel = Array.from(blockSet).map((t) => t.toLowerCase()).join(",");
const nonLeaf = /* @__PURE__ */ new Set();
if (sel) {
ctx.root.querySelectorAll(sel).forEach((b) => {
let p = b.parentElement;
while (p) {
if (blockSet.has(p.nodeName)) {
nonLeaf.add(p);
break;
}
p = p.parentElement;
}
});
}
finder.eachBlock(ctx.root, (block) => {
if (!finder.levelAllows(block, "paragraph")) {
return;
@@ -1702,8 +1733,7 @@ var Juzhen = (() => {
if (finder.isAvoided(block)) {
return;
}
const sel = Array.from(blockSet).map((t) => t.toLowerCase()).join(",");
if (sel && block.querySelector(sel)) {
if (nonLeaf.has(block)) {
return;
}
processBlock(block, finder, blockSet, anyCjk, biaodian, chars);
+32 -2
View File
@@ -15,6 +15,7 @@ var Finder = class {
this.includeSelector = opts.scope.include;
this.featureCache = /* @__PURE__ */ new WeakMap();
this.levelCache = /* @__PURE__ */ new WeakMap();
this.categoryCache = /* @__PURE__ */ new WeakMap();
}
/** 該元素是否匹配 pill 選擇器(三分類之「整體」:code/kbd/行內公式等)。 */
pillMatches(el) {
@@ -46,8 +47,24 @@ var Finder = class {
* (自身排除最高)。**邏輯文本流之遍歷原語**collectRunsjiyaAdjacencyadjacentLogicalChar
* 之 isTransparent)皆據此**單一分類**分派;core/split 之 canSplit SPLITTABLE 白名單 ∩
* transparent(結構切分為獨立層,故更窄)。
*
* **記憶化**S3):分類含至多三次 el.matches()pillisolateavoid 選擇器),且被
* collectRunsjiyaAdjacencyjinzeorphansplit 各 pass 逐元素重算——以 categoryCache
* 記憶化砍除跨 pass 重複。安全性:單次 render 內一元素之分類**恆定**class/skip 屬性/
* nodeName 皆不因 render 變更;jz-* 包裹為新建元素、按 nodeName 分類亦恆定),且 Finder
* 隨 makeCtx 每次 render 重建 → 快取僅存活單次 render,無跨 render 失效問題(持久化屬 C1,
* 另議)。
*/
category(el) {
const cached = this.categoryCache.get(el);
if (cached) {
return cached;
}
const result = this.computeCategory(el);
this.categoryCache.set(el, result);
return result;
}
computeCategory(el) {
if (this.avoidsSelf(el)) {
return "avoid";
}
@@ -1665,6 +1682,20 @@ var orphanPass = {
const anyCjk = new RegExp("[" + options.ruleset.cjk + "]");
const biaodian = options.ruleset.biaodian;
const chars = options.orphan.chars;
const sel = Array.from(blockSet).map((t) => t.toLowerCase()).join(",");
const nonLeaf = /* @__PURE__ */ new Set();
if (sel) {
ctx.root.querySelectorAll(sel).forEach((b) => {
let p = b.parentElement;
while (p) {
if (blockSet.has(p.nodeName)) {
nonLeaf.add(p);
break;
}
p = p.parentElement;
}
});
}
finder.eachBlock(ctx.root, (block) => {
if (!finder.levelAllows(block, "paragraph")) {
return;
@@ -1675,8 +1706,7 @@ var orphanPass = {
if (finder.isAvoided(block)) {
return;
}
const sel = Array.from(blockSet).map((t) => t.toLowerCase()).join(",");
if (sel && block.querySelector(sel)) {
if (nonLeaf.has(block)) {
return;
}
processBlock(block, finder, blockSet, anyCjk, biaodian, chars);
+18
View File
@@ -63,6 +63,7 @@ export class Finder
private readonly levelTextSelector: string | null;
private readonly featureCache: WeakMap<Element, Record<string, boolean>>;
private readonly levelCache: WeakMap<Element, FeatureLevel>;
private readonly categoryCache: WeakMap<Element, InlineCategory>;
constructor(opts: ResolvedOptions)
{
@@ -79,6 +80,7 @@ export class Finder
this.includeSelector = opts.scope.include;
this.featureCache = new WeakMap();
this.levelCache = new WeakMap();
this.categoryCache = new WeakMap();
}
/** 該元素是否匹配 pill 選擇器(三分類之「整體」:code/kbd/行內公式等)。 */
@@ -116,8 +118,24 @@ export class Finder
* ****collectRunsjiyaAdjacencyadjacentLogicalChar
* isTransparent****core/split canSplit SPLITTABLE
* transparent
*
* ****S3 el.matches()pillisolateavoid
* collectRunsjiyaAdjacencyjinzeorphansplit pass categoryCache
* pass render ****classskip
* nodeName render jz-* nodeName Finder
* makeCtx render render render C1
*
*/
category(el: Element): InlineCategory
{
const cached = this.categoryCache.get(el);
if (cached) { return cached; }
const result = this.computeCategory(el);
this.categoryCache.set(el, result);
return result;
}
private computeCategory(el: Element): InlineCategory
{
if (this.avoidsSelf(el)) { return "avoid"; }
if (this.pillMatches(el)) { return "pill"; }
+20 -2
View File
@@ -144,6 +144,25 @@ export const orphanPass: StandalonePass = {
const biaodian = options.ruleset.biaodian;
const chars = options.orphan.chars;
// 「非葉塊」(含塊級後代之容器)預計算(S2):取代原逐塊 block.querySelector(sel) 掃整
// 棵子樹之 O(Σ塊子樹)(深塊嵌套下 O(n²))。一趟上攀標記——某塊為另一塊之最近塊祖先 ⟺
// 該塊含塊級後代。scope 無涉(與原 querySelector 同口徑:含域外後代塊亦算非葉),故據
// root 全塊集(querySelectorAll,非 eachBlock 之 in-scope 子集)計算。sel 亦外提出迴圈。
const sel = Array.from(blockSet).map((t) => t.toLowerCase()).join(",");
const nonLeaf = new Set<Element>();
if (sel)
{
ctx.root.querySelectorAll(sel).forEach((b) =>
{
let p = b.parentElement;
while (p)
{
if (blockSet.has(p.nodeName)) { nonLeaf.add(p); break; }
p = p.parentElement;
}
});
}
finder.eachBlock(ctx.root, (block) =>
{
// 段落級閘 per-node 功能閘 avoidisAvoided 組合判定:自身或祖先命中皆
@@ -152,8 +171,7 @@ export const orphanPass: StandalonePass = {
if (!finder.featureEnabledFor(block, PASS)) { return; }
if (finder.isAvoided(block)) { return; }
// 僅處理葉段落(無塊級後代):含巢狀塊之容器,其實義字屬內層段落,另行處理。
const sel = Array.from(blockSet).map((t) => t.toLowerCase()).join(",");
if (sel && block.querySelector(sel)) { return; }
if (nonLeaf.has(block)) { return; }
processBlock(block, finder, blockSet, anyCjk, biaodian, chars);
});
},
+17
View File
@@ -1077,6 +1077,23 @@ test("垂懸字 攀升守衛 revert:綁整 span 之退化路徑 render→rever
assert.equal(p.textContent, "開頭較長的一段文字內容之後接續結尾文字", "textContent 還原");
});
// S2 非葉塊預計算(取代逐塊 querySelector)等價性:含巢狀塊之容器(div>p)——容器為非葉、
// 直文字不綁(實義字屬內層段落),僅內層葉塊 <p> 綁定。鎖定「非葉塊跳過」路徑之行為不變。
test("垂懸字 S2 非葉塊:含巢狀塊之容器跳過、僅內層葉塊綁定", () =>
{
const { document } = setupDom(
'<div lang="zh-Hant">容器直接文字結尾甲乙<p>內層段落的實義字丙丁。</p></div>',
);
createJuzhen().render(document.body);
const div = document.querySelector("div");
const p = document.querySelector("p");
const pOrphan = p.querySelector("jz-orphan");
assert.ok(pOrphan, "內層葉塊 <p> 應產生 jz-orphan");
assert.equal(pOrphan.textContent, "丙丁。", "綁定 <p> 末 2 實義字+句號");
const directOrphan = Array.from(div.children).some((c) => c.nodeName === "JZ-ORPHAN");
assert.equal(directOrphan, false, "非葉容器 <div> 之直文字不應被綁入 jz-orphan");
});
// ----- 標點懸掛已移除(§6.5.6):hanging 選項保留為 no-op,永不產生 jz-hang-* -----
// 純負 margin 機制為機制級死路(寬度守恆矛盾,CSS 接觸不到斷行器);原生
// hanging-punctuation 為唯一正解但僅 Safari 支援、且與 inline-block 原子互斥。