组件只是闪亮的钩子

话题

在前端面试的过程中,我们可能会被问到一个问题:"组件和钩子有什么区别?"

面试官可能想要的答案是 "组件是 UI 的构建块,而钩子是 React 提供的用于管理状态和副作用的实用函数😴"

如果你的目标是找工作,这个答案还算令人满意。但如果你想给人留下深刻印象,更大胆的答案是:"没有区别🔥"

首先,什么是组件?

非常技术性的答案是,它是一个返回 ReactNode 的函数,它是任何可以在屏幕上呈现的东西(包括 JSX、字符串、数字、空值等等)。

以下是有效的 React 组件

jsx 复制代码
const MyComponent = () => {
  return "Hello World!";
};

组件可以使用 props 来渲染动态输入...

jsx 复制代码
const MyComponent = ({ name, onClick }) => {
  return <button onClick={onClick}>Hello {name}!</button>;
};

并使用钩子实现有状态的行为。

jsx 复制代码
const MyCounter = () => {
  const [count, increment] = useReducer((state) => state + 1, 0);

  return <button onClick={increment}>Counter {count}</button>;
};

添加一些额外的操作,这时候我们就可以得到一个使用了 React HooksReact 组件

jsx 复制代码
const MyFancyCounter = () => {
  const [count, increment] = useReducer((state) => state + 1, 0);

  // Add fancy styles every time `count` doubles
  const fancyClass = useMemo(() => {
    const orderOfMagnitude = Math.floor(Math.log2(count));
    let style = {};

    if (orderOfMagnitude <= 0) return "";

    switch (orderOfMagnitude) {
      case 1:
        return "fancy-1";
      case 2:
        return "fancy-2";
      case 3:
        return "fancy-3";
      default:
        // Beyond 255
        return "fanciest";
    }
  }, [count]);

  return (
    <button onClick={increment} className={fancyClass}>
      Counter {count}
    </button>
  );
};

无论我们在实现中做什么,我们都可以通过将其混合到 JSX 中将其用作另一个组件中的黑盒

jsx 复制代码
const FancyCounterApp = () => {
  return (
    <div>
      <h1>My Counter App</h1>
      <MyFancyCounter />
    </div>
  );
};

其次,什么是Hooks?

Hooks 是 React 内置函数,用于管理状态和效果。你可以通过将 React 提供的 hooks 包装在函数中来创建新的 hooks。

钩子可以接受任何输入并返回任何值。唯一的限制是(Hooks)钩子的规则,这些规则规定钩子不能有条件地调用。关键的是,这些规则是传递性的 ,这意味着任何调用钩子的东西本身都是钩子,并且必须遵循这些规则,直到它在组件中使用为止。

故事的关键就在这里:鉴于组件可传递地继承钩子的所有限制(钩子的规则),并且其输出更加严格(只能返回 ReactNodes ):组件确实是钩子的子类型 。任何组件都可以用作钩子

举个例子

我们之前最复杂的组件可以毫不费力地变成一个钩子:

jsx 复制代码
// It's prefixed by `use` now, its a hook!
const useFancyCounter = () => {
  const [count, increment] = useReducer((state) => state + 1, 0);

  // Add fancy styles every time `count` doubles
  const fancyClass = useMemo(() => {
    const orderOfMagnitude = Math.floor(Math.log2(count));
    let style = {};

    if (orderOfMagnitude <= 0) return "";

    switch (orderOfMagnitude) {
      case 1:
        return "fancy-1";
      case 2:
        return "fancy-2";
      case 3:
        return "fancy-3";
      default:
        // Beyond 255
        return "fanciest";
    }
  }, [count]);

  return (
    <button onClick={increment} className={fancyClass}>
      Counter {count}
    </button>
  );
};

// use useFancyCounter hooks to get ReactNode
const FancyCounterApp = () => {
  const myFancyCounter = useFancyCounter();  // ⭐⭐⭐
  
  return (
    <div>
      <h1>My Counter App</h1>
      {myFancyCounter}  // ⭐⭐⭐
    </div>
  );
};

到目前为止,这看起来似乎是无趣的语义,但这个练习有一个实际的一面。

无头组件

如前所述,组件的显著特征是它返回一个 ReactNode 。就 API 而言, ReactNode 是一个黑盒。由于 useFancyCounter() 仍返回一个 ReactNode ,因此其内部状态是不透明且不可使用的。

这个限制很重要,因为它阻止 FancyCounterApp 轻松实现以下功能:

  • {count} 渲染到 <button> 之外,在任意的React组件中使用
  • 添加另一个按钮以将计数增加 2
  • 一旦计数达到 10,就执行一些事件

由于 useFancyCounter 是一个钩子,它不是返回 ReactNode ,而是可以公开其事件处理程序和状态,在使用这个hooks的组件中可以利用它们获得更灵活的输出:

jsx 复制代码
// It's prefixed by `use` now, its a hook!
const useFancyCounter = () => {
  const [count, increment] = useReducer((state) => state + 1, 0);

  // Add fancy styles every time `count` doubles
  const fancyClass = useMemo(() => {
    const orderOfMagnitude = Math.floor(Math.log2(count));
    let style = {};

    if (orderOfMagnitude <= 0) return "";

    switch (orderOfMagnitude) {
      case 1:
        return "fancy-1";
      case 2:
        return "fancy-2";
      case 3:
        return "fancy-3";
      default:
        // Beyond 255
        return "fanciest";
    }
  }, [count]);

  return { fancyClass, increment, count };
};

useFancyCounter 现在是一个无头组件 ,因为它实现了所有与组件相关的功能(事件处理、状态管理、样式),但允许其调用者将其提炼为 JSX,而不是返回预先烘焙的 ReactNode

无头组件是实现组件级逻辑的钩子这种模式的灵活性是无与伦比的,没有基于组合的解决方案所带来的样板。

jsx 复制代码
const FancyCounterApp = () => {
  const { fancyClass, increment, count } = useFancyCounter();

  // performing some event once the count reaches 10
  useEffect(() => {
    if (count === 10) {
      showFireworks();
    }
  }, [count]);

  return (
    <div>
      <h1>My Counter App</h1>
      {/* 👇⭐⭐⭐👇*/}
      {/* rendering the `{count}` outside of the `<button>` */}
      <h2>Count is at {count}</h2>
      <button
        onClick={increment}
        // extend button styling
        className={clsx(fancyClass, "some-other-class")}
      >
        Increment by 1
      </button>
      {/* adding another button to increment the count by two */}
      <button
        onClick={() => {
          increment();
          increment();
        }}
      >
        Increment by 2
      </button>
    </div>
  );
};

单元测试

无头组件有效地将视图与视图模型分离,使它们可以单独测试。

jsx 复制代码
// Testing full component
describe("FancyCounterApp", () => {
  it("increments", () => {
    render(<FancyCounterApp />);

    const incrementBtn = screen.findByLabel("Increment by 1");

    fireEvent.click(incrementBtn);
    fireEvent.click(incrementBtn);
    fireEvent.click(incrementBtn);

    expect("Count is at 3").toBeInDocument();
  });
});

// Testing component logic only
describe("useFancyCounter", () => {
  it("increments", () => {
    const { result } = renderHook(() => useFancyCounter());

    act(() => result.current.increment());
    act(() => result.current.increment());
    act(() => result.current.increment());

    expect(result.current.count).toBe(3);
  });
});

组合 + 无头组件

虽然无头组件通常解决与组合相同类型的挑战,但这两种模式是兼容的。

如果你希望在将控制器作为独立单元的同时实现控制反转,则此组合模式非常有用。附加的好处是能够在使用上下文的组件和不使用上下文的组件之间重用视图模型逻辑。

目前也有比较成熟的无头组件库,比如:

React Aria

TanStack

Headless UI

小练习

咱们也简单写一个组合 + 无头组件吧

分别创造以下组件:

  • 视图组件:CountFireworks
  • 事件组件:IncrementBtnDecrementBtn
  • 顶层组件:FancyCounter
  1. 首先我们简单改造一下上述内容的 useFancyCounter Hooks
tsx 复制代码
import { useMemo, useReducer } from "react";

export function useFancyCounter() {
  const [count, dispatch] = useReducer((state, action) => {
    switch (action.type) {
      case 'increment':
        return state + 1;
      case 'decrement':
        return Math.max(0, state - 1);
      default:
        return state;
    }
  }, 0);

  const increment = () => dispatch({ type: 'increment' });
  const decrement = () => dispatch({ type: 'decrement' });

  // 每次计算添加一些样式
  const fancyClass = useMemo(() => {

    switch (count) {
      case 1:
        return "text-red-400";
      case 2:
        return "text-green-400";
      case 3:
        return "text-blue-400";
      default:
        return "";
    }
  }, [count]);

  return { fancyClass, increment, decrement, count };
};
  1. 写一下FancyCounter 顶层组件
tsx 复制代码
interface FancyCounterProps {
  children: ReactNode
}

interface FancyCounterContextType {
  count: number
  increment: () => void
  decrement: () => void
  fancyClass: string
}

// 创建Context 通过 Context.Provider 向下派发数据
const FancyCounterContext = createContext<FancyCounterContextType | null>(null)

const useFancyCounterContext = () => {
  const context = useContext(FancyCounterContext)
  if (!context) {
    throw new Error('useFancyCounterContext must be used within FancyCounter')
  }
  return context
}

const FancyCounter = ({ children }: FancyCounterProps) => {
  const { count, increment, decrement, fancyClass } = useFancyCounter()
  
  return (
    <FancyCounterContext.Provider value={{ count, increment, decrement, fancyClass }}>
      {children}
    </FancyCounterContext.Provider>
  )
}
  1. 再写一下视图组件:CountFireworks
tsx 复制代码
const Count = () => {
  const { count, fancyClass} = useFancyCounterContext()
  return <h2 className={clsx("text-2xl font-bold mb-4", fancyClass)}>Count is at {count}</h2>
}


const Fireworks = (props: { at: number }) => {
  const { count } = useFancyCounterContext()

  if (count === props?.at) {
    return <span>🎆 Fireworks!</span>
  }
  
  return null
}
  1. 以及事件组件:IncrementBtnDecrementBtn
tsx 复制代码
interface IncrementBtnProps {
  children: ReactNode
  by?: number
}

const IncrementBtn = ({ children, by = 1 }: IncrementBtnProps) => {
  const { increment } = useFancyCounterContext()
  
  return (
    <button
      onClick={() => {
        for (let i = 0; i < by; i++) {
          increment()
        }
      }}
      className={clsx(
        "px-4 py-2 rounded-lg font-medium transition-colors duration-200 w-fit",
        "bg-blue-500 hover:bg-blue-600 text-white",
        "focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2",
      )}
    >
      {children}
    </button>
  )
}

interface DecrementBtnProps {
  children: ReactNode
  by?: number
}

const DecrementBtn = ({ children, by = 1 }: DecrementBtnProps) => {
  const { decrement } = useFancyCounterContext()
  
  return (
    <button
      onClick={() => {
        for (let i = 0; i < by; i++) {
          decrement()
        }
      }}
      className={clsx(
        "px-4 py-2 rounded-lg font-medium transition-colors duration-200 w-fit no",
        "bg-red-500 hover:bg-red-600 text-white",
        "focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2",
      )}
    >
      {children}
    </button>
  )
}
  1. 最后再组装到一起
tsx 复制代码
FancyCounter.Count = Count
FancyCounter.IncrementBtn = IncrementBtn
FancyCounter.DecrementBtn = DecrementBtn
FancyCounter.Fireworks = Fireworks

export { FancyCounter } 
  1. 最最后再试着使用一下
tsx 复制代码
import { FancyCounter } from '../components/FancyCounter'

export default function HeadlessDemo() {
  return (
    <div className="min-h-screen bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
      <div className="max-w-md mx-auto bg-white rounded-xl shadow-md overflow-hidden md:max-w-3xl p-8">
        <FancyCounter>
          <FancyCounter.Fireworks at={10} />
          <div className="space-y-6">
            <h1 className="text-3xl font-bold text-gray-900">My Counter App</h1>
            <FancyCounter.Count />
            <div className="flex space-x-4">
              <FancyCounter.IncrementBtn by={1}>
                Increment by 1
              </FancyCounter.IncrementBtn>
              <FancyCounter.IncrementBtn by={2}>
                Increment by 2
              </FancyCounter.IncrementBtn>
              <FancyCounter.DecrementBtn by={1}>
                Decrement by 1
              </FancyCounter.DecrementBtn>
              <FancyCounter.DecrementBtn by={2}>
                Decrement by 2
              </FancyCounter.DecrementBtn>
            </div>
          </div>
        </FancyCounter>
      </div>
    </div>
  )
} 

芜湖莫得问题

结论

人们很容易忽视组件和钩子并不是独立的概念,因为它们总是表现为不同的原语。一旦被接受,无头组件就会成为一种明显的模式,能够实现更高的逻辑可重用性、灵活性和卓越的测试模式。

引用文章:Components Are Just Sparkling Hooks

相关推荐
张愚歌1 天前
快速上手Leaflet:轻松创建你的第一个交互地图
前端
甜瓜看代码1 天前
面试---h5秒开优化
面试
唐某人丶1 天前
教你如何用 JS 实现 Agent 系统(3)—— 借鉴 Cursor 的设计模式实现深度搜索
前端·人工智能·aigc
看到我请叫我铁锤1 天前
vue3使用leaflet的时候高亮显示省市区
前端·javascript·vue.js
南囝coding1 天前
Vercel 发布 AI Gateway 神器!可一键访问数百个模型,助力零门槛开发 AI 应用
前端·后端
AI大模型1 天前
前端学 AI 不用愁!手把手教你用 LangGraph 实现 ReAct 智能体(附完整流程 + 代码)
前端·llm·agent
zcychong1 天前
如何让A、B、C三个线程按严格顺序执行(附十一种解)?
java·面试
小红1 天前
网络通信基石:从IP地址到子网划分的完整指南
前端·网络协议
一枚前端小能手1 天前
🔥 前端储存这点事 - 5个存储方案让你的数据管理更优雅
前端·javascript
willlzq1 天前
深入探索Swift的Subscript机制和最佳实践
前端