跳转到内容

Blog

队列、栈与 ArrayDeque 面试题整理

队列和栈是数据结构里最基础、也最容易在面试中被追问的两个概念。

它们看起来都像是“存放一组元素的容器”,但核心区别在于元素进出顺序操作位置限制。如果再放到 Java 集合体系里,还会涉及 QueueDequeListStackArrayDeque 这些类和接口的关系。

这篇文章按面试回答的方式整理一遍。

队列遵循 先进先出

Queue: First In First Out, FIFO

也就是说,先进入队列的元素,会先被取出。

入队顺序:A -> B -> C
出队顺序:A -> B -> C

栈遵循 先进后出

Stack: First In Last Out, FILO

也可以说是 后进先出

Last In First Out, LIFO

也就是说,最后进入栈的元素,会最先被取出。

入栈顺序:A -> B -> C
出栈顺序:C -> B -> A

这是队列和栈最核心的区别。

队列的插入和删除发生在不同端:

  • 在一端插入元素,通常称为入队。
  • 在另一端删除元素,通常称为出队。

可以理解成排队买票:

队尾入队 -> [A, B, C] -> 队头出队

栈的插入和删除发生在同一端:

  • 插入元素叫入栈。
  • 删除元素叫出栈。
  • 能直接操作的一端叫栈顶。

可以理解成往桌上叠盘子:

栈顶
C
B
A
栈底

新盘子放在最上面,拿的时候也只能从最上面拿。

队列通常适合按顺序处理任务,例如消息队列、线程池任务队列、广度优先搜索等场景。

队列在逻辑上更强调“从头部取、从尾部放”。如果底层是链表结构,可以通过节点指针向后遍历;如果底层是数组结构,也可以通过下标或循环数组逻辑访问。

栈更强调“只看栈顶”。如果要取出最早进入栈底的元素,就必须先把上面的元素依次弹出。

例如:

栈顶 -> C
B
栈底 -> A

如果想拿到 A,就要先处理 CB

需要注意的是,面试里有时会说“队列遍历比栈快”。这个说法不能绝对化,因为遍历速度还取决于底层实现:

  • ArrayDeque 基于数组实现,访问和扩容方式与链表不同。
  • LinkedList 基于链表实现,遍历依赖节点引用。
  • Stack 继承自 Vector,底层也是数组结构,并且很多方法带同步开销。

所以更准确的说法是:队列和栈限制的是操作语义,不是简单决定遍历速度。具体性能要看底层实现。

从 Java 集合体系看,队列和栈并不是完全对称的两个接口。

队列通常使用 Queue 接口:

Queue<Integer> queue = new ArrayDeque<>();

Queue 继承自 Collection,常见方法包括:

offer(e) // 入队,失败时通常返回 false
poll() // 出队,队列为空时返回 null
peek() // 查看队头,队列为空时返回 null

栈在早期可以使用 Stack 类:

Stack<Integer> stack = new Stack<>();

但在现代 Java 代码里,通常更推荐用 Deque 来模拟栈:

Deque<Integer> stack = new ArrayDeque<>();

常见方法包括:

push(e) // 入栈
pop() // 出栈
peek() // 查看栈顶

原因是 Stack 是一个比较旧的类,它继承自 VectorVector 的很多方法带同步机制,在大多数普通场景下并不是首选。

因此,面试里可以这样回答:

Java 中队列通常使用 QueueDeque;栈不一定要用 Stack 类,实际开发中更推荐使用 Deque,常见实现是 ArrayDeque

ArrayDequeDeque 接口的一个常用实现。

Deque 的全称是 double-ended queue,也就是双端队列。它允许在两端插入和删除元素,因此既可以当队列用,也可以当栈用。

当作队列:

Deque<String> queue = new ArrayDeque<>();
queue.offer("A");
queue.offer("B");
queue.offer("C");
System.out.println(queue.poll()); // A
System.out.println(queue.poll()); // B
System.out.println(queue.poll()); // C

当作栈:

Deque<String> stack = new ArrayDeque<>();
stack.push("A");
stack.push("B");
stack.push("C");
System.out.println(stack.pop()); // C
System.out.println(stack.pop()); // B
System.out.println(stack.pop()); // A

可以看到,同样是 ArrayDeque,只要使用的方法不同,就可以表达不同的数据结构语义。

如果面试官问“队列和栈有什么区别”,可以按这几个点回答:

  1. 规则不同:队列是 FIFO,栈是 FILO/LIFO。
  2. 操作位置不同:队列一端插入、另一端删除;栈只在栈顶插入和删除。
  3. 使用场景不同:队列适合按顺序处理任务;栈适合回退、括号匹配、函数调用、深度优先搜索等场景。
  4. Java 实现不同:队列常用 QueueDeque;栈可以用 Stack,但更推荐用 Deque 的实现类 ArrayDeque
  5. 性能不能只按“队列快、栈慢”简单判断,要结合底层结构,例如数组、链表、同步开销和扩容机制。

队列和栈的区别可以先抓住一句话:

队列:先进先出,像排队。
栈:先进后出,像叠盘子。

在 Java 中,还要补充一句:

需要队列或栈语义时,优先考虑 Queue、Deque 和 ArrayDeque;不要一看到栈就只想到 Stack。

这样回答既覆盖了基础概念,也能体现出对 Java 集合体系的理解。

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 中为什么经常只拷贝变化路径。

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

可以记住这几句话:

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

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

电商订单过期处理面试题复盘

这是我 2024 年遇到的一道电商面试题,题目大概是:

假如在某个时刻,也就是某一秒内,有数以千计的商品订单同时过期,数据库应该怎么处理?

这道题表面问的是“订单过期”,实际考察的是高并发场景下怎么避免数据库被瞬时写流量打穿。

一个比较稳妥的回答是:不要让数据库自己去扛所有过期判断,也不要用定时任务在同一秒扫大量订单。订单创建时写入延时队列,到期后由消费者接收消息,再做批量更新和后续事件分发。

订单过期一般不是一个难逻辑。比如订单超过 30 分钟未支付,就把状态从 WAIT_PAY 改成 CLOSED

真正的问题在于:如果某个秒级时间点有大量订单同时过期,系统会出现几个压力点:

  • 数据库短时间内出现大量 update
  • 如果使用定时任务扫描,查询范围可能很大。
  • 单条订单逐个更新会造成大量数据库往返。
  • 过期后还要释放库存、通知用户、同步缓存,容易把主流程拖慢。
  • 消息重复、消费失败、订单已支付等情况都需要正确处理。

所以这道题不能只回答“定时任务扫一下订单表”。那样在低并发下能跑,但在电商场景里不够稳。

我当时记录的主思路是四步:

订单创建 -> 写入延时队列 -> 到期消费 -> 批量关闭订单 -> 发布后续业务事件

可以画成这样:

创建订单
|
| 写订单表
v
发送延时消息:orderId + expireTime
|
| 到期后投递
v
订单过期消费者
|
| 聚合一小批订单
v
批量更新订单状态
|
| 更新成功后发布事件
v
释放库存 / 退款 / 通知用户 / 同步 ES 或缓存

核心目标是:把同一秒的大量过期订单,拆成可控的批量写入和异步事件处理。

订单创建成功后,系统先写入订单表,然后把订单 ID 和过期时间写入延时队列。

消息里通常包含:

order-expire-message.json
{
"orderId": "202401010001",
"orderType": "NORMAL",
"expireTime": "2024-01-01T10:30:00+08:00"
}

延时时间可以这样计算:

TTL = expireTime - now()

也就是说,如果订单 30 分钟后过期,就发送一条 30 分钟后投递的延时消息。

这里可以选不同的实现:

方案说明
RabbitMQ TTL + 死信队列到期后消息进入死信队列,由消费者处理
RocketMQ 延时消息原生支持延时消息,适合常见延时等级
Redis ZSet用过期时间作为 score,消费者按时间拉取
时间轮适合大量延时任务调度

面试时不用把某个 MQ 的细节讲太深,重点是说明:订单创建时就把“未来要关闭订单”这件事交给延时机制,而不是等数据库定时扫描。

当延时时间到达,消息会被投递到订单过期消费者。

消费者拿到消息后,不能直接无脑关闭订单,而是要先做状态校验:

收到 orderId
|
查询订单当前状态
|
如果已支付:忽略
如果已关闭:忽略
如果待支付且已超时:关闭

原因很简单:延时消息到达时,订单可能已经被用户支付了。

所以关闭订单必须是有条件的。不能只根据“消息到了”就关闭。

如果每条消息都单独更新一次数据库,几千条过期订单就会变成几千次 update

更好的做法是:消费者先把消息暂存到一个批量处理队列里,然后按数量或时间窗口触发批量更新。

常见触发条件:

  • 累积到 100-500 条订单。
  • 或者等待 100ms 左右。
  • 两个条件满足任意一个就执行批量更新。

可以理解为一个小型缓冲区:

消息 1 -> buffer
消息 2 -> buffer
消息 3 -> buffer
...
达到 300 条,批量 update

这样做的好处是明显的:

  • 减少数据库连接和网络往返。
  • 降低数据库瞬时写压力。
  • 消费者可以通过批次大小控制吞吐。
  • 后续可以水平扩展多个消费者。

批量更新订单状态时,一定要带上状态条件。

比如只关闭仍然处于待支付状态的订单:

batch-close-orders.sql
UPDATE orders
SET status = 'CLOSED',
close_reason = 'TIMEOUT',
closed_at = NOW()
WHERE id IN (...)
AND status = 'WAIT_PAY'
AND expire_time <= NOW();

这里的 status = 'WAIT_PAY' 很关键。

它能保证:

  • 已支付订单不会被误关闭。
  • 已关闭订单重复消费也不会重复更新。
  • 延时消息重复投递时,更新操作仍然安全。

这就是幂等。

面试里可以直接说:消息队列天然可能重复投递,所以订单关闭逻辑必须是幂等的。

订单状态批量更新完成后,不要在同一个流程里把所有后续业务都做完。

应该发布一个订单关闭事件:

order-closed-event.json
{
"eventType": "ORDER_CLOSED",
"reason": "TIMEOUT",
"orderIds": ["202401010001", "202401010002"]
}

然后由不同消费者处理后续业务:

  • 释放库存。
  • 发起退款。
  • 通知用户。
  • 同步订单状态到 ES。
  • 删除或刷新缓存。
  • 写入业务日志。

这样可以把“关闭订单”和“关闭订单后要做什么”拆开。

关闭订单是核心链路,必须尽快完成;释放库存、通知用户、同步缓存这些动作可以异步扩展。

定时任务不是不能用,但它不适合直接承担高并发订单过期主链路。

如果用定时任务每秒扫一次订单表,大概会遇到几个问题:

  • 查询条件依赖 expire_time,数据量大时压力明显。
  • 某一秒过期订单过多时,任务执行时间不可控。
  • 任务失败后需要补偿。
  • 多实例部署时要处理分布式锁和重复扫描。
  • 单表订单量大时,还要考虑分库分表后的扫描范围。

定时任务更适合做兜底补偿,比如每隔一段时间扫描仍然处于 WAIT_PAY 且已经超时的订单,补偿可能丢失的延时消息。

所以更完整的方案是:

延时队列处理主流程
定时任务做兜底补偿

题目里说某一秒有数以千计订单过期,这其实就是一个瞬时流量峰值。

除了批量更新,还可以做一些削峰处理:

  • 多消费者并发消费,但限制每个消费者批量写入频率。
  • 将订单按业务线、商户、分库分表键分片处理。
  • 批量更新失败时拆分批次重试。
  • 对数据库连接池和 MQ 消费速度设置上限。
  • 监控积压量,必要时临时扩容消费者。

关键思想是:MQ 可以积压,消费者可以慢慢处理,但数据库不能被突然打爆。

这道题可以按下面这段话回答:

我不会让数据库在某一秒直接承受大量订单过期更新。订单创建成功后,会把订单 ID 和过期时间写入延时队列,TTL 设置为 expireTime - now()。消息到期后投递给订单过期消费者,消费者先校验订单是否仍是待支付状态,再把消息放入批量缓冲区,按固定数量或固定时间窗口统一批量更新订单状态。更新时 SQL 会带上 status = WAIT_PAY 和过期时间条件,保证幂等,避免误关已支付订单。订单关闭成功后再发布订单关闭事件,由库存、退款、通知、缓存同步等消费者异步处理。定时任务只作为兜底补偿,不作为主流程。

这段回答基本覆盖了面试官想听的几个关键词:

  • 延时队列。
  • 批量处理。
  • 幂等。
  • 削峰。
  • 事件驱动。
  • 兜底补偿。

这道题的重点不是“怎么把订单改成过期”,而是怎么在大量订单同时过期时保护数据库。

比较合理的设计是:

订单创建时写延时队列
到期后消费者接收消息
消费者聚合订单并批量更新
更新时保证幂等
关闭后发布业务事件
定时任务做兜底补偿

电商系统里很多问题都不是单点逻辑难,而是峰值、幂等、补偿、解耦这些工程问题难。这个题目正好把这些点串到了一起。

Vue 请求后端数据与跨域问题

这篇文章迁移自我早年写在博客园的一篇记录。当时遇到的问题很直接:后端接口已经写好了,Vue 前端应该怎么请求数据?请求时报跨域又应该怎么处理?

现在回头看,这其实是前后端分离开发里非常典型的一步:前端项目跑在一个端口,后端服务跑在另一个端口,浏览器因为同源策略限制,默认不允许它们随便互相请求。

这篇文章按现在的写法重新整理一遍:先用 axios 发起请求,再抽出请求模块,最后处理跨域问题。

假设后端提供了一个用户列表接口:

GET http://localhost:8088/user

前端 Vue 项目运行在另一个地址,例如:

http://localhost:8080/

这两个地址的协议、域名或端口只要有一个不同,就不是同源。这里端口不同,所以浏览器会把它们视为跨域请求。

前端请求接口可以使用 axios

Terminal window
npm install axios

安装后,在需要请求数据的 Vue 页面中引入:

import axios from 'axios';

如果项目使用的是 Vue 2 或 Options API,可以先在 methods 中写一个简单请求方法。

export default {
methods: {
fetchUsers() {
axios
// 这里填写后端接口地址
.get('http://localhost:8088/user')
.then(function (response) {
// 请求成功后,后端返回的数据在 response.data 中
console.log(response.data);
})
.catch(function (error) {
// 请求失败时会进入 catch
console.log(error);
})
.finally(function () {
// 不管成功还是失败,finally 都会执行
console.log('请求结束');
});
},
},
};

页面上可以绑定一个按钮测试请求:

<button @click="fetchUsers">请求用户数据</button>

点击按钮后,打开浏览器控制台。如果后端接口正常,并且跨域已经允许,就能在控制台看到返回数据。

如果你更喜欢同步风格,也可以用 async / await

import axios from 'axios';
export default {
methods: {
async fetchUsers() {
try {
// await 会等待请求完成
const response = await axios.get('http://localhost:8088/user');
// 后端真正返回的数据通常放在 response.data
console.log(response.data);
} catch (error) {
// 网络错误、接口错误、跨域错误都会进入这里
console.log(error);
}
},
},
};

这种写法在业务代码里更容易读,尤其是请求成功后还有多步处理时。

刚开始写一个请求时,直接在页面里写完整地址没有问题。

但接口一多,就会出现几个问题:

  • 每个页面都要写 http://localhost:8088
  • 请求地址散落在各个组件里。
  • 后续换服务器地址时,需要到处改。
  • 请求头、错误处理、登录 token 等逻辑难以统一。

所以更常见的做法是:把 axios 实例单独封装起来,再把具体接口拆到 api 目录里。

可以按下面的方式组织前端请求代码:

  • 文件夹src/
    • 文件夹api/
      • user.js
    • 文件夹utils/
      • request.js
    • 文件夹views/
      • UserList.vue

这里的职责可以这样理解:

  • utils/request.js:创建 axios 实例,统一配置基础地址、超时时间、拦截器。
  • api/user.js:按业务模块封装用户相关接口。
  • views/UserList.vue:页面组件,只关心调用哪个接口,不关心底层请求细节。

src/utils/request.js 中写入:

import axios from 'axios';
const request = axios.create({
// 接口服务器地址
// 后续接口只需要写 /user、/login 这类路径
baseURL: 'http://localhost:8088',
// 超时时间,单位是毫秒
timeout: 10000,
});
// 请求拦截器:请求发出前会先经过这里
request.interceptors.request.use(
function (config) {
// 如果后续有 token,可以在这里统一添加
// config.headers.Authorization = `Bearer ${token}`;
return config;
},
function (error) {
return Promise.reject(error);
}
);
// 响应拦截器:后端响应后会先经过这里
request.interceptors.response.use(
function (response) {
// 这里先直接返回 response
// 如果后端有统一结构,也可以只返回 response.data
return response;
},
function (error) {
// 可以在这里统一处理错误提示、登录过期等问题
return Promise.reject(error);
}
);
export default request;

这样封装后,页面里就不需要每次都写完整后端地址了。

src/api/user.js 中写:

import request from '../utils/request';
export function getUserList(params = {}) {
return request({
// axios 里字段名是 method,不是 methods
method: 'GET',
// 会和 baseURL 拼接成 http://localhost:8088/user
url: '/user',
// GET 请求参数
params,
});
}

这里要注意一个细节:axios 配置里的请求方式字段是 method,不是 methods

在页面组件中引入接口方法:

import { getUserList } from '../api/user';
export default {
data() {
return {
tableData: [],
};
},
methods: {
async loadUsers() {
try {
const response = await getUserList();
// 如果响应拦截器返回的是完整 response,就从 response.data 取数据
this.tableData = response.data;
} catch (error) {
console.log(error);
}
},
},
};

如果希望页面创建时自动请求,可以在 created 生命周期中调用:

import { getUserList } from '../api/user';
export default {
data() {
return {
tableData: [],
};
},
created() {
// 页面创建时加载数据
this.loadUsers();
},
methods: {
async loadUsers() {
const response = await getUserList();
this.tableData = response.data;
},
},
};

如果要在模板中展示:

<template>
<ul>
<li v-for="user in tableData" :key="user.id">
{{ user.name }}
</li>
</ul>
</template>

实际字段名要根据后端返回数据决定。

前端请求后端时,如果浏览器控制台出现类似错误:

Access to XMLHttpRequest at 'http://localhost:8088/user'
from origin 'http://localhost:8080'
has been blocked by CORS policy

这就是跨域问题。

它不是 axios 的问题,也不是 Vue 的问题,而是浏览器的同源策略在生效。

同源要求三者完全一致:

  • 协议相同,例如都是 http
  • 域名相同,例如都是 localhost
  • 端口相同,例如都是 8080

下面两个地址端口不同,所以不是同源:

前端:http://localhost:8080
后端:http://localhost:8088

浏览器会拦截前端 JavaScript 对后端的请求,除非后端明确允许这个来源访问。

如果后端是 Spring Boot,可以在 Controller 上添加 @CrossOrigin

import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@CrossOrigin
public class UserController {
@GetMapping("/user")
public List<User> listUsers() {
return userService.listUsers();
}
}

这样写表示这个 Controller 允许跨域访问。

如果想限制来源,可以写得更明确:

@CrossOrigin(origins = "http://localhost:8080")
@RestController
public class UserController {
// ...
}

这比完全放开更安全。

如果接口很多,不想每个 Controller 都写 @CrossOrigin,也可以做全局配置。

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry
// 哪些接口路径允许跨域
.addMapping("/**")
// 允许哪个前端地址访问
.allowedOrigins("http://localhost:8080")
// 允许哪些请求方法
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
// 允许携带哪些请求头
.allowedHeaders("*");
}
}

开发阶段可以先这样配置。正式环境中,allowedOrigins 不建议随便写成 *,最好明确填写真实前端域名。

开发环境也可以通过前端代理解决跨域。

如果使用 Vue CLI,可以在 vue.config.js 中配置:

module.exports = {
devServer: {
proxy: {
'/api': {
// 后端服务地址
target: 'http://localhost:8088',
// 是否改变请求来源
changeOrigin: true,
// 把 /api/user 重写成 /user
pathRewrite: {
'^/api': '',
},
},
},
},
};

这时 axios 的基础地址可以改成:

const request = axios.create({
baseURL: '/api',
});

请求:

request({
method: 'GET',
url: '/user',
});

浏览器实际请求的是前端开发服务器:

http://localhost:8080/api/user

再由前端开发服务器转发给后端:

http://localhost:8088/user

这样浏览器看到的是同源请求,就不会触发跨域限制。

浏览器跨域是浏览器限制。你用 Postman、Apifox、curl 能请求成功,不代表浏览器里也能请求成功。

axios 报错是不是后端没返回数据

Section titled “axios 报错是不是后端没返回数据”

不一定。跨域错误时,请求可能已经被浏览器拦截,前端拿不到正常响应。要先看浏览器控制台的 Network 和 Console。

如果使用普通函数,this 可能发生变化。

getUserList().then(function (response) {
// 这里的 this 不一定是 Vue 组件实例
this.tableData = response.data;
});

可以改成箭头函数:

getUserList().then((response) => {
// 箭头函数不会重新绑定 this
this.tableData = response.data;
});

或者直接使用 async / await

Vue 请求后端接口时,可以按这个顺序处理:

  1. 使用 axios 先完成一次最简单的请求。
  2. 确认后端接口能正常返回数据。
  3. 如果浏览器报跨域,让后端配置 CORS,或者在开发环境使用代理。
  4. 当接口变多后,把 axios 封装成统一的 request 模块。
  5. 按业务拆分 API 文件,页面只调用方法,不直接拼接口地址。

这篇旧文当年只是为了解决“前端怎么拿到后端数据”这个问题。现在重新整理后,它更像是一条前后端分离开发的入门路径:先跑通,再封装,最后处理跨域和工程结构。

原文记录:vue请求后端数据和跨域问题

ServletConfig 接口介绍

这篇文章迁移自我早年写在博客园的一篇记录。当时主要是在学习 Servlet 初始化参数:Servlet 容器初始化 Servlet 时,会创建一个 ServletConfig 对象,并把它交给当前 Servlet 使用。

简单说,ServletConfig 适合保存“只属于某一个 Servlet 的配置”。比如某个 Servlet 自己需要的用户名、开关、路径、默认参数等。

当 Web 容器创建并初始化 Servlet 时,会为这个 Servlet 准备一个 ServletConfig 对象。这个对象里包含当前 Servlet 的初始化信息,也可以通过它拿到整个 Web 应用的 ServletContext

需要记住两点:

  • 一个 Web 应用里可以有多个 Servlet,也就可以有多个 ServletConfig 对象。
  • 一个 Servlet 只对应一个 ServletConfig 对象,所以 Servlet 初始化参数默认只对当前 Servlet 有效。

如果把 Web 应用理解成一个项目,那么:

  • ServletContext 更像“整个项目的全局上下文”。
  • ServletConfig 更像“某一个 Servlet 自己的配置说明”。

ServletConfig 常用方法不多,重点是下面这几个:

方法作用
String getInitParameter(String name)根据参数名获取当前 Servlet 的初始化参数值
Enumeration<String> getInitParameterNames()获取当前 Servlet 所有初始化参数名
ServletContext getServletContext()获取当前 Web 应用的 ServletContext 对象
String getServletName()获取当前 Servlet 名称,也就是 web.xml<servlet-name> 的值

这里最常用的是 getInitParameter()getInitParameterNames()。前者适合读取单个配置,后者适合遍历所有配置。

Servlet 里有两类参数很容易混在一起:

配置位置所属对象读取方式生效范围
<context-param>ServletContextgetServletContext().getInitParameter()整个 Web 应用
<servlet> 里的 <init-param>ServletConfiggetServletConfig().getInitParameter()当前 Servlet

也就是说,context-param 不是当前 Servlet 的私有配置,它属于整个 Web 应用。ServletConfig#getServletContext() 只是让你可以从当前 Servlet 拿到全局上下文,并不代表这些全局参数属于 ServletConfig

这个区别非常重要。很多初学 Servlet 的时候,会把“通过 ServletConfig 拿到 ServletContext,再读取全局参数”误认为是在读取 Servlet 自己的初始化参数。

先看全局参数的写法。下面的 admin-emailadmin-nameadmin-password 都配置在 <context-param> 中,因此它们属于整个 Web 应用。

web.xml
<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd"
version="4.0">
<servlet>
<servlet-name>Servlet</servlet-name>
<servlet-class>main.java.com.Servlet</servlet-class>
</servlet>
<context-param>
<param-name>admin-email</param-name>
<param-value>123456@qq.com</param-value>
</context-param>
<context-param>
<param-name>admin-name</param-name>
<param-value>xiaoxi</param-value>
</context-param>
<context-param>
<param-name>admin-password</param-name>
<param-value>123456</param-value>
</context-param>
<servlet-mapping>
<servlet-name>Servlet</servlet-name>
<url-pattern>/Servlet</url-pattern>
</servlet-mapping>
</web-app>

读取这些全局参数时,需要先拿到 ServletContext

Servlet.java
package main.java.com;
import javax.servlet.ServletConfig;
import javax.servlet.ServletContext;
import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@WebServlet(name = "Servlet", value = "/Servlet")
public class Servlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
response.setContentType("text/plain;charset=UTF-8");
ServletConfig config = getServletConfig();
ServletContext servletContext = config.getServletContext();
String adminEmail = servletContext.getInitParameter("admin-email");
String adminName = servletContext.getInitParameter("admin-name");
String password = servletContext.getInitParameter("admin-password");
response.getWriter().println("admin-email: " + adminEmail);
response.getWriter().println("admin-name: " + adminName);
response.getWriter().println("admin-password: " + password);
}
@Override
protected void doPost(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
doGet(request, response);
}
}

这段代码里虽然先调用了 getServletConfig(),但真正读取参数的是 servletContext.getInitParameter()。所以它读取的是全局初始化参数。

使用 web.xml 配置当前 Servlet 参数

Section titled “使用 web.xml 配置当前 Servlet 参数”

如果希望参数只属于当前 Servlet,就应该把参数写到 <servlet> 里的 <init-param> 中。

web.xml
<servlet>
<servlet-name>MyServlet</servlet-name>
<servlet-class>java.com.MyServlet</servlet-class>
<init-param>
<param-name>name</param-name>
<param-value>xiaoxi</param-value>
</init-param>
<init-param>
<param-name>admin</param-name>
<param-value>xiaoxi</param-value>
</init-param>
</servlet>

这种参数才是 ServletConfig 最典型的使用场景。

MyServlet.java
ServletConfig config = getServletConfig();
String name = config.getInitParameter("name");
String admin = config.getInitParameter("admin");

如果另一个 Servlet 也想使用同名参数,需要在另一个 Servlet 的配置里重新声明。Servlet 私有参数不会自动共享。

除了 web.xml,也可以直接使用 @WebServlet@WebInitParam 配置当前 Servlet 的初始化参数。

这种方式更适合简单项目或示例代码,因为配置和 Servlet 类写在一起,阅读起来更直观。

HelloServlet.java
package main.java.com;
import javax.servlet.ServletConfig;
import javax.servlet.ServletException;
import javax.servlet.annotation.WebInitParam;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.Enumeration;
@WebServlet(
name = "helloServlet",
value = "/helloServlet",
initParams = {
@WebInitParam(name = "name", value = "测试"),
@WebInitParam(name = "admin", value = "123456")
}
)
public class HelloServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
response.setContentType("text/html;charset=UTF-8");
ServletConfig config = getServletConfig();
String servletName = config.getServletName();
Enumeration<String> initParameterNames = config.getInitParameterNames();
PrintWriter writer = response.getWriter();
writer.write("servletName: " + servletName + "<br/>");
while (initParameterNames.hasMoreElements()) {
String initParamName = initParameterNames.nextElement();
String initParamValue = config.getInitParameter(initParamName);
writer.write(initParamName + ": " + initParamValue + "<br/>");
}
writer.close();
}
@Override
protected void doPost(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
doGet(request, response);
}
}

这里的 initParams 配置只对 HelloServlet 生效。别的 Servlet 不能直接通过自己的 ServletConfig 读取这些参数。

如果项目很小,或者只是练习 Servlet,使用注解会比较方便。

如果项目配置比较多,或者需要把代码和配置分离,使用 web.xml 会更清晰。尤其是早期 Java Web 项目里,很多 Servlet、Filter、Listener 都会统一写在 web.xml 中。

可以简单按下面的方式判断:

场景推荐方式
示例代码、学习项目@WebServlet
配置较少的小项目@WebServletweb.xml 都可以
多个 Servlet 需要统一管理web.xml
希望参数不写死在 Java 类里web.xml

两种方式本质上都是告诉 Servlet 容器:这个 Servlet 叫什么、映射到哪个路径、初始化时带哪些参数。

第一,context-paraminit-param 不要混用。

如果一个参数是全站通用的,比如站点名称、管理员邮箱、上传目录,可以放在 <context-param> 中。如果一个参数只服务于某个 Servlet,就放在对应 Servlet 的 <init-param> 中。

第二,读取参数时要找对对象。

// 读取当前 Servlet 的初始化参数
getServletConfig().getInitParameter("name");
// 读取整个 Web 应用的全局参数
getServletContext().getInitParameter("admin-email");

第三,输出中文时记得设置响应编码。

response.setContentType("text/html;charset=UTF-8");

如果不设置编码,浏览器里可能会出现中文乱码。

第四,注意包名差异。

早期 Servlet 项目常见包名是 javax.servlet。如果使用的是 Tomcat 10、Spring Boot 3 或 Jakarta EE 新版本,包名会变成 jakarta.servlet

// 旧版本常见写法
import javax.servlet.ServletConfig;
// 新版本常见写法
import jakarta.servlet.ServletConfig;

学习旧项目或迁移项目时,这个差异很常见。

ServletConfig 的核心作用,是保存并读取当前 Servlet 的初始化参数。

需要特别分清楚:

  • ServletConfig 面向当前 Servlet。
  • ServletContext 面向整个 Web 应用。
  • <init-param> 是 Servlet 私有配置。
  • <context-param> 是 Web 应用全局配置。
  • 注解里的 initParams 只对当前 Servlet 生效。

把这几个概念理顺之后,Servlet 初始化参数就不难了。真正容易出错的地方,往往不是 API 本身,而是没有分清“这个配置到底属于谁”。

原文记录:ServletConfig接口介绍