第1章 1.4 Vue3 语法 + uniapp 组件

🎯 为什么要学这个?

上一章我们搞定了 pages.json,学会了怎么配置页面路径和底部 tab 栏。你有没有这种感觉:页面是能跳转了,但页面里面的内容还是一团糟——列表不知道怎么循环、condition(条件判断)不知道怎么做、组件不知道在哪里买...

痛点来了:

  • 想做个待办清单,发现列表数据不知道怎么渲染
  • 想根据不同状态显示不同内容,不知道用 if 还是 show
  • 想封装一个"卡片组件"给多个页面用,不知道从哪里下手

学完这一章,上面三个问题你都能自己解决。我会用 3 个小项目 带你从"看得懂"到"写得出来",最后你会有一个自己的「番茄钟小工具」可以真真切切用起来。


🧱 基础 25 分钟:核心概念先拿下

1. Vue3 的新写法:组合式 API(Composition API)

是什么: Vue3 新增的一种写代码的方式,把同一个功能的代码放在一起管理,而不是分散在 datamethods\n\nSimple tech illustration expla\n\nAI comic creation scene, creat\n\n、computed 里。

生活类比: 想象你收拾行李——旧写法像把所有人的衣服分别放三个箱子(上衣箱、裤子箱、袜子箱),找的时候要翻三个箱子;新写法是按人分,每个人一个行李包,找的时候直接拎出某个人的包就行。

为什么用: 代码更容易复用和维护,特别适合大项目。

第一个可运行代码:体验 setup

<script>
export default {
setup() {
// 定义一个响应式变量
const name = ref('小明')
const age = ref(18)

// 定义一个函数
const sayHello = () => {
  return `你好,我叫${name.value},今年${age.value}岁`
}

// 返回给模板用的东西
return {
  name,
  age,
  sayHello
}
}
}
</script>

这 3 行代码告诉你:ref() 用来定义变量,函数直接 return 出去就能在模板里用。

2. uniapp 三剑客:<view><text><image>

是什么: uniapp 最基础的三个组件,相当于建房子的「砖头」。

生活类比: <view> 是空房间,<text> 是房间里的文字,<image> 是墙上的画。

为什么要用: 所有复杂的界面都是这三个组件堆出来的。

第二个可运行代码:三剑客合体

<template>
<view class="container">
<text class="title">欢迎来到uniapp</text>
<image 
  class="logo"
  src="/static/logo.png" 
  mode="aspectFit"
/>
<text class="desc">想象无限,代码实现</text>
</view>
</template>

<style>
.container {
padding: 20px;
align-items: center;
}
.title {
font-size: 24px;
font-weight: bold;
color: #333;
}
.logo {
width: 150px;
height: 150px;
margin: 20px 0;
}
.desc {
font-size: 14px;
color: #666;
}
</style>

view 是容器,text 是文字,image 要指定 srcmode。这就是 uniapp 页面的基本骨架。

3. 条件渲染:v-if vs v-show

是什么: 两种控制元素显示/隐藏的方式。

区别在哪里:

  • v-if:条件为 false 时,元素彻底删除(像把东西扔出房间)
  • v-show:条件为 false 时,元素还在只是隐藏(像把东西藏在窗帘后面)

什么时候用哪个:
- 切换频率高 → 用 v-show(不用反复创建删除,节省性能)
- 切换频率低 → 用 v-if(不用占着 DOM 节点,省内存)

第三个可运行代码:登录状态切换

<script>
export default {
setup() {
const isLoggedIn = ref(false)

const toggleLogin = () => {
  isLoggedIn.value = !isLoggedIn.value
}

return { isLoggedIn, toggleLogin }
}
}
</script>

<template>
<view class="container">
<!-- v-if 版本:彻底销毁重建 -->
<view v-if="isLoggedIn" class="user-info">
  <text>欢迎回来,小明!</text>
</view>
<view v-else class="login-tip">
  <text>请先登录哦~</text>
</view>

<!-- v-show 版本:切换显示隐藏 -->
<view v-show="!isLoggedIn" class="login-btn">
  <text @click="toggleLogin">点我登录</text>
</view>

<view v-show="isLoggedIn" @click="toggleLogin" class="logout-btn">
  <text>退出登录</text>
</view>
</view>
</template>

4. 列表渲染:v-for

是什么: 循环输出列表中的每个元素。

生活类比: 就像快餐店叫号,1号、2号、3号...每个号牌对应一份套餐。

第四个可运行代码:购物清单

<script>
export default {
setup() {
// 定义一个购物清单
const groceries = ref([
  { id: 1, name: '西红柿', count: 2, price: 5 },
  { id: 2, name: '鸡蛋', count: 10, price: 8 },
  { id: 3, name: '面条', count: 1, price: 12 }
])

// 计算总价
const totalPrice = computed(() => {
  return groceries.value.reduce((sum, item) => {
    return sum + item.count * item.price
  }, 0)
})

return { groceries, totalPrice }
}
}
</script>

<template>
<view class="list">
<view 
  v-for="item in groceries" 
  :key="item.id"
  class="item"
>
  <text class="item-name">{{ item.name }}</text>
  <text class="item-count">x{{ item.count }}</text>
  <text class="item-price">¥{{ item.count * item.price }}</text>
</view>
<view class="total">
  <text>总计:¥{{ totalPrice }}</text>
</view>
</view>
</template>

:key="item.id" 很重要!它让 Vue 知道每个元素是谁,列表变化时能精准更新而不是重新渲染整个列表。

5. 事件处理:@click 和传参

是什么: 给元素绑定点击行为,触发对应的函数。

注意: 在 Vue3 的 setup 里,如果要给函数传参数,需要用箭头函数包一层:@click="() => 函数名(参数)"

第五个可运行代码:计数器

<script>
export default {
setup() {
const count = ref(0)

// 不传参:直接写函数名
const addOne = () => {
  count.value++
}

// 传参:用箭头函数包一层
const addN = (n) => {
  count.value += n
}

return { count, addOne, addN }
}
}
</script>

<template>
<view class="counter">
<text class="count">当前计数:{{ count }}</text>
<view class="btn-group">
  <view class="btn" @click="addOne">
    <text>+1</text>
  </view>
  <view class="btn" @click="() => addN(5)">
    <text>+5</text>
  </view>
  <view class="btn" @click="() => addN(10)">
    <text>+10</text>
  </view>
</view>
</view>
</template>

6. Props:从父组件传数据给子组件

是什么: 组件之间的"快递系统",父组件通过 props 把数据传给子组件。

生活类比: 你点外卖,商家(父组件)把饭菜(数据)装进餐盒,通过外卖员(props)送到你家(子组件)。

第六个可运行代码:卡片组件

// CardItem.vue(子组件)
<script>
export default {
// 声明要接收的props
props: {
title: String,
content: String,
tag: {
  type: String,
  default: '默认标签'
}
}
}
</script>

<template>
<view class="card">
<view class="card-header">
  <text class="card-title">{{ title }}</text>
  <text class="card-tag">{{ tag }}</text>
</view>
<text class="card-content">{{ content }}</text>
</view>
</template>

// 父组件中使用
<template>
<CardItem 
title="重要通知"
content="明天早上9点开会"
tag="工作"
/>
</template>

🔥 实战 35 分钟:3 个项目真刀真枪

项目 1(5 分钟):小明的名片展示

目标: 用组件化的方式展示一张名片

// pages/index/index.vue
<script>
export default {
data() {
return {
  user: {
    name: '小明',
    job: '全栈工程师',
    company: '某科技公司',
    skills: ['Vue', 'uniapp', 'Python'],
    email: 'xiaoming@example.com'
  }
}
}
}
</script>

<template>
<view class="business-card">
<view class="header">
  <text class="name">{{ user.name }}</text>
  <text class="job">{{ user.job }}</text>
</view>
<view class="company">
  <text>🏢 {{ user.company }}</text>
</view>
<view class="skills">
  <view 
    v-for="(skill, index) in user.skills" 
    :key="index"
    class="skill-tag"
  >
    <text>{{ skill }}</text>
  </view>
</view>
<view class="contact">
  <text>📧 {{ user.email }}</text>
</view>
</view>
</template>

<style>
.business-card {
margin: 20px;
padding: 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 16px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
}
.header {
margin-bottom: 15px;
}
.name {
display: block;
font-size: 28px;
font-weight: bold;
color: #fff;
}
.job {
display: block;
font-size: 16px;
color: rgba(255,255,255,0.8);
margin-top: 5px;
}
.company, .contact {
color: #fff;
margin: 10px 0;
font-size: 14px;
}
.skills {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 15px;
}
.skill-tag {
background: rgba(255,255,255,0.2);
padding: 4px 12px;
border-radius: 20px;
}
.skill-tag text {
color: #fff;
font-size: 12px;
}
</style>

预期输出: 一张渐变紫色背景的名片,包含姓名、职位、公司、技能标签、邮箱。

一句话解释:v-for 渲染 skills 数组,每个 skill 是个小标签。


项目 2(15 分钟):商品列表 + 价格计算

目标: 从 JSON 数据渲染商品列表,支持筛选和总价计算

// pages/goods/index.vue
<script>
export default {
data() {
return {
  products: [
    { id: 1, name: 'iPhone 15', price: 5999, category: '手机', stock: 100 },
    { id: 2, name: 'MacBook Pro', price: 12999, category: '电脑', stock: 50 },
    { id: 3, name: 'AirPods Pro', price: 1899, category: '配件', stock: 200 },
    { id: 4, name: 'iPad Air', price: 4799, category: '平板', stock: 80 },
    { id: 5, name: 'Apple Watch', price: 2999, category: '手表', stock: 120 }
  ],
  selectedCategory: '全部',
  cart: []
}
},
computed: {
// 筛选后的商品列表
filteredProducts() {
  if (this.selectedCategory === '全部') {
    return this.products
  }
  return this.products.filter(p => p.category === this.selectedCategory)
},
// 购物车总价
cartTotal() {
  return this.cart.reduce((sum, item) => sum + item.price * item.count, 0)
},
// 分类选项
categories() {
  const cats = [...new Set(this.products.map(p => p.category))]
  return ['全部', ...cats]
}
},
methods: {
addToCart(product) {
  // 查找购物车里有没有这个商品
  const exist = this.cart.find(item => item.id === product.id)
  if (exist) {
    exist.count++
  } else {
    this.cart.push({ ...product, count: 1 })
  }
  uni.showToast({
    title: '已加入购物车',
    icon: 'success'
  })
}
}
}
</script>

<template>
<view class="container">
<!-- 分类筛选 -->
<scroll-view scroll-x class="category-bar">
  <view 
    v-for="cat in categories" 
    :key="cat"
    :class="['cat-item', { active: selectedCategory === cat }]"
    @click="selectedCategory = cat"
  >
    <text>{{ cat }}</text>
  </view>
</scroll-view>

<!-- 商品列表 -->
<view class="product-list">
  <view 
    v-for="product in filteredProducts" 
    :key="product.id"
    class="product-item"
  >
    <view class="product-info">
      <text class="product-name">{{ product.name }}</text>
      <text class="product-category">{{ product.category }}</text>
      <text class="product-price">¥{{ product.price }}</text>
    </view>
    <view 
      class="add-btn"
      @click="addToCart(product)"
    >
      <text>+</text>
    </view>
  </view>
</view>

<!-- 购物车悬浮条 -->
<view class="cart-bar" v-if="cart.length > 0">
  <view class="cart-info">
    <text>共 {{ cart.length }} 件商品</text>
    <text class="cart-total">¥{{ cartTotal }}</text>
  </view>
  <view class="cart-btn">
    <text>去结算</text>
  </view>
</view>
</view>
</template>

<style>
.container {
min-height: 100vh;
background: #f5f5f5;
padding-bottom: 80px;
}
.category-bar {
white-space: nowrap;
background: #fff;
padding: 10px 0;
}
.cat-item {
display: inline-block;
padding: 8px 16px;
margin: 0 8px;
border-radius: 20px;
background: #f0f0f0;
}
.cat-item.active {
background: #007AFF;
}
.cat-item.active text {
color: #fff;
}
.product-list {
padding: 10px;
}
.product-item {
display: flex;
justify-content: space-between;
align-items: center;
background: #fff;
padding: 15px;
margin-bottom: 10px;
border-radius: 8px;
}
.product-info {
flex: 1;
}
.product-name {
display: block;
font-size: 16px;
font-weight: bold;
color: #333;
}
.product-category {
display: block;
font-size: 12px;
color: #999;
margin: 4px 0;
}
.product-price {
display: block;
font-size: 18px;
color: #ff6b6b;
font-weight: bold;
}
.add-btn {
width: 40px;
height: 40px;
background: #007AFF;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.add-btn text {
color: #fff;
font-size: 24px;
line-height: 1;
}
.cart-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 60px;
background: #333;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 20px;
}
.cart-info text {
color: #fff;
margin-right: 15px;
}
.cart-total {
font-size: 20px;
font-weight: bold;
color: #ff6b6b;
}
.cart-btn {
background: #007AFF;
padding: 10px 25px;
border-radius: 25px;
}
.cart-btn text {
color: #fff;
font-weight: bold;
}
</style>

预期输出: 显示商品列表,点击分类可以筛选,点击"+"可以加入购物车,底部悬浮条显示购物车总价。

一句话解释:computed 计算属性实现分类筛选和总价计算,用 v-for 渲染商品列表和购物车。


项目 3(15 分钟):番茄钟小工具(组合项目 1+2)

目标: 一个可配置时长的番茄钟,带待办清单功能

// pages/pomodoro/index.vue
<script>
export default {
data() {
return {
  // 番茄钟状态
  timeLeft: 25 * 60, // 秒
  isRunning: false,
  timerInterval: null,
  selectedDuration: 25,
  durations: [15, 20, 25, 30, 45, 60],

  // 待办清单
  todos: [
    { id: 1, text: '完成Vue3组件学习', done: false },
    { id: 2, text: '写一个番茄钟小工具', done: false },
    { id: 3, text: '整理笔记', done: true }
  ],
  newTodoText: ''
}
},
computed: {
// 格式化时间 MM:SS
formattedTime() {
  const minutes = Math.floor(this.timeLeft / 60)
  const seconds = this.timeLeft % 60
  return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
},
// 未完成数量
pendingCount() {
  return this.todos.filter(t => !t.done).length
}
},
methods: {
// 切换番茄钟状态
toggleTimer() {
  if (this.isRunning) {
    this.pauseTimer()
  } else {
    this.startTimer()
  }
},
startTimer() {
  this.isRunning = true
  this.timerInterval = setInterval(() => {
    if (this.timeLeft > 0) {
      this.timeLeft--
    } else {
      this.pauseTimer()
      uni.showToast({
        title: '时间到!休息一下吧~',
        icon: 'none'
      })
    }
  }, 1000)
},
pauseTimer() {
  this.isRunning = false
  if (this.timerInterval) {
    clearInterval(this.timerInterval)
    this.timerInterval = null
  }
},
resetTimer() {
  this.pauseTimer()
  this.timeLeft = this.selectedDuration * 60
},
setDuration(minutes) {
  this.selectedDuration = minutes
  this.resetTimer()
},

// 待办清单方法
addTodo() {
  if (!this.newTodoText.trim()) return
  this.todos.push({
    id: Date.now(),
    text: this.newTodoText.trim(),
    done: false
  })
  this.newTodoText = ''
},
toggleTodo(todo) {
  todo.done = !todo.done
},
deleteTodo(todo) {
  const index = this.todos.findIndex(t => t.id === todo.id)
  if (index > -1) {
    this.todos.splice(index, 1)
  }
}
},
beforeDestroy() {
// 页面销毁时清除定时器
if (this.timerInterval) {
  clearInterval(this.timerInterval)
}
}
}
</script>

<template>
<view class="container">
<!-- 番茄钟区域 -->
<view class="pomodoro-section">
  <text class="section-title">🍅 番茄钟</text>

  <!-- 时长选择 -->
  <scroll-view scroll-x class="duration-bar">
    <view 
      v-for="d in durations" 
      :key="d"
      :class="['duration-item', { active: selectedDuration === d }]"
      @click="setDuration(d)"
    >
      <text>{{ d }}分钟</text>
    </view>
  </scroll-view>

  <!-- 倒计时显示 -->
  <view class="timer-display">
    <text :class="['timer-text', { warning: timeLeft < 60 }]">
      {{ formattedTime }}
    </text>
  </view>

  <!-- 控制按钮 -->
  <view class="control-btns">
    <view class="control-btn secondary" @click="resetTimer">
      <text>重置</text>
    </view>
    <view 
      :class="['control-btn primary', { running: isRunning }]"
      @click="toggleTimer"
    >
      <text>{{ isRunning ? '暂停' : '开始' }}</text>
    </view>
  </view>
</view>

<!-- 待办清单区域 -->
<view class="todo-section">
  <text class="section-title">📝 待办清单</text>
  <text class="pending-hint">还有 {{ pendingCount }} 件事等着你</text>

  <!-- 输入框 -->
  <view class="input-row">
    <input 
      v-model="newTodoText"
      class="todo-input"
      placeholder="添加新待办..."
      @confirm="addTodo"
    />
    <view class="add-btn" @click="addTodo">
      <text>添加</text>
    </view>
  </view>

  <!-- 待办列表 -->
  <view class="todo-list">
    <view 
      v-for="todo in todos" 
      :key="todo.id"
      :class="['todo-item', { done: todo.done }]"
    >
      <view 
        class="todo-checkbox"
        @click="toggleTodo(todo)"
      >
        <text v-if="todo.done">✓</text>
      </view>
      <text class="todo-text">{{ todo.text }}</text>
      <view 
        class="delete-btn"
        @click="deleteTodo(todo)"
      >
        <text>×</text>
      </view>
    </view>
  </view>

  <!-- 空状态提示 -->
  <view v-if="todos.length === 0" class="empty-state">
    <text>还没有待办,添加一个吧~</text>
  </view>
</view>
</view>
</template>

<style>
.container {
min-height: 100vh;
background: linear-gradient(180deg, #1a1a2e 0%, #16213e 100%);
padding: 20px;
}
.section-title {
display: block;
font-size: 20px;
font-weight: bold;
color: #fff;
margin-bottom: 15px;
}

/* 番茄钟样式 */
.pomodoro-section {
background: rgba(255,255,255,0.1);
border-radius: 16px;
padding: 20px;
margin-bottom: 20px;
}
.duration-bar {
white-space: nowrap;
margin-bottom: 20px;
}
.duration-item {
display: inline-block;
padding: 6px 14px;
margin-right: 10px;
border-radius: 15px;
background: rgba(255,255,255,0.1);
}
.duration-item.active {
background: #ff6b6b;
}
.duration-item text {
color: #fff;
font-size: 13px;
}
.timer-display {
text-align: center;
padding: 30px 0;
}
.timer-text {
font-size: 64px;
font-weight: bold;
color: #fff;
font-family: monospace;
}
.timer-text.warning {
color: #ff6b6b;
}
.control-btns {
display: flex;
justify-content: center;
gap: 15px;
}
.control-btn {
padding: 12px 30px;
border-radius: 25px;
font-size: 16px;
}
.control-btn.primary {
background: #ff6b6b;
}
.control-btn.primary.running {
background: #ffd93d;
}
.control-btn.secondary {
background: rgba(255,255,255,0.2);
}
.control-btn text {
color: #fff;
font-weight: bold;
}

/* 待办清单样式 */
.todo-section {
background: rgba(255,255,255,0.1);
border-radius: 16px;
padding: 20px;
}
.pending-hint {
display: block;
font-size: 13px;
color: rgba(255,255,255,0.6);
margin-bottom: 15px;
}
.input-row {
display: flex;
gap: 10px;
margin-bottom: 15px;
}
.todo-input {
flex: 1;
background: rgba(255,255,255,0.1);
border-radius: 8px;
padding: 10px 15px;
color: #fff;
font-size: 14px;
}
.add-btn {
background: #4ecdc4;
padding: 10px 20px;
border-radius: 8px;
}
.add-btn text {
color: #fff;
font-weight: bold;
}
.todo-list {
margin-top: 10px;
}
.todo-item {
display: flex;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid rgba(255,255,255,0.1);
}
.todo-item.done .todo-text {
text-decoration: line-through;
color: rgba(255,255,255,0.4);
}
.todo-checkbox {
width: 24px;
height: 24px;
border: 2px solid rgba(255,255,255,0.3);
border-radius: 50%;
margin-right: 12px;
display: flex;
align-items: center;
justify-content: center;
}
.todo-item.done .todo-checkbox {
background: #4ecdc4;
border-color: #4ecdc4;
}
.todo-checkbox text {
color: #fff;
font-size: 14px;
}
.todo-text {
flex: 1;
color: #fff;
font-size: 14px;
}
.delete-btn {
padding: 5px 10px;
opacity: 0.5;
}
.delete-btn text {
color: #ff6b6b;
font-size: 20px;
}
.empty-state {
text-align: center;
padding: 30px 0;
}
.empty-state text {
color: rgba(255,255,255,0.5);
}
</style>

预期输出: 深色背景的番茄钟界面,上面是倒计时,下面是待办清单。可以选择时长、开始/暂停计时、添加/完成/删除待办。

一句话解释: 把项目 1 的组件思维和项目 2 的数据处理结合起来,用 computed 计算剩余时间,用 v-for 渲染待办列表。


💪 进阶 20 分钟:常见坑 + 调试技巧

坑 1:ref 对象的 .value 遗漏

// ❌ 错误写法
const count = ref(0)
count++ // 这样count还是0,不会变

// ✅ 正确写法
const count = ref(0)
count.value++ // 必须加 .value

解释: ref() 返回的是一个包装对象,必须通过 .value 才能访问或修改它的值。


坑 2:在模板里忘了 .value

<!-- ❌ 错误写法 -->
<text>{{ count + 1 }}</text>

<!-- ✅ 正确写法:在模板里不用加 .value,Vue 自动拆包 -->
<text>{{ count + 1 }}</text>

解释:<template> 里写变量不用加 .value,Vue 会自动处理。但在 <script> 里必须加。


坑 3:v-for 忘了加 :key

<!-- ❌ 错误写法 -->
<view v-for="item in list">

<!-- ✅ 正确写法 -->
<view v-for="(item, index) in list" :key="item.id">

解释: :key 帮助 Vue 高效追踪每个节点,没有它列表更新时可能会出现奇怪的 bug。


坑 4:数组/对象的响应式更新

// ❌ 错误写法:直接赋值会丢失响应式
this.list[0] = newValue  // 视图不会更新
this.obj.name = '新名字' // 可能不生效

// ✅ 正确写法:用 Vue 提供的数组方法或整体替换
this.list.splice(0, 1, newValue)  // 数组方法触发更新
this.obj = { ...this.obj, name: '新名字' }  // 整体替换触发更新

解释: Vue3 的响应式系统能监听到数组的 splicepush 等方法,但直接通过索引赋值它"听不到"。


坑 5:定时器没有在页面销毁时清除

// ❌ 错误写法
setup() {
const timer = setInterval(() => {
count.value++
}, 1000)
// 页面跳转后定时器还在跑!内存泄漏!
}
// ✅ 正确写法
setup() {
const timer = setInterval(() => {
count.value++
}, 1000)

// 页面销毁时清除
onUnmounted(() => {
clearInterval(timer)
})
}

调试技巧:console.log 的正确姿势

// ❌ 只打一个值,看不出是哪个变量
console.log(list)

// ✅ 带上标签,一眼认出
console.log('筛选后的商品列表:', filteredProducts.value)

// ✅ 对象太多层时,只看需要的字段
console.log('商品名:', list.map(item => item.name))

// ✅ 想要复制粘贴的格式
console.log(JSON.stringify(todos.value, null, 2))

进阶调试:uni-app 提供的 uni.setStorageSync('debug', data) 把数据存到本地缓存,然后在微信开发者工具的 Storage 面板里查看。


✏️ 练习题 + 作业题

练习题(5 道,10 分钟内完成)

练习 1(1 分钟):改改名字
- 输入:在番茄钟项目里,把 selectedDuration 默认值从 25 改成 40
- 预期输出:页面加载时倒计时显示 40:00
- 提示:data() 里改一个数字就行

练习 2(2 分钟):加个条件
- 输入:在商品列表里,当 stock(库存)为 0 时,按钮显示"缺货"而不是"+"
- 预期输出:库存为0的商品显示"缺货"字样,按钮不可点击
- 提示:用 v-if 判断 stock > 0

练习 3(2 分钟):新数据筛选
- 输入:把产品数据换成书籍:[{name: 'Python入门', price: 59}, {name: 'Vue3实战', price: 89}],实现分类筛选
- 预期输出:能正常显示书籍列表
- 提示:把 category 字段换成 typetag

练习 4(3 分钟):串起来
- 输入:在番茄钟的待办清单里,每完成一个待办就弹出一个 uni.showToast 提示"太棒了!"
- 预期输出:勾选完成一个待办,顶部弹出"太棒了!"
- 提示:在 toggleTodo 方法里加 uni.showToast

练习 5(2 分钟):报错分析
- 输入:以下代码运行后页面空白,找出原因

const name = ref('小明')
return { name }  // 忘记 return name.value
  • 预期输出:说出哪里错了
  • 提示:ref 创建的变量在 return 时要不要加 .value

作业题(30 分钟-2 小时)

作业:做一个「学习专注力统计」小工具

  • 需求描述:记录每天的学习专注时长,统计一周的数据,生成简单的图表

  • 功能点
    1. 顶部显示当前日期和"今日专注时长"
    2. 中间是一个番茄钟,点击开始/暂停
    3. 每次番茄钟完成(倒计时到0),自动把时长加入今日统计
    4. 底部展示最近7天的柱状图(用 view 堆出来就行)

  • 加分项
    1. 数据持久化到本地(uni.setStorageSync
    2. 支持切换"学习/工作/运动"三种专注模式,用不同颜色显示

  • 验收标准

  • 番茄钟能正常倒计时
  • 每天的数据独立累计
  • 7天柱状图能正确显示

  • 提交方式:评论区贴代码或 GitHub 链接


📚 总结 + 资源

本文学到的 3 件事:

  1. Vue3 组合式 APIref() 定义响应式变量,函数要 return 出去才能在模板用
  2. uniapp 核心组件<view><text><image> 是地基,v-if/v-for 是装修工具
  3. 组件化思维:把重复的 UI 封装成组件,props 传数据,事件做通信

延伸学习资源:

  1. uniapp 官方文档 - 组件:最权威的组件 API 手册
  2. Vue3 官方教程:官方出品的交互式教程
  3. 《Vue3 设计与实现》:一本从原理讲 Vue 的书,看完能进阶

互动钩子: 你有没有遇到过"明明代码没问题但页面就是不更新"的玄学 bug?是怎么解决的?评论区聊聊,老粉优先回复!


下一章我们要解决一个实际的问题:为什么同样尺寸的屏幕,不同手机显示的效果不一样? 答案就是 rpx 自适应单位。准备好你的设计稿,我们下一章见!

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。