【前端学习】阿里前端面试题
阿里前端面试题
使用过的koa2中间件
中间件就是「在请求到达服务器、到服务器返回响应之间,做一些特定事情的函数」
例如:
- 当用户访问你的网站时,你可能需要先判断他有没有登录(这就是「认证中间件」要做的);
- 然后解析他提交的表单数据(这就是「解析请求体的中间件」要做的);
- 再根据他访问的 URL 找到对应的处理逻辑(这就是「路由中间件」要做的);
- 最后如果出错了,要统一处理错误(这就是「错误处理中间件」要做的)。
每个中间件负责一个环节,Koa2 会按顺序执行它们,最终完成一次请求的处理
常用 Koa2 中间件:
路由中间件:koa-router
作用:帮你「根据用户访问的 URL 地址,找到对应的处理代码」
例如:
- 用户访问 http://你的网站/home,你希望显示首页内容;
- 用户访问 http://你的网站/user,你希望显示用户信息。
如果没有 koa-router,你就得自己写一堆 if-else 判断 URL,比如:
if (ctx.url === '/home') { ... }
else if (ctx.url === '/user') { ... }
有了 koa-router,就可以直接这样写,更清晰:
router.get('/home', (ctx) => { ctx.body = '首页' })
router.get('/user', (ctx) => { ctx.body = '用户页' })
请求体解析:koa-bodyparser 和 koa-multer
作用:帮你「读取用户提交的数据」(比如表单、JSON 数据、文件)
- 用户在网页上填了表单(比如登录时输入账号密码),点击提交后,数据会发送到服务器。但这些数据在服务器端不是直接能读的,需要「解析」才能用。
- koa-bodyparser 专门解析「普通数据」(比如 JSON、表单文字),解析后你可以用 ctx.request.body 直接拿到数据,比如 ctx.request.body.username 就是用户输入的账号。
- koa-multer 专门解析「文件」(比如用户上传的图片、文档),因为文件数据格式特殊,koa-bodyparser 处理不了,所以需要它来单独处理。
静态资源服务:koa-static
作用:帮你「直接返回网页、图片、CSS/JS 文件等静态内容」
比如你的项目里有 index.html、style.css、logo.png 这些文件,用户访问 http://你的网站/logo.png 时,服务器需要把这个图片返回给浏览器。
如果没有 koa-static,你得手动写代码读取文件再返回,很麻烦。有了它,只需要指定这些文件存在的文件夹(比如 public 文件夹),它会自动处理这些请求,直接返回对应的文件。
模板引擎集成:koa-views
作用:帮你「把动态数据嵌入到 HTML 里,生成最终的网页」
比如你想在网页上显示用户的名字(这个名字是从数据库查出来的,动态变化),直接写死在 HTML 里肯定不行。这时候可以用模板引擎(比如 EJS),在 HTML 里留一个「占位符」<%= username %>,然后用 koa-views 把从数据库拿到的 username 填进去,生成最终的 HTML 给浏览器。
跨域处理:@koa/cors
作用:解决「不同网站之间不能随便互相调用接口」的问题
比如你的前端页面部署在 http://a.com,而后端接口部署在 http://b.com,当前端想调用后端接口时,浏览器会出于安全考虑阻止这个请求(这叫「跨域限制」)。
@koa/cors 就是告诉浏览器:「允许 a.com 来调用我的接口」,这样前端就能正常请求后端了。你可以配置允许哪些网站访问、允许哪些操作(比如 GET/POST)等。
日志记录:koa-logger 或 koa-log4js
作用:帮你「记录用户的访问情况」,方便调试和排查问题
例如:
- 用户访问了哪个 URL?
- 服务器返回了成功还是失败(状态码)?
- 这次请求花了多长时间?
koa-logger 会在控制台直接打印这些信息(适合开发时用);koa-log4js 更强大,可以把日志存到文件里(适合上线后用,方便后期查看历史记录)
错误处理:koa-onerror
作用:帮你「统一处理服务器运行时的错误」,避免网站崩溃
比如代码里有 bug(比如调用了不存在的变量),如果没处理,服务器可能直接崩溃,用户会看到一个奇怪的错误页面。
koa-onerror 可以捕获这些错误,然后返回一个友好的提示(比如「服务器出错了,请稍后再试」),同时不会让服务器崩溃。
身份认证:koa-jwt 或 koa-passport
作用:帮你「判断用户是否登录,以及登录的是谁」
比如有些页面(如个人中心)只有登录后才能访问,这时候就需要验证用户身份。
koa-jwt 基于「Token」验证:用户登录成功后,服务器发一个 Token 给前端,前端下次请求时带上这个 Token,koa-jwt 会验证 Token 是否有效,有效就允许访问。
koa-passport 更灵活,支持多种登录方式(比如账号密码登录、微信登录、GitHub 登录等)。
请求限流:koa-ratelimit
作用:防止「恶意用户频繁访问你的接口,导致服务器压力过大」
比如有人故意每秒发 1000 次请求攻击你的服务器,可能会把服务器搞垮。koa-ratelimit 可以限制:「同一个 IP 地址,1 分钟内最多只能访问 100 次」,超过就拒绝,保护服务器。
压缩响应:koa-compress
作用:「减小服务器返回给浏览器的数据大小」,让网页加载更快
比如服务器要返回一个很大的 HTML 或 JSON 数据,koa-compress 会把它压缩(类似压缩包),浏览器收到后再解压。这样传输的数据量变小,加载速度就快了。
总结:中间件的核心作用就是「分工合作」,每个中间件解决一个具体问题,让你不用重复写代码,能快速搭建服务器。
koa-body原理
koa-bodyparser与koa-body的区别:
- koa-bodyparser 是「轻量版」:只解析 普通文本类型的请求体(如 JSON、表单数据),不支持文件上传。
- koa-body 是「全能版」:不仅能解析普通文本请求体,还支持 文件上传(multipart/form-data 类型),功能更全面。
koa-body 是 Koa 中一个常用的请求体解析中间件,它的核心作用是 自动解析客户端发送到服务器的请求体数据(比如表单数据、JSON、文件等),并把解析后的数据挂载到 ctx.request.body 或 ctx.body 上,方便开发者直接使用。
当客户端(比如浏览器、手机 App)向服务器发送请求时,除了 URL、请求方法(GET/POST 等),还可能携带一些「数据」,这些数据就存放在「请求体」里。例如:登录时提交的账号密码(表单数据);前端通过 fetch 发送的 JSON 数据({ "name": "张三" });上传的图片、文件等。
这些数据不会直接显示在 URL 里,而是藏在请求的「body」部分,服务器需要通过特定的方式才能读取到。
Koa 框架本身并不自带请求体解析功能。如果没有中间件,开发者需要手动读取请求体数据,过程非常繁琐:客户端发送的请求体是「数据流」,服务器需要监听 data 事件,一点点拼接数据;不同类型的请求体(JSON、表单、文件)格式不同,解析规则也不同(比如 JSON 需要用 JSON.parse(),表单需要按 key=value&key2=value2 拆分);处理文件上传时,还需要处理二进制数据、临时存储等问题。
koa-body 的作用就是 帮我们自动完成这些繁琐的工作,兼容多种数据格式,最终给出一个直接可用的解析结果
koa-body 本质上是对更低层的解析工具(如 co-body、formidable 等)的封装,它的工作流程可以分为 3 步:
- 判断请求体的「类型」:客户端发送请求时,会在请求头 Content-Type 中说明请求体的格式,koa-body 首先会读取这个字段,判断数据类型:
- application/json:JSON 格式数据(比如 { "name": "张三" });
- application/x-www-form-urlencoded:普通表单数据(比如 name=张三&age=18);
- multipart/form-data:带文件的表单数据(比如上传图片、文档)。
- 根据类型选择对应的解析方式:针对不同的 Content-Type,koa-body 会调用不同的解析逻辑:
- 解析 JSON 或普通表单:用 co-body(Koa 生态中处理请求体的工具)来解析。它会监听请求的 data 事件,把二进制数据流拼接成字符串,再根据类型转换:
- JSON 类型:用 JSON.parse() 转成 JavaScript 对象;
- 表单类型:按 & 分割字符串,再把 key=value 转成对象(比如 name=张三&age=18 转成 { name: '张三', age: '18' })。
- 解析带文件的表单(multipart/form-data):用 formidable(专门处理文件上传的工具)来解析。因为文件是二进制数据,需要特殊处理:
- 把文件临时存到服务器的硬盘上(可配置存储路径);
- 解析出普通表单字段(如文件名、描述)和文件信息(如存储路径、大小、类型);
- 最终返回一个包含字段和文件的对象。
- 解析 JSON 或普通表单:用 co-body(Koa 生态中处理请求体的工具)来解析。它会监听请求的 data 事件,把二进制数据流拼接成字符串,再根据类型转换:
- 把解析结果挂载到 Koa 的 ctx 上:解析完成后,koa-body 会把结果挂载到 ctx.request.body 或 ctx.body 上(默认是 ctx.request.body),开发者直接通过这个属性就能获取数据:
- 解析 JSON 或普通表单后
console.log(ctx.request.body); { name: '张三', age: '18' }
解析带文件的表单后
console.log(ctx.request.body); { username: '张三' }(普通字段)
console.log(ctx.request.files); { avatar: { path: '/tmp/xxx.png', size: 1024 } }(文件信息)
总结:koa-body 的原理可以概括为:「读类型 → 拆数据 → 转格式 → 给结果」
对于新手来说,不用深究底层代码,只要知道它能帮我们解析 JSON、表单、文件,并通过 ctx.request.body 获取数据就足够日常使用了
介绍自己写过的中间件
在使用 Koa2 开发时,除了使用现成的中间件,有时也会根据业务需求自定义中间件来解决特定问题
自定义中间件的核心逻辑:无论实现什么功能,Koa2 中间件的本质都是一个「异步函数」,格式为:
async (ctx, next) => {
// 1. 请求到达时的逻辑(如校验、记录)
// 2. 调用 await next() 让后续中间件执行
// 3. 后续中间件执行完后的逻辑(如格式化响应、记录耗时)
}
核心是通过 ctx 访问请求 / 响应数据,通过 next() 控制中间件执行顺序,按需拦截或放行请求。
异步函数(async/await)是 JavaScript 中用于简化异步操作的语法糖,它让原本需要用回调函数(Callback)或 Promise 链式调用(.then())处理的异步代码,变得像同步代码一样直观易读。
在 JavaScript 中,代码通常是「同步执行」的(从上到下,执行完一行再执行下一行)。但有些操作需要「等待」(比如从服务器请求数据、读取本地文件、定时任务等),这些需要等待的操作就是「异步操作」
异步:你不用一直盯着,中间可以做别的事
在 async/await 出现之前,处理异步操作主要靠 回调函数 或 Promise,但它们有明显缺点:
- 回调函数多层嵌套时,会形成「回调地狱」(代码缩进越来越深,难以维护);
- Promise 的链式调用(.then().then())虽然比回调好,但多了之后依然不够直观。
async/await 的出现,就是为了让异步代码写起来像同步代码一样清晰
异步函数的基本用法:
- 声明异步函数:async 关键字:在函数前面加 async,这个函数就变成了「异步函数」。它有两个特点:
- 函数的返回值会自动包装成一个 Promise 对象(即使你 return 一个普通值,比如 return 123,实际返回的是 Promise.resolve(123));
- 函数内部可以使用 await 关键字。
- 声明一个异步函数
async function fetchData() {
return '数据获取成功'; 等价于 return Promise.resolve('数据获取成功')
}
调用异步函数(返回的是 Promise)
fetchData().then(result => {
console.log(result); 输出:数据获取成功
});
- 声明一个异步函数
- 暂停等待:await 关键字:await 只能用在 async 函数内部,它的作用是「暂停执行当前异步函数,等待后面的 Promise 完成,然后拿到结果再继续执行」。简单说:await 后面跟一个 Promise 对象,它会「等这个 Promise 成功(resolved)」,然后把结果取出来,赋值给变量。
- 模拟一个异步操作(比如从服务器请求数据)
function getCoffee() {
return new Promise((resolve) => {
setTimeout(() => { 模拟2秒后完成
resolve('热咖啡好了');
}, 2000);
});
}
用 async/await 处理
async function orderCoffee() {
console.log('开始点咖啡');
const coffee = await getCoffee(); 等待 getCoffee() 完成,拿到结果
这 2 秒内,JavaScript 不会卡住,可以去做其他事(比如处理别的代码),但 orderCoffee() 函数在这里「暂停等待」
console.log(coffee); 2秒后输出:热咖啡好了
console.log('喝咖啡');
}
orderCoffee();
执行顺序:
1. 立即输出 "开始点咖啡"
2. 等待2秒
3. 输出 "热咖啡好了"
4. 输出 "喝咖啡" - await 让异步操作看起来像同步流程,比用 .then() 更直观:
- 不用 async/await 的写法(Promise 链式调用)
function orderCoffee() {
console.log('开始点咖啡');
getCoffee().then(coffee => {
console.log(coffee);
console.log('喝咖啡');
});
}
- 处理错误:try/catch:如果 await 后面的 Promise 失败(rejected),会抛出一个错误,需要用 try/catch 捕获,否则会导致程序报错
- getCoffee() {
return new Promise((resolve, reject) => {
setTimeout(() => {
reject('咖啡机坏了,做不了咖啡'); // 模拟失败
}, 2000);
});
}
async function orderCoffee() {
try {
console.log('开始点咖啡');
const coffee = await getCoffee(); // 等待失败的 Promise
console.log(coffee); // 这里不会执行
} catch (error) {
console.log('出错了:', error); // 2秒后输出:出错了:咖啡机坏了,做不了咖啡
}
}
orderCoffee();
总结:
- async:声明一个异步函数,使其返回值自动变为 Promise
- await:在异步函数内部,暂停等待 Promise 完成,直接获取结果(避免了 .then() 链式调用)。
- 作用:让异步代码的写法更接近同步代码,解决了回调地狱和 Promise 链式调用的可读性问题。
对于前端开发来说,async/await 是处理接口请求、文件操作等异步场景的必备语法
接口访问频率限制中间件
作用:限制同一 IP 在短时间内对某个接口的访问次数,防止恶意请求(比 koa-ratelimit 更简单,适合特定场景)
核心思路:
- 用一个对象记录每个 IP 的访问时间和次数({ ip: { count: 次数, lastTime: 最后访问时间 } });
- 每次请求进来时,检查该 IP 的访问记录:如果单位时间内次数超过限制,返回 429 错误(请求过于频繁);否则更新次数和时间。
简化代码:
function rateLimitMiddleware(limit = 10, timeWindow = 60000) { // 默认1分钟最多10次
const ipMap = new Map(); // 存储IP访问记录
return async (ctx, next) => {
const clientIp = ctx.ip; // 获取客户端IP
const now = Date.now();
const record = ipMap.get(clientIp) || { count: 0, lastTime: now }; // 如果该IP之前没有记录,就新建一条:次数0,最后访问时间为现在
// 如果超过时间窗口,重置计数
if (now - record.lastTime > timeWindow) {
record.count = 0;
record.lastTime = now;
}
// 检查是否超过限制
if (record.count >= limit) {
ctx.status = 429;
ctx.body = { message: '请求过于频繁,请稍后再试' };
return; // 不执行后续中间件
}
// 更新计数,继续执行后续逻辑
record.count++;
ipMap.set(clientIp, record);
await next(); // 放行
};
}
// 使用:对所有接口生效,或单独对某个路由生效
app.use(rateLimitMiddleware(5, 30000)); // 30秒最多5次
接口响应格式化中间件
作用:统一接口返回格式,避免前端处理不同格式的响应(比如成功时都返回 { code: 200, data: ... },失败时返回 { code: 500, message: ... })
核心思路:
- 先执行后续中间件(让业务逻辑处理请求);
- 拦截响应结果,用统一格式包装后返回;
- 同时处理错误(比如业务中抛出的异常),统一错误格式。
简化代码:
function responseFormatMiddleware() {
return async (ctx, next) => {
try {
await next(); // 先执行后续中间件(业务逻辑)
// 如果业务逻辑已经设置了body,统一包装
ctx.body = {
code: 200, // 成功状态码
message: 'success',
data: ctx.body || null // 业务返回的数据
};
} catch (error) {
// 捕获错误,统一错误格式
ctx.status = 200; // 有时前端希望HTTP状态码都是200,用code区分错误
ctx.body = {
code: error.code || 500, // 自定义错误码
message: error.message || '服务器内部错误',
data: null
};
}
};
}
// 使用:放在所有路由中间件之前,确保所有响应都经过格式化
app.use(responseFormatMiddleware());
// 业务路由中直接返回数据即可,无需关心格式
router.get('/user', async (ctx) => {
ctx.body = { name: '张三' }; // 最终会被包装成 { code:200, data: { name: '张三' } }
});
- 成功时:{ code: 200, message: 'success', data: 业务数据 }
- 失败时:{ code: 错误码, message: '错误信息', data: null }
接口权限校验中间件
作用:验证用户是否有权限访问某个接口(比如只有管理员能访问 /admin 相关接口)
核心思路:
- 假设用户登录后,Token 解析出的信息(如角色)存放在 ctx.state.user 中;
- 配置需要权限的接口路径和对应的角色(比如 { '/admin': ['admin'] });
- 请求进来时,检查当前接口是否需要权限,如果需要则验证用户角色是否匹配,不匹配则返回 403 错误。
简化代码:
function authMiddleware(authConfig = {}) { // 配置:{ 接口路径: 允许的角色数组 }
return async (ctx, next) => {
const { url } = ctx;
// 找到当前接口对应的权限配置(比如 /admin 对应 ['admin'])
const requiredRoles = Object.keys(authConfig).find(path => url.startsWith(path))
? authConfig[Object.keys(authConfig).find(path => url.startsWith(path))]
: null;
// 如果接口不需要权限,直接放行
if (!requiredRoles) {
await next();
return;
}
// 检查用户是否登录(假设未登录时 ctx.state.user 不存在)
if (!ctx.state.user) {
ctx.status = 401;
ctx.body = { message: '请先登录' };
return;
}
// 检查用户角色是否在允许的范围内
const userRole = ctx.state.user.role;
if (!requiredRoles.includes(userRole)) {
ctx.status = 403;
ctx.body = { message: '没有权限访问' };
return;
}
// 权限通过,执行后续逻辑
await next();
};
}
// 使用:配置需要权限的接口
app.use(authMiddleware({
'/admin': ['admin'], // /admin 开头的接口只有 admin 角色能访问
'/user/delete': ['admin', 'editor'] // 删除用户需要 admin 或 editor 角色
}));
有没有涉及到Cluster
在自定义 Koa 中间件时,我确实处理过涉及 Cluster(集群模式) 的场景,主要是为了解决「单机限流中间件在集群环境下失效」的问题。
Node.js 是单线程的,为了充分利用多核 CPU,Node 提供了 cluster 模块,可以启动多个子进程(worker)共享同一个端口。比如 4 核 CPU 可以启动 4 个子进程,同时处理请求。
但问题在于:子进程之间内存不共享。如果我们写的中间件依赖「进程内的内存数据」(比如之前提到的「限流中间件」用 ipMap 记录 IP 访问次数),在 Cluster 模式下会失效:
- 假设启动了 2 个子进程,同一个 IP 的请求可能被分配到不同的子进程;
- 每个子进程的 ipMap 是独立的,导致「总请求次数被多进程拆分计算」,限流规则形同虚设(比如限制 10 次 / 分钟,实际可能达到 20 次,因为两个进程各记 10 次)
我处理过的 Cluster 相关中间件:「分布式限流中间件」
为了解决 Cluster 模式下的限流问题,我基于「共享存储」改造了限流中间件,核心思路是:让所有子进程共享同一份访问记录(不再依赖单个进程的内存)
实现方案:用 Redis 作为共享存储(Redis 是单线程的,支持原子操作,适合计数场景),每个子进程都从 Redis 读写 IP 访问记录。
核心代码:
const redis = require('redis');
const { promisify } = require('util');
// 创建 Redis 客户端(所有子进程共享同一个 Redis 服务)
const client = redis.createClient({
host: 'localhost',
port: 6379
});
const getAsync = promisify(client.get).bind(client);
const setAsync = promisify(client.set).bind(client);
const incrAsync = promisify(client.incr).bind(client);
function clusterSafeRateLimit(limit = 10, timeWindow = 60000) {
return async (ctx, next) => {
const clientIp = ctx.ip;
const redisKey = `rate_limit:${clientIp}`; // Redis 中存储的 key
// 1. 检查 Redis 中是否已有该 IP 的记录
let count = await getAsync(redisKey);
if (!count) {
// 首次访问:初始化计数为 1,并设置过期时间(等于时间窗口)
await setAsync(redisKey, 1, 'EX', timeWindow / 1000); // EX 单位是秒
count = 1;
} else {
// 非首次访问:计数 +1
count = await incrAsync(redisKey);
}
// 2. 检查是否超过限制
if (Number(count) > limit) {
ctx.status = 429;
ctx.body = { message: '请求过于频繁,请稍后再试' };
return;
}
// 3. 放行
await next();
};
}
