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

Vue CI 质量流水线:类型、Lint、测试、构建、预览和制品

持续集成(Continuous Integration,CI)是指每次提交或合并请求进入共享分支时,由自动化环境重新安装依赖、执行质量检查并产出可验证结果。对 Vue 3 + TypeScript + Vite 项目而言,CI 不是把若干命令串起来,而是要回答五个不同问题:

  1. 类型检查:代码是否满足 TypeScript 和 Vue 单文件组件的静态类型约束?
  2. Lint:代码是否违反可自动检查的风格、语法和部分错误规则?
  3. 测试:在给定输入和环境下,行为是否符合预期?
  4. 构建:源代码、依赖和配置能否生成浏览器可加载的生产资源?
  5. 预览与制品:生成的资源是否能被启动和访问,并能被可靠保存、传递或部署?

可以把一个提交是否通过基础质量门禁形式化为:

pass(c)=T(c)L(c)U(c)B(c)P(c)\operatorname{pass}(c) = T(c)\land L(c)\land U(c)\land B(c)\land P(c)

其中:

  • cc 是某次提交;
  • TT 是类型检查;
  • LL 是 Lint;
  • UU 是测试;
  • BB 是构建;
  • PP 是对构建结果的预览或冒烟验证;
  • true 表示对应步骤以退出码 0 完成。

这个条件是必要条件的合取,不是质量的充分条件:所有检查通过,仍不能证明没有需求遗漏、性能问题或未覆盖的真实浏览器差异。


一、先明确流水线中的对象和边界

1. 源代码、构建结果、制品和缓存不是同一个东西

Vue 项目通常包含四类容易混淆的目录或数据:

  • 源代码src/public/index.html、配置文件和测试文件;
  • 依赖安装结果node_modules/,由 package-lock.json 等锁文件决定;
  • 构建结果:Vite 默认生成的 dist/
  • CI 制品(artifact):由 CI 平台保存并供下载、部署或后续任务使用的文件归档。

dist/ 是构建工具在工作区中产生的目录;artifact 是 CI 平台对某些文件进行上传、保存和命名后的结果。一个 dist/ 可以被上传为 artifact,但两者不是同一个概念。

缓存也不同:

  • 缓存用于加速,例如缓存 npm 下载内容;
  • 制品用于保存结果,例如保存本次构建的 dist/

缓存可以失效、被淘汰或重新生成;部署不应依赖缓存。部署应依赖可追踪的构建制品。

2. CI 的输入必须尽量确定

同一提交若在不同 Node.js 版本、不同依赖版本下产生不同结果,流水线就难以诊断。因此基础输入应包括:

  • 固定 Node.js 主版本或项目要求的精确版本范围;
  • 提交锁文件;
  • 使用 npm ci 而不是根据 package.json 重新解析依赖;
  • 明确环境变量;
  • 不把开发者本机生成的 dist/ 当作 CI 输入。

npm ci 要求锁文件与 package.json 一致,并依据锁文件安装依赖。若两者不一致,命令应失败;这比 CI 隐式修改锁文件更容易发现依赖漂移。


二、一个可运行的 Vue 3 + TypeScript + Vite 基线

下面假设项目已经由现代 Vite 模板创建,使用 Vue 3、Composition API 和 TypeScript。典型目录如下:

my-vue-app/
├─ src/
│  ├─ App.vue
│  └─ main.ts
├─ tests/
│  └─ unit/
├─ index.html
├─ package.json
├─ package-lock.json
├─ tsconfig.json
├─ tsconfig.app.json
├─ vite.config.ts
└─ eslint.config.js

一个可执行的脚本配置可以是:

{
  "scripts": {
    "dev": "vite",
    "typecheck": "vue-tsc --noEmit",
    "lint": "eslint .",
    "test": "vitest run",
    "test:watch": "vitest",
    "build": "vite build",
    "preview": "vite preview"
  }
}

这些脚本分别调用不同工具:

  • vue-tsc --noEmit:检查 .vue 单文件组件中的模板和脚本类型,但不输出 JavaScript;
  • eslint .:遍历项目中的可检查文件并报告规则违规;
  • vitest run:执行一次测试后退出,适合 CI;
  • vite build:生成生产构建;
  • vite preview:启动一个本地服务器,服务已经生成的构建目录。

这里的 npm run 只是 npm 对脚本的封装。npm run preview -- --host 127.0.0.1 中第二个 -- 之后的参数会传给 vite preview


三、类型检查:检查“能否被类型系统证明”

3.1 TypeScript 检查的对象

TypeScript 类型检查关注的是静态类型关系。例如:

function formatPrice(value: number): string {
  return `¥${value.toFixed(2)}`
}

formatPrice('12')

这里参数要求 number,调用处传入了 string,类型检查应报告错误。它通常不会执行函数,也不会验证按钮点击后页面是否真的显示了正确文字。

Vue 项目不能只运行普通的 tsc 就认为 .vue 文件被完整检查。.vue 文件包含模板、脚本和样式,Vue 官方工具链通常使用 vue-tsc 处理带有 Vue 语言服务的类型检查:

npm run typecheck

--noEmit 表示只检查,不写出编译产物。其成功条件不是“所有运行时行为正确”,而是“参与类型检查的代码满足当前 TypeScript、Vue 类型声明和配置”。

3.2 一个完整的类型错误传播过程

例如组件:

<script setup lang="ts">
const props = defineProps<{
  count: number
}>()

const label = props.count.toUpperCase()
</script>

<template>
  <span>{{ label }}</span>
</template>

检查过程可以拆成:

  1. defineProps 声明 count 的类型为 number
  2. props.count 因此被推导为 number
  3. toUpperCase() 是字符串方法,不是 number 的方法;
  4. vue-tsc 在模板编译和类型分析阶段报告错误;
  5. npm run typecheck 以非零退出码结束;
  6. CI 任务失败,后续依赖该任务的质量门禁不应继续通过。

修正为:

<script setup lang="ts">
const props = defineProps<{
  count: number
}>()

const label = String(props.count)
</script>

<template>
  <span>{{ label }}</span>
</template>

这里不是“把错误隐藏掉”,而是明确把数值转换为字符串。若业务要求价格格式化,还应使用具有业务语义的格式化函数,而不是任意转换。

3.3 类型检查的边界

类型检查无法保证:

  • API 在运行时一定返回声明的数据;
  • 用户一定传入合法文本;
  • DOM 查询一定找到元素;
  • CSS 布局在浏览器中没有溢出;
  • 异步请求一定成功;
  • 测试覆盖了关键分支。

例如:

type User = {
  name: string
}

const user = JSON.parse('{"name": 42}') as User
console.log(user.name.toUpperCase())

as User 是编译期断言,不会在运行时验证 JSON。类型检查可能通过,但实际调用 toUpperCase() 会失败。边界输入需要运行时校验、测试或接口契约,而不是仅依赖 TypeScript 断言。


四、Lint:检查“代码是否违反规则”

4.1 Lint 与类型检查不是同一层

Lint 是静态代码分析。它可以检查:

  • 未使用的变量;
  • 不允许的语法模式;
  • 不一致的导入方式;
  • Vue 模板中的特定问题;
  • 违反团队约定的代码结构;
  • 某些可静态发现的潜在错误。

例如:

const unusedValue = 1

export function add(a: number, b: number) {
  return a + b
}

如果启用了未使用变量规则,ESLint 可以报告 unusedValue。但它通常不会证明 add(1, 2) 的业务结果符合产品需求。

反过来,类型检查也不负责所有格式和规则问题。下表描述常见职责:

问题 类型检查 Lint
string 传给 number 参数 可能不检查
未使用变量 可能由编译选项检查
禁止某种导入方式
模板中缺少可访问性属性 可由 Vue 插件检查
运行时 API 返回错误数据
页面行为是否正确

4.2 ESLint 配置必须匹配项目文件

现代 ESLint 常见配置形式是根目录的 eslint.config.jseslint.config.mjs。具体配置取决于 ESLint、Vue ESLint 插件和 TypeScript ESLint 的版本,不能把不同代际的配置格式混用。

一个简化示例:

// eslint.config.js
import js from '@eslint/js'
import tseslint from 'typescript-eslint'
import vue from 'eslint-plugin-vue'

export default tseslint.config(
  {
    ignores: ['dist/', 'coverage/'],
  },
  js.configs.recommended,
  ...tseslint.configs.recommended,
  ...vue.configs['flat/recommended'],
)

这个示例表达了三层规则来源:

  1. ESLint 官方 JavaScript 推荐规则;
  2. TypeScript 语法和类型相关规则;
  3. Vue 单文件组件规则;
  4. 忽略构建和覆盖率输出,避免把生成文件当作源代码分析。

实际项目还需要依据安装的插件版本核对配置导出名称。配置格式是版本敏感内容:ESLint flat config、Vue ESLint 插件和 TypeScript ESLint 的组合必须使用相互兼容的版本。

执行:

npm run lint

若希望 CI 修复文件,不建议直接使用自动修复后默默继续构建。更安全的流程是开发者本地执行:

npx eslint . --fix

然后审查修改并提交。CI 主要负责判断提交是否合格,而不是替开发者修改工作区。

4.3 Lint 通过不等于类型通过

下面代码可能没有明显的格式问题:

function renderName(name: string) {
  return name.toUpperCase()
}

renderName(100)

若规则集没有执行完整类型感知分析,Lint 可能通过;vue-tsc 则应发现调用参数错误。因此将 linttypecheck 合并成一个“静态检查”概念,会削弱失败诊断能力。


五、测试:检查“在指定场景中行为是否正确”

5.1 测试的输入、执行和判定

以 Vitest 为例,测试至少包含:

  • 输入:函数参数、组件 props、用户事件或模拟请求;
  • 执行:调用函数、挂载组件、触发事件;
  • 断言:比较实际结果与预期结果;
  • 退出状态:所有断言通过则通常为 0,任一断言失败则为非零。

一个纯函数测试:

// src/formatPrice.ts
export function formatPrice(value: number): string {
  return `¥${value.toFixed(2)}`
}
// tests/unit/formatPrice.test.ts
import { describe, expect, it } from 'vitest'
import { formatPrice } from '../../src/formatPrice'

describe('formatPrice', () => {
  it('保留两位小数并添加货币符号', () => {
    expect(formatPrice(12)).toBe('¥12.00')
  })

  it('处理带小数的价格', () => {
    expect(formatPrice(12.5)).toBe('¥12.50')
  })
})

运行:

npm run test

预期结果是 Vitest 输出两个测试通过,并以退出码 0 结束。CI 使用 vitest run 而不是 vitest,因为后者通常进入监听模式,不会自然结束。

5.2 Vue 组件测试必须验证可观察行为

组件测试不应只测试内部变量名。更有价值的是验证用户可以观察到的结果:

<!-- src/components/CounterButton.vue -->
<script setup lang="ts">
import { ref } from 'vue'

const count = ref(0)
</script>

<template>
  <button type="button" @click="count++">
    Count: {{ count }}
  </button>
</template>

使用 Vue Test Utils 时,测试重点是挂载、查找按钮、触发点击和断言文本:

import { describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import CounterButton from '../../src/components/CounterButton.vue'

describe('CounterButton', () => {
  it('点击后显示递增的计数', async () => {
    const wrapper = mount(CounterButton)

    expect(wrapper.text()).toContain('Count: 0')

    await wrapper.get('button').trigger('click')

    expect(wrapper.text()).toContain('Count: 1')
  })
})

这里 await 很重要:事件触发可能引起 Vue 的异步更新,等待完成后再读取 DOM,才能断言更新后的状态。

5.3 测试失败、类型失败和构建失败的诊断路径不同

  • typecheck 失败:先看文件、行列号和推导类型;
  • lint 失败:看规则名,判断是代码错误还是规则配置不适用;
  • test 失败:看失败测试、实际值、期望值和测试环境;
  • build 失败:看模块解析、环境变量、插件转换和资源处理错误;
  • preview 失败:看端口、服务器日志和构建目录内容。

如果先运行测试,测试环境可能掩盖了生产构建问题;如果只运行构建,行为回归又可能未被捕获。因此这些步骤应保持独立的退出状态。


六、构建:把源代码转换为可部署资源

6.1 Vite 构建做了什么

Vite 的开发服务器和生产构建是两个不同场景:

  • 开发服务器强调快速启动和按需模块处理;
  • 生产构建通过 Rollup 等构建能力处理模块、资源、代码分割和压缩等任务;
  • 构建结果通常写入 dist/,除非配置修改了输出目录。

执行:

npm run build

可以抽象为:

src/*.vue、src/*.ts、依赖、配置
        │
        ▼
Vite 解析模块与插件
        │
        ▼
转换、打包、资源处理、生成 HTML
        │
        ▼
dist/index.html、dist/assets/*

构建成功的含义是:在当前 Node.js、依赖、配置和环境变量下,Vite 能生成资源。它不等于资源已经部署,也不等于所有浏览器都能正常执行。

6.2 构建阶段与运行阶段的环境变量

Vite 会处理以 VITE_ 开头的客户端环境变量。例如:

const apiBaseUrl = import.meta.env.VITE_API_BASE_URL

在构建时,Vite 将对应值替换或注入到客户端资源中。因为客户端资源最终会发送给浏览器,所以不能把数据库密码、私钥或服务端令牌放入 VITE_* 变量。

这产生一个常见因果关系:

  1. CI 设置 VITE_API_BASE_URL
  2. npm run build 读取该值;
  3. 该值进入 dist/ 中的 JavaScript;
  4. 上传 artifact 后,任何能下载 artifact 的人可能看到该值;
  5. 因此 VITE_* 只能存放可公开的客户端配置。

如果构建后修改环境变量,通常不能改变已经被静态替换进资源的值;要实现部署时配置,需要额外设计运行时配置文件或服务端注入机制,而不是假设 Vite 会自动重新读取变量。

6.3 构建前应清理旧输出

Vite 的具体清理行为受配置和版本影响。为了避免旧文件造成误判,可在构建前显式清理:

{
  "scripts": {
    "clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"",
    "build": "npm run clean && vite build"
  }
}

这段命令依赖 Node.js 的文件系统 API。若项目需要兼容更旧 Node.js 版本,应确认 rmSync 的可用性,或使用项目已有的跨平台清理工具。不要在 Windows、macOS 和 Linux 之间直接假设 rm -rf 都可用。


七、预览:验证“刚生成的构建能否被启动和访问”

7.1 vite preview 的准确含义

vite preview 启动一个本地静态服务器,服务已经存在的生产构建目录。典型流程是:

npm run build
npm run preview -- --host 127.0.0.1 --port 4173

如果 dist/index.html 存在,服务器启动后可以访问:

curl -I http://127.0.0.1:4173/

预期会获得成功的 HTTP 响应头,通常包括 200 状态。实际响应头会受 Vite 版本和配置影响,不能把具体的完整头集合当作稳定规范。

vite preview 不是生产级静态服务器,也不是部署命令。它适合:

  • 本地查看生产构建;
  • CI 中做基本 HTTP 冒烟检查;
  • 验证 dist/ 是否可被一个静态服务器读取。

生产环境可以使用 CDN、Nginx、对象存储或云平台提供的静态托管能力,并需要额外处理 HTTPS、缓存、压缩、SPA 回退和安全响应头。

7.2 CI 中启动和停止预览服务器

一个可执行的 shell 片段如下:

npm run build

npm run preview -- --host 127.0.0.1 --port 4173 > preview.log 2>&1 &
PREVIEW_PID=$!

cleanup() {
  kill "$PREVIEW_PID" 2>/dev/null || true
}
trap cleanup EXIT

for i in $(seq 1 30); do
  if curl --fail --silent --show-error \
    http://127.0.0.1:4173/ > /dev/null; then
    break
  fi

  if ! kill -0 "$PREVIEW_PID" 2>/dev/null; then
    cat preview.log
    exit 1
  fi

  sleep 1
done

curl --fail --silent --show-error \
  http://127.0.0.1:4173/ > /dev/null

逐步分析:

  1. npm run build 先生成 dist/,没有构建结果就没有可预览对象;
  2. & 让预览服务器在后台运行;
  3. 保存进程号,便于任务结束时停止进程;
  4. trap cleanup EXIT 确保成功或失败都尝试清理;
  5. 循环等待服务器就绪,避免刚启动就请求导致偶发失败;
  6. 如果进程已经退出,则输出日志并失败;
  7. curl --fail 让 HTTP 4xx/5xx 转为非零退出码;
  8. 最后的请求是独立的成功确认。

循环中的 301 是经验参数,不是 Vite 的规范保证。网络、CI 虚拟机负载和项目启动时间变化时,应根据实际日志调整。

7.3 HTTP 200 仍不能证明页面正确

curl / 只能证明服务器返回了入口资源。例如,以下问题可能仍然存在:

  • JavaScript 在浏览器中抛出异常;
  • 路由刷新时服务器没有返回 index.html
  • API 地址配置错误;
  • 页面需要真实浏览器 API;
  • CSS 或异步模块加载失败。

若需要验证浏览器行为,应在预览服务器之上增加 Playwright 等端到端测试。一个合理的分层是:

curl /                         检查服务器和入口文件
浏览器加载首页                 检查脚本执行和资源加载
端到端交互测试                 检查路由、点击、表单和网络行为
真实部署环境测试               检查 CDN、HTTPS、回退和运行时配置

每一层覆盖的故障不同,不能用低层检查替代高层检查。


八、制品:把可部署结果交给后续流程

8.1 制品的生命周期

一个典型制品生命周期是:

flowchart LR
    A[提交 Commit] --> B[安装锁定依赖]
    B --> C[类型检查]
    C --> D[Lint]
    D --> E[单元测试]
    E --> F[Vite 构建]
    F --> G[预览与冒烟检查]
    G --> H[上传 dist 制品]
    H --> I[下载制品]
    I --> J[部署或发布]

关键点在于:部署任务应下载并部署已经验证过的制品,而不是再次从源码构建一份“可能不同”的结果。

如果构建任务和部署任务分别执行:

构建任务:源码 A + Node 版本 X + 环境变量 Y → dist-1
部署任务:源码 A + Node 版本 Z + 环境变量 Y → dist-2

即使提交相同,依赖解析、工具版本或环境差异也可能使 dist-1dist-2 不同。复用 dist-1 可以缩小不确定性。

8.2 制品命名和追踪

制品至少应能关联到:

  • 提交 SHA;
  • 分支或合并请求;
  • 构建运行编号;
  • 构建所使用的 Node.js 和依赖锁文件。

CI 平台通常会为 artifact 生成内部标识,但工程上仍应使用清晰名称,例如:

vue-app-dist-${commit-sha}

不要把包含密钥、.env 私密文件或整个工作区直接上传。对于静态站点,通常只上传 dist/;源代码和依赖可以由仓库和锁文件恢复。


九、一个完整的 GitHub Actions 示例

下面的工作流使用 Ubuntu runner、Node.js 22、npm 锁文件和 GitHub artifact。Node.js 版本只是示例;项目应根据 engines、部署平台和依赖支持范围选择,并在升级时验证。

# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main]
  pull_request:

permissions:
  contents: read

concurrency:
  group: ci-${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

jobs:
  quality:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm

      - name: Install dependencies
        run: npm ci

      - name: Type check
        run: npm run typecheck

      - name: Lint
        run: npm run lint

      - name: Unit tests
        run: npm run test

      - name: Build
        run: npm run build
        env:
          VITE_API_BASE_URL: https://api.example.com

      - name: Preview smoke check
        shell: bash
        run: |
          npm run preview -- --host 127.0.0.1 --port 4173 > preview.log 2>&1 &
          PREVIEW_PID=$!

          cleanup() {
            kill "$PREVIEW_PID" 2>/dev/null || true
          }
          trap cleanup EXIT

          ready=0
          for i in $(seq 1 30); do
            if curl --fail --silent --show-error \
              http://127.0.0.1:4173/ > /dev/null; then
              ready=1
              break
            fi

            if ! kill -0 "$PREVIEW_PID" 2>/dev/null; then
              cat preview.log
              exit 1
            fi

            sleep 1
          done

          if [ "$ready" -ne 1 ]; then
            cat preview.log
            exit 1
          fi

          curl --fail --silent --show-error \
            http://127.0.0.1:4173/ > /dev/null

      - name: Upload preview log on failure
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: preview-log-${{ github.sha }}
          path: preview.log
          if-no-files-found: warn

      - name: Upload production artifact
        uses: actions/upload-artifact@v4
        with:
          name: dist-${{ github.sha }}
          path: dist
          if-no-files-found: error

9.1 这个顺序为什么成立

顺序不是唯一方案,但它体现了依赖关系:

  1. checkout 提供源代码和锁文件;
  2. setup-node 提供规定的 Node.js,并配置 npm 缓存;
  3. npm ci 提供可复现的依赖环境;
  4. 类型检查和 Lint 不需要构建结果;
  5. 单元测试验证代码行为;
  6. 构建需要前面安装的依赖和构建时环境变量;
  7. 预览依赖 dist/,所以必须在构建后执行;
  8. 只有构建和预览成功,才上传生产制品。

可以并行化类型检查、Lint 和测试以缩短时间,但并行只改变调度方式,不改变它们各自必须成功的条件。构建任务通常依赖安装步骤,却不一定依赖三项检查的执行结果;是否等全部质量检查通过后再构建,取决于团队对反馈速度和资源消耗的取舍。

9.2 缓存的正确使用

actions/setup-node 的 npm 缓存通常缓存 npm 下载内容,不是 node_modules/,也不是 dist/。缓存命中可以减少下载时间,但 npm ci 仍会按照锁文件重新准备安装结果。

缓存失效时,正确行为是重新下载并继续执行。若流水线只能在缓存命中时工作,说明把缓存误当成了依赖输入。

9.3 并发取消的风险

concurrency:
  group: ci-${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

这表示同一工作流和同一引用上,新运行可以取消旧运行,减少重复消耗。对于 main 上的发布流程要谨慎:如果旧任务已经完成构建但尚未部署,取消策略可能影响发布顺序。质量检查可以激进取消,生产部署通常应使用更严格的串行和审批策略。


十、生产交付时如何复用制品

质量任务上传 dist 后,部署任务可以下载 artifact:

deploy:
  needs: quality
  runs-on: ubuntu-latest
  if: github.ref == 'refs/heads/main' && github.event_name == 'push'

  steps:
    - name: Download production artifact
      uses: actions/download-artifact@v4
      with:
        name: dist-${{ github.sha }}
        path: dist

    - name: Inspect artifact
      run: |
        test -f dist/index.html
        find dist -maxdepth 2 -type f -print

这里的 needs: quality 表示部署任务依赖质量任务成功。test -f 是一个最小制品完整性检查:如果入口文件不存在,部署不应继续。

实际部署到静态托管平台时,还要确认:

  • SPA 路由是否配置了未知路径回退到 index.html
  • 资源路径是否与 base 配置一致;
  • API 地址是否对应目标环境;
  • CDN 是否正确处理旧 HTML 和新资源的缓存关系;
  • artifact 下载内容是否确实来自目标提交。

不要在部署任务里再次执行 npm installnpm run build,除非你明确接受“部署时重新构建”带来的环境差异。


十一、失败路径与诊断方法

11.1 npm ci 失败

常见表现:

npm ci can only install packages when package.json and package-lock.json are in sync

原因通常是开发者修改了 package.json,却没有提交同步更新的锁文件。恢复步骤是:

npm install
git diff package-lock.json
git add package.json package-lock.json
git commit

不能通过在 CI 中改用 npm install 来掩盖问题,因为这样会让 CI 重新解析依赖,降低可重复性。

11.2 类型检查失败但本地通过

应逐项比较:

  1. Node.js 版本;
  2. npm ci 与本地已有 node_modules 的差异;
  3. TypeScript、Vue、vue-tsc 版本;
  4. tsconfig 是否通过环境变量或路径不同;
  5. 本地是否只运行了编辑器诊断,而没有运行完整命令。

本地已有旧依赖时,开发者看到的结果可能不是锁文件对应的结果。使用全新目录执行 npm ci && npm run typecheck,通常更容易复现 CI。

11.3 Lint 检查了不应检查的文件

如果报告来自 dist/、覆盖率目录或临时文件,说明 ESLint 的忽略配置不完整。应优先调整扫描范围和忽略规则,而不是对生成代码大规模添加禁用注释。

同时要避免忽略整个 src/ 或所有 .vue 文件。那会使命令“成功”,但实际没有检查关键代码。

11.4 测试在 CI 中挂起

最常见原因是使用了监听模式:

{
  "scripts": {
    "test": "vitest"
  }
}

CI 应改为:

{
  "scripts": {
    "test": "vitest run"
  }
}

如果测试仍不退出,应检查:

  • 是否创建了未关闭的定时器;
  • 是否启动了未关闭的 HTTP 服务;
  • 是否存在未完成的 Promise;
  • 测试框架配置是否启用了监听;
  • 测试是否依赖真实外部网络。

11.5 构建成功但预览失败

诊断顺序应为:

test -f dist/index.html
find dist -maxdepth 2 -type f -print
npm run preview -- --host 127.0.0.1 --port 4173

如果进程立即退出,读取 preview.log。如果服务器运行但请求失败,检查:

  • 端口是否已被占用;
  • host 是否绑定到了 CI 可访问的地址;
  • dist 输出目录是否被改名;
  • vite.config.ts 是否配置了不同的 outDir
  • 构建是否实际生成了目标环境所需文件。

11.6 预览首页成功但部署后路由 404

这是静态 SPA 常见边界。访问 / 只需要服务器返回 dist/index.html;而访问 /settings 时,静态服务器可能寻找 dist/settings,找不到就返回 404。

修复不在 Vue 组件中,而在部署服务器的回退配置中。例如服务器需要将未知页面路径回退到 index.html,同时不能把不存在的静态资源错误地回退成 HTML。具体配置取决于 Nginx、CDN 或托管平台,不能用 vite preview 的成功替代生产服务器验证。


十二、质量门禁的取舍

1. 是否上传测试覆盖率

覆盖率报告可以帮助发现未执行分支,但覆盖率只是“执行过哪些代码”的统计,不等于断言质量。下面两组测试的行覆盖率可能相近,业务价值却不同:

expect(add(1, 2)).toBe(3)

和:

expect(add(1, 2)).toBe(999)

后者会失败,反而说明断言捕获了行为变化。若启用覆盖率,应将其作为额外证据,并结合分支覆盖率、关键路径和测试审查。

2. 是否把所有检查都设为硬门禁

通常类型检查、Lint、单元测试和生产构建适合成为合并门禁。浏览器端到端测试则可能更慢、更依赖服务和外部资源,可以拆成独立任务,但必须明确哪些失败阻止发布,哪些只产生报告。

关键不是让流水线步骤越多越好,而是使每个硬门禁都对应一种实际风险,并让失败能够定位到明确原因。

3. 不要用 || true 隐藏失败

例如:

npm run typecheck || true

会把非零退出码转换为成功,破坏质量条件中的合取关系。若某条规则暂时只告警,应使用工具或 CI 平台明确表达“告警但不阻塞”,而不是吞掉所有错误。否则后续维护者无法区分“检查通过”和“检查根本失败但被忽略”。


十三、从提交到制品的完整状态转换

一个提交的典型状态可以表示为:

stateDiagram-v2
    [*] --> CheckedOut
    CheckedOut --> DependenciesReady: npm ci 成功
    DependenciesReady --> TypeChecked: vue-tsc 成功
    DependenciesReady --> Linted: eslint 成功
    DependenciesReady --> Tested: vitest run 成功
    TypeChecked --> Built: 所需质量任务满足
    Linted --> Built
    Tested --> Built
    Built --> Previewed: vite preview + HTTP 检查成功
    Previewed --> ArtifactUploaded: 上传 dist 成功
    ArtifactUploaded --> Deployable
    DependenciesReady --> Failed: 安装失败
    TypeChecked --> Failed: 类型错误
    Linted --> Failed: Lint 错误
    Tested --> Failed: 测试失败
    Built --> Failed: 构建失败
    Previewed --> Failed: 服务器或冒烟检查失败
    ArtifactUploaded --> Failed: 上传失败

这个状态图中的“所需质量任务满足”不是 Vite 或 GitHub Actions 自动规定的固定状态,而是团队配置的门禁策略。可以让构建与类型、Lint、测试并行,也可以要求三者全部完成后才开始构建;但最终发布必须消费经过约定检查的构建结果。


十四、最终检查应回答的具体问题

一条合格的 Vue CI 流水线至少应能回答:

  • package.json 与锁文件是否同步?
  • 当前 Node.js 和依赖版本是否明确?
  • .vue 文件是否经过 vue-tsc 检查?
  • Lint 是否覆盖源代码而不是只检查少数文件?
  • 测试是否在 CI 中一次性退出?
  • 构建是否从干净输入生成 dist/
  • 客户端环境变量是否包含敏感信息?
  • vite preview 是否服务了刚刚生成的构建?
  • 预览服务器失败时是否保留日志?
  • curl 检查失败时是否真的让任务失败?
  • 制品是否带有提交标识?
  • 部署是否复用已验证制品,而不是重新构建?
  • SPA 路由、资源路径和目标环境配置是否在真实部署环境中验证?

类型、Lint、测试、构建、预览和制品分别覆盖静态约束、规则约束、行为约束、产物生成、HTTP 可访问性和交付可追踪性。它们组合起来,才能把“代码看起来能运行”推进为“这次提交经过了可重复、可诊断并可交付的验证流程”。


系列导航与关联阅读

官方资料

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