uniapp開發鴻蒙:網絡請求與數據交互實戰

引入:構建健壯的網絡層

在前幾篇文章中,我們學習了uniapp鴻蒙開發的環境配置、頁面佈局、狀態管理等核心知識。今天,我們將深入探討網絡請求與數據交互的完整方案,這是應用與後端服務通信的橋樑,也是保證應用穩定性和用户體驗的關鍵環節。

uniapp提供了uni.request作為網絡請求的基礎API,但直接使用會遇到代碼冗餘、缺乏統一管理、錯誤處理複雜等問題。通過合理的封裝和架構設計,我們可以構建出高效、可維護的網絡請求體系。

一、基礎請求封裝

1.1 創建請求配置文件

首先創建配置文件,統一管理請求參數:

utils/config.js

// 環境配置
export const ENV = process.env.NODE_ENV || 'development'

// 基礎URL配置
export const BASE_URL = {
  development: 'https://dev-api.example.com',
  production: 'https://api.example.com'
}[ENV]

// 請求超時時間
export const TIMEOUT = 15000

// 公共請求頭
export const COMMON_HEADERS = {
  'Content-Type': 'application/json',
  'X-App-Version': '1.0.0'
}

// 業務狀態碼映射
export const ERROR_CODE_MAP = {
  401: '登錄狀態已過期',
  403: '無權限訪問',
  500: '服務器異常,請稍後重試'
}

1.2 核心請求方法封裝

utils/request.js

import { BASE_URL, TIMEOUT, COMMON_HEADERS } from './config'

// 防重複請求隊列
const pendingRequests = new Map()

// 生成請求唯一標識
const generateReqKey = (config) => {
  return `${config.url}&${config.method}&${JSON.stringify(config.data)}`
}

// 基礎請求方法
const request = (options = {}) => {
  // 合併配置
  const mergedConfig = {
    url: options.url,
    method: options.method || 'GET',
    data: options.data || {},
    header: {
      ...COMMON_HEADERS,
      ...(options.header || {}),
      'Authorization': uni.getStorageSync('token') || ''
    },
    timeout: options.timeout || TIMEOUT,
    loading: options.loading !== false, // 默認顯示loading
    ...options
  }

  // 處理完整URL
  if (!mergedConfig.url.startsWith('http')) {
    mergedConfig.url = mergedConfig.url.startsWith('/')
      ? `${BASE_URL}${mergedConfig.url}`
      : `${BASE_URL}/${mergedConfig.url}`
  }

  // 防重複請求檢查
  const requestKey = generateReqKey(mergedConfig)
  if (pendingRequests.has(requestKey)) {
    return Promise.reject(new Error('重複請求'))
  }
  pendingRequests.set(requestKey, true)

  return new Promise((resolve, reject) => {
    uni.request({
      ...mergedConfig,
      success: (res) => {
        pendingRequests.delete(requestKey)
        resolve(res)
      },
      fail: (err) => {
        pendingRequests.delete(requestKey)
        reject(err)
      }
    })
  })
}

// 常用請求方法封裝
export const get = (url, data, options = {}) => {
  return request({
    url,
    data,
    method: 'GET',
    ...options
  })
}

export const post = (url, data, options = {}) => {
  return request({
    url,
    data,
    method: 'POST',
    ...options
  })
}

export const put = (url, data, options = {}) => {
  return request({
    url,
    data,
    method: 'PUT',
    ...options
  })
}

export const del = (url, data, options = {}) => {
  return request({
    url,
    data,
    method: 'DELETE',
    ...options
  })
}

export default request

二、攔截器系統實現

2.1 請求攔截器

utils/interceptors.js

import { ERROR_CODE_MAP } from './config'

// 請求攔截器
uni.addInterceptor('request', {
  invoke: (config) => {
    console.log('請求開始:', config)
    
    // 自動添加token
    const token = uni.getStorageSync('token')
    if (token) {
      config.header = config.header || {}
      config.header.Authorization = token
    }
    
    // 顯示loading
    if (config.loading) {
      uni.showLoading({
        title: '加載中...',
        mask: true
      })
    }
    
    return config
  },
  
  success: (res) => {
    console.log('請求成功:', res)
    
    // 隱藏loading
    uni.hideLoading()
    
    const { statusCode, data } = res
    
    // HTTP狀態碼錯誤處理
    if (statusCode >= 400) {
      const errorMsg = ERROR_CODE_MAP[statusCode] || `網絡錯誤: ${statusCode}`
      uni.showToast({
        title: errorMsg,
        icon: 'none'
      })
      return Promise.reject(res)
    }
    
    // 業務狀態碼處理
    if (data && data.code !== 200) {
      uni.showToast({
        title: data.message || '請求失敗',
        icon: 'none'
      })
      
      // token過期處理
      if (data.code === 401) {
        uni.removeStorageSync('token')
        uni.reLaunch({
          url: '/pages/login/login'
        })
        return Promise.reject(res)
      }
      
      return Promise.reject(res)
    }
    
    return res
  },
  
  fail: (err) => {
    console.error('請求失敗:', err)
    uni.hideLoading()
    
    // 網絡錯誤處理
    uni.showToast({
      title: '網絡異常,請檢查網絡連接',
      icon: 'none'
    })
    
    return Promise.reject(err)
  }
})

2.2 響應攔截器優化

utils/response.js

// 響應統一處理
export const handleResponse = (response) => {
  const { statusCode, data } = response
  
  if (statusCode >= 200 && statusCode < 300) {
    // 業務成功
    if (data && data.code === 200) {
      return data.data
    }
    
    // 業務失敗
    throw new Error(data.message || '請求失敗')
  }
  
  // HTTP錯誤
  throw new Error(`HTTP錯誤: ${statusCode}`)
}

// 錯誤統一處理
export const handleError = (error) => {
  console.error('請求錯誤:', error)
  
  if (error.errMsg) {
    // 網絡錯誤
    uni.showToast({
      title: '網絡異常,請檢查網絡連接',
      icon: 'none'
    })
  } else if (error.message) {
    // 業務錯誤
    uni.showToast({
      title: error.message,
      icon: 'none'
    })
  }
  
  throw error
}

三、API模塊化管理

3.1 用户模塊API

api/user.js

import { get, post } from '@/utils/request'

// 用户登錄
export const login = (data) => {
  return post('/user/login', data)
}

// 獲取用户信息
export const getUserInfo = () => {
  return get('/user/info')
}

// 更新用户信息
export const updateUserInfo = (data) => {
  return post('/user/update', data)
}

// 退出登錄
export const logout = () => {
  return post('/user/logout')
}

3.2 商品模塊API

api/product.js

import { get, post } from '@/utils/request'

// 獲取商品列表
export const getProductList = (params) => {
  return get('/product/list', params)
}

// 獲取商品詳情
export const getProductDetail = (id) => {
  return get(`/product/detail/${id}`)
}

// 添加商品到購物車
export const addToCart = (data) => {
  return post('/cart/add', data)
}

// 獲取購物車列表
export const getCartList = () => {
  return get('/cart/list')
}

3.3 API統一出口

api/index.js

export * from './user'
export * from './product'

四、數據緩存策略

4.1 本地存儲封裝

utils/storage.js

// 帶過期時間的緩存
export const setWithExpire = (key, data, expire = 60 * 60 * 1000) => {
  const cacheObj = {
    data,
    timestamp: Date.now() + expire
  }
  
  try {
    uni.setStorageSync(key, JSON.stringify(cacheObj))
  } catch (e) {
    console.error('設置緩存失敗:', e)
  }
}

// 獲取帶過期時間的緩存
export const getWithExpire = (key) => {
  try {
    const str = uni.getStorageSync(key)
    if (!str) return null
    
    const cacheObj = JSON.parse(str)
    if (Date.now() > cacheObj.timestamp) {
      uni.removeStorageSync(key)
      return null
    }
    
    return cacheObj.data
  } catch (e) {
    console.error('獲取緩存失敗:', e)
    return null
  }
}

// 清除所有緩存
export const clearAllCache = () => {
  try {
    const storageInfo = uni.getStorageInfoSync()
    storageInfo.keys.forEach(key => {
      if (key.startsWith('cache_')) {
        uni.removeStorageSync(key)
      }
    })
  } catch (e) {
    console.error('清除緩存失敗:', e)
  }
}

4.2 請求緩存策略

utils/cache.js

import { getWithExpire, setWithExpire } from './storage'

// 請求緩存
export const cachedRequest = async (key, requestFn, expire = 5 * 60 * 1000) => {
  // 從緩存獲取
  const cachedData = getWithExpire(key)
  if (cachedData) {
    return cachedData
  }
  
  // 發起請求
  try {
    const data = await requestFn()
    setWithExpire(key, data, expire)
    return data
  } catch (error) {
    console.error('請求失敗:', error)
    throw error
  }
}

// 清除指定緩存
export const clearCache = (key) => {
  uni.removeStorageSync(key)
}

五、文件上傳下載

5.1 文件上傳

utils/upload.js

import { BASE_URL } from './config'

// 上傳文件
export const uploadFile = (filePath, name = 'file') => {
  return new Promise((resolve, reject) => {
    uni.uploadFile({
      url: `${BASE_URL}/upload`,
      filePath,
      name,
      header: {
        'Authorization': uni.getStorageSync('token') || ''
      },
      success: (res) => {
        const data = JSON.parse(res.data)
        if (data.code === 200) {
          resolve(data.data)
        } else {
          reject(new Error(data.message || '上傳失敗'))
        }
      },
      fail: (err) => {
        reject(err)
      }
    })
  })
}

// 選擇並上傳圖片
export const chooseAndUploadImage = () => {
  return new Promise((resolve, reject) => {
    uni.chooseImage({
      count: 1,
      success: (res) => {
        const tempFilePath = res.tempFilePaths[0]
        uploadFile(tempFilePath)
          .then(resolve)
          .catch(reject)
      },
      fail: reject
    })
  })
}

5.2 文件下載

utils/download.js

// 下載文件
export const downloadFile = (url, fileName) => {
  return new Promise((resolve, reject) => {
    const downloadTask = uni.downloadFile({
      url,
      success: (res) => {
        if (res.statusCode === 200) {
          // 保存到本地
          uni.saveFile({
            tempFilePath: res.tempFilePath,
            success: (saveRes) => {
              resolve(saveRes.savedFilePath)
            },
            fail: reject
          })
        } else {
          reject(new Error(`下載失敗: ${res.statusCode}`))
        }
      },
      fail: reject
    })
    
    // 監聽下載進度
    downloadTask.onProgressUpdate((res) => {
      console.log('下載進度:', res.progress)
    })
  })
}

六、錯誤處理與重試機制

6.1 統一錯誤處理

utils/errorHandler.js

// 全局錯誤處理器
export const errorHandler = {
  // 網絡錯誤
  networkError: (error) => {
    console.error('網絡錯誤:', error)
    uni.showToast({
      title: '網絡異常,請檢查網絡連接',
      icon: 'none'
    })
  },
  
  // 業務錯誤
  businessError: (error) => {
    console.error('業務錯誤:', error)
    uni.showToast({
      title: error.message || '操作失敗',
      icon: 'none'
    })
  },
  
  // 登錄過期
  authError: () => {
    uni.removeStorageSync('token')
    uni.showModal({
      title: '提示',
      content: '登錄已過期,請重新登錄',
      showCancel: false,
      success: () => {
        uni.reLaunch({
          url: '/pages/login/login'
        })
      }
    })
  },
  
  // 未知錯誤
  unknownError: (error) => {
    console.error('未知錯誤:', error)
    uni.showToast({
      title: '系統異常,請稍後重試',
      icon: 'none'
    })
  }
}

// 全局錯誤捕獲
export const setupGlobalErrorHandler = () => {
  // Vue錯誤捕獲
  if (typeof Vue !== 'undefined') {
    Vue.config.errorHandler = (err, vm, info) => {
      console.error('Vue錯誤:', err, info)
      errorHandler.unknownError(err)
    }
  }
  
  // Promise錯誤捕獲
  window.addEventListener('unhandledrejection', (event) => {
    console.error('Promise錯誤:', event.reason)
    errorHandler.unknownError(event.reason)
  })
}

6.2 請求重試機制

utils/retry.js

// 請求重試
export const retryRequest = async (requestFn, maxRetries = 3, delay = 1000) => {
  let retries = 0
  
  while (retries < maxRetries) {
    try {
      return await requestFn()
    } catch (error) {
      retries++
      
      // 如果是網絡錯誤,等待後重試
      if (error.errMsg && error.errMsg.includes('網絡錯誤')) {
        if (retries < maxRetries) {
          await new Promise(resolve => setTimeout(resolve, delay * retries))
          continue
        }
      }
      
      throw error
    }
  }
}

七、實戰案例:商品列表頁

7.1 頁面實現

pages/product/list.vue

<template>
  <view class="container">
    <!-- 搜索框 -->
    <view class="search-box">
      <uni-search-bar
        placeholder="搜索商品"
        v-model="searchKeyword"
        @confirm="handleSearch"
        @clear="handleClearSearch"
      />
    </view>
    
    <!-- 商品列表 -->
    <view class="product-list">
      <view
        v-for="item in productList"
        :key="item.id"
        class="product-item"
        @click="goToDetail(item.id)"
      >
        <image :src="item.image" class="product-image" mode="aspectFill" />
        <view class="product-info">
          <text class="product-name">{{ item.name }}</text>
          <text class="product-price">¥{{ item.price }}</text>
          <text class="product-sales">已售{{ item.sales }}件</text>
        </view>
      </view>
    </view>
    
    <!-- 加載更多 -->
    <view v-if="hasMore" class="load-more">
      <uni-load-more :status="loadingMore ? 'loading' : 'more'" />
    </view>
    
    <!-- 空狀態 -->
    <view v-if="!loading && productList.length === 0" class="empty">
      <image src="/static/empty.png" class="empty-image" />
      <text class="empty-text">暫無商品</text>
    </view>
  </view>
</template>

<script setup>
import { ref, onMounted } from 'vue'
import { getProductList } from '@/api/product'

const searchKeyword = ref('')
const productList = ref([])
const loading = ref(false)
const loadingMore = ref(false)
const hasMore = ref(true)
const currentPage = ref(1)
const pageSize = ref(10)

// 獲取商品列表
const fetchProductList = async (page = 1, isLoadMore = false) => {
  if (loading.value || loadingMore.value) return
  
  if (isLoadMore) {
    loadingMore.value = true
  } else {
    loading.value = true
  }
  
  try {
    const params = {
      page,
      pageSize: pageSize.value,
      keyword: searchKeyword.value
    }
    
    const res = await getProductList(params)
    const list = res.list || []
    
    if (page === 1) {
      productList.value = list
    } else {
      productList.value = [...productList.value, ...list]
    }
    
    // 判斷是否還有更多
    hasMore.value = list.length >= pageSize.value
    currentPage.value = page
  } catch (error) {
    console.error('獲取商品列表失敗:', error)
  } finally {
    loading.value = false
    loadingMore.value = false
  }
}

// 搜索
const handleSearch = () => {
  currentPage.value = 1
  fetchProductList(1)
}

// 清除搜索
const handleClearSearch = () => {
  searchKeyword.value = ''
  currentPage.value = 1
  fetchProductList(1)
}

// 加載更多
const loadMore = () => {
  if (!hasMore.value || loadingMore.value) return
  fetchProductList(currentPage.value + 1, true)
}

// 跳轉到詳情頁
const goToDetail = (id) => {
  uni.navigateTo({
    url: `/pages/product/detail?id=${id}`
  })
}

// 初始化
onMounted(() => {
  fetchProductList()
})
</script>

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

.search-box {
  margin-bottom: 20rpx;
}

.product-list {
  display: flex;
  flex-direction: column;
  gap: 20rpx;
}

.product-item {
  display: flex;
  background-color: #fff;
  border-radius: 16rpx;
  overflow: hidden;
  box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.1);
}

.product-image {
  width: 200rpx;
  height: 200rpx;
}

.product-info {
  flex: 1;
  padding: 20rpx;
  display: flex;
  flex-direction: column;
  justify-content: space-between;
}

.product-name {
  font-size: 28rpx;
  color: #333;
  font-weight: bold;
}

.product-price {
  font-size: 32rpx;
  color: #ff6b35;
  font-weight: bold;
}

.product-sales {
  font-size: 24rpx;
  color: #999;
}

.load-more {
  padding: 30rpx 0;
}

.empty {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  padding: 100rpx 0;
}

.empty-image {
  width: 200rpx;
  height: 200rpx;
  margin-bottom: 20rpx;
}

.empty-text {
  font-size: 28rpx;
  color: #999;
}
</style>

7.2 頁面配置

pages.json

{
  "pages": [
    {
      "path": "pages/product/list",
      "style": {
        "navigationBarTitleText": "商品列表",
        "enablePullDownRefresh": true
      }
    }
  ]
}

八、鴻蒙平台適配

8.1 鴻蒙特有配置

manifest.json

{
  "app-plus": {
    "harmony": {
      "network": {
        "cleartextTraffic": true // 允許HTTP請求
      }
    }
  }
}

8.2 鴻蒙網絡權限

manifest.json

{
  "app-plus": {
    "harmony": {
      "requestPermissions": [
        {
          "name": "ohos.permission.INTERNET"
        }
      ]
    }
  }
}

九、性能優化建議

9.1 請求優化

  1. 防抖節流:對頻繁觸發的請求進行防抖處理
  2. 請求合併:合併多個小請求為一個大請求
  3. 數據壓縮:開啓Gzip壓縮減少傳輸數據量
  4. CDN加速:靜態資源使用CDN加速

9.2 緩存優化

  1. 合理設置緩存時間:根據數據更新頻率設置緩存過期時間
  2. 緩存版本控制:數據更新時清除舊緩存
  3. 內存緩存:頻繁訪問的數據使用內存緩存

9.3 錯誤降級

  1. 網絡降級:網絡異常時使用本地緩存數據
  2. 接口降級:接口失敗時展示降級頁面
  3. 重試機制:網絡波動時自動重試

總結

通過本篇文章的學習,我們掌握了uniapp在鴻蒙平台下的網絡請求與數據交互的完整方案:

  1. 請求封裝:基礎請求方法的封裝和常用方法擴展
  2. 攔截器系統:請求和響應攔截器的統一處理
  3. API管理:模塊化的API組織方式
  4. 數據緩存:本地存儲和請求緩存策略
  5. 文件操作:文件上傳下載的完整實現
  6. 錯誤處理:全局錯誤捕獲和重試機制
  7. 實戰案例:商品列表頁的完整實現
  8. 鴻蒙適配:鴻蒙平台特有的網絡配置

關鍵要點

  • 使用攔截器統一處理token、loading、錯誤提示
  • 模塊化組織API接口,提高可維護性
  • 合理使用緩存策略,提升用户體驗
  • 完善的錯誤處理機制,保證應用穩定性

下一篇文章,我們將深入講解uniapp在鴻蒙平台下的性能優化與調試技巧,包括內存優化、渲染優化、打包優化等核心內容,幫助大家構建更高效的應用。