首页 / 文章

Node.js与Express面试准备:70道附精准答案的题目。

对70道Node.js与Express面试题的系统性梳理,涵盖事件循环、流处理、中间件、安全性及扩展性等内容,并对常见答案进行修正。

发布者:REACTAPP.TOP阅读约 21 分钟4861 词

来源: Node.js & Express.js Interview Guide

针对 Node.js 岗位的后端面试通常会围绕几个核心主题:单线程运行时如何处理数千个连接、事件循环的实际作用,以及 Express 如何将原始的 HTTP 服务器转化为应用程序。那些死记硬背的简短答案在面对追问时就会失效,因此本指南按主题将 70 个常见问题分类,并为每个答案提供充分的解释,让你能够有理有据地作答。如果常见的标准答案已经过时或不准确,也会注明修正内容,因为能否察觉这些细节往往决定了候选人是从众准备还是真正具备实力。

Node.js 基础

问题 1:什么是 Node.js?

这是一种在浏览器外部执行 JavaScript 的运行时环境。它内置了 Google 的 V8 引擎,并提供了用于处理文件、网络和进程的 API。其架构为事件驱动型,具备非阻塞式的输入/输出机制,因此体积小巧,非常适合处理大量数据及实时服务。

问题2:Node.js 是单线程的吗?

你的 JavaScript 代码在一条主线程上运行,这条线程负责驱动事件循环。在底层,libuv 会为那些没有合适异步操作系统接口的任务维护一个线程池,这类任务包括文件系统操作、dns.lookup、某些 crypto 函数以及 zlib 操作。所以准确的答案是:你的代码运行在单线程上,而系统层面则是多线程的。

问题3:为什么 Node.js 速度这么快?

  • V8 会将 JavaScript 编译为机器码而非进行解释执行。
  • 输入和输出均为异步且非阻塞的。
  • 事件循环能够在无需为每个请求创建线程的情况下实现并发处理。
  • 该模型在处理受网络或磁盘限制的任务时表现优异,但在处理依赖CPU的计算任务时则较为薄弱。
  • Q4. 同步代码与异步代码

    同步操作会在下一行代码执行之前完成,从而阻塞其后的一切操作。而异步操作,如文件读取或数据库查询,则会在启动后被交由后台处理;程序会继续执行,随后通过回调函数或承诺对象返回结果。在下面的同步版本中,日志会等待整个读取过程完成(并且由于没有指定编码方式,因此直接打印Buffer对象):

    // Synchronous
    const data = fs.readFileSync('file.txt');
    console.log(data);
    

    在异步版本中,由于文件读取会在事件循环的后续轮次中完成,因此最后的日志行会先于文件内容被打印出来:

    // Asynchronous
    fs.readFile('file.txt', (err, data) => {
      console.log(data);
    });
    console.log("This runs first!");
    

    Q5. 什么是npm?

    npm 是随 Node.js 提供的包管理器。你可以用它来安装、发布和管理依赖项,它会将这些依赖项以及脚本记录在 package.json 中。

    Q6. package.json 与 package-lock.json 的区别

    package.json 用于描述项目:包括项目名称、版本、入口点、脚本以及该项目所接受的依赖范围。package-lock.json 则会记录下每个已安装包的精确版本信息,包括间接依赖在内的所有版本,这样无论在何种机器或 CI 环境中运行,都能安装出完全一致的依赖结构。两者都应提交到版本控制中。

    Q7. 应该了解哪些核心模块?

    常见的内置模块包括 fs、path、http 和 https、os、events、stream、buffer、crypto、util、url、querystring(现已被视为过时,应使用 URLSearchParams)、child_process、cluster 以及 zlib。

    Q8. require() 与 import 的区别

    CommonJS 和 ES 模块之间的差异经常被提及:

    • require 是同步加载的;而 ES 模块则是异步加载的,同时还提供了返回承诺的动态 import() 语句。
    • .js 文件默认使用 CommonJS;ES 模块则需要在 package.json 中指定 "type": "module",或者使用 .mjs 扩展名。
  • CommonJS 使用 module.exports 进行导出;ES 模块则使用 export 和 export default。
  • require 可以在任何地方调用,包括条件语句内部;而静态的 import 声明会被提升,必须位于顶层。
  • Q9. module.exports 与 exports 的区别

    exports 最初指的是与 module.exports 相同的对象。向其中添加属性是可行的,但赋值新值(exports = {...})仅会重新绑定局部变量,模块仍然会导出原来的对象。若要替换整个导出内容,则需对 module.exports 进行赋值。

    Q10. 什么是 REPL?

    REPL 是 Read、Eval、Print、Loop 的缩写。不带参数运行 node 会打开一个交互式 shell,它会计算你输入的每个表达式并输出结果,非常适合快速进行实验。

    事件循环与异步模式

    问题11:什么是事件循环?

    它是一种让单个 JavaScript 线程能够执行非阻塞式输入/输出的机制。当调用栈为空时,循环会从队列中取出下一个可执行的回调并加以运行。关于它与 libuv 之间交互的更深入讲解,请参阅我们关于 libuv、事件循环和线程池的文章。

    问题12:事件循环有哪些阶段?

    1. 定时器:setTimeout 和 setInterval 设置的定时回调已到达执行时间。
  • 待处理回调:在上一轮迭代中延迟的某些系统操作回调。
  • 空闲与准备阶段:内部维护工作。
  • 轮询阶段:收集新完成的输入/输出事件并执行其对应的回调。
  • 检查阶段:setImmediate() 触发的回调。
  • 关闭回调:如 socket.on('close') 这样的处理函数。
  • 微任务队列(先处理 process.nextTick,再处理 Promise 相关任务)会在每个单独的回调执行完毕后清空,而不仅仅是在不同阶段之间。自 Node 11 版本以来一直如此,这也是一个常见的疑问。

    Q13. nextTick、Promise、setTimeout 与 setImmediate

    • process.nextTick() 触发的回调会在当前操作一完成就立即执行,优先于 Promise 相关的微任务。
  • .then 等承诺式回调会在 nextTick 队列为空时执行,但仍在循环继续之前。
  • setTimeout(fn, 0) 会在定时器阶段,即其最小延迟时间过后立即执行。
  • setImmediate() 会在检查阶段执行,也就是同一轮循环的轮询阶段之后。
  • 该脚本会触发这四种回调类型:

    console.log('start');
    setTimeout(() => console.log('setTimeout'), 0);
    setImmediate(() => console.log('setImmediate'));
    process.nextTick(() => console.log('nextTick'));
    Promise.resolve().then(() => console.log('promise'));
    console.log('end');
    

    预期输出中首先显示同步日志,然后是微任务。从主模块调度时,这两个宏任务的出现顺序可能不同,这取决于循环开始时定时器阈值是否已过;而在文件或套接字回调中,setImmediate 总是会优先执行:

    // Output:
    // start
    // end
    // nextTick
    // promise
    // setTimeout (or setImmediate — order can vary if not inside I/O)
    // setImmediate (or setTimeout)
    

    Q14. 回调函数与回调地狱

    回调函数是您传递给某个操作的函数,以便在操作完成时被调用。当每个步骤都依赖于前一个步骤时,回调函数会越来越深入地嵌套,从而形成所谓的“回调地狱”结构,这种结构不仅难以阅读,而且在处理错误时也会十分麻烦:

    getUser(id, (user) => {
      getPosts(user, (posts) => {
        getComments(posts, (comments) => {
          // deeply nested - hard to manage
        });
      });
    });
    

    Promise以及async/await机制可以简化这种嵌套结构。

    Q15. 什么是Promise?

    Promise是一个代表异步操作未来结果的对象。它要么处于待处理状态,要么带着某个值被满足,要么因为某种原因被拒绝;一旦状态确定,它就不会再改变。您可以通过.then和.catch来添加处理函数:

    const promise = new Promise((resolve, reject) => {
      if (success) resolve(data);
      else reject(error);
    });
    promise.then(data => console.log(data)).catch(err => console.error(err));
    

    Q16. 什么是async/await?

    这是一种基于 Promise 的语法,使得异步代码能够从上到下依次执行。async 函数总是返回一个 Promise,而 await 会暂停该函数,直到所等待的 Promise 解决,若发生拒绝则会产生异常,可以被捕获:

    async function getUser() {
      try {
        const res = await fetch('/api/user');
        const data = await res.json();
        return data;
      } catch (err) {
        console.error(err);
      }
    }
    

    请注意,此示例仅记录错误并隐式返回 undefined,因此调用者无法区分失败与空结果。通常重新抛出异常是更好的选择。

    Q17. Promise 组合器

    • Promise.all() 在所有输入都解决时才会解决,一旦有任何输入拒绝就会立即拒绝。
    • Promise.race() 会以最先解决的输入结果为准,无论是解决还是拒绝。
    • Promise.allSettled() 会等待所有输入并报告各自的最终状态。
  • Promise.any() 会在第一个满足条件的输入出现时立即执行,只有当所有输入都失败时才会以 AggregateError 的形式拒绝。
  • Q18. 阻塞式与非阻塞式

    像 fs.readFileSync 这样的阻塞式代码会在执行完成前阻止其他所有操作。而像 fs.readFile 这样的非阻塞式代码则会在开始处理任务的同时让程序继续运行。在服务器上,一次阻塞调用会导致所有正在处理的请求停滞。

    流、缓冲区与事件

    Q19. 什么是流?

    流以分块的方式逐步处理数据,而非将所有数据存储在内存中。对于大文件、媒体内容及网络流量来说,流是理想的处理工具。流共有四种类型:

    • 可读流,例如 fs.createReadStream
    • 可写流,例如 fs.createWriteStream
    • 双向模式,可同时读取和写入,例如 TCP 套接字
    • 转换模式,一种在数据传输过程中对其进行处理的双向流,例如 zlib.createGzip

    Q20. 什么是缓冲区?

    缓冲区在 V8 堆之外分配的内存中存储原始二进制数据。凡是数据以字节形式存在而非字符串或对象的地方都会用到它:流、文件、套接字。从字符串创建缓冲区时会以十六进制形式显示底层的字节值:

    const buf = Buffer.from('Hello');
    console.log(buf); // <Buffer 48 65 6c 6c 6f>
    

    Q21. 什么是 EventEmitter?

    events 模块中的 EventEmitter 实现了观察者(发布/订阅)模式,Node 的许多 API 都基于此模式构建。你可以这样创建一个实例:

    const EventEmitter = require('events');
    const emitter = new EventEmitter();
    

    然后使用 on 方法注册监听器,再用 emit 方法触发它们,并可传递任意参数:

    emitter.on('greet', (name) => console.log(`Hello, ${name}`));
    emitter.emit('greet', 'John');
    

    当调用 emit 时,监听器会按照注册顺序同步执行。

    Q22. 如何处理大量数据而不耗尽内存?

    使用流:分块读取数据,并将可读流导入到可写流中,这样无论文件大小如何,内存使用量都能基本保持不变:

    fs.createReadStream('input.txt').pipe(fs.createWriteStream('output.txt'));
    

    在生产环境中,stream.pipeline 比 .pipe() 更为合适,因为它能够传递错误并清理链式结构中的所有流。

    更深入的运行时概念

    Q23. process.nextTick() 与 setImmediate() 的详细对比

    nextTick 回调会在循环继续执行之前运行,甚至先于 Promise 的微任务。而 setImmediate 回调则要等待轮询阶段之后的检查阶段。由于每个 nextTick 都可以再次调度下一个回调,因此递归链可能会使循环陷入停滞状态,导致待处理的读写操作永远无法得到处理。这种故障模式在 how process.nextTick silently starves the event loop 中有详细阐述。

    Q24. 什么是集群模式?为何要使用它?

    单个 Node 进程仅能使用一个核心来运行 JavaScript 代码。cluster 模块会创建多个工作进程,每个进程都有独立的事件循环和内存,同时这些进程可以共享一个服务器端口,从而使机器上的所有核心都能处理请求。使用方式如下:

    const cluster = require('cluster');
    const os = require('os');
    

    然后从主进程为每个CPU fork出一个工作进程,同时这些工作进程负责加载服务器:

    if (cluster.isMaster) {
      os.cpus().forEach(() => cluster.fork());
    } else {
      require('./server');
    }
    

    cluster.isMaster仍然可用,但已被弃用,建议使用cluster.isPrimary;在新代码中应使用后者。

    Q25. cluster与worker_threads的区别

    • cluster会创建具有独立内存的单独进程,非常适合扩展HTTP服务器。
    • worker_threads则在一个进程内创建线程,它们可以通过SharedArrayBuffer或数据缓冲区进行内存共享,因此适合处理如图像处理这类需要大量CPU算力的任务。

    Q26. 如何处理需要大量CPU算力的任务?

    CPU密集型代码会阻塞事件循环,因此应将其移至其他地方:放到worker_threads中、子进程中,或通过队列和后台工作进程完全移出服务范围。有关实现方案,请参阅使用worker threads和池来分担CPU密集型任务。

    Q27. 什么是中间件?

    中间件是指位于接收请求与发送响应之间的代码,它能够对请求进行检测、转换、验证或记录。如下文关于Express的说明所示,Express的整体设计都是基于这一理念构建的。

    Q28. 什么是子进程?

    child_process模块可在独立的进程中运行外部程序或其他Node脚本。其中有四个函数较为重要:

    • spawn()会以流式方式输出结果,因此非常适合处理大量输出数据。
  • exec() 会通过 shell 执行并缓存输出,适用于生成少量结果的情况。
  • execFile() 会直接执行文件而无需通过 shell,因此在处理不可信的参数时更为安全。
  • fork() 是专为 Node 模块设计的 spawn 变体,可建立用于消息传递的 IPC 通道。
  • Q29. 什么是 libuv?

    libuv 是提供 Node 事件循环、异步输入/输出及线程池功能的 C 库,它能够隐藏 Linux、macOS 和 Windows 之间事件通知机制的差异。

    Q30. setTimeout 与 setInterval 的区别

    setTimeout(fn, delay) 会在至少经过 delay 毫秒后执行一次 fn。而 setInterval(fn, delay) 会以该间隔反复执行它,直到调用 clearInterval() 为止。

    Q31. 如何处理未捕获的异常和未处理的拒绝

    针对这两种情况都有进程级的处理机制。对于未被捕获的同步异常,应记录日志后退出,因为进程可能处于不一致状态:

    process.on('uncaughtException', (err) => {
      console.error('Uncaught Exception:', err);
      process.exit(1); // recommended: exit after cleanup
    });
    

    对于没有处理函数的拒绝承诺:

    process.on('unhandledRejection', (reason, promise) => {
      console.error('Unhandled Rejection:', reason);
    });
    

    从 Node 15 开始,未处理的拒绝默认会导致进程崩溃,因此注册该监听器可以改变这一行为。应将这两种情况都视为最后的日志记录手段,而非控制流;应在代码本身中使用 try/catch 和 .catch()。

    Q32. 什么是内存泄漏?如何发现它?

    内存泄漏指的是在不再需要时仍可访问的内存:不断增长的全球缓存、从未被移除的定时器或监听器,以及持有大型对象的闭包。可通过 --inspect 标志与 Chrome DevTools 的堆快照,或是 heapdump 和 clinic.js 等工具进行诊断,并随时间对比这些快照。

    Q33. __dirname 与 __filename 的区别

    __dirname 表示包含当前模块的文件夹的绝对路径;__filename 则表示文件本身的绝对路径。这两者仅存在于 CommonJS 中。在 ES 模块中,可通过 import.meta.url 获取它们,或在较新的 Node 版本中使用 import.meta.dirname 和 import.meta.filename。

    Q34. 为何要使用路径模块?

    path 通过 path.join、path.resolve、path.basename 和 path.extname 等函数以跨平台的方式构建和解析文件路径,因此你无需手动拼接字符串,也不用担心 Windows 的反斜杠与 Unix 的正斜杠带来的问题。

    Q35. fs.readFile 与 fs.createReadStream 的区别

    fs.readFile 会在回调之前将整个文件加载到内存中,这对大文件来说存在风险。fs.createReadStream 则以数据块的形式传输文件,从而保持内存使用量稳定,并允许立即开始处理。

    Express 基础知识

    Q36. 什么是 Express?

    Express 是建立在 Node 的 http 模块之上的一个极简且中立的 Web 框架。它提供了路由功能、中间件管道以及模板引擎集成,从而省去了大量原始请求处理中的冗余代码。

    Q37. 基本的 Express 服务器

    创建一个应用对象:

    const express = require('express');
    const app = express();
    

    然后注册路由并开始监听:

    app.get('/', (req, res) => {
      res.send('Hello World');
    });app.listen(3000, () => console.log('Server running on port 3000'));
    

    Q38. Express 中的中间件是什么?

    Express 中间件是一个接收 req、res 和 next 的函数。它可以执行任意代码,修改请求或响应,通过响应结束处理流程,或者调用 next() 将控制权交给下一个函数。请求日志记录器就是典型的例子:

    app.use((req, res, next) => {
      console.log(`${req.method} ${req.url}`);
      next();
    });
    

    如果中间件既不响应也不调用 next(),请求就会挂起。

    Q39. Express 中间件的种类

    1. 应用层中间件,通过 app.use() 或 app.METHOD() 添加。
    2. 路由层中间件,添加到 express.Router() 实例上。
  • 错误处理,通过其四个参数 (err, req, res, next) 来识别。
  • 内置模块,如 express.json() 和 express.static()。
  • 第三方模块,如 cors、morgan 和 helmet。
  • Q40. 路由是如何工作的?

    路由将 HTTP 方法与路径及处理函数关联起来。典型的 CRUD 资源结构如下:

    app.get('/users', (req, res) => { /* get all users */ });
    app.post('/users', (req, res) => { /* create user */ });
    app.put('/users/:id', (req, res) => { /* update user */ });
    app.delete('/users/:id', (req, res) => { /* delete user */ });
    

    Q41. express.Router() 是什么?

    路由器是一个拥有自身路由和中间件的独立小型应用,可被挂载到某个路径上。它有助于按功能对大型应用进行组织。可在单独的模块中定义路由:

    // userRoutes.js
    const router = require('express').Router();
    router.get('/', (req, res) => res.send('All users'));
    module.exports = router;
    

    然后将其挂载在主应用中,此时其 / 路径会变为 /users:

    // app.js
    app.use('/users', require('./userRoutes'));
    

    Q42. app.use() 与 app.get() 等方法的区别是什么?

    app.use() 可匹配所有 HTTP 方法以及以指定前缀开头的任意路径,因此可用于注册中间件和路由。而 app.get()、app.post() 等方法则仅匹配特定的方法及完整的路由模式。

    Q43. req.params、req.query 和 req.body

    • req.params 用于存储带名称的路由片段:/users/:id 会得到 req.params.id。
    • req.query 用于存储查询字符串中的值:/users?age=25 会得到 req.query.age,该值始终为字符串类型。
    • req.body 用于存储 POST 和 PUT 等方法对应的已解析请求数据,除非先使用了 express.json() 这样的请求体解析中间件,否则其值为未定义状态。

    Q44. JSON 与表单数据的解析

    有两个内置解析器可覆盖大多数情况,一个用于处理 JSON 数据体,另一个用于处理 URL 编码的 HTML 表单:

    app.use(express.json());               // parses application/json
    app.use(express.urlencoded({ extended: true })); // parses form data
    

    Q45. express.static() 是什么?

    它是一个内置中间件,用于从指定目录提供图片、样式表、脚本文件以及 HTML 文件:

    app.use(express.static('public'));
    

    Express 的中间层机制

    Q46. 错误处理

    错误由带有四个参数的中间件处理,这些中间件会在所有路由之后被注册:

    app.use((err, req, res, next) => {
      console.error(err.stack);
      res.status(500).json({ message: 'Something went wrong' });
    });
    

    在路由和中间件内部,可通过 next(err) 将错误传递给该中间件:

    app.get('/user/:id', async (req, res, next) => {
      try {
        const user = await getUser(req.params.id);
        res.json(user);
      } catch (err) {
        next(err); // passed to error-handling middleware
      }
    });
    

    在生产环境中,避免向客户端发送 err.stack 或内部错误信息。

    Q47. 404 错误处理

    Express 没有内置的 JSON 格式 404 响应处理机制。需在每个路由之后添加一个通用中间件;如果请求到达该中间件且没有其他路由匹配,则由它处理:

    app.use((req, res) => {
      res.status(404).json({ message: 'Route not found' });
    });
    

    Q48. CORS 与 cors 包

    Cross-Origin Resource Sharing 是浏览器中用于决定来自不同源的页面是否可以读取另一来源响应的机制。浏览器会强制执行这一规则,而服务器则通过响应头来选择是否启用该功能,cors 包会为你设置这些响应头:

    const cors = require('cors');
    app.use(cors()); // allow all origins
    app.use(cors({ origin: 'https://example.com' })); // restrict origin
    

    代码片段展示了两种选择:允许所有源或仅允许某个特定源;只需使用其中一种,不能同时使用两种,因为宽松的设置会优先生效。

    Q49. 会话与 Cookie

    加载 Cookie 和会话中间件:

    const cookieParser = require('cookie-parser');
    const session = require('express-session');
    

    然后使用签名密钥及相关选项为它们注册:

    app.use(cookieParser());
    app.use(session({
      secret: 'mySecretKey',
      resave: false,
      saveUninitialized: true
    }));
    

    应将密钥保存在环境变量中而非代码中,并考虑设置 saveUninitialized: false 以避免为匿名访问者创建会话。默认的内存存储方式并不适合生产环境使用。

    Q50. JWT 认证

    导入库:

    const jwt = require('jsonwebtoken');
    

    下面的代码在用户登录时会对包含用户ID的令牌进行签名,使用中间件保护路由——该中间件会从Authorization请求头中提取令牌并对其进行验证,然后将解码后的数据附加到req.user上:

    // Generate token on login
    const token = jwt.sign({ id: user.id }, 'secretKey', { expiresIn: '1h' });// Middleware to protect routes
    function auth(req, res, next) {
      const token = req.headers.authorization?.split(' ')[1];
      if (!token) return res.status(401).json({ message: 'No token' });
      try {
        req.user = jwt.verify(token, 'secretKey');
        next();
      } catch (err) {
        res.status(403).json({ message: 'Invalid token' });
      }
    }app.get('/protected', auth, (req, res) => res.json({ data: 'secret data' }));
    

    与会话处理类似,硬编码的'secretKey'应从配置文件中获取。对于缺失的令牌返回401状态码,对于无效的令牌返回403状态码是一种常见做法,不过许多API对这两种情况都会返回401。

    Q51. 请求验证

    像express-validator或joi这样的库可以定义规则并收集错误信息。使用express-validator时,需要先导入相关辅助函数:

    const { body, validationResult } = require('express-validator');
    

    然后在处理程序之前依次调用这些规则中间件,并检查验证结果:

    app.post('/user',
      body('email').isEmail(),
      body('password').isLength({ min: 6 }),
      (req, res) => {
        const errors = validationResult(req);
        if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() });
        // proceed
      }
    );
    

    如需更深入地比较各种验证方法,请参阅Zod与express-validator的对比。

    Q52. 文件上传

    multer用于解析multipart/form-data格式的请求。需先配置存储目标:

    const multer = require('multer');
    const upload = multer({ dest: 'uploads/' });
    

    然后根据不同路由应用相应配置;upload.single('file')期望在名为file的字段中存在一个文件,并将其作为req.file提供:

    app.post('/upload', upload.single('file'), (req, res) => {
      res.json({ file: req.file });
    });
    

    在实际系统中,还需限制文件大小和类型,绝不能轻信客户端提供的文件名。

    Q53. 使用Mongoose连接MongoDB

    首先建立连接:

    const mongoose = require('mongoose');
    mongoose.connect('mongodb://localhost:27017/mydb');
    

    接着定义模型,并在路由中使用该模型:

    const User = mongoose.model('User', new mongoose.Schema({
      name: String,
      email: String
    }));app.post('/users', async (req, res) => {
      const user = await User.create(req.body);
      res.status(201).json(user);
    });
    

    直接将req.body传递给create函数会导致客户端可以设置任意架构字段,因此应先进行验证或筛选允许的字段。

    Q54. 中间件执行顺序

    Express会按照中间件注册的顺序从上到下执行,一旦遇到不调用next()的函数,整个执行链就会停止。正是由于这种顺序,404处理程序和错误处理程序会被放在最后。

    Q55. 模板引擎

    模板引擎负责根据服务器端数据渲染HTML。常见的选择有EJS、Pug(原名Jade)和Handlebars。要使用它们,只需设置视图引擎相关选项并调用res.render函数即可:

    app.set('view engine', 'ejs');
    app.get('/', (req, res) => res.render('index', { name: 'John' }));
    

    高级Express与Node技术

    Q56. Express 4与Express 5中的异步错误处理

    在 Express 4 中,async 处理器返回的被拒绝的承诺不会被框架捕获;如果没有 try/catch 以及 next(err) 或相应的封装函数,它就会成为未处理的拒绝状态。Express 5 会自动将处理器和中间件返回的被拒绝承诺转发给错误处理函数。在 Express 4 中,常见的解决办法是使用一个小型高阶函数:

    // Express 4 workaround
    const asyncHandler = fn => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
    

    用该函数封装处理器后,所有的拒绝情况都会被传递给 next:

    app.get('/data', asyncHandler(async (req, res) => {
      const data = await fetchData(); // if this throws, asyncHandler catches it
      res.json(data);
    }));
    

    这一模式在 使用 asyncHandler 移除 Express 路由中的 try/catch 代码模板 中有详细说明。

    Q57. 如何构建大型 Express 应用

    常见的 MVC 风格架构会将不同功能分离到不同的文件夹中:

    project/
    ├── controllers/     # business logic
    ├── routes/          # route definitions
    ├── models/          # DB schemas
    ├── middlewares/      # custom middleware
    ├── config/          # DB, env configs
    ├── utils/           # helper functions
    ├── app.js
    └── server.js
    

    路由用于声明端点并委托给控制器,控制器负责处理请求逻辑,而模型则封装数据访问功能。将用于配置应用的 app.js 与用于启动应用的 server.js 分开,也能便于在测试中导入该应用。

    Q58. 请求频率限制

    express-rate-limit 可以统计指定时间窗口内每个客户端的请求次数。首先需要导入该模块:

    const rateLimit = require('express-rate-limit');
    

    然后应用频率限制规则,此处为例:每15分钟允许每个IP地址发送100次请求:

    const limiter = rateLimit({
      windowMs: 15 * 60 * 1000, // 15 minutes
      max: 100 // limit each IP to 100 requests per window
    });app.use(limiter);
    

    默认的存储方式是在内存中,因此每个集群进程会独立统计请求次数;在扩展规模时需使用Redis等共享存储方案。如果在代理服务器后运行,需配置 trust proxy 选项,以便使用真实的客户端IP地址。

    Q59. 加强Express应用的安全性

    • 使用 helmet 设置防护性的HTTP头部。
  • 验证并清理所有输入,以防止 SQL 或 NoSQL 注入以及 XSS 攻击。
  • 通过 HTTPS 提供服务。
  • 实施速率限制。
  • 从环境变量中读取敏感信息,例如使用 dotenv,而非将其提交到代码中。
  • 保持依赖项更新,并运行 npm audit。
  • 为基于 Cookie 和表单的应用程序添加 CSRF 防护。
  • Q60. res.send()、res.json() 和 res.end() 的区别

    • res.send() 可以接收字符串、对象或缓冲区,并自动选择合适的 Content-Type。
    • res.json() 会将参数序列化为 JSON 格式,并设置 Content-Type: application/json。
    • res.end() 会在不发送更多数据的情况下结束响应,实际上是 Node 自有方法的简化封装。

    Q61. next('route') 是做什么的?

    它会跳过当前路由剩余的回调函数,直接转到下一个匹配的路由。此机制仅适用于通过 app.METHOD() 或 router.METHOD() 注册且包含多个回调函数的处理器。

    Q62. 提升性能

    • 使用集群技术或 PM2 为每个核心启动一个进程。
    • 将响应缓存到 Redis 或内存中。
    • 利用 compression 中间件压缩响应,或让代理服务器来完成压缩。
    • 在前面部署 Nginx 或其他反向代理/负载均衡器。
    • 避免将占用大量 CPU 资源的操作放在事件循环中处理。
    • 对数据库连接进行池化管理。
    • 考虑使用 HTTP/2 协议。
    • 全程采用异步输入/输出方式,避免在请求路径中使用 Sync 类型。

    Q63. Express 框架下的 REST 与 GraphQL 对比

    REST 提供了许多具有固定响应格式的端点,因此客户端常常会获取过多或过少的数据。GraphQL 只提供一个端点,客户端可以明确指定所需的数据。它通常通过 express-graphql 或 apollo-server-express 插件集成到 Express 中;不过这两个插件现已被弃用,建议参考最新的 Apollo 或 GraphQL Yoga 文档。

    Q64. Express 如何处理并发请求?

    每个请求只不过是事件循环中的一系列回调而已。由于数据库和网络操作都是非阻塞的,单个线程就可以处理多个请求:网络流量由操作系统的异步机制负责,文件操作则交由 libuv 线程池处理,而回调会在结果到达时立即执行。与传统的多线程服务器不同,这里不需要为每个请求单独分配一个线程,这也正是为什么一个缓慢的同步处理程序会影响所有请求的原因。

    Q65. PM2 与集群模块的区别

    cluster 是用于实现多进程扩展的内置工具。PM2 则是基于相同理念开发的进程管理器,它还具备崩溃后自动重启、负载均衡、日志处理、无停机时间重新加载以及监控功能,正因如此它被广泛应用于众多生产环境。

    Q66. 为什么要在 Node 前面放置反向代理?

    像 Nginx 这样的反向代理可以终止 TLS 加密、在多个实例之间分配负载、缓存数据、压缩文件并提供静态文件,从而将这些工作从 Node 中剥离出来,让应用程序进程能够专注于业务逻辑。

    Q67. 针对特定环境的配置

    dotenv 包会将 .env 文件中的变量加载到 process.env 中,如果某个值缺失,应用程序则会使用默认值:

    require('dotenv').config();
    const PORT = process.env.PORT || 3000;
    

    较新版本的 Node 还可以通过 --env-file 参数直接加载环境文件。切勿提交包含敏感信息的 .env 文件。

    Q68. app.all() 与 app.use()

    app.all() 会匹配特定路由路径上的所有 HTTP 方法。app.use() 也会匹配所有方法,但针对的是该路径及其下的所有内容,正因如此它才被用作挂载中间件的工具。

    Q69. 使用 Express 和 MongoDB 实现分页

    从查询字符串中读取page和limit,并为它们设置合理的默认值,然后再结合使用skip和limit:

    app.get('/posts', async (req, res) => {
      const page = parseInt(req.query.page) || 1;
      const limit = parseInt(req.query.limit) || 10;
      const posts = await Post.find()
        .skip((page - 1) * limit)
        .limit(limit);
      res.json(posts);
    });
    

    在投入生产前需增强安全性:为parseInt传入进制参数,将page的值限制在至少1以上,同时为limit设置最大值,并添加稳定的排序规则。对于非常大的集合,随着偏移量的增加skip的性能会变差,而在带索引的字段上使用游标进行分页则表现更好。

    Q70. 什么是幂等性?

    当重复执行某个操作后服务器状态与仅执行一次时相同,该操作即为幂等操作。GET、PUT和DELETE被定义为幂等操作;而POST通常不是,因为每次调用都可能创建新的资源。只有在幂等请求中,超时后的重试才是安全的,这也是为什么支付接口常常会在POST请求中添加幂等性键的原因。

    关键要点

    • 大多数关于Node的问题都可以归结为一种模型:单个JavaScript线程、具有明确阶段的事件循环,每次回调后都会处理完微任务,其余工作则由libuv负责。
    • 大多数关于Express的问题则可以归结为另一种模型:一系列按顺序排列的中间件,每个函数要么直接响应,要么调用next(),错误则会被传递给四参数处理函数。
  • 面试官会看重对那些会随时间变化的细节的精准把握,比如cluster.isPrimary、未处理拒绝时的崩溃问题、Express 5的异步错误以及ESM路径辅助函数。
  • 每个答案都应配上生产环境中的注意事项(配置文件中的敏感信息、错误传播机制、共享的限流存储),因为这通常是接下来的问题。