课程进度 课程大纲 已发布 18/18 课
第一部分:落笔之前先选画布——产品、像素与坐标
第二部分:给像素世界装上大脑——模型、调度、输入与工具
第三部分:从“能拖动”到“值得信赖”——交互、文字、资产与恢复
第四部分:大师级决策——性能、Worker、GPU、SDK、协作与 AI
从一个五岁孩子也能理解的游戏开始
本章唯一的真理:选择一个库,是一项涉及产品、架构、许可、维护和退出成本的决策,而不是比拼谁的演示最炫酷。
桌上有三种制造车辆的方法。第一个箱子里只有钢板和螺丝:方向盘、刹车和车轮都要由你自己制造。它让你拥有最大的自由度,可以造出一辆火星探测车,但每一颗损坏的螺丝都得由你负责。第二个箱子里是一辆完整的校车:座椅、安全带和车门都已经可以正常使用,因此能迅速载人,但你必须遵守供应商规定的尺寸和使用合同。第三种方法是购买成熟的底盘,同时保留对车身、路线牌和乘客名单的控制权。
先做一个预测:团队既然学会了炼钢,是否就应该通过开采矿石来开始明天的校车接送?不应该。现在再预测一下:如果校车供应商明年更改了连接器,而乘客名单只存在于供应商的仪表板上,更换车辆会容易吗?不会。真正的问题是:哪些东西必须由你拥有,哪些可以买来,合同成本是多少,以及如果它发生故障或你决定离开,如何完整带走乘客和路线?
- 写下目的地先定义产品能力
- 比较底盘不要只比较速度
- 阅读使用合同为生产许可编制预算
- 安装自己的连接器领域适配层
- 演练更换车辆所有数据都能完整迁出
本章仅评估 tldraw@5.3.2,绝不评估 latest。固定版本可以让代码、迁移、许可证审查和回归基线指向同一个对象。这并不意味着永远不升级;它意味着每次升级都是一个明确的项目。
将玩具类比映射到 Canvas
| 玩具车辆决策 | Canvas 实验室决策 | 必须回答的问题 |
|---|---|---|
| 用钢材制造整辆车 | 自建 / 原生 Canvas | 团队是否会永久负责编辑器内核? |
| 购买一辆完整的校车 | 购买 / SDK | 现有能力是否匹配,许可和升级是否可以接受? |
| 购买底盘并制造车身 | 混合方案 | SDK 与业务领域之间的边界在哪里? |
| 乘客和路线 | 领域模型 | 由业务后端拥有的长期事实 |
| 仪表板的内部信号 | SDK Store | Shape、Selection、Tool 和渲染所需的记录 |
| 连接器适配器 | 领域适配器 | 双向转换、ID 映射和故障隔离 |
| 使用合同 | 许可证 | 开发、试用、商业和业余生产条款 |
| 替换车门 | 退出策略 | 导出、替换 Renderer、数据回迁和演练 |
| 年度检查 | 升级回归测试 | 自定义 Shape/Tool/Binding/Migration 回归测试 |
这个类比也有其边界:SDK 并不是一辆完全封闭的校车。tldraw 的源代码是可见的,而且提供了许多扩展点。“购买”主要意味着取得许可,并将维护责任委托给 SDK。适配层也无法神奇地消除所有耦合。选择操作的体验、剪贴板格式、协作协议和导出像素仍会形成产品依赖,必须列入退出检查清单。
先消除错误直觉
- “tldraw 的名称中包含 ‘draw’,因此每个 Shape 都会被绘制到 Canvas 2D 位图中。” 在 5.3.2 中,ShapeUtil 的
component()返回一个 React 元素。主要 Shape 使用 React 组件层以及 DOM/HTML/SVG。选择轮廓、框选、吸附以及类似的叠加层可能由 OverlayUtil 在 Canvas 2D 中绘制。并非“一切都是 Canvas 2D”。 - “tldraw sync 内置的就是 Yjs。” 错误。tldraw sync 使用权威服务器、客户端待确认/已确认层以及差异协调机制。官方文档明确说明它不是 CRDT/Yjs。Yjs 是另一种可以单独集成的后端选项。
- “源代码在 GitHub 上,所以生产环境永远免费。” 错误。版本 5.3.2 受 tldraw SDK 许可证约束。开发不需要密钥。生产环境需要有效的试用、商业或业余密钥。商业生产需要商业许可证;业余许可证仅供非商业用途,并会保留水印。
- “只读就意味着权限安全。” 只读是一种客户端交互模式。恶意客户端仍可直接调用网络/API。服务器必须验证身份、对象权限和每一次写入。
- “让业务表等同于 TLStore 是最简单的解决方案。” SDK Record 与产品领域具有不同的版本、ID 和生命周期。将 Store 视为唯一的后端模型,会把退出和报表能力绑定到 SDK 上。
- “5.x 升级只能包含与补丁版本兼容的更改。” tldraw 官方声明其不使用 SemVer;普通的次要版本也可能包含破坏性变更。请固定精确版本、阅读发布说明、执行迁移并进行回归测试。
- “这个库很受欢迎,所以构建还是购买的问题已经替我们决定好了。” 用户任务、对象复杂度、无障碍性、许可、协作、团队能力和退出成本才是决策输入。
生产背包
前置条件
第 1 章已经定义了 Renderer/产品边界。第 15 章提供了真实的性能 Trace。第 16 章验证是否确实需要 Worker/GPU。没有这些证据,“我们未来某天可能会有 100,000 个对象”并不能成为采用复杂技术的正当理由。无论选择哪种 SDK,第 5 章和第 13 章中的领域文档与迁移都会继续作为长期事实存在。
正式知识
选择矩阵。 原生 Canvas 2D 适合高度定制的产品、简单对象、非常规绘图算法,以及愿意承担 Geometry、Selection、Accessibility、Text、History、Clipboard、Collaboration 和 Migration 全部成本的团队。SVG/DOM 适合重视文本、语义、Accessibility、CSS 和 Layout,对象数量适中,并且原生浏览器交互很有价值的产品。Konva 风格的 Scene Graph 适合希望使用 Canvas 2D,但不想从头维护每个 Node、Event 和 Transform 的团队。GPU 2D 适合大量 Sprite、动态效果、高 Fill Rate 和自定义 Shader——前提是 Profile 已证明 GPU 路径确实有帮助。Three.js 适合真正需要 Scene/Camera/Mesh/Material、Perspective、Depth、Lighting 和 Raycasting 的场景,而不是普通 2D 无限画布的默认升级方案。
tldraw 适用于白板、图表、工作流、无限画布,以及复杂的选择、工具、历史记录、文本、绑定、导出和协作功能。在 5.3.2 中,主要的 Shape 渲染由 React 组件层次结构驱动,ShapeUtil 可以返回 HTML/SVG。新的 OverlayUtil 系统则将临时的选择/吸附 UI 直接绘制到 Canvas 2D 中。因此,准确的描述是“主要使用 React/DOM/SVG,部分叠加层使用 Canvas 2D”,而不是任何一种绝对化口号。
固定依赖版本。 使用 npm install --save-exact tldraw@5.3.2 @tldraw/sync@5.3.2,提交锁文件,并让 CI 断言软件包版本。绝不要使用 ^5.3.2。客户端和同步服务器必须一起发布。双方都要注册自定义 Shape/Binding 的 Schema 和 Migration。更改锁文件之前,先在隔离的兼容性环境中打开旧 Snapshot,导出黄金文档,并运行浏览器回归和视觉回归测试。
生产许可。 tldraw@5.3.2 是源码可用软件,并非像 MIT 或 Apache 那样采用宽松许可证的开源软件。请核实截至 2026-08-29 的官方条款:开发无需密钥;生产使用需要有效的许可证密钥。100 天试用期仅用于评估。商业产品和企业内部生产用途需要商业许可。非商业用户可以申请酌情授予的爱好者许可证,该许可证会带有“made with tldraw”水印。密钥在客户端本地验证,可以公开,但其中包含允许的主机、类型和到期时间。价格是采购决策的输入,因此绝不要在 ADR 中猜测固定金额。记录销售报价、续订信息、域名、到期时间和法务批准。还要将许可证遥测纳入隐私审查。当前官方的许可证密钥页面称,商业密钥不会发送任何数据,而试用版/爱好者版会发送许可证 ID、SDK 版本和页面 URL。这些并非 Canvas 内容,但 URL 仍可能包含敏感路径或查询参数。测试生产域名和路由,并由法务/隐私负责人确认。tldraw sync 包含在 SDK 许可证的覆盖范围内,但这并不意味着它是免费的托管服务。官方项目不托管生产环境的同步服务;团队必须自行托管并持久化数据。
Store 不是 Domain。 SDK 负责形状/页面/绑定/资产的画布表示、工具状态和内部 Migration。业务后端负责 WorkItem、权限、审批状态、审计、业务版本、Asset 策略和稳定的业务 ID。DomainAdapter.toCanvas 将业务对象投影为 Shape。fromCanvasCommand 将允许的编辑转换为业务 Command。Shape 中的 meta.domainId 只是引用;不要将薪资、机密信息或完整的业务 Payload 放入 Store。
扩展面。 自定义 Shape 提供 props 验证器、Migration、几何结构、React 组件、指示器和调整尺寸行为。自定义 Tool 使用分层 StateNode。自定义 Binding 表达由 BindingUtil 维护的有向关系。自定义 Record 必须声明其 document/session/presence 作用域、验证器和 Migration。Domain Adapter 是你的防腐层。视觉 Export 使用 SDK 的 SVG/PNG 流水线;语义 Export 使用你自己的 Domain JSON。Readonly Mode 提供 Viewer,但感知权限的控件也必须根据能力隐藏或禁用命令,且服务器必须重新验证每次写入。
证据与兼容性(已于 2026-08-29 验证)
固定 npm Registry 发行版 tldraw 5.3.2,并验证匹配的 @tldraw/sync 5.3.2。有关 React 组件层次结构和 Geometry 契约,请参阅官方 Shapes 文档;有关 Canvas 2D 临时层这一例外,请参阅 Overlay 工具。有关 Store 作用域、自定义 Record 和 Migration,请参阅 Store 与持久化。有关 tldraw sync 的权威服务器模型,请参阅协作;官方项目还明确指出,tldraw sync 并非 Yjs/CRDT。有关生产密钥、许可证类型和当前数据传输的信息,请遵循更具体的许可证密钥页面,并阅读完整的许可证。采购前仍必须由法律顾问审查实际协议;本教程不构成法律建议。
本章的工程增量
起点: Canvas Lab 的自定义内核提供完整的知识产权所有权和教学价值,但交付白板级交互的成本很高。终点: 制作一个固定到 5.3.2 的 Hybrid 技术验证,重建业务 Card Shape、创建 Tool、依赖项 Binding、自定义 Record、Domain Adapter、Migration、双重 Export、Readonly 和权限控件,并编写一份包含可执行退出计划的 ADR。
添加以下文件和接口:
src/sdk/tldraw/domain.ts和DomainAdapter.ts:唯一的业务边界;src/sdk/tldraw/TaskShapeUtil.tsx:自定义 Shape + props Migration;src/sdk/tldraw/CreateTaskTool.ts:自定义 Tool;src/sdk/tldraw/DependencyBindingUtil.ts:自定义 Binding;src/sdk/tldraw/schema.ts:文档作用域的自定义 Record;src/sdk/tldraw/CanvasSdkView.tsx:许可证、只读和权限控件;src/sdk/tldraw/export.ts:Domain JSON 和视觉导出;docs/adr/017-canvas-sdk.md:自建/购买/混合决策和退出演练。
下面是一个可运行的自定义 Shape、Migration、Tool、Domain Adapter 和权限外壳。它有意让业务 Task 不包含任何 tldraw 导入:
import { useMemo } from 'react';
import {
BaseBoxShapeUtil,
HTMLContainer,
Rectangle2d,
StateNode,
T,
Tldraw,
TLBaseShape,
TLCreateShapePartial,
TLShapeId,
createShapeId,
createShapePropsMigrationIds,
createShapePropsMigrationSequence,
exportAs,
resizeBox,
} from 'tldraw';
import 'tldraw/tldraw.css';
import { createCanvasStore } from './schema';
export type Task = Readonly<{
id: string;
title: string;
status: 'todo' | 'doing' | 'done';
revision: number;
}>;
type TaskShape = TLBaseShape<
'task-card',
{ w: number; h: number; domainId: string; title: string; status: Task['status'] }
>;
const versions = createShapePropsMigrationIds('task-card', { AddStatus: 1 });
const migrations = createShapePropsMigrationSequence({
sequence: [
{
id: versions.AddStatus,
up: (props: Record<string, unknown>) => {
props.status ??= 'todo';
},
down: (props: Record<string, unknown>) => {
delete props.status;
},
},
],
});
export class TaskShapeUtil extends BaseBoxShapeUtil<TaskShape> {
static override type = 'task-card' as const;
static override props = {
w: T.number,
h: T.number,
domainId: T.string,
title: T.string,
status: T.literalEnum('todo', 'doing', 'done'),
};
static override migrations = migrations;
override getDefaultProps(): TaskShape['props'] {
return { w: 240, h: 112, domainId: '', title: 'New task', status: 'todo' };
}
override getGeometry(shape: TaskShape) {
return new Rectangle2d({ width: shape.props.w, height: shape.props.h, isFilled: true });
}
override component(shape: TaskShape) {
return (
<HTMLContainer
style={{
pointerEvents: 'all',
border: '2px solid #334155',
borderRadius: 12,
background: '#fff',
padding: 12,
}}
>
<strong>{shape.props.title}</strong>
<p>{shape.props.status}</p>
</HTMLContainer>
);
}
override getIndicatorPath(shape: TaskShape) {
const path = new Path2D();
path.rect(0, 0, shape.props.w, shape.props.h);
return path;
}
override onResize(shape: TaskShape, info: Parameters<typeof resizeBox>[1]) {
return resizeBox(shape, info);
}
}
export const DomainAdapter = {
toShape(
task: Task,
existingId?: TLShapeId,
): TLCreateShapePartial<TaskShape> & { id: TLShapeId; props: TaskShape['props'] } {
return {
id: existingId ?? createShapeId(task.id),
type: 'task-card',
x: 0,
y: 0,
props: { w: 240, h: 112, domainId: task.id, title: task.title, status: task.status },
};
},
toDomainPatch(
shape: Pick<TaskShape, 'props'>,
before: Task,
): Pick<Task, 'id' | 'title' | 'status' | 'revision'> {
if (shape.props.domainId !== before.id) throw new Error('DOMAIN_ID_MISMATCH');
return {
id: before.id,
title: shape.props.title,
status: shape.props.status,
revision: before.revision,
};
},
};
export class CreateTaskTool extends StateNode {
static override id = 'create-task';
override onEnter() {
this.editor.setCursor({ type: 'cross', rotation: 0 });
}
override onPointerDown() {
const p = this.editor.inputs.getCurrentPagePoint();
const id = createShapeId();
this.editor.createShape<TaskShape>({ id, type: 'task-card', x: p.x, y: p.y });
this.editor.select(id);
if (!this.editor.getInstanceState().isToolLocked) this.editor.setCurrentTool('select');
}
override onExit() {
this.editor.setCursor({ type: 'default', rotation: 0 });
}
}
type Capabilities = Readonly<{ edit: boolean; export: boolean }>;
const tools = [CreateTaskTool];
export function CanvasSdkView({
capabilities,
licenseKey,
}: {
capabilities: Capabilities;
licenseKey: string;
}) {
const store = useMemo(createCanvasStore, []);
return (
<div style={{ position: 'fixed', inset: 0 }}>
<Tldraw
licenseKey={licenseKey}
store={store}
tools={tools}
onMount={(editor) => {
editor.updateInstanceState({ isReadonly: !capabilities.edit });
const onExport = async () => {
if (!capabilities.export) throw new Error('EXPORT_FORBIDDEN');
await exportAs(editor, [...editor.getCurrentPageShapeIds()], { format: 'svg' });
};
(globalThis as typeof globalThis & { exportCanvas?: () => Promise<void> }).exportCanvas =
onExport;
return () => {
delete (globalThis as typeof globalThis & { exportCanvas?: () => Promise<void> })
.exportCanvas;
};
}}
/>
</div>
);
}
此技术验证仅为演示而将导出函数暴露到全局。生产 UI 应通过显式组件/服务注入该函数。服务器权限仍是真正的安全边界。下面是自定义 Binding 和自定义 Record 的基本数据契约。客户端和同步服务器必须注册相同的验证器/Migration:
import {
BaseRecord,
BindingUtil,
CustomRecordInfo,
RecordId,
T,
TLBaseBinding,
createCustomRecordId,
createCustomRecordMigrationIds,
createCustomRecordMigrationSequence,
createTLStore,
} from 'tldraw';
import { TaskShapeUtil } from './TaskShapeUtil';
export type DependencyBinding = TLBaseBinding<'task-dependency', { kind: 'blocks' | 'relates' }>;
export class DependencyBindingUtil extends BindingUtil<DependencyBinding> {
static override type = 'task-dependency' as const;
static override props = { kind: T.literalEnum('blocks', 'relates') };
override getDefaultProps(): DependencyBinding['props'] {
return { kind: 'blocks' };
}
}
export interface CanvasNote extends BaseRecord<'canvas-note', RecordId<CanvasNote>> {
taskId: string;
text: string;
createdAt: number;
}
const noteVersions = createCustomRecordMigrationIds('canvas-note', { AddCreatedAt: 1 });
export const canvasNoteInfo: CustomRecordInfo = {
scope: 'document',
validator: T.object({
id: T.string,
typeName: T.literal('canvas-note'),
taskId: T.string,
text: T.string,
createdAt: T.number,
}),
migrations: createCustomRecordMigrationSequence({
sequence: [
{
id: noteVersions.AddCreatedAt,
up: (record) => {
record.createdAt ??= 0;
},
down: (record) => {
delete record.createdAt;
},
},
],
}),
createDefaultProperties: () => ({ taskId: '', text: '', createdAt: 0 }),
};
export const newCanvasNote = (taskId: string): CanvasNote =>
({
id: createCustomRecordId('canvas-note'),
typeName: 'canvas-note',
taskId,
text: '',
createdAt: Date.now(),
}) as CanvasNote;
export const createCanvasStore = () =>
createTLStore({
shapeUtils: [TaskShapeUtil],
bindingUtils: [DependencyBindingUtil],
records: { 'canvas-note': canvasNoteInfo },
});
回到校车比喻:Task 是乘客记录;TaskShape 是座位卡。替换座位卡 Library 时,不得改变乘客的身份。依赖项 Binding 是座位之间的路线提示。Readonly 只管前门按钮;它不是警察,因此服务器仍需检票。只有将自定义 Record 放入 createTLStore({ records }) 时,它才算真正完成注册。如果你只是导出一个验证器对象,却让 <Tldraw> 创建其默认 Store,测试便永远无法覆盖它。客户端和同步服务器必须使用相同的 Schema 选项。
测试用于证明往返适配、独立于 UI 的权限,以及 Migration 能够打开旧 Snapshot:
import { expect, test } from 'vitest';
import { DomainAdapter } from '../DomainAdapter';
test('Domain → Shape → Domain preserves business identity', () => {
const task = { id: 'task-42', title: 'Review contract', status: 'doing' as const, revision: 7 };
const shape = DomainAdapter.toShape(task);
expect(shape.props.domainId).toBe('task-42');
expect(
DomainAdapter.toDomainPatch({ ...shape, props: { ...shape.props, status: 'done' } }, task),
).toEqual({ ...task, status: 'done' });
});
test('the server rejects an unauthorized write that bypasses readonly', () => {
const authorize = (canEdit: boolean, command: { type: string }) =>
canEdit && ['UpdateTask', 'MoveTask'].includes(command.type);
expect(authorize(false, { type: 'MoveTask' })).toBe(false);
expect(authorize(true, { type: 'DeleteWorkspace' })).toBe(false);
});
运行 npm ls tldraw @tldraw/sync。预期两者都恰好显示 5.3.2,且不会被去重为其他版本。运行 npm exec vitest run src/sdk/tldraw;预期 Domain 往返、v0 Shape Migration、Unknown Record、权限以及视觉/语义导出测试全部通过。运行 npm exec playwright test tests/tldraw-sdk.spec.ts;预期自定义 Tool/Shape/Binding、Readonly、键盘和导出测试在固定的浏览器矩阵中全部通过。
ADR-017:自建 / 购买 / 混合决策
状态: 已接受用于技术验证。只有在许可证报价、性能/无障碍回归和退出演练通过后,才可进入生产环境。
背景与约束: 产品是一个 Visual Operations Canvas,需要文本、连接线、多人协作、History、Clipboard、Migration,并且必须在三个月内交付。业务 Task/Permission/Audit 必须以后端为权威来源。它不是 3D 应用。第 15 章中的可见对象规模适合使用 DOM/React 剔除;尚无证据表明需要 GPU。
选项: 自建(当前的 Raw Canvas 内核)控制力最强,但交付成本和长期基础能力维护成本最高。购买(将所有数据直接放入 TLStore)速度最快,但会造成不可接受的业务耦合和退出耦合。混合(tldraw 负责编辑器体验;我们负责 Domain/权限/审计)同时满足时间和控制要求。最终决策为 Hybrid,并固定使用 tldraw 5.3.2。
所有权: SDK Store 拥有画布投影、Page/Shape/Binding/Asset 引用以及编辑器 History。业务后端拥有 Task、稳定的业务 ID、权限、审计、资产授权和领域版本。按照第 13 章和第 18 章的做法,分别界定 Session/Presence 的范围。领域写入只能通过 Adapter/Command API。
导出: 合法且适合长期保存的导出格式是带版本的 Domain JSON + Asset 清单。视觉导出使用 SDK SVG/PNG,并配有黄金测试。保留 CanvasPortableV1(位置、尺寸、文本、连接和 Asset ID),将其作为中立的替代 Renderer 格式。绝不能只保留 SDK Snapshot,并将其作为唯一备份。
迁移与测试: 将 Domain schema 和 SDK schema 作为两条独立的迁移链进行测试,并使用真实的匿名化黄金文档。每次升级都要运行类型检查、迁移固件、适配器往返、自定义 Shape/Tool/Binding、键盘/屏幕阅读器、视觉/导出、性能以及同步版本偏差测试。客户端和服务器应一同发布。保留上一个构建版本,并提供只读降级方案。
许可成本: 开发无需密钥。对于生产环境,年度 TCO 中应包含试用/商业/爱好者资格条件、报价、续约日期、允许的主机、不同许可类型下的数据传输行为、法律/隐私审查以及到期后的回退方案。商业项目应使用商业许可,不得用爱好者许可规避费用。将实际合同附在 ADR 中,而不是猜测价格。当版本或条款发生变化时重新核查;本章的日期并非永久承诺。
退出策略: 领域层绝不导入 tldraw。每个稳定 ID 都可以进行映射。每天生成 Domain JSON/PortableV1。Compatibility Renderer 可以以只读方式打开文档,并执行基本编辑。每季度在隔离环境中将黄金文档导入替代 Renderer,并记录不受支持的功能。退出顺序为:冻结新的 SDK 功能 → 导出并验证 → 转换绑定/文本/资产 → 双读比较 → 在低流量时段切换 → 保留只读的旧版 Viewer → 撤销许可。
SDK 停止维护: 固定一个经过审计的版本并保留构建资产。评估安全风险和浏览器兼容性退化窗口。先启用 Compatibility Renderer,再通过 PortableV1 迁移。分支版本只是临时的风险缓解措施;不要假设团队能够永远维护整个 SDK。
升级导致 Custom Shape 损坏: 绝不要覆盖旧 Snapshot。在影子环境中,让新版本读取其副本,然后运行 Shape 属性迁移以及视觉/语义差异比较。如果适配器、几何结构、导出或同步门禁失败,则回滚锁文件和服务器。如果无法避免发布,请在迁移前采用双读/旧写。绝不要直接为旧客户端写入不可逆的新格式。
后果: 我们获得了成熟的交互能力,并减少了基础必备功能的实现工作。我们接受对许可、React/DOM 渲染特性、版本升级和同步运维的依赖。Adapter、PortableV1 和每季度的退出演练是接受此依赖的硬性条件,而非装饰性文档。
有意破坏它
| 注入的故障 | 症状 | 证据 | 修复 | 回归测试 | 恢复 |
|---|---|---|---|---|---|
| 将 TLStore 当作业务数据库 | 报表与退出依赖 SDK 字段 | 导入图和备份格式 | Domain + Adapter | 架构边界测试 | 从 Domain 重建投影 |
| 从 5.3.2 升级后 Shape 损坏 | 旧卡片空白或尺寸异常 | 黄金迁移/视觉差异 | 属性迁移和版本固定 | 真实 Snapshot | 回滚客户端/服务器 |
| 同步服务器与客户端版本不同 | 房间拒绝数据或数据损坏 | 握手/schema 日志 | 一同发布 | 版本偏差测试套件 | 切换为只读并要求刷新 |
| 生产许可密钥无效/已过期 | SDK 在生产环境中不可用 | 许可诊断和到期台账 | 修正密钥和续约流程 | 预发布主机/到期测试 | Compatibility Viewer |
| 仅隐藏 Delete 按钮 | API 仍可删除对象 | 服务器审计 | 对每个 Command 进行授权 | 绕过 UI 的请求 | 拒绝操作并保留修订版本 |
| 自定义 Binding 未在服务器注册 | 同步/验证失败 | Schema 不匹配 | 两端使用相同 schema | 连接固件 | 阻止写入并保留在本地 |
| 导出仅保留 SDK Snapshot | 新 Library 无法恢复语义 | 退出演练中缺少 Task | Domain JSON + PortableV1 | 每季度导入 | 读取后端 Domain |
| SDK 停止维护 | 无人修复新浏览器中的回归问题 | 风险登记册 | 冻结、替换或使用受限分支 | 兼容性矩阵 | 分阶段迁移 |
用证据验收
| 自动化证据 | 人工证据 | 通过条件 |
|---|---|---|
| 精确版本断言、适配器往返、迁移/黄金测试、权限、导出、同步偏差和性能回归 | 键盘/屏幕阅读器任务、生产许可预发布测试以及每季度退出演练 | 所有自动化门禁均通过;用户可以通过 PortableV1 在替代 Viewer 中打开核心内容 |
| 导入图禁止 Domain 依赖 tldraw | 架构、法务、采购和安全团队联合审查 ADR | 所有权、TCO、允许的主机、续约和退出负责人均已获批 |
| 决策问题 | 本章的答案与证据 |
|---|---|
| 为什么选择 SDK? | 白板基础必备功能符合交付窗口和基准规模;不是因为它流行 |
| 谁拥有数据? | SDK 拥有画布投影;业务后端拥有 Domain/Permission/Audit |
| 如何导出/迁移? | Domain JSON + PortableV1 + 视觉导出;两条迁移链均使用黄金测试 |
| 如何测试? | 覆盖自定义扩展、无障碍、视觉、导出、性能和同步偏差的完整测试链 |
| License 的成本是多少? | 商业报价/续约计入 TCO;本教程不猜测具体数字 |
| 如何退出? | Adapter、稳定 ID、中立格式、Compatibility Renderer 和每季度演练 |
- 依赖项精确固定为
tldraw@5.3.2及其匹配的同步包。 - 应准确描述为“主要使用 React/DOM/SVG Shapes,Canvas 2D 叠加层属于例外”,绝不能说全部使用 Canvas 2D。
- 应准确描述 tldraw 同步由服务器权威控制,而不是 Yjs/CRDT。
- ADR 中包含生产许可密钥、商业/爱好者条件、续约和法律审查。
- Custom Shape、Tool、Binding、Record、Migration、Export、Readonly 和 Permission 控制均有证据。
- 业务模型不是 SDK Store,退出演练也不是假设性的。
- 给出最终的自建 / 购买 / 混合决策,并解释被否决选项的真实成本。
给五岁孩子讲明白
不要说“SDK”“Adapter”“License”或“Migration”。解释为什么知道如何制造车轮,并不意味着每次出行前都要先炼钢。购买底盘时,为什么必须保留乘客名单?如果供应商更换连接器或停止营业,你要如何把车辆带回家?
一个不使用术语的好答案
送孩子上学需要安全带、刹车和车门。成熟的底盘已经解决了这些棘手问题,因此可以更早启程。只有当路线确实不同寻常,并且团队愿意永远维修每一颗螺丝时,才应该从头构建一切。把孩子的姓名、权限和路线保存在自己的台账中;仪表盘上只放展示和驾驶所需的座位卡,并使用一个可检查的连接器在两者之间进行转换。购买前,写明合同、年度成本和到期时间,并定期在另一辆简单的车辆中测试该台账。如果供应商更换连接器或不再维修校车,每位乘客仍然可以完好无损地离开。