AI Chat 长会话性能实践:消息虚拟化、动态高度与流式滚动设计

本文基于 AI Mind 项目的真实实现整理。

GitHub:github.com/HWYD/ai-min...

对应代码版本:v0.5.3--v0.5.4

线上体验:ai.hwyblog.cloud/instant-min...

AI Mind 是一个持续迭代中的 Next.js AI Chat 项目。从最基础的本地聊天开始,逐步加入流式协议、工具调用、MCP、Skill 和 Agent 能力。

如果你对这个项目感兴趣,或者这篇文章对你有一点帮助,也欢迎顺手到 GitHub 帮 AI Mind 点个 Star⭐,这会是对我继续更新很大的鼓励。


一开始,聊天消息列表看起来是最普通的 React 页面:拿到 messages,逐条 map,流式回复来了就更新最后一条,再把容器滚到最底部。

但当一段会话变成上千条消息,里面同时有 Markdown、代码块、图片、工具结果、推理面板和可展开详情时,这套简单方案很快就会遇到问题。完整 DOM 会拖慢布局;同一条 assistant 消息会不断变高;用户上滑阅读时,自动滚动又可能把视口抢回末尾。更难的是,内容变高与滚动位置修正如果跨越一次浏览器绘制,就会出现"新字先出现一瞬、随后整体上移"的视觉延迟。

这篇文章基于 AI Mind 当前的实现,集中讨论一个具体问题:怎样让虚拟列表、动态高度、流式增量和用户阅读意图各自有明确的事实来源,并在同一条时序链路中协作?

先把整体思路说清楚:虚拟化只解决"哪些 DOM 节点需要挂载",并不会自动处理聊天交互。AI Mind 把职责拆成几层:react-virtuoso 管物理滚动与真实尺寸测量,业务策略管用户意图,流式缓冲管何时提交文本,消息列表适配层只把稳定身份和初始几何交给虚拟化库。一旦其中两层同时接管同一件事,最终通常就会表现成跳动、闪动或抢滚动。

一、长聊天列表真正难在哪里

普通信息流通常只需要追加新 item,且高度变化较少。AI 聊天不同:一条 assistant 消息在生命周期中会经历 loading、文本逐步到达、Markdown 语法闭合、代码块高亮、图片加载、推荐问题出现等多个阶段。它不是"追加一行文本",而是一棵会持续重排的内容树。

如果只用完整渲染,历史越长,浏览器需要参与布局、绘制和事件处理的节点越多。如果先接入虚拟列表,再把每次流式文本更新都写成 scrollTop = scrollHeight - clientHeight,问题会从性能转变为交互:虚拟化库正在根据真实测量更新 size tree,业务代码又在另一边直接修正像素位置,两个系统都认为自己拥有滚动。

AI Mind 最终没有把"短会话完整渲染、长会话再虚拟化"做成两套实现,而是让所有非空会话都进入同一个虚拟消息视口。这样短会话和长会话共享身份、测量和滚动契约,后续不会因为消息数量跨过阈值突然切换物理滚动模型。

这张图真正需要关注的是那条反向链路:业务策略不会自己维护一个平行滚动容器,只通过列表暴露的公共 scrollToEnd 回到 Virtuoso,物理坐标仍由 Virtuoso 处理。

二、模块分工:从页面、策略到单个文本 part

这套设计没有把滚动逻辑都塞进一个"大 hook",而是拆到几个职责明确的模块里,每个模块只维护一种事实。定位问题时,可以先判断它属于"文本何时提交""列表如何测量"还是"用户是否允许跟随",不需要在多个组件里同时追 scrollTop

模块 负责的事实 关键实现细节 明确不负责的事
InstantMindPage(聊天页面编排层) 当前展示会话、共享滚动视口和 Composer 占位 presentationKey 隔离展示生命周期;把 composerOverlayInset + 54 交给列表作为底部安全区;给进入事件附带会话与 sequence 不直接写 scrollTop,不解释流式 chunk
ChatMessageList(虚拟列表适配层) messages 到稳定展示 entry 的映射,以及 Virtuoso 的唯一配置入口 暴露窄接口 scrollToEnd(behavior);用已测总高执行回底;设置 alignToBottomatBottomThreshold={4}followOutput={false} 不判断用户是不是在阅读历史
useChatScrollPolicy(滚动意图策略) following / reading、回底按钮和会话进入的时序 只通过列表 handle 发命令;将同批高度增长用微任务合并;用户向上输入立即加阅读锁 不保存消息高度,不解析 Markdown
message-height-hints(高度提示缓存) 已稳定消息在相同几何条件下的可复用初始高度 以渲染指纹、列宽、展示状态等条件匹配;未命中就回退结构化估算 不恢复像素滚动位置,也不保存 Virtuoso 内部状态
MessageDisclosureProvider(详情展示状态容器) 虚拟节点卸载后仍有阅读意义的展开状态 presentationKey / conversationId 隔离;详情手动展开前通知阅读策略 不保存 hover、复制反馈等瞬态 UI
useChatStream(流式主链)与 stream-message-reducer(消息树归约器) 模型事件如何变成消息 parts 前者选择模型的提交窗口,后者只处理已合并 delta 的状态更新 不触碰 DOM 或滚动命令
useStreamTextBuffer(文本提交缓冲) 网络 delta 何时合并进 React messageId + partType + partId 为 Map key 聚合;timer 后交给 rAF;代码围栏优先申请最近一帧 不决定展示位置,也不感知用户阅读锁
TextPart(文本 part 渲染层) 一段流式 Markdown 的稳定渲染生命周期 Streamdown 始终处于 streaming mode,结束时仅停止动画状态 不替代列表的真实高度测量

模块之间的通信方向也被固定下来:页面将真实视口和带 scope 的事件交给策略;列表把 Virtuoso 的测量结果向上报告;策略只调用 ChatMessageListHandle;流式缓冲只向 reducer 提交合并后的文本。这样不会出现某个模块既读用户意图、又改消息树、还直接改物理滚动位置的情况。

页面层只编排,不直接滚动

InstantMindPage 先拿到实际的消息视口,再挂载 ChatMessageList,避免列表在不存在的滚动父容器上建立另一套坐标系。它以 presentationKey 作为一次展示生命周期的边界:切换会话或重新进入历史时,旧列表的回调不能写入新会话;草稿升级为持久会话时,则保持同一展示身份,避免无意义地重建列表。

页面层还把 Composer 的覆盖高度传为 bottomInset。列表通过 Footer 留出同等高度,而不是在每条消息上加底边距,因此最后一条消息既不会被输入框遮住,也不会让每个 item 的几何模型变复杂。

列表层只适配,不猜业务意图

ChatMessageList 是 Virtuoso 的唯一装配点。它把普通消息和续问期间的 TurnEntry 转成同一个 data 序列,收集 itemsRenderedrangeChangedtotalListHeightChanged 等物理事件,再向页面和策略层报告。它暴露的 handle 只有 scrollToEnd,内部按当前 totalHeight 调用 Virtuoso 的 scrollTo;这让业务层不必依赖 item index,也不会触发 scrollToIndex 为未测量尾项进行的内部重试。

策略层只解释"该不该跟随"

useChatScrollPolicy 不持有另一个滚动容器,也不试图估算消息高度。它只把用户输入、列表测量和会话进入状态折算成意图,再通过上述 handle 发出一次 autosmooth 回底命令。这样一来,流式缓冲、图片加载和折叠展开都复用同一个阅读锁,不需要各自再补一段滚动代码。

三、先划边界:谁负责滚动,谁负责"该不该滚动"

ChatMessageList(虚拟消息列表适配层)将 messages 转换为带稳定身份的展示 entry,再交给 Virtuoso。它的职责是适配消息结构、初始高度、Composer 底部安全区和 item 渲染;不根据业务状态猜测用户是否还想跟随。

tsx 复制代码
<Virtuoso
  alignToBottom
  computeItemKey={computeMessageItemKey}
  customScrollParent={scrollParent ?? undefined}
  data={messageEntries}
  followOutput={false}
  heightEstimates={heightEstimates}
  initialTopMostItemIndex={{ index: 'LAST', align: 'end' }}
  totalListHeightChanged={handleTotalHeightChange}
/>

这里的 followOutput={false} 是有意为之。Virtuoso 自带的 follow 功能可以覆盖一部分"尾项增高"的场景,但它不知道业务层的阅读锁:用户是因为滚轮、触摸、键盘还是手动展开详情而离开底部?新会话、草稿升级为持久会话、延迟图片和 Composer 高度变化又该如何处理?这些不是一个布尔回调能够完整表达的语义。

因此,useChatScrollPolicy(聊天滚动策略)独立维护两种意图:

  • following:用户处于底部或明确要求回到底部,允许新的高度增长推动视口。
  • reading:用户正在阅读历史,后续布局变化不能把视口拉回底部。

生成状态不是阅读意图。回答结束、建议问题出现、图片稍后加载,甚至页面短暂处于稳定状态,都不能自动把 reading 改回 following。只有接受一轮新的提问、点击"回到底部",或用户通过真实向下输入回到真实底部,才允许恢复跟随。

这里有一个很容易误判的 UI 状态:流式期间底部状态会短暂变成 false,但这并不代表应该马上显示"回到底部"按钮。按钮只在 reading 且确实离底时展示;处于 following 时,策略正在补齐这段几何差,按钮不应该反复出现和消失。

阅读锁不能只监听 scroll。内容增长、图片完成解码和浏览器锚定同样会触发滚动事件,却不是用户要求恢复跟随。策略会识别向上的滚轮、触摸手势、键盘导航与滚动条拖动,立刻切到 reading;向下滚轮、EndPageDown 等真实输入会打开一个短暂的连续输入窗口,只有这段时间内确实滚到距底部 4px 内,才恢复 following。代码块等内部可滚动区域会先被排除,避免在阅读长代码时意外锁住整个会话。

会话历史的首次进入也遵循同样的"意图先于动画"原则。页面为每次进入生成 conversationId + sequence 的观察 scope;策略在最后一项已挂载、进入可见 range、已到末尾且列表不再滚动后,才揭示历史内容。它不会把"发出第一次定位命令"当成"已经稳定进入",因此旧会话延迟回调也不会污染新会话。

四、虚拟列表最怕身份漂移,而不是高度不准

虚拟化会回收离屏节点,再把有限数量的 DOM 节点分配给新进入视口的消息。没有稳定 key 时,一条消息的 Markdown、展开状态或局部操作状态可能被复用到另一条消息上。

AI Mind 的 item key 从展示 entry 的稳定身份导出:普通消息使用 message id;具有特殊交接语义的条目使用专用 key。

tsx 复制代码
function computeMessageItemKey(_index: number, entry: MessageListEntry) {
  return entry.itemKey ?? (entry.kind === 'turn' ? entry.userMessage.id : entry.message.id)
}

这里没有把数组下标当 key。流式更新、删除问答、草稿升格和历史会话切换都会改变数组结构,下标只能描述"当前位置",不能描述"这是哪条消息"。

还有一类状态也不能绑在 DOM 生命周期上:用户主动展开、且具有阅读意义的详情状态。推理过程、Agent 详情、工作流详情等内容滚出视口后会卸载,但用户主动展开的选择不应该丢失。AI Mind 通过 MessageDisclosureProvider(消息详情展示状态容器)按会话和稳定内容身份隔离这些状态;复制反馈、hover 等短暂状态则允许随卸载重置。这样避免为了"保住折叠状态"而放弃虚拟化。

展开详情本身也会改变 item 高度。MessageListItem(单项渲染桥接层)在挂载和卸载时把 item index 报给策略;用户点击可展开摘要前先标记为阅读,随后发生的高度增长就不会把视口抢回尾部。这个顺序比在展开后再补偿 scrollTop 更可靠,因为它没有假设详情最终会长多少。

五、动态高度不能只靠猜,也不能每次都重新猜

虚拟列表需要初始高度帮助建立 size tree,但聊天消息的真实高度并不可靠:同一段文本在不同列宽下会换行,CJK 与 ASCII 的视觉宽度不同,代码块、表格、图片和工具卡片也有完全不同的几何特征。

ChatMessageList 会根据消息结构、当前列宽和实际可见的 parts 生成 heightEstimates。这不是固定行高:文本估算区分 prose、标题、列表、围栏代码和表格;富卡片包含固定 chrome 和内容区域;关闭 reasoning 时不把 reasoning 面板计入估算。

但估算只有一个角色:帮助第一次布局,不能取代真实 measurement。 Virtuoso 继续测量挂载 DOM,并以测得的尺寸更新自己的 size tree。

流式消息是最容易产生抖动的地方。Markdown 可能先被识别为普通段落,随后闭合为标题、列表或代码块。若每个 token 都重新计算并传入新的 heightEstimates,业务层的估算和 Virtuoso 的真实测量会成为两套不断竞争的高度来源。

实际实现里,当前 streaming assistant 会在同一个消息生命周期内冻结启发式估算,真实 DOM 仍由 Virtuoso 持续测量。流结束后,只有渲染指纹稳定、连续两次 item size 一致、字体准备完成、列表没有滚动、默认详情展示状态未变化时,完成消息的实测高度才会成为本地 IndexedDB hint。

这份 hint 只是可丢弃的性能提示,不属于业务事实。它同时匹配会话、消息身份、渲染指纹、精确列宽、几何版本和展示状态;任何条件不匹配都回退结构化估算,绝不恢复 scrollTop 或完整 Virtuoso state。

具体到数据流,itemsRendered 提供当前可见条目的实际尺寸候选,MessageListItem 的挂载状态保证候选仍属于当前展示生命周期。只有满足稳定条件的完成消息才写入 hint;正在流式增长的 assistant 则保留首次启发式估算,交给 Virtuoso 的真实 DOM measurement 持续修正。这样可以避免"每个 token 都换一套估算",让启发式高度和真实 measurement 来回竞争。

六、流式文本先解决"何时提交",再谈"如何滚动"

模型传回 chunk 的频率和大小并不稳定。有的模型细粒度返回,有的模型一次返回很长的文本。若每个 chunk 都直接进入 React reducer,Markdown 解析、DOM 更新和 Virtuoso 测量会被放大;若只用固定长时间窗口,单次 token 较大的模型又会呈现为一块一块地出现。

useStreamTextBuffer(流式文本缓冲)把"接收模型 delta"和"写入 React 消息树"分开。相同 messageId + partType + partId 的连续 delta 先在 Map 中合并,再在 rAF 中一次提交。默认模型使用 40ms 窗口,既减少高频状态更新,也保留足够的输出连续性。

当前主分支还针对模型输出形态做了小范围差异化:deepseek-v4-pro 的窗口为 0ms,避免一个 40ms 窗口堆积过多文字;但 0ms 不等于每个网络事件直接改 React state,timer 之后仍然通过 rAF 合并到最近一次绘制。代码围栏出现时也会提前申请 rAF,减少 Markdown 代码块边界明显滞后的时间。

ts 复制代码
const DEFAULT_STREAM_TEXT_FLUSH_INTERVAL_MS = 40

const STREAM_TEXT_FLUSH_INTERVAL_BY_MODEL: Partial<Record<ChatModel, number>> = {
  'deepseek/deepseek-v4-pro': 0,
  'deepseek/deepseek-v4-flash': 40,
}

function enqueue(messageId: string, partId: string, partType: 'text' | 'reasoning', delta: string) {
  // 先按 message / part / type 合并 pending delta
  scheduleFlushByTimer()

  if (delta.includes('```')) {
    scheduleFlushByAnimationFrame()
  }
}

flush 时,缓冲会先取消已登记的 timer/rAF,再取出 Map 的 values、清空 Map,并把这一批 PendingTextDelta 交给 reduceStreamTextDeltas。因此一次提交最多对应一次消息树归约;网络事件、React 更新和虚拟列表测量之间不再是一对一关系。flushIntervalMs = 0 也仍经过 timer → rAF 路径,它表示不额外等待 40ms 窗口,而不是在网络回调里同步改 state。

这一层既不读取 scrollTop,也不调用列表 handle,只负责决定文本什么时候进入 reducer。滚动策略和提交节奏分开以后,调整打字感时就不会顺带破坏阅读锁。

Streamdown 的生命周期也遵守同一个稳定性原则。一个文本 part 从开始到结束始终使用 mode="streaming" 和同一份动画配置,结束时只把 isAnimating 切为 false。当前逐词 fadeIn 仅为 24ms;它保留轻微的出现反馈,但不在回答完成时更换 mode 或动画 props。否则 Markdown 解析路径和子树结构同时切换,已闭合语法又恰好升级时会被感知为闪动。

七、从"等待底部状态"到"使用已测总高"

流式跟随的一个隐蔽问题来自事件顺序。

text 复制代码
文本提交
→ React 更新 DOM
→ Virtuoso 测得新的 item / total height
→ totalListHeightChanged
→ atBottomStateChange(false)
→ 回到底部

旧思路会等 atBottomStateChange(false) 再安排回底。问题在于,totalListHeightChanged 已经说明内容真的变高了,但 atBottom 仍可能保留增长前的 true。在这个空档里,新内容可能先以旧的 scrollTop 参与绘制,再在下一帧被拉到末尾。

现在的策略将"已测得的正向总高变化"作为可跟随的确定事实。首个总高回调只建立基线;之后只要总高变大,并且仍处于 following、没有历史进入定位任务,就通过微任务合并同一批变化并调用公共 scrollToEnd('auto')

ts 复制代码
const scheduleFollowGrowth = useCallback(() => {
  if (followGrowthMicrotaskRef.current) return
  followGrowthMicrotaskRef.current = true

  queueMicrotask(() => {
    followGrowthMicrotaskRef.current = false
    if (intentRef.current === 'following' && !pendingEntryRef.current) {
      issueScrollToEnd('auto')
    }
  })
}, [issueScrollToEnd])

// totalListHeightChanged 已给出 Virtuoso 完成 measurement 的总高。
if (previousHeight !== null && height > previousHeight) {
  scheduleFollowGrowth()
}

微任务的作用很直接:把同一同步任务内连续到达的测量增长合并成一次回底,同时避免再多等待一个 rAF。浏览器的 ResizeObserver 测量位于绘制前的布局流程中,因此这个安排有机会让新总高与新的 scrollTop 在下一次 paint 前一起生效。

这条路径还有两点限制。第一,微任务只处理已经测得的正向总高,不替代会话首次进入、显式回底、Composer 或 viewport 变化所需的 rAF 合并路径。第二,它不会突破 reading 锁,也不会绕开 pending conversation entry。缩短时序空档的前提仍然是尊重用户当前的阅读状态。

在当前 Chromium 真实页面回归中,慢速和快速流式增长均记录为最长一帧重新贴底;快速路径仍保持每帧最多一次 scrollToEnd,且没有观察到 scrollTop 反向移动。这是当前实现的浏览器证据,不等于对所有浏览器、GPU 合成路径或移动设备作绝对保证。

八、续问的首个 assistant 不能凭空多出一个 item

已有历史的会话中继续提问时,用户通常希望刚发出的问题出现在视口上部,随后看到回答向下生长。如果提交 user 消息后先用一个带 runway 的条目占位,首个 assistant chunk 又作为一个新的 Virtuoso item 插入,就会出现一个中间状态:旧 item 的最小高度仍在,新 item 又临时获得默认估算高度。即使 scrollTop 完全没有反向,用户消息也会在中间帧被向下推开。

AI Mind 这里没有继续叠 timer,也没有实时计算 assistant 高度,而是构造了一个只属于展示层的 TurnEntry:把刚提交的 user 消息和 assistant loading slot 放进同一个虚拟条目。assistant 首包到达时,只替换这个 slot 的内容,item key 保持不变。

tsx 复制代码
const turnEntry: TurnEntry = {
  assistantMessage,
  itemKey: getAcceptedTurnRunwayItemKey(userMessage.id),
  kind: 'turn',
  userMessage,
}

const replyRunway = `clamp(10rem, calc(72dvh - ${bottomInset}px - 4rem), 48rem)`

runway 直接作用于 assistant slot 的 min-height。短回复由浏览器的 intrinsic sizing 自然填充这段空间;回复超过 runway 后,列表总高才继续增长,并由前面的 measurement → follow 链路接管。它不进入真实 messages、流式协议、hydration 或高度 hint,因此不会把临时展示策略污染为聊天业务数据。

九、几个看似合理、实际上会带来回归的做法

1. 开启 followOutput 再保留业务 auto-scroll

这会制造两个底部跟随控制器。Virtuoso 不知道业务阅读锁,业务层也不知道库的内部跟随分支。开始时可能看起来很顺,遇到延迟图片、折叠展开或用户上滑后就会出现抢回底部的问题。

2. 每次内容 render 都直接滚到底

此时拿到的总高未必是 Virtuoso 最新测量值,容易对旧高度发出无效命令,随后测量再触发第二次移动。正确的触发点是测量完成后的总高变化,而不是 reducer 每一次提交。

3. 给回底额外加 10ms 或两帧缓冲

固定等待不会减少高度变化,只会让新内容以旧位置停留更久,再产生更大的位移。10ms 也不与 60Hz、120Hz 的绘制节奏对齐。当前实现选择的是减少测量到回底的中间帧,而不是人为扩大这个空档。

4. 用 token 数预测尾部预留高度

Markdown 的最终高度由列宽、换行、代码围栏、表格、字体和异步资源共同决定。按 token 预测会引入第二本高度账,最后仍然需要 Virtuoso 纠正。runway 只描述一个固定、视口相对的展示空间;真实高度仍然由 DOM measurement 决定。

5. 把顶部通知、导航和持续错误提示塞进虚拟列表 Header

这些内容本身并不是消息项。短暂操作反馈由页面级 Portal 呈现更合适,持续故障则应该靠近对应功能和恢复操作;放进 Header 既会影响列表总高,也可能在用户滚动历史后失去可见性。

十、如何验证:不能只看"最后确实到底了"

聊天滚动 bug 多半发生在中间帧,因此最终 gap = 0 不足以说明交互正确。AI Mind 的回归覆盖两类证据:

  1. 组件与策略测试:验证阅读锁、会话代次、同帧命令合并、空列表、显式回底、流式缓冲以及稳定 item handoff。
  2. 真实 Chromium 页面测试 :通过 1000 条混合高度 fixture 和真实聊天页面,覆盖历史进入、首问、续问、快速/慢速流式增长、按钮显隐、图片延迟加载、宽度重排、Composer 高度变化、手动展开和原生 End 回底。

浏览器回归不是只断言最终位置,还会逐帧记录 scrollTopscrollHeight、底部 gap 和回底命令数量。当前结果验证了三个关键约束:流式 following 时不会出现反向 scrollTop;快速增长每帧最多一个回底命令;用户进入 reading 后,后续增长不会移动当前阅读锚点。

这些记录还能帮助区分两类问题:如果 scrollTop 在正向增长中来回倒退,通常说明存在两个物理控制器或错误补偿;如果 scrollTop 单调增加,但视觉上仍然出现内容"先出现后上移",就应该继续检查测量到回底之间的绘制时序和合成路径,而不是盲目增大底部阈值。

十一、当前边界与后续方向

当前实现刻意没有加入服务端 cursor pagination、跨设备阅读位置恢复、第二套消息 ResizeObserver、手工 scrollTop 补偿,也没有让虚拟化库的内置 follow 和业务策略同时工作。这些能力以后可能仍会需要,但在当前聊天数据规模和交互目标下,它们会先带来更多难以验证的状态组合。

还有一个现实边界需要说明:当前 Chromium 回归中,测量后的跟随已经收敛到最长一帧,但像素级残影仍可能受浏览器版本、GPU 合成和设备刷新率影响。后续若继续优化,应先补充视觉级采样证据,再决定是否需要调整渲染层或合成层;不能用更多 timer 或更多滚动命令覆盖症状。

从长会话虚拟化一路优化到流式跟随,最后稳定下来的核心其实是一组职责边界:Virtuoso 相信真实 DOM,策略相信用户意图,缓冲只管理提交节奏,展示层临时状态不污染业务消息。 这些边界稳定以后,再加入新的消息类型、模型输出形态和异步内容时,聊天滚动就不容易重新退化成一堆难以预测的副作用。

项目地址

👉 GitHub:github.com/HWYD/ai-min...

👉 线上体验:ai.hwyblog.cloud/instant-min...

如果这篇文章或者 AI Mind 项目对你有帮助,也欢迎顺手给项目点个 Star⭐。后续我还会继续整理版本实现、设计取舍和踩坑过程。

相关推荐
幸运小圣1 小时前
SSE 与 WebSocket 新手入门:前端实时通信完全指南【JavaScript】
前端·javascript·websocket
艾伦野鸽ggg1 小时前
25级开学 JS 考核题解
前端·javascript
leoZ2311 小时前
2026-09-08-mysql-57-init-walkthrough
前端·javascript·数据库·vue.js·opencv·mysql·adb
paopaokaka_luck1 小时前
非遗文物数字化系统(AI非遗问答、ONNX图像识别、协同过滤推荐、ECharts数据分析、非遗知识浏览与互动、文创商城订单闭环、文化活动报名签到、社区交流)
前端·javascript·vue.js·人工智能·数据分析·echarts
乘风gg2 小时前
AI Coding 提效 2 倍是真的吗?到底怎么衡量效果
前端·ai编程·claude
用户921080262862 小时前
从对象到原型链:理解 this、构造函数和 new
前端
猫不易2 小时前
Webpack 与 Vite:从 Loader / Plugin 到 Rolldown 统一引擎
前端·vite
YHL2 小时前
🎯 Danci —— 用 AI 驱动开发一个全栈英语单词学习平台
前端·后端
平头哥~2 小时前
Day 20 _ 3D 翻卡_perspective 写错人,卡片就穿帮
前端·3d·css3·学习资料