Vue 编码规约

本规约旨在为 Vue 相关的代码编写提供统一的标准与规范。遵循本风格指南不仅有助于规避编码中的常见错误,亦能保证整个项目代码的一致性,降低后续人员的维护和阅读成本。


1 必要规约(强制)

  • 1.1 组件名应该始终是多个单词的。eslint: vue/multi-word-component-names
  • 1.1 Prop 定义应该尽量详细。eslint: vue/require-prop-types vue/require-default-prop
    • 这起到了组件 API 文档的作用,方便其他开发人员查阅其使用方式。
    • 在开发模式下,当传入了类型不符的属性值时,Vue 会在控制台输出警告,帮助快速排查错误。
  • 1.1 必须为 v-for 设置键值 key。eslint: vue/require-v-for-key
  • 1.1 避免 v-if 和 v-for 用在一起。eslint: vue/no-use-v-if-with-v-for
    • 为了按条件过滤列表项(例如:v-for="user in users" v-if="user.isActive")。此时应将数据源替换为一个只返回过滤后结果的计算属性(例如 activeUsers)。
    • 为了防止渲染本应隐藏的列表(例如:v-for="user in users" v-if="shouldShowUsers")。此时应将 v-if 指令上移至外层包裹元素(如 ul, ol<template>)上。
  • 1.1 为组件样式设置作用域。

以下规则旨在规避编码中的严重错误或逻辑冲突。开发人员应在所有场景中严格遵守,除非是在极其罕见的边缘场景中且完全理解底层运行机制。

除了根组件 App 之外,自定义组件的名称应该始终由多个单词组成。这样可以避免与现有的和未来的原生 HTML 元素(因为原生 HTML 标签名都是单个单词)发生冲突。

html
<!-- 糟糕的示例 -->
<Item />
<item></item>

<!-- 推荐的示例 -->
<TodoItem />
<todo-item></todo-item>

在提交的代码中,组件的 Prop 属性声明应该尽可能详细,至少应当指定其类型(Type)。

为什么?

js
// 选项式 API (Options API)
// 糟糕的示例 - 仅在原型开发时可接受
props: ['status']

// 推荐的示例
props: {
  status: String
}

// 更好的示例
props: {
  status: {
    type: String,
    required: true,
    validator: (value) => {
      return ['syncing', 'synced', 'version-conflict', 'error'].includes(value)
    }
  }
}
js
// 组合式 API (Composition API)
// 糟糕的示例 - 仅在原型开发时可接受
const props = defineProps(['status'])

// 推荐的示例
const props = defineProps({
  status: String,
})

// 更好的示例
const props = defineProps({
  status: {
    type: String,
    required: true,
    validator: (value) => {
      return ['syncing', 'synced', 'version-conflict', 'error'].includes(value)
    },
  },
})

在组件以及原生元素上,v-for 指令的使用必须伴随着唯一的 key 属性绑定,以便在更新子树时维护元素或组件的内部状态(例如,输入框焦点、动画中的状态一致性等)。

html
<!-- 糟糕的示例 -->
<ul>
  <li v-for="todo in todos">{{ todo.text }}</li>
</ul>

<!-- 推荐的示例 -->
<ul>
  <li v-for="todo in todos" :key="todo.id">{{ todo.text }}</li>
</ul>

永远不要在同一个 HTML 元素上同时挂载 v-ifv-for

常见错误场景及解决思路:

html
<!-- 糟糕的示例 -->
<!-- 在 Vue 3 中 v-if 拥有比 v-for 更高的优先级,此时在计算 v-if 时迭代变量 user 尚未定义,会导致报错 -->
<ul>
  <li v-for="user in users" v-if="user.isActive" :key="user.id">
    {{ user.name }}
  </li>
</ul>
js
// 推荐的示例 - 使用计算属性过滤
const activeUsers = computed(() => {
  return users.value.filter((user) => user.isActive)
})
html
<!-- 推荐的示例 - 配合计算属性的 HTML 模板 -->
<ul>
  <li v-for="user in activeUsers" :key="user.id">{{ user.name }}</li>
</ul>

<!-- 推荐的示例 - 使用 <template> 进行包裹判断 -->
<ul>
  <template v-for="user in users" :key="user.id">
    <li v-if="user.isActive">{{ user.name }}</li>
  </template>
</ul>

对于应用层面的开发,顶级 App 组件以及主要的布局(Layout)组件中的样式可以保持全局,但其他所有的子组件都应该有明确的样式隔离范围。

隔离策略: 在单文件组件(SFC)中,并不要求一定使用 scoped 属性,你可以通过使用 CSS Modules、类似 BEM 的 CSS 命名约定,或其他的样式设计方案来实现局部作用域。

IMPORTANT

对于向外部发布的产品级组件库,应该优先使用基于特定类名前缀(如 BEM 规则)的样式隔离方案,以取代 scoped 属性。这可以使外部开发者在覆盖组件内部样式时更加容易,且避免样式选择器的特异性(Specificity)过高。

vue
<!-- 糟糕的示例 -->
<template>
  <button class="btn btn-close">×</button>
</template>

<style>
/* 容易污染其他组件的全局命名空间 */
.btn-close {
  background-color: red;
}
</style>
vue
<!-- 推荐的示例 - 使用 scoped 属性 -->
<template>
  <button class="button button-close">×</button>
</template>

<style scoped>
.button {
  border: none;
  border-radius: 2px;
}
.button-close {
  background-color: red;
}
</style>
vue
<!-- 推荐的示例 - 使用 CSS Modules -->
<template>
  <button :class="[$style.button, $style.buttonClose]">×</button>
</template>

<style module>
.button {
  border: none;
  border-radius: 2px;
}
.buttonClose {
  background-color: red;
}
</style>
vue
<!-- 推荐的示例 - 使用 BEM 命名规范 -->
<template>
  <button class="c-Button c-Button--close">×</button>
</template>

<style>
.c-Button {
  border: none;
  border-radius: 2px;
}
.c-Button--close {
  background-color: red;
}
</style>

2 强烈推荐规约(推荐)

  • 2.1 单个组件应有独立的文件。
  • 2.1 单文件组件的文件名应该始终是 PascalCase 或 kebab-case。
    • PascalCase 与编辑器在 JS/JSX 以及 Vue 模板中的自动提示和代码补全表现最契合。
    • 混合使用不同的大小写规范,在对大小写不敏感的文件系统(例如 macOS 的默认配置)中,可能会在 Git 提交或部署在 Linux 服务器时导致令人头疼的找不到文件报错。
  • 2.1 应用特定样式的基组件应该全部以特定的前缀开头。
  • 2.1 和父组件紧密耦合的子组件应该以父组件名作为前缀。
  • 2.1 组件名中的单词顺序应该以高级/通用的词开头。
  • 2.1 组件名应倾向于完整单词而非缩写。
  • 2.2 无内容的组件在单文件组件、字符串模板和 JSX 中应自闭合。eslint: vue/html-self-closing
  • 2.2 模板中的组件名应该采用 PascalCase。eslint: vue/component-name-in-template-casing
    • 与 JS 的导入声明直接吻合,使得查找组件定义极其方便。
    • 能在视觉上与普通的单单词原生 HTML 元素形成明显的反差。
  • 2.2 JS/JSX 中的组件名应该始终为 PascalCase。eslint: vue/component-definition-name-casing
  • 2.2 Prop 命名应该在声明时使用 camelCase,在 DOM 模板中使用 kebab-case。eslint: vue/attribute-hyphenation
  • 2.2 多个属性的元素应该分多行撰写。eslint: vue/max-attributes-per-line
  • 2.2 模板中只应该包含简单的表达式。
  • 2.2 复杂的计算属性应该分割为尽可能多的简单计算属性。
  • 2.2 非空的 HTML 属性值应该始终加双引号。eslint: vue/html-quotes
  • 2.2 指令简写应保持一致。eslint: vue/v-bind-style vue/v-on-style vue/v-slot-style

这些规则能够极大地改善大部分 Vue 项目中的代码可读性与开发效率。即便违反这些规则您的代码也能正常运行,但在无合理及明确的理由下,不应违反这些规则。

只要项目搭建了构建系统(如 Vite、Webpack),每个自定义的组件都应该声明在自己独立的单文件(SFC)或脚本文件里。这有助于您在后续的重构与维护中以极高的效率检索和定位目标代码。

js
// 糟糕的示例 - 将多个组件堆叠在同一个全局模块内
app.component('TodoList', {
  /* ... */
})
app.component('TodoItem', {
  /* ... */
})
bash
# 推荐的示例 - 清晰的目录与独立的文件
components/
|- TodoList.vue
|- TodoItem.vue

单文件组件(SFC)的文件名格式在项目范围内必须保持统一,要么全部采用大驼峰命名法(PascalCase),要么全部采用连字符命名法(kebab-case)。

为什么?

bash
# 糟糕的示例
components/
|- mycomponent.vue
|- myComponent.vue

# 推荐的示例
components/
|- MyComponent.vue
# 或者
components/
|- my-component.vue

无状态的、纯展示性的基础组件(例如包裹特定样式的按钮、表格、弹窗等),其文件名应当以统一的前缀开头(例如 BaseAppV)。

特征描述: 这些组件不应包含特定的业务逻辑,也绝对不可以依赖全局状态存储(例如 Pinia 的 store),通常只包含基础 HTML 标签、其他基础组件或第三方组件包。

bash
# 糟糕的示例
components/
|- MyButton.vue
|- VueTable.vue
|- Icon.vue

# 推荐的示例
components/
|- BaseButton.vue
|- BaseTable.vue
|- BaseIcon.vue
# 或者
components/
|- AppButton.vue
|- AppTable.vue
# 或者
components/
|- VButton.vue
|- VTable.vue

如果某个组件只在某个特定父组件的上下文中才有意义(如 Todo 列表的单项、搜索栏中的关闭按钮),那么这种依赖关系应当在它的名称中体现,使用父组件名作为前缀。

TIP

我们不鼓励通过深度嵌套子目录的方式来维护这种依赖(例如 components/TodoList/Item/Button.vue),这样会在编辑器中出现大片同名(如 index.vue)文件,大大增加快速切换文件的成本。

bash
# 糟糕的示例
components/
|- TodoList.vue
|- TodoItem.vue
|- TodoButton.vue

# 推荐的示例
components/
|- TodoList.vue
|- TodoListItem.vue
|- TodoListItemButton.vue

组件命名应当以最抽象、最通用的分类词(如 SearchSettings)作为开头,以具体的修饰性或者终结功能的名词(如 ButtonInputCheckboxClear)结尾。

为什么? 在编辑器以及文件树展示中,文件通常按字母顺序排序。这种命名法能够保证属于同一功能模块的组件天然堆叠在一起,便于直观了解。

bash
# 糟糕的示例
components/
|- ClearSearchButton.vue
|- ExcludeFromSearchInput.vue
|- LaunchOnStartupCheckbox.vue
|- RunSearchButton.vue

# 推荐的示例
components/
|- SearchButtonClear.vue
|- SearchButtonRun.vue
|- SearchInputQuery.vue
|- SettingsCheckboxLaunchOnStartup.vue
|- SettingsCheckboxTerms.vue

组件命名时应尽量使用英文全称,避免使用团队成员可能不熟悉的生僻简写。

bash
# 糟糕的示例
components/
|- SdSettings.vue
|- UProfOpts.vue

# 推荐的示例
components/
|- StudentDashboardSettings.vue
|- UserProfileOptions.vue

没有任何子项和插槽内容的组件应当自闭合。这不仅可以减少多余的闭合标签,提高代码的紧凑程度,更能清晰地向他人表明该组件“被设计为”不包含任何子内容。

WARNING

由于传统的 HTML 规范不接受非 Void 的自定义元素自闭合,所以在直接嵌入在 HTML DOM 文件中的模板中,绝对不要使用自闭合写法,必须写全闭合标签。

vue
<!-- 糟糕的示例 (在单文件组件 SFC 中) -->
<MyComponent></MyComponent>

<!-- 糟糕的示例 (在原生 DOM 页面模板中) -->
<my-component />
vue
<!-- 推荐的示例 (在单文件组件 SFC 中) -->
<MyComponent />

<!-- 推荐的示例 (在原生 DOM 页面模板中) -->
<my-component></my-component>

在单文件组件(SFC)和字符串模板中,引入的自定义组件推荐始终使用大驼峰(PascalCase)。

优势:

(对于原生 DOM 页面模板,受限于 HTML 解析器的大小写不敏感属性,依然必须使用连字符 kebab-case 写法)

html
<!-- 糟糕的示例 (SFC) -->
<mycomponent />
<myComponent />

<!-- 推荐的示例 (SFC) -->
<MyComponent />

<!-- 推荐的示例 (DOM 模板) -->
<my-component></my-component>

在 JS 文件或 JSX 代码中声明和引用组件时,应统一采用 PascalCase 规范。

js
// 糟糕的示例
import myComponent from './MyComponent.vue'
import MyComponent from './MyComponent.vue'

app.component('MyComponent', {
  /* ... */
})

// 推荐的示例
app.component('MyComponent', {
  /* ... */
})

在声明 Props 的 JS/TS 脚本中应当使用小驼峰(camelCase)。但在 HTML 原生模板传递属性时,应配合 HTML 规范转化为连字符(kebab-case)格式。

(在单文件组件的模板与 JSX 内,也可以统一采用 camelCase 传参,但前提是必须保证项目范围内的一致性,且不可在同一个文件中混用两种风格)

js
// 糟糕的示例
const props = defineProps({
  'greeting-text': String,
})
html
<!-- 糟糕的示例 (DOM 模板内使用小驼峰) -->
<welcome-message greetingText="hi"></welcome-message>
js
// 推荐的示例 - 声明时使用小驼峰
const props = defineProps({
  greetingText: String,
})
html
<!-- 推荐的示例 (SFC 模板中保持统一) -->
<WelcomeMessage greeting-text="hi" />

<!-- 推荐的示例 (DOM 模板中必须转成连字符) -->
<welcome-message greeting-text="hi"></welcome-message>

当一个标签上绑定的指令或属性过多时,为了提高代码的易读性,应当进行换行,每一个属性独占一行。

html
<!-- 糟糕的示例 -->
<MyComponent foo="a" bar="b" baz="c" @click="handleClick" />

<!-- 推荐的示例 -->
<MyComponent foo="a" bar="b" baz="c" @click="handleClick" />

HTML 模板应该专注于描述“展示什么(What)”,而不应当包含过于冗长复杂的逻辑运算。复杂的运算表达式应当被抽离并重构为计算属性(Computed)或方法(Methods),这还能提高代码复用的可能。

html
<!-- 糟糕的示例 -->
{{ fullName.split(' ').map((word) => { return word[0].toUpperCase() +
word.slice(1) }).join(' ') }}
vue
<!-- 推荐的示例 -->
<template>
  {{ normalizedFullName }}
</template>

<script setup>
const normalizedFullName = computed(() => {
  return fullName.value
    .split(' ')
    .map((word) => word[0].toUpperCase() + word.slice(1))
    .join(' ')
})
</script>

不要在单个计算属性中处理多步无关的高复杂逻辑,应将其拆分为逻辑独立、语义化命名的多个小计算属性。这有助于团队编写对应的单元测试,增强代码的复用性与后期可维护性。

js
// 糟糕的示例 - 单个计算属性处理多重结算
const price = computed(() => {
  const basePrice = manufactureCost.value / (1 - profitMargin.value)
  return basePrice - basePrice * (discountPercent.value || 0)
})

// 推荐的示例 - 职责拆分与解耦 const basePrice = computed(() => manufactureCost.value / (1 - profitMargin.value)) const discount = computed(() => basePrice.value * (discountPercent.value || 0)) const finalPrice = computed(() => basePrice.value - discount.value)



虽然在不含特殊字符时,HTML 允许属性值省略引号,但这在编写包含空格的参数时非常容易导致语法隐患。请统一加上双引号。

```html static
<!-- 糟糕的示例 -->
<input type=text>
<AppSidebar :style={width:sidebarWidth+'px'}>

<!-- 推荐的示例 -->
<input type="text">
<AppSidebar :style="{ width: sidebarWidth + 'px' }">

在整个项目中,对于常用的 Vue 指令简写形式(使用 : 代替 v-bind:@ 代替 v-on:# 代替 v-slot:),应当采用“要么全部使用简写,要么全部使用全称”的一致性规范。建议全团队统一采用简写格式。

html
<!-- 糟糕的示例 - 混用简写与全称 -->
<input v-bind:value="newTodoText" :placeholder="newTodoInstructions" />
<input v-on:input="onInput" @focus="onFocus" />
html
<!-- 推荐的示例 -->
<input :value="newTodoText" :placeholder="newTodoInstructions" />
<input @input="onInput" @focus="onFocus" />

3 推荐规约(参考)

当存在多种同样优异的编码方案时,可以通过约定一种默认选项来避免开发过程中的反复讨论与纠结。只要能够保持全项目的一致性,允许项目组有合理的微调。

为了保持不同组件文件内部结构的统一,对于 Options 声明,我们推荐使用如下顺序进行定义组织:

  1. 全局感知 - name / inheritAttrs
  2. 模板编译器配置 - compilerOptions
  3. 模板依赖 - components / directives
  4. 组合式引入 - extends / mixins / provideinject
  5. 接口 - props / emits / expose
  6. 组合式入口 - setup
  7. 本地状态 - data / computed
  8. 副作用事件 - watch / 以及各声明周期钩子(按生命周期执行先后顺序书写,如 createdmountedunmounted
  9. 非响应式实例属性 - methods
  10. 视图定义 - templaterender 函数

在组件以及普通的 HTML 元素标签上绑定属性时,推荐遵循以下属性类型的顺序进行组织:

  1. 定义 - is
  2. 列表渲染 - v-for
  3. 条件渲染 - v-if / v-else-if / v-else / v-show
  4. 渲染修饰 - v-pre / v-once
  5. 全局全局属性 - id
  6. 唯一标识 - ref / key
  7. 双向绑定 - v-model
  8. 普通属性 - 各类静态或动态绑定的属性
  9. 事件监听 - v-on / @
  10. 内容覆写 - v-html / v-text

当组件文件较长、选项内容较为复杂时,在多行的选项结构(Options)或方法间插入一个空行,可以显著缓解视觉压迫感,并方便使用键盘进行代码块跳转。

*.vue 单文件组件内,<script><template><style> 的顶级标签排布顺序应当全局一致。我们推荐将 <style> 始终放置在文件最底部,因为 <style> 是三个标签中唯一可能不存在的。

vue
<!-- 推荐的顶级结构 -->
<script setup>
/* 代码逻辑 */
</script>

<template>
  <!-- 模板结构 -->
</template>

<style scoped>
/* 样式声明 */
</style>

4 谨慎使用规约(参考)

  • 4.1 在 scoped 样式中避免使用元素选择器。
    • Vue 的样式隔离是通过为元素追加特殊的哈希属性(如 data-v-f3f3eg9)实现的。普通的标签选择器会被编译为类似 button[data-v-f3f3eg9] 的属性匹配,浏览器在处理大规模的元素匹配时,其解析效率要大大低于纯类名匹配的选择器(如 .btn-close[data-v-f3f3eg9])。
  • 4.1 避免使用隐式父子组件通信或直接修改 Props 值。eslint: vue/no-mutating-props
    • 在子组件中直接使用 this.$parent 去读取或修改父组件的数据状态。
    • 在子组件中直接去修改由 Prop 接收的来自父组件的对象或数组属性值(如 props.todo.text = 'newValue')。

部分 Vue 特性主要是为了平滑迁移老旧代码或应对某些极端的罕见边缘情况设计的。过度频繁使用它们容易埋下 Bug 或导致数据流难以追踪。以下规约对此类高危特性做出警示。

当在 <style scoped> 中编写样式时,应当尽量使用 .my-class 选择器,而避免使用 buttondiv 等全局元素选择器。

为什么?

vue
<!-- 糟糕的示例 -->
<template>
  <button>×</button>
</template>

<style scoped>
button {
  background-color: red;
}
</style>
vue
<!-- 推荐的示例 -->
<template>
  <button class="btn-close">×</button>
</template>

<style scoped>
.btn-close {
  background-color: red;
}
</style>

Vue 组件交互所倡导的黄金法则是:“Props 向下,Events 向上”。

开发中应当竭力避免以下破坏该黄金法则的行为:

潜在隐患: 这两种做法会导致父组件所维护的状态在毫不知情的情况下被子组件隐式篡改,导致整个单向数据流向极其混乱,后续非常难定位到底是哪段代码污染了共享状态。

若确实需要在子组件触发对父状态的更新,应当通过**事件派发(Event Emits)**或者使用 Vue 的 v-model 双向绑定映射机制,将状态修改的权限重新移交还给父组件本身。

vue
<!-- 糟糕的示例 - 子组件强行篡改父组件的状态 -->
<script setup>
const props = defineProps({
  todo: {
    type: Object,
    required: true,
  },
})

function renameTodo() {
  props.todo.text = '通过子组件强行重命名'
}
</script>

<template>
  <span>
    {{ todo.text }}
    <button @click="renameTodo">重命名</button>
  </span>
</template>
vue
<!-- 推荐的示例 - 采用单向事件通知,父组件掌握数据修改权 -->
<script setup>
const props = defineProps({
  todo: {
    type: Object,
    required: true,
  },
})
const emit = defineEmits(['update:todo'])

function renameTodo() {
  // 派发事件,将全新的值抛给父组件
  emit('update:todo', { ...props.todo, text: '由父组件状态更新重命名' })
}
</script>

<template>
  <span>
    {{ todo.text }}
    <button @click="renameTodo">重命名</button>
  </span>
</template>

参考资料