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

这篇文章记录一次前端模拟面试的完整过程:先保留原始题目和回答,再逐题点评,并给出更适合面试表达的版本。题目覆盖 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 成本说明。

我的回答

markdown
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. 【文章延伸·代码阅读】下面这段组件组合是否合法?如果不合法,请说明违反了什么边界;如果要实现“客户端外壳包住服务端文章内容”,应该怎么改?

tsx
// 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>;
}

我的回答

markdown
```tsx
客户端组件不能直接import 服务端组件,否则就会把服务端组件的代码也发送到客户端。
服务端组件中可以import客户端组件。
如果客户端组件想要访问服务端资源,可以由服务端组件申请,然后服务端组件中使用该客户端组件,并且将数据以props的形式传递给客户端组件。
```

点评

评价:6.5/10

正确点:判断对了,Client Component 不能直接 import Server Component;也说明了原因是可能把服务端代码拉进客户端 bundle。

需要修正:题目问的是“客户端外壳包住服务端文章内容”,最佳模式是 children 组合,而不是把文章数据全部作为 props 传给客户端组件后重新渲染。后者会削弱 Server Component 渲染正文的收益。

更好的表达:

tsx
// 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 缓存、权限泄露和路由拆分角度回答。

我的回答

markdown
如果直接在同一个 `/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 后如何避免重复请求第一页,切换“热门/最新”时怎么处理,文章发布后首页缓存应该如何刷新?

我的回答

markdown
网站首页有多个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?请指出问题,并给出一个更安全的渲染思路。

tsx
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>
  ),
};

我的回答

markdown
因为原来那样就是在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。

更好的表达:

tsx
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 也不是收录保证?请结合内容站的公开文章页、预览页、编辑器页和用户私有页说明。

我的回答

markdown
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。

我的回答

markdown
```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 防止重复触发。

更好的实现:

tsx
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。

我的回答

markdown
```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。

更好的实现:

ts
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 的执行关系。

js
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');

我的回答

markdown
执行顺序是同步代码、微任务、宏任务。
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、权限边界中选择。

我的回答

markdown
我会先挑选首页/专栏页 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 的区别。
评论
0/100