Skip to content

数据绑定和事件处理 - 让页面动起来

⚡ 静态页面只是开始,动态交互才是精彩!今天我们来学习如何让页面活起来

1. 数据绑定基础

文本插值

vue
<template>
  <view class="text-demo">
    <!-- 基础文本绑定 -->
    <text>\{\{ message \}\}</text>
    
    <!-- 表达式绑定 -->
    <text>\{\{ count * 2 \}\}</text>
    
    <!-- 三元运算符 -->
    <text>\{\{ isVip ? 'VIP用户' : '普通用户' \}\}</text>
    
    <!-- 方法调用 -->
    <text>\{\{ formatDate(currentTime) \}\}</text>
  </view>
</template>

<script>
export default {
  data() {
    return {
      message: 'Hello UniApp',
      count: 10,
      isVip: true,
      currentTime: new Date()
    }
  },
  methods: {
    formatDate(date) {
      return date.toLocaleDateString()
    }
  }
}
</script>

属性绑定

vue
<template>
  <view class="attr-demo">
    <!-- 动态class -->
    <view :class="{ active: isActive, disabled: isDisabled }">
      动态class
    </view>
    
    <!-- 动态style -->
    <view :style="{ color: textColor, fontSize: fontSize + 'px' }">
      动态样式
    </view>
    
    <!-- 动态属性 -->
    <image :src="imageUrl" :alt="imageAlt" class="dynamic-image" />
    
    <!-- 条件渲染 -->
    <view v-if="showContent">显示的内容</view>
    <view v-else>隐藏时显示这个</view>
    
    <!-- 列表渲染 -->
    <view v-for="(item, index) in list" :key="item.id" class="list-item">
      \{\{ index + 1 \}\}. \{\{ item.name \}\}
    </view>
  </view>
</template>

<script>
export default {
  data() {
    return {
      isActive: true,
      isDisabled: false,
      textColor: '#007aff',
      fontSize: 16,
      imageUrl: '/static/demo.jpg',
      imageAlt: '演示图片',
      showContent: true,
      list: [
        { id: 1, name: '苹果' },
        { id: 2, name: '香蕉' },
        { id: 3, name: '橙子' }
      ]
    }
  }
}
</script>

<style>
.active {
  background-color: #007aff;
  color: white;
}
.disabled {
  opacity: 0.5;
}
.dynamic-image {
  width: 100px;
  height: 100px;
}
.list-item {
  padding: 10px;
  border-bottom: 1px solid #eee;
}
</style>

2. 双向数据绑定

表单控件绑定

vue
<template>
  <view class="form-demo">
    <!-- 输入框 -->
    <view class="form-group">
      <text class="label">用户名:</text>
      <input v-model="formData.username" placeholder="请输入用户名" />
      <text class="value">当前值:\{\{ formData.username \}\}</text>
    </view>
    
    <!-- 多行文本 -->
    <view class="form-group">
      <text class="label">个人简介:</text>
      <textarea v-model="formData.bio" placeholder="请输入个人简介" />
    </view>
    
    <!-- 开关 -->
    <view class="form-group">
      <text class="label">接收通知:</text>
      <switch v-model="formData.notifications" />
      <text class="value">\{\{ formData.notifications ? '开启' : '关闭' \}\}</text>
    </view>
    
    <!-- 滑块 -->
    <view class="form-group">
      <text class="label">音量:\{\{ formData.volume \}\}%</text>
      <slider 
        v-model="formData.volume" 
        min="0" 
        max="100" 
        @change="onVolumeChange"
      />
    </view>
    
    <!-- 选择器 -->
    <view class="form-group">
      <text class="label">城市:</text>
      <picker 
        :range="cities" 
        :value="cityIndex"
        @change="onCityChange"
      >
        <view class="picker-text">\{\{ cities[cityIndex] \}\}</view>
      </picker>
    </view>
    
    <!-- 显示所有数据 -->
    <view class="data-display">
      <text class="title">表单数据:</text>
      <text class="data">\{\{ JSON.stringify(formData, null, 2) \}\}</text>
    </view>
  </view>
</template>

<script>
export default {
  data() {
    return {
      formData: {
        username: '',
        bio: '',
        notifications: true,
        volume: 50,
        city: '北京'
      },
      cities: ['北京', '上海', '广州', '深圳'],
      cityIndex: 0
    }
  },
  methods: {
    onVolumeChange(e) {
      console.log('音量变化:', e.detail.value)
    },
    onCityChange(e) {
      this.cityIndex = e.detail.value
      this.formData.city = this.cities[this.cityIndex]
    }
  }
}
</script>

<style>
.form-demo {
  padding: 20px;
}
.form-group {
  margin-bottom: 20px;
}
.label {
  display: block;
  margin-bottom: 8px;
  font-weight: bold;
}
.value {
  color: #666;
  font-size: 14px;
  margin-left: 10px;
}
.picker-text {
  padding: 10px;
  border: 1px solid #ddd;
  border-radius: 4px;
}
.data-display {
  margin-top: 30px;
  padding: 15px;
  background: #f5f5f5;
  border-radius: 8px;
}
.title {
  font-weight: bold;
  display: block;
  margin-bottom: 10px;
}
.data {
  font-family: monospace;
  white-space: pre-wrap;
  font-size: 12px;
}
</style>

3. 事件处理

基础事件

vue
<template>
  <view class="event-demo">
    <!-- 点击事件 -->
    <button @click="handleClick">普通点击</button>
    
    <!-- 带参数的事件 -->
    <button @click="handleClickWithParam('Hello')">带参数点击</button>
    
    <!-- 事件对象 -->
    <button @click="handleClickWithEvent">获取事件对象</button>
    
    <!-- 阻止默认行为 -->
    <button @click.stop="handleStopPropagation">阻止冒泡</button>
    
    <!-- 长按事件 -->
    <view @longpress="handleLongPress" class="long-press-area">
      长按我试试
    </view>
    
    <!-- 触摸事件 -->
    <view 
      @touchstart="handleTouchStart"
      @touchmove="handleTouchMove"
      @touchend="handleTouchEnd"
      class="touch-area"
    >
      触摸区域
    </view>
    
    <!-- 自定义事件计数器 -->
    <view class="counter">
      <button @click="decrement">-</button>
      <text class="count">\{\{ count \}\}</text>
      <button @click="increment">+</button>
    </view>
  </view>
</template>

<script>
export default {
  data() {
    return {
      count: 0,
      touchStartTime: 0
    }
  },
  methods: {
    handleClick() {
      uni.showToast({
        title: '按钮被点击了',
        icon: 'success'
      })
    },
    
    handleClickWithParam(message) {
      uni.showToast({
        title: `参数:\${message}`,
        icon: 'none'
      })
    },
    
    handleClickWithEvent(e) {
      console.log('事件对象:', e)
      uni.showToast({
        title: '查看控制台',
        icon: 'none'
      })
    },
    
    handleStopPropagation(e) {
      e.stopPropagation()
      uni.showToast({
        title: '阻止了事件冒泡',
        icon: 'none'
      })
    },
    
    handleLongPress() {
      uni.showToast({
        title: '长按触发',
        icon: 'none'
      })
    },
    
    handleTouchStart(e) {
      this.touchStartTime = Date.now()
      console.log('触摸开始:', e.touches[0])
    },
    
    handleTouchMove(e) {
      console.log('触摸移动:', e.touches[0])
    },
    
    handleTouchEnd(e) {
      const duration = Date.now() - this.touchStartTime
      console.log('触摸结束,持续时间:', duration + 'ms')
    },
    
    increment() {
      this.count++
    },
    
    decrement() {
      this.count--
    }
  }
}
</script>

<style>
.event-demo {
  padding: 20px;
}
.event-demo button {
  margin: 10px 0;
  width: 100%;
}
.long-press-area {
  background: #f0f0f0;
  padding: 20px;
  text-align: center;
  margin: 10px 0;
  border-radius: 8px;
}
.touch-area {
  background: #e0f7fa;
  padding: 30px;
  text-align: center;
  margin: 10px 0;
  border-radius: 8px;
}
.counter {
  display: flex;
  align-items: center;
  justify-content: center;
  margin: 20px 0;
}
.counter button {
  width: 50px;
  height: 50px;
  margin: 0 10px;
}
.count {
  font-size: 24px;
  font-weight: bold;
  min-width: 50px;
  text-align: center;
}
</style>

4. 计算属性和侦听器

计算属性

vue
<template>
  <view class="computed-demo">
    <view class="shopping-cart">
      <text class="title">购物车</text>
      
      <view v-for="item in cartItems" :key="item.id" class="cart-item">
        <text class="item-name">\{\{ item.name \}\}</text>
        <text class="item-price">¥\{\{ item.price \}\}</text>
        <view class="quantity-control">
          <button @click="decreaseQuantity(item.id)">-</button>
          <text class="quantity">\{\{ item.quantity \}\}</text>
          <button @click="increaseQuantity(item.id)">+</button>
        </view>
        <text class="item-total">¥\{\{ itemTotal(item) \}\}</text>
      </view>
      
      <view class="cart-summary">
        <text class="summary-item">商品数量:\{\{ totalQuantity \}\}</text>
        <text class="summary-item">商品总价:¥\{\{ totalPrice \}\}</text>
        <text class="summary-item">运费:¥\{\{ shippingFee \}\}</text>
        <text class="summary-total">总计:¥\{\{ finalTotal \}\}</text>
      </view>
    </view>
  </view>
</template>

<script>
export default {
  data() {
    return {
      cartItems: [
        { id: 1, name: 'iPhone 15', price: 5999, quantity: 1 },
        { id: 2, name: 'iPad Air', price: 4599, quantity: 2 },
        { id: 3, name: 'AirPods Pro', price: 1999, quantity: 1 }
      ]
    }
  },
  
  computed: {
    // 计算总数量
    totalQuantity() {
      return this.cartItems.reduce((total, item) => total + item.quantity, 0)
    },
    
    // 计算商品总价
    totalPrice() {
      return this.cartItems.reduce((total, item) => {
        return total + (item.price * item.quantity)
      }, 0)
    },
    
    // 计算运费
    shippingFee() {
      return this.totalPrice >= 5000 ? 0 : 20
    },
    
    // 计算最终总价
    finalTotal() {
      return this.totalPrice + this.shippingFee
    }
  },
  
  methods: {
    itemTotal(item) {
      return item.price * item.quantity
    },
    
    increaseQuantity(id) {
      const item = this.cartItems.find(item => item.id === id)
      if (item) {
        item.quantity++
      }
    },
    
    decreaseQuantity(id) {
      const item = this.cartItems.find(item => item.id === id)
      if (item && item.quantity > 1) {
        item.quantity--
      }
    }
  }
}
</script>

<style>
.shopping-cart {
  padding: 20px;
}
.title {
  font-size: 20px;
  font-weight: bold;
  margin-bottom: 20px;
}
.cart-item {
  display: flex;
  align-items: center;
  padding: 15px;
  border-bottom: 1px solid #eee;
}
.item-name {
  flex: 1;
  font-weight: bold;
}
.item-price {
  width: 80px;
  color: #666;
}
.quantity-control {
  display: flex;
  align-items: center;
  margin: 0 15px;
}
.quantity-control button {
  width: 30px;
  height: 30px;
  margin: 0 5px;
}
.quantity {
  min-width: 30px;
  text-align: center;
}
.item-total {
  width: 80px;
  text-align: right;
  font-weight: bold;
  color: #ff6b6b;
}
.cart-summary {
  margin-top: 20px;
  padding: 15px;
  background: #f8f9fa;
  border-radius: 8px;
}
.summary-item {
  display: block;
  margin-bottom: 8px;
}
.summary-total {
  display: block;
  font-size: 18px;
  font-weight: bold;
  color: #ff6b6b;
  margin-top: 10px;
  padding-top: 10px;
  border-top: 1px solid #ddd;
}
</style>

侦听器

vue
<template>
  <view class="watch-demo">
    <view class="search-section">
      <input 
        v-model="searchKeyword" 
        placeholder="搜索商品..."
        class="search-input"
      />
      <text class="search-tip">\{\{ searchTip \}\}</text>
    </view>
    
    <view class="user-section">
      <text class="label">用户信息:</text>
      <input v-model="user.name" placeholder="姓名" />
      <input v-model="user.email" placeholder="邮箱" />
      <text class="user-status">\{\{ userStatus \}\}</text>
    </view>
    
    <view class="counter-section">
      <text class="label">计数器:\{\{ counter \}\}</text>
      <button @click="counter++">增加</button>
      <text class="counter-info">\{\{ counterInfo \}\}</text>
    </view>
  </view>
</template>

<script>
export default {
  data() {
    return {
      searchKeyword: '',
      searchTip: '请输入搜索关键词',
      user: {
        name: '',
        email: ''
      },
      userStatus: '请填写用户信息',
      counter: 0,
      counterInfo: ''
    }
  },
  
  watch: {
    // 简单侦听
    searchKeyword(newVal, oldVal) {
      console.log('搜索关键词变化:', oldVal, '->', newVal)
      
      if (newVal.length === 0) {
        this.searchTip = '请输入搜索关键词'
      } else if (newVal.length < 2) {
        this.searchTip = '关键词太短,请输入至少2个字符'
      } else {
        this.searchTip = `正在搜索"\${newVal}"...`
        this.performSearch(newVal)
      }
    },
    
    // 深度侦听对象
    user: {
      handler(newVal, oldVal) {
        console.log('用户信息变化:', newVal)
        
        if (!newVal.name && !newVal.email) {
          this.userStatus = '请填写用户信息'
        } else if (!newVal.name) {
          this.userStatus = '请填写姓名'
        } else if (!newVal.email) {
          this.userStatus = '请填写邮箱'
        } else if (!this.isValidEmail(newVal.email)) {
          this.userStatus = '邮箱格式不正确'
        } else {
          this.userStatus = '用户信息完整'
        }
      },
      deep: true,
      immediate: true
    },
    
    // 侦听计算属性
    counter: {
      handler(newVal, oldVal) {
        if (newVal > oldVal) {
          this.counterInfo = `计数器增加了 \${newVal - oldVal}`
        } else if (newVal < oldVal) {
          this.counterInfo = `计数器减少了 \${oldVal - newVal}`
        }
        
        // 特殊值提醒
        if (newVal === 10) {
          uni.showToast({
            title: '恭喜达到10!',
            icon: 'success'
          })
        }
      },
      immediate: true
    }
  },
  
  methods: {
    performSearch(keyword) {
      // 模拟搜索延迟
      setTimeout(() => {
        if (this.searchKeyword === keyword) {
          this.searchTip = `找到 \${Math.floor(Math.random() * 100)} 个相关结果`
        }
      }, 500)
    },
    
    isValidEmail(email) {
      const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
      return emailRegex.test(email)
    }
  }
}
</script>

<style>
.watch-demo {
  padding: 20px;
}
.search-section,
.user-section,
.counter-section {
  margin-bottom: 30px;
  padding: 15px;
  background: #f8f9fa;
  border-radius: 8px;
}
.label {
  display: block;
  font-weight: bold;
  margin-bottom: 10px;
}
.search-input {
  width: 100%;
  padding: 10px;
  border: 1px solid #ddd;
  border-radius: 4px;
  margin-bottom: 10px;
}
.search-tip {
  color: #666;
  font-size: 14px;
}
.user-section input {
  width: 100%;
  padding: 10px;
  border: 1px solid #ddd;
  border-radius: 4px;
  margin-bottom: 10px;
}
.user-status {
  color: #007aff;
  font-size: 14px;
}
.counter-info {
  color: #666;
  font-size: 14px;
  margin-left: 10px;
}
</style>

小结

今天我们学习了:

  • ✅ 数据绑定的各种方式
  • ✅ 双向数据绑定在表单中的应用
  • ✅ 事件处理和事件修饰符
  • ✅ 计算属性的使用场景
  • ✅ 侦听器的深度监听和即时触发

核心要点

  • 数据驱动视图更新
  • 合理使用计算属性提高性能
  • 侦听器用于响应数据变化
  • 事件处理实现用户交互

下一篇预告

下一篇我们将学习《API调用详解 - 网络请求、本地存储、设备信息》,学习如何与后端交互和使用设备功能。


数据绑定和事件处理是现代前端开发的核心,掌握了这些,你就能构建出真正动态的应用!