每日面试题(2026-05-20)- 前端

一、JavaScript/TypeScript

1. event loop

面试题: 请分析以下代码的输出顺序,并详细解释每一步的执行过程:

javascript 复制代码
console.log('script start');

setTimeout(() => {
  console.log('setTimeout 0');
}, 0);

Promise.resolve().then(() => {
  console.log('promise1');
}).then(() => {
  console.log('promise2');
});

queueMicrotask(() => {
  console.log('queueMicrotask');
});

console.log('script end');

参考答案:

arduino 复制代码
script start
script end
promise1
queueMicrotask
promise2
setTimeout 0

详细解析:

  1. console.log('script start') --- 同步代码,立即执行
  2. setTimeout --- 宏任务,放入宏任务队列
  3. Promise.resolve().then(...) --- 微任务,放入微任务队列
  4. queueMicrotask(...) --- 微任务,放入微任务队列
  5. console.log('script end') --- 同步代码,立即执行
  6. 同步代码执行完毕,开始执行微任务队列(按 FIFO 顺序)
  7. 先执行 promise1 的回调,由于链式调用又产生新的微任务 promise2
  8. 执行 queueMicrotask 的回调
  9. 再执行 promise2 的回调
  10. 微任务队列为空,执行宏任务 setTimeout

核心考点: 理解宏任务(macrotask)与微任务(microtask)的区别,以及 Event Loop 的完整执行顺序。微任务优先级高于宏任务,且会在每个宏任务执行完毕后、下一个宏任务开始前清空。


2. promise

面试题 : 实现一个 Promise.all 的 polyfill,要求支持:

  1. 接收一个 Promise 数组
  2. 所有 Promise 成功时返回结果数组
  3. 任一 Promise 失败时立即 reject
  4. 处理空数组情况

参考答案:

javascript 复制代码
/**
 * Promise.all Polyfill 实现
 * @param {Array<Promise>} promises - Promise 数组
 * @returns {Promise<Array>}
 */
function promiseAll(promises) {
  return new Promise((resolve, reject) => {
    // 处理空数组情况
    if (!Array.isArray(promises)) {
      return reject(new TypeError('Promise.all expects an array'));
    }

    const results = new Array(promises.length);
    let completedCount = 0;

    // 空数组直接 resolve 空数组
    if (promises.length === 0) {
      return resolve(results);
    }

    promises.forEach((promise, index) => {
      // 处理非 Promise 值(自动包装为 Promise)
      Promise.resolve(promise)
        .then((value) => {
          results[index] = value;
          completedCount++;

          // 所有 Promise 都完成时 resolve
          if (completedCount === promises.length) {
            resolve(results);
          }
        })
        .catch((error) => {
          // 任一失败立即 reject
          reject(error);
        });
    });
  });
}

// 测试用例
const p1 = Promise.resolve(1);
const p2 = new Promise((resolve) => setTimeout(() => resolve(2), 100));
const p3 = 3; // 非 Promise 值

promiseAll([p1, p2, p3]).then(console.log); // [1, 2, 3]
promiseAll([]).then(console.log); // []
promiseAll([p1, Promise.reject('error')]).catch(console.error); // error

详细解析:

  • Promise.resolve(promise) 可以处理非 Promise 值,自动包装为 resolved Promise
  • 使用 completedCount 计数器跟踪完成的 Promise 数量,确保结果顺序与输入顺序一致
  • 任一 Promise reject 时立即调用 reject,符合 Promise.all 的短路特性
  • 空数组直接 resolve 空数组,这是 ES2020 规范要求

3. async/await

面试题 : 分析以下代码,解释 async/await 的错误处理机制,并说明如何正确捕获错误:

javascript 复制代码
async function fetchData() {
  const response = await fetch('/api/data');
  const data = await response.json();
  return data;
}

// 方式1
fetchData().then(data => console.log(data));

// 方式2
try {
  const data = await fetchData();
} catch (error) {
  console.error(error);
}

参考答案:

javascript 复制代码
/**
 * async/await 错误处理最佳实践
 */

// 方式1: 使用 try-catch 包裹 await
async function safeFetchData() {
  try {
    const response = await fetch('/api/data');
    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }
    const data = await response.json();
    return data;
  } catch (error) {
    // 捕获 fetch 网络错误 或 response.json() 解析错误
    console.error('Fetch failed:', error);
    return null; // 或返回默认值
  }
}

// 方式2: 使用 .catch() 链式调用
fetchData()
  .then(data => console.log(data))
  .catch(error => console.error('Error:', error));

// 方式3: Promise.all 中的错误处理
async function fetchMultiple() {
  try {
    const [users, posts] = await Promise.all([
      fetch('/api/users').then(r => r.json()),
      fetch('/api/posts').then(r => r.json())
    ]);
    return { users, posts };
  } catch (error) {
    // 任一请求失败都会进入这里
    console.error('One or more requests failed:', error);
    throw error;
  }
}

// 方式4: 使用 Promise.allSettled 避免单个失败影响整体
async function fetchMultipleSafe() {
  const results = await Promise.allSettled([
    fetch('/api/users').then(r => r.json()),
    fetch('/api/posts').then(r => r.json())
  ]);

  results.forEach((result, index) => {
    if (result.status === 'fulfilled') {
      console.log(`Request ${index} succeeded:`, result.value);
    } else {
      console.error(`Request ${index} failed:`, result.reason);
    }
  });
}

详细解析:

  • async 函数总是返回一个 Promise,即使内部抛出错误也会转为 rejected Promise
  • await 会暂停 async 函数执行,等待 Promise 完成,但不会阻塞主线程
  • 错误会沿着 Promise 链传播,可以用 try-catch 或 .catch() 捕获
  • Promise.all 中任一失败会导致整体失败,使用 Promise.allSettled 可以获取每个 Promise 的结果状态

4. 闭包

面试题: 什么是闭包?请实现一个计数器函数,要求:

  1. 支持增加、减少、获取当前值
  2. 支持设置步长
  3. 值不能被外部直接修改

参考答案:

javascript 复制代码
/**
 * 闭包实现的计数器
 * @param {number} initialValue - 初始值
 * @param {number} step - 步长
 * @returns {Object} 计数器方法
 */
function createCounter(initialValue = 0, step = 1) {
  // 私有变量,外部无法直接访问
  let value = initialValue;
  let currentStep = step;

  return {
    increment() {
      value += currentStep;
      return value;
    },
    decrement() {
      value -= currentStep;
      return value;
    },
    getValue() {
      return value;
    },
    setStep(newStep) {
      currentStep = newStep;
      return currentStep;
    },
    reset() {
      value = initialValue;
      currentStep = step;
      return value;
    }
  };
}

// 使用示例
const counter = createCounter(10, 2);
console.log(counter.getValue()); // 10
console.log(counter.increment()); // 12
console.log(counter.increment()); // 14
console.log(counter.decrement()); // 12
counter.setStep(5);
console.log(counter.increment()); // 17

// 无法直接修改 value
counter.value = 100; // 无效
console.log(counter.getValue()); // 17

详细解析:

  • 闭包是指函数能够记住并访问其词法作用域,即使该函数在其词法作用域之外执行
  • 通过闭包实现了数据封装(private variable),valuecurrentStep 无法从外部直接访问
  • 返回的对象方法形成了闭包,保持对私有变量的引用
  • 这是 JavaScript 中实现模块模式(Module Pattern)的核心机制

二、React

1. hooks原理

面试题: 请解释 React Hooks 的实现原理,并分析以下代码为什么会得到意外的结果:

javascript 复制代码
function Example() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    console.log('Effect:', count);
  });

  const handleClick = () => {
    setCount(count + 1);
    setCount(count + 1);
    setCount(count + 1);
  };

  return <button onClick={handleClick}>{count}</button>;
}

参考答案:

React Hooks 基于链表结构数组索引实现:

  1. Hooks 存储结构: React 使用一个单向链表来存储组件的所有 Hook 状态
  2. 按顺序调用 : Hooks 必须在组件顶层按相同顺序调用,这是 Rules of Hooks 的根本原因
  3. 状态更新 : useState 返回的 setState 会触发组件重新渲染
javascript 复制代码
/**
 * 简化版 useState 实现原理
 */
function useState(initialValue) {
  // 从 Hooks 链表中获取当前 Hook
  const hook = getCurrentHook();

  if (!hook.state) {
    hook.state = initialValue;
  }

  const setState = (newValue) => {
    hook.state = newValue;
    // 触发重新渲染
    scheduleUpdate();
  };

  return [hook.state, setState];
}

问题分析:

javascript 复制代码
const handleClick = () => {
  // 三次 setCount 都基于同一个 count 值(闭包捕获)
  setCount(count + 1); // count = 0 + 1 = 1
  setCount(count + 1); // count = 0 + 1 = 1
  setCount(count + 1); // count = 0 + 1 = 1
};

解决方案:

javascript 复制代码
const handleClick = () => {
  // 使用函数式更新,基于最新状态
  setCount(prev => prev + 1); // 1
  setCount(prev => prev + 1); // 2
  setCount(prev => prev + 1); // 3
};

详细解析:

  • Hooks 通过数组索引来对应每个 Hook 调用,所以不能在循环或条件语句中调用
  • 函数式更新 setState(prev => prev + 1) 可以获取最新状态,避免闭包陷阱
  • useEffect 的依赖数组决定了 effect 何时重新执行,空数组 [] 表示只在挂载时执行

2. fiber

面试题: 什么是 React Fiber?它解决了什么问题?请描述 Fiber 树的工作流程。

参考答案:

React Fiber 是 React 16 引入的新的协调引擎(Reconciliation),核心目标是:

  1. 可中断的渲染: 将渲染工作拆分为小单元,可暂停、恢复、丢弃
  2. 优先级调度: 不同更新可以设置不同优先级
  3. 并发特性基础: 为 Suspense、Concurrent Mode 提供底层支持
javascript 复制代码
/**
 * Fiber 节点结构(简化版)
 */
interface FiberNode {
  type: any;           // 组件类型(函数、类、DOM标签)
  key: string | null;  // diff 标识
  stateNode: any;      // 真实 DOM 节点或组件实例
  child: Fiber | null; // 第一个子 Fiber
  sibling: Fiber | null; // 下一个兄弟 Fiber
  return: Fiber | null;  // 父 Fiber
  pendingProps: any;   // 新 props
  memoizedProps: any;  // 当前 props
  memoizedState: any;  // 当前 state(Hooks 链表头)
  effectTag: number;   // 副作用标记(Placement/Update/Deletion)
  nextEffect: Fiber | null; // 副作用链表
}

Fiber 工作流程:

sql 复制代码
1. Render 阶段(可中断)
   ├── 构建 Fiber 树(深度优先遍历)
   ├── 执行 diff 算法
   ├── 标记副作用(effectTag)
   └── 可中断,让出主线程

2. Commit 阶段(不可中断)
   ├── 执行 DOM 操作(Placement/Update/Deletion)
   ├── 执行生命周期/componentDidMount/Update
   └── 执行 useEffect 回调

详细解析:

  • 双缓冲技术: React 维护两棵 Fiber 树(current 和 workInProgress),避免直接操作当前视图
  • 时间切片 : 利用 requestIdleCallbackscheduler 包,在浏览器空闲时执行渲染工作
  • 优先级机制: 用户交互(高优先级)可以打断低优先级的渲染任务
  • 错误边界: Fiber 架构支持更优雅的错误处理和恢复

3. diff算法

面试题: React 的 Diff 算法原理是什么?为什么 React 选择这样的策略?分析以下场景的 Diff 过程:

jsx 复制代码
// 更新前
<ul>
  <li key="a">A</li>
  <li key="b">B</li>
  <li key="c">C</li>
</ul>

// 更新后
<ul>
  <li key="a">A</li>
  <li key="d">D</li>
  <li key="b">B</li>
  <li key="c">C</li>
</ul>

参考答案:

React Diff 算法的核心策略(基于两个假设):

  1. 不同类型的元素产生不同的树 --- 直接替换整个子树
  2. 通过 key 属性可以暗示哪些子元素是稳定的 --- 复用相同 key 的节点
typescript 复制代码
Diff 算法流程(同级比较):

1. 单节点比较(新子节点只有一个)
   ├── key 和 type 都相同 → 复用节点,更新 props
   ├── key 或 type 不同 → 删除旧节点,创建新节点

2. 多节点比较(新子节点有多个)
   ├── 第一轮:按顺序遍历,key 相同则复用,遇到不同则停止
   ├── 第二轮:将剩余旧节点放入 Map(key → Fiber)
   ├── 第三轮:遍历剩余新节点,从 Map 中查找可复用节点
   └── 第四轮:处理移动(lastPlacedIndex 判断位置)

场景分析:

less 复制代码
更新前: [a, b, c]
更新后: [a, d, b, c]

第一轮遍历:
  a vs a → key 相同,复用
  b vs d → key 不同,停止

第二轮: 旧节点剩余 [b, c],放入 Map
  Map: { b: Fiber_b, c: Fiber_c }

第三轮: 新节点剩余 [d, b, c]
  d: Map 中找不到,创建新节点
  b: Map 中找到,复用,检查位置
     lastPlacedIndex = 1(a 的位置)
     b 的旧位置 = 1 >= lastPlacedIndex → 不移动,lastPlacedIndex = 1
  c: Map 中找到,复用
     c 的旧位置 = 2 >= lastPlacedIndex → 不移动

结果: 创建 d,复用 a、b、c,b 和 c 不移动

详细解析:

  • React 的 Diff 是O(n) 复杂度,牺牲最优解换取性能平衡
  • key 是 Diff 的核心,必须使用稳定且唯一的标识(避免使用 index)
  • 没有 key 时,React 会按索引比较,可能导致不必要的 DOM 操作
  • 在列表头部插入元素时,使用 index 作为 key 会导致所有元素重新渲染

4. React18并发

面试题 : React 18 的并发特性(Concurrent Features)有哪些?请解释 useTransitionuseDeferredValue 的使用场景和区别。

参考答案:

React 18 引入了并发渲染(Concurrent Rendering),核心特性包括:

javascript 复制代码
/**
 * useTransition: 标记非紧急更新
 * 用于:开始按钮加载状态、切换Tab等
 */
import { useTransition, useState } from 'react';

function TabContainer() {
  const [isPending, startTransition] = useTransition();
  const [tab, setTab] = useState('home');

  const switchTab = (newTab) => {
    // 紧急更新:立即更新 UI(如按钮状态)
    // 非紧急更新:startTransition 包裹的 setTab 可以被中断
    startTransition(() => {
      setTab(newTab);
    });
  };

  return (
    <div>
      {isPending && <div>加载中...</div>}
      <button onClick={() => switchTab('home')}>首页</button>
      <button onClick={() => switchTab('about')}>关于</button>
      <TabContent tab={tab} />
    </div>
  );
}

/**
 * useDeferredValue: 延迟更新某个值
 * 用于:搜索建议、实时过滤等
 */
import { useDeferredValue } from 'react';

function SearchResults({ query }) {
  // deferredQuery 会延迟更新,保持 UI 响应
  const deferredQuery = useDeferredValue(query);

  // 使用 deferredQuery 进行耗时计算/渲染
  const results = useMemo(() => {
    return heavySearch(deferredQuery);
  }, [deferredQuery]);

  return (
    <div>
      {/* 输入框使用原始 query,保持即时响应 */}
      {/* 搜索结果使用 deferredQuery,允许延迟 */}
      {deferredQuery !== query && <div>搜索中...</div>}
      <ResultsList results={results} />
    </div>
  );
}

区别对比:

特性 useTransition useDeferredValue
控制粒度 控制状态更新 控制值的变化
使用方式 包裹 setState 包装一个值
典型场景 Tab 切换、路由跳转 搜索输入、实时过滤
返回值 isPending 状态 延迟后的值

详细解析:

  • 并发渲染: React 18 允许同时准备多个版本的 UI,根据优先级决定渲染哪个
  • 自动批处理: 所有状态更新默认自动批处理,减少重渲染次数
  • Suspense 改进 : 支持数据获取场景,配合 React.lazy 实现代码分割加载
  • startTransition: 明确标记非紧急更新,可被紧急更新中断

三、Next.js

1. App Router

面试题: Next.js 13+ 的 App Router 与 Pages Router 有什么区别?请说明 App Router 的核心特性。

参考答案:

bash 复制代码
App Router vs Pages Router 对比:

特性                Pages Router              App Router
─────────────────────────────────────────────────────────────
路由定义            pages/ 目录               app/ 目录
组件类型            全部是客户端组件           Server Component 默认
数据获取            getStaticProps/getServer   async 组件直接 fetch
                    SideProps/getInitialProps
布局                _app.js / _document.js     layout.js(嵌套布局)
加载状态            无内置                     loading.js
错误处理            无内置                     error.js
API 路由            pages/api/                 app/api/ 或 Route Handlers

App Router 核心特性:

typescript 复制代码
/**
 * app/layout.tsx - 根布局
 * 所有页面共享的 HTML 结构
 */
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="zh">
      <body>
        <header>导航栏</header>
        <main>{children}</main>
        <footer>页脚</footer>
      </body>
    </html>
  );
}

/**
 * app/blog/layout.tsx - 嵌套布局
 * 仅 /blog/* 路由使用
 */
export default function BlogLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <div className="blog-layout">
      <aside>博客侧边栏</aside>
      <article>{children}</article>
    </div>
  );
}

/**
 * app/blog/[slug]/page.tsx - 动态路由
 * Server Component 默认,可直接获取数据
 */
export default async function BlogPost({
  params,
}: {
  params: { slug: string };
}) {
  // 直接在服务端获取数据
  const post = await fetch(`https://api.example.com/posts/${params.slug}`)
    .then(res => res.json());

  return (
    <article>
      <h1>{post.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
    </article>
  );
}

详细解析:

  • Server Components: 默认在服务端渲染,不打包到客户端 bundle,减少 JS 体积
  • 嵌套布局: 布局可以嵌套,每个路由段都可以定义自己的 layout
  • loading.tsx: 自动包裹 Suspense,显示加载状态
  • error.tsx: 自动创建 Error Boundary,隔离错误
  • parallel routes : @folder 语法支持同一布局中并行渲染多个页面

2. SSR

面试题: Next.js App Router 中的 SSR 有哪些渲染策略?请解释 SSR、SSG、ISR 的区别和使用场景。

参考答案:

typescript 复制代码
/**
 * 1. SSR (Server-Side Rendering) - 每次请求服务端渲染
 * 适用于:个性化内容、频繁变化的数据
 */
// app/page.tsx - 默认就是 SSR
export default async function Page() {
  // 每次请求都会在服务端执行
  const data = await fetch('https://api.example.com/data', {
    cache: 'no-store' // 禁用缓存,每次都重新获取
  });
  const json = await data.json();

  return <div>{json.title}</div>;
}

/**
 * 2. SSG (Static Site Generation) - 构建时生成
 * 适用于:博客、文档、不常变化的内容
 */
export default async function Page() {
  // 构建时获取数据,生成静态 HTML
  const data = await fetch('https://api.example.com/data', {
    cache: 'force-cache' // 默认值,构建时缓存
  });
  const json = await data.json();

  return <div>{json.title}</div>;
}

/**
 * 3. ISR (Incremental Static Regeneration) - 增量静态再生
 * 适用于:大型电商网站、新闻网站
 */
export default async function Page() {
  const data = await fetch('https://api.example.com/data', {
    next: {
      revalidate: 60 // 每 60 秒重新生成页面
    }
  });
  const json = await data.json();

  return <div>{json.title}</div>;
}

/**
 * 4. Streaming SSR - 流式渲染
 * 适用于:需要快速首字节时间 (TTFB) 的场景
 */
import { Suspense } from 'react';

export default function Page() {
  return (
    <div>
      {/* 立即渲染,不等待 */}
      <h1>页面标题</h1>

      {/* Suspense 包裹异步组件,流式传输 */}
      <Suspense fallback={<div>加载中...</div>}>
        <SlowComponent />
      </Suspense>
    </div>
  );
}

async function SlowComponent() {
  const data = await fetch('https://api.example.com/slow-data');
  const json = await data.json();
  return <div>{json.content}</div>;
}

渲染策略对比:

策略 渲染时机 数据新鲜度 性能 适用场景
SSG 构建时 构建时 最优 博客、文档
ISR 构建时 + 定时更新 可配置 很好 电商、新闻
SSR 每次请求 实时 个性化内容
Streaming 流式传输 实时 最好 复杂页面

详细解析:

  • cache: 'no-store' --- 完全禁用缓存,每次请求都重新获取
  • cache: 'force-cache' --- 构建时缓存,相当于 SSG
  • next.revalidate --- ISR 配置,指定重新验证时间
  • Streaming 允许页面分块传输,提高首屏加载速度

3. streaming

面试题: Next.js 的 Streaming 是什么?如何实现 Streaming UI?请给出代码示例。

参考答案:

Streaming 是一种渐进式渲染技术,允许页面内容分块传输到客户端,无需等待所有数据就绪。

typescript 复制代码
/**
 * app/page.tsx - Streaming 实现
 */
import { Suspense } from 'react';
import { Skeleton } from '@/components/ui/Skeleton';

export default function DashboardPage() {
  return (
    <div className="dashboard">
      {/* 1. 立即渲染的静态内容 */}
      <header>
        <h1>数据仪表盘</h1>
      </header>

      {/* 2. 快速数据(可能已缓存)*/}
      <Suspense fallback={<Skeleton height={200} />}>
        <QuickStats />
      </Suspense>

      {/* 3. 慢速数据(流式传输)*/}
      <div className="grid grid-cols-2">
        <Suspense fallback={<Skeleton height={400} />}>
          <RevenueChart />
        </Suspense>
        <Suspense fallback={<Skeleton height={400} />}>
          <UserAnalytics />
        </Suspense>
      </div>

      {/* 4. 最慢的数据(最后到达)*/}
      <Suspense fallback={<Skeleton height={300} />}>
        <RecentActivity />
      </Suspense>
    </div>
  );
}

/**
 * 快速统计组件 - 可能使用缓存数据
 */
async function QuickStats() {
  const stats = await fetch('/api/stats', {
    next: { revalidate: 60 }
  }).then(r => r.json());

  return (
    <div className="stats-grid">
      <StatCard title="总用户" value={stats.users} />
      <StatCard title="总收入" value={stats.revenue} />
      <StatCard title="订单数" value={stats.orders} />
    </div>
  );
}

/**
 * 收入图表组件 - 较慢的数据查询
 */
async function RevenueChart() {
  // 模拟慢查询
  const data = await fetch('/api/revenue', {
    cache: 'no-store'
  }).then(r => r.json());

  return (
    <div className="chart-container">
      <h2>收入趋势</h2>
      <LineChart data={data} />
    </div>
  );
}

/**
 * 用户分析组件
 */
async function UserAnalytics() {
  const data = await fetch('/api/users/analytics')
    .then(r => r.json());

  return (
    <div className="analytics-container">
      <h2>用户分析</h2>
      <PieChart data={data} />
    </div>
  );
}

/**
 * 最近活动组件 - 最慢的数据
 */
async function RecentActivity() {
  // 复杂的聚合查询
  const activities = await fetch('/api/activities')
    .then(r => r.json());

  return (
    <div className="activity-feed">
      <h2>最近活动</h2>
      <ul>
        {activities.map(activity => (
          <li key={activity.id}>{activity.description}</li>
        ))}
      </ul>
    </div>
  );
}

详细解析:

  • TTFB 优化: Streaming 显著降低首字节时间,用户更快看到内容
  • Suspense 边界: 每个 Suspense 包裹的组件可以独立加载,不阻塞其他内容
  • 渐进式增强: 页面从骨架屏逐步填充为完整内容,提升感知性能
  • Error Boundary: 单个组件失败不会影响整个页面

4. Server Component

面试题: 什么是 React Server Component(RSC)?与 Client Component 有什么区别?如何在 Next.js 中使用?

参考答案:

React Server Component 是一种在服务端执行的 React 组件,不发送到客户端。

typescript 复制代码
/**
 * Server Component(默认)
 * 文件顶部不需要 'use client'
 * 可以直接访问后端资源(数据库、文件系统等)
 */
import { db } from '@/lib/db';

// 直接在服务端获取数据
export default async function ProductList() {
  // 直接查询数据库,不暴露给客户端
  const products = await db.query('SELECT * FROM products');

  return (
    <div className="product-list">
      {products.map(product => (
        <ProductCard key={product.id} product={product} />
      ))}
    </div>
  );
}

/**
 * Client Component
 * 需要使用浏览器 API 或交互时
 */
'use client';

import { useState } from 'react';

export function ProductCard({ product }: { product: Product }) {
  const [isLiked, setIsLiked] = useState(false);

  return (
    <div className="product-card">
      <img src={product.image} alt={product.name} />
      <h3>{product.name}</h3>
      <p>¥{product.price}</p>
      <button onClick={() => setIsLiked(!isLiked)}>
        {isLiked ? '❤️' : '🤍'}
      </button>
    </div>
  );
}

Server vs Client Component 对比:

特性 Server Component Client Component
执行环境 服务端 浏览器
包体积 零(不发送到客户端) 包含在 bundle 中
数据获取 直接访问数据库/API useEffect / SWR / React Query
状态管理 不支持 useState/useEffect 支持所有 Hooks
浏览器 API 不可用 可用
交互性

混合使用模式:

typescript 复制代码
/**
 * Server Component 作为容器,Client Component 作为叶子节点
 * 这是推荐的最佳实践
 */

// app/page.tsx (Server Component)
import { ProductList } from './ProductList';
import { SearchFilter } from './SearchFilter'; // Client Component

export default async function Page() {
  // 服务端获取初始数据
  const categories = await fetchCategories();

  return (
    <div>
      {/* Client Component: 需要交互 */}
      <SearchFilter categories={categories} />

      {/* Server Component: 纯展示 */}
      <ProductList />
    </div>
  );
}

// SearchFilter.tsx (Client Component)
'use client';

import { useState } from 'react';

export function SearchFilter({ categories }: { categories: Category[] }) {
  const [selectedCategory, setSelectedCategory] = useState('all');

  return (
    <div className="filter-bar">
      <select
        value={selectedCategory}
        onChange={(e) => setSelectedCategory(e.target.value)}
      >
        <option value="all">全部</option>
        {categories.map(cat => (
          <option key={cat.id} value={cat.id}>{cat.name}</option>
        ))}
      </select>
    </div>
  );
}

详细解析:

  • 零 Bundle Size: Server Component 代码不打包到客户端,减少 JS 体积
  • 直接后端访问: 可以直接连接数据库、读取文件,无需 API 层
  • 自动代码分割: Client Component 自动按需加载
  • 安全: 敏感逻辑(数据库查询、API 密钥)不会暴露给客户端

四、前端工程化

1. webpack/vite

面试题: Webpack 和 Vite 有什么区别?Vite 为什么比 Webpack 快?生产环境 Vite 使用什么打包工具?

参考答案:

css 复制代码
Webpack vs Vite 核心区别:

特性              Webpack                      Vite
─────────────────────────────────────────────────────────────
开发模式          先打包再服务                  原生 ESM,按需编译
启动时间          O(n) 随项目增大               O(1) 常数时间
HMR 速度          随模块数增加而变慢            始终快速
配置复杂度        复杂,需要大量配置            开箱即用
生产打包          自身处理                      Rollup
生态成熟度        非常成熟                      快速发展中

Vite 快的原因:

javascript 复制代码
/**
 * Vite 开发服务器原理
 */

// 1. 利用浏览器原生 ESM,无需打包
// 浏览器直接请求模块,Vite 只处理被请求的模块

// 2. 依赖预构建(Pre-bundling)
// 使用 esbuild 将 CJS/UMD 依赖转换为 ESM
// esbuild 是 Go 编写,比 JavaScript 构建工具快 10-100 倍

// 3. 源码按需编译
// 只编译当前页面需要的模块,不是整个项目

// 4. 高效的 HMR
// 模块级别的热更新,精确替换变更模块

Vite 配置示例:

typescript 复制代码
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { visualizer } from 'rollup-plugin-visualizer';

export default defineConfig({
  plugins: [
    react(),
    // 打包分析(仅在分析模式启用)
    process.env.ANALYZE === 'true' && visualizer({
      open: true,
      gzipSize: true,
      brotliSize: true,
    }),
  ],
  build: {
    // 生产环境使用 Rollup
    rollupOptions: {
      output: {
        // 代码分割策略
        manualChunks: {
          // 将 React 相关库打包到一起
          'react-vendor': ['react', 'react-dom'],
          // 将 UI 库打包到一起
          'ui-vendor': ['antd', '@ant-design/icons'],
        },
      },
    },
    // 开启 source map
    sourcemap: true,
    // 开启 gzip/brotli 压缩报告
    reportCompressedSize: true,
  },
  server: {
    // 开发服务器配置
    port: 3000,
    proxy: {
      '/api': {
        target: 'http://localhost:8080',
        changeOrigin: true,
      },
    },
  },
  optimizeDeps: {
    // 预构建依赖
    include: ['lodash-es', 'moment'],
  },
});

详细解析:

  • 开发模式: Vite 利用浏览器原生 ESM,跳过打包步骤;Webpack 需要先将所有模块打包成 bundle
  • 预构建: Vite 使用 esbuild 预构建依赖,处理 CJS 到 ESM 的转换和模块合并
  • 生产环境: Vite 使用 Rollup 进行打包,因为 Rollup 更适合生产优化(tree shaking 更好)
  • HMR: Vite 的 HMR 通过 WebSocket 推送变更,只更新变更的模块

2. monorepo

面试题: 什么是 Monorepo?前端 Monorepo 有哪些常用工具?请比较 pnpm workspace、Turborepo、Nx 的优缺点。

参考答案:

Monorepo 是将多个相关项目放在同一个代码仓库中管理的模式。

perl 复制代码
Monorepo 目录结构示例:

my-monorepo/
├── package.json          # 根 package.json
├── pnpm-workspace.yaml   # pnpm workspace 配置
├── turbo.json            # Turborepo 配置
├── packages/             # 共享包
│   ├── ui/               # UI 组件库
│   │   ├── package.json
│   │   └── src/
│   ├── utils/            # 工具函数库
│   │   ├── package.json
│   │   └── src/
│   └── hooks/            # 共享 Hooks
│       ├── package.json
│       └── src/
├── apps/                 # 应用项目
│   ├── web/              # 主站
│   │   ├── package.json
│   │   └── src/
│   └── admin/            # 管理后台
│       ├── package.json
│       └── src/
└── tools/                # 工具脚本
    └── eslint-config/

工具对比:

工具 特点 优点 缺点
pnpm workspace 依赖管理 + 工作区 磁盘高效、安装快、天然支持 workspace 无构建缓存和任务编排
Turborepo 任务编排 + 远程缓存 增量构建、远程缓存、管道定义简单 相对较新,生态建设中
Nx 完整工具链 功能最全、插件丰富、IDE 支持好 学习曲线陡峭、配置复杂
Lerna + yarn 老牌方案 生态成熟、publish 功能完善 维护放缓、被替代趋势

Turborepo 配置示例:

json 复制代码
// turbo.json
{
  "$schema": "https://turbo.build/schema.json",
  "globalDependencies": ["**/.env.*local"],
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": [".next/**", "!.next/cache/**", "dist/**"]
    },
    "test": {
      "dependsOn": ["build"],
      "outputs": ["coverage/**"]
    },
    "lint": {
      "dependsOn": ["^build"]
    },
    "dev": {
      "cache": false,
      "persistent": true
    }
  }
}
yaml 复制代码
# pnpm-workspace.yaml
packages:
  - 'packages/*'
  - 'apps/*'
  - 'tools/*'
json 复制代码
// package.json
{
  "scripts": {
    "build": "turbo run build",
    "test": "turbo run test",
    "lint": "turbo run lint",
    "dev": "turbo run dev --parallel",
    "clean": "turbo run clean && rm -rf node_modules"
  }
}

详细解析:

  • Workspace 协议 : workspace:* 允许包之间互相引用,pnpm 会自动链接
  • 依赖提升: pnpm 的 content-addressable store 避免重复安装,节省磁盘空间
  • 增量构建: Turborepo 通过哈希判断任务是否需要重新执行,只构建变更的部分
  • 远程缓存: 团队共享构建缓存,CI 环境可以直接下载缓存结果

3. pnpm/turbo

面试题: pnpm 相比 npm/yarn 有什么优势?Turborepo 的任务管道(Pipeline)是如何工作的?

参考答案:

pnpm 核心优势:

markdown 复制代码
1. 内容可寻址存储(Content-addressable store)
   - 所有包只存储一份,通过硬链接引用
   - 节省 70%+ 磁盘空间

2. 非扁平 node_modules
   - 严格的依赖隔离
   - 只能访问直接声明的依赖,避免幽灵依赖

3. 更快的安装速度
   - 并行下载和安装
   - 更好的缓存机制

4. Workspace 原生支持
   - 内置 monorepo 支持
   - 过滤器和并行执行
bash 复制代码
# pnpm 常用命令

# 安装所有依赖
pnpm install

# 添加依赖到指定 workspace
pnpm add lodash --filter @myapp/web

# 运行所有 workspace 的 build
pnpm -r run build

# 只运行变更的 workspace
pnpm --filter "...[origin/main]" build

# 并行运行 dev
pnpm -r --parallel run dev

Turborepo Pipeline 工作原理:

json 复制代码
{
  "pipeline": {
    // 构建任务:依赖其他包的 build 完成后执行
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**", ".next/**"]
    },
    // 测试任务:依赖当前包的 build 完成后执行
    "test": {
      "dependsOn": ["build"],
      "inputs": ["src/**/*.test.ts"],
      "outputs": ["coverage/**"]
    },
    // lint 任务:可以并行执行
    "lint": {
      "dependsOn": []
    },
    // dev 任务:不缓存,持续运行
    "dev": {
      "cache": false,
      "persistent": true
    }
  }
}
markdown 复制代码
Pipeline 执行流程:

1. 解析依赖图
   @myapp/utils ──→ @myapp/ui ──→ @myapp/web
        │                               │
        └───────────────────────────────┘

2. 拓扑排序执行
   Step 1: build @myapp/utils
   Step 2: build @myapp/ui (等待 utils 完成)
   Step 3: build @myapp/web (等待 ui 完成)

3. 缓存检查
   - 计算输入文件哈希
   - 检查本地/远程缓存
   - 缓存命中则跳过执行

4. 并行执行无依赖任务
   lint 和 build 可以并行(如果 lint 不依赖 build)

详细解析:

  • 幽灵依赖: npm/yarn 的扁平 node_modules 允许访问未声明的依赖,pnpm 的严格结构避免此问题
  • 哈希计算: Turborepo 综合考虑文件内容、环境变量、依赖版本计算缓存键
  • 远程缓存: 可以配置 S3、Vercel 等作为远程缓存后端,团队共享构建结果
  • Affected : --filter 支持基于 Git 变更历史只构建受影响的项目

4. 微前端

面试题: 什么是微前端?有哪些实现方案?请比较 qiankun、Module Federation、iframe 的优缺点。

参考答案:

微前端是将大型前端应用拆分为独立部署的小应用的架构模式。

markdown 复制代码
微前端核心特性:

1. 技术栈无关
   - 主应用和子应用可以使用不同框架(React/Vue/Angular)

2. 独立开发部署
   - 每个微应用独立仓库、独立 CI/CD

3. 运行时集成
   - 在浏览器端动态加载和组合

4. 隔离性
   - JS 隔离、CSS 隔离、路由隔离

方案对比:

方案 原理 优点 缺点
iframe 浏览器原生隔离 完美隔离、简单 体验差、路由同步难、性能开销
qiankun JS Sandbox + HTML Entry 生态成熟、隔离完善 配置复杂、性能损耗、JS Sandbox 有边界
Module Federation Webpack 5 模块共享 体验好、共享依赖、无缝集成 强依赖 Webpack、版本对齐
Web Components 原生组件标准 标准方案、框架无关 兼容性、生态不成熟
Garfish 字节跳动开源 性能优化、多实例 生态相对较小

qiankun 示例:

typescript 复制代码
// 主应用 (React)
import { registerMicroApps, start } from 'qiankun';

registerMicroApps([
  {
    name: 'react-app',
    entry: '//localhost:3001',
    container: '#subapp-container',
    activeRule: '/react',
    props: { brand: 'MyBrand' }, // 传递数据
  },
  {
    name: 'vue-app',
    entry: '//localhost:3002',
    container: '#subapp-container',
    activeRule: '/vue',
  },
]);

start({
  sandbox: {
    strictStyleIsolation: true, // Shadow DOM 样式隔离
    experimentalStyleIsolation: true, // Scoped CSS
  },
});

// 子应用 (React) - 导出生命周期
export async function bootstrap() {
  console.log('react app bootstraped');
}

export async function mount(props) {
  ReactDOM.render(<App />, props.container.querySelector('#root'));
}

export async function unmount(props) {
  ReactDOM.unmountComponentAtNode(props.container.querySelector('#root'));
}

Module Federation 示例:

javascript 复制代码
// 主应用 webpack.config.js
const { ModuleFederationPlugin } = require('webpack').container;

module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'host',
      remotes: {
        // 远程应用地址
        remote_app: 'remote_app@http://localhost:3001/remoteEntry.js',
      },
      shared: {
        // 共享依赖,避免重复加载
        react: { singleton: true, requiredVersion: '^18.0.0' },
        'react-dom': { singleton: true },
      },
    }),
  ],
};

// 主应用使用远程组件
import React, { Suspense } from 'react';

// 动态导入远程模块
const RemoteButton = React.lazy(() => import('remote_app/Button'));

function App() {
  return (
    <div>
      <h1>主应用</h1>
      <Suspense fallback="加载中...">
        <RemoteButton />
      </Suspense>
    </div>
  );
}

详细解析:

  • JS 隔离: qiankun 使用 Proxy 沙箱隔离 window 对象,但无法隔离所有全局变量
  • CSS 隔离: Shadow DOM 或 Scoped CSS 防止样式冲突
  • 共享依赖: Module Federation 的 shared 配置确保只加载一份 React/Vue
  • 通信机制: props 传递、自定义事件、全局状态管理(Redux/Zustand)

五、前端性能优化

1. 首屏优化

面试题: 如何优化前端首屏加载时间(FCP、LCP)?请列出至少 5 个具体优化手段。

参考答案:

sql 复制代码
首屏优化核心指标:

FCP (First Contentful Paint) - 首次内容绘制
LCP (Largest Contentful Paint) - 最大内容绘制
TTFB (Time to First Byte) - 首字节时间
FIP (First Input Delay) - 首次输入延迟

优化手段:

typescript 复制代码
/**
 * 1. 资源预加载
 */
// HTML <head> 中
<head>
  {/* 预连接到关键域名 */}
  <link rel="preconnect" href="https://cdn.example.com" />
  <link rel="dns-prefetch" href="https://api.example.com" />

  {/* 预加载关键资源 */}
  <link rel="preload" href="/fonts/main.woff2" as="font" type="font/woff2" crossorigin />
  <link rel="preload" href="/css/critical.css" as="style" />

  {/* 预获取下一页资源 */}
  <link rel="prefetch" href="/about" />
</head>

/**
 * 2. 关键 CSS 内联
 */
// 将首屏需要的 CSS 直接内联到 HTML
<head>
  <style>
    /* Critical CSS - 只包含首屏样式 */
    .header { /* ... */ }
    .hero-section { /* ... */ }
    .nav { /* ... */ }
  </style>
  {/* 非关键 CSS 异步加载 */}
  <link rel="preload" href="/css/non-critical.css" as="style" onload="this.onload=null;this.rel='stylesheet'" />
</head>

/**
 * 3. 图片优化
 */
// Next.js Image 组件自动优化
import Image from 'next/image';

function Hero() {
  return (
    <Image
      src="/hero.jpg"
      alt="Hero"
      width={1200}
      height={600}
      priority // 优先加载(预加载)
      placeholder="blur" // 模糊占位
      blurDataURL="data:image/jpeg;base64,..."
    />
  );
}

/**
 * 4. 代码分割与懒加载
 */
import { lazy, Suspense } from 'react';

// 路由级别分割
const Dashboard = lazy(() => import('./Dashboard'));
const Settings = lazy(() => import('./Settings'));

function App() {
  return (
    <Suspense fallback={<Skeleton />}>
      <Router>
        <Route path="/dashboard" component={Dashboard} />
        <Route path="/settings" component={Settings} />
      </Router>
    </Suspense>
  );
}

/**
 * 5. SSR / SSG
 */
// Next.js 自动 SSR,减少客户端渲染时间
// 使用 getStaticProps / generateStaticParams 预渲染页面

/**
 * 6. 压缩与缓存
 */
// nginx 配置
// gzip on;
// gzip_types text/plain text/css application/json application/javascript;

// CDN 缓存策略
// Cache-Control: public, max-age=31536000, immutable

详细解析:

  • 预加载优先级 : preconnect > dns-prefetch > preload > prefetch
  • 关键 CSS: 内联首屏 CSS 可避免渲染阻塞,剩余 CSS 异步加载
  • 图片格式: WebP/AVIF 比 JPEG/PNG 体积小 25-50%
  • 字体优化 : font-display: swap 避免 FOIT(Flash of Invisible Text)

2. 懒加载

面试题: 前端懒加载有哪些实现方式?请实现一个图片懒加载的 Hook,并说明 Intersection Observer 的原理。

参考答案:

typescript 复制代码
/**
 * 图片懒加载 Hook - 使用 Intersection Observer
 */
import { useEffect, useRef, useState } from 'react';

function useLazyImage(src: string) {
  const imgRef = useRef<HTMLImageElement>(null);
  const [isVisible, setIsVisible] = useState(false);
  const [isLoaded, setIsLoaded] = useState(false);

  useEffect(() => {
    const img = imgRef.current;
    if (!img) return;

    // 创建 Intersection Observer
    const observer = new IntersectionObserver(
      (entries) => {
        entries.forEach((entry) => {
          // 当元素进入视口时
          if (entry.isIntersecting) {
            setIsVisible(true);
            // 加载完成后停止观察
            observer.unobserve(img);
          }
        });
      },
      {
        root: null, // 使用视口作为根
        rootMargin: '50px', // 提前 50px 开始加载
        threshold: 0.1, // 元素可见 10% 时触发
      }
    );

    observer.observe(img);

    return () => {
      observer.disconnect();
    };
  }, []);

  // 当可见时设置真实图片地址
  useEffect(() => {
    if (isVisible && imgRef.current) {
      const img = imgRef.current;
      img.src = src;
      img.onload = () => setIsLoaded(true);
    }
  }, [isVisible, src]);

  return { imgRef, isVisible, isLoaded };
}

// 使用示例
function LazyImage({ src, alt, width, height }: LazyImageProps) {
  const { imgRef, isLoaded } = useLazyImage(src);

  return (
    <div
      ref={imgRef}
      style={{
        width,
        height,
        backgroundColor: '#f0f0f0',
        opacity: isLoaded ? 1 : 0.5,
        transition: 'opacity 0.3s',
      }}
    >
      <img
        alt={alt}
        style={{ width: '100%', height: '100%', objectFit: 'cover' }}
      />
    </div>
  );
}

Intersection Observer 原理:

markdown 复制代码
Intersection Observer 工作机制:

1. 注册观察目标
   observer.observe(targetElement)

2. 浏览器优化计算
   - 使用主线程之外的线程计算元素可见性
   - 避免频繁的 scroll/resize 事件监听导致的性能问题

3. 触发回调条件
   - 目标元素与根容器(视口)的交叉区域变化
   - 满足设置的 threshold 和 rootMargin 条件

4. 异步回调
   - 回调在空闲时执行,不会阻塞主线程

传统方式的问题:
- scroll 事件频繁触发,需要节流/防抖
- getBoundingClientRect() 强制重排(Reflow)
- 主线程计算,影响性能

Intersection Observer 优势:
- 异步计算,不阻塞主线程
- 浏览器原生优化
- 自动处理所有边界情况

详细解析:

  • rootMargin: 可以设置提前加载距离,实现"即将进入视口时加载"
  • threshold : 数组形式支持多阈值触发,如 [0, 0.25, 0.5, 0.75, 1]
  • 图片占位: 懒加载时需要占位防止布局抖动(CLS)
  • 原生支持 : loading="lazy" 属性是现代浏览器的原生懒加载方案

3. 长列表优化

面试题: 长列表渲染性能问题如何解决?请实现一个虚拟列表(Virtual List),并解释其原理。

参考答案:

typescript 复制代码
/**
 * 虚拟列表核心原理:
 *
 * 1. 只渲染可视区域内的元素
 * 2. 通过 padding 或 transform 模拟滚动位置
 * 3. 复用 DOM 节点,只更新数据
 *
 * 总高度 = 数据总数 × 单项高度
 * 可视区域高度 = container.clientHeight
 * 渲染数量 = 可视区域高度 / 单项高度 + 缓冲数量
 * 起始索引 = scrollTop / 单项高度
 */

import React, { useState, useRef, useCallback, useMemo } from 'react';

interface VirtualListProps {
  items: any[];
  itemHeight: number;
  renderItem: (item: any, index: number) => React.ReactNode;
  bufferSize?: number; // 上下缓冲数量
}

function VirtualList({
  items,
  itemHeight,
  renderItem,
  bufferSize = 5,
}: VirtualListProps) {
  const containerRef = useRef<HTMLDivElement>(null);
  const [scrollTop, setScrollTop] = useState(0);

  // 计算总高度
  const totalHeight = items.length * itemHeight;

  // 计算可视区域渲染范围
  const { startIndex, endIndex, offsetY } = useMemo(() => {
    const start = Math.floor(scrollTop / itemHeight);
    const visibleCount = Math.ceil(
      (containerRef.current?.clientHeight || 0) / itemHeight
    );

    // 添加缓冲
    const startIndex = Math.max(0, start - bufferSize);
    const endIndex = Math.min(items.length, start + visibleCount + bufferSize);

    // 偏移量,让可视元素正确显示
    const offsetY = startIndex * itemHeight;

    return { startIndex, endIndex, offsetY };
  }, [scrollTop, itemHeight, items.length, bufferSize]);

  // 只渲染可视区域 + 缓冲区的数据
  const visibleItems = useMemo(() => {
    return items.slice(startIndex, endIndex).map((item, index) => ({
      ...item,
      _virtualIndex: startIndex + index,
    }));
  }, [items, startIndex, endIndex]);

  const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
    setScrollTop(e.currentTarget.scrollTop);
  }, []);

  return (
    <div
      ref={containerRef}
      style={{
        height: '400px',
        overflow: 'auto',
        position: 'relative',
      }}
      onScroll={handleScroll}
    >
      {/* 占位元素,撑开滚动条 */}
      <div style={{ height: totalHeight, position: 'relative' }}>
        {/* 可视区域内容 */}
        <div
          style={{
            position: 'absolute',
            top: offsetY,
            left: 0,
            right: 0,
          }}
        >
          {visibleItems.map((item) => (
            <div
              key={item._virtualIndex}
              style={{
                height: itemHeight,
                boxSizing: 'border-box',
              }}
            >
              {renderItem(item, item._virtualIndex)}
            </div>
          ))}
        </div>
      </div>
    </div>
  );
}

// 使用示例
function App() {
  const items = Array.from({ length: 10000 }, (_, i) => ({
    id: i,
    text: `Item ${i}`,
  }));

  return (
    <VirtualList
      items={items}
      itemHeight={50}
      bufferSize={3}
      renderItem={(item) => (
        <div style={{ padding: '10px', borderBottom: '1px solid #eee' }}>
          {item.text}
        </div>
      )}
    />
  );
}

详细解析:

  • 渲染数量: 假设列表高度 400px,单项 50px,则只渲染约 8-16 个 DOM 节点,而非 10000 个
  • 滚动性能: 滚动事件只更新数据索引,DOM 节点复用,避免大量创建/销毁
  • 动态高度 : 实际项目中可以使用 ResizeObserver 动态测量每项高度
  • 横向虚拟列表: 原理相同,计算水平方向的滚动位置

4. 虚拟滚动

面试题: 虚拟滚动和懒加载有什么区别?请实现一个支持动态高度的虚拟列表。

参考答案:

复制代码
虚拟滚动 vs 懒加载:

特性          虚拟滚动                    懒加载
───────────── ─────────────────────────── ──────────────────────────
渲染策略      只渲染可视区域 + 缓冲区       按需加载数据,已加载的保留
DOM 数量      固定少量(可视区大小)        随滚动增加
适用场景      大量数据本地渲染              分页加载/无限滚动
内存占用      低(固定)                  逐渐增加
实现复杂度    高(需计算位置)              低
typescript 复制代码
/**
 * 动态高度虚拟列表
 * 使用 ResizeObserver 测量每项实际高度
 */
import React, {
  useState,
  useRef,
  useCallback,
  useMemo,
  useEffect,
} from 'react';

interface DynamicVirtualListProps {
  items: any[];
  renderItem: (item: any, index: number) => React.ReactNode;
  estimateHeight?: number; // 预估高度
  bufferSize?: number;
}

function DynamicVirtualList({
  items,
  renderItem,
  estimateHeight = 50,
  bufferSize = 3,
}: DynamicVirtualListProps) {
  const containerRef = useRef<HTMLDivElement>(null);
  const itemRefs = useRef<Map<number, HTMLDivElement>>(new Map());
  const [scrollTop, setScrollTop] = useState(0);
  const [heights, setHeights] = useState<Map<number, number>>(new Map());

  // 使用 ResizeObserver 测量高度
  useEffect(() => {
    const observer = new ResizeObserver((entries) => {
      const newHeights = new Map(heights);
      entries.forEach((entry) => {
        const index = Number(entry.target.getAttribute('data-index'));
        newHeights.set(index, entry.contentRect.height);
      });
      setHeights(newHeights);
    });

    itemRefs.current.forEach((el) => observer.observe(el));
    return () => observer.disconnect();
  }, [heights]);

  // 计算累计高度(用于定位)
  const { totalHeight, offsetMap } = useMemo(() => {
    let total = 0;
    const offsets = new Map<number, number>();

    items.forEach((_, index) => {
      offsets.set(index, total);
      total += heights.get(index) || estimateHeight;
    });

    return { totalHeight: total, offsetMap: offsets };
  }, [items, heights, estimateHeight]);

  // 二分查找确定起始索引
  const findStartIndex = useCallback(
    (scrollTop: number) => {
      let left = 0;
      let right = items.length - 1;

      while (left < right) {
        const mid = Math.floor((left + right) / 2);
        const offset = offsetMap.get(mid) || 0;
        if (offset < scrollTop) {
          left = mid + 1;
        } else {
          right = mid;
        }
      }

      return Math.max(0, left - bufferSize);
    },
    [items.length, offsetMap, bufferSize]
  );

  // 计算渲染范围
  const { startIndex, endIndex, offsetY } = useMemo(() => {
    const start = findStartIndex(scrollTop);
    const containerHeight = containerRef.current?.clientHeight || 0;

    // 找到结束索引
    let end = start;
    let currentHeight = 0;
    while (end < items.length && currentHeight < containerHeight + estimateHeight * bufferSize * 2) {
      currentHeight += heights.get(end) || estimateHeight;
      end++;
    }

    const offsetY = offsetMap.get(start) || 0;
    return { startIndex: start, endIndex: Math.min(items.length, end), offsetY };
  }, [scrollTop, findStartIndex, items.length, heights, estimateHeight, bufferSize, offsetMap]);

  const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
    setScrollTop(e.currentTarget.scrollTop);
  }, []);

  const setItemRef = useCallback((index: number, el: HTMLDivElement | null) => {
    if (el) {
      itemRefs.current.set(index, el);
    } else {
      itemRefs.current.delete(index);
    }
  }, []);

  return (
    <div
      ref={containerRef}
      style={{ height: '400px', overflow: 'auto' }}
      onScroll={handleScroll}
    >
      <div style={{ height: totalHeight, position: 'relative' }}>
        <div style={{ position: 'absolute', top: offsetY, left: 0, right: 0 }}>
          {items.slice(startIndex, endIndex).map((item, idx) => {
            const realIndex = startIndex + idx;
            return (
              <div
                key={realIndex}
                ref={(el) => setItemRef(realIndex, el)}
                data-index={realIndex}
              >
                {renderItem(item, realIndex)}
              </div>
            );
          })}
        </div>
      </div>
    </div>
  );
}

// 使用示例 - 动态高度内容
function App() {
  const items = Array.from({ length: 1000 }, (_, i) => ({
    id: i,
    text: `Item ${i} - ${'内容'.repeat(Math.random() * 10)}`,
  }));

  return (
    <DynamicVirtualList
      items={items}
      estimateHeight={60}
      renderItem={(item) => (
        <div style={{ padding: '10px', borderBottom: '1px solid #eee' }}>
          <h4>{item.text}</h4>
          <p>一些动态高度的内容...</p>
        </div>
      )}
    />
  );
}

详细解析:

  • 动态高度挑战: 无法预先知道每项高度,需要运行时测量
  • ResizeObserver: 监听元素尺寸变化,实时更新高度缓存
  • 累计高度: 维护每项的偏移量,支持快速定位
  • 二分查找: 从 O(n) 优化到 O(log n) 查找起始索引
  • 高度变化: 内容动态变化时需要重新计算布局

本文件由自动化工具生成,仅供学习参考。

相关推荐
颯沓如流星3 小时前
前端 UI 组件专业术语科普指南
前端·ui
超*3 小时前
Bright Data Web Scraping指南 2026: 使用 MCP + Dify 自动采集海外社交媒体数据
前端·人工智能·媒体
洛宇3 小时前
(建议收藏)转型AI应用工程师之RAG:从入门到实战
前端·人工智能·面试
ID_180079054734 小时前
企业级淘宝评论 API最简说明,JSON 返回示例
java·服务器·前端
张元清4 小时前
Ref 逃生舱:用 React Hook 解决闭包陈旧、回调身份不稳和强制更新
前端·javascript·面试
牛奶4 小时前
抛弃TCP改用UDP,HTTP3疯了吗?
前端·tcp/ip·http3
暗冰ཏོ4 小时前
CSS 超详细讲解(从基础到高级实战)
前端·css·css3·sass·scss
历程里程碑4 小时前
54 深入解析poll多路复用技术
java·linux·服务器·开发语言·前端·数据结构·c++
&&月弥4 小时前
react快速入门
前端·react.js