模块 2 -- 场景图 (Scene Graph)

理解 G 的树形场景管理、DOM 兼容继承链与节点操作 API

1. 什么是场景图

场景图(Scene Graph)是一种用树形结构组织和管理图形对象的经典模式。 它广泛应用于游戏引擎(Unity、Unreal)、3D 引擎(Three.js)和 2D 渲染库中。

在场景图中:

                        Canvas (Window)
                             |
                        Document
                             |
                    documentElement (Group#g-root)
                       /          \
                 Group A          Group B
                /    \               |
          Circle  Rect         Text

    Canvas   -- 对应浏览器 window,提供 rAF、devicePixelRatio 等能力
    Document -- 对应 window.document,管理文档树
    Group    -- 容器节点,类似 <div> / SVG <g>
    Circle 等 -- 叶子图形节点,类似 <circle> / <rect>
为什么用场景图而非平坦列表?

平坦列表中每个图形的变换是绝对的,移动一组图形需要逐个修改。 场景图中只需移动父节点,所有子节点自动跟随。 此外,场景图支持基于树结构的剔除(Culling)、事件冒泡和样式继承,是构建复杂可视化场景的基础。

2. DOM 兼容 API 设计理念

G 的核心设计决策之一是场景图 API 与 DOM API 保持兼容。这不是偶然的相似,而是刻意的设计。 源码中到处可以看到对 MDN 文档的引用注释:

/**
 * can be treated like Window in DOM
 * provide some extra methods like `window`, such as:
 * * `window.requestAnimationFrame`
 * * `window.devicePixelRatio`
 *
 * prototype chains: Canvas(Window) -> EventTarget
 */
export class Canvas extends EventTarget implements ICanvas { ... }

这种设计带来的好处:

DOM 与 G 对象映射

DOM 概念G 对应源码位置
windowCanvasg-lite/src/Canvas.ts
window.documentCanvas.documentg-lite/src/dom/Document.ts
document.documentElement (<html>)Document.documentElement (Group#g-root)Document.ts 构造函数
HTMLElementDisplayObjectg-lite/src/display-objects/DisplayObject.ts
<div>Groupg-lite/src/display-objects/Group.ts
<circle> / <rect>Circle / Rectg-lite/src/display-objects/*.ts
element.styledisplayObject.style (Proxy)DisplayObject.ts 构造函数
EventTargetEventTargetg-lite/src/dom/EventTarget.ts

3. 继承链: EventTarget -> Node -> Element -> DisplayObject

这是 G 场景图的核心继承链,直接对应 DOM 的类层次结构。每一层添加特定能力:

EventTarget                       // 事件监听: addEventListener, dispatchEvent
    |                              // 基于 eventemitter3 实现
    |                              // 支持 capture + once 选项
    v
Node                              // 树结构: parentNode, childNodes, ownerDocument
    |                              // isConnected 标记是否挂载到文档
    |                              // DOCUMENT_POSITION_* 常量
    v
Element                            // DOM 操作: appendChild, removeChild, querySelector
    |                              // 组件数据: transformable, renderable, cullable, sortable
    |                              // entity 唯一 ID (自增计数器)
    v
DisplayObject                      // 图形基类: style 代理, 属性系统, 动画
    |                              // PARSED_STYLE_LIST 静态属性列表
    |                              // config / nodeName / isCustomElement
    v
Circle / Rect / Path / Text / ...  // 具体图形: 各自的 StyleProps 和几何计算

3.1 EventTarget -- 事件基座

位于 g-lite/src/dom/EventTarget.ts。基于 eventemitter3 实现, 提供与 DOM 一致的事件接口:

export class EventTarget implements IEventTarget {
    // 内部事件发射器
    emitter = new EventEmitter();

    // 标准 DOM 事件方法
    addEventListener(
        type: string,
        listener: EventListenerOrEventListenerObject,
        options?: boolean | AddEventListenerOptions,
    ) {
        let capture = false;
        let once = false;
        if (isBoolean(options)) capture = options;
        else if (options) ({ capture = false, once = false } = options);

        // capture 事件使用后缀区分
        if (capture) type += 'capture';

        if (once) this.emitter.once(type, listener);
        else this.emitter.on(type, listener);
    }

    // 别名: on = addEventListener
    on(type, listener, options) { ... }
    removeEventListener(type, listener, options) { ... }
    dispatchEvent(event) { ... }
}

3.2 Node -- 树结构

位于 g-lite/src/dom/Node.ts。对应 DOM 的 Node 接口, 维护树形关系:

export abstract class Node extends EventTarget implements INode {
    // 文档引用 -- 类似 node.ownerDocument
    ownerDocument: IDocument | null = null;

    // 挂载状态 -- 调用 appendChild 后变为 true
    isConnected = false;

    // 子节点列表
    childNodes: IChildNode[] = [];

    // 父节点引用
    parentNode: (INode & IParentNode) | null = null;

    // 便捷访问器
    get firstChild(): IChildNode | null {
        return this.childNodes[0] || null;
    }
    get lastChild(): IChildNode | null {
        return this.childNodes[this.childNodes.length - 1] || null;
    }

    // DOM 位置比较常量
    static DOCUMENT_POSITION_DISCONNECTED = 1;
    static DOCUMENT_POSITION_PRECEDING = 2;
    static DOCUMENT_POSITION_FOLLOWING = 4;
    static DOCUMENT_POSITION_CONTAINS = 8;
    static DOCUMENT_POSITION_CONTAINED_BY = 16;
}

3.3 Element -- DOM 操作 + 组件数据

位于 g-lite/src/dom/Element.ts。这是最"厚"的一层,承担两个职责:

  1. DOM 操作方法 -- appendChild、removeChild、querySelector 等
  2. ECS 组件数据 -- transformable、renderable、cullable、sortable、geometry
export class Element<StyleProps, ParsedStyleProps>
    extends Node
    implements IElement<StyleProps, ParsedStyleProps> {

    // 唯一实体 ID(自增)
    entity = entityCounter++;

    // 变换组件: 位置、旋转、缩放、世界矩阵
    transformable: Transform = {
        localPosition: [0, 0, 0],
        localRotation: [0, 0, 0, 1],    // 四元数
        localScale: [1, 1, 1],
        localTransform: [1,0,0,0, ...],   // 4x4 矩阵(16个元素)
        worldTransform: [1,0,0,0, ...],   // 最终世界矩阵
        origin: [0, 0, 0],               // 变换原点
    };

    // 渲染组件: 包围盒、脏标记
    renderable: Renderable = {
        bounds: undefined,
        boundsDirty: true,
        renderBounds: undefined,
        dirty: false,
    };

    // 剔除组件: 可见性判断
    cullable: Cullable = { ... };

    // 排序组件: zIndex 排序
    sortable: Sortable = { ... };

    // 几何组件: 内容包围盒
    geometry: Geometry = { ... };
}

3.4 DisplayObject -- 图形基类

位于 g-lite/src/display-objects/DisplayObject.ts。在 Element 之上增加:

/**
 * prototype chains: DisplayObject -> Element -> Node -> EventTarget
 *
 * mixins: Animatable, Transformable, Visible
 *
 * Provide abilities in scene graph, such as:
 * * transform `translate/rotate/scale`
 * * add/remove child
 * * visibility and z-index
 */
export class DisplayObject<StyleProps, ParsedStyleProps>
    extends Element<StyleProps, ParsedStyleProps> {

    // 所有可解析的样式属性名
    static PARSED_STYLE_LIST = new Set([
        'fill', 'stroke', 'opacity', 'lineWidth',
        'transform', 'visibility', 'zIndex',
        'cursor', 'pointerEvents', 'clipPath',
        // ... 共 30+ 个属性
    ]);

    // 构造时的原始配置
    config: DisplayObjectConfig<StyleProps>;

    constructor(config) {
        super();
        this.config = config;
        this.id = config.id || '';
        this.name = config.name || '';
        this.nodeName = config.type || Shape.GROUP;

        // 解析初始样式属性
        this.initAttributes(config.style);

        // 创建 style Proxy(如果启用)
        if (runtime.enableStyleSyntax) {
            this.style = new Proxy({ ... }, {
                get: (target, name) => this.getAttribute(name),
                set: (_, prop, value) => {
                    this.setAttribute(prop, value);
                    return true;
                },
            });
        }
    }
}

4. Canvas -- 根容器(对应 window)

Canvas 类是整个场景的入口,对应浏览器的 window 对象。 它继承自 EventTarget,位于 g-lite/src/Canvas.ts

Canvas 的核心职责:

const canvas = new Canvas({
    container: 'my-container',  // DOM 容器 id 或 HTMLElement
    width: 600,
    height: 400,
    renderer: new CanvasRenderer(),
    background: 'transparent',  // 默认透明
    cursor: 'default',         // 默认光标
});

// Canvas 初始化后自动创建 Document
// canvas.document.documentElement 是根 Group 节点 (id='g-root')
// canvas.document.defaultView 反向指回 canvas

canvas.addEventListener(CanvasEvent.READY, () => {
    // 渲染器初始化完毕,可以添加图形
    canvas.appendChild(myShape);
});
Canvas 构造器中的关键步骤(源码解读)

1. 解析配置,计算实际宽高和 DPR

2. 实现 Window 接口(rAF、customElements、设备检测)

3. 创建 Document,设置 document.defaultView = this

4. 初始化渲染上下文(context.renderingContext.root = document.documentElement

5. 创建默认正交相机(near=0.1, far=1000, z=500)

6. 初始化渲染器(注册所有插件,调用插件的 init 钩子)

5. Document -- 虚拟 DOM 文档

Document 类对应 window.document,位于 g-lite/src/dom/Document.ts。 继承链:Document -> Node -> EventTarget

5.1 核心属性

属性类型说明
documentElementGroup根节点,id 为 'g-root',对应 DOM 的 <html>
defaultViewCanvas | null反向引用 Canvas(对应 document.defaultView === window
timelineIAnimationTimelineWAAPI 动画时间线,所有动画的时间源
nodeNamestring值为 'document'

5.2 Document 构造器

export class Document extends Node implements IDocument {
    constructor() {
        super();
        this.nodeName = 'document';

        // 创建动画时间线
        this.timeline = new runtime.AnimationTimeline(this);

        // 初始化可继承的默认样式
        const initialStyle = {};
        BUILT_IN_PROPERTIES.forEach(({ n, inh, d }) => {
            if (inh && d) {
                initialStyle[n] = isFunction(d) ? d(Shape.GROUP) : d;
            }
        });

        // 创建根元素 -- 类似 DOM 中的 <html>
        this.documentElement = new Group({
            id: 'g-root',
            style: initialStyle,
        });
        this.documentElement.ownerDocument = this;
        this.documentElement.parentNode = this;
        this.childNodes = [this.documentElement];
    }
}

5.3 createElement -- 工厂方法

Document 提供 createElement 方法,类似 document.createElement('div'), 根据 tagName 查找已注册的自定义元素类并实例化:

// Document.createElement 内部逻辑
createElement(tagName, options) {
    // 从 customElements 注册表查找对应类
    let clazz = this.defaultView.customElements.get(tagName);

    if (!clazz) {
        console.warn('Unsupported tagName: ', tagName);
        clazz = tagName === 'tspan' ? Text : Group;
    }

    const shape = new clazz(options);
    shape.ownerDocument = this;
    return shape;
}

// 使用示例
const circle = canvas.document.createElement('circle', {
    style: { r: 10, fill: 'red' }
});

6. DisplayObject 基类详解

6.1 属性系统

DisplayObject 的属性分为两层:原始值(attributes)解析值(parsedStyle)。 设置属性时通过 styleValueRegistry 进行解析和标准化:

// 设置属性 -- 触发样式解析和脏标记
circle.setAttribute('fill', 'red');

// 等价的 style 代理写法
circle.style.fill = 'red';

// 读取属性
circle.getAttribute('fill');    // 'red' (原始值)
circle.parsedStyle.fill;       // 解析后的颜色对象

6.2 style Proxy 实现

runtime.enableStyleSyntax 为 true 时,DisplayObject 构造器中会创建一个 Proxy 对象作为 this.style。通过 get/set trap 将读写操作 代理到 getAttribute/setAttribute

// 源码 DisplayObject.ts 构造器中
this.style = new Proxy(
    {
        setProperty: (name, value) => this.setAttribute(name, value),
        getPropertyValue: (name) => this.getAttribute(name),
        removeProperty: (name) => this.removeAttribute(name),
    },
    {
        get: (target, name) => {
            if (target[name] !== undefined) return target[name];
            return this.getAttribute(name);
        },
        set: (_, prop, value) => {
            this.setAttribute(prop, value);
            return true;
        },
    },
);
DOM 兼容细节

style Proxy 同时支持 style.fill = 'red'(属性赋值)和 style.setProperty('fill', 'red')(CSS 标准方法)两种写法, 与浏览器中 HTMLElement.style 的行为完全一致。

6.3 PARSED_STYLE_LIST

这是一个静态 Set,列出所有需要通过样式系统解析的属性名。 每个子类可以扩展这个列表(例如 Circle 增加 cxcyr)。 源码中 DisplayObject 基类定义了 30+ 个通用样式属性:

static PARSED_STYLE_LIST = new Set([
    'fill', 'fillOpacity', 'fillRule',
    'stroke', 'strokeOpacity', 'strokeWidth',
    'lineCap', 'lineJoin', 'lineWidth',
    'lineDash', 'lineDashOffset', 'miterLimit',
    'opacity', 'visibility', 'display',
    'cursor', 'pointerEvents', 'draggable', 'droppable',
    'filter', 'clipPath',
    'shadowColor', 'shadowBlur', 'shadowOffsetX', 'shadowOffsetY',
    'transform', 'transformOrigin',
    'zIndex',
    // ... 等
]);

7. Group 容器与节点操作

7.1 Group -- 容器节点

Group 对应 SVG 的 <g> 元素和 HTML 的 <div>, 是一个不渲染自身但可以包含子节点的容器:

/**
 * its attributes are inherited by its children.
 * @see https://developer.mozilla.org/zh-CN/docs/Web/SVG/Element/g
 */
export class Group extends DisplayObject {
    constructor(options = {}) {
        super({
            type: Shape.GROUP,
            ...options,
        });
    }
}

使用 Group 组织场景:

import { Group, Circle, Rect } from '@antv/g';

// 创建分组容器
const uiLayer = new Group({ id: 'ui-layer' });
const dataLayer = new Group({ id: 'data-layer' });

// 在数据层中添加图形
const circle = new Circle({ style: { cx: 50, cy: 50, r: 20, fill: 'blue' } });
const rect = new Rect({ style: { x: 100, y: 100, width: 80, height: 40, fill: 'green' } });

dataLayer.appendChild(circle);
dataLayer.appendChild(rect);

// 挂载到画布
canvas.appendChild(uiLayer);
canvas.appendChild(dataLayer);

// 移动整个数据层 -- 所有子节点自动跟随
dataLayer.style.transform = 'translate(100, 50)';

// 隐藏整个UI层
uiLayer.style.visibility = 'hidden';

7.2 可用的图形类型

G 在 g-lite/src/display-objects/ 目录下提供以下内置图形:

类名对应 SVG说明
Group<g>容器,不渲染自身
Circle<circle>圆形(cx, cy, r)
Ellipse<ellipse>椭圆(cx, cy, rx, ry)
Rect<rect>矩形(x, y, width, height, radius)
Line<line>直线(x1, y1, x2, y2)
Polyline<polyline>折线(points)
Polygon<polygon>多边形(points)
Path<path>路径(d 属性,SVG path 语法)
Text<text>文本
Image<image>图片
HTML-HTML 内容(仅 Canvas/WebGL 渲染器)
Fragment-文档片段,批量挂载优化
CustomElement-自定义元素基类

8. 节点操作 API 详解

以下 API 全部定义在 Element 类中,与 DOM 标准一致:

8.1 添加节点

// appendChild -- 添加子节点到末尾
parent.appendChild(child);

// appendChild 支持 index 参数 -- 插入到指定位置
parent.appendChild(child, 0);  // 插入到开头

// insertBefore -- 在参考节点前插入
parent.insertBefore(newChild, refChild);

// append -- 批量添加多个节点
parent.append(child1, child2, child3);

// prepend -- 添加到开头
parent.prepend(child);
注意: appendChild 的内部流程

1. 检查子节点是否已销毁(destroyed 为 true 则抛出异常)

2. 调用 runtime.sceneGraphService.attach(child, this, index) 挂载到场景图

3. 如果父节点已连接到 Document,触发 mountChildren 遍历挂载

4. 如果启用了 MutationObserver,分发 INSERTED 事件

8.2 移除节点

// removeChild -- 移除子节点(保留引用,可重新添加)
parent.removeChild(child);

// remove -- 节点自行移除
child.remove();

// removeChildren -- 移除所有子节点
parent.removeChildren();

// destroy -- 销毁节点(递归销毁子节点,移除事件,不可复用)
child.destroy();

// destroyChildren -- 递归销毁所有子节点
parent.destroyChildren();

// replaceChild -- 替换子节点
parent.replaceChild(newChild, oldChild);

// replaceWith -- 节点自行替换
oldChild.replaceWith(newChild);

// replaceChildren -- 替换所有子节点
parent.replaceChildren(child1, child2);

8.3 查询节点

// getElementById -- 按 id 查找
const node = canvas.document.getElementById('my-circle');

// getElementsByClassName -- 按 class 查找
const nodes = group.getElementsByClassName('highlight');

// getElementsByName -- 按 name 查找
const nodes = group.getElementsByName('data-point');

// getElementsByTagName -- 按标签名查找
const circles = group.getElementsByTagName('circle');

// querySelector -- CSS 选择器(需要 g-plugin-css-select)
const node = group.querySelector('#id .class circle');

// querySelectorAll -- CSS 选择器获取所有匹配
const nodes = group.querySelectorAll('circle[fill="red"]');

// closest -- 向上查找最近匹配的祖先
const ancestor = circle.closest('.my-group');

// matches -- 检查节点是否匹配选择器
if (circle.matches('.highlight')) { ... }

// find / findAll -- 函数式查找
const big = group.find(node => node.style.r > 50);
const all = group.findAll(node => node.style.fill === 'red');

8.4 节点遍历

// 子节点访问
node.childNodes        // IChildNode[] -- 所有子节点
node.children          // IElement[] -- 同 childNodes(类型别名)
node.childElementCount // number
node.firstChild        // 第一个子节点
node.lastChild         // 最后一个子节点
node.firstElementChild // 同 firstChild
node.lastElementChild  // 同 lastChild

// 兄弟节点
node.nextSibling       // 下一个兄弟
node.previousSibling   // 上一个兄弟

// 父节点
node.parentNode        // 父节点引用
node.parentElement     // 同 parentNode(类型不同)

// 文档引用
node.ownerDocument     // 所属 Document
node.isConnected      // boolean -- 是否已挂载到文档

8.5 cloneNode -- 深拷贝

// 浅拷贝 -- 只复制节点自身
const cloned = circle.cloneNode();

// 深拷贝 -- 递归复制所有子节点
const clonedGroup = group.cloneNode(true);

// cloneNode 会:
//   1. 复制所有样式属性(展开新对象,不共享引用)
//   2. 共享 clipPath 引用(性能优化)
//   3. 复制本地变换矩阵
//   4. 递归复制子节点(deep=true 时,跳过 marker)

9. 综合代码示例

下面是一个完整的场景图操作示例,展示从创建到查询到销毁的全过程:

import { Canvas, CanvasEvent, Group, Circle, Rect, Text } from '@antv/g';
import { Renderer as CanvasRenderer } from '@antv/g-canvas';

// 1. 创建画布
const canvas = new Canvas({
    container: 'app',
    width: 800,
    height: 600,
    renderer: new CanvasRenderer(),
});

canvas.addEventListener(CanvasEvent.READY, () => {

    // 2. 构建场景树
    const root = new Group({ id: 'scene' });

    const bgGroup = new Group({ id: 'background' });
    const fgGroup = new Group({
        id: 'foreground',
        className: 'interactive',
    });

    const bg = new Rect({
        id: 'bg-rect',
        style: { x: 0, y: 0, width: 800, height: 600, fill: '#1a1a2e' },
    });

    const c1 = new Circle({
        id: 'circle-1',
        name: 'data-point',
        className: 'highlight',
        style: { cx: 200, cy: 300, r: 40, fill: '#e94560' },
    });

    const c2 = new Circle({
        id: 'circle-2',
        name: 'data-point',
        style: { cx: 400, cy: 300, r: 30, fill: '#0f3460' },
    });

    const label = new Text({
        style: {
            x: 200, y: 50,
            text: 'Scene Graph Demo',
            fill: '#e6edf3',
            fontSize: 24,
            textAlign: 'center',
        },
    });

    // 3. 组装场景树
    bgGroup.appendChild(bg);
    fgGroup.append(c1, c2, label);
    root.append(bgGroup, fgGroup);
    canvas.appendChild(root);

    // 4. 查询操作
    const found = root.getElementById('circle-1');
    // found === c1

    const points = root.getElementsByName('data-point');
    // points === [c1, c2]

    const highlighted = root.getElementsByClassName('highlight');
    // highlighted === [c1]

    const circles = root.getElementsByTagName('circle');
    // circles === [c1, c2]

    // 5. 树结构检查
    c1.parentNode === fgGroup;   // true
    c1.isConnected;              // true (已挂载)
    c1.ownerDocument === canvas.document;  // true
    fgGroup.childElementCount;   // 3
    c1.nextSibling === c2;       // true

    // 6. 变换传播 -- 移动前景组,所有子节点跟随
    fgGroup.style.transform = 'translate(50, 0)';

    // 7. 动态操作 -- 移除、克隆
    c2.remove();                 // c2 从 fgGroup 中移除
    const c1Copy = c1.cloneNode();  // 克隆 c1
    c1Copy.style.cx = 600;
    fgGroup.appendChild(c1Copy);

    // 8. 销毁场景
    // root.destroy() -- 递归销毁所有子节点
});

DOM (浏览器)

const div = document.createElement('div');
div.id = 'box';
div.className = 'card';
div.style.color = 'red';

document.body.appendChild(div);

const el = document
    .querySelector('#box');
el.addEventListener('click', fn);
el.remove();

G (场景图)

const rect = new Rect({
    id: 'box',
    className: 'card',
    style: { fill: 'red' },
});
canvas.appendChild(rect);

const el = canvas.document
    .getElementById('box');
el.addEventListener('click', fn);
el.remove();

10. 关键设计总结

设计点实现方式收益
DOM 兼容继承链 EventTarget -> Node -> Element -> DisplayObject 与浏览器 API 保持一致,零学习成本
Canvas = Window Canvas 持有 Document,提供 rAF、DPR 完整的"虚拟浏览器"抽象
Document 根节点 documentElement 是 Group#g-root 所有用户节点都是根节点的子孙
style Proxy ES6 Proxy 代理到 getAttribute/setAttribute 支持 obj.style.fill = 'red' 写法
ECS 组件内联 transformable/renderable/cullable/sortable 直接挂在 Element 上 避免额外查找开销,高性能
属性解析分层 attributes(原始值)vs parsedStyle(解析值) 支持 CSS 值解析、继承、单位转换
场景图服务 runtime.sceneGraphService 负责 attach/detach/query 场景图逻辑与节点类解耦