这篇文章记录一次前端模拟面试的完整过程:先保留原始题目和回答,再逐题点评,并给出更适合面试表达的版本。题目覆盖 Next.js App Router、Server Component / Client Component、SSR / ISR、Hydration、SEO、前端埋点、事件循环和内容站优化。
1. 【文章延伸·八股文】Server Component 和 Client Component 的核心区别是什么?在 Next.js App Router 中,为什么推荐“服务端负责稳定内容,客户端负责交互能力”?请结合 useState、useEffect、事件绑定、服务端资源访问、bundle 体积和 hydration 成本说明。
我的回答
Server Component和Client Component的核心区别在于是否需要水合。
两者都会在服务端渲染出html,但是只有Client Component的代码会被发送到客户端进行水合。
所以,如果全部都做成Client Component,就会导致js体积变大,影响性能。
需要交互的组件,或者说依赖真实dom进行的组件,就需要拆分成Client shell。
useState和useEffect依赖真实的React生命周期,需要在客户端才能进行。
事件绑定依赖真实dom,所以也需要在客户端进行。
Server Component可以直接访问服务端资源,而Client Component则不可以直接访问服务端资源。
点评
评价:7/10
正确点:抓住了 Client Component 需要发送 JS 到浏览器并 hydration,而 Server Component 不把组件代码发到客户端;也提到了 bundle 体积、hydration 成本、事件绑定和服务端资源访问。
需要修正:两者都会在服务端渲染出 HTML 这句话要更严谨。Client Component 在初始加载时可以被服务端预渲染出 HTML,但它仍需要客户端 JS 和 hydration;Server Component 则不会在客户端 hydration。useState、useEffect 更准确地说是依赖客户端 React runtime 和浏览器环境。
更好的表达:
Server Component 默认在服务端执行,适合查询数据、访问服务端资源、渲染稳定内容和 SEO 内容,它的组件代码不会进入客户端 bundle,也不需要 hydration。Client Component 需要加
'use client',可以使用useState、useEffect、事件绑定和浏览器 API,但它的 JS 会发送到浏览器并参与 hydration。所以在 App Router 里推荐把文章正文、标题、metadata 这类稳定内容放在 Server Component,把点赞、评论、目录滚动、表单输入这类交互能力拆到 Client Component。这样可以减少 JS 体积和 hydration 成本,同时保留必要的交互。
2. 【文章延伸·代码阅读】下面这段组件组合是否合法?如果不合法,请说明违反了什么边界;如果要实现“客户端外壳包住服务端文章内容”,应该怎么改?
// ClientShell.tsx
'use client';
import ArticleContent from './ArticleContent';
export default function ClientShell() {
return (
<section>
<button onClick={() => console.log('like')}>点赞</button>
<ArticleContent />
</section>
);
}
// ArticleContent.tsx,默认 Server Component
export default async function ArticleContent() {
const article = await getArticleFromDatabase();
return <article>{article.content}</article>;
}
我的回答
```tsx
客户端组件不能直接import 服务端组件,否则就会把服务端组件的代码也发送到客户端。
服务端组件中可以import客户端组件。
如果客户端组件想要访问服务端资源,可以由服务端组件申请,然后服务端组件中使用该客户端组件,并且将数据以props的形式传递给客户端组件。
```
点评
评价:6.5/10
正确点:判断对了,Client Component 不能直接 import Server Component;也说明了原因是可能把服务端代码拉进客户端 bundle。
需要修正:题目问的是“客户端外壳包住服务端文章内容”,最佳模式是 children 组合,而不是把文章数据全部作为 props 传给客户端组件后重新渲染。后者会削弱 Server Component 渲染正文的收益。
更好的表达:
// page.tsx,Server Component
import ClientShell from './ClientShell';
import ArticleContent from './ArticleContent';
export default function Page() {
return (
<ClientShell>
<ArticleContent />
</ClientShell>
);
}
// ClientShell.tsx
'use client';
export default function ClientShell({ children }: { children: React.ReactNode }) {
return (
<section>
<button onClick={() => console.log('like')}>点赞</button>
{children}
</section>
);
}
// ArticleContent.tsx,Server Component
export default async function ArticleContent() {
const article = await getArticleFromDatabase();
return <article>{article.content}</article>;
}
关键点是:ClientShell 不能 import ArticleContent,但 Server Page 可以同时 import ClientShell 和 ArticleContent,然后把服务端渲染结果作为 children 传给 ClientShell。
3. 【文章延伸·场景设计】Visionary Reader 页从 SSR 继续演进到 ISR 时,为什么不能直接在同一个 /reader/[articleId] 页面里同时支持“公开文章访问”和“作者预览非公开文章”?请从 cookie/headers、用户态数据、公共 HTML 缓存、权限泄露和路由拆分角度回答。
我的回答
如果直接在同一个 `/reader/[articleId]` 页面里同时支持“公开文章访问”和“作者预览非公开文章”,就会导致权限泄露。
这个页面的html有两种情况,一个是公开文章,另一类是非公开文章。
如果做成ISR,当缓存的内容是非公开文章时,不可见该页面的用户,在访问该页面时。
该页面的html就会先返回,然后鉴权发现该用户无权限后再变成404页面。
这样就会导致数据泄露。
点评
评价:7/10
正确点:识别出了核心风险:公开文章和作者预览非公开文章混在同一路由里,做 ISR 会有权限泄露风险。
需要修正:HTML 先返回,然后鉴权发现无权限再变成 404 这个描述不准确。ISR 命中缓存后,通常不会再针对当前用户重新鉴权生成 HTML;真正的泄露点是未授权用户可能直接拿到之前缓存好的非公开 HTML。
更好的表达:
ISR 缓存的是某个 URL 对应的 HTML。如果
/reader/123既能给普通用户展示公开文章,又能给作者展示非公开文章,那么同一个 URL 的 HTML 就依赖当前用户身份。一旦作者访问非公开文章并生成缓存,后续未登录用户访问同一个路径时,可能直接命中这份缓存,造成权限泄露。
所以公开阅读页必须不读 cookie、不依赖 viewer,只查询
is_published = 1且view_permission = 'all'的文章,并允许force-static/ ISR。作者预览页应该拆到/reader/preview/[articleId],读取 cookie 做鉴权,设置force-dynamic,并加robots: noindex。
4. 【文章延伸·场景设计】一个内容站首页已经把首屏文章列表改成 ISR,但仍保留客户端 Tab 切换和加载更多。请设计这类页面的数据流:首屏数据怎么来,hydration 后如何避免重复请求第一页,切换“热门/最新”时怎么处理,文章发布后首页缓存应该如何刷新?
我的回答
网站首页有多个tab,并且list有加载更多的功能。
那么首屏数据可以由服务端获取并且渲染。
比如,tab默认展示最新的前10条数据。
那么服务端就可以直接从数据库获取这10条数据,然后渲染出html。
客户端组件中,用intersectionObserver监听加载更多按钮是否进入视口。
如果进入视口,就调用loadMore函数,加载更多数据。
点评
评价:5/10
正确点:说对了首屏数据由服务端获取并渲染;默认 Tab 可以先渲染最新文章;IntersectionObserver 触发加载更多也是一个可行方案。
需要修正:没有回答 hydration 后如何避免重复请求第一页;没有回答切换热门/最新时怎么处理;也没有回答文章发布后首页 ISR 缓存如何刷新。
更好的表达:
首页可以拆成 Server Page + Client Component。Server Page 查询默认 Tab,比如“最新”的第一页文章,开启
revalidate = 300或按业务事件 revalidate,然后把initialArticles传给客户端组件。客户端组件用
initialArticles初始化列表状态。hydration 后如果当前 Tab 仍是默认的new,第一次 effect 要跳过请求,避免重复拉第一页。用户切换到
hot时,清空当前列表和分页状态,请求/api/articles?sort=hot&pageNum=0。再切回new时,可以选择复用初始数据,也可以重新请求第一页,取决于实时性要求。加载更多可以用按钮或 IntersectionObserver 触发,请求下一页并 append。文章发布、审核通过、删除或公开状态变化后,服务端应该调用
revalidatePath('/'),让首页缓存尽快刷新。
5. 【文章延伸·代码阅读】下面这个 Markdown 服务端渲染片段为什么可能产生 hydration error 或非法 DOM?请指出问题,并给出一个更安全的渲染思路。
function Paragraph({ children }: { children: React.ReactNode }) {
return <p className="paragraph">{children}</p>;
}
const components = {
p: Paragraph,
img: (props: React.ImgHTMLAttributes<HTMLImageElement>) => (
<div className="imageWrap">
<img {...props} />
</div>
),
};
我的回答
因为原来那样就是在p当中渲染div,这是不合法的,如下:
<p>
<div></div>
</p>
浏览器解析时,会改变dom结构,变成:
<p></p>
<div></div>
<p></p>
所以,html结构就对不上了,就会导致水合错误。
```tsx
function Paragraph({ children }: { children: React.ReactNode }) {
if (children.type === 'img') { // 如果子元素是图片,就不用P标签包裹即可。
return children;
}
return <p className="paragraph">{children}</p>;
}
const components = {
p: Paragraph,
img: (props: React.ImgHTMLAttributes<HTMLImageElement>) => (
<div className="imageWrap">
<img {...props} />
</div>
),
};
```
点评
评价:8/10
正确点:核心问题判断正确:p 里面不能放 div。你也能说明浏览器会修正 DOM,导致服务端 HTML 和 React 预期结构不一致,从而产生 hydration error。
需要修正:children.type === 'img' 这个判断太脆弱。children 可能是数组、字符串、Fragment,也可能已经被 img renderer 替换成了 div。
更好的表达:
function hasImageChild(children: React.ReactNode) {
return React.Children.toArray(children).some((child) => {
return React.isValidElement(child) && child.type === 'img';
});
}
function Paragraph({ children }: { children: React.ReactNode }) {
if (hasImageChild(children)) {
return <div className="paragraph imageParagraph">{children}</div>;
}
return <p className="paragraph">{children}</p>;
}
更本质的原则是:Markdown 渲染器最终输出的真实 HTML 必须合法。不要让 p > div、p > figure 这类非法结构出现在服务端 HTML 里,否则浏览器会自动修正 DOM,React hydration 时就会发现结构对不上。
6. 【文章延伸·八股文】sitemap.xml、robots.txt、canonical 分别解决什么 SEO 问题?为什么说 robots.txt 不是权限系统,sitemap 也不是收录保证?请结合内容站的公开文章页、预览页、编辑器页和用户私有页说明。
我的回答
sitemap.xml 是一个 XML 文件,用于告诉搜索引擎网站的页面和文章。
robots.txt 是一个文本文件,用于告诉搜索引擎哪些页面可以爬取,哪些页面不可以爬取。
canonical 是一个标签,用于告诉搜索引擎,多个相同的页面,哪个是主版本。
robots.txt 和 sitemap.xml都相当于是给搜索引擎的一个参考,而不是强制的规则。
搜索引擎可以根据这两个文件,来判断哪些页面可以爬取,哪些页面不可以爬取。
但是,搜索引擎可以忽略这两个文件,根据自己的判断,来爬取页面。
点评
评价:6.5/10
正确点:三个概念的基本定义是对的,也能说明它们主要是给搜索引擎的信号,不是绝对强制。
需要修正:sitemap 不是告诉搜索引擎“网站所有页面”,而是告诉它“站点希望被发现的公开 URL 清单”。robots.txt 不是权限系统,不能阻止用户或恶意爬虫访问。
更好的表达:
sitemap.xml负责告诉搜索引擎哪些公开页面值得发现,比如首页、公开文章页、专栏页。它只应该包含已发布且公开可访问的 URL,不应该包含草稿、预览页、编辑器页。
robots.txt负责告诉搜索引擎哪些路径不要抓,比如/api/、/editor/、/reader/preview/、用户私有页。但它不是权限系统,不能阻止用户或恶意爬虫访问,真正的安全仍要靠服务端鉴权。
canonical负责告诉搜索引擎当前页面的标准 URL,避免?from=xxx、带斜杠、不带斜杠、www/非 www 等重复地址分散权重。首页 canonical 指向首页,文章页 canonical 应该指向自己的/reader/[id]。sitemap 只是发现入口,不保证收录;robots 只是抓取建议,不等于访问控制。
7. 【文章延伸·手撕题】实现一个简化版 useTrackExposure。
要求:返回一个 ref,业务组件可以绑定到目标 DOM;使用 IntersectionObserver 判断元素是否进入视口;支持 threshold,默认 0.5;支持 once,默认只上报一次;触发时调用 onExpose({ intersectionRatio, time });组件卸载时清理 observer。
我的回答
```tsx
const useTrackExposure = ({ once = true, threshold = 0.5, onExpose }) => {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
onExpose({ intersectionRatio: entry.intersectionRatio, time: Date.now() });
if (once) {
observer.disconnect();
}
}
});
}, { threshold });
observer.observe(ref.current!);
return () => observer.disconnect();
}, [ref, threshold, once, onExpose]);
}
```
点评
评价:6.5/10
正确点:使用了 useRef、IntersectionObserver、threshold、once 和 cleanup,上报参数也包含 intersectionRatio 和时间。
需要修正:代码没有 return ref;observer.observe(ref.current!) 对 null 不安全;最好判断 entry.intersectionRatio >= threshold;once 为 true 时建议用 hasReportedRef 防止重复触发。
更好的实现:
import { useEffect, useRef } from 'react';
type ExposurePayload = {
intersectionRatio: number;
time: number;
};
type Options = {
threshold?: number;
once?: boolean;
onExpose: (payload: ExposurePayload) => void;
};
export function useTrackExposure<T extends HTMLElement>({
threshold = 0.5,
once = true,
onExpose,
}: Options) {
const targetRef = useRef<T | null>(null);
const hasReportedRef = useRef(false);
useEffect(() => {
const target = targetRef.current;
if (!target || typeof IntersectionObserver === 'undefined') return;
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
const visibleEnough = entry.isIntersecting && entry.intersectionRatio >= threshold;
if (!visibleEnough) continue;
if (once && hasReportedRef.current) continue;
onExpose({ intersectionRatio: entry.intersectionRatio, time: Date.now() });
if (once) {
hasReportedRef.current = true;
observer.unobserve(entry.target);
}
}
},
{ threshold }
);
observer.observe(target);
return () => {
observer.unobserve(target);
observer.disconnect();
};
}, [threshold, once, onExpose]);
return targetRef;
}
8. 【文章延伸·手撕题】实现一个简化版批量埋点队列。
要求:push(event) 添加事件;队列长度达到 batchSize 时立即 flush;超过 batchInterval 仍未 flush 时自动 flush;页面隐藏时尽量 flush;flush 失败时把事件放回队列或进入失败队列,并说明取舍;只需要写核心 TypeScript 逻辑,不需要完整 React SDK。
我的回答
```ts
class BatchQueue {
private queue: any[] = [];
private timer: number | null = null;
constructor(private batchSize: number = 10, private batchInterval: number = 1000) {
this.batchSize = batchSize;
this.batchInterval = batchInterval;
}
push(event: any) {
this.queue.push(event);
if (this.queue.length >= this.batchSize) {
this.flush();
}
if (this.timer) {
clearTimeout(this.timer);
}
this.timer = setTimeout(() => {
if (this.queue.length > 0) {
this.flush();
} else {
clearTimeout(this.timer);
this.timer = null;
}
}, this.batchInterval);
}
flush() {
this.queue.forEach((event) => {
this.flushEvent(event);
});
this.queue = [];
}
flushEvent(event: any) {
// 实际的埋点逻辑
}
}
```
点评
评价:4.5/10
正确点:有队列;push 后能按 batchSize 触发 flush;有定时器思路;能把批量上报抽成类。
需要修正:flush 不是批量上报,而是逐条 flushEvent;没有处理异步失败;没有页面隐藏时 flush;flush 失败后事件会丢;timer 每次 push 都重置,持续有事件进入时会一直延后 flush。
更好的实现:
type TrackEvent = Record<string, unknown>;
type BatchQueueOptions = {
batchSize?: number;
batchInterval?: number;
send: (events: TrackEvent[]) => Promise<void>;
};
class BatchTrackQueue {
private queue: TrackEvent[] = [];
private failedQueue: TrackEvent[] = [];
private timer: ReturnType<typeof setTimeout> | null = null;
private flushing = false;
constructor(private options: BatchQueueOptions) {
document.addEventListener('visibilitychange', this.handleVisibilityChange);
}
push(event: TrackEvent) {
this.queue.push(event);
if (this.queue.length >= (this.options.batchSize ?? 10)) {
void this.flush();
return;
}
this.ensureTimer();
}
private ensureTimer() {
if (this.timer) return;
this.timer = setTimeout(() => {
this.timer = null;
void this.flush();
}, this.options.batchInterval ?? 5000);
}
async flush() {
if (this.flushing || this.queue.length === 0) return;
this.flushing = true;
const events = this.queue.splice(0, this.queue.length);
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
try {
await this.options.send(events);
} catch {
this.failedQueue.push(...events);
} finally {
this.flushing = false;
if (this.queue.length > 0) this.ensureTimer();
}
}
private handleVisibilityChange = () => {
if (document.visibilityState === 'hidden') {
void this.flush();
}
};
destroy() {
if (this.timer) clearTimeout(this.timer);
document.removeEventListener('visibilitychange', this.handleVisibilityChange);
void this.flush();
}
}
失败时更倾向于进入失败队列,而不是直接塞回主队列头部。这样可以避免接口持续失败时阻塞新事件,也方便对失败队列做最大长度、重试次数和指数退避控制。
9. 【随机八股·代码阅读】下面代码的输出顺序是什么?请解释宏任务、微任务、async/await 和 Promise.then 的执行关系。
console.log('A');
setTimeout(() => {
console.log('B');
Promise.resolve().then(() => console.log('C'));
}, 0);
async function run() {
console.log('D');
await Promise.resolve();
console.log('E');
}
run();
Promise.resolve().then(() => console.log('F'));
console.log('G');
我的回答
执行顺序是同步代码、微任务、宏任务。
Promise.then 是微任务,setTimeout 是宏任务。
async/await 是Promise.then的语法糖。
这个是事件循环的机制,系统会维护两个队列,一个是微任务队列,一个是宏任务队列。
系统会先执行同步代码,然后执行微任务队列中的任务,最后执行宏任务队列中的任务。
执行过程中碰到Promise.then,就会把Promise.then中的任务放到微任务队列中。碰到宏任务,就会把宏任务放到宏任务队列中。
```js
A D G E F B C
```
点评
评价:8/10
正确点:输出顺序正确:A D G E F B C。同步、微任务、宏任务的大方向也正确。
需要修正:最后执行宏任务队列中的任务 这句话要更严谨。事件循环是每轮取一个宏任务执行,然后清空当前微任务队列,再进入下一轮宏任务。
更好的表达:
先执行全局同步代码:输出
A,注册setTimeout,调用run()输出D,await Promise.resolve()后面的E进入微任务队列,然后注册F的微任务,最后输出G。当前宏任务结束后清空微任务队列,先执行
await后续,所以输出E,再执行后注册的then,输出F。下一轮事件循环执行
setTimeout宏任务,输出B,这个宏任务里又注册了一个微任务,当前宏任务结束后输出C。最终顺序是:
A D G E F B C。
10. 【综合追问题】如果让你继续优化 Visionary 内容站的前端体验和 SEO,你会优先做哪三件事?请分别说明目标、技术方案、可能风险和验证方式。可以从首页/专栏页 SSR 或 ISR、文章 JSON-LD、图片 alt/OG、缓存刷新、前端监控埋点、Core Web Vitals、权限边界中选择。
我的回答
我会先挑选首页/专栏页 SSR 或 ISR、文章 JSON-LD、图片 alt/OG。
这几个点是最影响SEO的,因为它们直接关系到搜索引擎的收录和排名。
点评
评价:3/10
正确点:选的三个方向都合理:专栏/首页 SSR 或 ISR、JSON-LD、图片 alt/OG。也知道这些和 SEO 直接相关。
需要修正:题目要求分别说明目标、技术方案、风险和验证方式,但原回答只列了方向,没有展开。
更好的表达:
第一优先级是把专栏页和作者公开主页做 SSR/ISR。目标是让搜索引擎能直接在初始 HTML 中看到文章列表、标题、摘要和内链。技术上可以拆成 Server Page + Client Shell,公开数据走 ISR,客户端保留分页和关注等交互。风险是缓存和权限边界混在一起,所以只能缓存公开数据,发布、删除、公开状态变化时要
revalidatePath。验证方式是查看页面源码、构建输出、Search Console 抓取结果和索引情况。第二优先级是给文章页补 JSON-LD。目标是让搜索引擎更准确理解文章标题、作者、发布时间、封面、摘要和 canonical URL。技术上在文章 Server Component 或 metadata 层输出
Article/BlogPosting结构化数据。风险是字段和页面内容不一致会影响可信度。验证方式是用 Google Rich Results Test 和 Schema Validator 检查。第三优先级是优化图片 alt、OG 图片和正文图片加载。目标是提升图片搜索、分享卡片和首屏性能。技术上要求封面有稳定 OG 图,正文图片补 alt,首屏图片设置合理尺寸和 priority,非首屏懒加载。风险是图片尺寸不稳定导致 CLS,或者 OG 图缺失影响分享展示。验证方式是 Lighthouse、Core Web Vitals、真实分享预览和 Search Console 图片索引数据。
总体评价
整体水平:中等偏上,偏工程实践型。
你的强项是:对 Server / Client Component、ISR 权限泄露、hydration error、event loop 这些核心概念有基本判断力;第 1、3、5、9 题答得比较稳;对自己文章里写过的主题有记忆,但表达还需要更面试化。
最需要提升的三点:
- 回答场景题时要补完整链路:目标、方案、边界、风险、验证方式。
- 手撕题要补边界:返回值、空值、异步失败、资源清理、重复触发、并发 flush。
- 概念题要避免“差不多对”的表述,比如 ISR 命中缓存后不会再按用户鉴权、robots 不是权限、Client Component 预渲染和 hydration 的区别。