返回

Vue globalThis 文件作用域类型提示:告别 declare global 冲突

vue.js

Vue 文件中为 globalThis 添加文件作用域 TypeScript 类型提示

在 Vue 项目开发中,有时我们想利用 TypeScript 给 globalThis 添加一些类型信息,主要是为了增强开发体验,获得更好的代码提示和类型检查。但问题来了:如果直接在不同的 .vue 文件里使用 declare global 来声明 globalThis 上的属性,这些声明会互相冲突,污染全局类型空间。

具体来说,就像下面这样:

场景

假设有两个 Vue 文件,我们希望在各自的文件内部为 globalThis 声明不同的类型,并且这些类型声明只在当前文件生效,互不干扰。项目配置中,TypeScript 主要用于提供类型提示,并不参与最终的编译检查。

比如在 File1.vue 里,我们想让 globalThis.arrayOnestring[] 类型:

<!-- File1.vue -->
<script setup lang="ts">
// 理想情况下的声明(但会全局污染)
/*
declare global {
    var arrayOne: string[]
    var arrayTwo: string[]
}
*/

// 在这里,如果 File2.vue 也声明了 arrayOne,TS 会报错或类型混乱
// globalThis.arrayOne = ['a', 'b'] // 类型可能不符合预期

// 希望 globalThis.arrayThree 在这里没有类型提示 (或为 any)
// globalThis.arrayThree --> (property) arrayThree: any // ts-plugin(7017) - 期望效果
</script>

<template>
</template>

而在 File2.vue 里,我们又希望 globalThis.arrayOnenumber[] 类型,同时还多一个 arrayThree 属性:

<!-- File2.vue -->
<script setup lang="ts">
// 理想情况下的声明(但会全局污染)
/*
declare global {
    var arrayOne: number[]
    var arrayThree: number[]
}
*/

// 在这里,类型会与 File1.vue 冲突
// globalThis.arrayOne = [1, 2] // 类型可能不符合预期

// 希望 globalThis.arrayTwo 在这里没有类型提示 (或为 any)
// globalThis.arrayTwo --> (property) arrayTwo: any // ts-plugin(7017) - 期望效果
</script>

<template>
</template>

这种直接使用 declare global 的方式行不通。它会合并所有 declare global 块中的声明,如果不同文件对同一全局变量声明了不同的类型,TypeScript 编译器(以及 IDE 的语言服务)就会报告错误或者导致类型提示混乱。这显然不是我们想要的结果。我们需要一种方法,让 globalThis 的类型提示局限在单个文件内部。

问题根源分析:declare global 的特性

TypeScript 中的 declare global 块是专门用来扩展全局作用域类型的。它的设计意图就是影响整个项目。当你写下 declare global { var someVar: SomeType; },你实际上是在告诉 TypeScript:“嘿,整个项目都知道,全局作用域(包括 windowglobalThis)下现在有一个叫做 someVar 的变量,它的类型是 SomeType”。

正因为这种全局性,如果在多个文件里用 declare global 对同一个变量(比如 globalThis.arrayOne)做出不同的类型定义,就会产生冲突。TypeScript 不知道该以哪个为准,自然就会报错或者表现异常。这与 JavaScript 运行时变量可以随意改变类型不同,TypeScript 在类型检查层面要求定义是明确且一致的。

我们要的效果是“文件级”的 globalThis 类型增强,而 declare global 提供的是“项目级”的。此路不通。

解决方案:利用局部接口和类型断言

既然不能全局声明,那就把类型定义限制在文件内部。我们可以通过在每个 .vue 文件内部定义一个 局部接口 ,这个接口了 当前文件 希望 globalThis 拥有的额外属性和类型,然后使用 类型断言 (Type Assertion)globalThis “扮演”成这个带有额外类型的角色。

这种方法的核心思路是:

  1. 不真正修改全局类型定义 :我们不去碰 declare global
  2. 在文件内部创建“替身”类型 :定义一个只在当前文件可见的接口,本文件特有的 globalThis 结构。
  3. 告诉 TypeScript “按这个类型来理解” :通过类型断言 (as),让 TypeScript 在当前文件作用域内,将 globalThis 视为我们定义的那个包含额外属性的局部接口类型。

下面是具体的操作步骤和代码示例:

步骤一:在每个 Vue 文件中定义局部接口

在每个需要特殊 globalThis 类型的 .vue 文件的 <script setup lang="ts"> 块内,定义一个接口。这个接口可以只包含你希望在本文件中添加到 globalThis 的属性,或者为了更严谨,可以继承自 typeof globalThis 并添加新属性。

File1.vue

<script setup lang="ts">
// 1. 定义一个只在 File1.vue 内部有效的接口
// 这个接口描述了我们期望在这个文件里 globalThis 额外拥有的类型
interface File1GlobalAugmentation {
  arrayOne: string[];
  arrayTwo: string[];
}

// 2. (可选但推荐) 定义一个结合了原始 globalThis 和我们补充类型的完整类型
type File1ScopedGlobalThis = typeof globalThis & File1GlobalAugmentation;

// 3. 使用类型断言,创建一个当前文件作用域内带有特定类型的 globalThis 引用
//    注意:这不会真的改变 globalThis,只是改变了 TypeScript 对它的看法(在本文件内)
const fileScopedGlobal = globalThis as File1ScopedGlobalThis;

// --- 现在,可以通过 fileScopedGlobal 来访问带有类型提示的属性 ---

// 正确提示: fileScopedGlobal.arrayOne 是 string[]
fileScopedGlobal.arrayOne = ["hello", "world"];
// fileScopedGlobal.arrayOne = [1, 2]; // 这里会报类型错误,符合预期

// 正确提示: fileScopedGlobal.arrayTwo 是 string[]
fileScopedGlobal.arrayTwo = ["foo", "bar"];

// --- 检查隔离性 ---

// 访问 File2.vue 中定义的属性 (arrayThree)
// 因为 File1ScopedGlobalThis 没有定义 arrayThree,TypeScript 会报错或者提示不存在
// fileScopedGlobal.arrayThree; // 类型错误:Property 'arrayThree' does not exist on type 'File1ScopedGlobalThis'. ts(2339)
// 或者直接访问原始 globalThis
// globalThis.arrayThree; // 通常会是 any 类型,或者如果没有在其他地方用 declare global 声明,TS Language Service 插件可能会提示未知属性 ts-plugin(7017)
console.log(globalThis.arrayThree); // 运行时实际可能有值,但 TS 类型层面无感知

// --- 为什么不直接用 (globalThis as File1ScopedGlobalThis).arrayOne? ---
// 每次访问都写断言太麻烦了。创建一个带类型的局部常量更方便。
</script>

<template>
</template>

File2.vue

<script setup lang="ts">
// 1. 定义只在 File2.vue 内部有效的接口
interface File2GlobalAugmentation {
  arrayOne: number[]; // 注意,这里的 arrayOne 类型与 File1.vue 不同
  arrayThree: number[];
}

// 2. (可选但推荐) 定义结合后的类型
type File2ScopedGlobalThis = typeof globalThis & File2GlobalAugmentation;

// 3. 使用类型断言创建局部引用
const fileScopedGlobal = globalThis as File2ScopedGlobalThis;

// --- 使用带有类型提示的局部引用 ---

// 正确提示: fileScopedGlobal.arrayOne 是 number[]
fileScopedGlobal.arrayOne = [10, 20];
// fileScopedGlobal.arrayOne = ["a", "b"]; // 类型错误,符合预期

// 正确提示: fileScopedGlobal.arrayThree 是 number[]
fileScopedGlobal.arrayThree = [30, 40];

// --- 检查隔离性 ---

// 访问 File1.vue 中定义的属性 (arrayTwo)
// fileScopedGlobal.arrayTwo; // 类型错误:Property 'arrayTwo' does not exist on type 'File2ScopedGlobalThis'. ts(2339)
// 或者直接访问原始 globalThis
// globalThis.arrayTwo; // 同样是 any 或未知属性提示
console.log(globalThis.arrayTwo); // 运行时实际可能有值,但 TS 类型层面无感知
</script>

<template>
</template>

步骤二:通过带类型的局部变量访问

如上代码所示,定义好局部接口和类型别名后,创建一个局部常量 fileScopedGlobal,它通过 as 类型断言获得了我们期望的类型。之后,在这个文件内部,始终通过 fileScopedGlobal 这个变量来访问那些“被增强”了类型的 globalThis 属性。

原理和作用解释

  • 局部接口/类型 : interface File1GlobalAugmentationtype File1ScopedGlobalThis 只存在于 File1.vue<script> 块作用域内。它们不会影响 File2.vue 或项目中的任何其他文件。同样,File2 的定义也只在 File2.vue 内有效。
  • 类型断言 (as) : globalThis as File1ScopedGlobalThis 这行代码并没有在运行时改变 globalThis 对象本身。它只是对 TypeScript 编译器/语言服务的一个指令:“在这个上下文中,请把 globalThis 当作 File1ScopedGlobalThis 类型来处理”。这使得 TypeScript 能够基于 File1ScopedGlobalThis 的定义来提供代码提示和类型检查。
  • 隔离效果 : 因为类型定义是局部的,并且类型信息的应用也是通过局部变量 fileScopedGlobal 进行的,File1.vue 中的类型规则(如 arrayOnestring[])不会泄露到 File2.vue;反之亦然。每个文件都可以独立定义自己对 globalThis 的“看法”,而不会互相打架。

安全建议与注意事项

  1. 运行时与类型系统分离 :务必记住,这种方法只解决了 类型提示 层面的隔离。它 不保证 运行时 globalThis 上真的存在这些属性,或者属性的实际类型与你断言的一致。如果运行时代码没有正确设置 globalThis.arrayOne,那么即使类型提示正确,fileScopedGlobal.arrayOne[0] 这样的访问仍可能导致运行时错误 (e.g., TypeError: Cannot read properties of undefined (reading '0'))。

    • 谁负责设置 globalThis 属性? 你的应用程序逻辑需要负责在某个地方实际地给 globalThis.arrayOne, globalThis.arrayTwo, globalThis.arrayThree 赋值。这个类型技巧本身不执行赋值操作。
    • 确保类型与实际值匹配 :尽管 TypeScript 在这个文件内相信了你的断言,但最好还是确保赋给 globalThis 属性的值确实符合你在接口中声明的类型,否则可能隐藏潜在的运行时 bug。
  2. 避免直接访问原始 globalThis :在使用了 fileScopedGlobal 这种带类型引用的文件内部,尽量避免再直接通过 globalThis.xxx 的方式访问那些你已经做了特殊类型定义的属性。直接访问 globalThis.arrayOne 可能得不到期望的精确类型提示(可能还是 any 或冲突类型),从而失去了使用这个技巧的意义。坚持使用 fileScopedGlobal.arrayOne

  3. 代码清晰度 :虽然这种方法能解决问题,但引入了额外的接口定义和类型断言。如果 globalThis 上附加的属性非常多,或者这种模式在项目中广泛使用,需要考虑其对代码可读性的影响。注释清楚为何使用这种模式会很有帮助。

进阶使用技巧:创建辅助类型

如果这种模式用得多,每次都写 typeof globalThis & MyLocalInterface 可能有点繁琐。可以创建一个通用的辅助类型:

// 可放在一个共享的 .ts 文件或直接在 .vue 文件顶部定义
type ScopedGlobal<T> = typeof globalThis & T;

// --- 在 File1.vue 使用 ---
interface File1GlobalAugmentation {
  arrayOne: string[];
  arrayTwo: string[];
}
const fileScopedGlobal = globalThis as ScopedGlobal<File1GlobalAugmentation>;
fileScopedGlobal.arrayOne.push('!'); // 提示 string[] 方法

// --- 在 File2.vue 使用 ---
interface File2GlobalAugmentation {
  arrayOne: number[];
  arrayThree: number[];
}
const fileScopedGlobal = globalThis as ScopedGlobal<File2GlobalAugmentation>;
fileScopedGlobal.arrayOne.push(99); // 提示 number[] 方法

这样能稍微简化一点代码。

总的来说,通过定义局部接口并结合类型断言,我们可以在不污染全局类型定义的前提下,为单个 Vue 文件提供定制化的 globalThis 类型提示,满足了在文件范围内隔离类型定义的需求。只是要牢记,这是一种“欺骗”TypeScript 以获得更好开发体验的方式,运行时的安全性仍需开发者自行保障。