1.背景需求
最近在做一个显示图片后,通过按钮点击下载的图片的效果。网上有很多参考的例子,大概思路就是创建一个img标签,然后通过流方式输出并通过到a链接模拟打开一个新页签,触发浏览器下载图片。
大致如下:
async function downloadByNewImage(url) {
const img = new Image();
img.src = url;
img.onload = async () => {
const canvas = document.createElement('canvas');
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);
canvas.toBlob(async (blob) => {
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = blobUrl;
a.download = 'image.jpg';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(blobUrl);
}, 'image/jpeg');
};
img.onerror = (error) => {
alert(error)
console.error('图片加载失败:', error);
};
}
2.问题
但在实际开发中却发现,图片使用img标签可以正常展示,但是使用上面的代码却无法下载,后台提示
Uncaught (in promise) SecurityError: Failed to execute 'toBlob' on 'HTMLCanvasElement': Tainted canvases may not be exported.
报错在canvas.toBlob
这里,从错误看出是触发了安全限制问题,
3.方案
方案1 new image + crossOrigin
上网查有些说要加上 img.crossOrigin = 'Anonymous';
实际运行,却连img.onload都没有进入,触发的是img.onerror事件
从打印的对象看貌似无法正常获取图片。
方案2 使用已有img
心想既然img标签都显示了,我直接拿现有已经load好的img的data 不就好了吗? 把下载代码改成如下,直接通过document.getElementById 获取已经加载好的元素。
async function downloadByImg(id) {
const img = document.getElementById(id)
const fileUrl = img.src
alert(fileUrl)
const canvas = document.createElement('canvas')
canvas.width = img.naturalWidth
canvas.height = img.naturalHeight
const ctx = canvas.getContext('2d')
ctx.drawImage(img, 0, 0) // 由于跨域限制,这里拿不到img的data数据给canvas
canvas.toBlob(async (blob) => {
try {
const blobUrl = URL.createObjectURL(blob) // 这里由于跨域 blob 信息为空
const a = document.createElement('a')
a.href = blobUrl
a.download = 'image.jpg'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(blobUrl)
} catch (err) {
alert(err)
console.error('下载失败:', err);
}
}, 'image/jpeg')
}
仍然是在canvas.toBlob
提示安全异常。
方案3 fetch
不使用img,使用fetch去获取图片资源再下载到本地。
function downloadByFetch(id) {
const img = document.getElementById(id)
const fileUrl = img.src
alert(fileUrl)
fetch(fileUrl)
.then(res => res.blob())
.then(blob => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'image.jpg';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
})
.catch(err => {
alert(err)
console.error('下载失败:', err);
});
}
这里可以看到 当执行fetch时候,抛出了异常,不过提示不是很具体 TypeError: Failed to fetch
4.重新梳理问题
这里我们先整理一下逻辑。
- 为什么图片用img可以正常显示?
- 为什么下载的时候就不行,并且提示安全问题?
- 如何验证是否跨域?
问题1:为什么图片用img可以正常显示?
检查url
对比url
- 本地启动使用 http://localhost:5173
- 请求的图片地址为 d2ve8bm7uu9b66.cloudfront.net/xxxxxxx.png
由于同源策略,不同的源 协议+域名+端口,http://localhost:5173 和 d2ve8bm7uu9b66.cloudfront.net:80 两种确实存在跨域的问题。
那为什么使用img还是能正常,因为img是很早期的标签,使用场景也是公开的、无需敏感数据的资源(如新闻配图、CDN 托管的图片等)。出于实用性和历史兼容考虑,所以一般浏览器是放行。
那可以让他变的严谨吗?
可以,就是直接在img 标签上加上 crossOrigin="anonymous",这时候这个img标签就会严格按同源策略来玩。
<div>
<img width="100" height="100" id="img1"
src="https://d2ve8bm7uu9b66.cloudfront.net/xxxx/uploads/store_img/f265fd523eb94679a8038a7cc9e2aea0.png"
alt="" />
图1
</div>
<div>
<img width="100" height="100" crossOrigin="anonymous"
src="https://d2ve8bm7uu9b66.cloudfront.net/xxxx/uploads/store_img/f265fd523eb94679a8038a7cc9e2aea0.png"
alt="" />
图2 set crossOrigin="anonymous"
</div>
我们看到图1不设置正常显示,图二由于设置了crossOrigin="anonymous"
就不会正常显示了。
问题2.为什么下载的时候就不行,并且提示安全问题?
下载的时候,由于执行的是js代码,而不是html自己内部的标签,所以浏览器出于安全考虑,都走严格的同源策略。
这里可以参考MDN的描述:
developer.mozilla.org/zh-CN/docs/…
为什么已经加载资源,浏览器也能知道是跨域?
浏览器确实不是吃素的,你想到的他也一定想的比你全面。所有的资源都应该维护了源信息,每次操作的时候会先判断同源策略,不满足就直接抛异常或者返回空对象。 这也就是为什么上面已经加载的img标签内容,即使都缓存到浏览器了,但是操作资源仍然报错或者为空的原因。
问题3.如何验证是否跨域
浏览器判断资源是否跨域的核心属性,是响应头的 access-control-allow-origin
的值 对比当前访问的url 是否是同源。
如:
access-control-allow-origin : * # 则所有图片都放行
access-control-allow-origin : test.cloudfront.net # 只有当前域名和他一致才放行
通过浏览器开发者工具
通过chrome开发者模式network->headers->response headers 可以查看access-control-allow-origin信息
CORS放行情况
这里本地启动了nginx 模拟了一个域名dev-backend2.xxx.com,
- 我们先看图2,由于设置了
crossOrigin="anonymous"
,服务端由于提前配置好了对dev-backend2.xxx.com 这里域名的CORS策略 , - 所以返回头包含了access-control-allow-origin: dev-backend2.xxx.com
- 由于与当前域名一致,所以图片就能正常加载。
图1由于没有设置crossOrigin="anonymous"
,响应头也就不会返回access-control-allow-origin,但是由于同源策略对于img标签宽容的原因也能正常加载。
CORS不放行情况
如果使用vite自带启动的服务如: http://localhost:5173
可以看到CORS提示
这里没有返回access-control-allow-origin,所以无法匹配当前http://localhost:5173 源信息。
可以看到具体CORS提示
使用curl
不带域名请求(等价于img标签请求)
请求
curl -I "https://test-hg-team3.s3.us-west-2.amazonaws.com/xxxx/uploads/national-flag/20241209/a97ff3108237a6ecca8740c8fdca0011deafffda.png"
响应 (这时不会返回access-control-allow-origin)
HTTP/2 200
content-type: image/png
content-length: 39118
date: Tue, 20 May 2025 08:22:33 GMT
last-modified: Mon, 20 Jan 2025 10:50:58 GMT
etag: "c11c0f0d9f88c49ef54f37d2eaba2715"
x-amz-server-side-encryption: AES256
x-amz-version-id: nn9NQHyVAB4tOO8z5TCjhhiB4U9Em0vf
accept-ranges: bytes
server: AmazonS3
x-cache: Miss from cloudfront
via: 1.1 7213dea1fc38301ac719160ac4d5ab22.cloudfront.net (CloudFront)
x-amz-cf-pop: SFO53-P3
x-amz-cf-id: rgNljbOq-hz8uxH9ZfxyNnO3KLMPBkOq4OlAfZ-65TSlOpQuvS3nEA==
vary: Origin
带域名(等价于img标签+crossOrigin="anonymous")
请求
curl -I -H "Origin: test-backend.xxxx.com" "https://d2ve8bm7uu9b66.cloudfront.net/xxxx/uploads/store_img/f265fd523eb94679a8038a7cc9e2aea0.png"
响应 (返回了access-control-allow-origin)
HTTP/2 200
content-type: image/png
content-length: 39118
date: Tue, 20 May 2025 08:22:33 GMT
last-modified: Mon, 20 Jan 2025 10:50:58 GMT
etag: "c11c0f0d9f88c49ef54f37d2eaba2715"
x-amz-server-side-encryption: AES256
x-amz-version-id: nn9NQHyVAB4tOO8z5TCjhhiB4U9Em0vf
accept-ranges: bytes
server: AmazonS3
x-cache: Hit from cloudfront
via: 1.1 81a496fdef0fdb965948725f69ee8f48.cloudfront.net (CloudFront)
x-amz-cf-pop: SFO53-P3
x-amz-cf-id: SnXnq1yaZKIOqbqjVrbtYSx6KwcwiXFT3SBDUOGMMhouE8-oc3JddQ==
age: 140
access-control-allow-origin: test-backend.xxxx.com
vary: Origin
5.如何解决
方案1: 设为同源
其实最好的方案就是遵循同源策略,即让所有资源跟你现在访问的域名保持一致。
- 使用nginx把所有资源转到跟你域名一致。
- 图片下载的时候,通过同源的后台接口转发图片
方案2: 服务端配置 CORS放行
- 通过nginx对合法的域名设置access-control-allow-origin 返回 * 或指定的域名
- 通过第三方域名转发图片资源,如注册一个配置放行的域名www.test.com, 然后把当前的url改为这个新域名访问
注意如果还是使用 new Image()方式请求,还是得设置img.crossOrigin = 'Anonymous' ,不然浏览器不会尝试同源判断,直接返回空数据
其他方案:
- 通过第三方存储桶上传配置,在上传资源的时候指定access-control-allow-origin 的信息(待验证)
- 就是使用a标签
target="_blank"
打开图片在新页签,然后让用户自己另存为哈。(如果用户能接受的话)
6.总结
关于线上的跨域问题,前端只能配合。重点还是后端,要么转发到统一域名下,要么开启CORS。
遗留问题(薛定谔的猫)
在实际线上项目中,运维明明配置好了CORS,但是首次访问图片正常显示,点击下载图片时还是报错。此时打开开发者的控制台看可以看到输出上面类似的跨域错误,但神奇的是再点击一次下载又可以了,并且返回头也是包含access-control-allow-origin 放行信息。后面即使关闭开发者界面,依然生效,如同薛定谔的猫,观察就变化了。 只能通过服务端全链路日志跟踪才好排查,待续....