一、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
详细解析:
console.log('script start')--- 同步代码,立即执行setTimeout--- 宏任务,放入宏任务队列Promise.resolve().then(...)--- 微任务,放入微任务队列queueMicrotask(...)--- 微任务,放入微任务队列console.log('script end')--- 同步代码,立即执行- 同步代码执行完毕,开始执行微任务队列(按 FIFO 顺序)
- 先执行
promise1的回调,由于链式调用又产生新的微任务promise2 - 执行
queueMicrotask的回调 - 再执行
promise2的回调 - 微任务队列为空,执行宏任务
setTimeout
核心考点: 理解宏任务(macrotask)与微任务(microtask)的区别,以及 Event Loop 的完整执行顺序。微任务优先级高于宏任务,且会在每个宏任务执行完毕后、下一个宏任务开始前清空。
2. promise
面试题 : 实现一个 Promise.all 的 polyfill,要求支持:
- 接收一个 Promise 数组
- 所有 Promise 成功时返回结果数组
- 任一 Promise 失败时立即 reject
- 处理空数组情况
参考答案:
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 Promiseawait会暂停 async 函数执行,等待 Promise 完成,但不会阻塞主线程- 错误会沿着 Promise 链传播,可以用 try-catch 或 .catch() 捕获
Promise.all中任一失败会导致整体失败,使用Promise.allSettled可以获取每个 Promise 的结果状态
4. 闭包
面试题: 什么是闭包?请实现一个计数器函数,要求:
- 支持增加、减少、获取当前值
- 支持设置步长
- 值不能被外部直接修改
参考答案:
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),
value和currentStep无法从外部直接访问 - 返回的对象方法形成了闭包,保持对私有变量的引用
- 这是 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 基于链表结构 和数组索引实现:
- Hooks 存储结构: React 使用一个单向链表来存储组件的所有 Hook 状态
- 按顺序调用 : Hooks 必须在组件顶层按相同顺序调用,这是
Rules of Hooks的根本原因 - 状态更新 :
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),核心目标是:
- 可中断的渲染: 将渲染工作拆分为小单元,可暂停、恢复、丢弃
- 优先级调度: 不同更新可以设置不同优先级
- 并发特性基础: 为 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),避免直接操作当前视图
- 时间切片 : 利用
requestIdleCallback或scheduler包,在浏览器空闲时执行渲染工作 - 优先级机制: 用户交互(高优先级)可以打断低优先级的渲染任务
- 错误边界: 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 算法的核心策略(基于两个假设):
- 不同类型的元素产生不同的树 --- 直接替换整个子树
- 通过 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)有哪些?请解释 useTransition 和 useDeferredValue 的使用场景和区别。
参考答案:
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'--- 构建时缓存,相当于 SSGnext.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) 查找起始索引
- 高度变化: 内容动态变化时需要重新计算布局
本文件由自动化工具生成,仅供学习参考。