用canvans画一个流程图

效果图

组件代码如下:

js 复制代码
<template>
  <div class="g6_main">
    <canvas
      @click="clickCanvas"
      :width="canvasWidth"
      :height="canvasHeight"
      class="g6_main_content"
    ></canvas>
    <el-dialog
      title="审批流程节点信息"
      :visible.sync="dialogData.show"
      width="40%"
      top="20vh"
      custom-class="g6_dia"
      :append-to-body="true"
    >
      <div class="c333 fs16" style="padding: 15px 0 30px 0">
        <div class="t-c">
          <span>{{
            `${dialogData.actionName}&nbsp;&nbsp;&nbsp;${dialogData.approveDesc}`
          }}</span>
        </div>
      </div>
    </el-dialog>
  </div>
</template>

<script>
  import cloneDepp from "lodash/cloneDeep";

  /**
   * 每一项数据带有nodeConfig节点对象,记录数据节点配置
   *
   * this.nodesData 数据结构:
   * [
   * [[--这里面是真正的数据也是个数组--], [], [], [], []], // 第一行 每行数组长度为 this.lineNum
   * [[], [], [], [], []], // 第二行
   * ]
   */
  const lineWidth = 4; // 线条宽度
  const lineColor = "#d3e3f4"; //  线条颜色
  export default {
    name: "Approve-G6",
    data() {
      return {
        nodesData: [],
        originData: [], // 原始数据
        canvasWidth: 0,
        canvasHeight: 0,
        lineNum: 5, // 每一行可以放多少个节点
        lineHGap: 140, // 节点之间的水平间隔 为了好看建议在 120 ~ 200 之间
        lineVGap: 60, // 节点之间的垂直间隔
        nodeConfig: {
          nodeVGap: 15, // 单个节点内部之间上下间隔
          w: 190, // 节点宽度
          h: 90, // 节点高度
        },
        lineCenterY: [], // 每一行的中心Y轴坐标
        dialogData: {
          show: false,
          actionName: "",
          approveDesc: "",
        },
        scale: 1, // 缩放比例
        canvasTranlate: {
          x: 0,
          y: 0,
        },
        resizeObserver: null,
      };
    },
    mounted() {
      this.observeParentDom();
    },
    beforeUnmount() {
      this.resizeObserver.disconnect();
    },
    methods: {
      destoryCanvas() {
        const instance = this.getCanvasInstance();
        if (instance) {
          instance.clearRect(0, 0, this.canvasWidth, this.canvasHeight);
          instance.resetTransform();
        }
      },
      // 点击画布
      clickCanvas(evt) {
        let { offsetX, offsetY } = evt;
        for (let i = 0; i < this.nodesData.length; i++) {
          for (let k = 0; k < this.nodesData[i].length; k++) {
            if (!this.nodesData[i][k]?.length) continue;
            for (let j = 0; j < this.nodesData[i][k].length; j++) {
              let { x, y, w, h } = this.nodesData[i][k][j]["nodeConfig"];
              let inRect = this.isPointInRect(
                x,
                y,
                w,
                h,
                offsetX - this.canvasTranlate.x,
                offsetY - this.canvasTranlate.y
              );
              if (inRect) {
                let { actionName, approveDesc } = this.nodesData[i][k][j];
                this.showApproveInfo(actionName, approveDesc);
                return void 0;
              }
            }
          }
        }
      },
      // 判断点是否在矩形内
      isPointInRect(x, y, w, h, x1, y1) {
        return x <= x1 && x1 <= x + w && y <= y1 && y1 <= y + h;
      },
      // 显示审批信息
      showApproveInfo(actionName, approveDesc) {
        this.dialogData.actionName = actionName;
        this.dialogData.approveDesc = approveDesc;
        this.dialogData.show = true;
      },
      // 获取组件父级宽度大小
      getFatherSize() {
        if (!this.$el.parentElement) return;

        const { offsetWidth } = this.$el.parentElement;
        document.querySelector(".g6_main_content").style.width =
          offsetWidth + "px";
        this.canvasWidth = offsetWidth * this.scale;
      },
      /**
       * 将数组分组,每组包含lineNum个元素,不足的补冲默认数组,并将分组后的奇数行倒序
       * @param arr
       * @param chunkSize
       */
      chunkArray(arr, chunkSize) {
        const result = [];
        let isOdd = 0;
        let placeholder = [];
        for (let i = 0; i < arr.length; i += chunkSize) {
          let chunk = arr.slice(i, i + chunkSize);
          if (chunk.length < chunkSize) {
            chunk = chunk.concat(
              new Array(chunkSize - chunk.length)
                .fill(0)
                .map((v) => JSON.parse(JSON.stringify(placeholder)))
            );
          }
          if (isOdd % 2 === 1) {
            chunk = chunk.reverse();
          }
          isOdd++;
          result.push([...chunk]);
        }
        return result;
      },
      // 根据实际数据计算canvas高度
      getCanvasHeight(d) {
        let h = 0;
        let preLineH = 0; // 每一行的高度
        for (let i = 0; i < d.length; i++) {
          preLineH = 0;
          for (let j = 0; j < d[i].length; j++) {
            if (!d[i][j]?.length) continue;
            let len = d[i][j].length;
            let VGap = (len - 1) * this.nodeConfig.nodeVGap;
            let nodeH = len * this.nodeConfig.h;
            preLineH = Math.max(preLineH, nodeH + VGap);
          }
          h += preLineH + this.lineVGap;
        }
        h -= this.lineVGap;
        document.querySelector(".g6_main_content").style.height = h + "px";
        this.canvasHeight = Math.ceil(h * this.scale);
      },
      // 获取画布实例
      getCanvasInstance() {
        const canvas = document.querySelector(".g6_main_content");
        const ctx = canvas?.getContext("2d");
        return canvas ? ctx : null;
      },
      /**
       * 扩展:支持不同圆角半径
       * @param {Object} radii - 圆角半径配置
       * @param {number} radii.topLeft - 左上角半径
       * @param {number} radii.topRight - 右上角半径
       * @param {number} radii.bottomRight - 右下角半径
       * @param {number} radii.bottomLeft - 左下角半径
       */
      drawRoundRectAdvanced(ctx, x, y, width, height, radii) {
        ctx.beginPath();
        ctx.globalCompositeOperation = "source-over"; // 修改混合模式
        // 左上角
        ctx.moveTo(x + radii.topLeft, y);
        ctx.arcTo(x + width, y, x + width, y + radii.topRight, radii.topRight);
        // 右下角
        ctx.arcTo(
          x + width,
          y + height,
          x + width - radii.bottomRight,
          y + height,
          radii.bottomRight
        );
        // 左下角
        ctx.arcTo(
          x,
          y + height,
          x,
          y + height - radii.bottomLeft,
          radii.bottomLeft
        );
        // 左上角
        ctx.arcTo(x, y, x + radii.topLeft, y, radii.topLeft);
        ctx.closePath();
      },
      // 绘制节点
      drawNode(ctx, cfg) {
        let fillColor = ["#d4eaff", "#FFFFFF"];
        let { x, y, w, h } = cfg;
        let o = {
          topLeft: 8,
          topRight: 8,
          bottomRight: 8,
          bottomLeft: 8,
        };
        for (let i = 0; i < 2; i++) {
          if (i === 0) {
            o.bottomRight = 0;
            o.bottomLeft = 0;
            o.topLeft = 8;
            o.topRight = 8;
          } else {
            o.bottomRight = 8;
            o.bottomLeft = 8;
            o.topLeft = 0;
            o.topRight = 0;
          }
          this.drawRoundRectAdvanced(ctx, x, !i ? y : y + h / 2, w, h / 2, o);
          ctx.fillStyle = fillColor[i];
          ctx.fill();
        }
        // 绘制边框
        ctx.strokeStyle = "#7EA7CE";
        ctx.lineWidth = 1;
        ctx.beginPath();
        ctx.roundRect(x, y, w, h, 8);
        ctx.stroke();
      },
      // 处理字符串超出长度
      fittingString(s, len = 10) {
        if (s.length <= len) {
          return s;
        }
        return s.substring(0, len) + "...";
      },
      // 绘制居中文字
      drawCenteredText(ctx, node) {
        let { x, y, w, h } = node.nodeConfig;
        y = y - h / 4;
        const paddingUp = 8;
        const paddingleft = 8;
        const cpaddingUp = 30;
        const cpaddingLeft = -5;
        const textX = x + paddingleft + (w - 2 * paddingleft) / 2;
        const textY = y + paddingUp + (h - 2 * paddingUp) / 2;
        const circleX = x + cpaddingLeft + (w - 20 * paddingleft) / 4;
        const circleY = y + cpaddingUp + (h - 2 * paddingUp) / 2;
        ctx.beginPath();
        // 设置文字对齐方式
        ctx.textAlign = "center";
        ctx.font = "15px Arial";
        ctx.textBaseline = "middle";
        ctx.fillStyle = "#0062A9";

        // 绘制文字
        let { approveSort, actionName } = node;
        ctx.fillText(this.fittingString(actionName, 10), textX, textY);
        this.drawNumberCircle(ctx, circleX, circleY, 12, approveSort);
        ctx.shadowBlur = 0; // 阴影模糊程度,数值越大越模糊
        ctx.closePath();
      },
      // 绘制带序号的圆形节点
      drawNumberCircle(ctx, x, y, radius, number) {
        ctx.beginPath();
        ctx.shadowColor = "rgba(0, 0, 0, 0.3)"; // 半透明黑色阴影,更自然
        ctx.shadowBlur = 10; // 阴影模糊程度,数值越大越模糊
        ctx.shadowOffsetX = 0; // 水平偏移量为0
        ctx.shadowOffsetY = 0; // 垂直偏移量为0(两者均为0,实现四周均匀阴影)
        ctx.arc(x - 2, y, radius, 0, Math.PI * 2);
        ctx.fillStyle = "#fff";
        ctx.fill();
        // ctx.strokeStyle = "red";// 设置边框颜色
        // ctx.stroke();// 绘制边框
        ctx.fillStyle = "#1A4F9B"; // 设置字体颜色
        ctx.font = "bold 14px Arial";
        ctx.textAlign = "center";
        ctx.textBaseline = "middle";
        ctx.fillText(number, x - 2, y + 1);
      },
      drawCenteredDescText(ctx, node) {
        let { x, y, w, h } = node.nodeConfig;
        y = y + h / 4;
        const paddingUp = 8;
        const paddingleft = 12;
        let textX = x + paddingleft + (w - 2 * paddingleft) / 2;
        let textY = y + paddingUp + (h - 2 * paddingUp) / 2;

        // 设置文字对齐方式
        ctx.textAlign = "center";
        ctx.font = "bold 12px Arial";
        ctx.textBaseline = "middle";
        ctx.fillStyle = "#333333";

        // 绘制文字
        let { approveDesc } = node;
        const lineWordNum = 14;
        approveDesc = this.fittingString(approveDesc, lineWordNum * 2 - 1);
        if (approveDesc.length > 12) {
          ctx.textAlign = "start";
          textX = textX - (w - 2 * paddingleft) / 2;
          textY = textY - paddingUp;
          ctx.fillText(approveDesc.substring(0, lineWordNum), textX, textY);
          ctx.fillText(
            approveDesc.substring(lineWordNum, approveDesc.length),
            textX,
            textY + 20
          );
        } else {
          ctx.fillText(approveDesc, textX, textY);
        }
        // ctx.fillText(, textX, textY, w, h);
      },
      // 根据数据绘制节点
      drawNodes(ctx, nodesData) {
        for (let i = 0; i < nodesData.length; i++) {
          for (let k = 0; k < nodesData[i].length; k++) {
            if (!nodesData[i][k]?.length) continue;
            for (let j = 0; j < nodesData[i][k].length; j++) {
              let { nodeConfig } = nodesData[i][k][j];
              this.drawNode(ctx, nodeConfig);
              this.drawCenteredText(ctx, nodesData[i][k][j]);
              this.drawCenteredDescText(ctx, nodesData[i][k][j]);
            }
          }
        }
      },
      // 计算画布每一行绘制的统一Y中心轴坐标,后续绘制实际节点时,根据该Y轴坐标进行响应计算并绘制
      getLineCenterY(d) {
        let saveCenterY = [];
        let lastLineY = 0; // 上一行的计算到的y轴底部坐标
        for (let i = 0; i < d.length; i++) {
          let curLineH = 0; // 当前行节点中最大节点高度
          for (let k = 0; k < d[i].length; k++) {
            if (!d[i][k]?.length) continue;
            for (let j = 0; j < d[i][k].length; j++) {
              let len = d[i][k].length;
              let nodeRealHeight =
                len * this.nodeConfig.h + (len - 1) * this.nodeConfig.nodeVGap;
              curLineH = Math.max(curLineH, nodeRealHeight);
            }
          }
          saveCenterY.push(lastLineY + curLineH / 2);
          lastLineY += curLineH + this.lineVGap;
        }
        return saveCenterY;
      },
      // 根据行中心坐标以及节点中矩形元素个数获取第一个矩形的Y坐标
      getFirstRectY(centerY, len) {
        let realHeightHaft =
          (len * this.nodeConfig.h + (len - 1) * this.nodeConfig.nodeVGap) / 2;
        return centerY - realHeightHaft;
      },
      // 获取实际元素四边的中点坐标
      getCenterPointerEgde(cfg) {
        let { x, y, w, h } = cfg;
        return {
          topCenter: [x + w / 2, y], // 上中
          rightCenter: [x + w, y + h / 2], // 右中
          bottomCenter: [x + w / 2, y + h], // 下中
          leftCenter: [x, y + h / 2], // 左中
        };
      },
      // 判断path上箭头方向
      getArrowDirection(isOdd) {
        if (!Number.isInteger(isOdd)) return "v";
        if (isOdd % 2 === 0) return ">"; // ^表示上,v表示下,<和>
        if (isOdd % 2 === 1) return "<";
      },
      // 绘制边上的圆形
      drawEgdeCircle(ctx, x, y, r) {
        r = r || 10;
        // 开始路径
        ctx.beginPath();
        // 设置填充色
        ctx.fillStyle = "#6d758c";
        // 圆心位置 (x, y), 半径, 起始角度, 结束角度, 方向
        ctx.arc(x, y, r, 0, Math.PI * 2, false);
        // 填充
        ctx.fill();
        ctx.closePath();
      },
      // 绘制边上的圆形箭头
      drawEdgeArrow(ctx, textX, textY, isOdd) {
        ctx.beginPath();
        ctx.globalCompositeOperation = "source-over"; // 修改混合模式
        const arrowTxt = this.getArrowDirection(isOdd);
        ctx.textAlign = "center";
        ctx.font = "20px Arial";
        ctx.textBaseline = "middle";
        ctx.fillStyle = "#FFFFFF";
        ctx.fillText(arrowTxt, textX, textY + 1);
        ctx.closePath();
      },
      // 绘制水平边
      drawHorizontalLineEdge(ctx, start, end, centerY, isOdd) {
        // 左边节点起点
        let sKey = "rightCenter";
        let circleX = -1;
        for (let i = 0; i < start.length; i++) {
          ctx.beginPath();
          ctx.globalCompositeOperation = "destination-over"; // 修改混合模式
          let { anchor } = start[i].edgeConfig;
          let posi = anchor[sKey];
          if (circleX === -1) {
            circleX = posi[0] + this.lineHGap / 2;
          }
          ctx.moveTo(posi[0], posi[1]);
          ctx.lineTo(posi[0] + this.lineHGap / 2, centerY);
          ctx.lineWidth = lineWidth; // 设置线的宽度
          ctx.strokeStyle = lineColor; // 设置线的颜色
          ctx.lineCap = "round";
          ctx.stroke();
          ctx.closePath();
        }
        let eKey = "leftCenter";
        for (let i = 0; i < end.length; i++) {
          this.drawEgdeCircle(ctx, circleX, centerY, 10);
          this.drawEdgeArrow(ctx, circleX, centerY, isOdd);
        }
        for (let i = 0; i < end.length; i++) {
          this.drawEgdeCircle(ctx, circleX, centerY, 10);
          this.drawEdgeArrow(ctx, circleX, centerY, isOdd);
          ctx.beginPath();
          ctx.globalCompositeOperation = "destination-over"; // 修改混合模式
          let { anchor } = end[i].edgeConfig;
          let posi = anchor[eKey];
          ctx.moveTo(posi[0] - this.lineHGap / 2, centerY);
          ctx.lineTo(posi[0], posi[1]);
          ctx.lineWidth = lineWidth; // 设置线的宽度
          ctx.strokeStyle = lineColor; // 设置线的颜色
          ctx.lineCap = "round";
          ctx.stroke();
          ctx.closePath();
        }
      },
      // 绘制垂直边
      drawVerticalLineEdge(ctx, start, end) {
        let { bottomCenter } = start["edgeConfig"]["anchor"];
        let { topCenter } = end["edgeConfig"]["anchor"];
        this.drawEgdeCircle(
          ctx,
          bottomCenter[0],
          (bottomCenter[1] + topCenter[1]) / 2,
          10
        );
        this.drawEdgeArrow(
          ctx,
          bottomCenter[0],
          (bottomCenter[1] + topCenter[1]) / 2
        );
        ctx.beginPath();
        ctx.globalCompositeOperation = "destination-over"; // 修改混合模式
        ctx.moveTo(bottomCenter[0], bottomCenter[1]);
        ctx.lineTo(topCenter[0], topCenter[1]);
        ctx.lineWidth = lineWidth; // 设置线的宽度
        ctx.strokeStyle = lineColor; // 设置线的颜色
        ctx.lineCap = "round";
        ctx.stroke();
        ctx.closePath();
      },
      // 集中执行 drawEdge
      drawEdges(ctx, nodes) {
        let isOdd = 0;

        for (let i = 0; i < nodes.length; i++) {
          for (let k = 0; k < nodes[i].length; k++) {
            if (!nodes[i][k + 1]?.length) continue;
            if (!nodes[i][k]?.length || k === nodes[i].length - 1) continue;
            this.drawHorizontalLineEdge(
              ctx,
              nodes[i][k],
              nodes[i][k + 1],
              this.lineCenterY[i],
              isOdd
            );
          }
          let hasNextLine =
            nodes[i]?.length &&
            nodes[i].length === this.lineNum &&
            nodes[i + 1]?.length; // 是否有下一行数据
          if (hasNextLine) {
            let startNodes = nodes[i][isOdd % 2 === 0 ? this.lineNum - 1 : 0];
            let endNodes = nodes[i + 1][isOdd % 2 === 0 ? this.lineNum - 1 : 0];
            this.drawVerticalLineEdge(
              ctx,
              startNodes[startNodes.length - 1],
              endNodes[0]
            );
          }
          isOdd++;
        }
      },
      /**
       * 如果数据只有一行,且一行元素个数小于this.lineNum时,图形平移至画布中心
       * 否则 根据画布余量 图形平移至画布中心
       * @param ctx 画布上下文
       */
      translateCenterCanvas(ctx) {
        let lineOneRealNum = this.nodesData[0].filter(
          (item) => item?.length
        ).length;
        let contentWidth = 0;

        if (this.nodesData.length === 1 && lineOneRealNum < this.lineNum) {
          contentWidth =
            lineOneRealNum * this.nodeConfig.w +
            (lineOneRealNum - 1) * this.lineHGap;
        } else {
          contentWidth =
            this.lineNum * this.nodeConfig.w +
            (this.lineNum - 1) * this.lineHGap;
        }

        const canvasWidth = this.canvasWidth / this.scale;
        const restLen = Math.max(0, canvasWidth - contentWidth);
        this.canvasTranlate.x = restLen / 2;
        ctx.translate(restLen / 2, 0);
      },
      // 计算一行放多少个节点
      getLineNum(w) {
        let lineNum = Math.floor(w / this.nodeConfig.w);
        let accumulatedW = 0;
        for (let k = 1; k <= lineNum; k++) {
          accumulatedW += this.nodeConfig.w + this.lineHGap;
          if (w < accumulatedW) {
            if (w < accumulatedW - this.lineHGap) {
              lineNum = k - 1;
            } else {
              lineNum = k;
            }
            break;
          }
        }
        this.lineNum = lineNum;
      },
      initCanvas(nodes) {
        this.originData = cloneDepp(nodes);
        this.scale = window.devicePixelRatio || 1;
        this.getFatherSize();
        let realCanvasWidth = this.canvasWidth / this.scale; // 真实画布作画宽度 在缩放屏幕时,画布大小会发生变化,所以需要将画布内容限制在css尺寸内
        this.getLineNum(realCanvasWidth);
        if (nodes?.length) {
          this.nodesData = this.chunkArray(nodes, this.lineNum);
          this.getCanvasHeight(this.nodesData);
          this.lineCenterY = this.getLineCenterY(this.nodesData);

          this.$nextTick(() => {
            let ctx = this.getCanvasInstance();
            ctx.resetTransform();
            // 缩放上下文
            ctx.scale(this.scale, this.scale);
            ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight);
            if (this.scale > 1) {
              ctx.imageSmoothingEnabled = false;
            }

            this.translateCenterCanvas(ctx);
            for (let i = 0; i < this.nodesData.length; i++) {
              let lineItem = this.nodesData[i];
              for (let k = 0; k < lineItem.length; k++) {
                if (!lineItem[k]?.length) continue;
                let lineItemtarget = lineItem[k];
                let lineItemtargetLen = lineItemtarget.length;
                let startY = this.getFirstRectY(
                  this.lineCenterY[i],
                  lineItemtargetLen
                ); // 第一个矩形的起始y坐标
                for (let j = 0; j < lineItemtarget.length; j++) {
                  let cfg = {
                    x: 0 + k * this.lineHGap + this.nodeConfig.w * k + 4, // 4是整体向右偏移4
                    y:
                      startY +
                      j * this.nodeConfig.h +
                      j * this.nodeConfig.nodeVGap,
                    w: this.nodeConfig.w,
                    h: this.nodeConfig.h,
                  };
                  let edgeCfg = {
                    anchor: this.getCenterPointerEgde(cfg),
                  };
                  lineItemtarget[j].nodeConfig = cfg;
                  lineItemtarget[j].edgeConfig = edgeCfg;
                }
              }
            }
            this.drawNodes(ctx, this.nodesData);
            this.drawEdges(ctx, this.nodesData);
          });
        }
      },
      resizeDom() {
        this.destoryCanvas();
        this.initCanvas(this.originData);
      },
      observeParentDom() {
        if (!this.$el.parentElement) return;
        if (this.resizeObserver) {
          this.resizeObserver.disconnect();
          this.resizeObserver = null;
          return;
        }
        const resizeObserver = new ResizeObserver((entries) => {
          for (const entry of entries) {
            this.resizeDom();
          }
        });

        resizeObserver.observe(this.$el.parentElement);
        this.resizeObserver = resizeObserver;
      },
    },
  };
</script>

<style lang="less">
  .g6_dia {
    .el-dialog__header .el-dialog__title {
      font-size: 18px;
    }
  }
</style>

页面使用组件:

xml 复制代码
<template>
    <div>
        <div><ApproveG6 ref="canvasApprove"></ApproveG6></div>
    </div>
</template>
js 复制代码
<script>
methods: {
    // 接口返回的数据用这个方法来处理结构
    handlerData(dataList){
        let list = [];
        if (
          Array.isArray(dataList) && dataList.length) {
          dataList.forEach((o, ind) => {
            if (
              ind == 0 ||
              (ind > 0 &&
                o.approveSort !=
                  dataList[ind - 1].approveSort)
            ) {
              list[o.approveSort - 1] = [];
            }
            list[o.approveSort - 1].push(o);
          });

          list = list.filter((o) => {
            return o.length;
          });

          this.endStep = list.length || 0;

          if (list.length % 4 != 0) {
            const num = 4 - (list.length % 4);
            for (let i = 0; i < num; i++) {
              list.push([]);
            }
          }
        }
        list = list.filter((v) => !!v?.length);
        this.$nextTick(() => {
            this.$refs.canvasApprove?.initCanvas(list);
          });
    }
}
</script>
相关推荐
大金乄1 小时前
TreeSelect 是一个基于 Element UI 的树形选择器组件,结合了 el-select 和 el-tree 的功能,支持单选和多选模式,支持树形
前端
大金乄2 小时前
自动构建打包脚本(开发环境)
前端
jerrywus2 小时前
为什么每个程序员都应该试试 cmux:AI 加持的终端效率革命
前端·人工智能·claude
codeniu2 小时前
@logicflow/vue-node-registry 在 Vite 中无法解析的踩坑记录与解决方案
前端·javascript
孟祥_成都2 小时前
AI 术语满天飞?90% 的人只懂名词,不懂为什么!
前端·人工智能
Lupino2 小时前
被 React “玩弄”的 24 小时:为了修一个不存在的 Bug,我给大模型送了顿火锅钱
前端·react.js
米丘2 小时前
了解 Javascript 模块化,更好地掌握 Vite 、Webpack、Rollup 等打包工具
前端
Heo2 小时前
深入 React19 Diff 算法
前端·javascript·面试
滕青山2 小时前
个人所得税计算器 在线工具核心JS实现
前端·javascript·vue.js