ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Set.has() 返回 false?Vite HMR 下的模块实例漂移与解决方案

Set.has() 返回 false?Vite HMR 下的模块实例漂移与解决方案 1. 现象描述Set.has() 背叛了 JavaScript 语义先说一个我上周真实踩过的坑一个 Vite 3 Vue 3 的中型项目本地跑了一整天 dev server。下午改一个跟业务八竿子打不着的工具文件控制台刷过一屏 HMR 日志然后负责“任务调度面板”的同事跑过来说现场里所有任务都变成了“未注册”状态。第一反应是响应式丢了或者事件没触发。结果都不是。最后定位到一个极其诡异的地方// modules/taskRegistry.js export const activeTasks new Set() export function register(task) { activeTasks.add(task) } export function isActive(task) { return activeTasks.has(task) }业务代码是这样的逻辑创建任务 →register(task)→ 立刻isActive(task)判定。结果热更新之后的第一次调用register执行成功activeTasks.size确实变大了但isActive(task)返回false。这个结果在 JavaScript 语言层面是“不可能”的。Set.prototype.has()对对象类型的键采用的是引用相等规范里叫 SameValueZero跟的规则在对象上是完全一致的——同一个引用只要add过、没deletehas()必然返回true。如果返回了false那么逻辑上只剩两种可能要么你手里这个对象并不是当初add进去的那个对象要么你现在用的这个 Set 并不是当初add时用的那个 Set。注意这个结论是排中律级别的没有第三种情况。所以问题就变成了**为什么一个在业务上“同一个”的对象会在 HMR 之后变成“两个”对象为什么一个在代码里“同一个”的模块会在 HMR 之后产生“两份”状态**这就要从 Vite HMR 的模块实例机制说起。2. 根因拆解HMR 重新执行模块等于给同一个文件换了一张脸2.1 先从 Set 的相等性规则说起把对象放进 Set 里本质上是用对象的内存地址引用作为键。业务上你看到的是“一个任务对象”但 V8 看到的是“某个模块实例里创建出来的某个堆对象”。两个内容完全相同的对象只要不在同一个堆地址上在 Set 眼里就是两个东西。很多人写代码时默认把“对象”和“对象的逻辑身份”混为一谈。在正常运行时没毛病因为一个模块在浏览器里只有一个实例new Task()只有一份所有 import 同一个模块的地方拿到的是同一个类。问题恰恰出在“模块只有一个实例”这个前提被 HMR 打破了。2.2 Vite HMR 到底做了什么ESM 在浏览器里的加载规则是同一个 URL 只产生一个 module record也就是一个模块实例。Vite dev server 正是靠这个规则保证“模块单例”的。但 HMR 更新时Vite 会把改动模块的 URL 加上一个时间戳参数重新发起 import比如/src/modules/taskRegistry.js ← 初始加载 /src/modules/taskRegistry.js?t1700012345678 ← HMR 后重新执行浏览器看到?t...这个 URL 和原来的 URL 不同就会当作一个全新的模块来解析执行。于是同一个源文件产生了两个 module record旧的 module record 还活在老代码的闭包里里面的activeTasksSet、Task类、各种导出函数都还是旧的那一套新的 module record 由 HMR 客户端加载出来里面是一套全新的 Set、全新的类、全新的函数。如果更新事件的接收方没有做“把新实例替换进老引用”的操作应用里就会同时存在两套“平行宇宙”。旧代码往旧 Set 里add新代码往新 Set 里add互不相通。这是整个问题的根源HMR 打碎了“模块即单例”的默认假设。2.3 三种会导致 has() 返回 false 的引用漂移根据“皮”到底漂在哪一层实际踩坑时你会见到三种形态。形态 ASet 漂移热更新的模块本身导出了 Set比如 registry 模块被编辑。编辑后它重新执行导出了一个全新的activeTasks。旧消费者模块没有跟着重跑的那些手里的activeTasks还是旧的。此时新模块的has()对任何旧对象都返回false因为新 Set 是空的而旧模块的has()对新对象也返回false因为新对象不在旧 Set 里。形态 B键对象漂移热更新的模块导出一个类或工厂函数。比如Task类所在的文件被编辑Task类被重新创建。旧模块创建的task1是旧类的实例新模块创建的task2是新类的实例。即使task1和task2字段完全相同Set 依然认为它们是两个不同的对象。如果注册表 Set 本身没换只是新创建的task2不在里面那has(task2)就是false同时has(task1)是true业务上却已经完全找不到task1了。形态 C两个一起漂移最常见的“双漂”场景是循环依赖。a.js引用b.jsb.js又引用a.js中间任何一个文件被 HMR 触发Vite 在重跑依赖图时就可能让环形链上的模块出现“新旧双实例”。此时 Set 是新的对象也是新的肉眼根本看不出问题但has()就是 falseinstanceof也是 false。这是所有形态里最难排查的一种。3. 最小复现把 HMR 的“平行宇宙”逼出来实话说这类问题在不同项目里触发条件差异很大因为它强依赖模块依赖图和 HMR 接收边界。这里给一个我在本地验证过的、能稳定复现“Set 漂移”的最小方案建议你照着建一个空项目跑一遍比看十篇文章都直观。3.1 环境与目录结构用 npm 创建一个 vanilla 模板就行npm create vitelatest hmr-set-demo -- --template vanilla cd hmr-set-demo npm install npm run dev新建两个模块专门复现问题src/ ├── main.js └── taskRegistry.jstaskRegistry.js是这个 Demo 的核心注意看最后那行import.meta.hot.accept()// src/taskRegistry.js export const activeTasks new Set() export function register(task) { activeTasks.add(task) } export function isActive(task) { return activeTasks.has(task) } if (import.meta.hot) { import.meta.hot.accept() }main.js模拟一个已经持有旧模块引用的消费者// src/main.js import * as registry from ./taskRegistry.js const task { id: task-1, name: demo } registry.register(task) console.log(HMR 前旧模块视角, registry.isActive(task)) // true if (import.meta.hot) { import.meta.hot.accept(./taskRegistry.js, (nextModule) { console.log(HMR 触发新模块视角, nextModule.isActive(task)) // 变成了 false console.log(HMR 触发旧模块的 Set 大小, registry.activeTasks.size) // 还是 1 console.log(两个 Set 是否是同一个, registry.activeTasks nextModule.activeTasks) // false }) }3.2 触发步骤打开页面控制台第一行输出HMR 前旧模块视角 true。给taskRegistry.js随便加一行注释并保存。观察控制台会依次出现HMR 触发新模块视角 falseHMR 触发旧模块的 Set 大小 1两个 Set 是否是同一个 false到这里现象就已经完整复现了。注意一个细节旧模块的 Set 大小仍然是 1说明task这个对象确实还被装着只是新模块手里的 Set 是另一个空 Set。业务代码如果切换到了新模块的isActive拿旧对象去问得到的自然是false。3.3 再复现“对象漂移”把复现目标换成“对象变了、Set 没变”把 demo 改成两个文件// src/task.js export class Task { constructor(name) { this.id crypto.randomUUID() this.name name } }// src/withRegistry.js import { Task } from ./task.js import { activeTasks } from ./registry.js export function createTask(name) { const task new Task(name) activeTasks.add(task) return task } export function isActive(task) { return activeTasks.has(task) }registry.js导出同一个 Set但它不参与 HMR 触发只作被共享状态。此时编辑task.jsTask类被 HMR 重建createTask生产出来的task2是全新对象。如果你在界面上持有关闭了旧对象再用新注册表判定has()就是 false。这种形态在 React Fast Refresh 场景里尤其常见因为组件的 hook state 会被保留暴露给用户的“对象”还是旧的但后面的注册逻辑已经换成新模块导出的了。3.4 在浏览器里观察模块实例复现之后建议在模块里放一个“实例戳”以后排查类似问题可以直接看到第几代实例// 任何模块顶部都可以放 globalThis.__moduleStamp (globalThis.__moduleStamp || 0) 1 export const moduleStamp instance-${globalThis.__moduleStamp}把这个moduleStamp连同 Set、对象一起打出来你会直观看到 HMR 前后模块代际发生了变化。有了这个工具再遇到“对象明明一样但判定失败”第一反应就不是怀疑业务代码了。4. 定位三板斧把两小时踩坑浓缩成检查清单4.1 先确认你手里的是“同一个对象”很多人在这一步就绕远了。不要用JSON.stringify(obj1) JSON.stringify(obj2)来判断这在引用场景下没有意义。直接在代码里打console.log(obj1 obj2) // false —— 这才是真相 console.log(obj1.id, obj2.id) // task-1 task-1 —— 业务上看起来一样如果是false就说明对象引用已经不等了后面根本不用继续讨论业务逻辑。这是最快的分诊方式先确定是“对象不同”还是“Set 不同”。4.2 确认 Set 是否还是原来那一个在注册表相关代码里把 Set 本身也打出来跟其他模块里引用的 Set 做引用比较// moduleA import { activeTasks } from ./registry.js console.log(moduleA 的 Set:, activeTasks) // moduleB import { activeTasks } from ./registry.js console.log(moduleB 的 Set:, activeTasks) console.log(两者引用相等, activeTasksA activeTasksB)如果引用不等说明两个模块分别拿到了不同世代的模块实例。顺藤摸瓜看是谁先“接受”了更新、谁还留在旧世代就能定位 HMR 边界的分岔点。4.3 打开 Network 面板找 ?tVite 重新执行模块时请求 URL 里会带?t时间戳参数。这是定位“哪个模块被重建”最直观的线索打开 DevTools —— Network 面板。复现一次 HMR 触发。按?t过滤请求看到的所有请求就是被 HMR 重建的模块清单。如果清单里出现了registry.js那 Set 漂移基本就实锤了如果清单里是task.js之类的“身份模块”那对象漂移就实锤了。这个办法在线下排查时比读源码快得多。5. 解决方案别再把对象引用当 Set 的键5.1 最推荐用稳定 ID 代替对象引用这是我从那次踩坑之后默认采用的做法。注册表类 Set 里存的不要是对象而是稳定 ID// taskRegistry.js export const activeTaskIds new Set() export function register(task) { activeTaskIds.add(task.id) } export function isActive(task) { return activeTaskIds.has(task.id) }字符串和数字没有“模块实例”的概念HMR 重建一万次task-1还是task-1。只要业务 ID 本身稳定isActive的结果就不会受模块代际影响。代价是你在需要完整对象时得额外做一次 ID 到对象的映射但对于“活跃状态判断”这类场景这个代价完全值得。5.2 如果你一定要维护 Set用 hot.data 把实例留下来如果你确实需要保留 Set 里的对象引用那就得让 Set 本身跨 HMR 存活。Vite 提供了import.meta.hot.data它是 HMR 更新过程中唯一会持久化的数据载体。// taskRegistry.js const data import.meta.hot.data export const activeTasks data.activeTasks ?? new Set() data.activeTasks activeTasks export function register(task) { activeTasks.add(task) } export function isActive(task) { return activeTasks.has(task) } if (import.meta.hot) { import.meta.hot.accept() }关键在于?? new Set()这个表达式模块第一次执行时没有旧数据就建 SetHMR 重新执行时data.activeTasks已经存在于是新旧模块导出的是同一个 Set。这样老消费者和新消费者手里的 Set 引用就是相等的。不过注意这个方案只能解决“Set 漂移”如果对象的类本身被重建对象引用还是会变所以实践中我还是建议以 ID 为主、Set 引用为辅。5.3 把“身份型”模块从热更新源里摘出去另一个思路是共享身份状态的模块尽量让它不参与热更新链。不要编辑存放单例 Set / 常量 / 基类的模块不要在经常改的组件文件里导出非组件的身份型变量把纯常量、类、枚举挪到一个“只读依赖”的目录里日常开发不会碰它。浏览器缓存机制决定了只要这个模块的 URL 不变、不被重新 import它的实例就一直存在。所以把身份型模块变成依赖图里的“稳定叶子”HMR 根本不会触发它的重跑问题从源头消失。5.4 解掉循环依赖从源头避免双实例循环依赖 HMR 是双重实例的最高发组合。Vite 官方 issue 里不止一次出现过因为环形依赖导致模块被复制成两份的案例。最彻底的解决方式是解环把A和B共同依赖的 Set、类型、常量抽到独立模块C让A - C、B - C而不是A - B如果暂时解不了环就至少保证环上的import.meta.hot.accept处理足够干净避免旧模块闭包继续存活。解环之后模块图变成单向HMR 重建时不会出现“新旧两代同时存在”的尴尬局面instanceof和Set.has都会恢复可信。5.5 框架层还有一招关掉特定文件的 HMR如果某个文件就是“身份型”的改动它之后不值得冒运行态出 bug 的风险可以直接让 Vite 对它的更新降级为整页刷新// 身份型模块底部 if (import.meta.hot) { import.meta.hot.decline() }一旦decline()再编辑这个模块时 Vite 会放弃局部热更直接刷新页面。页面刷新后旧的模块实例全部销毁从头加载所有 Set、类都是新一代的不会有两代共存的问题。代价是改动这个文件时开发体验稍差但相比调试“平行宇宙”的时间成本完全划算。6. 常见问题速查与避坑清单现象大概率原因处理方向Set.has()对“同一业务对象”返回 false且size正常Set 实例被 HMR 重建用 ID 做键或hot.data持久化 Setinstanceof也返回 false类定义所在模块被 HMR 重建用 ID / duck typing或多态判定没有报错但注册表数据“消失”HMR 重置了模块级状态用import.meta.hot.data持久化循环依赖模块出现重复执行环形依赖导致新旧两代模块图并存解环或hot.decline()Fast Refresh 后组件状态保留但判定异常状态闭包持有旧模块导出新渲染用新模块导出不跨模块共享对象引用改用 ID最后整理一份避坑清单基本是我日常开发默认遵守的规则模块级Set的键尽量用稳定 ID不要用对象引用。常量、枚举、基类放在不会频繁编辑的稳定模块里。在 React 文件里不要同时导出组件和模块级状态。环形依赖能解就解不要图省事留着。个别身份型模块允许用hot.decline()牺牲一点热更体验。看到?t请求就意识到“新模块实例已产生”。7. 一点个人体会这个坑最折磨人的地方不是它难而是它只在 dev server 长时间运行后出现而且代码看起来完全正常。你输出task.name是对的你输出activeTasks.size也是对的你甚至能在这个 Set 里找到看起来一样的对象——但has()就是返回 false。我个人现在的习惯是在调试这类问题前先问自己一句——“这个 Set 到底是哪个模块实例的 Set”一旦把思维从“业务对象”切换到“模块实例 引用身份”问题就清楚了一半。这次踩坑之后我也把团队里所有跨模块共享的注册表型 Set 都改成了 ID 键后面再没出现过类似的问题。如果你也被这种“不可能”的现象折磨过不妨照上面几个方案逐个试一遍基本能在十分钟内找到答案。
返回列表