Skip to content

计算属性

计算属性是 Vue 中一个非常重要的特性,它允许你声明式地描述依赖响应式状态的复杂逻辑。计算属性会基于其响应式依赖被缓存,只有当依赖发生改变时才会重新计算。

基本示例

模板中的表达式虽然方便,但也只能用来做简单的操作。如果在模板中写太多逻辑,会让模板变得臃肿,难以维护。比如说,我们有这样一个包含嵌套数组的对象:

javascript
const author = reactive({
  name: 'John Doe',
  books: [
    'Vue 2 - Advanced Guide',
    'Vue 3 - Basic Guide',
    'Vue 4 - The Mystery'
  ]
})

我们想根据 author 是否已有一些书籍来展示不同的信息:

vue
<template>
  <p>Has published books:</p>
  <span>{{ author.books.length > 0 ? 'Yes' : 'No' }}</span>
</template>

这里的模板看起来有些复杂。我们必须认真看好一会儿才能明白它的计算依赖于 author.books。更重要的是,如果在模板中需要不止一次这样的计算,我们可不想将这样的代码在模板里重复好多遍。

因此我们推荐使用计算属性来描述依赖响应式状态的复杂逻辑。这是重构后的示例:

选项式 API

vue
<template>
  <div>
    <p>Has published books:</p>
    <span>{{ publishedBooksMessage }}</span>
  </div>
</template>

<script>
export default {
  data() {
    return {
      author: {
        name: 'John Doe',
        books: [
          'Vue 2 - Advanced Guide',
          'Vue 3 - Basic Guide',
          'Vue 4 - The Mystery'
        ]
      }
    }
  },
  computed: {
    // 一个计算属性的 getter
    publishedBooksMessage() {
      // `this` 指向当前组件实例
      return this.author.books.length > 0 ? 'Yes' : 'No'
    }
  }
}
</script>

组合式 API

vue
<template>
  <div>
    <p>Has published books:</p>
    <span>{{ publishedBooksMessage }}</span>
  </div>
</template>

<script setup>
import { reactive, computed } from 'vue'

const author = reactive({
  name: 'John Doe',
  books: [
    'Vue 2 - Advanced Guide',
    'Vue 3 - Basic Guide',
    'Vue 4 - The Mystery'
  ]
})

// 一个计算属性 ref
const publishedBooksMessage = computed(() => {
  return author.books.length > 0 ? 'Yes' : 'No'
})
</script>

我们在这里定义了一个计算属性 publishedBooksMessage

在组合式 API 中,computed() 方法期望接收一个 getter 函数,返回值为一个计算属性 ref。和其他一般的 ref 类似,你可以通过 publishedBooksMessage.value 访问计算结果。计算属性 ref 也会在模板中自动解包,因此在模板表达式中引用时无需添加 .value

Vue 的计算属性会自动追踪响应式依赖。它会检测到 publishedBooksMessage 依赖于 author.books,所以当 author.books 改变时,任何依赖于 publishedBooksMessage 的绑定都会同时更新。

计算属性缓存 vs 方法

你可能注意到我们在表达式中像这样调用一个函数也会获得和计算属性相同的结果:

vue
<template>
  <p>{{ calculateBooksMessage() }}</p>
</template>

<script setup>
import { reactive } from 'vue'

const author = reactive({
  name: 'John Doe',
  books: [
    'Vue 2 - Advanced Guide',
    'Vue 3 - Basic Guide',
    'Vue 4 - The Mystery'
  ]
})

function calculateBooksMessage() {
  return author.books.length > 0 ? 'Yes' : 'No'
}
</script>

若我们将同样的函数定义为一个方法而不是计算属性,两种方式在结果上确实是完全相同的。

然而,不同之处在于计算属性值会基于其响应式依赖被缓存。一个计算属性仅会在其响应式依赖更新时才重新计算。这意味着只要 author.books 不改变,无论多少次访问 publishedBooksMessage 都会立即返回先前的计算结果,而不用重复执行 getter 函数。

这也解释了为什么下面的计算属性永远不会更新,因为 Date.now() 并不是一个响应式依赖:

javascript
const now = computed(() => Date.now())

相比之下,方法调用总是会在重渲染发生时再次执行函数。

为什么需要缓存呢?想象一下我们有一个非常耗性能的计算属性 list,需要循环一个巨大的数组并做许多计算逻辑,并且可能有其他计算属性依赖于 list。没有缓存的话,我们会重复执行 list 的 getter 函数很多次!如果你确定不需要缓存,那么也可以使用方法调用。

可写计算属性

计算属性默认是只读的。当你尝试修改一个计算属性时,你会收到一个运行时警告。只在某些特殊场景中你可能才需要用到"可写"的属性,你可以通过同时提供 getter 和 setter 来创建:

选项式 API

vue
<template>
  <div>
    <p>First name: <input v-model="firstName" /></p>
    <p>Last name: <input v-model="lastName" /></p>
    <p>Full name: <input v-model="fullName" /></p>
  </div>
</template>

<script>
export default {
  data() {
    return {
      firstName: 'John',
      lastName: 'Doe'
    }
  },
  computed: {
    fullName: {
      // getter
      get() {
        return this.firstName + ' ' + this.lastName
      },
      // setter
      set(newValue) {
        // 注意:我们这里使用的是解构赋值语法
        [this.firstName, this.lastName] = newValue.split(' ')
      }
    }
  }
}
</script>

组合式 API

vue
<template>
  <div>
    <p>First name: <input v-model="firstName" /></p>
    <p>Last name: <input v-model="lastName" /></p>
    <p>Full name: <input v-model="fullName" /></p>
  </div>
</template>

<script setup>
import { ref, computed } from 'vue'

const firstName = ref('John')
const lastName = ref('Doe')

const fullName = computed({
  // getter
  get() {
    return firstName.value + ' ' + lastName.value
  },
  // setter
  set(newValue) {
    // 注意:我们这里使用的是解构赋值语法
    [firstName.value, lastName.value] = newValue.split(' ')
  }
})
</script>

现在当你再运行 fullName.value = 'John Doe' 时,setter 会被调用而 firstNamelastName 会随之更新。

最佳实践

Getter 不应有副作用

计算属性的 getter 应只做计算而没有任何其他的副作用,这一点非常重要,请务必牢记。举例来说,不要在 getter 中做异步请求或者更改 DOM!一个计算属性的声明中描述的是如何根据其他值派生一个值。因此 getter 的职责应该仅为计算和返回该值。在之后的指引中我们会讨论如何使用侦听器来执行有副作用的操作来响应状态的改变。

避免直接修改计算属性值

从计算属性返回的值是派生状态。可以把它看作是一个"临时快照",每当源状态发生变化时,就会创建一个新的快照。更改快照是没有意义的,因此计算属性的返回值应该被视为只读的,并且永远不应该被更改——应该更新它所依赖的源状态以触发新的计算。

实际应用示例

购物车计算

vue
<template>
  <div class="shopping-cart">
    <h2>购物车</h2>
    
    <div v-if="items.length === 0" class="empty-cart">
      购物车为空
    </div>
    
    <div v-else>
      <div v-for="item in items" :key="item.id" class="cart-item">
        <span class="item-name">{{ item.name }}</span>
        <span class="item-price">¥{{ item.price }}</span>
        <input 
          v-model.number="item.quantity" 
          type="number" 
          min="1" 
          class="quantity-input"
        >
        <span class="item-total">¥{{ itemTotal(item) }}</span>
        <button @click="removeItem(item.id)" class="remove-btn">删除</button>
      </div>
      
      <div class="cart-summary">
        <div class="summary-row">
          <span>商品数量:</span>
          <span>{{ totalQuantity }}</span>
        </div>
        <div class="summary-row">
          <span>商品总价:</span>
          <span>¥{{ subtotal }}</span>
        </div>
        <div class="summary-row">
          <span>运费:</span>
          <span>¥{{ shippingFee }}</span>
        </div>
        <div class="summary-row">
          <span>优惠:</span>
          <span class="discount">-¥{{ discount }}</span>
        </div>
        <div class="summary-row total">
          <span>总计:</span>
          <span>¥{{ finalTotal }}</span>
        </div>
        
        <button 
          @click="checkout" 
          :disabled="items.length === 0"
          class="checkout-btn"
        >
          结算 ({{ totalQuantity }} 件商品)
        </button>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, computed } from 'vue'

const items = ref([
  { id: 1, name: 'iPhone 15', price: 5999, quantity: 1 },
  { id: 2, name: 'MacBook Pro', price: 12999, quantity: 1 },
  { id: 3, name: 'AirPods Pro', price: 1999, quantity: 2 }
])

// 计算单个商品总价
function itemTotal(item) {
  return item.price * item.quantity
}

// 计算商品总数量
const totalQuantity = computed(() => {
  return items.value.reduce((total, item) => total + item.quantity, 0)
})

// 计算商品小计
const subtotal = computed(() => {
  return items.value.reduce((total, item) => {
    return total + (item.price * item.quantity)
  }, 0)
})

// 计算运费(满99免运费)
const shippingFee = computed(() => {
  return subtotal.value >= 9999 ? 0 : 20
})

// 计算优惠金额(满10000减500)
const discount = computed(() => {
  if (subtotal.value >= 20000) {
    return 1000
  } else if (subtotal.value >= 10000) {
    return 500
  }
  return 0
})

// 计算最终总价
const finalTotal = computed(() => {
  return subtotal.value + shippingFee.value - discount.value
})

function removeItem(id) {
  const index = items.value.findIndex(item => item.id === id)
  if (index > -1) {
    items.value.splice(index, 1)
  }
}

function checkout() {
  alert(`结算成功!总金额: ¥${finalTotal.value}`)
}
</script>

<style scoped>
.shopping-cart {
  max-width: 600px;
  margin: 0 auto;
  padding: 20px;
}

.empty-cart {
  text-align: center;
  color: #666;
  font-size: 18px;
  padding: 40px;
}

.cart-item {
  display: flex;
  align-items: center;
  padding: 15px;
  border-bottom: 1px solid #eee;
  gap: 15px;
}

.item-name {
  flex: 1;
  font-weight: bold;
}

.item-price,
.item-total {
  font-weight: bold;
  color: #e74c3c;
}

.quantity-input {
  width: 60px;
  padding: 5px;
  text-align: center;
  border: 1px solid #ddd;
  border-radius: 4px;
}

.remove-btn {
  padding: 5px 10px;
  background-color: #dc3545;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 12px;
}

.cart-summary {
  margin-top: 20px;
  padding: 20px;
  background-color: #f8f9fa;
  border-radius: 8px;
}

.summary-row {
  display: flex;
  justify-content: space-between;
  margin-bottom: 10px;
  font-size: 16px;
}

.summary-row.total {
  font-size: 18px;
  font-weight: bold;
  border-top: 2px solid #ddd;
  padding-top: 10px;
  margin-top: 15px;
}

.discount {
  color: #28a745;
}

.checkout-btn {
  width: 100%;
  padding: 15px;
  background-color: #007bff;
  color: white;
  border: none;
  border-radius: 6px;
  font-size: 16px;
  font-weight: bold;
  cursor: pointer;
  margin-top: 15px;
}

.checkout-btn:disabled {
  background-color: #6c757d;
  cursor: not-allowed;
}
</style>

搜索和过滤

vue
<template>
  <div class="search-filter">
    <h2>用户搜索</h2>
    
    <div class="controls">
      <input 
        v-model="searchQuery"
        placeholder="搜索用户名或邮箱..."
        class="search-input"
      >
      
      <select v-model="selectedRole" class="role-filter">
        <option value="">所有角色</option>
        <option value="admin">管理员</option>
        <option value="user">普通用户</option>
        <option value="moderator">版主</option>
      </select>
      
      <select v-model="selectedStatus" class="status-filter">
        <option value="">所有状态</option>
        <option value="active">活跃</option>
        <option value="inactive">非活跃</option>
        <option value="banned">已封禁</option>
      </select>
    </div>
    
    <div class="results-info">
      <span>找到 {{ filteredUsers.length }} 个用户</span>
      <span v-if="hasFilters">(共 {{ users.length }} 个用户)</span>
    </div>
    
    <div class="user-list">
      <div 
        v-for="user in paginatedUsers" 
        :key="user.id"
        class="user-card"
        :class="`status-${user.status}`"
      >
        <div class="user-info">
          <h3>{{ user.name }}</h3>
          <p>{{ user.email }}</p>
          <div class="user-meta">
            <span class="role">{{ getRoleLabel(user.role) }}</span>
            <span class="status">{{ getStatusLabel(user.status) }}</span>
          </div>
        </div>
      </div>
    </div>
    
    <div v-if="totalPages > 1" class="pagination">
      <button 
        @click="currentPage--" 
        :disabled="currentPage === 1"
        class="page-btn"
      >
        上一页
      </button>
      
      <span class="page-info">
        第 {{ currentPage }} 页,共 {{ totalPages }} 页
      </span>
      
      <button 
        @click="currentPage++" 
        :disabled="currentPage === totalPages"
        class="page-btn"
      >
        下一页
      </button>
    </div>
  </div>
</template>

<script setup>
import { ref, computed } from 'vue'

const searchQuery = ref('')
const selectedRole = ref('')
const selectedStatus = ref('')
const currentPage = ref(1)
const pageSize = ref(5)

const users = ref([
  { id: 1, name: '张三', email: 'zhangsan@example.com', role: 'admin', status: 'active' },
  { id: 2, name: '李四', email: 'lisi@example.com', role: 'user', status: 'active' },
  { id: 3, name: '王五', email: 'wangwu@example.com', role: 'moderator', status: 'inactive' },
  { id: 4, name: '赵六', email: 'zhaoliu@example.com', role: 'user', status: 'banned' },
  { id: 5, name: '钱七', email: 'qianqi@example.com', role: 'admin', status: 'active' },
  { id: 6, name: '孙八', email: 'sunba@example.com', role: 'user', status: 'active' },
  { id: 7, name: '周九', email: 'zhoujiu@example.com', role: 'moderator', status: 'active' },
  { id: 8, name: '吴十', email: 'wushi@example.com', role: 'user', status: 'inactive' }
])

// 检查是否有过滤条件
const hasFilters = computed(() => {
  return searchQuery.value || selectedRole.value || selectedStatus.value
})

// 过滤用户
const filteredUsers = computed(() => {
  let result = users.value
  
  // 搜索过滤
  if (searchQuery.value) {
    const query = searchQuery.value.toLowerCase()
    result = result.filter(user => 
      user.name.toLowerCase().includes(query) ||
      user.email.toLowerCase().includes(query)
    )
  }
  
  // 角色过滤
  if (selectedRole.value) {
    result = result.filter(user => user.role === selectedRole.value)
  }
  
  // 状态过滤
  if (selectedStatus.value) {
    result = result.filter(user => user.status === selectedStatus.value)
  }
  
  return result
})

// 计算总页数
const totalPages = computed(() => {
  return Math.ceil(filteredUsers.value.length / pageSize.value)
})

// 分页用户
const paginatedUsers = computed(() => {
  const start = (currentPage.value - 1) * pageSize.value
  const end = start + pageSize.value
  return filteredUsers.value.slice(start, end)
})

// 当过滤条件改变时重置页码
computed(() => {
  if (currentPage.value > totalPages.value) {
    currentPage.value = 1
  }
})

function getRoleLabel(role) {
  const labels = {
    admin: '管理员',
    user: '普通用户',
    moderator: '版主'
  }
  return labels[role] || role
}

function getStatusLabel(status) {
  const labels = {
    active: '活跃',
    inactive: '非活跃',
    banned: '已封禁'
  }
  return labels[status] || status
}
</script>

<style scoped>
.search-filter {
  max-width: 800px;
  margin: 0 auto;
  padding: 20px;
}

.controls {
  display: flex;
  gap: 15px;
  margin-bottom: 20px;
  flex-wrap: wrap;
}

.search-input {
  flex: 1;
  min-width: 200px;
  padding: 10px;
  border: 1px solid #ddd;
  border-radius: 6px;
  font-size: 16px;
}

.role-filter,
.status-filter {
  padding: 10px;
  border: 1px solid #ddd;
  border-radius: 6px;
  font-size: 16px;
  background-color: white;
}

.results-info {
  margin-bottom: 20px;
  color: #666;
  font-size: 14px;
}

.user-list {
  display: grid;
  gap: 15px;
  margin-bottom: 20px;
}

.user-card {
  padding: 20px;
  border: 1px solid #ddd;
  border-radius: 8px;
  background-color: white;
}

.user-card.status-active {
  border-left: 4px solid #28a745;
}

.user-card.status-inactive {
  border-left: 4px solid #ffc107;
}

.user-card.status-banned {
  border-left: 4px solid #dc3545;
}

.user-info h3 {
  margin: 0 0 5px 0;
  color: #333;
}

.user-info p {
  margin: 0 0 10px 0;
  color: #666;
}

.user-meta {
  display: flex;
  gap: 10px;
}

.role,
.status {
  padding: 4px 8px;
  border-radius: 4px;
  font-size: 12px;
  font-weight: bold;
}

.role {
  background-color: #007bff;
  color: white;
}

.status {
  background-color: #6c757d;
  color: white;
}

.pagination {
  display: flex;
  justify-content: center;
  align-items: center;
  gap: 15px;
}

.page-btn {
  padding: 8px 16px;
  background-color: #007bff;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

.page-btn:disabled {
  background-color: #6c757d;
  cursor: not-allowed;
}

.page-info {
  font-size: 14px;
  color: #666;
}
</style>

表单验证

vue
<template>
  <div class="form-validation">
    <h2>用户注册</h2>
    
    <form @submit.prevent="submitForm" class="registration-form">
      <div class="form-group">
        <label for="username">用户名:</label>
        <input 
          id="username"
          v-model="form.username"
          type="text"
          :class="{ error: usernameError }"
          @blur="validateUsername"
        >
        <span v-if="usernameError" class="error-message">{{ usernameError }}</span>
      </div>
      
      <div class="form-group">
        <label for="email">邮箱:</label>
        <input 
          id="email"
          v-model="form.email"
          type="email"
          :class="{ error: emailError }"
          @blur="validateEmail"
        >
        <span v-if="emailError" class="error-message">{{ emailError }}</span>
      </div>
      
      <div class="form-group">
        <label for="password">密码:</label>
        <input 
          id="password"
          v-model="form.password"
          type="password"
          :class="{ error: passwordError }"
          @blur="validatePassword"
        >
        <span v-if="passwordError" class="error-message">{{ passwordError }}</span>
        <div class="password-strength">
          <div class="strength-bar" :class="passwordStrengthClass"></div>
          <span class="strength-text">{{ passwordStrengthText }}</span>
        </div>
      </div>
      
      <div class="form-group">
        <label for="confirmPassword">确认密码:</label>
        <input 
          id="confirmPassword"
          v-model="form.confirmPassword"
          type="password"
          :class="{ error: confirmPasswordError }"
          @blur="validateConfirmPassword"
        >
        <span v-if="confirmPasswordError" class="error-message">{{ confirmPasswordError }}</span>
      </div>
      
      <div class="form-group">
        <label for="age">年龄:</label>
        <input 
          id="age"
          v-model.number="form.age"
          type="number"
          :class="{ error: ageError }"
          @blur="validateAge"
        >
        <span v-if="ageError" class="error-message">{{ ageError }}</span>
      </div>
      
      <div class="form-group">
        <label>
          <input 
            v-model="form.agreeToTerms"
            type="checkbox"
            :class="{ error: termsError }"
          >
          我同意<a href="#" @click.prevent>服务条款</a>
        </label>
        <span v-if="termsError" class="error-message">{{ termsError }}</span>
      </div>
      
      <div class="form-summary">
        <div class="validation-status">
          <span :class="{ valid: isFormValid, invalid: !isFormValid }">
            {{ isFormValid ? '✓ 表单验证通过' : '✗ 请修正错误后提交' }}
          </span>
        </div>
        
        <button 
          type="submit" 
          :disabled="!isFormValid || isSubmitting"
          class="submit-btn"
        >
          {{ isSubmitting ? '提交中...' : '注册' }}
        </button>
      </div>
    </form>
  </div>
</template>

<script setup>
import { reactive, ref, computed } from 'vue'

const form = reactive({
  username: '',
  email: '',
  password: '',
  confirmPassword: '',
  age: null,
  agreeToTerms: false
})

const errors = reactive({
  username: '',
  email: '',
  password: '',
  confirmPassword: '',
  age: '',
  terms: ''
})

const isSubmitting = ref(false)
const hasValidated = reactive({
  username: false,
  email: false,
  password: false,
  confirmPassword: false,
  age: false
})

// 用户名验证
const usernameError = computed(() => {
  if (!hasValidated.username && !form.username) return ''
  if (!form.username) return '用户名不能为空'
  if (form.username.length < 3) return '用户名至少3个字符'
  if (form.username.length > 20) return '用户名不能超过20个字符'
  if (!/^[a-zA-Z0-9_]+$/.test(form.username)) return '用户名只能包含字母、数字和下划线'
  return ''
})

// 邮箱验证
const emailError = computed(() => {
  if (!hasValidated.email && !form.email) return ''
  if (!form.email) return '邮箱不能为空'
  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
  if (!emailRegex.test(form.email)) return '请输入有效的邮箱地址'
  return ''
})

// 密码强度计算
const passwordStrength = computed(() => {
  const password = form.password
  if (!password) return 0
  
  let score = 0
  if (password.length >= 8) score++
  if (/[a-z]/.test(password)) score++
  if (/[A-Z]/.test(password)) score++
  if (/[0-9]/.test(password)) score++
  if (/[^a-zA-Z0-9]/.test(password)) score++
  
  return score
})

const passwordStrengthClass = computed(() => {
  const strength = passwordStrength.value
  if (strength <= 1) return 'weak'
  if (strength <= 3) return 'medium'
  return 'strong'
})

const passwordStrengthText = computed(() => {
  const strength = passwordStrength.value
  if (strength <= 1) return '弱'
  if (strength <= 3) return '中等'
  return '强'
})

// 密码验证
const passwordError = computed(() => {
  if (!hasValidated.password && !form.password) return ''
  if (!form.password) return '密码不能为空'
  if (form.password.length < 8) return '密码至少8个字符'
  if (passwordStrength.value < 2) return '密码强度太弱,请包含大小写字母、数字或特殊字符'
  return ''
})

// 确认密码验证
const confirmPasswordError = computed(() => {
  if (!hasValidated.confirmPassword && !form.confirmPassword) return ''
  if (!form.confirmPassword) return '请确认密码'
  if (form.password !== form.confirmPassword) return '两次输入的密码不一致'
  return ''
})

// 年龄验证
const ageError = computed(() => {
  if (!hasValidated.age && !form.age) return ''
  if (!form.age) return '年龄不能为空'
  if (form.age < 18) return '年龄必须满18岁'
  if (form.age > 120) return '请输入有效的年龄'
  return ''
})

// 条款验证
const termsError = computed(() => {
  if (!form.agreeToTerms) return '请同意服务条款'
  return ''
})

// 表单整体验证
const isFormValid = computed(() => {
  return !usernameError.value &&
         !emailError.value &&
         !passwordError.value &&
         !confirmPasswordError.value &&
         !ageError.value &&
         !termsError.value &&
         form.username &&
         form.email &&
         form.password &&
         form.confirmPassword &&
         form.age &&
         form.agreeToTerms
})

function validateUsername() {
  hasValidated.username = true
}

function validateEmail() {
  hasValidated.email = true
}

function validatePassword() {
  hasValidated.password = true
}

function validateConfirmPassword() {
  hasValidated.confirmPassword = true
}

function validateAge() {
  hasValidated.age = true
}

async function submitForm() {
  if (!isFormValid.value) return
  
  isSubmitting.value = true
  
  try {
    // 模拟API调用
    await new Promise(resolve => setTimeout(resolve, 2000))
    alert('注册成功!')
    
    // 重置表单
    Object.assign(form, {
      username: '',
      email: '',
      password: '',
      confirmPassword: '',
      age: null,
      agreeToTerms: false
    })
    
    Object.assign(hasValidated, {
      username: false,
      email: false,
      password: false,
      confirmPassword: false,
      age: false
    })
  } catch (error) {
    alert('注册失败,请重试')
  } finally {
    isSubmitting.value = false
  }
}
</script>

<style scoped>
.form-validation {
  max-width: 500px;
  margin: 0 auto;
  padding: 20px;
}

.registration-form {
  background-color: #f8f9fa;
  padding: 30px;
  border-radius: 8px;
}

.form-group {
  margin-bottom: 20px;
}

label {
  display: block;
  margin-bottom: 5px;
  font-weight: bold;
  color: #333;
}

input[type="text"],
input[type="email"],
input[type="password"],
input[type="number"] {
  width: 100%;
  padding: 10px;
  border: 2px solid #ddd;
  border-radius: 4px;
  font-size: 16px;
  transition: border-color 0.3s;
  box-sizing: border-box;
}

input:focus {
  outline: none;
  border-color: #007bff;
}

input.error {
  border-color: #dc3545;
}

.error-message {
  display: block;
  color: #dc3545;
  font-size: 14px;
  margin-top: 5px;
}

.password-strength {
  margin-top: 8px;
}

.strength-bar {
  height: 4px;
  border-radius: 2px;
  transition: all 0.3s;
  margin-bottom: 4px;
}

.strength-bar.weak {
  width: 33%;
  background-color: #dc3545;
}

.strength-bar.medium {
  width: 66%;
  background-color: #ffc107;
}

.strength-bar.strong {
  width: 100%;
  background-color: #28a745;
}

.strength-text {
  font-size: 12px;
  color: #666;
}

input[type="checkbox"] {
  margin-right: 8px;
}

a {
  color: #007bff;
  text-decoration: none;
}

a:hover {
  text-decoration: underline;
}

.form-summary {
  margin-top: 30px;
  padding-top: 20px;
  border-top: 1px solid #ddd;
}

.validation-status {
  margin-bottom: 15px;
  text-align: center;
}

.valid {
  color: #28a745;
  font-weight: bold;
}

.invalid {
  color: #dc3545;
  font-weight: bold;
}

.submit-btn {
  width: 100%;
  padding: 12px;
  background-color: #007bff;
  color: white;
  border: none;
  border-radius: 6px;
  font-size: 16px;
  font-weight: bold;
  cursor: pointer;
  transition: background-color 0.3s;
}

.submit-btn:hover:not(:disabled) {
  background-color: #0056b3;
}

.submit-btn:disabled {
  background-color: #6c757d;
  cursor: not-allowed;
}
</style>

调试计算属性

计算属性的 getter 应该只做计算而没有任何其他的副作用。但有时你可能需要调试计算属性,Vue 提供了两个调试钩子:

javascript
const plusOne = computed(() => count.value + 1, {
  onTrack(e) {
    // 当 count.value 被追踪为依赖时触发
    debugger
  },
  onTrigger(e) {
    // 当 count.value 被更改时触发
    debugger
  }
})
  • onTrack 将在响应式 property 或 ref 作为依赖项被追踪时被调用。
  • onTrigger 将在依赖项变更导致副作用被触发时被调用。

这两个回调都会接收到一个调试器事件,其中包含有关所依赖项的信息。建议在以上回调中编写 debugger 语句来调试依赖。

下一步

现在你已经了解了计算属性的强大功能,让我们继续学习:

计算属性是 Vue 响应式系统的重要组成部分,掌握它将让你能够高效地处理复杂的数据逻辑!

基于 Vue.js 官方文档构建的学习宝典