Composable 中处理 DOM 元素

26 阅读2分钟

这是一个非常经典的问题:在 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. 为什么这样抽离是正确的?

  1. 自动绑定:在 Vue 3 中,只要 Composable 返回的 ref 名称与模板中的 ref="..." 属性值一致,Vue 会自动完成 DOM 绑定。
  2. 降低组件复杂度InputField.vue 不再需要关心如何操作 Range 对象、如何处理 \u200B 占位符,它只需要调用 unOrRedo 这个指令即可。
  3. 光标逻辑复用:光标定位逻辑(placeCaretAtEnd)被封装在了 Hook 内部,不会污染主组件的命名空间。

💡 核心总结贴纸

Composable 处理 DOM 的黄金定律

  • 定义在内:在 Hook 内部 const myRef = ref(null)
  • 返回引用return { myRef }
  • 外部绑定:组件 const { myRef } = useHook(),模板 <div ref="myRef">
  • 安全访问:在 Hook 的函数内访问 myRef.value 时需做非空检查。