首页 / 文章

利用四种命名过的React架构模式来操控AI编码代理

学习四种 React 模式(片段组合、自定义钩子、多态组件、还原器),以及如何在提示词中为它们命名,从而获得更整洁的 AI 生成代码。

发布者:REACTAPP.TOP阅读约 12 分钟2819 词

来源: How I Get AI to Write Cleaner React Code with 4 Architecture Patterns

像 Claude 或 OpenCode 这样的 AI 编程工具只需几条提示就能生成可用的 React 功能模块,生成的代码通常能满足需求。但其弱点在于结构:生成的组件往往有数百行代码,将标记、数据、效果处理和事件处理器混放在同一个文件中。要求工具“使其模块化”往往只能得到针对特定页面的代码片段,而非真正可复用的组件。本文介绍了四种能够改善这种结构的模式,更重要的是,还展示了如何在提示中明确指定这些名称,从而让工具按照你的意图构建架构,而非自行选择。

为何可运行的代码并不等同于良好的架构

一个常见的代码整洁性准则是让 React 组件的行数保持在大约100行左右,实际上限可定为200行。这只是一个经验性建议而非硬性规定,但当文件行数达到500行或更多时,若不加干预,AI生成的代码质量就会下降。

显而易见的解决办法是要求智能体将代码拆分。它通常会照做,把用户界面拆分成多个较小的组件,并放入专门用于该页面的文件夹中。这些小组件虽然规模变小了,但仍然只与单一功能相关,很少能在其他地方使用。这仅仅是拆分,并非真正的模块化。

对于小型项目来说,这几乎无关紧要。但对于需要多年演进的软件而言,这意味着每一次改动都会影响到那些原本并未设计为可重用或可扩展的代码。解决之道并非停止使用人工智能,而是要了解能让 React 代码库实现扩展的设计模式,并用这些模式来指导智能体。代码生成的成本正在降低,而如何构建代码的决策仍需由人来做。若想更深入地了解人工智能输出为何常常忽视整洁代码原则,可参阅当人工智能编写你的 React 应用却忽视整洁代码原则时。

模式1:将各部分拆分为四个组件

向开发人员请求一个UI组件时,他们通常会将容器、标题、列表渲染以及各个项目整合到一个组件中。随着应用规模的扩大,这种重复现象会变得十分明显,因为几乎在所有地方都会出现相同的结构:博客、在线商店、作品集、目录、SaaS产品着陆页以及控制面板。

想象一下一个电商首页,上面有新品、热门产品和热销产品这几个板块。每个板块看起来都一模一样,只有展示的数据不同。其实每个板块都可以拆分为四个部分,各自承担不同的功能:

  • 板块容器
  • 板块标题
  • 卡片列表
  • 单个卡片

板块本身

板块是父组件,它通过属性接收数据并传递给子组件,因此它了解组件的构成结构,但并不掌握具体的样式细节。

export function ProductSection({ title, subtitle, products }) {
  return (
    <section>
      <SectionHeading
        title={title}
        subtitle={subtitle}
      />

      <ProductList products={products} />
    </section>
  )
}

标题部分

专用的标题组件将标题和副标题的样式集中管理,这样应用中的每个部分都能保持一致,无需任何人重新创建标记结构。

export function SectionHeading({ title, subtitle }) {
  return (
    <div>
      <h2>{title}</h2>
      <p>{subtitle}</p>
    </div>
  )
}

列表

该列表只有一个职责:遍历集合并为每个项目渲染一张卡片,同时使用固定的key值。

export function ProductList({ products }) {
  return (
    <div className="grid">
      {products.map(product => (
        <ProductCard
          key={product.id}
          product={product}
        />
      ))}
    </div>
  )
}

请注意,它并不知道产品的长什么样。它只知道如何布局列表,这正是之后能轻松用于其他类型卡片的原因。

卡片

卡片仅显示一个项目,除此之外 nothing else。

export function ProductCard({ product }) {
  return (
    <div>
      <img src={product.image} />
      <h3>{product.title}</h3>
      <p>${product.price}</p>
    </div>
  )
}

它唯一关心的就是展示效果。在实际代码中,还会为图片添加有意义的alt属性,而这个简化示例并未包含该部分。

两种方法的比较

如果使用默认设置,该组件往往会生成类似这样的结果:

function Products({ products }) {
  return (
    <section>
      <h2>Latest Products</h2>

      <div className="grid">
        {products.map(product => (
          <article key={product.id}>
            <img src={product.image} />
            <h3>{product.title}</h3>
            <p>${product.price}</p>
          </article>
        ))}
      </div>
    </section>
  )
}

从技术层面来看并没有问题。问题会在后续出现,当其他页面也需要相同的标题样式、卡片设计或网格布局时。到那时,要么重复编写标记代码,要么从因重复而逐渐膨胀的代码库中提取组件。

通过将内容拆分为四个组件,页面本身就简化为数据和结构两部分:

function HomePage() {
  return (
    <>
      <ProductSection
        title="Hot Products"
        subtitle="Most popular products right now"
        products={hotProducts}
      />

      <ProductSection
        title="New Products"
        subtitle="Recently added to our store"
        products={newProducts}
      />

      <ProductSection
        title="Trending Products"
        subtitle="What everyone is buying"
        products={trendingProducts}
      />

      <ProductSection
        title="Discounted Products"
        subtitle="Best deals this week"
        products={discountedProducts}
      />
    </>
  )
}

文件结构保持简洁且易于预测:

components/
│
├── SectionHeading.tsx
├── ProductCard.tsx
├── ProductList.tsx
└── ProductSection.tsx

现在每个构建模块都有明确的用途。相同的标题可以在任何部分使用,新增的产品行只需再添加一个ProductSection元素,而全新的页面也可以由这些相同的组件组合而成。

推动其实现

不要让智能体直接创建产品页面,而应明确说明结构:“这里使用4组件UI模式:一个ProductSection,它由SectionHeading和ProductList组成,ProductList负责渲染ProductCard元素,每个组件仅承担一项功能。”这样一句话就能改变输出的结构,使得结果更易于阅读、复用和维护。

模式2:将逻辑移至自定义钩子中

大多数React开发者都了解自定义钩子,但当让智能体来编写代码时却很容易被忽略。当需要创建新页面时,智能体通常会将状态、副作用、API调用和事件处理程序都放在组件内部。经过几次迭代后,由于UI与逻辑混杂在一起,文件行数往往会超过300行。

将逻辑放在组件内部有时是可行的。但一旦其他地方也需要相同的逻辑,或者该逻辑开始影响组件的渲染效果,将其提取为钩子通常是更好的选择。常见的候选包括:

  • useDebounce(),用于在发送请求前延迟搜索输入
  • useApi(),用于处理数据获取以及加载和错误状态
  • useLocalStorage(),用于在浏览器中持久化状态
  • useClickOutside(),用于在用户点击其他位置时关闭下拉菜单或模态框
  • usePagination(),用于管理页面状态
  • useToggle(),用于处理诸如侧边栏是否展开之类的布尔状态

组件内的搜索逻辑

以产品搜索框为例。典型的实现方式会在状态中同时保存原始查询字符串和去抖后的版本,使用一个带有定时器的效应来更新去抖后的值,再使用另一个效应在该值发生变化时进行数据获取:

function SearchProducts() {
  const [query, setQuery] = useState("");
  const [debouncedQuery, setDebouncedQuery] = useState("");

  useEffect(() => {
    const timer = setTimeout(() => {
      setDebouncedQuery(query);
    }, 500);

    return () => clearTimeout(timer);
  }, [query]);

  useEffect(() => {
    if (!debouncedQuery) return;

    fetch(`/api/products?search=${debouncedQuery}`);
  }, [debouncedQuery]);

  return (
    <input
      value={query}
      onChange={(e) => setQuery(e.target.value)}
      placeholder="Search products..."
    />
  )
}

这种做法可行。但如果三个界面都需要相同的去抖功能,要么重复编写三遍,要么日后花费更多时间和资源来提取该逻辑。此外还有两点关于稳定性的建议:在将查询字符串放入URL之前应先用encodeURIComponent进行编码,而且真正的数据获取逻辑需要能够处理响应顺序混乱的情况,例如在效应清理阶段使用AbortController。

提取去抖钩子

定时器逻辑会被移到一个独立的钩子中,该钩子接收一个值和延迟时间作为参数,然后返回去抖后的值:

// hooks/useDebounce.ts

export function useDebounce(value, delay = 500) {
  const [debouncedValue, setDebouncedValue] = useState(value)

  useEffect(() => {
    const timer = setTimeout(() => {
      setDebouncedValue(value)
    }, delay)

    return () => clearTimeout(timer)
  }, [value, delay])

  return debouncedValue
}

清理函数会在延迟时间结束前值发生变化时立即清除待处理的定时器,这正是它具备防抖功能而非单纯延迟功能的缘故。如果在调用方修改了delay值,将delay包含在依赖数组中就能确保钩子的正确性。

这样一来,该组件便回归到其真正的用途:

function SearchProducts() {
  const [query, setQuery] = useState("")

  const debouncedQuery = useDebounce(query)

  useEffect(() => {
    if (!debouncedQuery) return

    fetch(`/api/products?search=${debouncedQuery}`)
  }, [debouncedQuery])

  return (
    <input
      value={query}
      onChange={(e) => setQuery(e.target.value)}
    />
  );
}

渲染逻辑留在组件内部,而防抖功能则由钩子处理,两者各司其职。

何时该使用钩子

并非每个useState()调用都需要专属的钩子。适合使用钩子的情形包括:

  • 相同的逻辑出现在或将来会出现在多个组件中;
  • 组件变得过于复杂;
  • 某个功能需要结合多个useState()调用、效应或事件处理程序。
  • API调用、表单、搜索、分页、认证或存储相关代码正在扰乱渲染逻辑;
  • 你希望独立于UI来测试这些逻辑。
  • 明确提出要求

    由于代码生成工具默认会将逻辑直接写在组件中,因此应明确说明:“将此功能中的可复用逻辑放入自定义钩子中,例如useApi()、useDebounce()或useLocalStorage(),让组件仅负责处理标记语法。”这一原则在所有四种模式中都适用:你提供的架构背景信息越充分,生成的代码质量就越高。

    模式3:使用一个多态组件而非大量相似组件

    可复用组件的目标并非增加其数量,而是避免重复。代码生成工具常常会生成类似这样的组件系列:

    Button.tsx
    LinkButton.tsx
    IconButton.tsx
    

    这些组件几乎拥有完全相同的样式。不同之处在于它们渲染的元素:有时是真正的<button>,有时是锚点链接,有时是路由链接,有时则是仅包含图标的控件。多态模式用一个组件替代了整个系列,该组件可以根据实际情况渲染为所需的任意元素。

    重复的版本

    典型生成的组件对如下所示:

    // Button.tsx
    export function Button({ children }) {
      return (
        <button className="btn">
          {children}
        </button>
      )
    }
    
    // LinkButton.tsx
    export function LinkButton({ href, children }) {
      return (
        <a href={href} className="btn">
          {children}
        </a>
      )
    }
    

    样式之所以重复,只是因为底层的HTML元素发生了变化。

    TypeScript中的多态Button

    文本版本允许使用可选的as属性,其类型为通用的ElementType,默认值为"button"。其余属性则根据as所指向的元素或组件,通过ComponentPropsWithoutRef获取,同时会省略as本身以避免冲突。

    type ButtonProps<E extends ElementType = "button"> = {
      as?: E
      children: ReactNode
      variant?: "primary" | "outline"
    } & Omit<React.ComponentPropsWithoutRef<E>, "as">
    
    const Button = <E extends ElementType = "button">({
      as,
      children,
      variant = "primary",
      ...rest
    }: ButtonProps<E>) => {
      const Component = as || "button"
    
      return (
        <Component {...rest}>
          {children}
        </Component>
      )
    }
    

    由于属性类型遵循 E 规范,传递 as="a" 可使 href 成为有效值,而传递路由器中的 Link 对象则可使其 to 属性生效,所有这些操作都会经过类型检查。示例已简化:虽然 variant 被接受但尚未应用,完整版本会将其映射到类名,并合并任何传入的 className 值。在渲染原生按钮时,为 type="button" 设置默认值也很重要,这样就能避免意外提交周围的表单。

    现在的同一个组件可以覆盖所有情况:

    <Button>
      Save
    </Button>
    
    <Button
      variant="outline"
    >
      Cancel
    </Button>
    
    <Button
      as="a"
      href="/pricing"
    >
      Pricing
    </Button>
    
    <Button
      as={Link}
      to="/dashboard"
    >
      Dashboard
    </Button>
    
    <Button
      as="button"
      onClick={handleDelete}
    >
      Delete
    </Button>
    

    保存、取消、价格链接、控制面板路由以及删除操作都采用相同的设计;只有渲染的元素或行为会发生变化。

    何时使用它

    一个简单的测试:如果用户界面保持不变,但底层元素发生了变化,那么该组件就是良好的多态化候选者。常见示例包括:

    • 有时充当链接的按钮
    • 能够渲染 h1、h2、p 或 span 的排版组件
    • 有时是 article,有时又是可点击链接的卡片
    • 能够以 section、div 或 main 形式呈现的布局容器

    这并非某种特立独行的偏好。Radix UI、Chakra UI 和 Flexy UI 等组件库在构建灵活的设计系统时也严重依赖这一理念。

    如何实现它

    开发人员往往为每一个细微的变化都创建一个新的组件,因此应明确告知他们遵循这样的模式:“编写一个多态的 Button 组件,该组件接受 as 属性,并能够渲染原生的 button、a 标签或 React Router 的 Link;在同一个组件内部处理不同的样式变体和尺寸,而非创建新的组件。”这样就能减少组件数量、避免重复代码,并形成更为统一的设计体系。

    模式4:使用 reducer 统一管理相关状态

    这可能是开发人员最容易忽视的模式。他们的惯常做法是为每个状态值单独使用 useState(),对于简单组件来说这没问题。但随着功能规模的扩大,最终就需要同时管理五六个相互关联的状态:

    const [loading, setLoading] = useState(false);
    const [products, setProducts] = useState([]);
    const [error, setError] = useState(null);
    const [page, setPage] = useState(1);
    const [search, setSearch] = useState("");
    const [sortBy, setSortBy] = useState("latest");
    

    一旦涉及到分页、过滤、排序、加载和错误处理等功能,通常一个用户操作就需要同时更新其中多个功能,这样一来组件结构就会变得难以理解。

    协调的设置器

    以下是使用独立状态时的 fetch 实现方式:

    setLoading(true)
    
    try {
      const data = await fetchProducts()
    
      setProducts(data)
      setError(null)
    } catch {
      setError("Something went wrong")
    } finally {
      setLoading(false)
    }
    

    对于同一个逻辑事件,会有多个设置器同时被触发。这种集中在一起的情况正是表明使用 reducer 能更好地表达该逻辑的典型迹象。

    将状态转换建模为动作

    通过 reducer,你可以一次性描述状态的结构,并列出它可能经历的状态转换。如果你使用过 Redux,就会对这种语法感到熟悉:

    const initialState = {
      loading: false,
      products: [],
      error: null,
    };
    
    function reducer(state, action) {
      switch (action.type) {
        case "FETCH_START":
          return {
            ...state,
            loading: true,
          };
    
        case "FETCH_SUCCESS":
          return {
            loading: false,
            products: action.payload,
            error: null,
          };
    
        case "FETCH_ERROR":
          return {
            ...state,
            loading: false,
            error: action.payload,
          };
    
        default:
          return state;
      }
    }
    

    FETCH_START用于设置加载标志,FETCH_SUCCESS用于存储产品信息并同时清除加载状态和错误状态,而FETCH_ERROR则用于记录错误信息。成功时会返回一个全新的完整对象;如果后续在状态中添加了如page或sortBy之类的字段,记得也要使用...state将其包含进去,否则这些值将会丢失。

    该组件会通过派发动作来更新状态,而非直接调用设置器函数:

    const [state, dispatch] = useReducer(
      reducer,
      initialState
    )
    
    dispatch({ type: "FETCH_START" })
    
    dispatch({
      type: "FETCH_SUCCESS",
      payload: products,
    })
    

    现在的每次状态更新都通过同一个函数来完成。要了解该功能的运行方式,只需查看还原器即可,无需在组件中四处查找setState调用;由于还原器是纯函数,因此很容易进行单元测试。

    何时适合使用还原器

    并非所有组件都适用还原器。在以下情况下使用还原器会更有成效:

    • 多个状态变化同时发生;
    • 该功能包含许多不同的操作;
    • 状态转换越来越难以追踪;
    • 该功能为购物车、多步骤向导、登录流程、大型表单或复杂的控制面板。

    对于模态框或切换按钮,使用普通的 useState() 仍然是更好的选择。

    引导使用

    如果没有指导,智能体几乎总会生成一堆 useState() 调用。明确要求所需的结构:“使用 useReducer 来实现:定义带名称的操作类型,将所有状态变化放在 reducer 函数中,让组件仅负责渲染和发送动作。” 功能越复杂,这种方式的效果就越明显。

    关键要点

    • AI 智能体能够可靠地生成可运行的 React 代码,但往往会采用单体组件、内联逻辑、几乎相同的变体以及分散的状态管理方式。
    • 有四种模式可以解决这些默认问题:模块组合、自定义钩子、多态组件以及还原器。
    • 每种模式都有明确的触发条件;应在触发条件出现时再应用该模式,而非凭直觉行事。
    • 为模式以及提示词中需要使用的元素命名,是让智能体生成更优架构的最简单方法。
    • 代码生成技术每年都在进步,但如何构建代码结构仍是开发者的职责,而掌握这些模式正是实现这一目标的关键。