这是一个非常经典的问题:在 Composable 中处理 DOM 元素。
答案是:可以抽离,而且非常推荐。 在 Vue 3 的 Composition API 中,你可以直接将 ref(null) 定义在 Composable 内部并返回,或者从外部将 DOM Ref 作为参数传入。
为了保持逻辑的纯粹性,我建议采用 “定义在内,暴露在外” 的方案。这样 InputField.vue 就不需要维护这个 DOM 引用了。
1. 编写 useInputHistory.js
我们需要将 chatStore 的操作、nextTick 逻辑以及光标控制(placeCaretAtEnd)全部收拢。
JavaScript
// composables/useInputHistory.js
import { ref, nextTick } from 'vue';
import { useChatStore } from '../stores/chat.js';
export function useInputHistory() {
const chatStore = useChatStore();
// 在 Composable 内部定义 DOM Ref
const skillAreaRef = ref(null);
// 辅助函数:将光标移至 contenteditable 末尾
const placeCaretAtEnd = () => {
const el = skillAreaRef.value;
if (!el) return;
const range = document.createRange();
const selection = window.getSelection();
range.selectNodeContents(el);
range.collapse(false);
selection.removeAllRanges();
selection.addRange(range);
};
/**
* 撤销或重做逻辑
* @param {string} keyFlag 'z' 或 'y'
* @param {Ref} userInput 外部传入的普通输入框文本引用
* @param {string} mode 模式标识
*/
const unOrRedo = async (keyFlag, userInput, mode = null) => {
// 1. 获取 store 计算后的新文本
const finallyMsg = chatStore.operationOfInput(keyFlag);
const newContent = finallyMsg ?? '';
// 2. 更新基础 userInput (textarea 使用)
userInput.value = newContent;
// 3. 处理翻译模式 (contenteditable 使用)
if (mode === 'translate' && skillAreaRef.value) {
if (!newContent) {
// 处理特殊占位符逻辑
skillAreaRef.value.innerText = '\u200B';
skillAreaRef.value.classList.add('empty');
} else {
skillAreaRef.value.innerText = newContent;
skillAreaRef.value.classList.remove('empty');
}
// 等待 DOM 更新后重置光标
await nextTick();
placeCaretAtEnd();
}
};
return {
skillAreaRef, // 暴露出去,让模板进行绑定
unOrRedo
};
}
2. 在 InputField.vue 中替换逻辑
现在,你需要删除组件内原有的 skillAreaRef 定义,改为从 Hook 中获取。
JavaScript
// InputField.vue
<script setup>
// ... 其他引入
import { useInputHistory } from '@/components/ChatInput/composables/useInputHistory.js';
// 1. 初始化 Hook
// 注意:这里拿到的 skillAreaRef 直接对应模板里的 ref="skillAreaRef"
const { skillAreaRef, unOrRedo } = useInputHistory();
// 2. 之前的 handleKeydown 和 handleTranslateKeydown 逻辑
const handleKeydown = (event) => {
// ...
if (ctrlKey && (keyFlag === 'z' || keyFlag === 'y')) {
event.preventDefault();
// 传入 userInput,Hook 会自动处理文本框和 skillArea
unOrRedo(keyFlag, userInput);
return;
}
};
const handleTranslateKeydown = (e) => {
// ...
if (ctrlKey && (keyFlag === 'z' || keyFlag === 'y')) {
e.preventDefault();
// 传入模式标识 'translate'
unOrRedo(keyFlag, userInput, 'translate');
return;
}
};
// 3. 删除组件内原来的 unOrRedo 函数和 placeCaretAtEnd 函数
</script>
<template>
<span
ref="skillAreaRef"
class="skill-user-message"
...
></span>
</template>
3. 为什么这样抽离是正确的?
- 自动绑定:在 Vue 3 中,只要 Composable 返回的
ref名称与模板中的ref="..."属性值一致,Vue 会自动完成 DOM 绑定。 - 降低组件复杂度:
InputField.vue不再需要关心如何操作 Range 对象、如何处理\u200B占位符,它只需要调用unOrRedo这个指令即可。 - 光标逻辑复用:光标定位逻辑(
placeCaretAtEnd)被封装在了 Hook 内部,不会污染主组件的命名空间。
💡 核心总结贴纸
Composable 处理 DOM 的黄金定律:
- 定义在内:在 Hook 内部
const myRef = ref(null)。- 返回引用:
return { myRef }。- 外部绑定:组件
const { myRef } = useHook(),模板<div ref="myRef">。- 安全访问:在 Hook 的函数内访问
myRef.value时需做非空检查。