开发指南与最佳实践
本指南涵盖了 agentic-ui 项目的开发流程、最佳实践、性能优化和常见问题解决方案。
📋 目录
📚 相关文档
🛠️ 开发环境设置
系统要求
- Node.js: >= 16.0.0 (推荐使用 LTS 版本)
- 包管理器: pnpm >= 7.0.0 (推荐) 或 npm >= 8.0.0
- 操作系统: Windows 10+, macOS 10.15+, 或 Linux
环境配置
# 1. 克隆项目
git clone git@github.com:ant-design/agentic-ui.git
cd agentic-ui
# 2. 安装依赖
pnpm install
# 3. 启动开发服务器
pnpm start
# 4. 在浏览器中打开 http://localhost:8000
IDE 配置
VSCode 推荐插件
{
"recommendations": [
"esbenp.prettier-vscode",
"bradlc.vscode-tailwindcss",
"ms-vscode.vscode-typescript-next",
"formulahendry.auto-rename-tag",
"christian-kohler.path-intellisense"
]
}
编辑器设置
{
"editor.formatOnSave": true,
"typescript.preferences.importModuleSpecifier": "relative"
}
📁 项目结构说明
核心目录结构
agentic-ui/
├── src/ # 源代码
│ ├── MarkdownEditor/ # 核心编辑器模块
│ │ ├── editor/ # 编辑器核心逻辑
│ │ ├── hooks/ # 编辑器相关 Hooks
│ │ └── utils/ # 编辑器工具函数
│ ├── components/ # 全局组件
│ ├── hooks/ # 全局 Hooks
│ ├── utils/ # 工具函数
│ ├── types/ # TypeScript 类型定义
│ └── plugins/ # 插件系统
├── docs/ # 文档和示例
│ ├── components/ # 组件文档
│ ├── development/ # 开发文档
│ └── demos/ # 演示代码
├── tests/ # 测试文件
└── scripts/ # 构建脚本
文件命名规范
- 组件文件: PascalCase (如
MarkdownEditor.tsx)
- 工具函数: camelCase (如
parseMarkdown.ts)
- 类型定义: PascalCase +
.types.ts (如 Editor.types.ts)
- 样式文件: camelCase +
.style.ts (如 editor.style.ts)
- 测试文件: 原文件名 +
.test.tsx (如 Editor.test.tsx)
🔄 开发流程
功能开发流程
-
需求分析
- 明确功能需求和用户场景
- 评估技术可行性和复杂度
- 设计 API 接口和组件结构
-
分支管理
# 创建功能分支
git checkout -b feature/your-feature-name
# 开发过程中定期同步主分支
git fetch origin
git rebase origin/main
-
编码规范
- 遵循 TypeScript 最佳实践
- 使用 oxlint 和 Prettier 保证代码质量
- 添加必要的注释和文档
-
测试编写
- 单元测试覆盖核心逻辑
- 集成测试验证组件交互
- E2E 测试确保用户流程正常
-
代码审查
- 提交 Pull Request
- 同行代码审查
- 修改意见和优化建议
代码提交规范
使用 Conventional Commits 规范:
# 功能开发
git commit -m "feat: add markdown table support"
# Bug 修复
git commit -m "fix: resolve editor crash on empty content"
# 文档更新
git commit -m "docs: update API documentation"
# 性能优化
git commit -m "perf: improve rendering performance for large documents"
# 重构
git commit -m "refactor: extract common editor utilities"
# 测试
git commit -m "test: add unit tests for markdown parser"
⚡ 性能优化
React Hooks 依赖陷阱
以下模式是项目中实际出现并修复过的 bug,开发时务必注意。
1. 空数组/空对象字面量在依赖中导致死循环
[] 和 {} 每次渲染都是新引用,放入 useEffect / useMemo / useCallback 的依赖数组会导致无限重执行。
// ❌ 错误:每次渲染 items 都是新的 [],effect 无限触发
const MyComponent = ({ items = [] }) => {
useEffect(() => {
setInternalActiveKey(items[0]?.key);
}, [items]);
};
// ✅ 正确:模块级常量提供稳定引用
const EMPTY_ITEMS: MyComponentProps['items'] = [];
const MyComponent = ({ items = EMPTY_ITEMS }) => {
useEffect(() => {
setInternalActiveKey(items[0]?.key);
}, [items]);
};
同样适用于 || {} / || [] / ?? {} 等回退模式:
// ❌ 错误:schema 为 null 时,每次渲染 safeSchema 都是新对象
const safeSchema = schema || {};
const result = useMemo(() => validate(safeSchema), [safeSchema]);
// ✅ 正确:模块级常量
const EMPTY_SCHEMA = {} as SchemaType;
const safeSchema = schema ?? EMPTY_SCHEMA;
const result = useMemo(() => validate(safeSchema), [safeSchema]);
关键规则:凡是在 hook 依赖数组中使用的值,绝不能由 || []、|| {}、= []、= {} 等字面量创建。必须使用模块级常量或 useMemo 稳定引用。
2. 状态既在依赖中又在 effect 内被设置 — 多余触发
当 useEffect 的依赖包含某个 state,而 effect 内部又调用了该 state 的 setter,会导致 effect 重复执行。
// ❌ 错误:currentRightWidth 变化 → effect 重建 → setCurrentRightWidth → 循环
useEffect(() => {
const handleResize = () => {
if (currentRightWidth > maxWidth) {
setCurrentRightWidth(maxWidth);
}
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [currentRightWidth, getMaxRightWidth]);
// ✅ 正确:用 ref 持有 state,依赖中移除 state
const currentRightWidthRef = useRef(currentRightWidth);
currentRightWidthRef.current = currentRightWidth;
useEffect(() => {
const handleResize = () => {
if (currentRightWidthRef.current > maxWidth) {
setCurrentRightWidth(maxWidth);
}
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [getMaxRightWidth]);
同样模式适用于:isEditorFocused、isControlled 等布尔状态在 event listener effect 中使用的情况。
3. 对象类型的 hook 依赖应提取为原始值
当 hook 依赖是对象时,每次父组件渲染都会产生新引用,导致 effect 过度触发。
// ❌ 错误:antdContext?.locale 是对象,每次渲染都可能新引用
useEffect(() => {
const antdLocale = antdContext.locale.locale;
// ...
}, [antdContext?.locale, autoDetect, language]);
// ✅ 正确:只依赖需要的原始值(字符串)
useEffect(() => {
const antdLocale = antdContext.locale.locale;
// ...
}, [antdContext?.locale?.locale, autoDetect, language]);
4. props.children 作为依赖 — 过度触发
props.children 在父组件每次渲染时都是新引用,不应直接作为 effect 依赖。
// ❌ 错误:每次父渲染都触发 effect
useEffect(() => {
syncOrderedFromChildren(props.children);
}, [props.children]);
// ✅ 正确:提取稳定的 key 序列作为依赖
const childrenKeys = useMemo(
() =>
React.Children.toArray(props.children)
.filter(React.isValidElement)
.map((child) => child.key),
[props.children],
);
useEffect(() => {
syncOrderedFromChildren(props.children);
}, [childrenKeys]);
5. 闭包陈旧 — 缺失依赖
effect 内使用了变量但未列入依赖,导致闭包捕获旧值。
// ❌ 错误:props.plugins 变化不会触发重新解析
useEffect(() => {
editorRef.current?.store?.updateNodeList(
parserMdToSchema(formatted, props.plugins).schema,
);
}, [props.initValue]); // 缺少 props.plugins
// ✅ 正确:补全依赖
useEffect(() => {
editorRef.current?.store?.updateNodeList(
parserMdToSchema(formatted, props.plugins).schema,
);
}, [props.initValue, props.plugins]);
对于只在初始化时执行的 effect(如 onInit),如果回调可能变化,应使用 useRefFunction 或 ref 持有:
// ❌ 错误:onChange 闭包捕获初始值,后续变化不会更新
useEffect(() => {
if (!readonly && onChange) {
codeEditor.on('change', () => {
onChange(newValue); // 永远是初始的 onChange
});
}
}, [aceLoaded]); // 缺少 onChange
// ✅ 正确:用 ref 持有最新回调
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
useEffect(() => {
if (!readonly && onChangeRef.current) {
codeEditor.on('change', () => {
onChangeRef.current?.(newValue);
});
}
}, [aceLoaded]);
6. ref.current 不应作为 hook 依赖
React 不会追踪 ref.current 的变化,将其放入依赖数组不可靠。
// ❌ 错误:ref.current 变化不会触发 effect
useEffect(() => {
if (nodeRef.current !== props.instance) {
initialNote();
}
}, [props.instance, markdownEditorRef.current]); // ref.current 不可靠
// ✅ 正确:移除 ref.current 依赖,加注释说明
useEffect(() => {
if (nodeRef.current !== props.instance) {
initialNote();
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- markdownEditorRef.current 是可变 ref
}, [props.instance]);
7. ref 同步应使用 useLayoutEffect
用无依赖 useEffect 同步 ref 值的模式应改为 useLayoutEffect,避免在 paint 后再做同步写入。
// ❌ 不推荐:paint 后才同步 ref
useEffect(() => {
callbackRef.current = props.onCallback;
});
// ✅ 推荐:DOM 更新后、paint 前同步 ref
useLayoutEffect(() => {
callbackRef.current = props.onCallback;
});
渲染性能优化
1. 组件 Memoization
// 使用 React.memo 避免不必要的重新渲染
const MElement = React.memo<ElementProps>(
({ element, children, ...props }) => {
return <div {...props}>{children}</div>;
},
(prevProps, nextProps) => {
// 自定义比较逻辑
return (
prevProps.element === nextProps.element &&
prevProps.children === nextProps.children
);
},
);
2. 虚拟滚动
// 对于大量内容,使用虚拟滚动优化性能
import { FixedSizeList as List } from 'react-window';
const VirtualizedEditor: React.FC = () => {
const renderItem = ({ index, style }) => (
<div style={style}>
<EditorLine index={index} />
</div>
);
return (
<List height={600} itemCount={itemCount} itemSize={35}>
{renderItem}
</List>
);
};
3. 懒加载和代码分割
// 插件懒加载
const KatexPlugin = lazy(() => import('../plugins/katex'));
const MermaidPlugin = lazy(() => import('../plugins/mermaid'));
// 在编辑器中按需加载
const Editor: React.FC = () => {
return (
<Suspense fallback={<Loading />}>
{enableKatex && <KatexPlugin />}
{enableMermaid && <MermaidPlugin />}
</Suspense>
);
};
内存优化
1. 清理事件监听器
const useEditorEvents = () => {
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// 处理键盘事件
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, []);
};
2. 避免内存泄漏
const useAsyncOperation = () => {
const [data, setData] = useState(null);
const abortControllerRef = useRef<AbortController>();
const fetchData = async () => {
// 取消之前的请求
abortControllerRef.current?.abort();
const controller = new AbortController();
abortControllerRef.current = controller;
try {
const response = await fetch('/api/data', {
signal: controller.signal,
});
const result = await response.json();
setData(result);
} catch (error) {
if (error.name !== 'AbortError') {
console.error('Fetch error:', error);
}
}
};
useEffect(() => {
return () => {
abortControllerRef.current?.abort();
};
}, []);
return { data, fetchData };
};
🧪 测试策略
测试金字塔
E2E Tests (少量)
←─────────────────→
Integration Tests (适量)
←─────────────────────────→
Unit Tests (大量)
单元测试
// Editor.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { MarkdownEditor } from '../MarkdownEditor';
describe('MarkdownEditor', () => {
it('should render with initial value', () => {
render(<MarkdownEditor initValue="# Hello World" />);
expect(screen.getByText('Hello World')).toBeInTheDocument();
});
it('should handle text input', () => {
const onChange = jest.fn();
render(<MarkdownEditor onChange={onChange} />);
const editor = screen.getByRole('textbox');
fireEvent.input(editor, { target: { value: '# Test' } });
expect(onChange).toHaveBeenCalledWith('# Test', expect.any(Array));
});
});
集成测试
// EditorIntegration.test.tsx
describe('Editor Integration', () => {
it('should work with plugins', async () => {
render(<MarkdownEditor initValue="$$E=mc^2$$" plugins={[katexPlugin]} />);
await waitFor(() => {
expect(screen.getByRole('math')).toBeInTheDocument();
});
});
});
E2E 测试
// e2e/editor.spec.ts
import { test, expect } from '@playwright/test';
test('complete editing workflow', async ({ page }) => {
await page.goto('/');
// 输入内容
await page.fill('[data-testid="editor"]', '# Hello World');
// 验证预览
await expect(page.locator('h1')).toContainText('Hello World');
// 测试工具栏功能
await page.click('[data-testid="bold-button"]');
await page.type('[data-testid="editor"]', 'bold text');
await expect(page.locator('strong')).toContainText('bold text');
});
🔍 调试技巧
浏览器调试
// 在组件中添加调试信息
const Editor: React.FC = () => {
// React DevTools 中可以看到这个值
const debugInfo = useMemo(
() => ({
nodeCount: editor.children.length,
selectionPath: editor.selection?.anchor.path,
}),
[editor],
);
return <div data-debug={JSON.stringify(debugInfo)}>...</div>;
};
2. 性能分析
// 使用 React Profiler 分析性能
import { Profiler } from 'react';
const onRenderCallback = (id, phase, actualDuration) => {
console.log('Render info:', { id, phase, actualDuration });
};
<Profiler id="Editor" onRender={onRenderCallback}>
<MarkdownEditor />
</Profiler>;
3. 错误边界
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error) {
return { hasError: true };
}
componentDidCatch(error, errorInfo) {
console.error('Editor error:', error, errorInfo);
// 发送错误报告到监控服务
}
render() {
if (this.state.hasError) {
return <div>编辑器出现错误,请刷新页面重试</div>;
}
return this.props.children;
}
}
日志系统
// utils/logger.ts
export const logger = {
debug: (message: string, data?: any) => {
if (process.env.NODE_ENV === 'development') {
console.log(`[DEBUG] ${message}`, data);
}
},
error: (message: string, error?: Error) => {
console.error(`[ERROR] ${message}`, error);
// 生产环境中发送到错误监控服务
},
performance: (label: string, fn: () => void) => {
const start = performance.now();
fn();
faq;
const end = performance.now();
console.log(`[PERF] ${label}: ${end - start}ms`);
},
};
❓ 常见问题
开发环境问题
Q: 启动项目时出现 "Cannot resolve module" 错误
A: 检查以下几点:
- 确保已运行
pnpm install
- 删除
node_modules 和 pnpm-lock.yaml,重新安装
- 检查 Node.js 版本是否符合要求
# 清理并重新安装
rm -rf node_modules pnpm-lock.yaml
pnpm install
Q: TypeScript 类型错误
A: 常见解决方案:
- 重启 TypeScript 服务:
Ctrl+Shift+P → "TypeScript: Restart TS Server"
- 检查类型导入路径是否正确
- 确保所有依赖的类型包已安装
性能问题
Q: 大文档编辑时出现卡顿
A: 优化建议:
- 启用虚拟滚动
- 减少不必要的重新渲染
- 使用
React.memo 优化组件
- 考虑分页或懒加载
// 示例:优化大文档渲染
const OptimizedEditor = React.memo(() => {
const [visibleRange, setVisibleRange] = useState({ start: 0, end: 100 });
return (
<VirtualList onVisibleRangeChange={setVisibleRange}>
{/* 只渲染可见范围内的内容 */}
</VirtualList>
);
});
Q: 插件加载慢
A: 优化策略:
- 使用动态导入 (
React.lazy)
- 实现插件预加载机制
- 缓存插件资源
兼容性问题
Q: 在某些浏览器中功能异常
A: 检查清单:
- 浏览器版本支持情况
- Polyfill 是否正确加载
- CSS 兼容性问题
- JavaScript API 兼容性
🤝 贡献指南
贡献流程
-
Fork 项目
# 在 GitHub 上 Fork 项目,然后克隆你的 Fork
git clone https://github.com/your-username/agentic-ui.git
-
创建分支
git checkout -b feature/your-feature
-
开发和测试
# 开发功能
pnpm start
# 运行测试
pnpm test
# 代码检查
pnpm lint
-
提交代码
git add .
git commit -m "feat: add your feature"
git push origin feature/your-feature
-
创建 Pull Request
- 在 GitHub 上创建 PR
- 填写详细的描述和变更说明
- 等待代码审查和合并
代码质量要求
- 测试覆盖率: 新功能需要有相应的测试,保持覆盖率在 80% 以上
- 代码风格: 遵循项目的 oxlint 和 Prettier 配置
- 文档更新: 新功能需要更新相应的文档和示例
- 向后兼容: 避免破坏性变更,如有必要需要提供迁移指南
提问和讨论
- GitHub Issues: 报告 Bug 和功能请求
- GitHub Discussions: 技术讨论和问答
- Code Review: 积极参与代码审查,提供建设性意见
通过遵循本指南,你可以更高效地参与 agentic-ui 项目的开发。如果遇到任何问题,欢迎在 GitHub 上提出 Issue 或参与 Discussion。