写了一个缩略图工具, 生成页面预览图--js-minimap

2,533 阅读5分钟

业务开发时收到一个需求,需要生成一个缩略图让用户拖拽快速移动,本以为应该有很多现成的库来使用,一番搜索后并没有找到,于是自己实现了一下js-minimap, 快速生成缩略图,支持监听内容区域变化自动更新,也就是上面文章封面的效果。

在线演示

preview

实现原理

首先思考基本实现过程其实还是比较清晰简单的:

  1. 根据比例生成内容区域的缩略图
  2. 获取当前内容区域的整体宽度(scrollWidth)、可见宽度(clientWidth)、当前滚动距离(scrollLeft)
  3. 根据2中显示区域的信息,计算出缩略图中阴影区域(标记可见范围)的宽高及位置
  4. 监听内容区域滚动事件,重新计算缩略图阴影区域的位置
  5. 缩略图阴影区域添加拖拽功能,修改内容区域的滚动距离

其中生成缩略图是比较麻烦的地方,其他步骤都是比较常规逻辑

生成缩略图

生成缩略图就是也截屏,一开始想到的是html2canvas,但是html2canvas在截屏的时候只能截出可视区域的范围,所以又换了一个dom-to-image-more,这个库fork自dom-to-image,修复了一些bug(比如内容区有加载失败的图片会导致截屏失败)。

import domToImage from 'dom-to-image-more'

domToImage
    .toPng(container, {
        width: container.scrollWidth,
        height: container.scrollHeight
    })
    .then((dataUrl) => {
        const img = new Image()
        img.className = 'minimap-preview'
        img.src = dataUrl
        img.style.width = mapWidth + 'px'
        img.style.height = mapHeight + 'px'
        mapContainer.appendChild(img)
        resolve()
    })
    .catch(function (error) {
        console.error('oops, something went wrong!', error)
        reject(error)
    })

原理和html2canvas类似,都是从顶层节点开始递归clone节点和样式。

dom-to-image-more核心代码:

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;
        });
      // 使用getComputedStyle克隆样式。
      function cloneStyle() {
        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)
              );
            });
          }
        }
      }
      // clone伪类样式
      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() {
        if (original instanceof HTMLTextAreaElement) { clone.innerHTML = original.value; }
        if (original instanceof HTMLInputElement) { clone.setAttribute("value", original.value); }
      }
      // 处理svg
      function fixSvg() {
        if (!(clone instanceof SVGElement)) return;
        clone.setAttribute("xmlns", "http://www.w3.org/2000/svg");
        ...
      }
    }
  }

计算可视区域范围及位置

获取内容区域(container)的可视宽高、滚动区域宽高、滚动距离。

private getDomSize() {
    const { options } = this
    const { container } = options
    const {
      clientWidth,
      clientHeight,
      scrollWidth,
      scrollHeight,
      scrollLeft,
      scrollTop,
    } = container

    return {
      containerWidth: clientWidth,
      containerHeight: clientHeight,
      containerScrollWidth: scrollWidth,
      containerScrollHeight: scrollHeight,
      containerScrollLeft: scrollLeft,
      containerScrollTop: scrollTop,
    }
}

计算内容区域可视范围占总大小的比例,计算出在缩略图中可视区容器(mapSelector)的大小

private renderMapSelector() {
    const { mapSelector, mapWidth, mapHeight } = this
    const {
      containerWidth,
      containerHeight,
      containerScrollWidth,
      containerScrollHeight,
    } = this.getDomSize()
    const mapSelectorWidth = (containerWidth / containerScrollWidth) * mapWidth
    const mapSelectorHeight =
      (containerHeight / containerScrollHeight) * mapHeight
    mapSelector.style.width = mapSelectorWidth + 'px'
    mapSelector.style.height = mapSelectorHeight + 'px'
}

计算mapSelector的位置

private setMapSelectorPosition() {
    const { mapSelector, mapWidth, mapHeight } = this
    const {
      containerScrollWidth,
      containerScrollHeight,
      containerScrollLeft,
      containerScrollTop,
    } = this.getDomSize()

    const left = containerScrollLeft * (mapWidth / containerScrollWidth)
    const top = containerScrollTop * (mapHeight / containerScrollHeight)
    mapSelector.style.left = left + 'px'
    mapSelector.style.top = top + 'px'
}

监听滚动及缩略图拖拽

给内容容器添加scroll的事件监听,重新设置mapSelector的位置(setMapSelectorPosition),即可让mapSelector跟随内容区域可视区域变化而移动。

在拖拽mapSelector时,可直接设置内容容器的滚动位置,这样在更新内容容器的可视区范围的同时,也自动更新了mapSelector的位置。

兼容内容区域变化

MutationObserver可以自动监听DOM节点及其子节点的变化,如新增/删除节点、文本变化、大小变化...。因此可以使用MutationObserver监听内容区域,自动更新缩略图。

private observeContainer() {
    const observer = new MutationObserver(
      // 防抖
      lodashThrottle((mutationList: MutationRecord[]) => {
        this.reset()
      }, 30),
    )
    observer.observe(this.options.container, {
      subtree: true,
      childList: true,
      attributes: true,
      characterData: true,
    })
}

待优化:采用增量方式优化缩略图的更新

如上所说的dom-to-imagehtml2canvas的工作原理,生成截屏的过程中都会遍历所有子节点再重新渲染出来,这必然是一个很耗费性能的操作,页面约复杂耗时越长,并且每次截屏都是全量更新,当页面变化较快时,如果一直做更新操作,就会卡顿了。

后面又看了一下web录屏方案rrweb的工作原理,录屏就是不停的截屏组成视频,而在截屏时也是遍历子节点,那么如果要保证24帧率,必然会导致卡顿。rrweb的解决方案是,只在开始录屏时完全遍历节点,后续使用MutationObserverEventListener等,监听DOM的变化,根据变化的内容增量更新,只记录和更新发生了变化的部分,这就大大减少的性能的损耗。

目前我的这个工具还没有实现增量更新,因此在使用时,如果页面更新较为频繁导致卡顿,需要设置observe=false,手动控制更新。后续如果有时间的话,会考虑按照此方案进行一下优化。