perf+feat: 掃描/佈局提速(中間/S4+S1)+增量恢復(Tier3 C1/C3/C2)

承 Tier1(S2+S3,已於 main 168e9e7)。本批為效能第二層+第三層,全程獨立複驗(差分/往返/
增量≡全量/進程隔離測速/真瀏覽器)+4 份真人撰寫真實文檔驗收,行為保真、提速真實。

掃描層(無狀態、保行為):
- 中間發現+S4:insidePill/isAvoided 之「逐文本節點沿祖先鏈」以 ancestorOrSelfPill/…Avoided
  迭代回填記憶化(每元素恰算一次、兄弟共用祖先鏈快取),O(文本×depth)→O(元素)。實測掃描層
  el.matches() 累計約降五成;深嵌套 render 進程隔離墻鐘 pre-Tier1 707ms→254ms(2.8×)。

佈局層(僅瀏覽器、正確性敏感):
- S1 gapTrim 對齊門:尾隨 margin 僅在右緣 flush 之對齊可見,唯 left/start(ltr) 之 ragged 右緣
  可整批跳過,省其 O(gaps) 次同步重排。實測非兩端對齊內容 gapTrim 數千 ms→數十 ms;justify/
  center/right/RTL 保留完整逐一定案、行為不變(真瀏覽器差分:justify trim 集指紋逐位相同)。

增量恢復(第三層,向下兼容 API):
- C1 持久化 Finder:跨 render 復用元素鍵快取(category/level/feature/祖先游走)。正確性靠
  失效:新增 finder.invalidate(el) 清整棵子樹快取(祖先屬性變則後代快取變脏),revert 自動失效
  其子樹(安全預設)。
- C3 rerender(root)=revert+render;局部恢復(實測編輯 400 段中一段 766×、matches 1441×)。
- C2 observe/disconnect:MutationObserver 自動增量——內容/分類屬性變化重處理最近塊、新增之塊
  直接 render;render/revert 期間暫停觀察,自身注入絕不自觸發。
- append 修復(IV-3 差分複驗發現、單測漏掉):向葉塊(帶 jz-orphan)追加子塊使其轉非葉,須撤
  容器陳舊 orphan——追加塊時若塊級父有 jz-orphan 直接子則改重處理該父。

測試 130→140(+S2 非葉塊、C1/C3、C2 觀察/失效/append、不自觸發)。基準設施:bench-incremental
(增量恢復)、bench-layout(版面層,headless Chrome)。dist 重建。
This commit is contained in:
2026-07-12 02:20:20 +08:00
parent 1fae8ebf25
commit c5a503580c
11 changed files with 1200 additions and 87 deletions
+70 -15
View File
@@ -64,6 +64,11 @@ export class Finder
private readonly featureCache: WeakMap<Element, Record<string, boolean>>;
private readonly levelCache: WeakMap<Element, FeatureLevel>;
private readonly categoryCache: WeakMap<Element, InlineCategory>;
// 祖先游走記憶化(S3 延伸/S4):ancestorOrSelfPill/…Avoided 之逐元素結果,使 insidePill
// isAvoided 之「逐文本節點沿祖先鏈」由 O(文本節點×depth) 收斂為 O(元素數)——兄弟節點共用
// 祖先鏈快取。同 categoryCacheFinder 隨 render 重建 → 僅存活單次 render、無跨 render 失效。
private readonly pillAncestorCache: WeakMap<Element, boolean>;
private readonly avoidAncestorCache: WeakMap<Element, boolean>;
constructor(opts: ResolvedOptions)
{
@@ -81,6 +86,30 @@ export class Finder
this.featureCache = new WeakMap();
this.levelCache = new WeakMap();
this.categoryCache = new WeakMap();
this.pillAncestorCache = new WeakMap();
this.avoidAncestorCache = new WeakMap();
}
/**
* 清除 el 及其**整棵子樹**之元素鍵快取(C1 失效原語)。持久化 Finder(跨 render 復用快取)
* 下,元素之屬性或內容變化會令其分類(category)/級別(level)/功能(feature)/祖先游走
* pillavoid ancestor)快取失效。**須清整棵子樹、非僅該元素**:level/feature/祖先游走
* 之結果依賴祖先鏈,某祖先之 data-jz-leveldata-juzhenclass 變化會令其**後代**之快取值變脏,
* 即便後代自身未動。querySelectorAll('*') 走子樹(O(子樹),native、無遞迴棧風險)。移除之元素
* 由 WeakMap 自動回收,無需顯式清。 */
invalidate(el: Element): void
{
this.forget(el);
el.querySelectorAll("*").forEach((c) => this.forget(c));
}
private forget(e: Element): void
{
this.categoryCache.delete(e);
this.pillAncestorCache.delete(e);
this.avoidAncestorCache.delete(e);
this.levelCache.delete(e);
this.featureCache.delete(e);
}
/** 該元素是否匹配 pill 選擇器(三分類之「整體」:code/kbd/行內公式等)。 */
@@ -101,13 +130,28 @@ export class Finder
* .katex 內部曾被 jiyaspacing 直接變更)。 */
insidePill(node: Node): boolean
{
let p: Node | null = node.parentNode;
while (p && p.nodeType === 1)
const p = node.parentElement;
return p ? this.ancestorOrSelfPill(p) : false;
}
/** el 或其祖先鏈任一為 pill(記憶化)。沿祖先鏈迭代(非遞迴,任意深度棧安全)至最近之
* 已快取/pill 命中/鏈頂,途中非 pill 元素入棧、以斷點之值一次回填——每元素恰算一次,
* 兄弟共用祖先鏈快取。insidePill 為 pillMatches 之最大來源(S3 延伸)。 */
private ancestorOrSelfPill(el: Element): boolean
{
const chain: Element[] = [];
let cur: Element | null = el;
let base = false;
while (cur)
{
if (this.pillMatches(p as Element)) { return true; }
p = p.parentNode;
const c = this.pillAncestorCache.get(cur);
if (c !== undefined) { base = c; break; }
if (this.pillMatches(cur)) { this.pillAncestorCache.set(cur, true); base = true; break; }
chain.push(cur); // pillMatches 為 false,其值=斷點之 base
cur = cur.parentElement;
}
return false;
for (const e of chain) { this.pillAncestorCache.set(e, base); }
return base;
}
/**
@@ -194,18 +238,29 @@ export class Finder
* 元素下探時則用 avoidsSelf(僅自身,祖先由遞迴涵蓋)。 */
isAvoided(node: Node): boolean
{
if (node.nodeType === 1)
if (node.nodeType === 1) { return this.ancestorOrSelfAvoided(node as Element); }
// 非元素節點:自身僅判 isSkipped 謂詞(無標籤/屬性),祖先走元素鏈(記憶化)。
if (this.userIsSkipped && this.userIsSkipped(node)) { return true; }
const p = node.parentElement;
return p ? this.ancestorOrSelfAvoided(p) : false;
}
/** el 或其祖先鏈任一為 avoid 邊界(記憶化,迭代回填,同 ancestorOrSelfPill)。 */
private ancestorOrSelfAvoided(el: Element): boolean
{
const chain: Element[] = [];
let cur: Element | null = el;
let base = false;
while (cur)
{
if (this.avoidsSelf(node as Element)) { return true; }
const c = this.avoidAncestorCache.get(cur);
if (c !== undefined) { base = c; break; }
if (this.avoidsSelf(cur)) { this.avoidAncestorCache.set(cur, true); base = true; break; }
chain.push(cur);
cur = cur.parentElement;
}
else if (this.userIsSkipped && this.userIsSkipped(node)) { return true; }
let p: Node | null = node.parentNode;
while (p && p.nodeType === 1)
{
if (this.avoidsSelf(p as Element)) { return true; }
p = p.parentNode;
}
return false;
for (const e of chain) { this.avoidAncestorCache.set(e, base); }
return base;
}
/** scope.include 啟用判定:未設則恆 true;設則須有命中祖先(opt-in)。 */
+174 -15
View File
@@ -160,17 +160,163 @@ export interface Juzhen
{
render(root?: Element | string): void;
revert(root?: Element | string): void;
/** revert 後重新 render 同一(子)樹(C3 增量便利);等價於 revert(root) + render(root)。
* 頁面局部變化後,對受影響之最近塊調用即可只重處理該子樹、免全樹重掃。 */
rerender(root?: Element | string): void;
/** 清除該(子)樹之內部元素快取(C1)。**revert 已自動失效其子樹**,故僅在「改了既有元素之
* 分類相關屬性(classdata-jz-*lang 等)但不走 revert」時才需顯式調用,以免下次 render
* 沿用陳舊分類。內容(子節點/文本)變化不改分類、無需 invalidate(但須 revert+render 該塊
* 方能重處理)。 */
invalidate(root?: Element | string): void;
/** 開始觀察 root 子樹之 DOM 變化(C2 增量模式,opt-in):內容/分類屬性變化時自動只重處理
* 受影響之最近塊(revert+render),新增之塊直接 render——免消費端手動 rerender 或全樹重掃。
* **juzhen 自身之變更**jz-* 注入、data-jz* 屬性)於處理期間暫停觀察、絕不自觸發。需
* MutationObserver(瀏覽器/jsdom 有;無則 no-op)。重複 observe 前自動 disconnect 前者。 */
observe(root?: Element | string): void;
/** 停止 C2 觀察。 */
disconnect(): void;
readonly options: ResolvedOptions;
}
// C2 觀察之屬性白名單:僅「影響分類/處理」之屬性觸發重處理(其餘如 styletitlearia-*
// 不影響 juzhen 輸出,忽略以免無謂重掃)。刻意**不含** data-jz*juzhen 自身注入)。
const OBSERVE_ATTRS = [
"class", "lang", "id", "contenteditable",
"data-jz-skip", "data-jz-level", "data-jz-style", "data-juzhen", "data-juzhen-off",
];
/** 建立一個聚珍實例。 */
export function createJuzhen(opts: JuzhenOptions = {}): Juzhen
{
const options = normalizeOptions(opts);
// 持久化 FinderC1):跨 renderrevert 復用元素鍵快取(categorylevelfeature/祖先游走
// 記憶化)。舊版每次 makeCtx new Finder、快取單次 render 即棄;持久化後,未變子樹之快取跨
// render 復用(如串流追加內容時,既有祖先之 level/feature 免重算)。**正確性**靠失效:revert
// 自動清其子樹快取(安全預設),顯式 invalidate 供「改屬性不 revert」之情形。移除之元素由
// WeakMap 自動回收。
const finder = new Finder(options);
function makeCtx(root: Element): RenderContext
{
return { root, options, finder: new Finder(options) };
return { root, options, finder };
}
function renderImpl(el: Element): void
{
runPasses(PASSES, makeCtx(el));
// margin 後備模式以 root 屬性作 CSS 開關(見 css/_jiya.css);halt 模式不設。
if (options.features.jiya && options.jiyaConfig.halfWidth === "margin")
{
el.setAttribute("data-jz-halfwidth", "margin");
}
// justifyAtoms:false → jz-charjz-jinze 改 inline(分頁器相容,見 css)。
if (!options.justifyAtoms)
{
el.setAttribute("data-jz-atoms", "inline");
}
}
function revertImpl(el: Element): void
{
revertPasses(PASSES, makeCtx(el));
el.removeAttribute("data-jz-halfwidth");
el.removeAttribute("data-jz-atoms");
// 失效(安全預設):清 el 子樹之元素鍵快取,使後續 render 對「屬性已變」之元素重新分類。
// 內容變化雖不改分類(此清屬多餘),但廉價且無害,且 render 本就將重掃該子樹。
finder.invalidate(el);
}
// ----- C2 觀察狀態 -----
let observer: MutationObserver | null = null;
let observeRoot: Element | null = null;
const observeConfig: MutationObserverInit = {
subtree: true, childList: true, characterData: true,
attributes: true, attributeFilter: OBSERVE_ATTRS.slice(),
};
// renderrevert 期間暫停觀察 → juzhen 自身之 DOM 變更不入觀察隊列、絕不自觸發。
function pauseObserver(): void { if (observer) { observer.disconnect(); } }
function resumeObserver(): void
{
if (observer && observeRoot)
{
observer.takeRecords(); // 丟棄暫停期間之殘留(保險)
observer.observe(observeRoot, observeConfig);
}
}
/** node 之最近塊祖先(含自身若為塊);達 observeRoot 仍無塊 → 回 observeRoot。 */
function nearestBlock(node: Node): Element | null
{
let e: Element | null = node.nodeType === 1 ? (node as Element) : node.parentElement;
while (e)
{
if (options.finder.blockTags.has(e.nodeName)) { return e; }
if (e === observeRoot) { break; }
e = e.parentElement;
}
return observeRoot;
}
function onMutations(records: MutationRecord[]): void
{
const rerenderBlocks = new Set<Element>(); // 既有塊:revert+render
const renderNew: Element[] = []; // 新增之塊:直接 render(無須 revert
for (const m of records)
{
if (m.type === "childList")
{
m.addedNodes.forEach((n) =>
{
if (n.nodeType === 1 && options.finder.blockTags.has(n.nodeName))
{
// 新增之塊:**通常**獨立 render(串流追加免全容器重掃)。例外:若其塊級
// 父 P 原為**葉塊**(帶 `jz-orphan`——垂懸字為唯一「僅作用於葉塊」之 pass),
// 追加塊令 P 轉非葉,全量路徑會撤除 P 之 `jz-orphan`;故此時改重處理 P
// (涵蓋新塊),否則增量會殘留陳舊 orphan(IV-3 差分複驗發現)。純塊容器
// (無直接內容、無 jz-orphan)不觸此例外、仍最小 render 新塊。
const np = n as Element;
const P = np.parentElement;
const pIsBlock = !!P && options.finder.blockTags.has(P.nodeName);
const pHadLeafOrphan = !!P && Array.prototype.some.call(
P.children, (c) => (c as Element).nodeName === "JZ-ORPHAN");
if (pIsBlock && pHadLeafOrphan) { rerenderBlocks.add(P as Element); }
else if (!pIsBlock && P) { const b = nearestBlock(P); if (b) { rerenderBlocks.add(b); } }
else { renderNew.push(np); }
}
else if (n.nodeType === 1 || n.nodeType === 3)
{
// 新增之行內元素/文本 → 所在塊之間隙/禁則邊界變,重處理最近塊。
const b = nearestBlock(m.target);
if (b) { rerenderBlocks.add(b); }
}
});
if (m.removedNodes.length)
{
const b = nearestBlock(m.target);
if (b) { rerenderBlocks.add(b); }
}
}
else // attributes(白名單)/characterData(文本變化)
{
const b = nearestBlock(m.target);
if (b) { rerenderBlocks.add(b); }
}
}
if (!rerenderBlocks.size && !renderNew.length) { return; }
pauseObserver();
// 去嵌套:只保留極大塊(移除為他者後代者),免重複重處理(重複亦正確、僅浪費)。
const all = Array.from(rerenderBlocks).filter((b) => b.isConnected);
const blocks = all.filter((b) => !all.some((o) => o !== b && o.contains(b)));
for (const b of blocks) { revertImpl(b); renderImpl(b); }
for (const el of renderNew)
{
if (!el.isConnected) { continue; } // 同批又被移除
if (blocks.some((b) => b.contains(el))) { continue; } // 已由塊重處理涵蓋
renderImpl(el);
}
resumeObserver();
}
return {
@@ -179,25 +325,38 @@ export function createJuzhen(opts: JuzhenOptions = {}): Juzhen
{
const el = resolveRoot(root, options);
if (!el) { return; }
runPasses(PASSES, makeCtx(el));
// margin 後備模式以 root 屬性作 CSS 開關(見 css/_jiya.css);halt 模式不設。
if (options.features.jiya && options.jiyaConfig.halfWidth === "margin")
{
el.setAttribute("data-jz-halfwidth", "margin");
}
// justifyAtoms:false → jz-charjz-jinze 改 inline(分頁器相容,見 css)。
if (!options.justifyAtoms)
{
el.setAttribute("data-jz-atoms", "inline");
}
pauseObserver(); renderImpl(el); resumeObserver();
},
revert(root?: Element | string): void
{
const el = resolveRoot(root, options);
if (!el) { return; }
revertPasses(PASSES, makeCtx(el));
el.removeAttribute("data-jz-halfwidth");
el.removeAttribute("data-jz-atoms");
pauseObserver(); revertImpl(el); resumeObserver();
},
rerender(root?: Element | string): void
{
const el = resolveRoot(root, options);
if (!el) { return; }
pauseObserver(); revertImpl(el); renderImpl(el); resumeObserver();
},
invalidate(root?: Element | string): void
{
const el = resolveRoot(root, options);
if (el) { finder.invalidate(el); }
},
observe(root?: Element | string): void
{
if (typeof MutationObserver === "undefined") { return; } // 無版面環境:no-op
const el = resolveRoot(root, options);
if (!el) { return; }
if (observer) { observer.disconnect(); }
observeRoot = el;
observer = new MutationObserver(onMutations);
observer.observe(el, observeConfig);
},
disconnect(): void
{
if (observer) { observer.disconnect(); observer = null; observeRoot = null; }
},
};
}
+29 -1
View File
@@ -249,12 +249,40 @@ export const lineEdgePass: StandalonePass = {
const TRIM = "jz-hws-trim";
// 尾隨 margin-right 是否**可見**、需去行末隙,取決於容器對齊:
// · justifyjustify-all → 右緣 flush,行末 margin 內縮右緣 0.25em(需去);
// · rightend(ltr) → 內容離右緣 0.25em(需去);· center → 行內容含 margin 一併置中、
// 偏移 0.125em(需去);
// · **leftstart(ltr) → 右緣 ragged,行末 margin 落於不可見側**(可跳過)。
// 唯 leftstart(ltr) 可安全整批跳過,省其每個 O(gaps) 次同步重排(S1:常見之非兩端對齊左
// 起內容零成本;其餘對齊之逐一定案為 margin 機制固有代價、保留)。RTL 一律保留(保守)。
// text-aligndirection 皆繼承,故同一 parentElement 下之間隙共用同值,per-parent memo。
function skippableAlign(g: Element, cache: Map<Element, boolean>): boolean
{
const key = g.parentElement;
if (!key) { return false; }
const cached = cache.get(key);
if (cached !== undefined) { return cached; }
let result = false;
const view = g.ownerDocument ? g.ownerDocument.defaultView : null;
if (view)
{
const cs = view.getComputedStyle(g);
const ltr = cs.direction !== "rtl";
result = cs.textAlign === "left" || (cs.textAlign === "start" && ltr);
}
cache.set(key, result);
return result;
}
function trimGaps(root: Element, finder: Finder, wrapSet: Set<string>): void
{
const alignCache = new Map<Element, boolean>();
const gaps = Array.from(root.querySelectorAll("jz-hws")).filter(
(g) => finder.inScope(g) && !finder.isAvoided(g)
&& finder.levelAllows(g, "paragraph")
&& finder.featureEnabledFor(g, "spacing"),
&& finder.featureEnabledFor(g, "spacing")
&& !skippableAlign(g, alignCache),
);
if (!gaps.length) { return; }