@vobs/runtime 1.0.0 → 1.2.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/async-boundary.cjs +623 -0
- package/dist/async-boundary.cjs.map +1 -0
- package/dist/async-boundary.d.cts +18 -0
- package/dist/async-boundary.d.ts +18 -0
- package/dist/async-boundary.js +620 -0
- package/dist/async-boundary.js.map +1 -0
- package/dist/bind.cjs +312 -0
- package/dist/bind.cjs.map +1 -0
- package/dist/bind.d.cts +8 -0
- package/dist/bind.d.ts +8 -0
- package/dist/bind.js +308 -0
- package/dist/bind.js.map +1 -0
- package/dist/boundary.cjs +621 -0
- package/dist/boundary.cjs.map +1 -0
- package/dist/boundary.d.cts +18 -0
- package/dist/boundary.d.ts +18 -0
- package/dist/boundary.js +619 -0
- package/dist/boundary.js.map +1 -0
- package/dist/debug.cjs +94 -0
- package/dist/debug.cjs.map +1 -0
- package/dist/debug.d.cts +58 -0
- package/dist/debug.d.ts +58 -0
- package/dist/debug.js +85 -0
- package/dist/debug.js.map +1 -0
- package/dist/dynamic.cjs +801 -0
- package/dist/dynamic.cjs.map +1 -0
- package/dist/dynamic.d.cts +15 -0
- package/dist/dynamic.d.ts +15 -0
- package/dist/dynamic.js +796 -0
- package/dist/dynamic.js.map +1 -0
- package/dist/error-boundary.cjs +693 -0
- package/dist/error-boundary.cjs.map +1 -0
- package/dist/error-boundary.d.cts +22 -0
- package/dist/error-boundary.d.ts +22 -0
- package/dist/error-boundary.js +690 -0
- package/dist/error-boundary.js.map +1 -0
- package/dist/error.cjs +117 -0
- package/dist/error.cjs.map +1 -0
- package/dist/error.d.cts +53 -0
- package/dist/error.d.ts +53 -0
- package/dist/error.js +111 -0
- package/dist/error.js.map +1 -0
- package/dist/fragment.cjs +216 -0
- package/dist/fragment.cjs.map +1 -0
- package/dist/fragment.d.cts +13 -0
- package/dist/fragment.d.ts +13 -0
- package/dist/fragment.js +213 -0
- package/dist/fragment.js.map +1 -0
- package/dist/hmr.cjs +106 -0
- package/dist/hmr.cjs.map +1 -0
- package/dist/hmr.d.cts +24 -0
- package/dist/hmr.d.ts +24 -0
- package/dist/hmr.js +98 -0
- package/dist/hmr.js.map +1 -0
- package/dist/index.cjs +1597 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +15 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +1540 -0
- package/dist/index.js.map +1 -0
- package/dist/ops.cjs +574 -0
- package/dist/ops.cjs.map +1 -0
- package/dist/ops.d.cts +39 -0
- package/dist/ops.d.ts +39 -0
- package/dist/ops.js +554 -0
- package/dist/ops.js.map +1 -0
- package/dist/profiler.cjs +523 -0
- package/dist/profiler.cjs.map +1 -0
- package/dist/profiler.d.cts +20 -0
- package/dist/profiler.d.ts +20 -0
- package/dist/profiler.js +520 -0
- package/dist/profiler.js.map +1 -0
- package/dist/ref.cjs +169 -0
- package/dist/ref.cjs.map +1 -0
- package/dist/ref.d.cts +10 -0
- package/dist/ref.d.ts +10 -0
- package/dist/ref.js +166 -0
- package/dist/ref.js.map +1 -0
- package/dist/renderer.cjs +4 -0
- package/dist/renderer.cjs.map +1 -0
- package/dist/renderer.d.cts +18 -0
- package/dist/renderer.d.ts +18 -0
- package/dist/renderer.js +3 -0
- package/dist/renderer.js.map +1 -0
- package/dist/template.cjs +28 -0
- package/dist/template.cjs.map +1 -0
- package/dist/template.d.cts +6 -0
- package/dist/template.d.ts +6 -0
- package/dist/template.js +25 -0
- package/dist/template.js.map +1 -0
- package/package.json +26 -6
- package/src/boundaries.test.ts +1 -0
- package/src/dynamic.test.ts +171 -0
- package/src/dynamic.ts +125 -15
- package/src/hmr.test.ts +1 -0
- package/src/index.ts +1 -0
- package/src/ref.test.ts +1 -0
- package/src/template.ts +24 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
interface VobsRenderer<NodeType = Node, TextNode extends NodeType = NodeType, ElementNode extends NodeType = NodeType, CommentNode extends NodeType = NodeType> {
|
|
2
|
+
createText(content: string): TextNode;
|
|
3
|
+
createElement(tag: string): ElementNode;
|
|
4
|
+
createComment(content: string): CommentNode;
|
|
5
|
+
insertBefore(parent: NodeType, child: NodeType, anchor: NodeType | null): void;
|
|
6
|
+
removeChild(parent: NodeType, child: NodeType): void;
|
|
7
|
+
setTextContent(node: TextNode, content: string): void;
|
|
8
|
+
setProperty(node: ElementNode, key: string, value: unknown): void;
|
|
9
|
+
setAttribute(node: ElementNode, key: string, value: string): void;
|
|
10
|
+
addEventListener(node: ElementNode, event: string, handler: EventListener): void;
|
|
11
|
+
removeEventListener(node: ElementNode, event: string, handler: EventListener): void;
|
|
12
|
+
nextSibling(node: NodeType): NodeType | null;
|
|
13
|
+
clear(container: NodeType): void;
|
|
14
|
+
beginHydration?(): void;
|
|
15
|
+
completeHydration?(): void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type { VobsRenderer };
|
package/dist/renderer.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":[],"names":[],"mappings":"","file":"renderer.js"}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
|
|
5
|
+
|
|
6
|
+
// packages/runtime/src/template.ts
|
|
7
|
+
var templateCache = /* @__PURE__ */ new Map();
|
|
8
|
+
function createTemplate(html) {
|
|
9
|
+
let template = templateCache.get(html);
|
|
10
|
+
if (!template) {
|
|
11
|
+
template = document.createElement("template");
|
|
12
|
+
template.innerHTML = html;
|
|
13
|
+
templateCache.set(html, template);
|
|
14
|
+
}
|
|
15
|
+
return template;
|
|
16
|
+
}
|
|
17
|
+
__name(createTemplate, "createTemplate");
|
|
18
|
+
function cloneTemplate(template) {
|
|
19
|
+
const root = template.content.firstElementChild;
|
|
20
|
+
if (!root) throw new Error("Vobs: \u9759\u6001\u6A21\u677F\u7F3A\u5C11\u6839\u5143\u7D20\uFF0C\u8BF7\u68C0\u67E5\u7F16\u8BD1\u4EA7\u7269");
|
|
21
|
+
return root.cloneNode(true);
|
|
22
|
+
}
|
|
23
|
+
__name(cloneTemplate, "cloneTemplate");
|
|
24
|
+
|
|
25
|
+
exports.cloneTemplate = cloneTemplate;
|
|
26
|
+
exports.createTemplate = createTemplate;
|
|
27
|
+
//# sourceMappingURL=template.cjs.map
|
|
28
|
+
//# sourceMappingURL=template.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/template.ts"],"names":[],"mappings":";;;;;;AAKA,IAAM,aAAA,uBAAoB,GAAA,EAAiC;AAGpD,SAAS,eAAe,IAAA,EAAmC;AAChE,EAAA,IAAI,QAAA,GAAW,aAAA,CAAc,GAAA,CAAI,IAAI,CAAA;AACrC,EAAA,IAAI,CAAC,QAAA,EAAU;AACb,IAAA,QAAA,GAAW,QAAA,CAAS,cAAc,UAAU,CAAA;AAC5C,IAAA,QAAA,CAAS,SAAA,GAAY,IAAA;AACrB,IAAA,aAAA,CAAc,GAAA,CAAI,MAAM,QAAQ,CAAA;AAAA,EAClC;AACA,EAAA,OAAO,QAAA;AACT;AARgB,MAAA,CAAA,cAAA,EAAA,gBAAA,CAAA;AAWT,SAAS,cAAc,QAAA,EAAqC;AACjE,EAAA,MAAM,IAAA,GAAO,SAAS,OAAA,CAAQ,iBAAA;AAC9B,EAAA,IAAI,CAAC,IAAA,EAAM,MAAM,IAAI,MAAM,8GAAyB,CAAA;AACpD,EAAA,OAAO,IAAA,CAAK,UAAU,IAAI,CAAA;AAC5B;AAJgB,MAAA,CAAA,aAAA,EAAA,eAAA,CAAA","file":"template.cjs","sourcesContent":["// 编译器静态模板提升的运行时支撑:\n// 编译期把完全静态的 JSX 子树序列化为 HTML 字符串,模块加载时解析一次,\n// 运行时通过 cloneNode 复制,替代逐个 createElement + setStaticProps + insertBefore。\n// 仅适用于 DOM 渲染器(模板本质是 HTML),自定义渲染器场景由编译产物不使用该优化兜底。\n\nconst templateCache = new Map<string, HTMLTemplateElement>()\n\n/** Parse a static template once; called at module load for each hoisted template. */\nexport function createTemplate(html: string): HTMLTemplateElement {\n let template = templateCache.get(html)\n if (!template) {\n template = document.createElement('template')\n template.innerHTML = html\n templateCache.set(html, template)\n }\n return template\n}\n\n/** Clone a hoisted template for one mount; the compiled HTML always has a single root element. */\nexport function cloneTemplate(template: HTMLTemplateElement): Node {\n const root = template.content.firstElementChild\n if (!root) throw new Error('Vobs: 静态模板缺少根元素,请检查编译产物')\n return root.cloneNode(true)\n}\n"]}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Parse a static template once; called at module load for each hoisted template. */
|
|
2
|
+
declare function createTemplate(html: string): HTMLTemplateElement;
|
|
3
|
+
/** Clone a hoisted template for one mount; the compiled HTML always has a single root element. */
|
|
4
|
+
declare function cloneTemplate(template: HTMLTemplateElement): Node;
|
|
5
|
+
|
|
6
|
+
export { cloneTemplate, createTemplate };
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Parse a static template once; called at module load for each hoisted template. */
|
|
2
|
+
declare function createTemplate(html: string): HTMLTemplateElement;
|
|
3
|
+
/** Clone a hoisted template for one mount; the compiled HTML always has a single root element. */
|
|
4
|
+
declare function cloneTemplate(template: HTMLTemplateElement): Node;
|
|
5
|
+
|
|
6
|
+
export { cloneTemplate, createTemplate };
|
package/dist/template.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
var __defProp = Object.defineProperty;
|
|
2
|
+
var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
|
|
3
|
+
|
|
4
|
+
// packages/runtime/src/template.ts
|
|
5
|
+
var templateCache = /* @__PURE__ */ new Map();
|
|
6
|
+
function createTemplate(html) {
|
|
7
|
+
let template = templateCache.get(html);
|
|
8
|
+
if (!template) {
|
|
9
|
+
template = document.createElement("template");
|
|
10
|
+
template.innerHTML = html;
|
|
11
|
+
templateCache.set(html, template);
|
|
12
|
+
}
|
|
13
|
+
return template;
|
|
14
|
+
}
|
|
15
|
+
__name(createTemplate, "createTemplate");
|
|
16
|
+
function cloneTemplate(template) {
|
|
17
|
+
const root = template.content.firstElementChild;
|
|
18
|
+
if (!root) throw new Error("Vobs: \u9759\u6001\u6A21\u677F\u7F3A\u5C11\u6839\u5143\u7D20\uFF0C\u8BF7\u68C0\u67E5\u7F16\u8BD1\u4EA7\u7269");
|
|
19
|
+
return root.cloneNode(true);
|
|
20
|
+
}
|
|
21
|
+
__name(cloneTemplate, "cloneTemplate");
|
|
22
|
+
|
|
23
|
+
export { cloneTemplate, createTemplate };
|
|
24
|
+
//# sourceMappingURL=template.js.map
|
|
25
|
+
//# sourceMappingURL=template.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/template.ts"],"names":[],"mappings":";;;;AAKA,IAAM,aAAA,uBAAoB,GAAA,EAAiC;AAGpD,SAAS,eAAe,IAAA,EAAmC;AAChE,EAAA,IAAI,QAAA,GAAW,aAAA,CAAc,GAAA,CAAI,IAAI,CAAA;AACrC,EAAA,IAAI,CAAC,QAAA,EAAU;AACb,IAAA,QAAA,GAAW,QAAA,CAAS,cAAc,UAAU,CAAA;AAC5C,IAAA,QAAA,CAAS,SAAA,GAAY,IAAA;AACrB,IAAA,aAAA,CAAc,GAAA,CAAI,MAAM,QAAQ,CAAA;AAAA,EAClC;AACA,EAAA,OAAO,QAAA;AACT;AARgB,MAAA,CAAA,cAAA,EAAA,gBAAA,CAAA;AAWT,SAAS,cAAc,QAAA,EAAqC;AACjE,EAAA,MAAM,IAAA,GAAO,SAAS,OAAA,CAAQ,iBAAA;AAC9B,EAAA,IAAI,CAAC,IAAA,EAAM,MAAM,IAAI,MAAM,8GAAyB,CAAA;AACpD,EAAA,OAAO,IAAA,CAAK,UAAU,IAAI,CAAA;AAC5B;AAJgB,MAAA,CAAA,aAAA,EAAA,eAAA,CAAA","file":"template.js","sourcesContent":["// 编译器静态模板提升的运行时支撑:\n// 编译期把完全静态的 JSX 子树序列化为 HTML 字符串,模块加载时解析一次,\n// 运行时通过 cloneNode 复制,替代逐个 createElement + setStaticProps + insertBefore。\n// 仅适用于 DOM 渲染器(模板本质是 HTML),自定义渲染器场景由编译产物不使用该优化兜底。\n\nconst templateCache = new Map<string, HTMLTemplateElement>()\n\n/** Parse a static template once; called at module load for each hoisted template. */\nexport function createTemplate(html: string): HTMLTemplateElement {\n let template = templateCache.get(html)\n if (!template) {\n template = document.createElement('template')\n template.innerHTML = html\n templateCache.set(html, template)\n }\n return template\n}\n\n/** Clone a hoisted template for one mount; the compiled HTML always has a single root element. */\nexport function cloneTemplate(template: HTMLTemplateElement): Node {\n const root = template.content.firstElementChild\n if (!root) throw new Error('Vobs: 静态模板缺少根元素,请检查编译产物')\n return root.cloneNode(true)\n}\n"]}
|
package/package.json
CHANGED
|
@@ -1,20 +1,40 @@
|
|
|
1
1
|
{
|
|
2
2
|
"license": "MIT",
|
|
3
3
|
"files": [
|
|
4
|
+
"dist",
|
|
4
5
|
"src",
|
|
5
6
|
"README.md",
|
|
6
7
|
"LICENSE"
|
|
7
8
|
],
|
|
8
9
|
"name": "@vobs/runtime",
|
|
9
|
-
"version": "1.
|
|
10
|
+
"version": "1.2.0",
|
|
11
|
+
"publishConfig": {
|
|
12
|
+
"access": "public"
|
|
13
|
+
},
|
|
10
14
|
"type": "module",
|
|
11
|
-
"main": "
|
|
12
|
-
"
|
|
15
|
+
"main": "./dist/index.cjs",
|
|
16
|
+
"module": "./dist/index.js",
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
13
18
|
"exports": {
|
|
14
|
-
".":
|
|
15
|
-
|
|
19
|
+
".": {
|
|
20
|
+
"types": "./dist/index.d.ts",
|
|
21
|
+
"import": "./dist/index.js",
|
|
22
|
+
"require": "./dist/index.cjs",
|
|
23
|
+
"default": "./dist/index.js"
|
|
24
|
+
},
|
|
25
|
+
"./error": {
|
|
26
|
+
"types": "./dist/error.d.ts",
|
|
27
|
+
"import": "./dist/error.js",
|
|
28
|
+
"require": "./dist/error.cjs",
|
|
29
|
+
"default": "./dist/error.js"
|
|
30
|
+
},
|
|
31
|
+
"./source": {
|
|
32
|
+
"types": "./src/index.ts",
|
|
33
|
+
"default": "./src/index.ts"
|
|
34
|
+
},
|
|
35
|
+
"./source/*": "./src/*"
|
|
16
36
|
},
|
|
17
37
|
"dependencies": {
|
|
18
|
-
"@vobs/reactivity": "1.
|
|
38
|
+
"@vobs/reactivity": "1.2.0"
|
|
19
39
|
}
|
|
20
40
|
}
|
package/src/boundaries.test.ts
CHANGED
package/src/dynamic.test.ts
CHANGED
|
@@ -226,3 +226,174 @@ describe('primitive list item updates', () => {
|
|
|
226
226
|
})
|
|
227
227
|
})
|
|
228
228
|
|
|
229
|
+
describe('keyed list minimal-move reconciliation', () => {
|
|
230
|
+
interface Row { id: number; name: string }
|
|
231
|
+
|
|
232
|
+
function setup(initial: Row[]) {
|
|
233
|
+
setRenderer(createDOMRenderer())
|
|
234
|
+
const parent = document.createElement('div')
|
|
235
|
+
const source = state(initial)
|
|
236
|
+
const rows = new Map<number, Element>()
|
|
237
|
+
insertList(
|
|
238
|
+
parent,
|
|
239
|
+
null,
|
|
240
|
+
() => source.value,
|
|
241
|
+
item => {
|
|
242
|
+
const el = document.createElement('li')
|
|
243
|
+
el.textContent = item.name
|
|
244
|
+
rows.set(item.id, el)
|
|
245
|
+
return el
|
|
246
|
+
},
|
|
247
|
+
row => row.id
|
|
248
|
+
)
|
|
249
|
+
const order = () =>
|
|
250
|
+
Array.from(parent.children)
|
|
251
|
+
.filter(node => node.tagName === 'LI')
|
|
252
|
+
.map(node => node.textContent)
|
|
253
|
+
return { parent, source, rows, order }
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// name → 稳定 id 映射:重排 name 顺序 = 真正的 key 重排
|
|
257
|
+
// (若用数组下标当 id,重排后 key 顺序不变,keyed reconcile 正确地零移动)
|
|
258
|
+
const ids = new Map<string, number>()
|
|
259
|
+
const rows = (names: string[]): Row[] => names.map(name => {
|
|
260
|
+
if (!ids.has(name)) ids.set(name, ids.size)
|
|
261
|
+
return { id: ids.get(name)!, name }
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
it('swaps two adjacent rows and preserves row node identity', async () => {
|
|
265
|
+
const { parent, source, rows: rowMap, order } = setup(rows(['a', 'b', 'c']))
|
|
266
|
+
const nodeB = rowMap.get(1)
|
|
267
|
+
const nodeC = rowMap.get(2)
|
|
268
|
+
|
|
269
|
+
source.value = rows(['a', 'c', 'b'])
|
|
270
|
+
await new Promise(resolve => setTimeout(resolve, 0))
|
|
271
|
+
|
|
272
|
+
expect(order()).toEqual(['a', 'c', 'b'])
|
|
273
|
+
// 最小移动:行节点复用,不整行重建;保留行不被移除
|
|
274
|
+
expect(rowMap.get(1)).toBe(nodeB)
|
|
275
|
+
expect(rowMap.get(2)).toBe(nodeC)
|
|
276
|
+
expect(parent.contains(nodeB!)).toBe(true)
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
it('moves the first row to the end', async () => {
|
|
280
|
+
const { source, order } = setup(rows(['a', 'b', 'c', 'd']))
|
|
281
|
+
|
|
282
|
+
source.value = rows(['b', 'c', 'd', 'a'])
|
|
283
|
+
await new Promise(resolve => setTimeout(resolve, 0))
|
|
284
|
+
|
|
285
|
+
expect(order()).toEqual(['b', 'c', 'd', 'a'])
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
it('reverses the whole list', async () => {
|
|
289
|
+
const { source, order } = setup(rows(['a', 'b', 'c', 'd', 'e']))
|
|
290
|
+
|
|
291
|
+
source.value = rows(['e', 'd', 'c', 'b', 'a'])
|
|
292
|
+
await new Promise(resolve => setTimeout(resolve, 0))
|
|
293
|
+
|
|
294
|
+
expect(order()).toEqual(['e', 'd', 'c', 'b', 'a'])
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
it('keeps DOM untouched when order is unchanged but item objects are new', async () => {
|
|
298
|
+
const { source, rows: rowMap, order } = setup(rows(['a', 'b', 'c']))
|
|
299
|
+
const nodesBefore = [rowMap.get(0), rowMap.get(1), rowMap.get(2)]
|
|
300
|
+
|
|
301
|
+
// 全新对象、相同 key、相同顺序:应当零移动零重建
|
|
302
|
+
source.value = [{ id: 0, name: 'a' }, { id: 1, name: 'b' }, { id: 2, name: 'c' }]
|
|
303
|
+
await new Promise(resolve => setTimeout(resolve, 0))
|
|
304
|
+
|
|
305
|
+
expect(order()).toEqual(['a', 'b', 'c'])
|
|
306
|
+
expect([rowMap.get(0), rowMap.get(1), rowMap.get(2)]).toEqual(nodesBefore)
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
it('handles mixed remove + insert + move in one update', async () => {
|
|
310
|
+
const { source, order } = setup(rows(['a', 'b', 'c', 'd', 'e']))
|
|
311
|
+
|
|
312
|
+
// 移除 b、新增 f、d 前移,同时 a 挪到末尾
|
|
313
|
+
source.value = [
|
|
314
|
+
{ id: 2, name: 'c' },
|
|
315
|
+
{ id: 3, name: 'd' },
|
|
316
|
+
{ id: 5, name: 'f' },
|
|
317
|
+
{ id: 4, name: 'e' },
|
|
318
|
+
{ id: 0, name: 'a' }
|
|
319
|
+
]
|
|
320
|
+
await new Promise(resolve => setTimeout(resolve, 0))
|
|
321
|
+
|
|
322
|
+
expect(order()).toEqual(['c', 'd', 'f', 'e', 'a'])
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
it('keeps the list anchored after surrounding sibling content', async () => {
|
|
326
|
+
setRenderer(createDOMRenderer())
|
|
327
|
+
const parent = document.createElement('div')
|
|
328
|
+
const head = document.createElement('p')
|
|
329
|
+
head.textContent = 'head'
|
|
330
|
+
parent.appendChild(head)
|
|
331
|
+
const source = state(rows(['a', 'b', 'c']))
|
|
332
|
+
insertList(
|
|
333
|
+
parent,
|
|
334
|
+
null,
|
|
335
|
+
() => source.value,
|
|
336
|
+
item => {
|
|
337
|
+
const el = document.createElement('li')
|
|
338
|
+
el.textContent = item.name
|
|
339
|
+
return el
|
|
340
|
+
},
|
|
341
|
+
row => row.id
|
|
342
|
+
)
|
|
343
|
+
const tail = document.createElement('p')
|
|
344
|
+
tail.textContent = 'tail'
|
|
345
|
+
parent.appendChild(tail)
|
|
346
|
+
|
|
347
|
+
source.value = rows(['c', 'a', 'b'])
|
|
348
|
+
await new Promise(resolve => setTimeout(resolve, 0))
|
|
349
|
+
|
|
350
|
+
expect(parent.textContent).toBe('headcabtail')
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
it('rebuilds index-tracked rows when they move', async () => {
|
|
354
|
+
setRenderer(createDOMRenderer())
|
|
355
|
+
const parent = document.createElement('div')
|
|
356
|
+
const source = state(rows(['a', 'b', 'c']))
|
|
357
|
+
insertList(
|
|
358
|
+
parent,
|
|
359
|
+
null,
|
|
360
|
+
() => source.value,
|
|
361
|
+
(item, index) => {
|
|
362
|
+
const el = document.createElement('li')
|
|
363
|
+
el.textContent = `${item.name}:${index}`
|
|
364
|
+
return el
|
|
365
|
+
},
|
|
366
|
+
row => row.id
|
|
367
|
+
)
|
|
368
|
+
expect(parent.textContent).toBe('a:0b:1c:2')
|
|
369
|
+
|
|
370
|
+
source.value = rows(['c', 'a', 'b'])
|
|
371
|
+
await new Promise(resolve => setTimeout(resolve, 0))
|
|
372
|
+
// index 参与渲染的行移动时必须重建,索引值正确
|
|
373
|
+
expect(parent.textContent).toBe('c:0a:1b:2')
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
it('falls back to indexed reconcile when keys are missing', async () => {
|
|
377
|
+
setRenderer(createDOMRenderer())
|
|
378
|
+
const parent = document.createElement('div')
|
|
379
|
+
// 原始类型项:keyOf 对 'b' 返回 null → 回退 indexed 调和
|
|
380
|
+
const source = state(['a', 'b', 'c'])
|
|
381
|
+
insertList(
|
|
382
|
+
parent,
|
|
383
|
+
null,
|
|
384
|
+
() => source.value,
|
|
385
|
+
item => {
|
|
386
|
+
const el = document.createElement('li')
|
|
387
|
+
el.textContent = item
|
|
388
|
+
return el
|
|
389
|
+
},
|
|
390
|
+
(item, index) => (item === 'b' ? null : index)
|
|
391
|
+
)
|
|
392
|
+
expect(parent.textContent).toBe('abc')
|
|
393
|
+
|
|
394
|
+
source.value = ['x', 'y', 'z']
|
|
395
|
+
await new Promise(resolve => setTimeout(resolve, 0))
|
|
396
|
+
expect(parent.textContent).toBe('xyz')
|
|
397
|
+
})
|
|
398
|
+
})
|
|
399
|
+
|
package/src/dynamic.ts
CHANGED
|
@@ -108,34 +108,144 @@ export function insertList<T>(
|
|
|
108
108
|
|
|
109
109
|
effect(() => {
|
|
110
110
|
const items = source()
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
111
|
+
|
|
112
|
+
// key 只计算一次:keyed 判定与调和共用,避免 keyOf 每轮被调用两遍。
|
|
113
|
+
let keys: unknown[] | null = null
|
|
114
|
+
if (keyOf) {
|
|
115
|
+
keys = new Array(items.length)
|
|
116
|
+
let allKeyed = items.length > 0
|
|
117
|
+
for (let index = 0; index < items.length; index++) {
|
|
118
|
+
const key = keyOf(items[index], index)
|
|
119
|
+
if (key == null) {
|
|
120
|
+
allKeyed = false
|
|
121
|
+
break
|
|
122
|
+
}
|
|
123
|
+
keys[index] = key
|
|
124
|
+
}
|
|
125
|
+
if (!allKeyed) keys = null
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const nextEntries = keys
|
|
129
|
+
? reconcileKeyed(items, keys, entries, renderItem)
|
|
114
130
|
: reconcileIndexed(items, entries, renderItem)
|
|
115
131
|
|
|
132
|
+
// tracksIndex 时 index 参与渲染,位置变化的行必须整体重建,新节点不在 DOM 中,
|
|
133
|
+
// 重排阶段强制插入;否则只同步记录的位置,节点保持原样交给重排阶段移动。
|
|
134
|
+
const refreshed = tracksIndex ? new Set<ListEntry<T>>() : null
|
|
116
135
|
for (let index = 0; index < nextEntries.length; index++) {
|
|
117
136
|
const entry = nextEntries[index]
|
|
118
|
-
if (tracksIndex
|
|
137
|
+
if (tracksIndex) {
|
|
138
|
+
if (entry.index !== index) {
|
|
139
|
+
refreshListEntry(parent, entry, index, renderItem)
|
|
140
|
+
refreshed!.add(entry)
|
|
141
|
+
}
|
|
142
|
+
} else {
|
|
143
|
+
entry.index = index
|
|
144
|
+
}
|
|
119
145
|
}
|
|
120
146
|
|
|
147
|
+
const retained = new Set(nextEntries)
|
|
121
148
|
for (const entry of entries) {
|
|
122
|
-
if (!
|
|
149
|
+
if (!retained.has(entry)) disposeEntry(parent, entry)
|
|
123
150
|
}
|
|
124
151
|
|
|
125
|
-
|
|
126
|
-
for (let index = nextEntries.length - 1; index >= 0; index--) {
|
|
127
|
-
insertBefore(parent, nextEntries[index].node, reference)
|
|
128
|
-
reference = nextEntries[index].node
|
|
129
|
-
}
|
|
152
|
+
reorderListEntries(parent, marker, entries, nextEntries, refreshed)
|
|
130
153
|
entries = nextEntries
|
|
131
154
|
})
|
|
132
155
|
}
|
|
133
156
|
|
|
157
|
+
/**
|
|
158
|
+
* 按 nextEntries 顺序整理 DOM,但只移动必须移动的节点。
|
|
159
|
+
*/
|
|
160
|
+
function reorderListEntries<T>(
|
|
161
|
+
parent: Node,
|
|
162
|
+
marker: Node,
|
|
163
|
+
previous: readonly ListEntry<T>[],
|
|
164
|
+
nextEntries: readonly ListEntry<T>[],
|
|
165
|
+
forceInsert: Set<ListEntry<T>> | null
|
|
166
|
+
): void {
|
|
167
|
+
const count = nextEntries.length
|
|
168
|
+
if (count === 0) return
|
|
169
|
+
|
|
170
|
+
// 首次挂载(旧列表为空):全部是新节点,倒序直插即可,跳过 Map/LIS 构建。
|
|
171
|
+
if (previous.length === 0) {
|
|
172
|
+
let reference: VobsNode = marker
|
|
173
|
+
for (let index = count - 1; index >= 0; index--) {
|
|
174
|
+
const node = nextEntries[index].node
|
|
175
|
+
insertBefore(parent, node, reference)
|
|
176
|
+
reference = node
|
|
177
|
+
}
|
|
178
|
+
return
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const oldIndexOf = new Map<ListEntry<T>, number>()
|
|
182
|
+
for (let index = 0; index < previous.length; index++) oldIndexOf.set(previous[index], index)
|
|
183
|
+
|
|
184
|
+
// seq[i] = 条目在旧序中的位置;新条目、重建条目与强制插入条目为 -1。
|
|
185
|
+
const seq: number[] = new Array(count)
|
|
186
|
+
for (let index = 0; index < count; index++) {
|
|
187
|
+
const entry = nextEntries[index]
|
|
188
|
+
seq[index] = forceInsert?.has(entry) ? -1 : oldIndexOf.get(entry) ?? -1
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const keep = computeKeptByLis(seq)
|
|
192
|
+
|
|
193
|
+
let reference: VobsNode = marker
|
|
194
|
+
for (let index = count - 1; index >= 0; index--) {
|
|
195
|
+
const node = nextEntries[index].node
|
|
196
|
+
if (keep[index]) {
|
|
197
|
+
reference = node
|
|
198
|
+
continue
|
|
199
|
+
}
|
|
200
|
+
insertBefore(parent, node, reference)
|
|
201
|
+
reference = node
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* 严格递增子序列(LIS)成员标记,O(n log n)。
|
|
207
|
+
* 负值(新节点)不参与 LIS,永远视为需要移动。
|
|
208
|
+
*/
|
|
209
|
+
function computeKeptByLis(seq: readonly number[]): boolean[] {
|
|
210
|
+
const count = seq.length
|
|
211
|
+
const keep = new Array<boolean>(count).fill(false)
|
|
212
|
+
const tailsIndex: number[] = []
|
|
213
|
+
const tailsValue: number[] = []
|
|
214
|
+
const prev = new Array<number>(count).fill(-1)
|
|
215
|
+
|
|
216
|
+
for (let i = 0; i < count; i++) {
|
|
217
|
+
const value = seq[i]
|
|
218
|
+
if (value < 0) continue
|
|
219
|
+
let lo = 0
|
|
220
|
+
let hi = tailsValue.length
|
|
221
|
+
while (lo < hi) {
|
|
222
|
+
const mid = (lo + hi) >> 1
|
|
223
|
+
if (tailsValue[mid] < value) lo = mid + 1
|
|
224
|
+
else hi = mid
|
|
225
|
+
}
|
|
226
|
+
if (lo === tailsValue.length) {
|
|
227
|
+
tailsValue.push(value)
|
|
228
|
+
tailsIndex.push(i)
|
|
229
|
+
} else {
|
|
230
|
+
tailsValue[lo] = value
|
|
231
|
+
tailsIndex[lo] = i
|
|
232
|
+
}
|
|
233
|
+
prev[i] = lo > 0 ? tailsIndex[lo - 1] : -1
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
let cursor = tailsIndex.length > 0 ? tailsIndex[tailsIndex.length - 1] : -1
|
|
237
|
+
while (cursor >= 0) {
|
|
238
|
+
keep[cursor] = true
|
|
239
|
+
cursor = prev[cursor]
|
|
240
|
+
}
|
|
241
|
+
return keep
|
|
242
|
+
}
|
|
243
|
+
|
|
134
244
|
function reconcileKeyed<T>(
|
|
135
245
|
items: readonly T[],
|
|
246
|
+
keys: readonly unknown[],
|
|
136
247
|
entries: Array<ListEntry<T>>,
|
|
137
|
-
renderItem: (item: T, index: number) => VobsNode
|
|
138
|
-
keyOf: (item: T, index: number) => unknown
|
|
248
|
+
renderItem: (item: T, index: number) => VobsNode
|
|
139
249
|
): Array<ListEntry<T>> {
|
|
140
250
|
const previous = new Map(entries.map(entry => [entry.key, entry]))
|
|
141
251
|
const seen = new Set<unknown>()
|
|
@@ -143,13 +253,13 @@ function reconcileKeyed<T>(
|
|
|
143
253
|
|
|
144
254
|
for (let index = 0; index < items.length; index++) {
|
|
145
255
|
const item = items[index]
|
|
146
|
-
const key =
|
|
256
|
+
const key = keys[index]
|
|
147
257
|
if (seen.has(key)) {
|
|
148
258
|
console.warn(`Vobs: 检测到重复的列表 key: ${String(key)}`)
|
|
149
259
|
}
|
|
150
260
|
seen.add(key)
|
|
151
|
-
|
|
152
|
-
|
|
261
|
+
const entry = previous.get(key)
|
|
262
|
+
if (entry) {
|
|
153
263
|
previous.delete(key)
|
|
154
264
|
if (isPrimitiveItem(item) && !Object.is(entry.value, item)) {
|
|
155
265
|
// 原始类型项在编译产物中被静态捕获,无法通过 item 信号刷新视图:值变化时必须重建行。
|
package/src/hmr.test.ts
CHANGED
package/src/index.ts
CHANGED
|
@@ -45,6 +45,7 @@ export { ref, setRef } from './ref'
|
|
|
45
45
|
export type { Ref, RefTarget } from './ref'
|
|
46
46
|
export { insertDynamic, insertDynamicValue, insertList, normalizeDynamicChild } from './dynamic'
|
|
47
47
|
export type { DynamicChild, NodeFactory } from './dynamic'
|
|
48
|
+
export { createTemplate, cloneTemplate } from './template'
|
|
48
49
|
export type { VobsLocatedError, VobsSourceLocation } from './ops'
|
|
49
50
|
export {
|
|
50
51
|
VobsError,
|
package/src/ref.test.ts
CHANGED
package/src/template.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// 编译器静态模板提升的运行时支撑:
|
|
2
|
+
// 编译期把完全静态的 JSX 子树序列化为 HTML 字符串,模块加载时解析一次,
|
|
3
|
+
// 运行时通过 cloneNode 复制,替代逐个 createElement + setStaticProps + insertBefore。
|
|
4
|
+
// 仅适用于 DOM 渲染器(模板本质是 HTML),自定义渲染器场景由编译产物不使用该优化兜底。
|
|
5
|
+
|
|
6
|
+
const templateCache = new Map<string, HTMLTemplateElement>()
|
|
7
|
+
|
|
8
|
+
/** Parse a static template once; called at module load for each hoisted template. */
|
|
9
|
+
export function createTemplate(html: string): HTMLTemplateElement {
|
|
10
|
+
let template = templateCache.get(html)
|
|
11
|
+
if (!template) {
|
|
12
|
+
template = document.createElement('template')
|
|
13
|
+
template.innerHTML = html
|
|
14
|
+
templateCache.set(html, template)
|
|
15
|
+
}
|
|
16
|
+
return template
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Clone a hoisted template for one mount; the compiled HTML always has a single root element. */
|
|
20
|
+
export function cloneTemplate(template: HTMLTemplateElement): Node {
|
|
21
|
+
const root = template.content.firstElementChild
|
|
22
|
+
if (!root) throw new Error('Vobs: 静态模板缺少根元素,请检查编译产物')
|
|
23
|
+
return root.cloneNode(true)
|
|
24
|
+
}
|