
我一直觉得一个看似简单的项目名背后往往藏着开发者最真实的需求和思考。这次要聊的“Rea”初看只是一个缩写但真正落地时它牵扯出的是关于前端工程化、组件封装、跨端复用、性能优化等一系列绕不开的问题。这篇文章我会把整个项目的来龙去脉、技术选型逻辑、核心代码实现以及我踩过的坑和排查过程全都摊开来讲。不管你是想做一个类似的工具还是想了解一个前端项目从0到1的完整思考路径这篇文章都应该能给你一些参考。1. 项目整体设计与思路拆解1.1 核心需求解析从一个模糊缩写到功能边界“Rea”这个名字最初并不是一个深思熟虑的品牌名更多是“React Enterprise Application”的缩写灵感。我的核心诉求不是做一个简单的Demo而是一套能支撑中型团队、多业务线并行开发的前端基础架构。这类架构在行业内常被称作“脚手架”或“工程化底座”但实际做起来它远比“npm create”生成一个模板要复杂得多。我把需求拆解成四个层面业务层面团队同时维护着三个以上的Web应用涉及数据看板、内容管理后台、用户权限中心这些应用有大量相似的页面和交互逻辑。如果每个应用都独立维护一套UI组件和工具函数人力浪费会非常明显。工程层面需要统一的技术栈React TypeScript、统一的代码规范ESLint Prettier、统一的构建流程Vite或Webpack。更重要的是组件和工具函数必须能跨项目复用而不是通过复制粘贴。体验层面权限控制要细化到按钮级别主题切换不能“一闪而过”大数据列表页面滚动必须流畅国际化文案要能从后端动态拉取。维护层面代码提交要有规范Commitlint发布版本要有清晰的Changelog组件库的文档和Demo必须和代码同步更新避免“文档过期”。所以“Rea”在项目初期本质上是一个决策框架它决定了我们后续所有开发的边界和准则。当你把这些问题想清楚后面的一切都是执行问题。1.2 技术选型背后的为什么技术选型是这类项目最容易被争论的部分。我的原则是“团队熟悉度优先性能其次生态兜底”。React 18 TypeScript团队主力栈TypeScript的静态类型检查在多人协作时能避免大量低级错误。React 18的并发特性如startTransition对于后续优化大数据列表渲染非常有用。Vite选Vite而不是Webpack核心原因是开发体验。Vite基于ESModule的按需编译冷启动速度是Webpack的几倍以上这在项目体积变大后体验差异极其明显。虽然Vite在大型项目构建时可能遇到一些兼容性问题下文会提到但整体利大于弊。Monorepopnpm workspace这是“Rea”区别于普通脚手架的关键。我把packages/ui组件库、packages/utils工具函数、apps/admin后台应用、apps/portal门户应用放在同一个仓库里。好处是跨项目复用代码时不再需要npm link或者发布到私有npm仓库直接引用workspace包即可改动一处所有引用的地方立刻生效。状态管理没有选择Redux而是用了Zustand。理由很简单Redux的样板代码太多对于一个需要快速迭代的业务团队来说心智负担太重。Zustand的API极其简洁且基于useSyncExternalStore与React 18的并发特性完美兼容。CSS方案最终选了CSS Modules CSS Variables。没有用Tailwind或CSS-in-JS因为我们的业务需要运行时动态换肤不同客户有不同品牌色CSS变量是最轻量的方案而CSS Modules能保证样式隔离避免组件库样式污染宿主应用。提示 技术选型没有“最好的”只有“最合适的”。如果你是一个3人小团队做内部工具完全没必要搞Monorepo和复杂的权限模型但如果你的目标是沉淀一套可复用的资产那这些复杂度是值得提前投入的。1.3 整体架构的层次划分架构上我参考了“洋葱模型”的分层思想但做了简化。整体分成四层每层职责单一依赖方向从上到下层级模块核心职责典型依赖应用层apps/admin、apps/portal页面组装、路由配置、业务逻辑编排业务组件、领域Hooks业务组件层packages/ui/src/biz带业务语义的组件如UserTable、AuthButton基础组件、Utils基础组件层packages/ui/src/base无业务语义的通用组件如Button、Modal、TableUtils、仅React基础设施层packages/utils、packages/hooks纯函数工具、通用Hooks如usePagination仅TypeScript这样的分层带来一个显著的收益当你需要修改一个通用Table组件的样式时你不会意外影响到另一个业务模块的特定逻辑。同时依赖方向是单向的循环依赖问题会大幅减少。2. 核心细节解析与实操要点2.1 权限控制从路由守卫到按钮级控制权限是所有中后台系统绕不开的痛点。“Rea”的权限模型没有用复杂的CASL或其他权限库而是基于“用户 - 角色 - 权限点”的经典RBAC模型结合前端路由和指令级组件实现。路由级权限的实现核心在于三层后端在用户登录时返回当前用户拥有的权限码数组如[dashboard:view, user:create, user:delete]。前端路由表在Meta中声明permission字段例如// apps/admin/src/router/routes.ts const routes: RouteRecordRaw[] [ { path: /user, name: UserManagement, component: () import(/views/user/index.vue), // 示例实际是TSX meta: { title: 用户管理, permission: user:view, // 访问该路由需要的权限码 }, }, ];在全局路由守卫中匹配meta.permission如果没有对应权限码则重定向到403页面。按钮级权限我用了一个封装好的Auth组件内部通过一个usePermission的Hook判断。这样业务代码里就不会到处是if (hasPermission(user:create))这样的条件判断代码会干净很多。// packages/hooks/src/usePermission.ts import { useAuthStore } from /store/auth; export function usePermission() { const permissionCodes useAuthStore((state) state.permissionCodes); const hasPermission (code: string) { if (permissionCodes.includes(*)) return true; // 超级管理员 return permissionCodes.includes(code); }; return { hasPermission }; }注意前端权限控制永远只是体验层面的优化真正的安全校验必须以后端接口为准。不要因为前端隐藏了某个按钮就觉得后端不需要校验权限了。攻击者完全可以绕过前端直接调用接口。2.2 主题切换CSS变量与运行时替换主题切换是另一个经常被低估复杂度的模块。很多团队用切换className的方式实现但那样会带来样式覆盖的优先级灾难。我的方案是彻底拥抱CSS变量Custom Properties。我在:root选择器下定义了一套默认的主题变量然后通过>/* packages/ui/src/styles/theme.css */ :root { --primary-color: #1677ff; --text-color: rgba(0, 0, 0, 0.88); --bg-color: #ffffff; } :root[data-themedark] { --primary-color: #4c9aff; --text-color: rgba(255, 255, 255, 0.85); --bg-color: #141414; }所有组件里的颜色都不再写死而是引用这些变量/* packages/ui/src/base/Button/style.css */ .btn { background-color: var(--primary-color); color: var(--text-color); }切换主题时我只需要动态改变根节点的>// packages/utils/src/theme.ts export function applyTheme(theme: light | dark) { document.documentElement.setAttribute(data-theme, theme); localStorage.setItem(app-theme, theme); }为了配合动态换肤后端同学配合提供了一个接口允许不同租户配置自己的品牌色。前端在应用启动时拉取配置合并到--primary-color之上。这就是CSS变量最大的价值把视觉定义权从代码中剥离出来变成运行时数据。2.3 国际化动态文案与组件解耦国际化i18n不仅仅是把中文替换成英文它会导致一个非常棘手的问题组件内部如果带有文案就无法和文案资源解耦。我的做法是所有的文案资源都被抽成独立文件并以“命名空间”区分// packages/ui/src/locales/zh-CN.ts export default { ui.button.confirm: 确定, ui.table.empty: 暂无数据, ui.modal.close: 关闭, }; // packages/ui/src/locales/en-US.ts export default { ui.button.confirm: Confirm, ui.table.empty: No Data, ui.modal.close: Close, };组件内不直接写中文而是通过一个useLocale的Hook获取当前语言环境下的文案// packages/ui/src/base/Modal/index.tsx import { useLocale } from /hooks/useLocale; export function Modal({ open, onClose, children }) { const { t } useLocale(); return ( div className{...} button onClick{onClose}{t(ui.modal.close)}/button {children} /div ); }应用层的业务文案则放在各自的语言包里。由于采用Monorepo组件库的语言包和应用层的语言包可以独立维护互不干扰。切换语言时需要同时变更两个层级的文案资源这在实践中需要格外细心很容易出现“组件变成英文了但业务菜单还是中文”的尴尬局面。我的解决方案是提供一个统一的LocaleProvider它负责收集所有层级的语言包然后下发到各个模块。2.4 通用组件封装原则保持扁平避免“上帝组件”做组件库最忌讳的是把所有功能塞进一个组件里。比如一个Table组件如果既要支持排序、过滤、分页、树形展示、拖拽列宽又要支持单元格合并、行选择、自定义操作列那这个组件的props会膨胀到难以维护最终变成一个谁也不敢动的“上帝组件”。我的封装原则有三条单一职责组件只做一件事不要尝试在一次迭代中满足所有人的要求。组合优于配置与其给Table加一个enableRowSelection布尔值不如提供一个SelectionTable组件它内部组合了Table和Checkbox。受控与半受控尽量让组件支持受控模式外部传入value和onChange这样业务代码可以灵活控制组件状态。举一个搜索表单的例子。业务里经常有“筛选条件”区域输入框、下拉框、日期选择器一应俱全。如果我把这些全部封装成一个庞大的SearchForm组件配置化渲染那业务方确实省了代码但代价是灵活性丧失殆尽。我的做法是只提供一个FormLayout组件负责处理栅格布局和间距具体的表单控件由业务侧自由组合。这样既保证了视觉上的整洁统一又没有限制业务方的发挥空间。3. 实操过程与核心环节实现3.1 从零初始化Monorepo工程Monorepo的搭建是整个项目的基石。我使用的是pnpm作为包管理器配合pnpm-workspace.yaml文件定义工作区。# pnpm-workspace.yaml packages: - apps/* - packages/*根目录的package.json里我只保留了公共的开发依赖和脚本命令实际应用和包的依赖都由各子包自行管理。{ name: rea-monorepo, private: true, scripts: { dev:admin: pnpm --filter rea/admin dev, build:admin: pnpm --filter rea/admin build, build:ui: pnpm --filter rea/ui build, lint: eslint \{apps,packages}/**/*.{ts,tsx}\ }, devDependencies: { typescript: ^5.3.3, eslint: ^8.56.0, prettier: ^3.1.1 } }这里有个关键细节子包之间的依赖声明。比如rea/admin依赖rea/ui在apps/admin/package.json中需要注明{ dependencies: { rea/ui: workspace:* } }pnpm的workspace:*协议会自动将本地的rea/ui包链接过来而不用每次都执行pnpm publish。开发时改动rea/ui的源码admin应用能够即时生效因为Vite直接编译了源码而不是编译后的产物。注意 在Monorepo中组件库的构建模式需要特别留意。如果你改的是rea/ui的src目录而admin应用引用的是rea/ui/dist目录即构建产物那么开发时改动不会生效。因此在开发环境下我会把组件库的package.json里main字段指向src/index.ts而在发布时再用构建脚本切换到dist目录。这是一个非常容易踩的坑。3.2 构建配置Vite插件与微调Vite的配置是“Rea”工程链中比较关键的一环。除了常规的vitejs/plugin-react之外我还做了几个微调。路径别名在apps/admin/vite.config.ts中配置指向src。// apps/admin/vite.config.ts import { defineConfig } from vite; import react from vitejs/plugin-react; import path from path; export default defineConfig({ plugins: [react()], resolve: { alias: { : path.resolve(__dirname, src), }, }, });组件库样式按需加载为了减少首屏样式体积我没有让组件库一次性引入所有样式。而是在组件库内部每个基础组件都import自己的style.css。Vite在构建时会自动分析依赖将用到的组件样式单独打包实现样式层面的“按需加载”。构建缓存与CHUNK拆分生产构建时我会手动配置rollupOptions.output.manualChunks把react、react-dom、zustand等稳定依赖拆分到独立Chunk这样浏览器缓存命中率能提升不少。build: { rollupOptions: { output: { manualChunks: { react: [react, react-dom], zustand: [zustand], }, }, }, },3.3 核心代码实现一个优雅的usePagination Hook分页是几乎所有列表页面的刚需。我封装了一个usePaginationHook它统一管理了分页状态、请求参数和列表数据。这个Hook是“Rea”里复用率最高的工具函数之一。// packages/hooks/src/usePagination.ts import { useState, useCallback, useEffect } from react; interface PaginationOptionsT { fetcher: (params: { page: number; pageSize: number }) Promise{ list: T[]; total: number }; initialPage?: number; initialPageSize?: number; } export function usePaginationT({ fetcher, initialPage 1, initialPageSize 10 }: PaginationOptionsT) { const [data, setData] useStateT[]([]); const [loading, setLoading] useState(false); const [page, setPage] useState(initialPage); const [pageSize, setPageSize] useState(initialPageSize); const [total, setTotal] useState(0); const loadData useCallback(async () { setLoading(true); try { const res await fetcher({ page, pageSize }); setData(res.list); setTotal(res.total); } finally { setLoading(false); } }, [fetcher, page, pageSize]); useEffect(() { loadData(); }, [loadData]); const refresh useCallback(() { loadData(); }, [loadData]); const setCurrentPage (p: number) setPage(p); const setCurrentPageSize (s: number) { setPageSize(s); setPage(1); // 改变每页条数时重置到第一页 }; return { data, loading, total, page, pageSize, setCurrentPage, setCurrentPageSize, refresh }; }使用的时候const { data, total, loading, page, pageSize, setCurrentPage, setCurrentPageSize } usePagination({ fetcher: (params) fetchUserList(params), });这个Hook的精髓在于它把请求状态与UI状态彻底解耦。业务页面不需要再关心loading如何赋值等问题只需专注于渲染data。3.4 核心代码实现高性能虚拟列表中后台系统经常遇到上千条数据渲染的场景直接map渲染会卡死页面。我实现了一个轻量级的虚拟列表组件只渲染可视区域内的行大幅提升滚动性能。核心思路是基于scrollTop和容器高度计算起始索引和结束索引。// packages/ui/src/base/VirtualList/index.tsx import { useRef, useState, useEffect, useCallback, ReactNode } from react; interface VirtualListPropsT { items: T[]; height: number; itemHeight: number; renderItem: (item: T) ReactNode; } export function VirtualListT({ items, height, itemHeight, renderItem }: VirtualListPropsT) { const containerRef useRefHTMLDivElement(null); const [scrollTop, setScrollTop] useState(0); const totalHeight items.length * itemHeight; const startIndex Math.max(0, Math.floor(scrollTop / itemHeight) - 2); // buffer const endIndex Math.min(items.length - 1, Math.floor((scrollTop height) / itemHeight) 2); const visibleItems items.slice(startIndex, endIndex 1); const handleScroll useCallback(() { if (containerRef.current) { setScrollTop(containerRef.current.scrollTop); } }, []); useEffect(() { const container containerRef.current; if (!container) return; container.addEventListener(scroll, handleScroll, { passive: true }); return () container.removeEventListener(scroll, handleScroll); }, [handleScroll]); return ( div ref{containerRef} style{{ overflowY: auto, height }} classNamevirtual-list div style{{ position: relative, height: totalHeight }} div style{{ position: absolute, top: startIndex * itemHeight, left: 0, right: 0, }} {visibleItems.map((item, index) ( div key{index} style{{ height: itemHeight }} {renderItem(item)} /div ))} /div /div /div ); }这里有几个细节值得解释动态高度问题如果列表项高度不固定虚拟列表的复杂度会直线上升。我的策略是给每个列表项一个预估高度渲染后再通过ResizeObserver修正实际值。但为了简单这个版本假设了固定高度。实际业务中如果高度变化很大建议直接用成熟的方案如社区内的虚拟滚动库。缓冲区域在计算起始索引时往前多渲染2个、往后多渲染2个避免快速滚动时出现短暂白屏。3.5 状态驱动UI基于角色的菜单生成菜单在“Rea”里不是静态配置的而是根据登录用户的权限动态生成的。后端在登录接口中返回了菜单树结构前端拿到之后动态注册路由和渲染菜单。// apps/admin/src/store/auth.ts interface UserInfo { name: string; roles: string[]; menus: MenuItem[]; } interface MenuItem { id: string; title: string; path: string; icon: string; children?: MenuItem[]; }前端在路由初始化时不再使用静态的routes而是将后端的菜单数据映射为Vue/React路由结构。这样菜单和路由由同一个数据源驱动不会出现“菜单有显示但路由未注册”的不一致问题。在渲染侧我用了一个递归组件主要是为了处理多级菜单。菜单每一项的显示与否完全取决于它是否出现在用户数据中。4. 常见问题与排查技巧实录4.1 启动报错Module not found: ESM packages这是使用Vite Monorepo最常见的痛点。当你在本地开发时某个依赖包通常是React或Zustand的版本在两个子包中不一致会导致“Duplicate React”或者莫名其妙的ESM错误。我遇到的报错是[vite] Internal server error: Failed to resolve import react/jsx-runtime from ...排查过程先检查node_modules中是否出现了多个react副本。使用pnpm why react命令查看依赖树发现admin应用依赖react18.2.0而ui包某个间接依赖锁定了react18.2.0的另一个版本实际上是因为peerDependency导致重复安装。解决方案在根目录的package.json中添加resolutions字段或使用pnpm.overrides来强制统一React版本。{ pnpm: { overrides: { react: 18.2.0, react-dom: 18.2.0 } } }在Monorepo中统一核心依赖版本是铁律。不要在子包里随意安装不同版本的React、Vue或其它框架级依赖否则你在排查问题时会被各种诡异错误折磨。4.2 样式隔离失效CSS Modules与全局样式纠缠在使用CSS Modules的过程中我遇到过组件库样式被外部某个全局CSS覆盖的问题。原因是一位同事在应用层写了一个全局样式/* apps/admin/src/styles/global.css */ button { border-radius: 0; }这个全局选择器直接影响了组件库中Button组件的圆角。排查困难是因为组件库单独开发环境下没有这个全局样式一切正常联调时问题才暴露。解决办法有两个方向约定应用层的全局样式只用于重置基础样式Reset不允许对具体HTML标签定义视觉样式。加固组件库的每个根节点都有自己独一无二的className前缀例如rea-btn以此增加选择器权重并在最前面引入Css Baseline类似Reset。最终我两者都做了。在组件库里我引入了一份精简的base-reset.css确保组件在不同宿主环境下的渲染一致性同时要求应用层不要写全局标签选择器。4.3 数据缓存陷阱列表页返回时状态丢失另一个高频问题是从列表页点击“详情”跳转再返回时列表页的页码、筛选条件、滚动位置全部丢失用户必须重新从头排查体验很糟糕。这其实是一个“前端路由缓存”问题。React Router默认情况下组件卸载后状态就会被销毁。我的解决方案是使用keepalive思想。在React中实现Keepalive比Vue要麻烦一些。我没有采用复杂的缓存库而是用zustand把列表页的关键状态筛选参数、分页信息、滚动位置提升到全局Store中在组件卸载时保存、重新挂载时恢复。// apps/admin/src/store/userList.ts interface UserListState { filters: Recordstring, any; page: number; pageSize: number; scrollTop: number; setFilters: (filters: Recordstring, any) void; setPage: (page: number) void; setScrollTop: (scrollTop: number) void; }这样一来列表页的组件本身是无状态的它只是全局状态的“投影”。这种方法对业务侵入小且可维护性强比引入一个重型的缓存路由库要轻量得多。4.4 构建速度优化从15分钟到3分钟项目运转一段时间之后生产构建越来越慢最后到了15分钟级别严重拖累发布效率。我做了三步优化构建缓存开启Vite的build.cacheDir让构建过程中依赖的二次编译结果可以被缓存。多进程压缩使用vite-plugin-compression插件并指定terserOptions的多进程参数。后来换用esbuild作为压缩器比Terser快了好几倍。忽略无用文件在tsconfig中设置include字段确保TypeScript编译器只处理应用的源码路径不去扫描node_modules里的类型声明。优化后的构建时间稳定在3分钟以内。核心收益来自于减少工作量而不是单纯提升单次计算速度。4.5 网络请求竞态快速切换Tab时响应错乱这是个大坑。在“数据看板”页面用户快速切换Tab每个Tab都有对应的异步请求。但异步响应回来的顺序不一定和请求顺序一致导致先发出去的请求后返回把后发出的数据覆盖了。排查时发现组件卸载后网络请求的回调仍然触发了setState。React 18中这不会直接报错旧版本会警告但会导致状态污染。解决思路将数据请求与组件生命周期解耦请求的结果放入全局Store页面渲染时从Store读取某个Tab对应的数据。或者在请求层增加一个“请求序列号”校验只有最新序列号的请求才能写入状态。我最终选择了方案二封装了一个简单的useRequestSequence// packages/hooks/src/useRequestSequence.ts import { useRef, useCallback } from react; export function useRequestSequenceT() { const seqRef useRef(0); const run useCallback(async (request: () PromiseT, onSuccess: (data: T) void) { const seq seqRef.current; try { const data await request(); if (seq seqRef.current) { onSuccess(data); } } catch (error) { if (seq seqRef.current) { throw error; } } }, []); return { run }; }在Tab切换时每次请求都会递增序列号旧请求即使返回了也会被丢弃。5. 经验沉淀与扩展思考5.1 组件库文档的自动同步组件库如果没有良好的文档团队成员很快就会弃用或者重复造轮子。“Rea”使用了一种“文档即Demo”的方式。每个组件的examples目录就是一个可运行的Demo同时它被自动抓取到文档站的对应位置。每次修改组件代码或Demo文档站更新后显示的内容始终与最新代码保持一致。这个思路源于Storybook但实现起来更轻只要约定目录结构和自动扫描规则即可。5.2 代码生成的辅助工具在一个大型项目里重复劳动不可避免。写一个新页面要新建路由、创建Store、写API请求函数、写列表页模板。为了快速生成这些样板代码我写了一个名为generate-page的脚本。运行如node scripts/generate-page.ts user/list它就会自动生成页面文件夹、对应的路由配置、初始Store文件和API封装。它不只是简单的“复制粘贴模板”而会根据页面类型列表页/详情页/表单页预置不同的初始结构。这种自动化脚本的价值在于它把团队规范直接固化在工具里新同事加入时不需要读长篇大论的设计文档而是直接运行命令生成符合规范的模板代码极大降低了上手门槛。5.3 关于“React Enterprise Application”的一些思考做完整个项目我的体会是前端架构不是一蹴而就的它是在一次次重构和换血中逐步长出来的。“Rea”这个项目最大的收获不是某个UI组件写得多漂亮而是一个可演进的架构底座。它允许我们在不推翻整体的前提下持续加入新能力比如将来接入微前端、或替换构建工具。如果让我重新做一次我可能会在更早的时间点引入设计Tokens体系。现在虽然使用了CSS变量管理视觉风格但业务组件里仍然有一些硬编码的颜色值隐藏在某个角落。设计Tokens可以在设计侧和代码侧形成一一对应关系让视觉变更的工程量进一步降低。这是一个值得后续继续深挖的方向。“Rea”目前还在持续迭代。从最初的一个模糊缩写到现在组件数量超过40个、覆盖3个业务系统的基础设施这条路走下来踩过的坑几乎都写在了这篇文章里。希望这些经验能帮你少走一些弯路。