MarkdownInputField 重构待办
本文用于记录
src/MarkdownInputField的架构 review 结论与后续重构计划。 仅作为内部跟进 issue 使用,不发布到组件文档。Review 时间:2026-05-01 Review 范围:
src/MarkdownInputField/全量(22 个子目录、~6500 行) Review 形式:架构层(模块边界、职责划分、循环依赖、API 一致性、可维护性)
概览
P0 / 高优先级(强烈建议尽快做)
#1 主组件 + Props 是「上帝接口」,没有真正的关注点分离
现状
MarkdownInputFieldProps把 编辑器 / 附件 / 语音 / 技能模式 / 放大 / 提示词优化 / 顶部操作区 / 发送按钮 / Tag / Suggestion / 粘贴 / UI / 回调 / ref 全部塞在同一个扁平接口里(30+ 字段)。- 主组件
MarkdownInputField.tsx548 行只是把这堆东西串起来。 actionsRender、toolsRender、quickActionRender、beforeToolsRender、operationBtnRender五个 render prop 概念高度重叠但定位模糊,文档不解释「什么时候用哪个」。actionsRender(props, defaultActions)的props类型是MarkdownInputFieldProps & MarkdownInputFieldProps['attachment'] & {...},把「组件 props 全集 + 附件配置 + 状态」全暴露给外部消费者。
影响
- 横向耦合极严重:每加一个能力需要在 props / 主组件 / Actions hook / Styles hook / renderHelpers 五处都改。
- 当前设计再加 1~2 个能力,主组件会突破 700 行。
建议
- 拆成 core props(value / onChange / onSend / disabled / typing / placeholder / triggerSendKey)+ feature configs(
attachment、voice、skill、enlarge、refine、top)+ slots(slots: { before?, tools?, actions?, quickActions?, top? }),向外只暴露slots一个 render-props 入口。 - 给
actionsRender等明确「传给你的是稳定的派生状态(value/isHover/isLoading/fileUploadStatus),不是组件全部 props」,砍掉MarkdownInputFieldProps & ...attachment & ...这种类型怪物。
#2 hooks 拆分是「按代码行数」而非「按职责」
现状
影响
- 每个 hook 把状态吐出来,主组件再当胶水把它们粘回去。
- 调用链更长、参数表更难读、调试更难。
useSendActionsNode的 useMemo 依赖数组有 27 个项,已经失去性能优化意义。
建议
useMarkdownInputFieldActions直接内联,5 行布尔运算不值得一个 hook。Layout+Styles合并成useInputFieldGeometry,对外只暴露最终结果(rightPadding/minHeight/enlargedStyle/collapseSendActions),不暴露中间 setter。setRightPadding、setTopRightPadding、setQuickRightOffset由 geometry hook 内部消化(QuickActions/SendActions 通过 props 把onResize回调透给 geometry hook,hook 内部接住),不要透出到主组件。
#3 类型定义重复发明轮子,多处 as any
现状
SendActions/index.tsx、QuickActions/index.tsx、FileUploadManager/index.tsx、renderHelpers.ts、AttachmentButton/index.tsx 里都重复定义了:
并且多处 as any:
MarkdownInputField.tsx:onFileMapChange: setFileMap as any、quickActionRender={props.quickActionRender as any}AttachmentButton/index.tsx:UploadProps.locale: anySuggestion/index.tsx:useState(() => ...)推导出 selectedItems 没有类型actionsRender的props: any
建议
- 把
FileUploadStatus、FileUploadSummary、AttachmentFile、AttachmentConfig集中到types/一个文件,模块只引用,不重新定义。 - 删除所有
as any(10+ 处),它们都掩盖了真实的类型不匹配。
#4 状态分散、受控/非受控逻辑不一致
现状
value、fileMap用useMergedState走受控/非受控双轨;isEnlarged全内部 state,外部无法控制(enlargeable没有defaultEnlarged/enlarged/onEnlargeChange);skillMode.open是受控的,但内部还要用useSkillModeState自己加一套「防止重复回调」的 ref(skipNextCallbackRef)来对抗自身设计——典型的受控状态被内部「再受控一次」反模式;recording在 hook 内部,没有onRecordingChange;value与setMDContent同步逻辑分散在 3 处:useMarkdownInputFieldRefs的 effect、useImperativeHandle里的 Proxy、主组件onChange里。
建议
- 所有「既能受控又能非受控」的状态走同一套规范:
{ value, defaultValue, onChange }或{ open, defaultOpen, onOpenChange },逐个补齐enlargeable/recording/skillMode。 useSkillModeState的skipNextCallbackRef是个红旗,正确做法是:受控就不维护内部 ref,外部传什么open就显示什么;handleCloseClick只调用onSkillModeOpenChange(false),不要绕过 effect。
P1 / 中优先级
#5 交叉依赖 / 循环引用风险
现状
AttachmentButton同时是 UI 按钮组件 和upLoadFileToServer上传业务函数 的导出源,被四处导入,破坏了「UI 模块就该只导出 UI」的边界。hooks/(应该是基础设施)反向依赖AttachmentButton、FilePaste(应该是业务模块),构成「基础设施依赖业务」反向依赖。useFileUploadManager实际是个业务 hook,被放在FileUploadManager/index.tsx而不是hooks/,目录划分不一致。
建议
- 把
upLoadFileToServer从AttachmentButton/index.tsx抽到utils/uploadFile.ts,按钮和上传函数分家。 - 所有跨模块共享的 hooks(
useFileUploadManager、useVoiceInputManager)统一搬到hooks/下。 - 用 ESLint
import/no-cycle+import/no-internal-modules做防御。
#6 Suggestion 越界包裹根节点
现状
tagInputProps同时传给Suggestion和BaseMarkdownEditor两遍,两份 state 不同步。Suggestion用 antd<Dropdown trigger={['click']}>把整个输入框包成一个「点击触发的下拉锚点」,与编辑器自身焦点逻辑冲突。Suggestion暴露SuggestionConnext(拼写错误:Co**nn**ext)给编辑器内部 TagPopup 通信——跨包反向通信,把MarkdownEditor耦合到MarkdownInputField/Suggestion上。
建议
- 重新评估
Suggestion是否必须包裹根节点,能否改成只包BaseMarkdownEditor。 tagInputProps只传一份。SuggestionConnext→SuggestionContext,并在文档里明确「这是跨包契约,破坏属于 breaking」。
#7 Refs hook 三个职责揉在一起,且包含运行时陷阱
现状
useMarkdownInputFieldRefs 同时干三件事:
- 创建 4 个 ref;
- 用 effect 同步外部
value→ 编辑器(带焦点 + lastEditorValueRef 双重门禁); useImperativeHandle用 Proxy 包editor.store。
陷阱:
useImperativeHandle的依赖数组只有[props.setValue],markdownEditorRef.current在挂载之后才有值时,外部拿到的是「editor 不存在」分支返回的占位对象。...editor是浅展开 —— 如果用户依赖editor.markdownEditorRef.current(Slate editor),它是稳定 ref,没问题;但editor.store上挂的方法是定义在原型/对象字面量上的,能否被 spread 出来取决于 store 的实现。
建议
- 拆成
useMarkdownInputRefs(纯 ref 创建)+useExternalValueSync(外部 value→editor)+useEditorImperativeHandle(暴露 ref)三个职责清晰的 hook。 useImperativeHandle不要返回「占位对象」,要么return undefined,要么强制要求 ref 只在挂载后访问,并在文档里写清楚。
#8 Mobile 检测与 SSR / 测试分支散落各处
现状
useMarkdownInputFieldLayout、BorderBeamAnimation:硬编码if (process.env.NODE_ENV === 'test') return;。SendButton:if (typeof window === 'undefined' || ...) return null—— SSR 防御散落在十几个文件里。isMobileDevice、getDeviceBrand、品牌识别表(280 行)在AttachmentButton/utils.ts,和「附件按钮」几乎没关系。
建议
- 抽
src/Utils/env.ts:isBrowser/isTest/isMobileDevice/getDeviceBrand全集中。 - 测试环境跳过 ResizeObserver 的逻辑放到测试 setup 里 mock
globalThis.ResizeObserver,源码不再if test。
#9 渲染层 / 样式层硬编码 magic number 太多
现状
- 主组件:
borderRadius = 16、enlargeable?.height ?? 980、tabIndex={1}。 useMarkdownInputFieldStyles:base = 114、extra = 90、140、90、106、52、280、980,无注释。useMarkdownInputFieldLayout:window.innerWidth < 460、clientWidth < 481、rightPadding = 64,两个断点(460/481)紧挨着。style.ts:硬编码boxShadow、var(--color-...)与 token 系统并存,绕过了@ant-design/cssinjs的 token 系统,与AGENTS.md里「使用 Token 系统」相悖。
建议
- 把 magic number 集中到
constants.ts并加注释来源。 - 460/481 两个断点统一成 1 个常量。
- token vs CSS variable 二选一,按
AGENTS.md应该用 token。
#10 BeforeToolContainer 像独立组件被错误归位
现状
476 行的 BeforeToolContainer:
- 自己实现了拖拽排序、Popover 溢出、横向滚动、阈值判定(
PAN_THRESHOLD); - 内部还拆出
DraggablePopupItem子组件 +React.memo; - 导出名为
ActionItemContainer(不叫 BeforeToolContainer),命名和文件名不一致; - 跟
MarkdownInputField几乎没有耦合,只是被作为beforeToolsRender的「建议实现」使用。
建议
- 移到
src/Components/,命名统一为OverflowActionBar或保留ActionItemContainer,文件名同步。 MarkdownInputField通过beforeToolsRender自由消费,不再耦合。
#11 useMemo 依赖膨胀,性能优化已失效
现状
useSendActionsNode的 useMemo:27 个依赖。defaultActions的 useMemo:18 个依赖,里面又含attachment(对象字面量在主组件每次构造),等价于不缓存。useBeforeTools的 useMemo 依赖props(整个MarkdownInputFieldProps对象),每次都失效。
建议
- 这种「由很多外部状态合成的 React node」要么不要 memo,要么把内层组件拆出去,让
React.memo+ props 比较代替手写 useMemo。 useBeforeTools接收props全集是反模式,改为只接收必要字段。
#12 错误处理与可观测性
现状
sendMessage里catch (error) { console.error('Send message failed:', error); throw error; }—— 既 log 又 throw,外部 onClick 是同步包装,会进入未捕获 promise 拒绝。handleFileRemoval、handleFileRetry、uploadImage大量console.error,没有走统一错误回调。useVoiceInputManager的startRecordingcatch 里直接吞错。Suggestion异步加载 items 失败完全没处理。RefinePromptButton异步失败也只是setRefineStatus('idle'),没有任何反馈。
建议
- 暴露统一的
onError?: (info: { type: 'send' | 'upload' | 'voice' | 'refine' | 'suggestion'; error: unknown; ... }) => void,所有 catch 走这个口子。 - 移除
console.error,改为通过回调或 throw 给调用方决定。
P2 / 各模块小问题(top 收录)
SendButton
useEffect(() => { props.onInit?.() }, [])用eslint-disable静默掉依赖告警,可改成useMountEffect。- SSR 检测放在所有 hook 之后才
return null,目前安全但只要后续在 if 后面加 hook 就崩。
AttachmentButton
upLoadFileToServer(业务)+ UI 按钮 + Popover 三合一文件 447 行,应当拆分(见 #5)。processFile链路里直接 mutate 同一个file对象的status/url/errorMessage,无 immutability。notifyChange = (m) => onFileMapChange?.(new Map(m))是为对抗「调用方直接传 setState」的 workaround,应该写在文档而非源码。WAIT_TIME_MS = 16await waitTime(16)没有注释来源。${maxSize}模板替换走replace,应该统一走compileTemplate。
FileUploadManager
- 与
AttachmentButton的upLoadFileToServer重复实现了一份单文件上传逻辑(在handleFileRetry里复刻),违反 DRY。 getAcceptValue把「微信 > vivo/oppo > 移动 > 默认」判断写死,没有 prop 可覆盖;微信/vivo/oppo 三个分支都返回'*',可合并。MOBILE_DEFAULT_ACCEPT~250 字符常量需要命名 + 注释来源。
VoiceInputManager
pendingRef和recording状态分管「瞬时锁」和「UI 状态」,但stopRecording在recording=false直接 return —— 如果start还在await voiceRecognizer({...})里、recording还没 true,外部的stopRecording就停不掉,会泄漏 recognizer。updateCurrentSentence依赖「sentenceStartIndexRef.current一直是有效 index」,用户在录音中手动编辑文本时 index 错位 → 幻觉文本。onError里直接setRecording(false)没有把 error 透给外部。
SkillModeBar
- 见 #4 的
skipNextCallbackRef反模式。 <SkillModeBar />→<SkillModeBarInner />的拆分可合并(hooks 写在 early return 之前即可)。
Suggestion
SuggestionConnext拼写错误(导出 API,改名是 breaking)。useEffect(() => { loadingData() }, [open])依赖只有[open],items函数变了不重新加载。selectedItems初值通过useState(() => items.map(...))计算,但没有 effect 监听items静态数组变化。
QuickActions
onResize?.(e.offsetWidth, rightOffset)通过getComputedStyle读right像素值再回传给父组件做 padding 计算,强耦合反模式。
BorderBeamAnimation
- 自己装一遍 ResizeObserver,与
Layout重复,可共享useElementSize。 React.useId用对了,渐变 ID 不会冲突。✅
RefinePromptButton
isBrowserEnv()自己又封了一遍,复用统一的isBrowser。
TopOperatingArea
targetRef ? () => targetRef.current || window : undefinedinline 函数每次 render 新建,BackTo 子组件可能因此每次都 reattach 监听器(待确认)。
Enlargement
- 标题
'放大' / '缩小'中文硬编码,没走 i18n。
FilePaste
processEntry的dirReader.readEntries在条目超过 100 时只返回前 100 个(浏览器 API 限制),这里没有循环调用 readEntries 直到为空,会丢文件。
跨模块 API 一致性问题
优先级建议
如果只能挑 5 件事做:
- #1 拆 Props,分 Slot:不做的话每加一个能力都是一次主组件改动。
- #2 合并 hooks:让 Layout / Styles / Actions 三个变成 1 个
useInputFieldGeometry,主组件可瘦到 ~300 行。 - #3 + #5 抽
upLoadFileToServer出 AttachmentButton;类型集中到types/;删除所有as any:解决依赖反向 + 类型安全。 - #4 修复
useSkillModeState的「自反 callback 防抖」反模式 + 统一受控/非受控规范:skipNextCallbackRef是设计 smell,必须修。 - #12 建立统一的
onError通道 + 删除console.error:提升可观测性。
值得保留的设计 ✅
useMarkdownInputFieldRefs里lastEditorValueRef+ 焦点检查的双重门禁,注释非常详细,是处理过真实 bug 的好设计。BorderBeamAnimation用React.useId解决多实例冲突。SkillModeBar把 hooks 全部塞到 inner 组件、外层做 early return 来稳定 hooks 顺序,思路是对的(虽然实现可合并)。SendButton的resolveSendDisabled单独抽函数,独立可测。useFileUploadManager的fileUploadSummary数据结构(totalCount/doneCount/uploadingCount/errorCount)设计完整,外部可做精细 UI。- 测试覆盖良好:
__tests__/8 个独立测试文件 +AttachmentButton/__tests__/+hooks/__tests__/。
跟进建议
- 推荐拆成 4~5 个 PR 提交,避免一次性大重构:
- PR 1(low risk):#5 类型集中 + 删除
as any+ 抽src/Utils/env.ts - PR 2(low risk):#10 搬走
BeforeToolContainer+ #9 magic number 常量化 - PR 3(medium):#3 抽
upLoadFileToServer+ 解开hooks ↔ AttachmentButton反向依赖 - PR 4(high):#2 合并 hooks →
useInputFieldGeometry - PR 5(breaking 候选):#1 拆 Props、分 Slot;#4 受控/非受控统一;#12
onError通道
- PR 1(low risk):#5 类型集中 + 删除
- breaking change 的项(#1 / #4 部分 / #6
SuggestionConnext改名)走下一个大版本。
