pgadmin的导出图实现,还在搞先美容后拍照再恢复?

38 阅读4分钟

PostgreSQL18的pgadmin中有一个ERDTool.jsx有1132行,这个体量理论上说非常庞大,但做过现实工程的都知道,其实只能算重组件中的mini尺寸了。pgadmin功能并不算有多丰富,怎么还是做成这样呢,当然不是维护团队不会拆分,毕竟还是做了199个jsx的。

首先映入眼帘的是registerEvents对19个EventBus的监听,这东西让人倒吸一口凉气,其中最醒目的莫过于this.eventBus.registerListener(ERD_EVENTS.DOWNLOAD_IMAGE, this.onImageClick),名字就不对劲,实现能如何呢?那么看一下这个onImageClick()

onImageClick() {
    this.setLoading(gettext('Preparing the image...'));

    /* Move the diagram temporarily to align it to top-left of the canvas so that when
     * taking the snapshot all the nodes are covered. Once the image is taken, repaint
     * the canvas back to original state.
     * Code referred from - zoomToFitNodes function.
     */
    this.diagramContainerRef.current?.classList.add('ERDTool-html2canvasReset');
    const margin = 10;
    let nodesRect = this.diagram.getEngine().getBoundingNodesRect(this.diagram.getModel().getNodes());
    let linksRect = this.diagram.getBoundingLinksRect();

    // Check what is to the most top left - links or nodes?
    let topLeftXY = {
      x: nodesRect.getTopLeft().x,
      y: nodesRect.getTopLeft().y
    };
    if(topLeftXY.x > linksRect.TL.x) {
      topLeftXY.x = linksRect.TL.x;
    }
    if(topLeftXY.y > linksRect.TL.y) {
      topLeftXY.y = linksRect.TL.y;
    }
    topLeftXY.x -= margin;
    topLeftXY.y -= margin;

    let canvasRect = this.canvasEle.getBoundingClientRect();
    let canvasTopLeftOnScreen = {
      x: canvasRect.left,
      y: canvasRect.top
    };
    let nodeLayerTopLeftPoint = {
      x: canvasTopLeftOnScreen.x + this.diagram.getModel().getOffsetX(),
      y: canvasTopLeftOnScreen.y + this.diagram.getModel().getOffsetY()
    };
    let nodesRectTopLeftPoint = {
      x: nodeLayerTopLeftPoint.x + topLeftXY.x,
      y: nodeLayerTopLeftPoint.y + topLeftXY.y
    };

    let prevTransform = this.canvasEle.querySelector('div').style.transform;
    this.canvasEle.childNodes.forEach((ele)=>{
      ele.style.transform = `translate(${nodeLayerTopLeftPoint.x - nodesRectTopLeftPoint.x}px, ${nodeLayerTopLeftPoint.y - nodesRectTopLeftPoint.y}px) scale(1.0)`;
    });

    // Capture the links beyond the nodes as well.
    const linkOutsideWidth = linksRect.BR.x - nodesRect.getBottomRight().x;
    const linkOutsideHeight = linksRect.BR.y - nodesRect.getBottomRight().y;
    this.canvasEle.style.width = this.canvasEle.scrollWidth + (linkOutsideWidth > 0 ? linkOutsideWidth : 0) + margin + 'px';
    this.canvasEle.style.height = this.canvasEle.scrollHeight + (linkOutsideHeight > 0 ? linkOutsideHeight : 0) + margin + 'px';

    setTimeout(()=>{
      let width = this.canvasEle.scrollWidth + 10;
      let height = this.canvasEle.scrollHeight + 10;
      let isCut = false;
      /* Canvas limitation - https://html2canvas.hertzen.com/faq */
      if(width >= 32767){
        width = 32766;
        isCut = true;
      }
      if(height >= 32767){
        height = 32766;
        isCut = true;
      }
      toPng(this.canvasEle, {width, height, pixelRatio: this.state.preferences.image_pixel_ratio || 1})
        .then((dataUrl)=>{
          DownloadUtils.downloadBase64UrlData(dataUrl, `${this.getCurrentProjectName()}.png`);
        }).catch((err)=>{
          console.error(err);
          let msg = gettext('Unknown error. Check console logs');
          if(err.name) {
            msg = `${err.name}: ${err.message}`;
          }
          pgAdmin.Browser.notifier.alert(gettext('Error'), msg);
        }).then(()=>{
          /* Revert back to the original CSS styles */
          this.diagramContainerRef.current.classList.remove('ERDTool-html2canvasReset');
          this.canvasEle.style.width = '';
          this.canvasEle.style.height = '';
          this.canvasEle.childNodes.forEach((ele)=>{
            ele.style.transform = prevTransform;
          });
          this.setLoading(null);
          if(isCut) {
            pgAdmin.Browser.notifier.alert(gettext('Maximum image size limit'),
              gettext('The downloaded image has exceeded the maximum size of 32767 x 32767 pixels, and has been cropped to that size.'));
          }
        });
    }, 1000);
  }

这种百行级函数,存在合理性且不论,无论如何它都不应该叫xxClick了,毕竟谁敢相信它所有的代码都是为了完成一个导出png功能?

当然只笼统的说它完成了「一个功能」,那也是委屈它了,这函数实质上究竟做了什么?

  • 状态管理: setLoading。
  • DOM 劫持: 直接操作样式和类名。
  • 复杂的几何计算: 处理包围盒(Bounding Box)。
  • IO 操作: 生成图片并触发下载。
  • 异常处理: 恢复状态弹出 notifier 警告。

每一项都是焦点,如果换成Java,这段代码还能再套上10个trycatch膨胀到500行,当然如果换成Java必然能规矩许多,不至于如此粗糙。

当然这函数远不止违法单一原则那么简单,几何计算中 margin = 10 和屏幕坐标转换逻辑非常硬核且粗糙,几乎宣判了这块UI已经不可更改了,同时否定了缩放/偏移变化,非常容易出 off-by-one 错误,已经消耗极大了,不想做复杂只想简单实现也可以克隆DOM做一个离屏渲染,还不需要关心什么margin偏移。

toPng还是html-to-image的,这种场景用这个本身就如同儿戏,而且既然都做这么复杂了,哪怕直接再补上一套原生代码,手动绘制,全丢这函数里,不用任何库,这段代码也不会更丑了。

检测到图片到了浏览器 canvas 限制,就直接剪裁+警告,不做一个执行前popup确认和zoom,可以说有些不可理喻了,现实中这个警告几乎不可能弹出来,因为符合的这个逻辑时,其占用的原始内存将达到惊人的 4GB,做这种巨型 DOM 树时UI会进行密集的像素计算。而且计算是同步的,会直接锁死浏览器主线程!程序早已卡死,一行代码都别想执行了。

还有setTimeout为什么 1000ms?为什么不是 500 或 2000?这是典型的“等它渲染完”的 hack,因为修改 transform / width/height 后,浏览器需要时间重排/重绘,html2canvas才能捕获正确内容。用requestAnimationFrame 循环检查或MutationObserver / ResizeObserver来检测实际变化完成不好么?

最后还是回到名字上,一个函数如果叫“xx点击”,它就没有资格去负责“计算并导出32767像素的位图”。

不过综合来说,这东西整体上也算勉强还行了,毕竟它是一个最终节点组件,而且基本上不太可能被依赖,只是功能性问题,不像它旁边那个1560行有15个useEffect的ResultSet.jsx,那都不能叫组件了,那是试图给React塞一个子系统,等PostgreSQL20发布,估计就没人敢改它了。还有用1300行的FormInput.tsx管理着FormIcon、StyledGrid、FormInput、InputSQL、FormInputSQL等子组件的超级组件,这几乎是想做一套扩展UI库。