计算属性和侦听器是Vue响应式系统的左膀右臂。一个负责"算",一个负责"看",配合默契。
在前面的文章中,我们已经初步接触了计算属性和侦听器。今天,让我们深入探索这两个核心特性,掌握它们的精髓。
📌 写作约定:本系列文章以 Vue 3
<script setup>语法糖 为主要讲解方式,这是Vue 3.2+官方推荐的写法。同时会顺带介绍Vue 2和Vue 3 Options API的写法作为对比,帮助大家理解演进过程和维护老项目。
一、计算属性:智能的计算器
计算属性(Computed)就像一个聪明的计算器——它记住了上次的计算结果,只有当输入数据变化时,才会重新计算。
1.1 缓存机制:计算属性的核心优势
计算属性最重要的特性是缓存。看这个例子:
<template>
<div>
<p>价格:{{ price }}</p>
<p>数量:{{ quantity }}</p>
<!-- 多次使用,只计算一次 -->
<p>总价:{{ totalPrice }}</p>
<p>总价:{{ totalPrice }}</p>
<p>总价:{{ totalPrice }}</p>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const price = ref(100)
const quantity = ref(2)
const totalPrice = computed(() => {
console.log('计算属性执行了') // 只会打印一次!
return price.value * quantity.value
})
</script>
缓存的工作原理:
第一次访问 totalPrice
↓
执行计算函数,结果缓存
↓
后续访问 totalPrice
↓
直接返回缓存值(不重新计算)
↓
price 或 quantity 变化
↓
缓存失效,下次访问时重新计算
1.2 计算属性 vs 方法
很多人会问:计算属性和方法有什么区别?
<template>
<div>
<!-- 计算属性:有缓存 -->
<p>计算属性:{{ totalPrice }}</p>
<p>计算属性:{{ totalPrice }}</p> <!-- 不会重新计算 -->
<!-- 方法:无缓存 -->
<p>方法:{{ getTotalPrice() }}</p>
<p>方法:{{ getTotalPrice() }}</p> <!-- 会重新计算 -->
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const price = ref(100)
const quantity = ref(2)
// 计算属性:有缓存
const totalPrice = computed(() => {
console.log('计算属性执行')
return price.value * quantity.value
})
// 方法:无缓存
const getTotalPrice = () => {
console.log('方法执行')
return price.value * quantity.value
}
</script>
对比总结:
| 特性 | 计算属性 | 方法 |
|---|---|---|
| 缓存 | ✅ 有 | ❌ 无 |
| 调用方式 | {{ totalPrice }} | {{ getTotalPrice() }} |
| 性能 | 更优(避免重复计算) | 一般 |
| 适用场景 | 数据派生、格式化 | 需要传参、事件处理 |
什么时候用方法?
<template>
<!-- 需要传参时,用方法 -->
<p>{{ formatPrice(price, 'USD') }}</p>
<p>{{ formatPrice(price, 'CNY') }}</p>
<!-- 事件处理,用方法 -->
<button @click="handleClick">点击</button>
</template>
<script setup>
const formatPrice = (price, currency) => {
const rates = { USD: 1, CNY: 7.2 }
return (price * rates[currency]).toFixed(2)
}
const handleClick = () => {
console.log('clicked')
}
</script>
1.3 可写计算属性
计算属性默认是只读的,但也可以设置为可写:
<template>
<div>
<p>姓:{{ firstName }}</p>
<p>名:{{ lastName }}</p>
<p>全名:{{ fullName }}</p>
<input v-model="fullName" placeholder="输入全名">
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const firstName = ref('张')
const lastName = ref('三')
// 可写计算属性
const fullName = computed({
// getter:读取时调用
get() {
return `${firstName.value}${lastName.value}`
},
// setter:赋值时调用
set(value) {
firstName.value = value.charAt(0)
lastName.value = value.slice(1)
}
})
</script>
实际应用场景:
<template>
<input v-model="searchQuery" placeholder="搜索...">
</template>
<script setup>
import { ref, computed } from 'vue'
const internalQuery = ref('')
// 可写计算属性:自动去空格
const searchQuery = computed({
get() {
return internalQuery.value
},
set(value) {
internalQuery.value = value.trim()
}
})
</script>
1.4 计算属性的最佳实践
✅ 推荐做法:
<script setup>
import { ref, computed } from 'vue'
const items = ref([
{ id: 1, name: 'iPhone', price: 6999, inStock: true },
{ id: 2, name: 'iPad', price: 3999, inStock: false },
{ id: 3, name: 'MacBook', price: 9999, inStock: true }
])
// ✅ 计算属性之间可以相互依赖
const availableItems = computed(() =>
items.value.filter(item => item.inStock)
)
const totalAvailablePrice = computed(() =>
availableItems.value.reduce((sum, item) => sum + item.price, 0)
)
// ✅ 返回新对象,避免修改原数据
const sortedItems = computed(() =>
[...items.value].sort((a, b) => a.price - b.price)
)
</script>
❌ 避免的做法:
<script setup>
import { ref, computed } from 'vue'
const items = ref([...])
let count = 0 // 非响应式数据
// ❌ 不要在计算属性中修改其他状态
const badComputed = computed(() => {
count++ // 副作用!会导致问题
return items.value.length
})
// ❌ 不要在计算属性中执行异步操作
const asyncComputed = computed(async () => {
const data = await fetchData() // 异步操作不会生效
return data
})
// ❌ 不要依赖非响应式数据
const wrongComputed = computed(() => {
return count // count不是响应式的,变化不会触发重新计算
})
</script>
二、侦听器:敏锐的观察员
侦听器(Watch)就像一个尽职的观察员——当数据变化时,它会立即采取行动。
2.1 watch的基本用法
Vue 3 <script setup> 写法:
<script setup>
import { ref, watch } from 'vue'
const count = ref(0)
// 监听ref
watch(count, (newVal, oldVal) => {
console.log(`count从${oldVal}变为${newVal}`)
})
</script>
对比Vue 3 Options API写法:
export default {
data() {
return { count: 0 }
},
watch: {
count(newVal, oldVal) {
console.log(`count从${oldVal}变为${newVal}`)
}
}
}
2.2 监听选项
watch支持多个选项来控制行为:
<script setup>
import { ref, watch } from 'vue'
const searchQuery = ref('')
watch(searchQuery, (newVal, oldVal, onCleanup) => {
console.log('搜索:', newVal)
// 清理函数:下次执行前调用
onCleanup(() => {
console.log('清理上一次的请求')
})
}, {
immediate: true, // 立即执行一次
deep: true, // 深度监听(用于对象)
flush: 'post' // DOM更新后执行
})
</script>
选项说明:
| 选项 | 作用 | 使用场景 |
|---|---|---|
immediate | 创建时立即执行一次 | 需要初始值时 |
deep | 深度监听对象内部变化 | 监听复杂对象 |
flush | 控制执行时机 | 需要访问更新后的DOM |
once | 只触发一次 | 一次性监听(Vue 3.4+) |
2.3 监听对象属性
监听对象的属性变化有多种方式:
<script setup>
import { ref, reactive, watch } from 'vue'
// =================== 监听ref对象 ===================
const user = ref({
name: '张三',
profile: {
age: 25,
city: '北京'
}
})
// 方式一:getter函数(推荐)
watch(() => user.value.name, (newVal) => {
console.log('名字变了:', newVal)
})
// 方式二:深度监听整个对象
watch(user, (newVal) => {
console.log('user变了')
}, { deep: true })
// 方式三:监听嵌套属性
watch(() => user.value.profile.age, (newVal) => {
console.log('年龄变了:', newVal)
})
// =================== 监听reactive对象 ===================
const state = reactive({
count: 0,
settings: {
theme: 'dark'
}
})
// reactive的属性可以直接用getter
watch(() => state.count, (newVal) => {
console.log('count变了:', newVal)
})
// 监听整个reactive对象(自动deep)
watch(state, (newVal) => {
console.log('state变了')
})
</script>
2.4 监听多个 数据源
<script setup>
import { ref, watch } from 'vue'
const firstName = ref('张')
const lastName = ref('三')
// 监听多个数据源
watch([firstName, lastName], ([newFirst, newLast], [oldFirst, oldLast]) => {
console.log(`名字从 ${oldFirst}${oldLast} 变为 ${newFirst}${newLast}`)
})
</script>
2.5 watch vs watchEffect
Vue 3提供了两种侦听方式:
<script setup>
import { ref, watch, watchEffect } from 'vue'
const count = ref(0)
const name = ref('张三')
// =================== watch:明确指定监听目标 ===================
watch(count, (newVal) => {
console.log('count变了:', newVal)
})
// =================== watchEffect:自动追踪依赖 ===================
watchEffect(() => {
// 自动追踪:用到谁就监听谁
console.log(`count=${count.value}, name=${name.value}`)
// count或name变化都会触发
})
</script>
对比:
| 特性 | watch | watchEffect |
|---|---|---|
| 依赖声明 | 显式指定 | 自动追踪 |
| 获取旧值 | ✅ 可以 | ❌ 不可以 |
| 立即执行 | 需要immediate | 默认立即执行 |
| 适用场景 | 需要比较新旧值 | 不关心旧值 |
选择建议:
<script setup>
import { ref, watch, watchEffect } from 'vue'
const count = ref(0)
const userId = ref(1)
// ✅ 用watch:需要比较新旧值
watch(userId, (newVal, oldVal) => {
if (newVal !== oldVal) {
fetchUserData(newVal)
}
})
// ✅ 用watchEffect:不关心旧值,自动追踪
watchEffect(() => {
document.title = `计数: ${count.value}`
})
</script>
2.6 侦听器的最佳实践
✅ 推荐做法:
<script setup>
import { ref, watch, onUnmounted } from 'vue'
const searchQuery = ref('')
let timer = null
// ✅ 清理副作用
watch(searchQuery, (newVal) => {
clearTimeout(timer)
timer = setTimeout(() => {
searchAPI(newVal)
}, 300)
})
// 组件卸载时清理
onUnmounted(() => {
clearTimeout(timer)
})
</script>
使用清理函数:
<script setup>
import { ref, watch } from 'vue'
const userId = ref(1)
watch(userId, (newVal, oldVal, onCleanup) => {
const controller = new AbortController()
// 发起请求
fetchUser(newVal, controller.signal)
// 清理函数:下次执行前或组件卸载时调用
onCleanup(() => {
controller.abort() // 取消上一次请求
})
})
</script>
三、计算属性 vs 侦听器:如何选择?
这是面试常考题,也是实际开发中经常纠结的问题。
3.1 核心区别
| 特性 | 计算属性 | 侦听器 |
|---|---|---|
| 返回值 | 必须返回值 | 可以不返回 |
| 缓存 | ✅ 有 | ❌ 无 |
| 异步 | ❌ 不支持 | ✅ 支持 |
| 副作用 | ❌ 不应该有 | ✅ 可以有 |
| 代码风格 | 声明式 | 命令式 |
3.2 选择指南
用计算属性当:
<script setup>
import { ref, computed } from 'vue'
const firstName = ref('张')
const lastName = ref('三')
const items = ref([...])
// ✅ 数据派生
const fullName = computed(() => `${firstName.value}${lastName.value}`)
// ✅ 数据过滤
const activeItems = computed(() => items.value.filter(i => i.active))
// ✅ 数据格式化
const formattedPrice = computed(() => `¥${price.value.toFixed(2)}`)
// ✅ 复杂计算
const totalPrice = computed(() => {
return items.value
.filter(i => i.selected)
.reduce((sum, i) => sum + i.price * i.quantity, 0)
})
</script>
用侦听器当:
<script setup>
import { ref, watch } from 'vue'
const searchQuery = ref('')
const userId = ref(1)
// ✅ 异步操作
watch(searchQuery, async (newVal) => {
const results = await searchAPI(newVal)
searchResults.value = results
})
// ✅ 需要比较新旧值
watch(userId, (newVal, oldVal) => {
if (newVal !== oldVal) {
fetchUserData(newVal)
}
})
// ✅ 副作用操作
watch(theme, (newVal) => {
document.body.className = newVal
localStorage.setItem('theme', newVal)
})
</script>
3.3 常见误区
误区一:用侦听器实现数据派生
<script setup>
import { ref, watch, computed } from 'vue'
const firstName = ref('张')
const lastName = ref('三')
// ❌ 不推荐:用watch实现数据派生
const fullName = ref('')
watch([firstName, lastName], ([first, last]) => {
fullName.value = `${first}${last}`
})
// ✅ 推荐:用computed
const fullName = computed(() => `${firstName.value}${lastName.value}`)
</script>
误区二:在计算属性中执行异步操作
<script setup>
import { ref, computed, watch } from 'vue'
const userId = ref(1)
// ❌ 错误:computed不支持异步
const userData = computed(async () => {
return await fetchUser(userId.value) // 返回Promise,不是数据
})
// ✅ 正确:用watch + ref
const userData = ref(null)
watch(userId, async (newVal) => {
userData.value = await fetchUser(newVal)
}, { immediate: true })
</script>
四、实战案例
4.1 搜索防抖
<template>
<div class="search-box">
<input
v-model="searchQuery"
placeholder="搜索用户..."
class="search-input"
/>
<div v-if="loading" class="loading">搜索中...</div>
<ul v-else-if="results.length" class="results">
<li v-for="user in results" :key="user.id" class="result-item">
<span class="name">{{ user.name }}</span>
<span class="email">{{ user.email }}</span>
</li>
</ul>
<div v-else-if="searchQuery && searched" class="empty">
未找到结果
</div>
</div>
</template>
<script setup>
import { ref, watch } from 'vue'
const searchQuery = ref('')
const results = ref([])
const loading = ref(false)
const searched = ref(false)
let debounceTimer = null
// 防抖搜索
watch(searchQuery, (newVal) => {
clearTimeout(debounceTimer)
if (!newVal.trim()) {
results.value = []
searched.value = false
return
}
debounceTimer = setTimeout(async () => {
loading.value = true
searched.value = true
// 模拟API请求
await new Promise(r => setTimeout(r, 300))
results.value = [
{ id: 1, name: `${newVal}用户1`, email: 'user1@example.com' },
{ id: 2, name: `${newVal}用户2`, email: 'user2@example.com' }
]
loading.value = false
}, 500) // 500ms防抖
})
</script>
<style scoped>
.search-box {
max-width: 400px;
margin: 20px auto;
}
.search-input {
width: 100%;
padding: 12px 16px;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 16px;
transition: border-color 0.3s;
}
.search-input:focus {
outline: none;
border-color: #42b983;
}
.loading {
text-align: center;
padding: 20px;
color: #666;
}
.results {
list-style: none;
padding: 0;
margin-top: 10px;
border: 1px solid #e0e0e0;
border-radius: 8px;
overflow: hidden;
}
.result-item {
display: flex;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid #f0f0f0;
}
.result-item:last-child {
border-bottom: none;
}
.result-item:hover {
background: #f9f9f9;
}
.name {
font-weight: 500;
}
.email {
color: #999;
font-size: 14px;
}
.empty {
text-align: center;
padding: 20px;
color: #999;
}
</style>
4.2 表单验证
<template>
<form @submit.prevent="handleSubmit" class="form">
<h2>用户注册</h2>
<!-- 用户名 -->
<div class="form-group">
<label>用户名</label>
<input v-model="form.username" @blur="touched.username = true" />
<p v-if="errors.username" class="error">{{ errors.username }}</p>
</div>
<!-- 邮箱 -->
<div class="form-group">
<label>邮箱</label>
<input v-model="form.email" type="email" @blur="touched.email = true" />
<p v-if="errors.email" class="error">{{ errors.email }}</p>
</div>
<!-- 密码 -->
<div class="form-group">
<label>密码</label>
<input v-model="form.password" type="password" @blur="touched.password = true" />
<p v-if="errors.password" class="error">{{ errors.password }}</p>
</div>
<!-- 确认密码 -->
<div class="form-group">
<label>确认密码</label>
<input v-model="form.confirmPassword" type="password" @blur="touched.confirmPassword = true" />
<p v-if="errors.confirmPassword" class="error">{{ errors.confirmPassword }}</p>
</div>
<!-- 密码强度 -->
<div class="password-strength">
<span>密码强度:</span>
<span :class="['strength', passwordStrength.level]">
{{ passwordStrength.text }}
</span>
</div>
<button type="submit" :disabled="!isValid" class="submit-btn">
注册
</button>
</form>
</template>
<script setup>
import { reactive, ref, computed, watch } from 'vue'
const form = reactive({
username: '',
email: '',
password: '',
confirmPassword: ''
})
const touched = reactive({
username: false,
email: false,
password: false,
confirmPassword: false
})
// =================== 计算属性:表单验证 ===================
const errors = computed(() => {
const errs = {}
if (touched.username && !form.username) {
errs.username = '请输入用户名'
} else if (touched.username && form.username.length < 3) {
errs.username = '用户名至少3个字符'
}
if (touched.email && !form.email) {
errs.email = '请输入邮箱'
} else if (touched.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email)) {
errs.email = '请输入有效的邮箱地址'
}
if (touched.password && !form.password) {
errs.password = '请输入密码'
} else if (touched.password && form.password.length < 6) {
errs.password = '密码至少6个字符'
}
if (touched.confirmPassword && !form.confirmPassword) {
errs.confirmPassword = '请确认密码'
} else if (touched.confirmPassword && form.password !== form.confirmPassword) {
errs.confirmPassword = '两次密码不一致'
}
return errs
})
// 表单是否有效
const isValid = computed(() => {
return form.username &&
form.email &&
form.password &&
form.confirmPassword &&
Object.keys(errors.value).length === 0
})
// =================== 计算属性:密码强度 ===================
const passwordStrength = computed(() => {
const pwd = form.password
if (!pwd) return { level: '', text: '未输入' }
let strength = 0
if (pwd.length >= 6) strength++
if (pwd.length >= 10) strength++
if (/[a-z]/.test(pwd) && /[A-Z]/.test(pwd)) strength++
if (/\d/.test(pwd)) strength++
if (/[!@#$%^&*]/.test(pwd)) strength++
const levels = {
0: { level: 'weak', text: '非常弱' },
1: { level: 'weak', text: '弱' },
2: { level: 'medium', text: '中等' },
3: { level: 'good', text: '强' },
4: { level: 'strong', text: '很强' },
5: { level: 'strong', text: '非常强' }
}
return levels[strength]
})
// =================== 侦听器:实时验证提示 ===================
watch(() => form.username, (newVal) => {
if (newVal && !touched.username) {
touched.username = true
}
})
const handleSubmit = () => {
// 标记所有字段为已触碰
Object.keys(touched).forEach(key => touched[key] = true)
if (isValid.value) {
console.log('提交表单:', form)
alert('注册成功!')
}
}
</script>
<style scoped>
.form {
max-width: 400px;
margin: 20px auto;
padding: 30px;
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0,0,0,0.1);
}
h2 {
text-align: center;
color: #333;
margin-bottom: 24px;
}
.form-group {
margin-bottom: 20px;
}
label {
display: block;
margin-bottom: 8px;
color: #333;
font-weight: 500;
}
input {
width: 100%;
padding: 12px;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 14px;
transition: border-color 0.3s;
}
input:focus {
outline: none;
border-color: #42b983;
}
.error {
color: #e74c3c;
font-size: 12px;
margin-top: 6px;
}
.password-strength {
margin-bottom: 20px;
font-size: 14px;
}
.strength {
font-weight: 500;
}
.strength.weak { color: #e74c3c; }
.strength.medium { color: #f39c12; }
.strength.good { color: #3498db; }
.strength.strong { color: #27ae60; }
.submit-btn {
width: 100%;
padding: 14px;
background: #42b983;
color: white;
border: none;
border-radius: 8px;
font-size: 16px;
cursor: pointer;
transition: background 0.3s;
}
.submit-btn:hover:not(:disabled) {
background: #3aa876;
}
.submit-btn:disabled {
background: #ccc;
cursor: not-allowed;
}
</style>
4.3 数据格式化与本地存储同步
<template>
<div class="settings-panel">
<h2>用户设置</h2>
<div class="setting-item">
<label>主题</label>
<select v-model="settings.theme">
<option value="light">浅色</option>
<option value="dark">深色</option>
<option value="auto">跟随系统</option>
</select>
</div>
<div class="setting-item">
<label>字体大小</label>
<input type="range" v-model.number="settings.fontSize" min="12" max="24" />
<span>{{ settings.fontSize }}px</span>
</div>
<div class="setting-item">
<label>
<input type="checkbox" v-model="settings.notifications" />
启用通知
</label>
</div>
<div class="setting-item">
<label>用户名</label>
<input v-model="settings.username" placeholder="输入用户名" />
</div>
<div class="preview">
<h3>预览效果</h3>
<p :style="{ fontSize: settings.fontSize + 'px' }">
这是{{ settings.username || '用户' }}的预览文本
</p>
</div>
<p class="save-status">{{ saveStatus }}</p>
</div>
</template>
<script setup>
import { reactive, computed, watch, watchEffect } from 'vue'
// =================== 从本地存储加载设置 ===================
const loadSettings = () => {
const saved = localStorage.getItem('userSettings')
return saved ? JSON.parse(saved) : {
theme: 'light',
fontSize: 16,
notifications: true,
username: ''
}
}
const settings = reactive(loadSettings())
const saveStatus = ref('')
// =================== 侦听器:保存到本地存储(防抖) ===================
let saveTimer = null
watch(settings, (newSettings) => {
clearTimeout(saveTimer)
saveStatus.value = '保存中...'
saveTimer = setTimeout(() => {
localStorage.setItem('userSettings', JSON.stringify(newSettings))
saveStatus.value = '已保存'
setTimeout(() => {
saveStatus.value = ''
}, 2000)
}, 500)
}, { deep: true })
// =================== watchEffect:应用主题 ===================
watchEffect(() => {
const theme = settings.theme
if (theme === 'auto') {
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches
document.body.className = isDark ? 'dark-theme' : 'light-theme'
} else {
document.body.className = `${theme}-theme`
}
})
// =================== 计算属性:验证用户名 ===================
const usernameError = computed(() => {
if (!settings.username) return ''
if (settings.username.length < 2) return '用户名至少2个字符'
if (settings.username.length > 20) return '用户名最多20个字符'
if (!/^[\u4e00-\u9fa5a-zA-Z0-9_]+$/.test(settings.username)) {
return '只能包含中文、字母、数字和下划线'
}
return ''
})
</script>
<style scoped>
.settings-panel {
max-width: 500px;
margin: 20px auto;
padding: 30px;
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0,0,0,0.1);
}
h2 {
color: #333;
margin-bottom: 24px;
}
.setting-item {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 20px;
padding-bottom: 20px;
border-bottom: 1px solid #f0f0f0;
}
.setting-item label {
min-width: 80px;
color: #333;
}
.setting-item select,
.setting-item input[type="text"] {
flex: 1;
padding: 8px 12px;
border: 2px solid #e0e0e0;
border-radius: 6px;
}
.setting-item input[type="range"] {
flex: 1;
}
.preview {
background: #f9f9f9;
padding: 20px;
border-radius: 8px;
margin-top: 20px;
}
.preview h3 {
margin-bottom: 12px;
color: #666;
}
.save-status {
text-align: center;
color: #42b983;
font-size: 14px;
margin-top: 16px;
}
</style>
五、总结
今天我们深入学习了计算属性和侦听器:
| 特性 | 计算属性 | 侦听器 |
|---|---|---|
| 核心用途 | 数据派生 | 响应变化 |
| 缓存 | ✅ 有 | ❌ 无 |
| 异步 | ❌ 不支持 | ✅ 支持 |
| 返回值 | 必须返回 | 可选 |
| 典型场景 | 格式化、过滤、计算 | API请求、副作用 |
记住这些要点:
- 能用计算属性就用计算属性——更简洁、有缓存
- 需要异步或副作用时用侦听器
- watch明确指定依赖,watchEffect自动追踪
- 在侦听器中记得清理副作用
- 不要在计算属性中修改其他状态
下一站预告
在下一篇文章《条件渲染与列表渲染》中,我们将深入学习:
- v-if与v-show的深度对比
- v-for的高级用法
- 列表渲染的性能优化
作者:洋洋技术笔记
发布日期:2026-03-02
系列:Vue.js从入门到精通 - 第4篇