TypeScript 模块系统的隐藏陷阱:为什么你的全局声明失效了?

17 阅读3分钟

一、遇到的情况

最近在开发一个基于 React + TypeScript + Vite 的微前端项目时,遇到了一个奇怪的问题:

src/vite-env.d.ts 文件中,我明明已经声明了全局 Window 接口的扩展:

/// <reference types="vite/client" />
declare global {
  interface Window {
    __POWERED_BY_WUJIE__?: boolean;
    __WUJIE?: any;
    isMicroFrontend?: () => boolean;
  }
}

但在代码中使用 window.__POWERED_BY_WUJIE__ 时,TypeScript 仍然报错:

Property 'POWERED_BY_WUJIE' does not exist on type 'Window & typeof globalThis'.

检查了 tsconfig.json 配置,确保 include 字段包含了 src 目录;重新编译了项目,甚至重启了 IDE,但问题依然存在。

二、分析问题

带着这个疑惑,我开始深入研究 TypeScript 的模块系统和声明文件机制。

1. 项目中其他声明文件的影响

首先,我发现项目根目录下存在一个 types/auto-import.d.ts 文件,内容如下:

/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
// biome-ignore lint: disable
export {}
declare global {
  const $at: typeof import('i18n-auto-extractor')['$at']
}

这个文件有一个关键特征:它包含 export {} 语句,这意味着它是一个模块类型的声明文件

2. TypeScript 的两种声明文件类型

TypeScript 中有两种主要的声明文件类型,它们的行为有很大不同:

a) 全局声明文件

  • 不包含任何 importexport 语句
  • 声明的类型直接添加到全局命名空间
  • 适用于传统的全局库声明

b) 模块声明文件

  • 包含 importexport 语句(即使是空导出)
  • 声明的类型默认只在模块内部可见
  • 必须使用 declare global 才能将类型添加到全局命名空间
  • 适用于现代模块化项目

3. 冲突的根源

问题的核心在于:

  • vite-env.d.ts 最初是一个全局声明文件(没有 import/export
  • auto-import.d.ts 是一个模块声明文件(有 export {}

当项目中同时存在这两种类型的声明文件时,TypeScript 的模块解析器会优先处理模块声明文件,而全局声明文件的类型可能无法正确传播到模块环境中。

换句话说,TypeScript 无法正确合并不同类型声明文件中的全局声明,导致 vite-env.d.ts 中的 Window 接口扩展被忽略。

三、解决方案

找到问题根源后,解决方案就很清晰了:

vite-env.d.ts 转换为模块声明文件,然后在模块内部使用 declare global 来声明全局类型。

具体修改如下:

/// <reference types="vite/client" />
export {} // 添加这行将文件转换为模块
declare global {
  interface Window {
    __POWERED_BY_WUJIE__?: boolean;
    __WUJIE?: any;
    isMicroFrontend?: () => boolean;
  }
}

仅仅添加一行 export {},问题就解决了!TypeScript 现在能正确识别 window.__POWERED_BY_WUJIE__ 等全局属性了。

四、总结:原因与原理

发生原因

这个问题的本质是 TypeScript 模块系统的一个常见 "陷阱":

  1. 模块隔离原则:模块声明文件创建了一个独立的作用域,与全局声明文件的作用域不同
  2. 声明合并规则:不同类型的声明文件(全局 vs 模块)中的全局声明不会自动合并

核心原理

当你添加 export {} 后,发生了这些变化:

  1. vite-env.d.ts全局声明文件变为模块声明文件
  2. TypeScript 现在将其视为与 auto-import.d.ts 同等的模块
  3. declare global 语句明确告诉 TypeScript:"在这个模块内部,我要扩展全局命名空间"
  4. 所有模块声明文件中的全局扩展会被 TypeScript 正确合并

TypeScript 的模块系统很强大,但也存在一些容易被忽略的细节。理解全局声明和模块声明的区别,以及它们之间的相互作用,是解决这类问题的关键。

记住这个简单的规则:

当需要在模块环境中声明全局类型时,将文件转换为模块,然后使用 declare global