第一篇:React 组件通信的底层逻辑
今天面试一个两年经验的 React 开发,简历写着熟悉 hooks 与状态管理。我问 React 中兄弟组件怎么共享状态,他答 props 层层传或者用 context。我追问 props 传多层有什么问题,context 直接把状态放在 provider 里有什么陷阱,如何让 context 实现按需更新——他逐渐含糊,最后卡在如何避免无关组件重渲染上。这就是典型的知道多种方式,却不理解使用场景。
本质一:props 与回调——最基础的父子通信
父传子靠 props,子传父靠回调,数据流单向可追踪。一旦跨越多层,中间组件被迫转发自身不需要的 props,造成"props 钻孔",代码冗余耦合,严重时牵一发动全身。
// ❌ Props 钻孔:Middle 完全不需要 user,却被迫传递
function App() {
const [user, setUser] = useState({ name: 'Alice' });
return <Middle user={user} setUser={setUser} />;
}
function Middle({ user, setUser }) {
// Middle 本身不用 user,只是中转
return <Child user={user} setUser={setUser} />;
}
function Child({ user, setUser }) {
return (
<div>
<span>{user.name}</span>
<button onClick={() => setUser({ name: 'Bob' })}>改名</button>
</div>
);
}
// ✅ 正常父子通信:直接传,层级浅时没问题
function Parent() {
const [count, setCount] = useState(0);
return <Counter count={count} onIncrement={() => setCount(c => c + 1)} />;
}
function Counter({ count, onIncrement }) {
return <button onClick={onIncrement}>点击了 {count} 次</button>;
}
本质二:context 解决跨层传递,但自带性能陷阱
context 让祖先直接向所有子孙提供数据,绕过中间组件。但直接把状态对象塞进 provider 会带来性能问题——只要 provider 的 value 引用变化,所有消费该 context 的组件都会重渲染,即使只用了其中一部分数据。
// ❌ 性能陷阱:每次 App 重渲染,value 都是新对象,所有消费者全部重渲
const AppContext = createContext(null);
function App() {
const [user, setUser] = useState({ name: 'Alice' });
const [theme, setTheme] = useState('dark');
// 每次任意状态变化,这个对象都是新引用
return (
<AppContext.Provider value={{ user, theme, setUser, setTheme }}>
<UserPanel /> {/* 只关心 user,但 theme 变也会重渲 */}
<ThemePanel /> {/* 只关心 theme,但 user 变也会重渲 */}
</AppContext.Provider>
);
}
// ✅ 拆分 context + useMemo:按领域隔离,减少无关重渲
const UserContext = createContext(null);
const ThemeContext = createContext(null);
function App() {
const [user, setUser] = useState({ name: 'Alice' });
const [theme, setTheme] = useState('dark');
const userValue = useMemo(() => ({ user, setUser }), [user]);
const themeValue = useMemo(() => ({ theme, setTheme }), [theme]);
return (
<UserContext.Provider value={userValue}>
<ThemeContext.Provider value={themeValue}>
<UserPanel /> {/* theme 变时不会重渲 */}
<ThemePanel /> {/* user 变时不会重渲 */}
</ThemeContext.Provider>
</UserContext.Provider>
);
}
function UserPanel() {
const { user } = useContext(UserContext);
return <div>用户:{user.name}</div>;
}
function ThemePanel() {
const { theme } = useContext(ThemeContext);
return <div>主题:{theme}</div>;
}
本质三:状态管理库——比 context 更专业的全局共享
context 的核心局限:无法跨非父子树共享(如跨路由)、与组件生命周期强绑定、大规模应用下调试困难、性能难以控制。
Zustand 本质是在 React 之外创建独立的响应式存储,通过发布-订阅让组件精确订阅部分状态,从机制上避免无关渲染。Zustand 默认不需要 Provider(这是它区别于 Context 的核心优势);Redux Toolkit 则仍然需要 <Provider store={store}> 包裹。
// ✅ Zustand:无需 Provider,组件直接订阅所需字段
import { create } from 'zustand';
const useStore = create((set) => ({
user: { name: 'Alice' },
theme: 'dark',
setUser: (user) => set({ user }),
setTheme: (theme) => set({ theme }),
}));
// UserPanel 只订阅 user,theme 变化不会触发重渲
function UserPanel() {
const user = useStore((state) => state.user); // 选择器精确订阅
return <div>用户:{user.name}</div>;
}
// ThemePanel 只订阅 theme,user 变化不会触发重渲
function ThemePanel() {
const theme = useStore((state) => state.theme);
return <div>主题:{theme}</div>;
}
// ✅ Redux Toolkit:需要 Provider,但 useSelector 同样支持精确订阅
import { configureStore, createSlice } from '@reduxjs/toolkit';
import { Provider, useSelector, useDispatch } from 'react-redux';
const userSlice = createSlice({
name: 'user',
initialState: { name: 'Alice' },
reducers: {
setUser: (state, action) => { state.name = action.payload; },
},
});
const store = configureStore({ reducer: { user: userSlice.reducer } });
function App() {
return (
<Provider store={store}> {/* Redux Toolkit 必须有 Provider */}
<UserPanel />
</Provider>
);
}
function UserPanel() {
const name = useSelector((state) => state.user.name); // 精确订阅
const dispatch = useDispatch();
return (
<button onClick={() => dispatch(userSlice.actions.setUser('Bob'))}>
{name}
</button>
);
}
本质四:其他通信配角
事件总线(如 mitt)适合极简单的跨组件通知,但在大型项目里难以追踪数据流,容易内存泄漏,不推荐大量使用。forwardRef + useImperativeHandle 适合父组件主动调用子组件方法,但本质上是命令式,应少用。render props / 组合模式可以把控制权反转,减少 props 钻孔,是设计层面的解法。
// ✅ forwardRef + useImperativeHandle:父调用子方法
const InputBox = forwardRef((props, ref) => {
const inputRef = useRef();
useImperativeHandle(ref, () => ({
focus: () => inputRef.current.focus(),
clear: () => { inputRef.current.value = ''; },
}));
return <input ref={inputRef} {...props} />;
});
function Parent() {
const inputRef = useRef();
return (
<>
<InputBox ref={inputRef} />
<button onClick={() => inputRef.current.focus()}>聚焦</button>
<button onClick={() => inputRef.current.clear()}>清空</button>
</>
);
}
// ✅ mitt 事件总线:跨组件简单通知(小型场景)
import mitt from 'mitt';
const emitter = mitt();
function ComponentA() {
useEffect(() => {
// 组件卸载时必须移除监听,否则内存泄漏
const handler = (data) => console.log('收到:', data);
emitter.on('update', handler);
return () => emitter.off('update', handler);
}, []);
return <div>A</div>;
}
function ComponentB() {
return <button onClick={() => emitter.emit('update', { msg: 'hello' })}>发送</button>;
}
灵魂总结
父子通信用 props 加回调,保证单向数据流;爷孙跨层级用 context,但必须配合拆分与 memo 控制渲染;全局状态用 Zustand(无需 Provider)或 Redux Toolkit(需要 Provider)实现精确订阅,摆脱 context 性能枷锁;透传与命令式场景用 ref,跨组件简单通知用事件总线,但都要克制使用。
第二篇:微前端 JS 隔离的完整答案
面试一个五年前端,简历写着主导过大型微前端架构落地,精通 JS 沙箱与隔离机制。我问 qiankun 是怎么做 JS 隔离的,他反应很快:主要靠快照沙箱和代理沙箱。快照沙箱是激活时记录 window 当前状态,失活时对比差异并恢复,适用于单实例场景;代理沙箱通过 Proxy 为每个子应用创建独立的全局对象代理,多实例也不会互相污染。
我继续追问:快照沙箱遍历 window 所有可枚举属性时开销怎么控制?代理沙箱如果子应用绕过 Proxy,比如用 eval 或 Function 构造器执行代码,这些变量能落到沙箱上吗?
第一层:快照沙箱与代理沙箱的原理与硬伤
// ✅ 快照沙箱简化实现
class SnapshotSandbox {
constructor() {
this.snapshot = {};
this.modifyPropsMap = {};
}
activate() {
// 记录当前 window 快照(只记录可枚举属性,性能硬伤所在)
for (const key in window) {
this.snapshot[key] = window[key];
}
// 恢复上次沙箱内的修改
Object.keys(this.modifyPropsMap).forEach(key => {
window[key] = this.modifyPropsMap[key];
});
}
deactivate() {
for (const key in window) {
if (window[key] !== this.snapshot[key]) {
this.modifyPropsMap[key] = window[key]; // 记录变更
window[key] = this.snapshot[key]; // 恢复原值
}
}
}
}
// ✅ 代理沙箱简化实现:多实例互不污染
class ProxySandbox {
constructor() {
this.fakeWindow = {};
this.active = false;
this.proxy = new Proxy(this.fakeWindow, {
get(target, key) {
// 先查沙箱自己的对象,找不到再查真实 window
return key in target ? target[key] : window[key];
},
set(target, key, value) {
if (this.active) {
target[key] = value; // 写入沙箱对象,不污染真实 window
}
return true;
}.bind(this),
});
}
activate() { this.active = true; }
deactivate() { this.active = false; }
}
// 两个子应用互不干扰
const sandbox1 = new ProxySandbox();
const sandbox2 = new ProxySandbox();
sandbox1.activate();
sandbox1.proxy.myLib = 'app1';
sandbox2.activate();
sandbox2.proxy.myLib = 'app2';
console.log(sandbox1.proxy.myLib); // 'app1'
console.log(sandbox2.proxy.myLib); // 'app2'
console.log(window.myLib); // undefined,真实 window 未被污染
第二层:逃逸防御与副作用回收
代理沙箱只能拦截显式的属性读写,eval / new Function 执行的代码、addEventListener、setTimeout 等都会绕过 Proxy 直接操作真实 window。
// ❌ 逃逸示例:eval 声明的变量落在真实 window,不在沙箱里
const sandbox = new ProxySandbox();
sandbox.activate();
// eval 在全局作用域执行,绕过 proxy
eval('var leaked = "污染了真实window"');
console.log(window.leaked); // "污染了真实window" ← 逃逸!
// ❌ new Function 同理
const fn = new Function('globalVar = "污染"');
fn(); // window.globalVar 被污染
// ✅ 副作用收集与回收:拦截 addEventListener / setTimeout
class SandboxWithSideEffects {
constructor() {
this.sideEffects = [];
}
// 代理 addEventListener,失活时自动移除
patchEventListener() {
const original = window.addEventListener.bind(window);
const self = this;
window.addEventListener = function(type, handler, options) {
self.sideEffects.push(() => window.removeEventListener(type, handler, options));
return original(type, handler, options);
};
}
// 代理 setTimeout,失活时自动清除
patchTimer() {
const original = window.setTimeout.bind(window);
const self = this;
window.setTimeout = function(fn, delay, ...args) {
const id = original(fn, delay, ...args);
self.sideEffects.push(() => clearTimeout(id));
return id;
};
}
// 失活时批量回收所有副作用
deactivate() {
this.sideEffects.forEach(cleanup => cleanup());
this.sideEffects = [];
// 清理逻辑超过 10ms 就分片,避免切换掉帧
}
}
第三层:Shadow Realm——原生执行域隔离
Shadow Realm 是 TC39 已进入 Stage 3 的提案(部分现代浏览器已支持),能提供真正隔离的全局作用域,从机制上杜绝变量逃逸。注意:这是新兴方案,目前框架以特性检测降级处理,不能假设所有环境都支持。
// ✅ Shadow Realm:每个子应用拥有完全独立的全局对象
if (typeof ShadowRealm !== 'undefined') {
const realm = new ShadowRealm();
// 在 realm 内执行的代码拥有独立的全局作用域
realm.evaluate('var x = 42');
console.log(typeof x); // "undefined",主 window 完全不受影响
// 通过 importValue 从 realm 取出值(只能传递 primitive 或 callable)
const getX = realm.evaluate('() => x');
console.log(getX()); // 42
// 子应用代码完全无法访问主应用的任何变量
realm.evaluate(`
try { console.log(document.title); } // 受限,无法访问主 document
catch(e) { console.log('隔离成功:', e.message); }
`);
} else {
// 降级为 ProxySandbox
console.log('ShadowRealm 不支持,降级为 Proxy 沙箱');
}
// ✅ qiankun 风格的特性检测自动分流
function createSandbox(appName) {
if (typeof ShadowRealm !== 'undefined') {
return new ShadowRealmSandbox(appName);
}
return new ProxySandbox(appName); // 降级
}
第三篇:跨域携带 Cookie 的死胡同与新方案
面试候选人,题目是跨域携带 Cookie。他答:前端设置 withCredentials,后端返回 Access-Control-Allow-Origin 不能用通配符,再加上 Access-Control-Allow-Credentials: true,cookie 就能自动带过去。
面试官笑了:两个域名完全不一样,无法共享 cookie domain 配置。他慌了,说 SameSite=None; Secure 就行了。但浏览器 cookie 的归属只看请求目标域名,不看页面来自哪。而且 2026 年,Chrome 已彻底禁用第三方 cookie,这些方案全部失效。
本质一:cookie 的发送规则
// cookie 发送的三个条件:
// 1. cookie 的 domain 与请求 URL 的主机匹配
// 2. cookie 的 path 与请求路径匹配
// 3. SameSite 策略允许跨站发送
// ✅ 同域携带 cookie(正常场景)
// 页面:https://app.example.com
// 请求:https://api.example.com(子域)
// cookie:domain=.example.com → 可以携带
fetch('https://api.example.com/user', {
credentials: 'include', // 携带 cookie
});
// 后端响应头(子域共享场景下)
// Access-Control-Allow-Origin: https://app.example.com (不能用 *)
// Access-Control-Allow-Credentials: true
// Set-Cookie: token=abc; Domain=.example.com; SameSite=Lax; Secure
// ❌ 跨站场景(两个完全不同的域名)
// 页面:https://site-a.com
// 要携带:https://site-b.com 的 cookie
// → 这在 2026 年的现代浏览器中已经不可能,SameSite=None 方案也失效
本质二:第三方 cookie 的消亡时间线
2020 年:Chrome 将 SameSite 默认值从 None 改为 Lax
2024 年:Chrome 开始对 1% 用户禁用第三方 cookie(灰度)
2026 年:所有主流浏览器(Chrome / Firefox / Safari)全面禁用第三方 cookie
SameSite=None; Secure 在第三方上下文中也被分区存储(Partitioned)
→ 跨站请求中携带另一个域的 cookie 已是死胡同
本质三:2026 年可落地的三条替代路径
// ✅ 方案一:BFF 令牌中转模式(最稳定)
// 前端只与自身域名通信,由 BFF 服务端代理跨域请求
// 前端代码(只请求自己域名的 BFF)
async function getUserData() {
const res = await fetch('/bff/api/user', {
credentials: 'include', // 携带自己域的 cookie
});
return res.json();
}
// BFF 服务端(Node.js 示例):以服务端身份请求第三方接口
app.get('/bff/api/user', async (req, res) => {
const sessionToken = req.cookies.session; // 读取前端的 cookie
// 服务端直接请求第三方,不受浏览器限制
const data = await fetch('https://third-party-api.com/user', {
headers: {
'Authorization': `Bearer ${sessionToken}`,
'X-Service-Token': process.env.SERVICE_SECRET,
},
}).then(r => r.json());
res.json(data);
});
// ✅ 方案二:FedCM 联合身份认证(W3C 标准)
// 利用 Federated Credential Management API,用户授权后获取 id_token
async function loginWithFedCM() {
try {
const credential = await navigator.credentials.get({
identity: {
providers: [{
configURL: 'https://idp.example.com/.well-known/web-identity',
clientId: 'my-client-id',
nonce: crypto.randomUUID(),
}],
},
});
// credential.token 是 IDP 颁发的 id_token
const { token } = credential;
// 将 token 显式注入后续请求头,不依赖 cookie 自动携带
await fetch('https://api.example.com/resource', {
headers: { 'Authorization': `Bearer ${token}` },
});
} catch (err) {
console.error('FedCM 失败,降级到传统登录', err);
}
}
// ✅ 方案三:Related Website Sets(关联网站集合)
// 适合同一公司旗下多个域名(如 example.com / example.co.uk / brand.com)
// 在 .well-known/related-website-set.json 中声明集合成员
// {
// "primary": "https://example.com",
// "associatedSites": ["https://example.co.uk", "https://brand.com"]
// }
// 前端通过 Storage Access API 请求访问权限
async function requestCookieAccess() {
try {
await document.requestStorageAccess();
// 获得授权后,集合内跨站请求可携带分区 cookie
await fetch('https://example.co.uk/api/data', { credentials: 'include' });
} catch (err) {
console.error('访问被拒绝', err);
}
}
本质四:老系统兼容方案(有历史包袱时)
// ✅ SSO 网关统一代理:cookie 只保留在中心域,前端显式注入凭据
// 登录流程:用户在 sso.example.com 登录 → 获取一次性跨域凭证
// 登录后,SSO 下发短期有效的 cross-domain-token
const crossDomainToken = await fetch('https://sso.example.com/token').then(r => r.json());
// 前端在所有子应用的请求拦截器中显式注入该凭据
// Axios 拦截器示例
axios.interceptors.request.use(config => {
config.headers['X-Cross-Domain-Token'] = crossDomainToken.value;
return config;
});
// 各子应用的 cookie 保留在各自域名,不依赖浏览器自动附加
// 网关收到 X-Cross-Domain-Token 后换取对应域的服务端 session
第四篇:百万任务不卡顿的浏览器调度体系
面试一个五年前端,简历写着主导过千万级数据可视化项目。我问:一个页面需要执行 100 万个任务,怎么保证浏览器不卡顿?
他答:用时间分片,把任务拆成小块,每执行一小段就让出主线程,用 requestIdleCallback 或 setTimeout 控制。思路没问题,但追问更深入的场景时他就卡住了。
第一层:任务分片与优先级调度
setTimeout(fn, 0) 和 requestIdleCallback 都有硬伤:前者最小延迟约 4ms 且不感知帧,后者在移动端触发不稳定且低优任务可能长期饿死。2026 年的标准答案是 scheduler.postTask()。
// ❌ 旧方案:setTimeout 分片,不感知用户输入,高低优混在一起
function processWithSetTimeout(tasks) {
let index = 0;
function run() {
const start = performance.now();
while (index < tasks.length && performance.now() - start < 5) {
tasks[index++](); // 固定 5ms 切片,低端机照样卡
}
if (index < tasks.length) setTimeout(run, 0);
}
run();
}
// ✅ 新方案一:scheduler.postTask() 原生优先级调度
async function processWithScheduler(tasks) {
const HIGH = [];
const NORMAL = [];
const LOW = [];
tasks.forEach(task => {
if (task.priority === 'user-input') HIGH.push(task);
else if (task.priority === 'data-update') NORMAL.push(task);
else LOW.push(task);
});
// user-blocking:用户正在等待的操作(最高优)
for (const task of HIGH) {
await scheduler.postTask(() => task.run(), { priority: 'user-blocking' });
}
// user-visible:用户可见但非立即阻塞
for (const task of NORMAL) {
await scheduler.postTask(() => task.run(), { priority: 'user-visible' });
}
// background:日志上报等后台任务(最低优,不会饿死,有超时提升)
for (const task of LOW) {
await scheduler.postTask(() => task.run(), { priority: 'background' });
}
}
// ✅ 新方案二:配合 scheduler.isInputPending() 动态让渡
async function processWithInputCheck(tasks) {
let index = 0;
while (index < tasks.length) {
// 检测到用户有输入,立刻让渡控制权
if (navigator.scheduling?.isInputPending()) {
await new Promise(resolve => setTimeout(resolve, 0));
continue;
}
tasks[index++]();
}
}
// ✅ 降级方案:不支持 scheduler API 时用 MessageChannel(比 setTimeout 更快)
function yieldToMain() {
return new Promise(resolve => {
const channel = new MessageChannel();
channel.port1.onmessage = resolve;
channel.port2.postMessage(undefined);
});
}
async function processWithYield(tasks) {
for (let i = 0; i < tasks.length; i++) {
tasks[i]();
if (i % 100 === 0) await yieldToMain(); // 每 100 个任务让出一次
}
}
第二层:Worker 线程池与职责分离
// ✅ 常驻 Worker 线程池(避免频繁创建销毁开销)
class WorkerPool {
constructor(workerScript, poolSize = navigator.hardwareConcurrency || 4) {
this.workers = Array.from({ length: poolSize }, () => new Worker(workerScript));
this.queue = [];
this.idle = [...this.workers];
}
run(data) {
return new Promise((resolve, reject) => {
const execute = (worker) => {
worker.onmessage = ({ data: result }) => {
resolve(result);
this.idle.push(worker);
if (this.queue.length) this.queue.shift()(this.idle.pop());
};
worker.onerror = reject;
worker.postMessage(data);
};
if (this.idle.length) {
execute(this.idle.pop());
} else {
this.queue.push(execute); // 排队等待空闲 worker
}
});
}
}
// worker.js(纯计算,不操作 DOM)
self.onmessage = ({ data }) => {
const result = heavyCompute(data); // 排序、转换、加密等
self.postMessage(result);
};
// 主线程:计算在 Worker,DOM 更新在主线程 + rAF 批处理
async function processAndRender(rawData) {
const pool = new WorkerPool('./worker.js');
const chunks = chunkArray(rawData, 1000); // 分块
const results = await Promise.all(chunks.map(chunk => pool.run(chunk)));
// 把所有 DOM 更新集中到一次 rAF,避免多次强制同步布局
requestAnimationFrame(() => {
const fragment = document.createDocumentFragment();
results.flat().forEach(item => {
const el = document.createElement('div');
el.textContent = item.value;
fragment.appendChild(el);
});
document.getElementById('list').appendChild(fragment);
});
}
// ✅ OffscreenCanvas:图表绘制全部在 Worker,主线程彻底解放
// 主线程
const canvas = document.getElementById('chart');
const offscreen = canvas.transferControlToOffscreen();
const worker = new Worker('./chart-worker.js');
worker.postMessage({ canvas: offscreen, data: chartData }, [offscreen]);
// chart-worker.js
self.onmessage = ({ data: { canvas, data } }) => {
const ctx = canvas.getContext('2d');
// 直接在 worker 里绘制,不阻塞主线程
drawChart(ctx, data);
};
第三层:自适应性能监控与防裂化
// ✅ PerformanceObserver 监控长任务,自动降级
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.duration > 50) {
console.warn(`长任务检测:${entry.duration.toFixed(0)}ms`);
// 连续两个长任务 + 交互延迟 → 自动降级
scheduler.adjustBatchSize('down'); // 伪代码,实际业务逻辑
}
}
});
observer.observe({ type: 'longtask', buffered: true });
// ✅ 任务老化计数器:低优任务等待超时自动提升优先级
class PriorityQueue {
constructor() {
this.tasks = [];
}
add(task, priority = 'background') {
this.tasks.push({ task, priority, addedAt: Date.now() });
}
flush() {
const now = Date.now();
this.tasks.forEach(item => {
// 低优任务等待超过 3 秒,自动提升为 user-visible
if (item.priority === 'background' && now - item.addedAt > 3000) {
item.priority = 'user-visible';
console.log('任务老化提升优先级');
}
});
// 按优先级排序执行
this.tasks.sort((a, b) => priorityWeight(a.priority) - priorityWeight(b.priority));
}
}
function priorityWeight(p) {
return { 'user-blocking': 0, 'user-visible': 1, 'background': 2 }[p] ?? 2;
}
第五篇:白屏排查的系统性诊断流程
面试一个自称对前端性能与稳定性有深度实践的三年前端工程师。我问用户打开页面白屏了,可能的原因有哪些,怎么排查?他答:可能是网络问题、JS 报错或者接口挂了,按 F12 看控制台和网络面板。
真正的白屏排查是一套系统性诊断流程,分四层。
第一层:基础信息确认
// ✅ 在页面入口处埋点,主动上报白屏
// 判断页面是否真正渲染了内容
function detectBlankPage() {
const checkTime = 3000; // 3 秒后检测
setTimeout(() => {
const rootEl = document.getElementById('root') || document.getElementById('app');
const hasContent = rootEl && rootEl.children.length > 0 && rootEl.innerText.trim().length > 0;
if (!hasContent) {
// 上报白屏事件
reportBlankPage({
url: location.href,
timing: performance.timing,
userAgent: navigator.userAgent,
timestamp: Date.now(),
});
}
}, checkTime);
}
// ✅ 检查关键生命周期事件是否触发
window.addEventListener('DOMContentLoaded', () => {
performance.mark('dom-ready');
});
window.addEventListener('load', () => {
performance.mark('page-load');
performance.measure('load-time', 'dom-ready', 'page-load');
});
第二层:链路与资源诊断
// ✅ 资源加载失败监听(捕获阶段,能拦截 img/script/link 的加载失败)
window.addEventListener('error', (event) => {
const target = event.target;
if (target && (target.tagName === 'SCRIPT' || target.tagName === 'LINK' || target.tagName === 'IMG')) {
console.error('资源加载失败:', target.src || target.href);
reportResourceError({ tag: target.tagName, url: target.src || target.href });
// SRI 完整性校验失败也会触发这里
}
}, true); // 必须用捕获阶段
// ✅ 检查 CSP 是否阻断脚本(监听 securitypolicyviolation 事件)
document.addEventListener('securitypolicyviolation', (e) => {
console.error('CSP 违规,脚本被阻断:', e.blockedURI, e.violatedDirective);
// 这种情况下控制台没有普通红色报错,需要主动监听
});
// ✅ 使用 Performance API 检测 FCP 是否触发
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.name === 'first-contentful-paint') {
console.log('FCP:', entry.startTime, 'ms');
if (entry.startTime > 3000) {
console.warn('FCP 超过 3s,疑似白屏');
}
}
}
});
observer.observe({ type: 'paint', buffered: true });
第三层:渲染与脚本执行层
// ✅ 捕获未处理的 Promise 异常(不触发 window.onerror,需单独监听)
window.addEventListener('unhandledrejection', (event) => {
console.error('未捕获的 Promise 异常,可能导致后续脚本停止:', event.reason);
reportError({ type: 'unhandledrejection', message: event.reason?.message });
event.preventDefault(); // 阻止默认控制台输出(可选)
});
// ✅ 全局错误边界(React):防止组件异常导致整个树白屏
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error) {
return { hasError: true, error };
}
componentDidCatch(error, info) {
// 上报错误和组件堆栈
reportError({ error: error.message, stack: info.componentStack });
}
render() {
if (this.state.hasError) {
return <div>页面出现问题,请刷新重试</div>; // 降级 UI,不白屏
}
return this.props.children;
}
}
// ✅ SSR 注水失败检测(React 18)
// 注水不匹配时 React 会输出警告,但不会抛错,需主动检测
if (typeof window !== 'undefined') {
const rootEl = document.getElementById('root');
if (rootEl && !rootEl.hasAttribute('data-reactroot') && rootEl.children.length === 0) {
console.warn('SSR 注水失败,客户端接管渲染');
}
}
第四层:工程化兜底与监控
// ✅ Service Worker 兜底:主服务不可用时返回最小可用 HTML
// sw.js
self.addEventListener('fetch', (event) => {
event.respondWith(
fetch(event.request).catch(() => {
// 网络请求失败时,返回缓存的骨架 HTML
return caches.match('/offline-shell.html') || new Response(
'<html><body><div id="root"><p>网络异常,请检查连接</p></div></body></html>',
{ headers: { 'Content-Type': 'text/html' } }
);
})
);
});
// ✅ 弱网检测与降级
const connection = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
if (connection) {
const effectiveType = connection.effectiveType; // '2g' | '3g' | '4g' | '5g'
if (effectiveType === '2g' || effectiveType === 'slow-2g') {
// 弱网时启用极简骨架,延迟加载非关键资源
document.documentElement.classList.add('low-bandwidth');
// 禁止自动播放视频、关闭高清图片
}
}
// ✅ 103 Early Hints:服务端提前推送关键 CSS,消除白屏等待
// 服务端(Node.js + HTTP/2)
res.writeEarlyHints({
link: [
'</styles/critical.css>; rel=preload; as=style',
'</fonts/main.woff2>; rel=preload; as=font; crossorigin',
],
});
第六篇:健壮的并发请求方案设计
面试场景:页面需要同时请求用户画像、商品库存、广告位配置三个接口,用 Promise.all。广告位接口挂了,整个 Promise.all 失败,用户画像和商品库存也出不来了。如何设计更健壮的方案?
一、Promise.allSettled——ES2020 的答案
// ❌ Promise.all:任一失败则整体失败
async function fetchAll_bad() {
const [user, stock, ad] = await Promise.all([
fetchUser(), // 成功
fetchStock(), // 成功
fetchAd(), // 失败 → 整体失败,user 和 stock 白请求了
]);
}
// ✅ Promise.allSettled:等所有请求安顿,单独处理每个结果
async function fetchAll_good() {
const results = await Promise.allSettled([
fetchUser(),
fetchStock(),
fetchAd(),
]);
results.forEach((result, index) => {
const names = ['用户画像', '商品库存', '广告位配置'];
if (result.status === 'fulfilled') {
console.log(`${names[index]} 成功:`, result.value);
} else {
console.warn(`${names[index]} 失败:`, result.reason);
// 广告位失败只是不展示广告,不影响核心功能
}
});
}
二、手工实现软失败
// ✅ 为每个 Promise 添加软失败包装,失败时返回约定的默认值
function withFallback(promise, fallback = null) {
return promise.catch(err => {
console.warn('请求失败,使用降级数据:', err.message);
return fallback;
});
}
async function fetchPageData() {
const [user, stock, ad] = await Promise.all([
withFallback(fetchUser(), { name: '游客', level: 0 }),
withFallback(fetchStock(), { available: true }),
withFallback(fetchAd(), null), // 广告失败返回 null,不展示广告
]);
return { user, stock, ad };
}
三、超时控制 + 并发限制 + 重试
// ✅ 给单个请求加超时限制
function withTimeout(promise, ms = 3000) {
const timeout = new Promise((_, reject) => {
const id = setTimeout(() => {
clearTimeout(id);
reject(new Error(`请求超时(${ms}ms)`));
}, ms);
});
return Promise.race([promise, timeout]);
}
// ✅ 带中断的超时(配合 AbortController,真正取消请求)
function fetchWithTimeout(url, options = {}, ms = 3000) {
const controller = new AbortController();
const id = setTimeout(() => controller.abort(), ms);
return fetch(url, { ...options, signal: controller.signal })
.finally(() => clearTimeout(id));
}
// ✅ 并发数量控制:控制同时发起的请求数,避免占满带宽
async function concurrentFetch(urls, limit = 3) {
const results = [];
const executing = new Set();
for (const url of urls) {
const promise = fetch(url).then(r => r.json()).finally(() => executing.delete(promise));
results.push(promise);
executing.add(promise);
if (executing.size >= limit) {
await Promise.race(executing); // 等最快的一个完成再继续
}
}
return Promise.allSettled(results);
}
// ✅ 失败重试(指数退避,避免雪崩)
async function fetchWithRetry(url, options = {}, retries = 3) {
for (let i = 0; i < retries; i++) {
try {
return await fetch(url, options).then(r => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
});
} catch (err) {
if (i === retries - 1) throw err;
const delay = Math.min(1000 * 2 ** i, 8000); // 1s → 2s → 4s
console.warn(`第 ${i + 1} 次重试,等待 ${delay}ms`);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
第七篇:微应用跨应用通信容错的四层防线
面试两年前端,简历写着精通微应用架构与跨应用通信。我问跨应用通信故障怎么做容错处理,他张口就来:加个重试,返回默认数据,隐藏报错模块就行。
第一层:基础兜底
// ✅ 基础通信封装:try-catch + 超时 + 默认数据
async function crossAppFetch(targetApp, method, params) {
try {
const result = await withTimeout(
postMessageRequest(targetApp, method, params),
2000
);
return result;
} catch (err) {
console.warn(`跨应用调用 ${targetApp}.${method} 失败:`, err);
return getDefaultData(method); // 返回约定的降级数据
}
}
// postMessage 跨应用通信基础封装
function postMessageRequest(targetApp, method, params) {
return new Promise((resolve, reject) => {
const requestId = Date.now() + Math.random();
const handler = ({ data }) => {
if (data.requestId === requestId) {
window.removeEventListener('message', handler);
data.error ? reject(data.error) : resolve(data.result);
}
};
window.addEventListener('message', handler);
targetApp.postMessage({ requestId, method, params }, targetApp.origin);
});
}
第二层:分类降级
// ✅ 父子应用通信降级:postMessage 失败时用 URL 参数 / localStorage 备选
class ParentChildComm {
async send(childWindow, data) {
try {
// 主通道:postMessage
childWindow.postMessage({ type: 'data', payload: data }, '*');
} catch (err) {
// 降级:localStorage 临时同步
localStorage.setItem('cross-app-fallback', JSON.stringify({ data, timestamp: Date.now() }));
console.warn('postMessage 失败,已写入 localStorage 降级通道');
}
}
listen(callback) {
// 主通道监听
window.addEventListener('message', ({ data }) => {
if (data.type === 'data') callback(data.payload);
});
// 轮询降级通道(备选)
setInterval(() => {
const raw = localStorage.getItem('cross-app-fallback');
if (raw) {
const { data, timestamp } = JSON.parse(raw);
if (Date.now() - timestamp < 5000) callback(data);
localStorage.removeItem('cross-app-fallback');
}
}, 500);
}
}
// ✅ 跨应用状态同步降级:快照恢复核心状态
class StateSyncManager {
constructor() {
this.snapshot = null;
}
saveSnapshot(state) {
this.snapshot = { ...state, savedAt: Date.now() };
sessionStorage.setItem('app-state-snapshot', JSON.stringify(this.snapshot));
}
restoreFromSnapshot() {
const raw = sessionStorage.getItem('app-state-snapshot');
if (raw) {
const snapshot = JSON.parse(raw);
console.warn('跨应用状态同步失败,从快照恢复');
return snapshot;
}
return null;
}
}
第三层:工程化熔断机制
// ✅ 自动熔断:失败频次超阈值时停止调用,避免级联崩溃
class CircuitBreaker {
constructor(options = {}) {
this.failureThreshold = options.failureThreshold || 5;
this.recoveryTime = options.recoveryTime || 10000;
this.failures = 0;
this.state = 'CLOSED'; // CLOSED | OPEN | HALF_OPEN
this.nextAttempt = null;
}
async call(fn) {
if (this.state === 'OPEN') {
if (Date.now() < this.nextAttempt) {
throw new Error('熔断器已开启,拒绝请求');
}
this.state = 'HALF_OPEN';
}
try {
const result = await fn();
this.onSuccess();
return result;
} catch (err) {
this.onFailure();
throw err;
}
}
onSuccess() {
this.failures = 0;
this.state = 'CLOSED';
}
onFailure() {
this.failures++;
if (this.failures >= this.failureThreshold) {
this.state = 'OPEN';
this.nextAttempt = Date.now() + this.recoveryTime;
console.error(`熔断器开启,${this.recoveryTime / 1000}s 后尝试恢复`);
}
}
}
const breaker = new CircuitBreaker({ failureThreshold: 5, recoveryTime: 10000 });
async function safeCrossAppCall(method, params) {
return breaker.call(() => crossAppFetch('subApp', method, params));
}
第四层:监控与用户体验
// ✅ 通信失败上报监控
function reportCommFailure(appName, method, error) {
const metric = {
type: 'cross-app-comm-failure',
app: appName,
method,
error: error.message,
timestamp: Date.now(),
url: location.href,
};
// 上报到监控平台(如 Sentry、自建监控)
navigator.sendBeacon('/api/monitor', JSON.stringify(metric));
}
// ✅ 弱网环境优化:自动切换轻量通信协议
const connection = navigator.connection;
const isSlowNetwork = connection && ['2g', 'slow-2g'].includes(connection.effectiveType);
async function adaptiveCrossAppCall(method, params) {
if (isSlowNetwork) {
// 弱网:只传核心字段,非核心通信延迟执行
const coreParams = pickCoreFields(params);
return crossAppFetch('subApp', method, coreParams);
}
return crossAppFetch('subApp', method, params);
}
第八篇:useOptimistic 核心原理深度解析
今天面试一个三年 React 经验的开发者,简历写着熟练使用 React 19+ 新特性,掌握乐观更新原理。我问:为什么 useOptimistic 不能在非用户交互场景调用?他只说了表层结论,追问底层机制时开始支支吾吾。
本质一:useOptimistic 的底层存储——状态队列加版本标记
React 19 内部通过一个与 fiber 节点绑定的状态队列管理乐观更新,每个乐观更新携带唯一 lane(优先级标记),与触发源(用户交互的 transition)强关联,而非简单的"版本号栈"。
// ✅ useOptimistic 基础用法(React 19)
import { useOptimistic, useTransition } from 'react';
function MessageList({ messages, onSend }) {
const [optimisticMessages, addOptimistic] = useOptimistic(
messages,
// reducer:如何将乐观值合并到当前状态
(currentMessages, newMessage) => [
...currentMessages,
{ ...newMessage, sending: true }, // 标记为发送中
]
);
const [isPending, startTransition] = useTransition();
async function handleSend(text) {
// ✅ 必须在 startTransition 内调用 addOptimistic
// 因为 transition 创建了 React 可追踪的交互上下文
startTransition(async () => {
addOptimistic({ id: Date.now(), text }); // 立即显示乐观状态
try {
await onSend(text); // 等待服务端确认
// 成功:服务端返回后,React 用真实数据替换乐观状态
} catch (err) {
// 失败:React 自动回滚到 addOptimistic 之前的状态
console.error('发送失败,已自动回滚');
}
});
}
return (
<ul>
{optimisticMessages.map(msg => (
<li key={msg.id} style={{ opacity: msg.sending ? 0.5 : 1 }}>
{msg.text} {msg.sending && '(发送中...)'}
</li>
))}
<button onClick={() => handleSend('新消息')}>发送</button>
</ul>
);
}
本质二:为什么不能在非用户交互场景调用
// ❌ 错误:在 useEffect 中直接调用 addOptimistic(非交互场景)
function BadComponent({ messages }) {
const [optimisticMessages, addOptimistic] = useOptimistic(messages, (s, v) => [...s, v]);
useEffect(() => {
// ❌ 这里没有 transition 上下文
// React 无法将这个乐观更新与任何"可回滚的交互"关联
// 如果后续有异步操作失败,React 不知道该回滚到哪个状态
addOptimistic({ id: 999, text: '自动添加' }); // 会被 React 警告或忽略
}, []);
return <ul>{optimisticMessages.map(m => <li key={m.id}>{m.text}</li>)}</ul>;
}
// ❌ 错误:在普通异步回调中调用(同样缺少交互上下文)
function BadComponent2() {
const [data, setData] = useState([]);
const [optimistic, addOptimistic] = useOptimistic(data, (s, v) => [...s, v]);
// 定时器触发,不是用户交互,没有 transition 上下文
useEffect(() => {
const id = setInterval(() => {
addOptimistic({ id: Date.now(), text: '定时推送' }); // ❌
}, 1000);
return () => clearInterval(id);
}, []);
}
// ✅ 正确:必须包裹在 startTransition 或 action 中
function GoodComponent({ onAutoRefresh }) {
const [data, setData] = useState([]);
const [optimistic, addOptimistic] = useOptimistic(data, (s, v) => [...s, v]);
const [, startTransition] = useTransition();
// 如果确实需要非交互触发,必须手动创建 transition
function triggerOptimisticUpdate() {
startTransition(() => {
addOptimistic({ id: Date.now(), text: '程序触发' }); // ✅
});
}
}
本质三:与 React 19 的 useActionState 配合使用
// ✅ React 19 推荐模式:useActionState + useOptimistic 配合
import { useActionState, useOptimistic } from 'react';
function TodoList({ initialTodos }) {
const [todos, formAction, isPending] = useActionState(
async (currentTodos, formData) => {
const text = formData.get('todo');
await addTodoToServer(text); // 服务端操作
return [...currentTodos, { id: Date.now(), text, done: false }];
},
initialTodos
);
const [optimisticTodos, addOptimistic] = useOptimistic(
todos,
(current, newTodo) => [...current, { ...newTodo, isPending: true }]
);
return (
<form action={(formData) => {
// action 内部自动创建 transition 上下文,addOptimistic 可以安全调用
addOptimistic({ id: Date.now(), text: formData.get('todo') });
formAction(formData);
}}>
<ul>
{optimisticTodos.map(todo => (
<li key={todo.id} style={{ opacity: todo.isPending ? 0.6 : 1 }}>
{todo.text}
</li>
))}
</ul>
<input name="todo" />
<button type="submit" disabled={isPending}>添加</button>
</form>
);
}
第九篇:Vite 为什么比 Webpack 快
面试一个精通工程化构建的五年前端,我问 Vite 为什么能比 Webpack 启动快这么多。他答:Vite 是新一代构建工具,Webpack 太老了,用了 ES 模块不用打包所以更快。这些都是表面答案,核心原理完全没讲清。
Webpack 的三个致命缺陷
// Webpack 启动时的工作流程(伪代码说明)
// 1. 从入口文件开始,递归解析所有依赖(可能有数千个模块)
// 2. 将所有模块打包合并成 bundle(哪怕你只改了一行代码)
// 3. 等全部打包完成,dev server 才能启动
// 项目有 1000 个模块时,每次启动都要完整走一遍这个流程
// 项目越大,启动越慢(可能需要 30s~120s)
// HMR(热更新)同样有问题:
// 改动一个文件 → 重新构建该文件的整个依赖链 → 推送到浏览器
// 即使是小改动,依赖链很长时也会明显卡顿
Vite 的四个核心优势
// ✅ 优势一:基于原生 ESM,开发时无需打包
// Vite dev server 启动时几乎不做任何构建工作
// 浏览器请求哪个模块,Vite 才即时编译那个模块
// 浏览器请求 /src/main.js
// 浏览器请求 /src/App.vue ← 只有这两个被请求了才会被编译
// 其他几百个未访问的模块完全不处理
// ✅ 优势二:依赖预构建(只做一次,之后缓存)
// vite.config.js
export default {
optimizeDeps: {
include: ['lodash-es', 'react', 'react-dom'], // 明确包含的依赖
exclude: ['my-local-package'], // 排除本地包
},
};
// Vite 用 esbuild(Go 编写,比 JS 工具快 10~100 倍)做依赖预构建
// 将 node_modules 中的 CommonJS/UMD 模块转为 ESM
// 结果缓存在 node_modules/.vite/deps/,二次启动直接复用
// ✅ 优势三:精确的 HMR(只更新改动模块本身)
// 改动 /src/components/Button.vue
// Vite HMR:只重新编译 Button.vue,通过 ESM HMR API 热替换这一个模块
// Webpack HMR:重新构建从入口到 Button 的整条依赖链
// ✅ 优势四:生产构建使用 Rollup(更好的 Tree-shaking)
// vite.config.js 生产配置
export default {
build: {
target: 'es2015',
rollupOptions: {
output: {
manualChunks: {
vendor: ['react', 'react-dom'], // 手动分包
utils: ['lodash-es'],
},
},
},
// Rollup 对 ESM 的 Tree-shaking 比 Webpack 更彻底
// 因为 ESM 的静态结构让 Rollup 能精确分析哪些代码真正被用到
},
};
Vite vs Webpack 对比总结
指标 Webpack Vite
--------------------------------------------------------------
冷启动 全量递归打包,慢(大项目30s+) 无需打包,极快(<1s)
HMR速度 重建依赖链,随项目增大变慢 只编译改动文件,始终极快
依赖处理 每次重新解析 预构建+缓存,二次启动快
模块规范 需兼容 CJS/AMD/UMD/ESM 原生 ESM,不需兼容转换
生产构建 Webpack(Terser) Rollup(更好的 Tree-shaking)
配置复杂度 高(大量 loader/plugin) 低(内置大部分常用功能)
第十篇:首屏加载优化的五个核心模块
面试一个高级前端,简历写着首屏加载提速 60%。我问:首次加载资源体积超 2MB,LCP 4.8 秒,怎么落地优化?他只说了图片懒加载、压缩、CDN,追问到细节就卡住了。
本质一:关键渲染路径优化
<!-- ✅ 关键 CSS 内联:避免额外 HTTP 请求阻塞渲染 -->
<head>
<!-- 内联首屏必须的 CSS(通常 < 14KB,一个 TCP 包内) -->
<style>
/* critical.css:只包含首屏可见元素的样式 */
body { margin: 0; font-family: sans-serif; }
.header { height: 60px; background: #fff; }
.hero { min-height: 400px; }
</style>
<!-- 非关键 CSS 异步加载,不阻塞渲染 -->
<!-- media="print" 技巧:先以打印样式异步加载,onload 后切换为 all -->
<link rel="stylesheet" href="/styles/non-critical.css"
media="print" onload="this.media='all'">
<noscript><link rel="stylesheet" href="/styles/non-critical.css"></noscript>
</head>
<!-- ✅ JS 脚本加载策略 -->
<!-- async:下载完立即执行,不保证顺序,适合无依赖的独立脚本(如统计) -->
<script async src="/analytics.js"></script>
<!-- defer:等 HTML 解析完再按顺序执行,适合有依赖关系的脚本 -->
<script defer src="/vendor.js"></script>
<script defer src="/app.js"></script> <!-- 保证在 vendor.js 之后执行 -->
<!-- type="module" 默认 defer 行为,且支持 ES 模块 -->
<script type="module" src="/main.js"></script>
本质二:资源优先级——preload / preconnect / prefetch
<head>
<!-- ✅ preload:强制优先加载当前页面必须的资源 -->
<!-- 适合:首屏关键 JS、关键字体、LCP 图片 -->
<link rel="preload" href="/fonts/main.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="/js/critical.js" as="script">
<!-- LCP 图片预加载(解决 LCP 图片发现太晚的问题) -->
<link rel="preload" href="/images/hero.jpg" as="image" fetchpriority="high">
<!-- ✅ preconnect:提前建立 TCP+TLS 连接,减少跨域资源首次请求延迟 -->
<!-- 适合:CDN 域名、字体服务、API 域名 -->
<link rel="preconnect" href="https://cdn.example.com">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="dns-prefetch" href="https://api.example.com"> <!-- 低优降级版 -->
<!-- ✅ prefetch:空闲时预加载下一页可能用到的资源,不抢占首屏带宽 -->
<!-- 适合:用户大概率会访问的下一个页面 -->
<link rel="prefetch" href="/js/detail-page.js" as="script">
</head>
本质三:弱网降级与容错
// ✅ 基于网络类型的服务端资源适配
// Node.js 服务端读取 Network Information API 提示的 header
app.get('/api/page-data', (req, res) => {
const saveData = req.headers['save-data'] === 'on'; // 用户开启了省流模式
const ect = req.headers['ect']; // '2g' | '3g' | '4g'
if (saveData || ect === '2g') {
return res.json({
images: 'low-res', // 返回低分辨率图片 URL
components: 'minimal', // 返回精简版组件数据
});
}
res.json({ images: 'hd', components: 'full' });
});
// ✅ 前端资源加载容错:超时自动重试
function loadScriptWithRetry(src, retries = 3) {
return new Promise((resolve, reject) => {
let attempts = 0;
function attempt() {
const script = document.createElement('script');
script.src = `${src}?t=${Date.now()}`; // 防止缓存旧失败请求
script.onload = resolve;
script.onerror = () => {
if (++attempts < retries) {
setTimeout(attempt, 1000 * attempts); // 递增延迟重试
} else {
reject(new Error(`${src} 加载失败,已重试 ${retries} 次`));
showFallbackUI(); // 展示降级骨架屏
}
};
document.head.appendChild(script);
}
attempt();
});
}
// ✅ 103 Early Hints(HTTP/2 + 现代 Node.js)
// 在主 HTML 响应之前,服务器提前推送资源提示
// Express + Node 18+
app.get('/', (req, res) => {
res.writeEarlyHints({
link: [
'</styles/critical.css>; rel=preload; as=style',
'<https://cdn.example.com>; rel=preconnect',
],
}, () => {
// 早期提示发出后,继续处理主响应
res.send(renderHTML());
});
});
本质四:路由懒加载 + 预加载动态平衡
// ✅ React 路由懒加载(减少首屏体积)
import { lazy, Suspense } from 'react';
import { Routes, Route, useNavigate } from 'react-router-dom';
// 路由级代码分割:首屏只加载当前路由的代码
const HomePage = lazy(() => import('./pages/Home'));
const DetailPage = lazy(() => import('./pages/Detail'));
const ProfilePage = lazy(() => import('./pages/Profile'));
function App() {
return (
<Suspense fallback={<PageSkeleton />}>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/detail/:id" element={<DetailPage />} />
<Route path="/profile" element={<ProfilePage />} />
</Routes>
</Suspense>
);
}
// ✅ 基于用户行为的智能预取:鼠标悬停时预加载
function NavLink({ to, label }) {
const [prefetched, setPrefetched] = useState(false);
function handleMouseEnter() {
if (prefetched) return;
setPrefetched(true);
// 用户悬停时触发预加载(用户大概率会点击)
if (to === '/detail') import('./pages/Detail');
if (to === '/profile') import('./pages/Profile');
}
return <a href={to} onMouseEnter={handleMouseEnter}>{label}</a>;
}
本质五:性能监控与持续迭代
// ✅ 监控核心 Web Vitals 指标
import { onLCP, onFID, onCLS, onINP } from 'web-vitals';
function reportWebVitals(metric) {
const { name, value, rating } = metric;
// rating: 'good' | 'needs-improvement' | 'poor'
if (rating === 'poor') {
console.warn(`${name}: ${value.toFixed(0)}ms - 需要优化`);
// 上报到监控平台
navigator.sendBeacon('/api/vitals', JSON.stringify(metric));
}
}
onLCP(reportWebVitals); // Largest Contentful Paint,理想 < 2.5s
onFID(reportWebVitals); // First Input Delay,理想 < 100ms(已被 INP 替代)
onCLS(reportWebVitals); // Cumulative Layout Shift,理想 < 0.1
onINP(reportWebVitals); // Interaction to Next Paint(新指标),理想 < 200ms
// ✅ 预加载与懒加载不冲突的缓存策略
// Service Worker 缓存配置:预加载资源存入 Cache,懒加载时优先命中
// sw.js
const CACHE_NAME = 'app-v1';
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request).then(cached => {
if (cached) return cached; // 命中缓存,无需再请求(预加载的资源在这里生效)
return fetch(event.request).then(response => {
// 缓存新资源(懒加载的路由 chunk 也会被缓存)
const toCache = response.clone();
caches.open(CACHE_NAME).then(cache => cache.put(event.request, toCache));
return response;
});
})
);
});
第十一篇:Vue3 与 React18 响应式更新机制深度对比
面试一个七年前端,简历写着熟练掌握 Vue3 和 React18,有大型项目性能调优经验。我问:Vue3 和 React18 的更新机制有什么根本不同,哪个更精准高效?他答:Vue3 是 Proxy 劫持,React 是状态快照,具体细节不太确定。
React18 的更新机制:组件级快照 + Fiber 调度
// ✅ React:不可变数据 + 组件级更新
function UserProfile() {
const [user, setUser] = useState({ name: 'Alice', address: { city: '北京' } });
function updateCity() {
// 必须返回新对象(不可变更新),React 通过引用比较判断是否更新
setUser(prev => ({
...prev,
address: { ...prev.address, city: '上海' }, // 生成新引用
}));
// ⚠️ 只改了 address.city,但整个 UserProfile 组件都会重渲
}
return <div onClick={updateCity}>{user.name} - {user.address.city}</div>;
}
// ✅ 用 useMemo / memo 减少 React 的冗余渲染
const AddressCard = memo(({ address }) => {
// 只有 address 引用变化时才重渲
return <div>{address.city}</div>;
});
function UserProfile() {
const [user, setUser] = useState({ name: 'Alice', address: { city: '北京' } });
// 用 useMemo 稳定不依赖变化的派生数据
const fullName = useMemo(() => `用户:${user.name}`, [user.name]);
return (
<>
<span>{fullName}</span>
<AddressCard address={user.address} /> {/* address 引用不变时不重渲 */}
</>
);
}
// ✅ React 18 自动批处理(同时合并多次 setState)
function handleClick() {
setCount(c => c + 1);
setFlag(f => !f);
// React 18 之前:触发两次重渲
// React 18 之后:自动批处理,只触发一次重渲(即使在异步回调中)
}
Vue3 的响应式机制:Proxy + 精准依赖追踪
// ✅ Vue3 Proxy 代理:精准到字段级别的更新
import { reactive, ref, computed, watchEffect } from 'vue';
const user = reactive({
name: 'Alice',
address: { city: '北京' }, // 深层属性也会被自动代理
});
// 直接修改深层属性,Vue3 能精准检测到
user.address.city = '上海';
// 只有依赖 address.city 的视图会更新,依赖 user.name 的不会动
// ✅ 依赖收集原理演示
const state = reactive({ count: 0, name: 'test' });
// watchEffect 执行时,内部访问了 state.count
// Vue3 的 Proxy getter 会把这个 effect 收集为 count 的依赖
watchEffect(() => {
console.log('count 变了:', state.count); // 只有 count 变才重新执行
// state.name 没有被访问,name 变化不会触发这个 effect
});
state.count++; // ✅ 触发上面的 watchEffect
state.name = 'new'; // ✅ 不触发上面的 watchEffect(未收集到依赖)
// ✅ Vue3 对 Map/Set/数组的完整支持(Vue2 的痛点)
const list = reactive([1, 2, 3]);
const map = reactive(new Map([['a', 1]]));
// Vue3 完全支持,Vue2 需要用 Vue.set 或重写数组方法才能触发响应
list.push(4); // ✅ 触发更新
list[0] = 99; // ✅ 触发更新(Vue2 不支持)
list.length = 1; // ✅ 触发更新(Vue2 不支持)
map.set('b', 2); // ✅ 触发更新(Vue2 完全不支持 Map)
Vue2 vs Vue3 vs React 的核心差异对比
// Vue2 的 defineProperty 局限(为什么要换 Proxy)
const obj = {};
Object.defineProperty(obj, 'name', {
get() { return this._name; },
set(val) { this._name = val; trigger(); },
});
// ❌ Vue2 检测不到的场景:
obj.newProp = 'value'; // 新增属性 → 无响应(需用 Vue.set)
delete obj.name; // 删除属性 → 无响应
obj.arr[0] = 'x'; // 数组下标修改 → 无响应(需用 splice 或 Vue.set)
obj.arr.length = 0; // 修改 length → 无响应
// ✅ Vue3 Proxy 全部支持:
const proxy = new Proxy({}, {
get(target, key, receiver) {
track(target, key); // 收集依赖
return Reflect.get(target, key, receiver);
},
set(target, key, value, receiver) {
const result = Reflect.set(target, key, value, receiver);
trigger(target, key); // 触发更新(包括新增属性)
return result;
},
deleteProperty(target, key) {
const result = Reflect.deleteProperty(target, key);
trigger(target, key); // 删除属性也能触发
return result;
},
});
第十二篇:刷新就好,不刷新就异常——渲染缺失三层防御
今天面了个四年前端。线上问题:新上线活动页,用户第一次打开关键内容不渲染,刷新就好。所有接口 200,没有报错,业务逻辑正常执行。
这种问题表面是渲染缺失,本质是资源加载时序紊乱加首屏渲染触发时机过早。
第一层:构建阶段——资源版本一致性
// ✅ Webpack/Vite 配置:所有静态资源带 contenthash
// vite.config.js
export default {
build: {
rollupOptions: {
output: {
// contenthash:文件内容变才改 hash,缓存更精确
entryFileNames: 'js/[name]-[hash].js',
chunkFileNames: 'js/[name]-[hash].js',
assetFileNames: 'assets/[name]-[hash][extname]',
},
},
},
};
// ✅ HTML 不启用强缓存(保证用户拿到最新 HTML,从而加载最新 hash 资源)
// nginx 配置
// location /index.html {
// add_header Cache-Control "no-cache, no-store, must-revalidate";
// }
// location /assets/ {
// add_header Cache-Control "public, max-age=31536000, immutable"; # 强缓存
// }
// ✅ 资源版本不匹配时主动刷新(解决用户持有旧 HTML 的问题)
async function checkAppVersion() {
try {
const res = await fetch('/version.json', { cache: 'no-cache' });
const { version } = await res.json();
const currentVersion = window.__APP_VERSION__; // 构建时注入
if (version !== currentVersion) {
console.warn('检测到新版本,即将刷新');
location.reload(true);
}
} catch {
// 版本检测失败不影响正常使用
}
}
第二层:运行时渲染时机控制
// ✅ 等待核心依赖就绪后再渲染(避免提前挂载)
async function bootstrapApp() {
try {
// 并行加载核心依赖
await Promise.all([
loadCriticalConfig(), // 加载配置
checkUserAuth(), // 鉴权
loadI18n(), // 国际化资源
]);
// 依赖全部就绪后才挂载 React/Vue 根节点
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);
// 移除骨架屏
document.getElementById('skeleton')?.remove();
} catch (err) {
// 关键依赖加载失败,展示降级页面
showErrorPage(err);
}
}
bootstrapApp();
// ✅ 异步组件按需加载时,等依赖就绪后再挂载(Vue3)
// 组件级等待,防止局部渲染空白
const AsyncModule = defineAsyncComponent({
loader: () => import('./HeavyModule.vue'),
loadingComponent: LoadingSpinner, // 加载中显示骨架
errorComponent: ErrorFallback, // 加载失败显示降级
delay: 200, // 200ms 内完成不显示 loading
timeout: 5000, // 超时后显示 error
});
第三层:异常兜底与监控
// ✅ 页面加载后校验核心模块是否正常渲染
function validateCriticalModules() {
const CRITICAL_SELECTORS = [
'#header',
'#main-content',
'.product-list',
];
const missingModules = CRITICAL_SELECTORS.filter(
sel => !document.querySelector(sel) || document.querySelector(sel).children.length === 0
);
if (missingModules.length > 0) {
console.error('关键模块渲染缺失:', missingModules);
// 上报
reportRenderFailure({ missing: missingModules, url: location.href });
// 自动触发重渲染(仅执行一次,防止死循环)
if (!window.__RENDER_RETRY__) {
window.__RENDER_RETRY__ = true;
triggerReRender(); // 通知框架重新渲染
}
}
}
// 在 load 事件后延迟检测(等框架渲染完成)
window.addEventListener('load', () => {
setTimeout(validateCriticalModules, 1000);
});
// ✅ 采集用户设备 + 资源版本 + 加载时序,精准定位复现条件
function collectDiagnosticInfo() {
return {
userAgent: navigator.userAgent,
screen: `${screen.width}x${screen.height}`,
connection: navigator.connection?.effectiveType,
appVersion: window.__APP_VERSION__,
loadTiming: performance.getEntriesByType('navigation')[0],
failedResources: performance.getEntriesByType('resource')
.filter(r => r.transferSize === 0 && r.decodedBodySize === 0)
.map(r => r.name),
};
}
第十三篇:大文件分片上传与断点续传
阿里面试官的标准:能把大文件分片上传与断点续传说清楚的,二面直接问;说不清楚的,哪怕 Promise 背得再溜也直接 pass。
一、为什么直接上传会出问题
单次请求体过大:
- 浏览器/服务器 body size 限制(Nginx 默认 1MB,需配置 client_max_body_size)
- 上传到 90% 断网 → 从头开始,浪费带宽和时间
- 无法展示可靠进度,无法续传
二、核心方案:分片上传 + 断点续传
// ✅ 完整实现:分片上传 + 断点续传 + 并发控制 + 进度展示
class FileUploader {
constructor(file, options = {}) {
this.file = file;
this.chunkSize = options.chunkSize || 1 * 1024 * 1024; // 默认 1MB
this.concurrency = options.concurrency || 3; // 并发数
this.maxRetries = options.maxRetries || 3; // 重试次数
this.fileHash = null;
this.uploadedChunks = new Set();
}
// Step 1:计算文件唯一标识(用 Web Worker 避免阻塞主线程)
async computeHash() {
return new Promise((resolve) => {
const worker = new Worker('/hash-worker.js');
worker.postMessage({ file: this.file });
worker.onmessage = ({ data }) => {
resolve(data.hash);
worker.terminate();
};
});
}
// Step 2:文件切片
createChunks() {
const chunks = [];
let start = 0;
let index = 0;
while (start < this.file.size) {
chunks.push({
index,
blob: this.file.slice(start, start + this.chunkSize),
start,
});
start += this.chunkSize;
index++;
}
return chunks;
}
// Step 3:询问服务端已上传了哪些分片
async checkUploadedChunks(fileHash) {
const res = await fetch(`/api/upload/check?hash=${fileHash}`);
const { uploadedIndexes } = await res.json();
return new Set(uploadedIndexes); // [0, 1, 3] → 只需要上传第 2 片
}
// Step 4:上传单个分片(带重试)
async uploadChunk(chunk, fileHash) {
const formData = new FormData();
formData.append('hash', fileHash);
formData.append('index', chunk.index);
formData.append('total', Math.ceil(this.file.size / this.chunkSize));
formData.append('chunk', chunk.blob);
for (let attempt = 0; attempt < this.maxRetries; attempt++) {
try {
const res = await fetch('/api/upload/chunk', { method: 'POST', body: formData });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
this.uploadedChunks.add(chunk.index);
this.onProgress?.((this.uploadedChunks.size / this.totalChunks) * 100);
return;
} catch (err) {
if (attempt === this.maxRetries - 1) throw err;
await new Promise(r => setTimeout(r, 1000 * (attempt + 1)));
}
}
}
// Step 5:并发控制上传(避免一次性发起所有请求)
async uploadWithConcurrency(chunks, fileHash) {
const queue = [...chunks];
const executing = new Set();
while (queue.length > 0 || executing.size > 0) {
while (executing.size < this.concurrency && queue.length > 0) {
const chunk = queue.shift();
const p = this.uploadChunk(chunk, fileHash).finally(() => executing.delete(p));
executing.add(p);
}
if (executing.size > 0) await Promise.race(executing);
}
}
// Step 6:通知服务端合并分片
async mergeChunks(fileHash, filename) {
await fetch('/api/upload/merge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
hash: fileHash,
filename,
total: Math.ceil(this.file.size / this.chunkSize),
chunkSize: this.chunkSize,
}),
});
}
// 完整上传流程
async upload(onProgress) {
this.onProgress = onProgress;
// 计算 hash(用于秒传和断点续传标识)
this.fileHash = await this.computeHash();
const chunks = this.createChunks();
this.totalChunks = chunks.length;
// 询问已上传分片
this.uploadedChunks = await this.checkUploadedChunks(this.fileHash);
// 秒传检测:全部分片已上传
if (this.uploadedChunks.size === chunks.length) {
console.log('秒传成功!文件已存在');
await this.mergeChunks(this.fileHash, this.file.name);
return;
}
// 只上传未传的分片
const pendingChunks = chunks.filter(c => !this.uploadedChunks.has(c.index));
await this.uploadWithConcurrency(pendingChunks, this.fileHash);
// 合并
await this.mergeChunks(this.fileHash, this.file.name);
console.log('上传完成!');
}
}
// hash-worker.js(Web Worker 中计算 hash,不阻塞 UI)
// 需要引入 spark-md5 库
importScripts('/lib/spark-md5.min.js');
self.onmessage = async ({ data: { file } }) => {
const spark = new SparkMD5.ArrayBuffer();
const chunkSize = 2 * 1024 * 1024;
let offset = 0;
while (offset < file.size) {
const chunk = await file.slice(offset, offset + chunkSize).arrayBuffer();
spark.append(chunk);
offset += chunkSize;
}
self.postMessage({ hash: spark.end() });
};
// 使用示例
const uploader = new FileUploader(file, { chunkSize: 2 * 1024 * 1024, concurrency: 3 });
await uploader.upload((progress) => {
progressBar.style.width = `${progress.toFixed(1)}%`;
progressText.textContent = `${progress.toFixed(1)}%`;
});
第十四篇:浏览器渲染性能与动画卡顿排查
面试官追问:浏览器一帧里有几个步骤?什么是 layout thrashing?2026 年的 View Transitions 和滚动驱动动画你了解吗?
第一阶段:渲染流水线与重排重绘
/* ✅ 只触发 composite:transform 和 opacity 是动画最佳选择 */
.box-good {
/* ✅ 移动位置:用 transform,不用 left/top */
transform: translateX(100px);
/* ✅ 显示隐藏:用 opacity,不用 visibility/display */
opacity: 0;
/* ✅ 提前创建合成层,避免动画时临时提升的性能波动 */
will-change: transform, opacity;
}
/* ❌ 触发重排(最贵):改变几何属性 */
.box-bad {
left: 100px; /* ❌ 触发 layout + paint + composite */
width: 200px; /* ❌ 触发 layout + paint + composite */
margin-top: 10px; /* ❌ 触发 layout + paint + composite */
}
/* ❌ 触发重绘(中等):改变外观属性 */
.box-repaint {
background-color: red; /* ❌ 触发 paint + composite,不触发 layout */
border: 1px solid red; /* ❌ 同上 */
}
第二阶段:强制同步布局(Layout Thrashing)
// ❌ 布局抖动:每帧内先写后读,强制同步布局
function badAnimation(elements) {
elements.forEach(el => {
el.style.width = '100px'; // 写(标记 layout 失效)
const height = el.offsetHeight; // 读(强制浏览器立即执行 layout!)
el.style.height = height + 'px'; // 写(再次标记失效)
// 如果有 100 个元素,就触发了 100 次 layout → 必掉帧
});
}
// ✅ 读写分离:先批量读,再批量写
function goodAnimation(elements) {
// 第一步:批量读取(只触发一次 layout)
const heights = elements.map(el => el.offsetHeight);
// 第二步:批量写入(不触发 layout,下一帧统一处理)
elements.forEach((el, i) => {
el.style.width = '100px';
el.style.height = heights[i] + 'px';
});
}
// ✅ 用 requestAnimationFrame 将写操作推到下一帧
function animateWithRAF(el) {
const height = el.offsetHeight; // 读(当前帧读,可以)
requestAnimationFrame(() => {
el.style.transform = `translateY(${height}px)`; // 写(下一帧统一写)
});
}
// ✅ 更好的方案:用 FLIP 技术实现无 layout 的位置动画
// First:记录初始位置
const first = el.getBoundingClientRect();
// Last:执行 DOM 变更(触发 layout,但只触发一次)
el.classList.add('moved');
const last = el.getBoundingClientRect();
// Invert:用 transform 抵消视觉位移(不触发 layout)
const deltaX = first.left - last.left;
const deltaY = first.top - last.top;
el.style.transform = `translate(${deltaX}px, ${deltaY}px)`;
// Play:动画归位(transform 动画,只触发 composite)
requestAnimationFrame(() => {
el.style.transition = 'transform 300ms ease';
el.style.transform = 'translate(0, 0)';
});
第三阶段:2026 年新增优化武器
// ✅ View Transitions API:页面/元素切换的原生过渡(无需手动操作 DOM)
async function navigateWithTransition(newPage) {
if (!document.startViewTransition) {
// 不支持时降级
renderPage(newPage);
return;
}
// startViewTransition 自动:
// 1. 截图当前状态
// 2. 执行 DOM 更新
// 3. 截图新状态
// 4. 用 CSS 动画过渡两个截图(在合成器线程,不阻塞主线程)
const transition = document.startViewTransition(() => {
renderPage(newPage); // 同步更新 DOM
});
await transition.finished;
}
// 自定义过渡动画(CSS 控制)
/* css */
/*
::view-transition-old(root) {
animation: fade-out 0.3s ease forwards;
}
::view-transition-new(root) {
animation: fade-in 0.3s ease forwards;
}
@keyframes fade-out { to { opacity: 0; transform: scale(0.95); } }
@keyframes fade-in { from { opacity: 0; transform: scale(1.05); } }
*/
// ✅ 滚动驱动动画(Scroll-driven Animations):完全在合成器线程,不阻塞主线程
/* 进度条随滚动变化 */
/*
@keyframes grow {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
#progress-bar {
transform-origin: left;
animation: grow linear;
animation-timeline: scroll(root); // 绑定到页面滚动
animation-fill-mode: both;
}
*/
// ✅ content-visibility: auto:跳过离屏元素的渲染计算
/* CSS */
/*
.feed-item {
content-visibility: auto;
contain-intrinsic-size: 0 300px; // 预留高度,防止滚动条抖动
}
*/
// 效果:只渲染当前视口附近的列表项,滚动到哪才渲染哪
// ✅ will-change 正确用法(用完要移除,避免内存浪费)
function animateElement(el) {
el.style.willChange = 'transform'; // 开始前提升合成层
el.animate([
{ transform: 'translateX(0)' },
{ transform: 'translateX(200px)' },
], { duration: 500, easing: 'ease' }).finished.then(() => {
el.style.willChange = 'auto'; // 动画结束后移除,释放内存
});
}
第十五篇:Vue 封装 Web Component 实现跨框架复用
面试简历写着精通 Vue 组件设计,熟悉前端新趋势的四年前端。我问 Vue 如何封装原生 Web Component,实现跨框架无侵入复用。他答:打包好直接引入。单纯打包引入会出现样式污染、响应式失联、生命周期不兼容。
一、Vue3 defineCustomElement:将 Vue 组件编译为原生自定义元素
// ✅ Vue3 官方支持:defineCustomElement
import { defineCustomElement } from 'vue';
// 把 Vue 组件转为自定义元素
const MyButtonElement = defineCustomElement({
name: 'MyButton',
props: {
label: String,
variant: { type: String, default: 'primary' },
},
emits: ['click'],
setup(props, { emit }) {
return () => (
<button
class={`btn btn--${props.variant}`}
onClick={() => emit('click', { label: props.label })}
>
{props.label}
</button>
);
},
// ✅ 关键:styles 会注入到 Shadow DOM,实现完全样式隔离
styles: [`
.btn { padding: 8px 16px; border-radius: 4px; cursor: pointer; }
.btn--primary { background: #007bff; color: white; border: none; }
.btn--secondary { background: #6c757d; color: white; border: none; }
`],
});
// 注册为全局自定义元素(带缓存,避免重复注册)
if (!customElements.get('my-button')) {
customElements.define('my-button', MyButtonElement);
}
// ✅ 在任何框架中直接使用(无需 Vue 运行时)
// HTML 原生
// <my-button label="点击我" variant="primary"></my-button>
// React 中使用
function ReactApp() {
return (
<my-button
label="React 里的 Vue 组件"
onMyClick={(e) => console.log('收到自定义事件:', e.detail)}
/>
);
}
// Angular 中使用(需在 module 设置 CUSTOM_ELEMENTS_SCHEMA)
// <my-button label="Angular 里用" (click)="handleClick($event)"></my-button>
二、样式隔离原理与数据双向同步
// ✅ Shadow DOM 实现完全样式隔离(自动)
// defineCustomElement 创建的组件默认使用 Shadow DOM
// 外部 CSS 无法穿透 Shadow DOM,内部 CSS 也不会泄漏到外部
// 验证隔离效果
const el = document.querySelector('my-button');
console.log(el.shadowRoot); // DocumentFragment(Shadow DOM 根节点)
// 外部 .btn 样式对 shadow DOM 内部无效,shadow 内的 .btn 也不影响外部
// ✅ 跨框架数据同步:attribute(字符串) vs property(任意类型)
const myBtn = document.querySelector('my-button');
// 方式一:attribute(适合简单字符串,会反映在 HTML 上)
myBtn.setAttribute('label', '新标签');
// 方式二:property(适合对象/数组等复杂类型)
myBtn.config = { icon: 'star', badge: 3 }; // 直接赋值 property
// Vue 的 defineCustomElement 会自动将 props 映射为 property
// ✅ 监听自定义事件(标准 CustomEvent,跨框架通用)
myBtn.addEventListener('click', (e) => {
console.log('自定义事件数据:', e.detail);
});
三、低版本兼容降级
// ✅ 特性检测 + polyfill 降级
function registerCustomElement(name, definition) {
if (typeof customElements !== 'undefined') {
if (!customElements.get(name)) {
customElements.define(name, definition);
}
} else {
// 非常老的浏览器:降级为普通 div + 手动初始化
console.warn(`customElements 不支持,${name} 降级为普通组件`);
loadPolyfill().then(() => customElements.define(name, definition));
}
}
async function loadPolyfill() {
if (typeof customElements === 'undefined') {
await import('@webcomponents/custom-elements'); // 动态加载 polyfill
}
}
第十六篇:跨域解决方案的体系化认知
面试一个三年前端,他把五种跨域方案都列出来,说遇到跨域就选一个试试。连 CORS 预检请求的触发条件都没说清。
第一层:同源策略的核心
同源 = 协议 + 域名 + 端口 完全一致
示例:
当前页面:https://app.example.com:443
✅ 同源:https://app.example.com/api(路径不同,同源)
❌ 跨域:http://app.example.com (协议不同)
❌ 跨域:https://api.example.com (子域不同)
❌ 跨域:https://app.example.com:8080(端口不同)
重要理解:
- 同源策略限制的是 JS 读取跨域响应,而非请求本身的发出
- 跨域请求实际上发出去了,服务器也收到了并处理了
- 是浏览器在收到响应后,检查 CORS 头决定是否允许 JS 读取
- 这就是为什么 CORS 配置错误时,服务器日志里有请求记录,但浏览器报错
第二层:CORS 简单请求 vs 预检请求
// ✅ 简单请求:不触发预检(OPTIONS)
// 条件:方法是 GET/HEAD/POST 之一,且 Header 只有安全头,Content-Type 是特定值
fetch('https://api.example.com/data', {
method: 'GET', // ✅ 简单方法
headers: {
'Content-Type': 'text/plain', // ✅ 简单 Content-Type
},
});
// ❌ 触发预检(OPTIONS)的情况
fetch('https://api.example.com/data', {
method: 'DELETE', // ❌ 非简单方法 → 预检
headers: {
'Content-Type': 'application/json', // ❌ 非简单 Content-Type → 预检
'Authorization': 'Bearer token', // ❌ 自定义 Header → 预检
},
});
// 浏览器会先发一个 OPTIONS 请求询问服务器是否允许,再发真实请求
// ✅ 服务端 CORS 配置(Node.js Express)
const cors = require('cors');
app.use(cors({
origin: ['https://app.example.com', 'https://m.example.com'], // 明确白名单,禁止 *
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true, // 允许携带 cookie
maxAge: 86400, // 预检结果缓存 24h,减少 OPTIONS 请求数
}));
// ✅ 携带 cookie 的跨域请求(同主域子域场景)
fetch('https://api.example.com/user', {
credentials: 'include', // 携带 cookie
// 同时服务端必须:
// Access-Control-Allow-Origin: https://app.example.com (不能是 *)
// Access-Control-Allow-Credentials: true
});
第三层:各方案适用场景
// ✅ 代理转发(最推荐,彻底绕开浏览器限制)
// 开发环境:Vite 代理配置
// vite.config.js
export default {
server: {
proxy: {
'/api': {
target: 'https://backend.example.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''), // /api/user → /user
configure: (proxy) => {
proxy.on('error', (err) => console.error('代理错误:', err));
},
},
},
},
};
// 生产环境:Nginx 反向代理
/*
server {
listen 80;
server_name app.example.com;
location /api/ {
proxy_pass https://backend.example.com/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
add_header Access-Control-Allow-Origin $http_origin always;
add_header Access-Control-Allow-Credentials true always;
}
}
*/
// ✅ JSONP(已过时,仅了解)
function jsonpRequest(url, callbackName) {
return new Promise((resolve, reject) => {
const script = document.createElement('script');
const cb = `jsonp_${Date.now()}`;
// 安全:校验回调函数名,只允许字母数字下划线
if (!/^\w+$/.test(callbackName)) throw new Error('非法回调名');
window[cb] = (data) => {
resolve(data);
delete window[cb];
document.head.removeChild(script);
};
script.src = `${url}?callback=${cb}`;
script.onerror = () => reject(new Error('JSONP 请求失败'));
document.head.appendChild(script);
});
}
// ⚠️ JSONP 只支持 GET,且存在 XSS 风险,现代项目不推荐
// ✅ postMessage 跨域通信(iframe 场景)
// 父页面
const iframe = document.getElementById('child-frame');
iframe.contentWindow.postMessage(
{ type: 'REQUEST', data: { userId: 123 } },
'https://child.example.com' // 必须指定具体 origin,禁止用 *
);
// 子页面(child.example.com)
window.addEventListener('message', (event) => {
// 安全:必须校验来源
if (event.origin !== 'https://parent.example.com') return;
const { type, data } = event.data;
if (type === 'REQUEST') {
event.source.postMessage(
{ type: 'RESPONSE', result: processData(data) },
event.origin // 只回复给父页面
);
}
});
第四层:安全与性能注意点
// ✅ CSRF 防御(携带 cookie 的跨域请求需要防 CSRF)
// 方式一:双重 cookie 验证
// 方式二:自定义 Header(CSRF token)
const csrfToken = document.cookie.match(/csrf=([^;]+)/)?.[1];
fetch('/api/sensitive', {
method: 'POST',
credentials: 'include',
headers: {
'X-CSRF-Token': csrfToken, // 自定义 header,简单跨域请求无法携带
'Content-Type': 'application/json',
},
body: JSON.stringify({ action: 'delete' }),
});
// ✅ 优化预检请求:设置 maxAge 缓存预检结果
// 服务端返回:Access-Control-Max-Age: 86400
// 浏览器缓存 OPTIONS 结果 24h,同 URL 不再重复发预检
// Chrome 最大值:7200s(2h);Firefox 最大值:86400s(24h)
第十七篇:JS 事件循环的 P7 级别理解
面试官:微任务为什么要在宏任务间隙全部清空?浏览器和 Node 的事件循环核心区别是什么?
第一阶段:执行顺序基础
// ✅ 基础执行顺序验证
console.log('1. 同步代码');
setTimeout(() => console.log('5. 宏任务(定时器)'), 0);
Promise.resolve()
.then(() => console.log('3. 微任务(Promise.then)'))
.then(() => console.log('4. 微任务(链式 then)'));
console.log('2. 同步代码');
// 输出顺序:1 → 2 → 3 → 4 → 5
// 原因:同步代码先执行完,再清空所有微任务,最后执行宏任务
// ✅ async/await 的执行顺序
async function main() {
console.log('A. async 函数同步部分');
await Promise.resolve();
console.log('C. await 之后(等价于 Promise.then,微任务)');
}
console.log('B. 调用 main 之后的同步代码(先于 C 执行)');
main();
// 输出:A → B → C
第二阶段:微任务清空机制
// ✅ 微任务会被清空到没有为止(包括微任务中产生的新微任务)
Promise.resolve()
.then(() => {
console.log('微任务 1');
return Promise.resolve(); // 产生新的微任务
})
.then(() => {
console.log('微任务 2(由微任务 1 产生)');
});
setTimeout(() => console.log('宏任务(等所有微任务清空才执行)'), 0);
// 输出:微任务 1 → 微任务 2 → 宏任务
// 即使微任务内产生新微任务,也会在同一轮全部清空,宏任务一直等着
// ⚠️ 风险:递归产生微任务会导致页面永久卡死,渲染永远无法进行
function infiniteMicrotask() {
Promise.resolve().then(infiniteMicrotask); // ❌ 死循环微任务,页面冻结
}
第三阶段:渲染时机 + RAF
// ✅ 渲染发生在:微任务清空后,下一个宏任务执行前
// 注意:并非每次微任务清空后都渲染,浏览器会根据屏幕刷新率决定(通常 16.6ms 一次)
// requestAnimationFrame 的执行时机:在渲染前,微任务之后
setTimeout(() => console.log('宏任务'), 0);
Promise.resolve().then(() => console.log('微任务'));
requestAnimationFrame(() => console.log('rAF(渲染前触发)'));
// 一次事件循环的顺序:
// 微任务 → rAF → 渲染(layout/paint/composite)→ 宏任务
// ✅ MutationObserver 是微任务(设计目的:DOM 变化后立即响应)
const observer = new MutationObserver((mutations) => {
console.log('DOM 变化(微任务):', mutations.length);
});
observer.observe(document.body, { childList: true });
document.body.appendChild(document.createElement('div'));
console.log('DOM 操作后的同步代码');
// 输出:DOM 操作后的同步代码 → DOM 变化(同步代码执行完才触发微任务)
第四阶段:Node.js 事件循环
// ✅ Node.js 事件循环六个阶段
/*
┌──────────────────────────────────┐
│ timers 阶段 │ ← setTimeout / setInterval 回调
│ pending callbacks 阶段 │ ← 上轮 I/O 错误回调
│ idle, prepare 阶段 │ ← 内部使用
│ poll 阶段 │ ← 等待新的 I/O 事件(核心阶段)
│ check 阶段 │ ← setImmediate 回调
│ close callbacks 阶段 │ ← socket.on('close') 等
└──────────────────────────────────┘
*/
// ✅ process.nextTick 优先级高于所有微任务(Node 独有)
process.nextTick(() => console.log('nextTick(最高优先级微任务)'));
Promise.resolve().then(() => console.log('Promise.then'));
setImmediate(() => console.log('setImmediate(check 阶段)'));
// 输出:nextTick → Promise.then → setImmediate
// 每个阶段切换时,先清空 nextTick 队列,再清空 Promise 队列
// ✅ Node 11+ 对齐浏览器行为
// Node 10 及以前:同一阶段的所有宏任务执行完,才统一清空微任务
// Node 11+:每执行一个宏任务,就清空一次微任务(与浏览器行为一致)
// 示例(Node 10 vs Node 11+ 结果不同)
setTimeout(() => {
console.log('timer1');
Promise.resolve().then(() => console.log('promise1'));
}, 0);
setTimeout(() => {
console.log('timer2');
Promise.resolve().then(() => console.log('promise2'));
}, 0);
// Node 10:timer1 → timer2 → promise1 → promise2
// Node 11+:timer1 → promise1 → timer2 → promise2(与浏览器一致)
第十八篇:防抖节流的场景化深度理解
面试一个五年前端,问防抖节流。他对答如流说完定义后,我问具体场景:滚动加载用防抖,搜索联想用节流,会出现什么问题?他愣了。
本质一:场景化控频,而非单纯延时
// ✅ 防抖(debounce):适合"触发完才执行"的场景
// 原理:每次触发重置定时器,只有停止触发后的 delay 时间才执行
function debounce(fn, delay = 300) {
let timer = null;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => {
fn.apply(this, args);
}, delay);
};
}
// 适合场景:
// ✅ 搜索框输入联想(停止输入后才请求)
const searchInput = document.getElementById('search');
searchInput.addEventListener('input', debounce((e) => {
fetchSuggestions(e.target.value); // 停止输入 300ms 后才请求
}, 300));
// ✅ 窗口 resize 后重新计算布局(拖动结束才计算)
window.addEventListener('resize', debounce(() => {
recalculateLayout();
}, 200));
// ✅ 按钮防重复点击(点击后 500ms 内不再响应)
button.addEventListener('click', debounce(handleSubmit, 500));
// ❌ 错误使用:滚动加载下一页用防抖 → 用户快速滚动时请求一直被延后,列表不加载
window.addEventListener('scroll', debounce(loadNextPage, 300)); // ❌ 体验极差
本质二:节流与防抖的正确选型
// ✅ 节流(throttle):适合"持续触发时,固定间隔执行"的场景
// 原理:固定时间窗口内只执行一次,不管触发多少次
function throttle(fn, interval = 300) {
let lastTime = 0;
return function (...args) {
const now = Date.now();
if (now - lastTime >= interval) {
lastTime = now;
fn.apply(this, args);
}
};
}
// 适合场景:
// ✅ 滚动加载(滚动时每 300ms 检测一次是否到底部)
window.addEventListener('scroll', throttle(() => {
const { scrollTop, scrollHeight, clientHeight } = document.documentElement;
if (scrollTop + clientHeight >= scrollHeight - 100) {
loadNextPage(); // 滚动时每 300ms 最多触发一次
}
}, 300));
// ✅ 鼠标移动(mousemove 100ms 内最多更新一次坐标)
document.addEventListener('mousemove', throttle((e) => {
updateCursorPosition(e.clientX, e.clientY);
}, 100));
// ✅ 游戏中的技能冷却(1 秒内只能释放一次)
button.addEventListener('click', throttle(castSkill, 1000));
本质三:进阶实现与工程化优化
// ✅ 防抖前缘执行(immediate):第一次立即执行,之后 delay 内不再执行
function debounceImmediate(fn, delay = 300) {
let timer = null;
return function (...args) {
const callNow = !timer; // 没有 timer 说明是第一次或 delay 过后
clearTimeout(timer);
timer = setTimeout(() => {
timer = null;
}, delay);
if (callNow) fn.apply(this, args); // 第一次立即执行
};
}
// 使用场景:提交按钮(第一次点击立即响应,防止重复点击)
// ✅ 节流时间戳 + 定时器双保障(保证最后一次也能执行)
function throttleComplete(fn, interval = 300) {
let lastTime = 0;
let timer = null;
return function (...args) {
const now = Date.now();
const remaining = interval - (now - lastTime);
if (remaining <= 0) {
// 达到间隔,立即执行
clearTimeout(timer);
timer = null;
lastTime = now;
fn.apply(this, args);
} else if (!timer) {
// 未达到间隔,设置定时器保证最后一次执行
timer = setTimeout(() => {
lastTime = Date.now();
timer = null;
fn.apply(this, args);
}, remaining);
}
};
}
// ✅ React Hook 封装(避免闭包陷阱和重复创建定时器)
import { useRef, useCallback } from 'react';
function useDebounce(fn, delay = 300) {
const timer = useRef(null);
const fnRef = useRef(fn);
fnRef.current = fn; // 每次渲染更新 ref,避免闭包陷阱
return useCallback((...args) => {
clearTimeout(timer.current);
timer.current = setTimeout(() => {
fnRef.current(...args); // 始终调用最新的 fn
}, delay);
}, [delay]); // delay 不变则 callback 引用稳定
}
// 使用
function SearchBox() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const debouncedSearch = useDebounce(async (text) => {
const data = await searchAPI(text);
setResults(data);
}, 300);
return (
<input
value={query}
onChange={(e) => {
setQuery(e.target.value);
debouncedSearch(e.target.value);
}}
/>
);
}
第十九篇:闭包的五个层面——从概念到内存优化
面试精通 JavaScript 的前端,他背出了闭包的定义,但追问变量生命周期和内存影响时完全卡住了。
第一层:基本概念与常见陷阱
// ✅ 闭包的本质:函数 + 其创建时的词法环境
function outer() {
let count = 0; // 词法环境中的变量
return function inner() {
count++; // inner 函数"记住"了 outer 的 count
return count;
};
}
const counter = outer();
console.log(counter()); // 1
console.log(counter()); // 2(count 保持在内存中)
// ❌ 经典陷阱:var 在循环中共享同一个词法环境
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0); // 输出:3 3 3(全是 3)
// 原因:闭包捕获的是 i 的引用,循环结束后 i=3
}
// ✅ 修复方式一:let(每次循环创建新的块级作用域)
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0); // 输出:0 1 2 ✅
}
// ✅ 修复方式二:IIFE(立即执行函数,每次传入当前 i 的值)
for (var i = 0; i < 3; i++) {
((j) => setTimeout(() => console.log(j), 0))(i); // 输出:0 1 2 ✅
}
第二层:作用域链与内存
// ✅ 闭包捕获的是引用,多个闭包共享同一变量
function makeSharedCounter() {
let count = 0;
const inc = () => ++count;
const dec = () => --count;
const get = () => count;
return { inc, dec, get };
}
const c = makeSharedCounter();
c.inc(); c.inc(); // count = 2
c.dec(); // count = 1
console.log(c.get()); // 1 (三个函数共享同一个 count)
// ✅ 手动解除闭包引用,触发 GC 回收
function createHeavyClosure() {
const largeData = new Array(1000000).fill('大数据'); // 占用大量内存
return function process() {
return largeData.length; // 闭包持有 largeData 引用,阻止 GC
};
}
let process = createHeavyClosure();
console.log(process()); // 1000000
// 不再需要时,手动解除引用
process = null; // ✅ GC 现在可以回收 largeData
第三层:闭包导致的内存泄漏
// ❌ DOM 事件回调中的内存泄漏
function setupButton() {
const btn = document.getElementById('btn');
const heavyData = new Array(100000).fill('占内存'); // 大数据
btn.addEventListener('click', function handler() {
// handler 通过闭包持有 heavyData 引用
// 即使 btn 从 DOM 中移除,heavyData 也无法被 GC
console.log(heavyData.length);
});
// ❌ 没有提供移除监听器的方法,内存永远泄漏
}
// ✅ 修复:提供清理函数
function setupButtonFixed() {
const btn = document.getElementById('btn');
const heavyData = new Array(100000).fill('占内存');
function handler() {
console.log(heavyData.length);
}
btn.addEventListener('click', handler);
// 返回清理函数
return function cleanup() {
btn.removeEventListener('click', handler); // 移除监听
// handler 无引用 → GC 回收 → heavyData 也可以被回收
};
}
// ❌ 定时器中的内存泄漏(React 组件卸载后定时器还在)
function BadComponent() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount(c => c + 1); // ❌ 组件卸载后定时器仍持有 setCount 引用
}, 1000);
// 没有清理!内存泄漏
}, []);
}
// ✅ 修复:返回清理函数
function GoodComponent() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => setCount(c => c + 1), 1000);
return () => clearInterval(id); // ✅ 组件卸载时清理定时器
}, []);
}
第四层:现代模式——React Hooks 中的闭包陷阱
// ❌ React Hooks 闭包陷阱:useEffect 捕获的是旧的 state
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
console.log(count); // ❌ 永远打印 0(闭包捕获了初始值)
setCount(count + 1); // ❌ 永远 0 + 1 = 1
}, 1000);
return () => clearInterval(id);
}, []); // 依赖数组为空 → effect 只执行一次 → count 永远是 0
return <div>{count}</div>;
}
// ✅ 修复方式一:函数式更新(不依赖当前 state)
useEffect(() => {
const id = setInterval(() => {
setCount(c => c + 1); // ✅ 始终在最新值基础上 +1
}, 1000);
return () => clearInterval(id);
}, []);
// ✅ 修复方式二:用 useRef 保存最新值
function Counter2() {
const [count, setCount] = useState(0);
const countRef = useRef(count);
countRef.current = count; // 每次渲染更新 ref
useEffect(() => {
const id = setInterval(() => {
console.log(countRef.current); // ✅ 始终是最新值
setCount(countRef.current + 1);
}, 1000);
return () => clearInterval(id);
}, []);
}
// ✅ WeakMap 实现私有属性(闭包 + WeakMap,不影响 GC)
const _privateData = new WeakMap();
class BankAccount {
constructor(balance) {
_privateData.set(this, { balance }); // 私有数据存在 WeakMap 里
}
deposit(amount) {
const data = _privateData.get(this);
data.balance += amount;
}
getBalance() {
return _privateData.get(this).balance;
}
}
const account = new BankAccount(1000);
account.deposit(500);
console.log(account.getBalance()); // 1500
// account 被 GC 后,WeakMap 中的对应数据也会自动被 GC(弱引用)
第五层:V8 优化与性能
// ✅ V8 逃逸分析:未逃逸的闭包对象可被栈分配,无 GC 压力
function localClosure() {
let x = 10;
function inner() { return x * 2; }
return inner(); // inner 没有逃逸出函数,V8 可优化为直接计算
}
// ❌ 在列表中频繁创建闭包:GC 压力大
function renderListBad(items) {
return items.map(item => ({
id: item.id,
onClick: () => handleClick(item.id), // 每个 item 创建一个新闭包
onHover: () => handleHover(item.id), // 又一个
}));
// 10000 个 item → 20000 个闭包对象
}
// ✅ 优化:事件委托 + 单一闭包
function renderListGood(items) {
document.getElementById('list').addEventListener('click', (e) => {
const id = e.target.closest('[data-id]')?.dataset.id;
if (id) handleClick(id); // 只需一个事件监听器
});
return items.map(item => `<li data-id="${item.id}">${item.name}</li>`).join('');
}
第二十篇:Vue3 的 ref 与 reactive 深度原理
面试两年 Vue3 开发,问 ref 和 reactive 的区别与响应式原理,他只说了用途,追问深层时开始支支吾吾。
第一层:设计初衷与核心区别
import { ref, reactive, isRef, isReactive } from 'vue';
// ✅ ref:包装任意类型(包括基本类型)
const count = ref(0); // 基本类型
const user = ref({ name: 'Alice' }); // 对象(内部会调用 reactive)
console.log(count.value); // 必须通过 .value 访问
console.log(isRef(count)); // true
// ✅ reactive:只能包装对象/数组/Map/Set
const state = reactive({ count: 0, name: 'Alice' });
console.log(state.count); // 直接访问,不需要 .value
console.log(isReactive(state)); // true
// ❌ 常见错误:reactive 包装基本类型
// const num = reactive(0); // ❌ 警告:value cannot be made reactive
// ✅ 为什么 ref 需要 .value?
// 基本类型按值传递,包裹成 { value: xxx } 才能用 Proxy 追踪变化
const wrapped = ref(42);
// 内部相当于:
// const wrapped = reactive({ value: 42 })
// 修改 wrapped.value = 100 → 触发 Proxy setter → 通知依赖更新
第二层:响应式原理——依赖收集与触发
import { effect, ref, reactive } from '@vue/reactivity'; // 核心包(与 Vue3 同源)
// ✅ 手动演示依赖收集过程
const state = reactive({ count: 0, name: 'Alice' });
// effect 是响应式系统的核心:执行时自动收集依赖
effect(() => {
// 执行时访问了 state.count → Proxy getter 触发 → track(state, 'count')
// 将当前 effect 记录为 count 的依赖
console.log('count 变化了:', state.count);
// 注意:没有访问 state.name,所以 name 变化不会触发这个 effect
});
state.count = 1; // → Proxy setter 触发 → trigger(state, 'count') → 重新执行上面的 effect
state.name = 'Bob'; // → trigger(state, 'name') → 无依赖,什么都不发生
// ✅ 深层属性的响应式(Vue3 自动深层代理)
const nested = reactive({ a: { b: { c: 1 } } });
effect(() => {
console.log('深层属性:', nested.a.b.c);
});
nested.a.b.c = 99; // ✅ 触发更新(Vue3 会在访问时递归代理,lazy proxy)
第三层:常见陷阱与正确用法
// ❌ 陷阱一:解构 reactive 对象丢失响应式
const state = reactive({ count: 0, name: 'Alice' });
const { count, name } = state; // ❌ count 和 name 是普通值,不再响应式
count = 10; // 不会触发更新
// ✅ 修复:使用 toRefs 解构,每个属性变成 ref
import { toRefs } from 'vue';
const { count, name } = toRefs(state); // ✅ count 和 name 是 ref,保持响应式
count.value = 10; // ✅ 触发更新
// ❌ 陷阱二:直接替换整个 reactive 对象丢失响应式
const data = reactive({ list: [] });
// data = reactive({ list: [1, 2, 3] }); // ❌ 整个引用被替换,响应式连接断开
// ✅ 修复:修改属性而非替换对象
data.list = [1, 2, 3]; // ✅ 修改属性,保持响应式
// 或者:
Object.assign(data, { list: [1, 2, 3] }); // ✅
// ❌ 陷阱三:将 reactive 对象赋值给 ref 后的行为
const obj = reactive({ count: 0 });
const refObj = ref(obj); // ref 包装 reactive 对象
// refObj.value === obj → true(ref 不会二次包装 reactive 对象)
refObj.value.count = 1; // ✅ 触发更新(本质上还是修改 reactive 对象)
第四层:性能优化——shallowRef / shallowReactive / markRaw
import { shallowRef, shallowReactive, markRaw, readonly } from 'vue';
// ✅ shallowRef:只有 .value 本身的变化触发更新(深层不追踪)
const shallowState = shallowRef({ a: { b: 1 } });
shallowState.value.a.b = 99; // ❌ 不触发更新(深层不追踪)
shallowState.value = { a: { b: 99 } }; // ✅ 替换整个 .value 才触发更新
// 适合:大型不可变数据(表格数据、图表 data)
// ✅ shallowReactive:只追踪第一层属性
const shallow = shallowReactive({ count: 0, nested: { value: 1 } });
shallow.count = 1; // ✅ 触发更新(第一层)
shallow.nested.value = 99; // ❌ 不触发更新(深层)
// ✅ markRaw:标记对象永远不被代理(适合第三方实例、大型只读数据)
import * as THREE from 'three';
const state = reactive({
// Three.js 场景对象有大量内部状态,不需要也不适合被 Vue 代理
scene: markRaw(new THREE.Scene()),
count: 0,
});
state.count = 1; // ✅ 触发更新
state.scene.add(mesh); // ✅ Three.js 正常工作,Vue 不追踪这个对象
// ✅ toRaw:获取原始对象(跳过响应式系统,用于性能敏感的批量操作)
import { toRaw } from 'vue';
const raw = toRaw(state); // 获取不被代理的原始对象
// 对 raw 的修改不会触发更新,适合在 worker 中处理或传给第三方库
第五层:Composable 最佳实践
// ✅ 封装可复用的响应式逻辑(Composable)
import { ref, onMounted, onUnmounted } from 'vue';
// 通用数据请求 composable
function useFetch(url) {
const data = ref(null);
const loading = ref(false);
const error = ref(null);
const controller = new AbortController();
async function fetchData() {
loading.value = true;
error.value = null;
try {
const res = await fetch(url, { signal: controller.signal });
data.value = await res.json();
} catch (err) {
if (err.name !== 'AbortError') {
error.value = err.message;
}
} finally {
loading.value = false;
}
}
onMounted(fetchData);
onUnmounted(() => controller.abort()); // 组件卸载时取消请求,防止内存泄漏
return { data, loading, error, refetch: fetchData };
}
// 鼠标位置 composable
function useMouse() {
const x = ref(0);
const y = ref(0);
function update(e) {
x.value = e.clientX;
y.value = e.clientY;
}
onMounted(() => window.addEventListener('mousemove', update));
onUnmounted(() => window.removeEventListener('mousemove', update)); // 清理!
return { x, y };
}
// 在组件中使用
export default {
setup() {
const { data, loading, error } = useFetch('/api/users');
const { x, y } = useMouse();
return { data, loading, error, x, y };
},
};
