【图形编辑器架构】渲染层篇 — 从 React 到 Canvas 的声明式渲染实现

发布日期 2025年10月1日 | 预计阅读时间:30 分钟

最近在准备面试,重写了之前的编辑器demo,重构了节点层,渲染层,事件层,本篇主要介绍渲染层相关的实现,主要是自定义渲染器react-reconciler对接canvas平台,封装渲染api,对接节点层 & 渲染层


🧑‍💻 写在开头

点赞 + 收藏 = 激励原创 🤣🤣🤣

在上一篇文章里,我们讲了节点树是如何管理数据、提供交互与状态支持的。

本篇我们来深挖"渲染层"------也就是把节点树中的状态,画到屏幕上的那一层。

你会看到:React + 自定义 Reconciler + Canvas 的无缝融合,是怎样一步步搭出来的。

你将学到:

  • React Reconciler 如何桥接自定义渲染目标
  • HostConfig 的核心方法与职责
  • 渲染器(SkiaLikeRenderer)如何组织更新、调度渲染
  • Canvas 元素抽象层 --- 从元素基类到具体形状
  • 渲染 API 抽象设计 + 多后端支持
  • 节点层到渲染层的数据流、坐标变换、性能优化

🍎 系列 & 背景延续

这系列文章是从架构、交互、节点树,到渲染、工程化脚本、自动化......

渲染层是中间关键那一环,承担把数据"变为画面"的责任。

当你做一个可视化编辑器、画板、流程图、白板这些东西,渲染层设计得好坏,决定体验流畅度、扩展性、可维护性。


一、渲染层总体架构

1.1 架构图 & 分层职责

less 复制代码
React 组件层 (JSX: <canvas-rect /> / <canvas-page />)
   ↓
React Reconciler(调和层 + HostConfig)  
   ↓
Canvas 元素抽象层 (CanvasElement、CanvasRect、CanvasPage...)  
   ↓
渲染 API 抽象层 (RenderApi 接口)  
   ↓
具体后端实现(Canvas 2D / WebGL / CanvasKit)  
   ↓
浏览器原生 Canvas API

每一层职责清晰:

  • React + Reconciler:做更新 diff、决定新增/更新/删除
  • CanvasElement:负责树形结构、递归渲染、子节点管理
  • RenderApi:屏蔽底层差异,提供统一绘制接口
  • 后端实现:具体调用 Canvas API 或其它引擎

架构目标是:声明式 + 高性能 + 后端可替换

1.2 设计理念 & 工程化思路

  • JSX 描述画面,让 Canvas 渲染也像写 React 组件
  • 抽象各层接口,方便替换(未来支持 WebGL、CanvasKit 等)
  • 利用 React Reconciler 的 diff 算法,实现增量更新、最小重绘
  • 用工厂 / 元素注册机制,便于扩展新图形类型

二、React Reconciler 与 HostConfig

2.1 Reconciler 简介

React Reconciler 是 React 内部的调和算法核心,它负责:

  • 对比新旧树差异
  • 调度更新
  • 调用 HostConfig 提供的方法(createInstance、commitUpdate、removeChild 等)

这也是 React 在不同平台(DOM / React Native / Canvas)能复用的关键。

实现虚拟dom的目的就是为了跨平台,常见的react-dom,就是一个针对dom平台的渲染器实现

2.2 HostConfig 核心方法拆解

下面是核心 HostConfig 的伪 / 实现代码与说明(改编自你项目的代码):

js 复制代码
function createSkiaLikeHostConfig(renderer: { getCanvas, requestRender }) {
  return {
    supportsMutation: true,
    supportsPersistence: false,
    isPrimaryRenderer: true,

    createInstance(type: string, props: CanvasElementProps) {
      return createCanvasElement(type as CanvasElementType, renderer.getCanvas(), props);
    },

    createTextInstance(text: string) {
      return createCanvasElement("canvas-container", renderer.getCanvas(), { children: text });
    },

    appendChild(parent, child) {
      parent.appendChild(child);
    },
    appendInitialChild: (parent, child) => {
      parent.appendChild(child);
    },
    removeChild(parent, child) {
      parent.removeChild(child);
    },
    insertBefore(parent, child, beforeChild) {
      parent.removeChild(child);
      parent.appendChild(child);
    },

    prepareUpdate(_instance, _type, oldProps, newProps) {
      return JSON.stringify(oldProps) !== JSON.stringify(newProps);
    },

    commitUpdate(instance, updatePayload, _type, _oldProps, newProps) {
      if (updatePayload) {
        instance.updateProps(newProps);
      }
    },
    commitTextUpdate(textInstance, _oldText, newText) {
      textInstance.updateProps({ children: newText });
    },

    prepareForCommit() { return null; },
    resetAfterCommit(_containerInfo) {
      renderer.requestRender();
    },

    appendChildToContainer(container, child) {
      container.appendChild(child);
    },
    removeChildFromContainer(container, child) {
      container.removeChild(child);
    },
    clearContainer(container) {
      container.children.forEach(child => child.destroy());
      container.children = [];
    },

    getRootHostContext() { return {}; },
    getChildHostContext(parentContext) { return parentContext; },
    shouldSetTextContent() { return false; },
    getCurrentEventPriority() { return 16; },
    // ... 其他占位 / 必要方法
  }
}

方法说明 & 时机

  • createInstance:React 要创建一个节点时调用
  • appendChild / removeChild / insertBefore:管理子节点树结构
  • prepareUpdate:判断是否需要更新,返回一个 "payload" 标记
  • commitUpdate / commitTextUpdate:真正把新的 props 应用到实例上
  • resetAfterCommit:在一次更新完成后被调用,这里触发 canvas 重绘
  • clearContainer:清理子节点

Tip: prepareUpdate 的判断要尽量精细,避免不必要更新。JSON 序列化比较简单,但性能可能不好------可针对关键属性单独比较。

2.3 SkiaLikeRenderer:渲染器核心

渲染器负责管理更新流程、调度渲染、执行绘制。以下是主要职责与核心代码:

js 复制代码
class SkiaLikeRenderer {
  reconciler: ReturnType<typeof Reconciler>;
  rootContainer: CanvasElement;
  fiberRoot: any = null;
  animationId: number | null = null;
  isRenderRequested = false;
  canvas: HTMLCanvasElement;
  renderApi: RenderApi;
  pixelRatio: number;

  constructor(canvas: HTMLCanvasElement, renderApi: RenderApi) {
    this.canvas = canvas;
    this.renderApi = renderApi;
    this.pixelRatio = window.devicePixelRatio || 1;
    const hostConfig = createSkiaLikeHostConfig(this);
    this.reconciler = Reconciler(hostConfig);
    this.rootContainer = createCanvasContainer(canvas, {});
  }

  render(element: React.ReactElement, callback?: () => void) {
    if (!this.fiberRoot) {
      this.fiberRoot = this.reconciler.createContainer(
        this.rootContainer, 0, null, false, null, "", () => {}, null
      );
    }
    this.reconciler.updateContainer(element, this.fiberRoot, null, () => {
      this.performRender();
      callback?.();
    });
  }

  requestRender() {
    if (this.isRenderRequested) return;
    this.isRenderRequested = true;
    this.animationId = requestAnimationFrame(() => {
      this.performRender();
      this.isRenderRequested = false;
    });
  }

  performRender() {
    this.clearCanvas();

    // 视图变换(平移 + 缩放)
    const viewState = coordinateSystemManager.getViewState();
    const scale = viewManager.getScale(viewState);
    const translation = viewManager.getTranslation(viewState);
    const viewTransform = {
      scale,
      offsetX: translation.pageX,
      offsetY: translation.pageY,
    };

    const context: RenderContext = {
      canvas: this.canvas,
      renderApi: this.renderApi,
      pixelRatio: this.pixelRatio,
    };

    this.renderApi.save();
    this.renderApi.translate(translation.pageX, translation.pageY);
    this.renderApi.scale(scale);

    this.rootContainer.render(context, viewTransform);

    this.renderApi.restore();
  }

  clearCanvas() {
    this.renderApi.save();
    this.renderApi.setTransform(1, 0, 0, 1, 0, 0);
    this.renderApi.clearRect(0, 0, this.canvas.width, this.canvas.height);
    this.renderApi.restore();
  }

  setCanvasSize(width: number, height: number) {
    this.canvas.width = width * this.pixelRatio;
    this.canvas.height = height * this.pixelRatio;
    this.canvas.style.width = width + "px";
    this.canvas.style.height = height + "px";
    this.renderApi.scale(this.pixelRatio);
  }

  clear() {
    if (this.animationId) {
      cancelAnimationFrame(this.animationId);
      this.animationId = null;
    }
    if (this.fiberRoot) {
      this.reconciler.updateContainer(null, this.fiberRoot, null, () => {});
      this.fiberRoot = null;
    }
    this.rootContainer.destroy();
    this.clearCanvas();
  }
}

核心流程说明

  1. 第一次 render 时,建立 Fiber 容器
  2. updateContainer 驱动 React diff + HostConfig 回调
  3. 更新完成后触发 performRender,清画布 + 应用视图变换 + 渲染元素树
  4. requestRender 防抖,避免重复重绘

三、Canvas 元素抽象层

这一层把 React 元素 / State 映射为可渲染的树结构。

3.1 基础抽象:CanvasElement

每个可渲染节点都继承自 CanvasElement。核心职责:

  • 保存 props(属性)
  • 管理子节点 children
  • 提供 render(context, viewTransform) 方法递归渲染
  • 提供 updateProps 接口更新属性
  • 支持 destroy 清理资源

示例代码:

js 复制代码
abstract class CanvasElement<T extends string = string> {
  abstract readonly type: T;
  protected props: CanvasElementProps;
  public children: CanvasElement[] = [];
  protected parent: CanvasElement | null = null;
  protected canvas: HTMLCanvasElement;

  constructor(canvas: HTMLCanvasElement, props: CanvasElementProps) {
    this.canvas = canvas;
    this.props = props;
  }

  appendChild(child: CanvasElement) {
    child.parent = this;
    this.children.push(child);
  }
  removeChild(child: CanvasElement) {
    const idx = this.children.indexOf(child);
    if (idx >= 0) {
      this.children.splice(idx, 1);
      child.parent = null;
    }
  }

  render(context: RenderContext, viewTransform?: ViewTransform) {
    this.onRender(context, viewTransform);
    this.children.forEach(child => child.render(context, viewTransform));
  }

  updateProps(newProps: Partial<CanvasElementProps>) {
    this.props = { ...this.props, ...newProps };
  }

  getProps(): CanvasElementProps {
    return this.props;
  }

  destroy() {
    this.onDelete();
    this.children.forEach(c => c.destroy());
    this.children = [];
    this.parent = null;
  }
  
  protected onDelete() { /* 子类可重写 */ }
  protected abstract onRender(context: RenderContext, viewTransform?: ViewTransform): void;
}

3.2 工厂 + 注册机制

用工厂方法根据类型创建对应 CanvasElement:

js 复制代码
const CanvasElements: Record<CanvasElementType, (canvas, props) => CanvasElement> = {
  "canvas-container": (c, p) => new CanvasContainer(c, p),
  "canvas-rect": (c, p) => new CanvasRect(c, p),
  "canvas-page": (c, p) => new CanvasPage(c, p),
  // ...其他类型
};

function createCanvasElement(type: CanvasElementType, canvas: HTMLCanvasElement, props: CanvasElementProps) {
  const creator = CanvasElements[type];
  if (!creator) {
    throw new Error(`Unknown canvas element type: ${type}`);
  }
  return creator(canvas, props);
}

这样未来新增元素类型,只要在这个映射里注册就能使用。

这里可以用依赖注入,实现一个最简单的loc容器,往里面注入,不过我还没改,现这样吧

3.3 具体元素:矩形、页面容器

CanvasRect

js 复制代码
class CanvasRect extends CanvasElement<"canvas-rect"> {
  readonly type = "canvas-rect";

  protected onRender(context: RenderContext) {
    const { renderApi } = context;
    const visible = this.props.visible !== false;
    if (!visible) return;

    const x = this.props.x || 0;
    const y = this.props.y || 0;
    const w = this.props.w || 100;
    const h = this.props.h || 100;
    const fill = this.props.fill || "#eeffaa";
    const radius = this.props.radius || 0;

    renderApi.save();
    try {
      renderApi.setFillStyle(fill);
      renderApi.renderRect({ x, y, width: w, height: h, radius });
    } finally {
      renderApi.restore();
    }
  }
}

核心是onRender,performRender函数中会从跟fiber开始递归调用onRender函数,渲染出整个节点树

CanvasPage

页面容器负责绘制页面背景 + 渲染页面里的所有节点:

js 复制代码
class CanvasPage extends CanvasElement<"canvas-page"> {
  readonly type = "canvas-page";

  protected onRender(context: RenderContext, viewTransform?: ViewTransform) {
    const page = pageManager.getCurrentPage();
    if (!page) return;
    const { renderApi } = context;

    // 背景
    renderApi.save();
    renderApi.setFillStyle(page.backgroundColor || "#ffffff");
    renderApi.renderRect({ x: 0, y: 0, width: page.width, height: page.height });
    renderApi.restore();

    // 子节点
    page.children.forEach(childId => {
      const node = nodeTree.getNodeById(childId);
      if (!node) return;
      const elmType = getCkTypeByType(node.type);
      const elm = createCanvasElement(elmType, this.canvas, node._state);
      elm.render(context, viewTransform);
    });
  }
}

四、渲染 API 抽象 + 多后端支持

4.1 定义抽象接口:RenderApi

接口包括画布操作、图形绘制、样式、路径操作、文本等方法:

ts 复制代码
interface RenderApi {
  scale(pixelRatio: number): void;
  translate(x: number, y: number): void;
  save(): void;
  restore(): void;
  setTransform(a,b,c,d,e,f): void;
  clearRect(x,y,w,h): void;
  renderRect(rect: { x, y, width, height, radius? });
  setFillStyle(style: string): void;
  setStrokeStyle(style: string): void;
  setLineWidth(w: number): void;
  beginPath(): void;
  moveTo(x,y): void;
  lineTo(x,y): void;
  stroke(): void;
  setFont(font: string): void;
  setTextAlign(align: CanvasTextAlign): void;
  setTextBaseline(baseline: CanvasTextBaseline): void;
  fillText(text: string, x: number, y: number): void;
  rotate(angle: number): void;
}

这个项目主要是用来写着学习巩固的,后面我想要切换引擎,所以封装了一层,这样 CanvasElement 这边就能不关心是 Canvas2D、WebGL 还是 CanvasKit,只调用这些统一方法。

4.2 Canvas 2D 实现

js 复制代码
class CanvasRenderApi implements RenderApi {
  private ctx: CanvasRenderingContext2D;

  constructor(canvas: HTMLCanvasElement) {
    const c = canvas.getContext("2d");
    if (!c) throw new Error("无法获取 Canvas 2D 上下文");
    this.ctx = c;
  }

  scale(r: number) { this.ctx.scale(r, r); }
  translate(x: number, y: number) { this.ctx.translate(x, y); }
  save() { this.ctx.save(); }
  restore() { this.ctx.restore(); }
  setTransform(a,b,c,d,e,f) { this.ctx.setTransform(a,b,c,d,e,f); }
  clearRect(x,y,w,h) { this.ctx.clearRect(x,y,w,h); }
  renderRect(rect) {
    if (!rect.radius || rect.radius === 0) {
      this.ctx.fillRect(rect.x, rect.y, rect.width, rect.height);
    } else {
      // 绘制圆角矩形逻辑略
      this.ctx.beginPath();
      // ...曲线绘制
      this.ctx.fill();
    }
  }
  setFillStyle(s) { this.ctx.fillStyle = s; }
  setStrokeStyle(s) { this.ctx.strokeStyle = s; }
  setLineWidth(w) { this.ctx.lineWidth = w; }
  beginPath() { this.ctx.beginPath(); }
  moveTo(x,y) { this.ctx.moveTo(x, y); }
  lineTo(x,y) { this.ctx.lineTo(x, y); }
  stroke() { this.ctx.stroke(); }
  setFont(f) { this.ctx.font = f; }
  setTextAlign(a) { this.ctx.textAlign = a; }
  setTextBaseline(b) { this.ctx.textBaseline = b; }
  fillText(t, x, y) { this.ctx.fillText(t, x, y); }
  rotate(a) { this.ctx.rotate(a); }
}

4.3 渲染引擎管理 & 切换

为了支持未来的引擎(比如 CanvasKit / WebGL),提供一个渲染引擎管理器:

js 复制代码
enum RenderEngineType {
  CANVAS = "canvas",
  CANVASKIA = "canvasKit",
}

class RenderingEngine {
  curRenderEngine: RenderEngineType | null = null;
  setCurRenderEngine(e: RenderEngineType) { this.curRenderEngine = e; }
  getCurRenderEngine() { return this.curRenderEngine; }
}

const renderingEngine = new RenderingEngine();

getRenderApi(canvas) 中,根据当前 engine 类型返回对应 RenderApi 实现。

js 复制代码
function getRenderApi(canvas) {
  const engine = renderingEngine.getCurRenderEngine();
  switch (engine) {
    case RenderEngineType.CANVAS:
      return new CanvasRenderApi(canvas);
    // case RenderEngineType.CANVASKIA:
    //   return new CanvasKitRenderApi(canvas);
    default:
      return new CanvasRenderApi(canvas);
  }
}

要切换引擎,只要调用 renderingEngine.setCurRenderEngine(...),然后重新渲染即可。


五、节点树 → 渲染层的数据流 & 坐标变换

5.1 从节点树到 Canvas 元素树

在 React 组件层,你可能写:

js 复制代码
<canvas-page>
  {currentPage.children.map(id => {
    const node = nodeTree.getNode(id);
    const elmType = getCkTypeByType(node.type);
    return <{elmType} key={id} {...node._state} />;
  })}
</canvas-page>

React Reconciler 会:

  1. 调用 createInstance 创建 CanvasPage 及其子 CanvasRect
  2. 构建 CanvasElement 树
  3. 更新 props 时,调用 commitUpdateupdateProps
  4. 最终触发 performRender 渲染整棵树

5.2 坐标系统与视图变换

编辑器里常见 "缩放 + 平移" 视图操作,需要把节点的世界坐标映射到屏幕坐标。渲染器执行的步骤:

js 复制代码
// 获取视图状态(zoom / panX / panY)
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(context, viewTransform);

// 恢复状态
this.renderApi.restore();

节点原始坐标 + 缩放 + 偏移,最终变成屏幕上的位置。

5.3 渲染上下文传递

渲染从根到叶节点逐层传递同一个上下文:

  • RenderContext 包含 canvas, renderApi, pixelRatio
  • ViewTransform 包含 scale + offsetX/Y
  • 每个 CanvasElement.onRender 从 context 拿 renderApi 去绘制

这样子节点不必自己关心画布、dpi、上下文管理等细节。

5.4 性能优化要点

  • 防抖渲染 :使用 requestAnimationFrame 合并多次更新
  • 减少无效更新prepareUpdate 判断属性变化,避免冗余 repaint
  • 脏矩形 / 部分重绘(可进阶):只重绘变更区域
  • 离屏缓存:对于复杂路径、纹理或渐变,可绘制到离屏 Canvas,后续直接 drawImage
  • 元数据缓存 / 属性缓存:避免重复计算样式 / 转换矩阵

六、实战 + 扩展 + 常见问题

6.1 完整初始化流程示例

js 复制代码
function initRenderer(canvas: HTMLCanvasElement): SkiaLikeRenderer {
  renderingEngine.setCurRenderEngine(RenderEngineType.CANVAS);
  const renderApi = getRenderApi(canvas);
  const renderer = new SkiaLikeRenderer(canvas, renderApi);
  renderer.setCanvasSize(window.innerWidth, window.innerHeight);
  return renderer;
}

// 在 React 组件里
useEffect(() => {
  const renderer = initRenderer(canvasRef.current);
  renderer.render(
    <>
      <canvas-grid gridSize={20} strokeStyle="#e0e0e0" />
      <canvas-page />
    </>
  );
  rendererRef.current = renderer;
}, []);

还要监听窗口 resize,重设 canvas.setSize + requestRender

6.2 扩展新元素类型(示例:Circle)

  1. CanvasCircle 继承 CanvasElement,实现 onRender
  2. 在工厂注册 canvas-circle 映射
  3. RenderApi 接口里加 arc / fill / stroke 方法
  4. 在 Canvas 2D 实现里实现这些方法
  5. 在 JSX 中使用 <canvas-circle x={100} y={100} radius={50} fill="#f00" />

6.3 常见问题 & 解决策略

问题 原因 解决
DPI 模糊、画布模糊 没处理 devicePixelRatio setCanvasSize 中乘以 pixelRatiorenderApi.scale
渲染太频繁,卡顿 每次状态变动都触发 requestRender 用防抖、批量更新、合并多次变更
更新不生效 prepareUpdate 总返回 false 或误判 精细对比关键属性,确保 commitUpdate 被调用
删除节点残留 delete / destroy 没写干净 removeChild / clearContainerchild.destroy()

6.4 性能监控示例

可以写一个简单的 FPS 监控器包裹 performRender

js 复制代码
class PerfMonitor {
  lastTime = performance.now();
  frameCount = 0;
  fps = 0;

  measure(fn: () => void) {
    const start = performance.now();
    fn();
    const duration = performance.now() - start;

    this.frameCount++;
    if (performance.now() - this.lastTime >= 1000) {
      this.fps = this.frameCount;
      this.frameCount = 0;
      this.lastTime = performance.now();
      console.log(`FPS: ${this.fps}, render time: ${duration.toFixed(2)}ms`);
    }
  }
}

// 在 performRender 中替换为 monitor.measure(() => { ... })

✍️ 写在最后 & 写给发布稿风格建议

这基本上就是一个很完整的渲染层的实现了,下一篇我会介绍如何定义一个事件渲染系统,编辑器的交互非常复杂,并且是通过鼠标指针发生交互,如果处理不好会导致交互紊乱,一个好的事件系统非常重要

相关推荐
java水泥工2 小时前
基于Echarts+HTML5可视化数据大屏展示-智慧消防大屏
前端·echarts·html5
杨超越luckly2 小时前
HTML应用指南:利用POST请求获取全国索尼体验型零售店位置信息
前端·arcgis·html·数据可视化·门店数据
ObjectX前端实验室2 小时前
【图形编辑器架构】节点树篇 — 从零构建你的编辑器数据中枢
前端·计算机图形学·图形学
ikun778g2 小时前
uniapp设置安全区
前端·javascript·vue.js·ui·uni-app
IT_陈寒3 小时前
React Hooks 实战:这5个自定义Hook让我开发效率提升了40%
前端·人工智能·后端
三月的一天3 小时前
React单位转换系统:设计灵活的单位系统与单位系统转换方案
前端·javascript·react.js
壕壕3 小时前
Re: 0x02. 从零开始的光线追踪实现-射线跟球的相交
macos·计算机图形学
xiaoyan20153 小时前
2025最新款Electron38+Vite7+Vue3+ElementPlus电脑端后台系统Exe
前端·vue.js·electron
梅孔立3 小时前
本地多版本 Node.js 切换指南:解决 Vue nodejs 等项目版本冲突问题
前端·vue.js·node.js