服务端5月30日 00:10
如何在 axios 中实现请求和响应拦截器?Axios 拦截器就是请求发出前、响应返回后统一插一层处理逻辑。请求拦截器常用来加 token、加请求 ID、处理 loading;响应拦截器常用来拆 `data`、统一处理业务错误、401 登录失效和网络异常。项目里一般不要直接改全局 axios,而是 `axios.create()` 建实例,再给实例挂拦截器,避免多个后端服务互相污染配置。
## 追问
### 请求拦截器和响应拦截器分别适合做什么?
请求拦截器改 `config`,比如加 `Authorization`、`baseURL`、防缓存参数。响应拦截器处理 `response` 或 `error`,比如把 `{ code, data, message }` 统一拆成 `data` 返回。
### 多个拦截器的执行顺序是什么?
请求拦截器后添加的先执行,响应拦截器先添加的先执行。排查问题时要注意顺序,否则 token 还没加上,请求日志就已经打印了旧配置。
### loading 为什么不能简单请求开始显示、结束隐藏?
并发请求会出问题。第一个请求结束就隐藏 loading,但其他请求还没回来。常见做法是维护计数器,请求加一,响应或错误减一,减到 0 再隐藏。
### 什么时候要移除拦截器?
临时调试、微前端子应用卸载、某个页面单独加拦截逻辑时要 `eject`,否则重复注册会导致同一个错误提示弹很多次。
## 写段代码
```javascript
const api = axios.create({ baseURL: '/api', timeout: 10000 });
api.interceptors.request.use(config => {
const token = localStorage.getItem('token');
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
api.interceptors.response.use(
res => res.data,
err => Promise.reject(err.response?.data || err)
);
```标签
JavaScript
JavaScript 是一种基于脚本的编程语言,主要用于在 Web 页面上实现交互式的效果和动态的内容。JavaScript 是一种解释性语言,不需要编译就可以直接在浏览器中运行。 JavaScript 的主要特点包括: 轻量级:JavaScript 代码通常比较短小,可以快速加载和执行。 可移植性:JavaScript 可以在各种不同的浏览器和操作系统上运行。 面向对象编程:JavaScript 支持面向对象编程,包括对象、继承、封装等特性,可以用于构建复杂的软件系统。 客户端脚本语言:JavaScript 主要用于在 Web 页面上实现交互式的效果和动态的内容,可以与 HTML 和 CSS 一起使用。 异步编程:JavaScript 支持异步编程,可以利用回调函数、Promise、async/await 等方式实现异步操作,提高程序的性能和响应能力。 JavaScript 在 Web 开发中扮演着非常重要的角色,它可以用于实现各种交互式的效果和动态的内容,如表单验证、动画效果、AJAX 等。同时,JavaScript 也可以用于开发各种类型的应用程序,如桌面应用程序、移动应用程序等。 如果您想成为一名 Web 开发人员,JavaScript 是必不可少的编程语言之一,需要掌握 JavaScript 的基本语法和常用的开发框架和库,如 React、Angular、Vue 等。掌握 JavaScript 可以帮助您更加高效和灵活地实现 Web 开发中的各种功能和效果,为自己的职业发展和个人成长打下坚实的基础。

服务端5月30日 00:10
axios 中如何进行错误处理?有哪些常见错误类型?Axios 错误处理先看三类:`error.response` 表示服务端返回了非 2xx 状态码,重点处理 400、401、403、404、5xx;`error.request` 表示请求发出但没收到响应,多半是网络、超时、CORS;两者都没有通常是请求配置写错。实际项目里建议:业务层只处理当前页面关心的错误,全局拦截器统一做登录失效、错误提示、日志和重试。
## 追问
### error.response 和 error.request 有什么区别?
`response` 说明后端有响应,只是状态码失败;`request` 说明请求发出去了但没有拿到响应。前者看 `status` 和 `data.message`,后者看 `code`,比如 `ECONNABORTED` 或 `ERR_NETWORK`。
### 401 应该在每个接口里处理吗?
不要。401 属于全局认证问题,放响应拦截器里统一清 token、跳登录页或刷新 token。页面里只关心业务错误,比如表单校验失败。
### 超时和 5xx 要不要自动重试?
可以,但只重试幂等请求,比如 GET。POST、支付、下单这类接口不能盲目重试,否则可能造成重复提交。
### 实际项目里最容易踩什么坑?
拦截器里 `return Promise.reject(error)` 忘了写,外层 catch 拿不到错误;另一个坑是把所有错误都弹 toast,导致 401 跳转时还弹一堆无意义提示。
## 写段代码
```javascript
axios.interceptors.response.use(r => r.data, error => {
if (error.response) {
const { status, data } = error.response;
if (status === 401) {
localStorage.removeItem('token');
location.href = '/login';
}
return Promise.reject(new Error(data?.message || `HTTP ${status}`));
}
if (error.code === 'ECONNABORTED') {
return Promise.reject(new Error('请求超时'));
}
return Promise.reject(new Error(error.message || '网络错误'));
});
```服务端5月29日 22:54
axios 如何实现并发请求和取消请求?代码怎么写?axios 的并发和取消分别基于 **Promise.all 和 AbortController/CancelToken**。
**并发请求:** 使用 `Promise.all` 同时发起多个请求,全部成功返回结果数组,任一失败则整体失败。`axios.all` 已废弃(v0.27+),直接用 `Promise.all`。需要逐个处理结果用 `Promise.allSettled`,无论成功失败都返回每个请求的状态。
```js
// 并发请求
const [users, posts] = await Promise.all([
axios.get('/api/users'),
axios.get('/api/posts')
]);
// 容错并发
const results = await Promise.allSettled([
axios.get('/api/a'),
axios.get('/api/b')
]);
results.forEach(r => {
if (r.status === 'fulfilled') handleData(r.value.data);
});
```
**取消请求:** 新方案用 `AbortController`(v0.22+推荐),旧方案 `CancelToken` 已废弃。AbortController 是浏览器原生 API,与 fetch 通用。
```js
// AbortController 取消
const controller = new AbortController();
axios.get('/api/data', { signal: controller.signal });
// 取消
controller.abort();
// 封装自动取消:同一接口新请求自动取消旧请求
const pending = new Map();
function fetchWithCancel(url) {
pending.get(url)?.abort();
const ctrl = new AbortController();
pending.set(url, ctrl);
return axios.get(url, { signal: ctrl.signal }).finally(() => pending.delete(url));
}
```
> **追问:**
> 1. Promise.all 和 Promise.allSettled 在错误处理上的区别是什么?
> 2. 如何控制并发数量(如最多同时 3 个请求)?
> 3. 取消请求后 axios 抛出的是什么错误?如何区分取消和真正的请求失败?
> 4. 路由切换时如何批量取消未完成的请求?
> 5. CancelToken 为什么被废弃?它和 AbortController 的实现原理有什么不同?服务端5月29日 22:54
axios 文件上传下载怎么做?进度监控和 CSRF 防护如何实现?axios 的高级特性主要有 **文件上传下载、进度监控、CSRF 防护、实例封装**。
**文件上传:** 使用 FormData 构建请求体,设置 `Content-Type: multipart/form-data`(axios 自动识别 FormData 并设置)。分片上传需手动将文件切片(Blob.slice),逐片上传并在后端合并。上传示例:`const form = new FormData(); form.append('file', fileBlob); axios.post('/upload', form)`。
**文件下载:** 配置 `responseType: 'blob'` 获取二进制数据,通过 `URL.createObjectURL(blob)` 创建临时链接,触发 `<a>` 标签下载。大文件下载注意内存,可用流式处理(Node 环境)。
**进度监控:** 上传用 `onUploadProgress`,下载用 `onDownloadProgress`,回调参数包含 `loaded` 和 `total`,计算百分比:`Math.round(loaded / total * 100)`。底层基于 XMLHttpRequest 的 progress 事件,Node 环境不支持。
**CSRF 防护:** 配置 `xsrfCookieName` 和 `xsrfHeaderName`,axios 自动从 Cookie 读取 XSRF-Token 并写入请求头。后端需在登录时设置 `Set-Cookie: XSRF-TOKEN=xxx; Path=/`。
**实例封装:** `axios.create()` 创建独立实例,拥有自己的拦截器、默认配置和适配器,适合多 API 服务的项目隔离配置。
> **追问:**
> 1. 分片上传如何实现断点续传?需要后端配合什么接口?
> 2. onUploadProgress 在 Node 环境下为什么不生效?有替代方案吗?
> 3. 大文件下载如何避免浏览器内存溢出?
> 4. axios.create 创建的实例和全局 axios 对象的拦截器是共享的吗?
> 5. XSRF-Token 的 Cookie 为什么不能设 HttpOnly?这和 XSS 风险如何权衡?服务端5月29日 22:54
axios 和 fetch 有什么区别?什么时候该用 axios 而非 fetch?axios 和 fetch 的核心区别在 **错误处理、拦截器、请求取消、数据转换、兼容性** 五个方面。
**错误处理:** fetch 只有网络故障才 reject,HTTP 4xx/5xx 仍走 resolve,需手动检查 `response.ok`;axios 自动将 4xx/5xx 转为 reject,错误处理更符合直觉。
**拦截器:** axios 内建请求/响应拦截器,统一添加 Token、处理错误码;fetch 无拦截器,需手动包装或用第三方库。
**请求取消:** axios 支持 CancelToken 和 AbortController;fetch 只支持 AbortController。axios 的超时配置(timeout)是内建的,fetch 需自己用 AbortController + setTimeout 实现。
**数据转换:** axios 自动 JSON 转换(请求时 stringify、响应时 parse);fetch 需手动调用 `response.json()`。axios 自动处理 URL 编码和 FormData。
**兼容性:** fetch 是浏览器原生 API,无依赖;axios 约 13KB gzipped。fetch 在旧浏览器需 polyfill(如 whatwg-fetch)。
**选 axios 的场景:** 需要拦截器、自动错误处理、超时控制、请求/响应转换、取消请求。**选 fetch 的场景:** 追求零依赖、简单请求、Service Worker 中使用、项目已在用 SWR/React Query 等上层库。
> **追问:**
> 1. fetch 的 `credentials: 'include'` 和 axios 的 `withCredentials: true` 行为是否一致?
> 2. fetch 如何实现和 axios 拦截器类似的功能?
> 3. 在 Service Worker 中为什么推荐用 fetch 而非 axios?
> 4. axios 的响应拦截器能拿到原始的 Response 对象吗?
> 5. React Query 或 SWR 底层用的是 axios 还是 fetch?能切换吗?服务端5月29日 22:54
axios 性能优化有哪些技巧?如何减少冗余请求?axios 性能优化围绕 **请求去重、缓存、并发控制、体积优化** 四个维度。
**请求去重:** 对同一接口的并发请求做合并,避免重复发送。实现方式:在请求拦截器中用 URL+参数生成 key,维护一个 Map 记录正在进行的请求,相同 key 的请求复用同一个 Promise,响应后删除 key。也叫"请求锁"或"请求合并"。
**请求缓存:** 对不常变的数据(配置信息、字典表)做本地缓存。在响应拦截器中按 URL+参数缓存响应,设置 TTL 过期。也可用 HTTP 缓存头(Cache-Control、ETag)配合浏览器缓存,axios 的 `adapter` 可实现自定义缓存适配器。
**并发控制:** 使用 `Promise.all` 并发请求提升加载速度;对大量并发用并发池控制(如 p-limit),避免浏览器同域 6 连接限制导致的排队。批量接口优先用后端聚合 API,减少请求次数。
**体积优化:** 开启 gzip 压缩(服务端配置);请求参数精简,只传必要字段;响应数据按需获取(分页、字段过滤);大文件上传用分片上传减少超时风险。
> **追问:**
> 1. 请求去重的 Map 在请求失败时如何清理?避免后续请求被阻塞?
> 2. 自定义缓存 adapter 如何处理 POST 请求的缓存策略?
> 3. 浏览器同域并发连接限制对 axios 有什么影响?如何绕过?
> 4. 如何实现 axios 请求的优先级队列?
> 5. 分片上传的断点续传如何在 axios 中实现?服务端5月29日 22:54
axios 存在哪些安全风险?如何防范 XSS 和 CSRF 攻击?axios 的安全风险集中在 **CSRF、XSS、敏感数据泄露、SSRF** 四个方面。
**CSRF 防护:** axios 内置 CSRF 防护,通过 `xsrfCookieName` 和 `xsrfHeaderName` 配置自动从 Cookie 读取 Token 并附加到请求头。后端需设置 `Set-Cookie: XSRF-TOKEN=xxx`,前端配置 `axios.defaults.xsrfCookieName = 'XSRF-TOKEN'`。对于 SameSite Cookie 策略,建议后端设置 `SameSite=Strict` 或 `Lax` 作为双重保障。
**XSS 防护:** axios 本身不执行 HTML,但返回数据若直接插入 DOM(如 v-html、dangerouslySetInnerHTML)会导致 XSS。必须对响应数据做转义,或使用 DOMPurify 净化。另外避免在 URL 参数中拼接用户输入,防止反射型 XSS。
**敏感数据泄露:** Token 不要存 localStorage(XSS 可读取),优先存 HttpOnly Cookie;请求拦截器中不要把 Token 打印到日志;响应拦截器中敏感字段(密码、密钥)应在日志中脱敏。
**SSRF 防护:** 服务端使用 axios 时,若 URL 来自用户输入,需校验目标地址,禁止请求内网 IP(127.0.0.1、10.x.x.x、192.168.x.x),使用白名单域名策略。
> **追问:**
> 1. axios 的 XSRF 防护机制在前后端分离架构下如何实现?
> 2. SameSite Cookie 的 Strict 和 Lax 模式对 CSRF 防护有什么影响?
> 3. HttpOnly Cookie 方案在跨域场景下如何配置 CORS?
> 4. 如何对 axios 响应数据做自动化脱敏?
> 5. SSRF 攻击中如何绕过 IP 黑名单?白名单方案怎么设计?服务端5月29日 22:54
axios 代码怎么做单元测试?Mock 请求的常用方案有哪些?测试 axios 代码的核心是 **隔离网络请求**,常用方案有三种:axios-mock-adapter、jest.mock + msw。
**axios-mock-adapter:** 直接拦截 axios 实例的请求,按 URL 和方法注册 mock 响应。适合纯 axios 项目,API 直观。示例:`mock.onGet('/api/user').reply(200, { name: 'test' })`。可模拟超时、网络错误、指定状态码。缺点是绑定了 axios 实例,切换请求库需重写 mock。
**jest.mock('axios'):** 直接 mock axios 模块,控制 `axios.get/post` 等方法的返回值。适合快速编写测试,`jest.mock('axios')` 后用 `axios.get.mockResolvedValue()` 设定返回。优点是零依赖,缺点是绕过了拦截器逻辑,无法测试中间件行为。
**MSW(Mock Service Worker):** 在 Service Worker 层拦截请求,不依赖任何 HTTP 库。最接近真实网络行为,可共享给集成测试和 E2E。通过 handler 匹配请求并返回 mock 数据。推荐用于新项目。
**选型建议:** 项目只用 axios → axios-mock-adapter;需要快速单元测试 → jest.mock;多请求库或需要更真实模拟 → MSW。
> **追问:**
> 1. jest.mock 和 jest.spyOn 在 mock axios 时有什么区别?
> 2. 如何测试请求拦截器中的 Token 注入逻辑?
> 3. MSW 的 Service Worker 在 Node 环境下如何工作?
> 4. 如何模拟 axios 的网络超时和 5xx 错误场景?
> 5. axios-mock-adapter 能否模拟请求进度(onUploadProgress)?服务端5月29日 22:54
axios 底层是怎么实现的?核心架构和请求流程是怎样的?axios 本质是 **请求/响应拦截器管道 + 适配器模式**,核心流程为:配置合并 → 请求拦截器 → 分发请求 → 响应拦截器 → 返回结果。
**适配器模式:** axios 不直接发请求,而是通过 adapter 抽象层。浏览器环境用 `xhr.js`(基于 XMLHttpRequest),Node 环境用 `http.js`(基于 Node 的 http/https 模块)。通过 `adapter` 配置项可自定义适配器,这是 axios 跨平台的关键。
**拦截器机制:** 维护两个链式数组 `request interceptors` 和 `response interceptors`,每个拦截器有 fulfilled 和 rejected 两个回调。请求时将配置、拦截器和 adapter 按 Promise 链顺序串联执行,形成 `[req1 → req2 → ... → adapter → ... → res2 → res1]` 的管道。
**配置合并策略:** 三层配置合并——默认配置(defaults)→ 实例配置(instance.defaults)→ 请求配置(请求参数),通过 `mergeConfig` 按策略合并,headers 单独处理。
**请求流程:** `axios.request()` 是统一入口,其他方法(get/post 等)最终都调用 request。内部创建 Promise 链,将拦截器成对插入链的两端,adapter 在链中间执行实际请求,支持取消(CancelToken)和超时控制。
> **追问:**
> 1. 拦截器的执行顺序是什么?请求拦截器和响应拦截器分别是正序还是逆序?
> 2. 如何自定义 adapter 实现 Mock 数据或缓存层?
> 3. CancelToken 基于什么原理实现的?为什么新增 AbortController 方案?
> 4. mergeConfig 对不同类型的配置项(如 headers、transformRequest)采用了什么合并策略?
> 5. axios 的 Promise 链如果中间某个拦截器抛异常,后续拦截器还能执行吗?服务端5月29日 01:38
axios 和 fetch 有什么区别?什么时候该用 axios?axios 是基于 Promise 的 HTTP 客户端,相比原生 fetch 的核心优势在于:请求/响应拦截器(统一添加 token、错误处理)、自动 JSON 转换(fetch 需手动 .json())、请求超时配置(fetch 需封装 AbortController+setTimeout)、上传进度监控、XSRF 防护,以及 4xx/5xx 自动 reject(fetch 只在网络故障时才 reject)。但 fetch 是浏览器原生 API,零体积开销,且正逐步补齐能力(AbortController 已支持取消)。
## 追问
**axios 的拦截器机制是怎么实现的?**
内部维护请求和响应两个拦截器数组(数组链),发送请求时按序执行请求拦截器,收到响应后按序执行响应拦截器,本质是 Promise 链式调用。
**fetch 如何实现请求超时?**
用 AbortController 创建 signal,配合 setTimeout 调用 controller.abort(),fetch 接收 signal 参数,超时后抛出 AbortError。axios 直接配置 timeout 字段即可。
**axios 在浏览器和 Node.js 端分别用什么发送请求?**
浏览器端基于 XMLHttpRequest,Node.js 端基于 http/https 模块,通过适配器模式统一 API。fetch 在 Node 18+ 才原生支持。
**axios 如何实现 XSRF 防护?**
读取指定 cookie(默认 XSRF-TOKEN)的值,自动写入请求头(默认 X-XSRF-TOKEN),配合后端双重 cookie 验证机制防跨站请求伪造。
## 写段代码
```javascript
const instance = axios.create({
baseURL: '/api',
timeout: 5000,
});
instance.interceptors.request.use(cfg => {
cfg.headers.Authorization = `Bearer ${token}`;
return cfg;
});
instance.interceptors.response.use(
res => res.data,
err => { if (err.response?.status === 401) redirectToLogin(); }
);
```前端5月28日 03:34
前端模块规范有哪些?模块如何异步加载?JavaScript 模块化经历了从全局变量污染到标准化模块系统的漫长演进,不同规范解决了不同阶段的问题。
## IIFE:最早的模块化尝试
在规范出现之前,开发者用立即执行函数表达式创建独立作用域:
```javascript
var MyModule = (function() {
var privateVar = 'hidden';
function privateMethod() { return privateVar; }
return {
publicMethod: function() { return privateMethod(); }
};
})();
```
IIFE 通过闭包隔离内部变量,只暴露全局接口。缺点是依赖关系靠全局变量传递,script 标签顺序一旦出错就全局崩溃。
## CommonJS:Node.js 的选择
```javascript
// math.js
module.exports = { add: (a, b) => a + b };
// main.js
const { add } = require('./math');
console.log(add(1, 2));
```
CommonJS 用 `require` 同步加载模块,`module.exports` 导出。核心特征:**运行时加载**,`require` 执行时才确定依赖;**输出值的拷贝**,模块内部变化不会影响已导入的值;`this` 指向当前模块。
同步加载在服务端不是问题——文件在本地磁盘,读取极快。但在浏览器中,模块要从网络下载,同步阻塞会让页面卡死。
## AMD:为浏览器而生
```javascript
define(['jquery', './utils'], function($, utils) {
return {
init: function() { $('body').append(utils.format()); }
};
});
```
AMD(Asynchronous Module Definition)用 `define` 声明模块和依赖,依赖在回调执行前全部加载完成。RequireJS 是最知名的实现。依赖必须前置声明,不管是否马上用到都会先加载。
## CMD:依赖就近
```javascript
define(function(require, exports, module) {
var $ = require('jquery'); // 用到时才加载
exports.init = function() { $('body').append('hello'); };
});
```
CMD 由 SeaJS 推广,和 AMD 的核心区别是依赖就近声明——只有执行到 `require` 时才加载对应模块。两者在浏览器端都已退出主流,被 ESModule 取代。
## UMD:兼容方案
```javascript
(function(root, factory) {
if (typeof exports === 'object') module.exports = factory();
else if (typeof define === 'function') define(factory);
else root.MyModule = factory();
})(this, function() {
return { version: '1.0' };
});
```
UMD 判断运行环境,兼容 CommonJS、AMD 和全局变量三种方式。库开发者打包时常用,确保代码在任何环境都能正常加载。
## ESModule:统一标准
```javascript
// math.js
export const add = (a, b) => a + b;
// main.js
import { add } from './math.js';
```
ESModule 是 JavaScript 语言层面的模块标准。与 CommonJS 的关键区别:**编译时静态分析**,`import/export` 必须在顶层,引擎在执行前就确定依赖关系;**输出值的引用**,模块内部变化会同步反映到导入方;顶层 `this` 为 `undefined`;天然支持 Tree-Shaking。
## 模块异步加载
异步加载的核心场景是按需加载——首屏不需要的代码延迟到使用时再请求,减少初始包体积。
**ESModule 动态导入**:`import()` 返回 Promise,可在任意位置调用,是实现代码分割和路由懒加载的标准方式:
```javascript
button.addEventListener('click', async () => {
const { openDialog } = await import('./dialog.js');
openDialog();
});
```
**AMD 异步加载**:`require([deps], callback)` 本身就是异步的,依赖列表中的模块并行下载后再执行回调。
**CommonJS**:`require` 本身是同步的,不支持浏览器原生的异步加载。但打包工具(Webpack 等)可以将 `import()` 语法编译为 CommonJS 环境下的异步加载 chunk。
## 追问
### import() 和顶层 import 有什么区别?
顶层 `import` 是静态声明,必须在模块顶层,编译时确定依赖关系,引擎可以做静态分析和 Tree-Shaking。`import()` 是动态函数调用,返回 Promise,可以在任何位置调用,运行时才加载模块。后者用于代码分割、路由懒加载等按需加载场景。静态 `import` 在严格模式下还会被提升到模块顶部执行。
### 为什么浏览器不支持 CommonJS 的 require?
`require` 是同步调用——读取文件、编译、执行,然后返回结果。在服务端文件在本地磁盘上,同步读取耗时可以忽略;但在浏览器里模块要从网络下载,网络延迟不可控,同步阻塞意味着页面卡死直到所有依赖下载完成。AMD 和 ESModule 都采用异步加载模型,不阻塞主线程。
### 库作者应该发布什么格式?
ESM + CJS 双格式——`package.json` 的 `exports` 字段同时声明两种格式的入口,CJS 兼容老工具和老版本 Node.js,ESM 支持 Tree-Shaking 和静态分析。典型配置:
```json
{
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
}
}
}
```
单独只发 CJS 会丢失 Tree-Shaking 能力,单独只发 ESM 会排除不支持 ESM 的旧环境。双格式是当前最稳妥的方案。前端5月28日 03:28
Webpack 有哪些优化手段?Webpack 有哪些优化手段?
从构建速度和产物体积两个方向回答,面试中最常考察的优化点如下:
## 构建速度优化
### 1. 持久化缓存
```js
// webpack.config.js
module.exports = {
cache: {
type: 'filesystem', // Webpack 5 支持
buildDependencies: {
config: [__filename]
}
}
}
```
首次构建后缓存写入 `node_modules/.cache/webpack`,二次构建可减少 60%-80% 时间。修改配置文件会自动失效。
### 2. 缩小 Loader 处理范围
```js
module.exports = {
module: {
rules: [{
test: /\.js$/,
include: path.resolve(__dirname, 'src'), // 只处理 src 目录
exclude: /node_modules/,
use: 'babel-loader'
}]
}
}
```
不配置 `include` 时,Babel 会遍历 `node_modules` 中所有 JS 文件,构建时间翻倍。
### 3. 多线程编译
- `thread-loader`:将耗时的 loader 放到 worker 池中并行执行
- `terser-webpack-plugin` 开启 `parallel: true`:并行压缩 JS
- Webpack 5 原生支持 `parallel` 配置
### 4. 优化模块解析
```js
module.exports = {
resolve: {
extensions: ['.ts', '.tsx', '.js'], // 按使用频率排列,减少尝试次数
alias: { '@': path.resolve(__dirname, 'src') }, // 别名减少路径查找
},
module: {
noParse: /lodash|jquery/, // 跳过已打包库的解析
}
}
```
`noParse` 告诉 Webpack 这些库没有 `import/require`,不需要解析依赖关系。
### 5. 使用更快的编译器
- `swc-loader` 替代 `babel-loader`(Rust 实现,速度快 20 倍以上)
- `esbuild-loader` 替代 `terser-webpack-plugin`(Go 实现)
### 6. DLL 预编译(Webpack 4 适用)
```js
// webpack.dll.config.js
new webpack.DllPlugin({
name: '[name]',
path: path.join(__dirname, 'dll', '[name]-manifest.json')
})
```
将 React、Vue 等不变库提前编译成 DLL,开发构建跳过。Webpack 5 的持久化缓存已基本替代此方案。
## 产物体积优化
### 1. 代码分割(Code Splitting)
**splitChunks — 提取公共依赖:**
```js
module.exports = {
optimization: {
splitChunks: {
chunks: 'all',
cacheGroups: {
vendors: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
chunks: 'all'
}
}
}
}
}
```
将 `node_modules` 中的库打包到独立 chunk,利用浏览器缓存。
**动态 import() — 按需加载:**
```js
const Home = React.lazy(() => import('./Home'));
const About = React.lazy(() => import('./About'));
```
路由级懒加载,首屏只加载当前路由代码。
> splitChunks 是配置层面的自动提取,动态 import() 是代码层面的手动分割,两者配合使用。
### 2. Tree Shaking
```js
module.exports = {
mode: 'production', // 自动开启
optimization: {
usedExports: true,
minimize: true,
}
}
```
**前提条件:**
- 必须使用 ESM(`import/export`),不能用 CommonJS(`require`)
- `babel-loader` 配置 `"modules": false` 防止转译为 CJS
- `package.json` 标记 `"sideEffects": false` 告知安全移除未使用导出
**为什么需要 ESM?** ESM 的 `import/export` 是静态声明,编译时可确定依赖关系;CommonJS 的 `require` 是运行时调用,可以在 `if` 中使用,打包工具无法静态分析。
### 3. Scope Hoisting(作用域提升)
```js
module.exports = {
optimization: {
concatenateModules: true, // 生产模式默认开启
}
}
```
将模块合并到同一个闭包中,减少函数声明和闭包数量,体积可减小 5%-10%。
### 4. 压缩
- JS:`terser-webpack-plugin`(Webpack 5 生产模式默认启用)
- CSS:`css-minimizer-webpack-plugin`
```js
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');
module.exports = {
optimization: {
minimizer: [
new CssMinimizerPlugin(),
],
}
}
```
### 5. externals 排除大型库
```js
module.exports = {
externals: {
react: 'React',
'react-dom': 'ReactDOM'
}
}
```
将 React、Lodash 等通过 CDN 引入,不打包进 bundle,显著减小产物体积。
### 6. 图片与资源优化
- 小于 8KB 的图片转 base64 内联(Webpack 5 的 `asset/inline`)
- 大图使用 `image-webpack-loader` 压缩或转 WebP
```js
{
test: /\.(png|jpg)$/,
type: 'asset',
parser: {
dataUrlCondition: { maxSize: 8 * 1024 } // 8KB 以下内联
}
}
```
### 7. IgnorePlugin 排除无用模块
```js
new webpack.IgnorePlugin({
resourceRegExp: /^\.\/locale$/,
contextRegExp: /moment$/
})
```
忽略 moment.js 的 locale 文件,体积减少约 200KB。
## 构建分析工具
| 工具 | 用途 |
|------|------|
| `webpack-bundle-analyzer` | 可视化分析产物体积,找出大依赖 |
| `speed-measure-webpack-plugin` | 统计各 loader/plugin 耗时 |
| `webpack --profile --json > stats.json` | 导出构建统计数据 |
## Webpack 5 新特性补充
- **Module Federation**:跨应用共享模块,实现微前端,避免重复打包公共依赖
- **持久化缓存**:替代 DLL 方案,配置更简单
- **Asset Modules**:内置资源模块,替代 `url-loader`/`file-loader`
- **更好的 Tree Shaking**:支持嵌套的 Tree Shaking 和内部模块的 Tree Shaking
## 追问
### splitChunks 和动态 import 有什么区别?
`splitChunks` 是 Webpack 配置层面的自动优化,根据策略提取公共依赖(如 `node_modules` 中的库),开发者无需手动干预。动态 `import()` 是代码层面的手动分割,由开发者决定哪些模块按需加载。两者互补:splitChunks 处理共享依赖,动态 import 处理路由级懒加载。
### Tree-Shaking 为什么需要 ESM?
ESModule 的 `import/export` 是静态声明,在编译阶段就能确定模块的依赖关系和哪些导出被使用。CommonJS 的 `require` 是运行时调用,可以写在 `if` 语句、循环或函数中,打包工具无法在编译时判断哪些代码会被执行,因此无法安全移除未使用的代码。此外,需确保 Babel 不将 ESM 转译为 CJS(配置 `"modules": false`),并在 `package.json` 中声明 `"sideEffects": false`。
### webpack 的 cache 缓存了什么?为什么能加快构建?
缓存了每个模块的 loader 处理结果和编译产物。首次构建后写入磁盘(`node_modules/.cache/webpack`),二次构建时 Webpack 检查文件时间戳和内容 hash,只重新编译变化的模块及其依赖链上的模块,未变化的模块直接使用缓存。典型场景:修改一个组件文件,只重新编译该文件和引用它的文件,其余数千模块跳过,构建时间从 30s 降到 5s 以内。Webpack 5 的 `filesystem` 缓存相比 Webpack 4 的 `memory` 缓存,重启开发服务器后缓存不丢失。
### Module Federation 解决了什么问题?
多个独立构建的应用之间共享模块,避免重复打包相同依赖。典型场景:微前端架构中,主应用和子应用都依赖 React,传统方案各自打包 React,用 Module Federation 可以在运行时共享一份 React,减少总体加载量。核心配置是 `exposes`(暴露模块)和 `remotes`(消费远程模块),实现应用间的模块级复用。前端5月28日 03:23
JavaScript 如何实现自定义事件?## 基本用法:CustomEvent 三步走
自定义事件的核心流程就三步:创建 → 监听 → 触发。
```javascript
// 1. 创建事件
const event = new CustomEvent('user-login', {
detail: { userId: 42, role: 'admin' }, // 携带数据
bubbles: true, // 是否冒泡
cancelable: true // 能否被 preventDefault() 取消
});
// 2. 监听
element.addEventListener('user-login', (e) => {
console.log(e.detail); // { userId: 42, role: 'admin' }
});
// 3. 触发
element.dispatchEvent(event);
```
`detail` 是 CustomEvent 独有的字段,用于携带任意数据,和 `Event` 构造函数的唯一区别就在这里。`Event` 只能表示"某件事发生了",`CustomEvent` 还能告诉你"发生了什么"。
## 事件的冒泡与委托
`bubbles: true` 让事件沿 DOM 树向上冒泡,这意味着你可以在父元素上统一监听子元素发出的自定义事件:
```javascript
// 子元素派发
child.dispatchEvent(new CustomEvent('item-delete', {
detail: { id: 1 },
bubbles: true
}));
// 父元素委托监听
parent.addEventListener('item-delete', (e) => {
console.log('删除了:', e.detail.id);
e.stopPropagation(); // 阻止继续冒泡
});
```
这是事件委托在自定义事件上的直接应用——不需要给每个子元素绑定监听器,一个父元素就够了。
## 手写 EventEmitter:脱离 DOM 的事件系统
自定义事件依赖 DOM,但面试常考的是纯 JS 的发布订阅实现。核心就是一个事件名到回调数组的映射:
```javascript
class EventEmitter {
constructor() {
this.events = new Map();
}
on(event, handler) {
if (!this.events.has(event)) {
this.events.set(event, []);
}
this.events.get(event).push(handler);
return this; // 支持链式调用
}
off(event, handler) {
const handlers = this.events.get(event);
if (handlers) {
this.events.set(event, handlers.filter(h => h !== handler));
}
return this;
}
emit(event, ...args) {
const handlers = this.events.get(event);
if (handlers) {
handlers.forEach(h => h(...args));
}
return this;
}
once(event, handler) {
const wrapper = (...args) => {
handler(...args);
this.off(event, wrapper);
};
this.on(event, wrapper);
return this;
}
}
```
`once` 的实现要点:用包装函数代替原函数注册,触发后自动解绑。面试写到这里基本够用。
## 自定义事件 vs 发布订阅:何时选谁
| 维度 | CustomEvent | EventEmitter |
|------|-------------|--------------|
| 依赖 | DOM 元素 | 纯 JS,无依赖 |
| 事件传播 | 冒泡/捕获,支持委托 | 无传播机制 |
| 数据传递 | `detail` 字段 | 直接参数 |
| 内存管理 | 移除元素自动清理 | 需手动 `off` |
| Shadow DOM | `composed: true` 可穿透 | 不涉及 |
简单判断:需要 DOM 层级传播用 CustomEvent,纯数据流用 EventEmitter。
## 无框架组件通信
没有 Vue/React 时,自定义事件就是组件通信的基础设施:
```javascript
// 子组件:触发事件
class MyInput extends HTMLElement {
connectedCallback() {
this.addEventListener('input', () => {
this.dispatchEvent(new CustomEvent('value-change', {
detail: { value: this.value },
bubbles: true
}));
});
}
}
// 父组件:监听子组件事件
parent.querySelector('my-input')
.addEventListener('value-change', (e) => {
console.log(e.detail.value);
});
```
这就是 Web Components 通信的基本模式,也是浏览器原生组件化方案的核心机制。
## Shadow DOM 边界与 composed
Shadow DOM 默认隔离事件。`composed: true` 让 CustomEvent 穿透 Shadow DOM 边界,否则事件被封闭在 Shadow DOM 内部:
```javascript
// Shadow DOM 内部派发
shadow.dispatchEvent(new CustomEvent('inner-action', {
detail: { data: 1 },
bubbles: true,
composed: true // 关键:穿透 Shadow DOM
}));
```
`composedPath()` 方法可以查看事件经过的完整路径,调试 Shadow DOM 事件传播时很有用。
## 内存泄漏防护
自定义事件常见的内存陷阱:移除元素前没解绑监听器,或者闭包引用了大对象。
```javascript
// 错误示范:匿名函数无法解绑
el.addEventListener('my-event', (e) => { /* ... */ });
// 无法 off,因为匿名函数没有引用
// 正确做法:保存引用
const handler = (e) => { /* ... */ };
el.addEventListener('my-event', handler);
// 用完解绑
el.removeEventListener('my-event', handler);
```
更安全的方案是用 `AbortController`:
```javascript
const controller = new AbortController();
el.addEventListener('my-event', handler, {
signal: controller.signal
});
// 批量清理
controller.abort();
```
一个 `abort()` 就能清理同一 controller 下的所有监听器,比逐个 `removeEventListener` 方便得多。
## 追问
### 自定义事件和发布订阅有什么区别?
自定义事件绑定在 DOM 元素上,有冒泡/捕获机制、事件委托、`stopPropagation` 等 DOM 事件系统的完整能力。发布订阅(EventEmitter)是纯 JS 模式,没有 DOM 树概念。需要跨层级通信、事件委托时用自定义事件;需要纯数据流、完全与 DOM 无关时用发布订阅。
### 不用框架(Vue/React)怎么用自定义事件做组件通信?
父组件给子组件传一个 DOM 元素引用,子组件在这个元素上 `dispatchEvent(new CustomEvent('change', { detail: value }))`,父组件监听这个元素的 `change` 事件。这是 Web Components 的通信基础。也可以用自定义元素的属性变化回调 `observedAttributes` + `attributeChangedCallback` 配合事件派发做双向通信。
### 自定义事件可以跨 Shadow DOM 吗?
可以。`composed: true` 的 CustomEvent 会穿透 Shadow DOM 边界,不设置则封闭在 Shadow DOM 内部。用 `event.composedPath()` 可以查看事件传播的完整路径,这对调试 Shadow DOM 内的事件流向很有帮助。前端5月28日 03:23
什么是柯里化函数?JavaScript 中有哪些使用场景?## 什么是柯里化?
柯里化(Currying)是将一个接受多个参数的函数,转换成一系列每次只接受一个参数的函数。转换后的函数会逐步收集参数,直到参数齐全才执行原函数。
```javascript
// 普通函数
function add(a, b, c) {
return a + b + c;
}
add(1, 2, 3); // 6
// 柯里化后
function curryAdd(a) {
return function(b) {
return function(c) {
return a + b + c;
};
};
}
curryAdd(1)(2)(3); // 6
```
核心价值是**参数复用**和**延迟执行**——你可以先固定部分参数,得到一个更具体的函数,等剩余参数就位再调用。
## 手写通用 curry 函数
面试中最常考的是手写一个通用的 `curry`,它能将任意多参数函数转为柯里化形式:
```javascript
function curry(fn) {
return function curried(...args) {
// 收集的参数数量 >= 原函数参数数量,直接执行
if (args.length >= fn.length) {
return fn.apply(this, args);
}
// 否则返回新函数继续收集
return (...nextArgs) => curried.apply(this, args.concat(nextArgs));
};
}
```
验证:
```javascript
function sum(a, b, c) {
return a + b + c;
}
const curriedSum = curry(sum);
curriedSum(1)(2)(3); // 6
curriedSum(1, 2)(3); // 6 —— 支持一次传多个参数
curriedSum(1)(2, 3); // 6
```
关键点:利用 `fn.length` 判断原函数期望的参数个数,利用闭包在调用链中持续传递已收集的参数。
## 柯里化的实际使用场景
### 参数复用:创建预设函数
```javascript
function log(level, timestamp, message) {
console.log(`[${level}] ${timestamp}: ${message}`);
}
const curriedLog = curry(log);
const errorLog = curriedLog('ERROR'); // 预设 level
const errorLogNow = errorLog(Date.now()); // 预设 timestamp
errorLogNow('接口超时'); // [ERROR] 1748400000000: 接口超时
errorLogNow('数据解析失败'); // 复用前两个参数
```
### bind 方法的本质
`Function.prototype.bind` 就是偏函数应用——预设 `this` 和部分参数,返回新函数:
```javascript
const obj = { x: 42 };
function getX(extra) {
return this.x + extra;
}
const boundGetX = getX.bind(obj, 8); // 预设 this + 第一个参数
boundGetX(); // 50
```
### Redux 中间件的函数链
Redux 中间件签名 `store => next => action => {}` 就是三级柯里化:
```javascript
const logger = store => next => action => {
console.log('dispatching', action);
const result = next(action);
console.log('next state', store.getState());
return result;
};
```
第一层拿到 `store`,第二层拿到 `next`(下一个中间件),第三层处理 `action`。分层柯里化使得每层的职责清晰,也方便测试——你可以只传入部分参数来测试中间件的某一步。
### React 事件处理中的参数绑定
```jsx
// 方式一:bind 预设参数
<button onClick={handleClick.bind(null, item.id)}>删除</button>
// 方式二:高阶函数(本质也是柯里化)
const handleClick = (id) => (e) => {
e.stopPropagation();
deleteItem(id);
};
<button onClick={handleClick(item.id)}>删除</button>
```
## 柯里化与偏函数的区别
两者容易混淆,核心区别在于**参数接收方式**:
- **柯里化**:将函数转为一系列**一元函数**,每次只接受一个参数(`f(a)(b)(c)`)
- **偏函数**:预先填充**部分参数**,返回的函数仍可接受**多个参数**(`f(a, b)` → `f'(c, d)`)
```javascript
// 柯里化:每次一个参数
const curriedAdd = curry((a, b, c) => a + b + c);
curriedAdd(1)(2)(3); // 6
// 偏函数:一次传剩余参数
function partial(fn, ...presetArgs) {
return (...laterArgs) => fn(...presetArgs, ...laterArgs);
}
const addFive = partial((a, b) => a + b, 5);
addFive(3); // 8
```
`bind` 是偏函数,不是严格柯里化——因为 `bind` 返回的函数可以一次接收多个参数。
## 柯里化的局限与注意事项
**性能开销**:每次柯里化调用都会创建新的闭包和函数对象,在性能敏感场景(如高频事件处理、大循环内)应避免过度使用。
**只处理显式参数**:`fn.length` 无法识别默认参数、rest 参数。带默认值的函数柯里化后行为可能不符合预期:
```javascript
function greet(name, greeting = 'Hello') {
return `${greeting}, ${name}`;
}
greet.length; // 1,只有 name 是显式参数
curry(greet)('Alice'); // 直接返回 "Hello, Alice",跳过了 greeting 的柯里化步骤
```
**调试困难**:柯里化链越长,调用栈越深,错误堆栈的可读性越差。生产代码中应控制嵌套层级。
## 追问
### 柯里化和偏函数有什么区别?
柯里化将函数拆成一元函数链(`f(a)(b)(c)`),偏函数预设部分参数但返回函数仍可多参(`f(a, b)` → `f'(c, d)`)。`bind` 是偏函数不是严格柯里化。
### 能手写一个通用的 curry 函数吗?
利用闭包持续收集参数,用 `fn.length` 判断参数是否齐全,齐全则执行,否则返回新函数继续收集。见上方"手写通用 curry 函数"部分。
### 柯里化在 React 中有什么应用?
事件处理器的参数预绑定:`onClick={handleClick(id)}` 用高阶函数延迟执行;Hooks 中的自定义 Hook 参数分步传入也是类似思路。前端5月28日 03:22
JavaScript 继承方式有哪几种?各自的优缺点是什么?JavaScript 的继承方式有以下七种,按演进顺序理解更容易记住:原型链继承、构造函数继承、组合继承、原型式继承、寄生式继承、寄生组合继承、ES6 class extends。面试中重点掌握组合继承的问题和寄生组合继承的优化思路。
## 原型链继承
将子类的原型指向父类的实例:
```js
function Parent() {
this.name = 'parent';
this.colors = ['red', 'blue'];
}
Parent.prototype.getName = function() {
return this.name;
};
function Child() {}
Child.prototype = new Parent();
const child1 = new Child();
const child2 = new Child();
child1.colors.push('green');
console.log(child2.colors); // ['red', 'blue', 'green'] — 共享了!
```
核心问题:所有子实例共享父实例的引用类型属性,一个实例修改了 `colors`,其他实例也受影响。
## 构造函数继承
在子类构造函数中调用父类构造函数:
```js
function Parent() {
this.colors = ['red', 'blue'];
}
function Child() {
Parent.call(this); // 借用构造函数
}
const child1 = new Child();
child1.colors.push('green');
console.log(new Child().colors); // ['red', 'blue'] — 不共享了
```
解决了引用属性共享的问题,但新的问题出现了:方法只能定义在构造函数里,每次创建实例都会重新创建方法,无法复用。而且根本访问不到父类原型上的方法。
## 组合继承
把上面两种方式组合起来——属性用构造函数继承,方法用原型链继承:
```js
function Parent(name) {
this.name = name;
this.colors = ['red', 'blue'];
}
Parent.prototype.getName = function() {
return this.name;
};
function Child(name, age) {
Parent.call(this, name); // 第二次调用 Parent
this.age = age;
}
Child.prototype = new Parent(); // 第一次调用 Parent
Child.prototype.constructor = Child;
```
这是最常用的经典方案,但有一个效率问题:Parent 被调用了两次。第一次 `new Parent()` 在子类原型上创建了 `name` 和 `colors`,第二次 `Parent.call(this)` 又在实例上创建了同名的属性,原型上的那份其实是多余的。
## 原型式继承
不定义构造函数,直接基于已有对象创建新对象:
```js
function createObj(o) {
function F() {}
F.prototype = o;
return new F();
}
// ES5 标准化了这个模式
Object.create(proto, propertiesObject);
```
和原型链继承有同样的共享问题——引用类型的属性会被所有派生对象共享。适用于不需要单独创建构造函数、只想让一个对象类似于另一个对象的场景。
## 寄生式继承
在原型式继承的基础上,增强对象后返回:
```js
function createChild(original) {
const clone = Object.create(original);
clone.sayHi = function() {
console.log('hi');
};
return clone;
}
```
通过封装函数给对象添加能力,但和构造函数继承一样——每个实例都会重新创建方法,无法复用。
## 寄生组合继承
组合继承的优化版,也是目前公认的最优继承方案:
```js
function Parent(name) {
this.name = name;
this.colors = ['red', 'blue'];
}
Parent.prototype.getName = function() {
return this.name;
};
function Child(name, age) {
Parent.call(this, name); // 只调用一次 Parent
this.age = age;
}
// 关键:用 Object.create 代替 new Parent()
Child.prototype = Object.create(Parent.prototype);
Child.prototype.constructor = Child;
```
和组合继承相比,只改了一行:把 `Child.prototype = new Parent()` 换成了 `Child.prototype = Object.create(Parent.prototype)`。这一改做到了两件事:只调用一次父类构造函数,子类原型上不会出现多余的实例属性。ES6 的 `class extends` 经 Babel 编译后,底层就是寄生组合继承。
## ES6 class extends
```js
class Parent {
constructor(name) {
this.name = name;
this.colors = ['red', 'blue'];
}
getName() {
return this.name;
}
}
class Child extends Parent {
constructor(name, age) {
super(name); // 必须在 this 之前调用
this.age = age;
}
}
```
语法糖,底层仍然是原型链 + 寄生组合继承,但做了额外约束:
- 不用 `new` 调用会报错(内置 `new.target` 检查)
- 类方法不可枚举
- `extends` 同时继承静态属性和原型方法
- `super` 作为关键字比 `Parent.call(this)` 更安全,确保父类构造函数在访问 `this` 之前执行
## 七种方式对比
| 方式 | 引用属性共享 | 方法复用 | 父类调用次数 | 适用场景 |
|------|:---:|:---:|:---:|------|
| 原型链继承 | 共享 | 可以 | 1 | 不含引用属性的简单继承 |
| 构造函数继承 | 不共享 | 不可以 | 1 | 只需继承属性不需方法 |
| 组合继承 | 不共享 | 可以 | 2 | 传统项目,兼容性要求高 |
| 原型式继承 | 共享 | 可以 | 0 | 基于对象快速派生 |
| 寄生式继承 | 共享 | 不可以 | 0 | 给对象添加增强能力 |
| 寄生组合继承 | 不共享 | 可以 | 1 | ES5 环境下最优选择 |
| ES6 class extends | 不共享 | 可以 | 1 | 现代开发首选 |
## 追问
### 寄生组合继承为什么是最优方案?
只调用一次父类构造函数,避免了组合继承中子类原型上出现冗余实例属性的问题。原型链保持干净——子类原型是通过 `Object.create(Parent.prototype)` 创建的空对象,只包含指向父类原型的方法,不包含父类实例属性。这是 ES5 环境下兼顾效率和正确性的最佳方案。
### ES6 class 和寄生组合继承有什么本质区别?
`class` 不只是语法糖,它在语义层面做了额外约束:1) 不用 `new` 调用直接报错(`new.target` 检查);2) 类方法默认不可枚举;3) `extends` 同时继承静态属性,寄生组合继承做不到这点需要手动处理;4) `super` 是关键字而非函数调用,引擎保证父类构造函数在子类访问 `this` 之前执行,比 `Parent.call(this)` 更安全。
### Object.create 和 new 有什么区别?
`Object.create(proto)` 创建一个新对象并将其 `__proto__` 指向 `proto`,不执行任何构造函数逻辑。`new Constructor()` 会执行构造函数,将函数体内的 `this` 绑定到新对象,执行初始化逻辑后返回该对象。寄生组合继承用 `Object.create` 替代 `new Parent()`,目的就是避免多执行一次父类构造函数中的初始化逻辑。
### 为什么组合继承中父类被调用了两次?
第一次是 `Child.prototype = new Parent()`,这一步在子类原型上创建了 `name`、`colors` 等实例属性。第二次是 `Parent.call(this, name)`,在子类实例自身上又创建了同名属性。实例访问属性时先找自身再找原型,所以原型上那些属性虽然存在但永远不会被访问到,属于冗余创建。寄生组合继承用 `Object.create` 绕过了第一次调用,只保留实例属性的正确初始化。前端5月28日 03:21
JavaScript 如何使用 setTimeout 模拟实现 setInterval?直接用 `setInterval` 有一个经典问题:如果回调执行时间超过了间隔时间,回调会在事件队列中堆积。用 `setTimeout` 递归模拟能解决这个痛点——每次回调执行完再设置下一次定时器,确保上一次执行完毕才开始下一轮计时。
```javascript
function mySetInterval(fn, delay) {
let timer = null;
function loop() {
fn();
timer = setTimeout(loop, delay);
}
timer = setTimeout(loop, delay);
return () => clearTimeout(timer);
}
const cancel = mySetInterval(() => console.log('tick'), 1000);
// cancel(); 需要停止时调用
```
核心思路:在回调函数末尾递归调用 `setTimeout`,使得下一次定时器只有在上一次回调执行完毕后才会被注册。返回一个取消函数,调用时 `clearTimeout` 清除当前等待中的定时器,递归链就此中断。
## setInterval 的回调堆积问题
`setInterval(fn, 100)` 的行为是:每隔 100ms 向任务队列放入一个 `fn`,不管前一个 `fn` 是否执行完毕。如果 `fn` 执行需要 150ms:
- 100ms:第一个回调进入队列,开始执行
- 200ms:第二个回调进入队列(第一个还没执行完)
- 300ms:第三个回调进入队列
结果是回调不断排队,等当前任务执行完后,队列中的回调会连续执行,间隔远小于预期。这不是"跳过间隔",而是"堆积后连续执行"。
## setTimeout 模拟的优势与局限
**优势:**
- 不会回调堆积,每次执行完才开始计时下一轮
- 间隔更可控,实际间隔 ≈ delay + 回调执行时间
**局限:**
- 仍然不精确——`setTimeout` 只保证"至少延迟 delay 毫秒",受事件循环和主线程阻塞影响
- 如果回调本身执行时间很长,实际间隔会远大于设定值
## 带错误处理的增强实现
基础版本有个隐患:回调抛异常时,递归链中断,定时器静默停止。加上 `try-catch` 可以保证异常不会打断后续执行:
```javascript
function mySetIntervalSafe(fn, delay) {
let timer = null;
function loop() {
try {
fn();
} catch (e) {
console.error('定时器回调异常:', e);
}
timer = setTimeout(loop, delay);
}
timer = setTimeout(loop, delay);
return () => clearTimeout(timer);
}
```
## 支持 this 绑定和参数传递
原生 `setInterval` 支持 `setInterval(fn, delay, arg1, arg2)` 的参数传递和 `this` 绑定。补全这两个能力:
```javascript
function mySetIntervalFull(fn, delay, ...args) {
let timer = null;
const context = this;
function loop() {
try {
fn.apply(context, args);
} catch (e) {
console.error('定时器回调异常:', e);
}
timer = setTimeout(loop, delay);
}
timer = setTimeout(loop, delay);
return () => clearTimeout(timer);
}
// 用法:传递参数
mySetIntervalFull((name, count) => {
console.log(`${name}: ${count}`);
}, 1000, 'tick', 42);
```
## 追问
### 如果需要真正精确的定时循环怎么办?
`setTimeout` 模拟解决的是回调堆积问题,不能解决定时精度问题。精确计时需要其他方案:
- **`requestAnimationFrame` + `performance.now()`**:适合动画循环,每帧检查时间戳决定是否执行
- **Web Worker 定时器**:在独立线程运行,不受主线程阻塞影响
- **时间戳补偿**:记录预期执行时间和实际已执行次数,根据偏差动态调整下一轮 delay
### 能不能用 setInterval 模拟 setTimeout?
可以,执行一次后立即 `clearInterval`:
```javascript
function mySetTimeout(fn, delay) {
const id = setInterval(() => {
fn();
clearInterval(id);
}, delay);
}
```
但实际中没人这么做——`setTimeout` 本身就是更合适的 API,这个追问考查的是对两个定时器语义的理解。
### 递归 setTimeout 会不会导致栈溢出?
不会。`setTimeout` 的回调是宏任务,每次执行时调用栈已经清空,递归是通过事件循环调度的,不是真正的函数递归调用,调用栈深度始终为 1。前端5月28日 03:20
如何实现 JavaScript 的 bind 方法?`bind` 是 JavaScript 中显式绑定 `this` 的三种方式之一,与 `call`、`apply` 不同,`bind` 不会立即执行函数,而是返回一个绑定了 `this` 和预设参数的新函数。面试中考察 `bind` 的实现,重点在于:`this` 绑定、参数柯里化、`new` 调用兼容、原型链维护。
## 最简实现:绑定 this + 预设参数
`bind` 的核心行为只有两件事:绑定 `this` 上下文,预设部分参数。
```javascript
Function.prototype.myBind = function(context, ...args) {
const fn = this;
return function(...newArgs) {
return fn.apply(context, [...args, ...newArgs]);
};
};
```
这段代码保存了原函数引用 `fn`,返回的新函数在调用时将预设参数 `args` 和新传入参数 `newArgs` 合并,通过 `apply` 将 `this` 绑定到 `context` 执行。
验证:
```javascript
const obj = { name: 'Alice' };
function greet(greeting, punctuation) {
return `${greeting}, ${this.name}${punctuation}`;
}
const bound = greet.myBind(obj, 'Hello');
console.log(bound('!')); // "Hello, Alice!"
```
## 兼容 new 调用
上面的实现在 `new bound()` 时会出错——`new` 创建的实例本应作为 `this`,但被 `bind` 的 `context` 覆盖了。`bind` 的规范要求:`new` 的优先级高于 `bind` 绑定的 `this`。
```javascript
Function.prototype.myBind = function(context, ...args) {
const fn = this;
return function bound(...newArgs) {
// new 调用时 this instanceof bound 为 true,应使用 new 创建的实例
return fn.apply(
this instanceof bound ? this : context,
[...args, ...newArgs]
);
};
};
```
`this instanceof bound` 是判断是否通过 `new` 调用的关键:当 `new bound()` 执行时,`this` 指向新创建的实例对象,该实例的原型链上有 `bound.prototype`,因此 `instanceof` 返回 `true`。
验证:
```javascript
function Person(name) {
this.name = name;
}
const BoundPerson = Person.myBind({ name: 'ignored' });
const p = new BoundPerson('Bob');
console.log(p.name); // "Bob",而非 "ignored"
```
## 维护原型链
上面的实现仍有一个问题:通过 `new bound()` 创建的实例无法访问原函数原型上的属性和方法。
```javascript
Person.prototype.sayHi = function() {
return `Hi, I'm ${this.name}`;
};
const p2 = new BoundPerson('Carol');
console.log(p2.sayHi()); // TypeError: p2.sayHi is not a function
```
原因是 `bound` 函数的 `prototype` 并没有指向 `fn.prototype`。需要用一个空对象作为桥接:
```javascript
Function.prototype.myBind = function(context, ...args) {
if (typeof this !== 'function') {
throw new TypeError('Bind must be called on a function');
}
const fn = this;
const fNOP = function() {};
const bound = function(...newArgs) {
return fn.apply(
this instanceof bound ? this : context,
[...args, ...newArgs]
);
};
// 维护原型链:让 bound 的实例能访问原函数的原型
fNOP.prototype = fn.prototype;
bound.prototype = new fNOP();
return bound;
};
```
为什么用 `fNOP` 做中介而不是直接 `bound.prototype = fn.prototype`?因为直接赋值后,修改 `bound.prototype` 会同步影响 `fn.prototype`,这不符合预期。中介对象隔断了这种引用关系。
验证:
```javascript
const p3 = new BoundPerson('Dave');
console.log(p3.sayHi()); // "Hi, I'm Dave"
console.log(p3 instanceof Person); // true
```
## 边界情况与细节
### this 不是函数时抛出错误
原生 `bind` 在非函数值上调用会抛出 `TypeError`,实现中应当对齐这一行为,已在上面的完整实现中包含。
### 箭头函数的 bind 无效
箭头函数没有自己的 `this`,其 `this` 由外层词法作用域决定。对箭头函数调用 `bind` 虽然不会报错,但绑定的 `this` 不会生效。
```javascript
const arrow = () => this;
const boundArrow = arrow.myBind({ x: 1 });
console.log(boundArrow()); // 仍是外层 this,而非 { x: 1 }
```
### bind 返回的函数没有 prototype 属性
原生 `bind` 返回的绑定函数 `prototype` 为 `undefined`,不能作为构造函数使用(虽然通过 `new` 仍可调用,但原型链由内部 `[[Constructor]]` 处理)。这一点在深度追问时需要注意。
### 多次 bind 不会叠加 this
`bind` 返回的函数内部 `this` 已固定,再次 `bind` 无法改变。但预设参数会叠加。
```javascript
function fn(a, b, c) { return [a, b, c]; }
const bound1 = fn.bind({ x: 1 }, 1);
const bound2 = bound1.bind({ x: 2 }, 2);
console.log(bound2(3)); // [1, 2, 3],this 仍是 { x: 1 }
```
## 追问
### bind 返回的函数再用 new 调用会发生什么?
`new` 优先级高于 `bind`。`new` 创建的新对象会作为 `this`,`bind` 指定的 `this` 被忽略,但预设参数仍然生效。这是 ES5 规范明确规定的优先级:`new` > `bind` > `apply/call` > 默认绑定。
### call、apply、bind 三者区别?
- `call(thisArg, arg1, arg2, ...)`:逐个传参,立即执行
- `apply(thisArg, [arg1, arg2])`:数组传参,立即执行
- `bind(thisArg, arg1, arg2)`:预设 this 和参数,返回新函数,不立即执行
三者的核心差异在于执行时机和传参方式。实际开发中 `bind` 常用于事件回调保留上下文,`call/apply` 常用于借用方法或类数组转数组。
### 为什么要用 fNOP 做中介而不是直接赋值 prototype?
直接 `bound.prototype = fn.prototype` 会导致两者引用同一个对象,后续对 `bound.prototype` 的修改(如添加属性)会污染原函数的原型。`fNOP` 中介创建了一个以 `fn.prototype` 为原型的新对象,既保证 `instanceof` 正确,又隔离了修改影响。前端5月28日 03:16
如何判断 JS 文件是 Node.js 环境还是浏览器环境?看三个层面:模块语法、全局对象、环境 API,基本够用。
**模块语法**是最直观的线索——用了 `require`/`module.exports` 的基本是 Node.js(CommonJS),但这不是充分条件,因为浏览器端打包工具也能处理 CJS。反过来,纯 `import`/`export`(ESM)两边都能跑,不能用来判断。
**全局对象**更可靠:访问 `process`、`__dirname`、`__filename`、`global` 的是 Node.js;访问 `window`、`document`、`navigator`、`localStorage` 的是浏览器。但要注意,SSR 框架(Next.js)里两者可能同时存在。
**环境 API** 是最终判据:调了 `fs`、`child_process`、`net`、`crypto`(非 Web Crypto 子集)等 Node 核心模块的只能在 Node 跑;用了 DOM API(`document.querySelector`、`addEventListener`)、`WebSocket`、`WebRTC` 的只能在浏览器跑。
一个实用的判断函数:
```js
function detectEnv() {
if (typeof process !== 'undefined' && process.versions?.node) return 'node';
if (typeof window !== 'undefined' && typeof document !== 'undefined') return 'browser';
return 'unknown';
}
```
这个函数够用但不完美——Web Worker 里有 `self` 没有 `window`,Electron 里两个都有。
## 追问
### Webpack 的 target 配置和这个问题有什么关系?
`target: 'node'` 时 Webpack 不会 polyfill `fs`/`path` 等 Node 模块,`target: 'web'` 时会。如果源码用了 Node API 但打包目标是浏览器,构建会报错或打出空模块。所以看 `webpack.config.js` 的 `target` 也能反推这个文件的预期运行环境。
### TypeScript 怎么区分这两种环境的类型?
`tsconfig.json` 里 `"lib": ["DOM"]` 会注入浏览器类型(`document`、`window`),不加就没有。`"types": ["node"]` 会注入 Node 类型(`process`、`__dirname`)。编译时 TS 就能帮你揪出混用的情况——比如在 `lib` 不含 `DOM` 的配置下写了 `document.getElementById`,会直接报类型错误。
### 实际项目中踩过什么坑?
Next.js 里最常见——组件里直接用 `window` 做判断,SSR 阶段 `window` 不存在就炸了。正确做法是把浏览器 API 调用放进 `useEffect` 或 `typeof window !== 'undefined'` 守卫里。另一个坑:库的 `package.json` 没配 `exports` 字段,Node 和浏览器拿到同一个入口文件,结果浏览器端 import 了 Node 模块直接白屏。前端5月28日 03:13
如何用 JavaScript 广度优先遍历 DOM 树?用队列。从根节点入队,每次出队一个节点处理,再把它的子节点依次入队,循环到队列为空。
```javascript
function bfsTraverse(root) {
if (!root) return;
const queue = [root];
while (queue.length) {
const node = queue.shift();
console.log(node.tagName);
for (const child of node.children) {
queue.push(child);
}
}
}
```
面试官问这道题,考的是你对树形结构和队列的理解——DOM 是棵树,BFS 用队列逐层扩展,DFS 用栈先钻到底。别搞混数据结构就行。
## 追问
### BFS 和 DFS 在 DOM 上各自适合什么场景?
BFS 适合找离根近的节点——比如页面第一个 `<article>` 标签。DFS 适合找深层嵌套的元素,比如 `<head>` 里的 `<meta>`。日常用的 `querySelector` 浏览器内部走的就是 DFS 前序遍历。
### `shift()` 的性能问题怎么解决?
`Array.prototype.shift()` 是 O(n),每次都要移动剩余元素。节点多的时候拖慢整体。两个解法:
**索引队列**(推荐面试写法):
```javascript
function bfsTraverse(root) {
if (!root) return;
const queue = [root];
let head = 0;
while (head < queue.length) {
const node = queue[head++];
for (const child of node.children) queue.push(child);
}
}
```
**链表队列**:生产环境更稳,但面试写起来费时间,提到就行。
实际面试直接写 `shift()` 版本没问题,能顺嘴提到这个优化点就够了。
### `node.children` 和 `node.childNodes` 有什么区别?
`children` 只返回元素节点(Element),`childNodes` 返回所有节点包括文本节点、注释节点。BFS 遍历 DOM 树通常用 `children`,除非你明确要处理文本节点。
### 时间和空间复杂度?
时间 O(n),每个节点入队出队各一次。空间 O(w),w 是树的最大宽度——最宽那一层有多少个节点。完全二叉树最宽层约 n/2 个节点,所以最坏空间也是 O(n)。
### 真实项目里什么时候会手写 DOM 遍历?
很少。浏览器提供了 `querySelectorAll`、`TreeWalker`、`NodeIterator` 等原生 API,绝大多数场景不需要手写遍历。但面试考这个是在验证你对数据结构的基本功,就像问快排不是为了让你手写排序,而是看你懂不懂分治思想。前端5月28日 03:13
什么是 JS Bridge?WebView 和原生通信有哪几种方式?JS Bridge 是 WebView 里 JS 和原生 App 之间互相调用的通信桥梁,Hybrid App 开发中几乎离不开它。实现方式主要有三种:**URL Scheme 拦截**——JS 通过 iframe.src 发自定义 scheme URL(如 `myapp://camera/open`),原生在 `shouldOverrideUrlLoading` 中拦截解析并执行,只能 JS→原生单向通信,且连续调用会丢消息需要队列化;**注入 API 对象**——原生通过 `addJavascriptInterface`(Android)或 `WKScriptMessageHandler`(iOS)把对象注入 WebView 的 JS 上下文,JS 直接调用方法,支持双向通信和回调,是当前最主流的方式;**prompt/console 拦截**——JS 调 `window.prompt()`,原生重写 `onJsPrompt()` 拦截消息并解析执行,性能比 URL Scheme 好且能拿到返回值。实际项目普遍以注入 API 为主、prompt 拦截为辅的混合方案。
## 追问
### JS Bridge 的回调机制怎么实现的?
调用时生成唯一 callbackId,和参数一起发给原生。原生处理完通过 `evaluateJavaScript()` 调用 JS 侧全局回调函数,把 callbackId 和结果传回来。如果原生要主动推消息给 JS,也是通过 `evaluateJavaScript()` 调用 JS 挂载的全局监听函数。本质是异步 request-response + publish-subscribe 混合模式。
### URL Scheme 连续调用丢消息的原因和解决方案?
iframe.src 连续赋值时前一条 URL 还没被 `shouldOverrideUrlLoading` 拦截就被覆盖了。解法:JS 侧维护消息队列,每次只发一条,原生处理完通过回调通知 JS 发下一条。也可以改用 `prompt` 拦截,它是同步的不会丢。
### addJavascriptInterface 有什么安全漏洞?
Android 4.2 之前,注入对象的任何方法都能被 JS 通过反射拿到 Java 层的 Runtime,执行任意系统命令。4.2 后要求方法必须加 `@JavascriptInterface` 注解才暴露。iOS 的 WKWebView 用 messageHandler 机制只传消息不直接暴露方法,天生更安全。实际开发中还要对传入参数做白名单校验,防止 XSS 注入调用敏感接口。
### 小程序和 WebView JS Bridge 有什么区别?
小程序逻辑层(JS)和渲染层(WebView)跑在不同线程,所有通信都要经过 Native 中转序列化,`setData` 走的就是这条通道。普通 WebView JSBridge 是同进程内通信,性能好但渲染和逻辑互相阻塞。小程序的代价是频繁 `setData` 序列化开销大,所以官方建议合并数据、减少调用次数。
### 怎么设计一个通用的 JS Bridge SDK?
定义统一协议格式:`{ module, method, params, callbackId }`,JS 侧封装 `call(method, params, callback)` 和 `on(event, handler)`,原生侧按 module 注册 handler。兼容层先尝试注入 API,失败降级到 prompt 拦截,再失败降级 URL Scheme。加上消息队列防丢、超时重试、日志上报,就是一套生产级方案。