在上一篇文章
👉 《从零打造 AI 全栈应用(二):前端路由工程化设计与性能优化实践》
我们系统拆解了 我的AI 应用中前端路由的工程化设计。
但在真实项目中,工程能力并不只体现在“大模块”上,很多时候,一个看似简单的通用组件,反而最能体现一个前端工程师的基本功。
本文将以一个真实项目中的 BackToTop(返回顶部)组件 为例,聊一聊:
- 通用组件应该如何设计
- 状态如何做到“组件自洽”
- scroll 事件为什么一定要做性能优化
- 节流函数在工程中的正确位置
- 组件卸载时为什么一定要清理副作用
这是一个“小组件”,但背后是完整的工程化思维。
一、为什么 AI 应用中更需要 BackToTop
在 AI 应用中,尤其是以下场景:
- 对话列表不断增长(Chat)
- 历史记录页内容较长
- 订单、日志类页面信息密集
用户会频繁滚动页面,如果没有「返回顶部」能力:
- 体验割裂
- 操作成本高
- 在移动端尤其明显
BackToTop 本质是一个高频、通用、体验型组件,非常适合作为工程规范的样板。
二、组件设计目标拆解
在动手写代码前,先明确这个组件的设计目标:
- 通用组件(可复用)
- 内部自管理状态(不依赖外部)
- 滚动到一定阀值才显示
- scroll 高频事件需性能优化
- 组件卸载不留副作用(防内存泄漏)
带着这些目标,再来看代码,会非常清晰。
三、组件整体结构设计
1. Props 设计:最小但有扩展性
interface BackToTopProps {
// 滚动超过多少像素后显示按钮
threshold?: number;
}
这里只有一个 threshold,但设计非常合理:
- 有默认值(400)
- 调用方可按页面高度自由配置
- 不把“策略”写死在组件内部
这是通用组件的基本素养:可配置,但不过度设计。
2. 组件自有状态:isVisible
const [isVisible, setIsVisible] = useState<boolean>(false);
这里有一个非常重要的设计点:
BackToTop 不依赖外部状态管理(Redux / Zustand)
原因很简单:
- 显示与否只和当前滚动位置有关
- 属于典型的“局部 UI 状态”
- 放进全局状态反而增加复杂度
面试表达:
UI 状态优先组件内收敛,业务状态才上升到全局。
四、scroll 事件监听:性能是第一原则
1. 最容易踩的坑:直接监听 scroll
window.addEventListener('scroll', toggleVisibility);
scroll 事件有一个特点:
触发频率极高(远高于 click、resize)
如果每次滚动都触发 setState:
- 频繁触发 React 更新
- 造成不必要的重渲染
- 在低端设备上非常明显
结论:scroll 一定要做性能优化。
2. 节流(throttle)是最合适的方案
在我的项目中使用了节流:
const thtottled_func = throttle(toggleVisibility, 200);
window.addEventListener('scroll', thtottled_func);
这意味着:
- 每 200ms 最多执行一次逻辑
- 显著减少 setState 次数
- 用户感知几乎无差别
这是一个非常典型、正确的工程选择。
五、节流函数的工程化位置:utils 目录
import { throttle } from '@/utils';
这一行 import,其实很“值钱”。
为什么?
- 节流 / 防抖是横向通用能力
- 不应和某个组件强绑定
- 放在 utils 目录,天然可复用
工程化的本质,就是让“好代码”有合适的归属。
1. throttle 实现解析
export function throttle(fun: ThrottleFunction, delay: number): ThrottleFunction {
let last: number | undefined;
let deferTimer: NodeJS.Timeout | undefined;
return function (...args: any[]) {
const now = +new Date();
if (last && now < last + delay) {
clearTimeout(deferTimer);
deferTimer = setTimeout(function () {
last = now;
fun(args);
}, delay);
} else {
last = now;
fun(args);
}
};
}
这个实现结合了:
- 时间戳
- 定时器兜底
特点是:
- 首次立即执行
- 高频触发时延迟执行最后一次
面试加分点:
节流用于高频连续触发场景(scroll、mousemove),防抖更适合输入类场景。
六、useEffect 中的副作用管理:非常关键
useEffect(() => {
const toggleVisibility = () => {
setIsVisible(window.scrollY > threshold);
};
const thtottled_func = throttle(toggleVisibility, 200);
window.addEventListener('scroll', thtottled_func);
return () => {
window.removeEventListener('scroll', thtottled_func);
};
}, [threshold]);
这里有两个非常正确的工程细节:
1. 副作用只做一件事:事件绑定
- 不在 render 中操作 DOM
- 所有副作用集中在 useEffect
2. 组件卸载时主动清理
return () => {
window.removeEventListener('scroll', thtottled_func);
};
如果不移除监听:
- 组件卸载后回调仍然存在
- 闭包中的 setState 可能触发警告
- 长时间运行会造成内存泄漏
这是很多初中级开发者容易忽略的点。
七、条件渲染:减少不必要的 DOM
if (!isVisible) return null;
这一行非常简单,但意义明确:
- 不显示就不渲染
- 减少 DOM 节点
- 逻辑直观、性能友好
八、UI 层:组件职责清晰
<Button
variant="outline"
size="icon"
onClick={scrollTop}
className="fixed bottom-6 right-6 rounded-full shadow-lg hover:shadow-xl z-50"
>
<ArrowUp className="h-4 w-4" />
</Button>
这里的职责划分非常干净:
- BackToTop:行为 + 状态
- Button / Icon:展示层
这也是你在上一篇文章中提到的 Shadcn UI 组件哲学的自然延伸。
九、总结:一个小组件,体现哪些工程能力?
这个 BackToTop 组件,至少体现了以下能力:
- 通用组件设计意识
- 状态内聚,不滥用全局状态
- 高频事件的性能优化
- 工具函数工程化抽离
- useEffect 副作用完整管理
- UI 与逻辑职责清晰
它并不复杂,但:
能写对这样的小组件,才能写稳复杂系统。