《TypeScript编程实战》15.1 Vite 与 TS 集成

Vite 已成为 TS 前端项目的默认构建工具,但它对 TypeScript 的态度常被误解。本节先讲清开发阶段的「按需编译」与构建阶段的「Rollup 打包」为何是两套引擎;再划清 tsconfig 与 Vite 的职责边界;最后落到 vite.config.ts 的类型写法、import.meta.env 与 import.meta.glob 的类型化,以及六个高频坑。

本节目标:理解 Vite 在开发与构建两个阶段为何使用不同的编译引擎,划清 tsconfig 与 Vite 的职责边界,写出类型安全的 vite.config.ts 与环境变量访问方式,并避开「Vite 不报类型错误」这类高频误解。

15.1 Vite 与 TS 集成

很多人第一次用 Vite 都会问同一个问题:我明明把类型写错了,为什么 vite dev 一点反应都没有?

这不是配置漏了,而是设计如此——Vite 根本不做类型检查。理解这一点,是理解 Vite 与 TypeScript 集成的全部起点。本节先把 Vite 的两套引擎讲清楚,再谈 tsconfig 的分工,最后落到配置文件与环境变量的类型化。

15.1.1 两套引擎:dev 与 build

Vite 在开发和生产两个阶段用的是完全不同的机制:

阶段引擎工作方式产物
devesbuild 转译 + 原生 ESM按需编译,浏览器请求哪个模块就编哪个内存中的模块图
buildRollup(Vite 7 起可换 Rolldown)全量打包,做 tree-shaking 与压缩dist/assets/*.js

开发阶段的核心是不做打包。浏览器原生支持 ESM,Vite 只负责把 .ts 转成 .js,import 语句原样保留:

// 源码 src/main.ts
import { createApp } from './app';
import type { Config } from './types';

createApp({ mode: 'dev' } satisfies Config);

浏览器实际收到的响应大致是这样:

// Vite dev server 响应(简化)
import { createApp } from '/src/app.ts';

createApp({ mode: 'dev' });

注意两处变化:import type 整行被删除(类型没有运行时存在),.ts 扩展名保留(由 dev server 拦截处理)。因为只处理被请求到的文件,一个几千模块的项目启动只要几百毫秒——它压根没编译全部代码。

生产构建则是另一套逻辑:Rollup 从入口出发做完整静态分析,把模块图压成少量 chunk,再交给压缩器。这也解释了为什么「dev 下正常、build 后报错」这类问题真实存在——两个阶段的分析深度完全不同。构建管线的细节见站内 Vite 的 Rollup 构建管线 。

15.1.2 为什么 Vite 不报类型错误

Vite 只做转译(transpile),不做类型检查(type check)。转译是逐文件的语法级改写,把 .ts 剥成 .js;类型检查需要跨文件构建完整类型图,代价高得多,不适合放在每次热更新里。

所以类型错误的正确捕获方式是单独跑 tsc:

{
  "scripts": {
    "dev": "vite",
    "build": "tsc -b --noEmit && vite build",
    "typecheck": "tsc -b --noEmit --watch"
  }
}

vite build 本身不会因为类型错误而失败。如果 CI 里只有 vite build,一个类型错误可以一路发布到线上——这是最典型的「类型安全幻觉」。

一个真实的反例:把 user.name 写成 user.nmae,构建照样通过:

$ vite build
vite v7.1.0 building for production...
✓ 42 modules transformed.
dist/assets/index-Cq3x8K.js   182.44 kB │ gzip: 58.21 kB
✓ built in 1.24s

产物里 nmae 静默变成 undefined,直到线上出现一片空白页。加上类型检查后才会立刻暴露:

$ tsc -b --noEmit
src/user.ts:12:18 - error TS2551: Property 'nmae' does not exist on type 'User'.
  Did you mean 'name'?

开发期想要即时反馈,可以装 vite-plugin-checker,它把 tsc 的类型诊断叠加到浏览器 overlay 上,同时保留 esbuild 的速度:

import { defineConfig } from 'vite';
import checker from 'vite-plugin-checker';

export default defineConfig({
  plugins: [checker({ typescript: { tsconfigPath: './tsconfig.app.json' } })],
});

15.1.3 tsconfig 与 Vite 的分工

既然转译由 esbuild 负责,tsconfig 里与「生成代码」有关的选项(如 target、jsx)其实不再被 Vite 读取。真正影响 Vite 行为、必须配对的是下面几个:

选项为什么 Vite 需要它
isolatedModules: trueesbuild 逐文件转译,没有跨文件类型信息
verbatimModuleSyntax: true强制 import type,避免类型被当成值导入
moduleResolution: "bundler"允许无扩展名导入,并尊重 exports 字段
noEmit: true类型检查交给 tsc、产物交给 Vite,二者不重叠

isolatedModules 是最容易踩的一个。esbuild 单文件转译时无法判断 export { Foo } 里的 Foo 是类型还是值,于是 TS 直接报错:

src/api.ts:3:10 - error TS1205: Re-exporting a type when
'isolatedModules' is enabled requires using 'export type'.

正确写法是显式标注:

// 错误:esbuild 不知道 ApiResult 是不是类型
export { ApiResult } from './types';

// 正确:用 export type 明确告知
export type { ApiResult } from './types';

verbatimModuleSyntax 更进一步:它要求所有纯类型导入都写成 import type,否则报 TS1484。这条规则看着啰嗦,却能在编译期就消灭「类型导入被误当成运行时导入、结果运行时取到 undefined」这类问题。

moduleResolution: "bundler" 是 TS 5.0 专为打包器加的解析模式。老的 "node" 模式会忽略 package.json 的 exports 字段,导致部分包的类型解析失败。如果你看到

error TS2307: Cannot find module 'some-esm-only-pkg' or its corresponding type declarations.

而该包明明装了,先检查这里。模块解析的完整对照见站内 TypeScript 模块解析与 ESM/CJS 。

15.1.4 vite.config.ts 的类型化写法

Vite 的配置文件本身就是 TS,用 defineConfig 包裹能拿到完整补全与校验:

import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { fileURLToPath, URL } from 'node:url';

export default defineConfig({
  plugins: [react()],
  resolve: {
    alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) },
  },
  build: { target: 'es2022', sourcemap: true },
});

defineConfig 的价值不只是补全。它接受函数形式,可以按命令与环境返回不同配置,并且返回值类型会校验字段合法性,写错字段名立刻报错:

export default defineConfig(({ command, mode }) => ({
  define: { __DEV__: JSON.stringify(command === 'serve') },
  build: { minify: mode === 'production' ? 'esbuild' : false },
}));

自定义插件时,用 Plugin 标注返回类型,避免被推断成 any 而失去检查:

import type { Plugin } from 'vite';

interface BannerOptions {
  text: string;
}

function bannerPlugin(options: BannerOptions): Plugin {
  return {
    name: 'banner',
    apply: 'build',
    transformIndexHtml(html) {
      return html.replace('</head>', `<!-- ${options.text} --></head>`);
    },
  };
}

plugins 数组的元素类型是 PluginOption,它允许嵌套数组与假值——因此可以写条件插件,false 会被自动忽略:

export default defineConfig(({ mode }) => ({
  plugins: [react(), mode === 'test' && checker()],
}));

15.1.5 环境变量与 import.meta.env

Vite 用 import.meta.env 取代 process.env,并默认暴露 MODE、BASE_URL、DEV、PROD 四个内置字段。自定义变量必须带 VITE_ 前缀才会被注入:

; .env.production
VITE_API_BASE=https://api.example.com
VITE_FEATURE_CHAT=true
SECRET_KEY=never-exposed   ; 无前缀,不会进入产物

类型上它是索引签名,需要自己补声明。在 src/vite-env.d.ts 里做接口合并即可:

/// <reference types="vite/client" />

interface ImportMetaEnv {
  readonly VITE_API_BASE: string;
  readonly VITE_FEATURE_CHAT: string;
}

interface ImportMeta {
  readonly env: ImportMetaEnv;
}

readonly 不是装饰:环境变量在构建期就被字面量替换掉了,运行期根本改不了,类型上也不该允许改。如果变量是可选注入(比如只在特定 CI 环境存在),写成可选属性并在读取处收窄:

interface ImportMetaEnv {
  readonly VITE_SENTRY_DSN?: string;
}
const dsn = import.meta.env.VITE_SENTRY_DSN;
if (!dsn) throw new Error('VITE_SENTRY_DSN 未配置');

还要确保 vite/client 类型被加载,否则 import.meta.env 会报「Property ’env’ does not exist on type ‘ImportMeta’」:

{ "compilerOptions": { "types": ["vite/client"] } }

15.1.6 类型化的 Vite 专属 API

Vite 给 import.meta 挂了两组非标准 API:glob 与 hot。它们都带完整类型,前提是 vite/client 已被加载。

import.meta.glob 在构建期把匹配到的文件展开成静态导入表,是路由自动注册与插件系统的常用手段。它是泛型函数,可以指定每个模块的导出形状:

import type { ComponentType } from 'react';

// 默认懒加载:值为返回 Promise 的函数
const pages = import.meta.glob<{ default: ComponentType }>('./pages/*.tsx');

// 立即加载:值为模块命名空间对象
const eager = import.meta.glob<{ meta: { title: string } }>('./pages/*.tsx', {
  eager: true,
});

const routes = Object.entries(pages).map(([path, load]) => ({
  path: path.replace('./pages', '').replace('.tsx', ''),
  component: load,
}));

注意泛型参数是整个模块的导出形状,不是 default 的类型。写成 import.meta.glob<ComponentType> 会在 load 的类型上出错——因为 load 是 () => Promise<{ default: ComponentType }>,不是组件本身。这个错误提示是

src/routes.ts:6:14 - error TS2345: Argument of type '() => Promise<ComponentType>'
is not assignable to parameter of type 'LazyComponent'.

import.meta.hot 用于自定义 HMR 边界。它的类型是 ViteHotContext | undefined,因此必须判空——这也是为什么模板里永远写着 if (import.meta.hot):

if (import.meta.hot) {
  import.meta.hot.accept((mod) => {
    if (!mod) return;
    render(mod.default);
  });
  import.meta.hot.dispose(() => chart?.destroy());
}

dispose 里回收上一轮实例是 HMR 正确性的关键:热更新只是重新执行模块,旧的定时器、WebSocket 与图表实例不会自动销毁,泄漏几次就会看到内存曲线一路上扬。

15.1.7 六个高频坑

一、以为 vite build 会检查类型。 不会。build 脚本必须是 tsc -b --noEmit && vite build。

二、关掉 isolatedModules。 有些老项目为省事关掉它,结果 export { SomeType } 在 esbuild 下变成对不存在绑定的运行时访问。

三、import type 漏写。 配合 verbatimModuleSyntax 能在编译期抓出,否则会变成运行时的 undefined is not a function。

四、moduleResolution 还用 "node"。 现代包普遍依赖 exports 字段,旧模式会解析失败。

五、环境变量忘了 VITE_ 前缀。 值为 undefined 却不报错,因为类型是索引签名。

六、vite.config.ts 被应用 tsconfig 覆盖。 它运行在 Node 环境,需要 "types": ["node"];把应用与配置拆成两个 tsconfig 是标准做法,参见 《TypeScript编程实战》1.2 严格模式与 tsconfig 分层 。

15.1.8 与本书其它章节的衔接

项目的初始脚手架(pnpm / tsx / tsup)见 《TypeScript编程实战》1.1 从零搭建(pnpm / tsx / tsup) ;路径别名 @/ 需要在 tsconfig 与 Vite 两侧同时配置,见 《TypeScript编程实战》2.1 路径别名与 monorepo 结构 ;本节提到的 sourcemap 与调试衔接见 《TypeScript编程实战》2.3 调试与 source map 。

站内延伸阅读:Vite 配置完全指南 、Vite 依赖预构建机制 、Vite dev server 内部原理 、从 Webpack 迁移到 Vite 。

小结

Vite 与 TypeScript 的集成只有一条主线:转译与类型检查是两件事。dev 阶段由 esbuild 逐文件转译,快得可以按需编译;build 阶段由 Rollup 全量打包;而类型检查始终由独立的 tsc -b --noEmit 承担,必须显式挂到 build 脚本或 CI 上。把这条线记牢,isolatedModules、verbatimModuleSyntax、moduleResolution: "bundler" 这些看似零散的配置就都有了解释——它们都是在「逐文件转译」这一前提下必须补上的约束。

下一节讨论构建阶段的另一半:Rollup 如何把模块图切分成 chunk,tree-shaking 依赖哪些前提才真正生效,以及动态 import() 在类型层面会带来什么。

阅读导航:上一节:14.3 分页、无限滚动与预取 · 下一节:15.2 代码分割与 tree-shaking 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes