倒反天罡:AI 友好的前端组件设计

119 阅读10分钟

最近在做 AI 前端代码生成,之前用 Antd 时候发现生成的代码质量不错,但因为内部沉淀了大量更符合业务规范的组件,于是借助 MCP 把内部组件信息提供给大模型。

结果虽然使用了内部组件,但即使用了 Claude 3.7 后生成的代码质量仍旧很低,综合分析了一下发现我们内部的组件对 AI 实在不友好,对照 Antd 后总结了几条 AI 友好的前端组件设计规则

使用原子化 CSS

TailwindCSS 究竟适合大型项目还是个人项目,使用 TailwindCSS 之后代码可维护性究竟是变好还是变坏,这些目前业内还有争议,但这些争论是以人来编写、维护代码为前提展开的,如果聚焦到 AI Code TailwindCSS 肯定是更友好的

一段简单的 HTML,开发者看到之后需要结合 CSS 文件才能了解其样式,如果需要对 container 样式做调整,考虑到全局影响大部分开发者是不敢轻易动 .container内容的

<div class="container">
  <button class="primary-btn">提交</button>
</div>
  • container 的样式可能涉及布局、背景色、边距等,AI 需要结合 CSS 文件才能理解具体效果。
  • primary-btn 的颜色可能依赖父级类(如 .dark .primary-btn),AI 难以直接推断样式规则。

而使用原子化的 CSS 后情况会有所改变,AI 会更擅长

<div class="container mx-auto p-4">
  <button class="bg-blue-500 hover:bg-blue-600 text-white font-bold py-2 px-4 rounded">提交</button>
</div>
  • 原子化 CSS 类名遵循单一职责原则,每个类名仅代表一种样式属性,修改只会影响当前 DOM,没有额外副作用。
  • 原子化 CSS 没有业务属性(尤其是人命名的还可能有歧义),降低了 AI Code 上下文依赖,提高了理解代码的效率。
  • 原子化 CSS 类名自身包含了足够的信息,AI 无需查看其它代码就能理解其样式效果,显著降低 token 数。
  • 由于原子化 CSS 类名有固定的命名规则,AI 可以依据这些规则来预测和推断新的样式的效果。

通用属性命名保持一致

如果我们提供了一个组件库,那么通用属性需要使用统一命名,降低开发者学习和 AI 训练成本

常规组件

属性名称描述适用组件备注
disabled设置组件为禁用状态Button, Input, Select, Checkbox, Radio, Switch 等通常用于控制用户交互,防止操作
loading显示加载状态Button, Spin, Table 等用于指示正在进行的操作或加载过程
visible控制组件的可见性Modal, Tooltip, Popover, Drawer 等用于显示或隐藏弹出层组件
onClick点击事件的回调函数Button, Icon, Card, Dropdown 等处理用户的点击操作
style组件的行内样式几乎所有组件用于自定义组件的样式
className组件的自定义类名几乎所有组件用于添加自定义 CSS 类
size设置组件大小Button, Input, Select, DatePicker, Avatar 等常见值如 small, middle, large
prefix输入框的前缀图标或内容Input在输入框前添加图标或文本
suffix输入框的后缀图标或内容Input在输入框后添加图标或文本
mode设置组件的模式或类型Menu, Cascader, Select 等控制组件的显示模式,如多选、标签模式等
theme设置组件的主题样式Button, Dropdown, Menu 等常见值如 light, dark
bordered是否显示边框Input, Select, Table 等控制组件是否显示边框
tooltip设置组件的工具提示信息Button, Icon, Avatar 等为组件添加悬停时显示的提示信息
placement设置弹出内容的位置Tooltip, Popover, Dropdown 等控制弹出层相对于目标元素的位置
borderRadius设置组件的圆角半径Button, Card, Avatar 等自定义组件的圆角样式
ghost透明背景样式Button, Card 等通常用于在深色背景上显示浅色组件
type设置组件的类型或风格Button, Input, Alert, Tag 等不同组件有不同的类型选项,如按钮类型 primary, default

表单相关

属性名称描述适用组件备注
checked设置选中状态Checkbox, Radio, Switch 等用于表示选中或激活状态
defaultValue组件的默认值Input, Select, DatePicker, Cascader 等设置组件的初始值
value组件的当前值Input, Select, DatePicker, Cascader 等用于受控组件,值由父组件管理
onChange值变化时的回调函数Input, Select, Checkbox, Radio, Switch, DatePicker 等处理用户输入或选择的变化
placeholder输入框的占位提示Input, Select, DatePicker 等提示用户输入内容
allowClear是否允许清除内容Input, Select, DatePicker 等显示清除按钮,允许用户一键清空输入
autoFocus组件挂载后自动聚焦Input, Select, Modal光标默认聚焦

API 语义化

在组件 API 设计中,开发者常面临简洁性与语义清晰性的平衡难题。为避免代码冗长或保持开放性,部分 API 会采用含义宽泛的名称(如 handle()process()),这种设计虽提升了灵活性,却可能牺牲语义明确性,导致新手难以快速理解功能逻辑,甚至让 AI 分析工具产生歧义

function handleData(data, options) {
  // 根据 options 决定是过滤、排序还是转换数据
}
  1. handleData 的功能不明确,开发者需查阅文档才能理解其支持的操作。
  2. 新用户可能误以为这是一个通用方法,但实际需要复杂的参数配置。
  3. AI 可能无法推断其具体行为,导致代码分析或智能提示不准确。

在保持简洁性的同时,通过语义化命名和文档注释增强可读性,同时通过扩展性设计(如参数、选项)兼顾开放性

// 语义明确的函数名,直接体现功能
function addUser(user) { /* ... */ }
function removeUser(userId) { /* ... */ }
function updateUser(userId, newData) { /* ... */ }

// 通过参数扩展开放性,而非宽泛命名
function findUsers(filter = {}, sort = {}) {
  // 支持灵活的过滤和排序参数
}

因此在进行组件设计时语义 > 简洁性,名称应直接反映核心功能(如 validateEmail() 优于 check()),通过参数或选项支持灵活配置,而非依赖宽泛的函数名。虽然现在大部分项目已经不再使用 React 15,可以感受下 React 15 的生命周期函数命名

函数名含义
constructor()构造函数,组件初始化
componentWillMount()将要挂载组件
render()渲染组件,生成 UI
componentDidMount()组件已挂载
componentWillReceiveProps()接收新 props前的准备
shouldComponentUpdate()是否应该更新,决定是否跳过后续渲染
componentWillUpdate()组件将要更新
componentDidUpdate()组件已更新
componentWillUnmount()组件将要卸载
  1. 阶段明确:函数名通过 Mount(挂载)、Update(更新)、Unmount(卸载)直接体现所处阶段。
  2. 时序清晰:Will 和 Did 后缀区分动作发生的时间点,这种命名方式让开发者无需查看文档即可推测函数的执行时机。
  3. 功能直白:render()、shouldComponentUpdate() 等名称直接描述功能,无需额外解释。

这样的 API 设计即使是新手也很容易指导该使用哪个

属性单一职责

单一职责原则是面向对象设计中的五大原则之一,主张一个模块、类或函数应该仅有一个引起其变化的原因,在前端开发中,这一原则同样适用于组件的属性设计。

示例中按钮组件的custom 属性,既用于控制按钮的样式,又用于决定按钮的点击事件逻辑。当开发者看到这个属性时,很难快速理解它的具体作用,而且在修改按钮样式或点击事件时,可能会影响到其它方面的功能,增加了代码的维护难度。

import React from 'react';

const CustomButton = (props) => {
  const { custom } = props;
  
  const handleClick = () => {
    if (custom === 'type1') {
      // 执行特定的点击逻辑
      console.log('Type 1 click');
    } else if (custom === 'type2') {
      // 执行另一种点击逻辑
      console.log('Type 2 click');
    }
  };

  const buttonStyle = custom === 'type1'
   ? { backgroundColor: 'red' }
    : { backgroundColor: 'blue' };

  return (
    <button style={buttonStyle} onClick={handleClick}>
      Custom Button
    </button>
  );
};

export default CustomButton;

当每个属性只负责一个明确的功能时,开发者和 AI 可以更容易地理解代码的意图。同时如果某个功能需要调整,只需要修改对应的属性即可,不会影响到其它不相关的功能,代码的修改和维护更加容易。

把示例中的 custom 属性拆分成 clickTypebackgroundColor 两个属性,clickType 专门控制点击逻辑,backgroundColor 专门控制按钮的背景颜色,代码会清晰非常多

import React from 'react';

const CustomButton = ({ clickType, backgroundColor }) => {
    const handleClick = () => {
        if (clickType === 'type1') {
            // 执行特定的点击逻辑
            console.log('Type 1 click');
        } else if (clickType === 'type2') {
            // 执行另一种点击逻辑
            console.log('Type 2 click');
        }
    };

    const buttonStyle = { backgroundColor };

    return (
        <button style={buttonStyle} onClick={handleClick}>
            Custom Button
        </button>
    );
};

export default CustomButton;    

属性值可枚举

如果属性值可枚举,尽量设计成 TypeScript 联合类型,方便开发者和 AI 学习使用,Antd Button 组件的多个属性都符合这一设计规范

shape设置按钮形状defaultcircleround
size设置按钮大小largemiddlesmall

属性值可拓展

属性可拓展性是指组件的属性允许开发者根据不同的需求传入不同类型的值,以实现多样化的功能和定制化效果。Antd Button 组件的 loading 属性就是一个很好的体现属性可拓展性的例子,它支持传入 boolean 类型或 React Node 类型的值。

当默认 loading 效果满足诉求时候传入 boolean 类型值即可

<Button type="primary" loading={true}>
  提交
</Button>

当需要自定义 loading 效果时候可以传入自定义的 Icon

<Button type="primary" loading={{ icon: <SyncOutlined spin /> }}>
  提交
</Button>

这样既保证了组件的业务拓展性,又没违背单一职责原则

组件可组合

随着个性化定制诉求的增多,复杂组件的属性可能会无限膨胀,组件的学习、维护成本显著提升。举个例子,组件中 header 部分的标题只能控制内容文案和对齐方式,如果希望标题前面有个 Icon 就需要升级组件,后续如果业务有修改标题颜色的诉求,需要继续升级组件

const Modal = (props) => {
  const {
    title = '',
    style = {},
    visible = false,
    onClose,
    titleAlign = 'left',
    children,
  } = props;

  function close(e) {
    e.stopPropagation();
    onClose();
  }
  
  if (!visible) return null;
  
  return (
    <div className={`modal`} style={style}>
      <div className="mask" onClick={close} />
      <div
        className="modal"
      >
        <div className="header">
          <div className={`title ${titleAlign}`}>
            {title}
            <div className="cancel-icon" onClick={close} />
          </div>
        </div>
        <div className="content">{children}</div>
      </div>
    </div>
  );
};
export default Modal;

传统的开放封闭原则和组合优先于继承理念同样可以给前端复杂组件设计指导

  • 组件无法覆盖变化时候,通过容器组件与子组件的组合实现功能扩展。
  • 使用 children 插槽让用户自定义内容,而非拓展 props。

上文中提到的 Antd 对自定义 loading 处理其实就使用了这一设计理念,Form 更是典型应用,通过提供 Form.Item 避免所有配置集中于 Form 组件,Form.Item 支持 children 更是提升了其内容的灵活性

<Form onFinish={handleFinish}>
  <Form.Item label="用户名" name="username" rules={[{ required: true }]}>
    <Input />
  </Form.Item>
</Form>

可以通过更多示例感受下我们在进行组件设计时候开放、封闭的粒度

过度依赖属性传递内容

<Page
  header={<Header />}
  sidebar={<Sidebar />}
  content={<Content />}
  footer={<Footer />}
/>

通过 children 表达布局

<Page>
  <Header />
  <Sidebar />
  <Content />
  <Footer />
</Page>

将子组件强制转为属性

<Tabs
  items={[
    { key: "1", label: "Tab 1", children: <div>Content 1</div> },
    { key: "2", label: "Tab 2", children: <div>Content 2</div> },
  ]}
/>

通过 children 嵌套 TabPane

<Tabs>
  <Tabs.TabPane tab="Tab 1" key="1">
    <div>Content 1</div>
  </Tabs.TabPane>
  <Tabs.TabPane tab="Tab 2" key="2">
    <div>Content 2</div>
  </Tabs.TabPane>
</Tabs>

拓展、组合开放粒度

当然并不是鼓励开发者无限制的开放组件的拓展性,毕竟拓展越多组件独立完成的功能就越少,开发者享受组件的提效优势会降低,组件的拓展开放粒度要适应业务实际变化的诉求,如果业务设计规范要求、统一了 Modal 标题的样式为基本文案,那么 Title 属性类型可以直接从 ReactNode 改为 String

组件文档规范

一个组件的 README 会优先被 AI 学习,这一点在用 Cursor 编程中有深刻的体会,组件文档至少要包含以下关键信息:

  1. 概述:简短描述组件的主要功能和使用场景
  2. 安装方式
  3. 基本用法:几个最高频的使用示例(可借助 AI 自动生成)
  4. Props/属性列表:所需的信息基本就是组件的 TS 描述,可根据 AI 自动生成
    • 名称
    • 类型
    • 默认值
    • 是否必填
    • 描述(用途和影响)