React 复合指令模式:利用自定义 Hooks 实现类似 Vue 指令的可复用 DOM 操作逻辑

各位老铁,大家好。

今天咱们不聊虚的,咱们来聊聊那个让无数 React 开发者深夜抓狂,却又不得不依赖的“DOM 操作”。

在 Vue 的世界里,开发者是上帝。你想让一个输入框自动聚焦?简单,v-focus 搞定。你想监听一个元素是否进入了视口?v-scroll 就位。Vue 的指令系统就像是给你发了一套瑞士军刀,你拿着它去干脏活累活,根本不需要关心刀是从哪儿来的,也不用担心用完刀之后怎么收场。

但在 React 的世界里,上帝是秃顶的,因为他总是对着屏幕思考。React 告诉你:“嘿,别碰 DOM!我们用数据驱动视图,DOM 只是数据的奴隶。” 于是,React 的信徒们开始写 useEffect,开始在组件里疯狂地访问 ref.current,把 DOM 操作逻辑像鼻涕一样粘在组件的各个角落。

这就像是你想给家里装个灯泡,结果电工告诉你:“你不能自己拧,你得写个算法算出电流强度,然后写个函数把灯泡放上去,最后还要写个清理函数防止短路。”

今天,我们要干的事儿,就是用 React 的方式,把那个“电工”请回来。我们要用自定义 Hooks 实现一套“复合指令模式”。这不仅仅是模仿 Vue,这是为了拯救我们那脆弱的组件逻辑,让 DOM 操作变得可复用、可组合、甚至带点仪式感。

准备好了吗?让我们开始这场“DOM 操作的救赎之旅”。


第一章:useEffect 的精神分裂症

首先,我们得承认一个问题:React 的 useEffect 就像一个精神分裂症患者。

当你写 useEffect(() => { ref.current.focus() }, []) 时,它在挂载的那一刻是清醒的,它知道要聚焦。但如果你在组件里加了个 state,或者父组件传了个新 props,组件重新渲染了,useEffect 又醒了。它看了看依赖数组,发现是空的,心想:“哦,没事,我不动。” 于是,那个输入框就悲剧地失去了焦点。

这就是为什么我们需要“指令”。指令的逻辑应该是“绑定”的,而不是“触发”的。它应该像胶水一样,粘在 DOM 上,只要那个 DOM 在,逻辑就在,DOM 没了,逻辑自然解绑。这叫什么?这叫“契约精神”。

在 Vue 里,这个契约是写在 mountedupdatedunmounted 生命周期里的。在 React 里,我们用自定义 Hook 来封装这个契约。

第二章:定义“指令 Hook”的契约

我们要定义的 Hook,必须遵循一个神圣的契约:

  1. 接收一个 Ref: 它需要知道要操作谁。就像电工需要知道你要接哪个插座。
  2. 处理挂载: 当 DOM 被挂载,它得干活。
  3. 处理卸载: 当组件销毁,它得收摊,防止内存泄漏。
  4. 处理更新: 如果 Ref 变了,或者配置变了,它得做出反应。

让我们来写第一个“指令”:useFocus

import { useEffect, useRef } from 'react';

/**
 * 模拟 Vue 的 v-focus 指令
 * @param {RefObject<HTMLInputElement>} ref - 目标 DOM 元素的引用
 */
function useFocus(ref) {
  useEffect(() => {
    // 挂载时:聚焦
    if (ref.current) {
      ref.current.focus();
    }

    // 卸载时:失去焦点(可选,取决于业务逻辑)
    return () => {
      if (ref.current) {
        ref.current.blur();
      }
    };
  }, [ref]); // 依赖 ref
}

// 使用示例
export default function FocusDemo() {
  const inputRef = useRef(null);

  useFocus(inputRef);

  return (
    <div>
      <h3>我是自动聚焦的输入框</h3>
      <input ref={inputRef} type="text" placeholder="看我!" />
    </div>
  );
}

看,多干净。这就是 React 的“指令”雏形。它把副作用封装成了一个可复用的函数。你不需要关心它是怎么实现的,你只需要把它扔到组件里,它就会像魔法一样工作。

但是,这还不够。React 的老毛病又犯了:太死板useFocus 只能聚焦,它不能帮你复制文本,也不能帮你监听滚动。

第三章:进阶指令——剪贴板与滚动

让我们来点硬核的。假设我们要实现一个 v-clipboard 指令。

在 Vue 里,这通常是监听点击事件,调用 navigator.clipboard.writeText,然后可能还要改改 UI 提示一下。

在 React 的指令模式里,我们需要一个 useClipboard Hook。

import { useEffect, useRef, useState } from 'react';

function useClipboard(text) {
  const [copied, setCopied] = useState(false);
  const timeoutRef = useRef(null);

  useEffect(() => {
    return () => {
      if (timeoutRef.current) clearTimeout(timeoutRef.current);
    };
  }, []);

  const copy = () => {
    navigator.clipboard.writeText(text).then(() => {
      setCopied(true);
      if (timeoutRef.current) clearTimeout(timeoutRef.current);
      timeoutRef.current = setTimeout(() => setCopied(false), 2000);
    });
  };

  return { copy, copied };
}

// 模拟 v-clipboard:click 的复合指令
function useClickToCopy(ref, text) {
  const { copy, copied } = useClipboard(text);

  useEffect(() => {
    const el = ref.current;
    if (!el) return;

    const handleClick = () => {
      copy();
    };

    el.addEventListener('click', handleClick);

    // 返回清理函数,确保点击事件被移除
    return () => {
      el.removeEventListener('click', handleClick);
    };
  }, [ref, copy]);

  return copied;
}

export default function ClipboardDemo() {
  const buttonRef = useRef(null);
  const isCopied = useClickToCopy(buttonRef, "Hello World!");

  return (
    <button ref={buttonRef}>
      {isCopied ? "已复制!" : "点击复制"}
    </button>
  );
}

看到了吗?useClickToCopy 就是一个复合指令。它结合了 DOM 事件监听(addEventListener)和状态管理(useState)。

复合模式的核心在于: 一个指令可以由多个逻辑块组成。就像乐高积木,你可以把“监听点击”和“执行复制”这两块积木拼在一起,形成一个复杂的功能。

第四章:Intersection Observer —— 视口监听的王者

在 Vue 里,监听元素是否进入视口,以前大家都在用 scroll 事件计算 getBoundingClientRect,那性能简直是灾难,就像是在高速公路上用算盘算数。

现在我们有 IntersectionObserver API,这可是现代浏览器的大杀器。让我们把它封装成一个指令 useInView

import { useEffect, useRef } from 'react';

function useInView(options) {
  const ref = useRef(null);
  const entry = useRef(null);

  useEffect(() => {
    const element = ref.current;
    if (!element) return;

    const observer = new IntersectionObserver(([e]) => {
      entry.current = e;
    }, options);

    observer.observe(element);

    return () => {
      observer.disconnect();
    };
  }, [options]);

  return { ref, entry };
}

// 使用场景:懒加载图片
export default function LazyImage() {
  const { ref, entry } = useInView({
    threshold: 0.1 // 10% 可见时触发
  });

  return (
    <div style={{ height: "400px", border: "1px dashed #ccc" }}>
      <img
        ref={ref}
        src="https://via.placeholder.com/400" // 假装是懒加载的图
        style={{ opacity: entry?.isIntersecting ? 1 : 0, transition: "opacity 0.3s" }}
        alt="Lazy Loaded"
      />
      <p>可见性状态: {entry?.isIntersecting ? "我出现了!" : "我还没出现"}</p>
    </div>
  );
}

这个 useInView 就是一个完美的复合指令。它不仅管理了 DOM 引用,还管理了观察者的生命周期(observedisconnect),甚至把结果暴露给了外部。这就是“指令”的精髓:它接管了 DOM 的生命周期,并反馈状态。

第五章:复合模式的高级玩法——链式调用与参数化

Vue 的指令非常强大,因为它可以接收参数。比如 v-if="isShow", v-for="item in list", v-on:click="doSomething"

在 React 的自定义指令模式里,我们如何处理参数?

最优雅的方式是使用“配置对象”。我们定义一个 Hook,它接收一个 ref 和一个 options 对象。

让我们来实现一个 useClickOutside 指令,并支持配置 ignoreSelector(忽略某些子元素)。

import { useEffect, useRef } from 'react';

function useClickOutside(options = {}) {
  const ref = useRef(null);
  const { ignoreSelector } = options;

  useEffect(() => {
    const element = ref.current;
    if (!element) return;

    const handleClick = (e) => {
      // 如果点击的是忽略元素,或者点击的是元素本身,直接返回
      if (e.target.closest(ignoreSelector) || element.contains(e.target)) {
        return;
      }

      // 如果点击的是外部,触发回调
      if (options.onClick) {
        options.onClick(e);
      }
    };

    // 使用事件委托,性能更好
    document.addEventListener('mousedown', handleClick);

    return () => {
      document.removeEventListener('mousedown', handleClick);
    };
  }, [ignoreSelector, options.onClick]);

  return ref;
}

// 复合模式:结合 useClickOutside 和 useToggle
function useClickOutsideToggle(ref, initialState = false) {
  const [isOpen, setIsOpen] = useState(initialState);

  useClickOutside(ref, {
    onClick: () => setIsOpen(false),
    ignoreSelector: '.dropdown-content' // 忽略下拉菜单的内容区域
  });

  return [isOpen, setIsOpen];
}

export default function Dropdown() {
  const [isOpen, setIsOpen] = useClickOutsideToggle(null);
  const menuRef = useRef(null);

  return (
    <div ref={menuRef} style={{ position: 'relative' }}>
      <button onClick={() => setIsOpen(!isOpen)}>菜单</button>
      {isOpen && (
        <div className="dropdown-content">
          <p>这是被忽略的内部元素</p>
          <p>点击外部会关闭我</p>
        </div>
      )}
    </div>
  );
}

在这个例子里,useClickOutsideToggle 是一个复合指令。它内部调用了 useClickOutside,并且注入了自己的业务逻辑(setIsOpen)。这就像是在工厂流水线上,一个工人负责组装零件,另一个工人负责质检,最后打包出厂。这就是“复合”的力量。

第六章:实战演练——打造一个“全能型”指令库

为了让大家彻底理解,我们来模拟一个真实的场景:长按触发

在 Vue 里,你可能会写一个 v-longpress 指令。在 React 里,我们需要处理 mousedownmouseuptouchstarttouchend 这一系列复杂的触摸事件,还要处理防抖和定时器。

让我们写一个 useLongPress 指令,它支持自定义触发时间、自定义触发回调,以及可选的取消回调。

import { useEffect, useRef } from 'react';

/**
 * 复合指令:长按触发
 * @param {RefObject<HTMLElement>} ref 
 * @param {Object} options 
 * @param {number} options.delay - 长按毫秒数,默认 500ms
 * @param {Function} options.onPressStart - 开始长按
 * @param {Function} options.onPressEnd - 结束长按(未触发)
 * @param {Function} options.onPress - 触发回调
 */
function useLongPress(ref, options = {}) {
  const {
    delay = 500,
    onPressStart,
    onPressEnd,
    onPress
  } = options;

  const timerRef = useRef(null);
  const isPressingRef = useRef(false);

  useEffect(() => {
    const element = ref.current;
    if (!element) return;

    const startHandler = (e) => {
      // 防止移动端长按弹出菜单
      e.preventDefault(); 

      isPressingRef.current = true;
      if (onPressStart) onPressStart(e);

      timerRef.current = setTimeout(() => {
        if (isPressingRef.current && onPress) {
          onPress(e);
        }
        isPressingRef.current = false;
        if (onPressEnd) onPressEnd(e);
      }, delay);
    };

    const endHandler = (e) => {
      if (!isPressingRef.current) return;

      isPressingRef.current = false;
      clearTimeout(timerRef.current);

      if (onPressEnd) onPressEnd(e);
    };

    element.addEventListener('mousedown', startHandler);
    element.addEventListener('mouseup', endHandler);
    element.addEventListener('mouseleave', endHandler);

    // 移动端适配
    element.addEventListener('touchstart', startHandler, { passive: false });
    element.addEventListener('touchend', endHandler);

    return () => {
      element.removeEventListener('mousedown', startHandler);
      element.removeEventListener('mouseup', endHandler);
      element.removeEventListener('mouseleave', endHandler);
      element.removeEventListener('touchstart', startHandler);
      element.removeEventListener('touchend', endHandler);
      clearTimeout(timerRef.current);
    };
  }, [ref, delay, onPressStart, onPressEnd, onPress]);
}

// 使用示例:长按删除按钮
export default function LongPressDemo() {
  const btnRef = useRef(null);

  useLongPress(btnRef, {
    delay: 800,
    onPressStart: () => console.log('长按开始...'),
    onPress: (e) => {
      alert('触发长按事件!你真狠,居然按了 800 毫秒!');
    },
    onPressEnd: () => console.log('松手了')
  });

  return (
    <button ref={btnRef} style={{ padding: '20px', background: 'red', color: 'white' }}>
      长按我试试
    </button>
  );
}

看这个代码结构。它没有把逻辑塞进组件里,而是把“事件监听”、“定时器管理”、“状态标记”全部封装在 Hook 里。组件只需要声明:“嘿,给我个 ref,我要长按 800ms 触发一个 alert”。

这就是指令模式的终极形态:关注点分离

第七章:处理那些“坑爹”的边缘情况

写指令 Hook 和写普通 Hook 有个最大的不同:你不知道 ref.current 什么时候会是 null

在 Vue 里,指令的生命周期和 DOM 的生命周期是强绑定的。在 React 里,如果父组件突然把 ref 换了,或者组件还没渲染完就卸载了,你的 Hook 可能会试图去操作一个不存在的节点。

我们需要加上防御性编程。

function useSafeEffect(effect, deps) {
  const isMounted = useRef(true);

  useEffect(() => {
    if (!isMounted.current) return;
    const cleanUp = effect();
    return () => {
      isMounted.current = false;
      if (cleanUp) cleanUp();
    };
  }, deps);
}

// 安全版 useFocus
function useSafeFocus(ref) {
  useSafeEffect(() => {
    if (ref.current) ref.current.focus();
    return () => {
      if (ref.current) ref.current.blur();
    };
  }, [ref]);
}

还有一个问题:闭包陷阱。在 useEffect 里,如果你引用了外部变量,它们会被快照捕获。如果你在指令里想用最新的 props,得小心。

function useDynamicClick(ref, handler) {
  useEffect(() => {
    const el = ref.current;
    if (!el) return;

    const handleClick = (e) => handler(e); // 这里 handler 是闭包

    el.addEventListener('click', handleClick);
    return () => el.removeEventListener('click', handleClick);
  }, [ref, handler]); // 必须把 handler 放进依赖数组
}

第八章:指令与组件的哲学辩论

现在,我们要回答一个终极问题:既然有了组件,为什么还要用指令?

React 的哲学是“一切皆组件”。把 DOM 操作逻辑写成 Hook,本质上也是组件思维(函数组件)。

但是,指令模式有其独特的价值:

  1. 语义化: useFocus(ref)ref.current.focus() 读起来更像是“给这个元素聚焦”。
  2. 复用性: 你可以把 useFocus 抽离到 use-custom-hooks 库里,让全世界的人受益,就像 Vue 的官方指令一样。
  3. 组合性: 指令可以层层叠加。v-if + v-clipboard + v-focus。在 React 里,你可以组合多个 Hooks:useConditionalFocus(isShow, ref)

第九章:构建一个“指令工厂”

为了达到极致的封装,我们可以写一个简单的“指令工厂函数”,模仿 Vue 的 Directive 注册机制。

虽然 React 不支持模板语法,但我们可以把 Hook 注册到一个 Map 里,然后在组件里像这样使用:

const directives = {
  focus: (el) => el.focus(),
  scroll: (el) => el.scrollIntoView(),
  clickOutside: (el, binding) => { /* ... */ }
};

function useDirective(name, el, binding) {
  const directive = directives[name];
  if (directive) {
    directive(el, binding);
  }
}

// 在组件中,通过 HOC 或者自定义渲染函数来调用
// 这部分比较 hacky,但在某些特殊场景(如高阶组件)很有用

当然,这种写法在 React 中不如 Vue 灵活,因为 React 是声明式的,指令是命令式的。但在某些需要精细控制 DOM 的场景(如富文本编辑器、Canvas 渲染器、Web Worker 通信)中,这种“指令模式”能极大地提高代码的可维护性。

第十章:总结——拥抱副作用

回到最初的话题。React 的 useEffect 并不坏,它是为了解决副作用问题而生的。但 useEffect 是全局的,它不知道自己针对的是哪个 DOM 节点。

自定义 Hook 的“指令模式”,本质上就是给副作用加上了“靶向性”

它把“副作用”变成了一种“可插拔的组件”。
它把“DOM 操作”变成了一种“声明式的契约”。

当你写下一个 useFocususeClickOutsideuseLongPress 时,你实际上是在构建一个DSL(领域特定语言)。你用 React 的语法,描述了你在 DOM 世界里想要的行为。

这就是为什么我们要研究这个。不是为了模仿 Vue,而是为了掌握一种更高级的代码组织方式

想象一下,你的项目里不再有乱七八糟的 useEffect,不再有散落在组件里的 document.getElementById。所有的 DOM 交互逻辑都被封装在了一个个漂亮的 Hook 里。当你看到 <input ref={useFocus(ref)} /> 时,你会感到一种莫名的、代码洁癖得到满足的快感。

好了,今天的讲座就到这里。现在,去你的代码里,种下几颗“指令”的种子吧。别忘了,用完记得 return 清理函数,别让你的组件变成内存泄漏的坟墓。

下课!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注