项目:react-ui | 版本:v1.1 | 生成日期:2026-10-06 | 依据:源码分析
这个库的性能工作有一个鲜明的特点:几乎每一项优化旁边都留着实测数据的注释——Button 的 gzip 体积对比、CSS 前缀的丢失清单、worker 数与 105 分钟的假失败、文档站入口 20.6MB 到 13.7MB 的图标治理。本篇把这些散落在源码注释里的性能决策串成三条链路:产物链(构建期,决定消费方装进包里的东西有多大、能不能摇树)、渲染链(运行时,决定重渲染的次数与范围)、主题链(CSS 级联,决定换肤要不要付 JS 成本)。每条链先讲已落地的机制,再讲边界与遗留。《05-核心实现流程》的六个主题已覆盖实现细节的部分(Box 管线、虚拟滚动测量、排序管线),本篇在性能视角下只引用不重述。
三个可发布包的构建形态一致(packages/ui/vite.config.ts、packages/hooks/vite.config.ts、packages/@xiaoye-react/pro/vite.config.ts),三个决策层层递进:
决策一:preserveModules 保住模块边界。 ui 配置的注释原文给出了事故复盘:「不再把 1400 个模块压成单个 index.js。模块边界一旦消失,下游打包器就摇不动树——实测 import { Button } 从 12 KB gzip 涨到 327 KB gzip,且会把 rrule / react-dropzone 这些用不到的实现一起带进消费方产物」。这个 27 倍的差距是全库性能注释里最重的一笔:单文件产物意味着任何一个具名导入都会把整包(含日期、日程、颜色、文件上传的完整实现)拖进消费方 bundle,摇树权被构建配置没收。修复方式是不拆 chunk 而是保模块——preserveModules: true + preserveModulesRoot: 'src',ESM 输出按源码目录结构铺开成上千个小文件,把「删掉没用到的模块」这件事交还给下游打包器。hooks 配置里同样写着「只 import 一个 use-hotkeys 也要背上全部 87 个 hook」的镜像表述(87 为注释口径,实测 hook 目录 83 个,口径差见第 13 篇)。配套的还有 files: ["es"](发出去的就是构建目录)与 dumi 时代的通配符导出废除——scripts/validate-package-exports.mjs 头注释记录:单文件时代 ./es/* 导出下每个组件目录只有 .d.ts,import x from '@xiaoye-react/ui/es/components/Button/Button' 类型检查通过、运行时 404;preserveModules 后运行时文件虽已存在,通配符仍被禁(把内部结构变成公开 API),只允许显式子路径。
决策二:运行时依赖外部化。 isExternal 按包名段匹配 dependencies + peerDependencies + react/react-dom(rollup 的 external 数组只精确匹配 id,而源码有 dayjs/plugin/utc 这类子路径,所以按段判定)。安全理由是行为性的:打进产物等于下游装两份实例,而 dayjs 的 extend/locale 与 @emotion/cache 的插入点都是全局状态,双实例即 bug(dayjs locale 互相覆盖、emotion 样式插错位置);体积理由则是外部化让依赖的摇树权也归下游。
决策三:minify 保持开启。 配置注释实测:minify 不影响摇树(Button 12,425 B gzip vs 关掉压缩的 12,423 B,差距 2 字节),但关掉会让 Vite 的 CSS 压缩链停摆,style.css 丢 8 处 -webkit/-moz 前缀(实测 appearance 属性)——「体积直觉」在这条注释面前被数据修正:想省体积去摇树,别去关压缩。pro 包差异点:只产 ESM(formats: ['es']),外部化含两个 peer 包。
packages/ui/src/core/styles-api/use-styles/use-styles.ts 的注释把这个缓存的动因写得很具体:「getStyles 每次都重跑 varsResolver / 主题 vars / filterProps 与 8 个 className 辅助函数,而 DataTable 这类组件每行每格都会重复请求同一批选择器(tr、td……),虚拟滚动下等于每帧重复解析上百次」。实现是函数体内的一个 Map<string, ReturnType<typeof computeStyles>>——注意它是每次渲染新建的 Map,因此缓存的生命周期严格等于一次渲染,不会跨渲染产生陈旧值;命中条件是「不带 options 的调用」(带 options 时结果依赖 options,注释明确「不适合按选择器复用」)。这是一个「按次渲染 memo」而非「跨渲染 memo」的设计:与 useMemo 相比它省的是同一次渲染内的重复计算(DataTable 的每行每个单元格都会请求自己的 getStyles),与全量缓存相比它零失效逻辑。同类思路也出现在 varsResolver 的消费端——get-style 内部对主题 vars 的合并。消费侧的对称设计是 use-resolved-styles-api:复合组件把 getStyles 挂上 context 供子件复用,避免每个子件重新装配解析链。
重渲染控制的第一性原理是「别让下游的 memo/useMemo 失效」。散布在源码里的落点按机制归四类:
快路径保引用:core/UIProvider/use-props/use-props.ts(45 行)的注释写明——「此前这里每次都新建 5~6 个对象,既白花时间又破坏 props 引用身份,让下游所有 useMemo/React.memo 判定永远命中不了」,于是无默认值时原样返回 props。core/store/store.ts 用闭包而非 this 实现 store,注释理由是「解构使用(const { setState } = store)时不会丢失上下文」。useUncontrolled 的 setter 用 useCallback 稳定引用 + ref 转发最新 onChange(两个理由见《04-数据模型》第 2 节:下游依赖它的 useCallback/useMemo 会每帧失效)。
Set/Map 化热路径:DataTable 的 selectedKeysSet/rowKeysSet/expandedKeysSet 三个 useMemo Set(注释:数组 includes 在万级数据全选时近 O(n²));Combobox 的 selectedValuesSet(判定 O(1),避免逐选项 includes 的 O(n×m))与 optionIndexMap(每个选项 O(1) 查索引,避免逐实例 findIndex);Notifications 的 position 分桶 Map(getDistributedNotifications)。这是把「全选、高亮、分桶」三个万级场景从平方级拉回线性/常数的直接手段。
ref 回调缓存:getExpandedMeasureRef(key, rowIndex) 按 key+rowIndex 缓存 ref 回调(expandedRefCacheRef: Map),因为内联箭头会让 ref 每渲染 detach/attach——虚拟滚动下等于每帧对每个可见行强制重测 + 回流;DirectionProvider 的 DIR_MUTATION_OPTIONS 模块级常量固定 attributeFilter: ['dir'],避免内联字面量每次渲染进入依赖导致 observer 反复重连;DataTable 的 measureElement 直接传库函数引用而非内联包装。
稳定依赖收敛:eslint 棘轮注释里的治理史(145 → 78 → 57)本质是一场全仓的引用稳定性审计——把「每渲染新建的闭包」包一层 useEffectEvent 再进依赖、把 form 的五个子状态机改成「容器稳定 + 成员每渲染赋值」,其效果等价于把全库的 effect 重跑率压了一个数量级。
外部 store 的重渲染面:Notifications 与模态栈(notifications.store.ts、modal-stack.ts)把跨组件状态放在模块级 createStore 里,组件经 useSyncExternalStore(store.subscribe, () => store.getState()) 订阅。重渲染的粒度因此是「订阅了这个 store 的组件」而非「React 树上层的所有人」——notifications.show() 从请求拦截器里调用时,不经过任何 setState 链路,只有 Notifications 宿主组件重渲染。useSyncExternalStore 的第三参数(SSR 快照)同取 getState(),保证服务端渲染快照一致、不触发 hydration 重建。
主题切换的成本模型是本库性能设计的最大杠杆。UIProvider 把主题翻译成 --ui-* CSS 变量注入 :root(UICssVariables),亮/暗两套语义变量同时写入、按 data-ui-color-scheme 属性级联生效——切换色彩方案时 React 树不重渲染,浏览器只是重新计算样式与重绘。配套的三个细节把这条链锁死:只有根 Provider(parentContext === null)向 documentElement 写属性并订阅 matchMedia('(prefers-color-scheme: dark)')(嵌套实例互覆盖的竞态消除,见《01-系统架构》第 3.4 节);组件样式只消费变量(height: var(--button-height)),variant 差异全部落在变量层,JS 不为变体计算样式;useProps/useUncontrolled 的快路径保证主题相关 props 变化时引用语义可控。SSR 侧 ColorSchemeScript 在 HTML 解析期写属性,防 FOUC(闪一下的「白→暗」重绘)——这既是体验问题也是性能问题(避免整树二次渲染)。hooks 侧的对称设计是「响应式状态收敛在环境层」:窗口 resize、系统换肤、方向变化全部由 Provider/MutationObserver/matchMedia 订阅,业务组件拿到的永远是已解析的静态结果(《04-数据模型》第 7 节的四条纪律之一)。
两处非运行时但同样记录在案的性能工作。文档站启动优化(ADMIN-ROADMAP.md 记录):实测 mako 无持久化缓存、webpack fs cache 冷 21 分钟/热 146 秒均不划算,最终保留 mako 构建链,改为把 phosphor/react-icons 的图标 barrel 从编译图移出(438 个文件改写,入口包 20.6MB → 13.7MB,顺带修掉 15 个不存在的图标导入);apps/docs/.dumirc.ts 把 react-router alias 到本地补丁实现 apps/docs/patches/react-router-cache.ts(注释:卡顿的主要来源);scripts/ensure-ui-build.mjs 在文档站启动前比对源码与产物 mtime、陈旧自动重建(避免「静默跑旧代码」的另一种性能陷阱)。测试基建:packages/ui/vitest.config.ts 把线程钉在 4(105 分钟假失败 → 可复现红绿),dates/schedule 的 testTimeout 20s——这些都是「让门禁可信」的性能工程。
已落地的机制之外,还有几处可辨识的优化空间(按投入产出排序):
CodeHighlight.tsx 已用 useMemo 按 code 输入 memo 高亮结果(注释:同步 CPU 高亮按输入 memo,任意父级重渲染都重跑高亮会阻塞主线程),但大代码块的同步高亮本身仍在主线程——接入 Web Worker 型 adapter(shiki 的 async loadContext 已为异步 ctx 预留接口)是自然演进;useProps 快路径对无 defaultProps 组件生效,但复合组件传名称数组时每次渲染执行 reduce 合并;若 profile 显示热点,可对「主题未配置该组件」的常见情形加 memo 集;fullyParallel: true 未设 workers 上限,CI 侧可评估固定 workers 数以稳定时长(⚠️ 待确认 CI 实测时长分布);check:published-types 旁补一条产物体积快照对比。三条链路的优化都有一个共同前提:先度量再动手,而这个仓库注释里的实测数据给了三套现成的度量方法。产物链用「消费方最小复现」:在干净目录装发布版,写一个只 import 目标导出的入口,用打包器分析产物体积——12KB 与 327KB 的对比就是这么测出来的,任何关于「打没打进产物」的争论都该用这个方法一锤定音;渲染链用「依赖数组审计 + 性能面板」:先读 eslint 注释里的五分类判断某条警告属于哪类(契约型依赖不能补),再用 React DevTools Profiler 验证重渲染面是否真的收敛在预期节点——useStyles 缓存、Set 索引这类优化的收益只有在大列表场景才显著,小样本测不出来是正常结论而非方法失效;主题链用「样式重计算计数」:切一次色彩方案,React Profiler 里应该看不到组件树重渲染,浏览器 DevTools 的样式面板应该只发生级联重算——任何 JS 帧都说明主题链被污染(常见根因是业务组件把 colorScheme 读进了组件 state)。三条链的度量口径一旦建立,第 6 节的遗留项就能逐条量化收益,避免「为优化而优化」。
小结:产物链的哲学是「把权交给下游」(模块边界、依赖摇树、样式静态化);渲染链的哲学是「引用稳定 + 热路径数据结构化」;主题链的哲学是「能交给 CSS 级联的绝不过 React」。三句话背后都是同一个取向——性能优化在这个库里不是加缓存,而是消除不必要的计算发生点。