@vobs/runtime 1.7.8 → 1.8.1
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/README.md +1 -1
- package/dist/async-boundary.cjs +14 -4
- package/dist/async-boundary.cjs.map +1 -1
- package/dist/async-boundary.js +17 -7
- package/dist/async-boundary.js.map +1 -1
- package/dist/bind.cjs +43 -3
- package/dist/bind.cjs.map +1 -1
- package/dist/bind.js +48 -8
- package/dist/bind.js.map +1 -1
- package/dist/boundary.cjs +159 -84
- package/dist/boundary.cjs.map +1 -1
- package/dist/boundary.js +162 -87
- package/dist/boundary.js.map +1 -1
- package/dist/client-only.cjs +323 -0
- package/dist/client-only.cjs.map +1 -0
- package/dist/client-only.d.cts +42 -0
- package/dist/client-only.d.ts +42 -0
- package/dist/client-only.js +299 -0
- package/dist/client-only.js.map +1 -0
- package/dist/debug.cjs.map +1 -1
- package/dist/debug.d.cts +12 -2
- package/dist/debug.d.ts +12 -2
- package/dist/debug.js.map +1 -1
- package/dist/dom-events.cjs +46 -0
- package/dist/dom-events.cjs.map +1 -0
- package/dist/dom-events.d.cts +23 -0
- package/dist/dom-events.d.ts +23 -0
- package/dist/dom-events.js +21 -0
- package/dist/dom-events.js.map +1 -0
- package/dist/dom-props.cjs +154 -0
- package/dist/dom-props.cjs.map +1 -0
- package/dist/dom-props.d.cts +21 -0
- package/dist/dom-props.d.ts +21 -0
- package/dist/dom-props.js +128 -0
- package/dist/dom-props.js.map +1 -0
- package/dist/dynamic.cjs +68 -14
- package/dist/dynamic.cjs.map +1 -1
- package/dist/dynamic.js +73 -19
- package/dist/dynamic.js.map +1 -1
- package/dist/error-boundary.cjs +159 -84
- package/dist/error-boundary.cjs.map +1 -1
- package/dist/error-boundary.js +162 -87
- package/dist/error-boundary.js.map +1 -1
- package/dist/error.cjs +52 -20
- package/dist/error.cjs.map +1 -1
- package/dist/error.d.cts +8 -0
- package/dist/error.d.ts +8 -0
- package/dist/error.js +52 -20
- package/dist/error.js.map +1 -1
- package/dist/fragment.cjs.map +1 -1
- package/dist/fragment.js +1 -1
- package/dist/fragment.js.map +1 -1
- package/dist/hmr.cjs +137 -1
- package/dist/hmr.cjs.map +1 -1
- package/dist/hmr.js +135 -1
- package/dist/hmr.js.map +1 -1
- package/dist/index.cjs +713 -236
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +5 -2
- package/dist/index.d.ts +5 -2
- package/dist/index.js +715 -245
- package/dist/index.js.map +1 -1
- package/dist/ops.cjs +561 -107
- package/dist/ops.cjs.map +1 -1
- package/dist/ops.d.cts +49 -4
- package/dist/ops.d.ts +49 -4
- package/dist/ops.js +557 -108
- package/dist/ops.js.map +1 -1
- package/dist/profiler.cjs +14 -4
- package/dist/profiler.cjs.map +1 -1
- package/dist/profiler.js +17 -7
- package/dist/profiler.js.map +1 -1
- package/dist/ref.cjs +160 -5
- package/dist/ref.cjs.map +1 -1
- package/dist/ref.js +160 -5
- package/dist/ref.js.map +1 -1
- package/dist/renderer.cjs.map +1 -1
- package/dist/renderer.d.cts +2 -0
- package/dist/renderer.d.ts +2 -0
- package/dist/show.cjs +196 -0
- package/dist/show.cjs.map +1 -0
- package/dist/show.d.cts +54 -0
- package/dist/show.d.ts +54 -0
- package/dist/show.js +171 -0
- package/dist/show.js.map +1 -0
- package/dist/svg.cjs +102 -0
- package/dist/svg.cjs.map +1 -0
- package/dist/svg.d.cts +21 -0
- package/dist/svg.d.ts +21 -0
- package/dist/svg.js +76 -0
- package/dist/svg.js.map +1 -0
- package/package.json +20 -2
- package/src/bind.ts +30 -2
- package/src/boundaries.test.ts +54 -1
- package/src/boundary.ts +24 -8
- package/src/clear-remove.test.ts +85 -0
- package/src/client-only.test.ts +140 -0
- package/src/client-only.ts +65 -0
- package/src/component-empty-return.test.ts +123 -0
- package/src/debug.ts +12 -1
- package/src/dom-events.ts +32 -0
- package/src/dom-props-consistency.test.ts +149 -0
- package/src/dom-props.test.ts +70 -0
- package/src/dom-props.ts +107 -0
- package/src/dynamic.ts +91 -14
- package/src/error.test.ts +76 -1
- package/src/error.ts +87 -22
- package/src/event-cleanup-growth.test.ts +128 -0
- package/src/events.test.ts +16 -3
- package/src/hmr.ts +8 -2
- package/src/index.ts +13 -1
- package/src/invariants.test.ts +252 -0
- package/src/list-fast-path.test.ts +83 -0
- package/src/list-reactive-item.test.ts +77 -0
- package/src/ops.ts +419 -60
- package/src/props.test.ts +61 -1
- package/src/ref-cleanup.test.ts +144 -0
- package/src/ref.ts +57 -8
- package/src/remove-listener-identity.test.ts +97 -0
- package/src/renderer.ts +3 -1
- package/src/select-hidden-subscription.test.ts +104 -0
- package/src/show-classlist.test.ts +205 -0
- package/src/show.ts +107 -0
- package/src/spread-stable-shape.test.ts +109 -0
- package/src/spread.test.ts +86 -0
- package/src/svg.test.ts +28 -1
- package/src/svg.ts +34 -0
package/src/ops.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
// 编译产物调用的基础操作
|
|
2
2
|
|
|
3
|
-
import { createOwner, getCurrentOwner, setOwnerDebugName, untrack, type Owner } from '@vobs/reactivity'
|
|
3
|
+
import { createOwner, effect, getCurrentOwner, setOwnerDebugName, untrack, type Owner } from '@vobs/reactivity'
|
|
4
4
|
import { isVobsFragment, type VobsNode } from './fragment'
|
|
5
|
+
import { domAttributeName, isPropertyName } from './dom-props'
|
|
6
|
+
import { resolveEventName } from './dom-events'
|
|
7
|
+
import { isSvgTag } from './svg'
|
|
8
|
+
import { formatVobsError } from './error'
|
|
5
9
|
import { describeDebugNode, getRuntimeDebugHooks, invokeRuntimeDebug, readDebugValue } from './debug'
|
|
6
10
|
import {
|
|
7
11
|
associateHmrInstance,
|
|
@@ -23,14 +27,42 @@ interface EventBinding {
|
|
|
23
27
|
}
|
|
24
28
|
const eventBindings = new WeakMap<object, Map<string, EventBinding>>()
|
|
25
29
|
|
|
30
|
+
/*
|
|
31
|
+
* 事件清理槽。目的:让**每个 (Owner, node, event) 只在 `Owner.cleanups` 里占一个槽**,
|
|
32
|
+
* 重绑 handler 时只换槽里的间接引用,数组不增长(理由见 addEventListener)。
|
|
33
|
+
*
|
|
34
|
+
* 用 WeakMap<Owner, Map<key, handle>> 而不是往 Owner 上挂属性:
|
|
35
|
+
* Owner 是 class 实例,挂新属性会把它推进字典模式(reactivity/src/owner.ts:30-50 记过这笔账)。
|
|
36
|
+
* key 由 per-node 数字 id 与事件名拼成,避免每次重绑比较长字符串前缀。
|
|
37
|
+
*/
|
|
38
|
+
const eventCleanupSlots = new WeakMap<Owner, Map<string, EventBinding>>()
|
|
39
|
+
const eventCleanupNodeIds = new WeakMap<object, number>()
|
|
40
|
+
let nextEventCleanupNodeId = 0
|
|
41
|
+
|
|
42
|
+
function eventCleanupKey(node: object, event: string): string {
|
|
43
|
+
let id = eventCleanupNodeIds.get(node)
|
|
44
|
+
if (id === undefined) {
|
|
45
|
+
id = ++nextEventCleanupNodeId
|
|
46
|
+
eventCleanupNodeIds.set(node, id)
|
|
47
|
+
}
|
|
48
|
+
return `${id}:${event}`
|
|
49
|
+
}
|
|
50
|
+
|
|
26
51
|
export function setRenderer<
|
|
27
52
|
NodeType,
|
|
28
53
|
TextNode extends NodeType,
|
|
29
54
|
ElementNode extends NodeType,
|
|
30
55
|
CommentNode extends NodeType
|
|
31
|
-
>(
|
|
56
|
+
>(
|
|
57
|
+
renderer: VobsRenderer<NodeType, TextNode, ElementNode, CommentNode> | undefined
|
|
58
|
+
): VobsRenderer<NodeType, TextNode, ElementNode, CommentNode> | undefined {
|
|
59
|
+
// 返回**上一份**渲染器(没有则是 undefined),调用方据此成对"安装 / 还原"。
|
|
60
|
+
// 全局单例原来只装不还:一次 renderToString 之后就永久停在 SSR 渲染器上(见 vobs/src/app.ts)。
|
|
61
|
+
const previous = currentRenderer as unknown as
|
|
62
|
+
VobsRenderer<NodeType, TextNode, ElementNode, CommentNode> | undefined
|
|
32
63
|
// 编译产物仍使用 DOM 节点声明;实际宿主类型由应用提供的渲染器决定。
|
|
33
|
-
currentRenderer = renderer as unknown as RuntimeRenderer
|
|
64
|
+
currentRenderer = (renderer as unknown as RuntimeRenderer) ?? null
|
|
65
|
+
return previous
|
|
34
66
|
}
|
|
35
67
|
|
|
36
68
|
export function getRenderer(): RuntimeRenderer {
|
|
@@ -44,38 +76,35 @@ export function createText(content: string): Text {
|
|
|
44
76
|
return getRenderer().createText(content)
|
|
45
77
|
}
|
|
46
78
|
|
|
47
|
-
/** SVG
|
|
48
|
-
export
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* SVG 标签全集(= TypeScript SVGElementTagNameMap 的键,运行时分发依据)。
|
|
52
|
-
* 与 React 的运行时清单同思路:只含纯 SVG 标签,刻意排除与 HTML 同名的
|
|
53
|
-
* a/script/style/title(它们在 JSX 类型里归 HTMLElement,按 HTML 创建)。
|
|
54
|
-
*/
|
|
55
|
-
const SVG_TAGS = new Set([
|
|
56
|
-
'animate', 'animateMotion', 'animateTransform', 'circle', 'clipPath', 'defs', 'desc',
|
|
57
|
-
'ellipse', 'feBlend', 'feColorMatrix', 'feComponentTransfer', 'feComposite',
|
|
58
|
-
'feConvolveMatrix', 'feDiffuseLighting', 'feDisplacementMap', 'feDistantLight',
|
|
59
|
-
'feDropShadow', 'feFlood', 'feFuncA', 'feFuncB', 'feFuncG', 'feFuncR',
|
|
60
|
-
'feGaussianBlur', 'feImage', 'feMerge', 'feMergeNode', 'feMorphology', 'feOffset',
|
|
61
|
-
'fePointLight', 'feSpecularLighting', 'feSpotLight', 'feTile', 'feTurbulence',
|
|
62
|
-
'filter', 'foreignObject', 'g', 'image', 'line', 'linearGradient', 'marker', 'mask',
|
|
63
|
-
'metadata', 'mpath', 'path', 'pattern', 'polygon', 'polyline', 'radialGradient',
|
|
64
|
-
'rect', 'set', 'stop', 'svg', 'switch', 'symbol', 'text', 'textPath', 'tspan',
|
|
65
|
-
'use', 'view'
|
|
66
|
-
])
|
|
79
|
+
/** SVG 命名空间与标签清单见 ./svg —— 编译期判定提升用的是同一份依据。 */
|
|
80
|
+
export { SVG_NAMESPACE } from './svg'
|
|
67
81
|
|
|
68
82
|
export function createElement(tag: string): Element {
|
|
69
83
|
// SVG 标签走命名空间创建:document.createElement('rect') 产物是 HTMLUnknownElement,
|
|
70
84
|
// 整棵 SVG 子树都不会渲染(无报错)。渲染器未实现 createSvgElement 时按 createElement
|
|
71
85
|
// 兜底(旧自定义渲染器保持原行为)。
|
|
72
|
-
if (
|
|
86
|
+
if (isSvgTag(tag)) {
|
|
73
87
|
const renderer = getRenderer()
|
|
74
88
|
if (renderer.createSvgElement) return renderer.createSvgElement(tag)
|
|
75
89
|
}
|
|
76
90
|
return getRenderer().createElement(tag)
|
|
77
91
|
}
|
|
78
92
|
|
|
93
|
+
/**
|
|
94
|
+
* 在 SVG 命名空间里建元素,**不看标签名**。
|
|
95
|
+
*
|
|
96
|
+
* 给编译器用在 `<svg>` 祖先下那些与 HTML 同名的标签(a / title / style / script)——
|
|
97
|
+
* 它们不在 SVG_TAGS 里,`createElement` 会按 HTML 建。只按名字判定命名空间是行不通的:
|
|
98
|
+
* `<svg><a href="…">` 会变成 HTML 锚点,而且不报错。
|
|
99
|
+
*
|
|
100
|
+
* 渲染器没实现 createSvgElement 时按 createElement 兜底(自定义渲染器保持原行为)。
|
|
101
|
+
*/
|
|
102
|
+
export function createSvgElement(tag: string): Element {
|
|
103
|
+
const renderer = getRenderer()
|
|
104
|
+
if (renderer.createSvgElement) return renderer.createSvgElement(tag)
|
|
105
|
+
return renderer.createElement(tag)
|
|
106
|
+
}
|
|
107
|
+
|
|
79
108
|
export function createComment(content: string): Comment {
|
|
80
109
|
return getRenderer().createComment(content)
|
|
81
110
|
}
|
|
@@ -121,7 +150,17 @@ function syncSelectValue(parent: Node): void {
|
|
|
121
150
|
while (current !== null) {
|
|
122
151
|
if ((current as Element).nodeName === 'SELECT') {
|
|
123
152
|
const read = selectValueReaders.get(current)
|
|
124
|
-
|
|
153
|
+
/*
|
|
154
|
+
* 读取必须 `untrack` —— 这里是在**插入 option 的过程中同步**重放当前值,
|
|
155
|
+
* 而插入往往发生在某个 effect 运行时(`insertList` / `insertDynamic` 都由 effect 驱动)。
|
|
156
|
+
* 若那次读取被算进外层 effect 的依赖,该 effect 以后会被这个信号唤醒:
|
|
157
|
+
* 一个**隐藏订阅** —— 依赖关系图上多一条没人打算建立的边,
|
|
158
|
+
* 表现为"插入选项时恰好重跑了某个不相关的 effect",很难追。
|
|
159
|
+
*
|
|
160
|
+
* 这条绑定的订阅由 `bindProperty` 自己的 effect 负责(bind.ts),
|
|
161
|
+
* 这里的重放只是把值**再写一次**,不需要也不应该建立依赖。
|
|
162
|
+
*/
|
|
163
|
+
if (read !== undefined) setProperty(current as Element, 'value', untrack(read))
|
|
125
164
|
return
|
|
126
165
|
}
|
|
127
166
|
current = current.parentNode
|
|
@@ -132,9 +171,16 @@ export function removeChild(
|
|
|
132
171
|
parent: Node,
|
|
133
172
|
child: VobsNode
|
|
134
173
|
): void {
|
|
135
|
-
|
|
174
|
+
/*
|
|
175
|
+
* **先摘 DOM,再释放 Owner。**
|
|
176
|
+
*
|
|
177
|
+
* 原来是反过来的:`disposeNodeOwner(child)` 抛错(用户 cleanup 里抛)就永远走不到
|
|
178
|
+
* 摘除那一步 —— DOM 里留着旧节点、它的 effect 却已经没了,屏幕上出现两棵树且没有提示。
|
|
179
|
+
* 顺序反过来之后,即使清理抛错,DOM 也已一致;错误照旧抛给调用方。
|
|
180
|
+
*/
|
|
136
181
|
if (isVobsFragment(child)) {
|
|
137
182
|
child.unmount(parent)
|
|
183
|
+
disposeNodeOwner(child)
|
|
138
184
|
return
|
|
139
185
|
}
|
|
140
186
|
getRenderer().removeChild(parent, child)
|
|
@@ -145,6 +191,7 @@ export function removeChild(
|
|
|
145
191
|
parent: describeDebugNode(parent)
|
|
146
192
|
})
|
|
147
193
|
}
|
|
194
|
+
disposeNodeOwner(child)
|
|
148
195
|
}
|
|
149
196
|
|
|
150
197
|
export function setTextContent(
|
|
@@ -173,7 +220,7 @@ export function setProperty(
|
|
|
173
220
|
const previousValue = getRuntimeDebugHooks()
|
|
174
221
|
? readDebugValue(() => Reflect.get(node, key))
|
|
175
222
|
: undefined
|
|
176
|
-
getRenderer().setProperty(node, key, value)
|
|
223
|
+
getRenderer().setProperty(node, PROPERTY_IDL_NAMES[key] ?? key, value)
|
|
177
224
|
if (key === 'value' && (node as { tagName?: unknown }).tagName === 'SELECT') {
|
|
178
225
|
scheduleSelectValueSync(node, value)
|
|
179
226
|
}
|
|
@@ -203,12 +250,20 @@ function scheduleSelectValueSync(node: Element, value: unknown): void {
|
|
|
203
250
|
pendingSelectValues.set(node, value)
|
|
204
251
|
if (selectSyncScheduled.has(node)) return
|
|
205
252
|
selectSyncScheduled.add(node)
|
|
253
|
+
/*
|
|
254
|
+
* 在这一刻就把渲染器抓下来,**不要**在微任务里再 getRenderer():
|
|
255
|
+
* 1) 应用销毁会把全局渲染器还原(没装过就是"没有"),那时微任务里的 getRenderer() 会抛
|
|
256
|
+
* "渲染器未初始化" —— 抛在微任务里没人接,直接变成进程级 unhandled error(实测
|
|
257
|
+
* `packages/ui/src/bind.test.ts` 就因为这条让 vitest 报 1 个 unhandled error,退出码非 0);
|
|
258
|
+
* 2) 语义上也更对:节点是哪个渲染器造的,就用哪个渲染器写回(期间全局可能已经换人)。
|
|
259
|
+
*/
|
|
260
|
+
const renderer = getRenderer()
|
|
206
261
|
queueMicrotask(() => {
|
|
207
262
|
selectSyncScheduled.delete(node)
|
|
208
263
|
if (!pendingSelectValues.has(node)) return
|
|
209
264
|
const pending = pendingSelectValues.get(node)
|
|
210
265
|
pendingSelectValues.delete(node)
|
|
211
|
-
|
|
266
|
+
renderer.setProperty(node, 'value', pending)
|
|
212
267
|
})
|
|
213
268
|
}
|
|
214
269
|
|
|
@@ -233,19 +288,186 @@ export function setAttribute(
|
|
|
233
288
|
}
|
|
234
289
|
|
|
235
290
|
export function spreadProps(node: Element, props: Record<string, unknown>): void {
|
|
236
|
-
|
|
237
|
-
|
|
291
|
+
applySpreadProps(node, null, props)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** 删除一个 attribute。渲染器没实现时退回空串(属性仍在,但内容清空)—— 老自定义渲染器的降级行为。 */
|
|
295
|
+
export function removeAttribute(node: Element, key: string): void {
|
|
296
|
+
const renderer = getRenderer()
|
|
297
|
+
if (renderer.removeAttribute) {
|
|
298
|
+
renderer.removeAttribute(node, key)
|
|
299
|
+
return
|
|
300
|
+
}
|
|
301
|
+
renderer.setAttribute(node, key, '')
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** 布尔型 property 的「清除」值是 false,其余是空串(与 React 的移除语义一致)。 */
|
|
305
|
+
const BOOLEAN_PROPERTIES = new Set([
|
|
306
|
+
'checked', 'selected', 'disabled', 'multiple', 'readOnly', 'required',
|
|
307
|
+
'hidden', 'open', 'indeterminate', 'defaultChecked', 'muted', 'autoFocus'
|
|
308
|
+
])
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* JSX 属性名 → **IDL 属性名**(`el[name] = value` 里的那个 name)。
|
|
312
|
+
*
|
|
313
|
+
* 多数名字两边相同,但少数不同;写错名字的后果是**静默的**:
|
|
314
|
+
* `Reflect.set(el, 'autoFocus', …)` 只是给元素挂了一个不反射的 expando,
|
|
315
|
+
* 既不产生 `autofocus` 属性、也不改变 IDL 属性 —— 于是 `autoFocus={false}` 毫无作用。
|
|
316
|
+
* 实测(jsdom,与真实 DOM 同语义):
|
|
317
|
+
* IDL 名 `autofocus`(全小写)→ has=true/idl=true,写 false 则 has=false/idl=false ← 反射正确
|
|
318
|
+
* JSX 名 `autoFocus`(驼峰) → 只挂 expando,属性与 IDL 都不变
|
|
319
|
+
* 这也解释了为什么 `disabled` / `checked` / `readOnly` / `tabIndex` 一直是对的:
|
|
320
|
+
* 它们的 IDL 名恰好与 JSX 名相同。
|
|
321
|
+
*/
|
|
322
|
+
const PROPERTY_IDL_NAMES: Readonly<Record<string, string>> = {
|
|
323
|
+
autoFocus: 'autofocus'
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* 把一份 props 应用(或**增量**应用到)节点上。
|
|
328
|
+
*
|
|
329
|
+
* `previous` 为 null 表示首次应用(`spreadProps` 的老路径);否则只处理真正变化的键,
|
|
330
|
+
* 并**移除**新对象里已经消失的键 —— 这正是原来缺的那一半:`{...props}` 只在创建时应用一次,
|
|
331
|
+
* 之后对象里删掉的键会永远留在 DOM 上,而且改了的值也不会生效。
|
|
332
|
+
*/
|
|
333
|
+
function applySpreadProps(
|
|
334
|
+
node: Element,
|
|
335
|
+
previous: Record<string, unknown> | null,
|
|
336
|
+
next: Record<string, unknown>
|
|
337
|
+
): void {
|
|
338
|
+
for (const [key, value] of Object.entries(next)) {
|
|
339
|
+
if (key === 'key') continue
|
|
340
|
+
if (previous !== null && Object.is(previous[key], value)) continue
|
|
341
|
+
|
|
342
|
+
if (key.startsWith('on')) {
|
|
343
|
+
// 事件:先摘旧监听(值变了、或新值不再是函数),再挂新的。
|
|
344
|
+
// 事件名必须过别名表:`onDoubleClick` 的真实事件名是 `dblclick`,
|
|
345
|
+
// 直接小写会得到不存在的 `"doubleclick"`(回调永不触发且静默)。
|
|
346
|
+
const eventName = resolveEventName(key) ?? key.slice(2).toLowerCase()
|
|
347
|
+
const previousHandler = previous?.[key]
|
|
348
|
+
if (typeof previousHandler === 'function') {
|
|
349
|
+
removeEventListener(node, eventName, previousHandler as EventListener)
|
|
350
|
+
}
|
|
351
|
+
if (typeof value === 'function') {
|
|
352
|
+
addEventListener(node, eventName, value as EventListener)
|
|
353
|
+
}
|
|
354
|
+
continue
|
|
355
|
+
}
|
|
238
356
|
if (key === 'ref') {
|
|
239
357
|
setRef(node, value)
|
|
240
358
|
continue
|
|
241
359
|
}
|
|
242
|
-
|
|
360
|
+
// null/undefined 表示"不提供值",**保持现状**(props.test.ts 把这条钉成了契约)。
|
|
361
|
+
// 注意它有一个已知后果:键仍在 `next` 里时移除循环够不着(`key in next`),
|
|
362
|
+
// 所以"值从中变成 null"不会清掉已有属性 —— 要清就得把键从对象里删掉。
|
|
363
|
+
if (value === null || value === undefined) continue
|
|
243
364
|
// property 键的 false 有语义(如 disabled={false} 必须清除),不能跳过;
|
|
244
|
-
// attribute 键的 false
|
|
245
|
-
else if (
|
|
365
|
+
// attribute 键的 false 表示"不设置",与 HTML 语义一致。
|
|
366
|
+
else if (isClassListKey(key)) applyClassList(node, value)
|
|
367
|
+
else if (isPropertyName(key)) setProperty(node, key, value)
|
|
246
368
|
else if (value === false) continue
|
|
247
369
|
else setAttribute(node, domAttributeName(key), key === 'style' && isStyleObject(value) ? formatStyle(value) : String(value))
|
|
248
370
|
}
|
|
371
|
+
|
|
372
|
+
if (previous === null) return
|
|
373
|
+
for (const key of Object.keys(previous)) {
|
|
374
|
+
if (key in next || key === 'key') continue
|
|
375
|
+
if (key.startsWith('on')) {
|
|
376
|
+
const eventName = resolveEventName(key) ?? key.slice(2).toLowerCase()
|
|
377
|
+
const previousHandler = previous[key]
|
|
378
|
+
if (typeof previousHandler === 'function') {
|
|
379
|
+
removeEventListener(node, eventName, previousHandler as EventListener)
|
|
380
|
+
}
|
|
381
|
+
continue
|
|
382
|
+
}
|
|
383
|
+
if (key === 'ref') continue
|
|
384
|
+
if (isPropertyName(key)) setProperty(node, key, BOOLEAN_PROPERTIES.has(key) ? false : '')
|
|
385
|
+
else removeAttribute(node, domAttributeName(key))
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* 把 `{...props}` 绑定成**响应式**的:对象变了就增量应用,消失的键会被移除。
|
|
391
|
+
*
|
|
392
|
+
* 编译器对带展开的 JSX 属性发射这个而不是 `spreadProps` —— 后者只在创建时应用一次,
|
|
393
|
+
* 于是「改了 props 不生效」「删掉的键留在 DOM 上」两个问题都**不报错**。
|
|
394
|
+
*
|
|
395
|
+
* ⚠️ `previous` 必须是**值的快照**,不能是 `next` 的引用。
|
|
396
|
+
* 这里存引用时,下一轮 `applySpreadProps` 里的 `Object.is(previous[key], value)`
|
|
397
|
+
* 读的是同一份值 → 恒等 → **整个 diff 短路**,移除循环也因 `key in next` 全跳过。
|
|
398
|
+
* 而编译器为 `<div {...props}>` 发射的正是**身份稳定 + getter**的形状
|
|
399
|
+
* (`bindSpreadProps(_el0, () => props)`,值经 getter 反应式求值),
|
|
400
|
+
* 于是真实产物下"改了 props 什么都不更新"。
|
|
401
|
+
*
|
|
402
|
+
* 快照用浅拷贝即可,且**必须在应用之前取**:拷贝会把 getter 求值一次、转成数据属性,
|
|
403
|
+
* 而 `applySpreadProps` 又会把 `next` 的每个键读一遍 —— 两者都在 effect 的追踪窗口内,
|
|
404
|
+
* 所以信号依赖不会因为多这一遍而丢失。
|
|
405
|
+
*/
|
|
406
|
+
/**
|
|
407
|
+
* `classList` 每次贡献的类名(按元素记账,便于下次替换而不是叠加)。
|
|
408
|
+
*
|
|
409
|
+
* 为什么需要记账:`classList` 与 `class` 写的是**同一个** HTML 属性。
|
|
410
|
+
* 若不记住上次由 `classList` 贡献了哪些,切换时要么越加越多、要么把作者的 `class` 抹掉。
|
|
411
|
+
*/
|
|
412
|
+
const classListContributions = new WeakMap<object, string[]>()
|
|
413
|
+
|
|
414
|
+
/** `classList` 值 → 类名数组。支持对象 / 数组 / 字符串三种写法。 */
|
|
415
|
+
export function parseClassList(value: unknown): string[] {
|
|
416
|
+
const names: string[] = []
|
|
417
|
+
const push = (candidate: unknown): void => {
|
|
418
|
+
if (typeof candidate === 'string') {
|
|
419
|
+
for (const name of candidate.split(/\s+/u)) if (name !== '') names.push(name)
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
if (Array.isArray(value)) {
|
|
423
|
+
for (const entry of value) {
|
|
424
|
+
if (entry !== null && typeof entry === 'object') {
|
|
425
|
+
for (const [name, on] of Object.entries(entry)) if (on) push(name)
|
|
426
|
+
} else push(entry)
|
|
427
|
+
}
|
|
428
|
+
} else if (value !== null && typeof value === 'object') {
|
|
429
|
+
for (const [name, on] of Object.entries(value)) if (on) push(name)
|
|
430
|
+
} else push(value)
|
|
431
|
+
return names
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* 应用 `classList`:与元素上已有的 `class` **合并**,并可反复切换。
|
|
436
|
+
*
|
|
437
|
+
* 存在的理由(外部踩坑文档 C 条):条件分支里的输入控件会丢焦点 ——
|
|
438
|
+
* `insertDynamic` 按引用比较(`next === current`),写条件表达式就会重建子树。
|
|
439
|
+
* 文档给出的对策是「固定渲染 + 响应式 class 显隐」,但此前只能手写字符串模板:
|
|
440
|
+
*
|
|
441
|
+
* ```tsx
|
|
442
|
+
* <div class={open.value ? 'panel is-open' : 'panel'}> // 易漏、易写错
|
|
443
|
+
* <div class="panel" classList={{ 'is-open': open.value }}> // 现在:
|
|
444
|
+
* ```
|
|
445
|
+
*
|
|
446
|
+
* `classList` 只负责它自己贡献的部分,作者写在 `class` 里的类名不受影响。
|
|
447
|
+
*/
|
|
448
|
+
export function applyClassList(node: Element, value: unknown): void {
|
|
449
|
+
const next = parseClassList(value)
|
|
450
|
+
const previous = classListContributions.get(node) ?? []
|
|
451
|
+
classListContributions.set(node, next)
|
|
452
|
+
const base = (node.getAttribute('class') ?? '')
|
|
453
|
+
.split(/\s+/u)
|
|
454
|
+
.filter(name => name !== '' && !previous.includes(name))
|
|
455
|
+
const merged = [...new Set([...base, ...next])]
|
|
456
|
+
setAttribute(node, 'class', merged.join(' '))
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function isClassListKey(key: string): boolean {
|
|
460
|
+
return key === 'classList'
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
export function bindSpreadProps(node: Element, source: () => Record<string, unknown>): void {
|
|
464
|
+
let previous: Record<string, unknown> | null = null
|
|
465
|
+
effect(() => {
|
|
466
|
+
const next = source() ?? {}
|
|
467
|
+
const snapshot = { ...next }
|
|
468
|
+
applySpreadProps(node, previous, next)
|
|
469
|
+
previous = snapshot
|
|
470
|
+
})
|
|
249
471
|
}
|
|
250
472
|
|
|
251
473
|
/** Apply compile-time host properties in one renderer pass. */
|
|
@@ -253,29 +475,13 @@ export function setStaticProps(node: Element, props: Record<string, unknown>): v
|
|
|
253
475
|
for (const [key, value] of Object.entries(props)) {
|
|
254
476
|
if (key === 'key' || key === 'ref' || key.startsWith('on')) continue
|
|
255
477
|
if (value === null || value === undefined) continue
|
|
256
|
-
if (
|
|
478
|
+
if (isClassListKey(key)) applyClassList(node, value)
|
|
479
|
+
else if (isPropertyName(key)) setProperty(node, key, value)
|
|
257
480
|
else if (value === false) continue
|
|
258
481
|
else setAttribute(node, domAttributeName(key), key === 'style' && isStyleObject(value) ? formatStyle(value) : String(value))
|
|
259
482
|
}
|
|
260
483
|
}
|
|
261
484
|
|
|
262
|
-
function isPropertyKey(key: string): boolean {
|
|
263
|
-
return key === 'value' || key === 'checked' || key === 'selected' || key === 'disabled'
|
|
264
|
-
|| key === 'multiple' || key === 'readOnly' || key === 'required'
|
|
265
|
-
|| key === 'autofocus' || key === 'hidden' || key === 'tabIndex'
|
|
266
|
-
|| key === 'colSpan' || key === 'rowSpan'
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
/** camelCase JSX 属性名 → HTML attribute 名(与 compiler 的 domAttributeName 保持一致) */
|
|
270
|
-
function domAttributeName(key: string): string {
|
|
271
|
-
switch (key) {
|
|
272
|
-
case 'className': return 'class'
|
|
273
|
-
case 'htmlFor': return 'for'
|
|
274
|
-
case 'autoComplete': return 'autocomplete'
|
|
275
|
-
case 'spellCheck': return 'spellcheck'
|
|
276
|
-
default: return key
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
485
|
|
|
280
486
|
function isStyleObject(value: unknown): value is Record<string, unknown> {
|
|
281
487
|
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
|
@@ -300,7 +506,10 @@ export function addEventListener(
|
|
|
300
506
|
}
|
|
301
507
|
const previous = bindings.get(event)
|
|
302
508
|
if (previous && previous.original === handler && previous.owner === owner) return
|
|
303
|
-
if (previous)
|
|
509
|
+
if (previous) {
|
|
510
|
+
// 旧监听先摘掉;Owner 清理槽由下面的 slots 原地续用,不在清理表里增删
|
|
511
|
+
renderer.removeEventListener(node, event, previous.handler)
|
|
512
|
+
}
|
|
304
513
|
const listener = owner ? (reason: Event) => {
|
|
305
514
|
// Owner 已销毁说明节点所属子树已被卸载/替换,事件来自游离 DOM,直接忽略。
|
|
306
515
|
// 否则 owner.run 会抛"已销毁的 Owner",在事件流里制造无意义的错误噪音。
|
|
@@ -316,16 +525,65 @@ export function addEventListener(
|
|
|
316
525
|
handled,
|
|
317
526
|
recovery: handled ? 'handled' : 'propagated'
|
|
318
527
|
})
|
|
528
|
+
/*
|
|
529
|
+
* 被 owner 的 onError 处理掉之后,错误就只剩 debug 钩子这一个出口 ——
|
|
530
|
+
* 没装 DevTools 时控制台一个字都没有:事件处理器抛错、界面没反应、无从查起。
|
|
531
|
+
* 没被处理的情况下面会 rethrow(浏览器自己会报),所以只在「被吞下」时补控制台。
|
|
532
|
+
*/
|
|
533
|
+
if (handled && getRuntimeDebugHooks()?.error === undefined) {
|
|
534
|
+
console.error(formatVobsError(error, { includeStack: true }))
|
|
535
|
+
}
|
|
319
536
|
if (!handled) throw error
|
|
320
537
|
}
|
|
321
538
|
} : handler
|
|
539
|
+
/*
|
|
540
|
+
* 这个 (node,event) 在 Owner 清理表里只占**一个**槽,替换 handler 时**原地**换掉它做的事。
|
|
541
|
+
*
|
|
542
|
+
* 此前是在**每次** addEventListener 里无条件 `owner.onDispose(...)`,而 `Owner.cleanups`
|
|
543
|
+
* 是只 push 的数组(reactivity/src/owner.ts:57,84)→ 换一次 handler 就多一条永不执行的
|
|
544
|
+
* 清理项,旧的 handler 闭包也被一并扣住。实测(.artifacts/reports/runtime.supplement.md
|
|
545
|
+
* 缺点 4 与既有报告缺点 1,两轮独立复现):500 次替换 → `cleanups.length === 500`;
|
|
546
|
+
* 一轮 `{...props}` 换 onClick → 202。组件的 effect 每次重跑都会重绑事件,
|
|
547
|
+
* 所以这是热路径上的**无界增长**(长寿命页面里等价于内存泄漏)。
|
|
548
|
+
*
|
|
549
|
+
* `binding` 是 `const`,所以槽里必须包一层可变的 `current` —— 换绑时改的是 `current`,
|
|
550
|
+
* 数组长度不变、旧闭包被释放。
|
|
551
|
+
*/
|
|
322
552
|
const binding: EventBinding = { handler: listener, owner, original: handler }
|
|
323
553
|
bindings.set(event, binding)
|
|
324
554
|
renderer.addEventListener(node, event, listener)
|
|
325
|
-
owner
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
555
|
+
if (!owner) return
|
|
556
|
+
/*
|
|
557
|
+
* **每个 (node,event) 在 Owner 清理表里只占一个槽。**
|
|
558
|
+
*
|
|
559
|
+
* 此前是在**每次** addEventListener 里无条件 `owner.onDispose(...)`,而 `Owner.cleanups`
|
|
560
|
+
* 是只 push 的数组(reactivity/src/owner.ts:57,84)→ 换一次 handler 就多一条永不执行的
|
|
561
|
+
* 清理项,旧的 handler 闭包也被一并扣住。实测(.artifacts/reports/runtime.supplement.md
|
|
562
|
+
* 缺点 4 与既有报告缺点 1,两轮独立复现):500 次替换 → `cleanups.length === 500`;
|
|
563
|
+
* 一轮 `{...props}` 换 onClick → 202。组件 effect 每次重跑都会重绑事件,
|
|
564
|
+
* 所以这是热路径上的**无界增长**(长寿命页面里等价于内存泄漏)。
|
|
565
|
+
*
|
|
566
|
+
* 做法:清理槽里存的是**间接引用**(owner → (node,event) → 当前 handle),
|
|
567
|
+
* 重绑时只换间接引用指向的东西,槽本身不增不减;最后一个引用被清掉时槽自然失效。
|
|
568
|
+
* 键用 per-owner 的 `eventCleanupKeys` 映射成数字,避免长字符串前缀比较。
|
|
569
|
+
*/
|
|
570
|
+
const key = eventCleanupKey(node, event)
|
|
571
|
+
let slots = eventCleanupSlots.get(owner)
|
|
572
|
+
if (!slots) {
|
|
573
|
+
slots = new Map()
|
|
574
|
+
eventCleanupSlots.set(owner, slots)
|
|
575
|
+
}
|
|
576
|
+
// 槽存的永远是"当前该清哪一条绑定",所以重绑/摘除后再绑都只是覆盖它
|
|
577
|
+
const hasSlot = slots.has(key)
|
|
578
|
+
slots.set(key, binding)
|
|
579
|
+
if (hasSlot) return
|
|
580
|
+
owner.onDispose(() => {
|
|
581
|
+
const current = eventCleanupSlots.get(owner)?.get(key)
|
|
582
|
+
if (!current) return
|
|
583
|
+
eventCleanupSlots.get(owner)?.delete(key)
|
|
584
|
+
const map = eventBindings.get(node)
|
|
585
|
+
if (map?.get(event) === current) map.delete(event)
|
|
586
|
+
renderer.removeEventListener(node, event, current.handler)
|
|
329
587
|
})
|
|
330
588
|
}
|
|
331
589
|
|
|
@@ -335,13 +593,64 @@ export function removeEventListener(
|
|
|
335
593
|
handler: EventListener
|
|
336
594
|
): void {
|
|
337
595
|
const renderer = getRenderer()
|
|
338
|
-
const
|
|
596
|
+
const map = eventBindings.get(node)
|
|
597
|
+
const binding = map?.get(event)
|
|
598
|
+
/*
|
|
599
|
+
* **必须比 handler 身份**(DOM 的 `removeEventListener` 语义就是如此)。
|
|
600
|
+
*
|
|
601
|
+
* 原来这里忽略传入的 `handler`:只要 `(node,event)` 上有绑定就一律摘掉,
|
|
602
|
+
* 并把绑定记录删掉。于是"用另一个 handler 调 remove"会把**别人的**绑定误摘 ——
|
|
603
|
+
* 调用方以为只是取消自己那次(本来应当是 no-op),实际把当前生效的监听弄没了。
|
|
604
|
+
*
|
|
605
|
+
* 现在的行为:
|
|
606
|
+
* - 有绑定且身份一致 → 摘掉(正常路径)
|
|
607
|
+
* - 有绑定但身份不同 → **no-op**(不去动别人的绑定)
|
|
608
|
+
* - 绑定记录里没有(例如由别处直接经 renderer 绑的)→ 按传入的 handler 兜底摘一次,
|
|
609
|
+
* 保持与渲染器 API 直连时的可用性
|
|
610
|
+
*/
|
|
611
|
+
if (binding) {
|
|
612
|
+
if (binding.handler !== handler) return
|
|
613
|
+
map?.delete(event)
|
|
614
|
+
}
|
|
339
615
|
renderer.removeEventListener(node, event, binding?.handler ?? handler)
|
|
340
|
-
eventBindings.get(node)?.delete(event)
|
|
341
616
|
}
|
|
342
617
|
|
|
618
|
+
/**
|
|
619
|
+
* 清空容器。
|
|
620
|
+
*
|
|
621
|
+
* 原来只调渲染器的 clear(DOM 实现就是 `textContent = ''`)—— **被清掉子树的 Owner
|
|
622
|
+
* 一个都不释放**:组件里的 effect 继续订阅信号(幽灵更新)、监听不解绑、onDispose 不跑。
|
|
623
|
+
* 现在先把子树里能找到的 Owner 释放掉,再清 DOM。
|
|
624
|
+
*
|
|
625
|
+
* 覆盖范围如实说明:`nodeOwners` 只登记**组件输出节点**(`createComponent` 时
|
|
626
|
+
* `associateNodeOwner`)。所以在组件边界上清理是完整的(组件 Owner 的销毁会级联它的
|
|
627
|
+
* effect 与子 Owner);而在组件外直接 `bindText` 之类建立的 effect 挂在调用方当时的
|
|
628
|
+
* Owner 上、不在节点表里,这里**清不到** —— 那部分归它的 Owner 管,通常由
|
|
629
|
+
* `app.destroy()` 负责。要彻底解决需要节点表能反向枚举,代价更大,先不做。
|
|
630
|
+
*
|
|
631
|
+
* 清理抛错不影响 DOM 被清空(先释放、后清 DOM 的顺序也保证了这一点)。
|
|
632
|
+
*/
|
|
343
633
|
export function clear(container: Node): void {
|
|
634
|
+
let firstError: unknown
|
|
635
|
+
const walk = (node: Node): void => {
|
|
636
|
+
try {
|
|
637
|
+
disposeNodeOwner(node as unknown as VobsNode)
|
|
638
|
+
} catch (error) {
|
|
639
|
+
firstError ??= error
|
|
640
|
+
}
|
|
641
|
+
for (const child of Array.from(node.childNodes)) walk(child)
|
|
642
|
+
}
|
|
643
|
+
for (const child of Array.from(container.childNodes)) walk(child)
|
|
644
|
+
|
|
344
645
|
getRenderer().clear(container)
|
|
646
|
+
if (getRuntimeDebugHooks()) {
|
|
647
|
+
invokeRuntimeDebug('domMutation', {
|
|
648
|
+
operation: 'clear',
|
|
649
|
+
target: describeDebugNode(container as unknown as VobsNode),
|
|
650
|
+
parent: describeDebugNode(container as unknown as VobsNode)
|
|
651
|
+
})
|
|
652
|
+
}
|
|
653
|
+
if (firstError) throw firstError
|
|
345
654
|
}
|
|
346
655
|
|
|
347
656
|
type VobsComponent = (...args: any[]) => VobsNode
|
|
@@ -385,20 +694,46 @@ export function createComponent<Component extends VobsComponent>(
|
|
|
385
694
|
// 重渲染前释放上一轮作用域,旧实例的 effect 不再订阅信号、portal 节点不再
|
|
386
695
|
// 残留在 body 中每轮热更新叠加;组件 Owner 与 HMR 注册保活。
|
|
387
696
|
const renderScope = owner.mark()
|
|
388
|
-
|
|
697
|
+
/*
|
|
698
|
+
* 局部类型放宽到运行时真实值域:组件返回 `null` / `undefined` / `false`
|
|
699
|
+
* 都是合法 JSX 写法(`cond ? <X/> : null`、`cond && <X/>`),
|
|
700
|
+
* 只是 `VobsComponent` 的声明把它写窄了。下面统一归一化成空注释节点。
|
|
701
|
+
*/
|
|
702
|
+
/*
|
|
703
|
+
* 用**独立变量**承接渲染结果再归一化。
|
|
704
|
+
* 直接写 `let node: VobsNode | null | undefined | false` 再比较 `node === false` 时,
|
|
705
|
+
* TS 的控制流分析会依据 `component(props)` 的声明类型把 `node` 收窄回 `VobsNode`,
|
|
706
|
+
* 于是 `false` 分支被判成"类型不可达"而报错 —— 但运行时它确实会出现。
|
|
707
|
+
*/
|
|
708
|
+
let rendered: unknown
|
|
389
709
|
try {
|
|
390
710
|
// 组件渲染必须 untrack:组件是 run-once 的,其渲染发生在某次 effect 求值
|
|
391
711
|
// (insertDynamic/insertBoundary 的渲染工厂、路由挂载)内时,若不切断追踪,
|
|
392
712
|
// 组件体内读取的信号会被收集为祖先 effect 的依赖——一次无关编辑就会触发
|
|
393
713
|
// 整棵子树销毁重建(输入框被换掉、焦点丢失、事件监听随旧树一起被清理)。
|
|
394
714
|
// 结构性响应只属于条件工厂与绑定 effect,组件本体渲染一次即止。
|
|
395
|
-
|
|
715
|
+
rendered = owner.run(() => untrack(() => component(props)))
|
|
396
716
|
} catch (error) {
|
|
397
717
|
owner.dispose()
|
|
398
718
|
attachSourceLocation(error, source)
|
|
399
719
|
attachComponentContext(error, componentName, owner.id)
|
|
400
720
|
throw error
|
|
401
721
|
}
|
|
722
|
+
/*
|
|
723
|
+
* 组件返回 `null` / `undefined` / `false` 时用**注释节点**代替。
|
|
724
|
+
*
|
|
725
|
+
* 这是生产事故(Labelune 2026-09-30 发版黑屏)的根因:`createComponent` 把渲染结果
|
|
726
|
+
* 直接当作 `nodeOwners` 这个 **WeakMap 的 key**,而 `null` / `undefined` 不是合法 key ——
|
|
727
|
+
* `nodeOwners.set(null, owner)` 抛 `Invalid value used as weak map key`。
|
|
728
|
+
* 后果特别贵:App 挂载即崩、**没有任何 JS 崩溃日志**、splash 兜底也失效,
|
|
729
|
+
* 只能靠人肉纪律「组件永远返回真实节点」避免。
|
|
730
|
+
*
|
|
731
|
+
* 「组件顶层条件 return null」是合法的 JSX 写法,框架不该崩。
|
|
732
|
+
* 注释节点是**渲染为空的既有原语**(fragment 与 dynamic 的标记都用它),
|
|
733
|
+
* 视觉结果与返回 null 完全一致,但满足 `VobsNode` 契约 —— 于是挂载、HMR 刷新、
|
|
734
|
+
* 节点替换、水合这些下游路径都不需要各自特判一个"空"分支。
|
|
735
|
+
*/
|
|
736
|
+
const node: VobsNode = isRenderableNode(rendered) ? rendered : createComment('vobs:empty')
|
|
402
737
|
associateNodeOwner(node, owner)
|
|
403
738
|
if (instance) {
|
|
404
739
|
instance.node = node
|
|
@@ -486,7 +821,31 @@ export function createBlock(factory: () => VobsNode | null | undefined | false):
|
|
|
486
821
|
return node
|
|
487
822
|
}
|
|
488
823
|
|
|
824
|
+
/**
|
|
825
|
+
* 组件渲染结果是否是**可用的节点值**。
|
|
826
|
+
*
|
|
827
|
+
* `VobsNode` 的真实值域是一个对象(`Node` / fragment / 自定义渲染器的宿主对象),
|
|
828
|
+
* 而合法 JSX 写法可能返回 `null` / `undefined` / `false`(`cond ? <X/> : null`、`cond && <X/>`)。
|
|
829
|
+
* 用一个守卫而不是直接比较,是因为 `component()` 的声明类型是 `VobsNode`,
|
|
830
|
+
* TS 的控制流会把 `=== false` 之类的比较判成"类型不可达"。
|
|
831
|
+
*/
|
|
832
|
+
function isRenderableNode(value: unknown): value is VobsNode {
|
|
833
|
+
return (typeof value === 'object' && value !== null) || typeof value === 'function'
|
|
834
|
+
}
|
|
835
|
+
|
|
489
836
|
export function associateNodeOwner(node: VobsNode, owner: Owner): void {
|
|
837
|
+
/*
|
|
838
|
+
* 防御:`node` 在类型上必然是 `VobsNode`,但运行时可能收到 `null`/`undefined`
|
|
839
|
+
* (组件返回空、第三方渲染器返回非节点)。直接当 WeakMap key 会抛
|
|
840
|
+
* `Invalid value used as weak map key` —— 那是**框架内部错误**,信息量为零,
|
|
841
|
+
* 且会让 App 挂载静默崩溃。这条断言把它变成一条能指认原因的报错。
|
|
842
|
+
*/
|
|
843
|
+
if (node === null || node === undefined || (typeof node !== 'object' && typeof node !== 'function')) {
|
|
844
|
+
throw new Error(
|
|
845
|
+
`Vobs: associateNodeOwner 收到非节点值(${String(node)})。`
|
|
846
|
+
+ '组件必须返回 VobsNode;返回 null/undefined 由 createComponent 转成空注释节点。'
|
|
847
|
+
)
|
|
848
|
+
}
|
|
490
849
|
nodeOwners.set(node, owner)
|
|
491
850
|
}
|
|
492
851
|
|