阅读视图

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

防抖与节流:前端性能优化的两大利器

在现代 Web 开发中,用户交互越来越频繁,而每一次交互都可能触发复杂的逻辑处理或网络请求。如果不加以控制,这些高频操作会带来严重的性能问题。为此,防抖(Debounce)节流(Throttle) 成为了前端开发中不可或缺的性能优化手段。

本文将结合一段实际代码和详细注释,深入浅出地讲解防抖与节流的核心思想、实现方式以及适用场景,并重点解析其中的关键逻辑。


一、为什么需要防抖和节流?

设想这样一个场景:用户在搜索框中输入关键词,每按一次键就发起一次 AJAX 请求获取搜索建议。如果用户快速输入“react”,那么会依次触发 rrereareacreact 五次请求。

  • 问题1:请求开销大
    每次请求都需要消耗带宽、服务器资源,甚至可能造成接口限流。
  • 问题2:用户体验差
    如果请求响应慢,旧的请求结果可能会覆盖新的输入内容,导致显示错乱。

因此,我们需要一种机制来减少不必要的执行次数,只保留关键的操作。这就是防抖和节流要解决的问题。

防抖:在一定时间内,只执行最后一次操作。
节流:每隔固定时间,最多执行一次操作。


二、防抖(Debounce)——“只认最后一次”

1. 核心思想

无论执行多少次,只执行最后一次。

就像王者荣耀中的“回城”技能:如果你在回城过程中被攻击,回城会被打断并重新计时。只有当你完整地等待一段时间后,回城才会真正生效。

2. 代码实现与闭包应用

// 高阶函数 参数或者返回值是函数 (返回值是函数 -> 闭包)
function debounce(fn, delay) {
  var id; // 自由变量,闭包保存
  return function(args) {
    if (id) clearTimeout(id); // 清除已有定时器,重新计时
    var that = this; // 保存 this 上下文
    id = setTimeout(function() {
      fn.call(that, args); // 延迟执行原函数 并绑定正确的this和参数
    }, delay);
    // 这样只有最后一次触发后等待delay毫秒后才会真正执行
  };
}

关键点解析:

  • 闭包的作用id 是一个自由变量,被返回的函数所引用,从而在多次调用之间保持状态。这使得每次触发都能访问并清除上一次的定时器。
  • clearTimeout(id) :确保只有最后一次触发后的 delay 时间才会真正执行函数。
  • this 和参数传递:通过 callapply 确保原函数在正确的上下文中执行,并传入正确的参数。

3. 使用示例

const inputb = document.getElementById('debounce');
let debounceAjax = debounce(ajax, 200);
inputb.addEventListener('keyup', function(e) {
  debounceAjax(e.target.value);
});

用户快速输入时,只有停止输入 200ms 后,才会发送最终的完整关键词请求,极大减少了无效请求。


三、节流(Throttle)——“冷却期内不执行,但最后补一次”

1. 核心思想

每隔一定时间,最多执行一次。

但注意:我们实现的是带尾随执行(trailing)的节流,即在冷却期结束后,如果期间有触发,会补一次执行

就像技能有 CD(冷却时间),但如果你在 CD 期间一直按技能,CD 结束后会自动释放一次。

2. 代码实现与“尾随执行”逻辑

function throttle(fn, delay) {
  let last, deferTimer; // last上一次执行事件  deferTimer延迟执行的定时器
  return function() {
    let that = this;  
    let _args = arguments; // 类数组对象 保存所有参数
    let now = +new Date(); // 拿到当前时间戳  +强制类型转换 毫秒数

    if (last && now < last + delay) {
      // 处于冷却期 上次执行时间存在 且当前时间还没到下次允许执行的时间
      clearTimeout(deferTimer);
      deferTimer = setTimeout(function() {
        last = now;
        fn.apply(that, _args);
      }, delay);
    } else {
      // 已过冷却期,立即执行
      last = now;
      fn.apply(that, _args);
    }
  };
}

重点解析 if (last && now < last + delay) 分支:

  • 条件成立含义:已经执行过至少一次(last 存在),且当前时间距离上次执行不足 delay 毫秒 → 正处于冷却期。
  • 但不能忽略这次触发!因为这可能是用户最后一次有效操作(比如完整输入了“react”)。
  • 所以我们设置一个延迟定时器,计划在冷却期结束后执行。
  • clearTimeout(deferTimer) 的作用:用户可能在冷却期内多次触发,但我们只关心最后一次,所以每次都要清除旧的定时器,只保留最新的。

3. 为什么需要“尾随执行”?

核心原因:避免丢失最后一次有效操作。

假设用户想搜 “react”,在 200ms 内快速打完,而节流 delay = 500ms:

  • 简单节流(无尾随)

    • r(0ms)→ 立即执行
    • re(100ms)→ 被忽略
    • rea(150ms)→ 被忽略
    • react(200ms)→ 被忽略
      → 用户停止输入 但永远不会发送'react' 搜索框显示的是r的结果 而不是用户真正想搜的react
  • 带尾随的节流

    • r(0ms)→ 立即执行(last = 0
    • re(100ms)→ 冷却期,设 timer(600ms 执行)
    • rea(150ms)→ 更新 timer(650ms)
    • react(200ms)→ 更新 timer(700ms)
      → 用户停止输入后,在 700ms 自动执行 ajax('react')结果正确

4. 什么时候不需要尾随?

按钮防连点:用户点击“提交”按钮,你希望 2 秒内只能点一次。
这种情况下,不需要在 2 秒后自动再提交一次!此时应使用无尾随的简单节流


四、防抖 vs 节流:如何选择?

特性 防抖(Debounce) 节流(Throttle)
执行时机 停止触发后 delay ms 执行 每隔 delay ms 最多执行一次
是否保证最后一次 ✅ 是 ✅(带尾随时)
典型场景 搜索建议、窗口 resize 滚动加载、鼠标移动、按钮点击(防连点)
类比 回城技能(被打断重计时) 技能 CD(冷却后可再放)
  • 搜索建议 → 用防抖:用户输入是连续的,我们只关心最终结果。
  • 滚动加载 → 用节流:用户持续滚动,我们需要定期检查是否到底部,不能等到停止滚动才加载。

五、总结

防抖和节流虽然都是用于限制函数执行频率,但它们的触发逻辑和适用场景截然不同

  • 防抖强调“只执行最后一次”,适用于用户意图明确、操作连续的场景,如搜索、表单校验。
  • 节流强调“定期执行”,适用于高频但需周期性响应的场景,如滚动、拖拽、游戏帧更新。

而我们在实现节流时,特别加入了尾随执行(trailing) 机制,这是为了兼顾性能与用户体验——既避免了过度请求,又确保不会丢失用户的最终操作。

正如注释中所说:
“核心原因:避免丢失最后一次有效操作。”

通过合理运用闭包、定时器和上下文绑定,我们不仅实现了功能,还保证了代码的健壮性和可复用性。这些技巧,正是前端工程师在性能优化道路上的必备武器。


小提示:在实际项目中,Lodash 等工具库已提供了成熟的 debouncethrottle 实现,支持更多选项(如 leadingtrailing 开关)。但理解其底层原理,才能在复杂场景中灵活应对。

希望本文能帮助你更清晰地掌握防抖与节流的本质。欢迎在评论区分享你的使用经验!

大模型Function Calling的函数如何调用的?

在真实开发中,大模型的 Function Calling(函数调用)不是“模型直接执行代码”,而是一套“声明-生成-解析-执行-反馈”的安全闭环机制。以下是我在项目中(如智能编程助手、自动化运维 Agent)的实际做法:

一、核心流程(生产级标准做法)

二、具体步骤

1. 注册函数

在调用 LLM 前,向模型描述有哪些函数可用(OpenAI 格式为例):

const tools = [{
  type: "function",
  function: {
    name: "read_file",
    description: "读取项目中的文件内容",
    parameters: {
      type: "object",
      properties: {
        path: { type: "string", description: "文件相对路径,如 src/main.ts" }
      },
      required: ["path"]
    }
  }
}];

关键:参数必须有明确 schema,防止模型传非法值。

2. 调用 LLM 并启用工具

const response = await openai.chat.completions.create({
  model: "gpt-4o",
  messages: [...],
  tools,               // ← 注册的函数列表
  tool_choice: "auto"  // 模型可自主决定是否调用
});

3. 解析模型返回

模型不会执行函数,而是返回结构化调用请求:

{
  "tool_calls": [
    {
      "id": "call_abc123",
      "function": {
        "name": "read_file",
        "arguments": "{"path":"src/utils.ts"}"
      }
    }
  ]
}

4. 安全执行函数

  • 绝不直接 eval!而是通过白名单映射:
const toolMap = {
  read_file: (args) => {
    // 1. 校验路径是否在项目目录内
    if (!args.path.startsWith("src/")) throw new Error("Access denied");
    // 2. 读取文件(沙箱隔离)
    return fs.readFileSync(args.path, "utf8");
  }
};

const result = await toolMap[funcName](parsedArgs);
  • 所有操作在受限环境中执行(如 Docker 沙箱、只读文件系统)。

5. 将结果反馈给模型

把函数执行结果作为“tool message”送回对话:

messages.push({
  role: "tool",
  tool_call_id: "call_abc123",
  content: result  // 文件内容
});

→ 模型基于此生成下一步(继续调用 or 最终回答)。

三、真实项目中的关键实践

问题 解决方案
模型传错参数(如 path: "../../../etc/passwd") 参数校验 + 路径归一化 + 白名单目录
函数执行超时/卡死 设置 timeout(如 5s) + AbortController
敏感操作(如删文件) 禁止高危函数,或需用户二次确认
多次调用循环 限制最大 tool_calls 次数(如 5 次)
调试困难 记录完整 trace:prompt → tool_call → result → final answer

四、为什么不用模型直接“写代码执行”?

  • 安全风险极高(任意代码执行 = RCE 漏洞);
  • 不可控(无法限流、审计、降级);
  • 不可靠(模型可能生成语法错误代码)。

正确做法:Function Calling 是“受控 API 调用”,不是“代码生成执行”

总结:

在实际项目中,大模型的 Function Calling 是一个安全代理机制

  1. 我们先向模型声明可用函数及其参数 schema
  2. 模型返回结构化调用请求(非执行);
  3. 后端严格校验参数、权限、路径,在沙箱中执行真实函数;
  4. 将结果反馈给模型,形成多轮推理闭环。

核心原则:模型只负责“决策”,不负责“执行” ——这是生产系统安全落地的底线。

海云前端丨前端开发丨简历面试辅导丨求职陪跑

React 中的 Props:组件通信与复用的核心机制

在 React 的组件化开发范式中,Props(属性) 是连接父子组件、实现数据流动与功能定制的关键桥梁。如果说状态(state)是组件内部的“私有记忆”,那么 Props 就是外部世界与组件对话的“公共接口”。通过 Props,父组件可以向子组件传递数据、回调函数、甚至其他组件本身,从而构建出高度可复用、可组合且职责清晰的 UI 体系。

组件即函数:参数驱动的 UI 单元

React 中的组件本质上是 JavaScript 函数。正如函数通过参数接收外部输入,组件也通过 props 对象接收来自父组件的配置信息:

function Greeting(props) {
  const { name, message, showIcon } = props;
  return (
    <>
      {showIcon && <span>👋</span>}
      <div>{name} {message}</div>
    </>
  );
}

当在父组件中使用 <Greeting name="张三" message="你好" showIcon /> 时,这些属性会被打包成一个对象传入 Greeting 函数。这种设计使得组件行为完全由输入决定,符合纯函数的思想,极大提升了可预测性与可测试性。

类型约束:提升健壮性与协作效率

为避免因传入错误类型的数据导致运行时错误,React 社区广泛采用 prop-types 库进行运行时类型检查:

import PropTypes from 'prop-types';

Greeting.propTypes = {
  name: PropTypes.string.isRequired,
  message: PropTypes.string,
  showIcon: PropTypes.bool
};

通过声明 name 为必需的字符串、showIcon 为布尔值,开发者能在控制台收到清晰的警告信息,尤其在团队协作中,这相当于一份自文档化的 API 契约,显著降低沟通成本。

children:内容分发的灵活通道

除了普通属性,React 还提供了一个特殊 prop —— children,用于传递组件标签之间的内容:

const Card = ({ children, className = '' }) => {
  return <div className={`card ${className}`}>{children}</div>;
};

// 使用
<Card className="user-card">
  <h2>张三</h2>
  <p>高级前端工程师</p>
</Card>

children 可以是任意 JSX、文本或组件,使得 Card 成为一个通用容器,其内部结构由使用者自由定义。这种模式类似于 Web Components 中的 <slot>,是实现高阶组件和布局复用的核心技巧。

组件作为 Prop:极致的定制能力

更进一步,Props 甚至可以接收整个组件作为值,从而实现动态 UI 结构:

const MyHeader = () => <h2 style={{ margin: 0, color: 'blue' }}>自定义标题</h2>;
const MyFooter = () => (
  <button onClick={() => alert('关闭弹窗')}>关闭</button>
);

<Modal HeaderComponent={MyHeader} FooterComponent={MyFooter}>
  <p>这是一个弹窗内容</p>
</Modal>

Modal 内部,通过 {<HeaderComponent />} 动态渲染传入的组件:

function Modal({ HeaderComponent, FooterComponent, children }) {
  return (
    <div style={styles.overlay}>
      <div style={styles.modal}>
        <HeaderComponent />
        <div style={styles.content}>{children}</div>
        <FooterComponent />
      </div>
    </div>
  );
}

这种方式将模态框的头部、尾部与主体内容完全解耦,调用方可以按需注入任意逻辑,使 Modal 具备极强的通用性和扩展性。

状态与 Props 的分工协作

在一个典型应用中,状态通常集中在上层组件(如页面级组件)管理,而下层 UI 组件则通过 Props 接收数据与行为:

// App.jsx(持有状态)
function App() {
  const [user] = useState({ name: "张三", role: "前端工程师" });
  return <Card><UserInfo user={user} /></Card>;
}

// UserInfo.jsx(仅展示)
function UserInfo({ user }) {
  return (
    <div>
      <h2>{user.name}</h2>
      <p>{user.role}</p>
    </div>
  );
}

这种“状态提升”模式确保了数据流的单向性:父组件负责数据来源与更新逻辑,子组件专注渲染。当需求变化时,只需调整父组件的状态管理,子组件无需修改,极大增强了系统的可维护性。

样式传递:兼顾封装与灵活性

组件常需支持自定义样式。通过 className Prop,可在保留内部默认样式的前提下,允许外部覆盖:

/* Card.css */
.card {
  border: 1px solid #ddd;
  border-radius: 8px;
  padding: 1rem;
}
// 合并类名
<div className={`card ${className}`}>{children}</div>

这样,<Card className="user-card"> 既能继承 .card 的基础样式,又能应用 .user-card 的特定风格,实现样式层面的“开闭原则”。

总结

Props 不仅是数据传递的通道,更是 React 组件设计哲学的体现:通过明确的输入输出契约,构建可组合、可复用、可测试的 UI 单元。从简单的字符串属性,到复杂的组件函数与嵌套内容,Props 提供了多层次的定制能力。配合类型检查、children 模式与组件作为 Prop 的高级用法,开发者能够像搭积木一样,将小型组件组装成复杂界面,同时保持各部分的独立性与清晰职责。掌握 Props 的各种使用场景,是编写高质量 React 应用的基石。

React Hooks:函数组件的状态与副作用管理艺术

在现代 React 开发中,函数组件已不再是“无状态”的代名词。借助 Hooks——以 use 开头的一系列内置函数,开发者可以在不编写类的前提下,轻松管理组件的状态、执行副作用、订阅外部数据源,甚至自定义逻辑复用机制。这一设计不仅让代码更贴近原生 JavaScript 的表达习惯,也推动了组件逻辑的清晰化与模块化。

useState:声明响应式状态

useState 是最基础的 Hook,用于在函数组件中引入可变状态:

const [num, setNum] = useState(0);

它返回一个包含当前状态值和更新函数的数组。值得注意的是,useState 的初始值可以是一个函数,适用于需要复杂同步计算的场景:

const [num, setNum] = useState(() => {
  const a = 1 + 2;
  const b = 2 + 3;
  return a + b; // 返回确定的初始值
});

这种形式确保初始化逻辑仅在组件首次渲染时执行一次,避免不必要的重复计算。但需注意:该函数必须是同步且纯的,不能包含异步操作(如 fetch),因为状态必须在渲染前确定。

此外,setNum 不仅能接收新值,还可接受一个函数,其参数为上一次的状态:

<button onClick={() => setNum(prev => prev + 1)}>
  {num}
</button>

当状态更新依赖于前一状态时(如计数器、列表追加),使用函数式更新能避免因闭包捕获旧值而导致的竞态问题,确保状态演进的正确性。

useEffect:统一处理副作用

如果说 useState 负责“记忆”,那么 useEffect 就负责“行动”。它用于执行副作用操作——即那些不影响组件渲染结果但必须发生的逻辑,如数据请求、定时器、DOM 操作等。

useEffect(() => {
  console.log('组件挂载完成');
}, []);

通过传入空依赖数组 [],该副作用仅在组件首次挂载后执行一次,等效于类组件中的 componentDidMount

当依赖项变化时,useEffect 会重新运行:

useEffect(() => {
  console.log(`num 变为 ${num}`);
}, [num]);

这类似于 componentDidUpdate,可用于监听特定状态或 props 的变化并作出响应。

清理副作用:防止内存泄漏

许多副作用需要在组件卸载或重新执行前进行清理,例如清除定时器、取消网络请求、移除事件监听器等。useEffect 支持返回一个清理函数

useEffect(() => {
  const timer = setInterval(() => {
    console.log(num);
  }, 1000);

  return () => {
    console.log('清除定时器');
    clearInterval(timer);
  };
}, [num]);

该清理函数会在以下两种情况下被调用:

  1. 组件卸载时:释放资源,防止内存泄漏;
  2. 下一次副作用执行前(若依赖项变化):先清理旧副作用,再执行新副作用。

这种机制确保了副作用的生命周期与组件状态严格同步,避免了常见的“已卸载组件仍尝试更新状态”错误。

副作用的本质:打破纯函数的边界

React 组件本质上应是一个纯函数:给定相同的 props 和 state,始终返回相同的 JSX。而副作用(如修改全局变量、发起网络请求、改变 DOM)则打破了这一原则,因其结果具有不确定性或对外部环境产生影响。

例如,以下函数存在副作用:

function add(nums) {
  nums.push(3); // 修改了外部数组
  return nums.reduce((a, b) => a + b, 0);
}

调用后,原始 nums 数组被改变,后续代码行为不可预测。而在 React 中,useEffect 正是将这类“不纯”的操作集中管理的容器,使主渲染逻辑保持纯净,提升可测试性与可维护性。

实际应用:数据获取与条件渲染

结合 useStateuseEffect,可实现典型的数据驱动 UI:

function App() {
  const [data, setData] = useState(null);

  useEffect(() => {
    fetch('/api/data')
      .then(res => res.json())
      .then(setData);
  }, []);

  return data ? <div>{data}</div> : <p>加载中...</p>;
}

这里,数据请求作为副作用在挂载后执行,成功后通过 setData 触发重新渲染,展示最新内容。整个流程清晰、线性,无需关心生命周期钩子的切换。

总结

React Hooks 通过 useStateuseEffect 等核心 API,将状态管理和副作用处理从类组件的生命周期中解放出来,赋予函数组件完整的逻辑表达能力。它们以声明式的方式描述“何时做什么”,而非“在哪个阶段做什么”,更符合直觉。同时,依赖数组机制强制开发者显式声明副作用的触发条件,提升了代码的可读性与健壮性。掌握 Hooks,不仅是使用现代 React 的必备技能,更是迈向函数式、响应式前端开发思维的关键一步。

第3章 Nest.js拦截器

3.1 拦截器介绍

Nest.js的拦截器和axios的拦截器是类似的,可以在网络请求处理的前后去执行额外的逻辑。拦截器从字面意思理解就是拦截,假设有流程A->B,拦截器要做的是A到B的过程中,将内容拦截下来处理后再丢给B,变成了A->拦截器->B。

在网络请求的逻辑中,拦截器的拦截位置如下:

  • 客户端请求->拦截器(前置逻辑)->路由处理器->拦截器(后置逻辑)->客户端响应。

Nest.js拦截器效果如图3-1所示。

image-20251212195404071

图3-1 Nest.js拦截器

Nest.js拦截器主要的用途有以下5点:

(1)统一响应格式:将返回数据包装成统一格式。

(2)日志记录:记录请求耗时、请求参数等信息。

(3)缓存处理:对响应数据进行缓存。

(4)异常映射:转换异常类型。

(5)数据转换:对响应数据进行序列化/转换。

在英语AI项目中,主要使用到第5点数据转换,因此我们主要学习这一点。

3.2 拦截器创建

如表1-2所示,可以通过nest g itc interceptor快速创建一个拦截器(interceptor可以替换为任何你想取的拦截器名称)。通过该命令会在src文件夹下创建interceptor文件夹,而interceptor文件夹下存放interceptor.interceptor.ts文件。

根据命令的生成规则,我们知道文件夹和文件的名称取决于我们命令对拦截器的名称,从而生成xxx文件夹和xxx.interceptor.ts文件。并且在这唯一的文件中,会提前生成好对应的Demo示例。

//src/interceptor/interceptor.interceptor.ts
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Observable } from 'rxjs';

@Injectable()
export class InterceptorInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    return next.handle();
  }
}

拦截器有两种使用方式:

(1)局部使用。

(2)全局使用。

当想局部使用时,例如只想在src文件夹下的user模块使用,我们只需要注册到user模块中。那怎么注册?有3种注册方式,在user.module.ts、user.controller.ts以及user.controller.ts都可以注册,最主要的区别在于局部作用范围不同。Nest.js拦截器局部注册如表3-1所示。

表3-1 Nest.js拦截器局部注册

注册方式 作用范围 代码位置 优点 缺点
模块级别 整个模块所有控制器 user.module.ts 统一管理,自动应用到所有路由 无法灵活排除某些路由
控制器级别 单个控制器所有路由 user.controller.ts 控制器粒度控制 需在每个控制器添加装饰器
路由级别 单个路由方法 user.controller.ts 最精细的控制 代码重复,管理复杂

局部使用的具体代码不演示,可通过AI或者官方文档学习使用。

3.3 全局拦截器使用

在英语AI项目中会使用到全局使用,我们这里学习具体如何全局使用。步骤为以下2步:

(1)使用nest g itc <拦截器名称>快速创建一个拦截器。

(2)将拦截器注册到main.ts文件中,即在main.ts文件中导入刚创建的拦截器,并且使用Nest应用程序实例方法useGlobalInterceptors()。

// main.ts文件
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { InterceptorInterceptor } from './interceptor/interceptor.interceptor';
async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalInterceptors(new InterceptorInterceptor());
  await app.listen(process.env.PORT ?? 3000);
}

bootstrap();

当然由于InterceptorInterceptor拦截器是一个类,所以我们需要使用new运算符创建拦截器的实例以供使用。到这里,InterceptorInterceptor拦截器就是全局使用,即每一个接口都会经过该拦截器。Nest.js全局注册的官方文档如图3-2所示。

image-20251212212343423

图3-2 Nest.js拦截器全局注册

此时来编写InterceptorInterceptor拦截器内的逻辑,可见引入了来自rxjs的Observable类,rxjs是Nest.js内部自带的,主要用于处理流的,使用频率不高。通常获取数据需要区分同步与异步,同步直接获取,而异步通过Promise的then或者catch方法获取。如果此时有rxjs,就不需要我们去关注获取的数据是同步或者异步的问题,减少心智负担。rxjs会将这些数据统一转成一个数据流,然后通过管道(pipe)去接收,接收到之后可由我们处理该数据格式,无论是通过map遍历处理还是filter过滤等等,最终将处理好的数据格式返回就行。

以上是rxjs的核心理念,除此之外,它还可以同时处理多个异步,而then或者catch方法每次只能处理一个。

像InterceptorInterceptor拦截器中的所返回的next.handle()就是一个Observable(数据流),所以我们需要通过pipe(管道)去接收数据然后使用rxjs的map方法对数据处理之后再返回数据。

我们将原始数据包裹在一个标准响应结构中,添加了时间戳、请求路径、业务状态码、成功标志和自定义消息。这样确保了所有经过此拦截器的HTTP响应都遵循统一的JSON格式,包括 { timestamp, data, path, message, code, success } 等标准化字段,前端可以统一处理和错误追踪。

// src/interceptor/interceptor.interceptor.ts文件
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Observable, map } from 'rxjs';
import { Request } from 'express';

@Injectable()
export class InterceptorInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    // 将通用的执行上下文切换到HTTP特定的上下文
    const ctx = context.switchToHttp();
    // 获取当前HTTP请求的详细对象,包含了请求方法、URL、请求头、参数、主体等所有信息。
    const request = ctx.getRequest<Request>();
    return next.handle().pipe(map((data) => {
      return {
        timestmap: new Date().toISOString(),
        data: data,
        path: request.url,
        message: 'success',//业务逻辑自定义
        code: 200,//业务逻辑自定义
        success: true,
      };
    }));
  }
}

此时在浏览器的URL输入http://localhost:3000/user/123,访问带参数的get请求,get请求拦截效果如图3-3所示。在这里体现的是:路由处理器->拦截器(后置逻辑)->客户端响应。

image-20251212220853053

图3-3 Nest.js全局拦截器-get请求拦截效果

message和code字段属于业务逻辑的部分,后续完成英语AI项目时,会根据业务实际逻辑去自定义设置。

3.4 优化全局拦截器

但此时全局拦截器还有一个很大的Bug,假如接口返回一个很大的数据,我们通过BigInt数据类型去处理返回,那么在通过全局拦截器时就会出现报错情况,全局拦截器处理BigInt类型报错如图3-4所示。

image-20251212222211323

图3-4 全局拦截器处理BigInt类型报错

报错是error TS2322: Type 'bigint' is not assignable to type 'string'。即bigint类型无法赋值给string类型,这是很正常的。因为全局拦截器的这些参数都是通过JavaScript标准内置对象JSON.stringify()进行格式化的,而JSON.stringify()是没办法处理BigInt值的。在MDN文档中是这样表述这一异常情况:当尝试去转换 BigInt类型的值会抛出TypeError("BigInt value can't be serialized in JSON")(BigInt 值不能 JSON 序列化)。

// src/app.service.ts文件
import { Injectable } from '@nestjs/common';

@Injectable()
export class AppService {
  getHello() {
    return BigInt(123456789123456789123456789)
  }
}

所以我们需要针对BigInt类型的值去处理,通过编写transformBigInt方法去单独处理这一情况,主要处理的事情是当遇到BigInt类型的值就将它转成一个字符串。

const transformBigInt = (data: any) => {
  if (typeof data === 'bigint') {
    return data.toString();
  }
  return data;
};

此时将接口(get请求)返回给用户的data数据放入transformBigInt方法中即可。

// src/interceptor/interceptor.interceptor.ts文件
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Observable, map } from 'rxjs';
import { Request } from 'express';

const transformBigInt = (data: any) => {
  if (typeof data === 'bigint') {
    return data.toString();
  }
  return data;
};

@Injectable()
export class InterceptorInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const ctx = context.switchToHttp();
    const request = ctx.getRequest<Request>();
    return next.handle().pipe(map((data) => {
      return {
        timestmap: new Date().toISOString(),
        data: transformBigInt(data),
        path: request.url,
        message: 'success',//业务逻辑自定义
        code: 200,//业务逻辑自定义
        success: true,
      };
    }));
  }
}

但此时还会报错同样的问题(Type 'bigint' is not assignable to type 'string'),这是很正常的。我们来梳理下流程:

(1)接口返回数据给前端。

(2)全局拦截器拦截接口返回的数据进行处理。

(3)全局处理后的数据返回给前端。

我们已经在全局拦截器中处理好类型转换问题(BigInt转String),如果还有问题,就只能在第一步的接口返回数据给前端的步骤中。前端访问的是接口,而接口是体现形式是路由,路由层从业务层获取数据返回给前端。因此在业务层的数据是BigInt类型,则路由层所拿到的数据也会是BigInt类型。由于Nest.js是强制使用TypeScript的,所以我们需要到app.controller.ts文件中将get默认请求所返回的类型限制从string改成any类型或者string和bigint的联合类型。此时就能正常运行代码。

// src/app.controller.ts
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';

@Controller()
export class AppController {
  constructor(private readonly appService: AppService) { }

  @Get()
  getHello(): string | bigint {
    return this.appService.getHello();
  }
}

出于严谨的考虑,我们需要处理相应的边界判断,假如BigInt类型在数组里,在对象里呢?原有的处理方式就又解析不了了。

return [BigInt(123456789123456789123456789)];
return { a: BigInt(123456789123456789123456789) };

所以需要进一步强化transformBigInt方法,对数组遍历处理内部可能存在的BigInt类型,而对象则通过Object.entries()静态方法将对象切换成保存键值对的二维数组后,遍历键值对并针对其中的value值处理可能存在的BigInt类型,最后通过Object.fromEntries()静态方法将键值对形式的二维数组重新转换回原始对象。

  • 对象打印效果:{ foo: "bar", baz: 42 }。

  • 将可迭代对象切成二维数组:[ ['foo', 'bar'], ['baz', 42] ]。

将对象切成二维数组更方便找到键值对的值并进行遍历操作。

const transformBigInt = (data: any) => {
  if (typeof data === 'bigint') {
    return data.toString();
  }
  if(Array.isArray(data)){
    return data.map(transformBigInt);
  }
  if(typeof data === 'object' && data !== null){
    return Object.fromEntries(Object.entries(data).map(([key, value]) => [key, transformBigInt(value)]));
  }
  return data;
};

做完以上的优化后,我们会发现接口要返回Date日期没办法正常返回给前端了,因为我们把对象全部都处理了,而JavaScript标准内置对象Date的使用是通过new运算符调用的实例对象,实例对象也是对象,也会被transformBigInt方法一并处理,所以在判断对象的内部逻辑中还需要判断是否是Date类型,若为Date类型则直接原路返回,不处理。

if(typeof data === 'object' && data !== null){
  if(data instanceof Date){
    return data
  }
  return Object.fromEntries(Object.entries(data).map(([key, value]) => [key, transformBigInt(value)]));
}

完整的全局拦截器如下代码所示,后续英语AI项目中,会将该全局拦截器直接拿过去使用。

// src/interceptor/interceptor.interceptor.ts文件
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Observable, map } from 'rxjs';
import { Request } from 'express';
//同步 异步 then catch ->数据流->pipe -> map filter -> 返回



const transformBigInt = (data: any) => {
  if (typeof data === 'bigint') {
    return data.toString();
  }
  if(Array.isArray(data)){
    return data.map(transformBigInt);
  }
  if(typeof data === 'object' && data !== null){
    if(data instanceof Date){
      return data
    }
    return Object.fromEntries(Object.entries(data).map(([key, value]) => [key, transformBigInt(value)]));
  }
  return data;
};

@Injectable()
export class InterceptorInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const ctx = context.switchToHttp();
    const request = ctx.getRequest<Request>();
    return next.handle().pipe(map((data) => {
      return {
        timestmap: new Date().toISOString(),
        data: transformBigInt(data),
        path: request.url,
        message: 'success',//业务逻辑自定义
        code: 200,//业务逻辑自定义
        success: true,
      };
    }));
  }
}

接下来对异常也格式化统一处理一下,逻辑思路与全局拦截器类似。当前端发起不符合规范和要求的网络请求,后端就会返回异常信息,方便前端去统一处理。

image-20251212235546507

图3-5 异常情况的处理

此时我们需要总结nest命令的表1-2,找到filter命令来生成一个过滤器。命令是:nest g f <过滤器名称>,我们就通过nest g f exceptionFilter来生成一份过滤器吧。成功在src文件夹下创建exception-filter文件夹和exception-filter文件夹下的exception-filter.filter.ts文件,这些生成文件的命名规则都是一致的,不再赘述。

// src/exception-filter/exception-filter.filter.ts
import { ArgumentsHost, Catch, ExceptionFilter } from '@nestjs/common';

@Catch()
export class ExceptionFilterFilter<T> implements ExceptionFilter {
  catch(exception: T, host: ArgumentsHost) {}
}

通过以上exception-filter.filter.ts文件的代码,我们发现异常处理@Catch()装饰器是空的,空的表示处理所有的异常操作,包括非HTTP请求都会处理,但我希望这个业务只处理和HTTP相关的异常就可以了。所以我们需要从@nestjs/common中引入一个HttpException类,然后让@Catch()装饰器去继承HttpException类就可以了。

// src/exception-filter/exception-filter.filter.ts
import { ArgumentsHost, Catch, ExceptionFilter, HttpException } from '@nestjs/common';

@Catch(HttpException)
export class ExceptionFilterFilter<T extends HttpException> implements ExceptionFilter {
  catch(exception: T, host: ArgumentsHost) {}
}

在这里我们可以看到这个很有意思的设计理念,通过Nest命令生成的内容,它希望我们都能用得上,这种思想和TypeScript所想表达的含义是一致的,只写用得上且必要的部分。因此在通过Nest CLI 在生成过滤器模板时,会会默认使用 @Catch()(不带任何参数),示例性地展示如何捕获所有异常。但它只是一个类模板,需要我们手动把它注册为全局过滤器,或者在控制器上使用。

只有当我们明确在@Catch()中指定具体的异常类型(如 @Catch(HttpException) 或 @Catch(WsException)),过滤器才会从“捕获所有异常”转变为“仅处理特定类型的异常”。如图3-6所示的官方文档也说明了不同协议层(HTTP 与 WebSocket)对应的异常类型不同,因此需要在 @Catch() 中明确指定对应的异常类型。

image-20251212235819873

图3-6 HTTP异常过滤层的说明

接下来我们来对异常处理情况进行统一的格式化处理。这里的code(异常状态码)就不采用我们自定义的,而是使用exception内部定义的状态码,因为Nest内置的HttpException已经为所有常见错误定义了标准化的状态码(如 400、401、403、404、500 等),这些状态码符合 HTTP 协议本身的语义。直接使用exception.getStatus()可以确保服务端返回的错误信息在网络层面是可预测和通用的。Nest.js内置异常处理层说明如图3-7所示。

image-20251213000123007

图3-7 Nest.js内置异常处理层说明

当token过期了,exception.getStatus()会自动识别并设置成401状态码,没有权限则403状态码。因此exception.getStatus()会自动化的根据实际情况去调整,非常方便。对应的详细讲解可阅读Nest.js的官方文档:Exception filters | NestJS - A progressive Node.js framework

如果再自定义一套error code,就等于需要维护两套错误体系:HTTP 状态码 + 我们自己额外设计的业务错误码,这会造成重复劳动、文档负担加重以及维护难度上升。而直接使用 HttpException 内部的状态码可以保持异常捕获逻辑与框架一致,不需要额外重复造轮子。

// src/exception-filter/exception-filter.filter.ts文件
import { ArgumentsHost, Catch, ExceptionFilter,HttpException } from '@nestjs/common';
import { Request, Response } from 'express';
@Catch(HttpException)
export class ExceptionFilterFilter<T extends HttpException> implements ExceptionFilter {
  catch(exception: T, host: ArgumentsHost) {
    const ctx = host.switchToHttp()
    const request = ctx.getRequest<Request>()
    const response = ctx.getResponse<Response>()
    return response.status(exception.getStatus()).json({
      timestamp: new Date().toISOString(),
      path: request.url,
      message: exception.message,
      code: exception.getStatus(),
      success: false,
    })
  }
}

最后,过滤器和拦截器一样,在main.ts文件中全局注册一下,则可以作用于整个项目的异常情况处理。

// src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { InterceptorInterceptor } from './interceptor/interceptor.interceptor';
import { ExceptionFilterFilter } from './exception-filter/exception-filter.filter';
async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalInterceptors(new InterceptorInterceptor());
  app.useGlobalFilters(new ExceptionFilterFilter());
  await app.listen(process.env.PORT ?? 3000);
}

bootstrap();

全局异常情况的过滤处理效果如图3-8所示。

image-20251212235417767

图3-8 全局异常情况的过滤处理效果

面试官 : “ 说一下 Map 和 WeakMap 的区别 ? ”

一、Map vs WeakMap

特性 Map WeakMap
键的类型 任意类型(基本类型 / 引用类型) 仅支持引用类型(对象)
键的引用特性 强引用:键对象不会被 GC 回收 弱引用:键对象无其他引用时,会被 GC 自动回收(键值对随之消失)
遍历性 支持(keys ()/values ()/entries ()/forEach/for...of) 不支持(无遍历方法、无 size 属性)
键的枚举 / 获取 可获取所有键(如 Array.from (map.keys ())) 无法获取 / 枚举所有键(无 API)
常用 API set/get/has/delete/clear/size set/get/has/delete(无 clear/size)
内存占用 键对象未手动删除则一直占用 自动回收无引用的键,内存更友好
使用场景 需遍历 / 枚举、键为基本类型、长期存储键值对 临时关联数据(如 DOM 元素→元数据)、避免内存泄漏

核心差异:弱引用

  • Map 对键是强引用:即使键对象外部无引用,Map 仍持有该对象,GC 不会回收,可能导致内存泄漏;
  • WeakMap 对键是弱引用:键对象仅被 WeakMap 引用时,GC 会回收该对象,同时 WeakMap 中对应的键值对也会被移除(无需手动删除)。

示例

// Map:强引用导致内存泄漏风险
const map = new Map();
let obj = { id: 1 };
map.set(obj, "data");
obj = null; // 手动置空,但map仍引用obj,GC不会回收

// WeakMap:弱引用自动回收
const weakMap = new WeakMap();
let obj2 = { id: 2 };
weakMap.set(obj2, "data");
obj2 = null; // obj2无其他引用,GC回收后,weakMap中该键值对消失

二、Set vs WeakSet

特性 Set WeakSet
值的类型 任意类型(基本类型 / 引用类型) 仅支持引用类型(对象)
值的引用特性 强引用:值对象不会被 GC 回收 弱引用:值对象无其他引用时,会被 GC 自动回收(值随之移除)
遍历性 支持(keys ()/values ()/entries ()/forEach/for...of) 不支持(无遍历方法、无 size 属性)
值的枚举 / 获取 可获取所有值(如 Array.from (set)) 无法获取 / 枚举所有值(无 API)
常用 API add/has/delete/clear/size add/has/delete(无 clear/size)
内存占用 值对象未手动删除则一直占用 自动回收无引用的值,内存更友好
使用场景 需遍历 / 枚举、值为基本类型、存储唯一值集合 存储临时对象(如 DOM 元素集合)、避免内存泄漏

核心差异:弱引用

  • Set 对值是强引用:值对象即使外部无引用,Set 仍持有,GC 不回收;
  • WeakSet 对值是弱引用:值对象仅被 WeakSet 引用时,GC 会回收该对象,WeakSet 中对应的项也会被移除。

示例

// Set:强引用
const set = new Set();
let obj = { id: 1 };
set.add(obj);
obj = null; // set仍引用obj,GC不回收

// WeakSet:弱引用
const weakSet = new WeakSet();
let obj2 = { id: 2 };
weakSet.add(obj2);
obj2 = null; // obj2无其他引用,GC回收后,weakSet中该值消失

三、Map vs Set 区别(补充知识)

Map 和 Set 都是 ES6 新增的有序集合(迭代顺序为插入顺序) ,均为强引用、支持遍历、可存储唯一值,但核心定位和数据结构完全不同,以下是详细对比:

特性 Map Set
核心定位 键值对集合(键→值映射) 值的集合(仅存储唯一值,无键)
存储形式 [key, value] 键值对,键唯一、值可重复 单个值(value),值必须唯一
重复判定规则 键唯一(NaN 视为相同,对象引用不同则视为不同) 值唯一(规则同 Map 键的判定)
核心 API(增) set(key, value):按键存值 add(value):添加值
核心 API(查) get(key):按键取值;has(key):判断键是否存在 has(value):判断值是否存在(无 get
核心 API(删) delete(key):按键删除键值对 delete(value):按值删除项
遍历方式 可遍历键(keys())、值(values())、键值对(entries() 可遍历值(keys()/values() 等价,entries() 返回 [value, value]
长度 / 大小 size 属性:返回键值对数量 size 属性:返回唯一值数量
使用场景 1. 键值映射(如 ID→用户信息)2. 需要通过 “键” 快速查找 “值”3. 存储关联数据 1. 存储不重复的唯一值集合(如去重数组)2. 仅需判断 “值是否存在”3. 过滤重复数据

1. Map:键值对存储与查找

const map = new Map();
// 存:键唯一,值可重复
map.set("id1", { name: "张三" });
map.set("id2", { name: "李四" });
map.set("id1", { name: "张三2" }); // 覆盖id1的旧值

// 查:按键取值
console.log(map.get("id1")); // { name: "张三2" }
console.log(map.has("id2")); // true

// 遍历:键、值、键值对
for (const key of map.keys()) console.log(key); // id1、id2
for (const value of map.values()) console.log(value); // {name: "张三2"}、{name: "李四"}
for (const [k, v] of map.entries()) console.log(k, v);

2. Set:唯一值集合(无键)

const set = new Set();
// 存:值唯一,重复添加无效
set.add(1);
set.add(2);
set.add(1); // 无效果,1已存在

// 查:仅能判断值是否存在,无get
console.log(set.has(2)); // true
// console.log(set.get(2)); // 报错:Set 无get方法

// 遍历:keys/values等价,entries返回[值, 值]
for (const val of set.values()) console.log(val); // 1、2
for (const [v1, v2] of set.entries()) console.log(v1, v2); // 1 1、2 2

// 典型场景:数组去重
const arr = [1, 2, 2, 3];
const uniqueArr = [...new Set(arr)]; // [1,2,3]

3.核心总结

维度 Map Set
数据结构 键值对(字典) 单值集合(集合)
核心操作 按 “键” 存 / 取 / 删 按 “值” 增 / 判 / 删(无取值操作)
重复处理 键唯一(值可重复) 值唯一(无重复)
核心用途 键值映射、关联数据存储 去重、唯一值判断

简单记:

  • 需要 “通过一个标识找对应数据”→ 用 Map;
  • 只需要 “存储不重复的一组值,或判断值是否存在”→ 用 Set。

三、通用总结

类型 核心特点 适用场景
Map/Set 强引用、支持遍历、键 / 值可存任意类型 需持久存储、遍历、键 / 值为基本类型的场景
WeakMap/WeakSet 弱引用、不支持遍历、仅存引用类型 临时关联数据、避免内存泄漏(如 DOM / 临时对象)

关键提醒

WeakMap/WeakSet 无法遍历 / 获取 size,因为其内部数据会被 GC 动态修改,无法保证数据的稳定性;

而 Map/Set 是 “可预测” 的静态集合(除非手动修改)。

从零设计一个Vue路由系统:揭秘SPA导航的核心原理

想深入理解Vue路由?自己动手实现一个!本文将带你从零设计完整的路由系统,彻底掌握前端路由的核心原理。

前言:为什么需要前端路由?

在传统的多页面应用中,每次页面跳转都需要向服务器请求新页面,用户体验存在明显的中断感。而现代单页面应用(SPA)使用前端路由,实现了无刷新页面切换,大大提升了用户体验。

今天,我们就来亲手实现一个完整的Vue路由系统,深入理解其工作原理!

一、路由系统核心概念

1.1 路由系统三大核心

  • 路由器(Router):管理所有路由规则和状态
  • 路由表(Routes):定义路径与组件的映射关系
  • 路由视图(RouterView):动态渲染匹配的组件

1.2 两种路由模式

Hash模式:使用URL的hash部分(#后的内容)
  示例:http://example.com/#/home
  优点:兼容性好,无需服务器配置
  
History模式:使用HTML5 History API
  示例:http://example.com/home
  优点:URL更美观,更符合传统URL习惯

二、路由系统架构设计

2.1 系统架构图

graph TD
    A[URL变化] --> B{路由模式}
    B -->|Hash模式| C[监听hashchange事件]
    B -->|History模式| D[监听popstate事件]
    C --> E[解析当前路径]
    D --> E
    E --> F[匹配路由规则]
    F --> G[执行导航守卫]
    G --> H[更新路由状态]
    H --> I[渲染对应组件]
    I --> J[RouterView更新]

2.2 核心类设计

class VueRouter {
  constructor(options) {
    this.mode = options.mode || 'hash'
    this.routes = options.routes || []
    this.current = { path: '/', matched: [] }
    this.routeMap = this.createRouteMap()
    this.init()
  }
}

三、完整实现步骤

3.1 创建路由映射表

class VueRouter {
  constructor(options) {
    this.options = options
    this.routeMap = {}
    this.current = {
      path: '/',
      query: {},
      params: {},
      fullPath: '/',
      matched: []
    }
    
    // 创建路由映射表
    this.createRouteMap(options.routes || [])
    
    // 初始化路由
    this.init()
  }
  
  createRouteMap(routes, parentPath = '') {
    routes.forEach(route => {
      const record = {
        path: parentPath + route.path,
        component: route.component,
        parent: parentPath,
        meta: route.meta || {}
      }
      
      // 存储路由记录
      const normalizedPath = this.normalizePath(record.path)
      this.routeMap[normalizedPath] = record
      
      // 递归处理嵌套路由
      if (route.children) {
        this.createRouteMap(route.children, record.path + '/')
      }
    })
  }
  
  normalizePath(path) {
    // 处理路径格式:确保以/开头,不以/结尾(除了根路径)
    let normalized = path.replace(/\/+$/, '') || '/'
    if (!normalized.startsWith('/')) {
      normalized = '/' + normalized
    }
    return normalized
  }
}

3.2 实现路由模式

class VueRouter {
  init() {
    if (this.options.mode === 'history') {
      this.initHistoryMode()
    } else {
      this.initHashMode()
    }
  }
  
  initHashMode() {
    // 确保hash以/#/开头
    if (!location.hash) {
      location.hash = '/'
    }
    
    // 初始加载
    window.addEventListener('load', () => {
      this.transitionTo(this.getHash())
    })
    
    // 监听hash变化
    window.addEventListener('hashchange', () => {
      this.transitionTo(this.getHash())
    })
  }
  
  initHistoryMode() {
    // 初始加载
    window.addEventListener('load', () => {
      this.transitionTo(this.getPath())
    })
    
    // 监听popstate事件(浏览器前进后退)
    window.addEventListener('popstate', () => {
      this.transitionTo(this.getPath())
    })
  }
  
  getHash() {
    const hash = location.hash.slice(1)
    return hash || '/'
  }
  
  getPath() {
    const path = location.pathname + location.search
    return path || '/'
  }
}

3.3 实现路由匹配算法

class VueRouter {
  match(path) {
    const matched = []
    const params = {}
    
    // 查找匹配的路由记录
    let routeRecord = this.findRouteRecord(path)
    
    // 收集所有匹配的路由记录(包括父路由)
    while (routeRecord) {
      matched.unshift(routeRecord)
      routeRecord = this.routeMap[routeRecord.parent] || null
    }
    
    // 解析路径参数(动态路由)
    if (path.includes(':')) {
      this.extractParams(path, matched[matched.length - 1], params)
    }
    
    // 解析查询参数
    const query = this.extractQuery(path)
    
    return {
      path: this.normalizePath(path.split('?')[0]),
      fullPath: path,
      matched,
      params,
      query
    }
  }
  
  findRouteRecord(path) {
    const pathWithoutQuery = path.split('?')[0]
    const normalizedPath = this.normalizePath(pathWithoutQuery)
    
    // 精确匹配
    if (this.routeMap[normalizedPath]) {
      return this.routeMap[normalizedPath]
    }
    
    // 动态路由匹配(如 /user/:id)
    for (const routePath in this.routeMap) {
      if (this.isDynamicRoute(routePath)) {
        const pattern = this.pathToRegexp(routePath)
        if (pattern.test(normalizedPath)) {
          return this.routeMap[routePath]
        }
      }
    }
    
    return null
  }
  
  isDynamicRoute(path) {
    return path.includes(':')
  }
  
  pathToRegexp(path) {
    // 将路径模式转换为正则表达式
    const keys = []
    const pattern = path
      .replace(/\/:(\w+)/g, (_, key) => {
        keys.push(key)
        return '/([^/]+)'
      })
      .replace(/\//g, '\\/')
    
    return new RegExp(`^${pattern}$`)
  }
  
  extractParams(path, routeRecord, params) {
    const pathParts = path.split('/')
    const routeParts = routeRecord.path.split('/')
    
    routeParts.forEach((part, index) => {
      if (part.startsWith(':')) {
        const key = part.slice(1)
        params[key] = pathParts[index] || ''
      }
    })
  }
  
  extractQuery(path) {
    const query = {}
    const queryString = path.split('?')[1]
    
    if (queryString) {
      queryString.split('&').forEach(pair => {
        const [key, value] = pair.split('=')
        if (key) {
          query[decodeURIComponent(key)] = decodeURIComponent(value || '')
        }
      })
    }
    
    return query
  }
}

3.4 实现路由导航

class VueRouter {
  transitionTo(path, onComplete) {
    const route = this.match(path)
    
    // 导航守卫(简化版)
    const guards = this.runQueue(this.beforeHooks, route)
    
    guards.then(() => {
      // 更新当前路由
      this.current = route
      
      // 触发路由变化
      this.cb && this.cb(route)
      
      // 更新URL
      this.ensureURL()
      
      // 完成回调
      onComplete && onComplete()
    }).catch(() => {
      // 导航取消
      console.log('Navigation cancelled')
    })
  }
  
  push(location) {
    if (this.options.mode === 'history') {
      window.history.pushState({}, '', location)
      this.transitionTo(location)
    } else {
      window.location.hash = location
    }
  }
  
  replace(location) {
    if (this.options.mode === 'history') {
      window.history.replaceState({}, '', location)
      this.transitionTo(location)
    } else {
      const hash = location.startsWith('#') ? location : '#' + location
      window.location.replace(
        window.location.pathname + window.location.search + hash
      )
    }
  }
  
  go(n) {
    window.history.go(n)
  }
  
  back() {
    this.go(-1)
  }
  
  forward() {
    this.go(1)
  }
  
  ensureURL() {
    if (this.options.mode === 'history') {
      if (window.location.pathname !== this.current.path) {
        window.history.replaceState({}, '', this.current.fullPath)
      }
    } else {
      const currentHash = '#' + this.current.path
      if (window.location.hash !== currentHash) {
        window.location.replace(
          window.location.pathname + window.location.search + currentHash
        )
      }
    }
  }
}

3.5 实现RouterView组件

// RouterView组件实现
const RouterView = {
  name: 'RouterView',
  functional: true,
  render(_, { props, children, parent, data }) {
    // 标记为路由组件
    data.routerView = true
    
    // 获取当前路由匹配的组件
    const route = parent.$route
    const matchedComponents = route.matched.map(record => record.component)
    
    // 计算当前渲染深度(处理嵌套路由)
    let depth = 0
    let parentNode = parent
    while (parentNode && parentNode !== parent.$root) {
      if (parentNode.$vnode && parentNode.$vnode.data.routerView) {
        depth++
      }
      parentNode = parentNode.$parent
    }
    
    // 获取对应层级的组件
    const component = matchedComponents[depth]
    
    if (!component) {
      return children || []
    }
    
    // 渲染组件
    return createElement(component, data)
  }
}

3.6 实现RouterLink组件

// RouterLink组件实现
const RouterLink = {
  name: 'RouterLink',
  props: {
    to: {
      type: [String, Object],
      required: true
    },
    tag: {
      type: String,
      default: 'a'
    },
    exact: Boolean,
    activeClass: {
      type: String,
      default: 'router-link-active'
    },
    exactActiveClass: {
      type: String,
      default: 'router-link-exact-active'
    },
    replace: Boolean
  },
  render(h) {
    // 解析目标路由
    const router = this.$router
    const current = this.$route
    const { location, route } = router.resolve(this.to, current)
    
    // 生成href
    const href = router.options.mode === 'hash' 
      ? '#' + route.fullPath 
      : route.fullPath
    
    // 判断是否激活
    const isExact = current.path === route.path
    const isActive = this.exact ? isExact : current.path.startsWith(route.path)
    
    // 类名处理
    const classObj = {}
    if (this.activeClass) {
      classObj[this.activeClass] = isActive
    }
    if (this.exactActiveClass) {
      classObj[this.exactActiveClass] = isExact
    }
    
    // 点击处理
    const handler = e => {
      if (e.metaKey || e.ctrlKey || e.shiftKey) return
      if (e.defaultPrevented) return
      e.preventDefault()
      
      if (this.replace) {
        router.replace(location)
      } else {
        router.push(location)
      }
    }
    
    // 创建子元素
    const children = this.$slots.default || [this.to]
    
    const data = {
      class: classObj,
      attrs: {
        href
      },
      on: {
        click: handler
      }
    }
    
    return h(this.tag, data, children)
  }
}

3.7 Vue插件集成

// Vue插件安装
VueRouter.install = function(Vue) {
  // 混入$router和$route
  Vue.mixin({
    beforeCreate() {
      if (this.$options.router) {
        // 根实例
        this._routerRoot = this
        this._router = this.$options.router
        this._router.init(this)
        
        // 响应式定义$route
        Vue.util.defineReactive(this, '_route', this._router.current)
      } else {
        // 子组件
        this._routerRoot = (this.$parent && this.$parent._routerRoot) || this
      }
    }
  })
  
  // 定义$router和$route属性
  Object.defineProperty(Vue.prototype, '$router', {
    get() {
      return this._routerRoot._router
    }
  })
  
  Object.defineProperty(Vue.prototype, '$route', {
    get() {
      return this._routerRoot._route
    }
  })
  
  // 注册全局组件
  Vue.component('RouterView', RouterView)
  Vue.component('RouterLink', RouterLink)
}

四、使用示例

4.1 基本使用

// 1. 定义路由组件
const Home = { template: '<div>Home Page</div>' }
const About = { template: '<div>About Page</div>' }
const User = { 
  template: `
    <div>
      <h2>User {{ $route.params.id }}</h2>
      <router-view></router-view>
    </div>
  `
}
const Profile = { template: '<div>User Profile</div>' }

// 2. 创建路由实例
const router = new VueRouter({
  mode: 'history',
  routes: [
    { path: '/', component: Home },
    { path: '/about', component: About },
    { 
      path: '/user/:id', 
      component: User,
      children: [
        { path: 'profile', component: Profile }
      ]
    }
  ]
})

// 3. 创建Vue实例
const app = new Vue({
  router,
  template: `
    <div id="app">
      <nav>
        <router-link to="/">Home</router-link>
        <router-link to="/about">About</router-link>
        <router-link to="/user/123">User 123</router-link>
      </nav>
      <router-view></router-view>
    </div>
  `
}).$mount('#app')

4.2 导航守卫示例

// 全局前置守卫
router.beforeEach((to, from, next) => {
  console.log(`Navigating from ${from.path} to ${to.path}`)
  
  // 检查是否需要登录
  if (to.meta.requiresAuth && !isAuthenticated()) {
    next('/login')
  } else {
    next()
  }
})

// 全局后置钩子
router.afterEach((to, from) => {
  // 页面标题
  document.title = to.meta.title || 'My App'
  
  // 发送页面浏览统计
  trackPageView(to.path)
})

五、性能优化与高级特性

5.1 路由懒加载

// 动态导入组件(Webpack代码分割)
const User = () => import('./views/User.vue')

const router = new VueRouter({
  routes: [
    { 
      path: '/user/:id', 
      component: User,
      meta: {
        preload: true // 自定义预加载策略
      }
    }
  ]
})

// 实现预加载策略
router.onReady(() => {
  // 预加载匹配的路由组件
  router.getMatchedComponents().forEach(component => {
    if (component && component.preload) {
      component()
    }
  })
})

5.2 滚动行为控制

const router = new VueRouter({
  scrollBehavior(to, from, savedPosition) {
    // 返回滚动位置
    if (savedPosition) {
      return savedPosition
    }
    
    // 锚点导航
    if (to.hash) {
      return {
        selector: to.hash,
        behavior: 'smooth'
      }
    }
    
    // 页面顶部
    return { x: 0, y: 0 }
  }
})

六、完整流程图

graph TB
    subgraph "初始化阶段"
        A[创建VueRouter实例] --> B[创建路由映射表]
        B --> C[选择路由模式]
        C --> D[初始化事件监听]
    end
    
    subgraph "导航过程"
        E[触发导航] --> F{路由模式}
        F -->|Hash| G[hashchange事件]
        F -->|History| H[popstate/API调用]
        G --> I[解析目标路径]
        H --> I
        I --> J[路由匹配]
        J --> K[执行导航守卫]
        K --> L{守卫结果}
        L -->|通过| M[更新路由状态]
        L -->|取消| N[导航中止]
        M --> O[触发响应式更新]
        O --> P[RouterView重新渲染]
        P --> Q[完成导航]
    end
    
    subgraph "组件渲染"
        R[RouterView组件] --> S[计算渲染深度]
        S --> T[获取匹配组件]
        T --> U[渲染组件]
    end

七、总结

通过自己动手实现一个Vue路由系统,我们可以深入理解:

  1. 路由的核心原理:URL与组件的映射关系
  2. 两种模式的区别:Hash与History的实现差异
  3. 导航的生命周期:从触发到渲染的完整流程
  4. 组件的渲染机制:RouterView如何处理嵌套路由

这个实现虽然简化了官方Vue Router的一些复杂特性,但涵盖了最核心的功能。理解了这些基本原理后,无论是使用Vue Router还是排查相关问题时,都会更加得心应手。

彻底搞懂 React useRef:从自动聚焦到非受控表单的完整指南

useRef 详解:从自动聚焦到非受控表单,彻底掌握 React 的“持久引用”

在 React 的世界里,useState 是大家耳熟能详的主角——它负责管理状态、驱动界面更新。但还有一个低调却不可或缺的角色:useRef。它不像 useState 那样会触发重新渲染,却在很多关键场景中默默支撑着应用的正常运行。今天,我们就用生活化的比喻和真实代码,带你彻底理解 useRef 的两大核心用途。


一、什么是 useRef?它和 useState 有什么区别?

✨ 基本定义

const refContainer = useRef(initialValue);
  • useRef 返回一个可变的引用对象,其结构为 { current: initialValue }
  • 这个对象在组件的整个生命周期内保持不变(同一个引用)。
  • 修改 ref.current 不会触发组件重新渲染

与 useState 对比

特性 useState useRef
是否可变 是(通过 setter 更新) 是(直接赋值 ref.current = ...
是否触发重渲染 ✅ 是 ❌ 否
用途 管理需要反映在 UI 上的状态 存储不需要触发更新的值 / 获取 DOM 元素
初始值是否参与依赖 是(用于 useEffect 等) 否(.current 变化不会被 React 感知)

💡
useState 是“公告栏”——内容一变,全村都知道;
useRef 是“私人笔记本”——你写多少字,别人看不见,但你自己随时能查。


🎯 场景一:让输入框“自动聚焦”——挂载后立刻获得焦点

想象一下你打开一个登录页面,光标已经自动停在用户名输入框里,不用你手动点一下——是不是很贴心?这种体验背后,就离不开 useRef

来看这段代码:

import {useRef, useEffect } from 'react';

export default function App() {
  const inputRef = useRef(null); // 创建一个“引用盒子”

  useEffect(() => {
    inputRef.current.focus(); // 页面加载完,立刻聚焦
  }, []);

  return (
    <>
      <input ref={inputRef} type="text" />
    </>
  );
}

运行展示:

未命名的设计 (3).gif

🔍 它是怎么工作的?

  • useRef(null) 创建了一个持久存在的对象,它的 .current 属性初始为 null
  • <input ref={inputRef} /> 被渲染时,React 会自动把真实的 DOM 元素(比如 <input> 标签)赋值给 inputRef.current
  • useEffect(组件挂载后执行)中,我们调用 inputRef.current.focus(),就像对这个输入框说:“嘿,准备好接收输入吧!”

当我们在此基础上添加响应式状态时:

import { 
  useState,
  useRef ,
  useEffect
} from 'react'

export default function App(){
  const [count, setCount] = useState(0) // 响应式状态
  
    const inputRef = useRef(null) //初始值为空
    console.log(inputRef.current);
    console.log(count);
    
    useEffect(() => {
  console.log(inputRef.current);
      inputRef.current.focus()
    }, [])
  return(
    <>
    <input ref={inputRef} type="text" />
    {count}
    <button type="button" onClick={() => setCount(count + 1)}>增加</button>
    </>
  )
}

运行程序:

未命名的设计 (5).gif

我们可以发现,程序先是输出了ref和count的初始值null和0,此时返回 JSX,React 准备将 <input ref={inputRef} /> 挂载到 DOM。

React 将 JSX 渲染为真实 DOM。 此时,<input> 元素被创建,并且 React 自动将该 DOM 元素赋值给 inputRef.current。输出 < input type="text" >

当我们点击增加按钮时,触发重新渲染,程序输出1和< input type="text" >,为什么useRef的.current不是输出null,那是因为useRef.current 一旦被 React 赋值,就会一直保留该值,直到组件卸载或手动修改。

📱 适用场景

  • 登录/注册页的首字段自动聚焦
  • 移动端减少用户点击次数,提升体验
  • 表单弹窗打开后自动定位到第一个输入框

总结

useStateuseRef 的核心区别:useState 管理响应式状态,更新会触发组件重新渲染;而 useRef 创建一个可变且持久的引用对象,其 .current 值在首次渲染时为 null(DOM 尚未挂载),挂载后指向真实 DOM 元素,后续渲染中保持引用不变,且修改它不会引起重渲染。


⏱️ 场景二:存储定时器 ID——避免“失联”的定时任务

再来看一个经典问题:为什么我启动了定时器,却无法停止它?

如果你这样写:

// 此处省去导入
export default function App(){
    let intervalId = null
    const [count,setCount] = useState(0)
    function start(){
        intervalId = setInterval(()=>{
        console.log('tick~~~')
    },1000)
    console.log(intervalId);
}
useEffect(() =>{
 console.log(intervalId);
},[count])
function stop(){
    clearInterval(intervalId)
}

return(
 <>
  <button onClick={start}>开始</button>
  <button onClick={stop}>停止</button>
  {count}
  <button type="button" onClick={() => setCount(count + 1)}>增加</button>
 </>
)

当我们点击开始按钮时:

未命名的设计 (6).gif

定时器开始每秒打印 'tick~~~'此时组件没有重新渲染,点击停止时,clearInterval(id) 成功清除定时器。

而当我们点击增加按钮时,就会出现这种情况:

未命名的设计 (8).gif

tick~~~持续输出,定时器无法清理! 那是因为当我们点击增加按钮时,组件会重新渲染,React 会重新调用 App 组件函数(即重新渲染),intervalId 都会被重置为 nullclearInterval(null) 无效, 真正的定时器 ID 已经“丢失” ,无法被清除 → 'tick~~~' 持续输出!

问题在于:每次 count 变化导致组件重新渲染时,let intervalId = null 会被重新执行,之前的定时器 ID 就“丢失”了。

✅ 正确做法:用 useRef 保存 ID

import { useRef, useState, useEffect } from 'react';

export default function App() {
  const intervalId = useRef(null); // ✅ 持久存储
  const [count, setCount] = useState(0);

  function start() {
    intervalId.current = setInterval(() => {
      console.log('tick~~~');
    }, 1000);
  }

  function stop() {
    clearInterval(intervalId.current);
  }

  return (
    <>
      <button onClick={start}>开始</button>
      <button onClick={stop}>停止</button>
      {count}
      <button onClick={() => setCount(count + 1)}>增加</button>
    </>
  );
}

🧩 为什么 useRef 能解决?

  • useRef 返回的对象在整个组件生命周期中始终是同一个对象
  • 即使组件多次重新渲染,timerId.current 依然保留上次的值。
  • 因此,stop() 总能拿到正确的定时器 ID。

上述两个场景体现了useRef 提供一个跨渲染保持不变的可变容器,适合存储 DOM 引用或副作用相关的标识(如定时器 ID),且修改它不会引起组件重新渲染


📝 受控 vs 非受控:表单数据的两种获取方式

React 表单有两种处理思路:受控组件非受控组件。它们的核心区别在于:谁在掌控表单的值

1️⃣ 受控组件(Controlled Component)——“一切尽在掌握”

当表单元素的值由 React 状态(state)驱动时,这个表单元素就是一个受控组件

  • 表单元素的值由 React state 控制。
  • 必须配合 onChange 更新状态。
import { useState } from 'react';

export default function LoginForm() {
  const [form, setForm] = useState({ username: '', password: '' });

  const handleChange = (e) => {
    setForm({
      ...form,
      [e.target.name]: e.target.value // 动态更新字段
    });
  };

  const handleSubmit = (e) => {
    e.preventDefault();
    console.log(form); // { username: '...', password: '...' }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        name="username"
        value={form.username}
        onChange={handleChange}
        placeholder="请输入用户名"
      />
      <input
        name="password"
        value={form.password}
        onChange={handleChange}
        placeholder="请输入密码"
        type="password"
      />
      <button type="submit">注册</button>
    </form>
  );
}
受控组件的核心原则

“有 value,必有 onChange。”

否则输入框会被 React “锁住”,用户无法输入

如果没有onChange,会发生什么?

  1. 初始时 form.username = '',输入框为空 ✅
  2. 用户输入 "alice" → 浏览器尝试把输入框值改为 "alice"
  3. 但 React 在渲染时又强制把 value 设回 '' (因为 form.username 没变!)
  4. 结果:输入框“卡住”,用户无法输入任何内容!  ❌

为什么选择受控组件?

  • 数据完全受控,便于校验、格式化、联动(如确认密码)
  • 符合 React 单向数据流理念

2️⃣ 非受控组件(Uncontrolled Component)——“用时再取”

  • 表单元素自己管理值(像传统 HTML)。
  • 通过 useRef 在需要时读取 .current.value
import { useRef } from 'react';

export default function CommentBox() {
  const textareaRef = useRef(null);

  const handleSubmit = () => {
    const comment = textareaRef.current.value;
    if (!comment) return alert('请输入评论');
    console.log(comment);
  };

  return (
    <div>
      <textarea ref={textareaRef} placeholder="输入评论..." />
      <button onClick={handleSubmit}>提交</button>
    </div>
  );
}

  • 优点:性能略高(无状态更新),代码更简洁。
  • 适用场景:评论框、文件上传、一次性提交的简单表单。

核心区别:表单数据由谁“掌控”?

对比维度 受控组件(Controlled) 非受控组件(Uncontrolled)
数据来源 React 的 state DOM 元素自身(原生 HTML 行为)
如何更新值 通过 onChange 同步到 state 用户直接操作 DOM,React 不干预
如何读取值 直接读取 state 通过 ref.current.value 获取
是否需要 value + onChange ✅ 必须配对使用 ❌ 不需要(通常只用 ref
是否触发 re-render 每次输入都触发 无状态变化,不触发
适合场景 需要实时校验、格式化、联动的复杂表单(如登录、注册、设置页) 一次性提交、简单输入(如评论框、搜索框、文件上传)
优点 - 数据流清晰 - 易于验证/转换/联动 - 符合 React 响应式理念 - 代码简洁 - 性能略高(无频繁 setState) - 接近原生 HTML 习惯
缺点 - 代码量稍多 - 频繁输入可能引发多余渲染(可通过防抖优化) - 无法实时响应输入 - 难以实现动态校验或字段联动 - 违背“状态驱动 UI”原则

✨ 总结:useRef 的核心价值

用途 说明 示例
1. 访问 DOM 元素 获取真实 DOM,调用原生方法 .focus(), .scrollIntoView()
2. 持久存储可变值 保存不触发重渲染的数据 定时器 ID、WebSocket 实例
3. 构建非受控组件 一次性读取表单值 评论框、文件上传

记住一句话:

需要界面跟着变?用 useState
只想悄悄存个东西或操作 DOM?用 useRef

useRef 虽不张扬,却是 React 开发中不可或缺的“幕后英雄”。

掌握它,你就能在“优雅的 React”和“灵活的 DOM”之间自由切换,写出既健壮又高效的代码!

React Hooks :useRef、useState 与受控/非受控组件全解析

Hooks 函数之 useRef 与 useState,受控组件与非受控组件

大家好呀~ 今天咱们来聊聊 React 中非常重要的两个 Hooks 以及表单处理的两种方式 ——useRef、useState,还有受控组件与非受控组件。这些可是 React 开发中的基础又核心的知识点哦,掌握它们能让咱们的代码更优雅、更高效~ 😊

一、useRef 📌

1. 什么是 useRef

useRef 是 React 中用于持久化数据(跨组件渲染周期保存值)和直接操作 DOM 元素的 Hook,核心作用是 “在组件生命周期内保持一个可变的引用”。简单说,它就像一个 “保险箱”,可以存东西,而且不管组件怎么重新渲染,这个 “保险箱” 本身不会变,里面的东西却能随时拿出来用~

2. useRef 的特性

  • 返回值:调用 useRef(initialValue) 会返回一个不可变的容器对象(ref 对象),该对象只有一个属性 current,用于存储实际值。
  • 跨渲染周期保持引用:useRef 返回的 ref 容器对象在组件的整个生命周期内引用地址不变(即使组件多次重新渲染,它还是同一个对象)。
  • 修改 current 不会触发重渲染:直接修改 refContainer.current 的值,不会导致组件重新渲染,这是它与 useState 的核心区别。

代码举例:

咱们来看 App2.jsx 中的例子:

jsx

import { useRef, useState } from 'react'

export default function App() {
  const [count, setCount] = useState(0); // 响应式状态,修改会触发重渲染
  console.log('组件渲染了...'); // 每次count变化都会打印,证明重渲染
  
  // 创建ref对象,初始值为null
  const inputRef = useRef(null); 
  console.log('ref容器对象:', inputRef); // 每次渲染打印的都是同一个对象(引用不变)

  // 组件挂载完成后执行(类似vue的onMounted)
  useEffect(() => {
    console.log('input DOM元素:', inputRef.current); // 此时current已指向input元素
    inputRef.current.focus(); // 操作DOM,让输入框自动聚焦
  }, []);

  return (
    <>
      {/* 将ref对象绑定到input元素 */}
      <input ref={inputRef} />
      <p>count: {count}</p>
      {/* 点击按钮修改count,触发组件重渲染 */}
      <button onClick={() => setCount(count + 1)}>count ++</button>
    </>
  )
}

代码效果详解:

  • 当点击 count ++ 按钮时,count 状态变化会触发组件重渲染,控制台会打印 “组件渲染了...”。
  • 但每次打印的 inputRef 都是同一个对象(引用地址不变),这体现了 “跨渲染周期保持引用” 的特性。
  • useEffect 钩子在组件挂载后执行,此时 inputRef.current 已经指向了 input DOM 元素,通过 focus() 方法实现了自动聚焦,展示了 useRef 操作 DOM 的能力。
  • 即使多次修改 inputRef.current(比如手动修改它的值),也不会触发组件重渲染,这和 useState 完全不同~

注意观察,光标自动聚焦于输入框、组件渲染了而inputRef容器对象始终不变、修改inputRef.current的值不会触发重新渲染:

QQ202612-0438.gif

3. useRef 与 useState 比较 💡

特性 useRef useState
本质 用于存储 “持久化的可变值”(非响应式) 用于管理组件的响应式状态
触发重渲染 修改 current 不会触发重渲染 调用 setXxx 会触发重渲染
跨渲染周期 容器对象引用不变,current 可修改 每次渲染会创建新的状态变量(值可能相同)
适用场景 操作 DOM、存储定时器 ID、缓存数据等 管理影响 UI 展示的状态(如表单输入、开关状态等)

hooks函数中的useState 在我前面的文章里讲解过,不清楚或者感兴趣的小伙伴可以去翻出来看看。

咱们再看一个对比案例:

jsx

// App.jsx(用普通变量存定时器ID,失败案例)
export default function App() {
  let intervalId = null; // 每次渲染都会重置为null
  const [count, setCount] = useState(0);

  function start() {
    intervalId = setInterval(() => { console.log('tick~~~'); }, 1000);
  }

  function stop() {
    clearInterval(intervalId); // 无法停止!因为intervalId已被重置
  }

  return <>{/* 按钮省略 */}</>;
}

注意观察:点击开始按钮,系统自动分配一个ID值,点击停止,可以停止因为此时ID值未被修改;再次点击开始按钮,点击count++,然后再点击停止,此时无法停止,因为ID因count值修改触发useState响应式状态修改导致重新渲染而被重置了。

QQ202612-02344.gif

// App2.jsx(用useRef存定时器ID,成功案例)
export default function App() {
  const intervalId = useRef(null); // 跨渲染周期保持引用
  const [count, setCount] = useState(0);

  function start() {
    intervalId.current = setInterval(() => { console.log('tick~~~'); }, 1000);
  }

  function stop() {
    clearInterval(intervalId.current); // 成功停止!因为current保留了定时器ID
  }

  return <>{/* 按钮省略 */}</>;
}

注意观察:点击开始按钮,系统自动分配一个ID值,点击停止,可以停止因为此时ID值未被修改;再次点击开始按钮,点击count++,然后再点击停止,此时亦可以停止,因为useRef 声明的ID不会因组件渲染而重置,即ID值仍未被修改。

QQ202612-02935.gif

这个例子完美体现了两者的区别:普通变量会在组件重渲染时重置,而 useRef 的 current 能持久化存储值,这就是为什么管理定时器、计时器这类跨渲染周期的值时,useRef 是更好的选择~

二、受控组件与非受控组件 📝

在 React 中,表单元素(如 input、textarea、select 等)的状态管理方式分为受控组件和非受控组件,两者的核心区别在于状态由谁来管理。

1. 受控组件(Controlled Components)

(1)定义

组件的状态(value)由 React 的状态(useState)管理,表单元素的值完全受 React 控制。就像老板(React 状态)直接指挥员工(表单元素),员工做什么都得听老板的~

(2)工作原理
  • 通过 value 属性将 React 状态与表单元素绑定(单向绑定)。
  • 通过 onChange 事件监听输入变化,实时更新 React 状态。
  • 表单元素的值始终与 React 状态保持一致,形成 “数据驱动视图”。
(3)特点
  • 状态完全由 React 控制:表单的值始终和 value 状态保持一致,不会出现 “失控” 的情况。
  • 实时可操作性:可以随时通过修改 value 状态来改变表单值(比如重置输入、强制填写格式等)。
  • 适合场景:需要实时验证(如输入长度限制)、表单联动(如一个输入框变化影响另一个)、实时展示输入内容的场景。

2. 非受控组件(Uncontrolled Components)

(1)定义

组件的状态由 DOM 自身管理,React 不直接控制表单元素的值,而是通过 ref 访问 DOM 元素获取值。就像老板(React)不直接指挥员工(表单元素),但需要时会去 “查岗”(通过 ref 获取值)~

(2)工作原理
  • 使用 useRef 创建一个 ref 对象,绑定到表单元素。
  • 不通过 onChange 实时更新状态,而是在需要时(如提交表单)通过 ref.current.value 读取 DOM 中的值。
(3)特点
  • 状态由 DOM 管理:输入的值直接存在 DOM 中,React 不跟踪实时变化。
  • 按需获取值:只有在需要时(如提交、点击按钮)才通过 ref 读取值,减少了状态更新的频率。
  • 适合场景:一次性读取值(如表单提交)、性能敏感场景(避免频繁状态更新)、文件上传(input type="file" 必须用非受控方式)。

3. 受控组件与非受控组件比较 🆚

维度 受控组件 非受控组件
状态管理者 React 状态(useState) DOM 自身
获取值方式 直接从状态变量获取 通过 ref.current.value 获取
实时性 实时更新状态,可即时响应 不实时更新,按需读取
适用场景 实时验证、表单联动、动态反馈 一次性提交、文件上传、性能优化

如何选择?

  • 大部分场景优先用受控组件,因为它能更好地体现 React “数据驱动” 的思想,状态可控性更强。
  • 当需要操作文件(file input)、追求性能(减少状态更新)或只需要一次性获取值时,用非受控组件更合适。

4.实战演练:

废话不多说,先看代码:

jsx

// 从 React 库中导入所需的两个 Hook:
// useState:用于创建和管理组件的响应式状态
// useRef:用于创建 DOM 元素引用,实现对原生 DOM 的直接访问
import {
  useState,
  useRef
} from 'react'

// 定义并导出 App 函数式组件,作为整个表单的根组件
export default function App() {

  // 初始化受控组件的响应式状态
  // value:存储第一个输入框的输入内容,初始值为空字符串
  // setValue:更新 value 状态的方法,调用后会触发组件重渲染,同步更新关联的 UI
  const [value, setValue] = useState('');

  // 创建一个 ref 引用对象,初始值为 null
  // 后续将绑定到第二个输入框,用于获取该输入框的原生 DOM 元素
  const inputRef = useRef(null);

  // 定义表单提交的处理函数,e 是浏览器原生的表单提交事件对象
  const doLogin = (e) => {
    // 阻止浏览器表单提交的默认行为(默认会刷新页面,破坏 React 单页应用的运行状态)
    e.preventDefault();
    // 通过 ref 引用对象的 current 属性,获取第二个输入框的 DOM 元素,读取其 value 属性(用户输入内容)并打印
    console.log(inputRef.current.value);
  }

  // 渲染组件的 UI 结构
  return (
    // 表单标签,绑定 onSubmit 事件,提交时触发 doLogin 处理函数
    <form onSubmit={doLogin}>
      <div>
        {/* 实时渲染受控组件的状态值 value,直观展示输入内容与 React 状态的同步效果 */}
        {value}
        
        {/* 第一个输入框:受控组件(由 React 状态管理输入值) */}
        <input
          type="text"  // 输入框类型为文本输入
          value={value}  //  React 状态 value 绑定到输入框的 value 属性让状态控制输入框的显示内容
          onChange={(e) => setValue(e.target.value)}  // 绑定输入变化事件
          // 输入变化时,获取输入框当前值(e.target.value),调用 setValue 更新 React 状态 value
          // 实现「输入变化 → 更新状态 → 更新 UI」的闭环,保持状态与输入框内容一致
        />

        {/* 第二个输入框:非受控组件(由 DOM 自身管理输入值) */}
        {/* 不绑定 value 属性和 onChange 事件,仅通过 ref 属性绑定 inputRef,用于后续获取 DOM 元素的值 */}
        <input type="text" ref={inputRef} />
      </div>
      {/* 表单提交按钮,type="submit" 点击后会触发表单的 onSubmit 事件 */}
      <button type="submit">登录</button>
    </form>
  )
}

代码效果解释:

这段代码在一个简单的登录表单中,同时实现了受控组件非受控组件两种 React 表单处理模式:

其中第一个输入框是受控组件,它通过 useState 定义响应式状态 value,以 value={value} 实现 React 状态对输入框显示值的单向绑定,又通过 onChange 事件监听输入变化,实时调用 setValue(e.target.value) 将 DOM 输入值同步到 React 状态中,输入框的所有状态完全由 React 管控,还能通过页面上渲染 {value} 实时预览输入内容。

而第二个输入框是非受控组件,它不依赖 React 状态管理,仅通过 useRef 创建的 inputRef 绑定到 DOM 元素,输入内容直接存储在 DOM 自身的 value 属性中,不在输入过程中做实时状态同步(代码中的非受控组件仅通过 ref 绑定 DOM 元素,输入内容只存储在 DOM 自身的 value 属性中,没有触发任何 React 状态更新,也不会触发组件重渲染,因此无法直接像受控组件那样实时展示输入内容。),仅在表单提交(触发 doLogin 函数)时,通过 inputRef.current.value 按需读取 DOM 中的输入值,同时表单提交时通过 e.preventDefault() 阻止了浏览器默认的页面刷新行为,清晰对比了 React 两种表单处理方式的核心差异与实现逻辑。

QQ202612-1857.gif

三、面试官会问 🤔

  1. useRef 和 useState 的核心区别是什么? 答:useRef 存储的是 “非响应式” 的持久化值,修改 current 不会触发重渲染;useState 管理的是响应式状态,调用 setter 会触发重渲染。useRef 适合存储跨渲染周期的非 UI 相关数据(如 DOM、定时器 ID),useState 适合管理影响 UI 的状态。
  2. 为什么 useRef 能跨组件渲染周期保存值? 答:因为 useRef 返回的 ref 容器对象在组件整个生命周期内引用地址不变,即使组件重新渲染,这个对象也不会被重新创建,所以 current 属性存储的值能被持久化。
  3. 受控组件和非受控组件的区别是什么?分别适合什么场景? 答:受控组件由 React 状态管理值,通过 value 和 onChange 绑定,适合实时验证、表单联动;非受控组件由 DOM 管理值,通过 ref 获取,适合一次性读取、文件上传等场景。
  4. 如何用 useRef 操作 DOM 元素? 答:先用 useRef(null) 创建 ref 对象,然后通过 ref 属性绑定到 DOM 元素,当组件挂载后,ref.current 就会指向该 DOM 元素,进而可以调用 DOM 方法(如 focus()scrollIntoView())。

四、结语 🌟

今天咱们详细学习了 useRef、useState 以及受控组件和非受控组件的知识。useRef 就像一个 “持久化的工具箱”,帮我们存储跨渲染周期的数据和操作 DOM;而受控与非受控组件则是表单处理的两种思路,各有适用场景。

其实这些知识点并不难,关键是多动手实践~ 比如用受控组件做一个带实时验证的登录表单,用 useRef 实现一个自动聚焦的搜索框,相信练习之后大家会理解得更透彻!😘

为什么你的动态路由 “初始化了却没用”?揭秘 Vue Router 快照时机坑

提到 Vue Router,相信每一位前端开发者都不陌生。在日常开发中,尤其是搭建权限管控型后台管理系统时,很多同学应该都遇到过这样一个棘手的问题:我们需要通过前端实现权限菜单的动态渲染与路由控制。

先来看一段常见的业务代码:在路由前置守卫中,我们做了菜单路由的初始化处理 —— 登录成功后,前端会请求接口获取当前用户对应的权限菜单列表,将其持久化存储后,在路由跳转前调用 initRouter 方法,实现动态路由的添加。

但此时会出现一个令人困惑的问题:当我们成功进入某个动态添加的路由页面(例如 /dashboard)后,一旦刷新页面,该路由就会 “丢失”,页面无法正常渲染。这背后的原因究竟是什么呢?

router.beforeEach((to, from, next) => {
  const userStore = useUserStore()
  if (to.path !== '/login' && !userStore.token) {
    return next('/login')
  }
  if (!isInitRouter) {
    console.log('初始化菜单Menu')
    initRouter()
    isInitRouter = true
  }
  return next() 
})
// 一开始的默认静态路由
const router = createRouter({
  history: createWebHistory(), // history模式
  routes: [
    {
      path: '/',
      name: 'layout',
      redirect: '/dashboard',
      children: [],
      component: () => import('../views/layout/index.vue')
    },
    {
      path: '/login',
      component: () => import('../views/login/index.vue')
    }
  ]
})

快照拍的是 “启动瞬间路由表的全貌”,默认是静态路由表(因为动态路由还没机会注册),但如果动态路由提前注册了,快照就会包含它。快照是「导航周期启动瞬间」拍的,而是「导航周期启动瞬间」拍一次,整个导航周期(包括所有守卫、匹配、渲染环节)都共用这份快照。
先拍快照,后初始化动态路由,初始化改不了已拍好的快照。

  1. 先拍快照:导航周期一启动,就立刻给当前的路由表拍一张 “只读照片”(副本),这张照片里只有提前定义的静态路由(比如 /login),没有动态路由(比如 /dashboard);
  2. 后初始化:快照拍好之后,才会进入 beforeEach 守卫,执行你的 initRouter() 方法(动态添加 /dashboard 等路由,更新原路由表);
  3. 初始化改不了已拍好的快照:你通过 initRouter() 确实更新了「原路由表」(新增了动态路由),但那张提前拍好的「快照照片」不会同步更新 —— 它是一张独立的、固定不变的副本,导航周期全程只会用这张快照做事,不会再去读取更新后的原路由表。
next() 的调用方式 行为描述 是否开启新导航周期?
next()(无参数) 继续当前导航周期,用当前周期的快照匹配路由 ❌ 不开启,沿用旧周期
next('/login')(带路径字符串) 终止当前导航周期,发起一个新导航周期(目标:/login ✅ 开启新周期
next({ ...to, replace: true })(带路由对象) 终止当前导航周期,发起一个新导航周期(目标:路由对象指定的路径) ✅ 开启新周期

注:开启新的导航周期会重新走一次路由守卫

什么是导航周期?

导航周期的启动时机是刷新 / 进入页面的一瞬间」,但「导航周期本身是一整套连贯的流程」—— 不是 “瞬间结束”,而是从 “启动瞬间” 开始,依次执行 “拍快照、守卫、匹配、渲染” 等步骤,直到页面显示完成才结束(只是整个流程很快,体感上像 “一瞬间”)。

解决方案

一、要解决动态路由生效的问题,核心思路就是在添加路由后,通过 next(retryPath)等方式主动开启一个新的导航周期。在新的周期里,路由器就会基于包含新路由的、更新后的映射表来拍“快照”了

二、在跳转之前初始化路由表。正确的顺序是:先动态添加路由(让路由表变成最新),再执行跳转(启动新导航周期,拍新快照),新快照会包含最新路由表,跳转必然能找到对应路由,不会白屏。

快照触发时机

每次有效跳转都会启动一个新的导航周期—— 快照和 “导航周期” 是「一一对应」的:一个新导航周期,必然对应一次新快照;没有新导航周期,就不会有新快照。导航周期开始->新的快照->跳转

有效跳转:必然启动新导航周期,拍新快照

只要操作能引发「路由路径变化」或「路由查询参数 / 哈希值变化」(即路由状态改变),都属于有效跳转,一定会启动新导航周期,进而拍摄新快照。

  1. 路径完全变化(最常见)
    • 示例:/login/dashboard/dashboard/profile、页面刷新 /dashboard、点击 <router-link to="/setting">、浏览器前进 / 后退(路径变化);
    • 结果:启动新导航周期,拍新快照(快照为当前路由表全貌)。
  1. 路径不变,查询参数变化
    • 示例:/list?page=1/list?page=2(路径都是 /list,仅查询参数 page 变化)、/detail?id=1/detail?id=2
    • 结果:同样启动新导航周期,拍新快照(哪怕路径不变,查询参数变化也属于路由状态变化,会触发新周期)。
  1. 路径不变,哈希值变化
    • 示例:/home#top/home#bottom(路径 /home 不变,仅哈希值 # 后面的内容变化);
    • 结果:启动新导航周期,拍新快照。

无效跳转:不启动新导航周期,不拍新快照

只有一种情况属于无效跳转:跳转的目标路由与当前路由完全一致(路径、查询参数、哈希值均无变化) ,此时 Vue Router 会直接忽略该跳转请求,不会启动新导航周期,自然也不会拍摄新快照。

  1. 当前路由是 /dashboard,执行 router.push('/dashboard')(路径、参数、哈希均一致);
  2. 当前路由是 /list?page=1,执行 router.push('/list?page=1')(查询参数无变化);
  3. 当前路由是 /home#top,点击 <router-link to="/home#top">(哈希值无变化)。

注意

router.beforeEach((to, from, next) => {
  const userStore = useUserStore()
  if (to.path !== '/login' && !userStore.token) {
    return next('/login')
  }
  if (!isInitRouter) {
    console.log('初始化菜单Menu')
    initRouter()
    isInitRouter = true
  }
  return next({ ...to }) // 这样写会循环卡死
})

原因:触发了无限循环的导航周期——return next({ ...to }) 会持续终止当前导航周期,同时启动一个和当前目标完全一致的新导航周期,而新周期又会重复执行守卫逻辑,再次触发 return next({ ...to }),如此往复没有尽头,最终导致浏览器主线程被占用,页面卡死。

解决方案:仅在动态路由初始化后,按需执行 next({ ...to });其他场景执行 next(),同时添加 replace: true 优化体验。

步骤演示

一、正常进入(从 /login 跳转 /dashboard):没问题的流程

正常进入是「先访问 /login,登录后再跳转 /dashboard」,两步走,初始化时机提前了:

步骤 1:访问 /login(第一个导航周期,提前完成初始化)

  1. 触发导航:用户输入网址访问 /login → 启动「导航周期 A」;

  2. 拍快照 A:启动瞬间拍快照,此时路由表只有静态路由 /login(快照 A = 静态路由表);

  3. 执行 beforeEach 守卫:

    • 登录校验:to.path === '/login',条件不成立,跳过;
    • !isInitRouter === true → 执行 initRouter()(注册 /dashboard 等动态路由,路由表更新为「静态 + 动态」);
    • isInitRouter 设为 true
    • return next() → 继续导航周期 A,用快照 A 匹配 /login,匹配成功 → 显示 /login 页面;
  4. 关键结果:此时「路由表已经更新」(有 /dashboard),只是导航周期 A 的快照 A 是旧的,但不影响 /login 显示。

步骤 2:登录成功,跳转 /dashboard(第二个导航周期,快照拍到新路由表)

  1. 触发导航:登录成功后执行 router.push('/dashboard') → 启动「导航周期 B」;

  2. 拍快照 B:启动瞬间拍快照,此时路由表已经是「静态 + 动态」(步骤 1 已初始化),快照 B = 完整路由表(含 /dashboard

  3. 执行 beforeEach 守卫:

    • 登录校验:to.path === '/dashboard' 且有 token,跳过;
    • !isInitRouter === false → 跳过 initRouter()
    • return next() → 继续导航周期 B,用快照 B 匹配 /dashboard,匹配成功 → 正常显示页面;

正常进入的核心:初始化提前完成

initRouter() 在第一个导航周期(/login)就执行了,路由表提前更新;后续跳转 /dashboard 时,新导航周期的快照能拍到完整路由表,自然没问题。


二、刷新 /dashboard:不行的流程

刷新是「直接访问 /dashboard」,一步到位,初始化时机滞后了:

步骤 1:刷新 /dashboard(唯一导航周期,先拍快照后初始化)

  1. 触发导航:用户刷新 /dashboard 网址 → 启动「导航周期 C」;

  2. 拍快照 C:启动瞬间拍快照,此时路由表还是「初始静态路由」(无 /dashboardinitRouter() 还没执行),快照 C = 旧静态路由表

  3. 执行 beforeEach 守卫:

    • 登录校验:to.path === '/dashboard' 且有 token,跳过;
    • !isInitRouter === true → 执行 initRouter()(注册 /dashboard路由表更新为「静态 + 动态」);
    • isInitRouter 设为 true
    • return next() → 继续导航周期 C,用快照 C(旧静态路由表)匹配 /dashboard
  4. 关键结果:快照 C 中没有 /dashboard,匹配失败 → 页面白屏(无组件可渲染)。

刷新不行的核心:顺序反了

「导航周期 C 启动(拍旧快照)」在前,「initRouter() 初始化(更新路由表)」在后;旧导航周期只能用已拍好的快照 C,哪怕后续路由表更新了,也无法改变快照 C 的内容,导致匹配失败。

扩展

vue-router@3 和 vue-router@4 路由守卫的return和next的区别是否触发请看下一篇文章《动态路由跳转失效?原来是 next() 与 return 的用法搞反了!》

Vue 3 深度解析:watch 与 watchEffect 的终极对决

Vue 3 深度解析:watch 与 watchEffect 的终极对决

在 Vue 3 的响应式系统中,watchwatchEffect 是两个强大的工具,但许多开发者对它们的使用场景和区别感到困惑。今天,我们将深入剖析这两个API,帮助你做出明智的选择!

一、基础概念解析

1.1 watch:精准的响应式侦探

watch 是一个相对"精确"的观察者,它需要你明确指定要监听的源(source)和回调函数。

import { ref, watch } from 'vue'

const count = ref(0)
const user = ref({ name: '小明', age: 25 })

// 监听单个ref
watch(count, (newVal, oldVal) => {
  console.log(`计数从 ${oldVal} 变为 ${newVal}`)
})

// 监听响应式对象
watch(
  () => user.value.age,
  (newAge, oldAge) => {
    console.log(`年龄从 ${oldAge} 变为 ${newAge}`)
  }
)

// 监听多个源
watch([count, () => user.value.age], ([newCount, newAge], [oldCount, oldAge]) => {
  console.log(`计数或年龄发生变化`)
})

1.2 watchEffect:自动的依赖收集器

watchEffect 则更加"智能"和"自动",它会自动追踪其回调函数内部访问的所有响应式依赖。

import { ref, watchEffect } from 'vue'

const count = ref(0)
const double = ref(0)

watchEffect(() => {
  // 自动追踪 count.value 和 double.value
  double.value = count.value * 2
  console.log(`count: ${count.value}, double: ${double.value}`)
})

// 自动执行,输出: count: 0, double: 0
count.value++ // 自动触发,输出: count: 1, double: 2

二、核心区别对比

让我用一个流程图来展示它们的工作机制差异:

graph TD
    A[开始监听] --> B{选择哪种方式?}
    
    B -->|精确监听特定数据| C[使用 watch]
    B -->|自动追踪依赖| D[使用 watchEffect]
    
    C --> C1[明确指定依赖源]
    C1 --> C2[初始不执行<br/>除非设置 immediate: true]
    C2 --> C3[回调参数包含<br/>新旧值]
    C3 --> C4[需要手动清理?]
    C4 -->|是| C5[返回清理函数]
    C4 -->|否| C6[监听完成]
    
    D --> D1[执行回调函数]
    D1 --> D2[自动收集依赖]
    D2 --> D3[依赖变化时<br/>重新执行]
    D3 --> D4[无新旧值参数<br/>只有最新值]
    D4 --> D6[监听完成]
    
    C5 --> C6

2.1 依赖追踪方式不同

watch 需要显式声明依赖:

// 必须明确指定要监听什么
watch(count, callback) // 直接监听ref
watch(() => obj.prop, callback) // 监听getter函数
watch([source1, source2], callback) // 监听数组

watchEffect 自动收集依赖:

// 自动发现内部使用的所有响应式数据
watchEffect(() => {
  // 这里用到的所有响应式数据都会被自动追踪
  console.log(count.value + user.value.age)
})

2.2 初始执行时机不同

const data = ref(null)

// watch 默认不会立即执行
watch(data, (newVal) => {
  console.log('数据变化:', newVal)
})
// 需要 immediate: true 才会立即执行
watch(data, callback, { immediate: true })

// watchEffect 总是立即执行一次
watchEffect(() => {
  console.log('数据:', data.value) // 立即执行
})

2.3 访问新旧值的方式不同

const count = ref(0)

// watch 可以访问新旧值
watch(count, (newVal, oldVal) => {
  console.log(`从 ${oldVal} 变为 ${newVal}`)
})

// watchEffect 只能访问当前值
watchEffect(() => {
  console.log(`当前值: ${count.value}`)
  // 无法直接获取旧值
})

2.4 停止监听的方式

两种方式都返回停止函数:

// watch 的停止方式
const stopWatch = watch(count, callback)
stopWatch() // 停止监听

// watchEffect 的停止方式
const stopEffect = watchEffect(callback)
stopEffect() // 停止监听

三、实际应用场景

场景1:表单验证(适合使用 watch)

import { ref, watch } from 'vue'

const form = ref({
  username: '',
  email: '',
  password: ''
})

const errors = ref({})

// 监听用户名变化
watch(
  () => form.value.username,
  (newUsername) => {
    if (newUsername.length < 3) {
      errors.value.username = '用户名至少3个字符'
    } else {
      delete errors.value.username
    }
  },
  { immediate: true } // 立即执行以验证初始值
)

// 监听邮箱格式
watch(
  () => form.value.email,
  (newEmail) => {
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
    if (!emailRegex.test(newEmail)) {
      errors.value.email = '邮箱格式不正确'
    } else {
      delete errors.value.email
    }
  }
)

场景2:自动保存(适合使用 watchEffect)

import { ref, watchEffect } from 'vue'

const document = ref({
  title: '未命名文档',
  content: '',
  lastSaved: null
})

let saveTimeout = null

// 自动追踪文档变化并保存
watchEffect((onCleanup) => {
  // 清理之前的定时器
  onCleanup(() => {
    if (saveTimeout) clearTimeout(saveTimeout)
  })
  
  // 设置新的定时器
  saveTimeout = setTimeout(async () => {
    if (document.value.content.trim()) {
      try {
        await saveToServer(document.value)
        document.value.lastSaved = new Date()
        console.log('文档已自动保存')
      } catch (error) {
        console.error('保存失败:', error)
      }
    }
  }, 1000) // 防抖:1秒后保存
})

场景3:组合使用

import { ref, watch, watchEffect } from 'vue'

const searchQuery = ref('')
const searchResults = ref([])
const isLoading = ref(false)

// watchEffect:自动管理 loading 状态和请求
const stopEffect = watchEffect(async (onCleanup) => {
  if (!searchQuery.value.trim()) {
    searchResults.value = []
    return
  }
  
  isLoading.value = true
  
  // 清理函数:取消未完成的请求
  let aborted = false
  onCleanup(() => {
    aborted = true
    isLoading.value = false
  })
  
  try {
    const results = await searchAPI(searchQuery.value)
    if (!aborted) {
      searchResults.value = results
    }
  } catch (error) {
    if (!aborted) {
      console.error('搜索失败:', error)
      searchResults.value = []
    }
  } finally {
    if (!aborted) {
      isLoading.value = false
    }
  }
})

// watch:监听特定条件,执行特定操作
watch(
  () => searchResults.value.length,
  (newLength) => {
    if (newLength === 0 && searchQuery.value.trim()) {
      console.log('没有找到相关结果')
    }
  }
)

四、性能优化技巧

4.1 使用 watch 的深度监听

const nestedObj = ref({
  user: {
    profile: {
      name: '小明',
      details: {
        address: '...'
      }
    }
  }
})

// 深度监听
watch(
  nestedObj,
  (newVal) => {
    console.log('对象深度变化:', newVal)
  },
  { deep: true } // 深度监听
)

// 对比:watchEffect 默认就是"深度"的,因为它追踪所有访问
watchEffect(() => {
  console.log(nestedObj.value.user.profile.details.address)
  // 任何层次的访问都会被追踪
})

4.2 使用 flush 选项控制执行时机

// 默认:组件更新前执行
watch(source, callback)

// DOM 更新后执行
watch(source, callback, { flush: 'post' })

// 同步执行(慎用)
watch(source, callback, { flush: 'sync' })

// watchEffect 也有相同的选项
watchEffect(callback, { flush: 'post' })

4.3 使用 reactive 对象的注意事项

import { reactive, watch, watchEffect, toRefs } from 'vue'

const state = reactive({
  count: 0,
  user: { name: '小明' }
})

// ❌ 错误:直接监听 reactive 对象
watch(state, callback) // 可能会得到意外结果

// ✅ 正确:使用 getter 函数
watch(() => state.count, callback)

// ✅ 正确:使用 toRefs
const { count } = toRefs(state)
watch(count, callback)

// ✅ watchEffect 可以直接使用
watchEffect(() => {
  console.log(state.count, state.user.name)
})

五、选择指南:什么时候用什么?

使用 watch 的场景:

  1. 需要新旧值对比时
  2. 只关心特定数据的变化
  3. 需要控制初始执行时机
  4. 数据变化频率高,但不需要每次变化都执行
  5. 需要监听嵌套对象的特定属性

使用 watchEffect 的场景:

  1. 依赖多个数据源,不想一一列出
  2. 副作用逻辑复杂,依赖关系动态变化
  3. 需要立即执行并响应式更新
  4. 逻辑相对独立,自成一体
  5. 进行异步操作,需要自动清理

决策流程图:

graph TD
    A[开始选择] --> B{需要监听什么?}
    
    B -->|明确的特定数据| C[考虑 watch]
    B -->|多个/动态的依赖| D[考虑 watchEffect]
    
    C --> C1{需要新旧值对比?}
    C1 -->|是| C2[选择 watch]
    C1 -->|否| C3{初始需要立即执行?}
    
    D --> D1{需要自动依赖追踪?}
    D1 -->|是| D2[选择 watchEffect]
    D1 -->|否| C3
    
    C3 -->|是且依赖简单| C4[watch + immediate]
    C3 -->|是且依赖复杂| D2
    C3 -->|否| C5[watch]
    
    C2 --> E[最终决定: watch]
    C4 --> E
    C5 --> E
    D2 --> F[最终决定: watchEffect]

六、实战总结

6.1 黄金法则

  1. 明确依赖用 watch,模糊依赖用 watchEffect
  2. 需要旧值用 watch,只要最新值用 watchEffect
  3. 初始不执行用 watch,立即执行用 watchEffect
  4. 简单监听用 watch,复杂副作用用 watchEffect

6.2 代码示例对比

// 场景:用户过滤和排序
const users = ref([])
const filter = ref('')
const sortBy = ref('name')

// 方案1:使用 watch(明确)
watch([filter, sortBy], () => {
  fetchFilteredUsers(filter.value, sortBy.value)
}, { immediate: true })

// 方案2:使用 watchEffect(自动)
watchEffect(() => {
  fetchFilteredUsers(filter.value, sortBy.value)
})

// 结论:两者都能工作,根据喜好选择

6.3 最佳实践建议

  1. 在组合式函数中优先使用 watchEffect,因为它更符合响应式思维
  2. 在需要精确控制时使用 watch,特别是需要防抖或节流时
  3. 记得清理副作用,特别是定时器和异步操作
  4. 谨慎使用 deep 和 flush 选项,它们可能影响性能
  5. 在 Vue 3.2+ 中考虑使用 watchPostEffect 和 watchSyncEffect 作为语法糖

结语

watchwatchEffect 都是 Vue 3 响应式系统的强大工具,没有绝对的优劣,只有适合的场景。理解它们的核心差异,结合具体需求选择,才能写出更优雅、高效的代码。

记住:watch 是"我告诉你监听什么",而 watchEffect 是"你自己发现需要监听什么"。

希望这篇深度解析能帮助你在 Vue 3 开发中做出更明智的选择!如果你有更多疑问,欢迎在评论区留言讨论。

Three.js 入门教程:从零开始构建你的第一个3D应用

前言

在Web开发领域,3D图形渲染一直是一个充满挑战的技术领域。Three.js作为最受欢迎的JavaScript 3D库,让开发者能够轻松地在浏览器中创建令人惊叹的3D效果和交互式体验。本教程将带你从零开始,使用Three.js构建你的第一个3D应用。

什么是Three.js?

Three.js是一个轻量级的JavaScript 3D库,它基于WebGL技术构建,为Web开发者提供了简单而强大的3D图形渲染能力。无论是创建游戏、3D数据可视化、交互式产品展示,还是艺术创作,Three.js都能满足你的需求。

项目初始化

首先,确保你的项目已经安装了Three.js。在本教程中,我们将使用ES模块的方式导入Three.js:

import * as THREE from "three";

这个导入语句会将整个Three.js库导入为我们可以在代码中使用的THREE对象。

创建3D场景的核心组件

在Three.js中,任何3D场景都需要三个核心组件:场景(Scene)相机(Camera)渲染器(Renderer)。让我们逐一了解它们。

1. 创建场景

const scene = new THREE.Scene();

场景(Scene)是3D世界的容器,所有的3D对象、光源、材质等都会被添加到这个场景中。你可以把它想象成一个虚拟的3D空间,我们将在其中放置各种3D元素。

2. 创建相机

const camera = new THREE.PerspectiveCamera(
  45, // 视角
  window.innerWidth / window.innerHeight, // 宽高比
  0.1, // 近平面
  1000 // 远平面
);

相机(Camera)决定了我们观察3D世界的角度。PerspectiveCamera(透视相机)模拟了人眼的视觉效果:

  • 视角(Field of View):设置为45度,这是人眼的自然视角
  • 宽高比(Aspect Ratio):根据窗口尺寸动态计算,确保3D内容不会出现变形
  • 近平面(Near Clipping Plane):设置为0.1,定义了相机能看到的最近距离
  • 远平面(Far Clipping Plane):设置为1000,定义了相机能看到的最远距离

3. 创建渲染器

const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

渲染器(Renderer)是Three.js的核心组件,它负责将3D场景渲染到2D画布上:

  • WebGLRenderer:使用WebGL技术进行硬件加速渲染
  • setSize():设置渲染器的尺寸,通常与窗口大小保持一致
  • appendChild():将渲染器的DOM元素添加到HTML页面中

创建3D对象

现在让我们在场景中创建一个简单的3D立方体。

1. 创建几何体

const geometry = new THREE.BoxGeometry(1, 1, 1);

几何体(Geometry)定义了3D对象的形状。这里我们创建了一个1×1×1的立方体。

2. 创建材质

const material = new THREE.MeshBasicMaterial({ color: 0x00ff00 });

材质(Material)定义了3D对象的外观。我们使用了MeshBasicMaterial,这是最简单的材质类型,只定义颜色。这里设置了绿色(0x00ff00)。

3. 创建网格

const cube = new THREE.Mesh(geometry, material);

网格(Mesh)是几何体和材质的组合,代表一个完整的3D对象。

4. 添加到场景

scene.add(cube);

将创建的立方体添加到场景中,这样它就会在渲染时显示出来。

相机定位和观察

camera.position.z = 5;
camera.lookAt(0, 0, 0);

这两行代码设置相机的位置和朝向:

  • 相机位置:将相机沿Z轴向后移动5个单位,确保我们能看到立方体
  • 相机朝向:让相机朝向场景的中心点(0, 0, 0)

动画循环

Three.js中最激动人心的部分就是创建动画。通过requestAnimationFrame函数,我们可以创建流畅的3D动画:

function animate() {
  requestAnimationFrame(animate);
  
  // 旋转
  cube.rotation.x += 0.01;
  cube.rotation.y += 0.01;
  
  // 渲染
  renderer.render(scene, camera);
}

animate();

动画原理

  1. requestAnimationFrame:这是浏览器提供的API,它会告诉浏览器我们需要执行动画,让浏览器在下一次重绘之前调用我们的函数
  2. 旋转动画:每帧都将立方体绕X轴和Y轴旋转0.01弧度
  3. 渲染:调用渲染器将当前场景渲染到屏幕上

扩展功能

响应式设计

为了让我们的3D应用适应不同屏幕尺寸,我们可以添加窗口resize事件监听:

window.addEventListener('resize', () => {
  camera.aspect = window.innerWidth / window.innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(window.innerWidth, window.innerHeight);
});

添加光照

为了让3D对象看起来更加真实,我们可以添加光照:

const light = new THREE.DirectionalLight(0xffffff, 1);
light.position.set(5, 5, 5);
scene.add(light);

const ambientLight = new THREE.AmbientLight(0x404040);
scene.add(ambientLight);

创建多个对象

我们可以创建更多的3D对象来丰富场景:

// 创建多个立方体
for (let i = 0; i < 10; i++) {
  const geometry = new THREE.BoxGeometry(1, 1, 1);
  const material = new THREE.MeshBasicMaterial({
    color: Math.random() * 0xffffff
  });
  const cube = new THREE.Mesh(geometry, material);
  
  cube.position.x = (Math.random() - 0.5) * 20;
  cube.position.y = (Math.random() - 0.5) * 20;
  cube.position.z = (Math.random() - 0.5) * 20;
  
  scene.add(cube);
}

总结

通过这个简单的Three.js应用,我们了解了3D图形编程的基本概念:

  1. 场景管理:使用Scene容器管理所有3D对象
  2. 视角控制:通过Camera控制观察3D世界的角度
  3. 渲染输出:使用Renderer将3D场景渲染到2D画布
  4. 几何体和材质:创建和配置3D对象的外观
  5. 动画实现:使用requestAnimationFrame创建流畅的动画效果

Three.js的强大之处在于它提供了丰富的功能集,包括各种几何体、材质、光照模型、纹理、阴影、粒子系统等。随着学习的深入,你可以创建越来越复杂的3D应用。

下一步学习建议

  1. 探索更多几何体:学习球体、圆柱体、平面等基本几何体
  2. 材质进阶:研究Lambert材质、Phong材质等更真实的光照模型
  3. 纹理映射:学习如何为3D对象添加纹理图片
  4. 交互控制:使用Three.js的控制器添加鼠标交互功能
  5. 性能优化:学习如何优化3D场景的性能

Three.js的世界是广阔的,通过不断实践和探索,你将能够创造出令人惊叹的3D Web体验。希望这个入门教程能够帮助你迈出3D Web开发的第一步!

构建现代 React 应用:从项目初始化到路由与数据获取

在当今前端生态中,React 凭借其声明式编程模型、组件化架构和强大的社区支持,成为构建用户界面的事实标准。然而,一个真正健壮的 React 应用不仅依赖于核心库本身,更需要一套完整的工程化体系——从项目脚手架、模块管理,到路由控制与副作用处理。借助 Vite 这一现代化构建工具,我们可以快速搭建高性能开发环境,并通过 React Router 和 Hooks 实现结构清晰、逻辑内聚的应用程序。

项目初始化:Vite 驱动的极速开发体验

现代前端项目的起点不再是手动配置 Webpack 或 Babel,而是使用 npm create vite 命令一键生成标准化模板:

npm create vite my-react-app -- --template react
cd my-react-app
npm install
npm run dev

Vite 利用浏览器原生 ES 模块(ESM)能力,在开发阶段按需加载模块,实现毫秒级冷启动与即时热更新。项目根目录下的 index.html 作为入口文件,包含一个 <div id="root"></div> 容器,用于挂载 React 应用:

// main.jsx
import { createRoot } from 'react-dom/client'
import App from './App'
createRoot(document.getElementById('root')).render(<App />)

这种设计将构建工具与业务代码解耦,开发者只需关注组件逻辑,而 Vite 负责处理模块解析、CSS 预处理器(如 Stylus)、TypeScript 编译等底层细节。依赖被明确划分为 dependencies(运行时依赖,如 reactreact-router-dom)和 devDependencies(开发工具,如 vitestylus),确保生产环境精简高效。

组件化架构:函数即 UI 单元

React 的核心思想是“一切皆组件”。每个页面或功能模块都被封装为一个函数组件,接收 props 并返回 JSX 描述的 UI 结构。例如,首页组件 Home 负责展示 GitHub 仓库列表:

const Home = () => {
  const [repos, setRepos] = useState([]);
  
  useEffect(() => {
    fetch('https://api.github.com/users/username/repos')
      .then(res => res.json())
      .then(json => setRepos(json));
  }, []);

  return (
    <div>
      <h1>Home</h1>
      {repos.length ? (
        <ul>
          {repos.map(repo => (
            <li key={repo.id}>
              <a href={repo.html_url} target="_blank" rel="noreferrer">
                {repo.name}
              </a>
            </li>
          ))}
        </ul>
      ) : <p>暂无仓库</p>}
    </div>
  );
};

这里,useState 管理响应式状态 repos,而 useEffect 处理副作用(如 API 请求)。空依赖数组 [] 确保请求仅在组件首次挂载后执行一次,避免重复调用。这种“状态 + 副作用”的组合,构成了 React 函数组件的核心逻辑单元。

路由系统:多页面导航的基石

单页应用(SPA)需要在不刷新页面的前提下切换视图,这正是 React Router 的职责所在。通过安装 react-router-dom,我们可以定义路径与组件的映射关系:

// router/index.jsx
import { Routes, Route } from 'react-router-dom';
import Home from '../pages/Home';
import About from '../pages/About';

export default function AppRoutes() {
  return (
    <Routes>
      <Route path="/" element={<Home />} />
      <Route path="/about" element={<About />} />
    </Routes>
  );
}

<Routes> 作为路由总管,根据当前 URL 匹配对应的 <Route>,并渲染其 element 属性指定的组件。这种声明式路由配置清晰直观,易于维护。

导航与布局:Link 与 Router 上下文

页面间的跳转不应使用原生 <a> 标签(会触发整页刷新),而应使用 React Router 提供的 <Link> 组件:

// App.jsx
import { BrowserRouter as Router, Link } from 'react-router-dom';
import AppRoutes from './router';

function App() {
  return (
    <Router>
      <nav>
        <ul>
          <li><Link to="/">Home</Link></li>
          <li><Link to="/about">About</Link></li>
        </ul>
      </nav>
      <AppRoutes />
    </Router>
  );
}

<BrowserRouter>(简称 Router)为整个应用提供路由上下文,使 <Link><Routes> 能够协同工作。点击链接时,URL 更新,<Routes> 重新匹配并渲染新组件,全程无页面跳转,用户体验流畅。

工程化思维:从开发到上线的闭环

一个完整的 React 项目遵循 dev → test → production 的生命周期:

  • 开发阶段npm run dev 启动 Vite 开发服务器,支持热更新;
  • 测试阶段:可集成 Jest、React Testing Library 等工具验证组件行为;
  • 生产阶段npm run build 生成优化后的静态资源,部署至 CDN 或服务器。

Vite 在此过程中扮演“基建”角色:它基于 Node.js,利用原生 ESM 提升开发效率,同时通过 Rollup 打包生产代码,实现代码分割、Tree Shaking 等优化。而 React 本身则聚焦于 UI 渲染——react 包含核心逻辑(如 Hooks、组件模型),react-dom 负责将虚拟 DOM 映射到真实浏览器节点,二者分工明确,共同构成现代前端应用的运行基础。

总结

npm init vite 初始化项目,到使用 useStateuseEffect 管理状态与副作用,再到通过 React Router 实现多页面导航,这一整套流程体现了现代前端工程化的精髓:工具自动化、结构模块化、逻辑组件化。Vite 提供了极速开发体验,React 提供了声明式 UI 范式,React Router 解决了 SPA 路由问题——三者结合,让开发者能够以最小的认知成本构建出高性能、可维护的应用。掌握这套工作流,不仅是应对日常开发的利器,更是理解现代 Web 应用架构的关键一步。

用「点外卖」的例子讲透HttpClient

想象你要点外卖

传统方式(像打电话订餐):

csharp

// 每次都要:找餐厅电话→打电话→等接通→点餐→等确认→挂电话
// 想再点一杯饮料?重新打一遍电话!
public void 传统点餐()
{
    // 第一次:点主食
    找电话号码("肯德基");
    打电话("我要一个汉堡");
    等待接通();
    说明需求();
    等待确认();
    挂断电话();
    
    // 第二次:点饮料(又要重复所有步骤)
    找电话号码("肯德基");
    打电话("再加一杯可乐");
    等待接通();
    说明需求();
    等待确认();
    挂断电话();
    
    // 第三次:查订单状态(还得再打)
    找电话号码("肯德基");
    打电话("我的餐到哪了");
    // ... 效率极低!
}

使用HttpClient(像用外卖APP):

csharp

// 就像在美团APP里操作
HttpClient 我的外卖APP = new HttpClient();

// 一次设置,多次使用
我的外卖APP.BaseAddress = new Uri("https://api.kfc.com/");

async Task 智能点餐()
{
    // 点主食(像在APP里选商品)
    var 汉堡订单 = await 我的外卖APP.PostAsync("order", 汉堡数据);
    
    // 加饮料(直接在订单里添加,不用重新打开)
    var 饮料订单 = await 我的外卖APP.PostAsync("order/add", 可乐数据);
    
    // 查进度(刷新一下就行)
    var 订单状态 = await 我的外卖APP.GetAsync("order/status");
    
    // 所有操作都在同一个APP里完成,不用反复"打电话"
}

HttpClient的核心功能(像外卖APP的特性)

1. 连接复用 - 不用反复登录

csharp

// ❌ 传统:每次都要重新登录
public void 传统方式()
{
    // 点餐
    打开APP();
    登录();
    点餐();
    退出APP();
    
    // 查订单(又要重新登录)
    打开APP();
    登录();
    查订单();
    退出APP();
}

// ✅ HttpClient:一次登录,持续使用
public class 我的外卖服务
{
    private HttpClient 外卖APP = new HttpClient();
    
    public 我的外卖服务()
    {
        // 只需要登录一次
        外卖APP.BaseAddress = new Uri("https://外卖平台.com/");
        外卖APP.DefaultRequestHeaders.Add("Authorization", "我的登录凭证");
    }
    
    public async Task 点餐() => await 外卖APP.PostAsync(...);
    public async Task 查订单() => await 外卖APP.GetAsync(...);
    public async Task 取消订单() => await 外卖APP.DeleteAsync(...);
    // 所有操作都用同一个登录状态
}

2. 同时处理多个请求 - 并行点多家餐厅

csharp

public async Task 点周末大餐()
{
    // 同时向多家餐厅下单
    var 肯德基任务 = 我的外卖APP.PostAsync("kfc/order", 炸鸡数据);
    var 麦当劳任务 = 我的外卖APP.PostAsync("mcdonald/order", 汉堡数据);
    var 奶茶店任务 = 我的外卖APP.PostAsync("milktea/order", 奶茶数据);
    
    // 等待所有餐厅接单(并行等待,效率高)
    await Task.WhenAll(肯德基任务, 麦当劳任务, 奶茶店任务);
    
    Console.WriteLine("所有订单已提交!");
}

3. 发送各种类型的数据 - 不只是文字

csharp

public async Task 提交订单()
{
    // 1. 发送JSON数据(像填写详细的配送信息)
    var 订单信息 = new 
    {
        商品 = "香辣鸡腿堡套餐",
        数量 = 1,
        地址 = "北京市朝阳区xxx",
        备注 = "不要辣椒,快点送"
    };
    
    var json内容 = new StringContent(
        JsonSerializer.Serialize(订单信息),
        Encoding.UTF8,
        "application/json");  // 告诉服务器这是JSON格式
    
    await 我的外卖APP.PostAsync("order", json内容);
    
    // 2. 发送表单数据(像填写简单问卷)
    var 表单数据 = new FormUrlEncodedContent(new[]
    {
        new KeyValuePair<string, string>("rating", "5"),
        new KeyValuePair<string, string>("comment", "很好吃")
    });
    
    await 我的外卖APP.PostAsync("feedback", 表单数据);
    
    // 3. 上传文件(像发送发票照片)
    using var 照片流 = File.OpenRead("发票.jpg");
    var 文件内容 = new StreamContent(照片流);
    
    var 多部分内容 = new MultipartFormDataContent
    {
        { new StringContent("12345"), "orderId" },
        { 文件内容, "invoice", "发票.jpg" }
    };
    
    await 我的外卖APP.PostAsync("upload/invoice", 多部分内容);
}

完整的外卖APP示例

csharp

public class 智能外卖管家
{
    private readonly HttpClient 我的APP;
    
    public 智能外卖管家()
    {
        我的APP = new HttpClient
        {
            BaseAddress = new Uri("https://api.food-delivery.com/v1/"),
            Timeout = TimeSpan.FromSeconds(30)  // 30秒没响应就取消
        };
        
        // 设置APP的通用配置
        我的APP.DefaultRequestHeaders.Add("User-Agent", "MyFoodApp/1.0");
        我的APP.DefaultRequestHeaders.Add("X-API-Key", "my_secret_key");
    }
    
    // 搜索餐厅
    public async Task<List<餐厅>> 搜索餐厅(string 关键词, string 地址)
    {
        string url = $"restaurants?keyword={关键词}&address={地址}";
        var 响应 = await 我的APP.GetAsync(url);
        
        响应.EnsureSuccessStatusCode();  // 如果失败就抛出异常
        
        return await 响应.Content.ReadFromJsonAsync<List<餐厅>>();
    }
    
    // 下单
    public async Task<订单结果> 下单(订单 新订单)
    {
        var 内容 = new StringContent(
            JsonSerializer.Serialize(新订单),
            Encoding.UTF8,
            "application/json");
        
        var 响应 = await 我的APP.PostAsync("orders", 内容);
        
        if (!响应.IsSuccessStatusCode)
        {
            // 处理失败情况
            var 错误信息 = await 响应.Content.ReadAsStringAsync();
            throw new Exception($"下单失败:{错误信息}");
        }
        
        return await 响应.Content.ReadFromJsonAsync<订单结果>();
    }
    
    // 跟踪订单
    public async Task<配送状态> 查看配送状态(string 订单号)
    {
        var 响应 = await 我的APP.GetAsync($"orders/{订单号}/tracking");
        
        if (响应.StatusCode == HttpStatusCode.NotFound)
        {
            throw new Exception("订单不存在");
        }
        
        响应.EnsureSuccessStatusCode();
        return await 响应.Content.ReadFromJsonAsync<配送状态>();
    }
    
    // 批量操作:收藏多家餐厅
    public async Task 批量收藏餐厅(List<string> 餐厅ID列表)
    {
        var 任务列表 = new List<Task>();
        
        foreach (var 餐厅ID in 餐厅ID列表)
        {
            var 任务 = 我的APP.PostAsync($"restaurants/{餐厅ID}/favorite", null);
            任务列表.Add(任务);
        }
        
        // 同时发送所有收藏请求
        await Task.WhenAll(任务列表);
        Console.WriteLine("所有餐厅已收藏!");
    }
}

实际使用场景

场景1:获取天气来决定点什么

csharp

public async Task 智能推荐()
{
    // 先查天气
    var 天气响应 = await 我的外卖APP.GetAsync("https://api.weather.com/北京");
    var 天气数据 = await 天气响应.Content.ReadFromJsonAsync<天气信息>();
    
    // 根据天气推荐
    string 推荐餐品;
    if (天气数据.温度 < 10)
        推荐餐品 = "火锅";
    else if (天气数据.温度 > 30)
        推荐餐品 = "冷面";
    else
        推荐餐品 = "便当";
    
    // 搜索推荐餐品
    var 餐厅列表 = await 搜索餐厅(推荐餐品, "我家地址");
    Console.WriteLine($"根据天气推荐:{推荐餐品}");
}

场景2:处理超时和重试

csharp

public async Task<订单结果> 稳定下单(订单 新订单)
{
    int 重试次数 = 0;
    int 最大重试次数 = 3;
    
    while (重试次数 < 最大重试次数)
    {
        try
        {
            // 尝试下单
            return await 下单(新订单);
        }
        catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.RequestTimeout)
        {
            // 超时了,等一会再试
            重试次数++;
            Console.WriteLine($"请求超时,第{重试次数}次重试...");
            
            if (重试次数 >= 最大重试次数)
                throw new Exception("多次尝试失败,请检查网络");
                
            await Task.Delay(1000 * 重试次数);  // 等待时间递增
        }
        catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.TooManyRequests)
        {
            // 服务器说请求太多了,等5秒再试
            Console.WriteLine("服务器忙,等待5秒...");
            await Task.Delay(5000);
        }
    }
    
    throw new Exception("下单失败");
}

HttpClient的最佳实践

✅ 正确:像长期使用一个外卖APP

csharp

// 在整个应用生命周期中使用同一个HttpClient实例
public class 全局外卖服务
{
    // 静态的,整个应用只创建一个
    private static readonly HttpClient 我的长期APP = new HttpClient
    {
        BaseAddress = new Uri("https://api.food.com/"),
        Timeout = TimeSpan.FromSeconds(30)
    };
    
    // 所有方法都使用这个实例
    public async Task 点餐() => await 我的长期APP.PostAsync(...);
    public async Task 查订单() => await 我的长期APP.GetAsync(...);
}

❌ 错误:像每次点餐都下载一个新APP

csharp

public void 错误的点餐方式()
{
    for (int i = 0; i < 10; i++)
    {
        // 错误!每次都要创建新的HttpClient
        var 临时APP = new HttpClient();
        await 临时APP.GetAsync("https://api.food.com/order");
        // 忘记释放,会导致资源泄露!
        // 就像手机里装了10个相同的外卖APP
    }
}

✅ 更好:使用IHttpClientFactory(像官方应用商店)

csharp

// 在Startup.cs中配置
services.AddHttpClient("外卖服务", client =>
{
    client.BaseAddress = new Uri("https://api.food.com/");
    client.DefaultRequestHeaders.Add("Accept", "application/json");
});

// 使用时获取
public class 订单服务
{
    private readonly IHttpClientFactory _app商店;
    
    public 订单服务(IHttpClientFactory app商店)
    {
        _app商店 = app商店;
    }
    
    public async Task 处理订单()
    {
        // 从"应用商店"获取一个配置好的HttpClient
        var 我的APP = _app商店.CreateClient("外卖服务");
        
        // 使用它
        await 我的APP.PostAsync("orders", ...);
        
        // 不用操心释放,工厂会管理生命周期
    }
}

常见问题解决方案

问题1:网络不稳定怎么办?

csharp

public async Task<string> 稳定获取菜单()
{
    try
    {
        // 设置合理的超时时间
        我的APP.Timeout = TimeSpan.FromSeconds(15);
        
        // 使用CancellationToken可以中途取消
        var 取消令牌源 = new CancellationTokenSource();
        
        // 如果5秒还没响应,就取消
        取消令牌源.CancelAfter(TimeSpan.FromSeconds(5));
        
        var 响应 = await 我的APP.GetAsync("menu", 取消令牌源.Token);
        return await 响应.Content.ReadAsStringAsync();
    }
    catch (TaskCanceledException)
    {
        return "请求超时,请稍后重试";
    }
    catch (HttpRequestException)
    {
        return "网络连接失败,请检查网络";
    }
}

问题2:需要添加认证信息?

csharp

public void 设置登录状态(string 令牌)
{
    // 方法1:添加到默认请求头
    我的APP.DefaultRequestHeaders.Authorization = 
        new AuthenticationHeaderValue("Bearer", 令牌);
    
    // 方法2:每次请求单独添加
    var 请求 = new HttpRequestMessage(HttpMethod.Get, "user/profile");
    请求.Headers.Authorization = new AuthenticationHeaderValue("Bearer", 令牌);
    
    // 方法3:使用HttpClientHandler
    var handler = new HttpClientHandler
    {
        UseCookies = true,
        CookieContainer = new CookieContainer()
    };
    handler.CookieContainer.Add(new Uri("https://api.food.com"), 
                               new Cookie("session", 令牌));
}

总结比喻

HttpClient = 你的智能手机外卖APP

  1. 一次安装,长期使用 - 不用每次点餐都重新下载APP
  2. 保持登录状态 - 登录一次,后续操作都不用再登录
  3. 并行操作 - 可以同时浏览餐厅、查看订单、联系客服
  4. 处理各种数据 - 能发文字、图片、文件,就像APP能发消息、传照片
  5. 智能重试 - 网络不好时会自动重试,就像APP的"重新加载"
  6. 统一管理 - 所有外卖操作都在一个APP里完成

核心思想转变:
从「每次都要重新打电话联系餐厅」
变成「有一个智能外卖APP,所有餐厅、所有操作都在里面完成」

HttpClient让你的程序能像使用外卖APP一样,高效、稳定地与各种网络服务通信!

C#,为什么要用LINQ?

想象你有一个杂乱的书架(数据集合)

不使用LINQ的情况(传统方式):

csharp

// 你有一堆书
List<Book> bookshelf = GetBookshelf();

// 你想找出所有计算机类的书,并按书名排序
List<Book> computerBooks = new List<Book>();

// 传统方式:像人工翻找每本书
foreach (var book in bookshelf)
{
    if (book.Category == "计算机")  // 逐一检查
    {
        computerBooks.Add(book);  // 手动收集
    }
}

// 然后再手动排序
computerBooks.Sort((a, b) => a.Title.CompareTo(b.Title));

使用LINQ的情况(智能助手帮你找书):

csharp

// 使用LINQ:像告诉智能助手你的需求
var computerBooks = bookshelf
    .Where(book => book.Category == "计算机")  // "只要计算机类的"
    .OrderBy(book => book.Title)                // "按书名排序"
    .ToList();                                  // "整理好给我"

LINQ的三种表达方式

1. 方法语法(最常用) - 像用手机App筛选外卖

csharp

var results = bookshelf
    .Where(b => b.Price < 50)      // 价格低于50元
    .OrderByDescending(b => b.Rating) // 按评分降序
    .Select(b => b.Title)          // 只要书名
    .Take(5);                     // 取前5本

2. 查询语法 - 像用自然语言提问

csharp

var results = from book in bookshelf
              where book.Price < 50
              orderby book.Rating descending
              select book.Title
              take 5;
// 翻译:"从书架里,找价格低于50的书,按评分倒序排,只要书名,取前5个"

3. SQL风格 - 像数据库查询

csharp

// LINQ to SQL / Entity Framework
var cheapBooks = from b in dbContext.Books
                 where b.Price < 30
                 select new { b.Title, b.Author };
// 这个会被翻译成SQL:SELECT Title, Author FROM Books WHERE Price < 30

现实生活场景对比

场景:整理家庭相册

csharp

// 你手机里有1000张照片
List<Photo> allPhotos = GetPhotosFromPhone();

// 需求1:找出去年拍的所有海滩照片,按时间排序
var beachPhotos = allPhotos
    .Where(p => p.Date.Year == 2023)
    .Where(p => p.Tags.Contains("海滩"))
    .OrderBy(p => p.Date);

// 需求2:统计每个月拍了多少张照片
var monthlyStats = allPhotos
    .GroupBy(p => p.Date.ToString("yyyy-MM"))
    .Select(g => new {
        Month = g.Key,
        Count = g.Count(),
        AvgSize = g.Average(p => p.FileSize)
    });

// 需求3:找出文件最大的10张照片
var largestPhotos = allPhotos
    .OrderByDescending(p => p.FileSize)
    .Take(10);

LINQ的核心优势

  1. 声明式编程:告诉计算机"要什么",而不是"怎么做"

    csharp

    // 传统:循环、判断、收集(关注过程)
    // LINQ:条件、排序、投影(关注结果)
    
  2. 延迟执行:像外卖下单,不马上做

    csharp

    var query = books.Where(b => b.Price > 100); // 只是"定义需求"
    
    // 真正需要数据时才执行(像下单后才开始做饭)
    foreach (var book in query)  // 这里才执行查询
    {
        Console.WriteLine(book.Title);
    }
    
  3. 可组合性:像搭积木

    csharp

    var baseQuery = books.AsQueryable();
    
    // 根据不同条件组合查询
    if (userWantsCheapBooks)
        baseQuery = baseQuery.Where(b => b.Price < 50);
    
    if (userWantsByAuthor)
        baseQuery = baseQuery.Where(b => b.Author == "刘慈欣");
        
    // 最后才执行
    var results = baseQuery.ToList();
    

常见的LINQ操作(像厨房工具)

csharp

// 1. 过滤(筛子):Where
books.Where(b => b.Rating > 4.0)

// 2. 映射(榨汁机):Select
books.Select(b => b.Title)  // 只要书名
books.Select(b => new { b.Title, b.Price }) // 提取部分信息

// 3. 排序(整理架):OrderBy/ThenBy
books.OrderBy(b => b.Price).ThenBy(b => b.Title)

// 4. 分组(分类盒):GroupBy
books.GroupBy(b => b.Category)  // 按类别分组

// 5. 聚合(计算器)
books.Count()                    // 总数
books.Average(b => b.Price)      // 平均价格
books.Sum(b => b.Pages)         // 总页数
books.Max(b => b.Price)         // 最贵的

// 6. 连接(拼图):Join(像合并两个清单)
var bookOrders = books.Join(orders,
    book => book.Id,           // 书的ID
    order => order.BookId,     // 订单中的书ID
    (book, order) => new { book.Title, order.Quantity });

实际代码示例:电商商品筛选

csharp

public class ProductService
{
    public List<Product> SearchProducts(
        decimal? maxPrice = null,
        string category = null,
        int minRating = 0,
        string sortBy = "price")
    {
        var query = _products.AsQueryable();
        
        // 动态构建查询条件
        if (maxPrice.HasValue)
            query = query.Where(p => p.Price <= maxPrice.Value);
        
        if (!string.IsNullOrEmpty(category))
            query = query.Where(p => p.Category == category);
            
        if (minRating > 0)
            query = query.Where(p => p.Rating >= minRating);
        
        // 动态排序
        query = sortBy switch
        {
            "price" => query.OrderBy(p => p.Price),
            "rating" => query.OrderByDescending(p => p.Rating),
            "sales" => query.OrderByDescending(p => p.MonthlySales),
            _ => query.OrderBy(p => p.Id)
        };
        
        return query.ToList();  // 一次性执行所有条件
    }
}

总结

LINQ就像你的数据管家:

  • 你不需要亲自翻箱倒柜找数据
  • 只需要告诉它你的需求(条件、排序、分组)
  • 它帮你高效地整理、筛选、计算
  • 无论是内存集合、数据库、XML还是其他数据源,用法都一致

关键思想转变:
从"我怎么一步步操作数据" → 到"我想要什么样的数据结果"

这就好比从"自己去仓库搬货、分类、贴标签"变成了"给仓储机器人一个需求清单,它帮你搞定一切"。

HUNT0 上线了——尽早发布,尽早发现

HUNT0 上线了——尽早发布,尽早发现

✨文章摘要(AI生成)

HUNT0 是一个面向 maker 和 indie hacker 的社区驱动发布目录,想把“发布”变成一件有仪式感、可被发现、可获得反馈的事:你可以预约发布日期,通过声望加权投票与评论拿到早期反馈,再通过榜单与 Explore(按筛选意图组织)快速发现值得关注的新产品。

v1.0.0 先把核心闭环跑通:launch → discover → vote → discuss → recap/reward。我们做了实时同步投票状态、结构化的提交流程(免费排队 + Premium Launch)、以及提醒/奖项等自动化机制,目标是减少噪音,让早期发现更“有章可循”。

一切都始于一张有点“丑”的草图:一把向前倾斜的梯子。

这张梯子草图最终成为了 HUNT0 的 logo。它提醒我们:进步是一格一格爬出来的——与其等完美,不如先把不完美的东西发出去。

那张后来变成 HUNT0 logo 的原始梯子草图

这也是口号 “Ship Early, Hunt Early” 的由来:创作者尽早发布,社区才能尽早发现——去浏览、投票、讨论,帮助好产品找到第一批用户。

如果你在公开构建(build in public),欢迎来这里发布;如果你喜欢探索新东西,就从“hunt”开始。

什么是 HUNT0?

HUNT0 是一个社区驱动的发布目录,让“发布”和“发现”同频发生:

  • 预约发布日期,把发布变成一件明确的事件
  • 通过投票与评论获得早期反馈
  • 用榜单与 Explore 按时间与兴趣组织“新产品”,而不是按噪音排序

上线 v1.0.0:我们做了什么

v1.0.0 先聚焦在核心闭环:launch → discover → vote → discuss → recap/reward

1)首页榜单:“今天该看什么?”

首页按时间窗口组织,方便你快速扫到值得关注的内容:

  • Top Products Launching Today:今天(UTC)发布的产品
  • Yesterday / This Week / This Month:按更长时间窗口回顾

投票状态会在页面内 实时同步:你在任意位置给某个产品投票,页面上的所有实例会立刻一起更新,无需刷新。

2)Explore:按意图筛选,而不是靠运气

Explore 支持 分类、标签(最多 10 个)、时间范围、全文搜索,并支持分页。

在相同筛选条件下,Premium Launch 会在排序上优先于免费发布——在“更需要曝光”的时刻更有效。

3)产品页:展示、访问、讨论一站式

每个产品都有独立详情页,便于更深入地了解与互动:

  • 核心信息与外链(Visit)
  • 截图画廊与更长的产品介绍(About)
  • 声望加权的投票与评论
  • 榜单/奖项徽章(例如日榜/周榜/月榜 Top 3)

4)提交:免费排队 + Premium Launch

我们把“发布”设计成一个可控流程,而不只是贴个链接:

  • Free Launch:每日容量有限(默认 10 个名额/天
  • Premium Launch:通过 Stripe Checkout 付费,获得更强曝光

如果 Premium 未完成支付,提交会以草稿形态保留在你的 dashboard,不会公开展示,直到支付成功。

提交也支持更丰富的展示信息:

  • 最多 3 个分类
  • 最多 10 个标签
  • 联系方式/社交链接(至少 1 个)
  • logo 与截图,让产品页更完整

声望系统:让贡献“有分量”

投票不是固定的一人一票。HUNT0 使用 Reputation → Level → Vote Weight

  • 通过参与获得声望(每日访问、投票、评论、发布)
  • 等级越高,投票权重越大
  • 榜单按加权投票聚合,更多反映可信贡献者的偏好

它既是激励机制,也是在早期社区里减少噪音的实用手段。

提醒与奖项:把发布当成“事件”

为了让发布更有“时刻感”,我们加了一些自动化:

  • 发布提醒邮件:在 UTC 发布日开始前 1 小时发送
  • 日榜/周榜/月榜奖项:自动计算 Top 3 并通知创作者(可选公开复盘)

开始使用

  • 如果你是创作者:去 Submit 预约发布日期
  • 如果你想发现新产品:去 Explore 按分类/标签筛选
  • 如果你想看 logo 的故事:去 About 看梯子的来源

我们会持续迭代发现、榜单和社区激励机制。Launch something, hunt something——也欢迎告诉我们哪里可以做得更好。

深入理解 React 中的 useRef:不只是获取 DOM 元素

在 React 函数组件的世界中,useRef 是一个常被提及但又容易被误解的 Hook。很多初学者第一次接触它时,往往只把它当作“获取 DOM 元素”的工具;而随着使用深入,又可能会疑惑:为什么不能用 useRef 替代 useState 来避免不必要的重渲染?本文将结合两个典型示例,系统梳理 useRef 的核心机制、使用场景与常见误区,帮助你真正掌握这个“默默奉献”的 Hook。


一、useRef 的基本能力:持久化引用对象

useRef 的本质是创建一个可变且持久化的引用对象。它的返回值是一个普通 JavaScript 对象,结构为 { current: initialValue }。关键在于:

  • 每次组件重新渲染时,useRef 返回的是同一个对象引用
  • 修改 .current 属性不会触发组件重新渲染
  • 它不参与 React 的响应式更新机制,因此被称为“非响应式存储”。

这与 useState 形成鲜明对比:useState 的状态变更会触发 UI 更新,而 useRef 的变更则“静默”发生。


二、场景一:获取 DOM 元素并操作

最常见的 useRef 用法是绑定到 JSX 元素上,从而在组件逻辑中直接操作 DOM。

import { useRef, useEffect, useState } from 'react';

export default function App() {
  const [count, setCount] = useState(0);
  const inputRef = useRef(null);

  useEffect(() => {
    // 组件挂载后,inputRef.current 指向真实的 <input> 元素
    inputRef.current.focus();
  }, []);

  return (
    <>
      <input ref={inputRef} />
      {count}
      <button onClick={() => setCount(count + 1)}>count ++</button>
    </>
  );
}

在这个例子中:

  • 初始渲染时,inputRef.currentnull,因为 DOM 尚未生成;
  • React 在完成 DOM 挂载后,会自动将对应的 DOM 节点赋值给 ref.current
  • useEffect(依赖项为空数组)在首次挂载后执行,此时 inputRef.current 已是有效的 <input> 元素,调用 .focus() 实现自动聚焦。

值得注意的是:即使 inputRef.currentnull 变为 DOM 节点,组件也不会重新渲染。这正是 useRef 的设计初衷——提供一种不干扰 React 渲染流程的方式来访问或存储数据。


三、场景二:存储可变值以避免状态重置

除了操作 DOM,useRef 还非常适合用于在多次渲染之间持久化存储可变值,尤其是在处理副作用(如定时器)时。

考虑以下错误写法:

// ❌ 错误:使用普通变量存储定时器 ID
let intervalId = null;

function start() {
  intervalId = setInterval(() => {
    console.log('tick~~~');
  }, 1000);
}

function stop() {
  clearInterval(intervalId); // 可能为 null!
}

问题在于:每当 count 状态更新,整个函数组件会重新执行,let intervalId = null 会被再次初始化,导致之前保存的定时器 ID 丢失。结果是:

  • 多次点击“开始”会创建多个定时器;
  • “停止”按钮无法清除旧的定时器;
  • 造成内存泄漏逻辑混乱

正确的做法是使用 useRef

import { useEffect, useRef, useState } from 'react';

export default function App() {
  const intervalId = useRef(null);
  const [count, setCount] = useState(0);

  function start() {
    intervalId.current = setInterval(() => {
      console.log('tick~~~');
    }, 1000);
  }

  function stop() {
    clearInterval(intervalId.current);
  }

  return (
    <>
      <button onClick={start}>开始</button>
      <button onClick={stop}>停止</button>
      {count}
      <button onClick={() => setCount(count + 1)}>count ++</button>
    </>
  );
}

这里的关键在于:

  • useRef(null) 在组件整个生命周期内始终返回同一个对象
  • 即使 count 更新导致组件重新渲染,intervalId.current 依然保留着上次设置的定时器 ID;
  • 因此 clearInterval 能正确清除目标定时器,避免资源泄露。

四、useRef 与 useState 的核心区别

虽然 useRefuseState 都能“存值”,但它们的设计目标截然不同:

特性 useState useRef
是否触发重渲染 ✅ 是 ❌ 否
值变更是否被 React 跟踪 ✅ 是(通过调度更新) ❌ 否
适用场景 驱动 UI 变化的状态 存储不需触发 UI 更新的可变数据
初始值 每次调用 useState(initial) 仅在首次生效 useRef(initial) 的初始值也仅在首次生效,但后续 .current 可任意修改

常见误区:能否用 useRef 替代 useState?

不能。

假设你试图用 useRef 存储计数器值以“避免重渲染”:

const countRef = useRef(0);
// ...
<button onClick={() => countRef.current++}>+1</button>
<p>{countRef.current}</p>

你会发现:点击按钮后,页面上的数字不会更新!因为 React 并不知道 countRef.current 发生了变化,自然不会重新执行渲染逻辑。

结论

  • 如果你需要同时存储值并更新 UI,必须使用 useState
  • 如果你只需要在不触发重渲染的前提下保存中间状态或引用(如定时器 ID、前一次的 props、滚动位置等),才应使用 useRef

五、useRef 的典型应用场景总结

  1. 访问 DOM 节点
    如聚焦输入框、测量元素尺寸、触发动画等。

  2. 持久化存储可变值

    • 定时器/延时器 ID(setInterval / setTimeout
    • WebSocket 实例
    • 第三方库的实例(如地图、图表对象)
    • 上一次的 props 或 state(用于对比)
  3. 跨渲染保持状态而不触发更新
    例如记录组件是否已挂载(在异步回调中判断是否还能安全 setState)。


结语

useRef 虽然名字里有 “ref”,但它远不止是“获取 DOM 的工具”。它是一个轻量级、非响应式的持久化容器,在需要“记住某些东西但又不想打扰 React 渲染流程”时大显身手。

正确使用 useRef,能让你的组件更高效、更健壮;而误用它(如试图替代状态管理),则会导致 UI 不更新或逻辑错乱。理解其“非响应式”和“引用持久化”的两大特性,是掌握这一 Hook 的关键。

在实际开发中,当你遇到以下情况时,不妨想想 useRef

  • “我需要在组件里保存一个值,但它变了不需要刷新页面。”
  • “我想在挂载后操作某个 DOM 元素。”
  • “我的定时器怎么关不掉了?是不是 ID 丢了?”

答案,往往就在 useRef 之中。

Vue Router 深度解析:从基础概念到高级应用实践

引言

在现代Web应用开发中,单页应用(SPA)已经成为主流开发模式。Vue.js作为当前最流行的前端框架之一,其官方路由库Vue Router在构建复杂SPA应用中扮演着至关重要的角色。本文将通过分析一个完整的Vue Router项目实例,深入探讨Vue Router的核心概念、使用方法和最佳实践,帮助开发者全面掌握这一强大的路由管理工具。

一、Vue Router基础概念解析

1.1 SPA应用的本质

单页Web应用(Single Page Web Application, SPA)的核心特点是整个应用只有一个完整的HTML页面。与传统的多页应用不同,SPA在用户与应用程序交互时,不会重新加载整个页面,而是通过JavaScript动态更新页面的部分内容。这种模式带来了更流畅的用户体验,减少了页面切换时的白屏时间,使应用更接近原生应用的体验。

从提供的代码中可以看到,App.vue作为应用的根组件,通过<router-view>标签动态渲染不同的路由组件,这正是SPA架构的典型实现。

1.2 路由的基本概念

在Vue Router中,路由本质上是路径(path)与组件(component)之间的映射关系。这种映射关系使得当用户访问特定URL时,能够展示对应的Vue组件。

javascript

复制下载

// 路由配置示例
const router = new VueRouter({
  routes: [
    {
      path: '/about',
      component: UserAbout
    },
    {
      path: '/home',
      component: UserHome
    }
  ]
})

二、Vue Router的核心配置与使用

2.1 路由器的创建与配置

index.js文件中,我们可以看到完整的路由器配置示例。路由器通过VueRouter构造函数创建,接收一个配置对象,其中routes数组定义了所有的路由规则。

javascript

复制下载

import VueRouter from 'vue-router'
import UserAbout from '../pages/UserAbout.vue'
import UserHome from '../pages/UserHome.vue'

const router = new VueRouter({
  routes: [
    {
      name: 'guanyu',
      path: '/about',
      component: UserAbout,
      meta: { title: '关于' }
    }
  ]
})

2.2 路由组件与一般组件的区别

Vue Router的一个重要实践是将路由组件和一般组件分离存储:

  • 路由组件通常存放在pagesviews文件夹中
  • 一般组件通常存放在components文件夹中

这种分离有助于代码的组织和维护,使项目结构更加清晰。从代码中可以看到,UserAboutUserHome等路由组件都存放在pages目录下,而UserBanner这样的展示组件则存放在components目录下。

2.3 多级路由(嵌套路由)的实现

Vue Router支持嵌套路由,允许在父路由组件中嵌套子路由组件。这在构建复杂布局时非常有用。

javascript

复制下载

{
  name: 'zhuye',
  path: '/home',
  component: UserHome,
  children: [
    {
      name: 'xinwen',
      path: 'news',  // 注意:此处不加/,表示相对路径
      component: UserNews
    },
    {
      name: 'xiaoxi',
      path: 'message',
      component: UserMessage,
      children: [
        {
          name: 'xiangqing',
          path: 'detail',
          component: MessageDetail
        }
      ]
    }
  ]
}

UserHome.vue组件中,通过<router-view>标签来渲染子路由组件,实现了嵌套路由的展示。

三、路由参数传递的多种方式

3.1 Query参数传递

Query参数是Vue Router中最常用的参数传递方式之一。它通过URL的查询字符串传递参数,适合传递可选参数。

vue

复制下载

<!-- UserMessage.vue中的query参数传递 -->
<router-link :to="{
  path: '/home/message/detail',
  query: {
    id: message.id,
    title: message.title
  }
}">
  {{message.title}}
</router-link>

在目标组件MessageDetail.vue中,可以通过$route.query获取这些参数:

vue

复制下载

<template>
  <ul>
    <li>id: {{$route.query.id}}</li>
    <li>title: {{$route.query.title}}</li>
  </ul>
</template>

3.2 Params参数传递

Params参数通过URL路径的一部分传递,适合传递必选参数。使用params参数时需要注意路由配置:

javascript

复制下载

{
  path: 'detail/:id/:title',  // 定义params参数
  component: MessageDetail
}

传递params参数时,必须使用name配置而非path配置:

vue

复制下载

<router-link :to="{
  name: 'xiangqing',
  params: {
    id: message.id,
    title: message.title
  }
}">
  {{message.title}}
</router-link>

3.3 Props配置简化参数接收

Vue Router提供了props配置,可以将路由参数作为组件的props传递,使组件更加独立和可复用。

javascript

复制下载

{
  name: 'xiangqing',
  path: 'detail',
  component: Detail,
  
  // 第一种写法:props值为对象
  props: { a: 900 }
  
  // 第二种写法:props值为布尔值
  props: true  // 将params参数作为props传递
  
  // 第三种写法:props值为函数
  props(route) {
    return {
      id: route.query.id,
      title: route.query.title
    }
  }
}

四、编程式路由导航

除了使用<router-link>进行声明式导航外,Vue Router还提供了编程式导航API,使路由跳转更加灵活。

4.1 基本导航方法

javascript

复制下载

// UserMessage.vue中的编程式导航示例
methods: {
  pushShow(m) {
    this.$router.push({
      path: '/home/message/detail',
      query: {
        id: m.id,
        title: m.title
      }
    })
  },
  replaceShow(m) {
    this.$router.replace({
      path: '/home/message/detail',
      query: {
        id: m.id,
        title: m.title
      }
    })    
  }
}

4.2 历史记录管理

Vue Router提供了多种历史记录管理方法:

javascript

复制下载

// UserBanner.vue中的历史记录控制
methods: {
  forward() {
    this.$router.forward();
  },
  back() {
    this.$router.back();
  },
  go() {
    this.$router.go(-2);  // 后退两步
  }
}

五、高级路由功能

5.1 缓存路由组件

在某些场景下,我们需要保持组件的状态,避免组件在切换时被销毁。Vue Router通过<keep-alive>组件实现路由组件的缓存。

vue

复制下载

<!-- UserHome.vue中的缓存配置 -->
<keep-alive include="UserNews">
  <router-view></router-view>
</keep-alive>

include属性指定需要缓存的组件名称(注意是组件name,不是路由name)。如果需要缓存多个组件,可以使用数组:

vue

复制下载

<keep-alive :include="['UserNews', 'UserMessage']">
  <router-view></router-view>
</keep-alive>

5.2 组件生命周期扩展

当使用<keep-alive>缓存组件时,Vue组件会获得两个新的生命周期钩子:

javascript

复制下载

// UserNews.vue中的生命周期示例
activated() {
  console.log('UserNews组件被激活了');
  // 启动定时器
  this.timer = setInterval(() => {
    this.opacity -= 0.01;
    if(this.opacity <= 0) {
      this.opacity = 1;
    }
  }, 16)
},
deactivated() {
  console.log('UserNews组件被停用了');
  // 清除定时器
  clearInterval(this.timer);
}

这两个钩子只在被缓存的组件中生效,分别在组件激活和失活时触发,非常适合处理如定时器、事件监听等需要在组件不显示时清理的资源。

六、路由守卫系统

路由守卫是Vue Router中用于权限控制和路由拦截的强大功能。

6.1 全局守卫

全局守卫作用于所有路由,包括全局前置守卫和全局后置守卫。

javascript

复制下载

// 全局前置守卫
router.beforeEach((to, from, next) => {
  // 权限检查示例
  if(to.meta.isAuth) {
    alert('需要授权才能查看');
    // 可以在这里进行登录检查等操作
  } else {
    next();  // 放行
  }
});

// 全局后置守卫
router.afterEach((to, from) => {
  // 修改页面标题
  document.title = to.meta.title || '默认标题';
});

6.2 独享守卫

独享守卫只作用于特定路由,在路由配置中直接定义:

javascript

复制下载

{
  path: '/about',
  component: UserAbout,
  beforeEnter(to, from, next) {
    // 进入/about路由前的逻辑
    next();
  }
}

6.3 组件内守卫

组件内守卫定义在组件内部,提供了更细粒度的控制:

javascript

复制下载

// UserAbout.vue中的组件内守卫
export default {
  name: 'UserAbout',
  
  // 进入守卫
  beforeRouteEnter(to, from, next) {
    // 在渲染该组件的对应路由被验证前调用
    // 不能获取组件实例 `this`
    next();
  },
  
  // 离开守卫
  beforeRouteLeave(to, from, next) {
    // 在导航离开该组件的对应路由时调用
    // 可以访问组件实例 `this`
    next();
  }
}

七、路由模式选择

Vue Router支持两种路由模式:hash模式和history模式。

7.1 Hash模式

Hash模式使用URL的hash部分(#后面的内容)来模拟完整的URL,而不触发页面重新加载。这是Vue Router的默认模式。

特点:

  • 兼容性好,支持所有浏览器
  • 不需要服务器端特殊配置
  • URL中带有#号,不够美观

7.2 History模式

History模式利用HTML5 History API实现,提供了更清洁的URL。

javascript

复制下载

const router = new VueRouter({
  mode: 'history',  // 启用history模式
  routes: [...]
})

特点:

  • URL更美观,没有#号
  • 需要服务器端配置支持,避免刷新时出现404错误
  • 兼容性相对较差(现代浏览器都支持)

八、项目架构与最佳实践

8.1 项目结构组织

基于提供的代码,我们可以总结出良好的Vue Router项目结构:

text

复制下载

src/
├── components/     # 一般组件
│   └── UserBanner.vue
├── pages/         # 路由组件
│   ├── UserAbout.vue
│   ├── UserHome.vue
│   ├── UserNews.vue
│   ├── UserMessage.vue
│   └── MessageDetail.vue
├── router/        # 路由配置
│   └── index.js
└── App.vue       # 根组件

8.2 路由配置管理

对于大型项目,建议将路由配置拆分到多个文件中:

javascript

复制下载

// router/modules/home.js
export default {
  path: '/home',
  component: () => import('@/pages/UserHome.vue'),
  children: [...]
}

// router/index.js
import homeRoutes from './modules/home'
import aboutRoutes from './modules/about'

const routes = [
  homeRoutes,
  aboutRoutes
]

8.3 路由懒加载

使用Webpack的动态导入语法实现路由懒加载,可以显著提升应用性能:

javascript

复制下载

{
  path: '/about',
  component: () => import(/* webpackChunkName: "about" */ '../pages/UserAbout.vue')
}

九、常见问题与解决方案

9.1 路由重复点击警告

当重复点击当前激活的路由链接时,Vue Router会抛出警告。可以通过以下方式解决:

javascript

复制下载

// 方法1:在push时捕获异常
this.$router.push('/path').catch(err => {
  if (err.name !== 'NavigationDuplicated') {
    throw err
  }
})

// 方法2:重写VueRouter原型方法
const originalPush = VueRouter.prototype.push
VueRouter.prototype.push = function push(location) {
  return originalPush.call(this, location).catch(err => err)
}

9.2 页面滚动行为控制

Vue Router允许自定义页面滚动行为:

javascript

复制下载

const router = new VueRouter({
  routes: [...],
  scrollBehavior(to, from, savedPosition) {
    // 返回滚动位置
    if (savedPosition) {
      return savedPosition
    } else {
      return { x: 0, y: 0 }
    }
  }
})

十、总结

Vue Router作为Vue.js生态中不可或缺的一部分,为构建复杂的单页应用提供了完整的路由解决方案。从基础的路由配置到高级的守卫系统,从简单的参数传递到复杂的嵌套路由,Vue Router都提供了简洁而强大的API。

通过本文对示例代码的深入分析,我们可以总结出使用Vue Router的最佳实践:

  1. 合理组织项目结构:分离路由组件和一般组件,使代码更易维护
  2. 合理使用路由参数:根据场景选择query或params参数传递方式
  3. 善用路由守卫:实现灵活的权限控制和路由拦截
  4. 优化组件生命周期:合理使用缓存和对应的生命周期钩子
  5. 考虑路由模式:根据项目需求选择合适的路由模式
  6. 实现代码分割:使用路由懒加载优化应用性能

随着Vue 3的普及,Vue Router 4也带来了更多新特性和改进,但核心概念和设计思想与Vue Router 3保持一致。掌握本文介绍的核心概念和实践技巧,将为开发者构建高效、可维护的Vue.js应用奠定坚实基础。

cloudflare的worker中的Environment环境变量和不同环境配置

大家好,我是1024小神,想进 技术群 / 私活群 / 股票群 或 交朋友都可以私信我,如果你觉得本文有用,一键三连 (点赞、评论、关注),就是对我最大的支持~

在cloudflare中配置不同的环境变量和环境是开发中肯定会遇到的,比如密钥不能明文存储,比如开发环境和测试环境隔离,这里的配置和在vite中配置环境变量还是不一样的,所以这里记录一下。官方文档:developers.cloudflare.com/workers/wra…

环境变量

环境变量的文档:developers.cloudflare.com/workers/wra…

或者在wrangler.jsonc同级目录配置.env文件:注意.env文件不应该被git记录

API_HOST="value"
API_TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"

就是在wrangler.jsonc中定义变量名称,然后在代码中获取:

export default {
  async fetch(request, env, ctx) {
    return new Response(`API host: ${env.API_HOST}`);
  },
};

这里有更详细的用法说明:developers.cloudflare.com/workers/con…

当然wrangler.jsonc定义的是配置会被git同步到仓库中,肯定是不安全的,所以这里配置的一定是不重要的或测试环境的变量,在后台worker中可以配置生产环境的变量:

不同的环境

为不同的环境配置不同的环境变量也是必须的,这里有两种方式,一个是在Wrangler.jsonc中配置,另外一个就是通过配置文件.env.test、.env.prod等实现,就和在前端中配置一样简单。我这里推荐使用配置文件的方式,因为这种方式可以避免环境变量泄漏风险。

配置.env.test文件:

使用命令启动:

wrangler dev --env test

就可以看到加载的环境变量:

或者写一个接口来查询环境变量信息:

得到的结果:

如果你有好的想法或需求,都可以私信我,我这里有很多程序员朋友喜欢用代码来创造丰富多彩的计算机世界
❌