前端海报生成的几种方式:从 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 绘制性能最佳,但开发成本较高

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

相关推荐
摸鱼的春哥15 小时前
Agent教程21:知识图谱🕸,让AI🤖学会联想
前端·javascript·后端
SuperEugene15 小时前
Vue3 组件拆分实战规范:页面 / 业务 / 基础组件边界清晰化,高内聚低耦合落地指南|Vue 组件与模板规范篇
前端·javascript·vue.js·前端框架
泯泷15 小时前
阶段二:为什么先设计指令集,编译器和运行时才能稳定对齐?
前端·javascript·架构
Dxy123931021615 小时前
HTML常用布局详解:从基础到进阶的网页结构指南
前端·html
ywf121517 小时前
前端的dist包放到后端springboot项目下一起打包
前端·spring boot·后端
恋猫de小郭18 小时前
2026,Android Compose 终于支持 Hot Reload 了,但是收费
android·前端·flutter
hpoenixf1 天前
2026 年前端面试问什么
前端·面试
还是大剑师兰特1 天前
Vue3 中的 defineExpose 完全指南
前端·javascript·vue.js
泯泷1 天前
阶段一:从 0 看懂 JSVMP 架构,先在脑子里搭出一台最小 JSVM
前端·javascript·架构
2501_933907211 天前
宁波小程序开发服务与技术团队专业支持
科技·微信小程序·小程序