From 27776c58ab1d6a3ddb9bf96cfec6b64d4a4b310f Mon Sep 17 00:00:00 2001 From: sixbird <6sixbird@gmail.com> Date: Fri, 28 Aug 2026 06:38:27 +0900 Subject: [PATCH] perf: cache header/side offsets while computing cell geometry getCellInfoByIndex() re-measured every `.js-table-header th` (one per column) and every `.js-table-side` (one per row) each time a single cell's logical (x, y) was resolved. The methods that walk the whole table - markup(), getAllPoints(), getSelectedPoints(), getCellIndexByPos() - therefore issued `cells x (cols + rows)` getBoundingClientRect() calls, which made cell selection, drag selection, merge/split, row/column insertion and paste quadratic in table size and froze the browser on large tables. Take one snapshot of the header/side offsets and share it for the duration of a single synchronous coordinate-reading call, then drop it. The snapshot only exists inside _withGeometryCache() scopes and is discarded at every DOM-rewriting boundary (beforeUpdated(), and both before and after onUpdated() rewrites the wrapper width), so no measurement can outlive the layout it was taken from. Outside a scope the offsets are read exactly as before. Methods that are not wrapped on purpose: updateTable() (event dispatcher whose branches re-render), mergeCells() (hands control back to the browser through confirm(); its heavy isSelectedCellsRectangle() call is cached anyway), getClipBoardData()/processPaste() (mutate the DOM directly / wait for the paste asynchronously). Co-Authored-By: Claude Fable 5 --- src/index.js | 102 +++++++++++++++- test/geometry-cache.test.js | 237 ++++++++++++++++++++++++++++++++++++ 2 files changed, 333 insertions(+), 6 deletions(-) create mode 100644 test/geometry-cache.test.js diff --git a/src/index.js b/src/index.js index 8b5deb3..38c349c 100644 --- a/src/index.js +++ b/src/index.js @@ -88,6 +88,10 @@ export default class aTable extends aTemplate { this.convert = {}; this.convert.getStyleByAlign = this.getStyleByAlign; this.convert.setClass = this.setClass; + // getCellInfoByIndex が参照するヘッダー座標のスナップショット。 + // _withGeometryCache() のスコープ内だけ保持し、その外では常に null + this._geo = null; + this._geoDepth = 0; const html = `
@@ -207,6 +211,47 @@ export default class aTable extends aTemplate { return this._getElementByQuery(`[data-cell-id='${x}-${y}']`); } + // getCellInfoByIndex はセル 1 個の論理座標を求めるたびに + // .js-table-header th (列数) と .js-table-side (行数) の座標を読み直すため、 + // 全セルを走査する処理では getBoundingClientRect が + // 「セル数 × (列数 + 行数)」回発生する。レイアウトが変化しない同期処理の + // 間だけ 1 回分をスナップショットして共有するためのキャッシュ + _buildGeometryCache() { + const headers = this._getElementsByQuery('.js-table-header th'); + const sides = this._getElementsByQuery('.js-table-side'); + return { + headerLefts: [].map.call(headers, header => util.offset(header).left), + sideTops: [].map.call(sides, side => util.offset(side).top) + }; + } + + // スコープ外 (_geoDepth === 0) では保持せず毎回読み直すので、 + // ラップされていない経路の挙動・コストは変更前と同じ + _getGeometryCache() { + if (this._geo) { + return this._geo; + } + const geo = this._buildGeometryCache(); + if (this._geoDepth > 0) { + this._geo = geo; + } + return geo; + } + + // fn の実行中だけスナップショットを共有する。入れ子で呼ばれた場合は + // 最も外側のスコープが閉じるまで保持し、閉じた時点で必ず破棄する + _withGeometryCache(fn) { + this._geoDepth += 1; + try { + return fn(); + } finally { + this._geoDepth -= 1; + if (this._geoDepth === 0) { + this._geo = null; + } + } + } + getCellInfoByIndex(x, y) { const cell = this.getCellByIndex(x, y); if (!cell) { @@ -219,15 +264,14 @@ export default class aTable extends aTemplate { let returnTop = -1; const width = parseInt(cell.getAttribute('colspan')); const height = parseInt(cell.getAttribute('rowspan')); - const headers = this._getElementsByQuery('.js-table-header th'); - const sides = this._getElementsByQuery('.js-table-side'); - [].forEach.call(headers, (header, index) => { - if (util.offset(header).left === left) { + const geometry = this._getGeometryCache(); + geometry.headerLefts.forEach((headerLeft, index) => { + if (headerLeft === left) { returnLeft = index; } }); - [].forEach.call(sides, (side, index) => { - if (util.offset(side).top === top) { + geometry.sideTops.forEach((sideTop, index) => { + if (sideTop === top) { returnTop = index; } }); @@ -593,6 +637,8 @@ export default class aTable extends aTemplate { } onUpdated() { + // 直前に update() が DOM を作り直しているのでスナップショットは無効 + this._geo = null; const table = this._getElementByQuery('table'); const inner = this._getSelf().parentNode; const elem = this._getElementByQuery('.a-table-selected .a-table-editable'); @@ -611,6 +657,8 @@ export default class aTable extends aTemplate { } else { inner.style.width = 'auto'; } + // 上の幅の書き換えでレイアウトが動くため、ここでも破棄しておく + this._geo = null; if (this.afterRendered) { this.afterRendered(); @@ -1196,6 +1244,8 @@ export default class aTable extends aTemplate { } beforeUpdated() { + // update() の呼び出し元が DOM を触っている可能性があるので破棄してから測り直す + this._geo = null; this.changeSelectOption(); this.markup(); } @@ -1575,3 +1625,43 @@ export default class aTable extends aTemplate { } } + +// 1 回の同期処理の中で getCellInfoByIndex を複数回呼ぶメソッド。 +// 実行中だけヘッダー座標のスナップショットを共有させることで、 +// getBoundingClientRect の呼び出しを「セル数 × (列数 + 行数)」から +// 「列数 + 行数 + セル数」へ減らす。 +// ラップは前後で _geoDepth を増減するだけで、引数・戻り値・例外・ +// 副作用はいずれも元のメソッドのまま変わらない。 +// +// 一方、次のメソッドは意図的に対象外にしている。 +// - updateTable: イベントの振り分け役で、分岐の中で update() や +// putCaret()、コピー / ペーストが走り DOM が変わるため +// - mergeCells: 座標を読んだあとに confirm() でブラウザへ制御を返すため +// (重い isSelectedCellsRectangle() 側でキャッシュは効く) +// - getClipBoardData / processPaste: DOM を直接書き換える、または +// setTimeout でペーストを待つ非同期経路のため +const GEOMETRY_SCOPED_METHODS = [ + 'getSelectedPoints', + 'getAllPoints', + 'getCellIndexByPos', + 'markup', + 'selectRange', + 'selectRow', + 'selectCol', + 'removeRow', + 'removeCol', + 'insertRowAbove', + 'insertRowBelow', + 'insertColLeft', + 'insertColRight', + 'insertTable', + 'splitCell', + 'isSelectedCellsRectangle' +]; + +GEOMETRY_SCOPED_METHODS.forEach((name) => { + const method = aTable.prototype[name]; + aTable.prototype[name] = function geometryScoped(...args) { + return this._withGeometryCache(() => method.apply(this, args)); + }; +}); diff --git a/test/geometry-cache.test.js b/test/geometry-cache.test.js new file mode 100644 index 0000000..0931057 --- /dev/null +++ b/test/geometry-cache.test.js @@ -0,0 +1,237 @@ +import { describe, it, expect, afterEach, vi } from 'vitest'; +import aTable from '../src/index.js'; +import { layoutSimpleTable, mockRect } from './helpers.js'; + +function createTable(html) { + document.body.innerHTML = `${html}
`; + return new aTable('.table'); +} + +const grid3x3 = 'ABC' + + 'DEF' + + 'GHI'; + +// _withGeometryCache を素通しにすると _geoDepth が 0 のままになり、 +// getCellInfoByIndex は毎回ヘッダー座標を読み直す (= キャッシュ導入前と同じ経路) +function withoutGeometryCache(instance, fn) { + const original = instance._withGeometryCache; + instance._withGeometryCache = f => f(); + try { + return fn(); + } finally { + instance._withGeometryCache = original; + } +} + +// ヘッダー / サイドに対する getBoundingClientRect の呼び出し回数を数える。 +// layoutSimpleTable が差し替えたモックの戻り値はそのまま使う +function countGeometryReads(instance) { + const counter = { calls: 0 }; + const elements = [ + ...instance._getElementsByQuery('.js-table-header th'), + ...instance._getElementsByQuery('.js-table-side') + ]; + elements.forEach((element) => { + const original = element.getBoundingClientRect.bind(element); + element.getBoundingClientRect = () => { + counter.calls += 1; + return original(); + }; + }); + counter.size = elements.length; + return counter; +} + +describe('座標キャッシュ: 結果が変わらないこと', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('getAllPoints: キャッシュの有無で同じ結果を返す', () => { + const t = createTable(grid3x3); + layoutSimpleTable(t, 3, 3); + const cached = t.getAllPoints(); + const uncached = withoutGeometryCache(t, () => t.getAllPoints()); + expect(cached).toHaveLength(9); + expect(cached).toEqual(uncached); + }); + + it('getAllPoints: 結合セルを含む表でもキャッシュの有無で同じ結果を返す', () => { + const t = createTable('AC' + + 'DEF'); + layoutSimpleTable(t, 3, 2); + // C は colspan=2 の A の右隣なので論理 x は 2 (left = 300) + mockRect(t.getCellByIndex(1, 0), { left: 300, top: 0 }); + const cached = t.getAllPoints(); + const uncached = withoutGeometryCache(t, () => t.getAllPoints()); + expect(cached).toEqual([ + { x: 0, y: 0, width: 2, height: 1 }, + { x: 2, y: 0, width: 1, height: 1 }, + { x: 0, y: 1, width: 1, height: 1 }, + { x: 1, y: 1, width: 1, height: 1 }, + { x: 2, y: 1, width: 1, height: 1 } + ]); + expect(cached).toEqual(uncached); + }); + + it('getCellIndexByPos: キャッシュの有無で同じ結果を返す', () => { + const t = createTable(grid3x3); + layoutSimpleTable(t, 3, 3); + for (let y = 0; y < 3; y += 1) { + for (let x = 0; x < 3; x += 1) { + const cached = t.getCellIndexByPos(x, y); + const uncached = withoutGeometryCache(t, () => t.getCellIndexByPos(x, y)); + expect(cached).toEqual({ row: y, col: x }); + expect(cached).toEqual(uncached); + } + } + }); + + it('一連の編集操作の結果 (getTable) がキャッシュの有無で一致する', () => { + const scenario = (disableCache) => { + const t = createTable(grid3x3); + if (disableCache) { + t._withGeometryCache = f => f(); + } + layoutSimpleTable(t, 3, 3); + t.select(0, 0); + t.selectRange(1, 1); + layoutSimpleTable(t, 3, 3); + t.insertColRight(1); + layoutSimpleTable(t, 4, 3); + t.insertRowBelow(0); + layoutSimpleTable(t, 4, 4); + // selectRow は contextmenu() 経由でイベントを参照する + t.e = { preventDefault: () => {}, clientX: 0, clientY: 0 }; + t.selectRow(2); + layoutSimpleTable(t, 4, 4); + t.removeCol(3); + const html = t.getTable(); + document.body.innerHTML = ''; + return html; + }; + const withCache = scenario(false); + const withoutCache = scenario(true); + expect(withCache).toBe(withoutCache); + expect(withCache).toContain('A'); + }); +}); + +describe('座標キャッシュ: 計測回数', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('getAllPoints 中のヘッダー座標の計測は 1 巡だけになる', () => { + const t = createTable(grid3x3); + layoutSimpleTable(t, 3, 3); + const counter = countGeometryReads(t); + + t.getAllPoints(); + const cachedCalls = counter.calls; + + counter.calls = 0; + withoutGeometryCache(t, () => t.getAllPoints()); + const uncachedCalls = counter.calls; + + // キャッシュあり: ヘッダー + サイドを 1 回ずつ + expect(cachedCalls).toBe(counter.size); + // キャッシュなし: セル 9 個ぶん繰り返す + expect(uncachedCalls).toBe(counter.size * 9); + expect(cachedCalls).toBeLessThan(uncachedCalls); + }); + + it('入れ子で呼ばれてもスナップショットは 1 回しか作られない', () => { + const t = createTable(grid3x3); + layoutSimpleTable(t, 3, 3); + const counter = countGeometryReads(t); + // isSelectedCellsRectangle は getSelectedPoints / getAllPoints / getCellByPos を + // まとめて呼ぶので、入れ子のスコープでキャッシュが共有されることを確認する + t.select(0, 0); + counter.calls = 0; + t.isSelectedCellsRectangle(); + expect(counter.calls).toBe(counter.size); + }); +}); + +describe('座標キャッシュ: 破棄のタイミング', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('スコープを抜けたらキャッシュは破棄される', () => { + const t = createTable(grid3x3); + layoutSimpleTable(t, 3, 3); + t.getAllPoints(); + expect(t._geo).toBeNull(); + expect(t._geoDepth).toBe(0); + }); + + it('スコープ内ではキャッシュを保持し、最も外側を抜けたときに破棄する', () => { + const t = createTable(grid3x3); + layoutSimpleTable(t, 3, 3); + let geoInScope = null; + let depthInScope = -1; + t._withGeometryCache(() => { + t.getAllPoints(); + geoInScope = t._geo; + depthInScope = t._geoDepth; + }); + expect(depthInScope).toBe(1); + expect(geoInScope).not.toBeNull(); + expect(geoInScope.headerLefts).toEqual([0, 100, 200, 300]); + expect(geoInScope.sideTops).toEqual([0, 50, 100]); + expect(t._geo).toBeNull(); + }); + + it('例外が投げられてもキャッシュとスコープの深さは元に戻る', () => { + const t = createTable(grid3x3); + layoutSimpleTable(t, 3, 3); + expect(() => { + t._withGeometryCache(() => { + t.getAllPoints(); + throw new Error('boom'); + }); + }).toThrow('boom'); + expect(t._geo).toBeNull(); + expect(t._geoDepth).toBe(0); + }); + + it('update() は DOM を作り直すのでキャッシュを破棄する', () => { + const t = createTable(grid3x3); + layoutSimpleTable(t, 3, 3); + t.update(); + expect(t._geo).toBeNull(); + + // スコープの内側で update() が走った場合も破棄され、以降は測り直す + const build = vi.spyOn(t, '_buildGeometryCache'); + t._withGeometryCache(() => { + t.getAllPoints(); + expect(t._geo).not.toBeNull(); + const buildsBeforeUpdate = build.mock.calls.length; + t.update(); + expect(t._geo).toBeNull(); + t.getAllPoints(); + expect(build.mock.calls.length).toBeGreaterThan(buildsBeforeUpdate); + }); + build.mockRestore(); + }); + + it('呼び出しをまたいでキャッシュが残らず、レイアウト変更後は測り直す', () => { + const t = createTable(grid3x3); + layoutSimpleTable(t, 3, 3); + expect(t.getCellInfoByIndex(1, 1)).toEqual({ x: 1, y: 1, width: 1, height: 1 }); + + // 行の高さが変わった状況 (top が 50 刻みから 80 刻みへ) を模す + const sides = t._getElementsByQuery('.js-table-side'); + [].forEach.call(sides, (side, index) => { + mockRect(side, { top: index * 80 }); + }); + for (let y = 0; y < 3; y += 1) { + for (let x = 0; x < 3; x += 1) { + mockRect(t.getCellByIndex(x, y), { left: (x + 1) * 100, top: y * 80 }); + } + } + expect(t.getCellInfoByIndex(1, 1)).toEqual({ x: 1, y: 1, width: 1, height: 1 }); + }); +});