跳转到内容

前端

标签「前端」下的 3 篇文章

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 组件之间的数据边界:谁拥有状态,谁负责修改状态,谁只是接收状态并渲染。

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

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

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

这道题看起来像是在问 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 中为什么经常只拷贝变化路径。

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

可以记住这几句话:

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

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

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请求后端数据和跨域问题