发布日期 2025年10月2日 | 预计阅读时间:25 分钟
最近在准备面试,重写了之前的编辑器demo,重构了节点层,渲染层,事件层,梳理了"节点树与渲染树的绑定机制"。本篇重点讲解数据层到渲染层的映射,也就是如何实现 NodeTree → SkiaNode → CanvasElement 的双向绑定。
🧑💻 写在开头
点赞 + 收藏 = 支持原创 🤣🤣🤣
上一篇文章我们聊了 渲染层实现,今天继续推进 ------ 把视角放在"数据层与渲染层的衔接"上。 你会看到一个清晰的三层设计:数据层(NodeTree)、绑定层(SkiaNode)、渲染层(CanvasElement),它们之间如何传递数据与引用,最终实现"单一数据源 + 实时渲染"的效果。
本篇你将学到:
- NodeTree 如何管理业务数据与状态
- CanvasElement 如何做到无状态渲染
- SkiaNode 如何作为"桥梁"实现双向绑定
- 数据流 + 渲染流的完整过程
- 为什么这是单向数据流 + 双向引用而不是传统双向绑定
核心是节点树的数据是供渲染树消费的,渲染树只会读取,不会修改他,所以传入的时候还可以直接freeze节点对象
🍎 系列背景 & 延续
这个系列文章,主要记录了编辑器从 0 到 1 的实现细节:
- 节点树架构
- 渲染层对接 Reconciler + Canvas
- 数据层与渲染层绑定机制
- 后续的交互事件系统
今天的主题是"节点树与渲染树绑定原理" ------ 这是整个编辑器的数据中枢到渲染引擎之间的关键桥梁。
仓库代码:canvas-test-demo 其中react-reconciler分支是最新的,实现了节点树、react-reconciler对接canvas,伪双向绑定,渲染api层隔离等
🏗️ 架构概览
整个系统采用分层架构,将数据管理和渲染逻辑完全解耦:
css
┌─────────────────────────────────────────────────────────────┐
│ 应用层 (React) │
│ EditorContainer.tsx │
└───────────────────────────┬─────────────────────────────────┘
│
┌───────────────────┼───────────────────┐
│ │ │
┌───────▼────────┐ ┌───────▼────────┐ ┌──────▼──────┐
│ 数据层 │ │ 绑定层 │ │ 渲染层 │
│ NodeTree │◄─┤ SkiaNode ├─►│CanvasElement│
└────────────────┘ └────────────────┘ └─────────────┘
│ BaseState │ jsNode │ props
│ PageState │ skiaDom │ onRender
│ RectangleState │ │
└─ Store层 React Reconciler Canvas API
三层架构
层级 | 核心类 | 职责 | 数据结构 |
---|---|---|---|
数据层 | BaseNode , PageNode , Rectangle |
管理业务数据和状态 | BaseState , PageState |
绑定层 | SkiaNode |
连接数据层和渲染层 | 引用传递(双向) |
渲染层 | CanvasElement , CanvasRect , CanvasPage |
Canvas 渲染和视图显示 | CanvasElementProps |
💡 核心概念
1. 节点树(NodeTree)- 数据层
设计原则:单一数据源(Single Source of Truth)
typescript
// 数据层:存储所有节点的状态
export class NodeTree {
private nodes: Map<string, SkiaNode> = new Map();
addNode(nodeState: BaseState) {
// 根据类型创建不同的节点
switch (nodeState.type) {
case "rectangle":
node = new Rectangle(nodeState as RectangleState);
break;
case "page":
node = new PageNode(nodeState as PageState);
break;
}
this.nodes.set(nodeState.id, node);
}
}
关键特性:
- ✅ 所有节点以
Map<id, Node>
形式扁平化存储 - ✅ 树形关系通过
children: string[]
(ID 数组) 维护 - ✅ 数据与视图完全解耦,便于序列化和状态管理
2. 渲染树(CanvasElement)- 渲染层
设计原则:无状态渲染(Stateless Rendering)
typescript
// 渲染层:只负责渲染,数据来自 props.jsNode
export abstract class CanvasElement<T, P> {
protected props: P; // 包含 jsNode 引用
public children: CanvasElement[] = [];
render(context: RenderContext, viewTransform?: ViewTransform) {
this.onRender(context, viewTransform); // 渲染自己
this.children.forEach(child => child.render(...)); // 递归渲染
}
}
关键特性:
- ✅ 不存储业务数据,只存储渲染所需的引用
- ✅ 通过
props.jsNode
实时读取数据层的最新状态 - ✅ 支持递归渲染,构建完整的视图树
3. 绑定层(SkiaNode)- 桥梁
设计原则:懒加载 + 引用传递
typescript
// 绑定层:连接数据层和渲染层
export class SkiaNode extends BaseNode {
protected _skiaDom?: CanvasElement;
// 懒加载:首次访问时才创建渲染元素
get skiaDom(): CanvasElement | undefined {
if (!this._skiaDom) {
this._skiaDom = this.createSkiaDom();
}
return this._skiaDom;
}
// 🔑 核心:将自己作为 jsNode 传递给渲染层
protected getSkiaDomProps(): Record<string, unknown> {
return {
jsNode: this, // ✨ 建立双向绑定的关键
};
}
}
🔄 完整流程解析
阶段 1️⃣:初始化 - 构建节点树
typescript
// 1. 编辑器初始化
export const initEditor = () => {
initState({}); // 初始化全局状态
initNodeTree(); // 构建节点树
};
// 2. 从 Store 加载数据并创建节点
export const initNodeTree = () => {
// 加载页面数据
const pages = pageStore.getPage();
nodeTree.createAllElements(pages);
// 加载元素数据
const elements = elementStore.getElement();
nodeTree.createAllElements(elements);
// 设置当前页
pageManager.switchToPage(Object.keys(pages)[0]);
};
数据流向:
scss
Store (PageState, RectangleState)
→ nodeTree.createAllElements()
→ nodeTree.addNode()
→ new PageNode(state) / new Rectangle(state)
→ nodes.set(id, node)
结果:
- ✅ 内存中建立了完整的节点树
- ✅ 每个节点持有对应的
_state
- ✅ 树形关系通过
PageNode.children: string[]
维护
阶段 2️⃣:渲染器创建 - 初始化渲染引擎
typescript
// 1. 创建渲染引擎
export function initRenderingEngine(canvas: HTMLCanvasElement) {
renderingEngine.setCanvas(canvas);
renderingEngine.setCurRenderEngine(RenderEngineType.CANVAS);
const renderApi = getRenderApi(canvas); // 获取 Canvas 2D API 封装
const renderer = createSkiaLikeRenderer(canvas, renderApi);
return renderer;
}
// 2. 渲染器内部构造
export class SkiaLikeRenderer {
private reconciler: ReturnType<typeof Reconciler>;
private rootContainer: CanvasElement;
constructor(canvas: HTMLCanvasElement, renderApi: RenderApi) {
// 创建 React Reconciler(自定义渲染器)
const hostConfig = createSkiaLikeHostConfig(this);
this.reconciler = Reconciler(hostConfig);
// 创建根容器
this.rootContainer = createCanvasElement("canvas-page", canvas, {});
}
}
关键组件:
- React Reconciler:自定义渲染器,将 JSX 转换为 CanvasElement
- RenderApi:Canvas 2D API 的封装,提供统一的绘图接口
- rootContainer:渲染树的根节点
阶段 3️⃣:JSX 渲染 - 触发绑定流程
typescript
// 在 React 组件中调用渲染
const renderSkiaLikeUI = () => {
if (rendererRef.current) {
rendererRef.current.render(
<>
<canvas-page></canvas-page> {/* JSX 元素 */}
</>
);
}
};
React Reconciler 的处理流程:
typescript
// 1. createInstance - 创建 CanvasElement 实例
createInstance(type: string, props: CanvasElementProps): CanvasElement {
const canvasType = type as CanvasElementType; // "canvas-page"
return createCanvasElement(canvasType, canvas, props);
}
// 2. appendInitialChild - 构建父子关系
appendInitialChild(parent: CanvasElement, child: CanvasElement): void {
parent.appendChild(child);
}
// 3. 渲染完成回调 - 触发 Canvas 绘制
this.reconciler.updateContainer(element, this.fiberRoot, null, () => {
this.performRender(); // 🎨 执行实际渲染
});
阶段 4️⃣:双向绑定建立 - CanvasPage 的 onRender
这是最关键的一步!
typescript
// CanvasPage 的渲染逻辑
export class CanvasPage extends CanvasElement<"canvas-page", CanvasPageProps> {
protected onRender(
_context: RenderContext,
_viewTransform?: ViewTransform
): void {
// 1️⃣ 获取当前页面的数据节点
const pageSkiaDom = pageManager.getCurrentPage()?.skiaDom;
const { jsNode } = pageSkiaDom?.getProps() as CanvasPageProps;
// 2️⃣ 渲染页面自身(示例:红色矩形)
const { renderApi } = _context;
renderApi.save();
renderApi.setFillStyle("red");
renderApi.renderRect({ x: 0, y: 0, width: 100, height: 100 });
renderApi.restore();
// 3️⃣ 🔑 关键:从数据层构建渲染层的树结构
(jsNode as PageNode)?.children.forEach((_childId) => {
// 通过 ID 获取子节点(数据层)
const child = nodeTree.getNodeById(_childId);
// 获取子节点的渲染元素(触发懒加载)
const skiaDom = child?.skiaDom;
if (skiaDom) {
// 建立渲染树的父子关系
this.appendChild(skiaDom);
}
});
}
}
这段代码的精妙之处:
- ID → 数据节点 :
nodeTree.getNodeById(childId)
获取数据层节点 - 数据节点 → 渲染元素 :
child.skiaDom
触发懒加载,创建对应的 CanvasElement - 渲染元素 → 数据节点 :创建时传入
jsNode: this
,建立反向引用 - 构建渲染树 :
this.appendChild(skiaDom)
构建父子关系
树结构的同步:
yaml
数据层(NodeTree) 渲染层(CanvasElement)
PageNode { CanvasPage {
children: ["rect-1", "rect-2"] children: [
_skiaDom: CanvasPage ─────────► props.jsNode: PageNode ◄──┐
} │ │
│ └──────────────────────────┘
│ children.forEach
│
├─ Rectangle("rect-1") { CanvasRect {
│ _skiaDom: CanvasRect ───► props.jsNode: Rectangle ◄──┐
│ } │ │
│ └───────────────────────────┘
│
└─ Rectangle("rect-2") { CanvasRect {
_skiaDom: CanvasRect ───► props.jsNode: Rectangle ◄──┐
} │ │
└───────────────────────────┘
🔗 双向绑定的实现原理
核心机制:引用传递 + 实时读取
绑定方向 1:数据层 → 渲染层
typescript
// SkiaNode 持有渲染元素的引用
export class SkiaNode extends BaseNode {
protected _skiaDom?: CanvasElement; // ✅ 数据层 → 渲染层
get skiaDom(): CanvasElement | undefined {
if (!this._skiaDom) {
this._skiaDom = createCanvasElement(
this.skiaType,
this.canvas,
{ jsNode: this } // 🔑 传递自己的引用
);
}
return this._skiaDom;
}
}
作用:
- 数据层可以访问对应的渲染元素
- 便于触发渲染更新:
node.skiaDom?.updateProps(...)
绑定方向 2:渲染层 → 数据层
typescript
// CanvasElement 通过 props.jsNode 持有数据节点引用
export class CanvasRect extends CanvasElement<"canvas-rect", CanvasRectProps> {
protected onRender(context: RenderContext): void {
const { jsNode } = this.props; // ✅ 渲染层 → 数据层
// 🔑 实时读取数据节点的最新属性
const x = jsNode.x || 0; // 通过 getter 读取
const y = jsNode.y || 0;
const fill = jsNode.fill || "#eeffaa";
const radius = jsNode.radius || 0;
// 使用最新数据进行渲染
renderApi.setFillStyle(fill);
renderApi.renderRect({ x, y, width: w, height: h, radius });
}
}
作用:
- 渲染时直接读取数据层的最新值
- 无需手动同步,数据始终一致
为什么不是"真正的双向绑定"?
严格来说,这是单向数据流 + 双向引用:
特性 | 传统双向绑定 | 本架构 |
---|---|---|
数据修改 | View ↔ Model 互相修改 | 只在数据层修改 |
视图更新 | 自动触发 | 手动调用 requestRender() |
数据来源 | 可能不一致 | 单一数据源(NodeTree) |
实现复杂度 | 高(需要监听机制) | 低(引用传递) |
优势:
- ✅ 数据流清晰,易于调试
- ✅ 避免循环更新问题
- ✅ 性能可控(按需渲染)
🎨 渲染流程详解
完整的渲染链路
typescript
// 1. 触发渲染
renderer.requestRender();
// 2. 在下一帧执行渲染
requestAnimationFrame(() => {
this.performRender();
});
// 3. 执行实际渲染
performRender(): void {
// 清空画布
this.clearCanvas();
// 获取视图状态
const viewState = coordinateSystemManager.getViewState();
const scale = viewManager.getScale(viewState);
const translation = viewManager.getTranslation(viewState);
// 应用变换
this.renderApi.save();
this.renderApi.translate(translation.pageX, translation.pageY);
this.renderApi.scale(scale);
// 🔑 渲染根容器(递归渲染所有子元素)
this.rootContainer.render(renderContext, viewTransform);
this.renderApi.restore();
}
// 4. 递归渲染子树
render(context: RenderContext, viewTransform?: ViewTransform): void {
// 渲染自身(触发 onRender)
this.onRender(context, viewTransform);
// 渲染所有子元素
this.children.forEach((child) => {
child.render(context, viewTransform);
});
}
渲染时机
触发场景 | 调用方式 | 说明 |
---|---|---|
初次加载 | renderer.render() 回调 |
创建完 Fiber 树后立即渲染 |
数据变化 | renderer.requestRender() |
手动触发重绘 |
窗口调整 | handleResize → requestRender() |
响应式更新 |
交互事件 | 事件处理器 → requestRender() |
用户操作后更新 |
🛡️ 类型系统设计
泛型 Props 体系
为了实现类型安全的绑定,我们为每种元素定义了专门的 Props 类型:
typescript
// 基础 Props(所有元素共有)
export interface BaseCanvasElementProps {
id?: string;
visible?: boolean;
zIndex?: number;
children?: unknown;
[key: string]: unknown;
}
// 矩形元素的 Props(指定具体的节点类型)
export interface CanvasRectProps extends BaseCanvasElementProps {
jsNode?: Rectangle; // 🔑 指定具体类型
x?: number;
y?: number;
w?: number;
h?: number;
fill?: string;
radius?: number;
}
// 页面元素的 Props
export interface CanvasPageProps extends BaseCanvasElementProps {
jsNode?: PageNode; // 🔑 不同元素对应不同节点类型
x?: number;
y?: number;
w?: number;
h?: number;
}
泛型 CanvasElement
typescript
export abstract class CanvasElement<
T extends string = string, // 元素类型
P extends BaseCanvasElementProps = BaseCanvasElementProps // Props 类型
> {
abstract readonly type: T;
protected props: P; // 🔑 类型安全的 props
updateProps(newProps: Partial<P>): void {
this.props = { ...this.props, ...newProps };
}
getProps(): P {
return this.props;
}
}
具体实现类
typescript
// CanvasRect 指定自己的类型和 Props
export class CanvasRect extends CanvasElement<"canvas-rect", CanvasRectProps> {
readonly type = "canvas-rect" as const;
protected onRender(context: RenderContext): void {
const { jsNode } = this.props; // ✅ jsNode 类型为 Rectangle
// ✅ TypeScript 知道 jsNode 有 radius 属性
const radius = jsNode.radius || 0; // 无类型错误
}
}
类型映射表
typescript
// 元素类型 → Props 类型的映射
export type CanvasElementPropsMap = {
"canvas-grid": CanvasGridProps;
"canvas-ruler": CanvasRulerProps;
"canvas-page": CanvasPageProps;
"canvas-rect": CanvasRectProps;
};
// 类型安全的创建函数
export function createCanvasElement<T extends CanvasElementType>(
type: T,
canvas: HTMLCanvasElement,
props: CanvasElementPropsMap[T] // 🔑 根据类型自动推导 Props
): ReturnType<CanvasElementCreatorMap[T]> {
// ...
}
优势:
- ✅ 编译时类型检查,避免运行时错误
- ✅ IDE 智能提示,提高开发效率
- ✅ 重构安全,修改类型定义自动传播
⚡ 性能优化策略
1. 懒加载渲染元素
typescript
// 只在首次访问 skiaDom 时创建
get skiaDom(): CanvasElement | undefined {
if (!this._skiaDom) {
this._skiaDom = this.createSkiaDom(); // 延迟创建
}
return this._skiaDom;
}
收益:
- 减少初始化时的内存占用
- 未渲染的节点不创建渲染元素
2. requestAnimationFrame 防抖
typescript
requestRender(): void {
if (this.isRenderRequested) return; // 🔑 防止重复请求
this.isRenderRequested = true;
this.animationId = requestAnimationFrame(() => {
this.performRender();
this.isRenderRequested = false;
});
}
收益:
- 多次数据修改只触发一次渲染
- 与浏览器刷新率同步,避免无效渲染
3. 引用传递而非数据复制
typescript
// ❌ 不好的做法:复制数据
props: {
x: node.x,
y: node.y,
fill: node.fill,
// ... 数据可能过期
}
// ✅ 好的做法:传递引用
props: {
jsNode: this // 始终读取最新值
}
收益:
- 减少内存占用
- 避免数据同步开销
- 保证数据一致性
4. 局部更新优化(未来改进)
当前每次渲染都会重建 children,可以优化为:
typescript
protected onRender(...): void {
const { jsNode } = this.props;
// 🔑 只在 children 变化时重建
const currentChildIds = (jsNode as PageNode)?.children || [];
if (!this.childrenIdsEquals(this.lastChildIds, currentChildIds)) {
this.rebuildChildren(currentChildIds);
this.lastChildIds = currentChildIds;
}
}
📊 数据流总结
完整的数据流向图
scss
┌─────────────────────────────────────────────────────────────┐
│ 1. 初始化阶段 │
└─────────────────────────────────────────────────────────────┘
Store (JSON/State)
↓ initNodeTree()
NodeTree.nodes: Map<id, SkiaNode>
↓ createAllElements()
PageNode, Rectangle, Pencil ...
↓ pageManager.switchToPage()
当前页面激活
┌─────────────────────────────────────────────────────────────┐
│ 2. 渲染器创建 │
└─────────────────────────────────────────────────────────────┘
initRenderingEngine(canvas)
↓
new SkiaLikeRenderer(canvas, renderApi)
↓
Reconciler + rootContainer
┌─────────────────────────────────────────────────────────────┐
│ 3. JSX 渲染触发 │
└─────────────────────────────────────────────────────────────┘
renderer.render(<canvas-page />)
↓ React Reconciler
createInstance("canvas-page", props)
↓
new CanvasPage(canvas, {})
↓ updateContainer callback
renderer.performRender()
┌─────────────────────────────────────────────────────────────┐
│ 4. 双向绑定建立 │
└─────────────────────────────────────────────────────────────┘
CanvasPage.onRender()
↓ pageManager.getCurrentPage()
PageNode (数据层)
↓ .skiaDom (懒加载)
┌──────────────────────────────┐
│ 创建 CanvasPage 渲染元素 │
│ props: { jsNode: PageNode } │ ← 建立引用
└──────────────────────────────┘
↓ PageNode.children.forEach(childId)
nodeTree.getNodeById(childId) → Rectangle
↓ Rectangle.skiaDom (懒加载)
┌──────────────────────────────┐
│ 创建 CanvasRect 渲染元素 │
│ props: { jsNode: Rectangle } │ ← 建立引用
└──────────────────────────────┘
↓ CanvasPage.appendChild(CanvasRect)
渲染树构建完成
┌─────────────────────────────────────────────────────────────┐
│ 5. 递归渲染 │
└─────────────────────────────────────────────────────────────┘
rootContainer.render(context, viewTransform)
↓
CanvasPage.onRender() → 构建 children
↓ children.forEach
CanvasRect.render()
↓
CanvasRect.onRender()
↓ 读取 props.jsNode
Rectangle.x, Rectangle.y, Rectangle.fill ...
↓
renderApi.setFillStyle(fill)
renderApi.renderRect({ x, y, width, height, radius })
↓
Canvas 绘制完成 ✅
🎯 总结
双向绑定的本质
本架构的"双向绑定"实际上是:
- 单向数据流:数据只在数据层修改
- 双向引用:数据层和渲染层互相持有引用
- 实时读取:渲染时动态读取最新数据
核心优势
特性 | 实现方式 | 收益 |
---|---|---|
数据一致性 | 单一数据源 + 引用传递 | 无需手动同步 |
类型安全 | 泛型 Props 系统 | 编译时错误检查 |
性能优化 | 懒加载 + requestAnimationFrame | 按需创建,避免重复渲染 |
架构清晰 | 三层分离 | 易于测试和维护 |
可扩展性 | 工厂模式 + 类型映射 | 新增节点类型只需扩展配置 |
设计模式应用
- 工厂模式 :
createCanvasElement()
根据类型创建对应元素 - 观察者模式变体:渲染层主动拉取数据,而非被动监听
- 组合模式:树形结构的递归渲染
- 策略模式 :不同元素类型有不同的渲染策略(
onRender
) - 单例模式 :
nodeTree
,pageManager
等全局管理器
关键技术点
- React Reconciler:自定义渲染器,将 JSX 渲染到 Canvas
- 泛型系统:类型安全的 Props 映射
- 懒加载:延迟创建渲染元素,优化性能
- 引用传递:避免数据复制,保证一致性
- 递归渲染:自动处理树形结构
未来优化方向
- ✨ 增量更新:只更新变化的节点
- ✨ 虚拟滚动:大量节点时的性能优化
- ✨ 离屏渲染:复杂图形的缓存策略
- ✨ WebWorker:将部分计算移到后台线程
- ✨ 脏标记系统:精确追踪需要重绘的区域
📚 参考资料
注:这套架构借鉴了 Figma、Canva 等专业设计工具的设计思想,在保证性能的同时实现了清晰的数据流和类型安全。