Live2d Test Env

【论术】g6笔记

我总是在与自己对话,有时是在纸上,有时是在静思中。 ---洪信子

背景:需求是实现一个类似思维导图的图表,不想再使用别人封装了几手的第三方库 索性用下g6。

重要提示:由于项目框架版本是是vue2.6,所以使用的是g6的^4.8.24版本
本文既是一篇需求实现,也是一篇关于g6使用的总结。

实现效果:

image

g6的生命周期

g6跟前端技术框架一样都有生命周期的概念, 通过理解各个生命周期所作的事有助于掌握g6

一场舞台剧为例:从搭建舞台、安排演员到正式演出,最后谢幕拆台。

  1. 初始化(Initialization)

幕后筹备阶段,主要定义画板,定义道具和连接线(如果有)

  • 主要做的事: 实例化图表,配置容器(container)、宽高、模式(modes)、布局(layout)和默认节点/边样式。
  • 主要方法:new G6.Graph(config): 核心构造函数
  • 关键配置

container: 指定 DOM 容器。
modes: 配置如 drag-canvas、zoom-canvas 等交互。
defaultNode / defaultEdge: 定义全局默认外观。

  1. 数据装载阶段 (Data Loading)

将JSON数据填充到g6实例中

  • 主要方法:graph.data(data): 读取数据源。此时 G6 会根据数据创建内部的节点和边实例。
  1. 渲染阶段 (Rendering)

大幕拉开,演员根据剧本就位并开始表演。

  • 主要做的事: 计算布局(如果配置了 layout)、生成图形元素(Shape)、并将其绘制在 Canvas/SVG 上。
  • 主要方法:graph.render(): 最关键的方法。它会触发整个渲染链路。
  • 内部逻辑:

调用 layout 计算每个节点的位置 (x, y)。
根据 defaultNode 和数据中的属性绘制图形。
触发 afterrender 事件。

  1. 更新与交互阶段 (Updating & Interaction)

演出进行中,可能会有剧本改动(数据更新)或观众互动(用户操作)。

  • 主要做的事: 动态修改节点/边的状态、属性,或者响应鼠标事件。
  • 主要方法:

数据更新:

graph.changeData(data): 修改全量数据,会自动重新布局并平滑过渡。
graph.updateItem(item, config): 更新单个节点或边的属性(如颜色、大小)。
graph.addItem(type, model): 动态添加新的节点或边

状态切换:

graph.setItemState(item, state, value): 改变元素状态(如:选中 selected、激活 hover)。

布局变换:

graph.updateLayout(cfg): 动态更换布局算法。

  1. 销毁阶段 (Destruction)

演出结束,清理现场。

  • 主要做的事: 释放内存,移除 DOM 元素,解绑所有事件监听器。
  • 主要方法:

graph.destroy(): 彻底销毁图实例。在开发框架的组件卸载(Unmount)生命周期里,必须调用此方法防止内存泄漏。


个人理解所有的图表框架大致都是走这样的流程:

  1. 图表容器搭建,
  2. 基础内容物组装(定义展示规则)
  3. 数据组装/填充/交互
  4. 图表渲染,(事件交互处理)
    g6也如此,因而也可以基于上述步骤对g6的渲染流程做一个总结,理解了这一核心流程,g6将不再是天堑

1. 图表容器搭建

这一步主要是div+css的搭建,注意要将ref赋予到g6容器上,如果想要实现自适应,则宽高分别设置为100%;如果想要额外加工具栏,则g6容器父容器设置为相对定位,并设置溢出隐藏,防止g6容器内容物溢出。

/* g6的父组件 */
.g6-wrapper {
  position: relative;
  width: 100%;
  height: 100%;
  background-color: #f7f9fb;
  overflow: hidden;
}
/*g6实例,注意加上在dom上加上ref*/
.g6-container {
  width: 100%;
  height: 100%;
}

2.基础内容物组装(定义展示规则) ❗

image

如果上一步是为了搭建展示的舞台,那这一步就是为了定义舞台上的道具规则,比如舞台上应该有什么道具,包括它的形状,位置,包括出现的时机(因何而生?随何而变?)。

想要在g6容器上展示内容,就需要使用G6.registerNode方法来定义节点(也就是容器内容物)。

函数签名如下:

registerNode: (shapeType: string, nodeDefinition: ShapeOptions | ShapeDefine, extendShapeType?: string) => any

必传参数两个:1. 定义的道具名 2.定义的道具配置
这两个的意义:

  1. 定义的道具名可以在下一步数据填充时直接调用, 这个道具名在外部代表了本次定义的道具本身。
  2. ❗定义的道具配置: 可以在这里描述该道具的形状,位置,以及触发的时机。

参数2既支持对象形式也支持函数形式,对象形式下重要的有两个配置项

  • 绘制 draw: (cfg: ModelConfig, group: IGroup) => IShape
  • 获取控制点 getAnchorPoints?: (cfg?: ModelConfig) => number[][] | undefined
  • cfg指的是当前在绘制的节点原始信息和业务数据,可以根据它来判断当前在画的是哪个节点,注意:它是只读的

这里要讲的太过繁琐,注意理解截图内容
附代码:

import G6 from "@antv/g6";
// 计算元素宽度
const calcNodeWidth = (label, fontSize = 14) => {
  if (!label) return 0;
  const size = G6.Util.getTextSize(label, fontSize);
  return Math.max(size[0] + 30, 100);
};

// 业务相关无需理会
export const colorMap = [
  { fill: "#EAF2FF", text: "#1890FF" }, // L1: 淡蓝色
  { fill: "#FFF7E6", text: "#FA8C16" }, // L2: 淡黄色
  { fill: "#FFF1F0", text: "#F5222D" }, // L3: 淡红色
  { fill: "#E6FFFB", text: "#13C2C2" }, // L4: 淡青色
  { fill: "#F0F5FF", text: "#2F54EB" }, // L5: 淡紫色
];
// 数组转对象, 业务相关无需理会
const levelColorMap = colorMap.reduce((last, pre, idx) => {
  last[idx] = pre;
  return last;
}, {});
// 获取对应的颜色,业务相关无需理会
const getColorIndex = (dataSort) => {
  if (!dataSort) return 0;
  if (dataSort.startsWith("L2")) {
    return 1;
  }
  const match = dataSort.match(/L(\d+)/);
  if (match) {
    const level = parseInt(match[1]);
    return Math.max(0, level - 1);
  }
  return 0;
};

G6.registerNode("mindmap-node", {
  draw: (cfg, group) => {
    if (cfg.id === "virtual-root") {
      return group.addShape("rect", {
        attrs: { width: 0, height: 0, opacity: 0 },
      });
    }
    const colorIndex = getColorIndex(cfg.dataSort);
    const levelStyle = colorMap[colorIndex] || colorMap[0];
    const nodeWidth = calcNodeWidth(cfg.label);
    const nodeHeight = 40;
    // 主容器
    const keyShape = group.addShape("rect", {
      attrs: {
        x: 0,
        y: -nodeHeight / 2,
        width: nodeWidth,
        height: nodeHeight,
        radius: 4,
        fill: levelStyle.fill,
        lineWidth: 0,
        cursor: "pointer",
      },
      name: "main-box",
    });

    group.addShape("text", {
      /*
      想要文字居中: 1. x要是父容器的一半 2.textAlign要居中  两者缺一则不可实现居中效果
      */
      attrs: {
        text: cfg.label,
        x: nodeWidth / 2,
        y: 0,
        textAlign: "center",
        textBaseline: "middle",
        fill: levelStyle.text,
        fontSize: 14,
        cursor: "pointer",
      },
      name: "node-label",
    });

    if (cfg.children && cfg.children.length > 0) {
      const btnR = 8;
      group.addShape("circle", {
        attrs: {
          x: nodeWidth,
          y: 0,
          r: btnR,
          fill: "#fff",
          stroke: levelStyle.text,
          lineWidth: 1,
          cursor: "pointer",
        },
        name: "collapse-icon-bg",
      });
      group.addShape("path", {
        attrs: {
          path: [
            ["M", nodeWidth - 4, 0],
            ["L", nodeWidth + 4, 0],
          ],
          stroke: levelStyle.text,
          lineWidth: 2,
          cursor: "pointer",
        },
        name: "collapse-icon-line-h",
        capture: false,
      });
      if (cfg.collapsed) {
        group.addShape("path", {
          attrs: {
            path: [
              ["M", nodeWidth, -4],
              ["L", nodeWidth, 4],
            ],
            stroke: levelStyle.text,
            lineWidth: 2,
            cursor: "pointer",
          },
          name: "collapse-icon-line-v",
          capture: false,
        });
        // console.log("cfg :>> ", cfg);
        //  如果当前节点有子级,就渲染角标到右上角。
        if (cfg.children && cfg.children.length > 0) {
          const childCount = cfg.children.length; // 子节点数量
          /* 当前节点处于折叠态下角标的文字变化 */
          // 1. 获取文字内容
          const textStr = childCount > 99 ? "99+" : String(childCount);
          // 2. 定义基础样式参数
          const fontSize = 10;
          const badgeHeight = 16;
          const paddingX = 8;
          // 3. 计算文字真实宽度
          const textWidth = G6.Util.getTextSize(textStr, fontSize)[0];
          // 4. 计算角标总宽度
          // 逻辑:宽度 = 文字宽 + 内边距。但不能小于高度 (保证个位数时是正圆)
          const badgeWidth = Math.max(badgeHeight, textWidth + paddingX);
          const radius = badgeHeight / 2; // 半径设为高度一半 -> 形成圆角胶囊
          const bgX = nodeWidth - badgeWidth / 2;
          const bgY = -nodeHeight / 2 - badgeHeight / 2;
          /*  */
          group.addShape("rect", {
            attrs: {
              x: bgX,
              y: bgY,
              width: badgeWidth,
              height: badgeHeight,
              radius: radius, // 关键:高度的一半
              fill: "#FF4D4F",
              stroke: "#fff",
              lineWidth: 1,
            },
            name: "child-count-bg",
            capture: false,
          });

          group.addShape("text", {
            attrs: {
              // 文字 x = 背景 x + 宽度一半
              x: bgX + badgeWidth / 2,
              y: bgY + badgeHeight / 2 + 1, // +1 微调垂直居中
              text: textStr,
              fontSize: fontSize,
              fill: "#fff",
              textAlign: "center",
              textBaseline: "middle",
              fontWeight: "bold",
              fontFamily: "Arial, sans-serif",
            },
            name: "child-count-text",
            capture: false,  // 防止鼠标穿透
          });
        }
      }
    }
    return keyShape;
  },
  getAnchorPoints: (cfg) => {
    if (cfg.id === "virtual-root") return [];
    return [
      [0, 0.5],
      [1, 0.5],
    ];
  },
});

注意点

  1. keyShape对象在draw函数中的意义:g6在渲染该道具时的标识,在一个draw函数中,返回的这个keyShape将被作为主体渲染,有且仅有一个,而在draw函数中使用group.addShape绘制的其他道具将作为keyShape的装饰品存在,换句话说,在当前draw函数中,keyShape代表的就是道具本身。getAnchorPoints所定位的坐标点,也是基于keyShape的坐标作为参照物从而进行的定位.

  2. g6不支持css中的自动宽度,因而定义了calcNodeWidth函数来计算宽度

这里是实现g6绘制的重点,可以按照上述的思维导图一步步理解。

总结:

基础内容物组装需要使用 G6.registerNode(shapeType,ShapeOptions)来实现

shapeType : 定义的道具名,此道具名在渲染时代表此道具本身
ShapeOptions :{draw:(cfg, group)=> keyShape} : 道具配置

draw:(cfg, group)=> keyShape , 描述该道具的形状,位置,以及触发的时机,最终返回keyShape对象作为本次绘制的道具结果实体

cfg : 当前要绘制的节点的基础信息以及填充后的当前节点的数据,还有当前节点的状态(折叠/展开) 注意它是只读的
group : 当前节点的图形容器实例

通过group.addShape()来绘制节点,绘制的节点里也包括draw方法返回的keyShape

keyShape: 作为主容器,其余基于group.addShap画的都是该容器的装饰
group.addShap(type :string,config : object) => shapeInstance: 绘制节点的方法
type:要绘制的形状类型
config :{attrs,name }:要绘制的形状对应的配置

attrs: object 要绘制的详细参数
name :绘制的当前形状的名字
getAnchorPoints:(cfg)=>number[][] | undefined 决定连线从节点的哪个位置与节点连接,目的是为了强制约束连线的落点,注意返回的是二维数组,如果返回的是空数组,表示道具与连线不会有交集
二维数组外层数组的个数决定了有几条连接线与当前道具连接(可供连线接入的插口数量).
二维数组内层数组里每一项描述了每一个连线具体长在道具的哪个部位 [ [x, y],[x,y] ] ,x,y的取值为 0~1
典型值:假设x取值为0/0.5/1: 0为节点左边,0.5为节点水平中心,1为右边
典型值:假设y取值为0/0.5/1: 0为节点顶部,0.5为节点垂直中心,1为底部

3.数据组装/填充/交互 ❗

如果说步骤1是为了搭建舞台,
如果说步骤2是为了定义舞台上应该摆放的物体定义物体的形态,(注意:是静态的,并没有放置在舞台上)
那步骤3就是为了将舞台与g6关联,并将在舞台外的物体放置到舞台内预定的位置,并定义它们该展示的形态(包括交互)

在这里所做的配置画布,将步骤2所定义的道具挂载到画布中:
由于涉及到获取G6的DOM实例,因而在组件挂载完毕后才可执行此步骤
此步骤要处理的有以下这些

  1. 配置画布,以及用户对画布本身的交互能力
  2. 配置节点道具(包括展示逻辑),包括通例和特例
  3. 配置连线(包括展示逻辑),配置通例和特例
  4. 定义节点道具和线的交互能力
  5. 配置初始化数据 (注意,这里只是填充初始化数据)
/*
初始化时配置画布本身和交互
*/
// 配置容器
const container = this.$refs.g6Container;
    this.graph = new G6.TreeGraph({
        container,
        width: container.offsetWidth,  // 配置容器宽高以自适应
        height: container.offsetHeight || 600,
        animate: false,
        modes: {
          default: ["drag-canvas", "zoom-canvas"],  // 提供画布能力,拖拉拽/缩放
        },
        defaultNode: { type: "mindmap-node" }, // 默认渲染节点
        // 默认线
        defaultEdge: {
          type: "polyline",
          style: {
            stroke: "#A3B1BF",
            lineWidth: 1,
            radius: 0,
            offset: 20,
            endArrow: {
              path: G6.Arrow.triangle(5, 6, 0),
              fill: "#A3B1BF",
            },
          },
        },
        layout: {
          type: "mindmap",
          direction: "H",
          getHeight: () => 40,
          getWidth: (node) =>
            node.id === "virtual-root" ? 0 : calcNodeWidth(node.label),  // 动态配置节点宽
          getVGap: () => 24,
          getHGap: (node) => (node.id === "virtual-root" ? 0 : 60),
          getSide: () => "right",
        },
        minZoom: 0.5, // 最小缩放
        maxZoom: 20,  // 最大缩放
        fitView: true,
        fitViewPadding: [20, 40], // 图表内容距离画布边缘的留白 单位是像素
      });

/*
 需求:思维导图的根节点不允许在界面展示,因而须配置特例节点
 在界面上隐匿根节点,返回对象则表述遵循默认节点,注意:g6内部会将这里的返回数据与默认节点进行合并操作,必须返回对象形式
*/
   this.graph.edge((edge) => {
        if (edge.source === "virtual-root") {
          return { style: { opacity: 0, lineWidth: 0, endArrow: false } };
        }
        return {};
      });

/*
 需求:节点点击是要展示和折叠,因而要判断当前点击的是哪个自定义节点,只有是展开或者折叠图标才需要触发
collapse-icon-line-h 、collapse-icon-line-h 指的是加号或减号,
collapse-icon-bg指的是它们的背景本身。
*/
      this.graph.on("node:click", (evt) => {
        const { item, target } = evt;
        const targetName = target.get("name");
        if (
          [
            "collapse-icon-bg",
            "collapse-icon-line-h",
            "collapse-icon-line-v",
          ].includes(targetName)
        ) {
          const model = item.getModel();
          if (model.children?.length) {
            this.graph.updateItem(item, { collapsed: !model.collapsed });
            this.graph.layout();  // 变化后须重新布局
          }
        }
      });

/*
  上述的只是定义而没有应用,接下来才是应用
  g6的初始化处理
*/
      // 初始化数据
      this.graph.data(this.forestData);
      // 重新布局
      this.graph.render();
      // 迫使画布中内容居中并保留40px的 padding
      this.graph.fitView(40);


4.图表渲染 ❗

前三步准备完毕,这一步也就是传入真实数据,然后再让G6重新渲染画布和图表。

// 递归工具函数,将数据格式化成G6需要的格式
const transformData = (nodes) => {
  if (!nodes || !Array.isArray(nodes)) return [];
  return nodes.map((node) => ({
    ...node,
    label: node.dataName, //  dataName -> label
    children: transformData(node.children), 
  }));
};



// 注意,获取数据的函数要在init图表函数之后使用
    async getChartResource() {
      const res = await Api({
        key: "",
      });
      // 重新赋值
      const formattedData = {
        id: "virtual-root",
        children: transformData(res),
      };
      this.forestData = formattedData;
      if (this.graph) {
        // 将修改后的值填充到g6内  this.graph.changeData用于修改填充内的数据
        this.graph.changeData(this.forestData);
        // 使用 $nextTick 确保 DOM准确获取
        this.$nextTick(() => {
          if (!this.graph || this.graph.get("destroyed")) return;
          const container = this.$refs.g6Container;
          const width = container.offsetWidth;
          const height = container.offsetHeight;
          this.graph.changeSize(width, height);
          // 重新布局
          this.graph.layout();
          // 加个毫秒级延时器,确保内部计算完成
          setTimeout(() => {
            this.graph.fitView(40);
          }, 50);
        });
      }
    },

一点补充

一些优化

性能优化: 比如组件卸载前要将图表销毁;比如resize事件时要加防抖函数重新布局
交互优化: 展示加载中和暂无数据文案

在渲染时要用到的api

g6虽然复杂,但是实际上最主要的api却并不多

API 名 舞台角色 动作实质 性能损耗 典型场景 注意点 与之搭配的 API
data(data) 定剧本 纯数据注入。舞台大幕紧闭。 极低 初始化或彻底换戏。 单独使用无效,屏幕不会有任何反应。 render()
render() 大幕开启 从 0 到 1。解析、布局并绘制全场。 极高 第一次初始化,或推翻重演。 消耗巨大,严禁在循环或高频交互中调用。 data(), fitView()
layout() 阵型走位 演员不变,仅重新计算坐标并位移。 中等 节点折叠/展开、手动对齐。 只是挪动位子,不会增删演员,也不会重绘形状。 updateItem(), fitView()
updateItem() 局部补妆 针对单个演员进行追光、变装。 极低 点击选中、修改标签文字。 只管改属性,不管挪位子。改完如果挤到别人,需手动调布局。 layout(), setItemState()
changeData() 剧中换角 从 A 到 B。自动 Diff、增删并重排。 中等/高 动态加分支、搜索结果实时刷新。 会尽可能复用旧演员实例,比 render 性能更好、更平滑。 fitView()
fitView() 镜头对焦 调整焦距和机位,让所有人刚好入镜。 初始渲染后、舞台扩建后、阵型大变后。 它不移动演员,只移动“相机”。注意设置 maxZoom值, 单节点时会变得格外大 render(), layout(), changeSize()

可以通过一个常规流程来描述上述api之间的关系:

  1. 确定关系:data(确定谁是谁)。

  2. 分配空间:render / layout(确定谁在哪)。

  3. 刷新状态:updateItem(确定谁变了)。

  4. 动态修正:changeData(确定谁走了,谁来了)。

  5. 视觉校准:fitView(确定我该看哪)。

也可以一句话总结:
先填充数据(data),再算空间(layout),最后调镜头(fitView);交互时,先打补丁(updateItem),再重排空间(layout),最后再追焦(fitView)。

上述的接口也可以分成三个维度来看

  1. 数据操作(你是谁)

只负责操作数据:data()、updateItem()、changeData()

  1. 几何计算 (你在哪)

调用g6引擎计算数据映射后的物理坐标,这里要尤其注意api之间的性能差异 : layout()、render()

  1. 视觉渲染 (我怎么看)

渲染观察者的视角 fitView()、changeSize()

写在最后

遇到bug多查文档,多问ai,这一类图表常见的解决思路要么数据格式没配好,要么道具没定义好,要么渲染/调用时机不对,虽然繁琐但是并不深奥
以上。

posted @ 2026-02-11 10:29  致爱丽丝  阅读(99)  评论(0)    收藏  举报