@wsxjs/wsx-core 0.0.30 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chunk-NEHWERG6.mjs +1424 -0
- package/dist/chunk-PIKDVFOA.mjs +1337 -0
- package/dist/chunk-PNIWQQN6.mjs +1330 -0
- package/dist/chunk-PP54HBAY.mjs +1337 -0
- package/dist/index.js +1720 -1988
- package/dist/index.mjs +170 -345
- package/dist/jsx-runtime.js +110 -154
- package/dist/jsx-runtime.mjs +1 -1
- package/dist/jsx.js +110 -154
- package/dist/jsx.mjs +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/base-component.ts +8 -309
- package/src/index.ts +1 -0
- package/src/jsx-factory.ts +3 -1
- package/src/light-component.ts +111 -123
- package/src/utils/cache-key.ts +17 -2
- package/src/utils/dom-utils.ts +7 -47
- package/src/utils/element-update.ts +107 -231
- package/src/utils/update-children-helpers.ts +67 -78
- package/src/web-component.ts +108 -96
package/src/base-component.ts
CHANGED
|
@@ -19,19 +19,6 @@ interface ReactiveStateStorage {
|
|
|
19
19
|
setter: (value: unknown | ((prev: unknown) => unknown)) => void;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
/**
|
|
23
|
-
* Focus state interface for focus preservation during rerender
|
|
24
|
-
*/
|
|
25
|
-
interface FocusState {
|
|
26
|
-
key: string;
|
|
27
|
-
elementType: "input" | "textarea" | "select" | "contenteditable";
|
|
28
|
-
value?: string;
|
|
29
|
-
selectionStart?: number;
|
|
30
|
-
selectionEnd?: number;
|
|
31
|
-
scrollTop?: number; // For textarea
|
|
32
|
-
selectedIndex?: number; // For select
|
|
33
|
-
}
|
|
34
|
-
|
|
35
22
|
/**
|
|
36
23
|
* Base configuration interface
|
|
37
24
|
*/
|
|
@@ -62,28 +49,18 @@ export abstract class BaseComponent extends HTMLElement {
|
|
|
62
49
|
protected _autoStyles?: string;
|
|
63
50
|
|
|
64
51
|
/**
|
|
65
|
-
*
|
|
52
|
+
* Unique instance ID for this component
|
|
53
|
+
* Used for generating stable cache keys (RFC 0059)
|
|
54
|
+
* Can be manually set or auto-generated by cache-key.ts if missing
|
|
66
55
|
* @internal
|
|
67
56
|
*/
|
|
68
|
-
|
|
57
|
+
public _wsxInstanceId?: string;
|
|
69
58
|
|
|
70
59
|
/**
|
|
71
|
-
*
|
|
72
|
-
* @internal - 由 rerender() 方法管理
|
|
73
|
-
*/
|
|
74
|
-
protected _pendingFocusState: FocusState | null = null;
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* 防抖定时器,用于延迟重渲染(当用户正在输入时)
|
|
78
|
-
* @internal
|
|
79
|
-
*/
|
|
80
|
-
private _rerenderDebounceTimer: number | null = null;
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* 待处理的重渲染标志(当用户正在输入时,标记需要重渲染但延迟执行)
|
|
60
|
+
* DOM Cache Manager for fine-grained updates (RFC 0037)
|
|
84
61
|
* @internal
|
|
85
62
|
*/
|
|
86
|
-
|
|
63
|
+
protected _domCache = new DOMCacheManager();
|
|
87
64
|
|
|
88
65
|
/**
|
|
89
66
|
* 正在渲染标志(防止在 _rerender() 执行期间再次触发 scheduleRerender())
|
|
@@ -160,40 +137,6 @@ export abstract class BaseComponent extends HTMLElement {
|
|
|
160
137
|
return this._domCache;
|
|
161
138
|
}
|
|
162
139
|
|
|
163
|
-
/**
|
|
164
|
-
* 处理 blur 事件,在用户停止输入时执行待处理的重渲染
|
|
165
|
-
* @internal
|
|
166
|
-
*/
|
|
167
|
-
private handleGlobalBlur = (event: FocusEvent): void => {
|
|
168
|
-
// 检查 blur 的元素是否在组件内
|
|
169
|
-
const root = this.getActiveRoot();
|
|
170
|
-
const target = event.target as HTMLElement;
|
|
171
|
-
|
|
172
|
-
if (target && root.contains(target)) {
|
|
173
|
-
// 用户停止输入,执行待处理的重渲染
|
|
174
|
-
if (this._pendingRerender && this.connected) {
|
|
175
|
-
// 清除防抖定时器
|
|
176
|
-
if (this._rerenderDebounceTimer !== null) {
|
|
177
|
-
clearTimeout(this._rerenderDebounceTimer);
|
|
178
|
-
this._rerenderDebounceTimer = null;
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
// 延迟一小段时间后重渲染,确保 blur 事件完全处理
|
|
182
|
-
requestAnimationFrame(() => {
|
|
183
|
-
if (this._pendingRerender && this.connected && !this._isRendering) {
|
|
184
|
-
this._pendingRerender = false;
|
|
185
|
-
// 设置渲染标志,防止在 _rerender() 执行期间再次触发
|
|
186
|
-
// 注意:_isRendering 标志会在 _rerender() 的 onRendered() 调用完成后清除
|
|
187
|
-
this._isRendering = true;
|
|
188
|
-
// 调用 _rerender() 执行实际渲染(不再调用 rerender(),避免循环)
|
|
189
|
-
// _isRendering 标志会在 _rerender() 完成所有异步操作后清除
|
|
190
|
-
this._rerender();
|
|
191
|
-
}
|
|
192
|
-
});
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
};
|
|
196
|
-
|
|
197
140
|
/**
|
|
198
141
|
* 可选生命周期钩子:组件已断开
|
|
199
142
|
*/
|
|
@@ -260,11 +203,6 @@ export abstract class BaseComponent extends HTMLElement {
|
|
|
260
203
|
*/
|
|
261
204
|
protected scheduleRerender(): void {
|
|
262
205
|
if (!this.connected) {
|
|
263
|
-
// 如果组件已断开,清除定时器
|
|
264
|
-
if (this._rerenderDebounceTimer !== null) {
|
|
265
|
-
clearTimeout(this._rerenderDebounceTimer);
|
|
266
|
-
this._rerenderDebounceTimer = null;
|
|
267
|
-
}
|
|
268
206
|
return;
|
|
269
207
|
}
|
|
270
208
|
|
|
@@ -279,60 +217,6 @@ export abstract class BaseComponent extends HTMLElement {
|
|
|
279
217
|
return;
|
|
280
218
|
}
|
|
281
219
|
|
|
282
|
-
// 检查是否有需要持续输入的元素获得焦点(input、textarea、select、contenteditable)
|
|
283
|
-
// 按钮等其他元素应该立即重渲染,以反映状态变化
|
|
284
|
-
const root = this.getActiveRoot();
|
|
285
|
-
let activeElement: HTMLElement | null = null;
|
|
286
|
-
|
|
287
|
-
if (root instanceof ShadowRoot) {
|
|
288
|
-
activeElement = root.activeElement as HTMLElement | null;
|
|
289
|
-
} else {
|
|
290
|
-
const docActiveElement = document.activeElement;
|
|
291
|
-
if (docActiveElement && root.contains(docActiveElement)) {
|
|
292
|
-
activeElement = docActiveElement as HTMLElement;
|
|
293
|
-
}
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
// 只对需要持续输入的元素跳过重渲染(input、textarea、select、contenteditable)
|
|
297
|
-
// 按钮等其他元素应该立即重渲染
|
|
298
|
-
// 如果元素有 data-wsx-force-render 属性,即使是要持续输入的元素也强制重渲染
|
|
299
|
-
if (activeElement) {
|
|
300
|
-
// 检查是否是需要持续输入的元素
|
|
301
|
-
// 注意:radio 和 checkbox 不需要持续输入,应该立即重渲染
|
|
302
|
-
const isInputElement =
|
|
303
|
-
(activeElement instanceof HTMLInputElement &&
|
|
304
|
-
activeElement.type !== "radio" &&
|
|
305
|
-
activeElement.type !== "checkbox") ||
|
|
306
|
-
activeElement instanceof HTMLTextAreaElement ||
|
|
307
|
-
activeElement instanceof HTMLSelectElement ||
|
|
308
|
-
activeElement.hasAttribute("contenteditable");
|
|
309
|
-
|
|
310
|
-
// 检查是否有强制渲染属性
|
|
311
|
-
const forceRender = activeElement.hasAttribute("data-wsx-force-render");
|
|
312
|
-
|
|
313
|
-
// 如果是输入元素且没有强制渲染属性,跳过重渲染,等待 blur 事件
|
|
314
|
-
if (isInputElement && !forceRender) {
|
|
315
|
-
// 标记需要重渲染,但延迟到 blur 事件
|
|
316
|
-
this._pendingRerender = true;
|
|
317
|
-
|
|
318
|
-
// 清除之前的定时器(不再使用定时器,只等待 blur)
|
|
319
|
-
if (this._rerenderDebounceTimer !== null) {
|
|
320
|
-
clearTimeout(this._rerenderDebounceTimer);
|
|
321
|
-
this._rerenderDebounceTimer = null;
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
// 不执行重渲染,等待 blur 事件
|
|
325
|
-
return;
|
|
326
|
-
}
|
|
327
|
-
// 对于按钮等其他元素,或者有 data-wsx-force-render 属性的输入元素,继续执行重渲染(不跳过)
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
// 没有焦点元素,立即重渲染(使用 requestAnimationFrame 确保在下一个渲染帧执行)
|
|
331
|
-
// 如果有待处理的重渲染,也立即执行
|
|
332
|
-
if (this._pendingRerender) {
|
|
333
|
-
this._pendingRerender = false;
|
|
334
|
-
}
|
|
335
|
-
|
|
336
220
|
// 标记已调度渲染(批量更新的关键)
|
|
337
221
|
this._hasScheduledRender = true;
|
|
338
222
|
|
|
@@ -383,19 +267,7 @@ export abstract class BaseComponent extends HTMLElement {
|
|
|
383
267
|
* @internal
|
|
384
268
|
*/
|
|
385
269
|
protected cleanup(): void {
|
|
386
|
-
//
|
|
387
|
-
if (this._rerenderDebounceTimer !== null) {
|
|
388
|
-
clearTimeout(this._rerenderDebounceTimer);
|
|
389
|
-
this._rerenderDebounceTimer = null;
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
// 移除 blur 事件监听器
|
|
393
|
-
const root = this.getActiveRoot();
|
|
394
|
-
// 注意:在 disconnectedCallback 中,shadowRoot 仍然可用
|
|
395
|
-
root.removeEventListener("blur", this.handleGlobalBlur as EventListener, true);
|
|
396
|
-
|
|
397
|
-
// 清除待处理的重渲染标志
|
|
398
|
-
this._pendingRerender = false;
|
|
270
|
+
// 清理逻辑已简化,不再需要清除定时器或事件监听器
|
|
399
271
|
}
|
|
400
272
|
|
|
401
273
|
/**
|
|
@@ -403,13 +275,7 @@ export abstract class BaseComponent extends HTMLElement {
|
|
|
403
275
|
* @internal
|
|
404
276
|
*/
|
|
405
277
|
protected initializeEventListeners(): void {
|
|
406
|
-
//
|
|
407
|
-
// 关键修复:将监听器在这个组件的根节点上注册(ShadowRoot 或 Host Element)
|
|
408
|
-
// 而不是 document。因为 blur 事件在 Shadow DOM 中默认是不冒泡且不跨越边界的 (composed: false)
|
|
409
|
-
// 如果监听器在 document 上,它无法捕获到 Shadow DOM 内部元素的 blur 事件
|
|
410
|
-
// 使用 capture: true 确保我们在事件到达目标之前捕获它(对于 blur 主要是为了捕获)
|
|
411
|
-
const root = this.getActiveRoot();
|
|
412
|
-
root.addEventListener("blur", this.handleGlobalBlur as EventListener, true);
|
|
278
|
+
// 初始化逻辑已简化,不再需要添加 blur 监听器
|
|
413
279
|
}
|
|
414
280
|
|
|
415
281
|
/**
|
|
@@ -491,171 +357,4 @@ export abstract class BaseComponent extends HTMLElement {
|
|
|
491
357
|
}
|
|
492
358
|
return this;
|
|
493
359
|
}
|
|
494
|
-
|
|
495
|
-
/**
|
|
496
|
-
* 捕获当前焦点状态(在重渲染之前调用)
|
|
497
|
-
* @returns 焦点状态,如果没有焦点元素则返回 null
|
|
498
|
-
*/
|
|
499
|
-
protected captureFocusState(): FocusState | null {
|
|
500
|
-
const root = this.getActiveRoot();
|
|
501
|
-
let activeElement: Element | null = null;
|
|
502
|
-
|
|
503
|
-
// 获取活动元素
|
|
504
|
-
if (root instanceof ShadowRoot) {
|
|
505
|
-
// Shadow DOM: 使用 shadowRoot.activeElement
|
|
506
|
-
activeElement = root.activeElement;
|
|
507
|
-
} else {
|
|
508
|
-
// Light DOM: 检查 document.activeElement 是否在组件内
|
|
509
|
-
const docActiveElement = document.activeElement;
|
|
510
|
-
if (docActiveElement && root.contains(docActiveElement)) {
|
|
511
|
-
activeElement = docActiveElement;
|
|
512
|
-
}
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
if (!activeElement || !(activeElement instanceof HTMLElement)) {
|
|
516
|
-
return null;
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
// 检查元素是否有 data-wsx-key 属性
|
|
520
|
-
const key = activeElement.getAttribute("data-wsx-key");
|
|
521
|
-
if (!key) {
|
|
522
|
-
return null; // 元素没有 key,跳过焦点保持
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
const tagName = activeElement.tagName.toLowerCase();
|
|
526
|
-
const state: FocusState = {
|
|
527
|
-
key,
|
|
528
|
-
elementType: tagName as FocusState["elementType"],
|
|
529
|
-
};
|
|
530
|
-
|
|
531
|
-
// 处理 input 和 textarea
|
|
532
|
-
if (
|
|
533
|
-
activeElement instanceof HTMLInputElement ||
|
|
534
|
-
activeElement instanceof HTMLTextAreaElement
|
|
535
|
-
) {
|
|
536
|
-
state.value = activeElement.value;
|
|
537
|
-
state.selectionStart = activeElement.selectionStart ?? undefined;
|
|
538
|
-
state.selectionEnd = activeElement.selectionEnd ?? undefined;
|
|
539
|
-
|
|
540
|
-
// 对于 textarea,保存滚动位置
|
|
541
|
-
if (activeElement instanceof HTMLTextAreaElement) {
|
|
542
|
-
state.scrollTop = activeElement.scrollTop;
|
|
543
|
-
}
|
|
544
|
-
}
|
|
545
|
-
// 处理 select
|
|
546
|
-
else if (activeElement instanceof HTMLSelectElement) {
|
|
547
|
-
state.elementType = "select";
|
|
548
|
-
state.selectedIndex = activeElement.selectedIndex;
|
|
549
|
-
}
|
|
550
|
-
// 处理 contenteditable
|
|
551
|
-
else if (activeElement.hasAttribute("contenteditable")) {
|
|
552
|
-
state.elementType = "contenteditable";
|
|
553
|
-
const selection = window.getSelection();
|
|
554
|
-
if (selection && selection.rangeCount > 0) {
|
|
555
|
-
const range = selection.getRangeAt(0);
|
|
556
|
-
state.selectionStart = range.startOffset;
|
|
557
|
-
state.selectionEnd = range.endOffset;
|
|
558
|
-
}
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
return state;
|
|
562
|
-
}
|
|
563
|
-
|
|
564
|
-
/**
|
|
565
|
-
* 恢复焦点状态(在重渲染之后调用)
|
|
566
|
-
* @param state - 之前捕获的焦点状态
|
|
567
|
-
*/
|
|
568
|
-
protected restoreFocusState(state: FocusState | null): void {
|
|
569
|
-
if (!state || !state.key) {
|
|
570
|
-
return;
|
|
571
|
-
}
|
|
572
|
-
|
|
573
|
-
const root = this.getActiveRoot();
|
|
574
|
-
const target = root.querySelector(`[data-wsx-key="${state.key}"]`) as HTMLElement;
|
|
575
|
-
|
|
576
|
-
if (!target) {
|
|
577
|
-
return; // 元素未找到,跳过恢复
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
// 立即同步恢复值,避免闪烁
|
|
581
|
-
// 这必须在 appendChild 之后立即执行,在浏览器渲染之前
|
|
582
|
-
if (state.value !== undefined) {
|
|
583
|
-
if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) {
|
|
584
|
-
// 直接设置 value 属性,覆盖 render() 中设置的值
|
|
585
|
-
// 使用 .value 而不是 setAttribute,因为 .value 是当前值,setAttribute 是初始值
|
|
586
|
-
target.value = state.value;
|
|
587
|
-
}
|
|
588
|
-
}
|
|
589
|
-
|
|
590
|
-
// 恢复 select 状态
|
|
591
|
-
if (state.selectedIndex !== undefined && target instanceof HTMLSelectElement) {
|
|
592
|
-
target.selectedIndex = state.selectedIndex;
|
|
593
|
-
}
|
|
594
|
-
|
|
595
|
-
// 使用 requestAnimationFrame 恢复焦点和光标位置
|
|
596
|
-
// 这样可以确保 DOM 完全更新,但值已经同步恢复了
|
|
597
|
-
requestAnimationFrame(() => {
|
|
598
|
-
// 再次查找元素(确保元素仍然存在)
|
|
599
|
-
const currentTarget = root.querySelector(
|
|
600
|
-
`[data-wsx-key="${state.key}"]`
|
|
601
|
-
) as HTMLElement;
|
|
602
|
-
|
|
603
|
-
if (!currentTarget) {
|
|
604
|
-
return;
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
// 再次确保值正确(防止被其他代码覆盖)
|
|
608
|
-
if (state.value !== undefined) {
|
|
609
|
-
if (
|
|
610
|
-
currentTarget instanceof HTMLInputElement ||
|
|
611
|
-
currentTarget instanceof HTMLTextAreaElement
|
|
612
|
-
) {
|
|
613
|
-
// 只有在值不同时才更新,避免触发额外的事件
|
|
614
|
-
if (currentTarget.value !== state.value) {
|
|
615
|
-
currentTarget.value = state.value;
|
|
616
|
-
}
|
|
617
|
-
}
|
|
618
|
-
}
|
|
619
|
-
|
|
620
|
-
// 聚焦元素(防止页面滚动)
|
|
621
|
-
currentTarget.focus({ preventScroll: true });
|
|
622
|
-
|
|
623
|
-
// 恢复光标/选择位置
|
|
624
|
-
if (state.selectionStart !== undefined) {
|
|
625
|
-
if (
|
|
626
|
-
currentTarget instanceof HTMLInputElement ||
|
|
627
|
-
currentTarget instanceof HTMLTextAreaElement
|
|
628
|
-
) {
|
|
629
|
-
const start = state.selectionStart;
|
|
630
|
-
const end = state.selectionEnd ?? start;
|
|
631
|
-
currentTarget.setSelectionRange(start, end);
|
|
632
|
-
|
|
633
|
-
// 恢复 textarea 滚动位置
|
|
634
|
-
if (
|
|
635
|
-
state.scrollTop !== undefined &&
|
|
636
|
-
currentTarget instanceof HTMLTextAreaElement
|
|
637
|
-
) {
|
|
638
|
-
currentTarget.scrollTop = state.scrollTop;
|
|
639
|
-
}
|
|
640
|
-
} else if (currentTarget.hasAttribute("contenteditable")) {
|
|
641
|
-
// 恢复 contenteditable 选择
|
|
642
|
-
const selection = window.getSelection();
|
|
643
|
-
if (selection) {
|
|
644
|
-
const range = document.createRange();
|
|
645
|
-
const textNode = currentTarget.childNodes[0];
|
|
646
|
-
if (textNode && textNode.nodeType === Node.TEXT_NODE) {
|
|
647
|
-
const maxPos = Math.min(
|
|
648
|
-
state.selectionStart,
|
|
649
|
-
textNode.textContent?.length || 0
|
|
650
|
-
);
|
|
651
|
-
range.setStart(textNode, maxPos);
|
|
652
|
-
range.setEnd(textNode, state.selectionEnd ?? maxPos);
|
|
653
|
-
selection.removeAllRanges();
|
|
654
|
-
selection.addRange(range);
|
|
655
|
-
}
|
|
656
|
-
}
|
|
657
|
-
}
|
|
658
|
-
}
|
|
659
|
-
});
|
|
660
|
-
}
|
|
661
360
|
}
|
package/src/index.ts
CHANGED
package/src/jsx-factory.ts
CHANGED
|
@@ -201,12 +201,14 @@ export function Fragment(_props: unknown, children: JSXChildren[]): DocumentFrag
|
|
|
201
201
|
|
|
202
202
|
if (typeof child === "string" || typeof child === "number") {
|
|
203
203
|
const textNode = document.createTextNode(String(child));
|
|
204
|
-
(textNode as
|
|
204
|
+
(textNode as Text & { __wsxManaged?: boolean }).__wsxManaged = true;
|
|
205
205
|
fragment.appendChild(textNode);
|
|
206
206
|
} else if (child instanceof HTMLElement || child instanceof SVGElement) {
|
|
207
207
|
fragment.appendChild(child);
|
|
208
208
|
} else if (child instanceof DocumentFragment) {
|
|
209
209
|
fragment.appendChild(child);
|
|
210
|
+
} else if (child instanceof Node) {
|
|
211
|
+
fragment.appendChild(child);
|
|
210
212
|
}
|
|
211
213
|
});
|
|
212
214
|
|