
1. 先搞清楚一件事VSCode悬停提示到底是从哪“读”出来的1.1 悬停面板不是在“读注释”而是在读语言服务返回的符号信息用 Vue3 Pinia 写 Store 时你大概率也遇过这种场景明明在 store 文件里写了一大段注释跑到组件里悬停store.xxx却只有一个孤零零的类型签名辛苦写的说明文字连影子都看不到。我之前一度以为是 VSCode 抽风或者某个插件没装好后来才意识到悬停提示根本不是“编辑器就近找注释”这么简单它背后是一整套语言服务链路。VSCode 的 hover 功能本质上是编辑器把当前鼠标位置发给语言服务器语言服务器根据这个文件解析出来的 AST、符号表、类型信息组装一份 Hover 内容返回。对 TypeScript / JavaScript 来说返回的内容通常包含三类符号的类型签名、JSDoc 注释里提取的文档说明、声明位置信息。注意关键词语言服务器、符号表。它不是搜索引擎更不是“离鼠标最近的注释阅读器”。在 Vue3 项目里这条链路更绕。.vue 文件不能直接被 TypeScript 处理需要 VolarVue Language Features先把script setup、模板、样式拆开脚本部分再交给 tsserver 做类型分析。写好的注释能不能出现在悬停面板里取决于一个很本质的问题注释在 AST 里是不是真的“挂”在了那个符号上以及经过 Pinia 类型包装之后这个符号的“身份”有没有被保留。1.2 悬停面板里的三块信息来源完全不同我在实际排查中习惯把 hover 面板的信息拆成三类类型签名比如const count: Refnumber或者(property) count: number这是类型系统算出来的跟注释没有任何关系。文档注释只有/** ... */这种 JSDoc/TSDoc 风格注释才会被语言服务器提取。普通//注释不会进 hover。声明位置悬停面板底部可能会显示“来自 xxx.ts”配合 Go to Definition 跳转到对应声明。这个跳转目标决定了 VSCode 用的是哪个符号的文档。这三类信息里最坑的就是第二类。很多人以为“写了注释就应该显示”但语言服务器只认特定格式、特定位置的注释。你写的位置差一行、写法从 JSDoc 变成普通注释结果都完全不同。等到了 Pinia Store 场景里注释还要跨越“对象字面量 → Pinia 类型包装 → store 实例属性”一整条链才能作用到组件里任何一个环节把文档关联丢掉外部悬停就会立刻失语。1.3 一个最小实验先验证普通代码里注释与悬停的关系为了把问题隔离清楚我建了一个最简单的 TS 文件做过验证// 这是普通注释用于说明别的一些东西 const a 1 /** * 这是 JSDoc 注释用来描述这个函数 * param x 入参 x 的含义 * returns 返回值的含义 */ function add(x: number, y: number): number { return x y }在a上悬停显示const a: number那句“这是普通注释”不会出现。在add上悬停能看到函数签名、描述、参数说明、返回值说明。结论很朴素VSCode 的 hover 只认 JSDoc而且必须挂在对应声明上。这个实验看起来像废话但它是后面所有分析的基石。因为 Pinia Store 里的注释要想到达组件侧的 hover远比这个最小实验复杂得多。2. 注释在 Pinia Store 里“不生效”的三种典型情况2.1 用普通 // 注释而不是 JSDoc等于白写这是我在代码评审里见过最多的坑。有人这么写export const useUserStore defineStore(user, { state: () ({ // 是否已登录 isLoggedIn: false, }), })然后在组件里悬停store.isLoggedIn发现只有一个布尔类型于是抱怨注释不生效。其实不是不生效是语言服务器根本不把//注释视为文档来源。你要改成state: () ({ /** 是否已登录 */ isLoggedIn: false, }),能不能保证显示还要看下一条。但第一步至少得让你写的注释变成“语言服务器认得的文档”。2.2 注释挂在“对象字面量内部”而不是“类型成员”上Pinia 的 option store本质上是你把一个大对象传给defineStore。对象字面量里每个属性上的 JSDoc属于这个对象字面量类型的成员文档但defineStore返回的不是这个对象本身而是经过 Pinia 内部类型逻辑包装后的 store 实例。如果包装过程用到了映射类型、条件类型或者干脆重新声明了属性类型原注释就会丢。以我手头的项目经验来看option store 里 state 属性上的注释在不同 Pinia 版本下表现不完全一样。旧版本经常出现“内部悬停有、外部悬停无”新版本搭配较新的 Volar 有改善但依然不能保证。getters 和 actions 因为属性本身是函数注释保留概率会高一点。原因是函数符号在拷贝到 store 对象上时函数本身维护的 JSDoc 比较容易跟着走而普通属性很容易在类型映射中变成“新符号”。2.3 工具类型把符号“抹平”了注释链随之断裂这是最核心、也最容易被忽略的原因。Pinia 为了让 state 里的 ref 在 store 实例上自动解包、让 getter 变成只读属性、让 action 绑定 this内部会用一堆工具类型做变换。比如UnwrapReftypeof state、_UnwrapAllS、StoreWithGetters等等。这些工具类型小则做类型映射大则演算条件类型。每经过一层TypeScript 的符号表就可能生成一个“新符号”而不是延续原来的符号。文档注释附着在原符号上新符号拿不到hover 自然就只剩裸类型。我打一个比方你在文件 A 的封面上贴了一张写着说明的便利贴然后把文件 A 复印、装订、换封面变成文件 B。别人拿到文件 B看到的是新封面便利贴不会自动长出来。工具类型做的就是这个“复印装订换封面”的过程。理解这一点特别重要因为它解释了为什么你“明明写了注释却看不到”——不是你的问题是类型系统在处理过程中把文档丢掉了。3. Pinia 两种 Store 写法下的悬停体验完全不一样3.1 Option Storestate 里的注释最容易在外部丢失先看最常见的 option storeexport const useUserStore defineStore(user, { state: () ({ /** 用户昵称 */ nickname: guest as string, /** 是否已登录 */ isLoggedIn: false, }), getters: { /** 昵称首字母 */ initial: (state) state.nickname.charAt(0), }, actions: { /** * 更新用户昵称 * param name 新的昵称 */ updateName(name: string) { this.nickname name }, }, })在 store 文件内部悬停nickname声明处能看到“用户昵称”。但在组件里store.nickname上悬停大概率只剩(property) nickname: string之类的签名。getter 外部悬停能看到注释的概率稍高action 一般能看到签名加 JSDoc。但这里我用的是“一般”因为它跟 Pinia 版本、Volar 版本、TypeScript 版本都有关系不是一个确定性的结果。3.2 Setup Storereturn 的那一瞬间是注释的“生死线”如果你用 setup store情况会更加直观export const useCounterStore defineStore(counter, () { /** 计数器的当前数值 */ const count ref(0) /** * 将计数器加一 */ function increment() { count.value } return { count, increment } })count上确实有 JSDocincrement上也有。在 store 文件内部悬停它们都正常显示。问题出在return { count, increment }这里返回的是一个新对象字面量Pinia 再对这个闭包返回值做类型包装。返回值里count的类型是RefnumberPinia 会把它解包成number而这个解包过程就是“便利贴消失”的过程。外部组件悬停store.count我最常见到的结果是(property) count: number没有“计数器的当前数值”。这正是标题里说的“内外悬停提示差异”的直接原因内部悬停停在原始声明符号上外部悬停停在经过 Pinia 类型包装之后的新符号上。两者在运行时是同一个数据在 TypeScript 的符号表里却是两个不同的符号。3.3 四个关键位置的悬停表现对比我按照自己项目里观察到的现象整理成一张表。它不能覆盖所有版本组合但方向是稳定的悬停位置Option Store 表现Setup Store 表现Store 文件内部 state 声明处能显示 JSDoc能显示 JSDocStore 文件内部 getter/action 定义处能显示 JSDoc能显示 JSDoc组件里悬停 state 属性不稳定大概率丢失大概率丢失只剩类型组件里悬停 getter/action有概率保留有概率保留比 state 好最后一行“有概率保留”是基于函数符号更容易携带文档这个事实。后面我会讲怎么把“概率”变成“确定性”。4. 从“内部可见”到“外部丢失”注释经历过的完整旅程4.1 内部悬停为什么靠谱在 store 文件内部你看的是最原始的声明。count的声明是const count ref(0)TypeScript 在这个声明位置建立了符号JSDoc 就挂在符号上increment的声明是function increment()同样挂载了 JSDoc。option store 里state: () ({ ... })返回的对象字面量每个属性也都有真实的声明位置。所以内部悬停看到的永远是“第一手”符号信息。这就是为什么很多人会在 store 文件里觉得一切正常一到组件里就不对劲。4.2 外部悬停看到的是 Pinia 类型逻辑“现算”出来的属性在组件里写const store useCounterStore()之后store的类型并不是 defineStore 返回的闭包类型而是 Pinia 内部通过一系列类型组合出来的Storecounter, ...。访问store.count时TypeScript 回答的是这个包装后类型里的count属性。属性虽然是同一个名字但符号背后的“文档附着点”已经变了。悬停store.count时VSCode 显示(property) count: number这里的 property 是 Pinia 包装后的属性不是你上面那个 count 变量。JSDoc 没有随着类型映射迁移于是丢掉了。4.3 用“跳转到定义”验证注释有没有被传递判断注释到底丢没丢有一个很实用的排查办法右键点击悬停出来的变量选“转到定义”Go to Definition看它跳到哪里。如果跳回你的 store 文件、跳到你写的那行const count ref(0)说明 VSCode 还在用原符号文档大概率还在。如果跳到node_modules/pinia/dist/pinia.d.ts或者一个泛型海啸的类型声明里说明它用的是 Pinia 内部生成的新符号你的注释基本就没了。我排查过不少类似问题90% 丢注释的情况跳转都落在node_modules下的类型声明文件里。遇到这种情况再去改注释位置、加注释都是无用功因为外部悬停根本不读你的注释。5. 让外部悬停真正显示注释的实操方案5.1 方案A把文档写到具名接口/类型上不要只写在字面量属性上如果希望组件里悬停store.xxx能看到注释最可靠的做法是让外部类型来自一个具名、带着 JSDoc 的接口或类型别名。// store/counter.ts export interface CounterActions { /** 将计数器加一 */ increment: () void } export interface CounterState { /** 计数器的当前数值 */ count: number } export const useCounterStore defineStore(counter, () { const count ref(0) const actions: CounterActions { increment() { count.value }, } return { ...actions, count } })关键点在于actions变量有具名类型CounterActions返回对象里的increment属性继承的是CounterActions上那个带 JSDoc 的成员文档。实测下来组件里悬停store.increment时这种方式比“直接在函数上写 JSDoc 再 return”稳定得多。因为它避免了函数符号在对象合并时被重新创建。state 属性的问题更复杂因为 ref 会解包。如果你把count声明为Refnumber外部得到的是number而接口CounterState里count: number的文档有可能保留。我试的时候依然不稳定但如果把 state 相关的 ref 用一个具名接口约束初始值至少不会更差。5.2 方案B函数用具名函数声明加完整 JSDoc这个方法适合 action 不多的小 storeexport const useCounterStore defineStore(counter, () { const count ref(0) /** * 将计数器加一 * returns 当前计数 */ function increment() { count.value return count.value } return { count, increment } })函数声明本身自带符号return 时虽然对象字面量会重新建立属性但工具类型相对容易保留函数签名上的文档。Volar 在处理这类情况时通常能识别出来。前提是你别把它改成“先赋值给一个 fn 类型变量再 return”那样会多一层额外的包装注释又容易丢。5.3 方案C在调用侧少用解构直接访问 store 属性还有一类情况是从组件侧想办法。很多人习惯解构const store useCounterStore() const { count, increment } store解构出来的count是独立变量它的类型是 Pinia 包装后属性的拷贝注释通常也没有。如果你很依赖悬停提示尽量直接写store.count不要解构。解构会让“原符号的信息”再丢失一次。5.4 我踩过的一个典型坑在 return 对象上补 JSDoc早期为了“让外部能看到注释”我直接这么写return { /** 计数器的当前数值 */ count, /** 将计数器加一 */ increment, }看起来挺合理但实测外部悬停还是丢注释。原因依然是返回对象字面量在 defineStore 的类型系统里只充当类型来源它内部属性的 JSDoc 没有被 Pinia 的返回类型复用。这个坑我劝你别再踩。与其在“返回值”上做文章不如让返回值的类型来自一个具名接口或者让每个返回的函数本身携带足够完整的函数签名文档。6. 一句大实话悬停提示的可控性取决于你对类型的“具名程度”6.1 把这段经验浓缩成判断标准经过这一轮折腾我给自己总结了一条判断标准类型越是具名、越是独立的符号注释越容易跟着跑类型越是靠工具类型现算出来的注释越容易丢。interface CounterActions是具名类型文档挂在成员上保留概率高。defineStore(counter, () ({ ... }))的返回值是匿名对象Pinia 再做成 store文档保留就是“碰运气”。普通//注释在任何情况下都不会被 hover 读取要写/** */。基于这个判断标准团队里做公共 store 时我现在的习惯是任何需要被外面引用的 action先定义具名接口或类型再实现对象。state 里如果注释特别重要就在命名上直接做表达比如isLoggedIn这种自解释命名不依赖悬停。毕竟注释可以丢但代码本身该表达的信息不能丢。6.2 对“内外悬停提示”这件事的整体认识往回看标题里的问题本质不是“VSCode 能不能实现悬停显示”而是“你的注释在类型系统里挂在哪个符号上”。同一个 store内部悬停因为停在原始符号上所以有文档外部悬停因为经过了 Pinia 类型变换文档可能留在原始符号、没有被搬运到新符号于是只剩类型签名。最后再分享一个小技巧如果你实在不想为了悬停提示大改 store 结构可以在组件里用类型推导帮一把比如给useCounterStore()的返回值补一个显式类型注解。但这不是治本的办法。治本的办法永远只有一个——让代码里每个关键的类型都“具名化”把多点注释放到具名类型上。这套思路放到任何带类型包装的库上都适用值得养成习惯。