阅读视图

发现新文章,点击刷新页面。

React-深度拆解 React路由:从实战进阶到底层原理

前言

在单页面应用(SPA)开发中,前端路由是核心基石。它让我们可以根据 URL 的变化,在不刷新页面的情况下切换组件。本文将带你从 React Router 的基本使用出发,深入其底层的浏览器实现机制。

一、 React 路由配置实战

1. 基础环境搭建

首先,我们需要安装 React 路由的核心库:

npm install react-router-dom

2. 注入路由容器

需要在应用最顶层(如 main.jsx/App.jsx)包裹路由核心容器,决定使用 Hash 模式 还是 History 模式,二者的核心区别是 URL 是否带 #,以及服务端配置要求。

两种模式核心区别:

特性 History 模式 Hash 模式
URL 形式 https://xxx.com/home(无 #) https://xxx.com/#/home(带 #)
服务端配置 需要 Nginx配置兜底 无需服务端配置
兼容性 依赖 HTML5 History API 兼容所有浏览器
场景 =现代浏览器 老项目兼容
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter, HashRouter } from 'react-router-dom';
import App from './App';

const root = ReactDOM.createRoot(document.getElementById('root'));

// 方式1:History 模式(推荐,URL 无 #,美观)
root.render(
  <BrowserRouter>
    <App />
  </BrowserRouter>
);

// 方式2:Hash 模式(兼容老浏览器/无需后端配置,URL 带 #)
// root.render(
//   <HashRouter>
//     <App />
//   </HashRouter>
// );

3. 定义路由映射

通过 <Routes><Route> 定义「URL 路径 → 组件」的映射关系,用 <Link> 替代原生 <a> 标签实现无刷新导航。

import { Routes, Route, Link } from 'react-router-dom';
// 导入页面组件(需提前创建)
import Home from './pages/Home';
import About from './pages/About';
import User from './pages/User';
import NotFound from './pages/NotFound';

function App() {
  return (
    <div className="App">
      <nav style={{ margin: '10px 0' }}>
        <Link to="/" style={{ marginRight: '10px' }}>首页</Link>
        <Link to="/about" style={{ marginRight: '10px' }}>关于我们</Link>
        <Link to="/user/123">用户123</Link>
      </nav>

      {/* 路由匹配规则:Routes 是 v6 新增的容器(替代旧版 Switch) */}
      <Routes>
        {/* 基础路由:路径完全匹配时渲染对应组件 */}
        <Route path="/" element={<Home />} />
        <Route path="/about" element={<About />} />
        {/* 动态路由:匹配 /user/任意值,通过 params 传参 */}
        <Route path="/user/:id" element={<User />} />
        {/* 404 路由:匹配所有未定义的路径(必须放最后) */}
        <Route path="*" element={<NotFound />} />
      </Routes>
    </div>
  );
}

export default App;

二、 深度解析路由两种模式底层原理

React Router 的本质是基于浏览器原生能力的封装,核心作用是根据 URL 路径匹配对应的组件并渲染,实现单页面应用(SPA)的无刷新页面切换。。

2. Hash 模式实现原理

Hash 模式依赖 URL 中 # 后的哈希值,核心逻辑如下:

  • URL 变化监听:基于 window.hashchange 事件监听哈希值变化;

  • 设置 URL 值:通过 location.hash = '新路径' 修改哈希值;

  • 获取当前 URL 值:通过 location.hreflocation.hash 获取;

  • URL 页面跳转:通过 location.assign('#/新路径') 实现跳转;

  • 核心优势# 后的内容不会发送到服务器,所有请求都指向 域名/index.html,服务端无需额外配置。

3. History 模式实现原理

History 模式依赖 HTML5 新增的 History API,核心逻辑如下:

  • URL 变化监听:基于 window.popstate 事件监听浏览器前进 / 后退导致的 URL 变化;

  • 新增 / 替换历史记录

    • history.pushState(stateObj, title, url):新增一条历史记录(无刷新跳转);
    • history.replaceState(stateObj, title, url):替换当前历史记录(不新增记录);
  • 获取当前 URL 值:通过 window.location.pathname 获取;

  • 页面前进 / 后退:通过 history.go(number)(前进 / 后退指定步数)、history.back()(后退一步)实现;

  • 获取自定义状态:通过 history.state 获取 pushState 传入的自定义状态对象;

  • 核心注意:URL 无 #,但刷新页面时浏览器会向服务器发送对应路径的请求,需服务端配置兜底,否则会 404。


四、 注意:History 模式的服务器配置

当你使用 History 模式 部署到 Nginx 时,最常见的问题是:点击页面内的 Link 没问题,但一按刷新就 404

原因:History 模式下,浏览器会向服务器请求 /home 这个物理路径。服务器上并没有这个文件,因此报错。

解决方案:在 Nginx 配置中增加 try_files 指令,让所有找不到的请求都重定向到 index.html,交给 React Router 去处理。

location / {
     root   /usr/share/nginx/html;   # 前端打包文件的存放目录
     index  index.html index.htm;    # 默认访问文件
     try_files $uri $uri/ /index.html;  # 核心配置:如果找不到资源,统统指向 index.html
}

📝 总结

  • React 路由需先安装 react-router-dom,顶层包裹 BrowserRouter(History 模式)或 HashRouter(Hash 模式);

  • 核心使用 <Routes>/<Route> 定义路由规则,<Link> 实现导航;

  • Hash 模式无需服务端配置,History 模式需在 Nginx 中配置 try_files 兜底,避免刷新 404;

  • React Router 本质是监听 URL 变化(Hash 监听 hashchange,History 监听 popstate),匹配并渲染对应组件。


React-手把手带你实现 Keep-Alive 效果

前言

在 Vue 中,我们可以通过 <keep-alive> 轻松缓存组件实例。但在 React 中,组件卸载(Unmount)意味着状态和 DOM 的彻底销毁。如何在 React 中实现“切换页面不丢失滚动位置、不重置表单”?本文将为你拆解三种主流方案。

一、 Keep-Alive 的本质是什么?

在实现 React 版缓存组件前,先明确 Vue keep-alive 的核心逻辑,才能精准复刻:

  • 核心本质:缓存组件实例,保留组件内部状态(如输入框内容、滚动位置);
  • 行为特征:组件切换时不销毁 / 重建,仅通过「隐藏 / 显示」控制渲染状态;
  • React 目标:实现和 Vue 一致的效果 —— 组件切走不丢状态,切回来能恢复。

二、 React 实现 keep-alive 的 3 种方案

方案 1:CSS 隐藏 + 不卸载组件(最简单)

核心思路:

通过 display: none 隐藏不活跃的组件,保留组件的 DOM 节点和内部状态,仅切换 display 属性控制显示 / 隐藏,不触发组件的卸载 / 重新挂载生命周期。

适用场景

  • 少量组件切换(2-3 个,如 tab 标签页);
  • 简单业务场景(如表单页、列表页切换)。

优缺点

✅ 优点:实现简单,无额外依赖,状态保留完整;

❌ 缺点:所有组件都会挂载在 DOM 树中,组件数量多(如 5 个以上)会增加 DOM 节点数量,可能影响页面渲染性能。

import { useState } from 'react';

// 模拟 Tab 切换场景
const KeepAliveByCSS = () => {
  // 控制当前激活的标签
  const [activeKey, setActiveKey] = useState('tab1');

  return (
    <div>
      <div className="tab-header">
        <button onClick={() => setActiveKey('tab1')}>标签1</button>
        <button onClick={() => setActiveKey('tab2')}>标签2</button>
      </div>
      <div className="tab-content">
        {/* 始终挂载,仅通过 CSS 隐藏 */}
        <div style={{ display: activeKey === 'tab1' ? 'block' : 'none' }}>
          <Tab1 />
        </div>
        <div style={{ display: activeKey === 'tab2' ? 'block' : 'none' }}>
          <Tab2 />
        </div>
      </div>
    </div>
  );
};

// 带状态的子组件
const Tab1 = () => {
  // 切换标签后,输入框内容不会丢失
  const [inputVal, setInputVal] = useState('');
  return <input value={inputVal} onChange={(e) => setInputVal(e.target.value)} placeholder="标签1输入框" />;
};

const Tab2 = () => {
  const [count, setCount] = useState(0);
  return (
    <div>
      <p>计数:{count}</p>
      <button onClick={() => setCount(count + 1)}>增加</button>
    </div>
  );
};

方案 2:使用 react-activation 第三方库(推荐)

这是目前社区内最成熟的方案,它通过将组件渲染到“外部容器”再动态挂载回来的方式,模拟了 Vue 的行为。

核心功能

  • 提供 <KeepAlive> 容器组件,包裹需要缓存的组件即可生效;

  • 内置 useActivate/useDeactivate 钩子函数,分别在组件「激活」和「失活」时触发;

  • 支持缓存控制(如指定缓存 Key、条件缓存);

  • 能保留 DOM 状态(如滚动条位置、输入框焦点)。

使用示例

import { KeepAlive, useActivate, useDeactivate } from 'react-activation';
import { useState } from 'react';

const KeepAliveByLib = () => {
  const [show, setShow] = useState(true);

  return (
    <div>
      <button onClick={() => setShow(!show)}>
        {show ? '隐藏组件' : '显示组件'}
      </button>
      {/* 用 KeepAlive 包裹需要缓存的组件 */}
      {show && (
        <KeepAlive id="cached-component">
          <CachedComponent />
        </KeepAlive>
      )}
    </div>
  );
};

// 被缓存的组件
const CachedComponent = () => {
  const [inputVal, setInputVal] = useState('');
  const [scrollTop, setScrollTop] = useState(0);

  // 组件激活时触发(显示时)
  useActivate(() => {
    console.log('组件被激活');
  });

  // 组件失活时触发(隐藏时)
  useDeactivate(() => {
    console.log('组件被失活');
  });

  // 模拟滚动条状态保留
  const handleScroll = (e) => {
    setScrollTop(e.target.scrollTop);
  };

  return (
    <div>
      <input 
        value={inputVal} 
        onChange={(e) => setInputVal(e.target.value)} 
        placeholder="缓存的输入框" 
      />
      <div 
        style={{ height: '200px', overflow: 'auto', marginTop: '10px' }}
        onScroll={handleScroll}
      >
        {Array.from({ length: 50 }).map((_, index) => (
          <p key={index}>滚动测试行 {index + 1}</p>
        ))}
      </div>
      <p>当前滚动位置:{scrollTop}</p>
    </div>
  );
};

方案 3:全局状态管理 + 状态回显(Redux)

如果不想引入第三方库,也可以通过全局状态缓存实现核心效果,本质是组件卸载前存状态,重新挂载时取状态。

核心思路

在组件 useEffect 的清理函数中,将关键数据(输入框、计数、滚动位置等)保存到全局 Store;重新挂载时再读取初始化。

  • 优点:符合 React 数据流规范,内存占用可控。

  • 缺点

    1. 无法恢复 DOM 状态:如页面的滚动位置、输入框的焦点、已播放的视频进度,需单独编写逻辑处理。
    2. 开发成本高:每个需要缓存的组件都要手动编写保存/恢复逻辑。
  • 补救措施:若要恢复滚动位置,需手动在卸载前记录 scrollTop,并在渲染后通过 window.scrollTo 还原。


三、缓存 React Router 路由组件

实际开发中,最常见的场景是切换路由不丢失页面状态(如列表页滚动位置、表单输入内容),可结合 React Router + react-activation 实现。

核心代码如下:

import { BrowserRouter, Routes, Route, useLocation } from 'react-router-dom';
import { KeepAlive } from 'react-activation';
import Profile from './pages/Profile';
import Home from './pages/Home';
import Settings from './pages/Settings';

// 路由容器组件(获取当前路由路径)
const RouterContainer = () => {
  const location = useLocation();
  const currentPath = location.pathname;

  return (
    <Routes>
      {/* 普通路由(不缓存) */}
      <Route path="/" element={<Home />} />
      <Route path="/settings" element={<Settings />} />
      
      {/* 缓存路由组件:when 控制是否缓存,id 为缓存标识 */}
      <Route
        path="/profile"
        element={
          <KeepAlive id="profile" when={currentPath === "/profile"}>
            <Profile />
          </KeepAlive>
        }
      />
    </Routes>
  );
};

// 根组件
const App = () => {
  return (
    <BrowserRouter>
      <RouterContainer />
    </BrowserRouter>
  );
};

四、 方案选择建议

方案 优点 缺点 适用场景
CSS 隐藏 实现简单、无依赖 DOM 节点冗余、性能一般 少量组件(2-3 个)、简单 tab 切换
react-activation 功能完整、支持 DOM 状态缓存 新增第三方依赖 中大型项目、需完整 keep-alive 效果
全局状态缓存 无额外依赖、贴合状态管理 仅恢复数据、需手动处理 DOM 已用全局状态库、仅需数据缓存

五、总结

  1. React 无原生 keep-alive,但可通过CSS 隐藏、react-activation 库、全局状态缓存3 种方案模拟核心效果;
  2. 简单场景用 CSS 隐藏,中大型项目优先选 react-activation(兼顾易用性和完整性);
  3. 路由组件缓存可结合 React Router + react-activation 实现,核心是通过 KeepAlive 包裹路由元素并指定缓存标识。

Vue - @ 事件指南:原生 / 内置 / 自定义事件全解析

前言

在 Vue 开发中,@v-on 指令的简写,是绑定事件监听的核心语法。很多新手容易混淆不同类型的 @ 事件用法,本文整理了 Vue 中所有常用的 @ 事件类型,包括原生 DOM 事件、内置组件事件、自定义事件,以及提升开发效率的事件修饰符,看完就能直接上手用!

一、 Vue @ 事件的核心分类

Vue 中的 @ 事件本质是对 DOM 事件 / 组件事件的封装,核心分为三大类:

  • 原生 DOM 事件:浏览器自带的基础交互事件
  • Vue 内置组件事件:Vue 官方组件专属的状态监听事件
  • 自定义事件:组件间通信的核心自定义事件

二、原生 DOM 事件

这类事件是浏览器原生支持的 DOM 事件,Vue 可直接通过 @ 绑定,覆盖绝大部分交互场景,按类型整理如下:

1. 鼠标事件

事件语法 说明 常用场景
@click 点击事件(最常用) 按钮点击、卡片跳转
@dblclick 双击事件 列表项编辑、文件重命名
@mouseenter 鼠标进入(不冒泡) 悬浮提示、菜单展开
@mouseleave 鼠标离开(不冒泡) 悬浮提示隐藏、菜单收起
@mousemove 鼠标移动 拖拽跟随、坐标监听
@mousedown 鼠标按下 拖拽开始、按住触发
@mouseup 鼠标松开 拖拽结束、松开停止
@contextmenu 右键菜单事件 自定义右键菜单

2. 键盘事件

事件语法 说明 注意点
@keydown 键盘按下时触发 可监听组合键(如 @keydown.ctrl.s
@keyup 键盘松开时触发 常用 @keyup.enter 监听回车
@keypress 键盘按压时触发 已逐步废弃,推荐用 keydown 替代

3. 表单事件

事件语法 说明 触发时机对比
@input 输入框内容变化 实时触发(每输入一个字符都触发)
@change 表单值变化 失去焦点 / 选择完成后触发(如下拉框选值)
@submit 表单提交事件 点击提交按钮 / 按回车触发
@focus 元素获取焦点 输入框激活、下拉框展开
@blur 元素失去焦点 输入框失活、表单校验

4. 移动端触摸事件

事件语法 说明 适用场景
@touchstart 触摸开始 移动端点击、滑动开始
@touchend 触摸结束 移动端点击完成、滑动结束

5. 页面 / 窗口事件

事件语法 说明 优化建议
@scroll 滚动事件 监听页面滚动加载、导航栏吸顶
@resize 窗口大小变化 响应式布局适配、画布重绘

6.使用示例

<template>
  <div>
    <!-- 点击事件 -->
    <button @click="handleClick">普通点击</button>
    <!-- 键盘事件(监听回车) -->
    <input @keyup.enter="handleSearch" placeholder="按回车搜索" />
    <!-- 表单输入事件 -->
    <input @input="handleInput" @blur="handleBlur" placeholder="实时输入监听" />
  </div>
</template>

<script setup>
const handleClick = () => console.log('按钮被点击');
const handleSearch = () => console.log('执行搜索');
const handleInput = (e) => console.log('实时输入:', e.target.value);
const handleBlur = () => console.log('输入框失活,可做校验');
</script>


三、 Vue 内置组件事件:监听生命周期

Vue 的内置组件(如动画、路由)拥有自己独特的“生命周期事件”,让我们能精准控制交互细节。

内置组件 常用事件 触发时机
<transition> @before-enter / @enter 进入动画开始前与执行中
@after-enter 动画完全结束,常用于清理工作
@leave / @after-leave 离开动画的相关节点
<router-link> @click 点击跳转(Vue Router 内部处理)
@navigate (Vue Router 4+) 导航正式开始时触发

四、 自定义事件:父子通信核心

自定义事件是 Vue 父子组件通信的重要方式,子组件通过 emit 触发事件,父组件通过 @ 监听事件并接收参数。

  1. 子组件触发:使用 emit 抛出事件和数据。

  2. 父组件监听:通过 @ 绑定回调。

<!-- 子组件 Child.vue -->
<template>
  <button @click="sendData">向父组件传值</button>
</template>

<script setup>
  // 定义可触发的自定义事件
  const emit = defineEmits(['custom-event']);

  const sendData = () => {
    // 触发事件并传递参数
    emit('custom-event', { name: 'Vue', version: '3.x' });
  };
</script>

<!-- 父组件 Parent.vue -->
<template>
  <!-- 监听子组件自定义事件 -->
  <Child @custom-event="handleCustomEvent" />
</template>

<script setup>
  import Child from './Child.vue';

  const handleCustomEvent = (data) => {
    console.log('接收子组件数据:', data); // 输出:{ name: 'Vue', version: '3.x' }
  };
</script>


五、 扩展:事件修饰符

Vue 提供事件修饰符简化事件处理逻辑,无需手动调用 e.preventDefault()/e.stopPropagation(),常用修饰符如下:

1. 流程控制

  • .stop阻止冒泡。相当于 e.stopPropagation()
  • .prevent阻止默认行为。常用于 <a> 标签和 <form> 提交。
  • .capture:使用捕获模式触发事件。

2. 触发频率与性能

  • .once只触发一次。之后再点击将失效。

  • .passive提升性能(移动端必用)

3. 按键与鼠标修饰符

  • .enter / .esc / .space:特定按键触发。
  • .left / .right / .middle:限制特定的鼠标按键。

Vue-Vue2与Vue3核心差异与进化

前言

从 Vue 2 到 Vue 3,不仅仅是版本的跳跃,更是底层思想的革新。从 Object.definePropertyProxy,从 Options API 到 Composition API,Vue 3 在性能和开发体验上都实现了质的飞跃。本文将带你系统梳理两者的核心区别。

一、 响应式原理:从“属性拦截”到“对象代理”

响应式系统的升级是 Vue 3 性能提升的关键。

1. Vue 2:Object.defineProperty

  • 原理:初始化时通过递归遍历 data,为每个属性设置 gettersetter

  • 局限性

    • 无法检测到对象属性的新增删除
    • 无法直接监听数组索引的变化和 length 属性。
    • 必须使用 this.$set 等特有 API 来弥补。
    • 递归过程在处理大数据量时存在性能瓶颈。

2. Vue 3:ES6 Proxy

  • 原理:直接监听整个代理对象,拦截所有操作(如 get, set, deleteProperty, has 等)。

  • 优势

    • 原生支持:自动支持动态增删属性、数组下标修改。
    • 懒代理(Lazy Tracking) :只有当访问到深层属性时,才会动态将其转为响应式,大大提升了初始化速度。
    • 性能更好:省去了初始化时繁琐的递归遍历。

二、 编写模式:从“碎片化”到“模块化”

代码组织方式的改变直接影响了大型项目的维护成本。

1. Vue 2:选项式 API (Options API)

  • 痛点:逻辑被强行拆分在 datamethodscomputed 等固定选项中。当一个组件功能复杂时,同一个功能的代码会散落在各处,导致开发者反复上下滚动查找,难以维护。

2. Vue 3:组合式 API (Composition API)

  • 优势:通过 <script setup>,开发者可以按照功能逻辑将代码组织在一起。

  • 逻辑复用:可以轻松地将逻辑抽离成独立的 useHooks 函数,解决了 Vue 2 中 mixin 命名冲突和来源不明的问题。


三、 Vue 3 核心新特性与语法糖

1. 响应式新成员:ref vs reactive

  • ref:万能型。支持基本类型和引用类型,通过 .value 访问(模板中自动解包)。
  • reactive:对象型。仅支持引用类型,直接操作属性,无需 .value

2. defineModel:双向绑定的“减法”

在 Vue 3.4+ 中引入的 defineModel 极大地简化了父子组件通信:

  • Vue 2 做法:需要 props 接收值 + this.$emit('update:xxx') 触发更新。
  • Vue 3 新语法:子组件直接使用 const model = defineModel(),修改 model 的值会自动同步到父组件,代码量骤减。

3. 多根节点模板

  • Vue 2:模板内必须有一个唯一的根节点(通常是 <div>),否则报错。
  • Vue 3:原生支持多个根节点,减少了不必要的 DOM 层级,使 HTML 结构更简洁。

4. 异步处理神器:<Suspense>

  • 新增内置组件,专门用于处理异步组件的加载状态。它提供了 defaultfallback 两个插槽,可以优雅地展示“加载中”和“加载完成”的 UI 切换。

四、 总结:为什么要升 Vue 3?

类别 Vue2 Vue3
响应式原理 Object.defineProperty 逐个属性劫持 Proxy 代理整个对象,懒加载
编写模式 选项式API(Options API) 组合式API(Composition API +
模板规范 仅支持单个根节点 支持多个根节点
数据监听 无法监听对象增删、数组索引 原生支持对象增删、数组下标修改
组件双向绑定 props + emit 手动实现 defineModel 语法糖简化
异步加载 手动处理加载状态 内置 Suspense 组件

Vue2:数组/对象操作避坑大全

前言

在 Vue 2 开发中,你是否遇到过“明明数据变了,视图却没动”的诡异情况?这通常不是代码逻辑问题,而是由于 Vue 2 基于 Object.defineProperty 的响应式原理存在天然的局限性。本文将带你攻克这些响应式盲区。

一、 响应式的“硬伤”:为什么会失效?

Vue 2 在初始化阶段,会遍历 data 中的属性并使用 Object.defineProperty 将其转为 getter/setter

它的核心问题在于:

  1. 无法检测对象属性的添加或删除(因为它只在初始化时进行监听)。
  2. 无法检测数组索引的直接修改和长度变化

二、 对象操作:打破“属性新增”的僵局

1. 新增/删除属性

如果你直接通过 this.obj.newKey = value 赋值,Vue 是无法感知的。

  • 新增属性:使用 this.$set (或全局 Vue.set)。

    • 语法:this.$set(target, key, value)
    • 示例:this.$set(this.user, 'age', 18)
  • 删除属性:使用 this.$delete (或全局 Vue.delete)。

2. 批量修改属性

如果你需要一次性增加多个属性,不要写一堆 $setVue2 可以监听对象引用变化,最高效的方法是替换整个对象引用

// 这种方式 Vue 能够通过监听对象的引用变化来触发更新
this.user = Object.assign({}, this.user, {
  age: 18,
  gender: 'male'
});

// 批量更新user对象属性
this.user = {
  ...this.user,
  age: 20,
  gender: '男',
  address: '北京'
}

三、 数组操作:被“重写”的 7 个方法

在 Vue 2 中,直接执行 this.items[0] = 'new' 是不会触发更新的。解决方案同样是使用 this.$set,以及使用vue重写的相关数组方法。

1. 自动触发更新的方法

只要调用以下方法,Vue 就会自动检测到变化并更新视图:

  • push() / pop():队尾操作
  • unshift() / shift():队头操作
  • splice()最万能,可实现增、删、改。
  • sort():排序。
  • reverse():翻转。

2. 数组的特殊场景

  • 根据索引修改值

    • ❌ 错误:this.items[index] = newValue
    • ✅ 正确:this.$set(this.items, index, newValue)this.items.splice(index, 1, newValue)
  • 修改数组长度

    • ❌ 错误:this.items.length = 0 (清空数组失效)
    • ✅ 正确:this.items.splice(0)this.items = []

四、 进阶补充:Vue 3 是如何解决的?

  • Vue 3 使用了 ES6 Proxy:Proxy 代理的是整个对象而不是属性。

  • 优势:Proxy 可以原生监听到属性的动态添加、删除,以及数组索引的变化,因此在 Vue 3 中,你不再需要使用 $set 了!


五、 总结

  1. vue2对象新增属性:首选 this.$set,批量新增选 Object.assign

  2. vue2数组修改:养成使用 splicepush 等 7 个变异方法的习惯。

  3. 调试技巧:如果视图没更新,先用 console.log 确认数据是否变了,再检查是否触碰了上述响应式盲区。

Vue3:ref 与 reactive 超全对比

前言

在 Vue 3 的 Composition API 中,refreactive 是定义响应式数据的两大基石。很多初学者常纠结于“什么时候该用哪个”。本文将从底层原理到实战场景,带你彻底理清两者的区别。

一、 核心概念对比

1. ref:全能型选手

  • 定义:主要用于定义基本类型(String, Number, Boolean 等),也可以定义引用类型。

  • 本质:通过对原始值进行包装,生成一个具有 .value 属性的对象。对于引用类型,ref 内部会自动调用 reactive 来处理。

  • 访问控制

    • 在 JS 中必须通过 .value 访问;
    • <template> 模板中,Vue 会自动解包,直接写变量名即可,无需加 .value。

2. reactive:对象专家

  • 定义:专门用于定义引用类型(Object, Array, Map, Set)。

  • 本质:基于 ES6 Proxy 实现,直接代理整个对象。

  • 访问控制:像操作普通原生对象一样直接访问属性,无需 .value

    注意: 传入基本类型会触发 Vue 警告且丢失响应式。


二、 深度差异对比

特性 ref reactive
支持类型 基本类型 + 引用类型 仅限 引用类型
JS 访问方式 .value 直接访问属性
模板访问 自动解包,无需 .value 直接访问
底层实现 包装基本类型,内部调用 reactive 处理引用类型 基于 Proxy 深度代理整个对象
替换整个对象 支持 (ref.value = 新对象/新数组) 不支持(直接赋值会丢失代理,失去响应式)
解构支持 直接解构丢失响应式(需 toRefs 直接解构丢失响应式(需 toRefs

三、 使用场景:我该怎么选?

推荐使用 ref 的场景:

  1. 基本类型数据:计数器、开关状态、输入框的值。

  2. 需要重置的数据:例如从后端获取列表后,直接 list.value = res.data

  3. 简单组件逻辑:代码更清晰,.value 提醒这是一个响应式变量。

推荐使用 reactive 的场景:

  1. 复杂业务模型:包含多个相互关联属性的大对象(如用户信息、表单整组数据)。

  2. 追求原生感:不希望在逻辑代码中到处看到 .value

  3. 聚合数据:将一类变量聚合在一个对象中管理,减少变量声明。


四、 高频易错点

1. reactive 直接赋值整个对象会丢失响应式

let state = reactive({ count: 0 });
// ❌ 错误操作:这会导致 state 失去响应式,因为它变成了一个普通的普通对象
state = { count: 1 }; 

// ✅ 正确方案 A (ref):
const state = ref({ count: 0 });
state.value = { count: 1 };

// ✅ 正确方案 B (Object.assign):
Object.assign(state, { count: 1 });

2. 解构 reactive 数据丢失响应式

当你需要从一个响应式对象中提取属性并保持响应式时,必须使用 toRefs,否则会丢失响应式

const props = reactive({ title: 'Vue3', author: 'Gemini' });
// 直接解构:const { title } = props; -> title 只是一个普通的字符串
const { title } = toRefs(props); // -> title 变成了一个 ref,保持响应式

3. Watch 监听的差异

  • 监听 ref:默认只监听 .value 的变化,如果 ref 包裹的是对象,深度监听需要开启 { deep: true }

  • 监听 reactive:默认强制开启深度监听,且无法关闭。


📝 总结

  • ref 是万金油,虽然多了个 .value,但胜在灵活且不易出错。
  • reactive 适合组织复杂的对象数据,但要注意赋值和解构的陷阱。

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

前言

无论是单页面应用(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 缓存加速与调度原理

前言

在前端性能优化中,静态资源加载速度往往是首屏渲染的瓶颈。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资源上传部署,是必备性能优化手段

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

前言

在 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 流式传输:中断超时处理

前言

在开发 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无感刷新

前言

在前后端分离的项目中,为了安全,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单点登录:从同域到跨域实战

前言

在企业级应用集群中,如果用户每打开一个内部系统都要重新输入一次密码,体验将是灾难性的。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 维持全局登录态,通过“授权码交换”实现跨域权限传递。

❌