yaofly2012 / note

Personal blog
https://github.com/yaofly2012/note/issues
44 stars 5 forks source link

上传截屏的图片 #248

Open yaofly2012 opened 2 years ago

yaofly2012 commented 2 years ago

一、背景

项目里有个上传图片的功能,但用户经常上传截屏图片,此时用户的操作流程是截屏 -> 保存图片-> 选择图片并上传。为了用户体验也为了实现产品吹的牛皮,需要将3步改成2步,即截屏 ->上传

技术调研

截屏操作获取的图片存储在系统剪切板,所以得利用JS读取剪切板内容。研究下Clipboard API的使用。

测试环境

  1. Chrome V87
  2. FF V95

二、Navigator.clipboard

利用全局属性Navigator.clipboard可以利用JS操作系统剪切板。

使用限制

JS方式读写系统剪切板涉及用户隐私和数据安全问题,这个API使用场景有使用限制:

  1. 安全上下文(HTTPS) 必须是HTTPS或者localhost域名,否则不会暴露Navigator.clipboard属性: image

  2. 用户授权 image

  3. 文档要处于Focused状态,否则报错: image

  4. 文档如果以iframe方式被嵌入其他跨域文档中,则也不能执行

读数据Clipboard.read()

navigator.clipboard.read().then((data) => {
  for (let i = 0; i < data.length; i++) {
    if (!data[i].types.includes("image/png")) {
      alert("Clipboard contains non-image data. Unable to access it.");
    } else {
      data[i].getType("image/png").then((blob) => {
        imgElem.src = URL.createObjectURL(blob);
      });
    }
  }
});

三、粘贴(paste)事件

监听用户触发的粘贴操作。此时粘贴操作是用户主动触发的,所以排除了一些安全问题。

使用限制对比

使用限制 Navigator.clipboard API 粘贴(paste)事件
安全上下文(HTTPS) Y N
用户授权 Y N
文档要处于Focused状态 Y Y
弃用跨域 iframe 中的权限 Y N

ClipboardEvent.clipboardData属性

利用ClipboardEvent.clipboardData属性可以获取系统剪切板的数据。 总结下ClipboardEvent.clipboardData对象的几个比较重要的属性和方法: 属性/方法名称 描述
types属性 剪切板数据的格式列表(一份数据可以存在多个展示格式),比如text/plain, text/html, Files
items属性 剪切板里的数据列表,和types属性的值一一对应
getData方法 获取剪切板数据,但不能获取文件内容(得使用files属性)
files属性 文件内容,比如截图操作

实战

获取文本内容

window.addEventListener('paste', function(e) {
  const clipboardData = e.clipboardData || window.clipboardData;
  // 或者clipboardData.getData('text');
  const data = clipboardData.getData('text/plain');
  console.log(data)          
})

获取富文本内容

即copy带有样式的文本。

window.addEventListener('paste', function(e) {
  const clipboardData = e.clipboardData || window.clipboardData;
  const data = clipboardData.getData('text/html');
  console.log(data)          
})

获取并展示粘贴板数据

根据types属性的值读取剪切板数据。

window.addEventListener('paste', function(e) {
  const clipboardData = e.clipboardData || window.clipboardData;
  // 数据的格式列表(一份数据可以存在多个展示格式)
  const { types } = clipboardData;
  types.forEach(type => {
    console.log(`type=${type}`)
    const data = clipboardData.getData(type);
    // 不携带样式的文本(包含回车,换行等不可见字符)
    if('text/plain' === type) {
      document.getElementById('js-text').innerHTML = data;
      return;
    }
    // 富文本格式
    if('text/html' === type) {
      document.getElementById('js-html').innerHTML = data;
      return;
    }
  })

  // 文件
  if(clipboardData.files.length) {
    clipboardData.files.forEach(file => {
      if(!/^image/.test(file.type)) {
        return;
      }
      const reader = new FileReader();
      reader.addEventListener("load", function () {              
        document.getElementById('js-img-preview').src = reader.result;
      }, false);
      reader.readAsDataURL(file);
    })            
  }
})

兼容性

目前测试Chrome(V87),FF(V95)可以正常使用。详情见兼容性

参考

  1. MDN Element: paste event
  2. MDN Navigator.clipboard
  3. DataTransfer
  4. w3c Clipboard Events
  5. stackoverflow JavaScript get clipboard data on paste event (Cross browser)
yaofly2012 commented 2 years ago

实战:上传截屏的图片

一、监听paste事件

绑定在window对象上

  // Ctrl+V
  useEffect(() => {
    if(disabled) {
      return;
    }
    domHelpers.addEventListener(window, 'paste', handlePaste);

    function handlePaste(e) {
      const { files } = e.clipboardData || window.clipboardData || {}; 
      if(files && files.length) {
        // 执行上传文件操作
      }
    }

    return () => {
      domHelpers.removeEventListener(window, 'paste', handlePaste);
    }
  }, [disabled])

优点:

  1. 用户操作简单,直接Ctrl + V即可;
  2. 浏览器的安全限制少。

缺点:

  1. 粘贴事件绑定在window对象上的,所以只适用页面里只有一个上传文件入口的场景。

绑定在textarea/input/或者contenteditable="true"`元素上

可以处理同时存在多个上传文件的场景,但是:

  1. 交互有点繁琐(截图 -> 点击绑定元素 -> Ctrl + V);
  2. 绑定元素展示(本质是个输入框)不满足UI要求。

二、利用Navigator.clipboard API

主要解决命令式的(比如点击“粘贴”按钮)触发粘贴操作。

async function handlePaste() {    
    try {
      const items = await navigator.clipboard.read();
      const blobs= await Promise.all(items
          .filter(item => item.types && /^image/.test(item.types[0]))
          .map(item => item.getType(item.types[0])))
      // Blob转成File
      const files = blobs.map(blob => {
        const file = new File(
          [blob], 
          blob.type.replaceAll('/', '.'),
          {
            type: blob.type,
            lastModified: Date.now()
          }
        )        
        return file;
      })

      if(files.length) {
        // 上传文件操作
      }
    } catch(e) {
      console.error(e);
    }
  }

为啥要把Blob转成File?

问题

我们项目里如果不把Blob转成File,则接口是不能正确处理图片(具体原因是接口通过filename获取文件扩展名)。

对比两种case的差异

如果不转化,上传文件时数据内容是:

------WebKitFormBoundaryWKBAIMO2nAdjCFX0
Content-Disposition: form-data; name="file"; filename="blob"
Content-Type: image/png

xxxxx...
------WebKitFormBoundaryWKBAIMO2nAdjCFX0--

转化后,上传文件时数据内容是:

------WebKitFormBoundaryOWXckuaZauCoJffm
Content-Disposition: form-data; name="file"; filename="image.png"
Content-Type: image/png

xxxxxx...
------WebKitFormBoundaryOWXckuaZauCoJffm--

差异点就是Content-Dispositionfilename取值,即不转化文件名统一是blob,如果转化则可以自定义个文件名。

Blob PK File

FileBlob 的子类。就多几个属性:

  1. File.prototype.lastModified
  2. File.prototype.name

你是否也需要转化?

首先要看实际项目需要:

  1. 是否需要给截图文件指定特殊的名称?
  2. 后端接口是否依赖Content-Dispositionfilename的值(比如通过filename获取文件扩展名)?

优缺点

有点

  1. 可以处理页面中存在多个上传文件的场景。

缺点

  1. 浏览器的限制