普通视图

发现新文章,点击刷新页面。
今天 — 2026年3月18日首页

Vue-Vue Router核心原理+实战用法全解析

2026年3月18日 17:41

前言

无论是单页面应用(SPA)还是复杂的后台管理系统,路由(Router)都是其灵魂。它通过 URL 映射组件,实现了无刷新的页面切换。本文将从底层原生 API 出发,带你彻底弄懂 Vue Router 的运行机制。

一、 路由的本质:Hash vs History

前端路由的核心是:改变 URL,页面不刷新,但渲染不同的组件。 Vue Router 本质上是基于浏览器原生的 window.location.hashhistory API 实现的,通过监听 URL 变化,动态匹配路由规则并渲染对应组件,无需后端参与页面切换。

1. Hash 模式 (window.location.hash)

  • URL 特征:路径中携带# 符号,例如 http://xxx.com/#/homehttp://xxx.com/#/about
  • 底层依赖window.location.hash
  • 核心特性:URL 中 # 后的内容属于锚点定位,不会发送到服务器端,所有前端路由请求最终都会指向 域名/index.html,服务器只需返回首页文件即可。
  • 优势:无需额外配置服务器,刷新页面、直接访问子路由都不会出现 404 错误,兼容性极强。

2. History 模式 (window.history)

  • URL 特征:路径中无 # 符号,形态更简洁,例如 http://xxx.com/homehttp://xxx.com/about
  • 底层依赖:浏览器原生 history API
  • 核心坑点:当用户刷新页面、直接访问子路由时,浏览器会向服务器发送对应路径的 GET 请求(如请求 /home),如果服务器未配置路由指向,会直接返回 404 错误。
  • 解决方案:必须在 Nginx 等服务器中配置规则,将所有路由请求都指向项目入口 index.html,由前端路由接管匹配逻辑。
location / {
  root   /usr/share/nginx/html;
  index  index.html index.htm;
  # 关键:找不到资源时返回 index.html
  try_files $uri $uri/ /index.html; 
}

二、 底层原理实现

1. Hash 模式实现链路

  • 监听变化:基于 windowhashchange 事件,监听 URL 中 hash 值的变化。
  • 设置值:修改 location.hash手动修改路由路径。
  • 跳转:使用 location.assign()实现路由跳转。
  • 获取当前路径:通过 location.hreflocation.hash 解析。

2. History 模式实现链路

  • 监听变化:基于浏览器原生 popstate 事件,仅监听浏览器前进/后退操作触发的路由变化。

    ⚠️ 避坑点:调用 history.pushStatereplaceState 改变 URL 时,并不会触发 popstate。Vue Router 内部通过劫持这些方法手动触发了更新。

  • 操作记录

    • pushState(stateObj, title, url):添加历史记录。
    • replaceState(stateObj, title, url):替换当前记录。
  • 获取路径:基于 window.location.pathname获取纯路径部分。

  • 状态存储:通过 history.state 获取传给 pushState 的自定义对象。


三、 Vue 路由跳转实战

方法一:声明式导航 <router-link>

这是日常开发中最常用的方式,本质是对 <a> 标签的封装,默认无刷新跳转,语法简洁且支持路由参数传递。核心参数如下:

  • to(必传) :目标路由路径,支持字符串格式和对象格式

    • 字符串格式:<router-link to="/home">首页</router-link>
    • 对象格式:可搭配 name、query、params 实现精细化跳转
  • name:通过路由名称跳转(推荐,避免路径硬编码),示例::to="{ name: 'About' }"

  • query:传递查询参数,参数会拼接在 URL 中(刷新不丢失),示例::to="{ name: 'About', query: { name: 'test' } }",最终 URL:/about?name=test

  • params:传递动态路由参数,参数不会拼接在 URL(刷新会丢失),必须配合 name 使用,示例::to="{ name: 'About', params: { id: 123 } }"

    注意:若路由规则中未定义动态参数(如 :id),仅通过 name + params 传参,刷新页面后 params 会丢失;

    解决办法:在路由规则中添加 :id(必传)或 :id?(可选),例如 path: '/about/:id?'

方法二:编程式导航 useRouter

通过 useRouter 获取路由实例,用代码控制路由跳转,适合非点击触发的场景(如接口请求成功后跳转、条件判断跳转、定时器跳转等)

<script setup>
import { useRouter } from 'vue-router'
// 获取路由实例
const router = useRouter()

// 编程式跳转
const goToPage = () => {
  // 1. push 跳转(新增历史记录,可返回)
  router.push('/home')
  // 对象格式跳转
  router.push({ name: 'About', query: { name: 'test' } })

  // 2. replace 跳转(替换历史记录,不可返回)
  router.replace('/about')

  // 3. 路由前进/后退
  router.go(-1) // 后退一页
  router.back() // 后退一页(等价 go(-1))
  router.forward() // 前进一页(等价 go(1))
}
</script>

四、 Vue 路由监听三大方法

Vue 监听路由变化,本质是监听 route 对象(包含 path/params/query 等属性)的变化,触发自定义回调函数,常用于路由切换时更新数据、重置状态等场景.

1. 使用 watch + useRoute

通过 useRoute 获取当前路由对象,搭配 watch 监听器实现路由变化监听,支持立即执行、深度监听,适用性最广。

const route = useRoute();

watch(
  () => route.query,
  (newQuery) => {
    console.log('搜索参数变了:', newQuery);
  },
  { immediate: true, deep: true } // immediate 确保初始化时执行
);

2. 路由守卫 onBeforeRouteUpdate

Vue Router 提供的导航守卫,仅在组件复用时触发(例如 /detail/123/detail/456),路由跳转到其他组件时不会触发,适合列表页跳转详情页等场景。

  • 优点:不需要 watch 那么大的开销,专门针对参数更新。
  • 局限:离开该组件或首次进入时不触发。
<script setup>
import { onBeforeRouteUpdate } from 'vue-router'

// 组件复用时触发
onBeforeRouteUpdate((to, from) => {
  console.log('即将跳转至:', to.path)
  console.log('从:', from.path, '跳转而来')
  // 可在此处更新组件数据
})
</script>

3. 原生监听(底层方案)

直接监听浏览器原生路由事件,脱离 Vue Router API 实现监听,适合特殊定制场景,需注意事件解绑避免内存泄漏。

window.addEventListener('popstate', callback)


性能优化:CDN 缓存加速与调度原理

2026年3月18日 17:16

前言

在前端性能优化中,静态资源加载速度往往是首屏渲染的瓶颈。CDN(Content Delivery Network) 通过将资源分发至全球各地的边缘节点,实现了“物理距离”上的访问加速。本文将带你深入 CDN 的内部,看它是如何通过 DNS 调度实现就近访问的。

一、 核心概念:什么是 CDN?

CDN 是一种分布式网络构建。它通过在全国各地(乃至全球)部署海量边缘节点服务器,缓解因用户地域差异、带宽不同、服务器距离过远导致的访问延迟问题,让用户就近获取所需资源,大幅提升网站响应速度、访问成功率,同时减轻源服务器压力。

1. 解决的痛点

  • 物理距离过远:跨国、跨省访问带来的高延迟。
  • 运营商带宽瓶颈:跨运营商(如电信访问联通)的互联互通问题。
  • 源站压力过大:热点资源引发的服务器并发冲击。

二、 深度拆解:CDN 的通信与调度流程

当用户在浏览器输入一个使用了 CDN 的域名时,背后的解析流程比普通 DNS 复杂得多,CDN具体通信调度流程如下:

  1. 域名解析请求:用户在浏览器输入域名,浏览器向本地DNS服务器请求解析,获取对应IP地址。

  2. CNAME 指向:DNS服务器不会直接返回源站IP,而是返回一个CNAME(别名记录) ,该记录指向CDN专用的全局负载均衡(GSLB)系统。。

  3. 智能调度计算:浏览器重新向CDN全局负载均衡系统发起请求。GSLB 会根据以下维度进行综合计算:

    • 地理位置:用户 IP 距离哪个节点最近?
    • 运营商环境:用户是移动还是电信?选择匹配的线路。
    • 节点健康度:目标服务器当前的负载和带宽是否充足?
    • 资源命中情况:请求的资源在哪个节点有缓存?
  4. 返回边缘节点 IP:GSLB 选择一个最优的区域负载均衡设备(SLB) ,并将这个边缘节点的IP地址返回给用户浏览器。

  5. 资源获取与回源

    • 命中(Hit) :用户向该 IP 请求,边缘节点直接返回资源。
    • 回源(Miss) :如果该节点无缓存,则逐级向上寻找,直至回到源站服务器拉取内容并缓存到本地。

核心逻辑:用户永远不直接访问源站,而是访问CDN边缘节点,源站只负责提供原始资源,极大降低源站压力。


三、 评价指标:如何衡量 CDN 的服务质量?

CDN 的核心价值在于“命中”,我们通常用以下两个指标来评估:

指标 定义 理想状态
命中率 (Hit Rate) 用户访问的资源恰好在CDN节点缓存系统中的比例 越高越好。代表 CDN 拦截了大部分请求,减轻了源站压力。
回源率 (Origin Pull Rate) 用户访问的资源CDN节点无缓存/缓存过期,必须向上级节点或源站请求资源的次数,占总访问次数的比例。 越低越好。高回源率可能导致源站带宽瞬间爆满。

四、 进阶实战:CDN 预热与刷新

在实际项目部署中,我们经常会听到两个核心操作:

1. CDN 预热 (Pre-warming)

  • 场景:大版本上线或活动开启前(如双 11)。
  • 操作:主动将源站资源推送到全国各地的 CDN 节点。
  • 效果:用户在第一波访问时就能直接“命中”,避免瞬间大量请求涌向源站导致崩溃。

2. CDN 刷新 (Refresh)

  • 场景:修复了紧急 Bug,更新了相同文件名的静态资源。
  • 操作:强制清除节点上的缓存。用户下次访问时将触发回源。
  • 优化:推荐在打包时使用 Content Hash(如 main.v123.js),通过文件名变更自然失效,而非手动刷新。

五、 最佳实践:前端如何使用 CDN?

1. 第三方库托管

对于成熟的库(Vue, React, Echarts, Axios),直接使用公共 CDN(如 cdnjs, unpkg, 静态资源库)。

  • 优点:减少自建服务器带宽压力;利用浏览器缓存(如果用户在别的网站也加载过同一个 CDN 链接,则无需下载)。
    <!-- 示例:CDN引入Vue、Axios、ECharts -->
    <script src="https://cdn.jsdelivr.net/npm/vue@3.4.0/dist/vue.global.prod.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/axios@1.6.0/dist/axios.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
    

2. 静态资源部署

将打包生成的 dist 目录(JS、CSS、图片)直接部署至云厂商的 对象存储(如阿里云 OSS, 腾讯云 COS) 并绑定 CDN 加速。

  • 策略:HTML 放在自己的服务器(防止缓存无法更新),而静态资源全走 CDN。

六、核心总结

  • CDN本质:分布式节点+就近访问+缓存加速,解决远程访问延迟、源服务器压力大的问题
  • 调度核心:DNS解析→CNAME指向→负载均衡选最优节点→节点缓存响应
  • 质量关键:命中率越高、回源率越低,CDN加速效果越好
  • 前端用法:第三方库直引、项目dist资源上传部署,是必备性能优化手段
昨天 — 2026年3月17日首页

深度拆解 fetch-event-source库实现原理

2026年3月17日 17:38

前言

在 AI 大模型火热的今天,流式输出(Streaming)已成为标配。虽然浏览器原生提供了 EventSource (SSE),但在复杂的业务实战中,它却显得力不从心。本文将带你深度剖析 fetch-event-source 的底层实现,看看它是如何突破原生限制,优雅实现流式交互的。

一、 为什么原生 EventSource 走到了尽头?

原生 EventSource 在 AI 聊天场景中有两个“死穴”:

  1. 方法受限:只能发送 GET 请求。AI 聊天往往需要携带庞大的上下文(Context),URL 长度限制是无法逾越的障碍。
  2. 鉴权困境:无法自定义 Header。在需要通过 Authorization 传递 Token 的现代 Web 应用中,这非常致命。

fetch-event-source 的出现,本质上是给 fetch 套上了一层 SSE 的协议外壳,完美继承了 fetch 的灵活性。


二、 核心原理:基于 ReadableStream 的流式解析

fetch-event-source 的核心魔法在于利用了 fetch 返回值中的 Response.body。它是一个 ReadableStream(可读流),允许我们在数据还没全部到达时,就开始处理已经“流”进来的字节块。

1. 协议头强制对齐

要模拟 SSE,请求头必须严格遵守规范:

  • Accept: text/event-stream:告知后端我们需要流式响应。
  • Cache-Control: no-cache:禁用缓存,确保实时性。
  • Connection: keep-alive:保持长连接。

2. 状态机解析逻辑

由于 SSE 格式具有高度可预测性(以 \n 分隔行,以 \n\n 分隔消息块),我们可以通过一个简单的状态机进行逐行扫描:

  • data: 开头 -> 暂存数据片段。
  • event: 开头 -> 记录事件类型。
  • retry: 开头 -> 更新客户端的重连等待时间。
  • 空行 (\n\n) -> 表示一条消息解析完成,触发 onmessage 回调。

三、 手写一个简易版

理解原理最好的方式就是复刻它。以下是基于 fetchTextDecoder 的核心实现逻辑:

async function fetchEventSource(url, options) {
  const { signal, onopen, onmessage, onerror, retryDelay = 1000 } = options;
  let retryCount = 0;

  // 1. 循环处理(失败重试)
  while (!signal.aborted) {
    try {
      const response = await fetch(url, {
        method: 'POST', // 突破 GET 限制,支持 POST 发送上下文
        headers: {
          'Accept': 'text/event-stream',
          'Cache-Control': 'no-cache',
          'Content-Type': 'application/json',
          ...options.headers,
        },
        body: JSON.stringify(options.body),
        signal,
      });

      // 2. 响应合法性校验
      if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
      if (!response.headers.get('Content-Type')?.includes('text/event-stream')) {
        throw new Error('Invalid Content-Type, expected text/event-stream');
      }

      onopen?.({ response });

      // 3. 读取流式响应体 (核心)
      const reader = response.body.getReader();
      const decoder = new TextDecoder('utf-8');
      let buffer = ''; 

      while (true) {
        const { done, value } = await reader.read();
        if (done) break;

        // 解码二进制数据并追加到缓冲区
        buffer += decoder.decode(value, { stream: true });
        
        // 4. 按 SSE 规范拆分消息块 (\n\n)
        let parts = buffer.split('\n\n');
        buffer = parts.pop(); // 最后一个可能是残缺的,留到下一轮处理

        for (const part of parts) {
          // 这里解析 data: event: 等字段
          const parsed = parseSSEPart(part); 
          onmessage?.(parsed);
        }
      }

      await reader.releaseLock();
      if (signal.aborted) break;

      throw new Error('Connection closed by server');
    } catch (error) {
      // 5. 错误处理与指数退避重连
      const retry = onerror?.(error) ?? true;
      if (!retry || signal.aborted) break;

      const delay = retryDelay * Math.pow(2, retryCount);
      await new Promise(resolve => setTimeout(resolve, delay));
      retryCount++;
    }
  }
}

四、 总结

fetch-event-source 并不是魔法,它只是站在了 fetchReadableStream 的肩膀上,通过手动实现 SSE 协议解析,解决了原生 API 的痛点。在 AI 对话应用中,它是实现实时、鉴权、高扩展性流式输出的最佳实践。

SSE 流式传输:中断超时处理

2026年3月17日 17:23

前言

在开发 AI 聊天应用时,fetch-event-source 几乎是前端标配。但你是否思考过:为什么原生的 EventSource 不行?它是如何解析二进制流的?当网络波动导致连接“假死”时,如何实现无感重连和数据去重?本文将带你拆解这些核心细节。

一、 为什么原生 EventSource 在 AI 场景“退环境”了?

原生 EventSource 虽好,但在复杂的 AI 业务场景中有两个“致命伤”:

  1. 仅支持 GET 请求:AI 对话通常需要发送长篇累牍的上下文(Context),URL 长度限制会导致请求失败。
  2. 无法自定义 Header:无法在请求头中携带 Authorization 令牌,给鉴权带来了麻烦。

fetch-event-source 的原理:它是基于原生 fetchReadableStream(可读流) 实现的。它通过手动解析 HTTP 响应体中的二进制数据,模拟了 SSE 的行为,同时继承了 fetch 支持各种 Method 和 Header 的灵活性。


二、 核心实战:如何处理 SSE 异常中断与超时?

在长连接中,最怕“连接还在,但数据没了”的假死状态。我们需要对库进行二次封装,引入超时检测指数退避重连

1. 超时检测机制

设置一个心跳定时器。如果在规定时间内(如 15s)没有收到任何 onmessage 信号,说明连接可能已失效。

  • 动作:主动调用 abort() 中断当前请求,并触发重连。
  • 重置:每当有新数据到达或连接开启时,重置该定时器。

2. 指数退避自动重连

为了减轻服务器压力,重连间隔不应是固定的。

  • 策略:从 2s 开始,每次失败翻倍(2s → 4s → 8s...),上限 30s。
  • 终止:设置最大重连次数(如 10 次),失败后提示用户“服务器繁忙,请手动重试”。

3. 断点续传与去重

重连后,后端可能会重新推送历史数据。

  • 前端方案:维护一个 lastMsgId。请求时带上这个标识,让后端从断点处开始推送;或者前端根据 id 对收到的消息进行 Map 去重。

三、 中断超时处理实现:基于fetchEventSource 简易实现

import { fetchEventSource } from '@microsoft/fetch-event-source'
import { ElMessage, ElMessageBox } from 'element-plus'

// 全局状态管理(避免多请求冲突)
let controller = new AbortController()
let timeoutTimer = null // 超时定时器
let reconnectCount = 0 // 重连次数
let reconnectInterval = 2000 // 初始重连间隔(2s)
const MAX_RECONNECT_COUNT = 10 // 最大重连次数
const MAX_RECONNECT_INTERVAL = 30000 // 最大重连间隔(30s)
let lastMessageId = '' // 记录最后一条消息ID(断点续传用)

/**
 * 重置超时定时器(收到消息/建立连接时调用)
 * @param {number} timeout 超时时间(默认30s)
 */
const resetTimeoutTimer = (timeout = 30000) => {
  // 清除原有定时器
  if (timeoutTimer) clearTimeout(timeoutTimer)
  // 新建超时定时器:超时未收到消息则主动中断
  timeoutTimer = setTimeout(() => {
    ElMessage.warning('连接超时,正在尝试重连...')
    controller.abort() // 主动中断请求
    reconnectStream() // 触发重连
  }, timeout)
}

/**
 * 重连流式请求(指数退避策略)
 * @param {string} url 接口地址
 * @param {Object} headers 请求头
 * @param {Object} data 请求参数
 * @param {Function} handleMessage 消息处理回调
 */
const reconnectStream = async (url, headers, data, handleMessage) => {
  // 超过最大重连次数,停止自动重连
  if (reconnectCount >= MAX_RECONNECT_COUNT) {
    ElMessageBox.alert('服务器繁忙,请稍后手动重试', '重连失败', {
      confirmButtonText: '确定'
    })
    // 重置重连状态
    reconnectCount = 0
    reconnectInterval = 2000
    return
  }

  // 指数退避:间隔翻倍,不超过30s
  const currentInterval = Math.min(reconnectInterval, MAX_RECONNECT_INTERVAL)
  ElMessage.info(`第${reconnectCount + 1}次重连,间隔${currentInterval / 1000}s...`)

  // 延迟重连
  await new Promise((resolve) => setTimeout(resolve, currentInterval))

  // 更新重连状态
  reconnectCount++
  reconnectInterval *= 2

  // 重新发起请求(携带最后一条消息ID,实现断点续传)
  requestStream(
    url,
    headers,
    {
      ...data,
      lastMessageId: lastMessageId // 传给后端,让后端从断点续传
    },
    handleMessage
  )
}

/**
 * 流式请求核心方法(带超时、重连、断点续传)
 * @param {string} url 接口地址
 * @param {Object} headers 请求头
 * @param {Object} data 请求参数
 * @param {Function} handleMessage 消息处理回调(接收流式数据)
 */
export const requestStream = (url, headers, data, handleMessage) => {
  // 中断原有请求
  if (controller) controller.abort()
  controller = new AbortController()

  // 初始化超时定时器(30s超时检测)
  resetTimeoutTimer()

  fetchEventSource(url, {
    method: 'POST',
    signal: controller.signal,
    headers: {
      ...headers,
      Accept: 'text/event-stream', // SSE必需头
      'Cache-Control': 'no-cache'
    },
    body: JSON.stringify(data),
    openWhenHidden: true, // 页面隐藏时继续请求
    async onopen(response) {
      console.log('建立连接的回调')
      // 连接建立:重置超时定时器+重连状态
      resetTimeoutTimer()
      reconnectCount = 0
      reconnectInterval = 2000

      // 校验响应合法性
      if (!response.ok) {
        throw new Error(`连接失败,状态码:${response.status}`)
      }
    },
    onmessage(msg) {
      // 收到消息:重置超时定时器
      resetTimeoutTimer()

      // 记录最后一条消息ID(断点续传核心)
      if (msg.id) lastMessageId = msg.id
      // 处理消息(去重逻辑:避免重连后数据重复)
      handleMessage(msg)
    },
    onclose() {
      console.log('连接正常关闭')
      // 清除定时器+中断请求
      if (timeoutTimer) clearTimeout(timeoutTimer)
      controller.abort()
      // 重置状态
      reconnectCount = 0
      reconnectInterval = 2000
      lastMessageId = ''
    },
    onerror(err) {
      // 清除超时定时器
      if (timeoutTimer) clearTimeout(timeoutTimer)

      // 手动中断不触发重连(比如用户点击停止)
      if (controller.signal.aborted) {
        console.log('用户手动中断请求')
        return
      }

      // 异常重连
      ElMessage.error(`连接异常:${err.message || '网络错误'}`)
      reconnectStream(url, headers, data, handleMessage)

      // 必须抛出错误才会停止当前请求循环
      throw err
    }
  })
}

/**
 * 停止流式请求(手动中断)
 */
export const stopRequest = () => {
  // 清除超时定时器
  if (timeoutTimer) {
    clearTimeout(timeoutTimer)
    timeoutTimer = null
  }
  // 中断请求
  if (controller) {
    controller.abort()
    controller = new AbortController()
  }
  // 重置重连状态
  reconnectCount = 0
  reconnectInterval = 2000
  lastMessageId = ''
  ElMessage.info('已停止数据请求')
}


四、 注意:关于 Nginx 与浏览器限制

  1. Nginx 缓存屏蔽:一定要记得设置 proxy_buffering off;,否则 Nginx 会等缓冲区满了才一次性吐给前端,导致流式效果失效。
  2. 浏览器连接数限制:如果是 HTTP/1.1,浏览器对同一个域名的长连接通常限制在 6 个。如果打开多个 AI 对话页,可能会导致后续连接卡死。建议升级 HTTP/2,它可以多路复用,避开此限制。
  3. 手动停止 vs 自动重连:当用户点击“停止生成”时,必须标记一个 manualStop 状态位,否则 onerror 可能会误以为是网络异常而不断尝试重连。

五、💡 扩展:异步并发池 (Async Pool)

它不直接用于单个 SSE 连接,但在批量 AI 任务处理(例如一次性给 100 张图片生成描述)时非常有用。它可以限制同时进行的 HTTP 请求数量,防止瞬间撑爆浏览器带宽或后端并发限制。

1. 归属识别:唯一 ID + 专属缓存

  • 每个请求分配requestId(如stream-request-0);
  • streamDataCacherequestId为 key,每个请求的片段只往自己的缓存里加;
  • 即使多个请求的onmessage同时触发,也不会串数据(比如stream-request-0的片段绝不会跑到stream-request-1的缓存里)。

2. 有序拼接:数组按顺序存储片段

  • 每个请求的缓存里用fragments数组存储片段;
  • onmessage每次触发时,cache.fragments.push(msg.data)保证片段按返回顺序存储;
  • 收到结束标识[DONE]时,用join('')拼接数组,得到完整结果。

3. 并发控制:不等待 Promise 完成,只控制启动数

  • runningRequestCount记录正在运行的请求数;
  • runTasks里用while (runningRequestCount >= limit)等待,直到有请求结束、并发数下降;
  • 每个请求结束后(onclose/onerror),runningRequestCount--,并自动执行下一个任务;
  • 这种方式既限制了并发数,又不阻塞流式请求的 “持续返回片段”。
/**
 * 异步任务池(适配流式请求的并发控制)
 * @param {Array<Object>} requestList 批量请求列表(含url/headers/data)
 * @param {number} limit 最大并发数
 * @param {Function} onComplete 单个请求完成回调(参数:requestId, fullResult)
 */
export const batchStreamRequest = async (requestList, limit = 3, onComplete) => {
  // 为每个请求分配唯一ID
  const requestListWithId = requestList.map((item, index) => ({
    ...item,
    requestId: `stream-request-${index}`
  }))

  // 任务执行队列:递归执行,控制并发数
  const runTasks = async (taskIndex = 0) => {
    // 所有任务处理完毕
    if (taskIndex >= requestListWithId.length) return

    const currentTask = requestListWithId[taskIndex]
    const { requestId, url, headers, data } = currentTask

    // 等待:直到并发数低于限制
    while (runningRequestCount >= limit) {
      await new Promise((resolve) => setTimeout(resolve, 100)) // 每100ms检查一次
    }

    // 启动当前流式请求
    runningRequestCount++
    console.log(`启动请求${requestId},当前并发数:${runningRequestCount}`)

    // 执行单个流式请求(不等待完成,只标记启动)
    singleStreamRequest(requestId, url, headers, data, onComplete)
      .catch((err) => console.error(`请求${requestId}失败:`, err))
      .finally(() => {
        // 当前请求结束后,自动执行下一个任务
        runTasks(taskIndex + 1)
      })

    // 立即执行下一个任务(检查并发数)
    runTasks(taskIndex + 1)
  }

  // 启动任务队列
  await runTasks(0)
}

/**
 * 停止单个/所有流式请求
 * @param {string} [requestId] 可选:指定停止的请求ID,不传则停止所有
 */
export const stopStreamRequest = (requestId) => {
  if (requestId) {
    // 停止指定请求
    const controller = requestControllers[requestId]
    if (controller) {
      controller.abort()
      delete requestControllers[requestId]
      // 标记缓存为完成
      if (streamDataCache[requestId]) {
        streamDataCache[requestId].isCompleted = true
      }
      runningRequestCount--
    }
  } else {
    // 停止所有请求
    Object.keys(requestControllers).forEach((id) => {
      requestControllers[id].abort()
      delete requestControllers[id]
      if (streamDataCache[id]) {
        streamDataCache[id].isCompleted = true
      }
    })
    runningRequestCount = 0
    ElMessage.info('已停止所有流式请求')
  }
}

// ---------------------- 调用示例 ----------------------
// 批量请求列表
const batchRequests = [
  { url: '/api/stream/ai', headers: {}, data: { prompt: '介绍SSO单点登录' } },
  { url: '/api/stream/ai', headers: {}, data: { prompt: '介绍Token无感刷新' } },
  { url: '/api/stream/ai', headers: {}, data: { prompt: '介绍SSE流式请求' } },
  { url: '/api/stream/ai', headers: {}, data: { prompt: '介绍asyncPool并发控制' } }
]

// 执行批量请求(限制最大并发数2)
batchStreamRequest(batchRequests, 2, (requestId, fullResult) => {
  // 单个请求完成后的回调:拿到拼接好的完整结果
  console.log(`请求${requestId}完成,完整结果:`, fullResult)
  // 这里可以做后续处理:渲染、入库等
})

告别登录中断:前端双 Token无感刷新

2026年3月17日 00:23

前言

在前后端分离的项目中,为了安全,Token 通常会设置有效期。但如果 Token 过期时强制用户重新登录,会极大地破坏用户体验。如何做到在用户毫无察觉的情况下,自动完成 Token 的续期?本文将深度拆解 “双 Token 无感刷新” 的实现机制。

一、 为什么需要“无感刷新”?

举个简单例子,你正在某 App 编辑内容,中途切出几分钟,再切回来时,直接弹出登录页,提示“登录已过期,请重新登录”,这种场景很容易让用户流失。

传统的单 Token 方案存在一个两难境地:

  • 有效期过短:用户操作频繁,动不动就跳回登录页,用户体验极差。
  • 有效期过长:Token 一旦被截获,风险极高。

解决方案:双 Token 机制

  1. access_token:访问令牌。有效期短(如 1 小时),每次接口请求都携带,降低泄露风险。
  2. refresh_token:刷新令牌。有效期长(如 7 天),仅用于 access_token 过期时换取新令牌。

只要用户在 7 天内活跃过,系统就能通过 refresh_token 自动“续命”,实现长效无感登录。


二、 核心流程设计

  1. 正常请求:前端携带 access_token 访问。

  2. 触发过期:后端返回 401 Unauthorized

  3. 判断逻辑

    • 如果是普通接口报 401:说明 access_token 失效,尝试刷新。
    • 如果是刷新接口报 401:说明 refresh_token 也失效了,强制重新登录。
  4. 无感替换:前端自动调用刷新接口,获取新 Token 覆盖本地存储,并重新发起之前失败的请求。


三、 细节攻坚:如何处理并发请求?

痛点:如果页面同时发出了 5 个请求,而此时 Token 刚好过期,会导致这 5 个请求同时触发“刷新 Token”的操作,造成资源浪费甚至后端异常。

解决策略

  • 状态锁 (refreshing) :记录当前是否正在刷新中。
  • 任务队列 (queue) :在刷新期间到达的请求,先暂存起来,不直接报错。
  • 批量回放:等待 Token 刷新成功后,依次执行队列里的请求,实现“无感”衔接。

四、 代码实现 (Axios 拦截器)

以下是基于 Axios 的完整工程化实现:

import axios, { AxiosRequestConfig } from 'axios';

interface PendingTask {
    config: AxiosRequestConfig;
    resolve: Function;
}

let refreshing = false; // 状态锁:标志是否正在刷新 Token
let queue: PendingTask[] = []; // 请求队列:暂存 Token 刷新期间的请求

const axiosInstance = axios.create({
    baseURL: '/api'
});

// 1. 请求拦截器:自动注入 Token
axiosInstance.interceptors.request.use((config) => {
    const accessToken = localStorage.getItem('access_token');
    if (accessToken && config.headers) {
        config.headers.authorization = `Bearer ${accessToken}`;
    }
    return config;
});

// 2. 响应拦截器:处理 Token 过期
axiosInstance.interceptors.response.use(
    (response) => response,
    async (error) => {
        const { data, config } = error.response;

        // 情况 A:正在刷新 Token 中,将后续请求存入队列
        if (refreshing) {
            return new Promise((resolve) => {
                queue.push({ config, resolve });
            });
        }

        // 情况 B:access_token 过期 (状态码 401 且非刷新接口本身)
        if (data.statusCode === 401 && !config.url.includes('/refresh')) {
            refreshing = true;
            
            try {
                const res = await refreshToken();
                refreshing = false;

                if (res.status === 200) {
                    // 核心逻辑:Token 刷新成功,回放队列中的所有请求
                    queue.forEach(({ config, resolve }) => {
                        resolve(axiosInstance(config));
                    });
                    queue = []; // 清空队列
                    
                    // 执行当前触发刷新的那个请求
                    return axiosInstance(config);
                }
            } catch (err) {
                refreshing = false;
                queue = [];
                // 情况 C:refresh_token 也过期了,彻底清除登录态
                localStorage.clear();
                window.location.href = '/login';
                return Promise.reject(err);
            }
        }

        return Promise.reject(error);
    }
);

/**
 * 刷新 Token 的异步方法
 */
async function refreshToken() {
    const res = await axios.get('/api/refresh', {
        params: {
            token: localStorage.getItem('refresh_token')
        }
    });
    // 更新本地存储
    localStorage.setItem('access_token', res.data.accessToken);
    localStorage.setItem('refresh_token', res.data.refreshToken);
    return res;
}

五、 注意事项

  1. 并发请求的 Promise 挂起:在 refreshingtrue 时,返回一个不带 resolvenew Promise 是关键,它能让 Axios 请求处于 pending 状态。
  2. 错误捕获refreshToken 接口本身报错(如 500 或 401)必须妥善处理,直接引导至登录页。
  3. 安全性:普通项目中可以使用 localStorage,但在更高要求的项目中,建议配合 HttpOnly Cookie 存储 refresh_token 以防 XSS 攻击。
  4. 接口重定向陷阱:确保刷新 Token 的接口不会再次进入 401 拦截死循环。

SSO单点登录:从同域到跨域实战

2026年3月17日 00:13

前言

在企业级应用集群中,如果用户每打开一个内部系统都要重新输入一次密码,体验将是灾难性的。SSO(Single Sign-On) 的出现解决了这一痛点:它允许用户“一处登录,处处通行”。本文将深度拆解 SSO 的两种核心实现逻辑。

SSO 的核心概念

SSO(单点登录) 是指在多个应用系统中,用户只需要登录一次,就可以访问所有相互信任的应用系统。

典型场景: 登录了“支付宝”网页版后,直接打开“淘宝”、“天猫”或“阿里云”,你会发现自己已经处于登录状态。


二、 方案一:同域名下的 SSO(父域 Cookie 共享)

这是最简单的实现方式,利用了浏览器 Cookie 可以跨子域共享 的特性。

1. 实现原理

如果所有系统的域名都属于同一个顶级域名(如 a.company.comb.company.com),我们可以将 Cookie 的 Domain 设置为父级域名 .company.com

2. 执行流程

  1. 重定向: 用户访问业务系统 A,A 发现未登录,跳转至 sso.company.com
  2. 认证: 用户在 SSO 页面完成登录。
  3. 种下全局 Cookie: SSO 验证成功,在响应头设置 Set-Cookie: sessionid=xxx; Domain=.company.com; Path=/,这样所有子域名系统都会自动带上这个 Cookie
  4. 自动带入: 当用户跳转回系统 A 或访问系统 B 时,浏览器会自动带上这个 .company.com 域下的 Cookie。
  5. 校验: 业务系统后端获取 Cookie 并请求 SSO 服务验证有效性,完成登录。

注意: 该方案仅适用于公司内部子系统,局限性在于必须处于同一父域下。


三、 方案二:跨域名下的 SSO(Token+code模式)

当系统域名完全不同(如 taobao.comalipay.com)时,Cookie 无法跨域共享。此时需要一个独立的 统一认证中心(CAS/SSO)

核心流程:Token + Code 交换模式

1. 首次登录(以系统 A 为例)

  1. 路由拦截: 业务系统 A 的路由守卫发现本地无 token
  2. 跳转认证: A 引导用户跳转至 SSO 登录页,并携带回跳地址:https://sso.com/login?client_id=A&redirect_uri=https://a.com/callback
  3. SSO 认证: 用户在 SSO 完成登录,SSO 在自己的域名(sso.com)下种下 全局登录态 Cookie
  4. 下发 Code: SSO 生成一个临时授权码 code,通过 URL 重定向带回给系统 A:https://a.com/callback?code=xxxxxx
  5. 换取 Token: 系统 A 前端获取 code,再次向 SSO 服务发起请求。SSO 校验 Cookie + code 有效后,返回正式的 token
  6. 本地存储: 系统 A 获取 token 后存储在 localStorage 或本地 Cookie 中,登录成功。

2. 二次登录(访问系统 B)

  1. 无感跳转: 用户打开系统 B,B 发现未登录,跳转至 SSO 系统。
  2. Cookie 自动识别: 此时浏览器会自动带上 sso.com 域下的全局 Cookie。
  3. 直接授权: SSO 发现用户已登录,直接生成一个新的 临时 code 并重定向回系统 B。
  4. B 换取 Token: 系统 B 使用新 code 换取属于 B 的 token,实现单点登录。

四、 注意事项

1. 为什么不直接返回 Token,而是用 Code 换取?

安全性。 如果直接在 URL 中返回 Token,Token 会暴露在浏览器历史记录中,容易被窃取。使用 临时 code(通常有效期仅 1-5 分钟且只能使用一次)配合后端校验,安全性更高。

2. 重定向时的“瞬间空白”如何处理?

跨域 SSO 在进行域名跳转时,由于需要经过 SSO 系统的中转判断,不可避免会有短暂的白屏或闪烁。

  • UI 优化: 在重定向过程中展示一个统一的 Loading 动画。
  • 静默校验: 如果技术条件允许,可以通过 iframe 尝试静默检查 SSO 登录态,减少全屏跳转。

3. 安全增强

  • State 参数: 在跳转时增加一个随机字符串 state,并在回调时比对,防止 CSRF(跨站请求伪造) 攻击。
  • HTTPS: SSO 全流程必须在 HTTPS 协议下进行,防止敏感信息被中间人劫持。

五、 总结

跨域 SSO 的核心思想是:将“身份验证”权力收拢到统一认证中心,利用 SSO 域下的 Cookie 维持全局登录态,通过“授权码交换”实现跨域权限传递。

❌
❌