系统架构
组件拓扑、数据模型、领域模块、设计决策、主题与国际化。
书签浏览器是一个完全在浏览器内运行的单页 React 应用。它没有后端、没有账号体系、也没有用于书签数据的网络 API。所有状态都通过一个小型存储适配器持久化到 LocalStorage,UI 通过订阅该适配器来实现更新。
代码分为两层:
src/core/存放无框架领域逻辑:类型、存储、选择、键盘、历史、导入导出、元信息抓取、排序、循环检测与校验。src/components/存放基于 HeroUI、Tailwind CSS 与 Iconify 构建的 React UI。组件从App.tsx接收数据与回调,App.tsx负责整体编排。
本文档介绍组件拓扑、数据模型、持久化、领域模块、关键设计决策、主题与国际化。源码 src/ 仍是权威参考。
- 熟悉 React、TypeScript 与 Vite。
- 熟悉 HeroUI(React Aria)组合式组件与 Tailwind CSS。
- 了解
@dnd-kit拖拽库的基本用法。
| 组件 | 用途 |
|---|---|
App.tsx | 应用外壳。管理全局状态、设置、选择、视图路由、拖拽上下文与弹窗。 |
Header.tsx | 顶栏:搜索、主题与语言切换、侧边栏与属性面板开关、新建文件夹 / 新建书签。 |
Sidebar.tsx | 文件夹树、过滤视图(全部 / 收藏夹 / 稍后阅读 / 回收站)以及品牌页脚。 |
Toolbar.tsx | 面包屑、选择操作、撤销 / 重做、视图模式、排序。 |
ContentArea.tsx | 在 @dnd-kit 可排序上下文中以卡片 / 列表 / 平铺视图渲染子项。 |
BookmarkItem.tsx | 单个书签或文件夹卡片 / 列表行 / 平铺块,含封面、图标、颜色与内联重命名。 |
SortableBookmarkItem.tsx | BookmarkItem 的 @dnd-kit 可排序包装,含文件夹放置目标。 |
Inspector.tsx | 右侧面板,编辑选中项:标题、URL、颜色、封面、图标与元信息抓取。 |
SelectionToolbar.tsx | 浮动的多选操作条(收藏、稍后阅读、删除、恢复)。 |
SettingsModal.tsx | 应用设置对话框。 |
PanelResizer.tsx | 基于指针捕获的手柄,用于调整侧边栏与属性面板宽度。 |
ThemeSwitch.tsx | 供设置使用的 HeroUI Switch。 |
src/core/ | 无框架领域逻辑与 React hooks(见领域模块一节)。 |
src/i18n/ | zh 与 en 的 i18next 资源。 |
数据模型位于 src/core/types.ts。节点有两种类型:folder(文件夹)与 bookmark(书签)。
interface Node { id: string; type: 'folder' | 'bookmark'; parentId: string | null; title: string; url?: string; // 仅书签 orderKey: string; // LexoRank 风格排序键 color?: string; // hex 颜色 coverUrl?: string; coverType?: 'none' | 'uploaded' | 'remote' | 'generated'; coverAssetId?: string; iconUrl?: string; iconAssetId?: string; iconSource?: 'favicon' | 'user' | 'apple-touch' | 'other'; notes?: string; tags?: string[]; isFavorite?: boolean; isReadLater?: boolean; createdAt: number; // Unix 时间戳,毫秒 updatedAt: number; deletedAt?: number | null; // 软删除}每个节点通过 parentId 属于且仅属于一个父节点。保留文件夹 root 是树的顶层,不能被移动或删除。
StorageData 是以 aurabookmarks_data 键持久化的结构。
interface StorageData { version: number; nodes: Record<string, Node>; assets: Record<string, Asset>; metadataCache: Record<string, UrlMetadataCache>; settings: { theme: 'light' | 'dark' | 'system'; locale: 'zh' | 'en'; viewMode: 'list' | 'card' | 'tile'; sidebarOpen: boolean; autoExpandTree: boolean; cardFolderPreviewSize: '2x2' | '3x3' | '4x3'; customColors: string[]; defaultViewMode: 'list' | 'card' | 'tile'; rememberFolderView: boolean; folderViewModes: Record<string, string>; themeColor: string; singleClickAction: 'select' | 'open'; cardColumnsDesktop: number; cardColumnsMobile: number; tileColumnsDesktop: number; tileColumnsMobile: number; };}| 设置项 | 默认值 | 说明 |
|---|---|---|
theme | system | 浅色、深色或跟随系统。 |
locale | zh | zh 或 en。 |
viewMode | card | 当前文件夹的活动视图。 |
autoExpandTree | false | 侧边栏展开到当前文件夹。 |
cardFolderPreviewSize | 2x2 | 卡片 / 平铺视图中文件夹的封面预览网格。 |
customColors | [] | 用户自定义的颜色。 |
defaultViewMode | card | 进入无记忆视图的文件夹时使用的视图。 |
rememberFolderView | false | 是否按文件夹分别记忆视图。 |
themeColor | #3B82F6 | 强调色,驱动整套配色。 |
singleClickAction | select | 单击是选中还是打开。 |
cardColumnsDesktop | 4 | 桌面端卡片列数(2-9)。 |
cardColumnsMobile | 2 | 移动端卡片列数(1-4)。 |
tileColumnsDesktop | 4 | 桌面端平铺列数(1-7)。 |
tileColumnsMobile | 2 | 移动端平铺列数(1-2)。 |
src/core/ 按关注点组织,公开接口由 src/core/index.ts 统一导出。
| 模块 | 职责 |
|---|---|
types.ts | 领域类型与默认存储数据。 |
storage/ | StorageAdapter 与 getStorage() 单例。 |
orderKey.ts | LexoRank 风格排序键:generateOrderKey、generateOrderKeys、rebalanceOrderKeys。 |
cycleDetection.ts | detectCycle、detectCycleForMultiple、getDescendantIds、getAncestorIds、buildBreadcrumbs。 |
utils.ts | generateId、debounce、throttle、URL 与 HTML 工具、哈希、Data URL。 |
validation.ts | 针对用户提供的文件与 URL 的 Zod 校验与限制。 |
importExport/ | Netscape HTML 书签解析器与导出器。 |
metadata/ | 带 SSRF 校验与缓存的远程元信息与图标抓取器。 |
hooks/ | React hooks:useStorage、useNodes、useChildNodes、useNodeActions、useSettings、useTheme、useViewMode、useLocale、useSelection、useKeyboardShortcuts、useHistory。 |
src/core/storage/localStorage.ts 实现 StorageAdapter,这是基于 LocalStorage 的轻量可变层。
loadFromStorage()解析并规范化aurabookmarks_data:合并默认值、将旧版grid视图迁移为card、裁剪列数、删除旧版gridColumns字段。save()在写入前刷新 map 与对象引用,然后通知订阅者,确保从适配器派生出的 React 状态可靠更新。- 每次变更(创建、更新、移动、删除、恢复、设置)都经过
save()。 - 删除默认是软删除:
deleteNodes会设置deletedAt,除非传入hard: true。root节点始终被排除在外。
适配器暴露 subscribe(listener) 方法。useNodes 等 React hooks 通过它来在变化时重渲染,而不需要额外的状态库。
基于 LexoRank 风格的排序
Section titled “基于 LexoRank 风格的排序”orderKey.ts 实现类似 LexoRank 的排序键,使项目可以插入到任意相邻项之间而无需重排。moveNodes 通过计算前后兄弟键的中点,为目标插入位置生成新键。generateOrderKeys 为导入等批量操作生成连续的一段键。
移动之前,detectCycleForMultiple 会从目标父节点向上回溯到根目录,如果该移动会把文件夹放到自身或其子孙内部,则拒绝该移动。检测到循环时 moveNodes 返回 false,UI 会让项目保持原位。
hooks/useSelection.ts 以单个锚点实现文件管理器风格的选择语义,用于 Shift 范围选择。handleItemClick 将点击事件映射为 selectOne、toggleSelect(Ctrl / Cmd)或 selectRange(Shift)。getSelectionInfo 汇总当前选择(数量、是否包含文件夹或书签)。
hooks/useKeyboard.ts 绑定 useKeyboardShortcuts,将事件派发到 App.tsx 提供的回调。当用户在输入框内输入或正在重命名时,快捷键会被抑制。完整列表见前端指南。
历史(撤销 / 重做)
Section titled “历史(撤销 / 重做)”hooks/useHistory.ts 实现有界的撤销 / 重做栈。每个条目携带 undo 与 redo 闭包。具有相同 mergeKey 的条目会合并,因此连续的同类型编辑只占用一步撤销。默认上限为 100 步。
importExport/htmlParser.ts解析 Netscape 书签 HTML(DL/DT/<H3>/<A>),提取标题、URL、标签、图标与备注,并转换为节点。importExport/htmlExporter.ts在三种导出范围(all、folder、selection)下生成相同格式并触发下载。
metadata/fetcher.ts 抓取书签 URL 的标题、描述、Open Graph 与 Twitter 图片以及 favicon。它实施了几项防护:
- 只允许
http/httpsURL。 - 拒绝私有与回环网络地址(SSRF 防护)。
- 限制响应规模(5 秒超时、2 MB 上限、读到
</head>即停止)。 createMetadataFetcher包装器将结果缓存到metadataCache,缓存 24 小时。
由于抓取在浏览器内执行,部分未发送 CORS 头的网站会失败。此时 UI 会回退到 favicon 试探(getFaviconUrl 或 Google 的 favicon 服务)。
validation.ts 为用户输入定义 Zod 校验:
htmlFileSchema:.html/.htm,最大 5 MB。imageFileSchema:最大 200 KB,类型为png/jpeg/webp/svg。httpUrlSchema:合法的http/httpsURL。
状态与数据流
Section titled “状态与数据流”App.tsx 是编排的唯一所有者。它持有导航状态、选择、设置、视图路由、拖拽上下文与弹窗,并将数据与回调传递给组件。
视图路由支持四种视图:
bookmarks— 当前文件夹的内容。favorites—isFavorite为真的节点。readLater—isReadLater为真的节点。trash— 软删除(deletedAt)的节点。
活动视图与排序方式会先过滤、排序 currentChildren 再渲染。搜索根据 searchScope 作用于全部书签或当前文件夹。
| 决策 | 选择 | 备选 | 理由 |
|---|---|---|---|
| 运行形态 | 单页 React 应用、无后端 | 客户端-服务端应用 | 数据私密、可离线、无需为数据层建设部署基础设施。 |
| 持久化 | LocalStorage + 内存适配器 | SQLite / IndexedDB / D1 | 零配置且足以支撑个人书签库;适配器边界为日后更换存储保留空间。 |
| 核心 / UI 拆分 | 无框架的 src/core | 领域逻辑放在 React 组件内 | 纯模块无需 DOM 即可单测,且便于 UI 复用。 |
| 排序 | LexoRank 风格排序键 | 每次插入重新编号 | 相邻插入永不重写兄弟键,重排保持廉价且确定。 |
| 删除 | 软删除进入回收站 | 硬删除 | 允许用户恢复误删;hard: true 用于永久删除。 |
| 选择 | 单一锚点 + Shift 范围 | Redux 风格选择 store | 符合文件管理器行为,逻辑集中在独立 hook 中。 |
| 导入格式 | Netscape HTML | JSON / CSV | 浏览器原生导出格式,可导入任意现代浏览器。 |
| 元信息抓取 | 客户端 + SSRF 与大小防护 | 服务端代理 | 无需维护服务端;防护限制客户端抓取器的滥用风险。 |
主题使用 Tailwind 4 的 @theme inline 映射,加上 App.tsx 设置的运行时 CSS 变量。
src/styles/index.css通过@theme inline将--color-primary-*Tailwind 颜色映射到运行时 RGB 变量。App.tsx在themeColor变化时,从用户的主题色推导整套配色(50-950 色阶、强调色、焦点色、前景色)。- 暗色模式通过在文档根元素上切换
.dark类实现;@custom-variant dark声明让 Tailwind 的dark:变体与之匹配。 - 面板宽度由
App.tsx状态设置的--sidebar-width与--inspector-widthCSS 变量驱动。
src/i18n/index.ts 以 zh 与 en 资源初始化 i18next,资源来自 src/i18n/translations/。资源是带类型的 TypeScript 模块:en.ts 以从 zh.ts 派生的 Translation 类型作为类型依据,因此缺失或多余的键会在编译期报错。当前语言从设置读取,并以浏览器语言作为回退。