dom-to-image原理

背景


最近接到需求要做海报,UI给一张背景图然后内容自己填充最后生成一张图片。刚开始做没多想就拿canvas画,一些简单的还好,能够hold的住。遇到元素多的就麻烦了😹。后来发现有dom-to-image的方案,不错,解决的我的问题👍👍。话不多说,下面就来说说他是怎样实现的。

Api介绍


看了看代码,一共887行,代码量不多,每个方法都比较易懂。核心Api有以下几个。

    toSvg
    toPng
    toJpeg
    toBlob
    toPixelData

顾名思义了,这里就不多说,其实这几个方法实现方式都一样的。第一个方法toSvgdom节点转成svg,然后其他的方法都是拿到svg后处理为dataUrl再处理为cavnas再操作的。下面就拿 toPng 去展开说明。

代码解读


1. toPng

  function toPng(node, options) {
    return draw(node, options || {}).then(function (canvas) {
      return canvas.toDataURL();
    });
  }

可见,是通过draw方法将dom节点转为canvas,然后通过canvas获取图片资源。

2.draw

  function draw(domNode, options) {
    // 将dom节点转为svg
    return toSvg(domNode, options)
      // 拿到的svg是image data URL,这里进一步通过svg创建图片
      .then(util.makeImage)
      .then(util.delay(100))
      .then(function (image) {
        // 通过图片创建canvas并返回
        var canvas = newCanvas(domNode);
        canvas.getContext("2d").drawImage(image, 0, 0);
        return canvas;
      });
    // 新建canvas节点,处理dataUrl资源,和options参数
    function newCanvas(domNode) {
      var canvas = document.createElement("canvas");
      canvas.width = options.width || util.width(domNode);
      canvas.height = options.height || util.height(domNode);

      if (options.bgcolor) {
        var ctx = canvas.getContext("2d");
        ctx.fillStyle = options.bgcolor;
        ctx.fillRect(0, 0, canvas.width, canvas.height);
      }

      return canvas;
    }
  }

3.toSvg

  function toSvg(node, options) {
    options = options || {};
    copyOptions(options);
    return Promise.resolve(node)
      .then(function (node) {
       // 递归克隆dom节点
        return cloneNode(node, options.filter, true);
      })
       // 嵌入字体,找出所有font-face样式,添加入一个新的style里面
      .then(embedFonts)
       // 将图片链接转换为dataUrl形式使用
      .then(inlineImages)
       // 将options里面的一些style放进style里面
      .then(applyOptions)
      .then(function (clone) {
        // 创建svg,将dom节点通过 XMLSerializer().serializeToString() 序列化为字符串
        // 然后用 foreignObject 包裹,就能将dom转为svg。
        return makeSvgDataUri(
          clone,
          options.width || util.width(node),
          options.height || util.height(node)
        );
      });
    // 处理一些options的样式
    function applyOptions(clone) {
      if (options.bgcolor) clone.style.backgroundColor = options.bgcolor;

      if (options.width) clone.style.width = options.width + "px";
      if (options.height) clone.style.height = options.height + "px";

      if (options.style)
        Object.keys(options.style).forEach(function (property) {
          clone.style[property] = options.style[property];
        });

      return clone;
    }
  }

4. cloneNode

  function cloneNode(node, filter, root) {
    if (!root && filter && !filter(node)) return Promise.resolve();

    return Promise.resolve(node)
      .then(makeNodeCopy)
      .then(function (clone) {
        return cloneChildren(node, clone, filter);
      })
      .then(function (clone) {
        return processClone(node, clone);
      });

    function makeNodeCopy(node) {
      // 遇到canvas转为image对象
      if (node instanceof HTMLCanvasElement)
        return util.makeImage(node.toDataURL());
      // 克隆第一层
      return node.cloneNode(false);
    }
    // 克隆子节点
    function cloneChildren(original, clone, filter) {
      var children = original.childNodes;
      if (children.length === 0) return Promise.resolve(clone);
    
      return cloneChildrenInOrder(clone, util.asArray(children), filter).then(
        function () {
          return clone;
        }
      );
      // 递归克隆
      function cloneChildrenInOrder(parent, children, filter) {
        var done = Promise.resolve();
        children.forEach(function (child) {
          done = done
            .then(function () {
              return cloneNode(child, filter);
            })
            .then(function (childClone) {
              if (childClone) parent.appendChild(childClone);
            });
        });
        return done;
      }
    }

    function processClone(original, clone) {
      if (!(clone instanceof Element)) return clone;

      return Promise.resolve()
        .then(cloneStyle)
        .then(clonePseudoElements)
        .then(copyUserInput)
        .then(fixSvg)
        .then(function () {
          return clone;
        });
      // 克隆节点上面所有使用的样式。
      function cloneStyle() {
        // 顺便提提,为什么不用style,因为如果什么样式也没有设置的话,style是光秃秃的
        // 而getComputedStyle则能获取到应用在节点上面所有样式
        copyStyle(window.getComputedStyle(original), clone.style);

        function copyStyle(source, target) {
          if (source.cssText) target.cssText = source.cssText;
          else copyProperties(source, target);

          function copyProperties(source, target) {
            util.asArray(source).forEach(function (name) {
              target.setProperty(
                name,
                source.getPropertyValue(name),
                source.getPropertyPriority(name)
              );
            });
          }
        }
      }
      // 提取伪类样式,放到css
      function clonePseudoElements() {
        [":before", ":after"].forEach(function (element) {
          clonePseudoElement(element);
        });

        function clonePseudoElement(element) {
          var style = window.getComputedStyle(original, element);
          var content = style.getPropertyValue("content");

          if (content === "" || content === "none") return;

          var className = util.uid();
          clone.className = clone.className + " " + className;
          var styleElement = document.createElement("style");
          styleElement.appendChild(
            formatPseudoElementStyle(className, element, style)
          );
          clone.appendChild(styleElement);
          function formatPseudoElementStyle(className, element, style) {
            var selector = "." + className + ":" + element;
            var cssText = style.cssText
              ? formatCssText(style)
              : formatCssProperties(style);
            return document.createTextNode(selector + "{" + cssText + "}");

            function formatCssText(style) {
              var content = style.getPropertyValue("content");
              return style.cssText + " content: " + content + ";";
            }

            function formatCssProperties(style) {
              return util.asArray(style).map(formatProperty).join("; ") + ";";

              function formatProperty(name) {
                return (
                  name +
                  ": " +
                  style.getPropertyValue(name) +
                  (style.getPropertyPriority(name) ? " !important" : "")
                );
              }
            }
          }
        }
      }
      // 处理输入内容
      function copyUserInput() {
      ...
      }
      // 处理svg,创建命名空间
      function fixSvg() {
        if (!(clone instanceof SVGElement)) return;
        clone.setAttribute("xmlns", "http://www.w3.org/2000/svg");
        ...
      }
    }
  }

5.makeSvgDataUri

  function makeSvgDataUri(node, width, height) {
    return (
      Promise.resolve(node)
        .then(function (node) {
          // 将dom转换为字符串
          node.setAttribute("xmlns", "http://www.w3.org/1999/xhtml");
          return new XMLSerializer().serializeToString(node);
        })
        .then(util.escapeXhtml)
        .then(function (xhtml) {
          return (
            '<foreignObject x="0" y="0" width="100%" height="100%">' +
            xhtml +
            "</foreignObject>"
          );
        })
        /**
         * 顺带提一提
         * 不指定xmlns命名空间是不会渲染的
         * xmlns="http://www.w3.org/2000/svg"
         */

        .then(function (foreignObject) {
          return (
            '<svg xmlns="http://www.w3.org/2000/svg" width="' +
            width +
            '" height="' +
            height +
            '">' +
            foreignObject +
            "</svg>"
          );
        })
        .then(function (svg) {
          return "data:image/svg+xml;charset=utf-8," + svg;
        })
    );
  }

总结


总结下上面几个核心方法流程
  1. 递归去克隆dom节点,其中
    ① 遇到canvas转为image对象
    ② 提取元素computed样式,并且插到新建的style标签上面,对于":before,:after"这些伪元素,会提取其样式,放到新建样式名中并且插入到新建的style标签中,供所属的节点使用。
    ③ 处理输入内容和svg。

  2. 插入字体
    ① 获取所有样式表并处理为数组,提取包含 rule.type === CSSRule.FONT_FACE_RULE 规则,再提取包含 src 的 rules。
    ② 下载资源,将资源转为dataUrl并给 src 使用。

  3. 处理图片
    ① 图片都处理为dataUrl

  4. 序列化dom节点为字符串,然后在 foreignObject 嵌入转换好的字符串,foreignObject 能够在 svg 内部嵌入XHTML,再将svg处理为dataUrl数据。

  5. 用 canvas 渲染出处理好的 dataUrl 数据。

  6. 最后,拿到canvas了,想怎样处理都行了。

参考

SVG <foreignObject>简介与截图等应用

gitlab提取了源文件出来,并写了注释,有兴趣可以自己研究下,看看其中的工作方式。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,732评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,496评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,264评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,807评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,806评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,675评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,029评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,683评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 41,704评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,666评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,773评论 1 332
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,413评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,016评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,978评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,204评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,083评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,503评论 2 343

推荐阅读更多精彩内容