为什么 { ...state } 会悄悄杀掉响应式?yq-sanyi v0.4.2 的三处修复

背景:这三类 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 上可以下载。

相关推荐
Java后端的Ai之路1 小时前
01-React基础教程
开发语言·前端·python·react.js·前端框架
颜进强1 小时前
25 · NestJs DurableProviders 持久化 Provider:把 ContextId 当缓存键
前端·后端·ai编程
爱丶不疚1 小时前
Electron: Sentry 都采集了什么?你知道吗
前端·electron
恋猫de小郭1 小时前
Dart 4.0 要彻底移除 dart:mirrors,Augmentations 应该要来了
android·前端·flutter
流水白开1 小时前
React的Virtual DOM、Diff算法和Fiber
前端·react.js
乘风gg1 小时前
Spec Kit vs OpenSpec vs Superpowers:我为什么最后自己搭了一套
前端·ai编程·claude
颜进强1 小时前
24 · NestJs LazyLoadingModules 懒加载模块:你在前端天天 `import()` 懒路由,但 Nest 偏偏不能懒加载路由
前端·后端·ai编程