模块 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 的开发者可以立即上手
- 生态复用 -- D3.js 可以直接操作 G 的场景图(
selection.append、selection.attr) - 手势库兼容 -- Hammer.js 等基于 DOM Event 的库可以直接使用
- CSS 选择器 -- 通过
g-plugin-css-select支持querySelector语法
DOM 与 G 对象映射
| DOM 概念 | G 对应 | 源码位置 |
|---|---|---|
window | Canvas | g-lite/src/Canvas.ts |
window.document | Canvas.document | g-lite/src/dom/Document.ts |
document.documentElement (<html>) | Document.documentElement (Group#g-root) | Document.ts 构造函数 |
HTMLElement | DisplayObject | g-lite/src/display-objects/DisplayObject.ts |
<div> | Group | g-lite/src/display-objects/Group.ts |
<circle> / <rect> 等 | Circle / Rect 等 | g-lite/src/display-objects/*.ts |
element.style | displayObject.style (Proxy) | DisplayObject.ts 构造函数 |
EventTarget | EventTarget | g-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。这是最"厚"的一层,承担两个职责:
- DOM 操作方法 -- appendChild、removeChild、querySelector 等
- 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 之上增加:
- style 代理 -- 通过
Proxy实现obj.style.fill = 'red'的响应式写法 - PARSED_STYLE_LIST -- 静态属性集合,列出所有需要解析的样式属性
- 属性初始化 -- 构造时调用
runtime.styleValueRegistry.processProperties解析属性 - 动画支持 --
activeAnimations数组,animate()方法 - cloneNode -- 深拷贝节点,正确处理 clipPath / offsetPath 引用
/**
* 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 的核心职责:
- 创建并持有
Document(this.document = new Document()) - 提供
requestAnimationFrame/cancelAnimationFrame - 管理
devicePixelRatio - 初始化默认相机(正交投影,z=500)
- 初始化渲染器并注册插件
- 提供
customElements注册自定义元素的能力 - 发出生命周期事件(READY、BEFORE_RENDER、AFTER_RENDER 等)
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);
});
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 核心属性
| 属性 | 类型 | 说明 |
|---|---|---|
documentElement | Group | 根节点,id 为 'g-root',对应 DOM 的 <html> |
defaultView | Canvas | null | 反向引用 Canvas(对应 document.defaultView === window) |
timeline | IAnimationTimeline | WAAPI 动画时间线,所有动画的时间源 |
nodeName | string | 值为 '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;
},
},
);
style Proxy 同时支持 style.fill = 'red'(属性赋值)和
style.setProperty('fill', 'red')(CSS 标准方法)两种写法,
与浏览器中 HTMLElement.style 的行为完全一致。
6.3 PARSED_STYLE_LIST
这是一个静态 Set,列出所有需要通过样式系统解析的属性名。
每个子类可以扩展这个列表(例如 Circle 增加 cx、cy、r)。
源码中 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);
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 | 场景图逻辑与节点类解耦 |