Skip to content
On this page

Vue.js 计算属性与侦听器

Vue.js 提供了计算属性和侦听器两种机制来处理数据的派生和副作用。

计算属性 (Computed Properties)

基础用法

计算属性是基于它们的响应式依赖进行缓存的,只有依赖发生改变时才会重新计算:

vue
<template>
  <div>
    <h3>基础计算属性</h3>
    <p>原始消息: "{{ message }}"</p>
    <p>反转消息: "{{ reversedMessage }}"</p>
    <p>反转消息(方法): "{{ reverseMessage() }}"</p>
    <button @click="updateMessage">更新消息</button>
  </div>
</template>

<script>
export default {
  data() {
    return {
      message: 'Hello Vue!'
    }
  },
  computed: {
    // 计算属性的 getter
    reversedMessage() {
      console.log('计算属性被调用') // 只有在依赖变化时才会调用
      return this.message.split('').reverse().join('')
    }
  },
  methods: {
    // 方法 - 每次渲染都会调用
    reverseMessage() {
      console.log('方法被调用') // 每次渲染都会调用
      return this.message.split('').reverse().join('')
    },
    updateMessage() {
      this.message = `更新时间: ${new Date().toLocaleTimeString()}`
    }
  }
}
</script>

计算属性的 setter

计算属性可以设置 getter 和 setter:

vue
<template>
  <div>
    <h3>带 setter 的计算属性</h3>
    <p>姓名: {{ fullName }}</p>
    <button @click="updateFullName">更新全名</button>
    <button @click="updateFirstName">更新名</button>
    <button @click="updateLastName">更新姓</button>
  </div>
</template>

<script>
export default {
  data() {
    return {
      firstName: '',
      lastName: ''
    }
  },
  computed: {
    fullName: {
      // getter
      get() {
        return `${this.firstName} ${this.lastName}`
      },
      // setter
      set(newValue) {
        const names = newValue.split(' ')
        this.firstName = names[0] || ''
        this.lastName = names[1] || ''
      }
    }
  },
  methods: {
    updateFullName() {
      this.fullName = '李 四'
    },
    updateFirstName() {
      this.firstName = ''
    },
    updateLastName() {
      this.lastName = ''
    }
  }
}
</script>

复杂计算属性

vue
<template>
  <div>
    <h3>复杂计算属性</h3>
    <div>
      <input v-model="searchQuery" placeholder="搜索用户">
      <label>
        <input type="checkbox" v-model="showActiveOnly"> 只显示活跃用户
      </label>
    </div>
    
    <p>总用户数: {{ totalUsers }}</p>
    <p>活跃用户数: {{ activeUsersCount }}</p>
    <p>搜索结果数: {{ searchResults.length }}</p>
    
    <ul>
      <li v-for="user in searchResults" :key="user.id">
        {{ user.name }} - {{ user.status }}
      </li>
    </ul>
  </div>
</template>

<script>
export default {
  data() {
    return {
      searchQuery: '',
      showActiveOnly: false,
      users: [
        { id: 1, name: '张三', status: 'active', age: 25 },
        { id: 2, name: '李四', status: 'inactive', age: 30 },
        { id: 3, name: '王五', status: 'active', age: 28 },
        { id: 4, name: '赵六', status: 'active', age: 35 },
        { id: 5, name: '钱七', status: 'inactive', age: 22 }
      ]
    }
  },
  computed: {
    // 总用户数
    totalUsers() {
      return this.users.length
    },
    
    // 活跃用户数
    activeUsersCount() {
      return this.users.filter(user => user.status === 'active').length
    },
    
    // 搜索结果
    searchResults() {
      let results = this.users
      
      // 按搜索查询过滤
      if (this.searchQuery) {
        results = results.filter(user => 
          user.name.toLowerCase().includes(this.searchQuery.toLowerCase())
        )
      }
      
      // 按活跃状态过滤
      if (this.showActiveOnly) {
        results = results.filter(user => user.status === 'active')
      }
      
      return results
    },
    
    // 年龄统计
    ageStats() {
      const ages = this.users.map(user => user.age)
      return {
        min: Math.min(...ages),
        max: Math.max(...ages),
        avg: (ages.reduce((sum, age) => sum + age, 0) / ages.length).toFixed(1)
      }
    }
  }
}
</script>

侦听器 (Watchers)

基础侦听器

侦听器适合在数据变化时执行异步或开销较大的操作:

vue
<template>
  <div>
    <h3>基础侦听器</h3>
    <p>问一个问题是(Yes/No):</p>
    <p>{{ question }}</p>
    <input v-model="question" @input="debouncedGetAnswer">
    <p v-if="loading">正在思考...</p>
    <p v-else>{{ answer }}</p>
  </div>
</template>

<script>
export default {
  data() {
    return {
      question: '',
      answer: '问题以 ? 结尾时我会给你答案',
      loading: false
    }
  },
  watch: {
    // 侦听 question 的变化
    question(newQuestion, oldQuestion) {
      this.answer = '正在思考...'
      this.getAnswer()
    }
  },
  methods: {
    async getAnswer() {
      if (this.question.indexOf('?') === -1) {
        this.answer = '问题以 ? 结尾时我会给你答案'
        return
      }
      
      this.loading = true
      try {
        const res = await fetch('https://yesno.wtf/api')
        this.answer = (await res.json()).answer
      } catch (error) {
        this.answer = '无法获取答案,请稍后重试'
      } finally {
        this.loading = false
      }
    },
    
    // 防抖函数
    debouncedGetAnswer() {
      clearTimeout(this.timeoutId)
      this.timeoutId = setTimeout(() => {
        // 触发侦听器
        this.question = this.question // 确保触发侦听器
      }, 500)
    }
  }
}
</script>

深度侦听

vue
<template>
  <div>
    <h3>深度侦听器</h3>
    <div>
      <label>用户姓名: <input v-model="user.name"></label>
    </div>
    <div>
      <label>用户年龄: <input v-model.number="user.age" type="number"></label>
    </div>
    <div>
      <label>用户城市: <input v-model="user.address.city"></label>
    </div>
    <div>
      <label>用户街道: <input v-model="user.address.street"></label>
    </div>
    
    <p>用户信息: {{ JSON.stringify(user, null, 2) }}</p>
    <p>变更次数: {{ changeCount }}</p>
  </div>
</template>

<script>
export default {
  data() {
    return {
      user: {
        name: '张三',
        age: 25,
        address: {
          city: '北京',
          street: '朝阳区'
        }
      },
      changeCount: 0
    }
  },
  watch: {
    // 深度侦听对象
    user: {
      handler(newVal, oldVal) {
        console.log('用户信息发生变化:', newVal)
        this.changeCount++
      },
      deep: true // 开启深度侦听
    },
    
    // 只侦听特定嵌套属性
    'user.name'(newName, oldName) {
      console.log(`姓名从 ${oldName} 变为 ${newName}`)
    },
    
    // 侦听函数返回的值
    getUserInfo: {
      handler(newInfo, oldInfo) {
        console.log('用户信息摘要变化:', newInfo)
      },
      immediate: true // 立即执行
    }
  },
  computed: {
    getUserInfo() {
      return `${this.user.name} - ${this.user.age}岁 - ${this.user.address.city}`
    }
  }
}
</script>

侦听器选项

vue
<template>
  <div>
    <h3>侦听器选项</h3>
    <div>
      <label>计数器: <input v-model.number="count" type="number"></label>
    </div>
    <div>
      <label>延迟 (ms): <input v-model.number="delay" type="number"></label>
    </div>
    
    <p>延迟值: {{ delayedCount }}</p>
    <p>历史记录: {{ history.join(', ') }}</p>
  </div>
</template>

<script>
export default {
  data() {
    return {
      count: 0,
      delay: 1000,
      delayedCount: 0,
      history: []
    }
  },
  watch: {
    count: {
      handler(newVal) {
        // 使用防抖
        clearTimeout(this.debounceTimer)
        this.debounceTimer = setTimeout(() => {
          this.delayedCount = newVal
          this.history.push({
            value: newVal,
            timestamp: new Date()
          })
          // 保持历史记录在10条以内
          if (this.history.length > 10) {
            this.history.shift()
          }
        }, this.delay)
      },
      immediate: true // 立即以当前值触发回调
    }
  }
}
</script>

组合式 API 中的计算属性和侦听器

计算属性 (Composition API)

vue
<template>
  <div>
    <h3>组合式 API 计算属性</h3>
    <div>
      <input v-model="firstName" placeholder="">
      <input v-model="lastName" placeholder="">
    </div>
    
    <p>全名: {{ fullName }}</p>
    <p>全名(大写): {{ upperCaseFullName }}</p>
    <p>姓名长度: {{ nameLength }}</p>
    
    <button @click="resetNames">重置</button>
  </div>
</template>

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

export default {
  setup() {
    const firstName = ref('')
    const lastName = ref('')
    
    // 基础计算属性
    const fullName = computed(() => {
      return `${firstName.value} ${lastName.value}`
    })
    
    // 带有 getter 和 setter 的计算属性
    const upperCaseFullName = computed({
      get: () => {
        return fullName.value.toUpperCase()
      },
      set: (newValue) => {
        const names = newValue.split(' ')
        firstName.value = names[0] || ''
        lastName.value = names[1] || ''
      }
    })
    
    // 依赖多个响应式引用的计算属性
    const nameLength = computed(() => {
      return fullName.value.length
    })
    
    const resetNames = () => {
      firstName.value = ''
      lastName.value = ''
    }
    
    return {
      firstName,
      lastName,
      fullName,
      upperCaseFullName,
      nameLength,
      resetNames
    }
  }
}
</script>

侦听器 (Composition API)

vue
<template>
  <div>
    <h3>组合式 API 侦听器</h3>
    
    <!-- 基础侦听 -->
    <div>
      <input v-model="searchQuery" placeholder="搜索">
      <p>搜索查询: {{ searchQuery }}</p>
      <p>搜索延迟值: {{ debouncedQuery }}</p>
    </div>
    
    <!-- 侦听多个源 -->
    <div>
      <input v-model.number="x" type="number" placeholder="X值">
      <input v-model.number="y" type="number" placeholder="Y值">
      <p>坐标: ({{ x }}, {{ y }})</p>
      <p>距离原点: {{ distance }}</p>
    </div>
    
    <!-- 深度侦听 -->
    <div>
      <input v-model="user.name" placeholder="用户名">
      <input v-model.number="user.age" type="number" placeholder="用户年龄">
      <p>用户数据: {{ JSON.stringify(user) }}</p>
    </div>
    
    <p>状态: {{ status }}</p>
  </div>
</template>

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

export default {
  setup() {
    // 基础响应式数据
    const searchQuery = ref('')
    const debouncedQuery = ref('')
    const x = ref(0)
    const y = ref(0)
    const status = ref('idle')
    
    // 响应式对象
    const user = reactive({
      name: '张三',
      age: 25
    })
    
    // 计算属性
    const distance = computed(() => {
      return Math.sqrt(x.value ** 2 + y.value ** 2)
    })
    
    // 侦听单个源
    watch(searchQuery, (newQuery) => {
      status.value = 'searching'
      clearTimeout(window.searchTimeout)
      window.searchTimeout = setTimeout(() => {
        debouncedQuery.value = newQuery
        status.value = 'idle'
      }, 500)
    })
    
    // 侦听多个源
    watch([x, y], ([newX, newY], [oldX, oldY]) => {
      console.log(`坐标从 (${oldX}, ${oldY}) 变为 (${newX}, ${newY})`)
      status.value = 'updated'
      setTimeout(() => {
        status.value = 'idle'
      }, 1000)
    })
    
    // 深度侦听
    watch(
      () => ({ ...user }), // 创建对象的浅拷贝以检测变化
      (newUser, oldUser) => {
        console.log('用户数据变化:', newUser, oldUser)
        status.value = 'user-updated'
      },
      { deep: true }
    )
    
    // 侦听器选项
    watch(
      () => user.name,
      (newName) => {
        console.log(`用户名变更为: ${newName}`)
      },
      { 
        immediate: true, // 立即执行
        flush: 'post' // 组件更新后执行
      }
    )
    
    // watchEffect - 自动追踪依赖
    watchEffect(() => {
      // 自动追踪 searchQuery 的变化
      if (searchQuery.value) {
        console.log('搜索查询变化:', searchQuery.value)
        // 这里可以执行副作用,如API调用
      }
    })
    
    // watchEffect 高级用法
    const stopWatch = watchEffect(() => {
      console.log(`当前距离: ${distance.value}`)
    })
    
    // 在需要时停止侦听
    // stopWatch()
    
    return {
      searchQuery,
      debouncedQuery,
      x,
      y,
      distance,
      user,
      status
    }
  }
}
</script>

性能优化

计算属性的性能优势

vue
<template>
  <div>
    <h3>计算属性性能优化</h3>
    
    <div>
      <button @click="updateData">更新数据</button>
      <button @click="forceRerender">强制重渲染</button>
    </div>
    
    <p>渲染次数: {{ renderCount }}</p>
    <p>计算执行次数: {{ computedExecutionCount }}</p>
    
    <!-- 复杂计算属性 -->
    <div>
      <h4>复杂计算结果:</h4>
      <p v-for="(item, index) in expensiveCalculatedData" :key="index">
        {{ item }}
      </p>
    </div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      renderCount: 0,
      computedExecutionCount: 0,
      shouldCalculate: true,
      dataSource: Array.from({ length: 1000 }, (_, i) => i),
      updateTrigger: 0 // 用于强制更新
    }
  },
  computed: {
    expensiveCalculatedData() {
      // 只有当依赖变化时才重新计算
      this.computedExecutionCount++
      
      if (!this.shouldCalculate) {
        return []
      }
      
      // 模拟复杂计算
      return this.dataSource
        .filter(item => item % 2 === 0)
        .map(item => `处理后的数据项: ${item * 2}`)
        .slice(0, 10) // 只取前10项
    }
  },
  methods: {
    updateData() {
      // 更新数据源,这会触发计算属性重新计算
      this.dataSource = Array.from({ length: 1000 }, (_, i) => i + this.updateTrigger)
      this.updateTrigger++
    },
    forceRerender() {
      // 强制重渲染,但计算属性不会重新执行(因为依赖未变)
      this.renderCount++
    }
  },
  updated() {
    // 每次更新都会执行,但计算属性可能不会
    this.renderCount = this.renderCount // 触发更新
  }
}
</script>

侦听器的性能考虑

vue
<template>
  <div>
    <h3>侦听器性能优化</h3>
    
    <div>
      <input v-model="inputValue" placeholder="输入内容(防抖)">
      <p>实时值: {{ inputValue }}</p>
      <p>防抖值: {{ debouncedValue }}</p>
    </div>
    
    <div>
      <input v-model="searchTerm" placeholder="搜索(节流)">
      <p>搜索项: {{ searchTerm }}</p>
    </div>
    
    <div>
      <p>API调用次数: {{ apiCallCount }}</p>
      <p>最后结果: {{ lastResult }}</p>
    </div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      inputValue: '',
      debouncedValue: '',
      searchTerm: '',
      apiCallCount: 0,
      lastResult: '',
      debounceTimer: null,
      throttleTimer: null
    }
  },
  watch: {
    inputValue: {
      handler(newVal) {
        // 防抖实现
        clearTimeout(this.debounceTimer)
        this.debounceTimer = setTimeout(() => {
          this.debouncedValue = newVal
        }, 500)
      }
    },
    
    searchTerm: {
      handler(newVal) {
        // 节流实现
        if (!this.throttleTimer) {
          this.throttleTimer = setTimeout(() => {
            this.performSearch(newVal)
            this.throttleTimer = null
          }, 1000)
        }
      }
    }
  },
  methods: {
    async performSearch(query) {
      if (!query) {
        this.lastResult = ''
        return
      }
      
      this.apiCallCount++
      
      // 模拟API调用
      setTimeout(() => {
        this.lastResult = `搜索结果: ${query} (${this.apiCallCount} 次调用)`
      }, 300)
    }
  },
  beforeUnmount() {
    // 清理定时器
    clearTimeout(this.debounceTimer)
    clearTimeout(this.throttleTimer)
  }
}
</script>

实际应用示例

表单验证

vue
<template>
  <div>
    <h3>表单验证示例</h3>
    
    <form @submit.prevent="submitForm">
      <div>
        <label>用户名:</label>
        <input v-model="form.username" :class="{ error: errors.username }">
        <span v-if="errors.username" class="error">{{ errors.username }}</span>
      </div>
      
      <div>
        <label>邮箱:</label>
        <input v-model="form.email" :class="{ error: errors.email }">
        <span v-if="errors.email" class="error">{{ errors.email }}</span>
      </div>
      
      <div>
        <label>密码:</label>
        <input v-model="form.password" type="password" :class="{ error: errors.password }">
        <span v-if="errors.password" class="error">{{ errors.password }}</span>
      </div>
      
      <div>
        <label>确认密码:</label>
        <input v-model="form.confirmPassword" type="password" :class="{ error: errors.confirmPassword }">
        <span v-if="errors.confirmPassword" class="error">{{ errors.confirmPassword }}</span>
      </div>
      
      <button type="submit" :disabled="!isFormValid">提交</button>
    </form>
    
    <p>表单是否有效: {{ isFormValid ? '' : '' }}</p>
  </div>
</template>

<script>
export default {
  data() {
    return {
      form: {
        username: '',
        email: '',
        password: '',
        confirmPassword: ''
      }
    }
  },
  computed: {
    errors() {
      const errors = {}
      
      // 用户名验证
      if (!this.form.username) {
        errors.username = '用户名不能为空'
      } else if (this.form.username.length < 3) {
        errors.username = '用户名至少需要3个字符'
      }
      
      // 邮箱验证
      const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
      if (!this.form.email) {
        errors.email = '邮箱不能为空'
      } else if (!emailRegex.test(this.form.email)) {
        errors.email = '邮箱格式不正确'
      }
      
      // 密码验证
      if (!this.form.password) {
        errors.password = '密码不能为空'
      } else if (this.form.password.length < 6) {
        errors.password = '密码至少需要6个字符'
      }
      
      // 确认密码验证
      if (!this.form.confirmPassword) {
        errors.confirmPassword = '请确认密码'
      } else if (this.form.password !== this.form.confirmPassword) {
        errors.confirmPassword = '密码不匹配'
      }
      
      return errors
    },
    
    isFormValid() {
      // 如果没有错误,表单有效
      return Object.keys(this.errors).length === 0
    }
  },
  methods: {
    submitForm() {
      if (this.isFormValid) {
        console.log('表单提交:', this.form)
        alert('表单提交成功!')
      } else {
        console.log('表单验证失败:', this.errors)
      }
    }
  }
}
</script>

<style scoped>
.error {
  color: red;
  font-size: 0.9em;
}

input.error {
  border: 1px solid red;
}

form div {
  margin-bottom: 15px;
}
</style>

数据过滤和排序

vue
<template>
  <div>
    <h3>数据过滤和排序</h3>
    
    <div class="controls">
      <input v-model="searchQuery" placeholder="搜索...">
      <select v-model="sortField">
        <option value="name">按姓名排序</option>
        <option value="age">按年龄排序</option>
        <option value="salary">按薪资排序</option>
      </select>
      <select v-model="sortOrder">
        <option value="asc">升序</option>
        <option value="desc">降序</option>
      </select>
      <select v-model="filterByAge">
        <option value="">所有年龄</option>
        <option value="young">年轻人(&lt;30)</option>
        <option value="middle">中年人(30-50)</option>
        <option value="old">老年人(&gt;50)</option>
      </select>
    </div>
    
    <p>显示 {{ filteredAndSortedEmployees.length }} / {{ employees.length }} 个员工</p>
    
    <table>
      <thead>
        <tr>
          <th>姓名</th>
          <th>年龄</th>
          <th>部门</th>
          <th>薪资</th>
        </tr>
      </thead>
      <tbody>
        <tr v-for="employee in filteredAndSortedEmployees" :key="employee.id">
          <td>{{ employee.name }}</td>
          <td>{{ employee.age }}</td>
          <td>{{ employee.department }}</td>
          <td>{{ employee.salary }}</td>
        </tr>
      </tbody>
    </table>
  </div>
</template>

<script>
export default {
  data() {
    return {
      searchQuery: '',
      sortField: 'name',
      sortOrder: 'asc',
      filterByAge: '',
      employees: [
        { id: 1, name: '张三', age: 28, department: '技术部', salary: 15000 },
        { id: 2, name: '李四', age: 35, department: '销售部', salary: 12000 },
        { id: 3, name: '王五', age: 42, department: '人事部', salary: 10000 },
        { id: 4, name: '赵六', age: 25, department: '技术部', salary: 13000 },
        { id: 5, name: '钱七', age: 31, department: '财务部', salary: 11000 },
        { id: 6, name: '孙八', age: 48, department: '管理部', salary: 18000 }
      ]
    }
  },
  computed: {
    filteredEmployees() {
      let result = this.employees
      
      // 搜索过滤
      if (this.searchQuery) {
        const query = this.searchQuery.toLowerCase()
        result = result.filter(employee => 
          employee.name.toLowerCase().includes(query) ||
          employee.department.toLowerCase().includes(query)
        )
      }
      
      // 年龄过滤
      if (this.filterByAge) {
        switch (this.filterByAge) {
          case 'young':
            result = result.filter(emp => emp.age < 30)
            break
          case 'middle':
            result = result.filter(emp => emp.age >= 30 && emp.age <= 50)
            break
          case 'old':
            result = result.filter(emp => emp.age > 50)
            break
        }
      }
      
      return result
    },
    
    sortedEmployees() {
      const result = [...this.filteredEmployees]
      
      // 排序
      result.sort((a, b) => {
        let modifier = 1
        if (this.sortOrder === 'desc') {
          modifier = -1
        }
        
        if (a[this.sortField] < b[this.sortField]) return -1 * modifier
        if (a[this.sortField] > b[this.sortField]) return 1 * modifier
        return 0
      })
      
      return result
    },
    
    filteredAndSortedEmployees() {
      // 组合过滤和排序
      return this.sortedEmployees
    }
  }
}
</script>

<style scoped>
.controls {
  margin-bottom: 20px;
  display: flex;
  gap: 10px;
}

table {
  width: 100%;
  border-collapse: collapse;
}

th, td {
  border: 1px solid #ddd;
  padding: 8px;
  text-align: left;
}

th {
  background-color: #f2f2f2;
}
</style>

计算属性和侦听器是Vue.js中处理数据派生和副作用的强大工具,正确使用它们可以提高应用性能和可维护性。