背景:这三类 bug 为什么值得单独发一版
框架类的 bug 分两种:一种是崩,报错栈直接把你带到现场;另一种是静默失效------不报错、不崩,只是「该更新的时候没更新」「该响应的点击没响应」。第二种更难查,因为你连它坏了都不知道。
v0.4.2 处理的就是第二种。三个问题都符合一个特征:你按直觉写,代码能跑、编译通过、初次渲染也对,只有在特定后续操作下才会暴露。
问题一:声明成 getter 的 state,被克隆成了静态值
先说写法。你可以在 state 上用 getter 派生一个值:
js
yq.define('todo-list', {
template: '<p>{{ pendingCount }} left</p>',
script() {
return {
state: {
items: [],
get pendingCount() { return this.items.length }
}
}
}
})
直觉上 {{ pendingCount }} 应该跟着 items 走。但它不会------初次渲染拿到的是当时的数字,之后 items 再怎么变,页面都不动。
根因在 core 里取 state 的那一步。为了不让用户直接改到内部对象,框架会把 state 复制一份再代理,原来的写法是展开拷贝:
js
// 旧写法:getter 在这里被「求值」成普通数据
return { ...stateValue }
{ ...stateValue } 是按值 复制可枚举属性------遇到 getter 会立刻调用它 ,把返回值当成一个普通属性塞进新对象。于是 pendingCount 从「一个会重新计算的 getter」变成了「一个固定的数字快照」,响应式自然就断了。
修法 是别按值拷,按属性描述符拷,把 getter 原样搬过去:
js
// 新写法:保留 getter(accessor)描述符,读取时才求值
return Object.create(
Object.getPrototypeOf(stateValue),
Object.getOwnPropertyDescriptors(stateValue)
)
Object.getOwnPropertyDescriptors 拿到的描述符里,getter 带的是 get 函数而不是 value,Object.create 会把这些描述符原样挂到新对象上。这样 pendingCount 仍然是 getter,每次模板求值都重新算,而 state 上的读取又走在响应式代理里------依赖一变,绑定的 {{ pendingCount }} 就重渲染。
顺带一提:
derived(s)仍是复杂派生状态的推荐写法;这次只是让「直接用 getter」这条更朴素的路也能正常工作。
问题二:yq-for 行内的 yq-on,重渲染后就掉线
第二个问题更隐蔽。列表里的行内事件,写起来很自然:
html
<ul>
<li yq-for="t in todos" :key="t.id">
{{ t.title }}
<button yq-on:click="remove(t.id)">删除</button>
</li>
</ul>
初次渲染时点「删除」是好的。但只要列表更新过一次(增删一项、或 todos 重新赋值),这些按钮就点不动了。
根因 在事件的绑定时机。旧实现在渲染时把行内 handler 挂上去了,但 yq-for 重渲染会用新节点替换行元素,替换过程里没有把 handler 重新挂到新行上------事件监听随着旧节点一起被丢掉了。
修法是把「行事件」当成行元素的生命周期来管理:
- 渲染时用
bindRowEvents()把行作用域的 handler(包括handler(arg)这种带参形式)绑到行元素本身,并记录一组清理函数; - 重绑前先
unbindRowEvents()清掉上一轮,避免重复监听; - 元素被移除时(同 key 残留行等)也一并解绑,不留悬挂监听。
还有一处配套:行状态是个 Proxy,行的局部变量(t、索引)走 overlay。旧版对 overlay 之外的写入没有触发重渲染,所以「改了列表项但页面不动」。现在 Proxy 的 set 会把非局部写入转发给 requestUpdate(),改列表项就能原地重渲染那一行。
问题三:define() 强制要求 script
第三个是 API 手感问题。很多极简组件只有模板和样式,压根没有脚本:
html
<template id="yq-badge">
<span class="badge"><slot></slot></span>
</template>
但旧版 define() 要求 script 必填,纯模板组件也得硬塞一个空函数。v0.4.2 里 script 变成可选:省略、undefined、甚至传空对象 {},注册时都会被规范成 null。
类型上也同步收紧,ComponentDefinition.script 用了一个新别名:
ts
type ComponentScript = (() => unknown) | string | null | undefined
所以类型签名会直接告诉你「script 可以不写」,而不是运行时才报错。
数字:修完之后长这样
| 项 | v0.4.1 | v0.4.2 | 说明 |
|---|---|---|---|
| core.mjs(gzip) | 11.71 kB | 11.98 kB | ≤ 12.5 kB |
| core.global.js(gzip) | 11.90 kB | 12.16 kB | ≤ 12.5 kB |
| CI gzip 上限 | 12 kB | 12.5 kB | 公开上调 |
| 测试 | 298 / 298 | 313 / 313 | 三处修复都带回归 |
| 运行时依赖 | 0 | 0 | 不变 |
关于 gzip 上限从 12 → 12.5 kB:两处修复都要往渲染路径里加代码,硬压回 12 kB 会牺牲修法本身。我们没有静默改数字,而是作为一条 changelog 记录把预算上调 0.5 kB------预算写进 CI,就得连「放宽」这一步也摆在明面上。
另外顺手把 linkedom 的版本范围收紧了,并让 check-deps 把 dist 也纳入检查(#25),避免打包产物里混进未声明的依赖。
这次能带来什么启发
1. 「不报错的 bug」值得当成一版来修。 崩了你会立刻查;不崩的、只是「没更新」的,可能上线几个月都没人注意到。给这类行为补回归测试(这次 +15 个测试),比修本身更有长期价值。
2. 展开拷贝 { ...obj } 会吃掉 getter。 这是一个很容易复现、也很容易被忽略的坑:任何想「复制一份再代理」的地方,只要对象上挂了 getter,展开就会把它求值成静态值。要用 Object.getOwnPropertyDescriptors + Object.create 才能保住访问器语义。
3. 事件要在「元素的生命周期」上管理,而不是「渲染的那一次」。 列表类组件尤其如此:绑定、重绑、卸载三个时机都要有对应的清理,否则就是「第一次好用,之后失灵」。
4. 尺寸预算也可以有「明说的例外」。 预算的意义是逼你做取舍,不是逼你永远不加代码。真要超,就把它写成一条记录、说清为什么------比悄悄改常数诚实得多。
一起参与
仓库:github.com/YQteam-hq/yq-sanyi,Apache-2.0,零依赖。v0.4.2 的发布包(源码 tar.gz / zip)在 GitHub Releases 上可以下载。