悬浮提示从哪来:LSP、tsserver 与 .d.ts

在编辑器里对 useState 或 z.object 悬停,会弹出参数和返回类型;按住 Ctrl(Windows)或 Command(macOS)再点击,常常会跳进一份 .d.ts,而不是正在跑的那份 .js。这和「Node / 打包器如何找到运行时文件」不是同一条路。

下文把编辑期这条线称为类型契约:谁在提供智能提示,类型文件从哪来,以及它和运行时入口如何分叉。

核心区分: 悬浮和跳转读的是类型表面;浏览器或 Node 执行的是另一份实现。两边可以对齐,也可以故意分开。

先分清三个名字

日常口语里「TS 智能提示」常被揉成一团。拆开更清楚。其中 LSP 是 Language Server Protocol(语言服务器协议) 的缩写:编辑器不自己「懂」每一种语言,而是通过这套协议向背后的语言服务提问。

名称 是什么 本文关心的边界
LSP(Language Server Protocol) 编辑器与语言服务之间的协议 规定「补全、跳转、诊断」这类请求怎么往来;不规定某个 npm 包长什么样
tsserver TypeScript 自带的语言服务实现 很多编辑器已经内置 ;打开 .ts / .js 时,提示往往来自它,而不必单独再装一个「TS 语言包」
.d.ts 声明文件,描述类型表面 通常不参与运行时;给检查器和编辑器读

TypeScript 手册把 Declaration Files 定位为「描述已有 JavaScript 的形状」。可以记成:.d.ts 描述类型,通常不负责运行时执行。

三者不是并列的三个工具,而是自上而下叠在一起的分层:界面发起请求 → 协议约定怎么问 → 语言服务负责算 → 声明文件(或带类型的源码)提供材料。

flowchart TB subgraph L1[界面层] A[编辑器 悬浮 跳转 补全] end subgraph L2[协议层] B[LSP 语言服务器协议] end subgraph L3[服务层] C[tsserver 或同类语言服务] end subgraph L4[材料层] D[dts 声明文件] E[带类型的源码] end A --> B B --> C C --> D C --> E

用一张职责表对照同一条栈:

层 角色 典型对象
界面层 发起请求、展示结果 VS Code 等编辑器 UI
协议层 规定「怎么问、怎么答」 LSP
服务层 真正做补全、跳转、诊断 tsserver(常内置);Vue 场景下也可能是 Volar 等
材料层 被服务读入的类型表面 .d.ts / .d.cts 等;有时是可解析的 .ts 源码

本节重点: LSP 是协议层,tsserver 是服务层,.d.ts 是材料层。 缺协议则编辑器挂不上服务;缺服务则没人读声明;缺材料则服务无类型可推断。换 Vue 扩展时,动的是服务层,不是换一套 LSP 语法。

tsserver 常常已经在编辑器里

以 VS Code 为例:安装完成后就带有 TypeScript / JavaScript 语言支持,背后用的正是 tsserver (或与其同族的语言服务)。所以写普通 .ts 时,悬浮和跳转往往无需再装别的扩展------并不等于机器上额外装过一个叫 tsserver 的独立软件,它经常是编辑器内置能力的一部分。

其他编辑器或插件市场里的 TypeScript 支持,思路类似:由某个语言服务进程回答 LSP 请求;只是发行方式不同(内置、扩展或项目本地的 typescript 包)。

框架文件会打破「只靠内置 tsserver」的默认假设,但要和 JSX / TSX 分开看。.jsx / .tsx 仍是 TypeScript(及 JS 语言服务)认识的语法:文件整体还是「一份脚本」,标签会按 JSX 变换规则参与类型检查,所以内置 tsserver 就能提示、报错------不必再装一个「JSX 语言服务器」。

Vue 的单文件组件(.vue)不一样:同一文件里叠了模板、脚本、样式等块,不是 TypeScript 默认的单一脚本文件模型。内置服务看不懂整份 .vue 的结构,所以生态里长期需要专门的 Vue 语言扩展:早期常见 Vetur,后来主流转向 Volar(现多以官方 Vue 扩展的形式出现)。装扩展之后,又容易和编辑器自带的 TypeScript 支持同时处理同一批文件:

  • 两个服务同时管 .ts / 脚本块 → 重复提示、错误标记互相冲突、跳转不稳定;
  • 文档里曾推广过「接管模式」(Takeover Mode)一类做法:在工作区禁用内置 TypeScript 扩展,让 Vue 扩展统一提供语言服务;
  • 新版本扩展也可能改为挂 TypeScript 插件、与内置服务协作,具体以当前官方说明为准。

实用印象: 提示异常时,先问「此刻是谁在当语言服务」 ------内置 tsserver、Vue 扩展,还是两者叠在一起。这和某个 npm 包的 types 字段写错,是不同层的问题。

还要和另一条命令分开:项目里跑的 tsc(或打包器)负责编译与出包;编辑期持续回答问题的是语言服务。二者都懂 TypeScript,但开关和职责不同。声明文件如何被构建出来,可另文再写;本文只谈装进 node_modules 之后,编辑器怎么接上类型。

现象:悬浮与跳转到定义

以 Zod 为例(写法随大版本会变,结构足够说明问题):

ts 复制代码
import { z } from 'zod'

const User = z.object({
  name: z.string(),
  age: z.number().optional(),
})

type User = z.infer<typeof User>

在支持 TypeScript 语言服务的编辑器里,通常可以观察到:

  1. 悬停在 z.object 上,能看到参数与返回类型相关的提示。
  2. 跳转到定义时,往往进入包内的声明文件(扩展名可能是 .d.ts,也可能是 .d.cts 等变体),而不是去「执行」那份实现文件。
  3. 若类型与实现分文件存放,跳转结果和运行时解析到的文件可以不是同一个路径。

易错点: 跳进声明文件,不等于运行时也会加载这份文件。它只说明语言服务在用这份声明做类型推断。

类型从哪读:有没有「标准顺序」

有,但标准在 TypeScript 的模块解析 里,不在 npm 安装算法里。Node 跑 import 时按自己的 exports / main 选实现文件 ;tsserver / tsc 在解析同一个包名时,会再走一套「找类型」的规则。两套规则会看同一张 package.json,选中的文件可以不同。

官方把「和实现打在同一个 npm 包里」与「另发到 @types」写成两种发布方式,见 TypeScript 的 Publishing declaration files。消费侧可以记成下面这条主路径(细节随 moduleResolution 略有差异,见后文):

flowchart TB A[import 某个包名] --> B{包是否声明 exports} B -->|有且解析模式可认| C[在对应入口下找 types 条件] B -->|无或走旧解析| D[看顶层 types 或 typings] C --> E{是否找到声明文件} D --> E E -->|是| F[用包自带的 dts] E -->|否| G[试同名 js 旁的 dts] G --> H{旁路是否仍缺失} H -->|是| I[再找 node modules 下的 at types 包] H -->|否| F

用文字把同一条路径说死:

  1. 先看这个包自己有没有交出类型。
    • 现代包:在 exports 里为该入口配置 types 条件 (与 import / require / default 并列)。TypeScript 文档建议把 types 写在条件对象里靠前的位置,避免旧工具按「对象键顺序」误伤。
    • 兼容写法:顶层 types (与历史字段 typings 同义)指向根声明文件。
    • 还有 typesVersions:按 TypeScript 版本把声明重定向到不同目录(少见,排错时知道有这层即可)。
  2. 再试实现文件旁边的声明文件。
    例如解析到 dist/index.js 时,会找同目录的 dist/index.d.ts。这就是「.js 与 .d.ts 放一起」的常见形态,不一定非要再写一遍顶层 types。
  3. 包自己始终交不出类型时,才轮到 @types/*。
    导入 lodash 若包内无可用声明,再去解析 @types/lodash。
    若包已经自带类型,一般不会再去用同名的 @types 覆盖它。

标准结论: 顺序是「包自带(exports.types / types / 旁路 .d.ts)→ 再 @types」。 不是 npm 另立的第四种安装协议,而是 TypeScript 解析器的约定。

两种常见发布方式:打进包,或交给 @types

方式 磁盘上长什么样 谁维护版本
自带类型 与 dist/*.js 一起发布 *.d.ts / *.d.cts,并用 types / exports.types 指过去 与实现同一 npm 包、同一版号(如 zod、nanoid)
DefinitelyTyped 实现包几乎无声明;另装 @types/包名 @types/* 可与实现包不同步发版

TypeScript 的建议很直白:类型若能由源码生成,就和实现发在一起;否则再考虑提到 DefinitelyTyped。消费时:

  • 自带类型:通常不必 再装 @types/该包;强行装了,也优先信包自己的声明。
  • 纯 JS 老包:才需要 @types/xxx;提示正常只说明声明文件找得到,不保证与实现主版本锁步。

对照:zod(类型与实现分叉)

撰稿时 zod 根入口大致是这种形态(字段会随版本变化,以 npm view zod exports 为准):

json 复制代码
{
  "type": "module",
  "types": "./index.d.cts",
  "exports": {
    ".": {
      "types": "./index.d.cts",
      "import": "./index.js",
      "require": "./index.cjs"
    }
  }
}

可以对照着读:

  • 编辑期: 倾向走 types → index.d.cts
  • ESM 运行时: 走 import → index.js
  • CJS 运行时: 走 require → index.cjs

本节重点: 同一条 exports["."],可以让类型和运行时落到不同文件。 这是编辑期与运行时最直观的分叉点。

对照:nanoid(体量小,结构清楚)

nanoid 常见写法是根上声明 types,并在 exports 里同时给出类型与默认实现(节选示意):

json 复制代码
{
  "types": "./index.d.ts",
  "exports": {
    ".": {
      "types": "./index.d.ts",
      "browser": "./index.browser.js",
      "default": "./index.js"
    }
  }
}

对 import { nanoid } from 'nanoid' 做跳转时,语言服务通常先落到 types 指向的声明文件;打包器或 Node 再按环境去选 default / browser 等运行时条件。

对照:自己不带类型,靠 @types/*

有些包发布物里几乎没有声明文件,生态会走 DefinitelyTyped:另装 @types/包名(例如历史上常见的 @types/lodash)。此时运行时仍来自原来的包,类型表面来自 @types/*。两套版本号可能独立演进------提示正常,不代表与实现锁步发布。

tsconfig 在这条链上管什么

tsconfig.json 不负责改写 npm 压缩包里有什么。它改的是两件事:

  1. 按哪套规则 去 node_modules 里解析模块和类型;
  2. 自动带上哪些 全局 @types(不必 import 也能用的那些)。

下面只展开和「依赖类型对不上」最相关的几项;完整选项表见 TSConfig Reference。

moduleResolution:用哪套查找算法

决定遇到 import 'zod' 时,认不认 包上的 exports、以及里面的 types 条件。

常见取值 直观效果
bundler / node16 / nodenext 按现代规则读 exports,类型条件与运行时条件可以分叉
偏旧的 node(旧文档里的 Node 解析) 更依赖顶层 types、main,以及「.js 旁边有没有 .d.ts」;对复杂 exports 可能解析不完整

所以会出现:同一个依赖,A 仓库悬浮正常、B 仓库却跳进无类型的 .js------先对比两边的 moduleResolution,再怀疑包本身。

它不 决定装哪个版本,也不 生成 .d.ts。

paths:别名,且优先于 node_modules

把导入字符串映射到项目里的路径,例如 "@/*": ["./src/*"]。解析时往往先看 paths ,命中就不再去 node_modules 找同名包。

排错含义:跳转进了仓库内部一份手写声明或旧拷贝,而不是 npm 上的包------优先查 paths(以及是否配了 baseUrl),不要先怪依赖作者。

它只影响本项目的解析,改不了别人安装你的包时怎么解析。

typeRoots:自动 @types 从哪几个目录扫

默认会从 node_modules/@types 下扫描可自动启用的全局类型包。改成别的目录后,就只从你指定的根去扫。

常见误用:自定义了 typeRoots 却忘了把 node_modules/@types 加回去,结果 @types/node 明明装着,全局的 process 等却突然消失。

它管的是自动纳入的搜索根,不是「禁止 import 某个包的类型」。

types(tsconfig 里):白名单------哪些全局 @types 自动启用

这里的 types 和 package.json 里的 types 同名不同义:

写在哪 含义
package.json 的 types 这个 npm 包的类型入口文件在哪
tsconfig 的 types 编译启动时,自动启用 typeRoots 里的哪些包名 (如 "types": ["node"] 只自动启用 @types/node)

要点:

  • 不写 types 字段:默认尽量把 typeRoots 下的包都自动纳入(具体以当前 TypeScript 行为为准)。
  • 写了 "types": ["node"]:通常表示「全局只自动启用列出的这些」;没写上的 @types/jest 等,不会再默默进全局。
  • 不影响 import 'lodash' 再去解析 @types/lodash 这类「跟着模块导入走」的类型------那是模块解析,不是全局自动纳入。

可以把 tsconfig 的 types 想成:全局环境白名单 ;把 import 想成:按需去解析那个模块的类型。

skipLibCheck:跳过声明文件深查,但不改「读哪份」

设为 true 时,跳过对 node_modules 里大量 .d.ts 的逐条严格检查,加快 tsc,也减少「依赖之间声明互相冲突」带来的报错噪音。

它不改变 解析到哪一份声明文件,也不关闭 对你自己业务代码的检查。提示仍指向同一份 .d.ts,只是编译器不再深入检查这些声明文件内部的类型错误。

本节重点: 包上的 types / exports.types 说明声明文件在哪;tsconfig 说明按什么规则去找、自动启用哪些全局 @types。 moduleResolution 管算法,paths 管别名,typeRoots + types 管全局 @types,skipLibCheck 只管检查松紧。声明如何被生成,可另文再写。

和运行时入口怎么对齐排查

同一张 package.json 上,既有给 Node / 打包器用的运行时入口,也有给语言服务用的类型入口,读的人不同:

问题 优先看 典型症状
运行时报找不到模块 / 加载错文件 exports 的 import/require/default,以及 files 构建或 Node 报错
能跑,但没有提示、跳进了 .js 且毫无类型 包是否自带 types / exports.types;旁路 .d.ts 是否打进包;缺则是否安装 @types/*;moduleResolution 是否认 exports 编辑器「不智能」
提示有,运行行为却对不上 声明是否过时、是否改错了本地 .d.ts、是否装错了主版本 「类型说可以,运行却炸」
只有某个项目提示不对 该项目的 moduleResolution / paths / typeRoots,以及是否叠了第二个语言服务 换仓库或换工作区才复现

排查口诀: 跑不起来 → 先查运行时入口与安装清单;提示不对 → 先查包自带类型,再查 @types,最后才怀疑 tsconfig 解析模式。

三种容易混的错觉

  1. 有类型 ≠ 有完整运行时实现

    声明文件可以描述一个函数;真正执行仍取决于实现文件是否存在、是否被装进包。

  2. 改本地 node_modules 里的 .d.ts ≠ 改了依赖行为

    最多影响当前编辑器与类型检查;一刷新安装或换机器就回去了,也不会让线上用户自动获得同样的修改。

  3. 源码仓库里的注释 / JSDoc,未必出现在已安装包里

    若发布清单没带上对应声明或被裁掉,编辑器只能基于装到的文件提示。对照时仍以 node_modules 里的内容为准,而不是只看 GitHub 默认分支。

公开包上也能看到「类型只出现在 exports 条件里、顶层 types 很薄或另有安排」的形态。迭代快的 SDK 尤其常见:工具链若只认旧字段,观感会和现代语言服务不一致。排查时打开对方的 package.json,看类型条件实际指向哪里,比凭包名猜测更稳。

类型契约要点

  1. 链路: 编辑器经 LSP(语言服务器协议)问到语言服务,再落到 .d.ts / 可读的源码类型;VS Code 等环境里,这份服务常常是内置的 tsserver。
  2. 查找顺序: 包自带(exports.types / 顶层 types / 与 .js 旁路的 .d.ts)→ 再 @types/*;标准在 TypeScript 模块解析里。
  3. tsconfig: moduleResolution 等决定怎么找,不改写 npm 包内容;tsconfig 的 types 与 package.json 的 types 同名异义。
  4. 边界: 类型入口解释「提示与检查」;import / require / default 等解释「执行哪份文件」。框架扩展若与内置服务冲突,先理顺「谁在回答」。

版本号与发行标签如何决定装到哪一版依赖,以及安装后为何能在脚本里直接跑命令行工具,不在本文范围。

参考与数据源

相关推荐
Patrick_Wilson1 小时前
npm 的 bin 如何接到 npm run 与 npx
npm·node.js·前端工程化
Patrick_Wilson2 小时前
import 之后发生了什么:package.json、解析与安装产物
npm·node.js·前端工程化
Patrick_Wilson2 小时前
npm 里 SemVer 和 dist-tag 如何决定装哪一版
npm·node.js·前端工程化
oldnicke16 小时前
理解 TRAE 的 Agent + IDE 思路:用一个 Markdown 检查器拆解完整交付
typescript
钱栈up16 小时前
GitHub 热榜实测|REA:用 Agent 逆向一切
typescript·开源·github
keyipatience17 小时前
TypeScript学习(1)
前端·javascript·学习·typescript·html·css3
_弹琴不走心的程序猿21 小时前
里程碑2 工程化建设
前端工程化
zttbee1 天前
npm install卡顿
前端·npm·node.js
RuoyiOffice1 天前
Vue3+TypeScript 企业管理平台统一UI:新增一个模块,为什么还要重新调样式?
spring boot·typescript·vue3·组件化开发·企业管理软件·ruoyi office·统一ui