父组件调用:

<ImageCompressor ref="compressorRef" :showResult="false" :target-size="800 * 1024"
					@init-success="onInitSuccess" @init-fail="onInitFail" @compress-success="onCompressSuccess">
					<template #trigger="{ captureCompress, isInited }">
						<button class="sync-btn" size="mini" :disabled="!isInited" @click="captureCompress">
							{{ isInited ? '拍照' : '初始化中...' }}
						</button>
					</template>
				</ImageCompressor>
import ImageCompressor from '@/components/ImageCompressor/ImageCompressor.vue'
const compressorRef = ref(null)

	// 初始化成功
	const onInitSuccess = () => {
		console.log('组件初始化完成')
	}

	// 初始化失败
	const onInitFail = (err) => {
		console.error('组件初始化失败:', err)
	}

	// 压缩成功
	const onCompressSuccess = (result) => {
		console.log('base64结果:', result.base64);
		console.log('原临时文件路径:', result.tempFilePath);
		console.log('其他原有字段:', result.size, result.quality);
		if (result.base64Error) {
			console.error('base64转换失败:', result.base64Error);
		}
	}

子组件:开箱即用

<template>
  <view class="image-compressor">
    <canvas 
      canvas-id="compressCanvas"
      class="canvas-core"
      style="width: 400px; height: 400px; opacity: 0; position: fixed; top: 0; left: 0; z-index: -999;"
    ></canvas>

    <!-- 外部可自定义触发区域 -->
    <slot 
      name="trigger" 
      :captureCompress="captureAndCompress"
      :isInited="isInited"
    ></slot>

    <!-- 压缩结果展示(可选) -->
    <view v-if="showResult && compressResult" class="result-panel">
      <text class="result-title">压缩完成</text>
      <text class="result-desc">体积:{{ (compressResult.size / 1024).toFixed(2) }} KB</text>
      <text class="result-desc">质量:{{ compressResult.quality * 100 }}%</text>
      <image :src="compressResult.tempFilePath" class="result-img" mode="widthFix"></image>
    </view>
  </view>
</template>
<script setup>
import { ref, onMounted, nextTick, defineProps, defineEmits, defineExpose, getCurrentInstance } from 'vue'

// ========== 组件配置项 ==========
const props = defineProps({
  // 压缩目标体积(300KB)
  targetSize: {
    type: Number,
    default: 300 * 1024
  },
  // 是否显示压缩结果
  showResult: {
    type: Boolean,
    default: true
  },
  // 初始压缩质量
  initQuality: {
    type: Number,
    default: 0.8
  },
  // 最大缩放尺寸(rpx)
  maxSizeRpx: {
    type: Object,
    default: () => ({ width: 400, height: 400 })
  }
})

// ========== 组件事件 ==========
const emit = defineEmits([
  'init-success', 
  'init-fail',    
  'compress-success', 
  'compress-fail'     
])

// ========== 核心状态 ==========
const isInited = ref(false)       
const canvasCtx = ref(null)       
const compressResult = ref(null)  
const systemInfo = ref(null)      

// ========== 初始化逻辑 ==========
onMounted(async () => {
  try {
    systemInfo.value = uni.getSystemInfoSync()
    await nextTick()
    await forceInitCanvas()
  } catch (err) {
    emit('init-fail', '系统信息获取失败:' + err.message)
  }
})


const forceInitCanvas = async (retry = 0) => {
  const maxRetry = 5
  const retryDelay = 300

  try {
    const ctx = uni.createCanvasContext('compressCanvas', getCurrentInstance())
    if (!ctx) throw new Error('创建Canvas上下文失败')

    // 验证核心方法存在
    if (typeof ctx.draw !== 'function' || typeof ctx.drawImage !== 'function') {
      throw new Error('Canvas上下文方法缺失')
    }

    canvasCtx.value = ctx
    isInited.value = true
    emit('init-success')
    console.log('[ImageCompressor] 初始化完成')
    return
  } catch (err) {
    console.warn(`[ImageCompressor] 初始化失败(${retry+1}/${maxRetry}):`, err.message)
    
    if (retry < maxRetry) {
      await new Promise(resolve => setTimeout(resolve, retryDelay))
      await forceInitCanvas(retry + 1)
    } else {
      isInited.value = false
      emit('init-fail', 'Canvas初始化失败(已重试5次):' + err.message)
    }
  }
}

/**
 * rpx转px工具函数
 */
const rpxToPx = (rpx) => {
  return (rpx * systemInfo.value.screenWidth) / 750
}

/**
 * 将文件路径转换为base64
 */
const filePathToBase64 = (filePath) => {
  return new Promise((resolve, reject) => {
    uni.getFileSystemManager().readFile({
      filePath: filePath,
      encoding: 'base64',
      success: (res) => {
        resolve(`data:image/jpeg;base64,${res.data}`)
      },
      fail: (err) => {
        reject(`转换base64失败:${err.message}`)
      }
    })
  })
}

/**
 * 拍照并压缩
 */
const captureAndCompress = () => {
  if (!isInited.value || !canvasCtx.value) {
    const errMsg = '组件未完成初始化,请稍后重试'
    emit('compress-fail', errMsg)
    uni.showToast({ title: errMsg, icon: 'none' })
    return
  }

  uni.chooseImage({
    count: 1,
    sizeType: ['original'],
    sourceType: ['camera'],
    success: (res) => {
      uni.getImageInfo({
        src: res.tempFilePaths[0],
        success: (imgInfo) => {
          compressToTarget(res.tempFilePaths[0], imgInfo)
            .then(async (result) => {
              // 转换为base64
              try {
                const base64 = await filePathToBase64(result.tempFilePath)
                // 给结果对象添加base64字段
                result.base64 = base64
              } catch (e) {
                result.base64 = ''
                result.base64Error = e.message
              }
              compressResult.value = result
              emit('compress-success', result) // 返回包含base64的结果
              uni.showToast({ title: '压缩完成', icon: 'success' })
            })
            .catch((err) => {
              emit('compress-fail', err)
              uni.showToast({ title: '压缩失败', icon: 'none' })
            })
        },
        fail: (err) => emit('compress-fail', '获取图片信息失败:' + err.message)
      })
    },
    fail: (err) => emit('compress-fail', '拍照失败:' + err.message)
  })
}

/**
 * 压缩核心逻辑等比缩放
 */
const compressToTarget = (tempFilePath, imgInfo) => {
  return new Promise((resolve, reject) => {
    let quality = props.initQuality
    const minQuality = 0.1
    const qualityStep = 0.05
    // 转换最大尺寸为px
    const maxWidth = rpxToPx(props.maxSizeRpx.width)
    const maxHeight = rpxToPx(props.maxSizeRpx.height)
    const maxSize = props.targetSize
    let compressCount = 0

    // 计算等比缩放尺寸
    const calculateScaledSize = () => {
      let { width, height } = imgInfo
      
      // 如果图片本身小于最大尺寸,直接使用原图尺寸
      if (width <= maxWidth && height <= maxHeight) {
        return { width, height }
      }
      
      // 计算缩放比例
      const scaleRatio = Math.min(maxWidth / width, maxHeight / height)
      
      // 等比缩放
      return {
        width: width * scaleRatio,
        height: height * scaleRatio
      }
    }

    // 递归压缩
    const compress = () => {
      compressCount++
      try {
        // 获取等比缩放后的尺寸
        const { width: drawWidth, height: drawHeight } = calculateScaledSize()
        
        // 清空画布
        canvasCtx.value.clearRect(0, 0, drawWidth, drawHeight)
        
        // 绘制图片
        canvasCtx.value.drawImage(
          tempFilePath,
          0, 0,          // 绘制起点:左上角,无偏移
          drawWidth,     // 等比缩放后的宽度
          drawHeight     // 等比缩放后的高度
        )

        // 绘制完成后转换临时文件
        canvasCtx.value.draw(false, () => {
          uni.canvasToTempFilePath({
            canvasId: 'compressCanvas',
            x: 0,
            y: 0,
            width: drawWidth,           // 使用实际绘制宽度
            height: drawHeight,         // 使用实际绘制高度
            destWidth: drawWidth,       // 输出宽度 = 绘制宽度
            destHeight: drawHeight,     // 输出高度 = 绘制高度
            quality: quality,
            fileType: 'jpg',
            success: (res) => {
              // 精准校验体积(300KB以内)
              uni.getFileInfo({
                filePath: res.tempFilePath,
                success: (fileInfo) => {
                  const size = fileInfo.size
                  const sizeKB = (size / 1024).toFixed(2)

                  // 达标判断
                  if (size <= maxSize || quality <= minQuality) {
                    resolve({
                      tempFilePath: res.tempFilePath,
                      size,
                      quality,
                      width: drawWidth,          // 实际输出宽度
                      height: drawHeight,        // 实际输出高度
                      originWidth: imgInfo.width, // 原图宽度
                      originHeight: imgInfo.height, // 原图高度
                      compressCount,
                      finalSizeKB: sizeKB
                    })
                  } else {
                    // 降低质量继续压缩
                    quality -= qualityStep
                    quality = Math.max(quality, minQuality)
                    setTimeout(compress, 100)
                  }
                },
                fail: (err) => reject(`获取文件体积失败:${err.message}`)
              })
            },
            fail: (err) => reject(`Canvas转临时文件失败:${err.message}`)
          }, getCurrentInstance()) // 传入实例确保路径正确
        })
      } catch (e) {
        reject(`压缩第${compressCount}次失败:${e.message}`)
      }
    }

    compress()
  })
}

defineExpose({
  captureAndCompress,
  forceInitCanvas,
  isInited
})
</script>

css:

<style scoped>
.image-compressor {
  width: 100%;
  box-sizing: border-box;
}

.canvas-core {
  width: 1000px !important;  
  height: 1000px !important;
  opacity: 0 !important;
  position: fixed !important;
  top: 0 !important;
  left: 0 !important;
  z-index: -999 !important;
  pointer-events: none !important;
  display: block !important;
  visibility: hidden !important;
  background: transparent !important;
}

.result-panel {
  width: 90%;
  margin: 20rpx auto;
  padding: 20rpx;
  background: #fff;
  border-radius: 10rpx;
  box-shadow: 0 2rpx 10rpx rgba(0,0,0,0.1);
}

.result-title {
  font-size: 32rpx;
  font-weight: bold;
  color: #333;
  margin-bottom: 10rpx;
  display: block;
}

.result-desc {
  font-size: 24rpx;
  color: #666;
  line-height: 1.5;
  display: block;
  margin-bottom: 5rpx;
}

.result-img {
  width: 100%;               
  max-width: 400rpx;         
  max-height: auto;              
  border-radius: 8rpx;
  margin-top: 15rpx;
  background: #f5f5f5;
}
</style>
Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐