组件只是闪亮的钩子

话题

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

面试官可能想要的答案是 "组件是 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

相关推荐
阿珊和她的猫3 小时前
v-scale-scree: 根据屏幕尺寸缩放内容
开发语言·前端·javascript
PAK向日葵5 小时前
【算法导论】PDD 0817笔试题题解
算法·面试
加班是不可能的,除非双倍日工资7 小时前
css预编译器实现星空背景图
前端·css·vue3
wyiyiyi7 小时前
【Web后端】Django、flask及其场景——以构建系统原型为例
前端·数据库·后端·python·django·flask
gnip8 小时前
vite和webpack打包结构控制
前端·javascript
excel8 小时前
在二维 Canvas 中模拟三角形绕 X、Y 轴旋转
前端
阿华的代码王国9 小时前
【Android】RecyclerView复用CheckBox的异常状态
android·xml·java·前端·后端
一条上岸小咸鱼9 小时前
Kotlin 基本数据类型(三):Booleans、Characters
android·前端·kotlin
Jimmy9 小时前
AI 代理是什么,其有助于我们实现更智能编程
前端·后端·ai编程
ZXT9 小时前
promise & async await总结
前端