Vue 基础体系 · 第 2/70 篇。示例基于 Vue 3、Composition API、TypeScript 与现代 Vite 工具链;版本敏感能力会单独标注。

Vue 项目与 Vite 工具链:创建、环境变量、构建、代理和依赖治理

Vue 3 项目通常由三层工具组成:

  1. Vue:负责组件、响应式状态、模板和运行时渲染。
  2. Vite:负责开发服务器、模块转换、依赖预构建、生产构建和静态资源处理。
  3. Node.js 与包管理器:负责运行工具链、解析依赖、安装包和生成锁文件。

因此,“创建一个 Vue 项目”并不只是执行一个脚手架命令。一个可交付项目还必须明确:

  • 开发服务器如何启动;
  • TypeScript 是否经过类型检查;
  • 环境变量从哪里来,哪些变量可以进入浏览器;
  • 开发环境代理如何工作,生产环境由谁承担反向代理;
  • 构建产物是什么,如何验证;
  • 依赖如何安装、锁定、升级、审计和恢复。

本文示例基于 Vue 3、Composition API、TypeScript 和现代 Vite 工具链。Vite 的命令、Node.js 支持范围及部分配置能力会随主版本变化,实际使用时应以当前 Vite 官方文档和项目生成的配置为准。


一、创建 Vue 项目前,先明确工具链边界

1. Node.js 是工具链的运行时

Vite 本身是 Node.js 程序。执行 npm create vitenpm run devnpm run build 时,首先运行的是 Node.js,然后 Node.js 加载 Vite、Vue 插件、TypeScript 检查工具等依赖。

Node.js 版本过低时,常见失败表现包括:

This version of Node.js is not supported
SyntaxError: Unexpected token
Cannot find module ...

这些错误不一定来自 Vue 代码,可能是工具链依赖使用了当前 Node.js 不支持的语法或 API。

先检查环境:

node --version
npm --version

输出类似:

v22.14.0
10.9.2

具体需要哪个 Node.js 版本,应查看当前 Vite 版本的 engines 要求。不要只根据团队其他项目的版本推断,因为不同 Vite 主版本的最低 Node.js 版本可能不同。

工程上通常还应固定 Node.js 版本,例如使用 .nvmrc

22.14.0

或者在 package.json 中声明范围:

{
  "engines": {
    "node": ">=20.19.0"
  }
}

engines 主要用于声明和提示,不等同于所有环境都会强制阻止安装。CI 中仍应显式检查 Node.js 版本。

2. 用官方脚手架创建 Vue + TypeScript 项目

创建项目:

npm create vite@latest my-vue-app -- --template vue-ts
cd my-vue-app
npm install
npm run dev

命令的因果关系如下:

  1. npm create vite@latest 下载并执行 Vite 的项目生成器;
  2. my-vue-app 是目录名;
  3. --template vue-ts 选择 Vue 3 + TypeScript 模板;
  4. npm install 根据 package.json 安装依赖并生成或更新锁文件;
  5. npm run dev 执行 package.json 中的 dev 脚本,启动 Vite 开发服务器。

终端通常会显示类似:

VITE vX.Y.Z  ready in 300 ms

➜  Local:   http://localhost:5173/
➜  Network: use --host to expose

这里的 5173 是 Vite 常见的默认端口,但如果端口被占用,Vite 可能自动选择其他端口,因此应以终端实际输出为准。

可以用浏览器访问:

http://localhost:5173/

也可以使用:

curl -I http://localhost:5173/

预期得到 200 OK 或类似的成功响应。若项目设置了 SPA 回退,直接访问一个不存在的前端路径也可能返回 index.html,这不代表该路由一定存在,而是开发服务器将页面交给前端路由处理。

3. 生成项目后,应先阅读脚本而不是假设脚本名称

典型的 package.json 可能包含:

{
  "scripts": {
    "dev": "vite",
    "build": "vue-tsc -b && vite build",
    "preview": "vite preview"
  }
}

但不同模板版本可能生成不同脚本,例如使用 tscvue-tsc 或项目引用配置。应以当前项目文件为准:

npm run

这会列出可执行脚本。

三个核心命令的职责不同:

  • vite:启动开发服务器;
  • vite build:生成生产构建产物;
  • vite preview:用 Vite 提供本地静态预览服务。

vite preview 不是生产服务器。它适合检查构建结果是否能够被访问,不负责替代生产环境中的 Nginx、CDN、对象存储或云平台静态托管。


二、一个 Vue + TypeScript 项目的基本执行链

一个典型请求链可以抽象为:

flowchart LR
    A[浏览器请求页面] --> B[Vite 开发服务器]
    B --> C[index.html]
    C --> D[main.ts]
    D --> E[Vue createApp]
    E --> F[根组件 App.vue]
    F --> G[组件模板与响应式状态]
    G --> H[浏览器 DOM]

    I[浏览器 API 请求 /api] --> B
    B --> J[开发代理]
    J --> K[后端服务]

开发阶段,浏览器不一定直接获得一个已经打包完成的 JavaScript 文件。Vite 会按模块提供源码,并在浏览器请求模块时进行必要转换。这样可以减少开发启动时的全量打包成本。

生产构建则不同:

flowchart LR
    A[Vue 源码] --> B[Vite 构建]
    B --> C[模块解析]
    C --> D[TypeScript/模板转换]
    D --> E[依赖处理与代码分割]
    E --> F[压缩与资源命名]
    F --> G[dist 静态文件]
    G --> H[CDN/Nginx/静态托管]

需要注意,TypeScript 的“转换”和“类型检查”是两个概念:

  • Vite 需要把 .ts.tsx 等代码转换为浏览器可执行的 JavaScript;
  • 类型检查则需要 tscvue-tsc 分析类型关系。

许多 Vite 开发流程为了保持速度,不会在每次模块转换时完整执行类型检查。因此,项目应该把类型检查作为独立脚本或构建前置步骤。

例如:

{
  "scripts": {
    "typecheck": "vue-tsc --noEmit",
    "build": "npm run typecheck && vite build"
  }
}

vue-tsc 能够理解 Vue 单文件组件中的 <script setup lang="ts"> 和模板类型。只执行 vite build,不能自动证明所有 TypeScript 类型正确。


三、Vue 入口、Composition API 与 TypeScript 的最小端到端示例

典型入口文件是 src/main.ts

import { createApp } from 'vue'
import './style.css'
import App from './App.vue'

createApp(App).mount('#app')

对应的 index.html 通常包含挂载节点:

<!doctype html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vue App</title>
  </head>
  <body>
    <div id="app"></div>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

createApp(App) 创建 Vue 应用实例,.mount('#app') 将根组件渲染到 #app 元素中。根组件通常使用 Composition API:

<script setup lang="ts">
import { computed, ref } from 'vue'

interface User {
  id: number
  name: string
}

const users = ref<User[]>([
  { id: 1, name: 'Ada' },
  { id: 2, name: 'Linus' }
])

const keyword = ref('')

const filteredUsers = computed(() => {
  const normalized = keyword.value.trim().toLowerCase()

  if (!normalized) {
    return users.value
  }

  return users.value.filter((user) =>
    user.name.toLowerCase().includes(normalized)
  )
})
</script>

<template>
  <main>
    <label>
      搜索:
      <input v-model="keyword" />
    </label>

    <ul>
      <li v-for="user in filteredUsers" :key="user.id">
        {{ user.id }} - {{ user.name }}
      </li>
    </ul>
  </main>
</template>

这里有三条重要数据流:

  1. keyword 是响应式引用,模板中的 v-model 会更新它;
  2. filteredUsers 是计算状态,只在依赖的 keywordusers 变化后重新计算;
  3. v-for 根据 filteredUsers 产生 DOM,:key 用于帮助 Vue 识别列表项身份。

TypeScript 的 interface User 只在开发和检查阶段存在,构建后的浏览器代码不会携带这个接口定义。类型信息不会自动在运行时验证后端 JSON,因此 API 响应仍需要运行时校验或可靠的服务端契约。


四、环境变量:配置输入,不是秘密存储

1. 环境变量的本质

环境变量是构建或启动工具时提供给进程的键值输入。例如:

VITE_API_BASE=/api npm run dev

在 Unix-like shell 中,这表示只对这次命令设置 VITE_API_BASE

Vite 会将环境变量分为两类:

  • Vite 内置变量:例如 import.meta.env.MODEimport.meta.env.DEVimport.meta.env.PRODimport.meta.env.BASE_URL
  • 用户变量:默认只有以 VITE_ 开头的变量才会暴露给客户端代码。

客户端读取:

const apiBase = import.meta.env.VITE_API_BASE

这些值会被替换或注入到前端构建结果中。只要变量名以 VITE_ 开头,就应假设用户最终可以在浏览器开发者工具、静态资源或构建产物中看到它。

因此,下面的做法是错误的:

VITE_DATABASE_PASSWORD=secret
VITE_PRIVATE_SIGNING_KEY=secret

前端环境变量适合放:

VITE_API_BASE=/api
VITE_APP_TITLE=用户中心

不适合放数据库密码、JWT 签名密钥、云厂商私钥、内部管理接口凭证等秘密。秘密应保留在服务端或 CI/CD 的安全变量系统中。

2. .env 文件和 mode

Vite 支持按模式加载环境文件:

.env
.env.local
.env.development
.env.development.local
.env.production
.env.production.local

常见理解是:

  • .env:所有模式共享;
  • .env.development:开发模式使用;
  • .env.production:生产模式使用;
  • .local:本机或部署环境专用,通常不提交到 Git。

可提交的示例文件可以命名为:

.env.example

内容:

VITE_API_BASE=/api

本地文件:

.env.local

内容:

VITE_API_BASE=http://localhost:8080/api

通常应将 .env.local 加入 .gitignore

.env.local
.env.*.local

环境文件的最终值取决于模式、文件优先级和外部环境变量。已有的 shell 环境变量通常具有更高优先级,Vite 不会用环境文件覆盖已经存在的变量。因此,排查“明明改了 .env 却不生效”时,应同时检查:

printenv VITE_API_BASE

以及启动命令是否指定了其他 mode。

启动开发模式:

npm run dev -- --mode development

生产构建:

npm run build -- --mode production

-- 用于将参数继续传给底层 Vite 命令。开发服务器或构建进程启动时才读取环境配置;修改 .env 后通常需要重新启动进程。

3. 环境变量全部是字符串

例如:

VITE_ENABLE_DEBUG=false
VITE_PORT=3000

读取后仍然是字符串:

const enabled = import.meta.env.VITE_ENABLE_DEBUG

console.log(enabled === false)   // false
console.log(enabled === 'false')  // true

如果直接写:

if (import.meta.env.VITE_ENABLE_DEBUG) {
  // ...
}

那么字符串 'false' 是真值,代码会错误地进入分支。

应显式解析:

function parseBoolean(value: string | undefined, fallback = false) {
  if (value === undefined) {
    return fallback
  }

  return value === 'true'
}

const debugEnabled = parseBoolean(import.meta.env.VITE_ENABLE_DEBUG)

数字也需要转换:

const timeoutMs = Number(import.meta.env.VITE_TIMEOUT_MS ?? 5000)

if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
  throw new Error('VITE_TIMEOUT_MS 必须是正数')
}

4. 在 vite.config.ts 中读取环境变量

配置文件本身运行在 Node.js 中,不能默认把客户端代码中的 import.meta.env 当作同一套运行环境使用。若配置需要根据 mode 变化,应使用 loadEnv

import { defineConfig, loadEnv } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig(({ mode }) => {
  const env = loadEnv(mode, process.cwd(), 'VITE_')

  return {
    plugins: [vue()],
    server: {
      port: Number(env.VITE_DEV_PORT ?? 5173)
    }
  }
})

第三个参数 'VITE_' 表示只加载此前缀开头的变量。若配置确实需要读取没有此前缀的变量,可以传入空字符串:

const env = loadEnv(mode, process.cwd(), '')

但这样会把更多环境变量读入配置,容易误把服务端秘密混入配置处理流程。除非有明确理由,否则应限制前缀。

5. 不要用动态属性访问环境变量

这种写法通常不能被 Vite 的静态替换机制正确处理:

const key = 'VITE_API_BASE'
const apiBase = import.meta.env[key]

应直接访问:

const apiBase = import.meta.env.VITE_API_BASE

如果需要按名称选择配置,应先显式建立映射:

const config = {
  apiBase: import.meta.env.VITE_API_BASE,
  appTitle: import.meta.env.VITE_APP_TITLE
}

const value = config.apiBase

这是因为构建工具通常通过静态分析识别环境变量访问,而不是在浏览器运行时保留一个完整的环境变量对象。


五、为环境变量建立类型声明与运行时边界

TypeScript 默认不一定知道项目自定义环境变量。可以在 src/vite-env.d.ts 或其他被 tsconfig 包含的声明文件中扩展类型:

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

interface ImportMetaEnv {
  readonly VITE_API_BASE: string
  readonly VITE_APP_TITLE?: string
}

interface ImportMeta {
  readonly env: ImportMetaEnv
}

这个声明的作用是让编辑器和类型检查知道变量名称及类型,但它不会在运行时验证变量是否真的存在。

因此,类型声明:

readonly VITE_API_BASE: string

并不意味着部署时一定设置了 VITE_API_BASE。如果缺失,构建结果仍可能出现空值或未预期行为。可以在应用启动时验证:

function requireEnv(value: string | undefined, name: string): string {
  if (!value) {
    throw new Error(`缺少环境变量:${name}`)
  }

  return value
}

export const runtimeConfig = {
  apiBase: requireEnv(import.meta.env.VITE_API_BASE, 'VITE_API_BASE')
}

完整的客户端请求封装示例:

import { runtimeConfig } from './config'

interface ApiErrorBody {
  message?: string
}

export async function requestJson<T>(
  path: string,
  init?: RequestInit
): Promise<T> {
  const response = await fetch(`${runtimeConfig.apiBase}${path}`, {
    ...init,
    headers: {
      Accept: 'application/json',
      ...init?.headers
    }
  })

  if (!response.ok) {
    let detail = ''

    try {
      const body = (await response.json()) as ApiErrorBody
      detail = body.message ? `:${body.message}` : ''
    } catch {
      // 响应不是 JSON 时保留 HTTP 状态即可
    }

    throw new Error(`请求失败 ${response.status}${detail}`)
  }

  return (await response.json()) as T
}

这里的 T 只保证调用方对返回值的静态使用方式,不验证服务器返回的数据结构。如果后端契约不稳定,应在边界位置增加运行时校验,例如使用专门的 schema 校验库,或手写必要的字段检查。


六、Vite 开发代理:解决开发期跨域,不是生产网关

1. 为什么浏览器会遇到跨域

假设前端运行在:

http://localhost:5173

后端运行在:

http://localhost:8080

前端请求:

fetch('http://localhost:8080/users')

此时请求源的协议、主机或端口与页面不同,浏览器会执行同源策略检查。后端必须通过 CORS 响应头允许该来源,否则浏览器可能阻止前端读取响应。

开发代理的思路是:

浏览器 -> localhost:5173/api/users -> Vite -> localhost:8080/users

浏览器看到的请求仍然属于 localhost:5173,跨域请求由 Vite 在 Node.js 进程中代发。

2. 一个可运行的代理配置

vite.config.ts

import { defineConfig, loadEnv } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig(({ mode }) => {
  const env = loadEnv(mode, process.cwd(), 'VITE_')
  const backendUrl = env.VITE_DEV_BACKEND_URL ?? 'http://localhost:8080'

  return {
    plugins: [vue()],

    server: {
      port: 5173,

      proxy: {
        '/api': {
          target: backendUrl,
          changeOrigin: true,
          secure: false
        }
      }
    }
  }
})

前端代码只请求相对路径:

const response = await fetch('/api/users')

如果后端实际接口是:

http://localhost:8080/api/users

则不需要重写路径。

如果后端实际接口是:

http://localhost:8080/users

则可以重写:

proxy: {
  '/api': {
    target: backendUrl,
    changeOrigin: true,
    secure: false,
    rewrite: (path) => path.replace(/^\/api/, '')
  }
}

此时:

/api/users

会被代理为:

/users

3. targetchangeOriginsecurerewrite 的含义

  • target:代理要连接的后端地址;
  • changeOrigin:是否修改转发请求中的 Host 等来源信息,某些后端虚拟主机配置需要它;
  • secure:使用 HTTPS 目标时是否校验证书;设置为 false 可以绕过本地自签名证书校验,但不应把它当成生产安全方案;
  • rewrite:修改转发路径,不会改变浏览器地址栏中的路径。

WebSocket 代理需要单独启用:

proxy: {
  '/socket.io': {
    target: 'ws://localhost:8080',
    ws: true,
    changeOrigin: true
  }
}

4. 代理失败时如何诊断

假设浏览器请求 /api/users 失败,诊断顺序应区分不同故障层:

浏览器没有发出请求

检查:

  • 是否触发了请求代码;
  • 是否在请求前抛出了 JavaScript 异常;
  • Network 面板中是否出现请求;
  • 请求路径是否真的以 /api 开头。

Vite 返回 404

可能是:

  • 代理匹配前缀错误;
  • rewrite 删除了后端实际需要的路径;
  • Vite 配置修改后没有重启开发服务器。

Vite 返回 502 或连接错误

可能是:

  • 后端没有启动;
  • target 主机或端口错误;
  • 容器环境中使用了错误的 localhost

在容器中,localhost 通常指当前容器,而不是宿主机或另一个服务容器。此时应使用 Docker Compose 服务名或可达的网络地址。

后端返回 401、403 或 500

这通常说明代理已经成功连接后端,问题进入了认证、权限、请求格式或后端业务层。不要把所有代理错误都归因于 CORS。

5. 代理只解决开发阶段

server.proxy 只影响 Vite 开发服务器。执行:

npm run build

后,dist 中没有一个 Vite 代理进程会继续替你转发 /api

生产环境必须由以下某个组件承担转发:

  • Nginx 或 Apache;
  • 云负载均衡器;
  • CDN 边缘规则;
  • Kubernetes Ingress;
  • 独立后端网关;
  • 应用平台提供的路由配置。

例如 Nginx 的概念配置:

location /api/ {
    proxy_pass http://backend:8080/;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}

location / {
    try_files $uri $uri/ /index.html;
}

这里有一个容易出错的细节:proxy_pass 末尾斜杠会影响 URI 拼接方式。实际部署前应使用具体请求验证:

curl -i https://example.com/api/users

同时检查后端收到的实际路径,而不能只根据配置文字推测。


七、生产构建:从源码到 dist

1. 构建命令和输出

执行:

npm run build

如果脚本是:

{
  "scripts": {
    "build": "vue-tsc -b && vite build"
  }
}

则流程是:

  1. vue-tsc -b 检查 TypeScript 和 Vue 模板;
  2. 检查失败时,后面的 vite build 不会执行;
  3. 类型检查成功后,Vite 解析模块、转换代码、处理资源并写入 dist

成功时通常会输出类似:

✓ built in 2.31s
dist/index.html
dist/assets/index-xxxxx.js
dist/assets/index-xxxxx.css

文件名中的哈希通常由内容决定。内容变化后哈希变化,便于浏览器和 CDN 长期缓存静态资源。

构建完成后检查:

find dist -maxdepth 2 -type f

预览:

npm run preview

随后根据终端输出访问预览地址。预览服务验证的是 dist,不是开发服务器中的源码转换过程。

2. base 决定资源 URL 前缀

如果应用部署在域名根路径:

https://example.com/

通常使用:

export default defineConfig({
  base: '/'
})

如果应用部署在子路径:

https://example.com/admin/

应配置:

export default defineConfig({
  base: '/admin/'
})

或者构建时传入:

vite build --base=/admin/

base 会影响构建产物中脚本、样式和动态资源的引用路径。忘记设置时,常见表现是:

  • 页面 HTML 可以打开;
  • JavaScript 请求 /assets/...
  • 实际资源位于 /admin/assets/...
  • 浏览器得到 404,页面停留在空白状态。

配置后应检查:

grep -R "assets/" dist/index.html

并用部署后的真实子路径验证,而不是只在 / 根路径访问。

3. 静态资源的两种处理方式

通过模块导入的资源

<script setup lang="ts">
import logoUrl from './assets/logo.svg'
</script>

<template>
  <img :src="logoUrl" alt="Logo" />
</template>

这类资源会进入 Vite 的资源处理流程,通常获得哈希文件名,并参与依赖关系分析。

public 目录中的资源

目录结构:

public/
  favicon.svg

访问方式:

<link rel="icon" href="/favicon.svg" />

public 下的文件通常按原文件名直接复制到构建输出目录,不经过模块导入和哈希重命名。它适合必须保持固定 URL 的文件,例如:

  • favicon.ico
  • robots.txt
  • 某些外部系统约定的固定文件名。

误区是把所有图片都放入 public。如果资源是应用模块的一部分,通过导入通常更容易获得正确的路径处理和缓存策略。

4. 构建时环境变量与运行时配置不是一回事

Vite 的环境变量默认在构建时进入前端资源。例如:

VITE_API_BASE=https://api.example.com npm run build

构建完成后,dist 中的 JavaScript 已经包含这个值。把同一个 dist 部署到测试、预发布和生产环境时,不能仅通过修改服务器进程环境变量改变已经构建好的值。

这形成两种交付模型:

每个环境分别构建

测试环境变量 -> 构建测试包
生产环境变量 -> 构建生产包

优点是简单、明确;风险是不同环境的构建产物可能不完全相同。

构建一次,运行时注入

例如让服务器返回一个固定路径的配置文件:

{
  "apiBase": "/api",
  "release": "2025-03-08"
}

应用启动时读取:

const response = await fetch('/runtime-config.json', {
  cache: 'no-store'
})

const runtimeConfig = await response.json()

这种模型可以让同一份静态资源在多个环境复用,但增加了启动时序、缓存失效和配置加载失败处理。若配置加载失败,应用应该显示明确错误,而不是静默使用错误的默认地址。


八、SPA 路由与静态服务器回退

Vue Router 的 history 模式会产生如下前端路由:

/settings/profile

当用户从应用内部点击跳转时,浏览器通常已经加载了 index.html,Vue Router 在客户端处理路径。

但用户直接刷新 /settings/profile 时,服务器会把它当作静态文件路径。如果服务器没有该文件,可能返回 404。正确的服务器行为是:

  1. 真实存在的静态文件直接返回;
  2. 不存在但属于前端路由的路径返回 index.html
  3. API 路径 /api/* 转发给后端,而不是回退到 index.html

Nginx 示例:

location /api/ {
    proxy_pass http://backend:8080/;
}

location / {
    try_files $uri $uri/ /index.html;
}

若把 /api 也回退到 index.html,前端收到的可能是 HTML,却按 JSON 解析,从而出现:

Unexpected token '<', "<!doctype "... is not valid JSON

这类错误的根因不是 JSON 库,而是静态服务器路由规则把 API 请求错误地交给了前端页面。


九、代码分割、动态导入与构建诊断

Vite 会处理静态导入:

import UserPage from './pages/UserPage.vue'

如果页面不需要在首屏加载,可以使用动态导入:

const UserPage = () => import('./pages/UserPage.vue')

Vue Router 中常见写法:

const routes = [
  {
    path: '/users',
    component: () => import('./views/UsersView.vue')
  }
]

动态导入通常形成独立的异步 chunk。请求链变为:

  1. 首屏加载入口 JavaScript;
  2. 用户进入 /users
  3. 浏览器请求对应异步 chunk;
  4. chunk 加载成功后实例化组件;
  5. 组件参与渲染。

因此,代码分割不是“文件越多越好”。如果把极小且总会使用的模块拆成大量 chunk,可能增加请求和加载调度;如果所有页面都静态导入,则首屏 JavaScript 可能过大。应结合真实入口、网络缓存和页面访问路径检查构建结果。

常见失败表现及诊断方法:

构建成功但页面空白

检查:

npm run preview

然后查看浏览器 Console 和 Network:

  • JavaScript 是否 404;
  • base 是否正确;
  • 是否发生运行时异常;
  • 是否有动态 chunk 加载失败;
  • 是否把生产 API 地址配置错误。

构建后请求仍指向开发地址

检查:

grep -R "localhost:8080" dist

如果能搜到开发地址,通常说明:

  • 使用了错误的 mode;
  • .env.production 没有被加载;
  • shell 中已有同名变量覆盖了文件配置;
  • 配置在构建后无法再改变。

类型检查通过但运行时数据错误

TypeScript 只检查静态代码和声明,不会自动验证网络响应。例如:

interface User {
  id: number
  name: string
}

const user = await requestJson<User>('/users/1')

如果后端返回:

{
  "id": "not-a-number",
  "name": null
}

TypeScript 不会在浏览器运行时自动抛错。API 契约需要服务端规范、生成代码、运行时 schema 校验或组合使用。组件类型、模板检查和 API 契约应作为独立的类型边界治理,而不是把接口声明当作运行时验证。


十、vite.config.ts 的配置边界

一个基础配置可以写成:

import { defineConfig, loadEnv } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig(({ mode }) => {
  const env = loadEnv(mode, process.cwd(), 'VITE_')

  return {
    base: env.VITE_BASE_PATH ?? '/',
    plugins: [vue()],

    server: {
      host: '127.0.0.1',
      port: Number(env.VITE_DEV_PORT ?? 5173),
      strictPort: false
    },

    preview: {
      port: Number(env.VITE_PREVIEW_PORT ?? 4173)
    },

    build: {
      outDir: 'dist',
      sourcemap: false
    }
  }
})

配置项的作用:

  • plugins: [vue()]:让 Vite 识别并转换 Vue 单文件组件;
  • base:设置部署路径前缀;
  • server:配置开发服务器;
  • preview:配置本地构建预览服务器;
  • build.outDir:设置构建输出目录;
  • build.sourcemap:决定是否生成 source map。

source map 便于定位压缩代码对应的源码,但也可能暴露源码结构。是否在生产生成,应根据错误监控系统、访问控制和组织安全要求决定。即使生成 source map,也不应把秘密写进源码,因为 source map 可能被下载或上传到第三方监控平台。

配置文件中的 serverpreview 不是同一个服务器:

  • server 只影响 vite 开发服务器;
  • preview 只影响 vite preview
  • 生产服务器的端口、压缩、TLS、缓存和代理由部署平台配置。

十一、依赖治理:从“能安装”到“可复现、可升级、可恢复”

1. package.json 与锁文件的关系

package.json 声明项目直接依赖,例如:

{
  "dependencies": {
    "vue": "^3.5.0"
  },
  "devDependencies": {
    "@vitejs/plugin-vue": "^6.0.0",
    "vite": "^7.0.0",
    "typescript": "~5.8.0",
    "vue-tsc": "^2.2.0"
  }
}

这里的版本符号表示允许的版本范围:

  • ^3.5.0:通常允许更新到小于 4.0.0 的兼容版本;
  • ~5.8.0:通常允许更新到小于 5.9.0 的补丁版本;
  • 固定版本:只允许该精确版本。

实际安装的完整依赖树由锁文件记录,例如 npm 的:

package-lock.json

锁文件不仅记录直接依赖,也记录间接依赖的具体版本、解析地址和完整性校验值。提交锁文件的意义是让开发机、CI 和部署环境尽量安装相同的依赖树。

CI 中应优先使用:

npm ci

而不是:

npm install

npm ci 通常要求锁文件与 package.json 一致,并按锁文件执行干净安装。若两者不一致,它会失败,而不是悄悄修改锁文件。这种失败是有价值的,因为它阻止了“本地能跑、CI 安装出另一棵依赖树”的情况。

2. dependenciesdevDependencies 的判断标准

判断依据不是“开发者是否使用”,而是生产运行时是否需要该包

对于典型的纯前端 Vue 项目:

  • Vue 运行在浏览器中,通常放在 dependencies
  • Vite、Vue 插件、TypeScript、vue-tsc 只参与开发和构建,通常放在 devDependencies

但如果项目同时包含一个 Node.js 服务端,服务端运行时需要的包必须放在 dependencies。不能因为部署脚本只执行了 npm install --production,就把服务端运行依赖误放到 devDependencies

前端最终打包后,部分 dependencies 也会被内联进静态资源。dependencies 的分类仍然有助于表达项目生命周期和部署要求,但并不意味着每个依赖都会作为独立文件出现在服务器上。

3. 添加、升级和移除依赖

添加依赖:

npm install vue-router

添加开发依赖:

npm install --save-dev eslint

查看依赖树:

npm ls --depth=0
npm ls vite

检查过时依赖:

npm outdated

升级时不要直接把所有包一次性升级到最新版本。更可控的流程是:

  1. 记录当前分支和锁文件;
  2. 选择一个相关依赖或一个兼容升级组;
  3. 修改版本范围并重新安装;
  4. 执行类型检查、单元测试和生产构建;
  5. 检查构建产物、代理行为和关键页面;
  6. 提交 package.json 与锁文件。

例如升级 Vite 后,应重点检查:

  • Node.js 最低版本要求;
  • 插件兼容性;
  • 配置选项是否发生弃用或行为变化;
  • 构建输出和资源路径;
  • CI 是否仍能执行 npm ci

4. peerDependencies 冲突不能简单忽略

某些库通过 peerDependencies 声明兼容的 Vue、TypeScript 或框架版本。如果出现:

ERESOLVE unable to resolve dependency tree

应先阅读冲突链:

npm ls
npm explain <package-name>

不要默认使用:

npm install --legacy-peer-deps

这个选项可能绕过依赖约束,使项目进入“安装成功但运行时不兼容”的状态。只有在确认依赖实际兼容、且团队明确接受该策略时,才应将其作为临时方案,并记录原因。

5. overrides 用于控制间接依赖

当间接依赖存在已知问题,而上游尚未发布可用版本时,npm 支持通过 overrides 约束解析结果:

{
  "overrides": {
    "some-transitive-package": "1.2.3"
  }
}

这不是无条件修复。强制替换间接依赖可能破坏上层包的 API 或类型假设。使用后应:

npm install
npm ls some-transitive-package
npm run typecheck
npm run build

并记录:

  • 为什么需要覆盖;
  • 哪个上层依赖引入了它;
  • 何时可以移除覆盖;
  • 是否存在上游修复版本。

6. 安全审计的边界

可以执行:

npm audit

它能根据公开漏洞数据库报告部分依赖风险,但不能证明项目安全,也不能证明升级后行为正确。

npm audit fix

可能修改版本范围和锁文件,甚至触发主版本升级。执行前应保存变更并阅读 diff:

git diff -- package.json package-lock.json

依赖安全治理至少要区分:

  • 直接依赖还是间接依赖;
  • 生产代码是否实际使用受影响路径;
  • 漏洞是否适用于当前构建和部署方式;
  • 自动修复是否引入破坏性升级;
  • 是否需要等待上游发布修复。

十二、常见错误的因果分析

错误一:把 process.env 用在浏览器代码中

错误示例:

const apiBase = process.env.API_BASE

Vite 浏览器代码的常规访问方式是:

const apiBase = import.meta.env.VITE_API_BASE

如果项目确实需要兼容某些旧代码,可以通过构建配置显式定义替换,但这不是把完整 Node.js process.env 暴露给浏览器的理由。客户端不存在可安全读取的服务器环境变量集合。

错误二:修改 .env 后页面仍使用旧值

可能原因:

  • 没有重启 Vite;
  • 使用了错误的 mode;
  • 外部 shell 变量覆盖了文件变量;
  • 读取的变量没有 VITE_ 前缀;
  • 访问方式使用了动态属性;
  • 实际运行的是已经构建好的旧 dist

诊断时可以在应用入口临时输出非敏感变量:

console.log({
  mode: import.meta.env.MODE,
  dev: import.meta.env.DEV,
  apiBase: import.meta.env.VITE_API_BASE
})

不要为了调试把秘密输出到浏览器 Console,因为只要进入客户端,它就已经不是秘密。

错误三:认为开发代理配置会随构建产物部署

错误理解:

配置了 server.proxy,所以生产请求 /api 也会自动转发。

实际情况是:

server.proxy -> 只存在于 Vite 开发服务器
dist         -> 只有静态文件
生产代理      -> 需要 Nginx、网关、CDN 或平台配置

验证方法是用生产域名执行:

curl -i https://example.com/api/health

并确认响应来自后端,而不是 index.html

错误四:把 vite build 当作完整质量检查

vite build 主要验证构建流程能否生成产物。它不自动覆盖:

  • Vue 模板的全部类型错误;
  • 单元测试;
  • 端到端交互;
  • API 返回数据结构;
  • 生产网关路径;
  • 运行时环境变量;
  • 浏览器兼容性和权限策略。

一个更清晰的脚本组织方式是:

{
  "scripts": {
    "typecheck": "vue-tsc --noEmit",
    "test": "vitest run",
    "build": "npm run typecheck && npm run test && vite build"
  }
}

具体测试工具和脚本名称取决于项目是否安装并配置了对应依赖。不要在未安装工具时直接复制脚本。

错误五:删除锁文件来“解决安装问题”

删除锁文件可能暂时绕过某些解析结果,但它也会重新计算整棵依赖树,导致大量间接依赖变化。正确做法通常是:

  1. 保存当前锁文件;
  2. 读取 npm 的冲突信息;
  3. 确认 package.json 与锁文件是否同步;
  4. 只升级相关依赖;
  5. 在干净环境执行 npm ci 验证;
  6. 失败时恢复锁文件,而不是继续叠加修改。

如果怀疑安装缓存损坏,可以在确认锁文件正确后重新安装,但缓存清理不是解决版本冲突的通用方法。


十三、从创建到交付的一套可验证流程

下面是一套适合新项目和 CI 的基础流程:

# 1. 检查 Node.js
node --version

# 2. 安装锁定的依赖
npm ci

# 3. 执行 Vue + TypeScript 类型检查
npm run typecheck

# 4. 构建生产资源
npm run build

# 5. 本地预览构建结果
npm run preview

每一步的验证目标不同:

步骤 验证对象 失败说明
node --version 工具链运行时 Node.js 版本可能不满足要求
npm ci 锁文件和依赖树 package.json、锁文件或 peer 依赖存在问题
npm run typecheck Vue、TypeScript、模板类型 静态类型或模板使用不一致
npm run build 生产构建流程 配置、模块、资源或插件处理失败
npm run preview dist 的可访问性 构建后路径、资源或运行时逻辑可能有问题

然后验证 API:

curl -i http://localhost:5173/api/health

验证生产部署:

curl -i https://example.com/
curl -i https://example.com/assets/
curl -i https://example.com/api/health

第二条命令中的具体路径应替换为实际构建生成的资源文件。核心不是请求一个不存在的 /assets/ 目录,而是确认:

  • HTML 能返回;
  • HTML 引用的 JavaScript 和 CSS 能返回;
  • API 路径被转发到后端;
  • 前端路由刷新不会返回 404;
  • 错误响应不会被错误地替换成 index.html

十四、依赖和构建问题的恢复策略

构建失败

先保留完整日志:

npm run build 2>&1 | tee build.log

然后区分:

  • 类型错误:查看 vue-tsc 报告的文件和模板位置;
  • 模块解析错误:检查导入路径、大小写和依赖是否安装;
  • 插件错误:检查 Vite、Vue 插件和 Node.js 版本;
  • 环境变量错误:检查 mode、变量前缀和 shell 覆盖;
  • 资源路径错误:检查 base 和部署子路径。

依赖安装失败

先检查:

npm ci
npm ls
npm explain <package-name>

如果最近刚升级依赖,可以比较:

git diff HEAD~1 -- package.json package-lock.json

确认修改范围后,优先恢复到最近一次可用锁文件,再单独处理目标依赖。不要让一次依赖升级同时混入大量无关格式化或源代码修改,否则难以定位回归原因。

生产页面白屏

可以按以下路径检查:

  1. HTML 是否返回 200
  2. HTML 中引用的资源 URL 是否包含正确的 base
  3. JavaScript、CSS、动态 chunk 是否返回 200
  4. 浏览器 Console 是否有运行时异常;
  5. API 请求是否打到了正确的生产地址;
  6. /api 是否被静态服务器错误回退为 index.html
  7. 是否有旧 HTML 引用了已被清理的旧哈希资源。

如果采用 CDN 或强缓存,HTML 和带哈希的静态资源应采用不同缓存策略:HTML 通常需要较短缓存或重新验证,带内容哈希的静态资源可以长期缓存。具体策略属于部署系统配置,不由 Vite 单独决定。


十五、规范保证、常见实现与工程取舍

需要区分三类结论:

规范或工具明确保证的行为

  • Vite 支持通过 import.meta.env 访问内置环境信息;
  • 默认只有特定前缀的用户环境变量会暴露给客户端;
  • vite build 生成静态构建产物;
  • server.proxy 配置开发服务器代理;
  • TypeScript 类型声明不会自动产生运行时校验。

常见实现,但不应当成绝对保证

  • 开发服务器默认端口通常是 5173
  • 构建文件通常位于 dist
  • 静态资源通常带内容哈希;
  • Vite 开发阶段通常按请求提供模块并进行转换。

这些行为可能通过配置或版本变化而改变,应以项目实际输出为准。

需要根据项目选择的经验方案

  • 是否启用生产 source map;
  • 是否每个环境分别构建;
  • 是否使用运行时配置;
  • 是否将 API 统一为 /api 前缀;
  • 是否允许依赖自动升级;
  • 是否把所有升级拆成单独变更;
  • 是否在构建前执行测试和类型检查。

真正稳定的 Vue + Vite 工具链,不是配置文件越复杂越好,而是每个边界都能被解释和验证:

  • Node.js 负责运行工具;
  • npm 和锁文件负责复现依赖;
  • Vite 负责开发转换和生产构建;
  • Vue 负责组件运行时与响应式更新;
  • import.meta.env 提供受控的构建配置输入;
  • 开发代理只承担本地转发;
  • 生产网关负责真实部署路径和 API 路由;
  • vue-tsc、测试和运行时校验分别承担不同层次的正确性检查。

系列导航与关联阅读

官方资料

本文依据 Vue、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。