发布日期 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();
}
}
核心流程说明
- 第一次
render
时,建立 Fiber 容器 updateContainer
驱动 React diff + HostConfig 回调- 更新完成后触发
performRender
,清画布 + 应用视图变换 + 渲染元素树 - 用
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 会:
- 调用
createInstance
创建CanvasPage
及其子CanvasRect
等 - 构建 CanvasElement 树
- 更新 props 时,调用
commitUpdate
→updateProps
- 最终触发
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)
- 写
CanvasCircle
继承CanvasElement
,实现onRender
- 在工厂注册
canvas-circle
映射 - 在
RenderApi
接口里加arc
/fill
/stroke
方法 - 在 Canvas 2D 实现里实现这些方法
- 在 JSX 中使用
<canvas-circle x={100} y={100} radius={50} fill="#f00" />
6.3 常见问题 & 解决策略
问题 | 原因 | 解决 |
---|---|---|
DPI 模糊、画布模糊 | 没处理 devicePixelRatio |
在 setCanvasSize 中乘以 pixelRatio 并 renderApi.scale |
渲染太频繁,卡顿 | 每次状态变动都触发 requestRender |
用防抖、批量更新、合并多次变更 |
更新不生效 | prepareUpdate 总返回 false 或误判 |
精细对比关键属性,确保 commitUpdate 被调用 |
删除节点残留 | delete / destroy 没写干净 | 在 removeChild / clearContainer 要 child.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(() => { ... })
✍️ 写在最后 & 写给发布稿风格建议
这基本上就是一个很完整的渲染层的实现了,下一篇我会介绍如何定义一个事件渲染系统,编辑器的交互非常复杂,并且是通过鼠标指针发生交互,如果处理不好会导致交互紊乱,一个好的事件系统非常重要