前端海报生成的几种方式:从 Canvas 到 Skyline

一、前言

层叠上下文(Stacking Context) CSS 中的一个重要概念,决定了元素在 z 轴上的层叠顺序。html2canvas 需要正确解析这些信息以确保截图的层级关系正确。

Skyline 渲染引擎 :微信小程序的新一代渲染引擎,基于自研的渲染架构,相比传统 WebView 渲染具有更好的性能和更丰富的能力。

在 Web 开发中,分享海报是比较常见的需求,一般用于营销活动分享、用户 UGC 内容输出、社交传播等场景,而业界在具体技术实现上存在多种方案。本文将结合作者的开发经验,介绍前端生成海报的主要技术方案(包括 Canvas 原生绘制、自研 Canvas 插件、html2canvas 类插件、微信小程序 Skyline 等)。

二、主流技术方案详解

2.1 Canvas 原生绘制

  1. 创建/获取 Canvas 元素

  2. 加载外部资源(图片/字体)并处理跨域

  3. 按层级绘制背景、文字、图片等元素(根据 x/y 轴定位,文本换行需手动计算)

  4. 调用 toDataURL()convertToBlob()导出图片

代码示例:

js 复制代码
// 获取Canvas上下文
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");

// 绘制渐变背景
const gradient = ctx.createLinearGradient(0, 0, 0, canvas.height);
gradient.addColorStop(0, "#4a90e2");
gradient.addColorStop(1, "#2c5aa0");
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, canvas.width, canvas.height);

// 绘制文本(带阴影效果)
ctx.fillStyle = "white";
ctx.font = "bold 48px Arial";
ctx.textAlign = "center";
ctx.shadowColor = "rgba(0, 0, 0, 0.3)";
ctx.shadowBlur = 10;
ctx.fillText("海报标题", canvas.width / 2, 200);

// 绘制图片
const img = new Image();
img.onload = function () {
  ctx.drawImage(img, 0, 0, img.width, img.height);
};
img.src = "background.jpg";

// 绘制几何图形(圆形)
ctx.fillStyle = "rgba(255, 255, 255, 0.8)";
ctx.beginPath();
ctx.arc(100, 100, 50, 0, Math.PI * 2);
ctx.fill();

// 导出图片
const dataURL = canvas.toDataURL("image/png");
const link = document.createElement("a");
link.download = "poster.png";
link.href = dataURL;
link.click();

优缺点:

✅ 灵活性高(像素级控制)

✅ 性能稳定(避免 DOM 渲染开销)

❌ 开发效率低,复杂布局需手动计算(如多列文本、动态间距)

❌ 代码复用性不高

典型应用场景:

适用于对性能要求极高、布局相对固定的海报生成,如游戏内截图、数据可视化图表等。

2.2 自研 Canvas 插件

为了解决 Canvas 原生绘制的复杂性和代码复用问题,我们可以基于业务需求封装自研的 Canvas 插件。以 GameSDK 海报插件为例(公司内部插件,无外部链接):

  • 原理:基于 Canvas 原生 API 封装,提供声明式的海报生成方案
  • 关键步骤
  1. 定义海报配置(尺寸、背景、图层数组)

  2. 解析图层配置并计算布局位置

  3. 按图层类型(图片/文字/二维码/矩形)依次绘制

  4. 导出生成的海报图片

代码示例:

js 复制代码
// 海报插件核心类
class Poster {
  constructor(options) {
    const { width, height, background = "#fff", views = [] } = options;
    this._canvas = document.createElement("canvas");
    this._context = this._canvas.getContext("2d");
    this._canvas.width = width;
    this._canvas.height = height;
    this._views = views;

    // 绘制背景
    this._context.fillStyle = background;
    this._context.fillRect(0, 0, width, height);
  }

  // 生成海报 base64 URL
  async generateDataURL(type = "image/jpeg") {
    // 处理图层位置和布局
    this._formatViews = this.formatPosition(this._views);
    this._formatViews = this.formatFlexBox(this._formatViews);

    // 按类型绘制每个图层
    for (const view of this._formatViews) {
      switch (view.type) {
        case "image":
          await this._generateImage(view);
          break;
        case "text":
          await this._generateText(view);
          break;
        case "qrcode":
          await this._generateQRCode(view);
          break;
        case "rect":
          await this._generateRect(view);
          break;
      }
    }
    return this._canvas.toDataURL(type);
  }

  // 绘制图片图层(支持跨域和旋转)
  _generateImage(view) {
    return new Promise((resolve, reject) => {
      const image = new Image();
      const { width, height, src, rotate } = view;
      const { left, top } = view;

      image.crossOrigin = "Anonymous";
      image.onload = () => {
        if (rotate) {
          // 旋转绘制
          this._context.save();
          this._context.translate(left + width / 2, top + height / 2);
          this._context.rotate((rotate * Math.PI) / 180);
          this._context.drawImage(
            image,
            -width / 2,
            -height / 2,
            width,
            height
          );
          this._context.restore();
        } else {
          this._context.drawImage(image, left, top, width, height);
        }
        resolve();
      };
      image.onerror = reject;
      image.src = src;
    });
  }
  // 其他绘制方法
}

使用示例

js 复制代码
const poster = new Poster({
  width: 750,
  height: 1334,
  background: "#f5f5f5",
  views: [
    // 图片
    {
      type: "image",
      width: 200,
      height: 200,
      src: "https://p1.dailygn.com/img/g-marketing-act-assets/2021_09_17_17_31_03/game-sdk-logo_s7858.png~q75.png",
    },
    // 二维码
    {
      type: "qrcode",
      width: 100,
      height: 100,
      left: 100,
      top: 100,
      text: location.href,
    },
    // 文字
    {
      type: "text",
      width: 200,
      height: 100,
      text: "我是一串文字",
      color: "red",
      align: "center",
      left: 0,
      top: 0,
    },
  ],
});

// 生成海报
poster.generateDataURL().then((dataURL) => {
  const img = document.createElement("img");
  img.src = dataURL;
  document.body.appendChild(img);
});

优缺点:

✅ 开发效率高(声明式配置)

✅ 易于扩展(模块化设计)

❌ 需要长期维护,以覆盖更多的使用场景

❌ 不适用于过于复杂的布局

典型应用场景:

适用于有一定复杂度但布局相对规范的海报生成,如商品分享卡片、活动宣传海报等。

2.3 html2canvas

html2canvas 是一个流行的前端开源库,在 GitHub 上已有 30k+ 的 star,它允许开发者在浏览器中直接对网页进行"截图",以生成海报。

  • 原理:通过读取 DOM 和应用于元素的不同样式,在 Canvas 上绘制页面内容
  • 关键步骤
  1. 资源预处理:处理图片跨域、字体加载等异步资源,确保渲染时可用

  2. DOM 克隆:使用 DocumentCloner 在 iframe 中克隆目标元素,避免影响原页面

  3. 样式解析:通过 parseTree 深度优先遍历 DOM,为每个节点创建 ElementContainer 对象

  4. 层叠上下文构建:解析 z-indexposition 等属性,生成正确的绘制顺序

  5. Canvas 渲染:使用 CanvasRenderer 将解析后的元素树绘制到 Canvas 上

代码示例:

js 复制代码
// html2canvas 实现流程(简版)
const html2canvas = async (element, options = {}) => {
  // 步骤 1: 初始化配置和上下文
  // - 合并用户选项和默认值,创建 resourceOptions, windowOptions 等。
  // - 创建 Context 对象,用于管理日志、缓存和资源请求。
  const context = new Context(contextOptions, windowBounds);

  // 步骤 2: DOM 克隆
  // - 创建 DocumentCloner 实例。
  // - 调用 toIFrame 方法,在一个隐藏的 iframe 中深度克隆目标 DOM。
  // - 这个过程会等待 iframe 内的资源(如字体、图片)加载完成。
  const documentCloner = new DocumentCloner(context, element, cloneOptions);
  const container = await documentCloner.toIFrame(ownerDocument, windowBounds); // toIFrame 返回的是 iframe 容器
  const clonedElement = documentCloner.clonedReferenceElement; // 克隆的目标元素在 cloner 的属性中

  // 步骤 3: DOM 解析和样式计算
  // - 使用 parseTree 递归遍历克隆后的 DOM 树。
  // - 为每个元素创建 ElementContainer,并计算和存储其样式(getComputedStyle)。
  const root = parseTree(context, clonedElement);

  // 步骤 4: Canvas 渲染
  // - 创建 CanvasRenderer 实例,传入渲染配置。
  // - 调用 renderer.render 方法,它内部会先构建层叠上下文,然后按正确顺序绘制。
  const renderer = new CanvasRenderer(context, renderOptions);
  const canvas = await renderer.render(root); // render 方法接收 ElementContainer 树的根节点

  // 步骤 5: 清理
  // - 渲染完成后,移除用于克隆的 iframe 容器。
  DocumentCloner.destroy(container);

  return canvas;
};

使用示例:

js 复制代码
// 基础用法
html2canvas(document.body).then(function (canvas) {
  document.body.appendChild(canvas);
});

// 带配置选项的用法
html2canvas(element, {
  allowTaint: false,
  useCORS: true,
  scale: 2, // 提高清晰度
  width: 800,
  height: 600,
  backgroundColor: "#ffffff",
  logging: false,
}).then((canvas) => {
  // 转换为图片并下载
  const link = document.createElement("a");
  link.download = "screenshot.png";
  link.href = canvas.toDataURL();
  link.click();
});

// 截取特定区域
const targetElement = document.querySelector(".poster-container");
html2canvas(targetElement, {
  x: 0,
  y: 0,
  width: targetElement.offsetWidth,
  height: targetElement.offsetHeight,
  scrollX: 0,
  scrollY: 0,
}).then((canvas) => {
  const dataURL = canvas.toDataURL("image/jpeg", 0.8);
  // 处理生成的图片
});

// 处理跨域图片
html2canvas(element, {
  useCORS: true,
  proxy: "https://proxy-server.com/proxy",
  allowTaint: false,
}).then((canvas) => {
  // 处理结果
});

优缺点:

✅ 使用简单,少量配置即可

✅ 支持复杂 DOM 结构和 CSS 样式

❌ 依赖浏览器兼容性,不同浏览器渲染效果可能存在差异

❌ 存在多个已知问题和坑点,可能需要修改源码或寻找替代方案(可参考:html2canvas - 项目中遇到的那些坑点汇总(更新中...) - xing.org1^ - 博客园

典型应用场景:

适用于需要将复杂 HTML 页面转换为图片的场景,如用户 UGC 内容分享、复杂布局的营销海报等。

2.4 wxml2canvas

wxml2canvas 是一款用于微信小程序的分享图生成插件,设计思想与 html2canvas 类似。不过由于微信小程序 API 的限制,以及插件本身功能相对简单,在能力上与 html2canvas 有一定差距。

  • 原理:通过 SelectorQuery API 获取 WXML 元素样式信息,在 Canvas 上绘制页面内容
  • 关键步骤
  1. 图片预加载:预加载所有图片资源(网络图片下载、base64 转本地文件)

  2. 绘制任务分发:按类型(wxml、text、image、rect 等)分发绘制任务

  3. WXML 节点解析:使用 wx.createSelectorQuery() 获取节点样式、位置和数据属性

  4. 坐标转换适配:处理相对定位、缩放比例和边界限制

  5. Canvas 导出:延时保存,通过 wx.canvasToTempFilePath导出图片

代码示例:

js 复制代码
// wxml2canvas 实现流程(简版)
class Wxml2Canvas {
  constructor(options = {}) {
    // 1. 初始化配置
    this.device = wx.getSystemInfoSync();
    this.zoom = options.zoom || this.device.windowWidth / 375; // 缩放比例
    this.element = options.element; // Canvas ID
    this.width = options.width * this.zoom;
    this.height = options.height * this.zoom;
    this.background = options.background || '#ffffff';
    this._init();
  }

  // 2. 主绘制入口
  draw(data = {}, that) {
    this.data = data;
    // 2.1. 预加载所有图片资源
    this._preloadImage(data.list).then(() => {
      this._draw(); // 开始绘制
    }).catch(this.errorHandler);
  }

  // 3. 绘制任务分发
  _draw() {
    let list = this.data.list || [];
    let all = [];

    // 3.1. 遍历绘制任务列表
    list.forEach((item, i) => {
      all[i] = new Promise((resolve, reject) => {
        // 3.2. 根据类型分发绘制任务
        switch (item.type) {
          case 'wxml':
            this._drawWxml(item, item.style, resolve, reject);
            break;
          case 'text':
            this._drawText(item, item.style, resolve, reject);
            break;
          case 'image':
          case 'radius-image':
            this._drawRect(item, item.style, resolve, reject, 'image');
            break;
          // ... 其他类型
        }
      });
    });

    // 3.3. 等待所有绘制完成后导出
    Promise.all(all).then(() => {
      this._saveCanvasToImage();
    });
  }

  // 4. WXML节点绘制(核心方法)
  _drawWxml(item, style, resolve, reject) {
    // 4.1. 获取WXML节点信息
    this._getWxml(item, style).then((results) => {
      // 4.2. 按top值排序,实现分层
      let sorted = this._sortListByTop(results[0]);
      let all = [];

      // 4.3. 分别处理块级和行内元素
      all = this._drawWxmlBlock(item, sorted, all, progress, results[1]);
      all = this._drawWxmlInline(item, sorted, all, progress, results[1]);

      Promise.all(all).then(resolve).catch(reject);
    });
  }

  // 5. 获取WXML节点信息
  _getWxml(item, style) {
    const query = wx.createSelectorQuery();

    // 5.1. 获取所有目标元素
    const p1 = new Promise((resolve, reject) => {
      query.selectAll(item.class).fields({
        dataset: true,
        size: true,
        rect: true,
        computedStyle: ['width', 'height', 'fontSize', 'color',
                       'backgroundColor', 'backgroundImage', ...]
      }, (res) => {
        // 5.2. 格式化图片信息
        const formated = this._formatImage(res);
        // 5.3. 预加载图片
        this._preloadImage(formated.list).then(() => {
          resolve(formated.res);
        }).catch(reject);
      }).exec();
    });

    // 5.4. 获取限制区域信息
    const p2 = new Promise((resolve) => {
      if (!item.limit) {
        resolve({ top: 0, width: this.width / this.zoom });
        return;
      }
      query.select(item.limit).fields({
        dataset: true, size: true, rect: true
      }, resolve).exec();
    });

    return Promise.all([p1, p2]);
  }

  // 6. 坐标转换适配
  _transferWxmlStyle(sub, item, limitLeft, limitTop) {
    // 6.1. 处理相对定位
    sub.left = this._parseNumber(sub.left) - limitLeft + (item.x || 0) * this.zoom;
    sub.top = this._parseNumber(sub.top) - limitTop + (item.y || 0) * this.zoom;
    // 6.2. 处理padding等样式
    // ...
    return sub;
  }

  // 7. Canvas导出
  _saveCanvasToImage() {
    // 7.1. 延时保存(等待绘制完成,避免样式错乱)
    setTimeout(() => {
      wx.canvasToTempFilePath({
        canvasId: this.element,
        success: (res) => {
          this.finishDraw(res.tempFilePath);
        },
        fail: this.errorHandler
      });
    }, this.device.system.indexOf('iOS') === -1 ? 300 : 100);
  }
}

使用示例:

js 复制代码
// WXML结构
// <view class="poster-container">
//   <view class="poster-title draw_canvas"
//         data-type="text"
//         data-text="海报标题">
//     海报标题
//   </view>
//   <image class="poster-avatar draw_canvas"
//          data-type="image"
//          data-url="{{avatarUrl}}"
//          src="{{avatarUrl}}" />
// </view>
// <canvas canvas-id="posterCanvas" class="poster-canvas"></canvas>

Page({
  generatePoster() {
    const drawer = new Wxml2Canvas({
      width: 375,
      height: 667,
      element: "posterCanvas",
      background: "#ffffff",
      destZoom: 3,
      finish: (url) => {
        this.setData({ posterUrl: url });
        wx.previewImage({ urls: [url] });
      },
      error: (err) => {
        console.error("生成海报失败:", err);
      },
    });

    // 定义绘制任务
    const data = {
      list: [
        {
          type: "wxml",
          class: ".draw_canvas",
          limit: ".poster-container",
          x: 0,
          y: 0,
        },
      ],
    };

    drawer.draw(data);
  }
});

优缺点:

✅ 使用简单(与原生 Canvas 绘制相比)

✅ 支持中等复杂度的图文布局

❌ 支持绘制类型有限,不支持自定义组件、Canvas 等类型,且对渐变(Gradients)、阴影(Shadows)、变形(Transforms)、滤镜(Filter)等 CSS 高级特性支持不佳

❌ 该插件已多年无人维护,如踩坑可能需要修改源码或寻找替代方案(如 wxml2canvas-2d 插件)

典型应用场景:

适用于微信小程序中相对简单的海报生成需求,如商品分享卡片、用户成就展示等。

2.5 Skyline

Skyline 是微信小程序推出的新一代渲染引擎,配合 Snapshot 组件 可以实现高性能的截图功能。与 wxml2canvas 插件相比,Skyline + Snapshot 不涉及 Canvas 绘制,海报开发效率更高。

  • 原理:基于 Skyline 渲染引擎的原生截图能力,直接将渲染结果转换为图片
  • 关键步骤
  1. 配置 Skyline 渲染环境

  2. 使用 Snapshot 组件包裹需要截图的内容

  3. 调用 takeSnapshot API 生成图片

  4. 保存或分享生成的图片

使用示例(以 uniapp 为例):

  1. 配置 Skyline 环境

manifest.jsonmp-weixin 中添加 lazyCodeLoading 及 rendererOptions 配置:

json 复制代码
"mp-weixin": {
  "lazyCodeLoading" : "requiredComponents",
  "rendererOptions" : { "skyline" : { "defaultDisplayBlock" : true } }
}

pages.jsonpages->style 中添加 renderer 及 componentFramework 配置:

json 复制代码
{
  "pages" : [
    {
      "path" : "pages/demo/index",
      "style" : {
        "renderer" : "skyline",
        "componentFramework" : "glass-easel"
      }
    }
  ]
}
  1. 页面实现
html 复制代码
<template>
  <snapshot id="poster" class="poster-container">
    <view class="poster-content">
      <view class="header">
        <image class="avatar" :src="userAvatar" />
        <view class="user-info">
          <text class="username">{{ username }}</text>
          <text class="activity">发起学习活动</text>
        </view>
      </view>

      <view class="content">
        <view class="progress-section">
          <text class="progress-label">学习进度</text>
          <text class="progress-value">{{ progress }}%</text>
        </view>
        <view class="progress-bar">
          <view class="progress-fill" :style="{ width: progress + '%' }"></view>
        </view>
      </view>

      <view class="footer">
        <image class="qrcode" :src="qrcodeUrl" />
        <text class="qrcode-tip">长按识别小程序码</text>
      </view>
    </view>
  </snapshot>

  <button @click="generatePoster">生成海报</button>
</template>
  1. 截图逻辑
js 复制代码
const generatePoster = () => {
  uni
    .createSelectorQuery()
    .select("#poster")
    .node()
    .exec((res) => {
      const node = res[0].node;

      node.takeSnapshot({
        type: "arraybuffer",
        format: "png",
        success: (snapshotRes) => {
          // 保存到本地文件系统
          const filePath = `${wx.env.USER_DATA_PATH}/poster.png`;
          const fs = uni.getFileSystemManager();
          fs.writeFileSync(filePath, snapshotRes.data, "binary");

          // 保存到相册
          uni.saveImageToPhotosAlbum({
            filePath,
            success: () => {
              uni.showToast({ title: "保存成功" });
            },
            fail: (err) => {
              console.error("保存失败:", err);
            },
          });
        },
        fail: (err) => {
          console.error("截图失败:", err);
        },
      });
    });
};

优缺点:

✅ 使用简单,少量配置即可

✅ 支持复杂布局,样式还原度高

❌ Skyline 环境下存在较多适配问题,如不支持原生导航栏、absolutefixed 定位失效等(可参考:Skyline 渲染引擎常见问题),建议在单独的海报页面开启 Skyline

❌ 需要设置最低基础库版本为 3.0.1(Snapshot 组件要求),可能导致部分用户需要升级微信

❌ Skyline 暂不支持鸿蒙系统,需要走 WebView 渲染

典型应用场景:

适用于微信小程序中复杂布局的海报生成,如用户游戏战绩、复杂布局的营销海报等。

三、技术选型总结与建议

在前端海报生成的技术选型中,不同方案各有优劣,需要根据具体的业务场景、开发效率要求和技术栈来选择。一般来说,微信小程序中 Skyline 方案的开发效率较高,其次是 wxml2canvas 类插件;H5 场景中,复杂布局下 html2canvas 开发效率较高,简单布局下自研 Canvas 插件开发效率较高,原生 Canvas 绘制最为灵活,但开发效率相对较低。

3.1 技术方案对比

以下是各技术方案的详细对比(注:表格为粗略预估数据,非实际评测结果,具体效果需结合实际场景验证):

技术方案 适用场景 开发效率 维护难度 性能表现 布局复杂度支持 兼容性
Canvas 原生绘制 H5/小程序 ⭐⭐ ⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐
自研 Canvas 插件 H5/小程序 ⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐⭐
html2canvas H5 ⭐⭐⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐
wxml2canvas 小程序 ⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐⭐
Skyline + Snapshot 小程序 ⭐⭐⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐

3.2 选型建议

  • 微信小程序场景:优先选择 Skyline + Snapshot 方案,开发效率高且样式还原度好;如需兼容低版本微信或鸿蒙系统,可考虑 wxml2canvas 作为降级方案

  • H5 场景:复杂布局推荐 html2canvas,简单布局可选择自研 Canvas 插件或原生 Canvas 绘制

  • 跨平台场景:建议封装自研 Canvas 插件,统一 H5 和小程序的实现逻辑

  • 高性能要求:原生 Canvas 绘制性能最佳,但开发成本较高

以上技术方案已在线上环境验证过,但在不同的需求背景及设备要求下,效果可能会有所差异。建议根据具体场景进行充分的评估和测试。

相关推荐
GoldKey4 小时前
gcc 源码阅读---语法树
linux·前端·windows
晓风伴月5 小时前
微信小程序:在ios中border边框显示不全
ios·微信小程序·小程序
Xf3n1an5 小时前
html语法
前端·html
张拭心5 小时前
亚马逊 AI IDE Kiro “狙击”Cursor?实测心得
前端·ai编程
漠月瑾-西安5 小时前
如何在 React + TypeScript 中实现 JSON 格式化功能
javascript·jst实现json格式化
烛阴6 小时前
为什么你的Python项目总是混乱?层级包构建全解析
前端·python
止观止6 小时前
React响应式组件范式:从类组件到Hooks
javascript·react.js·ecmascript
@大迁世界6 小时前
React 及其生态新闻 — 2025年6月
前端·javascript·react.js·前端框架·ecmascript
LJianK16 小时前
Java和JavaScript的&&和||
java·javascript·python
红尘散仙7 小时前
Rust 终端 UI 开发新玩法:用 Ratatui Kit 轻松打造高颜值 CLI
前端·后端·rust