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

代码在https://stackblitz.com/这个网站的Components项目里面,这个网站比code sandbox要好一些。

1、react folder structure

image-20251207160927865

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

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

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

useState 本身的定义数量几乎不影响性能,真正的性能杀手是“状态更新导致的组件树大面积重绘”。

我们可以从以下几个维度来深入分析:

  1. 变量定义的内存开销:微乎其微

在 JavaScript 中,定义几十个甚至上百个 useState 变量,占用的内存非常小。React 内部通过一个链表(Linked List)来管理这些 Hook,只要你的组件逻辑清晰,定义多个变量本身不会让页面变慢。

  1. 状态更新的范围:才是关键

如果你把所有文件夹的状态都放在父组件的一个大对象(比如 allFoldersState)里,那么:

  1. 你的方案:局部化状态 (State Colocation)

像你提到的,在每个 Folder 组件内部定义 folderOpen

Having multiple useState calls in a component is generally fine because React handles Hook management very efficiently. The real performance concern isn't the number of state variables, but the scope of re-renders. (在组件里写多个 useState 没问题,因为 React 管理 Hook 的效率很高。真正的性能担忧不是变量的数量,而是重新渲染的范围。)

By keeping the folderOpen state local to each Folder component (State Colocation), we ensure that an update only triggers a re-render of that specific branch, rather than the entire tree. This is actually a standard performance best practice. (通过把 folderOpen 状态留在每个 Folder 组件内部,我们确保了更新只触发该分支的重绘,而不是整棵树。这实际上是标准的性能优化最佳实践。)

 

进一步优化的思路:

A. 记忆化 (Memoization)

确保你的文件夹项使用了 React.memo

B. 虚拟滚动 (Virtualization)

如果文件夹列表有数千行,无论你怎么优化 useState,DOM 节点的数量都会拖慢浏览器。

C. 原子化状态库 (Optional)

如果文件夹之间有复杂的联动(比如点击 A 自动关闭 B),导致状态不得不频繁在父子间传递:

3、编写一点CSS

4、在App.tsx中引入

效果:

 

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

小结:

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

2、react folder structure + 路径展开法

“路径展开法”(Path-based approach 或 Flattened Tree Structure)在处理深层嵌套超大型树形结构时,通常比“递归嵌套法”更具工程优势。

什么是路径展开法?

不再使用 children: [...] 这种嵌套对象,而是将数据打平为一个数组,每个节点记录自己的 idparentIdpath(例如 /root/folder1/file.txt)。

 

特点:

  1. 数据平铺化 (Data Flattening)

Logic: Instead of keeping the tree structure, we convert it into a simple array where each item knows its path and depth. 逻辑: 我们不再保留嵌套的树结构,而是将其转换为一个简单的数组,每个元素都记录了自己的“路径”和“深度”。

  1. 状态提升与中心化 (Lifting State Up)

Logic: We moved the folderOpen state from individual child components to a single expandedIds Set in the parent. 逻辑: 我们把原本分散在每个子组件里的 folderOpen 状态,统一提取到了父组件的一个 expandedIds(集合)中。

  1. 动态可见性计算 (Dynamic Visibility Calculation)

Logic: We use a .filter() method to decide which nodes should be rendered on the screen. 逻辑: 我们使用 .filter() 方法来决定哪些节点应该显示在屏幕上。

  1. 样式缩进 (Styling with Depth)

Logic: Instead of nesting HTML tags, we use the depth property to apply padding-left. 逻辑: 我们不再使用 HTML 标签的层层嵌套,而是利用 depth 属性来应用左侧内边距。

 

Feature (特性)Recursive Approach (递归法)Flattened Approach (平铺法)
ComplexitySimple for small treesBetter for large, complex trees
RenderingDeep recursion (Slow)Flat iteration (Fast)
VirtualizationImpossibleEasy (Ready for react-window)
StateDistributed (Hard to sync)Centralized (Easy to manage)

By shifting from a recursive structure to a flattened data model, we treat the tree as a list. This allows us to use State Colocation for better management and prepares the component for Virtualization, ensuring O(1) or O(n) performance even with massive datasets.

(通过从递归结构转向平铺数据模型,我们将树视为列表。这使我们能够更好地管理状态,并为虚拟化渲染做好准备,确保即使面对海量数据也能保持极高性能。)

 

代码实现:

  1. 将树形结构扁平化

不管是前端处理还是后端处理,需要将树形结构扁平化。

  1. 编写组件

在这个实现中,我们只需要维护一个 expandedIdsSet 来管理哪些路径是展开的。

效果:

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可能会触发无限渲染。

image-20260612155335902

然后我使用了useState懒初始化。

这个方式在纯react项目里面是没有问题的,但是在nextjs项目中,会出现hydration mismatch的问题,原因是即使组件写了:

Next.js App Router 仍然会:

  1. 服务端先预渲染 HTML
  2. 浏览器下载 JS
  3. React Hydrate

服务端第一次渲染

服务器没有localStorage,所以会报错。


所以还是使用useEffect来从storage里面获取数据,虽然有报错,但这是合理的报错。

完整代码如下,我添加了tailwindcss,然后handleSelect的逻辑也优化了一下:

有了results之后,怎么使用上下方向键来选择result,然后使用enter键来触发handleSelect呢?

核心思路:

  1. 用一个 state 记录当前高亮项。键盘操作不需要和results框联系起来,因为键盘操作的时候,光标还在input里面,所以onKeyDown事件需要绑定到input上。通过这个 state 来记录results里面的高亮状态。 这一点是最重要的。
  2. ArrowDown → 索引 +1
  3. ArrowUp → 索引 -1
  4. Enter → 触发当前项的 handleSelect

1. 新增 activeIndex

默认:

表示当前没有选中任何项。

2. 当搜索结果变化时重置

否则:

索引会越界。

3. 处理键盘事件

给 input 添加:

实现:

4. 高亮当前项

原来:

改成:

5. 添加 ARIA

很多高级前端面试官会问。

这样屏幕阅读器也能正确朗读。

 

5、Build infinite nested comments system

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

image-20251208140912410

1、先准备数据:

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

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

image-20251208142401325

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

然后在CommentList里面引入:

基本效果已经出来了:

image-20260612170913246

 

下一步,为每一个评论Item加上回复评论的按钮和输入框,先实现简单UI即可。

就类似于下面这种效果:

image-20251208143549783

 

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

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

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

 

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

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

小结:

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

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

效果是这样的:

image-20251208161617000

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

递归就是要重复自己,这一点要记住了,所以必须在CommentItem组件里面重复自己,怎么重复自己呢?就是在jsx代码里面使用自己。

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

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

6、React Tab Component

Myntra, Zepto这些印度公司问过。

 

1、实现功能

在App.tsx中引入:

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

2、优化样式:

效果:

小结:

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

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

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

1、使用react-dom提供的createPortal方法来创建组件。

在组件中,使用父组件传递过来的参数isOpen来判断是否显示,如果isOpen为true,则返回null。

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

重点CSS解释:

fixed

等于:

作用:

而不是相对于父元素。


inset-0

等于:

效果:


bg-black/50

等于:

效果:

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

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

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

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

nextjs官方命令创建的nextjs项目,是没有index.html文件的,这时候该怎么办呢?

可以在layout.tsx里面创建挂载点:

然后:

3、事件冒泡

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

4、使用

使用起来就非常简单了,只需要一个state来控制显示/隐藏即可。

效果:

 

我有个问题,比如说tooltip、popover这些类型的弹窗,不是占据整个页面来定位的,而是相对于里面的内容来定位的,是不是这样?这些类型的弹窗该怎么做呢?

你问到了一个非常重要的 UI 架构问题。

实际上:

虽然都属于"浮层(Overlay)",但定位方式完全不同。

第一类:Modal 类

例如:

特点:

例如:

都和页面内容没关系。

所以:

非常合适。


第二类:Anchor 类

例如:

特点:

例如:

举个例子:

Popover 要出现在:

而不是:


很多人第一反应

直接这样写:

这样确实能工作。


但是很快会遇到问题:

overflow:hidden

Popover 被裁掉。


transform

创建新的:

导致层级异常。


页面滚动


所以现代 UI 库:

其实也会:


Tooltip 实际是 Portal

例如:

挂到 body。


很多人会问:

既然挂到 body 了,怎么还能跟着 Button?

答案:

通过计算坐标。


原理

按钮:

获得:


然后 Tooltip:

渲染:


结果:

虽然:


为什么不用 absolute?

因为:

依赖:

很容易受到:

影响。


而:

配合:

得到的是:

最稳定。


真实项目怎么做?

几乎没人自己算。

都用:Floating UI

现在是行业标准。


例如:


按钮:


Popover:


Floating UI 自动处理:


例如:

正常:

放不下。

Floating UI 自动变成:

这叫:


面试回答

如果面试官问:

Tooltip 或 Popover 需要 createPortal 吗?

比较完整的回答:

Tooltip、Popover、Dropdown 等浮层虽然需要相对于触发元素定位,但生产环境通常仍会使用 createPortal 挂载到 body,避免受到父容器 overflow、transform 和 z-index 的影响。定位则通过 getBoundingClientRect 获取触发元素的位置,或者直接使用 Floating UI 这类库来计算坐标和处理边界碰撞、自动翻转等问题。因此现代 UI 库中的 Tooltip 和 Popover 通常都是 Portal + Positioning Engine 的组合,而不是简单的 absolute 定位。

这其实就是为什么像 Radix UI、Headless UI、MUI、Ant Design 的 Tooltip/Popover,DOM 看起来都跑到了 <body> 下面,但视觉上仍然能精准贴在按钮旁边。

tooltip范例代码

需要传一个targetRef给tooltip,这样就可以让tooltip相对于anchor定位了。

使用:

效果:

 

其实这种实现方式不是很好,因为一般来说,我们是将要包裹的元素直接写进标签里面,而不是通过targetRef来传递。

但是如果要使用原始方法来实现,要解决下面的问题,非常复杂:

所以还是使用floating ui来帮忙实现。

floating ui实现

目录结构

为了方便展示,我放一个文件里。


Tooltip.tsx

TooltipTrigger.tsx

支持:

 

TooltipContent.tsx

使用方式

面试重点:这里面最核心的是什么?

很多人会回答:

Floating UI。

其实不是。

真正的核心是:

即:

这种 API。

内部靠:

实现。

面试官如果让你设计 Tooltip,通常真正考察的不是定位,而是:

这些能力如何组合在一起。

中文口语版

这个 Tooltip 采用的是 Compound Components(组合组件)模式。

首先,Tooltip 组件内部会创建一个 Context,用来共享状态和引用。

TooltipTrigger 负责拿到触发元素的 DOM 节点,并把这个节点注册给 Floating UI 作为 reference element。

当用户 hover 到 Trigger 上时,Floating UI 会把 open 状态更新为 true。

然后 TooltipContent 从同一个 Context 里面获取 open 状态和 reference element。

接着通过 Floating UI 计算 Tooltip 的位置,并使用 Portal 渲染到 document.body 下,避免被父元素的 overflow 或 z-index 影响。

最终 Tooltip 虽然在 DOM 结构上挂载到了 body 下面,但视觉上仍然会跟随 Trigger 元素。

整个实现主要依赖四个核心能力:


This Tooltip is implemented using the Compound Components pattern.

The Tooltip component creates a Context to share state and references between its child components.

TooltipTrigger registers the target DOM element as the reference element for Floating UI.

When the user hovers over the trigger, Floating UI updates the open state.

TooltipContent then reads the open state and reference element from the same Context.

Floating UI calculates the position of the tooltip, and the content is rendered through a Portal into document.body to avoid overflow and stacking context issues.

Although the tooltip is rendered outside of the normal DOM hierarchy, it is visually positioned relative to the trigger element.

The implementation is mainly built on four concepts: