创见博客
模拟面试(一)
七崽爱吃小饼干2026/07/22阅读 5专栏 模拟面试

这篇文章记录一次前端模拟面试的完整过程:先给出题目,再保留原始回答,最后逐题点评并给出更适合面试表达的版本。

整体看,这次回答方向是对的,尤其是第 1、4、5、6、8 题的主线基本抓住了。但有几处概念表达需要更严谨,第 7 题空着,第 9、10 题偏简略,面试里容易被继续追问。

1. Server Component 和 Client Component 的核心区别是什么?为什么不能把页面全部写成 Client Component?

我的回答

markdown
这两种组件都是服务端渲染的,两者的核心区别在于,Client Component具有交互等依赖浏览器真实环境的功能,需要在客户端进行,所以有水合的过程。
而Server Component则是在服务端渲染的,没有依赖浏览器真实环境的功能,所以没有水合的过程。
水合过程需要把对应的js代码都发送给客户端,然后进行水合。如果全部都做成Client Component,就会导致js体积变大,影响性能。

点评

评价:7/10

正确点:

  • 抓住了 Client Component 需要发送 JS 到客户端并 hydration。
  • 抓住了 Server Component 不需要 hydration。
  • 能说明全部 Client Component 会导致 JS 体积变大、性能变差。

需要修正:

  • “这两种组件都是服务端渲染的”这句话不够严谨。Client Component 在 Next.js App Router 中通常会参与服务端预渲染初始 HTML,但它的组件代码会发送到浏览器,并在客户端 hydration。
  • Server Component 不只是“没有依赖浏览器环境”,更关键是:组件逻辑只在服务端执行,代码不会进入客户端 bundle。
  • 可以补充:Server Component 可以访问数据库、文件系统、服务端密钥;Client Component 不能直接做这些。

更好的表达:

Server Component 和 Client Component 的核心区别不是有没有 HTML,而是组件代码是否会发送到浏览器、是否需要 hydration。Server Component 只在服务端执行,不进入客户端 bundle,不需要 hydration,适合内容、数据读取和静态 UI。Client Component 会被打包到浏览器,可以使用 useState、useEffect、事件和浏览器 API,但需要 hydration。不能全部写成 Client Component,因为会增加客户端 JS 体积和 hydration 成本,也会让很多本来可以服务端完成的数据获取退化成客户端请求。

2. Client Component 也能服务端预渲染 HTML,那它和 CSR 的区别在哪里?

我的回答

markdown
区别在于,Client Componnet是在服务端渲染html,然后在客户端进行水合,数据是可以在服务端获取的
而CSR是在客户端渲染空白的html,然后在客户端执行js代码,渲染出完整的页面

点评

评价:6.5/10

正确点:

  • 你知道 Client Component 可以服务端预渲染 HTML。
  • 你知道 CSR 是客户端执行 JS 后渲染页面。

需要修正:

  • “Client Component 数据是可以在服务端获取的”这句话容易被误解。Client Component 自己不能直接访问数据库或服务端密钥,但它可以接收 Server Component 传下来的数据,也可以在服务端预渲染时得到初始 HTML。
  • CSR 不是一定“空白 HTML”,更准确是:核心内容不在初始 HTML 中,而是等客户端 JS 执行、请求数据后再渲染。

建议补充关键判断标准:

判断页面是否退化为 CSR,不是看它是不是 Client Component,而是看核心内容的数据获取发生在哪里。如果文章正文在服务端获取并进入初始 HTML,就不是典型 CSR;如果正文要等 useEffect + fetch + setState 后才出现,就是 CSR 数据渲染路径。

3. 一个文章阅读页从 CSR 改造成 SSR,你会如何拆分 Server Page 和 Client Shell?

我的回答

markdown
首先server page和client page的主要区别在于是否需要在客户端进行水合。
文章阅读页的主体内容,不需要交互,就都可以拆分成server page。
而目录、评论、分享等功能,需要绑定事件,依赖真实dom,所以需要拆分成client shell。

点评

评价:7/10

正确点:

  • 你能区分文章主体内容放服务端,目录、评论、分享放客户端。
  • 能说出依赖真实 DOM 和事件绑定的部分需要 Client Shell。

需要补充:

  • “Server Page 和 Client Page 的区别在于是否需要水合”方向对,但这里题目问的是架构拆分,最好直接讲页面结构。
  • 可以明确:Server Page 负责 params、数据查询、notFound、metadata、Markdown 服务端渲染;Client Shell 负责点赞、收藏、评论、分享、阅读记录、目录滚动。

更好的表达:

我会把 page.tsx 改成 Server Component,在服务端根据 articleId 查询文章,渲染标题、作者、发布时间、正文和 metadata。外层再包一个 ReaderClientShell,只把 articleId、authorId、markdown 这类可序列化数据传进去。点赞、收藏、评论、分享、阅读记录、目录滚动这些依赖事件、DOM 或登录态的逻辑放在 Client Shell。这样初始 HTML 有文章正文,同时保留客户端交互。

4. Next.js Reader 页从 SSR 继续演进到 ISR 时,为什么要先拆分公开阅读页和预览页?

我的回答

markdown
因为所有人看到的公开阅读页的内容都是相同的,所以公开页面可以做缓存,所有的用户都可以从缓存中获取同一份页面。
而预览页的内容需要根据鉴权判断,用户是否有访问权限,对应的路由就不一样,所以不能做缓存。
如果缓存了的话,页面就会先渲染出预览页的缓存内容,然后发现用户无权限访问后,再跳转到403页面。就会导致预览页数据泄露。

点评

评价:7.5/10

正确点:

  • 抓住了公开页所有人看到相同内容,可以缓存。
  • 抓住了预览页依赖鉴权,不能公共缓存。
  • 能意识到缓存可能导致数据泄露。

需要修正:

  • “页面先渲染出预览页缓存内容,然后发现用户无权限访问后,再跳转 403”这个过程不一定准确。真正的问题是:如果同一个 URL 的 HTML 因用户不同而不同,公共 ISR 缓存可能直接把作者访问时生成的非公开 HTML 复用给未授权用户。
  • 重点不是“对应路由不一样所以不能缓存”,而是拆路由后职责不一样:/reader/[id] 只查公开文章,可缓存;/reader/preview/[id] 读 cookie 鉴权,动态渲染。

更好的表达:

ISR 缓存的是 URL 对应的 HTML,如果这个 HTML 依赖当前用户身份,就不能作为公共缓存。公开阅读页只展示已发布且公开的文章,不读 cookie,同一个 URL 对所有用户输出一致,可以做 ISR。预览页需要读取 cookie 判断作者权限,同一个 URL 对不同用户结果不同,必须动态渲染。否则作者访问时生成的非公开内容可能被缓存,后续未登录用户命中缓存造成泄露。

5. revalidate = false 配合 revalidatePath 和 revalidate = 300 有什么区别?分别适合什么场景?

我的回答

markdown
revalidate = false 表示页面不会自动重新获取数据,用户需要手动刷新才能获取到最新的数据。 这时候就相当于SSG
revalidatePath(path) 表示手动刷新指定路径的页面缓存,比如阅读页可以配合revalidate = false使用,在文章更新时触发revalidatePath(path),从而实现只有文章更新时才会刷新缓存。
revalidate = 300 表示页面会缓存300秒,300秒后,用户访问该页面时,就会重新生成该页面的缓存。

点评

评价:7/10

正确点:

  • 你理解了 revalidatePath 是主动刷新指定路径。
  • 你理解了 revalidate = 300 是缓存 300 秒,过期后重新生成。
  • 能把文章更新和主动刷新关联起来。

需要修正:

  • “用户需要手动刷新才能获取最新数据”不准确。不是用户浏览器手动刷新,而是服务端缓存不会按时间自动失效,需要业务调用 revalidatePath 或重新部署等方式让缓存失效。
  • revalidate = false 不等于普通 SSG,但语义接近“无限期缓存,直到手动失效”。

更好的表达:

revalidate = 300 是时间驱动的 ISR,缓存最多 300 秒,过期后的下一次请求触发重新生成。revalidate = false 表示不按时间自动过期,页面可以长期复用缓存,需要在文章发布、更新、删除时调用 revalidatePath('/reader/id') 主动失效。文章详情页内容变化不频繁,更适合长期缓存加业务事件驱动刷新;列表页或实时性要求不高的页面可以用固定时间 revalidate。

6. 为什么 <p> 包住 AntD Image 可能导致 Next.js Hydration Error?你会怎么排查和修复?

我的回答

markdown
因为当<p>标签是行内标签,不能包裹<div>,而AntD Image组件是的最外层是块级元素。
当<p>标签中包裹了<div>,比如:
<p>
  <div>
    <Image src="/image.jpg" alt="Image" />
  </div>
</p>
浏览器渲染时会变成
<p></p>
<div>
    <Image src="/image.jpg" alt="Image" />
</div>
<p></p>
这时候就会出现浏览器渲染的html和服务端不一致的情况,就会导致水合错误。
修复的方案就是,在markdown渲染器中,当渲染img时,把p去掉,改成div包裹即可。

点评

评价:8/10

正确点:

  • 你准确抓住了 <p> 不能包 <div>。
  • 你说明了浏览器会自动修正 DOM。
  • 你知道服务端 HTML 和客户端预期 DOM 不一致会导致 hydration error。
  • 修复方向也对:Markdown 渲染时图片段落用 div 包裹。

需要修正:

  • <p> 不是“行内标签”,它是块级语义元素,但它的内容模型只允许 phrasing content,不能包含 <div>。
  • 示例里 <Image /> 是 React 组件,浏览器最终看到的是 AntD 输出的真实 DOM,不是 <Image> 标签。面试时最好强调“看真实 DOM”。

更好的表达:

原生 <img> 放在 <p> 里是合法的,但 AntD Image 最终输出的外层可能是 <div class="ant-image">。Markdown 默认会把图片放进段落里,于是 React 预期结构变成 <p><div>...</div></p>。这是非法 HTML,浏览器解析时会自动闭合 <p>,导致真实 DOM 结构和 React hydrate 时预期结构不一致,从而报 hydration error。修复方式是在 Markdown 的 p renderer 里判断段落是否包含图片,如果包含就渲染成 <div>,普通文本段落仍然渲染成 <p>。

7. 为什么 Markdown 渲染器要拆成 SSR 入口和 CSR 入口?

我的回答

markdown

点评

评价:0/10,未回答

这题空着了,是比较重要的一题。

推荐答案:

因为 Markdown 渲染里有两类能力:一类是服务端安全的解析和渲染,比如 GFM、数学公式、高亮、HTML sanitize、链接过滤、基础标签渲染;另一类是客户端增强,比如代码复制、图片预览、折叠、diagram 渲染,这些依赖 useState、useEffect、document、navigator。如果混在一个组件里,Server Component 引入时会因为客户端 Hook 或浏览器 API 报错。所以要拆成 core、server、client 三层:core 共享解析和安全逻辑,server 不带 "use client" 用于 SSR,client 保留复制、预览等交互增强。这样 reader 页可以服务端输出正文,编辑器仍然可以使用客户端增强版。

8. 前端埋点 SDK 从 img 上报重构成 Hooks 体系,主要解决了哪些工程问题?

我的回答

markdown
主要是三个方面:
1. 从代码复用性来讲,改成hooks以后,click、exposure等事件都不需要重复实现,代码复用性高。
2. 从性能来讲,在hooks的底层中集成了批量上报、idle触发等功能,可以减少请求次数,降低上报请求的优先级,提高性能。
3. 从可靠性来讲,hooks的底层集成了失败重试机制,当上报失败时,数据会缓存到失败队列,然后在合适的时机才会重新触发。

点评

评价:8.5/10

正确点:

  • 三个方向非常清楚:复用性、性能、可靠性。
  • 你提到了 click、exposure 这些场景 Hook。
  • 你提到了批量上报、idle 调度、失败队列、重试。

可以加强:

  • 可以补一句为什么 img 方案不够:它只解决“怎么发出去”,没有解决“什么时候触发、如何复用、失败怎么办、请求太多怎么办”。
  • 可靠性可以补充指数退避、localStorage 失败缓存。
  • 性能可以补充页面隐藏 flush、减少首屏大量曝光请求。

更好的表达:

原来的 img 上报只封装了发送动作,业务仍然要自己处理点击、曝光、停留等触发逻辑,而且失败后基本无法补偿,每条埋点单独请求也会影响首屏。Hooks 化后,点击、曝光、页面停留、自定义事件都变成场景 Hook,提升复用性;底层统一走发送链路,支持失败缓存和指数退避重试,提升可靠性;同时支持批量上报、页面隐藏 flush 和 requestIdleCallback 空闲调度,减少低优先级埋点对核心请求的干扰。

9. 曝光埋点为什么不能在组件 mount 时直接上报?useTrackExposure 用 IntersectionObserver 解决了什么问题?

我的回答

markdown
因为组件mount并不一定时真正的在页面可见,比如组件挂载时,实际在页面不可见的范围。
useTrackExposure 用 IntersectionObserver 解决了这个问题,当组件进入可见范围时,才会上报曝光事件。

点评

评价:6.5/10

正确点:

  • 你抓住了 mount 不等于可见。
  • 你知道 IntersectionObserver 用来判断进入可见范围后再上报。

需要补充:

  • 可见不是只有“进入视口”,还要看可见比例,比如 50% 才算曝光。
  • 可以讲 exposureOnce 控制是否只上报一次。
  • 可以讲为什么不用 scroll 手动计算:浏览器调度,性能更好。
  • 可以讲 Hook 返回 ref,业务只需要绑定 DOM。

更好的表达:

组件 mount 只能说明它进入了 DOM,不代表用户真的看到了它,比如列表中首屏以下的卡片也可能已经渲染。useTrackExposure 返回一个 ref 绑定到目标 DOM,用 IntersectionObserver 监听元素和视口的交叉状态,在 intersectionRatio 达到配置阈值时才上报。它还可以通过 exposureOnce 控制是否只上报一次,并在组件卸载时 unobserve/disconnect,避免重复监听和内存泄漏。

10. 页面停留时长为什么不能简单用进入时间和离开时间相减?useTrackPageStay 应该如何统计有效停留?

我的回答

markdown
因为用户在页面停留时,可能并没有实际的操作,这段时间就不应该算有效停留。
所以useTrackPageStay 应该统计用户在页面停留的有效时间,即用户在页面停留时,实际的操作时间。
useTrackPageStay 通过监听用户的鼠标、键盘、滚动、触摸等操作,当一定时间内,用户没有操作时,就会暂停计时,累计有效时长。
只有用户重新活跃时,才会恢复计时,累计有效时长。

点评

评价:6.5/10

正确点:

  • 你知道不能简单用进入时间和离开时间相减。
  • 你知道要监听鼠标、键盘、滚动、触摸等活跃行为。
  • 你知道长时间无操作要暂停计时。

需要修正:

  • “实际的操作时间”这个表达略窄。页面停留不是只统计操作瞬间,而是统计“页面可见且用户仍处于活跃状态的时间片段”。
  • 需要补充页面显隐:切到后台时应立即结算。
  • 需要补充 lastActiveRef - startTimeRef,避免把无操作时间算进去。
  • 需要补充 minDuration/maxDuration 过滤噪声和异常值。
  • 页面隐藏时可以强制单条上报,避免批量队列来不及发送。

更好的表达:

进入时间和离开时间相减会把页面隐藏、用户离开电脑、长时间不操作、电脑休眠等时间都算进去,容易高估真实停留。useTrackPageStay 应该统计页面可见且用户仍然活跃的时间片段。实现上用 startTimeRef 记录片段开始时间,用 lastActiveRef 记录最后一次鼠标、键盘、滚动、触摸等活跃行为;页面隐藏时立即结算,长时间超过 timeout 不活跃也结算。结算时用 lastActiveRef - startTimeRef,再用 minDuration 过滤过短停留,用 maxDuration 限制异常长值。这样得到的是有效活跃停留,而不是页面打开总时长。

总体建议

现在的答案更像“知道主线”,但面试中还需要补两类东西:

  • 边界条件:比如缓存和权限、页面显隐、非法 DOM、配置阈值、失败重试。
  • 更准确的术语:比如 Client Component 不是简单等于 CSR,<p> 不是行内标签,revalidate = false 不是用户手动刷新。

如果把第 7 题补上,并把 2、4、5、9、10 的细节加强,这套回答会更像中高级前端候选人的表达。

评论
0/100