# 图片上传

# 预计目标


image.pngimage.pngimage.png

# 需要的功能


图片大小限制

event.preventDefault()
const file = event.target.files[0]
const fileName = file.name
const fileSize = file.size
const fileExt = fileName.split('.').pop()
const accept = '.jpg,.jpeg,.png'
const maxSize = 15 * 1024 * 1024

if (accept && accept.indexOf(fileExt) === -1) {
  return Toast.show('文件类型不支持')
}

if (fileSize > maxSize) {
  return Toast.show('文件体积超出上传限制')
}


图片上传

try {
  Toast.loading()
  const data = await courseUploader(newFile)
  const fileUrl = `https://${data.Location}`
  this.setState({ imageList: [fileUrl] }, Toast.hide)
} catch (error) {
  Toast.show('上传失败')
} finally {
  this.isLoading = false
}


图片压缩

// 图片压缩
export const imageCompress = (file) => {
  return new Promise((resolve, reject) => {
    const reader = new FileReader()
    const image = new Image()

    reader.readAsDataURL(file)
    reader.onload = function(e) {
      image.src = e.target.result
      image.onerror = () => {
        return reject('图像加载失败')
      }
      image.onload = function() {
        const canvas = document.createElement('canvas')
        const context = canvas.getContext('2d')

        const originWidth = this.width
        const originHeight = this.height
        const maxWidth = 1000
        const maxHeight = 1000
        let targetWidth = originWidth
        let targetHeight = originHeight

        if (originWidth > maxWidth || originHeight > maxHeight) {
          if (originWidth / originHeight > maxWidth / maxHeight) {
            targetWidth = maxWidth
            targetHeight = Math.round(maxWidth * (originHeight / originWidth))
          } else {
            targetHeight = maxHeight
            targetWidth = Math.round(maxHeight * (originWidth / originHeight))
          }
        }

        canvas.width = targetWidth
        canvas.height = targetHeight

        context.clearRect(0, 0, targetWidth, targetHeight)
        context.drawImage(image, 0, 0, targetWidth, targetHeight)
        const dataUrl = canvas.toDataURL('image/jpeg', 0.92)

        return resolve(dataURLtoFile(dataUrl))
      }
    }
  })
}


图片base64转file

// 图片base64转file
export const dataURLtoFile = (dataurl, filename = 'file') => {
  let arr = dataurl.split(',')
  let mime = arr[0].match(/:(.*?);/)[1]
  let suffix = mime.split('/')[1]
  let bstr = atob(arr[1])
  let n = bstr.length
  let u8arr = new Uint8Array(n)
  while (n--) {
    u8arr[n] = bstr.charCodeAt(n)
  }
  return new File([u8arr], `${filename}.${suffix}`, {
    type: mime
  })
}


判断移动端图片是 竖图还是横图
Exif-js github文档 (opens new window)

orientation值 旋转角度
1
3 180°
6 顺时针90°
8 逆时针90°
import EXIF from 'exif-js'
// 只在移动端生效
EXIF.getData(file, function() {
  const orient = EXIF.getTag(this, 'Orientation')
  if (orient === 6) {
    // 竖图
    // 做向右旋转90度度处理
  } else {
    // 不做特殊处理
  }
})


canvas图片旋转

const canvas = document.createElement('canvas')
const context = canvas.getContext('2d')

...
context.clearRect(0, 0, targetWidth, targetHeight) // 清空内容区
...

canvas.width = targetHeight // targetHeight 当前图片高度 把canvas重绘
canvas.height = targetWidth // targetWidth 当前图片宽度 把canvas重绘
context.translate(targetHeight / 2, targetWidth / 2) // 设置当前图的中心区域
context.rotate(90 * Math.PI / 180) // 向右旋转90度
context.drawImage(image, -targetWidth / 2, -targetHeight / 2, targetWidth, targetHeight)

# 完整代码


index.js

import { PureComponent, Fragment, createRef } from 'react'
import DocumentTitle from 'react-document-title'
import Textarea from 'react-textarea-autosize'
import router from 'umi/router'
import styled from 'styled-components'
import classnames from 'classnames'
import { courseUploader } from '@@/utils/cos'
import { imageCompress } from '@@/utils/imageCrop'
import Toast from '@@/components/Toast'
import withStyled from '@@/styles/withStyled'
import notrService from '@@/services/note'
import wx from 'weixin-js-sdk'

import iconCloseGray from '@@/assets/icons/icon-close-gray.png'
import iconImg from '@@/assets/icons/icon-img.png'

const NoteController = withStyled(styled.div`
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  z-index: 100;
  margin: 0 auto;
  padding: 9px 24px;
  display: flex;
  max-width: 480PX;
  align-items: center;
  justify-content: flex-end;
  width: 100%;
  background-color: #F9FAFC;
`)

const NoteChooseImage = withStyled(styled.div`
  margin: 0 24px 0 0;
  width: 24px;
  height: 24px;
  background-size: 100%;
  background-repeat: no-repeat;
  background-image: url(${iconImg});
  &.notouch {
    filter: opacity(0.5);
  }
`)

const InputForImage = withStyled(styled.input`
  display: none;
`)

const NotePublish = withStyled(styled.div`
  padding: 9px 20px;
  font-size: 14px;
  color: #222222;
  background-color: ${(props) => props.theme.primaryColor};
  border-radius: 16px;
`)

const ImageArea = withStyled(styled.div`
  padding: 0 24px 24px;
  display: flex;
  flex-wrap: wrap;
  width: 100%;
  height: auto;
`)

const ImageDisplay = withStyled(styled.div`
  position: relative;
  margin: 0 0 10px;
  width: 96px;
  height: 96px;
  background-position: center;
  background-size: 100%;
  background-repeat: no-repeat;
  background-image: url(${(props) => props.image});
  border-radius: 8px;
  &:nth-of-type(3n-1) {
    margin: 0 auto 10px;
  }
`)

const ImageDelete = withStyled(styled.div`
  position: absolute;
  top: 4PX;
  right: 4PX;
  width: 16PX;
  height: 16PX;
  background-size: 100%;
  background-repeat: no-repeat;
  background-image: url(${iconCloseGray});
  border-radius: 50%;
`)

const textareaStyle = {
  marginTop: 60, paddingLeft: 24, paddingRight: 24, paddingBottom: 24, width: '100%', fontSize: 16, color: '#475669', lineHeight: 2, border: 0, resize: 'none'
}

class CreatePage extends PureComponent {
  constructor(props) {
    super(props)
    const { match: { params: { uniqueId } } } = this.props
    this.uniqueId = uniqueId
    this.inputNode = createRef()
    this.isLoading = false
    this.state = {
      text: '',
      notouch: false,
      imageList: []
    }
  }

  action = {
    handleImageClick: () => {
      const { imageList } = this.state

      if (imageList.length !== 0) {
        return Toast.show('只能上传一张图片')
      }
      this.inputNode.current.click()
    },
    handleImageChange: async (event) => {
      event.preventDefault()
      if (this.isLoading) {
        return false
      }

      const file = event.target.files[0]
      const fileName = file.name
      const fileSize = file.size
      const fileExt = fileName.split('.').pop()
      const accept = '.jpg,.jpeg,.png'
      const maxSize = 15 * 1024 * 1024

      if (accept && accept.indexOf(fileExt) === -1) {
        return Toast.show('文件类型不支持')
      }

      if (fileSize > maxSize) {
        return Toast.show('文件体积超出上传限制')
      }

      this.isLoading = true
      const newFile = await imageCompress(file)
      try {
        Toast.loading()
        const data = await courseUploader(newFile)
        const fileUrl = `https://${data.Location}`
        this.setState({ imageList: [fileUrl], notouch: true }, Toast.hide)
      } catch (error) {
        Toast.show('上传失败')
      } finally {
        this.isLoading = false
      }
    },
    handleDelete: (index, event) => {
      event.stopPropagation()
      const imageList = [...this.state.imageList]
      imageList.splice(index, 1)
      this.setState({ imageList, notouch: false })
    },
    handleNoteChange: (e) => {
      this.setState({ text: e.target.value })
    },
    handleFetch: async () => {
      const len = this.state.text.length
      if (len >= 10 && len <= 1000) {
        try {
          Toast.loading()
          await notrService.writeNote(this.uniqueId, {
            noteContent: this.state.text,
            imageUrl: this.state.imageList
          })
          setTimeout(() => {
            Toast.show('笔记发表成功')
            const pathname = `/note/${this.uniqueId}`
            router.replace({
              pathname,
              query: {
                tabKey: 'mine'
              }
            })
          }, 500)
        } catch (error) {
          setTimeout(() => { Toast.show(error.message) }, 500)
        }
      } else {
        Toast.show('笔记内容字数错误,字数在10-1000内')
      }
    },
    previewImage: (event) => {
      event.stopPropagation()
      wx.previewImage({
        current: this.state.imageList[0], // 当前显示图片的http链接
        urls: this.state.imageList // 需要预览的图片http链接列表
      })
    }
  }

  render() {
    const { text, imageList, notouch } = this.state
    return (
      <DocumentTitle title='写笔记'>
        <Fragment>
          <NoteController>
            <InputForImage ref={this.inputNode} type='file' accept='image/*' onClick={(e) => { e.target.value = '' }} onChange={this.action.handleImageChange} />
            <NoteChooseImage className={classnames({ notouch })} onClick={this.action.handleImageClick} />
            <NotePublish onClick={this.action.handleFetch}>发表</NotePublish>
          </NoteController>
          <Textarea minRows={5} placeholder='记录学习后的珍贵收获~' value={text} onChange={this.action.handleNoteChange} style={textareaStyle} />
          {imageList.length !== 0 && (
            <ImageArea>
              {imageList.map((item, index) => (
                <ImageDisplay image={item} key={index} onClick={this.action.previewImage}>
                  <ImageDelete onClick={(event) => this.action.handleDelete(index, event)} />
                </ImageDisplay>
              ))}
            </ImageArea>
          )}
        </Fragment>
      </DocumentTitle>
    )
  }
}

export default CreatePage


imageCrop.js

import { getClientWidth } from '@@/utils/dom'
import EXIF from 'exif-js'

const clientWidth = getClientWidth()
const maxImageWidth = clientWidth >= 480 ? 480 : clientWidth

// 浏览器是否支持 webp 图片格式
export const isWebpSupport = () => {
  const dataUrl = document.createElement('canvas').toDataURL('image/webp')
  return dataUrl.indexOf('data:image/webp') === 0
}

// 根据屏幕分辨率获取长度
const getImageLength = (length) => {
  const imageLength = Math.floor(Number(length) || 0) || maxImageWidth
  return window.devicePixelRatio * imageLength
}

// 腾讯数据万象图片链接拼接
// 参考文档 https://cloud.tencent.com/document/product/460/6929
export const fasterImageUrl = (imageUrl, options) => {
  if (!imageUrl || !String(imageUrl).startsWith('http')) {
    return imageUrl
  }

  const { width } = Object.assign({}, options)
  const imageWidth = getImageLength(width)

  const formatSuffix = isWebpSupport() ? '/format/webp' : ''
  const widthSuffix = `/w/${imageWidth}`

  return `${imageUrl}?imageView2/2${formatSuffix}${widthSuffix}`
}

// 获取分享链接小图标
export const getShareImageUrl = (imageUrl) => {
  if (!imageUrl || !String(imageUrl).startsWith('http')) {
    return imageUrl
  }

  return `${imageUrl}?imageView2/2/w/200`
}

// 图片base64转file
export const dataURLtoFile = (dataurl, filename = 'file') => {
  let arr = dataurl.split(',')
  let mime = arr[0].match(/:(.*?);/)[1]
  let suffix = mime.split('/')[1]
  let bstr = atob(arr[1])
  let n = bstr.length
  let u8arr = new Uint8Array(n)
  while (n--) {
    u8arr[n] = bstr.charCodeAt(n)
  }
  return new File([u8arr], `${filename}.${suffix}`, {
    type: mime
  })
}

// 图片压缩 并 判断是否需要旋转
export const imageCompress = (file) => {
  return new Promise((resolve, reject) => {
    const reader = new FileReader()
    const image = new Image()

    reader.readAsDataURL(file)
    reader.onload = function(e) {
      image.src = e.target.result
      image.onerror = () => {
        return reject('图像加载失败')
      }
      image.onload = function() {
        const canvas = document.createElement('canvas')
        const context = canvas.getContext('2d')

        const originWidth = this.width
        const originHeight = this.height
        const maxWidth = 1000
        const maxHeight = 1000
        let targetWidth = originWidth
        let targetHeight = originHeight

        if (originWidth > maxWidth || originHeight > maxHeight) {
          if (originWidth / originHeight > maxWidth / maxHeight) {
            targetWidth = maxWidth
            targetHeight = Math.round(maxWidth * (originHeight / originWidth))
          } else {
            targetHeight = maxHeight
            targetWidth = Math.round(maxHeight * (originWidth / originHeight))
          }
        }

        context.clearRect(0, 0, targetWidth, targetHeight)
        // 图片翻转问题解决方案
        // 在移动端,手机拍照后图片会左转90度,下面的函数恢复旋转问题
        // orient = 6时候,是图片竖着拍的
        EXIF.getData(file, function() {
          const orient = EXIF.getTag(this, 'Orientation')
          if (orient === 6) {
            canvas.width = targetHeight
            canvas.height = targetWidth
            context.translate(targetHeight / 2, targetWidth / 2)
            context.rotate(90 * Math.PI / 180)
            context.drawImage(image, -targetWidth / 2, -targetHeight / 2, targetWidth, targetHeight)
          } else {
            canvas.width = targetWidth
            canvas.height = targetHeight
            context.drawImage(image, 0, 0, targetWidth, targetHeight)
          }
          const dataUrl = canvas.toDataURL('image/jpeg', 0.8)
          return resolve(dataURLtoFile(dataUrl))
        })
      }
    }
  })
}


腾讯云存储照片  cos.js+utils.js
cos.js

import COS from 'cos-js-sdk-v5'
import UUID from 'uuid/v4'
import utilsService from '@@/services/utils'

// 创建认证实例
const courseInstance = new COS({
  getAuthorization: async (options, callback) => {
    const uploadKey = await utilsService.getUploadKey(options)
    callback(uploadKey)
  }
})

// 获取分片上传实例
const getUploader = (instance, region, bucket) => {
  return (file) => {
    const fileName = file.name
    const fileExt = fileName.split('.').pop()
    const fileHash = UUID()
    const fileKey = fileName === fileExt ? `client/${fileHash}` : `client/${fileHash}.${fileExt}`

    return new Promise((resolve, reject) => {
      instance.sliceUploadFile({
        Region: region,
        Bucket: bucket,
        Key: fileKey,
        Body: file
      }, (error, data) => {
        if (error) {
          return reject(error)
        }
        return resolve(data)
      })
    })
  }
}

export const courseUploader = getUploader(courseInstance, 'ap-beijing', 'course-1252068037')


utils.js

import { courseRequest as Axios } from '@@/utils/axios'

class UtilsService {
  async getUploadKey() {
    const { data: { credentials, expiredTime } } = await Axios.get('/util/get-temporary-key', {
      params: {
        durationSeconds: 60 * 60
      }
    })

    const { tmpSecretId, tmpSecretKey, sessionToken } = credentials
    return {
      TmpSecretId: tmpSecretId,
      TmpSecretKey: tmpSecretKey,
      XCosSecurityToken: sessionToken,
      ExpiredTime: expiredTime
    }
  }
}

export default new UtilsService()

# 莫名的坑

  1. 在安卓上无法上传图片的问题,不能触发onChange,解决办法 修改**accept**为: accept='image/*'

详细解释:如果在input上面添加了_accept字段,并且设置了一些值(png,jpeg,jpg),会发现,有的安卓是触发不了onChange,因为在accept的时候发生了拦截,使input的值未发生改变,所以建议使用_accept='image/*',在onChange里面写格式判断。

  1. 自动打开相机,而没有让用户选择相机或相册,解决办法 去掉  capture****="camera"
  2. 手机拍照,或者手机里面度竖图,发生左转90度的变化,就需要单独判断是不是竖图,然后向右旋转