【翻译】我为 Vue 项目定制的 ESLint 配置方案

5 阅读9分钟

原文链接:My Opinionated ESLint Setup for Vue Projects

作者:Alexander Opalic

作为一名拥有 7 年多经验的 Vue 开发者,我逐渐形成了一套极具个人风格的 Vue 组件编写规范。这些规则可能并非对所有人都适用,但我认为值得分享出来,供你挑选适合自己项目的部分。核心目标是通过代码结构约束,让代码对开发者和 AI 代理都具备良好的可读性。

这些规则并非凭空设定 —— 它们源于我曾深入探讨过的多种编码模式:

ESLint 规则正是这些模式的自动化落地 —— 即便团队规模不断扩大,也能确保代码库的一致性。

💡 说明AI 时代为何 linting 愈发重要:随着 AI 代理参与更多代码编写工作,严格的代码检查变得至关重要。它就像一种「反向压力」机制 —— 当 AI 犯错时,自动化反馈能让它自行修正,无需人工干预。你的反馈精力(时间和注意力)是有限的:如果把精力浪费在提醒 AI「遗漏导入」或「类型错误」上,就无法专注于架构决策或复杂逻辑设计。类型检查器、代码检查器和测试套件共同构成了这种反向压力:它们会自动拦截劣质代码,让你无需亲自动手。如今,你的 ESLint 配置已成为提示词的一部分 —— 它是一道自动化质量关卡,能让 AI 持续迭代直至代码符合要求。

目录

  • 为何使用双检查器?Oxlint + ESLint
    • Oxlint:速度王者
  • 配置方式
  • 必选规则
    • 循环复杂度限制
    • 禁止嵌套三元表达式
    • 禁止类型断言
    • 禁止枚举
    • 禁止 else/else-if
    • 禁止原生 try/catch
    • 禁止直接操作 DOM
    • 功能边界约束
    • Vue 组件命名规范
    • Vue 无用代码检测
    • 禁止硬编码国际化字符串
    • 禁止禁用国际化规则
    • 禁止硬编码路由字符串
    • 强制使用集成测试工具函数
    • 强制使用 pnpm 包管理目录
  • 可选规则
    • 强制使用 Vue 3.5+ API
    • 显式组件 API
    • 模板嵌套深度限制
    • 测试中使用更规范的断言
    • 测试结构规则
    • 测试中优先使用 Vitest 定位器
    • Unicorn 规则
  • 自定义本地规则
    • 组合式函数必须依赖 Vue
    • 禁止硬编码 Tailwind 颜色
    • 测试描述块中禁止 let
    • 提取复杂条件表达式
    • 仓库操作必须包裹 tryCatch
  • 完整配置代码
  • 总结

为何使用双检查器?Oxlint + ESLint

我同时使用两款代码检查器:先运行 Oxlint,再运行 ESLint。为何要这样做?核心是为了兼顾速度和检查覆盖面。

Oxlint:速度王者

Oxlint 基于 Rust 开发,在大型代码库上的运行速度比 ESLint 快 50-100 倍。我的提交前钩子执行时间从秒级缩短到毫秒级。

// package.json 中配置
"lint:oxlint": "oxlint . --fix --ignore-path .gitignore",
"lint:eslint": "eslint . --fix --cache",
"lint": "run-s lint:*"  // 先运行 oxlint,再运行 eslint

取舍说明:Oxlint 支持的规则较少,它主要处理:

  • 正确性与可疑模式检查 —— 提早捕获 Bug
  • 核心 ESLint 等效规则 —— 如 no-consoleno-explicit-any
  • TypeScript 基础规则 —— 如 array-typeconsistent-type-definitions

但 Oxlint 缺少以下能力:

  • Vue 专属规则(vue/*
  • 导入边界规则(import-x/*
  • Vitest 测试规则(vitest/*
  • 国际化规则(@intlify/vue-i18n/*
  • 自定义本地规则

配置方式

Oxlint 先运行,提供快速反馈;ESLint 后运行,进行全面检查。eslint-plugin-oxlint 包会告知 ESLint 跳过已被 Oxlint 处理的规则。

// eslint.config.ts
import pluginOxlint from 'eslint-plugin-oxlint'

export default defineConfigWithVueTs(
  // ... 其他配置
  ...pluginOxlint.buildFromOxlintConfigFile('./.oxlintrc.json'),
)
// .oxlintrc.json
{
  "$schema": "./node_modules/oxlint/configuration_schema.json",
  "categories": {
    "correctness": "error",
    "suspicious": "warn"
  },
  "rules": {
    "typescript/no-explicit-any": "error",
    "eslint/no-console": ["error", { "allow": ["warn", "error"] }]
  }
}

必选规则

这些规则能捕获实际 Bug 并确保代码可维护性,建议所有 Vue 项目都启用。

循环复杂度限制

复杂函数难以测试和理解,该规则限制每个函数的分支逻辑数量。

// eslint.config.ts
{
  rules: {
    'complexity': ['warn', { max: 10 }]
  }
}

✗ 反面示例

function processOrder(order: Order) {
  if (order.status === 'pending') {
    if (order.items.length > 0) {
      if (order.payment) {
        if (order.payment.verified) {
          if (order.shipping) {
            // 嵌套 5 层,复杂度持续增加...
          }
        }
      }
    }
  }
}

✓ 正面示例

function processOrder(order: Order) {
  if (!isValidOrder(order)) return

  processPayment(order.payment)
  scheduleShipping(order.shipping)
}

function isValidOrder(order: Order): boolean {
  return order.status === 'pending'
    && order.items.length > 0
    && order.payment?.verified === true
}

阈值参考:

  • ESLint 默认值:20(宽松)
  • 本项目使用:10(严格)
  • 遗留代码库可选用中间值:15

ESLint 规则文档:complexity

禁止嵌套三元表达式

嵌套三元表达式可读性极差,应使用提前返回或独立变量替代。

// eslint.config.ts
{
  rules: {
    'no-nested-ternary': 'error'
  }
}

✗ 反面示例

const label = isLoading ? 'Loading...' : hasError ? 'Failed' : 'Success'

✓ 正面示例

function getLabel() {
  if (isLoading) return 'Loading...'
  if (hasError) return 'Failed'
  return 'Success'
}

const label = getLabel()

ESLint 规则文档:no-nested-ternary

禁止类型断言

类型断言(as Type)会绕过 TypeScript 的类型检查,隐藏潜在 Bug。应使用类型守卫或正确的类型定义替代。

// eslint.config.ts
{
  rules: {
    '@typescript-eslint/consistent-type-assertions': ['error', {
      assertionStyle: 'never'
    }]
  }
}

💡 说明as const 断言始终被允许,即便设置了 assertionStyle: 'never'。常量断言不会绕过类型检查 —— 它只会让类型更具体。

✗ 反面示例

const user = response.data as User  // 如果数据不是 User 类型怎么办?

const element = document.querySelector('.btn') as HTMLButtonElement
element.click()  // 如果 element 是 null,运行时会报错

✓ 正面示例

// 使用类型守卫
function isUser(data: unknown): data is User {
  return typeof data === 'object'
    && data !== null
    && 'id' in data
    && 'name' in data
}

if (isUser(response.data)) {
  const user = response.data  // TypeScript 自动推断为 User 类型
}

// 正确处理 null 情况
const element = document.querySelector('.btn')
if (element instanceof HTMLButtonElement) {
  element.click()
}

TypeScript ESLint 规则文档:consistent-type-assertions

禁止枚举

TypeScript 枚举存在一些缺陷:会生成 JavaScript 代码、支持数字反向映射、行为与联合类型不同。应使用字面量联合或常量对象替代。

// eslint.config.ts
{
  rules: {
    'no-restricted-syntax': ['error', {
      selector: 'TSEnumDeclaration',
      message: '使用字面量联合或 `as const` 常量对象替代枚举。'
    }]
  }
}

✗ 反面示例

enum Status {
  Pending,
  Active,
  Done
}

const status: Status = Status.Pending

✓ 正面示例

// 字面量联合 - 最简单的方式
type Status = 'pending' | 'active' | 'done'

// 如需关联值,使用常量对象
const Status = {
  Pending: 'pending',
  Active: 'active',
  Done: 'done'
} as const

type Status = typeof Status[keyof typeof Status]

ESLint 规则文档:no-restricted-syntax

禁止 else/else-if

elseelse-if 块会增加代码嵌套层级,提前返回更易读且能降低认知负担。

// eslint.config.ts
{
  rules: {
    'no-restricted-syntax': ['error',
      {
        selector: 'IfStatement > IfStatement.alternate',
        message: '避免使用 `else if`,优先使用提前返回或三元表达式。'
      },
      {
        selector: 'IfStatement > :not(IfStatement).alternate',
        message: '避免使用 `else`,优先使用提前返回或三元表达式。'
      }
    ]
  }
}

✗ 反面示例

function getDiscount(user: User) {
  if (user.isPremium) {
    return 0.2
  } else if (user.isMember) {
    return 0.1
  } else {
    return 0
  }
}

✓ 正面示例

function getDiscount(user: User) {
  if (user.isPremium) return 0.2
  if (user.isMember) return 0.1
  return 0
}

ESLint 规则文档:no-restricted-syntax

禁止原生 try/catch

原生 try/catch 块冗长且容易出错,应使用返回结果元组的工具函数替代。

// eslint.config.ts
{
  rules: {
    'no-restricted-syntax': ['error', {
      selector: 'TryStatement',
      message: '使用 @/lib/tryCatch 中的 tryCatch() 替代 try/catch。返回 Result<T> 元组:[error, null] | [null, data]。'
    }]
  }
}

✗ 反面示例

async function fetchUser(id: string) {
  try {
    const response = await api.get(`/users/${id}`)
    return response.data
  } catch (error) {
    console.error(error)
    return null
  }
}

✓ 正面示例

async function fetchUser(id: string) {
  const [error, response] = await tryCatch(api.get(`/users/${id}`))

  if (error) {
    console.error(error)
    return null
  }

  return response.data
}

tryCatch 工具函数返回 [error, null][null, data],类似 Go 语言的错误处理方式。

ESLint 规则文档:no-restricted-syntax

禁止直接操作 DOM

Vue 会管理 DOM,调用 document.querySelector 会绕过 Vue 的响应式系统和模板引用。应使用 useTemplateRef() 替代。如果使用 Vue 3.5+ 版本,内置规则已默认强制此规范。

// eslint.config.ts
{
  files: ['src/**/*.vue'],
  rules: {
    'vue/prefer-use-template-ref': 'error'
  }
}

✗ 反面示例

<script setup lang="ts">
function focusInput() {
  const input = document.getElementById('my-input')
  input?.focus()
}
</script>

<template>
  <input id="my-input" />
</template>

✓ 正面示例

<script setup lang="ts">
import { useTemplateRef } from 'vue'

const inputRef = useTemplateRef<HTMLInputElement>('input')

function focusInput() {
  inputRef.value?.focus()
}
</script>

<template>
  <input ref="input" />
</template>

ESLint 规则文档:no-restricted-syntax

功能边界约束

功能模块不应从其他功能模块导入代码,这样能保持代码模块化并避免循环依赖。如果你使用功能型架构,此规则至关重要 —— 详见《如何规划 Vue 项目结构》。

// eslint.config.ts
{
  plugins: { 'import-x': pluginImportX },
  rules: {
    'import-x/no-restricted-paths': ['error', {
      zones: [
        // === 跨功能隔离 ===
        // 功能模块不能从其他功能模块导入
        { target: './src/features/workout', from: './src/features', except: ['./workout'] },
        { target: './src/features/exercises', from: './src/features', except: ['./exercises'] },
        { target: './src/features/settings', from: './src/features', except: ['./settings'] },
        { target: './src/features/timers', from: './src/features', except: ['./timers'] },
        { target: './src/features/templates', from: './src/features', except: ['./templates'] },
        { target: './src/features/benchmarks', from: './src/features', except: ['./benchmarks'] },

        // === 单向依赖流 ===
        // 共享代码不能从功能模块或视图模块导入
        {
          target: ['./src/components', './src/composables', './src/lib', './src/db', './src/types', './src/stores'],
          from: ['./src/features', './src/views']
        },

        // 功能模块不能从视图模块导入(视图是顶层编排者)
        { target: './src/features', from: './src/views' }
      ]
    }]
  }
}

单向依赖流:架构强制严格的依赖层级 —— 视图编排功能,功能使用共享代码,反之则不允许。

views → features → shared (components, composables, lib, db, types, stores)

✗ 反面示例

// src/features/workout/composables/useWorkout.ts
import { useExerciseData } from '@/features/exercises/composables/useExerciseData'
// 跨功能导入!

✓ 正面示例

// src/features/workout/composables/useWorkout.ts
import { ExerciseRepository } from '@/db/repositories/ExerciseRepository'
// 改用共享数据库层

eslint-plugin-import-x 规则文档:no-restricted-paths

Vue 组件命名规范

一致的命名能让组件更易查找和识别。

// eslint.config.ts
{
  files: ['src/**/*.vue'],
  rules: {
    'vue/multi-word-component-names': ['error', {
      ignores: ['App', 'Layout']
    }],
    'vue/component-definition-name-casing': ['error', 'PascalCase'],
    'vue/component-name-in-template-casing': ['error', 'PascalCase', {
      registeredComponentsOnly: false
    }],
    'vue/match-component-file-name': ['error', {
      extensions: ['vue'],
      shouldMatchCase: true
    }],
    'vue/prop-name-casing': ['error', 'camelCase'],
    'vue/attribute-hyphenation': ['error', 'always'],
    'vue/custom-event-name-casing': ['error', 'kebab-case']
  }
}

✗ 反面示例

<!-- 文件:button.vue -->
<template>
  <base-button>点击</base-button>
</template>

✓ 正面示例

<!-- 文件:SubmitButton.vue -->
<template>
  <BaseButton>点击</BaseButton>
</template>

eslint-plugin-vue 规则文档:组件相关规则

Vue 无用代码检测

在无用代码成为技术债务前,检测出未使用的 props、ref 和 emit。

// eslint.config.ts
{
  files: ['src/**/*.vue'],
  rules: {
    'vue/no-unused-properties': ['error', {
      groups: ['props', 'data', 'computed', 'methods']
    }],
    'vue/no-unused-refs': 'error',
    'vue/no-unused-emit-declarations': 'error'
  }
}

✗ 反面示例

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

const props = defineProps<{
  title: string
  subtitle: string  // 从未使用!
}>()

const emit = defineEmits<{
  (e: 'click'): void
  (e: 'hover'): void  // 从未触发!
}>()

const buttonRef = ref<HTMLButtonElement>()  // 从未使用!
</script>

<template>
  <h1>{{ title }}</h1>
  <button @click="emit('click')">点击</button>
</template>

✓ 正面示例

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

const emit = defineEmits<{
  (e: 'click'): void
}>()
</script>

<template>
  <h1>{{ title }}</h1>
  <button @click="emit('click')">点击</button>
</template>

eslint-plugin-vue 规则文档:no-unused-properties

禁止硬编码国际化字符串

硬编码字符串会破坏国际化功能,@intlify/vue-i18n 插件能捕获这类问题。

// eslint.config.ts
{
  files: ['src/**/*.vue'],
  plugins: { '@intlify/vue-i18n': pluginVueI18n },
  rules: {
    '@intlify/vue-i18n/no-raw-text': ['error', {
      ignorePattern: '^[-#:()&+×/°′″%]+',
      ignoreText: ['kg', 'lbs', 'cm', 'ft/in', '—', '•', '✓', '›', '→', '·', '.', 'Close'],
      attributes: {
        '/.+/': ['title', 'aria-label', 'aria-placeholder', 'placeholder', 'alt']
      }
    }]
  }
}

attributes 选项还能捕获无障碍属性中的硬编码字符串。

✗ 反面示例

<template>
  <button>保存更改</button>
  <p>未找到项目</p>
</template>

✓ 正面示例

<template>
  <button>{{ t('common.save') }}</button>
  <p>{{ t('items.empty') }}</p>
</template>

eslint-plugin-vue-i18n 规则文档:no-raw-text

禁止禁用国际化规则

防止开发者通过 eslint-disable 注释绕过国际化检查。

// eslint.config.ts
{
  files: ['src/**/*.vue'],
  plugins: {
    '@eslint-community/eslint-comments': pluginEslintComments
  },
  rules: {
    '@eslint-community/eslint-comments/no-restricted-disable': [
      'error',
      '@intlify/vue-i18n/*'
    ]
  }
}

✗ 反面示例

<!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text -->
<button>保存更改</button>

✓ 正面示例

<button>{{ t('common.save') }}</button>

@eslint-community/eslint-plugin-eslint-comments 规则文档:no-restricted-disable

禁止硬编码路由字符串

为保证可维护性,应使用命名路由替代硬编码路径字符串。

// eslint.config.ts
{
  rules: {
    'no-restricted-syntax': ['error',
      {
        selector: 'CallExpression[callee.property.name="push"][callee.object.name="router"] > Literal:first-child',
        message: '使用 RouteNames 命名路由替代硬编码路径字符串。'
      },
      {
        selector: 'CallExpression[callee.property.name="push"][callee.object.name="router"] > TemplateLiteral:first-child',
        message: '使用 RouteNames 命名路由替代模板字符串。'
      }
    ]
  }
}

✗ 反面示例

router.push('/workout/123')
router.push(`/workout/${id}`)

✓ 正面示例

router.push({ name: RouteNames.WorkoutDetail, params: { id } })

ESLint 规则文档:no-restricted-syntax

强制使用集成测试工具函数

禁止在测试中直接调用 render()mount(),应使用集中式测试工具函数。关于 Vue 测试策略的更多内容,详见《Vue 3 测试金字塔》。

// eslint.config.ts
{
  files: ['src/**/__tests__/**/*.{ts,spec.ts}'],
  ignores: ['src/__tests__/helpers/**'],
  rules: {
    'no-restricted-imports': ['error', {
      paths: [
        {
          name: 'vitest-browser-vue',
          importNames: ['render'],
          message: '使用 @/__tests__/helpers/createTestApp 中的 createTestApp() 替代。'
        },
        {
          name: '@vue/test-utils',
          importNames: ['mount', 'shallowMount'],
          message: '使用 createTestApp() 替代直接挂载组件。'
        }
      ]
    }]
  }
}

✗ 反面示例

import { render } from 'vitest-browser-vue'
import { mount } from '@vue/test-utils'

const { getByText } = render(MyComponent)
const wrapper = mount(MyComponent)

✓ 正面示例

import { createTestApp } from '@/__tests__/helpers/createTestApp'

const { page } = await createTestApp({ route: '/workout' })

这能确保所有测试都使用一致的配置,包括路由、国际化和数据库。

ESLint 规则文档:no-restricted-imports

强制使用 pnpm 包管理目录

使用 pnpm 工作区时,强制依赖项使用目录引用。

// eslint.config.ts
import { configs as pnpmConfigs } from 'eslint-plugin-pnpm'

export default defineConfigWithVueTs(
  // ... 其他配置
  ...pnpmConfigs.recommended,
)

这能确保依赖项在 pnpm-workspace.yaml 中集中管理。

eslint-plugin-pnpm 规则文档:推荐配置

可选规则

这些规则能提升代码质量,但重要性稍低。建议在必选规则配置完成后再启用。

强制使用 Vue 3.5+ API

使用最新的 Vue 3.5 API 让代码更简洁。

// eslint.config.ts
{
  files: ['src/**/*.vue'],
  rules: {
    'vue/define-props-destructuring': 'error',
    'vue/prefer-use-template-ref': 'error'
  }
}

✗ Vue 3.5 之前的写法

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

const props = defineProps<{ count: number }>()
const buttonRef = ref<HTMLButtonElement>()

console.log(props.count)  // 使用 props. 前缀
</script>

<template>
  <button ref="buttonRef">点击</button>
</template>

✓ Vue 3.5+ 写法

<script setup lang="ts">
import { useTemplateRef } from 'vue'

const { count } = defineProps<{ count: number }>()
const buttonRef = useTemplateRef<HTMLButtonElement>('button')

console.log(count)  // 直接解构访问
</script>

<template>
  <button ref="button">点击</button>
</template>

eslint-plugin-vue 规则文档:define-props-destructuring

显式组件 API

要求使用 defineExposedefineSlots 让组件接口更清晰。

// eslint.config.ts
{
  files: ['src/**/*.vue'],
  rules: {
    'vue/require-expose': 'warn',
    'vue/require-explicit-slots': 'warn'
  }
}

✗ 反面示例

<script setup lang="ts">
function focus() { /* ... */ }
</script>

<template>
  <slot />
</template>

✓ 正面示例

<script setup lang="ts">
defineSlots<{
  default(): unknown
}>()

function focus() { /* ... */ }

defineExpose({ focus })
</script>

<template>
  <slot />
</template>

eslint-plugin-vue 规则文档:require-expose

模板嵌套深度限制

模板嵌套过深难以阅读,应将嵌套部分提取为独立组件。这一点非常重要 —— 能避免组件代码膨胀到 2000 行。

// eslint.config.ts
{
  files: ['src/**/*.vue'],
  rules: {
    'vue/max-template-depth': ['error', { maxDepth: 8 }],
    'vue/max-props': ['error', { maxProps: 6 }]
  }
}

✗ 反面示例

<template>
  <div>
    <div>
      <div>
        <div>
          <div>
            <div>
              <div>
                <div>
                  <span>嵌套太深了!</span>
                </div>
              </div>
            </div>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>

✓ 正面示例

<template>
  <Card>
    <CardHeader>
      <CardTitle>标题</CardTitle>
    </CardHeader>
    <CardContent>
      <span>内容</span>
    </CardContent>
  </Card>
</template>

eslint-plugin-vue 规则文档:max-template-depth

测试中使用更规范的断言

使用特定的匹配器让测试失败信息更清晰。

// eslint.config.ts
{
  files: ['src/**/__tests__/*'],
  rules: {
    'vitest/prefer-to-be': 'error',
    'vitest/prefer-to-have-length': 'error',
    'vitest/prefer-to-contain': 'error',
    'vitest/prefer-mock-promise-shorthand': 'error'
  }
}

✗ 反面示例

expect(value === null).toBe(true)
expect(arr.length).toBe(3)
expect(arr.includes('foo')).toBe(true)

✓ 正面示例

expect(value).toBeNull()
expect(arr).toHaveLength(3)
expect(arr).toContain('foo')

// 优先使用 Mock 简写
vi.fn().mockResolvedValue('data')  // 替代 mockReturnValue(Promise.resolve('data'))

eslint-plugin-vitest 规则文档:断言相关规则

测试结构规则

保持测试代码的组织性和可读性。

// eslint.config.ts
{
  files: ['src/**/__tests__/*'],
  rules: {
    'vitest/consistent-test-it': ['error', { fn: 'it' }],
    'vitest/prefer-hooks-on-top': 'error',
    'vitest/prefer-hooks-in-order': 'error',
    'vitest/no-duplicate-hooks': 'error',
    'vitest/require-top-level-describe': 'error',
    'vitest/max-nested-describe': ['error', { max: 2 }],
    'vitest/no-conditional-in-test': 'warn'
  }
}

✗ 反面示例

test('works', () => {})  // 不一致:test 和 it 混用
it('also works', () => {})

describe('feature', () => {
  it('test 1', () => {})

  beforeEach(() => {})  // 钩子在测试之后!

  describe('nested', () => {
    describe('too deep', () => {
      describe('way too deep', () => {})  // 嵌套 3 层!
    })
  })
})

✓ 正面示例

describe('feature', () => {
  beforeEach(() => {})  // 钩子在前,按顺序排列

  it('执行某个操作', () => {})
  it('执行另一个操作', () => {})

  describe('边界情况', () => {
    it('处理 null', () => {})
  })
})

// no-conditional-in-test 避免不稳定测试
// 反面:if (data.length > 0) { expect(data[0]).toBeDefined() }
// 正面:expect(data).toHaveLength(3); expect(data[0]).toBeDefined()

eslint-plugin-vitest 规则文档:结构相关规则

测试中优先使用 Vitest 定位器

使用 Vitest Browser 定位器替代原生 DOM 查询。

// eslint.config.ts
{
  files: ['src/**/__tests__/**/*.{ts,spec.ts}'],
  rules: {
    'no-restricted-syntax': ['warn', {
      selector: 'CallExpression[callee.property.name=/^querySelector(All)?$/]',
      message: '优先使用 page.getByRole()、page.getByText() 或 page.getByTestId() 替代 querySelector。Vitest 定位器对 DOM 变化的适应性更强。'
    }]
  }
}

✗ 反面示例

const button = container.querySelector('.submit-btn')
await button?.click()

✓ 正面示例

const button = page.getByRole('button', { name: '提交' })
await button.click()

相关文档:Vitest Browser 模式

Unicorn 规则

eslint-plugin-unicorn 包能捕获常见错误并强制现代 JavaScript 模式。

// eslint.config.ts
pluginUnicorn.configs.recommended,

{
  name: 'app/unicorn-overrides',
  rules: {
    // === 启用有价值的非推荐规则 ===
    'unicorn/better-regex': 'warn',              // 简化正则:/[0-9]/ → /\d/
    'unicorn/custom-error-definition': 'error',  // 正确的 Error 子类化
    'unicorn/no-unused-properties': 'warn',      // 无用代码检测
    'unicorn/consistent-destructuring': 'warn',  // 一致地使用解构变量

    // === 禁用与项目规范冲突的规则 ===
    'unicorn/no-null': 'off',                    // 数据库值使用 null
    'unicorn/filename-case': 'off',              // Vue 使用 PascalCase,测试使用 camelCase
    'unicorn/prevent-abbreviations': 'off',      // props、e、Db 等缩写是可接受的
    'unicorn/no-array-callback-reference': 'off', // arr.filter(isValid) 是合理的
    'unicorn/no-await-expression-member': 'off', // (await fetch()).json() 是合理的
    'unicorn/no-array-reduce': 'off',            // reduce 对聚合操作很有用
    'unicorn/no-useless-undefined': 'off'        // TypeScript 中 mockResolvedValue(undefined) 是合理的
  }
}

示例:

// unicorn/better-regex
// 反面:/[0-9]/
// 正面:/\d/

// unicorn/consistent-destructuring
// 反面:
const { foo } = object
console.log(object.bar)  // 使用 object.bar 而非解构

// 正面:
const { foo, bar } = object
console.log(bar)

eslint-plugin-unicorn 规则文档:规则列表

自定义本地规则

有时你需要的规则并不存在,这时可以自己编写。

组合式函数必须依赖 Vue

文件名以 use*.ts 命名的文件必须导入 Vue 相关模块。如果没有导入,它应该是一个工具函数,而非组合式函数。关于编写规范组合式函数的更多内容,详见《Vue 组合式函数风格指南》。

// eslint-local-rules/composable-must-use-vue.ts
const VALID_VUE_SOURCES = new Set(['vue', '@vueuse/core', 'vue-router', 'vue-i18n'])
const VALID_PATH_PATTERNS = [/^@/stores//]  // 全局状态组合式函数也符合要求

function isComposableFilename(filename: string): boolean {
  return /^use[A-Z]/.test(path.basename(filename, '.ts'))
}

const rule: Rule.RuleModule = {
  meta: {
    messages: {
      notAComposable: '文件 "{{filename}}" 未导入 Vue 相关模块。请重命名文件或添加 Vue 导入。'
    }
  },
  create(context) {
    if (!isComposableFilename(context.filename)) return {}

    let hasVueImport = false

    return {
      ImportDeclaration(node) {
        if (VALID_VUE_SOURCES.has(node.source.value)) {
          hasVueImport = true
        }
      },
      'Program:exit'(node) {
        if (!hasVueImport) {
          context.report({ node, messageId: 'notAComposable' })
        }
      }
    }
  }
}

✗ 反面示例

// src/composables/useFormatter.ts
export function useFormatter() {
  return {
    formatDate: (d: Date) => d.toISOString()  // 无 Vue 导入!
  }
}

✓ 正面示例

// src/lib/formatter.ts (重命名)
export function formatDate(d: Date) {
  return d.toISOString()
}

// 或添加 Vue 响应式特性:
// src/composables/useFormatter.ts
import { computed, ref } from 'vue'

export function useFormatter() {
  const locale = ref('en-US')
  const formatter = computed(() => new Intl.DateTimeFormat(locale.value))
  return { formatter, locale }
}

禁止硬编码 Tailwind 颜色

硬编码 Tailwind 颜色(bg-blue-500)会导致主题定制失效,应使用语义化颜色(bg-primary)。

// eslint-local-rules/no-hardcoded-colors.ts
// 状态颜色(red、amber、yellow、green、emerald)允许用于语义化状态标识
const HARDCODED_COLORS = ['slate', 'gray', 'zinc', 'blue', 'purple', 'pink', 'orange', 'indigo', 'violet']
const COLOR_UTILITIES = ['bg', 'text', 'border', 'ring', 'fill', 'stroke']

const rule: Rule.RuleModule = {
  meta: {
    messages: {
      noHardcodedColor: '避免使用 "{{color}}"。使用语义化类名,如 bg-primary、text-foreground。'
    }
  },
  create(context) {
    return {
      Literal(node) {
        if (typeof node.value !== 'string') return

        const matches = findHardcodedColors(node.value)
        for (const color of matches) {
          context.report({ node, messageId: 'noHardcodedColor', data: { color } })
        }
      }
    }
  }
}

✗ 反面示例

<template>
  <button class="bg-blue-500 text-white">点击</button>
</template>

✓ 正面示例

<template>
  <button class="bg-primary text-primary-foreground">点击</button>
</template>

💡 说明

状态颜色(redamberyellowgreenemerald)特意允许用于错误 / 警告 / 成功等状态标识。仅在表示语义化状态时使用这些颜色,不要用于通用样式。

测试描述块中禁止 let

测试描述块中的可变变量会创建隐藏状态,应使用设置函数替代。

// eslint-local-rules/no-let-in-describe.ts
const rule: Rule.RuleModule = {
  meta: {
    messages: {
      noLetInDescribe: '描述块中避免使用 `let`,使用设置函数替代。'
    }
  },
  create(context) {
    let describeDepth = 0

    return {
      CallExpression(node) {
        if (isDescribeCall(node)) describeDepth++
      },
      'CallExpression:exit'(node) {
        if (isDescribeCall(node)) describeDepth--
      },
      VariableDeclaration(node) {
        if (describeDepth > 0 && node.kind === 'let') {
          context.report({ node, messageId: 'noLetInDescribe' })
        }
      }
    }
  }
}

✗ 反面示例

describe('登录', () => {
  let user: User

  beforeEach(() => {
    user = createUser()  // 隐藏的变量修改!
  })

  it('正常工作', () => {
    expect(user.name).toBe('test')
  })
})

✓ 正面示例

describe('登录', () => {
  function setup() {
    return { user: createUser() }
  }

  it('正常工作', () => {
    const { user } = setup()
    expect(user.name).toBe('test')
  })
})

提取复杂条件表达式

复杂布尔表达式应命名,提取为独立变量。

// eslint-local-rules/extract-condition-variable.ts
const OPERATOR_THRESHOLD = 2  // 包含 2 个及以上逻辑运算符的条件需要提取

const rule: Rule.RuleModule = {
  meta: {
    messages: {
      extractCondition: '复杂条件应提取为命名常量。'
    }
  },
  create(context) {
    return {
      IfStatement(node) {
        // 跳过 TypeScript 需要内联用于类型收窄的模式
        if (isEarlyExitGuard(node.consequent)) return  // if (!x) return
        if (hasOptionalChaining(node.test)) return      // if (user?.name)
        if (hasTruthyNarrowingPattern(node.test)) return // if (arr && arr[0])

        if (countOperators(node.test) >= OPERATOR_THRESHOLD) {
          context.report({ node: node.test, messageId: 'extractCondition' })
        }
      }
    }
  }
}

智能例外:该规则跳过几种 TypeScript 需要内联用于类型收窄的模式:

  • 提前退出守卫:if (!user) return
  • 可选链:if (user?.name)
  • 真值收窄:if (arr && arr[0])

✗ 反面示例

if (user.isActive && user.role === 'admin' && !user.isBanned) {
  showAdminPanel()
}

✓ 正面示例

const canAccessAdminPanel = user.isActive && user.role === 'admin' && !user.isBanned

if (canAccessAdminPanel) {
  showAdminPanel()
}

仓库操作必须包裹 tryCatch

数据库调用可能失败,强制用 tryCatch() 包裹。

// eslint-local-rules/repository-trycatch.ts
// 匹配模式:get*Repository().method()
const REPO_PATTERN = /^get\w+Repository$/

const rule: Rule.RuleModule = {
  meta: {
    messages: {
      missingTryCatch: '仓库调用必须用 tryCatch() 包裹。'
    }
  },
  create(context) {
    return {
      AwaitExpression(node) {
        if (!isRepositoryMethodCall(node.argument)) return
        if (isWrappedInTryCatch(context, node)) return

        context.report({ node, messageId: 'missingTryCatch' })
      }
    }
  }
}

✗ 反面示例

const workouts = await getWorkoutRepository().findAll()  // 可能抛出异常!

✓ 正面示例

const [error, workouts] = await tryCatch(getWorkoutRepository().findAll())

if (error) {
  showError('加载训练计划失败')
  return
}

💡 说明该规则匹配 get*Repository() 模式,请确保你的仓库工厂函数遵循此命名规范。

完整配置代码

完整的 eslint.config.ts 示例:

import pluginEslintComments from '@eslint-community/eslint-plugin-eslint-comments'
import pluginVueI18n from '@intlify/eslint-plugin-vue-i18n'
import pluginVitest from '@vitest/eslint-plugin'
import skipFormatting from '@vue/eslint-config-prettier/skip-formatting'
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
import pluginImportX from 'eslint-plugin-import-x'
import pluginOxlint from 'eslint-plugin-oxlint'
import { configs as pnpmConfigs } from 'eslint-plugin-pnpm'
import pluginUnicorn from 'eslint-plugin-unicorn'
import pluginVue from 'eslint-plugin-vue'
import localRules from './eslint-local-rules'

export default defineConfigWithVueTs(
  { ignores: ['**/dist/**', '**/coverage/**', '**/node_modules/**'] },

  pluginVue.configs['flat/essential'],
  vueTsConfigs.recommended,
  pluginUnicorn.configs.recommended,

  // Vue 组件规则
  {
    files: ['src/**/*.vue'],
    rules: {
      'vue/multi-word-component-names': ['error', { ignores: ['App', 'Layout'] }],
      'vue/component-name-in-template-casing': ['error', 'PascalCase'],
      'vue/prop-name-casing': ['error', 'camelCase'],
      'vue/custom-event-name-casing': ['error', 'kebab-case'],
      'vue/no-unused-properties': ['error', { groups: ['props', 'data', 'computed', 'methods'] }],
      'vue/no-unused-refs': 'error',
      'vue/define-props-destructuring': 'error',
      'vue/prefer-use-template-ref': 'error',
      'vue/max-template-depth': ['error', { maxDepth: 8 }],
    },
  },

  // TypeScript 风格指南
  {
    files: ['src/**/*.{ts,vue}'],
    rules: {
      'complexity': ['warn', { max: 10 }],
      'no-nested-ternary': 'error',
      '@typescript-eslint/consistent-type-assertions': ['error', { assertionStyle: 'never' }],
      'no-restricted-syntax': ['error',
        { selector: 'TSEnumDeclaration', message: '使用字面量联合替代枚举。' },
        { selector: 'IfStatement > :not(IfStatement).alternate', message: '避免使用 else,优先使用提前返回。' },
        { selector: 'TryStatement', message: '使用 tryCatch() 替代 try/catch。' },
      ],
    },
  },

  // 功能边界规则
  {
    files: ['src/**/*.{ts,vue}'],
    plugins: { 'import-x': pluginImportX },
    rules: {
      'import-x/no-restricted-paths': ['error', {
        zones: [
          { target: './src/features/workout', from: './src/features', except: ['./workout'] },
          // ... 其他功能模块
          { target: './src/features', from: './src/views' },  // 单向依赖流
        ]
      }],
    },
  },

  // 国际化规则
  {
    files: ['src/**/*.vue'],
    plugins: { '@intlify/vue-i18n': pluginVueI18n },
    rules: {
      '@intlify/vue-i18n/no-raw-text': ['error', { /* 配置项 */ }],
    },
  },

  // 禁止禁用国际化规则
  {
    files: ['src/**/*.vue'],
    plugins: { '@eslint-community/eslint-comments': pluginEslintComments },
    rules: {
      '@eslint-community/eslint-comments/no-restricted-disable': ['error', '@intlify/vue-i18n/*'],
    },
  },

  // Vitest 规则
  {
    files: ['src/**/__tests__/*'],
    ...pluginVitest.configs.recommended,
    rules: {
      'vitest/consistent-test-it': ['error', { fn: 'it' }],
      'vitest/prefer-hooks-on-top': 'error',
      'vitest/prefer-hooks-in-order': 'error',
      'vitest/no-duplicate-hooks': 'error',
      'vitest/max-nested-describe': ['error', { max: 2 }],
      'vitest/no-conditional-in-test': 'warn',
    },
  },

  // 强制使用测试工具函数
  {
    files: ['src/**/__tests__/**/*.{ts,spec.ts}'],
    rules: {
      'no-restricted-imports': ['error', {
        paths: [
          { name: 'vitest-browser-vue', importNames: ['render'], message: '使用 createTestApp()' },
          { name: '@vue/test-utils', importNames: ['mount'], message: '使用 createTestApp()' },
        ]
      }],
    },
  },

  // 本地规则
  {
    files: ['src/**/*.{ts,vue}'],
    plugins: { local: localRules },
    rules: {
      'local/no-hardcoded-colors': 'error',
      'local/composable-must-use-vue': 'error',
      'local/repository-trycatch': 'error',
      'local/extract-condition-variable': 'error',
      'local/no-let-in-describe': 'error',
    },
  },

  // 禁用 Oxlint 已处理的规则
  ...pluginOxlint.buildFromOxlintConfigFile('./.oxlintrc.json'),

  // pnpm 目录强制规则
  ...pnpmConfigs.recommended,

  skipFormatting,
)

总结

类别规则目的
必选complexity限制函数复杂度
必选no-nested-ternary条件表达式可读性
必选consistent-type-assertions禁止不安全的 as 类型转换
必选no-restricted-syntax(枚举)