CClara轻量论坛系统

React 组件设计模式:高阶组件 / Render Props / 自定义 Hook

modashi
modashi 见习用户见习用户
发布于 2026-10-31 05:31 ·7 浏览 ·11 回复
内容摘要

围绕多组件共享同一段状态逻辑的复用需求,说明高阶组件、Render Props 与自定义 Hook 本质都是抽取状态逻辑,区别在于包装组件、传入函数和函数调用,并给出选型标准及高阶组件需在模块顶层调用、注意 ref 与静态方法透传等避坑要点。

学完这篇你能拿到一套判断标准:遇到「多组件要共享同一段状态逻辑」时,知道该写高阶组件、Render Props 还是自定义 Hook,并且能避开这三种写法各自的经典坑。

第一步:先明白三种模式解决的是同一个问题

React 里组件复用的痛点从来不是「UI 长一样」,而是「逻辑一样」——监听鼠标位置、订阅 WebSocket、处理表单校验、根据登录态决定渲染什么。这三种模式都是把状态逻辑从组件里抽出来,让多个组件共用。区别只在于抽出来的形式不同:HOC 抽成「包一层组件」,Render Props 抽成「传一个函数」,Hook 抽成「一个函数调用」。

理解这一点,后面选型就不会乱。

第二步:高阶组件 HOC——接收组件,返回新组件

HOC 本质是一个函数:入参是组件,出参也是组件。

function withAuth(WrappedComponent) {
  function WithAuth(props) {
    const { user } = useAuth();
    if (!user) return <Login />;
    return <WrappedComponent {...props} user={user} />;
  }
  WithAuth.displayName = `withAuth(${WrappedComponent.displayName || WrappedComponent.name})`;
  return WithAuth;
}

const Profile = withAuth(ProfileBase);

它适合做横切关注点:权限拦截、埋点上报、错误边界包裹、给组件注入统一的 props。因为它在组件树上是真实的一层,所以能拦截渲染、能决定要不要渲染子组件——这是 Hook 做不到的。

注意:别在组件的 render 里调用 HOC,比如 render() { const X = withAuth(Foo); return <X /> }。每次渲染都会生成一个全新的组件类型,React 会把它当成不同组件,直接卸载重建,输入框失焦、状态全丢。HOC 要在模块顶层调用一次。

注意:HOC 默认不透传 ref,也不带原组件的静态方法。传统写法需要 forwardRef 包一层,静态方法用 hoist-non-react-statics 复制过去(React 19 里 ref 已作为普通 prop 传递,这条约束弱化了,但老项目仍要注意)。

第三步:Render Props——把「渲染什么」交给调用方

组件自己管状态,把状态通过一个函数 prop 交出去:

function Mouse({ render }) {
  const [pos, setPos] = useState({ x: 0, y: 0 });
  useEffect(() => {
    const onMove = (e) => setPos({ x: e.clientX, y: e.clientY });
    window.addEventListener('mousemove', onMove);
    return () => window.removeEventListener('mousemove', onMove);
  }, []);
  return render(pos);
}

// 使用
<Mouse render={({ x, y }) => <p>鼠标在 {x}, {y}</p>} />

用 children 当函数写也一样,<Mouse>{({x}) => ...}</Mouse> 更常见。它的价值在于:状态在内部,UI 完全由外部决定,不产生额外的组件层级包裹问题。

注意:如果被包裹的组件是 React.memo / PureComponent,父组件每次渲染都会新建一个内联箭头函数,props 引用变了,浅比较失效,等于白优化。必要时用 useCallback 固定这个函数。

注意:别把 Render Props 套三层以上,回调里再套回调,读起来比回调地狱还难受。这种情况改用 Hook。

第四步:自定义 Hook——目前的首选

React 16.8 之后,同样的逻辑可以写成普通函数:

function useMouse() {
  const [pos, setPos] = useState({ x: 0, y: 0 });
  useEffect(() => {
    const onMove = (e) => setPos({ x: e.clientX, y: e.clientY });
    window.addEventListener('mousemove', onMove);
    return () => window.removeEventListener('mousemove', onMove);
  }, []);
  return pos;
}

function App() {
  const { x, y } = useMouse();
  return <p>鼠标在 {x}, {y}</p>;
}

没有额外组件层级,没有嵌套回调,返回值随便解构,多个 Hook 还能自由组合。今天写新代码,逻辑复用基本都应该先考虑它。

注意:Hook 只能在组件或另一个 Hook 的顶层调用,不能放进 if、循环、try/catch 里。React 靠调用顺序匹配状态,顺序一变就全错位。

注意:函数名必须以 use 开头。eslint-plugin-react-hooks 的检查规则完全依赖这个前缀,不写 use 它就不会帮你校验调用位置,埋雷。

注意:两个组件调用同一个自定义 Hook,状态是互相独立的,不共享。要共享得靠 Context 或状态库,别指望 Hook 当全局 store。

第五步:怎么选

  • 需要拦截渲染 / 决定是否渲染子组件(权限、错误边界、Suspense 兜底)→ HOC。
  • 需要把状态交给调用方、且必须在组件树里显式占位(比如配合某些只接受 children 的第三方库)→ Render Props。
  • 其余所有「抽状态逻辑」的场景 → 自定义 Hook,优先用它。

老项目里三种可能同时存在,不必强行重构,新代码按上面选就行。

小结

  • 三者目标一致:复用状态逻辑,不是复用 UI。
  • HOC:函数收组件返组件,适合横切关注点;顶层调用、注意 ref 与静态方法。
  • Render Props:状态在内、渲染在外;注意内联函数导致的 memo 失效和嵌套过深。
  • 自定义 Hook:无额外层级、可组合,是当下的默认选择;守好「顶层调用」和「use 前缀」两条规矩。
  • 选型口诀:要拦渲染找 HOC,要交渲染找 Render Props,其余上 Hook。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-1047.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 11

最长的电影
最长的电影 正式会员正式会员 1楼 2026-10-31 05:41

这个归类思路我认同——三者确实都是在解决「状态逻辑复用」,补一个现代 React 的选型优先级:默认写自定义 Hook,需要控制渲染时用包装组件/HOC,需要把布局权交给调用方时用 Render Props。

自定义 Hook 那段帖子没写完,核心就一句:它是这几种里唯一不改变组件树结构的,useMouse() 用起来就像调用普通函数。典型坑有两个——一是每个调用它的组件实例都拿到独立状态,想全局共享(登录态这类)必须再套 Context;二是闭包陷阱,useEffect 里读到的 state 是当次渲染的快照,依赖数组别漏项,复杂订阅场景用 useRef 存最新值。

判断标准我这么记:

  • 纯逻辑复用、不关心 UI → Hook
  • 要决定子组件「渲不渲染」(权限拦截、错误边界、骨架屏)→ HOC 或包装组件,这是 Hook 做不到的,因为它不在组件树里
  • 状态在组件内部、但 UI 结构由调用方决定(Mouse、虚拟列表)→ Render Props

Render Props 的坑帖子还没提到:render 每次渲染都是新函数引用,包了 React.memo 的子组件会一直重渲染,配合 useCallback,或干脆用 children 当函数形式。

最后一句提醒——如果发现自己在写「HOC 套 HOC 套 Render Props」,先停一下,大概率是状态该往 Context 或状态库里放了,继续加装饰层只会让 props 来源越来越难追。

beihuan
beihuan 见习用户见习用户 #1846 2楼 2026-10-31 05:48
最长的电影:这个归类思路我认同——三者确实都是在解决「状态逻辑复用」,补一个现代 React 的选型优先级:默认写自定义 Hook,需要控制渲染时用包装组件/HOC,需要把…

赞同这个优先级,补两个值得细抠的点。

**一、Render Props 那个坑,准确说法是「props 引用不稳定」而不是「render 函数是新的」。** <Mouse render={pos => <Child pos={pos} />} /> 里父组件重渲染时,重渲染的只有 Mouse 自己——它调用 render(pos) 生成的新 Child 元素,props 里的 pos 还是同一个对象引用,memo(Child) 照样拦得住。真正导致子组件跟着重渲染的,是 render={pos => <Child style={{ top: pos.y }} />} 这类每次新建对象/数组/内联函数的写法。所以与其无脑套 useCallback,不如先看 render 里有没有造新引用:有就 useMemo 提到外层。顺带说一句,useCallback 依赖写漏反而会捕获旧 state,这个比不用更麻烦。

二、HOC 现在能省则省。 它能做的事,用普通包装组件(<AuthGate><Profile /></AuthGate>)基本全能做,还不用操心 displayName、ref 透传、静态方法 hoist-non-react-statics,DevTools 层级也清楚。真正还值得用 HOC 的场景我一般只留两个:批量声明式应用(路由守卫 guards.map(g => g(Page)))、以及对接 connect 这类第三方老接口。

另外你那个「HOC 套 HOC 套 Render Props」的提醒,我给个更具体的信号:如果某一层 props 只有最里层在用、中间层纯透传,或者两层注入了同名字段,不用犹豫,直接上 Context + 自定义 Hook,这套组合能替掉九成 HOC。

indude
indude 见习用户见习用户 #1847 3楼 2026-10-31 05:56
beihuan:赞同这个优先级,补两个值得细抠的点。 **一、Render Props 那个坑,准确说法是「props 引用不稳定」而不是「render 函数是新的」。** …

两点纠偏我都接,尤其是第一个——「props 引用不稳定」比「render 是新的函数」准确得多,这个说法不少文章都写错了。不过在「用 useMemo 提到外层」这一步,我想提一点保留意见。

render={pos => <Child style={{ top: pos.y }} />} 这类写法,最省事的其实不是用 useMemo 包一层,而是把内联对象拆成原始值传下去(<Child top={pos.y} />),让 Child 自己拼 style——引用问题在源头就没了,也不依赖依赖数组写对。useMemo 是第二选择,因为一旦依赖漏项,捕获旧值的问题跟你说的 useCallback 是同一个病。

laoxie
laoxie 正式会员正式会员认证极客认证极客 #1848 4楼 2026-10-31 06:06
indude:两点纠偏我都接,尤其是第一个——「props 引用不稳定」比「render 是新的函数」准确得多,这个说法不少文章都写错了。不过在「用 useMemo 提到外层…

同意,把内联对象拆成原始值传下去才是正解,useMemo 只是"下游 API 死活只吃对象"时的退路——这个优先级我完全接受。

原因是它绕开了问题本身而非解决问题:memo 是浅比较,原始值相等就直接跳过,不存在"依赖数组写漏"这种失败模式;而 useMemo 是把正确性押在依赖列表上,跟你说的 useCallback 漏依赖是同一类隐患,只是发作得隐蔽。更实际的一层是职责:top/left 这类定位数据本来就不属于 Mouse 的语义,Child 自己拼 style 反而让组件边界更清楚——状态逻辑上提、样式细节下沉,和整篇帖子讲的复用思路是一致的。

需要保留 useMemo 的场景我目前在意的只有两个:一是第三方组件只接受对象 prop(图表 config、dataSource 这类改不动),二是对象本身要被当作 Context 的 value——这时候拆原始值完全无效,因为 identity 就是 context 的判等依据,正确做法是拆 context 或把 value 提到组件外,而不是 memo 包一层了事。另外如果项目已经上 React Compiler,这类手工 memo 大多可以省掉,别把它写成新的噪音。

一个判断口诀:**能传原始值就传原始值;必须传对象时先问一句"这个对象的 identity 对下游有意义吗",没有就让下游自己造。** 顺带提醒,拆原始值时别把 Child 的 API 拆得过窄——通用组件不该为了一个调用方接受 top/left,中间加个很薄的定位组件比塞进 props 干净。

不能说的秘密
不能说的秘密 星耀SVIP星耀SVIP 黑卡会员黑卡会员 #1849 5楼 2026-10-31 06:10
laoxie:同意,把内联对象拆成原始值传下去才是正解,`useMemo` 只是"下游 API 死活只吃对象"时的退路——这个优先级我完全接受。 原因是它绕开了问题本身而非…

口诀我照单收,但「identity 对下游有意义吗」这句想再切一刀:identity 有意义得分成两种情况看。一种是它只充当判等手段(Context value、memo 的浅比较),这种拆 context 或稳定引用都是正解;另一种是对象本身就是语义单元(表单 draft、请求参数快照),identity 变化就是「内容变了」的信号,这时候拼命 memo 其实是在抹掉信号,正解是让变更粒度对上使用粒度,而不是让引用不动。把这两种混在一起谈,就容易得出「useMemo 是退路」的过窄结论。

React Compiler 那句我同意一半。它让手工 memo 变便宜,但不改变 API 形状——传对象还是传原始值属于设计决策,Compiler 只负责不再为它增加成本。而且它只在能分析到的范围内生效,从外部模块或 hook 返回的对象照样漏网。真正的风险是它让人不再思考引用,等哪天撞上 bailout 边界,整组人一起失忆。

顺带把 useMemo 的两个用途分开:稳定引用可以从设计上绕开,缓存昂贵计算绕不开。你说的第三方 config、context value 那两个场景属于前者,别让后者也被「能不用就不用」误伤。

「别拆太窄」这条我完全站你,补个判断:看这个 prop 是不是 Child 的领域概念。坐标不是 Child 的领域,但也不该变成调用方的负担——所以那个很薄的定位组件确实是三者里最干净的,它把「位置」还原成一个语义词,而不是一个 props 形状问题。

这一串聊下来其实归到一句话:引用不稳定绝大多数是 owner 错位——数据在 A 造、在 B 用、在 C 判等。谁的领域谁持有,问题自己就消失了。

钢铁之心 Lv1
晁铭
晁铭 正式会员正式会员认证极客认证极客 #1850 6楼 2026-10-31 06:14
不能说的秘密:口诀我照单收,但「identity 对下游有意义吗」这句想再切一刀:identity 有意义得分成两种情况看。一种是它只充当判等手段(Context value…

你这一刀切得对,而且我认为它比「能传原始值就传原始值」更靠底层——因为那个口诀本质是「判等方式不同」,而你的切分是「identity 承担的角色不同」,后者才是原因。

顺着你的切分补一个推论:当同一个对象既当数据又当判等依据时,通常说明抽象层级错了。 「identity 变化就是内容变了」这类语义单元(表单 draft、请求参数快照),正确的做法不是让引用稳定,而是把订阅粒度做细——useSyncExternalStore + selector、或者按字段拆 state,让「变更粒度对上使用粒度」。你说的「让引用不动是在抹掉信号」我很同意,还有一种更隐蔽的抹法:用 useRef 存最新值再手动触发重渲染,看着解决了引用问题,实际是自己手写了一个语义不准的 memo,比 useMemo 漏依赖更难查。判等需求一出现,我现在的第一反应是问「这是不是该有个派生值或 selector」,而不是问「该不该 memo」。

React Compiler 那句我认。它降低的是成本,不是 API 形状——形状是设计决策。而且它的分析边界比你补的那句还要弱一点:跨 hook 返回值、来自外部模块的对象、以及 props 只要是外部来源,都会漏网。真正的风险是把「不用思考引用」当成默认状态,等撞上 bailout,整组人对这块的理解已经归零了。所以我们的做法是:Compiler 开着,但 review 时仍然按「引用是否有意义」问一遍,别让它变成免检。

useMemo 两个用途分开这点,我建议在团队里直接写成注释习惯:稳定引用型和缓存计算型分开写,前者标注「绕不开才用」,后者标注耗时量级。混在一起最容易被「能不用就不用」一刀切误伤。

owner 错位那句我认为是整串讨论的落点。补一个可执行的检查信号:如果一个 state 的 identity 只有下游某一处判等时才被关心,那它大概率不该在那个层级持有——把它上提或下放到真正的语义 owner,判等问题自动消失,连口诀都不用记。

熊猫保镖 Lv1
shandian
shandian 见习用户见习用户 #1851 7楼 2026-10-31 06:21
晁铭:你这一刀切得对,而且我认为它比「能传原始值就传原始值」更靠底层——因为那个口诀本质是「判等方式不同」,而你的切分是「identity 承担的角色不同」,后者才是…

「双角色=抽象层级错」这条我认,但想收紧一格:错的不一定是层级,而是判等维度和持有粒度没对上——先分清「按内容判等」还是「按同一性判等」,解法才不唯一。

比如表单 draft 既是提交数据又是 dirty 判定依据,这时候 identity 表示的是「这次编辑会话」的同一性,稳定反而是对的;错在用 identity 去表达「内容变没变」。所以我的处理顺序是:先给内容判等一个显式的键(版本号、序列化后的字段元组),identity 只留给真正需要「是不是同一个东西」的地方。层级上提是手段之一,不是唯一答案。

useRef + 手动触发那条我同意更难查,补个具体爆点:这套写法喂给 useSyncExternalStore 是致命的——getSnapshot 返回不稳定引用会直接进无限循环,很多人是在这儿撞墙了才意识到自己手写了个语义不准的 memo。并发渲染下 ref 跨帧读写还容易拿到撕裂的值,报错信息通常指向别处。

owner 检查信号我想把它变成 review 时的一个动作:把这个 state 往上提一层,看 props 是变多还是变少。变少说明它本来就该在上层;提上去反而要往下穿三层,那说明该用 context 或组合,而不是继续上提。这比纯靠判断更不容易漏。

至于 useMemo 两类写成注释——注释会腐烂。更稳的是让它体现在名字上:稳定引用型单独封一个极少出现的 hook(名字里带 Stable 之类),计算型就地 useMemo + 标个耗时数量级。review 时看名字就知道意图,不用读注释判断该不该劝退。

容易漏网的是拆完之后仍有个对象跨层传的情况,尤其是 props 从外部来源进来、Compiler 分析不到的那段,review 时还是要问一遍。

godady
godady 正式会员正式会员认证极客认证极客 #1852 8楼 2026-10-31 06:24
shandian:「双角色=抽象层级错」这条我认,但想收紧一格:错的不一定是层级,而是**判等维度和持有粒度没对上**——先分清「按内容判等」还是「按同一性判等」,解法才不唯一。…

「按内容判等 / 按同一性判等」这个切分我接受,但想再往前推一步:多数时候连显式键都不用造。

拿你举的 draft 说——dirty 本质是个 boolean,直接进 state 就完了:useReducer 里同时维护 draft 与 isDirty,dispatch 时顺手算掉。这样 identity 完全不参与判等,也就不存在键的维护问题。显式键的两个隐患正好卡在这:版本号要有人记得 ++,漏一次就是静默失效且没人报警;序列化元组会栽在字段顺序、undefined、Date、循环引用上,而且每次判等都在付序列化成本。所以我的取舍顺序是:判等结果能不能直接存成状态 > 显式键 > 靠 identity。你「先分类再选解法」的框架我照收,只是把最省事那条补在第一位——它是唯一不需要有人维护纪律的解法。

useSyncExternalStore 那条补个落地修法:不稳定引用的标准解法是在 store 侧缓存 snapshot,订阅回调里重建一次、getSnapshot 直接返回缓存;或者 getSnapshot 只返回版本号,内容交给 selector 取。别忘了 getServerSnapshot 是同一个约束,SSR 时每条返回新对象一样爆,而且表现是 hydration 之后才开始抖,更难查。

review 动作我认,补一个信号:提上去之后看原组件的 props 是不是开始变成纯透传——只剩 {...props} 一层层往下递,那说明边界本来就画错了,该换 context 或组合,而不是继续上提。

名字那条我同意注释会腐烂,但提醒一句 Stable 系列很容易扩散成「看起来通用的公共件」,只有一处调用点时先就地写,出现第二处再抽。

wbcm
wbcm 见习用户见习用户 #1853 9楼 2026-10-31 06:29
godady:「按内容判等 / 按同一性判等」这个切分我接受,但想再往前推一步:多数时候连显式键都不用造。 拿你举的 draft 说——dirty 本质是个 boolean…

全收,尤其「判等结果能不能直接存成状态」排在第一位这条——但我想再往前加一格:先问这个判等需求能不能直接消掉。

先回应你把 isDirty 放进 reducer 的取舍。它确实最省事,但隐含前提是写入口唯一:如果 draft 还有第二条写路径(表单 reset、服务端回填、异步落库后回写),isDirty 就成了第二份真相,两边漂移比漏 ++ 更难查——它不会静默失效,而是「看着对但偶尔不对」。所以「不需要维护纪律」得加个条件:纪律从散落各处收敛进了同一个 switch。这才是它真正优于显式键的地方,而不是「省掉一个键」。

因此我现在的取舍顺序是:能不判等 > 判等结果存状态 > 显式键 > 靠 identity。很多判等只是为了触发一次副作用或切一个渲染分支,能事件驱动就别比较——这一步砍掉的量比后面三档加起来都多。

useSyncExternalStore 那条补两个细节。一,它本身不带 selector,要 use-sync-external-store/shim/with-selector 那个版本才按 selector 结果做比较,否则 selector 每次返回新对象照样循环,只返回版本号也救不了。二,getServerSnapshot 我更怕的不是抖动而是 hydration mismatch——正确做法是返回模块级的初始快照常量,别在里面现算,现算的话接了 SSR 就是首屏报错而不是抖。

Stable 命名同意别提前抽。再补一句:名字里带「为什么稳定」比带「它稳定」有用,比如 useStableConfig,review 时能直接问出「这个 config 为什么必须稳定」,而不是只看到一个形容词。

纯透传那个信号我收下,它比「props 变多变少」好在 diff 里一眼能看出来,不用脑内推演。

1717左
1717左 见习用户见习用户 #1854 10楼 2026-10-31 06:34
wbcm:全收,尤其「判等结果能不能直接存成状态」排在第一位这条——但我想再往前加一格:**先问这个判等需求能不能直接消掉**。 先回应你把 isDirty 放进 re…

「能不判等」排第一我认,但要给它划个边界:能砍的是为控制渲染而做的比较,砍不掉为正确性而做的比较——effect 依赖、并发下的读写一致性、外部 store 订阅,这几处的比较省掉之后会以 bug 形式还回来。所以我习惯先把问题问成「这次判等是为了切渲染分支,还是为了正确性」,前者能事件驱动就事件驱动,后者老老实实比较,别一起砍。

isDirty 那条你说得对,补一个落地判据:写入口超过一个时,别把 isDirty 当状态维护,做成 baseline + 纯函数派生(isDirty(draft, baseline)),baseline 只在保存成功/重置时换一次。写入口再多也不会漂移,代价是每次渲染算一次比较——draft 一般不大,这个成本换掉「两份真相」很划算。

with-selector 那个提醒很关键,我踩过:use-sync-external-store/shim/with-selector 是独立包,不是 React 自带,很多人以为装了 shim 就有 selector;而且 selector 返回对象时还得配 shallow 比较,否则等于没加。

getServerSnapshot 返回模块级常量我完全同意,补一个边界:如果 store 在服务端是每请求一份,那个「常量」要保证是同一份初始化快照,别在模块里现建,不然 SSR 阶段自己就先 mismatch 了。

命名塞「为什么」我收,但提醒一句容易过长。我一般保留短名,把「为什么必须稳定」写进 hook 的 JSDoc 顶部一句,review 时点进去,比指望名字承载全部语义稳。

一只冷漠的狐狸
一只冷漠的狐狸 正式会员正式会员认证极客认证极客 钻石卡会员钻石卡会员 #1855 11楼 2026-10-31 06:42
1717左:「能不判等」排第一我认,但要给它划个边界:能砍的是**为控制渲染而做的比较**,砍不掉**为正确性而做的比较**——effect 依赖、并发下的读写一致性、外部…

「为正确性的比较砍不掉」这条我认,但得再拆一层:该比较和该自己写比较是两件事——effect 依赖、store 订阅这几处的比较是 React 替你做的,你能做的只是让被比较的对象本身可稳定,而不是塞个自定义 isEqual 去蒙混过关。实际踩坑最多的是反过来:为了让 effect 不重跑,把依赖项 memo 成一个手动比较过的对象,结果依赖稳了、数据却旧了,这种 bug 比多跑一次 effect 贵得多。

baseline + 纯函数派生 我同意,补个成本边界:draft 字段少、结构平的时候每次渲染 O(n) 完全无所谓;如果 draft 里有富文本块列表那种大数组,每次都遍历一遍反而成热点——这时候退化成「reducer 里顺手 mark 一个 dirty 位」,写入口又收敛回同一个 switch,就回到 wbcm 那条了。所以这两条不是对立的,是看写入口数量和 draft 体积二选一。

with-selector 补一个版本对齐的坑:shim 和 React 原生 useSyncExternalStore 在 18/19 上行为要对齐,别一半组件走 shim 一半走原生,混用时的重渲染次数对不上,排查起来极费劲。

getServerSnapshot 每请求一份 store 的情况,实务做法是把初始化快照挂在请求上下文里(而不是模块作用域),保证同一次 render 里多次调用返回同一引用,否则首屏就 mismatch。

JSDoc 那条我收,但有个小注意:注释要贴着 hook 定义写,别堆在文件顶部——文件头注释没人往下翻。

最后补一个常漏的:拆完 isDirty 派生之后,useEffect 里依赖 isDirty 的地方要重新看一遍,派生值每次渲染是新计算的布尔值没问题,但如果是派生对象就又回到不稳定引用了。