GroveTab 是一个 Chrome 新标签页扩展,使用 React 19 + TypeScript + Vite + Ant Design v6 + Zustand 构建。
- 单一职责:每个组件只做一件事
- 可复用性:公共组件提取到
src/shared/ui/ - 可组合性:使用 children 和 composition 模式
// ✅ 正确:使用 composition
<Card
title={<Typography.Title level={4}>标题</Typography.Title>}
extra={<Button>操作</Button>}
>
<p>内容</p>
</Card>
// ❌ 错误:过度嵌套 div
<div className="card">
<div className="card-header">
<div className="title">标题</div>
<div className="actions"><button>操作</button></div>
</div>
<div className="card-body"><p>内容</p></div>
</div>- 使用 PascalCase:
TabView,BookmarkCard - 文件名与组件名一致:
TabView.tsx导出TabView - 使用描述性名称,避免缩写:
BookmarkGridView而非BMGV
// ✅ 正确:使用对象状态合并更新
const [state, setState] = useState({ count: 0, loading: false });
setState(prev => ({ ...prev, loading: true }));
// ❌ 错误:过度拆分状态
const [count, setCount] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);// ✅ 正确:明确的依赖数组
useEffect(() => {
const controller = new AbortController();
fetchData(controller.signal);
return () => controller.abort();
}, [userId]); // 明确依赖
// ❌ 错误:缺少依赖或依赖过多
useEffect(() => {
fetchData();
}, []); // 缺少 userId 依赖// ✅ 正确:缓存昂贵计算
const sortedTabs = useMemo(
() => tabs.sort((a, b) => b.lastAccessed - a.lastAccessed),
[tabs]
);
// ✅ 正确:缓存回调函数
const handleTabClick = useCallback((tabId: string) => {
chrome.tabs.update(parseInt(tabId), { active: true });
}, []);
// ❌ 错误:不必要的缓存(简单计算)
const total = useMemo(() => items.length, [items]); // 过度优化// ✅ 正确:自定义 Hook 以 use 开头
function useTabActions() {
const closeTab = useCallback((tabId: string) => {
chrome.tabs.remove(parseInt(tabId));
}, []);
return { closeTab };
}
// 使用
const { closeTab } = useTabActions();// ✅ 正确:对纯组件使用 memo
const TabCard = React.memo(({ tab, onClose }: TabCardProps) => {
return (
<Card onClick={() => handleTabClick(tab.id)}>
{tab.title}
</Card>
);
}, (prevProps, nextProps) => {
return prevProps.tab.id === nextProps.tab.id
&& prevProps.tab.title === nextProps.tab.title;
});
// ❌ 错误:不必要的 memo(简单组件)
const SimpleText = React.memo(({ text }: { text: string }) => {
return <span>{text}</span>;
}); // 过度优化// ✅ 正确:使用 useCallback
const handleClick = useCallback(() => {
setCount(prev => prev + 1);
}, []);
<Button onClick={handleClick}>增加</Button>
// ❌ 错误:每次渲染创建新函数
<Button onClick={() => setCount(count + 1)}>增加</Button>// ✅ 正确:使用稳定唯一的 key
{tabs.map(tab => (
<TabCard key={tab.id} tab={tab} />
))}
// ❌ 错误:使用 index 作为 key
{tabs.map((tab, index) => (
<TabCard key={index} tab={tab} />
))}// ✅ 正确:使用虚拟滚动(大列表)
import { List } from 'react-virtualized';
<List
width={800}
height={600}
rowCount={tabs.length}
rowHeight={50}
rowRenderer={({ index, key, style }) => (
<div key={key} style={style}>
<TabCard tab={tabs[index]} />
</div>
)}
/>
// ❌ 错误:渲染 1000+ 个 DOM 节点
{tabs.map(tab => <TabCard key={tab.id} tab={tab} />)}// ✅ 正确:使用三元运算符(简单条件)
{isLoading ? <Spin /> : <TabList tabs={tabs} />}
// ✅ 正确:使用 &&(无 else 分支)
{isVisible && <SettingsPanel />}
// ✅ 正确:使用变量存储 JSX(复杂条件)
const content = () => {
if (isLoading) return <Spin />;
if (error) return <Alert message={error} />;
if (tabs.length === 0) return <Empty />;
return <TabList tabs={tabs} />;
};
return <div>{content}</div>;
// ❌ 错误:复杂的嵌套三元运算符
{isLoading ? <Spin /> : error ? <Alert /> : tabs.length === 0 ? <Empty /> : <TabList />}// ✅ 正确:使用明确的命名
const handleTabClick = (tabId: string) => { ... };
const handleSearchChange = (value: string) => { ... };
const handleSettingsSubmit = (values: Settings) => { ... };
// ✅ 正确:事件处理函数内联简单逻辑
<Button onClick={() => setVisible(true)}>打开</Button>
// ❌ 错误:事件处理函数包含复杂逻辑
<Button onClick={() => {
const tabs = await chrome.tabs.query({});
const filtered = tabs.filter(t => t.url.includes('google'));
setTabs(filtered);
setLoading(false);
}}>
查询
</Button>// ✅ 正确:使用 interface 定义对象形状(可扩展)
interface TabCardProps {
tab: chrome.tabs.Tab;
onClose: (tabId: string) => void;
}
// ✅ 正确:使用 type 定义联合类型/交叉类型
type ThemeMode = 'light' | 'dark' | 'auto';
type TabState = 'idle' | 'loading' | 'error';
// ❌ 错误:过度使用 type(无法利用声明合并)
type TabCardProps = {
tab: chrome.tabs.Tab;
onClose: (tabId: string) => void;
};// ✅ 正确:使用 interface 定义 Props
interface ButtonProps {
type?: 'primary' | 'default' | 'dashed';
loading?: boolean;
onClick?: () => void;
children: React.ReactNode;
}
const Button: React.FC<ButtonProps> = ({ type = 'default', loading = false, onClick, children }) => {
return (
<button className={`btn btn-${type}`} onClick={onClick} disabled={loading}>
{loading && <Spin />}
{children}
</button>
);
};
// ❌ 错误:使用 React.FC(丢失 children 类型信息)
const Button: React.FC<ButtonProps> = (props) => { ... };// ✅ 正确:使用具体类型
const [tabs, setTabs] = useState<chrome.tabs.Tab[]>([]);
// ✅ 正确:使用 unknown(需要类型守卫)
const parseData = (data: unknown): TabData | null => {
if (typeof data === 'object' && data !== null && 'id' in data) {
return data as TabData;
}
return null;
};
// ❌ 错误:使用 any
const [tabs, setTabs] = useState<any[]>([]);// ✅ 正确:使用泛型定义可复用组件
interface ListProps<T> {
items: T[];
renderItem: (item: T) => React.ReactNode;
keyExtractor: (item: T) => string;
}
function List<T>({ items, renderItem, keyExtractor }: ListProps<T>) {
return (
<div>
{items.map(item => (
<div key={keyExtractor(item)}>
{renderItem(item)}
</div>
))}
</div>
);
}
// 使用
<List
items={tabs}
renderItem={(tab) => <TabCard tab={tab} />}
keyExtractor={(tab) => tab.id.toString()}
/>// ✅ 正确:使用 import type
import type { TabCardProps } from './TabCard';
import type { chrome } from 'chrome';
// ✅ 正确:分离类型和值导入
import { Button } from 'antd';
import type { ButtonProps } from 'antd';
// ❌ 错误:混合导入
import { Button, ButtonProps } from 'antd';// ✅ 正确:使用 antd Token
.app-header {
padding: var(--ant-padding-md);
margin-bottom: var(--ant-margin-sm);
background: var(--ant-color-bg-container);
border-radius: var(--ant-border-radius-lg);
}
// ❌ 错误:硬编码值
.app-header {
padding: 16px;
margin-bottom: 8px;
background: #ffffff;
border-radius: 8px;
}// ✅ 正确:使用 antd 布局组件(无需额外 CSS)
<Flex align="center" justify="space-between" gap={10}>
<Typography.Title level={4}>标题</Typography.Title>
<Space>
<Button type="primary">保存</Button>
<Button>取消</Button>
</Space>
</Flex>
// ✅ 正确:使用 CSS Modules(需要自定义样式时)
// Component.module.less
// .container { padding: 16px; }
import styles from './Component.module.less';
<div className={styles.container}>内容</div>
// ❌ 错误:使用内联 style 对象(性能问题:每次渲染创建新对象)
<div style={{ display: 'flex', alignItems: 'center' }}>
内容
</div>性能说明:
style={{}}每次渲染创建新对象 → 破坏React.memo优化- 应使用 CSS Modules(
className={styles.xxx})或 antd 组件属性
// ✅ 正确:使用 antd 组件的 classNames prop
<Card
classNames={{
header: styles.header,
body: styles.body
}}
/>
// ❌ 错误:使用 className 覆盖组件样式
<Card className={styles.card}>| 原生元素 | 必须替换为 |
|---|---|
<button> |
<Button> |
<h1> - <h6> |
<Typography.Title> |
<p> |
<Typography.Paragraph> |
<input> |
<Input> |
<select> |
<Select> |
<textarea> |
<Input.TextArea> |
<a> |
<Typography.Link> 或 <Button type="link"> |
// ✅ 正确:使用 CSS Modules(.module.less)
// TabCard.module.less
.container {
padding: var(--ant-padding-md);
.title {
color: var(--ant-color-text);
}
}
// ❌ 错误:使用全局样式
// TabCard.less
.tab-card-container {
padding: 16px;
}// ✅ 正确:导入 CSS Modules
import styles from './TabCard.module.less';
<div className={styles.container}>
<span className={styles.title}>标题</span>
</div>
// ❌ 错误:使用全局类名
<div className="tab-card-container">
<span className="title">标题</span>
</div>禁止:
- ❌ 使用
display: flex(应使用<Flex>组件) - ❌ 使用
display: grid(应使用<Row>+<Col>) - ❌ 使用
gap属性(应使用<Flex gap={}>或<Space size={}>) - ❌ 使用
!important - ❌ 硬编码颜色值(必须使用 CSS 变量或设计 Token)
允许:
- ✅ 使用
display: flex仅用于微调 antd 组件内部样式(需添加注释说明) - ✅ 使用 antd 设计 Token(
--ant-*)进行样式定制
// ✅ 正确:使用 CSS 变量支持主题切换
.app-container {
background: var(--ant-color-bg-container);
color: var(--ant-color-text);
}
// ✅ 正确:使用 data-theme 属性
[data-theme='dark'] {
.app-container {
background: var(--ant-color-bg-container-dark);
}
}
// ❌ 错误:硬编码主题颜色
.app-container {
background: #ffffff;
[data-theme='dark'] & {
background: #141414; // 应使用 Token
}
}src/
├── shared/ # 共享代码
│ ├── ui/ # 公共 UI 组件
│ ├── hooks/ # 公共 Hooks
│ ├── utils/ # 工具函数
│ ├── styles/ # 全局样式
│ └── theme/ # 主题配置
├── features/ # 功能模块
│ ├── tabs/ # Tab 管理功能
│ │ ├── components/ # 功能相关组件
│ │ ├── hooks/ # 功能相关 Hooks
│ │ └── utils/ # 功能相关工具
│ ├── bookmarks/ # 书签功能
│ └── settings/ # 设置功能
├── pages/ # 页面组件
│ ├── newtab/ # 新标签页
│ └── popup/ # 弹出窗口
└── types/ # 全局类型定义
- 公共组件:必须提取到
src/shared/ui/目录 - 功能组件:必须提取到
src/features/<feature>/components/目录 - 页面组件:禁止在页面组件中直接编写复杂组件逻辑
- 公共 Hooks:必须提取到
src/shared/hooks/目录 - 功能 Hooks:必须提取到
src/features/<feature>/hooks/目录
// ✅ 正确:使用 Zustand 定义 Store
import { create } from 'zustand';
interface TabsStore {
tabs: chrome.tabs.Tab[];
loading: boolean;
fetchTabs: () => Promise<void>;
closeTab: (tabId: number) => Promise<void>;
}
const useTabsStore = create<TabsStore>((set, get) => ({
tabs: [],
loading: false,
fetchTabs: async () => {
set({ loading: true });
try {
const tabs = await chrome.tabs.query({});
set({ tabs, loading: false });
} catch (error) {
set({ loading: false });
console.error('Failed to fetch tabs:', error);
}
},
closeTab: async (tabId: number) => {
await chrome.tabs.remove(tabId);
set(state => ({
tabs: state.tabs.filter(tab => tab.id !== tabId)
}));
},
}));
export default useTabsStore;// ✅ 正确:按需订阅 Store
const tabs = useTabsStore((state) => state.tabs);
const fetchTabs = useTabsStore((state) => state.fetchTabs);
// ✅ 正确:使用 shallow 比较(避免不必要渲染)
import { shallow } from 'zustand/shallow';
const { tabs, loading } = useTabsStore(
(state) => ({ tabs: state.tabs, loading: state.loading }),
shallow
);
// ❌ 错误:订阅整个 Store
const store = useTabsStore(); // 任何状态变化都会触发重渲染// ✅ 正确:封装 Chrome API 调用
const useChromeTabs = () => {
const [tabs, setTabs] = useState<chrome.tabs.Tab[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const fetchTabs = useCallback(async () => {
setLoading(true);
setError(null);
try {
const result = await chrome.tabs.query({});
setTabs(result);
} catch (err) {
setError(err instanceof Error ? err : new Error('Unknown error'));
} finally {
setLoading(false);
}
}, []);
return { tabs, loading, error, fetchTabs };
};
// ❌ 错误:直接在组件中调用 Chrome API
const TabList = () => {
const [tabs, setTabs] = useState([]);
useEffect(() => {
chrome.tabs.query({}, (result) => {
setTabs(result);
});
}, []);
return <div>{/* ... */}</div>;
};// ✅ 正确:统一的错误处理
const useAsyncTask = <T,>(
task: () => Promise<T>,
options: { onSuccess?: (data: T) => void; onError?: (error: Error) => void } = {}
) => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const execute = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await task();
options.onSuccess?.(data);
return data;
} catch (err) {
const error = err instanceof Error ? err : new Error('Unknown error');
setError(error);
options.onError?.(error);
throw error;
} finally {
setLoading(false);
}
}, [task, options.onSuccess, options.onError]);
return { execute, loading, error };
};
// 使用
const { execute, loading, error } = useAsyncTask(
() => chrome.tabs.query({}),
{
onSuccess: (tabs) => console.log('Fetched', tabs.length, 'tabs'),
onError: (error) => message.error(error.message),
}
);// ✅ 正确:使用 Vitest + React Testing Library
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import TabCard from './TabCard';
describe('TabCard', () => {
it('should render tab title', () => {
const tab = { id: 1, title: 'Google', url: 'https://google.com' };
render(<TabCard tab={tab} onClose={vi.fn()} />);
expect(screen.getByText('Google')).toBeInTheDocument();
});
it('should call onClose when close button clicked', () => {
const tab = { id: 1, title: 'Google', url: 'https://google.com' };
const onClose = vi.fn();
render(<TabCard tab={tab} onClose={onClose} />);
fireEvent.click(screen.getByRole('button', { name: /close/i }));
expect(onClose).toHaveBeenCalledWith('1');
});
});- 公共组件必须有单元测试
- Hooks 必须有单元测试
- 工具函数必须有单元测试
- 目标覆盖率:≥ 80%
# TypeScript 类型检查
npm run type-check
# 构建验证
npm run build
# 单元测试
npm run test
# Lint 检查
npm run lint- ❌ TypeScript 类型错误
- ❌ 构建失败
- ❌ 单元测试失败
- ❌ Lint 错误
当发现违反上述规范的代码时,按以下优先级进行重构:
| 优先级 | 范围 | 示例 |
|---|---|---|
| P0 | 用户直接使用的视图 | Tab 视图、书签视图、设置面板 |
| P1 | 功能和侧边栏 | 历史面板、Arc 侧边栏、搜索框 |
| P2 | 辅助功能 | 开发者工具、洞察页面 |
以下情况可以申请例外(需在代码中添加 // eslint-disable-next-line 并说明原因):
- 第三方组件内部样式调整:如果必须调整第三方组件的样式,且无法通过 antd 组件实现
- 性能敏感场景:如果频繁重新渲染的组件,使用 antd 组件可能带来额外的性能开销(需通过性能测试验证)
- 浏览器兼容性:如果需要支持旧版浏览器,可能需要降级使用某些 API
本规范适用于 GroveTab 项目所有新代码和重构代码。