《uni-app跨平台开发完全指南》- 07 - 数据绑定与事件处理

引言:在上一章节中,我们详细介绍了页面路由与导航的相关知识点。今天我们讨论的是数据绑定与事件处理 ,深入研究数据是如何流动、用户交互如何响应的问题。我们平时用的app比如说输入框中打字,下方实时显示输入内容。这个看似简单的交互背后,隐藏着前端框架的核心思想------数据驱动视图

对比:传统DOM操作 vs 数据驱动

graph TB A[传统DOM操作] --> B[手动选择元素] B --> C[监听事件] C --> D[直接修改DOM] E[数据驱动模式] --> F[修改数据] F --> G[框架自动更新DOM] G --> H[视图同步更新]

在传统开发中,我们需要:

javascript 复制代码
// 传统方式
const input = document.getElementById('myInput');
const display = document.getElementById('display');

input.addEventListener('input', function(e) {
    // 手动更新DOM
    display.textContent = e.target.value; 
});

而在 uni-app 中:

html 复制代码
<template>
  <input v-model="message">
  <div>{{ message }}</div>
</template>

<script>
export default {
  data() {
    return {
      // 只需关注数据,DOM自动更新
      message: '' 
    }
  }
}
</script>

这种模式的转变,正是现代前端框架的核心突破。下面让我们深入研究其实现原理。


一、响应式数据绑定

1.1 数据劫持

Vue 2.x 使用 Object.defineProperty 定义对象属性实现数据响应式,让我们通过一段代码来加深理解这个机制:

javascript 复制代码
// 响应式原理
function defineReactive(obj, key, val) {
  // 每个属性都有自己的依赖收集器
  const dep = new Dep()
  
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter() {
      console.log(`读取属性 ${key}: ${val}`)
      // 依赖收集:记录当前谁在读取这个属性
      dep.depend()
      return val
    },
    set: function reactiveSetter(newVal) {
      console.log(`设置属性 ${key}: ${newVal}`)
      if (newVal === val) return
      val = newVal
      // 通知更新:值改变时通知所有依赖者
      dep.notify()
    }
  })
}

// 测试
const data = {}
defineReactive(data, 'message', 'Hello')
data.message = 'World'    // 控制台输出:设置属性 message: World
console.log(data.message) // 控制台输出:读取属性 message: World

1.2 完整的响应式系统架构

graph LR A[数据变更] --> B[Setter 触发] B --> C[通知 Dep] C --> D[Watcher 更新] D --> E[组件重新渲染] E --> F[虚拟DOM Diff] F --> G[DOM 更新] H[模板编译] --> I[收集依赖] I --> J[建立数据与视图关联]

原理说明

  • 当对响应式数据进行赋值操作时,会触发通过Object.defineProperty定义的setter方法。
  • setter首先比较新旧值是否相同,如果相同则直接返回,避免不必要的更新。
  • 如果值发生变化,则更新数据,并通过依赖收集器(Dep)通知所有观察者(Watcher)进行更新。
  • 这个过程是同步的,但实际的DOM更新是异步的,通过队列进行批量处理以提高性能。

1.3 v-model 的双向绑定原理

v-model 不是魔法,而是语法糖:

html 复制代码
<!-- 这行代码: -->
<input v-model="username">

<!-- 等价于: -->
<input 
  :value="username" 
  @input="username = $event.target.value"
>

原理分解:

sequenceDiagram participant U as 用户 participant I as Input元素 participant V as Vue实例 participant D as DOM视图 U->>I: 输入文字 I->>V: 触发input事件,携带新值 V->>V: 更新data中的响应式数据 V->>D: 触发重新渲染 D->>I: 更新input的value属性

1.4 不同表单元素的双向绑定

文本输入框

html 复制代码
<template>
  <view class="example">
    <text class="title">文本输入框绑定</text>
    <input 
      type="text" 
      v-model="textValue" 
      placeholder="请输入文本"
      class="input"
    />
    <text class="display">实时显示: {{ textValue }}</text>
    
    <!-- 原理展示 -->
    <view class="principle">
      <text class="principle-title">实现原理:</text>
      <input 
        :value="textValue" 
        @input="textValue = $event.detail.value"
        placeholder="手动实现的v-model"
        class="input"
      />
    </view>
  </view>
</template>

<script>
export default {
  data() {
    return {
      textValue: ''
    }
  }
}
</script>

<style scoped>
.example {
  padding: 20rpx;
  border: 2rpx solid #eee;
  margin: 20rpx;
  border-radius: 10rpx;
}
.title {
  font-weight: bold;
  color: #333;
  display: block;
  margin-bottom: 20rpx;
}
.input {
  border: 1rpx solid #ccc;
  padding: 15rpx;
  border-radius: 8rpx;
  margin-bottom: 20rpx;
}
.display {
  color: #007AFF;
  font-size: 28rpx;
}
.principle {
  background: #f9f9f9;
  padding: 20rpx;
  border-radius: 8rpx;
  margin-top: 30rpx;
}
.principle-title {
  font-size: 24rpx;
  color: #666;
  display: block;
  margin-bottom: 15rpx;
}
</style>

单选按钮组

html 复制代码
<template>
  <view class="example">
    <text class="title">单选按钮组绑定</text>
    
    <radio-group @change="onGenderChange" class="radio-group">
      <label class="radio-item">
        <radio value="male" :checked="gender === 'male'" /> 男
      </label>
      <label class="radio-item">
        <radio value="female" :checked="gender === 'female'" /> 女
      </label>
    </radio-group>
    
    <text class="display">选中: {{ gender }}</text>
    
    <!-- 使用v-model -->
    <text class="title" style="margin-top: 40rpx;">v-model简化版</text>
    <radio-group v-model="simpleGender" class="radio-group">
      <label class="radio-item">
        <radio value="male" /> 男
      </label>
      <label class="radio-item">
        <radio value="female" /> 女
      </label>
    </radio-group>
    
    <text class="display">选中: {{ simpleGender }}</text>
  </view>
</template>

<script>
export default {
  data() {
    return {
      gender: 'male',
      simpleGender: 'male'
    }
  },
  methods: {
    onGenderChange(e) {
      this.gender = e.detail.value
    }
  }
}
</script>

<style scoped>
.radio-group {
  display: flex;
  gap: 40rpx;
  margin: 20rpx 0;
}
.radio-item {
  display: flex;
  align-items: center;
  gap: 10rpx;
}
</style>

复选框数组

html 复制代码
<template>
  <view class="example">
    <text class="title">复选框数组绑定</text>
    
    <view class="checkbox-group">
      <label 
        v-for="hobby in hobbyOptions" 
        :key="hobby.value"
        class="checkbox-item"
      >
        <checkbox 
          :value="hobby.value" 
          :checked="selectedHobbies.includes(hobby.value)"
          @change="onHobbyChange($event, hobby.value)"
        /> 
        {{ hobby.name }}
      </label>
    </view>
    
    <text class="display">选中: {{ selectedHobbies }}</text>
    
    <!-- v-model简化版 -->
    <text class="title" style="margin-top: 40rpx;">v-model简化版</text>
    <view class="checkbox-group">
      <label 
        v-for="hobby in hobbyOptions" 
        :key="hobby.value"
        class="checkbox-item"
      >
        <checkbox 
          :value="hobby.value" 
          v-model="simpleHobbies"
        /> 
        {{ hobby.name }}
      </label>
    </view>
    
    <text class="display">选中: {{ simpleHobbies }}</text>
  </view>
</template>

<script>
export default {
  data() {
    return {
      hobbyOptions: [
        { name: '篮球', value: 'basketball' },
        { name: '阅读', value: 'reading' },
        { name: '音乐', value: 'music' },
        { name: '旅行', value: 'travel' }
      ],
      selectedHobbies: ['basketball'],
      simpleHobbies: ['basketball']
    }
  },
  methods: {
    onHobbyChange(event, value) {
      const checked = event.detail.value.length > 0
      if (checked) {
        if (!this.selectedHobbies.includes(value)) {
          this.selectedHobbies.push(value)
        }
      } else {
        const index = this.selectedHobbies.indexOf(value)
        if (index > -1) {
          this.selectedHobbies.splice(index, 1)
        }
      }
    }
  }
}
</script>

<style scoped>
.checkbox-group {
  display: flex;
  flex-direction: column;
  gap: 20rpx;
}
.checkbox-item {
  display: flex;
  align-items: center;
  gap: 10rpx;
}
</style>

二、事件处理

2.1 事件流:从点击到响应

浏览器中的事件流包含三个阶段:

graph TB A[事件发生] --> B[捕获阶段 Capture Phase] B --> C[目标阶段 Target Phase] C --> D[冒泡阶段 Bubble Phase] B --> E[从window向下传递到目标] C --> F[在目标元素上触发] D --> G[从目标向上冒泡到window]
解释说明:

第一阶段: 捕获阶段(事件从window向下传递到目标元素) 传递路径:Window → Document → HTML → Body → 父元素 → 目标元素; 监听方式:addEventListener(event, handler, true)第三个参数设为true;

第二阶段: 目标阶段(事件在目标元素上触发处理程序) 事件处理:在目标元素上执行绑定的事件处理函数,无论是否使用捕获模式; 执行顺序:按照事件监听器的注册顺序执行,与捕获/冒泡设置无关;

第三阶段: 冒泡阶段(事件从目标元素向上冒泡到window) 传递路径:目标元素 → 父元素 → Body → HTML → Document → Window; 默认行为:大多数事件都会冒泡,但focus、blur等事件不会冒泡;

2.2 事件修饰符原理详解

.stop 修饰符原理

javascript 复制代码
// .stop 修饰符的实现原理
function handleClick(event) {
  // 没有.stop时,事件正常冒泡
  console.log('按钮被点击')
  // 事件会继续向上冒泡,触发父元素的事件处理函数
}

function handleClickWithStop(event) {
  console.log('按钮被点击,但阻止了冒泡')
  event.stopPropagation() 
  // 事件不会继续向上冒泡
}

事件修饰符对照表

修饰符 原生JS等价操作 作用 使用场景
.stop event.stopPropagation() 阻止事件冒泡 点击按钮不触发父容器点击事件
.prevent event.preventDefault() 阻止默认行为 阻止表单提交、链接跳转
.capture addEventListener(..., true) 使用捕获模式 需要在捕获阶段处理事件
.self if (event.target !== this) return 仅元素自身触发 忽略子元素触发的事件
.once 手动移除监听器 只触发一次 一次性提交按钮

2.3 综合案例

html 复制代码
<template>
  <view class="event-demo">
    <!-- 1. .stop修饰符 -->
    <view class="demo-section">
      <text class="section-title">1. .stop 修饰符 - 阻止事件冒泡</text>
      <view class="parent-box" @click="handleParentClick">
        <text>父容器 (点击这里会触发)</text>
        <button @click="handleButtonClick">普通按钮</button>
        <button @click.stop="handleButtonClickWithStop">使用.stop的按钮</button>
      </view>
      <text class="log">日志: {{ logs }}</text>
    </view>

    <!-- 2. .prevent修饰符 -->
    <view class="demo-section">
      <text class="section-title">2. .prevent 修饰符 - 阻止默认行为</text>
      <form @submit="handleFormSubmit">
        <input type="text" v-model="formData.name" placeholder="请输入姓名" />
        <button form-type="submit">普通提交</button>
        <button form-type="submit" @click.prevent="handlePreventSubmit">
          使用.prevent的提交
        </button>
      </form>
    </view>

    <!-- 3. .self修饰符 -->
    <view class="demo-section">
      <text class="section-title">3. .self 修饰符 - 仅自身触发</text>
      <view class="self-demo">
        <view @click.self="handleSelfClick" class="self-box">
          <text>点击这个文本(自身)会触发</text>
          <button>点击这个按钮(子元素)不会触发</button>
        </view>
      </view>
    </view>

    <!-- 4. 修饰符串联 -->
    <view class="demo-section">
      <text class="section-title">4. 修饰符串联使用</text>
      <view @click="handleChainParent">
        <button @click.stop.prevent="handleChainClick">
          同时使用.stop和.prevent
        </button>
      </view>
    </view>
  </view>
</template>

<script>
export default {
  data() {
    return {
      logs: [],
      formData: {
        name: ''
      }
    }
  },
  methods: {
    handleParentClick() {
      this.addLog('父容器被点击')
    },
    handleButtonClick() {
      this.addLog('普通按钮被点击 → 会触发父容器事件')
    },
    handleButtonClickWithStop() {
      this.addLog('使用.stop的按钮被点击 → 不会触发父容器事件')
    },
    handleFormSubmit(e) {
      this.addLog('表单提交,页面可能会刷新')
    },
    handlePreventSubmit(e) {
      this.addLog('使用.prevent,阻止了表单默认提交行为')
      // 这里可以执行自定义的提交逻辑
      this.submitForm()
    },
    handleSelfClick() {
      this.addLog('.self: 只有点击容器本身才触发')
    },
    handleChainParent() {
      this.addLog('父容器点击事件')
    },
    handleChainClick() {
      this.addLog('按钮点击,但阻止了冒泡和默认行为')
    },
    addLog(message) {
      this.logs.unshift(`${new Date().toLocaleTimeString()}: ${message}`)
      // 只保留最近5条日志
      if (this.logs.length > 5) {
        this.logs.pop()
      }
    },
    submitForm() {
      uni.showToast({
        title: '表单提交成功',
        icon: 'success'
      })
    }
  }
}
</script>

<style scoped>
.event-demo {
  padding: 20rpx;
}
.demo-section {
  margin-bottom: 40rpx;
  padding: 20rpx;
  border: 1rpx solid #e0e0e0;
  border-radius: 10rpx;
}
.section-title {
  font-weight: bold;
  color: #333;
  display: block;
  margin-bottom: 20rpx;
  font-size: 28rpx;
}
.parent-box {
  background: #f5f5f5;
  padding: 20rpx;
  border-radius: 8rpx;
}
.log {
  display: block;
  background: #333;
  color: #0f0;
  padding: 15rpx;
  border-radius: 6rpx;
  font-family: monospace;
  font-size: 24rpx;
  margin-top: 15rpx;
  max-height: 200rpx;
  overflow-y: auto;
}
.self-box {
  background: #e3f2fd;
  padding: 30rpx;
  border: 2rpx dashed #2196f3;
}
</style>

三、表单数据处理

3.1 复杂表单设计

graph TB A[表单组件] --> B[表单数据模型] B --> C[验证规则] B --> D[提交处理] C --> E[即时验证] C --> F[提交验证] D --> G[数据预处理] D --> H[API调用] D --> I[响应处理] E --> J[错误提示] F --> J

3.2 表单案例

html 复制代码
<template>
  <view class="form-container">
    <text class="form-title">用户注册</text>
    
    <!-- 用户名 -->
    <view class="form-item" :class="{ error: errors.username }">
      <text class="label">用户名</text>
      <input 
        type="text" 
        v-model="formData.username" 
        placeholder="请输入用户名"
        @blur="validateField('username')"
        class="input"
      />
      <text class="error-msg" v-if="errors.username">{{ errors.username }}</text>
    </view>

    <!-- 邮箱 -->
    <view class="form-item" :class="{ error: errors.email }">
      <text class="label">邮箱</text>
      <input 
        type="text" 
        v-model="formData.email" 
        placeholder="请输入邮箱"
        @blur="validateField('email')"
        class="input"
      />
      <text class="error-msg" v-if="errors.email">{{ errors.email }}</text>
    </view>

    <!-- 密码 -->
    <view class="form-item" :class="{ error: errors.password }">
      <text class="label">密码</text>
      <input 
        type="password" 
        v-model="formData.password" 
        placeholder="请输入密码"
        @blur="validateField('password')"
        class="input"
      />
      <text class="error-msg" v-if="errors.password">{{ errors.password }}</text>
    </view>

    <!-- 性别 -->
    <view class="form-item">
      <text class="label">性别</text>
      <radio-group v-model="formData.gender" class="radio-group">
        <label class="radio-item" v-for="item in genderOptions" :key="item.value">
          <radio :value="item.value" /> {{ item.label }}
        </label>
      </radio-group>
    </view>

    <!-- 兴趣爱好 -->
    <view class="form-item">
      <text class="label">兴趣爱好</text>
      <view class="checkbox-group">
        <label 
          class="checkbox-item" 
          v-for="hobby in hobbyOptions" 
          :key="hobby.value"
        >
          <checkbox :value="hobby.value" v-model="formData.hobbies" /> 
          {{ hobby.label }}
        </label>
      </view>
    </view>

    <!-- 提交按钮 -->
    <button 
      @click="handleSubmit" 
      :disabled="!isFormValid"
      class="submit-btn"
      :class="{ disabled: !isFormValid }"
    >
      {{ isSubmitting ? '提交中...' : '注册' }}
    </button>

    <!-- 表单数据预览 -->
    <view class="form-preview">
      <text class="preview-title">表单数据预览</text>
      <text class="preview-data">{{ JSON.stringify(formData, null, 2) }}</text>
    </view>
  </view>
</template>

<script>
export default {
  data() {
    return {
      formData: {
        username: '',
        email: '',
        password: '',
        gender: 'male',
        hobbies: ['sports']
      },
      errors: {
        username: '',
        email: '',
        password: ''
      },
      isSubmitting: false,
      genderOptions: [
        { label: '男', value: 'male' },
        { label: '女', value: 'female' },
        { label: '其他', value: 'other' }
      ],
      hobbyOptions: [
        { label: '运动', value: 'sports' },
        { label: '阅读', value: 'reading' },
        { label: '音乐', value: 'music' },
        { label: '旅行', value: 'travel' },
        { label: '游戏', value: 'gaming' }
      ]
    }
  },
  computed: {
    isFormValid() {
      return (
        !this.errors.username &&
        !this.errors.email &&
        !this.errors.password &&
        this.formData.username &&
        this.formData.email &&
        this.formData.password &&
        !this.isSubmitting
      )
    }
  },
  methods: {
    validateField(fieldName) {
      const value = this.formData[fieldName]
      
      switch (fieldName) {
        case 'username':
          if (!value) {
            this.errors.username = '用户名不能为空'
          } else if (value.length < 3) {
            this.errors.username = '用户名至少3个字符'
          } else {
            this.errors.username = ''
          }
          break
          
        case 'email':
          if (!value) {
            this.errors.email = '邮箱不能为空'
          } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
            this.errors.email = '邮箱格式不正确'
          } else {
            this.errors.email = ''
          }
          break
          
        case 'password':
          if (!value) {
            this.errors.password = '密码不能为空'
          } else if (value.length < 6) {
            this.errors.password = '密码至少6个字符'
          } else {
            this.errors.password = ''
          }
          break
      }
    },
    
    async handleSubmit() {
      // 提交前验证所有字段
      this.validateField('username')
      this.validateField('email')
      this.validateField('password')
      
      // 报错直接返回
      if (this.errors.username || this.errors.email || this.errors.password) {
        uni.showToast({
          title: '请正确填写表单',
          icon: 'none'
        })
        return
      }
      
      this.isSubmitting = true
      
      try {
        // 接口调用
        await this.mockApiCall()
        
        uni.showToast({
          title: '注册成功',
          icon: 'success'
        })
        
        // 重置表单
        this.resetForm()
        
      } catch (error) {
        uni.showToast({
          title: '注册失败',
          icon: 'error'
        })
      } finally {
        this.isSubmitting = false
      }
    },
    
    mockApiCall() {
      return new Promise((resolve) => {
        setTimeout(() => {
          console.log('提交的数据:', this.formData)
          resolve()
        }, 2000)
      })
    },
    
    resetForm() {
      this.formData = {
        username: '',
        email: '',
        password: '',
        gender: 'male',
        hobbies: ['sports']
      }
      this.errors = {
        username: '',
        email: '',
        password: ''
      }
    }
  }
}
</script>

<style scoped>
.form-container {
  padding: 30rpx;
  max-width: 600rpx;
  margin: 0 auto;
}
.form-title {
  font-size: 36rpx;
  font-weight: bold;
  text-align: center;
  margin-bottom: 40rpx;
  color: #333;
}
.form-item {
  margin-bottom: 30rpx;
}
.label {
  display: block;
  margin-bottom: 15rpx;
  font-weight: 500;
  color: #333;
}
.input {
  border: 2rpx solid #e0e0e0;
  padding: 20rpx;
  border-radius: 8rpx;
  font-size: 28rpx;
}
.form-item.error .input {
  border-color: #ff4757;
}
.error-msg {
  color: #ff4757;
  font-size: 24rpx;
  margin-top: 8rpx;
  display: block;
}
.radio-group {
  display: flex;
  gap: 40rpx;
}
.radio-item {
  display: flex;
  align-items: center;
  gap: 10rpx;
}
.checkbox-group {
  display: flex;
  flex-wrap: wrap;
  gap: 20rpx;
}
.checkbox-item {
  display: flex;
  align-items: center;
  gap: 10rpx;
  min-width: 150rpx;
}
.submit-btn {
  background: #007AFF;
  color: white;
  border: none;
  padding: 25rpx;
  border-radius: 10rpx;
  font-size: 32rpx;
  margin-top: 40rpx;
}
.submit-btn.disabled {
  background: #ccc;
  color: #666;
}
.form-preview {
  margin-top: 50rpx;
  padding: 30rpx;
  background: #f9f9f9;
  border-radius: 10rpx;
}
.preview-title {
  font-weight: bold;
  margin-bottom: 20rpx;
  display: block;
}
.preview-data {
  font-family: monospace;
  font-size: 24rpx;
  color: #666;
  word-break: break-all;
}
</style>

四、组件间通信-自定义事件

4.1 自定义事件原理

4.2 以计数器组件为例

html 复制代码
<!-- 子组件:custom-counter.vue -->
<template>
  <view class="custom-counter">
    <text class="counter-title">{{ title }}</text>
    
    <view class="counter-controls">
      <button 
        @click="decrement" 
        :disabled="currentValue <= min"
        class="counter-btn"
      >
        -
      </button>
      
      <text class="counter-value">{{ currentValue }}</text>
      
      <button 
        @click="increment" 
        :disabled="currentValue >= max"
        class="counter-btn"
      >
        +
      </button>
    </view>
    
    <view class="counter-stats">
      <text>最小值: {{ min }}</text>
      <text>最大值: {{ max }}</text>
      <text>步长: {{ step }}</text>
    </view>
    
    <!-- 操作 -->
    <view class="quick-actions">
      <button @click="reset" size="mini">重置</button>
      <button @click="setToMax" size="mini">设为最大</button>
      <button @click="setToMin" size="mini">设为最小</button>
    </view>
  </view>
</template>

<script>
export default {
  name: 'CustomCounter',
  props: {
    // 当前值
    value: {
      type: Number,
      default: 0
    },
    // 最小值
    min: {
      type: Number,
      default: 0
    },
    // 最大值
    max: {
      type: Number,
      default: 100
    },
    // 步长
    step: {
      type: Number,
      default: 1
    },
    // 标题
    title: {
      type: String,
      default: '计数器'
    }
  },
  data() {
    return {
      currentValue: this.value
    }
  },
  watch: {
    value(newVal) {
      this.currentValue = newVal
    },
    currentValue(newVal) {
      // 设置限制范围
      if (newVal < this.min) {
        this.currentValue = this.min
      } else if (newVal > this.max) {
        this.currentValue = this.max
      }
    }
  },
  methods: {
    increment() {
      const newValue = this.currentValue + this.step
      if (newValue <= this.max) {
        this.updateValue(newValue)
      }
    },
    
    decrement() {
      const newValue = this.currentValue - this.step
      if (newValue >= this.min) {
        this.updateValue(newValue)
      }
    },
    
    updateValue(newValue) {
      this.currentValue = newValue
      
      // 触发自定义事件,通知父组件
      this.$emit('input', newValue)  // 用于 v-model
      this.$emit('change', {         // 用于普通事件监听
        value: newValue,
        oldValue: this.value,
        type: 'change'
      })
    },
    
    reset() {
      this.updateValue(0)
      this.$emit('reset', { value: 0 })
    },
    
    setToMax() {
      this.updateValue(this.max)
      this.$emit('set-to-max', { value: this.max })
    },
    
    setToMin() {
      this.updateValue(this.min)
      this.$emit('set-to-min', { value: this.min })
    }
  }
}
</script>

<style scoped>
.custom-counter {
  border: 2rpx solid #e0e0e0;
  border-radius: 15rpx;
  padding: 30rpx;
  margin: 20rpx 0;
  background: white;
}
.counter-title {
  font-size: 32rpx;
  font-weight: bold;
  text-align: center;
  display: block;
  margin-bottom: 25rpx;
  color: #333;
}
.counter-controls {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 30rpx;
  margin-bottom: 25rpx;
}
.counter-btn {
  width: 80rpx;
  height: 80rpx;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 36rpx;
  font-weight: bold;
}
.counter-value {
  font-size: 48rpx;
  font-weight: bold;
  color: #007AFF;
  min-width: 100rpx;
  text-align: center;
}
.counter-stats {
  display: flex;
  justify-content: space-around;
  margin-bottom: 25rpx;
  padding: 15rpx;
  background: #f8f9fa;
  border-radius: 8rpx;
}
.counter-stats text {
  font-size: 24rpx;
  color: #666;
}
.quick-actions {
  display: flex;
  justify-content: center;
  gap: 15rpx;
}
</style>

4.3 父组件使用

html 复制代码
<!-- 父组件:parent-component.vue -->
<template>
  <view class="parent-container">
    <text class="main-title">自定义计数器组件演示</text>
    
    <!-- 方式1:使用 v-model -->
    <view class="demo-section">
      <text class="section-title">1. 使用 v-model 双向绑定</text>
      <custom-counter 
        v-model="counter1" 
        title="基础计数器"
        :min="0" 
        :max="10"
        :step="1"
      />
      <text class="value-display">当前值: {{ counter1 }}</text>
    </view>
    
    <!-- 方式2:监听 change 事件 -->
    <view class="demo-section">
      <text class="section-title">2. 监听 change 事件</text>
      <custom-counter 
        :value="counter2"
        title="高级计数器"
        :min="-10"
        :max="20"
        :step="2"
        @change="onCounterChange"
      />
      <text class="value-display">当前值: {{ counter2 }}</text>
      <text class="event-log">事件日志: {{ eventLog }}</text>
    </view>
    
    <!-- 方式3:监听多个事件 -->
    <view class="demo-section">
      <text class="section-title">3. 监听多个事件</text>
      <custom-counter 
        v-model="counter3"
        title="多功能计数器"
        @reset="onCounterReset"
        @set-to-max="onSetToMax"
        @set-to-min="onSetToMin"
      />
      <text class="value-display">当前值: {{ counter3 }}</text>
    </view>
    
    
    <view class="demo-section">
      <text class="section-title">4. 计数器联动</text>
      <custom-counter 
        v-model="masterCounter"
        title="主计数器"
        @change="onMasterChange"
      />
      <custom-counter 
        :value="slaveCounter"
        title="从计数器"
        :min="0"
        :max="50"
        readonly
      />
    </view>
  </view>
</template>

<script>
import CustomCounter from '@/components/custom-counter.vue'

export default {
  components: {
    CustomCounter
  },
  data() {
    return {
      counter1: 5,
      counter2: 0,
      counter3: 10,
      masterCounter: 0,
      slaveCounter: 0,
      eventLog: ''
    }
  },
  methods: {
    onCounterChange(event) {
      console.log('计数器变化事件:', event)
      this.counter2 = event.value
      this.addEventLog(`计数器变化: ${event.oldValue} → ${event.value}`)
    },
    
    onCounterReset(event) {
      console.log('计数器重置:', event)
      this.addEventLog(`计数器重置为: ${event.value}`)
    },
    
    onSetToMax(event) {
      console.log('设置为最大值:', event)
      this.addEventLog(`设置为最大值: ${event.value}`)
    },
    
    onSetToMin(event) {
      console.log('设置为最小值:', event)
      this.addEventLog(`设置为最小值: ${event.value}`)
    },
    
    onMasterChange(event) {
      this.slaveCounter = Math.floor(event.value / 2)
    },
    
    addEventLog(message) {
      const timestamp = new Date().toLocaleTimeString()
      this.eventLog = `${timestamp}: ${message}\n${this.eventLog}`
      
      // 增进日志长度
      if (this.eventLog.split('\n').length > 5) {
        this.eventLog = this.eventLog.split('\n').slice(0, 5).join('\n')
      }
    }
  }
}
</script>

<style scoped>
.parent-container {
  padding: 30rpx;
  max-width: 700rpx;
  margin: 0 auto;
}
.main-title {
  font-size: 40rpx;
  font-weight: bold;
  text-align: center;
  margin-bottom: 40rpx;
  color: #333;
  display: block;
}
.demo-section {
  margin-bottom: 50rpx;
  padding: 30rpx;
  border: 2rpx solid #e0e0e0;
  border-radius: 15rpx;
  background: #fafafa;
}
.section-title {
  font-size: 28rpx;
  font-weight: bold;
  color: #007AFF;
  display: block;
  margin-bottom: 25rpx;
}
.value-display {
  display: block;
  text-align: center;
  font-size: 28rpx;
  margin-top: 20rpx;
  color: #333;
}
.event-log {
  display: block;
  background: #333;
  color: #0f0;
  padding: 20rpx;
  border-radius: 8rpx;
  font-family: monospace;
  font-size: 22rpx;
  margin-top: 15rpx;
  white-space: pre-wrap;
  max-height: 200rpx;
  overflow-y: auto;
}
</style>

五、性能优化

5.1 数据绑定性能优化

graph TB A[性能问题] --> B[大量数据响应式] A --> C[频繁的重新渲染] A --> D[内存泄漏] B --> E[Object.freeze 冻结数据] B --> F[虚拟滚动] C --> G[计算属性缓存] C --> H[v-once 单次渲染] C --> I[合理使用 v-if vs v-show] D --> J[及时销毁事件监听] D --> K[清除定时器]

5.2 优化技巧

html 复制代码
<template>
  <view class="optimization-demo">
    <text class="title">性能优化</text>
    
    <!-- 1. 计算属性缓存 -->
    <view class="optimization-section">
      <text class="section-title">1. 计算属性 vs 方法</text>
      <input v-model="filterText" placeholder="过滤文本" class="input" />
      
      <view class="result">
        <text>过滤后数量(计算属性): {{ filteredListLength }}</text>
        <text>过滤后数量(方法调用): {{ getFilteredListLength() }}</text>
      </view>
      
      <button @click="refreshCount">刷新计数</button>
      <text class="hint">打开控制台查看调用次数</text>
    </view>
    
    <!-- 2. v-once 静态内容优化 -->
    <view class="optimization-section">
      <text class="section-title">2. v-once 静态内容</text>
      <view v-once class="static-content">
        <text>这个内容只渲染一次: {{ staticTimestamp }}</text>
      </view>
      <button @click="updateStatic">更新静态内容(不会变化)</button>
    </view>
    
    <!-- 3. 大数据列表优化 -->
    <view class="optimization-section">
      <text class="section-title">3. 大数据列表渲染</text>
      <button @click="loadBigData">加载1000条数据</button>
      <button @click="loadOptimizedData">加载优化后的数据</button>
      
      <!-- 普通渲染 -->
      <view v-if="showNormalList">
        <text>普通渲染({{ normalList.length }}条):</text>
        <view v-for="item in normalList" :key="item.id" class="list-item">
          <text>{{ item.name }}</text>
        </view>
      </view>
      
      <!-- 虚拟滚动优化 -->
      <view v-if="showOptimizedList">
        <text>虚拟滚动渲染({{ optimizedList.length }}条):</text>
        <view class="virtual-list">
          <view 
            v-for="item in visibleItems" 
            :key="item.id" 
            class="list-item optimized"
          >
            <text>{{ item.name }}</text>
          </view>
        </view>
      </view>
    </view>
  </view>
</template>

<script>
export default {
  data() {
    return {
      filterText: '',
      refreshCount: 0,
      staticTimestamp: new Date().toLocaleTimeString(),
      normalList: [],
      optimizedList: [],
      showNormalList: false,
      showOptimizedList: false,
      visibleItems: [],
      bigData: []
    }
  },
  computed: {
    // 计算属性会自动缓存,只有依赖变化时才重新计算
    filteredListLength() {
      console.log('计算属性被执行')
      const list = this.generateTestList()
      return list.filter(item => 
        item.name.includes(this.filterText)
      ).length
    }
  },
  methods: {
    // 方法每次调用都会执行
    getFilteredListLength() {
      console.log('方法被调用')
      const list = this.generateTestList()
      return list.filter(item => 
        item.name.includes(this.filterText)
      ).length
    },
    
    generateTestList() {
      return Array.from({ length: 100 }, (_, i) => ({
        id: i,
        name: `项目 ${i}`
      }))
    },
    
    refreshCount() {
      this.refreshCount++
    },
    
    updateStatic() {
      this.staticTimestamp = new Date().toLocaleTimeString()
    },
    
    loadBigData() {
      this.showNormalList = true
      this.showOptimizedList = false
      
      // 生成大量数据
      this.normalList = Array.from({ length: 1000 }, (_, i) => ({
        id: i,
        name: `数据项 ${i}`,
        value: Math.random() * 1000
      }))
    },
    
    loadOptimizedData() {
      this.showNormalList = false
      this.showOptimizedList = true
      
      // 使用 Object.freeze 避免不必要的响应式
      this.optimizedList = Object.freeze(
        Array.from({ length: 1000 }, (_, i) => ({
          id: i,
          name: `数据项 ${i}`,
          value: Math.random() * 1000
        }))
      )
      
      // 虚拟滚动:只渲染可见项
      this.updateVisibleItems()
    },
    
    updateVisibleItems() {
      // 简化的虚拟滚动实现
      this.visibleItems = this.optimizedList.slice(0, 20)
    },
    
    // 防抖函数优化频繁触发的事件
    debounce(func, wait) {
      let timeout
      return function executedFunction(...args) {
        const later = () => {
          clearTimeout(timeout)
          func(...args)
        }
        clearTimeout(timeout)
        timeout = setTimeout(later, wait)
      }
    }
  },
  
  // 组件销毁时清理资源
  beforeDestroy() {
    this.normalList = []
    this.optimizedList = []
    this.visibleItems = []
  }
}
</script>

<style scoped>
.optimization-demo {
  padding: 30rpx;
}
.title {
  font-size: 36rpx;
  font-weight: bold;
  text-align: center;
  display: block;
  margin-bottom: 40rpx;
}
.optimization-section {
  margin-bottom: 40rpx;
  padding: 30rpx;
  border: 1rpx solid #ddd;
  border-radius: 10rpx;
}
.section-title {
  font-weight: bold;
  color: #007AFF;
  display: block;
  margin-bottom: 20rpx;
}
.input {
  border: 1rpx solid #ccc;
  padding: 15rpx;
  border-radius: 6rpx;
  margin-bottom: 15rpx;
}
.result {
  margin: 15rpx 0;
}
.result text {
  display: block;
  margin: 5rpx 0;
}
.hint {
  font-size: 24rpx;
  color: #666;
  display: block;
  margin-top: 10rpx;
}
.static-content {
  background: #e8f5e8;
  padding: 20rpx;
  border-radius: 6rpx;
  margin: 15rpx 0;
}
.list-item {
  padding: 10rpx;
  border-bottom: 1rpx solid #eee;
}
.list-item.optimized {
  background: #f0f8ff;
}
.virtual-list {
  max-height: 400rpx;
  overflow-y: auto;
}
</style>

总结

通过以上学习,我们深入掌握了 uni-app 中数据绑定与事件处理的核心概念:

  1. 响应式原理 :理解了 Vue 2.x 基于 Object.defineProperty 的数据劫持机制
  2. 双向绑定v-model 的本质是 :value + @input 的语法糖
  3. 事件系统:掌握了事件流、修饰符及其底层实现原理
  4. 组件通信:通过自定义事件实现子父组件间的数据传递
  5. 性能优化:学会了计算属性、虚拟滚动等优化技巧

至此数据绑定与时间处理就全部介绍完了,如果觉得这篇文章对你有帮助,别忘了一键三连~~~ 遇到任何问题,欢迎在评论区留言讨论。Happy Coding!

相关推荐
ajassi20002 小时前
开源 Objective-C IOS 应用开发(五)iOS操作(action)和输出口(Outlet)
ios·开源·objective-c
星光一影4 小时前
基于SpringBoot与Vue的海外理财系统设计与实现
vue.js·spring boot·后端·mysql·node.js·html5
麦麦大数据5 小时前
D037 vue+django三国演义知识图谱可视化系统
vue.js·django·知识图谱·neo4j·可视化
程序员小寒5 小时前
前端高频面试题之Vue(高级篇)
前端·javascript·vue.js
用户9714171814275 小时前
Vue3实现拖拽排序
javascript·vue.js
P7Dreamer5 小时前
Vue 插槽检测:$slots 的妙用与最佳实践
vue.js
阡陌昏晨5 小时前
H5性能优化-打开效率提升了62%
前端·javascript·vue.js
2501_915909065 小时前
Flutter 应用怎么加固,多工具组合的工程化实战(Flutter 加固/Dart 混淆/IPA 成品加固/Ipa Guard + CI)
android·flutter·ios·ci/cd·小程序·uni-app·iphone
敲敲了个代码6 小时前
11月3-5年Web前端开发面试需要达到的强度
前端·vue.js·学习·react.js·面试·职场和发展·web