各位老铁,大家好。
今天咱们不聊虚的,咱们来聊聊那个让无数 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 里,这个契约是写在 mounted、updated、unmounted 生命周期里的。在 React 里,我们用自定义 Hook 来封装这个契约。
第二章:定义“指令 Hook”的契约
我们要定义的 Hook,必须遵循一个神圣的契约:
- 接收一个 Ref: 它需要知道要操作谁。就像电工需要知道你要接哪个插座。
- 处理挂载: 当 DOM 被挂载,它得干活。
- 处理卸载: 当组件销毁,它得收摊,防止内存泄漏。
- 处理更新: 如果 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 引用,还管理了观察者的生命周期(observe 和 disconnect),甚至把结果暴露给了外部。这就是“指令”的精髓:它接管了 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 里,我们需要处理 mousedown、mouseup、touchstart、touchend 这一系列复杂的触摸事件,还要处理防抖和定时器。
让我们写一个 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,本质上也是组件思维(函数组件)。
但是,指令模式有其独特的价值:
- 语义化:
useFocus(ref)比ref.current.focus()读起来更像是“给这个元素聚焦”。 - 复用性: 你可以把
useFocus抽离到use-custom-hooks库里,让全世界的人受益,就像 Vue 的官方指令一样。 - 组合性: 指令可以层层叠加。
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 操作”变成了一种“声明式的契约”。
当你写下一个 useFocus,useClickOutside,useLongPress 时,你实际上是在构建一个DSL(领域特定语言)。你用 React 的语法,描述了你在 DOM 世界里想要的行为。
这就是为什么我们要研究这个。不是为了模仿 Vue,而是为了掌握一种更高级的代码组织方式。
想象一下,你的项目里不再有乱七八糟的 useEffect,不再有散落在组件里的 document.getElementById。所有的 DOM 交互逻辑都被封装在了一个个漂亮的 Hook 里。当你看到 <input ref={useFocus(ref)} /> 时,你会感到一种莫名的、代码洁癖得到满足的快感。
好了,今天的讲座就到这里。现在,去你的代码里,种下几颗“指令”的种子吧。别忘了,用完记得 return 清理函数,别让你的组件变成内存泄漏的坟墓。
下课!