跳转到内容

Blog

爬虫与 JS 逆向面试题复盘

这是一组爬虫和 JS 逆向相关的面试题。

这类面试不会只问“会不会用 requests”,更常见的是从一个具体业务场景开始追问:怎么登录、怎么抓动态接口、怎么处理反爬、百万级数据怎么调度、数据怎么进入后续处理链路。

这篇文章按面试题复盘的方式整理,重点是把回答讲得更清楚、更工程化。

同时要先明确一点:爬虫和逆向要遵守法律、站点协议和数据合规要求。面试中可以讲技术思路,但不应该表达绕过风控、攻击站点或采集敏感数据的意图。更稳的说法是:在授权范围内做数据采集和接口分析。

题目:

登录有两种方式,一种是账号密码登录,并且需要输入动态 token;另一种是二维码登录。如果要自动登录,你会选择哪种方式,为什么?说说实现方法。

我的回答倾向是:优先选择账号密码加 token 的方式。

原因:

  • 账号密码登录更适合程序化请求。
  • 登录流程相对稳定,便于抓包分析。
  • 登录成功后可以拿到 token、cookie 或 session。
  • 二维码登录通常依赖人工扫码,不适合长期自动化任务。

可以这样回答:

我会优先选择账号密码加 token 的登录方式。因为它更容易通过请求和响应模拟,流程上可以先请求登录页或初始化接口,拿到登录所需的 token、cookie,再携带账号密码和动态 token 请求登录接口。登录成功后保存 cookie 或 access token,后续请求统一带上认证信息。二维码登录更适合人工确认,自动化成本更高,而且很多二维码登录会绑定设备、时效和扫码确认,不适合爬虫任务长期稳定运行。

一个简化流程:

请求登录页或初始化接口
|
获取 token / csrf / cookie
|
提交账号、密码、动态 token
|
登录成功
|
保存 cookie 或 access token
|
后续请求携带认证信息

需要注意:如果动态 token 是验证码、短信码、二次验证,不能假设可以无成本自动化。面试里可以强调“在授权账号和合规场景下处理登录态”。

题目:

这种前端返回数据的网站,如何爬取数据?

现在很多网站是前端框架渲染,HTML 源码里没有完整数据。此时不要急着解析页面,而是先看网络请求。

常规步骤:

  1. 打开 Chrome DevTools。
  2. 进入 Network 面板。
  3. 过滤 XHR / Fetch 请求。
  4. 找到真正返回 JSON 数据的接口。
  5. 分析 URL、请求方法、参数、Headers、Cookie。
  6. 用 Python 模拟请求。
fetch-api-data.py
import requests
url = "https://example.com/api/list"
headers = {
"User-Agent": "Mozilla/5.0",
"Referer": "https://example.com/list",
}
params = {
"page": 1,
"size": 20,
}
response = requests.get(url, headers=headers, params=params, timeout=10)
data = response.json()
print(data)

如果接口参数是动态生成的,就继续分析 JS。

如果页面确实没有接口,或者数据必须通过浏览器运行后才出现,可以考虑 Selenium 或 Playwright。但大规模采集时,优先分析接口,因为浏览器自动化成本更高。

题目:

面对百万甚至千万数据量的爬取,你的爬取策略是怎么样的?爬取到的数据如何存储?

这题考察的是系统设计,而不是单机脚本。

可以从四层回答:

  • 任务拆分。
  • 并发控制。
  • 反爬与容错。
  • 数据存储和后续处理。

一个比较完整的链路是:

任务调度 -> 爬虫采集 -> Kafka -> Flink 清洗 -> 数据存储

如果只是普通项目,可以存 MySQL 或 CSV;如果是百万、千万规模,就要考虑分批写入、去重、失败重试、数据清洗和存储扩展。

可以这样回答:

面对百万级数据,我不会用一个单机脚本顺序爬。一般会先把任务拆成分页任务、关键词任务或 ID 区间任务,放到任务队列里,由多个爬虫节点并发消费。采集时会限制请求频率,设置超时重试和代理池,避免单点 IP 或账号压力过大。采集到的数据先进入 Kafka,后续由 Flink 做实时清洗,再写入 MySQL、ES 或数据仓库。对于失败任务会记录状态,后续补偿重试。

常用 Chrome DevTools 的 Network 面板。

主要看:

  • XHR / Fetch 请求。
  • 请求 URL。
  • 请求方法。
  • Query 参数和 Request Payload。
  • Headers。
  • Cookie。
  • Response。
  • Initiator 调用来源。

如果参数是动态生成的,会继续去 Sources 面板断点调试,或在 JS 文件中搜索参数名。

可以从几个现象判断:

  • 请求参数里存在动态加密参数。
  • 接口依赖 token、cookie、签名或时间戳。
  • 请求频率过高会被封 IP。
  • 返回内容出现验证码、空数据或风控页面。
  • 同一个接口在浏览器能访问,程序请求失败。
  • Headers 缺失时返回异常。

可以这样回答:

我会先比较浏览器正常请求和程序模拟请求的差异。如果同样的 URL 在浏览器里返回正常,但程序里返回空数据、验证码、403 或风控响应,就说明可能存在反爬。再继续分析是否有动态参数、token 校验、cookie 校验、频率限制或行为检测。

XPath 和 CSS Selector 都能定位 HTML 节点。

对比项XPathCSS Selector
语法类似路径表达式类似 CSS 选择器
能力更强,支持轴、文本、复杂路径简洁,适合常见选择
可读性复杂表达式可读性一般简单场景更清晰
爬虫常用度很常用也常用

面试可以说:

简单页面我会用 CSS Selector,因为语法简洁;复杂定位,比如按文本、层级、相邻节点查找时,我更倾向 XPath。

可以按流程回答:

  1. 使用 Network 抓包找到目标接口。
  2. 确认哪个参数是动态生成的。
  3. 全局搜索参数名。
  4. 在 Sources 面板下断点。
  5. 观察 Call Stack 调用链。
  6. 找到最终生成参数的函数。
  7. 用 Python 或 Node 复现算法。

更完整的回答:

我在项目中遇到过接口参数由 JS 加密生成的情况。处理时先通过 Network 找到接口和异常参数,然后在 JS 文件中搜索参数名。如果搜索不到,就从请求发起位置或 XHR 断点入手,在 Sources 里下断点,结合 Call Stack 分析调用链,找到参数生成函数。确定算法后,再用 Python 或 Node 复现,最后和浏览器生成结果对比,确保请求参数一致。

这类回答要强调“分析和复现授权接口参数”,不要说成攻击或绕过安全系统。

JS 混淆后,变量名和函数名可能没有意义,所以不要期待完全看懂所有代码。

常见思路:

  • 不全量还原,只找关键链路。
  • 通过 XHR/fetch 断点定位请求发起位置。
  • 使用 Call Stack 看调用链。
  • 打印关键变量。
  • 对关键函数做输入输出对比。
  • 必要时把关键函数拎出来运行。

可以这样回答:

遇到混淆 JS 时,我不会从头读完整文件,而是围绕目标接口定位关键参数。通过断点、调用栈、关键变量打印和函数输入输出分析,逐步缩小范围,最终定位生成参数的函数。

Selenium 是浏览器自动化工具,适合复杂页面、需要真实浏览器环境的场景。

但它的问题也明显:

  • 启动浏览器成本高。
  • 并发能力弱。
  • 资源占用大。
  • 速度慢。
  • 大规模采集不划算。

所以一般优先分析接口直接请求。只有接口很难复现、页面强依赖浏览器环境、或需要真实交互时,才考虑 Selenium 或 Playwright。

面试回答:

Selenium 可以用,但我不会作为首选。因为大规模采集更关注吞吐和稳定性,直接请求接口效率更高。Selenium 更适合登录、复杂交互或无法绕开浏览器渲染的页面。

长期稳定运行靠的不是一个脚本,而是容错和监控。

常见机制:

  • 请求超时。
  • 失败重试。
  • 指数退避。
  • 异常捕获。
  • 失败任务记录。
  • 账号状态检测。
  • IP 或代理状态检测。
  • 任务监控。
  • 健康检查。
  • 失败告警。

可以这样回答:

我会为爬虫设计超时重试、异常捕获、失败任务记录和任务监控机制。如果请求失败,会根据错误类型决定重试、切换账号、切换代理或标记任务失败。系统层面会有健康监测和失败上报,保证爬虫可以长期稳定运行。

如果项目里支持 500万+ / 日 的采集规模,可以这样回答:

系统支持 500 万以上日采集量。采集任务不是由单个脚本完成,而是通过任务调度系统统一拆分和分发,多节点并发执行。采集结果进入 Kafka,再由 Flink 进行实时清洗和处理。

面试时不要只报数字,最好补上支撑数字的架构。

整体链路可以这样描述:

调度系统
|
v
爬虫节点
|
v
Kafka
|
v
Flink
|
v
MySQL / Elasticsearch / 数据仓库

各模块职责:

模块作用
调度系统生成任务、分配任务、协调账号
爬虫节点执行采集、解析数据、处理重试
Kafka解耦采集和处理,缓冲流量
Flink实时清洗、过滤、转换
存储层存储清洗后的业务数据

这类回答会比“我用 Scrapy 分布式”更有工程感。

调度系统主要负责任务生成和账号协调。

你笔记中的规模是:

  • 1400+ 爬虫任务。
  • 400+ 账号 Cookie。
  • 任务信息存储在 Redis。

可以这样回答:

调度系统会把采集目标拆成具体任务,任务状态存储在 Redis 中。爬虫节点从 Redis 获取任务,执行后回写任务状态。账号 Cookie 也由调度系统统一管理,分配任务时会根据账号状态选择可用账号,避免单个账号压力过大。

Redis 适合做任务队列和状态缓存。

原因:

  • 读写性能高。
  • 支持 List、Set、Hash、Sorted Set 等结构。
  • 适合存任务状态、账号状态和临时调度数据。
  • 操作简单,延迟低。

可以补一句:

如果任务需要更强的可靠性、确认机制和重试语义,也可以引入消息队列;Redis 更适合轻量级任务调度和状态管理。

账号失效的表现:

  • 登录失败。
  • Cookie 失效。
  • 返回 401、403。
  • 返回验证码或风控页面。
  • 请求结果为空或异常。

处理方式:

  • 标记账号不可用。
  • 暂停该账号任务。
  • 重新调度任务。
  • 切换可用账号。
  • 触发重新登录或人工处理。

可以这样回答:

系统会根据响应状态和页面内容判断账号是否异常。一旦发现 Cookie 失效或登录状态异常,就标记账号状态,避免继续分配任务,同时把未完成任务重新放回队列,交给其他可用账号处理。

常用库:

  • requests:发送 HTTP 请求。
  • httpx:支持同步和异步请求。
  • scrapy:爬虫框架。
  • lxml:解析 HTML,支持 XPath。
  • beautifulsoup4:HTML 解析。
  • selenium:浏览器自动化。
  • playwright:现代浏览器自动化。

项目里如果主要使用 requests + XPath,可以这样说:

普通接口采集我主要使用 requests,请求接口后用 XPath 或 JSON 解析数据。如果是复杂任务调度和大规模采集,会考虑 Scrapy 或自研调度系统。

基本做法:

request-timeout.py
import requests
try:
response = requests.get(
"https://example.com/api",
timeout=(3, 10),
)
response.raise_for_status()
except requests.Timeout:
# 记录超时并重试
pass
except requests.RequestException:
# 记录其他请求异常
pass

可以配合:

  • 固定次数重试。
  • 指数退避。
  • 失败任务入库。
  • 切换代理或账号。

常见方式:

  • 设置请求间隔。
  • 限制并发数量。
  • 使用任务队列控制消费速度。
  • 对单域名限速。
  • 对单账号限速。
  • 对异常响应动态降速。

面试里可以说:

控制速度不只是 sleep,而是结合并发数、任务队列、账号维度和站点响应来动态调整,避免触发反爬,也保护目标站点和自身系统。

Docker 的价值:

  • 保证运行环境一致。
  • 方便部署。
  • 方便横向扩展多个爬虫节点。
  • 便于隔离依赖。
  • 适合配合 CI/CD。

爬虫系统里尤其适合把爬虫节点容器化。需要扩容时,可以快速启动多个容器实例。

Kafka 主要承担数据通道和缓冲层。

作用:

  • 解耦采集和处理。
  • 缓冲高峰流量。
  • 支持高吞吐数据传输。
  • 方便后续多个消费者处理数据。

可以这样回答:

爬虫采集速度和后续清洗入库速度不一定一致,所以中间用 Kafka 解耦。爬虫只负责把原始数据写入 Kafka,Flink 再从 Kafka 消费并清洗处理。

Redis 在项目中可以承担:

  • 任务队列。
  • 任务状态缓存。
  • 账号 Cookie 管理。
  • 去重集合。
  • 临时失败记录。
  • 限速计数。

面试回答:

Redis 主要用于调度层,保存任务队列、任务状态和账号 Cookie。因为它读写快,并且数据结构丰富,适合管理这种高频变化的临时状态。

可以回答 JS 加密参数逆向。

更完整的说法:

最难的是 JS 加密参数逆向。因为网站 JS 做了混淆,不能直接通过阅读代码看懂逻辑。我通过 Network 定位接口和动态参数,再用 Sources 下断点,结合调用栈分析参数生成流程,最后把关键算法用 Python 或 Node 复现出来。这个过程比较考验调试能力和耐心。

处理步骤:

  1. 先复现问题,确认是哪些请求失败。
  2. 对比正常浏览器请求和爬虫请求差异。
  3. 判断新增机制:token、cookie、签名、频率、验证码、行为检测。
  4. 如果是参数变化,重新调试 JS。
  5. 如果是频率问题,调整限速和调度策略。
  6. 如果涉及强验证或合规风险,停止采集或走授权接口。

可以这样回答:

我会先分析新增反爬属于哪一类,再决定策略。如果是参数签名变化,就重新定位 JS 生成逻辑;如果是频率限制,就降低并发、调整账号和代理策略;如果是登录态或 Cookie 变化,就更新账号状态检测和重新登录流程。对于验证码或强风控场景,需要评估合规性,不能盲目绕过。

如果面试官让你整体介绍这个爬虫项目,可以这样组织:

这个项目主要做大规模数据采集。整体链路是爬虫采集、Kafka 缓冲、Flink 清洗、最终写入存储。爬虫侧通过 Chrome DevTools 分析接口,优先直接请求接口而不是 Selenium。调度系统负责管理 1400 多个任务和 400 多个账号 Cookie,任务状态存储在 Redis。系统支持超时重试、失败任务记录、账号失效检测和健康监控。项目中比较难的是 JS 加密参数逆向,我通过断点调试、调用栈分析和算法复现解决过接口动态参数问题。

爬虫和逆向面试题,重点不是只会某个库,而是能把采集链路讲完整:

接口分析 -> 登录态处理 -> 参数逆向 -> 任务调度 -> 并发控制 -> 数据通道 -> 清洗入库 -> 监控补偿

如果能把这条链路讲清楚,再结合自己实际做过的规模、账号调度、Kafka/Flink、Redis 和 Docker,回答就会更像真实项目经验,而不是零散知识点。

一道 gRPC 简易计算器面试题复盘

这是我曾经做过的一道面试题中的一个小项目:用 Go + gRPC + gRPC-Web + Next.js 做一个简易计算器。

题目本身不复杂,只有加、减、乘、除四种运算。但它真正考察的不是“会不会写计算器”,而是能不能把前后端通信、接口契约、错误处理和跨端调用讲清楚。

项目地址:GolangNextGrpcSimpleCalculator

这道题可以理解为:

前端输入两个数字和一个运算符,通过 gRPC-Web 调用 Go 后端,后端完成计算并返回结果。

最小功能闭环包括:

  • 前端页面可以输入两个操作数。
  • 前端可以选择 +-*/ 四种运算符。
  • 前端通过 gRPC-Web 调用后端。
  • 后端使用 Go 实现 gRPC 服务。
  • 后端根据请求参数返回计算结果。
  • 除数为 0 或未知运算符时,需要返回错误。

如果只是做一个普通 HTTP 接口,这题可能很快就结束了。但这里要求使用 gRPC,就会多出一层“接口契约”的设计:前后端都要围绕 .proto 文件生成代码。

这个项目大致可以拆成两部分:

  • 文件夹calculator-backend/
    • 文件夹calculator/
      • calculator.proto
    • main.go
    • calculator_test.go
    • go.mod
  • 文件夹calculator-frontend/
    • 文件夹app/
      • 文件夹generated/
        • 文件夹calculator/
      • page.tsx
    • 文件夹calculator/
      • calculator.proto
    • package.json

后端负责定义和实现计算服务,前端负责生成 gRPC-Web 客户端代码并发起调用。

这里最核心的文件有三个:

文件作用
calculator.proto定义服务、请求结构、响应结构
main.go实现 Go gRPC 服务,并包装成 gRPC-Web HTTP 服务
page.tsx在 Next.js 页面中创建请求并调用后端

gRPC 的入口通常不是先写控制器,而是先写 .proto

calculator.proto
syntax = "proto3";
package calculator;
option go_package = "github.com/2760439882/calculator-backend/calculator;calculator";
service Calculator {
rpc Calculate(CalculationRequest) returns (CalculationResponse);
}
message CalculationRequest {
double operand1 = 1;
double operand2 = 2;
string operator = 3; // "+", "-", "*", "/"
}
message CalculationResponse {
double result = 1;
}

这份协议里有三个关键信息:

  • Calculator 是服务名。
  • Calculate 是远程调用方法。
  • CalculationRequestCalculationResponse 分别是请求和响应结构。

这就是 gRPC 和普通 REST 接口很不一样的地方。REST 接口经常先约定 URL 和 JSON 字段,而 gRPC 会先约定服务方法和强类型消息结构。

后端使用 Go 实现 Calculator 服务。

main.go
type server struct {
pb.UnimplementedCalculatorServer
}
func (s *server) Calculate(
ctx context.Context,
req *pb.CalculationRequest,
) (*pb.CalculationResponse, error) {
var result float64
switch req.Operator {
case "+":
result = req.Operand1 + req.Operand2
case "-":
result = req.Operand1 - req.Operand2
case "*":
result = req.Operand1 * req.Operand2
case "/":
if req.Operand2 == 0 {
return nil, fmt.Errorf("division by zero")
}
result = req.Operand1 / req.Operand2
default:
return nil, fmt.Errorf("unknown operator")
}
return &pb.CalculationResponse{Result: result}, nil
}

这段实现很适合面试时讲,因为它虽然简单,但覆盖了服务端接口实现的几个基本点:

  • 方法签名来自 .proto 生成代码。
  • 请求参数来自 CalculationRequest
  • 返回值必须符合 CalculationResponse
  • 错误可以通过 error 返回给调用方。
  • 除法需要额外处理除数为 0 的情况。

真正的业务逻辑只有一个 switch,但重点是:这个 switch 被放在了 gRPC 服务方法里,前端不会直接知道后端怎么计算,只知道自己要调用 Calculate

浏览器不能像 Go、Java、Node 服务端那样直接发起原生 gRPC 请求。原生 gRPC 基于 HTTP/2,而浏览器端直接使用 gRPC 会受到限制。

所以前端调用 gRPC 服务时,通常需要一层 gRPC-Web。

这个项目里,Go 后端把原始 gRPC 服务包装成了 gRPC-Web 服务:

main.go
grpcServer := grpc.NewServer()
pb.RegisterCalculatorServer(grpcServer, &server{})
wrappedGrpc := grpcweb.WrapServer(grpcServer)
httpServer := http.Server{
Addr: ":8080",
Handler: cors.New(cors.Options{
AllowedOrigins: []string{"http://localhost:3000"},
AllowedMethods: []string{"GET", "POST", "OPTIONS"},
AllowedHeaders: []string{
"Content-Type",
"X-Grpc-Web",
"X-User-Agent",
"grpc-timeout",
},
AllowCredentials: true,
}).Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if wrappedGrpc.IsGrpcWebRequest(r) ||
wrappedGrpc.IsAcceptableGrpcCorsRequest(r) ||
wrappedGrpc.IsGrpcWebSocketRequest(r) {
wrappedGrpc.ServeHTTP(w, r)
return
}
http.NotFound(w, r)
})),
}

这里有两个面试时值得说清楚的点。

第一,后端并不是直接写一个普通 HTTP JSON 接口,而是先创建 grpc.NewServer(),再通过 grpcweb.WrapServer() 包装。

第二,因为前端运行在 http://localhost:3000,后端运行在 http://localhost:8080,所以需要配置 CORS。否则浏览器会先把请求挡掉,根本到不了 gRPC-Web 服务。

前端是 Next.js 页面。因为要在浏览器里响应用户输入和发起请求,所以页面文件使用了 'use client'

page.tsx
'use client';
import { useState } from 'react';
import { CalculatorClient } from './generated/calculator/calculator_grpc_web_pb';
import { CalculateRequest } from './generated/calculator/calculator_pb';
const client = new CalculatorClient('http://localhost:8080', null, null);
export default function Home() {
const [operand1, setOperand1] = useState('');
const [operand2, setOperand2] = useState('');
const [operator, setOperator] = useState('+');
const [result, setResult] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const handleCalculate = () => {
setLoading(true);
setResult(null);
setError(null);
const req = new CalculateRequest();
req.setOperand1(parseFloat(operand1));
req.setOperand2(parseFloat(operand2));
req.setOperator(operator);
client.calculate(req, {}, (err, response) => {
setLoading(false);
if (err) {
setError('请求失败: ' + err.message);
return;
}
setResult(response.getResult());
});
};
}

这里的关键不是 useState,而是这三步:

  1. 使用生成的 CalculatorClient 创建客户端。
  2. 使用生成的 CalculateRequest 创建请求对象。
  3. 调用 client.calculate(),在回调里处理结果或错误。

这说明前端并没有手写请求路径、请求体字段和响应解析逻辑。它依赖 .proto 生成的代码来保证调用结构一致。

如果把这道题当成面试题看,它主要考察下面几类能力。

考点具体体现
gRPC 基础是否知道 .proto、service、message、生成代码
前后端通信是否知道浏览器不能直接使用原生 gRPC,需要 gRPC-Web
Go 服务端是否能注册服务、实现接口、启动服务
错误处理是否处理除数为 0、未知运算符、请求失败
跨域问题是否知道前后端端口不同会触发 CORS
工程意识是否能把生成代码、后端代码、前端代码分清楚

所以面试时不要只说“我实现了一个计算器”。更好的说法是:

我用 .proto 定义了计算服务和消息结构,再用 Go 实现 gRPC 服务。因为前端运行在浏览器里,不能直接调用原生 gRPC,所以后端用 gRPC-Web 包装了一层 HTTP 服务,并配置 CORS。前端通过生成的 gRPC-Web Client 创建请求对象,调用后端的 Calculate 方法,最后处理成功结果和错误信息。

这段回答会比“我写了加减乘除”更能说明你理解了题目。

这个项目作为面试题已经能跑通主流程,但如果继续完善,可以从下面几个方向补强。

第一,输入校验可以提前放在前端。

现在前端直接对输入值 parseFloat()。如果用户没有输入数字,可能得到 NaN。可以在发请求前判断两个操作数是否合法。

第二,后端错误可以使用 gRPC status。

当前代码使用 fmt.Errorf() 返回错误。实际项目里可以使用 status.Error() 搭配 codes.InvalidArgument,这样调用方可以更准确地区分错误类型。

return nil, status.Error(codes.InvalidArgument, "division by zero")

第三,运算符可以使用枚举。

当前 operator 是字符串,优点是直观,缺点是容易传入非法值。如果要更严谨,可以在 .proto 中定义 enum。

enum Operator {
OPERATOR_UNSPECIFIED = 0;
ADD = 1;
SUBTRACT = 2;
MULTIPLY = 3;
DIVIDE = 4;
}

第四,配置可以抽出来。

前端里的 http://localhost:8080 和后端 CORS 里的 http://localhost:3000 都是开发环境地址。后续如果部署,最好放进环境变量里。

这道题可以按四步讲:

  1. 先说题目目标:前端输入表达式,后端通过 gRPC 完成计算。
  2. 再说接口契约:用 .proto 定义 Calculate 方法、请求和响应。
  3. 接着说后端实现:Go 实现服务,处理四种运算和异常情况。
  4. 最后说浏览器调用:使用 gRPC-Web 生成客户端,前端发起请求并处理结果。

如果面试官继续追问,可以展开这几个点:

  • gRPC 和 REST 的区别是什么?
  • 为什么浏览器需要 gRPC-Web?
  • .proto 文件改了以后要做什么?
  • 如果除数为 0,应该怎么返回错误?
  • 如果以后要支持更多运算,应该怎么扩展?

这个项目的价值不在于计算器本身,而在于它用一个很小的功能,把 gRPC 项目里最关键的链路串起来了:

proto 定义 -> 生成代码 -> Go 实现服务 -> gRPC-Web 包装 -> 前端调用 -> 展示结果

对于面试题来说,这种项目很合适。它足够小,能在有限时间内完成;同时又能覆盖接口设计、后端实现、前端调用、跨域和错误处理这些真实工程里会遇到的问题。

React 父子组件通信面试题复盘

这是我曾经遇到过的一道 React 面试题,问题很常见:

React 父子组件之间如何通信?

这个问题看起来很基础,但其实很适合继续追问。因为它背后不只是 props 怎么传,而是 React 的数据流、状态放在哪里、组件边界怎么设计、什么时候用 Context、什么时候才需要状态管理库。

如果只是回答“父传子用 props,子传父用回调函数”,当然没错,但面试里还不够。更好的回答应该能顺着这个问题,把 React 的单向数据流讲清楚。

React 默认是单向数据流。

父组件可以通过 props 把数据传给子组件;子组件不能直接修改父组件的数据,如果子组件需要影响父组件,就由父组件传一个函数给子组件,子组件调用这个函数,把变化通知给父组件。

可以先用一句话概括:

父传子用 props,子传父用回调函数;如果兄弟组件之间要通信,就把状态提升到它们共同的父组件。

这是这道题的核心答案。

父传子最简单,就是把数据作为 props 传给子组件。

Parent.tsx
type User = {
id: number;
name: string;
};
function Parent() {
const user: User = {
id: 1,
name: 'xiaoxi',
};
return <UserCard user={user} />;
}
type UserCardProps = {
user: User;
};
function UserCard({ user }: UserCardProps) {
return (
<section>
<h2>{user.name}</h2>
<p>ID: {user.id}</p>
</section>
);
}

这里的数据方向很清楚:

Parent state/data -> props -> Child render

子组件只负责接收和展示,不负责决定这个数据从哪里来。

子组件不能直接修改父组件内部的 state。如果子组件需要触发父组件更新,就由父组件把更新函数传下去。

Counter.tsx
import { useState } from 'react';
function Parent() {
const [count, setCount] = useState(0);
const handleAdd = () => {
setCount((value) => value + 1);
};
return (
<div>
<p>当前数量:{count}</p>
<CounterButton onAdd={handleAdd} />
</div>
);
}
type CounterButtonProps = {
onAdd: () => void;
};
function CounterButton({ onAdd }: CounterButtonProps) {
return <button onClick={onAdd}>加一</button>;
}

这个过程可以理解为:

Child click -> call props function -> Parent setState -> Child receives new props

也就是说,子组件并没有“改父组件”,它只是触发了父组件提供的回调。

有时候子组件不只是触发动作,还需要把自己的数据交给父组件。

比如搜索框输入内容,父组件需要拿到关键词:

SearchBox.tsx
import { useState } from 'react';
function Parent() {
const [keyword, setKeyword] = useState('');
return (
<div>
<SearchBox onSearch={setKeyword} />
<p>当前搜索词:{keyword}</p>
</div>
);
}
type SearchBoxProps = {
onSearch: (keyword: string) => void;
};
function SearchBox({ onSearch }: SearchBoxProps) {
const [value, setValue] = useState('');
const handleSubmit = () => {
onSearch(value);
};
return (
<div>
<input value={value} onChange={(event) => setValue(event.target.value)} />
<button onClick={handleSubmit}>搜索</button>
</div>
);
}

这里的 onSearch(value) 就是典型的子传父。

面试时可以强调:React 里子传父不是反向修改数据,而是调用父组件传入的回调。

如果两个兄弟组件需要共享数据,通常不是让它们互相调用,而是把状态提升到共同父组件。

StateLifting.tsx
import { useState } from 'react';
function Parent() {
const [selectedId, setSelectedId] = useState<number | null>(null);
return (
<div>
<ProductList onSelect={setSelectedId} />
<ProductDetail productId={selectedId} />
</div>
);
}
type ProductListProps = {
onSelect: (id: number) => void;
};
function ProductList({ onSelect }: ProductListProps) {
return (
<ul>
<li onClick={() => onSelect(1)}>商品 1</li>
<li onClick={() => onSelect(2)}>商品 2</li>
</ul>
);
}
type ProductDetailProps = {
productId: number | null;
};
function ProductDetail({ productId }: ProductDetailProps) {
if (productId === null) return <p>请选择一个商品</p>;
return <p>当前商品 ID:{productId}</p>;
}

这就是 React 组件设计里很重要的一点:状态应该放在需要它的最小公共父组件中。

如果状态只属于一个组件,就放在这个组件内部;如果多个子组件都要用,就提升到公共父组件;如果很多层都要用,再考虑 Context 或状态管理库。

表单场景里,受控组件其实也是父子通信的一种体现。

父组件控制输入框的值,子组件只负责展示和触发修改。

ControlledInput.tsx
import { useState } from 'react';
function Parent() {
const [email, setEmail] = useState('');
return <EmailInput value={email} onChange={setEmail} />;
}
type EmailInputProps = {
value: string;
onChange: (value: string) => void;
};
function EmailInput({ value, onChange }: EmailInputProps) {
return (
<input
value={value}
onChange={(event) => onChange(event.target.value)}
placeholder="请输入邮箱"
/>
);
}

这里的核心是:

  • value 从父组件传给子组件。
  • onChange 从父组件传给子组件。
  • 子组件触发 onChange,父组件更新状态。
  • 新状态再通过 value 传回来。

所以受控组件不是“输入框自己管理值”,而是父组件管理值。

如果只是父子组件通信,不需要上来就用 Context。

但如果数据需要跨很多层传递,比如主题、登录用户、语言配置,就可以考虑 Context。

ThemeContext.tsx
import { createContext, useContext } from 'react';
type Theme = 'light' | 'dark';
const ThemeContext = createContext<Theme>('light');
function App() {
return (
<ThemeContext.Provider value="dark">
<Layout />
</ThemeContext.Provider>
);
}
function Layout() {
return <Toolbar />;
}
function Toolbar() {
const theme = useContext(ThemeContext);
return <button className={theme}>保存</button>;
}

Context 解决的是 props drilling,也就是一层层传 props 的问题。

但是它不应该被滥用。不是所有父子通信都需要 Context。对于很近的父子组件,直接用 props 更清楚。

有些场景不是传数据,而是父组件需要调用子组件暴露出来的方法。

比如父组件点击按钮,让子组件内部的输入框聚焦。

这时可以使用 forwardRefuseImperativeHandle

FocusInput.tsx
import { forwardRef, useImperativeHandle, useRef } from 'react';
type FocusInputRef = {
focus: () => void;
};
const FocusInput = forwardRef<FocusInputRef>(function FocusInput(_, ref) {
const inputRef = useRef<HTMLInputElement>(null);
useImperativeHandle(ref, () => ({
focus() {
inputRef.current?.focus();
},
}));
return <input ref={inputRef} placeholder="请输入内容" />;
});
function Parent() {
const inputRef = useRef<FocusInputRef>(null);
return (
<div>
<FocusInput ref={inputRef} />
<button onClick={() => inputRef.current?.focus()}>聚焦输入框</button>
</div>
);
}

不过这种方式要谨慎使用。

React 更推荐声明式的数据流。ref 更适合处理 DOM、聚焦、滚动、播放控制这类命令式场景,不适合作为普通业务数据通信的首选方案。

性能相关:回调函数会不会导致重复渲染

Section titled “性能相关:回调函数会不会导致重复渲染”

面试官可能继续问:父组件每次渲染都会创建新的回调函数,会不会导致子组件重复渲染?

答案是:可能会,但要结合场景看。

如果子组件使用了 memo,并且传入的回调函数每次都是新引用,子组件可能仍然会重新渲染。这时可以使用 useCallback 稳定函数引用。

MemoCallback.tsx
import { memo, useCallback, useState } from 'react';
const Child = memo(function Child({ onAdd }: { onAdd: () => void }) {
return <button onClick={onAdd}>加一</button>;
});
function Parent() {
const [count, setCount] = useState(0);
const handleAdd = useCallback(() => {
setCount((value) => value + 1);
}, []);
return (
<div>
<p>{count}</p>
<Child onAdd={handleAdd} />
</div>
);
}

但不要为了“看起来专业”到处写 useCallback。如果子组件没有性能问题,或者没有使用 memo,盲目使用 useCallback 反而会增加理解成本。

父子通信、兄弟通信、跨层级通信,本质上都是状态在哪里的问题。

可以按下面的顺序判断:

场景推荐方式
父组件给子组件数据props
子组件通知父组件回调函数
兄弟组件共享状态状态提升
多层组件共享稳定数据Context
大量页面共享复杂业务状态Zustand、Redux、Jotai 等状态管理库

状态管理库不是为了替代 props,而是为了解决更大范围、更复杂的数据共享和更新问题。

比如用户信息、购物车、权限、全局弹窗、复杂筛选条件,这类状态可能会跨多个页面或模块使用,就可以考虑状态管理库。

这道题可以这样回答:

React 是单向数据流。父组件向子组件传值用 props;子组件想影响父组件时,父组件传回调函数给子组件,子组件调用回调并把数据传回去。如果兄弟组件需要共享状态,就把状态提升到共同父组件。如果跨层级传递太深,可以用 Context;如果是跨页面、跨模块的复杂共享状态,再考虑 Redux、Zustand 这类状态管理库。特殊情况下,父组件需要调用子组件内部方法,可以用 refforwardRefuseImperativeHandle,但这更适合聚焦、滚动这类命令式场景,不是普通数据通信的首选。

如果面试官继续追问,可以展开这些点:

  • React 为什么强调单向数据流?
  • 状态提升解决什么问题?
  • Context 和状态管理库有什么区别?
  • 受控组件为什么也是父子通信?
  • ref 能不能替代 props?
  • useCallback 是否一定能优化性能?

React 父子通信这道题看似基础,但它可以引申到很多 React 核心思想:

  • 数据从父组件流向子组件。
  • 子组件通过回调通知父组件。
  • 多个组件共享状态时,优先状态提升。
  • 跨层级共享再考虑 Context。
  • 复杂全局状态再考虑状态管理库。
  • ref 是命令式能力,不是普通数据流的替代品。

所以这道题最重要的不是记住几个 API,而是理解 React 组件之间的数据边界:谁拥有状态,谁负责修改状态,谁只是接收状态并渲染。

Redis 缓存穿透、击穿、雪崩面试题复盘

这是 Redis 面试里非常高频的一类问题:

什么是缓存穿透、缓存击穿和缓存雪崩?它们有什么区别?应该怎么解决?

这三个概念很容易混在一起,因为它们的结果都可能是“请求打到数据库,数据库压力变大”。但它们的根因完全不同。

可以先用一句话区分:

穿透:查的数据根本不存在。
击穿:一个热点 Key 过期。
雪崩:大量 Key 同时过期,或者 Redis 整体不可用。

缓存穿透指的是:请求的数据在缓存中不存在,在数据库中也不存在。

比如:

  • 请求一个不存在的用户 ID:id = -1
  • 请求一个已经删除的商品
  • 恶意攻击者构造大量非法参数

正常缓存流程一般是:

请求 -> 查 Redis -> Redis 没有 -> 查数据库 -> 写入 Redis -> 返回数据

但如果数据根本不存在,就会变成:

请求 -> 查 Redis -> Redis 没有 -> 查数据库 -> 数据库也没有 -> 返回空

问题在于:下一次同样的非法请求过来,Redis 里还是没有,于是又会打到数据库。

如果有人构造大量不存在的 ID,请求就会绕过缓存,持续打到数据库。

缓存穿透常见有两种解决方案:缓存空对象和布隆过滤器。

当数据库查不到数据时,也往 Redis 里写一个空值或特殊值,并设置较短过期时间。

cache-null.js
async function getUser(id) {
const cacheKey = `user:${id}`;
const cached = await redis.get(cacheKey);
if (cached !== null) {
return cached === 'NULL' ? null : JSON.parse(cached);
}
const user = await db.queryUserById(id);
if (!user) {
await redis.set(cacheKey, 'NULL', 'EX', 60);
return null;
}
await redis.set(cacheKey, JSON.stringify(user), 'EX', 3600);
return user;
}

这样下一次请求同一个不存在的 ID,就会被 Redis 拦住,不会继续打数据库。

它的优点是简单,缺点是如果恶意请求的非法 ID 极多,Redis 里会出现很多空值缓存。所以空值缓存的过期时间一般要短一些。

布隆过滤器适合在请求进入缓存和数据库之前,先判断这个数据是否可能存在。

请求 userId
|
v
布隆过滤器判断
|
| 不存在 -> 直接拒绝
|
| 可能存在
v
查 Redis / DB

布隆过滤器的特点是:

  • 如果它判断不存在,那就一定不存在。
  • 如果它判断存在,只能说明可能存在。

所以它适合挡掉大量明显非法的请求。

面试里可以这样说:缓存空对象适合兜住少量不存在数据,布隆过滤器适合在入口处拦截大量非法 Key。

缓存击穿指的是:某一个热点 Key 在过期瞬间,大量并发请求同时打到数据库。

注意,击穿通常是单个 Key。

比如:

  • 某个大促商品详情页
  • 某个热门直播间信息
  • 某个高访问量用户主页
  • 某条热点新闻

这个 Key 平时在 Redis 里,所以数据库压力不大。

但它刚好过期时,大量请求同时进来,发现 Redis 没有,于是一起查数据库。

热点 Key 过期
|
大量请求同时进入
|
Redis 都没查到
|
全部打到数据库

这就是缓存击穿。

缓存击穿常见有两种方案:互斥锁和逻辑过期。

互斥锁的思路是:同一时间只允许一个线程去查数据库并重建缓存,其他线程等待或重试。

mutex-lock.js
async function getProduct(id) {
const cacheKey = `product:${id}`;
const lockKey = `lock:product:${id}`;
const cached = await redis.get(cacheKey);
if (cached) return JSON.parse(cached);
const locked = await redis.set(lockKey, '1', 'NX', 'EX', 10);
if (!locked) {
await sleep(50);
return getProduct(id);
}
try {
const product = await db.queryProductById(id);
await redis.set(cacheKey, JSON.stringify(product), 'EX', 3600);
return product;
} finally {
await redis.del(lockKey);
}
}

这个方案可以保护数据库,但会让部分请求等待,接口耗时可能变长。

逻辑过期的思路是:Redis 里的数据不设置物理过期,或者设置很长的物理过期;真正的过期时间写在 value 里。

logical-expire-value.json
{
"data": {
"id": 1,
"name": "热门商品"
},
"expireAt": "2026-05-20T20:00:00+08:00"
}

请求到来时:

  • 如果逻辑时间没过期,直接返回数据。
  • 如果逻辑时间过期,当前线程尝试拿锁。
  • 拿到锁的线程异步重建缓存。
  • 当前请求先返回旧数据。
查到缓存
|
判断逻辑过期
|
已过期 -> 尝试加锁 -> 异步重建缓存
|
直接返回旧数据

它的优点是响应速度更稳,不会让大量请求等待数据库。

缺点是用户可能短时间看到旧数据,所以它更适合对实时性要求不那么高的热点数据。

缓存雪崩指的是:大量 Key 在同一时间过期,或者 Redis 服务不可用,导致大量请求同时打到数据库。

它和击穿的区别是:

  • 击穿是单个热点 Key。
  • 雪崩是大量 Key,或者缓存层整体出问题。

比如:

  • 批量导入缓存时设置了相同过期时间。
  • 活动开始前预热了一批商品缓存,过期时间完全一样。
  • Redis 宕机或网络故障。
  • Redis 集群大面积不可用。

雪崩的危害比击穿更大,因为它不是一个点,而是一片。

缓存雪崩通常要从多个层面解决。

不要让大量 Key 设置完全一样的过期时间。

random-expire.js
const baseTtl = 3600;
const randomTtl = Math.floor(Math.random() * 300);
await redis.set(cacheKey, value, 'EX', baseTtl + randomTtl);

这样可以把过期时间打散,避免某一秒大量 Key 同时失效。

可以在 Redis 前面加本地缓存,比如 Caffeine、Guava Cache 或进程内缓存。

请求 -> 本地缓存 -> Redis -> 数据库

即使 Redis 短时间抖动,本地缓存也能顶住一部分热点请求。

不过本地缓存也会带来一致性问题,所以一般只适合热点数据或允许短暂不一致的数据。

当数据库压力过大时,不能让所有请求继续堆积。

可以做:

  • 数据库访问限流。
  • 熔断降级。
  • 返回兜底数据。
  • 返回“服务器繁忙,请稍后再试”。

降级不是偷懒,而是在系统压力过大时保护核心链路。

如果雪崩原因是 Redis 宕机,就需要高可用架构。

常见方案:

  • Redis 主从复制。
  • 哨兵模式。
  • Redis Cluster。
  • 多机房或多可用区部署。

高可用解决的是缓存服务不可用的问题,过期时间随机解决的是大量 Key 同时失效的问题。两者关注点不同。

维度缓存穿透缓存击穿缓存雪崩
核心原因数据根本不存在热点 Key 过期大量 Key 同时过期或 Redis 宕机
Key 数量大量不存在 Key单个热点 Key多个或大部分 Key
数据库状态数据库里也没有数据库里有数据数据库里有数据
主要危害绕过缓存打数据库单个热点打爆数据库大量请求压垮数据库
主要方案缓存空对象、布隆过滤器互斥锁、逻辑过期过期随机、多级缓存、限流降级、高可用

记忆时可以抓住关键词:

穿透:不存在
击穿:热点
雪崩:大量

这道题可以这样回答:

缓存穿透是请求的数据在缓存和数据库中都不存在,请求会绕过缓存持续打到数据库,常用缓存空对象和布隆过滤器解决。缓存击穿是某个热点 Key 在过期瞬间被大量并发请求访问,导致请求同时打到数据库,常用互斥锁或逻辑过期解决。缓存雪崩是大量 Key 同时过期,或者 Redis 服务不可用,导致大量请求同时涌向数据库,常用过期时间加随机值、多级缓存、限流降级和 Redis 高可用解决。

如果面试官继续追问,可以补充方案取舍:

  • 缓存空对象简单,但要设置较短 TTL,避免缓存污染。
  • 布隆过滤器适合拦截大量非法请求,但存在误判为可能存在。
  • 互斥锁能保护数据库,但会增加等待时间。
  • 逻辑过期响应快,但可能返回旧数据。
  • 过期随机能打散失效时间,但不能解决 Redis 宕机。
  • Redis 高可用能提升可用性,但不能替代限流和降级。

这类 Redis 面试题的关键,是把三个概念的根因讲清楚。

缓存穿透:缓存没有,数据库也没有。
缓存击穿:缓存没有,但数据库有,而且是热点 Key。
缓存雪崩:大量缓存同时没有,或者 Redis 整体不可用。

只要先分清根因,再说解决方案,就不会混乱。

最后再补一句工程思维:缓存是为了保护数据库,但缓存系统本身也会失效。所以真正可靠的设计,通常不是只靠一个方案,而是缓存策略、限流降级、异步重建和高可用架构一起配合。

深拷贝、浅拷贝与堆栈面试题复盘

这是前端面试里非常常见的一类问题:

什么是深拷贝和浅拷贝?它们和堆、栈有什么关系?

这道题看起来像是在问 API,实际上是在考察你是否理解 JavaScript 的数据类型、内存模型和引用关系。

如果只回答“浅拷贝只拷贝一层,深拷贝会递归拷贝”,只能算答到表面。更完整的回答应该从基本类型和引用类型讲起,再解释为什么对象拷贝容易互相影响。

在 JavaScript 中,数据大致可以分成两类:

  • 基本类型:stringnumberbooleanundefinednullsymbolbigint
  • 引用类型:objectarrayfunctionDateMapSet

基本类型保存的是值本身,赋值时通常是值的复制。

引用类型保存的是对象的引用,赋值时复制的是引用地址,所以多个变量可能指向同一个对象。

浅拷贝只拷贝对象第一层。如果第一层里还有对象,里面的对象仍然共享引用。

深拷贝会把嵌套对象也复制出来,让新对象和旧对象尽量互不影响。

面试里常见说法是:

  • 基本类型的值通常放在栈中。
  • 引用类型的对象内容通常放在堆中。
  • 变量里保存的是指向堆中对象的引用。

这是一种帮助理解的简化模型,不需要把它讲得像浏览器引擎源码一样复杂。

可以这样理解:

let name = 'xiaoxi';
栈:
name -> 'xiaoxi'

基本类型比较直接,变量和值之间的关系很简单。

再看对象:

const user = {
name: 'xiaoxi',
profile: {
age: 18,
},
};

可以理解为:

栈:
user -> 引用地址 0x001
堆:
0x001 -> {
name: 'xiaoxi',
profile: 引用地址 0x002
}
0x002 -> {
age: 18
}

对象本身放在堆里,变量 user 保存的是引用。

很多拷贝问题都从赋值开始。

reference-assignment.js
const user1 = {
name: 'xiaoxi',
profile: {
age: 18,
},
};
const user2 = user1;
user2.name = 'veyliss';
console.log(user1.name); // veyliss

这里 user2 = user1 并没有创建一个新对象,只是让 user2user1 指向同一个对象。

所以修改 user2.nameuser1.name 也会变。

可以理解为:

user1 -> 0x001
user2 -> 0x001

两个变量指向同一个堆对象。

浅拷贝会创建一个新的外层对象,但里面的引用类型属性仍然和原对象共享。

常见浅拷贝方式:

  • 展开运算符:{ ...obj }
  • Object.assign()
  • 数组的 slice()concat()Array.from()[...arr]

看一个例子:

shallow-copy.js
const user1 = {
name: 'xiaoxi',
profile: {
age: 18,
},
};
const user2 = {
...user1,
};
user2.name = 'veyliss';
user2.profile.age = 20;
console.log(user1.name); // xiaoxi
console.log(user1.profile.age); // 20

为什么 name 没有互相影响,但 profile.age 互相影响了?

因为 user2 是一个新的外层对象,但 profile 仍然指向同一个内部对象。

可以理解为:

user1 -> 0x001 -> {
name: 'xiaoxi',
profile: 0x002
}
user2 -> 0x003 -> {
name: 'xiaoxi',
profile: 0x002
}

外层对象不同,内层 profile 相同。

这就是浅拷贝。

深拷贝会递归复制对象中的嵌套对象,让新对象和旧对象不再共享内部引用。

deep-copy-result.js
const user1 = {
name: 'xiaoxi',
profile: {
age: 18,
},
};
const user2 = structuredClone(user1);
user2.profile.age = 20;
console.log(user1.profile.age); // 18
console.log(user2.profile.age); // 20

这时可以理解为:

user1 -> 0x001 -> profile -> 0x002
user2 -> 0x003 -> profile -> 0x004

外层对象不同,内层对象也不同。

以前很常见的一种写法是:

json-clone.js
const copy = JSON.parse(JSON.stringify(source));

这种方式简单,但有明显限制。

它适合普通 JSON 数据:

const source = {
name: 'xiaoxi',
tags: ['前端', 'JavaScript'],
};
const copy = JSON.parse(JSON.stringify(source));

但它处理不了很多特殊值:

json-clone-limit.js
const source = {
name: 'xiaoxi',
createdAt: new Date(),
sayHello() {
console.log('hello');
},
value: undefined,
};
const copy = JSON.parse(JSON.stringify(source));
console.log(copy.createdAt); // 字符串,不再是 Date
console.log(copy.sayHello); // undefined
console.log(copy.value); // undefined

它的主要问题包括:

  • Date 会变成字符串。
  • undefined、函数、symbol 会丢失。
  • MapSet 不能按原结构保留。
  • 遇到循环引用会直接报错。

所以面试时不要把 JSON.parse(JSON.stringify()) 说成万能深拷贝。

现代浏览器和 Node.js 中可以使用 structuredClone()

structured-clone.js
const source = {
name: 'xiaoxi',
createdAt: new Date(),
items: new Map([['count', 1]]),
};
const copy = structuredClone(source);
console.log(copy.createdAt instanceof Date); // true
console.log(copy.items instanceof Map); // true

相比 JSON 方法,structuredClone() 能保留更多内置类型,也能处理循环引用。

structured-clone-cycle.js
const source = {
name: 'xiaoxi',
};
source.self = source;
const copy = structuredClone(source);
console.log(copy.self === copy); // true

不过它也不是没有限制。函数、DOM 节点这类内容不能直接被结构化克隆。

面试里有时会要求手写一个简单版本。

最基础的写法是递归:

simple-deep-clone.js
function deepClone(value) {
if (value === null || typeof value !== 'object') {
return value;
}
const result = Array.isArray(value) ? [] : {};
for (const key in value) {
if (Object.prototype.hasOwnProperty.call(value, key)) {
result[key] = deepClone(value[key]);
}
}
return result;
}

这个版本可以处理普通对象和数组,但不能处理循环引用,也不能完整处理 DateMapSet 等类型。

如果要支持循环引用,可以用 WeakMap 记录已经拷贝过的对象。

deep-clone-with-weakmap.js
function deepClone(value, cache = new WeakMap()) {
if (value === null || typeof value !== 'object') {
return value;
}
if (cache.has(value)) {
return cache.get(value);
}
if (value instanceof Date) {
return new Date(value.getTime());
}
if (value instanceof Map) {
const result = new Map();
cache.set(value, result);
value.forEach((item, key) => {
result.set(deepClone(key, cache), deepClone(item, cache));
});
return result;
}
if (value instanceof Set) {
const result = new Set();
cache.set(value, result);
value.forEach((item) => {
result.add(deepClone(item, cache));
});
return result;
}
const result = Array.isArray(value) ? [] : {};
cache.set(value, result);
Reflect.ownKeys(value).forEach((key) => {
result[key] = deepClone(value[key], cache);
});
return result;
}

这个版本已经能覆盖不少面试场景:

  • 基本类型直接返回。
  • 普通对象和数组递归复制。
  • Date 单独处理。
  • MapSet 单独处理。
  • WeakMap 解决循环引用。
  • Reflect.ownKeys() 可以拿到 symbol key。

但它仍然不是完整工业级实现。比如属性描述符、原型链、不可枚举属性、函数、DOM 节点等,还需要更多额外处理。

不是所有场景都需要深拷贝。

场景推荐方式
只改第一层字段浅拷贝即可
React 更新一层状态展开运算符或 Object.assign()
嵌套对象也要完全隔离深拷贝
普通 JSON 数据复制JSON 方法可以考虑
复杂对象、循环引用structuredClone() 或专门工具
高性能、大数据量场景尽量避免无脑深拷贝

在 React 里也经常会遇到这个问题。

比如更新用户名称:

setUser((user) => ({
...user,
name: 'veyliss',
}));

这是浅拷贝,足够更新第一层。

如果要更新嵌套字段:

setUser((user) => ({
...user,
profile: {
...user.profile,
age: 20,
},
}));

这不是对整个对象做深拷贝,而是只拷贝发生变化的路径。这个方式在 React 里更常见,也更可控。

这道题可以这样回答:

JavaScript 里基本类型通常保存值本身,引用类型保存的是对象引用。对象内容可以理解为存在堆中,变量里保存的是引用地址。普通赋值只是复制引用,不会创建新对象。浅拷贝会创建一个新的外层对象,但嵌套对象仍然共享引用;深拷贝会递归复制嵌套对象,让新旧对象尽量不共享引用。常见浅拷贝方式有展开运算符、Object.assign()、数组 slice() 等;常见深拷贝方式有 structuredClone()、JSON 序列化和手写递归。JSON 方法有局限,会丢失函数、undefined,也不能处理循环引用。手写深拷贝时要考虑递归、特殊类型和循环引用,可以用 WeakMap 做缓存。

如果面试官继续追问,可以展开这些点:

  • 赋值、浅拷贝、深拷贝的区别。
  • 为什么浅拷贝会影响嵌套对象。
  • JSON.parse(JSON.stringify()) 有哪些问题。
  • structuredClone() 能解决什么,不能解决什么。
  • 手写深拷贝如何处理循环引用。
  • React 中为什么经常只拷贝变化路径。

深拷贝、浅拷贝和堆栈这道题,本质是在问你是否理解引用。

可以记住这几句话:

  • 基本类型更像“直接保存值”。
  • 引用类型更像“变量保存地址,对象放在堆里”。
  • 赋值复制的是引用。
  • 浅拷贝复制第一层。
  • 深拷贝递归复制嵌套层。
  • 深拷贝不是越多越好,要看业务是否真的需要完全隔离。

面试里把这些关系讲清楚,比单纯背一个手写深拷贝函数更重要。