【图形编辑器架构】渲染层篇 — 从 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(() => { ... })

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

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

相关推荐
mldong3 小时前
你的 Vue3 项目也能有钉钉同款审批流设计器:npm 装包,10 分钟画出第一条审批流
前端·vue.js
2分钟速写快排4 小时前
什么是 RAG?如何用 RAG 实现一个用户记忆?
前端·后端·ai编程
passerby60614 小时前
如何自己造一个时间处理库
前端·javascript·github
走到天涯海角5 小时前
react里面的长列表渲染优化
前端·react.js·前端框架
小羊没烦恼!5 小时前
Hello Web API系列教程——Web API与国际化
java·服务器·前端·javascript·php
北岛贰6 小时前
迷茫焦虑期,我做了一个带支付带官网的 AI 聊天虚拟恋人 App
前端·人工智能·后端
mayaairi7 小时前
Vue2 组件通讯(三):全局事件总线、PubSub、插槽与组件实例属性
前端·javascript·vue.js
kyriewen8 小时前
面试官问我:AI 都能写代码了,前端凭什么还值 25K
前端·javascript·人工智能
风骏时光牛马8 小时前
AI源码分析:拆解模型底层实现逻辑
前端
IT_陈寒9 小时前
React子组件莫名其妙重渲染?你可能漏了这个Hook
前端·人工智能·后端