开发指南与最佳实践

本指南涵盖了 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)

🔄 开发流程

功能开发流程

  1. 需求分析

    • 明确功能需求和用户场景
    • 评估技术可行性和复杂度
    • 设计 API 接口和组件结构
  2. 分支管理

    # 创建功能分支
    git checkout -b feature/your-feature-name
    
    # 开发过程中定期同步主分支
    git fetch origin
    git rebase origin/main
  3. 编码规范

    • 遵循 TypeScript 最佳实践
    • 使用 oxlint 和 Prettier 保证代码质量
    • 添加必要的注释和文档
  4. 测试编写

    • 单元测试覆盖核心逻辑
    • 集成测试验证组件交互
    • E2E 测试确保用户流程正常
  5. 代码审查

    • 提交 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');
});

🔍 调试技巧

浏览器调试

1. React Developer Tools

// 在组件中添加调试信息
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: 检查以下几点:

  1. 确保已运行 pnpm install
  2. 删除 node_modules 和 pnpm-lock.yaml,重新安装
  3. 检查 Node.js 版本是否符合要求
# 清理并重新安装
rm -rf node_modules pnpm-lock.yaml
pnpm install

Q: TypeScript 类型错误

A: 常见解决方案:

  1. 重启 TypeScript 服务:Ctrl+Shift+P → "TypeScript: Restart TS Server"
  2. 检查类型导入路径是否正确
  3. 确保所有依赖的类型包已安装

性能问题

Q: 大文档编辑时出现卡顿

A: 优化建议:

  1. 启用虚拟滚动
  2. 减少不必要的重新渲染
  3. 使用 React.memo 优化组件
  4. 考虑分页或懒加载
// 示例:优化大文档渲染
const OptimizedEditor = React.memo(() => {
  const [visibleRange, setVisibleRange] = useState({ start: 0, end: 100 });

  return (
    <VirtualList onVisibleRangeChange={setVisibleRange}>
      {/* 只渲染可见范围内的内容 */}
    </VirtualList>
  );
});

Q: 插件加载慢

A: 优化策略:

  1. 使用动态导入 (React.lazy)
  2. 实现插件预加载机制
  3. 缓存插件资源

兼容性问题

Q: 在某些浏览器中功能异常

A: 检查清单:

  1. 浏览器版本支持情况
  2. Polyfill 是否正确加载
  3. CSS 兼容性问题
  4. JavaScript API 兼容性

🤝 贡献指南

贡献流程

  1. Fork 项目

    # 在 GitHub 上 Fork 项目,然后克隆你的 Fork
    git clone https://github.com/your-username/agentic-ui.git
  2. 创建分支

    git checkout -b feature/your-feature
  3. 开发和测试

    # 开发功能
    pnpm start
    
    # 运行测试
    pnpm test
    
    # 代码检查
    pnpm lint
  4. 提交代码

    git add .
    git commit -m "feat: add your feature"
    git push origin feature/your-feature
  5. 创建 Pull Request

    • 在 GitHub 上创建 PR
    • 填写详细的描述和变更说明
    • 等待代码审查和合并

代码质量要求

  • 测试覆盖率: 新功能需要有相应的测试,保持覆盖率在 80% 以上
  • 代码风格: 遵循项目的 oxlint 和 Prettier 配置
  • 文档更新: 新功能需要更新相应的文档和示例
  • 向后兼容: 避免破坏性变更,如有必要需要提供迁移指南

提问和讨论

  • GitHub Issues: 报告 Bug 和功能请求
  • GitHub Discussions: 技术讨论和问答
  • Code Review: 积极参与代码审查,提供建设性意见

通过遵循本指南,你可以更高效地参与 agentic-ui 项目的开发。如果遇到任何问题,欢迎在 GitHub 上提出 Issue 或参与 Discussion。