说不定面试的时候,会给一个codesandbox的项目,让你现场编程。这是很有可能的。

1、react folder structure

image-20251207160927865

1、只有一个顶层文件夹,文件夹里面可以有文件夹或者文件。那么就有两种数据类型的node,leaf或者branch,使用isFolder属性来区分。

2、use recursive,递归组件,同一个组件 FolderStruc 渲染自己的子节点,实现树形结构。

其实我有一点是很担心的,我害怕递归层级多了,里面定义的const [folderOpen, setFolderOpen] = useState<boolean>(true);会有很多,会不会影响性能?react能够很好的处理吗?

在几百个节点以内:完全没问题,React 处理得很好,几乎可以忽略。也可以加上React.memo。 一旦节点数量达到 500+(尤其是全展开时),每个文件夹都带一个 useState,就会开始明显卡顿,甚至几千节点时直接卡死。这是因为每次 setState → 触发该组件 + 所有子组件重新渲染检查,即使子节点没变,也要走一次 diff,所以性能会开始变差。

此时可以使用路径展开法,单一全局状态来处理,下面我会做一个例子。

3、编写一点CSS

4、在App.tsx中引入

效果:

 

这个例子有点让我眼前一亮,为什么呢?因为margin-left的间距,我只需要设置.folder的就行了,就可以形成树形结构的效果。记得我修改element-ui树形结构样式的时候,是很麻烦的,要找到哪一个children、哪一个样式名,这个就很简单。

小结:

其实这个组件并不难,主要就是组件的递归。更加复杂的,像树形结构前面加上checkbox、加上intermediate状态、子级选满之后父级自动选中,这些才是困难的,有时间就做一下。

2、react folder structure + 路径展开法

 

3、OTP input

什么是OTP input?就是你在键盘上直接输入6位验证码,不用手动聚焦输入框,一个输入框输入完成之后,就会自动聚焦到下一个输入框。

有几点需要考虑:

1、根据需求,渲染出几个输入框:

image-20251207201221259

2、开始处理上面提出的几个需要考虑的点。

①将input变为受控组件。完成第1和2点:

效果:

②完成backspace删除(使用keydown事件)

可以看到,我按键操作情况。

③处理用户直接粘贴的事件,使用div上面的onPaste事件,几乎所有HTML的可交互元素,都有这个事件。

效果:

4、Autocomplete Search Bar

需求:

1、先完成基本的搜索框UI,然后请求数据,同时添加防抖

这样可以看到请求数据成功了,同时加上了防抖:

2、将results渲染到页面上,注意写法,results.length > 0 &&只有results有数据时,才展示。

加一点样式就是这样的效果:

image-20251208105509749

3、保存并展示之前搜索的最新的10个搜索词,点击这些搜索词可以直接搜索。

这10个搜索词是怎么来的呢?是用户点击了某一个搜索词之后,就添加到搜索词集里面。

代码里面主要就是saveToLocal,handleSelect,recentSearch赋初始值和渲染。这里没有使用useEffect,稍后会解释。

可以看到,点击了某一项之后,就会添加到recent searches里面去;点击recent searches里面的某一项,就会赋值到搜索输入框。

4、user friendly

主要是编写样式。

 

 

小结:

主要使用了受控组件、防抖、localStorage这些技术,还有一些产品设计方面的知识。

优化记录:

useEffect,在写获取recentsearch数据的时候,

报错:Error: Calling setState synchronously within an effect can trigger cascading renders。意思是在useEffect里面使用useState可能会触发无限渲染。

所以需要直接使用useState懒初始化。

如果是获取数据,那么直接使用react query。

useEffect哪些场景使用呢:

推荐等级场景(官方/社区公认的最佳用途)典型代码示例为什么只能/最好用 useEffect
5星订阅和取消订阅外部系统(WebSocket、EventBus、第三方事件)useEffect(() => { const id = socket.on('msg', handler); return () => socket.off(id); }, []);必须在组件卸载时清理,否则内存泄漏
5星手动操作第三方 DOM 库(Chart.js、Mapbox、Three.js、Tippy、Swiper 等)useEffect(() => { chart.current = new Chart(ref.current, config); return () => chart.current?.destroy(); }, []);这些库不是 React 管理的,必须自己创建/销毁
5星监听原生浏览器事件或全局事件(resize、scroll、keydown、自定义事件)useEffect(() => { window.addEventListener('resize', handler); return () => window.removeEventListener('resize', handler); }, []);必须在组件卸载时移除监听
5星发起接口请求(根据某个依赖变化)useEffect(() => { if (userId) fetchUser(userId); }, [userId]);数据获取是典型的“外部系统同步”
5星与外部状态库同步(Zustand、Jotai、Valtio、Redux 外的全局 store)useEffect(() => { return globalStore.subscribe(setState); }, []);官方底层就是用 useSyncExternalStore,但很多老库还是要自己写 effect
4星路由变化时滚动到顶部、记录页面访问、埋点统计useEffect(() => { window.scrollTo(0, 0); logPageView(); }, [location.pathname]);属于“与外部世界同步”
4星根据某个值动态修改 document.title、faviconuseEffect(() => { document.title = ${count} 条未读; }, [count]);修改全局对象
4星配合 useLayoutEffect 不合适但又必须在渲染后立刻做的事(极少数)极少见,一般改用 useLayoutEffect只有当你明确知道要异步但又不能放 state 初始化时才用
3星仅执行一次的初始化(但又必须在渲染后才能拿到 DOM 或某些值)const didInit = useRef(false); useEffect(() => { if (!didInit.current) { didInit.current = true; init(); } }, []);其实大多数都能改成 useState 懒初始化或 use()(React 19)

5、Build infinite nested comments system

这个就类似于reddit或者youtube里面的comments:

image-20251208140912410

1、先准备数据:

2、编写简单的CommentList组件,看一下效果:

可以看到,只渲染出了最外层的评论:

image-20251208142401325

3、这一步就开始做CommentItem组件,调用自己来实现递归

然后在CommentList里面引入:

基本效果已经出来了:

就类似于下面这种效果:

image-20251208143549783

4、这一步就是将评论数据添加到data中,同时状态要更新。怎么做呢?这里不使用真实数据库,而是直接采用uplift the state的方法,将用到的数据就放在App.tsx里面,使用子级更新父级数据的方法,来做。

在CommentList和CommentItem组件里面,传递addReply过去,然后绑定这个方法:

此时已经可以添加评论了:

 

功能已经完成了,剩下的工作就是编写样式了。

可以看到,结构很清楚了。

小结:

这个组件难在哪里,我觉得难在不晓得该在哪里递归。

①首先是组件,我准备直接在CommentList里面递归CommentItem的,就是下面这种样子:

效果是这样的:

image-20251208161617000

这根本就没有递归,最多就是把第二层展示出来了。

递归就是要重复自己,这一点要记住了,所以必须在CommentItem里面重复自己。

②递归函数我写的不好,老师的函数要记住。

③产品意识要有,这个评论应该是什么样的、功能、UI,心里要记住。

6、React Tab Component

Myntra, Zepto这些公司问过。

 

1、实现功能

在App.tsx中引入:

可以看到,功能已经实现了:

2、优化样式:

效果:

小结:

组件不难,但是需要知道使用{}来渲染内容。

7 - Modal、Dialog、Toast、Notification、Drawer、SideSheet、MessageBox、Tooltips 、Popovers等等

实现这些功能的套路就是:

1、使用react-dom提供的createPortal方法来创建组件。在组件中,使用父组件传递过来的参数isOpen来判断是否显示,如果isOpen为true,则返回null。

这是最重要的一步,完成了这一步,基本的功能就实现了。

2、不要直接将元素挂载到document.body上面

因为document.body下面是<div id="root"></div>,如果document.body的内容变化了,app里面也会变化,造成不必要的重绘重排,这样会造成布局抖动、CSS层叠上下文混乱问题,同时body上面绑定了很多第三方库的事件,容易误触发。

所以一般都是在public/index.html里面,创建几个专门的portal容器用来使用。

然后在utils里面定义各种类型的portal组件,这样使用起来就很方便了:

3、事件冒泡

portal 内部的事件仍然会像正常 React 组件一样冒泡到 React 树中它们的父组件和祖先组件,即使 DOM 节点在物理上很远。所以如果在portal里面定义了一个onClick事件,则需要使用e.stopPropagation()来阻止事件冒泡。

8 - table + pagination

一个参考案例

有一个很好的table案例,https://github.com/sadmann7/tablecn,如果不能直接使用,那么可以仿照写。这个案例也是使用tanstack/table来做的。代码在code文件夹里面,可以看到,还是非常复杂的。因为作者使用nextjs创建的项目,所以这个table是可以用在server component里面的,这就非常高级了。

这里的pagination和搜索使用到了nuqs这个库,useQueryState这个hook,将搜索条件和pagination相关参数放到了url上,然后从url上获取参数,然后触发更新。

这个案例是很复杂的,搞清楚了之后可以分享出来。

简单案例的实现步骤

table好做,直接照抄即可,tanstack/table里面第一个案例就做出来了:

image-20251224130247108

生成的表格是没有样式的,还需要自己加样式:

image-20251224140918275

困难的是pagination应该怎么做?

TanStack Table 的分页功能分为两种主要模式:客户端分页 和 服务端分页。 你现在的项目用的是服务端分页(manualPagination: true),这也是实际项目中最常用的方式。下面用最直白的语言解释它的实现原理。核心概念:

  1. 表格自己不存数据 TanStack Table 只是一个“渲染引擎”,它不负责存数据,也不自己切页。 它只负责:

    • 根据你给它的 data(当前页的数据)来渲染表格
    • 根据你给它的 pageCount(总页数)来显示分页控件
    • 当用户点“下一页”时,它会调用 onPaginationChange 通知你
  2. 分页状态由你自己管理 你需要用 React 的 state 来记录当前是第几页、每页几条:

  3. 点击翻页 → 触发 state 变化 → 重新请求数据

    当用户点“下一页”时发生的事:

    • 用户点击 → table.nextPage() 被调用
    • table 内部把 pageIndex +1
    • 调用 onPaginationChange(你设置的 setPagination)
    • state 更新 → React 重新渲染组件
    • useQuery 发现 queryKey 变了(因为包含了 pageIndex),自动重新请求后端
    • 后端返回新的那一页数据 → 表格显示新内容

    流程图(文字版):

代码中关键的几行

客户端分页 vs 服务端分页 对比(快速记忆)

特性客户端分页 (manualPagination: false)服务端分页 (manualPagination: true)
数据从哪来一次性把所有数据拿回来每次只拿当前页的数据
适合场景数据量很小(<1000条)数据量大、需要搜索、排序
性能前端压力大,首次加载慢后端承担分页逻辑,响应更快
实现难度简单,Table 自动处理需要自己管理 pageIndex 和请求
你现在用的是(推荐)

总结一句话“分页本质上就是:用户点翻页 → 更新 pageIndex → 带着新的页码重新请求后端 → 把后端返回的新数据塞给表格渲染” TanStack Table 只负责“通知你”和“渲染当前页”,真正的分页逻辑(切哪一页、请求哪一页)是你自己通过 state + useQuery 完成的。

看了案例之后,我发现不管是分页还是搜索,都是通过useQuery里面的queryKey来触发的。将pageNum、pageSize、keyword、各种搜索词这些状态放进queryKey里面去,当这些状态变化的时候,就会触发请求数据。

所以说react query真的解决了问题。

简单案例代码

就是code/real-project里面的TablePagination组件。这里就不粘贴了,代码还是比较多的。

简单案例的问题点

翻页时页面闪动

主要原因通常有 3 个:

  1. 数据请求期间,data 变为 undefined 或 null,表格瞬间渲染空数组 → 内容消失
  2. keepPreviousData 没开,导致旧数据被清空,新数据还没回来
  3. 组件重新渲染时,表格高度/布局跳动

第1和3都是写一些HTML+CSS,针对第二个,需要配置useQuery里面的placeholderData: keepPreviousData,在新数据回来前,保留旧数据,这样就不会在翻页的时候清空数据,新数据返回后重新渲染数据,造成很突兀的闪动情况。

同时也可以配置useQuery里面的gcTime和staleTime,让缓存数据存在的时间更长一些,显示起来就很快。

搜索框输入一个字母就会触发更新

①加防抖,编写一个防抖的hook。

②使用防抖hook新建一个变量,这个变量改变了才触发更新。

搜索框输入后,无法获取到最新的输入结果,造成返回结果不正确

这个其实是我的问题,因为上一个问题中,useQueryqueryKey里面的search要改为debouncedSearch,我没有改,所以造成这个问题。改了之后就好了。

固定列宽

①在列定义中指定宽度

首先,在你的 columns 配置中为特定列添加 size 属性。TanStack Table 默认的 size 值是 150。

②在渲染时应用样式 (Tailwind)

TanStack Table 本身不负责渲染样式,你需要手动将 size 应用到 thtd 上。为了确保宽度严格固定,建议使用 table-fixed 布局。

table 加上 table-fixed 类。这会告诉浏览器不要根据内容自动撑开列宽,而是遵循你设置的宽度。

在渲染 thtd 时,直接通过内联样式设置宽度:

注意:

如果width不起作用,那么添加min-width或者max-width,或者二者都添加。

溢出显示...,并且可以鼠标悬浮显示全部信息

①封装 OverflowTooltip 组件

这个组件会自动判断子元素是否溢出。如果是,则展示 Tooltip;如果不是,则只渲染原始文本。使用了shadcn的Tooltip组件。

②在 TanStack Table 的 columns 中应用

你可以直接在列定义的 cell 函数中使用该组件:

③关键 CSS 配合(提醒)

为了确保溢出检测(scrollWidth > clientWidth)准确生效,请务必检查以下两点:

  1. 表格布局:在 <table> 标签上必须有 table-fixed
  2. 单元格宽度:在 td 渲染时,需要显式设置宽度:

table和pagination单独抽离成组件