Vue 练习题(15 题)

学了语法和组件,得动手写才算真的会。这一页给 15 道精心挑选的练习题,按难度分三级——基础 6 题(模板 / 指令 / 方法)、进阶 5 题(组件 / props / emit / 组合式函数)、实战 4 题(综合应用)。每题都含题目描述、提示、可折叠的参考答案。

使用建议:先别急着展开答案。读题后自己打开本地 Vue 项目(或 Vue Playground)试着写,卡住了再看提示,实在写不出再对照参考答案。答案用 Vue 3 <script setup> 写法,不止一种实现,你的写法可能比参考更好——能跑通、可读性强就是好代码。

基础(6 题):模板 / 指令 / 方法

这一组覆盖最基础的响应式 API——ref / computed / v-model / v-for / v-text / 事件绑定,目标是用最短的代码解决一个小问题,训练"把思路翻译成模板"的能力。

第 1 题:翻转字符串

题目:用 ref 维护一个字符串,用 computed 计算它的翻转结果,在模板里用 v-model 双向绑定输入,并显示翻转后的值。比如输入 hello,显示 olleh。

提示:ref('hello') 创建响应式字符串;翻转用 text.value.split('').reverse().join('');computed 会自动追踪 ref 的变化重新计算。

参考答案
<script setup>
import { ref, computed } from 'vue'
const text = ref('hello')
// computed 自动追踪 text 的变化,只读
const reversed = computed(() => text.value.split('').reverse().join(''))
</script>

<template>
  <input v-model="text" />
  <p>翻转结果:{{ reversed }}</p>
  <!-- 输入 hello 时 显示: olleh -->
</template>

第 2 题:判断素数

题目:用 ref 维护一个数字,用 computed 判断它是否素数,并用 v-text 把判断结果渲染成一句话。比如 7 显示"7 是素数吗?是"。

提示:小于 2 的都不是素数;只需检查从 2 到 sqrt(n) 的整除性——超过平方根的因子必然配对一个更小的、已经检查过的因子。v-text 等价于 {{ }} 但赋给 textContent。

参考答案
<script setup>
import { ref, computed } from 'vue'
const n = ref(7)
// 素数判断:< 2 直接否;只需检查到 sqrt(n)
const isPrime = computed(() => {
  const x = n.value
  if (x < 2) return false
  for (let i = 2; i * i <= x; i++) {
    if (x % i === 0) return false
  }
  return true
})
</script>

<template>
  <input type="number" v-model.number="n" />
  <!-- v-text 把表达式结果作为文本写入 -->
  <p v-text="n + ' 是素数吗?' + (isPrime ? '是' : '否')"></p>
  <!-- 显示: 7 是素数吗?是 -->
</template>

第 3 题:FizzBuzz

题目:用 v-for 渲染 1 到 15 的 <li>。3 的倍数显示 Fizz,5 的倍数显示 Buzz,同时是 3 和 5 的倍数显示 FizzBuzz,其余显示数字本身。用 :class 给三类标签分别加不同颜色。

提示:判断顺序很关键——必须先判断"同时是 3 和 5 的倍数"(即 15 的倍数),否则会被前面的分支截胡。v-for 必须配 :key;函数返回类名字符串,配合 scoped style 上色。

参考答案
<script setup>
import { computed } from 'vue'
// 生成 1-15 的列表项,每项带 n 和 label
const items = computed(() =>
  Array.from({ length: 15 }, (_, i) => i + 1).map((n) => ({
    n,
    label:
      n % 15 === 0 ? 'FizzBuzz'
      : n % 3 === 0 ? 'Fizz'
      : n % 5 === 0 ? 'Buzz'
      : String(n),
  }))
)
// 根据标签返回不同 class
function cls(label) {
  if (label === 'FizzBuzz') return 'fb'
  if (label === 'Fizz') return 'fz'
  if (label === 'Buzz') return 'bz'
  return ''
}
</script>

<template>
  <ul>
    <li v-for="item in items" :key="item.n" :class="cls(item.label)">
      {{ item.n }}: {{ item.label }}
    </li>
  </ul>
</template>

<style scoped>
.fz { color: orange; }
.bz { color: teal; }
.fb { color: red; font-weight: bold; }
</style>

第 4 题:列表最大值

题目:用 ref 维护一个数字数组,用 computed 计算最大值并显示。比如 [3,1,4,1,5,9,2,6] 显示 9。

提示:用 reduce((a,b) => a > b ? a : b, -Infinity) 一行求最大;初始值用 -Infinity 兜底空数组;Math.max(...arr) 也能做但遇到超大数组会爆栈。

参考答案
<script setup>
import { ref, computed } from 'vue'
const nums = ref([3, 1, 4, 1, 5, 9, 2, 6])
// reduce 一行求最大,-Infinity 兜底空数组
const max = computed(() => nums.value.reduce((a, b) => (a > b ? a : b), -Infinity))
</script>

<template>
  <p>数组:{{ nums.join(', ') }}</p>
  <p>最大值:{{ max }}</p>
  <!-- 显示: 数组: 3, 1, 4, 1, 5, 9, 2, 6 / 最大值: 9 -->
</template>

第 5 题:数组求和

题目:用 ref 维护数组 [10,20,30,40],用 computed 同时派生"总和"与"平均值",在模板显示。空数组时平均值返回 0,不能抛错。

提示:reduce((a,b) => a+b, 0) 求和;平均要除以 nums.length,必须先判空,否则除零得 NaN。computed 之间可互相派生。

参考答案
<script setup>
import { ref, computed } from 'vue'
const nums = ref([10, 20, 30, 40])
// reduce 累加;再派生平均值
const sum = computed(() => nums.value.reduce((a, b) => a + b, 0))
const avg = computed(() => (nums.value.length ? sum.value / nums.value.length : 0))
</script>

<template>
  <p>数组:{{ nums.join(', ') }}</p>
  <p>总和:{{ sum }} / 平均:{{ avg }}</p>
  <!-- 显示: 总和: 100 / 平均: 25 -->
</template>

第 6 题:阶乘

题目:用 ref 维护一个数字 n,写 factorial() 函数,点击按钮时计算 n! 并显示结果。比如 5! = 120。

提示:函数里用一个累乘变量,从 2 乘到 n.value,把结果赋给另一个 ref;按钮用 @click="factorial" 触发;未计算前用 v-if 隐藏结果。

参考答案
<script setup>
import { ref } from 'vue'
const n = ref(5)
const result = ref(null)
// 阶乘:从 2 累乘到 n
function factorial() {
  let r = 1
  for (let i = 2; i <= n.value; i++) r *= i
  result.value = r
}
</script>

<template>
  <input type="number" v-model.number="n" />
  <button @click="factorial">计算 n!</button>
  <p v-if="result !== null">{{ n }}! = {{ result }}</p>
  <!-- 点击后 显示: 5! = 120 -->
</template>

进阶(5 题):组件 / props / emit / 组合式函数

这一组开始组合组件化思想——用 props 接数据、用 emit 发事件、用 slot 分发内容、用 v-model 双向绑定、用组合式函数封装可复用逻辑。做完了你会对 Vue 3 的"组件协作"有直观感受。

第 7 题:子组件用 props 接收并显示

题目:写一个 UserCard 子组件,用 defineProps 接收 name(必填字符串)和 age(数字,默认 0),在卡片里显示。父组件传入 name="小明" :age="18"。

提示:defineProps({name: {type, required}, age: {type, default}}) 带类型校验;非字符串 prop 父组件要用 : 绑定(否则当字符串传)。

参考答案
<!-- UserCard.vue 子组件 -->
<script setup>
// defineProps 声明接收的属性
defineProps({
  name: { type: String, required: true },
  age: { type: Number, default: 0 },
})
</script>

<template>
  <div class="card">
    <h4>姓名:{{ name }}</h4>
    <p>年龄:{{ age }}</p>
  </div>
</template>

<!-- 父组件用法 -->
<!--
<script setup>
import UserCard from './UserCard.vue'
</script>
<template>
  <UserCard name="小明" :age="18" />
</template>
-->
<!-- 渲染:一张卡片,显示"姓名:小明 / 年龄:18" -->

第 8 题:子组件用 emit 向父发事件

题目:写一个 CounterButton 子组件,显示当前计数,带 +/- 两个按钮。点击按钮时不直接改父状态,而是用 emit 发 change 事件,父组件监听后更新自己的 ref。

提示:子组件 defineEmits(['change']) 声明事件,函数里 emit('change', newValue) 触发;父组件用 @change="total = $event" 接住 $event(即事件参数)。这是"单向数据流"的标准做法。

参考答案
<!-- CounterButton.vue 子组件 -->
<script setup>
import { defineProps, defineEmits } from 'vue'
const props = defineProps({ count: Number })
// 声明要触发的事件
const emit = defineEmits(['change'])
function add() { emit('change', props.count + 1) }
function sub() { emit('change', props.count - 1) }
</script>

<template>
  <button @click="sub">-</button>
  <span>{{ count }}</span>
  <button @click="add">+</button>
</template>

<!-- 父组件用法 -->
<!--
<script setup>
import { ref } from 'vue'
import CounterButton from './CounterButton.vue'
const total = ref(0)
</script>
<template>
  <CounterButton :count="total" @change="total = $event" />
</template>
-->
<!-- 渲染:可增减的计数器 [ - ] 0 [ + ] -->

第 9 题:具名插槽 slot 分发内容

题目:写一个 CardLayout 子组件,提供 title、默认、footer 三个插槽,分别放在 header / main / footer 标签里。父组件用 <template #title> 简写分发内容。

提示:子组件 <slot name="title">默认标题</slot> 提供具名插槽;父组件用 <template #title>...</template>(等价于 v-slot:title)分发;没有 name 的就进默认插槽。

参考答案
<!-- CardLayout.vue 子组件 -->
<template>
  <section class="card">
    <header><slot name="title">默认标题</slot></header>
    <main><slot>默认内容</slot></main>
    <footer><slot name="footer" /></footer>
  </section>
</template>

<!-- 父组件用法:用 #name 简写分发内容到具名插槽 -->
<!--
<script setup>
import CardLayout from './CardLayout.vue'
</script>
<template>
  <CardLayout>
    <template #title>我的文章</template>
    <p>正文内容...</p>
    <template #footer>2026 © me</template>
  </CardLayout>
</template>
-->
<!-- 渲染:三栏卡片,标题/正文/页脚分别由父级填充 -->

第 10 题:v-model 在自定义组件上双向绑定

题目:写一个 CustomInput 子组件,在父组件里用 v-model="text" 双向绑定一个 ref。要求:输入框的值跟随父级,父级的值也跟随输入。

提示:Vue 3 的 v-model 默认绑定 modelValue prop + update:modelValue 事件;子组件用 :value="modelValue" 显示,用 @input 触发 emit('update:modelValue', newVal)。父组件写法与原生 input 完全一致。

参考答案
<!-- CustomInput.vue -->
<script setup>
// v-model 默认绑定 modelValue 属性 + update:modelValue 事件
defineProps(['modelValue'])
const emit = defineEmits(['update:modelValue'])
</script>

<template>
  <input
    :value="modelValue"
    @input="emit('update:modelValue', $event.target.value)"
  />
</template>

<!-- 父组件用法 -->
<!--
<script setup>
import { ref } from 'vue'
import CustomInput from './CustomInput.vue'
const text = ref('hi')
</script>
<template>
  <CustomInput v-model="text" />
  <p>{{ text }}</p>
</template>
-->
<!-- 输入框内容与父级 text 双向同步 -->

第 11 题:组合式函数 useCounter

题目:写一个 useCounter(initial) 组合式函数,封装 count(ref)、double(computed)、inc / dec / reset 方法,在组件里解构使用。

提示:组合式函数(Composable)就是"用 ref/computed 封装可复用状态的函数",约定文件名以 use 开头;返回 ref 和方法,在 <script setup> 里 const { count, inc } = useCounter(10) 解构;模板自动解包 ref。

参考答案
<!-- composables/useCounter.js -->
<!-- 把 ref 状态和方法封装到一个函数里,可在任意组件复用 -->
<script setup>
// useCounter.js 文件实际内容(纯 JS,不是 SFC):
/*
import { ref, computed } from 'vue'

export function useCounter(initial = 0) {
  const count = ref(initial)
  const double = computed(() => count.value * 2)
  function inc() { count.value++ }
  function dec() { count.value-- }
  function reset() { count.value = initial }
  return { count, double, inc, dec, reset }
}
*/

// App.vue 用法:
import { useCounter } from './composables/useCounter'
const { count, double, inc, dec, reset } = useCounter(10)
</script>

<template>
  <button @click="dec">-</button>
  <span>{{ count }} (×2 = {{ double }})</span>
  <button @click="inc">+</button>
  <button @click="reset">重置</button>
</template>
<!-- 渲染:计数 10,双倍 20,可增减可重置 -->

实战(4 题):综合应用

这一组每题都会综合用到多个知识点——reactive 状态、列表渲染、组件协作、组合式函数、表单校验——做出一个接近真实场景的小程序。建议每题都完整跑一遍,再想想怎么扩展。

第 12 题:Todo App(增删 + 完成)

题目:用 reactive 维护一个 Todo 列表,每项含 id / text / done。要求:输入框回车或点击按钮添加;勾选 checkbox 切换完成态(完成的加删除线样式);点删除按钮移除该项。

提示:reactive([]) 管理数组;@keyup.enter="add" 监听回车;v-model="t.done" 双向绑定布尔;:class="{ done: t.done }" 对象语法切换类;删除用 findIndex + splice。

参考答案
<script setup>
import { reactive, ref } from 'vue'
// reactive 适合管理对象/数组状态
const todos = reactive([])
const newTodo = ref('')
function add() {
  const text = newTodo.value.trim()
  if (!text) return
  todos.push({ id: Date.now(), text, done: false })
  newTodo.value = ''
}
function remove(id) {
  const i = todos.findIndex((t) => t.id === id)
  if (i !== -1) todos.splice(i, 1)
}
</script>

<template>
  <input v-model="newTodo" @keyup.enter="add" placeholder="回车添加" />
  <button @click="add">添加</button>
  <ul>
    <li v-for="t in todos" :key="t.id">
      <input type="checkbox" v-model="t.done" />
      <!-- 对象语法 :class 绑定 done 类 -->
      <span :class="{ done: t.done }">{{ t.text }}</span>
      <button @click="remove(t.id)">删除</button>
    </li>
  </ul>
</template>

<style scoped>
.done { text-decoration: line-through; color: #999; }
</style>
<!-- 渲染:可输入、回车添加、勾选完成、删除的 Todo 列表 -->

第 13 题:计数器组合式函数 useCounter(可复用)

题目:进阶版 useCounter(key, initial, step) —— 支持 localStorage 持久化(刷新页面值不丢)、可选步长。在同一个组件里复用两次,分别用不同的 key 和步长。

提示:首次进入从 localStorage.getItem(key) 读取(Number 转换失败回退到 initial);watch(count, ...) 监听变化自动写回;同组件里解构两次重命名 const { count: a, inc: incA } = useCounter('a', 0, 1)。

参考答案
<!-- composables/useCounter.js — 进阶版:localStorage 持久化 + 可选步长 -->
<script setup>
// useCounter.js 文件实际内容:
/*
import { ref, watch } from 'vue'

export function useCounter(key, initial = 0, step = 1) {
  // 从 localStorage 读,失败兜底到 initial
  const stored = Number(localStorage.getItem(key))
  const count = ref(Number.isNaN(stored) ? initial : stored)
  // watch 自动同步到 localStorage
  watch(count, (v) => localStorage.setItem(key, String(v)))
  const inc = () => (count.value += step)
  const dec = () => (count.value -= step)
  const reset = () => (count.value = initial)
  return { count, inc, dec, reset }
}
*/

// App.vue — 同一组件复用两次,各自独立、刷新不丢
import { useCounter } from './composables/useCounter'
const { count: a, inc: incA } = useCounter('a', 0, 1)
const { count: b, inc: incB } = useCounter('b', 100, 10)
</script>

<template>
  <p>A: {{ a }} <button @click="incA">+1</button></p>
  <p>B: {{ b }} <button @click="incB">+10</button></p>
</template>
<!-- 渲染:两个独立计数器,刷新后值保持 -->

第 14 题:表单组件(emit 输入 + 简单校验)

题目:写一个 LoginForm 组件,含邮箱、密码两个输入框。点击登录时做简单校验(非空、邮箱含 @、密码至少 6 位);校验通过则 emit('submit', data) 把表单数据发给父组件,失败则在表单内显示红字错误。

提示:用本地 error ref 存错误信息,v-if="error" 控制显示;@submit.prevent="submit" 阻止默认提交;校验通过后清空 error 再 emit;父组件监听 @submit 拿到数据自行处理。

参考答案
<!-- LoginForm.vue -->
<script setup>
import { ref } from 'vue'
const email = ref('')
const pwd = ref('')
const error = ref('')
const emit = defineEmits(['submit'])
// 简单校验:非空 + 邮箱含 @ + 密码长度
function validate() {
  if (!email.value || !pwd.value) return '邮箱和密码必填'
  if (!email.value.includes('@')) return '邮箱格式不正确'
  if (pwd.value.length < 6) return '密码至少 6 位'
  return ''
}
function submit() {
  const msg = validate()
  if (msg) { error.value = msg; return }
  error.value = ''
  emit('submit', { email: email.value, pwd: pwd.value })
}
</script>

<template>
  <form @submit.prevent="submit">
    <input v-model="email" placeholder="邮箱" />
    <input v-model="pwd" type="password" placeholder="密码" />
    <p v-if="error" class="err">{{ error }}</p>
    <button type="submit">登录</button>
  </form>
</template>

<style scoped>
.err { color: red; margin: 4px 0; }
</style>

<!-- 父组件:监听 submit 拿到表单数据 -->
<!--
<script setup>
import LoginForm from './LoginForm.vue'
function onSubmit(data) { console.log('收到', data) }
</script>
<template>
  <LoginForm @submit="onSubmit" />
</template>
-->
<!-- 渲染:带校验的登录表单,出错显示红字提示 -->

第 15 题:商品列表 + 购物车(多组件协作)

题目:拆三个组件——ProductList(接收 products 数组,每项点"加入购物车"emit add 事件)、Cart(接收 items,显示数量、清单、合计,emit clear 清空)、App(持有 products 和 cart 状态,连接两个子组件)。

提示:数据在父组件 ref([]),通过 :products / :items 向下传;子组件用 $emit('add', product) 在模板里直接触发(无需 setup 里定义);合计用 reduce;父组件 @add="add" / @clear="cart = []" 接事件改状态。

参考答案
<!-- ProductList.vue 子组件 -->
<script setup>
defineProps(['products'])
defineEmits(['add'])
</script>
<template>
  <ul>
    <li v-for="p in products" :key="p.id">
      {{ p.name }} - ¥{{ p.price }}
      <button @click="$emit('add', p)">加入购物车</button>
    </li>
  </ul>
</template>

<!-- Cart.vue 子组件 -->
<script setup>
defineProps(['items'])
defineEmits(['clear'])
</script>
<template>
  <div>
    <h4>购物车 ({{ items.length }})</h4>
    <ul><li v-for="(it, i) in items" :key="i">{{ it.name }} ¥{{ it.price }}</li></ul>
    <p>合计:¥{{ items.reduce((s, it) => s + it.price, 0) }}</p>
    <button @click="$emit('clear')">清空</button>
  </div>
</template>

<!-- App.vue 父组件 -->
<!--
<script setup>
import { ref } from 'vue'
import ProductList from './ProductList.vue'
import Cart from './Cart.vue'
const products = [
  { id: 1, name: '苹果', price: 5 },
  { id: 2, name: '香蕉', price: 3 },
]
const cart = ref([])
function add(p) { cart.value.push(p) }
</script>
<template>
  <ProductList :products="products" @add="add" />
  <Cart :items="cart" @clear="cart = []" />
</template>
-->
<!-- 渲染:商品可加购,购物车显示数量与合计,可清空 -->

练习建议

不要只看答案。把每道题在本地或 Vue Playground 亲手敲一遍,改一改输入试试边界情况(空数组、负数、超长字符串)。Vue 的响应式很灵活,但写错依赖关系时不会报错,只会"不更新",自己跑一遍才能发现。

试着扩展:做完第 12 题 Todo,能不能加"全部完成 / 未完成筛选"和 localStorage 持久化?做完第 14 题表单,能不能抽一个通用的 useForm 组合式函数?做完第 15 题购物车,能不能加"数量加减"和"小计"?每个扩展都是一次小型的真实开发。

更多练习资源:看官方 互动教程;做小项目去 Awesome Vue 找灵感;遇到不懂的先查 官方文档(有中文版)。

← 上一篇 Vue 实战项目

返回 Vue 教程目录

✈️💬