一、遇到的情况
最近在开发一个基于 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) 全局声明文件
- 不包含任何
import或export语句 - 声明的类型直接添加到全局命名空间
- 适用于传统的全局库声明
b) 模块声明文件
- 包含
import或export语句(即使是空导出) - 声明的类型默认只在模块内部可见
- 必须使用
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 模块系统的一个常见 "陷阱":
- 模块隔离原则:模块声明文件创建了一个独立的作用域,与全局声明文件的作用域不同
- 声明合并规则:不同类型的声明文件(全局 vs 模块)中的全局声明不会自动合并
核心原理
当你添加 export {} 后,发生了这些变化:
vite-env.d.ts从全局声明文件变为模块声明文件- TypeScript 现在将其视为与
auto-import.d.ts同等的模块 declare global语句明确告诉 TypeScript:"在这个模块内部,我要扩展全局命名空间"- 所有模块声明文件中的全局扩展会被 TypeScript 正确合并
TypeScript 的模块系统很强大,但也存在一些容易被忽略的细节。理解全局声明和模块声明的区别,以及它们之间的相互作用,是解决这类问题的关键。
记住这个简单的规则:
当需要在模块环境中声明全局类型时,将文件转换为模块,然后使用 declare global。