跳到主内容

TS 第三方包无类型?手写 .d.ts 兜底实战 ​

问题背景:能运行的包,为什么过不了类型检查 ​

在 Vue3、VitePress 或普通 Vite 项目中引入历史悠久的 npm 包时,经常遇到下面的报错:

text
Could not find a declaration file for module 'legacy-utils'.
'/node_modules/legacy-utils/index.js' implicitly has an 'any' type.
TS7016: Try `npm i --save-dev @types/legacy-utils` if it exists.

这不是运行时错误,而是 TypeScript 找不到模块对应的类型声明。常见原因有三类:

  1. 包本身没有携带 types 或 typings 字段;
  2. DefinitelyTyped 中不存在 @types/包名;
  3. 包导出了新 API,但内置类型声明没有同步更新。

如果项目开启了 noImplicitAny,CI 中的 vue-tsc、tsc --noEmit 会直接失败。此时可以临时关闭严格检查,但代价是丢失调用提示和参数校验。更稳妥的方式是为第三方包补一份本地 .d.ts 类型声明。

方案对比:三档兜底策略怎么选 ​

方案适用场景优点局限
安装 @types/pkg社区已有声明成本最低、类型完整老旧包可能没有
本地 declare module快速补齐无类型包零侵入、可渐进完善维护责任在本项目
给上游提 PR长期维护的活跃包所有用户受益周期不可控

实际工程中推荐组合使用:先查包内类型与 @types,没有再用本地声明兜底;如果包仍活跃,再把成熟声明贡献给上游。

类型声明加载流程 ​

TypeScript 会根据包元数据和 tsconfig.json 的配置寻找声明;理解这条链路后,很多“明明写了 d.ts 却不生效”的问题都能快速定位。

import 第三方包读取 package.json查找 types 字段包内 .d.ts查找 @types/pkgDefinitelyTyped加载本地声明types/shims.d.ts仍失败则 TS7016

实操一:最小 declare module 消除隐式 any ​

假设项目安装了一个虚构但常见风格的包 legacy-utils,它的 JavaScript 源码如下:

javascript
// node_modules/legacy-utils/index.js
export function formatPrice(value, symbol = '¥') {
  return `${symbol}${Number(value).toFixed(2)}`;
}

export function debounce(fn, wait) {
  let timer = null;
  return function (...args) {
    clearTimeout(timer);
    timer = setTimeout(() => fn.apply(this, args), wait);
  };
}

export const VERSION = '1.0.0';

在项目中新建 src/types/legacy-utils.d.ts:

typescript
// 为没有类型声明的第三方包提供兜底类型
declare module 'legacy-utils' {
  /** 格式化为带货币符号的价格字符串 */
  export function formatPrice(value: number | string, symbol?: string): string;

  /** 简单防抖函数 */
  export function debounce<Args extends unknown[]>(
    fn: (...args: Args) => void,
    wait?: number,
  ): (...args: Args) => void;

  export const VERSION: string;
}

然后确保 tsconfig.json 能包含这个文件:

json
{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "strict": true,
    "noImplicitAny": true,
    "skipLibCheck": true
  },
  "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue"]
}

在 Vue3 组件中即可获得参数提示和返回值校验:

vue
<script setup lang="ts">
import { ref } from 'vue';
import { formatPrice, debounce } from 'legacy-utils';

const keyword = ref('');
const priceText = ref(formatPrice(12)); // string

const search = debounce((value: string) => {
  console.log('搜索:', value);
}, 300);

function onInput(event: Event) {
  keyword.value = (event.target as HTMLInputElement).value;
  search(keyword.value);
}
</script>

<template>
  <input :value="keyword" @input="onInput" />
  <p>{{ priceText }}</p>
</template>

如果暂时无法准确描述参数,也不要一上来就写 declare module 'legacy-utils';。这会把整个模块变成 any,虽然能消除 TS7016,但所有调用点都会失去类型约束。建议先给核心函数补类型,再逐步收敛。

实操二:CommonJS 导出与类模块声明 ​

不少老包使用 module.exports 导出函数,同时在函数上挂载属性:

javascript
// node_modules/old-reporter/index.js
function Reporter(name) {
  this.name = name;
}

Reporter.prototype.send = function (event, payload) {
  console.log(this.name, event, payload);
};

Reporter.create = function (name) {
  return new Reporter(name);
};

module.exports = Reporter;

对应声明可以使用 export = 配合 esModuleInterop:

typescript
// src/types/old-reporter.d.ts
declare module 'old-reporter' {
  interface ReporterPayload {
    [key: string]: unknown;
  }

  class Reporter {
    name: string;
    constructor(name: string);
    send(event: string, payload?: ReporterPayload): void;
    static create(name: string): Reporter;
  }

  // 匹配 CommonJS 的 module.exports = Reporter
  export = Reporter;
}

tsconfig.json 中建议开启:

json
{
  "compilerOptions": {
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true
  }
}

业务代码即可使用默认导入风格:

typescript
import Reporter from 'old-reporter';

const reporter = Reporter.create('vitepress-site');
reporter.send('page_view', { path: '/guide/typescript' });

如果没有开启 esModuleInterop,则要写成 import Reporter = require('old-reporter');。现代 Vite 项目通常建议开启该选项,以减少导入风格差异。

实操三:declare module 做模块增强 ​

还有一种场景不是“完全无类型”,而是类型存在但缺少新字段。例如给 Vue 组件实例或第三方对象追加全局属性时,应使用模块增强而不是重复声明整个包。

typescript
// src/types/global-properties.d.ts
import type { App } from 'vue';

interface UserInfo {
  id: number;
  name: string;
}

declare module 'vue' {
  interface ComponentCustomProperties {
    $app: App;
    $currentUser: UserInfo;
  }
}

export {};

在组件中访问全局属性就会得到类型提示:

vue
<script setup lang="ts">
import { getCurrentInstance } from 'vue';

const instance = getCurrentInstance();
const userName = instance?.appContext.config.globalProperties.$currentUser.name;
console.log(userName);
</script>

关键点是:模块增强文件通常需要顶层 import 或 export {},让文件成为模块;而完全无类型包的通配声明才使用不带导入导出的环境模块写法。

声明文件放置与工程化建议 ​

推荐目录结构如下:

text
src/
├─ types/
│  ├─ legacy-utils.d.ts      # 第三方无类型包兜底
│  ├─ old-reporter.d.ts      # CommonJS 包声明
│  └─ global-properties.d.ts # Vue 模块增强
├─ App.vue
└─ main.ts
tsconfig.json

如果是 VitePress 项目,注意文档目录、.vitepress 配置目录和主题目录可能使用不同的 tsconfig。最稳妥的方式是在根 tsconfig.json 中统一 include 这些目录,或让配置文件继承根配置:

json
{
  "extends": "./tsconfig.json",
  "include": [
    ".vitepress/**/*.ts",
    ".vitepress/**/*.vue",
    "../src/types/**/*.d.ts"
  ]
}

避坑总结:类型声明不生效时优先排查这些点 ​

1. .d.ts 文件没有被 TypeScript 纳入编译 ​

常见错误现象是文件路径、导出写法都正确,但 TS7016 依旧存在。原因通常是 include 只写了 src/**/*.ts,没有包含 src/**/*.d.ts,或者声明文件放在了未被引用的 typings 目录。修正方法是显式加入声明文件:

json
{
  "include": ["src/**/*.ts", "src/**/*.d.ts"]
}

也可以使用 typeRoots 或 types,但它们主要面向 node_modules/@types 和全局类型入口,不建议把零散的业务兜底声明都塞进 typeRoots。

2. 模块名与导入路径不一致 ​

例如导入写的是 @vendor/utils/esm/browser,声明却写成 declare module '@vendor/utils',子路径仍然没有类型。修正方法是严格按照实际导入说明符声明:

typescript
declare module '@vendor/utils/esm/browser' {
  export function track(event: string): void;
}

如果包使用 exports 导出多个子路径,也要分别为公开入口补充声明,不要假设类型会自动从主入口透传。

3. 与包内置类型或 @types 发生重复声明 ​

常见报错是 Duplicate identifier、Cannot redeclare block-scoped variable,或者本地声明完全不生效。若包后来升级并自带类型,本地 declare module 'pkg' 可能与官方声明合并甚至冲突。修正方法是删除整包兜底声明,改为模块增强;若确实要覆盖错误类型,优先给上游提 issue,而不是长期维护分叉版本。

4. 把模块声明误写成全局脚本 ​

在 .d.ts 中随意写顶层 import 后,declare module 'pkg' 的作用域会发生变化;反过来,模块增强文件没有任何导入导出,又可能被当成全局声明,导致接口无法与原模块合并。判断标准很简单:补第三方无类型包用环境模块声明;扩展已有模块用模块增强,并保留顶层 import/export。

5. 滥用 any 导致兜底声明失去价值 ​

declare module 'legacy-utils'; 适合临时止血,但不应长期存在。它会让错误参数、错误返回值全部通过编译。更好的做法是先定义核心函数签名,再结合真实调用逐步补充联合类型、泛型和可选参数。类型声明不是为了“骗过编译器”,而是把 JavaScript 包的真实契约固化下来。

结语 ​

手写 .d.ts 是前端工程化中非常实用的兜底能力:用 declare module 补齐无类型包,用 export = 适配 CommonJS,用模块增强扩展已有类型。配合正确的 tsconfig.include,既能让 vue-tsc 和 CI 恢复稳定,也能为业务代码保留可靠的智能提示。

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。