做笔记应用时,最难处理的是内容组织。用户一边画脑图梳理结构,一边要打开 PDF 看文献,还要在视频片段旁边写观后感时,传统的“笔记本 + 附件”形式很快就变得低效。无限画布方案把脑图、PDF 和视频审阅放进同一个可平移、可缩放的平面里,让所有信息都以对象方式存在,而不是塞进固定面板。这个项目的核心不是“做一个好看的编辑器”,而是先定义出一套能统一承载脑图节点、PDF 页面和视频卡片的对象模型,再围绕坐标系、渲染管线与状态管理把功能逐层搭起来。
这篇实践的起点,是一个空白 Vite 项目,目标是从零拆解无限画布笔记的实现思路,覆盖画布视口、脑图布局、PDF 解析、视频帧截图和视觉样式,最后给出常见坑与生产建议。如果你准备实现类似的白板、知识库、课堂批注或设计走查工具,可以直接复用这套设计。
1. 无限画布笔记的定位:先想清楚对象模型,再考虑功能按钮
实际项目中,很多人一开始纠结“UI 怎么做才能高颜值”,结果代码里全是并列的输入框和按钮。无限画布笔记必须反着来:先建立“世界坐标系里的对象”这一概念,再考虑面板、工具栏和样式。
1.1 无限画布解决的是内容组织问题
无限画布笔记的核心不是画图,而是把内容放在二维无限平面中。脑图是一个以父节点为中心向外展开的树,PDF 是带页面尺寸的文件对象,视频是带播放状态和截图能力的媒体对象。如果不统一抽象,这三个功能会各自生成独立的 UI:脑图区域、PDF 上下翻页区域、视频播放器区域。这种“三块面板拼起来”的结构,会让知识割裂。
更好的做法是让所有内容都是可移动、可缩放、可关联的对象。每类对象有自己的位置、尺寸、内容数据和交互行为,公共的视口系统负责把世界坐标映射到屏幕坐标。这个思路类似白板工具,也类似地图引擎:用户看到的是一块矩形窗口,窗口里显示任意位置的内容。
1.2 对象模型:一类基础对象和三类业务对象
画布对象可以先用一个联合类型表达。基础字段统一包括 id、type、x、y、width、height、rotation、zIndex,业务字段按类型追加。下面是一个面向 TypeScript 的对象定义示例:
typescript复制type CanvasPoint = {
x: number;
y: number;
};
type BaseObject = {
id: string;
type: string;
x: number;
y: number;
width: number;
height: number;
rotation: number; // 弧度
zIndex: number;
// 用于查找和检索
title: string;
createdAt: number;
updatedAt: number;
};
type MindMapObject = BaseObject & {
type: 'mindmap';
rootNode: MindNode;
collapsedIds: string[];
layoutVersion: number;
};
type PdfObject = BaseObject & {
type: 'pdf';
fileId: string;
pageCount: number;
currentPage: number;
pageHeight: number;
pageWidth: number;
scale: number;
annotations: PdfAnnotation[];
};
type VideoObject = BaseObject & {
type: 'video';
fileId: string;
videoUrl: string;
posterUrl?: string;
currentTime: number;
playing: boolean;
reviewedMarks: VideoMark[];
};
这种定义有几个好处:
- 对象可以统一进入一个列表或 Map,画布渲染时只遍历一次;
- 拖拽、旋转、缩放这些公共操作直接操作 BaseObject 字段;
- 图片、便签、分组框等后续对象类型都可以扩展进 BaseObject。
1.3 状态容器:不要把对象状态和 UI 状态混在一起
无限画布的状态至少要分成四层:
- Viewport:offsetX、offsetY、scale;
- Objects:所有画布对象;
- Selection:当前选中对象 id;
- UI 状态:工具栏、右键菜单、弹窗。
推荐使用可外部访问的状态库,比如 Zustand。下面是一个极简 store 骨架:
typescript复制import { create } from 'zustand';
type ViewportState = {
offsetX: number;
offsetY: number;
scale: number;
setScale: (scale: number, originScreenX: number, originScreenY: number) => void;
setOffset: (x: number, y: number) => void;
};
export const useViewportStore = create<ViewportState>((set, get) => ({
offsetX: 0,
offsetY: 0,
scale: 1,
setScale: (nextScale, originScreenX, originScreenY) => {
// 缩放中心为鼠标位置
const { scale, offsetX, offsetY } = get();
const clamped = Math.min(Math.max(nextScale, 0.05), 4);
// 鼠标在屏幕坐标系下的位置,映射到世界坐标
const wx = (originScreenX - offsetX) / scale;
const wy = (originScreenY - offsetY) / scale;
set({
scale: clamped,
offsetX: originScreenX - wx * clamped,
offsetY: originScreenY - wy * clamped,
});
},
setOffset: (x, y) => set({ offsetX: x, offsetY: y }),
}));
这里要注意:缩放时不能只改 scale 不管 offset,否则画面会向画布左上角收缩,而不是停在鼠标所在位置。团队里新人最容易漏掉这个细节。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 技术选型和项目结构:用 Web 技术做无限画布,需要组合而不是二次造轮子
无限画布笔记涉及三层能力:渲染层、文件解析层、状态与持久化层。选型要看团队熟悉度和需求边界,不是越重越好。
2.1 渲染层:Canvas、SVG 还是纯 DOM
这里有一个常用于白板类项目的选型表:
| 方案 | 适合对象 | 优势 | 限制 |
|---|---|---|---|
| DOM + CSS transform | 卡片、文本、按钮密集时 | 开发快、可访问性好、文本选择方便 | 节点多时性能下降 |
| SVG | 图形、连线、矢量路径 | 图形描述清晰、支持事件 | 海量节点同样会卡 |
| Canvas 2D | 高密度图形绘制 | 渲染性能最好、适合几千上万节点 | 文本选择、事件命中、编辑框都要手动实现 |
做脑图、PDF 页面和视频卡片混合场景,最合适的路线是“DOM 为主、Canvas 兜底”:视口内对象数量控制在几百个以内用 DOM 渲染,缩放超过一定比例用 Canvas 绘制网格和缩放预览。这样既能快速做出高颜值,又能在对象数量增长时升级渲染层。
2.2 文件解析与媒体渲染组合
PDF 的解析和渲染通常使用 pdf.js,它可以把 PDF 页面渲染到 Canvas,也能提取文本内容。视频使用原生 video 标签,配合 Canvas 或 OffscreenCanvas 做帧截图。这两条线不需要统一为“自绘所有内容”,而是统一到画布对象的生命周期里。
如果用 Vue,会遇到“vue image 能显示 pdf 吗”这类问题。本质上 image 标签不能显示 PDF,需要先转成 Canvas 图像或使用 iframe/object。将 PDF 页渲染为图像后,就能放进任何组件体系。
2.3 一个可跑通的目录结构
实际项目里可以保持这样一个目录:
text复制src/
components/
canvas/
InfiniteCanvas.tsx
ViewportRoot.tsx
GridBackground.tsx
objects/
MindMapView.tsx
PdfView.tsx
VideoView.tsx
panels/
Toolbar.tsx
Inspector.tsx
Timeline.tsx
store/
viewportStore.ts
objectsStore.ts
selectionStore.ts
utils/
transform.ts
layoutMindMap.ts
pdf.ts
videoFrame.ts
workers/
pdfWorker.ts
在学习环境里,先不要急着加入云同步、协同编辑、权限系统,而是把画布缩放、对象渲染、文档解析三条线跑通。
3. 无限画布底板:坐标变换、缩放和拖动的一次到位实现
无限画布要做的第一件事,是让用户可以用鼠标拖动和滚轮缩放查看任意区域。这个功能看起来简单,但坐标变换写错了,后续所有对象定位都会乱。
3.1 世界坐标、屏幕坐标和视口变换
可以这样定义:
- 世界坐标:对象在无限平面中的真实位置;
- 屏幕坐标:用户屏幕上可见像素的位置;
- 视口变换:把世界坐标映射到屏幕坐标。
变换公式是:
text复制screenX = worldX * scale + offsetX
screenY = worldY * scale + offsetY
反向公式是:
text复制worldX = (screenX - offsetX) / scale
worldY = (screenY - offsetY) / scale
把这两个公式放到一个 utils/transform.ts 里:
typescript复制export function worldToScreen(
world: CanvasPoint,
viewport: { offsetX: number; offsetY: number; scale: number }
): CanvasPoint {
return {
x: world.x * viewport.scale + viewport.offsetX,
y: world.y * viewport.scale + viewport.offsetY,
};
}
export function screenToWorld(
screen: CanvasPoint,
viewport: { offsetX: number; offsetY: number; scale: number }
): CanvasPoint {
return {
x: (screen.x - viewport.offsetX) / viewport.scale,
y: (screen.y - viewport.offsetY) / viewport.scale,
};
}
所有鼠标事件都要先通过 screenToWorld 换算到世界坐标,再和对象做碰撞检测。否则,显示和点击的坐标系不一致,会出现“画布看着在这个位置,鼠标却点不到”的问题。
3.2 滚轮缩放和拖拽平移
在 React 里,可以在画布容器上监听 wheel 和 pointerdown/pointermove/pointerup。wheel 缩放的关键是保持鼠标下的世界坐标不变:
typescript复制function handleWheel(e: WheelEvent) {
e.preventDefault();
const rect = canvasRef.current!.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
const nextScale = viewport.scale * Math.exp(-e.deltaY * 0.001);
viewportStore.setScale(nextScale, mouseX, mouseY);
}
拖拽平移比较简单:记录拖拽起点和初始 offset,然后在 pointermove 里计算差值并更新 offset。需要注意:如果按住的是可输入框、视频、PDF 页面上的元素,不能触发平移。可以在事件捕获阶段判断 target 是否为画布本体或背景层。
3.3 对象容器如何跟随视口变换
如果使用 DOM 渲染对象,通常是给整个 world 容器加一个 transform:
typescript复制const worldStyle = {
transform: `translate(${offsetX}px, ${offsetY}px) scale(${scale})`,
transformOrigin: '0 0',
};
对象实际保存的是世界坐标,鼠标事件通过屏幕坐标 / scale 换算后与对象坐标比对。
注意:不要用 CSS scale 去缩放视频对象。视频元素在浏览器里做 CSS 缩放后,像素清晰度和播放性能都会下降,更稳妥的做法是按 world 分辨率重新设置视频播放器尺寸。
这个坑在缩放比例大于 2 时尤其明显:画面会变模糊,点击位置也可能偏。
4. 脑图:树形结构、布局算法和连接线渲染
脑图模块的目标不是做一个完整脑图软件,而是在无限画布上提供一种“树形组织内容”的表达方式。它要有三个能力:从数据生成布局、渲染节点和连线、允许折叠展开。
4.1 脑图数据模型:先定义树,再平整为位置表
脑图通常是一棵有根的树。节点之间的关系是父子关系,而不是画布上的任意连线。定义如下:
typescript复制type MindNode = {
id: string;
text: string;
note?: string;
children: MindNode[];
parentId?: string;
};
type LayoutedMindNode = MindNode & {
x: number;
y: number;
width: number;
height: number;
depth: number;
indexInParent: number;
};
用 text、children 就能表达任意树结构。折叠状态单独放在 collapsedIds 里,不污染节点数据。
4.2 布局算法:左右展开的简化版本
为方便审阅,可以做“根节点在左,子节点向右展开”的布局。每个节点的 x 由深度决定,y 由子树高度决定。先计算每个节点的子树高度:
typescript复制function getNodeHeight(node: MindNode, collapsedIds: Set<string>): number {
if (collapsedIds.has(node.id) || node.children.length === 0) return 1;
return node.children.reduce(
(sum, child) => sum + getNodeHeight(child, collapsedIds),
0
);
}
function layoutTree(
root: MindNode,
collapsedIds: Set<string>,
nodeSize = { width: 200, height: 48, gapX: 80, gapY: 16 }
): LayoutedMindNode[] {
const layouted: LayoutedMindNode[] = [];
function walk(node: MindNode, depth: number, yStart: number, parentId?: string, indexInParent = 0): number {
const nodeHeight = getNodeHeight(node, collapsedIds);
const yCenter = yStart + (nodeHeight * (nodeSize.height + nodeSize.gapY)) / 2 - nodeSize.gapY / 2;
layouted.push({
...node,
parentId,
indexInParent,
x: depth * (nodeSize.width + nodeSize.gapX),
y: yCenter,
width: nodeSize.width,
height: nodeSize.height,
depth,
});
if (collapsedIds.has(node.id)) return nodeHeight;
let childY = yStart;
node.children.forEach((child, idx) => {
childY += walk(child, depth + 1, childY, node.id, idx);
});
return nodeHeight;
}
walk(root, 0, 0);
return layouted;
}
这个算法只考虑水平展开,复杂度不高,但对大部分“从根节点梳理知识结构”的场景已经够用。更复杂的布局可以引入子树宽度、水平对称和自动换行。
4.3 节点渲染和贝塞尔连接线
节点用 DOM 卡片渲染,每个节点包含一个文本输入框、折叠按钮和背景。父节点到子节点的连线可以用 SVG 的贝塞尔曲线。
tsx复制function MindMapView({ layouted }: { layouted: LayoutedMindNode[] }) {
return (
<svg
style={{
position: 'absolute',
left: 0,
top: 0,
overflow: 'visible',
pointerEvents: 'none',
width: 0,
height: 0,
}}
>
{layouted.map((node) =>
node.parentId
? renderLink(parentById.get(node.parentId), node)
: null
)}
</svg>
);
}
连接线函数可以用 cubic bezier:
typescript复制function renderLink(from: LayoutedMindNode, to: LayoutedMindNode) {
const x1 = from.x + from.width;
const y1 = from.y + from.height / 2;
const x2 = to.x;
const y2 = to.y + to.height / 2;
const d = `M ${x1} ${y1} C ${x1 + (x2 - x1) / 2} ${y1}, ${x1 + (x2 - x1) / 2} ${y2}, ${x2} ${y2}`;
return <path key={to.id} d={d} stroke="#94a3b8" strokeWidth={1.5} fill="none" />;
}
SVG 里要注意 pointerEvents: none,否则连线会挡住节点的点击事件。脑图在无限画布中保存的是根节点,不保存每个节点的位置。这样修改节点文字、增删子节点后,重新调用 layoutTree 就能得到新位置。
4.4 脑图常见坑:布局后父节点矩形未更新、折叠状态没有触发重算
折叠展开是脑图最容易出问题的地方。推荐做法是把 collapsedIds 放进 MindMapObject,并在操作折叠后手动更新 layoutVersion,再由组件感知 layoutVersion 重新计算布局。否则,折叠节点后子节点仍然占据位置,画面会留出奇怪的空白。
5. PDF 审阅:解析渲染、页面坐标对齐和锚点标注
PDF 模块承担的职责很多:解析文件、渲染页面、显示页面缩略图、在页面上添加标注、把标注和脑图或视频建立关联。这也是用户搜索“pdf解析、pdf编辑器、web页面pdf打印”等词时最关心的部分。
5.1 用 pdf.js 把 PDF 页面渲染为画布可用的位图
先安装依赖:
bash复制npm install pdfjs-dist
在 Vite 项目里,worker 文件的处理方式需要单独配置。推荐把 worker 资源作为 URL 引入:
typescript复制import * as pdfjsLib from 'pdfjs-dist';
import workerUrl from 'pdfjs-dist/build/pdf.worker.mjs?url';
pdfjsLib.GlobalWorkerOptions.workerSrc = workerUrl;
将 PDF 页面渲染成 Canvas 的函数可以这样写:
typescript复制async function renderPageToCanvas(
pdf: PDFDocumentProxy,
pageNumber: number,
targetScale = 1.5
): Promise<HTMLCanvasElement> {
const page = await pdf.getPage(pageNumber);
const viewport = page.getViewport({ scale: targetScale });
const canvas = document.createElement('canvas');
canvas.width = viewport.width;
canvas.height = viewport.height;
const context = canvas.getContext('2d')!;
await page.render({ canvasContext: context, viewport }).promise;
return canvas;
}
这里 targetScale 是渲染 PDF 页面的分辨率系数,不是画布缩放比例。为了在高 DPI 屏幕上保持清晰,通常取 devicePixelRatio 的倍数。
5.2 页面尺寸、缩略图和打印导出
PDF 对象在画布中的真实尺寸应由 PDF 页面尺寸决定。保存页面宽度、高度和实际缩放系数,渲染时按这个尺寸放到对象区域中。缩略图通常单独渲染一个小比例 canvas,用于侧边栏或文档列表。
“web页面pdf打印”、“pdf转word”这类需求经常出现在项目外围。无限画布笔记里不需要直接做转 Word,但可以导出“当前画布视图为 PDF”。实现思路:先按视口宽度设置导出 Canvas 尺寸,再用 html2canvas 或逐对象绘制到 Canvas,最后调用 jsPDF 或 PDF.js 的 createDocument 生成文件。需要注意的是,导出 PDF 时不要直接截屏,否则文字在缩放后可能模糊。
5.3 页面锚点标注:使用相对坐标而不是像素坐标
PDF 审阅的核心是锚点。用户在 PDF 页面上看到一段需要记录的内容时,希望点击位置并写下一段笔记。如果保存像素坐标,那么 PDF 对象位置、对象缩放变化后标注就会错位。所以持久化的坐标应该使用相对比例。
定义标注类型:
typescript复制type PdfAnnotation = {
id: string;
pageNumber: number;
// xRatio、yRatio 是相对页面左上角的比例
xRatio: number;
yRatio: number;
widthRatio: number;
heightRatio: number;
color: string;
text: string;
createdAt: number;
};
点击页面添加标注时,把鼠标位置换算成页面相对位置:
typescript复制function toPageRatio(canvasRect: DOMRect, clientX: number, clientY: number) {
return {
xRatio: (clientX - canvasRect.left) / canvasRect.width,
yRatio: (clientY - canvasRect.top) / canvasRect.height,
};
}
渲染标注时再反向换算为像素矩形,绘制半透明覆盖层。
5.4 文字选中和高亮的实现思路
用户期望“选中 PDF 文字并高亮”。真正实现需要利用 pdf.js 的 getTextContent。流程是:
- 获取页面 textContent 里的 text items;
- 将每个 text item 通过 transform 映射到页面坐标;
- 将用户鼠标选中的屏幕范围转换为页面坐标范围;
- 命中范围内的 text item,计算其边界矩形;
- 在页面 canvas 上叠加半透明矩形。
这个步骤涉及坐标系转换,常见问题是:文字 item 的变换没有乘以 viewport 变换,导致高亮位置偏移。可以在 renderTextLayer 时使用 pdf.js 提供的 viewport 转换方法:
typescript复制const viewport = page.getViewport({ scale: targetScale });
const transform = viewport.transform;
实际项目中,并不是每次都要做全文检索。可以先做“区域内批注 + 手动高亮矩形”,等基础功能稳定后再加入文本层对齐。
5.5 PDF 模块的常见坑
| 现象 | 原因 | 处理方式 |
|---|---|---|
| 控制台提示设置 workerSrc | worker 文件未正确配置 | 使用 ?url 引入 worker,或确认路径 |
| PDF 跨域加载失败 | 资源服务未开启 CORS | 通过后端代理或 Nginx add_header Access-Control-Allow-Origin |
| 渲染出的页面模糊 | targetScale 未考虑 DPR | 根据 window.devicePixelRatio 调整渲染倍数 |
| 标注和页面错位 | 保存的是像素坐标而不是相对坐标 | 统一改用 xRatio/yRatio 和页面尺寸换算 |
| 内存占用过高 | 渲染了大量页面 Canvas 且没有释放 | 使用 LRU 缓存,页面离开视口后移除 Canvas |
6. 视频审阅:播放控制、帧截图和时间轴标记
视频审阅在无限画布里的价值,是让观看视频和整理笔记发生在同一空间。用户可以拉入一个视频对象,在暂停的某一帧旁写结论,也可以从时间轴直接跳到关键片段。
6.1 视频作为画布对象而不是独立播放器
把 video 元素直接放进 world 容器并跟随 transform 缩放,会面临性能问题。更好的做法是“画布里放一个视频卡片,真正的 video 元素只有在播放时才会被挂载到卡片内”。对象数据里只保存视频地址、封面图和当前时间。
tsx复制function VideoView({ object }: { object: VideoObject }) {
return (
<div className="video-card" style={objectStyle}>
<video
src={object.videoUrl}
poster={object.posterUrl}
controls
playsInline
/>
</div>
);
}
如果视频数量很多,不要让所有视频同时存在。可以在视口剔除里判断“不在屏幕范围内或不在预览模式”时,只显示封面图。
6.2 从视频里截图并保存为笔记
截图是视频审阅的常用操作。利用 video 元素和 canvas:
typescript复制async function captureVideoFrame(video: HTMLVideoElement): Promise<string> {
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const ctx = canvas.getContext('2d')!;
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
return canvas.toDataURL('image/png');
}
注意:视频未播放或 currentTime 为 0 时,drawImage 可能输出空白帧。截图前建议先 seek 到目标时间并等待 seeked 事件,再绘制。
typescript复制function seekTo(video: HTMLVideoElement, time: number): Promise<void> {
return new Promise((resolve) => {
const onSeeked = () => {
video.removeEventListener('seeked', onSeeked);
resolve();
};
video.addEventListener('seeked', onSeeked);
video.currentTime = time;
});
}
由于浏览器对自动播放策略的限制,seek 和截图都需要发生在用户交互之后。未点击页面时直接设置 currentTime 可能被拒绝,但绝大多数交互场景下可以正常执行。
6.3 时间轴标记与跳转
审阅过程中,用户点击“添加标记”时记录当前视频时间、文本、截图像素,并生成一个 VideoMark:
typescript复制type VideoMark = {
id: string;
time: number; // 秒
text: string;
screenshot: string; // data URL
createdAt: number;
};
渲染时间轴时,把时间转换为横向坐标:
typescript复制function timeToX(time: number, duration: number, width: number) {
return (time / duration) * width;
}
点击标记时执行 video.currentTime = mark.time,并滚动到对应视频卡片。这样,用户就可以从一段视频里快速回到关键画面。
6.4 视频模块的常见坑
| 现象 | 原因 | 处理方式 |
|---|---|---|
| 视频无法自动播放 | 浏览器自动播放策略限制 | 等待用户点击后播放,设置 muted 和 playsInline |
| 截图是黑屏 | 未等 seeked 就 drawImage | seek 后监听 seeked,再截取 |
| 多个视频同时播放声音 | 播放器没有互斥 | 维护一个 activeVideoRef,播放前暂停其他视频 |
| 无限画布缩放后视频模糊 | 用 CSS transform 缩放 video | 用真实分辨率重设视频元素宽高 |
7. 高颜值细节:网格、卡片、主题和交互反馈
高颜值的笔记应用不能只靠边框和阴影。无限画布的视觉设计要服务于“空间感”,让用户能快速判断自己处于哪个缩放层级。
7.1 网格背景和缩放层级
网格是无限画布最重要的视觉锚点。实现时可以绘制两层网格:底层大网格,缩放超过某个阈值后显示上层小网格。
tsx复制function GridBackground({ viewport }: { viewport: ViewportState }) {
const gridSize = 100;
const minorStep = viewport.scale > 0.8 ? 20 : 100;
const lines = [];
// 根据视口范围计算需要绘制的线
return (
<canvas
ref={canvasRef}
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%' }}
/>
);
}
在 Canvas 里根据视口范围动态绘制网格线,而不是绘制整块无限大的背景,可以保证任意拖动时都流畅。绘制时要用 viewport.offsetX 和 scale 计算网格原点和间距的偏移。
7.2 对象卡片样式
脑图节点、PDF 页面和视频卡片虽然内容不同,但可以共享一套基础卡片样式:圆角、浅色背景、细边框、下拉阴影。具体的颜色值可以放到主题变量里。
css复制.canvas-object {
position: absolute;
border-radius: 12px;
background: var(--object-bg);
border: 1px solid var(--border-color);
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
}
.card-selected {
border-color: var(--accent-color);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
}
选中态比普通态更明显,但不要用太重的描边遮挡内容。移动缩放手柄要小,且只在选中对象时显示。
7.3 深色模式和字体
深色模式不是简单把背景涂成黑色。要以主题变量管理背景、文字、边框、网格线和阴影。字体建议使用 system-ui 栈,保证跨平台一致性:
css复制:root {
--font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
--bg-color: #f8fafc;
--text-color: #1e293b;
--grid-color: #e2e8f0;
}
.dark {
--bg-color: #0f172a;
--text-color: #e2e8f0;
--grid-color: #1e293b;
}
高颜值的核心不是特效,而是层级清晰:背景保持安静,卡片承担内容,选中状态和信息反馈成为用户关注点。
8. 性能优化、生产落地和排查清单
无限画布笔记最大的风险不是功能开发,而是内容变多后的性能和稳定性。这里从几个维度给出生产建议。
8.1 渲染分层与视口剔除
当画布上有大量对象时,不要一次性渲染全部对象。可以在视口变化后计算当前可见矩形,只渲染与矩形相交的对象:
typescript复制function getVisibleRect(viewport: ViewportState, viewportWidth: number, viewportHeight: number) {
return {
x: -viewport.offsetX / viewport.scale,
y: -viewport.offsetY / viewport.scale,
width: viewportWidth / viewport.scale,
height: viewportHeight / viewport.scale,
};
}
function isVisible(obj: BaseObject, visibleRect: Rect) {
return (
obj.x + obj.width > visibleRect.x &&
obj.x < visibleRect.x + visibleRect.width &&
obj.y + obj.height > visibleRect.y &&
obj.y < visibleRect.y + visibleRect.height
);
}
视口变化是一个高频事件,在 React 里要做防抖或 requestAnimationFrame 节流,避免每次 offset 微调都触发全量渲染。
PDF 页面的图片缓存也很重要:一个 100 页的 PDF 不可能全部渲染成 Canvas 常驻内存。可以让同一个 PDF 对象只保留当前页和相邻页的渲染结果,其他页面按需渲染;同时记录页面版本,避免重复渲染。
8.2 持久化、导出和跨端能力
普通笔记应用至少要把画布对象保存到本地:IndexedDB 适合存储二进制文件,LocalStorage 适合存小对象。建议使用一个版本号字段,每次保存时递增,方便未来做服务端同步和冲突处理。
生产环境需要的额外能力包括:
- 配置外置化:把存储服务、文件上传地址、最大文件大小放到环境变量;
- 日志:记录对象加载耗时、PDF 和视频加载错误;
- 权限:多人协同时按文档粒度和对象粒度做访问控制;
- 回滚:保存操作要有快照或事务,避免写入一半导致整个文件损坏;
- 资源清理:关闭文档时释放 PDF 实例、停止视频播放、回收截图像素 Cache。
8.3 常见问题定位链路
下表归纳了几个典型问题,按“现象、可能原因、检查方式、处理建议”展开:
| 问题现象 | 常见原因 | 检查方式 | 处理建议 |
|---|---|---|---|
| 缩放后对象错位 | offset 没有随 scale 同步变化 | 比对鼠标位置和对象吸附坐标 | 使用统一的 worldToScreen/screenToWorld |
| PDF 空白或报错 | worker 配置缺失或跨域 | 查看控制台网络请求和 worker 日志 | 确认 workerSrc、检查 CORS |
| 视频无法播放 | 视频服务不支持 range 请求 | 用 curl 检查 Accept-Ranges 响应头 | 确认服务器支持 Range |
| 画布节点一多就卡 | 没有做视口剔除 | 性能面板查看 DOM 节点数量 | 启用剔除和 requestAnimationFrame 节流 |
| 刷新后内容丢失 | 只保存在内存 | 检查 LocalStorage/IndexedDB 写入 | 增加自动保存和版本号 |
排查时按“输入是否正确 -> 文件路径和命名 -> 依赖版本和 worker 加载 -> 配置是否生效 -> 权限、端口、网络 -> 日志异常”的顺序走,不要一上来就怀疑框架本身。
8.4 可复用的生产检查清单
- 视口缩放时 offset 与 scale 是否来自同一个 store,避免状态分层不一致。
- 所有画布对象是否有统一 id、title、zIndex。
- PDF 页面渲染是否按需进行,是否有 LRU 缓存上限。
- 视频对象是否有互斥播放逻辑,截图是否等待 seeked。
- 对象持久化是否带上 schema 版本,读取时是否做字段兼容。
- 是否对超大画布做视口剔除,是否是频繁触发重渲染。
- 是否处理了 CORS、Worker 路径、DPR 缩放这些环境问题。
- 是否在生产环境加入错误上报和对象级容灾。
这套设计同样适用于知识库、白板协作、课堂批注和设计走查工具。如果只想快速跑通,可以从一个包含网格、一个 PDF 页面、一个视频卡片的最小平板开始,再逐步加入脑图和多对象关联。先把对象模型、坐标变换和状态流转做对,后面的“高颜值”才有稳定的底座。
