JEPA4Japan · 教程

第 17 章:自己造车,还是购买成熟底盘?

6,450字 17分钟阅读 #Canvas#前端工程#无限画布#通俗讲解

固定使用 tldraw 5.x 版本,在原生 Canvas、SVG、Konva、GPU、Three.js 与 tldraw 之间编写自研/采购/混合方案 ADR。

课程进度 课程大纲 已发布 18/18 课

从一个五岁孩子也能理解的游戏开始

本章唯一的真理:选择一个库,是一项涉及产品、架构、许可、维护和退出成本的决策,而不是比拼谁的演示最炫酷。

桌上有三种制造车辆的方法。第一个箱子里只有钢板和螺丝:方向盘、刹车和车轮都要由你自己制造。它让你拥有最大的自由度,可以造出一辆火星探测车,但每一颗损坏的螺丝都得由你负责。第二个箱子里是一辆完整的校车:座椅、安全带和车门都已经可以正常使用,因此能迅速载人,但你必须遵守供应商规定的尺寸和使用合同。第三种方法是购买成熟的底盘,同时保留对车身、路线牌和乘客名单的控制权。

先做一个预测:团队既然学会了炼钢,是否就应该通过开采矿石来开始明天的校车接送?不应该。现在再预测一下:如果校车供应商明年更改了连接器,而乘客名单只存在于供应商的仪表板上,更换车辆会容易吗?不会。真正的问题是:哪些东西必须由你拥有,哪些可以买来,合同成本是多少,以及如果它发生故障或你决定离开,如何完整带走乘客和路线?

  1. 写下目的地先定义产品能力
  2. 比较底盘不要只比较速度
  3. 阅读使用合同为生产许可编制预算
  4. 安装自己的连接器领域适配层
  5. 演练更换车辆所有数据都能完整迁出
先做选择:路线是固定的,需要许多白板能力,上线时间紧迫,但业务记录必须继续由后端控制。答案通常是混合方案,而不是盲目地从头构建一切或把一切全部交出去。

本章仅评估 tldraw@5.3.2,绝不评估 latest。固定版本可以让代码、迁移、许可证审查和回归基线指向同一个对象。这并不意味着永远不升级;它意味着每次升级都是一个明确的项目。

将玩具类比映射到 Canvas

玩具车辆决策Canvas 实验室决策必须回答的问题
用钢材制造整辆车自建 / 原生 Canvas团队是否会永久负责编辑器内核?
购买一辆完整的校车购买 / SDK现有能力是否匹配,许可和升级是否可以接受?
购买底盘并制造车身混合方案SDK 与业务领域之间的边界在哪里?
乘客和路线领域模型由业务后端拥有的长期事实
仪表板的内部信号SDK StoreShape、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 无法恢复语义退出演练中缺少 TaskDomain 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”。解释为什么知道如何制造车轮,并不意味着每次出行前都要先炼钢。购买底盘时,为什么必须保留乘客名单?如果供应商更换连接器或停止营业,你要如何把车辆带回家?

一个不使用术语的好答案

送孩子上学需要安全带、刹车和车门。成熟的底盘已经解决了这些棘手问题,因此可以更早启程。只有当路线确实不同寻常,并且团队愿意永远维修每一颗螺丝时,才应该从头构建一切。把孩子的姓名、权限和路线保存在自己的台账中;仪表盘上只放展示和驾驶所需的座位卡,并使用一个可检查的连接器在两者之间进行转换。购买前,写明合同、年度成本和到期时间,并定期在另一辆简单的车辆中测试该台账。如果供应商更换连接器或不再维修校车,每位乘客仍然可以完好无损地离开。