15 · 自定义渲染与交互
在不改 `canvasArtifact` 协议的前提下,把右侧 Canvas 升级成可编辑、可保存、可分享的工作区。
- 源码目录:phase-06-advanced-features/lesson-15-custom-rendering-and-interaction
- 建议先读:
app/components/canvas/CanvasPanel.tsx、app/hooks/useCanvasArtifacts.ts、app/api/artifacts/route.ts
一、学习目标
本课所在阶段:第六阶段 · 高级功能。
学完这一课后,你应该能够:
- 解释为什么这一课几乎不改
canvasArtifact协议,却依然是一次明显的能力升级 - 看懂
CanvasPanel里为什么要同时维护originalCode和draftCode - 理解
CodePreviewPanel为什么要从工作区 UI 中单独拆出来 - 明白
canvasStore.updateArtifactCode(...)、canvasVersion和mergeArtifacts(...)分别解决什么问题 - 说清
/api/artifacts、artifact.service.ts、database/artifacts.ts怎样把工作区结果保存到 Supabase - 知道分享页
app/artifact/[id]/page.tsx和可调宽度面板ResizablePanel是如何补齐产品体验的
二、问题背景
第 14 课已经让你第一次把结构化 React 结果送进 Canvas。到那一步,你已经可以:
- 让模型在正文里输出
canvasArtifact - 在消息区看到可点击的
CanvasTitleCard - 在右侧打开一个只读的预览 / 代码面板
但这还远远算不上“工作区”。
只要你真的试着使用它,就会立刻碰到几个卡点:
- 组件只要想改一个按钮文案,就得把代码复制出去再改。
- 刷新页面以后,右侧工作区里的结果不会单独留下来。
- 你没法把某个比较满意的 artifact 分享给别人单独查看。
- 画布宽度固定,预览和编辑没有清晰边界。
所以这一课真正解决的问题是:
让 Canvas 从“能打开的查看器”,升级成“能继续改、能继续预览、能存起来、能分享出去的工作区”。
三、核心概念
1. 协议层稳定后,渲染层可以单独演进
第 14 课建立的协议核心并没有变:
- assistant 仍然输出
<canvasArtifact> - 前端仍然调用
parseCanvasArtifactsFromContent(...) MarkdownRenderer仍然把自定义标签映射成CanvasTitleCard
也就是说,这一课没有重新定义“模型怎么输出”,而是重新定义“前端怎么消费这个结果”。
这正是你在工程里经常会遇到的情况:
- 协议一旦稳定,最好不要每次都重做
- 体验升级应该尽量集中在消费层
- 这样后续功能迭代才不会把上下游接口一起拖乱
2. 工作区必须区分“当前 artifact”与“本地草稿”
CanvasPanel.tsx 这节课最关键的变化,就是把代码状态拆成了两份:
-
originalCode来自当前 artifact 的正式代码内容。 -
draftCode来自面板本地的可编辑副本。
这一步很重要,因为它让你可以:
- 先试着改
- 再点“应用到预览”
- 再决定要不要“保存”或“分享”
- 也可以随时“重置”回当前 artifact 的正式版本
如果没有这层分离,你每敲一个字符都会直接污染 store,后面就很难支持重置、比较修改、保存成功提示这些交互。
3. store 现在不只存“打开哪个 artifact”,还要管理版本和恢复
相对第 14 课,这一课的 CanvasArtifact 多了三类信息:
sessionIdcurrentVersionisPersisted
同时 canvasStore 也多了几类行为:
openArtifact(...)updateArtifactCode(...)mergeArtifacts(...)markArtifactPersisted(...)getInitialTab()/resetInitialTab()
这说明 store 不再只是“最小打开 / 关闭状态”,而是开始承担工作区生命周期管理。
4. 保存和分享把 artifact 从“页面内状态”升级成“可复用资产”
本课新增的 /api/artifacts 和 app/artifact/[id]/page.tsx,把 artifact 的定位往前推了一步:
- 它不再只是聊天页面右侧的临时结果
- 它可以被保存到
artifacts表 - 它可以通过
/artifact/{id}单独打开
不过也要注意本课边界:
- 保存的是 artifact 记录,不是回写 assistant 原消息正文
- 工作区编辑器还是
textarea,不是完整 IDE - 预览依旧是 iframe + Babel 的轻量方案,不是生产级沙箱运行时
5. 这一课的增量集中在工作区,Agent 主链路刻意没变
相对第 14 课,这一课真正新增的是:
| 维度 | 第 14 课 | 第 15 课 |
|---|---|---|
| Canvas 面板 | 只读预览 / 代码查看 | 草稿编辑、保存、分享、下载、复制 |
| preview 实现 | 逻辑写在 CanvasPanel.tsx | 抽离成 CodePreviewPanel.tsx |
| store | 只管可见性和激活项 | 新增更新、合并、持久化标记、初始标签页 |
| artifact 数据 | 只存在消息派生状态里 | 可落库、可按会话恢复、可分享 |
| 页面布局 | 固定宽度侧栏 | ResizablePanel 支持宽度记忆 |
同时有几条链路刻意保持不变:
app/agent/chatbot.ts继续沿用第 14 课的 Canvas 提示策略。app/canvas/canvas-prompt.ts和app/canvas/parse-canvas-artifacts.ts仍然围绕同一份canvasArtifact协议工作。- 聊天 SSE 主链路没有被新的工作区交互打断。
四、关键文件
app/components/canvas/CanvasPanel.tsx
这是本课的核心。它把右侧画布从只读查看器升级成真正的工作区,承担草稿编辑、保存、分享、复制、下载和重置行为。
app/components/canvas/CodePreviewPanel.tsx
这里把 JSX 预览运行时单独抽了出来。这样 CanvasPanel 负责交互,CodePreviewPanel 负责 iframe 渲染,职责会更清楚。
app/hooks/useCanvasArtifacts.ts
store 在这一课有了实质升级:除了打开 / 关闭,还要管合并服务端 artifact、更新代码、标记已保存、记录初始标签页。
它新增了 canvasVersion、canvasWidth、会话切换后加载 artifact、ResizablePanel 包装等逻辑,是工作区状态和页面状态同步的总控点。
这一课新增的持久化入口。GET 用于按 session_id 读取已保存 artifact,POST 用于保存当前工作区结果。
app/services/artifact.service.ts
它把 API 层和数据库读写隔开,把 ArtifactRow 转回前端真正要用的 CanvasArtifact 结构。
这里是 Supabase 数据访问层,提供 upsertArtifact(...)、getArtifactById(...) 和按会话查询的能力。
这是分享链路的入口。保存成功后,某个 artifact 可以在独立页面里直接打开,不必再进入原聊天会话。
五、整体流程
这张图里最关键的变化是:artifact 不再只靠消息正文“现算现用”,而是开始拥有自己的保存、恢复和分享生命周期。
六、运行过程
1. 解析器开始给 artifact 补上会话和版本字段
parseCanvasArtifactsFromContent(...) 现在接受第三个参数 sessionId,并给每个新解析出的 artifact 赋上:
sessionIdcurrentVersion: 1isPersisted: false
这样 artifact 从一开始就不仅仅是“从正文里抠出来的代码块”,而是一个可以进入保存流程的工作区对象。
2. page.tsx 新增了工作区同步层
这节课的页面状态明显比上一课更复杂,主要多了三件事:
-
canvasVersion当 store 内部 artifact 被直接修改时,用它触发重新取activeArtifact。 -
canvasWidth通过localStorage记住右侧面板宽度。 -
loadArtifacts()当threadId变化时,主动请求/api/artifacts?session_id=...,把服务端保存过的结果合并回来。
这一步非常重要,因为从本课开始,artifact 不能只依赖消息正文本身,还得考虑“用户后续编辑后保存下来的版本”。
3. CanvasPanel 先复制草稿,再决定是否提交
打开某个 artifact 时,CanvasPanel 会:
- 读取
artifact.code.content - 复制到
draftCode - 根据 store 里的
initialTab决定先打开预览还是编辑器
这样做的好处是:
- 你可以放心试改
- 预览更新和正式保存可以分开
- 切换不同 artifact 时,不会把上一个工作区的草稿串到下一个里
4. 预览运行时被单独抽到 CodePreviewPanel.tsx
这一课把 iframe 运行时从 CanvasPanel.tsx 里拆了出来。现在:
CanvasPanel.tsx主要负责交互和工具栏CodePreviewPanel.tsx负责清理 import、拼接 HTML、跑 Babel 和渲染 React 组件
这一步的价值不只是“文件更整洁”,而是为后续继续升级渲染器留出清晰边界。
5. “应用到预览”本质上是在更新 store 中的正式 artifact
这节课有个很容易误解的按钮叫“应用到预览”。
它并不是单纯让 iframe 刷新,因为 iframe 本来就会根据 draftCode 实时重算。这个按钮真正做的是:
- 调用
canvasStore.updateArtifactCode(...) - 更新 store 内部当前 artifact 的正式内容
- 让页面通过
canvasVersion重新拿到最新activeArtifact
所以它更准确的作用是:把本地草稿提交回当前工作区状态。
6. 保存和分享使用同一条 API 主链路
当你点击“保存”或“分享”时,CanvasPanel.tsx 都会走 persistArtifact():
POST /api/artifacts- API 层通过
artifactService.saveArtifact(...)组织数据 database/artifacts.ts调 Supabaseupsert- 成功后把返回的记录转回
CanvasArtifact canvasStore.markArtifactPersisted(...)更新前端状态
“分享”和“保存”的区别只是:
- 保存到此为止
- 分享会再拼出
/artifact/{id}链接并复制到剪贴板
7. 独立分享页会直接渲染 artifact
app/artifact/[id]/page.tsx 会按 id 读取 artifact,然后交给 ArtifactView.tsx。
这条链路解决的是:
- 不进入聊天页也能单独看一个 artifact
- 分享对象不需要先知道原始 session
- 即使记录还在保存中,
ArtifactNotFound.tsx也会自动重试几次
这就是本课相比上一课最明显的产品化升级之一。
8. 本课依然保留了明确的简化边界
虽然工作区已经明显更完整了,但它仍然是教学版实现:
- 编辑器使用
textarea,不是 Monaco - 预览是轻量 iframe + Babel,不是完整沙箱
- 保存的是 artifact 记录,不会反向改写 assistant 原消息里的 XML
- 右侧面板的复杂编排仍然没有扩展到完整设计器级别
这也意味着你在学这一课时,重点应该放在“状态边界”和“链路衔接”,而不是把它误以为是一套完整低代码平台。
七、关键代码解析
关键代码 1:app/components/canvas/CanvasPanel.tsx 如何管理草稿
const [tab, setTab] = useState<'preview' | 'editor'>('preview');
const [draftCode, setDraftCode] = useState('');
const originalCode = useMemo(() => artifact?.code.content ?? '', [artifact]);
useEffect(() => {
setDraftCode(originalCode);
setTab(canvasStore.getInitialTab());
canvasStore.resetInitialTab();
}, [originalCode, artifact?.id]);代码解析:
- 这段代码确立了本课工作区的核心交互模型:先复制一份草稿,再围绕草稿做预览、保存和重置。
originalCode和draftCode分开之后,才有可能支持“已修改 / 原始版本”这种状态判断,也才能安全切换不同 artifact。getInitialTab()让 store 可以控制“从标题卡打开时,默认先进预览还是编辑器”,说明工作区状态已经不再只是一个布尔值。
关键代码 2:app/hooks/useCanvasArtifacts.ts 如何把 store 升级成工作区状态中心
updateArtifactCode(artifactId: string, content: string) {
for (const [, messageArtifacts] of this.artifacts) {
const artifact = messageArtifacts.get(artifactId);
if (artifact) {
messageArtifacts.set(artifactId, {
...artifact,
code: {
...artifact.code,
content,
},
currentVersion: artifact.currentVersion + 1,
updatedAt: new Date(),
});
this.emit();
return;
}
}
}代码解析:
- 这里把 store 从“最小展示状态”升级成了“真正的工作区状态中心”。它已经开始负责版本递增和正式内容更新。
- 这一层更新不依赖消息正文重新解析,所以第 15 课才需要在
page.tsx里增加canvasVersion来强制重新取值。 - 如果没有这类方法,所有编辑逻辑都只能塞回组件局部状态,保存成功后页面也无法得到统一后的 artifact 版本。
关键代码 3:app/api/artifacts/route.ts 如何把工作区结果保存到服务端
export async function POST(request: NextRequest) {
const body = (await request.json()) as {
id: string;
messageId: string;
sessionId: string | null;
title: string;
type?: string;
codeContent: string;
codeLanguage?: string;
currentVersion?: number;
};
const userId = await getUserIdFromRequest(request);
const artifact = await artifactService.saveArtifact({
...body,
userId,
});
return NextResponse.json({ artifact });
}代码解析:
- 这段代码把“右侧面板里的编辑结果”正式变成了一个可保存、可恢复、可分享的服务端对象。
- API 层自己不碰 Supabase 细节,而是把工作交给
artifactService和database/artifacts.ts,这样层次更稳定。 - 如果没有这条持久化入口,工作区再丰富也只是一次性前端状态,刷新或换设备后就全没了。
八、常见问题
1. 为什么“应用到预览”和“保存”要分成两步?
因为它们解决的是不同问题:
- “应用到预览”更新当前页面里的正式 artifact 状态
- “保存”把这个状态写进数据库
这两步分开之后,你才能既拥有快速试改体验,又保留明确的持久化边界。
2. 为什么保存后还要有独立的 /artifact/{id} 页面?
因为聊天页和分享页面对的是两种场景:
- 聊天页强调上下文和持续生成
- 分享页强调直接打开某个结果
如果两者混在一起,链接分享和独立预览都会变得很别扭。
3. 为什么这节课还不用更重的编辑器和沙箱?
因为本课的重点不是做一个完整代码平台,而是把“草稿编辑 -> 预览 -> 保存 -> 分享”这条主链路先搭完整。轻量实现更利于你看清边界。
九、练习题
- 给
CanvasTitleCard增加“直接进编辑器”入口,然后通过canvasStore.openArtifact(artifactId, 'editor')打开工作区。 - 在
CanvasPanel里增加一个“未保存离开提醒”,让关闭面板时能提示当前草稿是否还没保存。 - 在分享页
ArtifactView.tsx里加入一个简单标题栏,展示 artifact 标题、版本号和返回首页按钮。
十、总结
第 15 课最重要的不是再发明一种新协议,而是证明了同一份 canvasArtifact 可以被更完整的前端工作区消费。
你应该把这一课记成三条主线:
- 协议保持稳定,升级集中在消费层和工作区状态层。
draftCode、store 更新、数据库持久化分别对应本地编辑、页面正式状态、跨会话存储三个层次。- 右侧 Canvas 已经从“只读查看器”进化成“可编辑、可保存、可分享的教学版工作区”。
登录以继续阅读
解锁完整文档、代码示例及更多高级功能。