Skip to content

事件處理

在uni-app中,事件處理是實現用戶交互的重要機制。本文將詳細介紹uni-app中的事件處理方式、事件類型以及最佳實踐。

事件綁定

基本語法

uni-app沿用了Vue的事件處理語法,使用v-on指令(簡寫為@)來監聽DOM事件:

html
<!-- 完整語法 -->
<button v-on:tap="handleTap">點擊我</button>

<!-- 縮寫語法 -->
<button @tap="handleTap">點擊我</button>

事件處理方法定義在組件的methods選項中:

javascript
export default {
  methods: {
    handleTap() {
      console.log('按鈕被點擊了')
    }
  }
}

內聯事件處理

對於簡單的事件處理邏輯,可以直接在模板中使用內聯JavaScript語句:

html
<button @tap="counter += 1">計數器: {{ counter }}</button>

事件傳參

在事件處理方法中,可以傳入自定義參數:

html
<button @tap="handleTap('hello', $event)">帶參數的事件</button>
javascript
methods: {
  handleTap(message, event) {
    console.log(message) // 'hello'
    console.log(event) // 原生事件對象
  }
}

提示

使用$event可以在傳入自定義參數的同時,獲取到原生的事件對象。

常用事件類型

uni-app支援多種事件類型,以下是一些常用的事件:

觸摸事件

  • tap:點擊事件,類似於HTML中的click事件
  • longpress:長按事件,手指長時間觸摸
  • touchstart:觸摸開始事件
  • touchmove:觸摸移動事件
  • touchend:觸摸結束事件
  • touchcancel:觸摸取消事件
html
<view @tap="handleTap">點擊</view>
<view @longpress="handleLongPress">長按</view>
<view 
  @touchstart="handleTouchStart" 
  @touchmove="handleTouchMove"
  @touchend="handleTouchEnd"
>
  觸摸區域
</view>

表單事件

  • input:輸入框內容變化時觸發
  • focus:輸入框聚焦時觸發
  • blur:輸入框失去焦點時觸發
  • change:選擇器、開關等值變化時觸發
  • submit:表單提交時觸發
html
<input 
  @input="handleInput" 
  @focus="handleFocus" 
  @blur="handleBlur" 
  placeholder="請輸入內容"
/>

<picker 
  @change="handleChange" 
  :range="['選項1', '選項2', '選項3']"
>
  <view>當前選擇: {{ currentSelection }}</view>
</picker>

<form @submit="handleSubmit">
  <!-- 表單內容 -->
  <button form-type="submit">提交</button>
</form>

生命週期事件

  • load:頁面載入時觸發
  • ready:頁面初次渲染完成時觸發
  • show:頁面顯示時觸發
  • hide:頁面隱藏時觸發

這些事件通常在頁面的生命週期鉤子函數中處理:

javascript
export default {
  onLoad(options) {
    console.log('頁面載入', options)
  },
  onReady() {
    console.log('頁面初次渲染完成')
  },
  onShow() {
    console.log('頁面顯示')
  },
  onHide() {
    console.log('頁面隱藏')
  }
}

滾動事件

  • scroll:滾動時觸發
html
<scroll-view 
  scroll-y 
  @scroll="handleScroll" 
  style="height: 300px;"
>
  <view v-for="item in items" :key="item.id">
    {{ item.text }}
  </view>
</scroll-view>
javascript
methods: {
  handleScroll(e) {
    console.log('滾動位置', e.detail)
    // e.detail.scrollTop 垂直滾動位置
    // e.detail.scrollLeft 水平滾動位置
    // e.detail.scrollHeight 滾動內容高度
    // e.detail.scrollWidth 滾動內容寬度
  }
}

事件修飾符

uni-app支援Vue的事件修飾符,用於處理事件的細節行為:

事件傳播修飾符

  • .stop:阻止事件冒泡
  • .prevent:阻止事件的默認行為
  • .capture:使用事件捕獲模式
  • .self:只當事件在該元素本身觸發時才觸發處理函數
  • .once:事件只觸發一次
html
<!-- 阻止事件冒泡 -->
<view @tap.stop="handleTap">阻止冒泡</view>

<!-- 阻止默認行為 -->
<form @submit.prevent="handleSubmit">
  <!-- 表單內容 -->
</form>

<!-- 只觸發一次 -->
<button @tap.once="handleTap">只能點擊一次</button>

按鍵修飾符

在處理鍵盤事件時,可以使用按鍵修飾符:

html
<!-- 按下回車鍵時提交表單 -->
<input @keyup.enter="submit" />

<!-- 按下Esc鍵時取消操作 -->
<input @keyup.esc="cancel" />

注意

按鍵修飾符主要在H5平台有效,在小程序和App平台可能需要使用其他方式處理鍵盤事件。

事件對象

事件處理函數會自動接收一個事件對象(event),包含事件的相關信息:

html
<view @tap="handleTap">點擊獲取事件信息</view>
javascript
methods: {
  handleTap(event) {
    console.log(event)
    
    // 常用屬性
    console.log(event.type) // 事件類型,如 'tap'
    console.log(event.target) // 觸發事件的元素
    console.log(event.currentTarget) // 當前處理事件的元素
    console.log(event.timeStamp) // 事件觸發的時間戳
    
    // 觸摸事件特有屬性
    if (event.touches) {
      console.log(event.touches) // 當前螢幕上的所有觸摸點
      console.log(event.changedTouches) // 觸發當前事件的觸摸點
    }
    
    // 表單事件特有屬性
    if (event.detail && event.detail.value !== undefined) {
      console.log(event.detail.value) // 表單組件的值
    }
  }
}

事件對象的跨平台差異

不同平台(小程序、H5、App)的事件對象可能存在差異,建議使用以下方式獲取通用屬性:

javascript
methods: {
  handleInput(event) {
    // 獲取輸入值
    const value = event.detail.value || event.target.value
    
    // 獲取dataset
    const dataset = event.currentTarget.dataset
  }
}

自定義事件

組件間通信

在自定義組件中,可以使用$emit方法觸發自定義事件,實現子組件向父組件通信:

子組件(child.vue):

html
<template>
  <view>
    <button @tap="sendMessage">發送消息</button>
  </view>
</template>

<script>
export default {
  methods: {
    sendMessage() {
      // 觸發自定義事件,並傳遞數據
      this.$emit('message', {
        content: '這是來自子組件的消息',
        time: new Date()
      })
    }
  }
}
</script>

父組件:

html
<template>
  <view>
    <!-- 監聽子組件的自定義事件 -->
    <child @message="handleMessage"></child>
    <view v-if="messageReceived">
      收到消息: {{ message.content }}
      時間: {{ formatTime(message.time) }}
    </view>
  </view>
</template>

<script>
import Child from './child.vue'

export default {
  components: {
    Child
  },
  data() {
    return {
      messageReceived: false,
      message: null
    }
  },
  methods: {
    handleMessage(msg) {
      this.messageReceived = true
      this.message = msg
    },
    formatTime(date) {
      return `${date.getHours()}:${date.getMinutes()}:${date.getSeconds()}`
    }
  }
}
</script>

事件總線(EventBus)

對於非父子組件間的通信,可以使用事件總線:

javascript
// eventBus.js
import Vue from 'vue'
export const eventBus = new Vue()

// 在Vue 3中可以使用mitt庫
// import mitt from 'mitt'
// export const eventBus = mitt()

組件A(發送事件):

javascript
import { eventBus } from '@/utils/eventBus'

export default {
  methods: {
    sendGlobalMessage() {
      eventBus.$emit('global-message', {
        from: 'ComponentA',
        content: '全局消息'
      })
    }
  }
}

組件B(接收事件):

javascript
import { eventBus } from '@/utils/eventBus'

export default {
  data() {
    return {
      messages: []
    }
  },
  created() {
    // 監聽全局事件
    eventBus.$on('global-message', this.receiveMessage)
  },
  beforeDestroy() {
    // 組件銷毀前移除事件監聽
    eventBus.$off('global-message', this.receiveMessage)
  },
  methods: {
    receiveMessage(msg) {
      this.messages.push(msg)
    }
  }
}

手勢識別

uni-app提供了基礎的觸摸事件,但對於複雜的手勢識別(如滑動、捏合、旋轉等),可以使用以下方法:

自定義手勢識別

html
<template>
  <view 
    class="gesture-area"
    @touchstart="handleTouchStart"
    @touchmove="handleTouchMove"
    @touchend="handleTouchEnd"
  >
    <text>{{ gestureInfo }}</text>
  </view>
</template>

<script>
export default {
  data() {
    return {
      gestureInfo: '請在此區域進行手勢操作',
      startX: 0,
      startY: 0,
      endX: 0,
      endY: 0,
      startTime: 0,
      isSwiping: false
    }
  },
  methods: {
    handleTouchStart(e) {
      const touch = e.touches[0]
      this.startX = touch.clientX
      this.startY = touch.clientY
      this.startTime = Date.now()
      this.isSwiping = true
    },
    handleTouchMove(e) {
      if (!this.isSwiping) return
      
      const touch = e.touches[0]
      this.endX = touch.clientX
      this.endY = touch.clientY
      
      // 計算移動距離
      const deltaX = this.endX - this.startX
      const deltaY = this.endY - this.startY
      
      // 顯示實時移動信息
      this.gestureInfo = `移動: X=${deltaX.toFixed(2)}, Y=${deltaY.toFixed(2)}`
    },
    handleTouchEnd(e) {
      if (!this.isSwiping) return
      this.isSwiping = false
      
      // 計算最終移動距離和方向
      const deltaX = this.endX - this.startX
      const deltaY = this.endY - this.startY
      const duration = Date.now() - this.startTime
      
      // 判斷手勢類型
      if (Math.abs(deltaX) > 50 || Math.abs(deltaY) > 50) {
        // 判斷方向
        if (Math.abs(deltaX) > Math.abs(deltaY)) {
          // 水平方向
          const direction = deltaX > 0 ? '右' : '左'
          this.gestureInfo = `水平滑動: ${direction}, 距離: ${Math.abs(deltaX).toFixed(2)}, 時間: ${duration}ms`
        } else {
          // 垂直方向
          const direction = deltaY > 0 ? '下' : '上'
          this.gestureInfo = `垂直滑動: ${direction}, 距離: ${Math.abs(deltaY).toFixed(2)}, 時間: ${duration}ms`
        }
      } else if (duration < 300 && Math.abs(deltaX) < 10 && Math.abs(deltaY) < 10) {
        this.gestureInfo = '輕觸'
      } else {
        this.gestureInfo = '未識別的手勢'
      }
    }
  }
}
</script>

<style>
.gesture-area {
  width: 100%;
  height: 300rpx;
  background-color: #f5f5f5;
  display: flex;
  justify-content: center;
  align-items: center;
  font-size: 28rpx;
}
</style>

使用第三方手勢庫

對於更複雜的手勢識別,可以使用第三方庫,如hammerjs(在H5平台):

bash
# 安裝hammerjs
npm install hammerjs
html
<template>
  <view ref="gestureElement" class="gesture-area">
    <text>{{ gestureInfo }}</text>
  </view>
</template>

<script>
// 僅在H5平台引入
let Hammer = null
if (process.env.UNI_PLATFORM === 'h5') {
  Hammer = require('hammerjs')
}

export default {
  data() {
    return {
      gestureInfo: '請在此區域進行手勢操作',
      hammer: null
    }
  },
  mounted() {
    // 僅在H5平台初始化Hammer
    if (Hammer) {
      this.$nextTick(() => {
        const element = this.$refs.gestureElement
        this.hammer = new Hammer(element)
        
        // 配置識別器
        this.hammer.get('swipe').set({ direction: Hammer.DIRECTION_ALL })
        this.hammer.get('pinch').set({ enable: true })
        this.hammer.get('rotate').set({ enable: true })
        
        // 監聽手勢事件
        this.hammer.on('tap', (e) => {
          this.gestureInfo = '輕觸'
        })
        
        this.hammer.on('swipe', (e) => {
          const direction = this.getDirection(e.direction)
          this.gestureInfo = `滑動: ${direction}, 速度: ${e.velocity.toFixed(2)}`
        })
        
        this.hammer.on('pinch', (e) => {
          this.gestureInfo = `捏合: 比例 ${e.scale.toFixed(2)}`
        })
        
        this.hammer.on('rotate', (e) => {
          this.gestureInfo = `旋轉: ${e.rotation.toFixed(2)}度`
        })
      })
    }
  },
  beforeDestroy() {
    // 銷毀Hammer實例
    if (this.hammer) {
      this.hammer.destroy()
      this.hammer = null
    }
  },
  methods: {
    getDirection(direction) {
      switch(direction) {
        case Hammer.DIRECTION_LEFT: return '左'
        case Hammer.DIRECTION_RIGHT: return '右'
        case Hammer.DIRECTION_UP: return '上'
        case Hammer.DIRECTION_DOWN: return '下'
        default: return '未知'
      }
    }
  }
}
</script>

事件委託

事件委託(Event Delegation)是一種常用的事件處理模式,通過將事件監聽器添加到父元素,而不是每個子元素,可以提高性能並簡化代碼:

html
<template>
  <view class="list" @tap="handleItemClick">
    <view 
      v-for="item in items" 
      :key="item.id" 
      class="list-item"
      :data-id="item.id"
    >
      {{ item.text }}
    </view>
  </view>
</template>

<script>
export default {
  data() {
    return {
      items: [
        { id: 1, text: '項目1' },
        { id: 2, text: '項目2' },
        { id: 3, text: '項目3' },
        { id: 4, text: '項目4' },
        { id: 5, text: '項目5' }
      ]
    }
  },
  methods: {
    handleItemClick(e) {
      // 獲取被點擊元素的dataset
      const dataset = e.target.dataset || e.currentTarget.dataset
      
      if (dataset.id) {
        const id = Number(dataset.id)
        const item = this.items.find(item => item.id === id)
        
        if (item) {
          uni.showToast({
            title: `點擊了: ${item.text}`,
            icon: 'none'
          })
        }
      }
    }
  }
}
</script>

性能優化

防抖與節流

對於頻繁觸發的事件(如滾動、輸入、調整窗口大小等),應使用防抖(Debounce)或節流(Throttle)技術來優化性能:

javascript
// utils/event.js

// 防抖函數
export function debounce(func, wait = 300) {
  let timeout
  return function(...args) {
    clearTimeout(timeout)
    timeout = setTimeout(() => {
      func.apply(this, args)
    }, wait)
  }
}

// 節流函數
export function throttle(func, wait = 300) {
  let timeout = null
  let previous = 0
  
  return function(...args) {
    const now = Date.now()
    const remaining = wait - (now - previous)
    
    if (remaining <= 0) {
      if (timeout) {
        clearTimeout(timeout)
        timeout = null
      }
      previous = now
      func.apply(this, args)
    } else if (!timeout) {
      timeout = setTimeout(() => {
        previous = Date.now()
        timeout = null
        func.apply(this, args)
      }, remaining)
    }
  }
}

在組件中使用:

html
<template>
  <view>
    <input @input="handleInput" placeholder="搜索..." />
    <scroll-view 
      scroll-y 
      @scroll="handleScroll" 
      style="height: 300px;"
    >
      <view v-for="item in items" :key="item.id">
        {{ item.text }}
      </view>
    </scroll-view>
  </view>
</template>

<script>
import { debounce, throttle } from '@/utils/event'

export default {
  data() {
    return {
      items: [],
      searchText: ''
    }
  },
  created() {
    // 創建防抖和節流函數
    this.debouncedSearch = debounce(this.search, 500)
    this.throttledScroll = throttle(this.onScroll, 200)
  },
  methods: {
    // 輸入事件使用防抖
    handleInput(e) {
      const value = e.detail.value || e.target.value
      this.searchText = value
      this.debouncedSearch(value)
    },
    search(text) {
      console.log('執行搜索:', text)
      // 實際搜索邏輯
    },
    
    // 滾動事件使用節流
    handleScroll(e) {
      this.throttledScroll(e)
    },
    onScroll(e) {
      console.log('處理滾動:', e.detail.scrollTop)
      // 實際滾動處理邏輯
    }
  }
}
</script>

避免內聯函數

在模板中使用內聯函數會導致每次重新渲染時創建新的函數實例,應盡量避免:

html
<!-- 不推薦 -->
<view v-for="item in items" :key="item.id" @tap="() => handleItemClick(item.id)">
  {{ item.text }}
</view>

<!-- 推薦 -->
<view v-for="item in items" :key="item.id" @tap="handleItemClick" :data-id="item.id">
  {{ item.text }}
</view>
javascript
methods: {
  handleItemClick(e) {
    const id = e.currentTarget.dataset.id
    // 處理點擊邏輯
  }
}

使用計算屬性代替方法

對於在模板中多次使用的數據轉換,應使用計算屬性而不是方法:

html
<!-- 不推薦 -->
<view v-for="item in items" :key="item.id">
  {{ formatPrice(item.price) }}
</view>

<!-- 推薦 -->
<view v-for="item in formattedItems" :key="item.id">
  {{ item.formattedPrice }}
</view>
javascript
computed: {
  formattedItems() {
    return this.items.map(item => ({
      ...item,
      formattedPrice: this.formatPrice(item.price)
    }))
  }
},
methods: {
  formatPrice(price) {
    return '¥' + price.toFixed(2)
  }
}

跨平台注意事項

事件命名差異

不同平台對事件的命名可能存在差異,例如:

  • Web平台使用click,而小程序使用tap
  • Web平台使用change,而小程序的某些組件可能使用bindchange

為了保持一致性,uni-app做了統一處理,建議使用uni-app推薦的事件名:

html
<!-- 在所有平台都使用tap事件 -->
<view @tap="handleTap">點擊</view>

<!-- 在所有平台都使用change事件 -->
<picker @change="handleChange" :range="options">選擇</picker>

事件對象差異

不同平台的事件對象結構可能不同,例如:

  • Web平台通過event.target.value獲取輸入值
  • 小程序通過event.detail.value獲取輸入值

為了處理這些差異,可以編寫兼容性代碼:

javascript
methods: {
  handleInput(event) {
    // 兼容不同平台
    const value = event.detail.value || (event.target && event.target.value) || ''
    this.inputValue = value
  }
}

事件冒泡差異

不同平台的事件冒泡機制可能存在差異,特別是在自定義組件嵌套時:

html
<!-- 父組件 -->
<view @tap="handleOuterTap">
  <custom-component @tap="handleInnerTap"></custom-component>
</view>

在某些平台上,點擊自定義組件可能會同時觸發內部和外部的tap事件。為了確保一致的行為,可以使用.stop修飾符:

html
<view @tap="handleOuterTap">
  <custom-component @tap.stop="handleInnerTap"></custom-component>
</view>

實際應用示例

拖拽排序列表

html
<template>
  <view class="drag-list">
    <view 
      v-for="(item, index) in items" 
      :key="item.id"
      class="drag-item"
      :class="{ 'dragging': draggingIndex === index }"
      :style="getItemStyle(index)"
      @touchstart="handleTouchStart($event, index)"
      @touchmove="handleTouchMove"
      @touchend="handleTouchEnd"
    >
      <text>{{ item.text }}</text>
    </view>
  </view>
</template>

<script>
export default {
  data() {
    return {
      items: [
        { id: 1, text: '項目1' },
        { id: 2, text: '項目2' },
        { id: 3, text: '項目3' },
        { id: 4, text: '項目4' },
        { id: 5, text: '項目5' }
      ],
      draggingIndex: -1,
      startY: 0,
      currentY: 0,
      itemHeight: 0,
      positions: []
    }
  },
  mounted() {
    // 獲取項目高度
    const query = uni.createSelectorQuery().in(this)
    query.select('.drag-item').boundingClientRect(data => {
      if (data) {
        this.itemHeight = data.height
        // 初始化位置數組
        this.positions = this.items.map((_, index) => index * this.itemHeight)
      }
    }).exec()
  },
  methods: {
    handleTouchStart(e, index) {
      this.draggingIndex = index
      this.startY = e.touches[0].clientY
      this.currentY = this.positions[index]
    },
    handleTouchMove(e) {
      if (this.draggingIndex < 0) return
      
      const moveY = e.touches[0].clientY - this.startY
      this.positions[this.draggingIndex] = this.currentY + moveY
      
      // 檢查是否需要交換位置
      const currentPos = this.positions[this.draggingIndex]
      let targetIndex = -1
      
      // 向下拖動
      if (moveY > 0 && this.draggingIndex < this.items.length - 1) {
        const nextPos = (this.draggingIndex + 1) * this.itemHeight
        if (currentPos > nextPos) {
          targetIndex = this.draggingIndex + 1
        }
      }
      // 向上拖動
      else if (moveY < 0 && this.draggingIndex > 0) {
        const prevPos = (this.draggingIndex - 1) * this.itemHeight
        if (currentPos < prevPos) {
          targetIndex = this.draggingIndex - 1
        }
      }
      
      // 交換位置
      if (targetIndex >= 0) {
        this.swapItems(this.draggingIndex, targetIndex)
        this.draggingIndex = targetIndex
      }
    },
    handleTouchEnd() {
      if (this.draggingIndex < 0) return
      
      // 重置位置
      this.positions = this.items.map((_, index) => index * this.itemHeight)
      this.draggingIndex = -1
    },
    swapItems(fromIndex, toIndex) {
      // 交換數組中的項目
      const temp = this.items[fromIndex]
      this.$set(this.items, fromIndex, this.items[toIndex])
      this.$set(this.items, toIndex, temp)
      
      // 交換位置數組中的值
      const tempPos = this.positions[fromIndex]
      this.$set(this.positions, fromIndex, this.positions[toIndex])
      this.$set(this.positions, toIndex, tempPos)
    },
    getItemStyle(index) {
      if (index === this.draggingIndex) {
        return {
          transform: `translateY(${this.positions[index]}px)`,
          zIndex: 10,
          transition: 'none'
        }
      }
      return {
        transform: `translateY(${this.positions[index]}px)`,
        transition: 'transform 0.2s ease'
      }
    }
  }
}
</script>

<style>
.drag-list {
  padding: 20rpx;
  position: relative;
  height: 600rpx;
}

.drag-item {
  height: 100rpx;
  background-color: #ffffff;
  border: 1rpx solid #eeeeee;
  border-radius: 8rpx;
  margin-bottom: 20rpx;
  padding: 0 30rpx;
  display: flex;
  align-items: center;
  position: absolute;
  left: 20rpx;
  right: 20rpx;
}

.dragging {
  box-shadow: 0 4rpx 10rpx rgba(0, 0, 0, 0.1);
  background-color: #f8f8f8;
}
</style>

下拉刷新與上拉載入

html
<template>
  <view class="container">
    <!-- 自定義下拉刷新 -->
    <view 
      class="refresh-container" 
      :style="{ height: refreshHeight + 'px' }"
      :class="{ 'refreshing': isRefreshing }"
    >
      <view class="refresh-icon" :class="{ 'rotate': isRefreshing }">↓</view>
      <text>{{ refreshText }}</text>
    </view>
    
    <!-- 內容區域 -->
    <scroll-view 
      scroll-y 
      class="scroll-view"
      @scrolltoupper="handleScrollToUpper"
      @scrolltolower="handleScrollToLower"
      @touchstart="handleTouchStart"
      @touchmove="handleTouchMove"
      @touchend="handleTouchEnd"
      :style="{ transform: `translateY(${translateY}px)` }"
    >
      <view class="list">
        <view 
          v-for="item in list" 
          :key="item.id"
          class="list-item"
        >
          <text class="item-title">{{ item.title }}</text>
          <text class="item-desc">{{ item.description }}</text>
        </view>
      </view>
      
      <!-- 載入更多 -->
      <view class="loading-more" v-if="hasMore || isLoadingMore">
        <view class="loading-icon" v-if="isLoadingMore"></view>
        <text>{{ loadingMoreText }}</text>
      </view>
    </scroll-view>
  </view>
</template>

<script>
export default {
  data() {
    return {
      list: [],
      page: 1,
      pageSize: 10,
      hasMore: true,
      isLoadingMore: false,
      isRefreshing: false,
      
      // 下拉刷新相關
      startY: 0,
      moveY: 0,
      translateY: 0,
      refreshHeight: 0,
      maxRefreshHeight: 80,
      refreshThreshold: 50,
      isTouching: false
    }
  },
  computed: {
    refreshText() {
      if (this.isRefreshing) {
        return '刷新中...'
      }
      return this.refreshHeight >= this.refreshThreshold ? '釋放立即刷新' : '下拉可以刷新'
    },
    loadingMoreText() {
      if (!this.hasMore) {
        return '沒有更多數據了'
      }
      return this.isLoadingMore ? '正在載入更多...' : '上拉載入更多'
    }
  },
  created() {
    // 初始載入數據
    this.loadData()
  },
  methods: {
    // 載入數據
    loadData(isRefresh = false) {
      if (isRefresh) {
        this.page = 1
        this.hasMore = true
      }
      
      // 模擬請求
      setTimeout(() => {
        const newData = Array.from({ length: this.pageSize }, (_, index) => {
          const itemIndex = (this.page - 1) * this.pageSize + index + 1
          return {
            id: `item-${this.page}-${index}`,
            title: `標題 ${itemIndex}`,
            description: `這是第${itemIndex}條數據的詳細描述信息,包含了一些相關內容。`
          }
        })
        
        if (isRefresh) {
          this.list = newData
        } else {
          this.list = [...this.list, ...newData]
        }
        
        // 判斷是否還有更多數據
        if (this.page >= 5) {
          this.hasMore = false
        } else {
          this.page++
        }
        
        // 重置狀態
        this.isRefreshing = false
        this.isLoadingMore = false
        
        // 如果是刷新,需要重置位置
        if (isRefresh) {
          this.resetRefresh()
        }
      }, 1000)
    },
    
    // 下拉刷新相關方法
    handleTouchStart(e) {
      // 只有在頂部才允許下拉刷新
      if (e.touches[0] && !this.isRefreshing) {
        this.startY = e.touches[0].clientY
        this.isTouching = true
      }
    },
    handleTouchMove(e) {
      if (!this.isTouching || this.isRefreshing) return
      
      this.moveY = e.touches[0].clientY
      let distance = this.moveY - this.startY
      
      // 只有下拉才觸發刷新
      if (distance <= 0) {
        this.translateY = 0
        this.refreshHeight = 0
        return
      }
      
      // 添加阻尼效果
      distance = Math.pow(distance, 0.8)
      
      // 限制最大下拉距離
      if (distance > this.maxRefreshHeight) {
        distance = this.maxRefreshHeight
      }
      
      this.translateY = distance
      this.refreshHeight = distance
    },
    handleTouchEnd() {
      if (!this.isTouching || this.isRefreshing) return
      
      this.isTouching = false
      
      // 如果達到刷新閾值,觸發刷新
      if (this.refreshHeight >= this.refreshThreshold) {
        this.isRefreshing = true
        this.translateY = this.refreshThreshold
        this.refreshHeight = this.refreshThreshold
        
        // 執行刷新
        this.loadData(true)
      } else {
        // 未達到閾值,重置位置
        this.resetRefresh()
      }
    },
    resetRefresh() {
      this.translateY = 0
      this.refreshHeight = 0
    },
    
    // 滾動事件處理
    handleScrollToUpper() {
      console.log('到達頂部')
    },
    handleScrollToLower() {
      if (this.hasMore && !this.isLoadingMore) {
        console.log('到達底部,載入更多')
        this.isLoadingMore = true
        this.loadData()
      }
    }
  }
}
</script>

<style>
.container {
  height: 100vh;
  position: relative;
  overflow: hidden;
}

.refresh-container {
  display: flex;
  justify-content: center;
  align-items: center;
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 0;
  transition: height 0.2s;
  overflow: hidden;
  background-color: #f5f5f5;
  z-index: 1;
}

.refresh-icon {
  font-size: 32rpx;
  margin-right: 10rpx;
  transition: transform 0.3s;
}

.rotate {
  animation: rotating 1s linear infinite;
}

@keyframes rotating {
  from {
    transform: rotate(0deg);
  }
  to {
    transform: rotate(360deg);
  }
}

.scroll-view {
  height: 100%;
  transition: transform 0.2s;
}

.list {
  padding: 20rpx;
}

.list-item {
  background-color: #ffffff;
  border-radius: 8rpx;
  padding: 20rpx;
  margin-bottom: 20rpx;
  box-shadow: 0 2rpx 6rpx rgba(0, 0, 0, 0.05);
}

.item-title {
  font-size: 32rpx;
  font-weight: bold;
  margin-bottom: 10rpx;
  display: block;
}

.item-desc {
  font-size: 28rpx;
  color: #666;
  display: block;
}

.loading-more {
  text-align: center;
  padding: 20rpx 0;
  color: #999;
  font-size: 24rpx;
  display: flex;
  justify-content: center;
  align-items: center;
}

.loading-icon {
  width: 30rpx;
  height: 30rpx;
  border: 2rpx solid #ccc;
  border-top-color: #666;
  border-radius: 50%;
  margin-right: 10rpx;
  animation: rotating 1s linear infinite;
}
</style>

圖片預覽與手勢縮放

html
<template>
  <view class="container">
    <!-- 圖片列表 -->
    <view class="image-grid">
      <view 
        v-for="(image, index) in images" 
        :key="index"
        class="image-item"
        @tap="previewImage(index)"
      >
        <image :src="image.thumbnail" mode="aspectFill" class="thumbnail"></image>
      </view>
    </view>
    
    <!-- 圖片預覽層 -->
    <view 
      class="preview-container" 
      v-if="showPreview"
      @tap="closePreview"
    >
      <swiper 
        class="preview-swiper" 
        :current="currentIndex"
        @change="handleSwiperChange"
        circular
      >
        <swiper-item 
          v-for="(image, index) in images" 
          :key="index"
          class="preview-item"
        >
          <view 
            class="zoom-container"
            @touchstart="handleTouchStart"
            @touchmove="handleTouchMove"
            @touchend="handleTouchEnd"
          >
            <image 
              :src="image.original" 
              mode="aspectFit" 
              class="preview-image"
              :style="getZoomStyle(index)"
            ></image>
          </view>
        </swiper-item>
      </swiper>
      
      <!-- 指示器 -->
      <view class="indicator">
        {{ currentIndex + 1 }}/{{ images.length }}
      </view>
      
      <!-- 關閉按鈕 -->
      <view class="close-btn" @tap.stop="closePreview">×</view>
    </view>
  </view>
</template>

<script>
export default {
  data() {
    return {
      images: [
        {
          thumbnail: '/static/images/thumb1.jpg',
          original: '/static/images/image1.jpg'
        },
        {
          thumbnail: '/static/images/thumb2.jpg',
          original: '/static/images/image2.jpg'
        },
        {
          thumbnail: '/static/images/thumb3.jpg',
          original: '/static/images/image3.jpg'
        },
        {
          thumbnail: '/static/images/thumb4.jpg',
          original: '/static/images/image4.jpg'
        },
        {
          thumbnail: '/static/images/thumb5.jpg',
          original: '/static/images/image5.jpg'
        },
        {
          thumbnail: '/static/images/thumb6.jpg',
          original: '/static/images/image6.jpg'
        }
      ],
      showPreview: false,
      currentIndex: 0,
      
      // 縮放相關
      scale: 1,
      baseScale: 1,
      lastScale: 1,
      offsetX: 0,
      offsetY: 0,
      lastX: 0,
      lastY: 0,
      touches: []
    }
  },
  methods: {
    previewImage(index) {
      this.currentIndex = index
      this.showPreview = true
      this.resetZoom()
    },
    closePreview() {
      this.showPreview = false
    },
    handleSwiperChange(e) {
      this.currentIndex = e.detail.current
      this.resetZoom()
    },
    
    // 手勢縮放相關
    handleTouchStart(e) {
      const touches = e.touches
      this.touches = touches
      
      if (touches.length === 1) {
        // 單指拖動
        this.lastX = touches[0].clientX
        this.lastY = touches[0].clientY
      } else if (touches.length === 2) {
        // 雙指縮放
        const touch1 = touches[0]
        const touch2 = touches[1]
        
        // 計算兩指之間的距離
        const distance = this.getDistance(
          touch1.clientX, touch1.clientY,
          touch2.clientX, touch2.clientY
        )
        
        this.baseScale = distance
      }
      
      // 阻止事件冒泡,防止關閉預覽
      e.stopPropagation()
    },
    handleTouchMove(e) {
      const touches = e.touches
      
      if (touches.length === 1 && this.scale > 1) {
        // 單指拖動(僅當放大時可拖動)
        const touch = touches[0]
        const deltaX = touch.clientX - this.lastX
        const deltaY = touch.clientY - this.lastY
        
        this.offsetX += deltaX
        this.offsetY += deltaY
        
        this.lastX = touch.clientX
        this.lastY = touch.clientY
      } else if (touches.length === 2) {
        // 雙指縮放
        const touch1 = touches[0]
        const touch2 = touches[1]
        
        // 計算新的兩指距離
        const distance = this.getDistance(
          touch1.clientX, touch1.clientY,
          touch2.clientX, touch2.clientY
        )
        
        // 計算縮放比例
        let newScale = (distance / this.baseScale) * this.lastScale
        
        // 限制縮放範圍
        if (newScale < 1) newScale = 1
        if (newScale > 3) newScale = 3
        
        this.scale = newScale
      }
      
      // 阻止默認行為和冒泡
      e.preventDefault()
      e.stopPropagation()
    },
    handleTouchEnd(e) {
      if (this.touches.length === 2) {
        this.lastScale = this.scale
      }
      
      // 如果縮小到原始大小,重置偏移
      if (this.scale <= 1) {
        this.resetZoom()
      }
      
      // 阻止事件冒泡
      e.stopPropagation()
    },
    getDistance(x1, y1, x2, y2) {
      const deltaX = x2 - x1
      const deltaY = y2 - y1
      return Math.sqrt(deltaX * deltaX + deltaY * deltaY)
    },
    resetZoom() {
      this.scale = 1
      this.lastScale = 1
      this.offsetX = 0
      this.offsetY = 0
    },
    getZoomStyle(index) {
      if (index !== this.currentIndex) {
        return {}
      }
      
      return {
        transform: `scale(${this.scale}) translate(${this.offsetX / this.scale}px, ${this.offsetY / this.scale}px)`
      }
    }
  }
}
</script>

<style>
.container {
  padding: 20rpx;
}

.image-grid {
  display: flex;
  flex-wrap: wrap;
}

.image-item {
  width: 33.33%;
  padding: 10rpx;
  box-sizing: border-box;
}

.thumbnail {
  width: 100%;
  height: 200rpx;
  border-radius: 8rpx;
}

.preview-container {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background-color: rgba(0, 0, 0, 0.9);
  z-index: 999;
  display: flex;
  justify-content: center;
  align-items: center;
}

.preview-swiper {
  width: 100%;
  height: 100%;
}

.preview-item {
  display: flex;
  justify-content: center;
  align-items: center;
}

.zoom-container {
  width: 100%;
  height: 100%;
  display: flex;
  justify-content: center;
  align-items: center;
}

.preview-image {
  max-width: 100%;
  max-height: 100%;
  transition: transform 0.1s ease;
}

.indicator {
  position: absolute;
  bottom: 60rpx;
  left: 0;
  right: 0;
  text-align: center;
  color: #fff;
  font-size: 28rpx;
}

.close-btn {
  position: absolute;
  top: 40rpx;
  right: 40rpx;
  width: 60rpx;
  height: 60rpx;
  border-radius: 50%;
  background-color: rgba(0, 0, 0, 0.5);
  color: #fff;
  font-size: 40rpx;
  display: flex;
  justify-content: center;
  align-items: center;
}
</style>

總結

事件處理是uni-app開發中不可或缺的一部分,掌握好事件處理機制可以幫助開發者構建更加交互豐富、用戶體驗更好的應用。本文介紹了uni-app中的事件綁定語法、常用事件類型、事件修飾符、事件對象、自定義事件、手勢識別等內容,並提供了多個實際應用示例。

在實際開發中,應注意以下幾點:

  1. 選擇合適的事件類型:根據交互需求選擇合適的事件類型,如點擊使用tap,長按使用longpress等。

  2. 注意跨平台差異:不同平台(小程序、H5、App)的事件處理可能存在差異,編寫代碼時應考慮兼容性。

  3. 優化性能:對於頻繁觸發的事件,使用防抖或節流技術進行優化;避免在模板中使用內聯函數;合理使用計算屬性代替方法。

  4. 合理組織代碼:將複雜的事件處理邏輯拆分為多個方法,提高代碼可讀性和可維護性。

通過合理使用事件處理機制,可以構建出交互流暢、體驗良好的uni-app應用。

一次開發,多端部署 - 讓跨平台開發更簡單