实现html页面保存为图片
本文的最终方案主要是利用html2canvas和canvas2image实现海报的分享与保存
已知可行方案
方案一
将DOM改写为canvas,然后利用canvas的toDataURL方法将DOM输出为包含图片展示的data URI
方案二
使用html2canvas.js
实现(可选搭配Canvas2Image.js
实现网页保存为图片)
方案三
使用rasterizeHTML.js
实现
解决方案的选择
方案一
需要手动计算每个DOM元素的Computed Style
,然后需要计算好元素在canvas的大小位置等属性。
方案一的难点:
- 相当于完全重写了整个页面的布局样式,增加了工作量。
- 由于canvas中没有的对象概念,对于元素丰富、布局复杂的页面,不易重构。
- 所有DOM元素改写进canvas会带来一些困难,例如:难以支持响应式,图片元素清晰度不佳和文字点击区域识别问题等。
方案二
该类功能中Github上stars最多(至今仍在维护),Stack Overflow亦有丰富的讨论。只需简单调用html2canvas方法并设定配置项即可。
方案三
该方案的限制较多,目前仅支持3类可转为canvas的目标格式: 页面url,html字符串和document对象。html2canvas是目前实现网页保存为图片功能的综合最佳选择。
需求分析/具体实现
我的需求是,将项目的网址生成一个二维码,然后把二维码放到生成的海报中间。
生成二维码
使用qrcode.js,使用方法参考官方文档,代码如下
getQrCode:function(){
new QRCode(_self.$refs.code, {
text: location.href,
colorDark: '#517db2',
width: 100,
height: 100,
correctLevel: QRCode.CorrectLevel.H,
});
}
html转canvas
基于html2canvas.js
可将一个元素渲染为canvas,只需要简单的调用html2canvas(element[, options]);
即可。下列html2canvas
方法会返回一个包含有<canvas>
元素的promise
:
html2canvas(document.body).then(function(canvas) {
document.body.appendChild(canvas);
});
canvas转image
上一步生成的canvas即为包含目标元素的<canvas>
元素对象。实现保存图片的目标只需要将canvas转image即可。
这里的转换方案有2种
:
-
基于原生canvas的
toDataURL
方法将canvas输出为data: URI
类型的图片地址,再将该图片地址赋值给<image>
元素的src属性即可 -
使用第三方库
Canvas2Image.js
,调用其convertToImage
方法即可
实际上,Canvas2Image.js
也是基于canvas.toDataURL
的封装,相比原生的canvas API对于转为图片的功能上考虑更为具体(未压缩的包大小为7.4KB),适合项目使用。
清晰度优化
生成图片之后会发现图片稍微有些模糊,最终图片的清晰度取决于第一步中html转换成的canvas的清晰度。
现有解决方案参考:
解决 canvas 在高清屏中绘制模糊的问题
其基本原理是,将canvas
的属性width
和height
属性放大为2倍(或者设置为devicePixelRatio
倍),最后将canvas的CSS样式width和height设置为原先1倍的大小。
含有跨域图片的配置
由于canvas对于图片资源的同源限制
,如果画布中包含跨域的图片资源则会污染画布,造成生成图片样式混乱或者html2canvas方法不执行等问题。
解决方案:
-
要求CDN的图片配置好
CORS
。CDN
配置好后,通过chrome开发者工具可以看到响应头中应含有Access-Control-Allow-Origin
的字段 -
开启
html2canvas
的useCORS
配置项。即作如下设置:
var opts = {useCORS: true};
html2canvas(element, opts);
完整代码
html部分:
<div class="scott-share-mask fixed" :class="{ fadeOut: showAni }" v-if="showShare"></div>
<div class="scott-poster fixed flex justify-start" :class="{ fadeOut: showAni }" v-if="showShare">
<div class="poster-main">
<div id="share" ref="share">
<div class="share-top">
<img @click="test" :src="post.metas._nv_thumbnail" alt="" />
<div class="date">
<div class="day">{{ parse_date(post.created_time).date }}</div>
<div class="year-month">{{ parse_date(post.created_time).year }}</div>
</div>
</div>
<div class="share-title">
<div class="info">
<div class="title">{{ post.title }}</div>
<div class="desc">
{{ post.excerpt }}
</div>
</div>
<div class="code" id="code" ref="code"></div>
</div>
</div>
<div id="content"></div>
<div class="share-action grid items-center justify-center">
<template v-if="!isCreating">
<NTooltip placement="top-start">
<template #trigger>
<div class="share-button items-center justify-center flex" @click="shareWb">
<i class="fa-brands fa-weibo"></i>
</div>
</template>
分享到微博
</NTooltip>
<NTooltip placement="top-start">
<template #trigger>
<div class="share-button items-center justify-center flex" @click="shareQq">
<i class="fa-brands fa-qq"></i>
</div>
</template>
分享到QQ
</NTooltip>
<NTooltip placement="top-start">
<template #trigger>
<div class="share-button items-center justify-center flex" @click="shareQzone">
<i class="fa-solid fa-star"></i>
</div>
</template>
分享到QQ空间
</NTooltip>
<NTooltip placement="top-start">
<template #trigger>
<div class="share-button items-center justify-center flex" @click="save">
<i class="fa-solid fa-image"></i>
</div>
</template>
保存海报
</NTooltip>
</template>
<p v-if="isCreating">正在生成海报...</p>
</div>
<div @click="closeModal" class="close flex items-center absolute justify-center">
<i class="fa-solid fa-close"></i>
</div>
</div>
</div>
css部分:
.scott-poster {
padding-bottom: 20vh;
top: 24px;
right: 0;
bottom: 0;
left: 0;
justify-content: center;
z-index: 1001;
animation: fadeInZoom 0.35s cubic-bezier(0.165, 0.84, 0.44, 1);
&.fadeOut {
animation: fadeOutZoom 0.35s cubic-bezier(0.165, 0.84, 0.44, 1);
}
.poster-main {
width: 25rem;
margin: auto;
border-radius: 0.625rem;
overflow: hidden;
transition-duration: 0.35s;
background: #fff;
box-shadow: 0 1rem 2rem rgb(0 0 0 / 20%), 0 1rem 1rem rgb(54 100 152 / 50%);
#share {
width: 25rem;
overflow: visible;
border-radius: 0.625rem;
}
#content {
line-height: 0;
}
.share-top {
width: 400px;
overflow: visible;
position: relative;
line-height: 0;
img {
max-width: 100%;
}
.date {
position: absolute;
left: 1.25rem;
bottom: 1.25rem;
font-size: 1.125rem;
color: #fff;
text-shadow: 0 0.3rem 0.5rem rgb(0 0 0 / 50%);
opacity: 0.8;
font-family: Play;
.day {
font-size: 3rem;
line-height: 1;
}
.year-month {
line-height: 1.75rem;
border-top: 1px solid hsla(0, 0%, 100%, 0.5);
}
}
}
.share-title {
padding: 1.25rem;
grid-template-columns: 1fr 110px;
display: grid;
.info {
padding-right: 10px;
}
.title {
color: #517db2;
padding-bottom: 1.25rem;
font-weight: 700;
}
.desc {
color: #73869a;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.code {
justify-content: end;
align-content: center;
display: grid;
img {
width: 100px;
height: 100px;
display: block;
}
}
}
.share-action {
border-top: 1px dashed rgba(54, 100, 152, 0.25);
height: 4.25rem;
background-color: #e8f0fa;
gap: 1rem;
grid-auto-flow: column;
p {
color: var(--primary-color);
text-shadow: 0 2px 2px var(--primary-opacity-3), 0 -1px var(--white-default);
}
.share-button {
height: 2.25rem;
width: 2.25rem;
border-radius: 9999px;
cursor: pointer;
font-size: 1rem;
line-height: 1.5rem;
text-shadow: 0 1px 1px #fff;
box-shadow: inset 0.3536rem 0.3536rem 0.625rem hsl(0deg 0% 100% / 80%), inset -0.1326rem -0.1326rem 0.25rem hsl(0deg 0% 100% / 30%), inset -0.3536rem -0.3536rem 0.625rem rgb(54 100 152 / 10%),
0.4419rem 0.4419rem 1rem rgb(54 100 152 / 30%);
background-color: #dae5f2;
transition: 0.35s;
&:nth-of-type(1) {
color: #f56c6c;
&:hover {
text-shadow: 0.1326rem 0.1326rem 0.1875rem rgb(245 108 108 / 50%), -1px -1px 1px hsl(0deg 0% 100% / 80%);
}
}
&:nth-of-type(2) {
color: #409eff;
&:hover {
text-shadow: 0.1326rem 0.1326rem 0.1875rem rgb(64 158 255 / 50%), -1px -1px 1px hsl(0deg 0% 100% / 80%);
}
}
&:nth-of-type(3) {
color: #e6a23c;
&:hover {
text-shadow: 0.1326rem 0.1326rem 0.1875rem rgb(230 162 60 / 50%), -1px -1px 1px hsl(0deg 0% 100% / 80%);
}
}
&:nth-of-type(4) {
color: #61be33;
&:hover {
text-shadow: 0.1326rem 0.1326rem 0.1875rem rgb(97 190 51 / 50%), -1px -1px 1px hsl(0deg 0% 100% / 80%);
}
}
}
}
}
.close {
margin-left: auto;
margin-right: auto;
margin-top: 20px;
left: 50%;
margin-left: -1.25rem;
width: 2.5rem;
border-radius: 9999px;
background: #fff;
cursor: pointer;
height: 2.5rem;
font-size: 18px;
line-height: 1;
color: var(--member-light-color, var(--theme-light-color));
opacity: 0.7;
transition: 0.35s;
box-shadow: 0 1rem 2rem rgb(0 0 0 / 20%), 0 1rem 1rem rgb(54 100 152 / 50%);
&:hover {
opacity: 1;
}
}
}
.scott-share-mask {
background: radial-gradient(rgba(54, 100, 152, 0.4), rgba(19, 40, 77, 0.8));
-webkit-backdrop-filter: blur(0.1rem);
backdrop-filter: blur(0.1rem);
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 1000;
animation: fadeIn linear 0.35s;
&.fadeOut {
animation: fadeOut 0.35s cubic-bezier(0.165, 0.84, 0.44, 1);
}
}
javascript部分:
handleShare() {
const _self = this;
//定义查找元素方法
function $(selector) {
return document.querySelector(selector);
}
var main = {
init: function () {
main.getQrCode();
},
//设置监听事件
getQrCode: function () {
new QRCode(_self.$refs.code, {
text: location.href,
colorDark: '#517db2',
width: 100,
height: 100,
correctLevel: QRCode.CorrectLevel.H,
});
main.html2Canvas();
},
//获取像素密度
getPixelRatio: function (context) {
var backingStore =
context.backingStorePixelRatio ||
context.webkitBackingStorePixelRatio ||
context.mozBackingStorePixelRatio ||
context.msBackingStorePixelRatio ||
context.oBackingStorePixelRatio ||
context.backingStorePixelRatio ||
1;
return (window.devicePixelRatio || 1) / backingStore;
},
//绘制dom 元素,生成截图canvas
html2Canvas: function () {
var shareContent = _self.$refs.share; // 需要绘制的部分的 (原生)dom 对象 ,注意容器的宽度不要使用百分比,使用固定宽度,避免缩放问题
var width = shareContent.offsetWidth; // 获取(原生)dom 宽度
var height = shareContent.offsetHeight; // 获取(原生)dom 高
var canvas = document.createElement('canvas'); //创建canvas 对象
var context = canvas.getContext('2d');
var scaleBy = main.getPixelRatio(context); //获取像素密度的方法 (也可以采用自定义缩放比例)
canvas.width = width * scaleBy; //这里 由于绘制的dom 为固定宽度,居中,所以没有偏移
canvas.height = height * scaleBy; // 注意高度问题,由于顶部有个距离所以要加上顶部的距离,解决图像高度偏移问题
context.scale(1, 1);
context.translate(0, 0);
// 定义html2canvas参数
var opts = {
scale: scaleBy, // 添加的scale 参数
canvas: canvas, //自定义 canvas
width: width, //dom 原始宽度
height: height,
logging: true,
dpi: window.devicePixelRatio * 2,
useCORS: true, // 【重要】开启跨域配置
};
html2canvas(_self.$refs.share, opts).then(function (canvas) {
var context = canvas.getContext('2d');
// 【重要】关闭抗锯齿
context.mozImageSmoothingEnabled = false;
context.webkitImageSmoothingEnabled = false;
context.msImageSmoothingEnabled = false;
context.imageSmoothingEnabled = false;
context.willReadFrequency = true;
_self.canvas = canvas;
var img = Canvas2Image.convertToImage(canvas, canvas.width, canvas.height);
img.style.width = canvas.width / scaleBy + 'px';
img.style.height = canvas.height / scaleBy + 'px';
document.getElementById('content').appendChild(img);
_self.isCreating = false;
$('#share').remove();
});
},
};
main.init();
};
效果预览
如果你觉得本篇文章对你有所帮助,可以帮我点个赞或者请我喝杯奶茶~万分感谢🎉🎉🎉