本节目标:理解 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 在开发和生产两个阶段用的是完全不同的机制:
| 阶段 | 引擎 | 工作方式 | 产物 |
|---|---|---|---|
| dev | esbuild 转译 + 原生 ESM | 按需编译,浏览器请求哪个模块就编哪个 | 内存中的模块图 |
| build | Rollup(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: true | esbuild 逐文件转译,没有跨文件类型信息 |
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 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。