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
+110
View File
@@ -0,0 +1,110 @@
// 增量恢復基準(jsdom)。度量「頁面局部變化」之恢復成本:全樹 revert+render(舊法)
// vs 局部 rerender(第三層 C3C2)。確定性 el.matches() 計數 計時中位數。
//
// 用法:node test/bench/bench-incremental.mjs [blocks] [iters]
import { JSDOM } from "jsdom";
import { createJuzhen } from "../../dist/juzhen.mjs";
const BLOCKS = Number(process.argv[2] || 400);
const ITERS = Number(process.argv[3] || 7);
function buildHtml(n)
{
let html = '<main lang="zh-Hant">';
for (let i = 0; i < n; i += 1)
{
html += '<p>第' + i + '段落文字<strong>粗體</strong>與 Latin word 混排,'
+ '句內點號、頓號;「引號」(括號)末尾實義字甲乙丙丁。</p>';
}
html += '</main>';
return html;
}
const HTML = buildHtml(BLOCKS);
function fresh()
{
const dom = new JSDOM("<!doctype html><html><body>" + HTML + "</body></html>");
globalThis.document = dom.window.document;
globalThis.NodeFilter = dom.window.NodeFilter;
globalThis.Node = dom.window.Node;
globalThis.window = dom.window;
globalThis.MutationObserver = dom.window.MutationObserver;
return dom;
}
function patchMatches(dom, counter)
{
let p = dom.window.document.querySelector("strong");
let proto = Object.getPrototypeOf(p);
while (proto && !Object.prototype.hasOwnProperty.call(proto, "matches"))
{
proto = Object.getPrototypeOf(proto);
}
const orig = proto.matches;
proto.matches = function (s) { counter.n += 1; return orig.call(this, s); };
return () => { proto.matches = orig; };
}
const median = (a) => a.slice().sort((x, y) => x - y)[Math.floor(a.length / 2)];
// ---- 場景:編輯「中間某一段」後恢復 ----
function editTarget(doc) { return doc.querySelectorAll("p")[Math.floor(BLOCKS / 2)]; }
// 全樹法:revert(root)+render(root)。
function fullCost(measureMatches)
{
const times = [];
let matches = 0;
for (let k = 0; k < ITERS; k += 1)
{
const dom = fresh();
const doc = dom.window.document;
const jz = createJuzhen();
jz.render(doc.body);
const p = editTarget(doc);
p.textContent = "改後第 K 段,新的實義字戊己庚辛。";
let restore, counter;
if (measureMatches && k === 0) { counter = { n: 0 }; restore = patchMatches(dom, counter); }
const t0 = performance.now();
jz.revert(doc.body);
jz.render(doc.body);
times.push(performance.now() - t0);
if (restore) { restore(); matches = counter.n; }
}
return { ms: median(times), matches };
}
// 增量法:rerender(改動塊)。
function incCost(measureMatches)
{
const times = [];
let matches = 0;
for (let k = 0; k < ITERS; k += 1)
{
const dom = fresh();
const doc = dom.window.document;
const jz = createJuzhen();
jz.render(doc.body);
const p = editTarget(doc);
p.textContent = "改後第 K 段,新的實義字戊己庚辛。";
let restore, counter;
if (measureMatches && k === 0) { counter = { n: 0 }; restore = patchMatches(dom, counter); }
const t0 = performance.now();
jz.rerender(p);
times.push(performance.now() - t0);
if (restore) { restore(); matches = counter.n; }
}
return { ms: median(times), matches };
}
const full = fullCost(true);
const inc = incCost(true);
console.log("增量恢復基準(" + BLOCKS + " 段;編輯中間一段後恢復):");
console.log(" 全樹 revert+render :", full.ms.toFixed(2), "ms |",
"el.matches()", full.matches);
console.log(" 局部 rerender(塊) :", inc.ms.toFixed(2), "ms |",
"el.matches()", inc.matches);
console.log(" 提速:時間 " + (full.ms / inc.ms).toFixed(1) + "× | matches "
+ (full.matches / Math.max(1, inc.matches)).toFixed(1) + "×");
+78
View File
@@ -0,0 +1,78 @@
<!doctype html>
<html lang="zh-Hant">
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="../../dist/juzhen.css">
<style>
/* 窄容器強制多行;大量 CJK↔Latin 邊界 → 大量 jz-hws gap。 */
#stage { width: 380px; font-size: 18px; line-height: 2; }
#stage.j { text-align: justify; }
</style>
</head>
<body>
<div id="result">pending</div>
<div id="stage"></div>
<script src="../../dist/juzhen.iife.js"></script>
<script>
// 計 getClientRects 呼叫數(布局讀取/reflow 代理)——S1 之目標即減少此類「寫後讀」。
let rectCalls = 0;
for (const proto of [Range.prototype, Element.prototype])
{
const orig = proto.getClientRects;
proto.getClientRects = function () { rectCalls += 1; return orig.apply(this, arguments); };
}
// 建 gap 密集之多行文檔:每段交替 CJK 字 + Latin 字母/數字 → 每邊界一個 jz-hws。
function buildStage(paras, boundariesPerPara)
{
const cjk = "中文字符測試內容甲乙丙丁戊己庚辛壬癸";
const ans = "abcdefghijklmnopqrstuvwxyz0123456789";
let html = "";
for (let p = 0; p < paras; p += 1)
{
let s = "";
for (let i = 0; i < boundariesPerPara; i += 1)
{
s += cjk.charAt((p + i) % cjk.length) + ans.charAt((p * 3 + i) % ans.length);
}
html += "<p>" + s + ",句末標點。</p>";
}
return html;
}
const params = new URLSearchParams(location.search);
const PARAS = Number(params.get("paras") || 40);
const BPP = Number(params.get("bpp") || 60);
const JUSTIFY = params.get("justify") !== "0"; // 預設 justify
const stage = document.getElementById("stage");
if (JUSTIFY) { stage.classList.add("j"); }
stage.innerHTML = buildStage(PARAS, BPP);
try
{
const Juzhen = window.Juzhen || globalThis.Juzhen;
if (!Juzhen) { throw new Error("Juzhen 全域未定義"); }
const jz = Juzhen.createJuzhen();
// 量測:render(含 lineEdge + gapTrim 之立即 run)。ResizeObserver 非同步回調不計入。
const rectsBefore = rectCalls;
const t0 = performance.now();
jz.render(stage);
const dt = performance.now() - t0;
const gaps = stage.querySelectorAll("jz-hws").length;
const trimmed = stage.querySelectorAll("jz-hws.jz-hws-trim").length;
const le = stage.querySelectorAll("jz-char.jz-half-le").length;
document.getElementById("result").textContent = JSON.stringify({
paras: PARAS, bpp: BPP, justify: JUSTIFY, gaps, trimmed, lineEdge: le,
getClientRects: rectCalls - rectsBefore,
renderMs: Math.round(dt * 10) / 10,
});
}
catch (e)
{
document.getElementById("result").textContent = "ERROR: " + (e && e.stack || e);
}
</script>
</body>
</html>
+1
View File
@@ -16,6 +16,7 @@ export function setupDom(bodyHtml = "")
globalThis.NodeFilter = dom.window.NodeFilter;
globalThis.Node = dom.window.Node;
globalThis.window = dom.window;
globalThis.MutationObserver = dom.window.MutationObserver; // C2 觀察(jsdom 支持)
return { dom, document: dom.window.document };
}
+178
View File
@@ -1344,3 +1344,181 @@ test("v1 相容層已移除:createCjkAutospace 不再導出(消費端已全
const mod = await import("../dist/juzhen.mjs");
assert.equal(mod.createCjkAutospace, undefined, "compat 導出已刪");
});
// ----- 第三層 增量恢復(C1 持久化 Finder 失效/C3 rerender-----
test("C3 rerender:局部子樹 revert+render,內容變化被重新處理", () =>
{
const { document } = setupDom(
'<p lang="zh-Hant">春天來了,很暖和。</p><p lang="zh-Hant">夏天到了,很热。</p>',
);
const jz = createJuzhen();
jz.render(document.body);
const p1 = document.querySelectorAll("p")[0];
// 改 p1 內容 → rerender(p1) 只重處理 p1。
jz.revert(p1);
p1.textContent = "秋天涼了,落葉紛紛。";
jz.render(p1);
assert.equal(p1.textContent, "秋天涼了,落葉紛紛。", "p1 內容更新");
assert.ok(p1.querySelector("jz-char"), "p1 重新 charify(標點原子化)");
// rerender 便利方法亦可用(等價 revert+render)。
jz.rerender(p1);
assert.ok(p1.querySelector("jz-char"), "rerender 後仍正確處理");
});
test("C1 失效正確性:既有元素分類屬性變化(transparent→pill)經 revert+render 正確重分類", () =>
{
const { document } = setupDom('<p lang="zh-Hant">前文<span>甲,乙</span>後文標點。</p>');
const jz = createJuzhen({ pillSelector: ".pill" });
jz.render(document.body);
const span = document.querySelector("span");
// 初始 span 透明 → 其內標點 ,被 charify。
assert.ok(span.querySelector("jz-char"), "透明 span 內標點被 charify");
// revert(自動失效子樹快取),令 span 成 pill,再 render。
jz.revert(document.body);
span.classList.add("pill");
jz.render(document.body);
// pill 內部不可見 → 不 charify。若持久化快取未失效、沿用「透明」則會誤 charify——本測驗證
// revert 之自動失效確實生效(元素分類屬性變化被正確反映)。
assert.equal(
document.querySelector("span").querySelectorAll("jz-char").length, 0,
"成為 pill 後其內不再 charify(快取已失效、重新分類為 pill)",
);
});
test("C1 invalidate:顯式失效後元素分類屬性變化亦被反映(不經 revert 之失效入口)", () =>
{
const { document } = setupDom('<p lang="zh-Hant">前文<span>丙,丁</span>尾。</p>');
const jz = createJuzhen({ pillSelector: ".pill" });
jz.render(document.body);
// 手動移除產物(模擬消費端自管 DOM),令 span 成 pill,顯式 invalidate 後 render。
jz.revert(document.body);
// 先 render 一次填充快取(span 為透明),再顯式 invalidate 驗證其清快取之效。
jz.render(document.body);
jz.revert(document.body);
const span = document.querySelector("span");
span.classList.add("pill");
jz.invalidate(document.body); // 顯式失效
jz.render(document.body);
assert.equal(
document.querySelector("span").querySelectorAll("jz-char").length, 0,
"invalidate 後成為 pill 之 span 內不再 charify",
);
});
test("C1 持久化快取不串擾:同實例多次 render 不同子樹,結果各自正確", () =>
{
const { document } = setupDom(
'<div id="a" lang="zh-Hant">甲文字,甲標點。</div><div id="b" lang="zh-Hant">乙文字、乙標點。</div>',
);
const jz = createJuzhen();
const a = document.getElementById("a");
const b = document.getElementById("b");
jz.render(a);
jz.render(b);
assert.ok(a.querySelector("jz-char") && b.querySelector("jz-char"), "兩子樹各自 charify");
assert.equal(a.textContent, "甲文字,甲標點。", "a 內容不變");
assert.equal(b.textContent, "乙文字、乙標點。", "b 內容不變");
jz.revert(a);
assert.equal(countTag(a, "jz-char"), 0, "revert a 清 a 產物");
assert.ok(b.querySelector("jz-char"), "b 不受 revert a 影響");
});
// ----- 第三層 C2 MutationObserver 增量模式(jsdom 支持 MutationObserver-----
const tick = () => new Promise((r) => setTimeout(r, 0));
test("C2 觀察:內容編輯自動重處理最近塊", async () =>
{
const { document } = setupDom('<p lang="zh-Hant">原本的文字,內容。</p>');
const jz = createJuzhen();
jz.render(document.body);
jz.observe(document.body);
const p = document.querySelector("p");
p.textContent = "改後的文字、新標點。";
await tick();
jz.disconnect();
assert.equal(p.textContent, "改後的文字、新標點。", "內容為新值");
assert.ok(p.querySelector("jz-char"), "編輯後自動重 charify(觀察生效)");
});
test("C2 觀察:追加新塊自動 render(不重掃既有容器)", async () =>
{
const { document } = setupDom('<div id="c" lang="zh-Hant"><p>既有段落文字。</p></div>');
const c = document.getElementById("c");
const jz = createJuzhen();
jz.render(c);
jz.observe(c);
const np = document.createElement("p");
np.textContent = "追加的新段落,實義字甲乙丙丁。";
c.appendChild(np);
await tick();
jz.disconnect();
assert.ok(np.querySelector("jz-char"), "新塊自動 charify");
assert.ok(np.querySelector("jz-orphan"), "新塊亦跑段落級 pass(垂懸字)");
});
test("C2 觀察:分類屬性變化(transparent→pill)自動重分類(快取失效聯動)", async () =>
{
const { document } = setupDom('<p lang="zh-Hant">前文<span>甲,乙</span>後標點。</p>');
const jz = createJuzhen({ pillSelector: ".pill" });
jz.render(document.body);
jz.observe(document.body);
assert.ok(document.querySelector("span").querySelector("jz-char"), "初始透明 span 內標點被 charify");
document.querySelector("span").classList.add("pill");
await tick();
jz.disconnect();
assert.equal(
document.querySelector("span").querySelectorAll("jz-char").length, 0,
"成 pill 後自動重處理、其內不再 charify(觀察→revert 失效→重分類)",
);
});
test("C2 觀察:不自觸發(juzhen 自身注入不致無限迴圈;一次編輯僅結算一次)", async () =>
{
const { document } = setupDom('<p lang="zh-Hant">測試文字,標點。</p>');
const jz = createJuzhen();
jz.render(document.body);
jz.observe(document.body);
const p = document.querySelector("p");
p.textContent = "新文字、標點。";
await tick();
await tick(); // 若自觸發、迴圈未止,多等一輪應顯露(產物重複增生)
jz.disconnect();
// 冪等且穩定:末尾標點恰一個 jz-char(無因迴圈重複增生)。
assert.ok(p.querySelector("jz-char"), "已處理");
assert.equal(countTag(p, "jz-orphan") <= 1, true, "無迴圈致 jz-orphan 增生");
});
test("C2 disconnect:停止觀察後編輯不再自動處理", async () =>
{
const { document } = setupDom('<p lang="zh-Hant">初始文字,標點。</p>');
const jz = createJuzhen();
jz.render(document.body);
jz.observe(document.body);
jz.disconnect();
const p = document.querySelector("p");
p.textContent = "斷開後編輯標點。";
await tick();
assert.equal(countTag(p, "jz-char"), 0, "disconnect 後不自動處理(純文本、無 jz-char)");
});
test("C2 觀察:向葉塊追加子塊 → 撤除容器陳舊 jz-orphan(IV-3 差分複驗所發現之修復)", async () =>
{
const { document } = setupDom('<div id="c" lang="zh-Hant">容器直接文字結尾實義字甲乙丙丁。</div>');
const c = document.getElementById("c");
const jz = createJuzhen();
jz.render(c);
jz.observe(c);
assert.ok(c.querySelector("jz-orphan"), "初始:葉塊 div 有 jz-orphan");
const np = document.createElement("p");
np.textContent = "追加段落實義字戊己庚辛。";
c.appendChild(np);
await tick();
jz.disconnect();
// div 轉非葉 → 其**直接**內容之 jz-orphan 須撤除(僅內層新 p 之 jz-orphan 保留)。
// 舊實作只 render 新塊、殘留容器陳舊 orphan → 與全樹重渲染分歧(IV-3 發現)。
const directOrphan = Array.from(c.children).some((x) => x.nodeName === "JZ-ORPHAN");
assert.equal(directOrphan, false, "容器轉非葉後,其直接 jz-orphan 已撤除");
assert.ok(np.querySelector("jz-orphan"), "新塊 p 自身之 jz-orphan 正常");
});