perf(DataTable): implement lazy wrapping and optimize initialization

This commit is contained in:
AI Engineer 2026-05-17 18:50:30 +08:00
parent 0bae1edceb
commit 3f19fb3a83
4 changed files with 145 additions and 92 deletions

70
dist/datatable.js vendored
View File

@ -1,4 +1,4 @@
import { Component, NewState, Util, RefreshState } from "@web/state"; import { Component, NewState, Util } from "@web/state";
import { VirtualScroll } from "@web/base"; import { VirtualScroll } from "@web/base";
Component.register("DataTable", (container) => { Component.register("DataTable", (container) => {
const vs = VirtualScroll(); const vs = VirtualScroll();
@ -21,19 +21,24 @@ Component.register("DataTable", (container) => {
const scrollEl = container.querySelector(".dt-body"); const scrollEl = container.querySelector(".dt-body");
if (!scrollEl) return; if (!scrollEl) return;
const res = vs.calc(scrollEl, state.list); const res = vs.calc(scrollEl, state.list);
if (res) Object.assign(state, { if (res) {
prevHeight: res.prevHeight, res.renderedList.forEach((item, i) => {
postHeight: res.postHeight, if (!item.__watch) {
_listStartIndex: res.listStartIndex, const wrapped = NewState(item);
renderedList: res.renderedList res.renderedList[i] = wrapped;
}); state.list[res.listStartIndex + i] = wrapped;
}
});
Object.assign(state, {
prevHeight: res.prevHeight,
postHeight: res.postHeight,
_listStartIndex: res.listStartIndex,
renderedList: res.renderedList
});
}
}; };
container.refresh = refresh; container.refresh = refresh;
state.__watch("list", (list) => { state.__watch("list", (list) => {
if (list && list.length > 0 && !list[0].__watch) {
state.list = list.map((item) => NewState(item));
return;
}
state._listStartIndex = 0; state._listStartIndex = 0;
const scrollEl = container.querySelector(".dt-body"); const scrollEl = container.querySelector(".dt-body");
state.renderedList = vs.reset(list, scrollEl || { clientHeight: 800 }) || []; state.renderedList = vs.reset(list, scrollEl || { clientHeight: 800 }) || [];
@ -56,8 +61,10 @@ Component.register("DataTable", (container) => {
}; };
container.clearAllActive = (keepSelection = false) => { container.clearAllActive = (keepSelection = false) => {
state.list.forEach((row) => { state.list.forEach((row) => {
if (row._editingF !== null) row._editingF = null; if (row && row.__watch) {
if (row._activeF !== null) row._activeF = null; if (row._editingF !== null) row._editingF = null;
if (row._activeF !== null) row._activeF = null;
}
}); });
if (!keepSelection) { if (!keepSelection) {
state.selStartR = -1; state.selStartR = -1;
@ -85,13 +92,14 @@ Component.register("DataTable", (container) => {
state.selStartF = state.selEndF = f; state.selStartF = state.selEndF = f;
} }
state.isSelecting = true; state.isSelecting = true;
state.list[r]._activeF = f; if (state.list[r] && state.list[r].__watch) state.list[r]._activeF = f;
} }
}; };
container.updateSelect = (r, f) => state.isSelecting && (state.selEndR = r, state.selEndF = f); container.updateSelect = (r, f) => state.isSelecting && (state.selEndR = r, state.selEndF = f);
container.endSelect = () => state.isSelecting = false; container.endSelect = () => state.isSelecting = false;
container.editCell = (row, f, fIdx) => { container.editCell = (row, f, fIdx) => {
var _a; var _a;
if (!row.__watch) return;
const rMin = Math.min(state.selStartR, state.selEndR), rMax = Math.max(state.selStartR, state.selEndR); const rMin = Math.min(state.selStartR, state.selEndR), rMax = Math.max(state.selStartR, state.selEndR);
const fMin = Math.min(state.selStartF, state.selEndF), fMax = Math.max(state.selStartF, state.selEndF); const fMin = Math.min(state.selStartF, state.selEndF), fMax = Math.max(state.selStartF, state.selEndF);
const rIdx = state.list.indexOf(row); const rIdx = state.list.indexOf(row);
@ -106,7 +114,12 @@ Component.register("DataTable", (container) => {
if (count > 1) { if (count > 1) {
const unwatch = row.__watch(f.id, (val) => { const unwatch = row.__watch(f.id, (val) => {
for (let r = rMin; r <= rMax; r++) { for (let r = rMin; r <= rMax; r++) {
if (state.list[r] !== row) state.list[r][f.id] = val; const cur = state.list[r];
if (cur !== row) {
const wrapped = cur.__watch ? cur : NewState(cur);
state.list[r] = wrapped;
wrapped[f.id] = val;
}
} }
unwatch(); unwatch();
}); });
@ -160,15 +173,21 @@ Component.register("DataTable", (container) => {
const fStart = Math.min(state.selStartF, state.selEndF); const fStart = Math.min(state.selStartF, state.selEndF);
if (rStart === -1) return; if (rStart === -1) return;
rows.forEach((rowData, i) => { rows.forEach((rowData, i) => {
const row = state.list[rStart + i]; let row = state.list[rStart + i];
if (row) rowData.forEach((val, j) => { if (row) {
const field = state.fields[fStart + j]; if (!row.__watch) {
if (field) { row = NewState(row);
if (typeof row[field.id] === "boolean") row[field.id] = val.toLowerCase() === "true"; state.list[rStart + i] = row;
else if (typeof row[field.id] === "number") row[field.id] = Number(val);
else row[field.id] = val;
} }
}); rowData.forEach((val, j) => {
const field = state.fields[fStart + j];
if (field) {
if (typeof row[field.id] === "boolean") row[field.id] = val.toLowerCase() === "true";
else if (typeof row[field.id] === "number") row[field.id] = Number(val);
else row[field.id] = val;
}
});
}
}); });
}; };
container.addEventListener("keydown", (e) => { container.addEventListener("keydown", (e) => {
@ -242,8 +261,3 @@ Component.register("DataTable", (container) => {
</div> </div>
` `
)); ));
if (typeof document !== "undefined") {
const initDataTable = () => RefreshState(document.documentElement);
if (document.readyState !== "loading") initDataTable();
else document.addEventListener("DOMContentLoaded", initDataTable, true);
}

File diff suppressed because one or more lines are too long

View File

@ -17,20 +17,28 @@ Component.register('DataTable', container => {
const scrollEl = container.querySelector('.dt-body') const scrollEl = container.querySelector('.dt-body')
if (!scrollEl) return if (!scrollEl) return
const res = vs.calc(scrollEl, state.list) const res = vs.calc(scrollEl, state.list)
if (res) Object.assign(state, { if (res) {
prevHeight: res.prevHeight, postHeight: res.postHeight, // 仅对渲染列表中的项进行响应式包装(如果尚未包装)
_listStartIndex: res.listStartIndex, renderedList: res.renderedList res.renderedList.forEach((item, i) => {
}) if (!item.__watch) {
const wrapped = NewState(item)
res.renderedList[i] = wrapped
// 同步回原列表
state.list[res.listStartIndex + i] = wrapped
}
})
Object.assign(state, {
prevHeight: res.prevHeight, postHeight: res.postHeight,
_listStartIndex: res.listStartIndex, renderedList: res.renderedList
})
}
} }
container.refresh = refresh container.refresh = refresh
state.__watch('list', list => { state.__watch('list', list => {
if (list && list.length > 0 && !list[0].__watch) {
state.list = list.map(item => NewState(item))
return
}
state._listStartIndex = 0 state._listStartIndex = 0
const scrollEl = container.querySelector('.dt-body') const scrollEl = container.querySelector('.dt-body')
// 初始预览,不全量包装
state.renderedList = vs.reset(list, scrollEl || { clientHeight: 800 }) || [] state.renderedList = vs.reset(list, scrollEl || { clientHeight: 800 }) || []
if (scrollEl) { if (scrollEl) {
vs.init(list, refresh) vs.init(list, refresh)
@ -55,8 +63,10 @@ Component.register('DataTable', container => {
container.clearAllActive = (keepSelection = false) => { container.clearAllActive = (keepSelection = false) => {
state.list.forEach(row => { state.list.forEach(row => {
if (row._editingF !== null) row._editingF = null if (row && row.__watch) {
if (row._activeF !== null) row._activeF = null if (row._editingF !== null) row._editingF = null
if (row._activeF !== null) row._activeF = null
}
}) })
if (!keepSelection) { if (!keepSelection) {
state.selStartR = -1; state.multiSelections = [] state.selStartR = -1; state.multiSelections = []
@ -82,7 +92,7 @@ Component.register('DataTable', container => {
state.selStartF = state.selEndF = f state.selStartF = state.selEndF = f
} }
state.isSelecting = true state.isSelecting = true
state.list[r]._activeF = f if (state.list[r] && state.list[r].__watch) state.list[r]._activeF = f
} }
} }
@ -90,6 +100,7 @@ Component.register('DataTable', container => {
container.endSelect = () => state.isSelecting = false container.endSelect = () => state.isSelecting = false
container.editCell = (row, f, fIdx) => { container.editCell = (row, f, fIdx) => {
if (!row.__watch) return; // 理论上双击的行一定已经包装过
const rMin = Math.min(state.selStartR, state.selEndR), rMax = Math.max(state.selStartR, state.selEndR) const rMin = Math.min(state.selStartR, state.selEndR), rMax = Math.max(state.selStartR, state.selEndR)
const fMin = Math.min(state.selStartF, state.selEndF), fMax = Math.max(state.selStartF, state.selEndF) const fMin = Math.min(state.selStartF, state.selEndF), fMax = Math.max(state.selStartF, state.selEndF)
const rIdx = state.list.indexOf(row) const rIdx = state.list.indexOf(row)
@ -107,7 +118,12 @@ Component.register('DataTable', container => {
if (count > 1) { if (count > 1) {
const unwatch = row.__watch(f.id, (val) => { const unwatch = row.__watch(f.id, (val) => {
for (let r = rMin; r <= rMax; r++) { for (let r = rMin; r <= rMax; r++) {
if (state.list[r] !== row) state.list[r][f.id] = val const cur = state.list[r]
if (cur !== row) {
const wrapped = cur.__watch ? cur : NewState(cur)
state.list[r] = wrapped
wrapped[f.id] = val
}
} }
unwatch() unwatch()
}) })
@ -156,15 +172,21 @@ Component.register('DataTable', container => {
const fStart = Math.min(state.selStartF, state.selEndF) const fStart = Math.min(state.selStartF, state.selEndF)
if (rStart === -1) return if (rStart === -1) return
rows.forEach((rowData, i) => { rows.forEach((rowData, i) => {
const row = state.list[rStart + i] let row = state.list[rStart + i]
if (row) rowData.forEach((val, j) => { if (row) {
const field = state.fields[fStart + j] if (!row.__watch) {
if (field) { row = NewState(row)
if (typeof row[field.id] === 'boolean') row[field.id] = val.toLowerCase() === 'true' state.list[rStart + i] = row
else if (typeof row[field.id] === 'number') row[field.id] = Number(val)
else row[field.id] = val
} }
}) rowData.forEach((val, j) => {
const field = state.fields[fStart + j]
if (field) {
if (typeof row[field.id] === 'boolean') row[field.id] = val.toLowerCase() === 'true'
else if (typeof row[field.id] === 'number') row[field.id] = Number(val)
else row[field.id] = val
}
})
}
}) })
} }
@ -232,9 +254,3 @@ Component.register('DataTable', container => {
</style> </style>
</div> </div>
`)) `))
if (typeof document !== 'undefined') {
const initDataTable = () => RefreshState(document.documentElement)
if (document.readyState !== 'loading') initDataTable()
else document.addEventListener('DOMContentLoaded', initDataTable, true)
}

View File

@ -34,6 +34,7 @@
</head> </head>
<body class="bg-light"> <body class="bg-light">
<div id="debug-log" style="position:fixed; top:0; right:0; background:rgba(0,0,0,0.8); color:white; z-index:9999; font-family:monospace; padding:10px; max-height:200px; overflow:auto;"></div>
<div class="demo-container"> <div class="demo-container">
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0 text-primary fw-bold">DataTable Professional</h4> <h4 class="mb-0 text-primary fw-bold">DataTable Professional</h4>
@ -49,48 +50,70 @@
import '@web/base' import '@web/base'
import '../src/index.js' import '../src/index.js'
const table = document.getElementById('myTable') const logEl = document.getElementById('debug-log');
const log = (msg) => {
console.log(msg);
logEl.innerText += msg + '\n';
};
const fields = [ log('Script starting after imports...');
{ id: 'id', name: 'ID', width: 60, type: 'text', pinned: 'left' },
{ id: 'name', name: 'User Name', width: 150, type: 'text', pinned: 'left' },
{ id: 'role', name: 'Role', width: 120, type: 'select', options: ['Admin', 'Editor', 'Viewer'] },
{ id: 'active', name: 'Active', width: 80, type: 'switch' },
{ id: 'bio', name: 'Bio', width: 250, type: 'textarea' },
{ id: 'gender', name: 'Gender', width: 120, type: 'radio', options: ['Male', 'Female', 'Other'] },
{ id: 'tags', name: 'Tags', width: 150, type: 'TagsInput' },
{ id: 'score', name: 'Score', width: 100, type: 'number' },
{ id: 'created', name: 'Created At', width: 150, type: 'date' },
{ id: 'actions', name: 'Actions', width: 100, type: 'text', pinned: 'right' }
]
const generateData = (count) => Array.from({ length: count }, (_, i) => ({ try {
id: i + 1, const table = document.getElementById('myTable')
name: 'User ' + (i + 1),
role: ['Admin', 'Editor', 'Viewer'][i % 3],
active: i % 2 === 0,
bio: 'This is the bio for user ' + (i + 1) + '. It might be a long text that needs a textarea for editing.',
gender: ['Male', 'Female', 'Other'][i % 3],
tags: ['State.js', 'Vite', 'Playwright'].slice(0, (i % 3) + 1),
score: Math.floor(Math.random() * 100),
created: new Date().toISOString().split('T')[0],
actions: '...'
}))
const data = generateData(500) const fields = [
{ id: 'id', name: 'ID', width: 60, type: 'text', pinned: 'left' },
{ id: 'name', name: 'User Name', width: 150, type: 'text', pinned: 'left' },
{ id: 'role', name: 'Role', width: 120, type: 'select', options: ['Admin', 'Editor', 'Viewer'] },
{ id: 'active', name: 'Active', width: 80, type: 'switch' },
{ id: 'bio', name: 'Bio', width: 250, type: 'textarea' },
{ id: 'gender', name: 'Gender', width: 120, type: 'radio', options: ['Male', 'Female', 'Other'] },
{ id: 'tags', name: 'Tags', width: 150, type: 'TagsInput' },
{ id: 'score', name: 'Score', width: 100, type: 'number' },
{ id: 'created', name: 'Created At', width: 150, type: 'date' },
{ id: 'actions', name: 'Actions', width: 100, type: 'text', pinned: 'right' }
]
// 数据初始化逻辑 const generateData = (count) => Array.from({ length: count }, (_, i) => ({
setTimeout(() => { id: i + 1,
Object.assign(table.state, { name: 'User ' + (i + 1),
fields: fields, role: ['Admin', 'Editor', 'Viewer'][i % 3],
list: data active: i % 2 === 0,
}) bio: 'This is the bio for user ' + (i + 1) + '. It might be a long text that needs a textarea for editing.',
gender: ['Male', 'Female', 'Other'][i % 3],
tags: ['State.js', 'Vite', 'Playwright'].slice(0, (i % 3) + 1),
score: Math.floor(Math.random() * 100),
created: new Date().toISOString().split('T')[0],
actions: '...'
}))
const data = generateData(500)
// 数据初始化逻辑
setTimeout(() => { setTimeout(() => {
window.testStatus = 'passed' try {
console.log('DataTable initialized in test page') log('Triggering RefreshState before init...');
}, 500) RefreshState(table);
}, 200)
log('Initializing table state...');
if (!table.state) table.state = NewState({});
Object.assign(table.state, {
fields: fields,
list: data
})
setTimeout(() => {
window.testStatus = 'passed'
log('DataTable initialized in test page: PASSED');
}, 500)
} catch (e) {
log('ERROR in state init: ' + e.message + '\n' + e.stack);
}
}, 200)
} catch (e) {
log('ERROR during startup: ' + e.message);
}
</script> </script>
</body> </body>