从零打造 AI 全栈应用(三):一个 BackToTop 组件背后的工程化与性能思维

5 阅读5分钟

在上一篇文章
👉 《从零打造 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 与逻辑职责清晰

它并不复杂,但:

能写对这样的小组件,才能写稳复杂系统。