thumbnail
将整个网页保存为图片是一个十分有趣的功能,常见于H5活动页的结尾页分享。以下则是项目中的一些小结和汇总。
 Scott

实现html页面保存为图片

本文的最终方案主要是利用html2canvas和canvas2image实现海报的分享与保存

已知可行方案

 方案一

将DOM改写为canvas,然后利用canvas的toDataURL方法将DOM输出为包含图片展示的data URI

 方案二

使用html2canvas.js实现(可选搭配Canvas2Image.js实现网页保存为图片)

 方案三

使用rasterizeHTML.js实现

解决方案的选择

 方案一

需要手动计算每个DOM元素的Computed Style,然后需要计算好元素在canvas的大小位置等属性。

 方案一的难点:

  1. 相当于完全重写了整个页面的布局样式,增加了工作量。
  2. 由于canvas中没有的对象概念,对于元素丰富、布局复杂的页面,不易重构。
  3. 所有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种

  1. 基于原生canvas的toDataURL方法将canvas输出为data: URI类型的图片地址,再将该图片地址赋值给<image>元素的src属性即可
  2. 使用第三方库Canvas2Image.js,调用其convertToImage方法即可

实际上,Canvas2Image.js也是基于canvas.toDataURL的封装,相比原生的canvas API对于转为图片的功能上考虑更为具体(未压缩的包大小为7.4KB),适合项目使用。

清晰度优化

生成图片之后会发现图片稍微有些模糊,最终图片的清晰度取决于第一步中html转换成的canvas的清晰度。

 现有解决方案参考:

  1. 解决canvas在高清屏中绘制模糊的问题
  2. html5 canvas绘制图片模糊的问题

解决 canvas 在高清屏中绘制模糊的问题

其基本原理是,将canvas的属性widthheight属性放大为2倍(或者设置为devicePixelRatio倍),最后将canvas的CSS样式width和height设置为原先1倍的大小。

含有跨域图片的配置

由于canvas对于图片资源的同源限制,如果画布中包含跨域的图片资源则会污染画布,造成生成图片样式混乱或者html2canvas方法不执行等问题。

 解决方案:

  1. 要求CDN的图片配置好CORSCDN配置好后,通过chrome开发者工具可以看到响应头中应含有Access-Control-Allow-Origin的字段
  2. 开启html2canvasuseCORS配置项。即作如下设置:
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();
};

效果预览


如果你觉得本篇文章对你有所帮助,可以帮我点个赞或者请我喝杯奶茶~万分感谢🎉🎉🎉