原文链接:My Opinionated ESLint Setup for Vue Projects
作为一名拥有 7 年多经验的 Vue 开发者,我逐渐形成了一套极具个人风格的 Vue 组件编写规范。这些规则可能并非对所有人都适用,但我认为值得分享出来,供你挑选适合自己项目的部分。核心目标是通过代码结构约束,让代码对开发者和 AI 代理都具备良好的可读性。
这些规则并非凭空设定 —— 它们源于我曾深入探讨过的多种编码模式:
- 《如何编写整洁的 Vue 组件》(2024 年 1 月 28 日):解释了我为何将业务逻辑拆分到纯函数中
- 《如何规划 Vue 项目结构》(2024 年 5 月 12 日):详细介绍了我的功能型架构方案
- 《使用 Nuxt Layers 构建模块化单体应用》(2025 年 11 月 2 日):将功能隔离思想应用于 Nuxt 项目
- 《TypeScript 中
as关键字的问题》(2024 年 1 月 21 日):阐述了我禁用类型断言的原因 - 《TypeScript 健壮的错误处理》(2023 年 11 月 18 日):介绍了
tryCatch规则背后的 Result 模式 - 《Vue 3 测试金字塔》(2025 年 12 月 14 日):解释了我的集成优先测试策略
- 《前端测试指南》(2024 年 10 月 26 日):分享了我的测试命名规范
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-console、no-explicit-any - TypeScript 基础规则 —— 如
array-type、consistent-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
else 和 else-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
要求使用 defineExpose 和 defineSlots 让组件接口更清晰。
// 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>
💡 说明
状态颜色(
red、amber、yellow、green、emerald)特意允许用于错误 / 警告 / 成功等状态标识。仅在表示语义化状态时使用这些颜色,不要用于通用样式。
测试描述块中禁止 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(枚举) | 用 |