普通视图

发现新文章,点击刷新页面。
今天 — 2025年12月25日首页

从 0 搭建 React 待办应用:状态管理、副作用与双向绑定模拟

作者 T___T
2025年12月25日 00:01

React 作为前端主流框架,其单向数据流 组件化 状态驱动视图的设计理念,看似抽象却能通过一个简单的 TodoList 案例彻底吃透。本文不只是 “解释代码”,而是从设计初衷、底层逻辑、实际价值三个维度,拆解 useState useEffect、受控组件模拟双向绑定、父子通信等核心知识点,让你不仅 “会用”,更 “懂为什么这么用”。

一、案例整体架构:先懂 “拆分逻辑”,再看 “代码细节”

在动手写代码前,React 开发的第一步是组件拆分—— 遵循单一职责原则,把复杂页面拆成独立、可复用的小组件,这是 React 组件化思想的核心。

本次 TodoList 的组件拆分如下:

组件名 核心职责 核心交互
App(根组件) 全局状态管理 + 核心逻辑封装 定义新增 / 删除 / 切换待办、数据持久化等方法
TodoInput 待办输入 + 提交 收集用户输入,触发 “新增待办” 逻辑
TodoList 待办列表渲染 展示待办项,转发 “删除 / 切换完成状态” 事件
TodoStats 待办数据统计 展示总数 / 已完成 / 未完成数,触发 “清除已完成” 逻辑

这种拆分的核心价值:每个组件只做一件事,便于维护、复用和调试(比如后续想改输入框样式,只动 TodoInput 即可,不影响列表和统计逻辑)。

二、核心 API 深度拆解:不止 “会用”,更懂 “为什么这么设计”

1. useState:React 状态管理的 “灵魂”

React 中所有可变数据都必须通过**状态(State)**管理,而 useState 是最基础、最核心的状态钩子 —— 它解决了 “函数组件无法拥有自身状态” 的问题,也是 “状态驱动视图” 的核心载体。

(1)基础原理:为什么需要 useState?

纯函数组件本身是 “无状态” 的(执行完就销毁,无法保存数据),而用户交互(比如输入待办、切换完成状态)必然需要 “保存可变数据”。useState 本质是给函数组件提供了持久化的状态存储空间,且这个存储空间和组件渲染周期绑定:

  • 状态更新 → 组件重新渲染 → 视图同步更新;
  • 状态不更新 → 组件不会重复渲染,保证性能。

(2)两种初始化方式:普通初始化 vs 惰性初始化

// 方式1:普通初始化(适合简单、无计算的初始值)
const [count, setCount] = useState(0);

// 方式2:惰性初始化(重点! TodoList 中用的就是这种)
const [todos, setTodos] = useState(() => {
  const saved = localStorage.getItem('todos');
  return saved ? JSON.parse(saved) : [];
});

关键区别与设计初衷

  • 普通初始化:useState(初始值) 中,初始值表达式会在组件每次渲染时都执行(哪怕状态没变化);
  • 惰性初始化:useState(() => { ... }) 中,传入的函数仅在**组件首次渲染*时执行一次,后续渲染不会再跑。

TodoList 中用惰性初始化的核心原因:localStorage.getItem('todos') 是浏览器本地读取操作,虽然开销小,但如果放在普通初始化里,每次组件渲染(比如新增 / 删除待办)都会重复读取本地存储,完全没必要;而惰性初始化只执行一次,既拿到了初始数据,又避免了性能浪费 —— 这是 React 性能优化的 “小细节”,也是理解 useState 设计的关键。

(3)状态更新的 “不可变原则”:为什么必须返回新值?

React 规定:状态是只读的,修改状态必须返回新值,不能直接修改原状态。比如这里的 “新增待办” 逻辑:

const addTodo = (text) => {
  // 错误写法:直接修改原数组(React 无法检测到状态变化,视图不更新)
  // todos.push({ id: Date.now(), text, completed: false });
  // setTodos(todos);

  // 正确写法:解构原数组 + 新增项,返回新数组
  setTodos([...todos, {
    id: Date.now(),
    text,
    completed: false
  }]);
};

底层逻辑:React 判断状态是否变化的依据是引用是否改变。数组 / 对象是引用类型,直接修改原数组(todos.push),数组的引用没变化,React 会认为 “状态没改”,因此不会触发组件重新渲染;而通过 [...todos] 解构生成新数组,引用变了,React 才能检测到状态变化,进而更新视图。

这也是 React “单向数据流” 的核心体现:状态更新是 “不可变” 的,每一次状态变化都会生成新值,便于追踪数据流转(比如调试时能清晰看到每次状态更新的前后值)。

2. useEffect:副作用处理的 “专属管家”

React 组件的核心职责是根据状态渲染视图,而像 “读取本地存储、发送网络请求、绑定事件监听、修改 DOM” 这类不直接参与渲染,但又必须执行的操作,统称为 “副作用(Side Effect)”。useEffect 是 React 专门为处理副作用设计的钩子,替代了类组件中 componentDidMount componentDidUpdate componentWillUnmount 等生命周期方法,且逻辑更集中。

(1)核心语法与执行机制

useEffect(() => {
  // 副作用逻辑:比如保存数据到本地存储
  localStorage.setItem('todos', JSON.stringify(todos));

  // 可选的清理函数(比如取消事件监听、清除定时器)
  return () => {
    // 组件卸载/依赖变化前执行
  };
}, [todos]); // 依赖数组:决定副作用的执行时机

执行时机的深度解析

  • 依赖数组为空 []:仅在组件首次渲染完成后执行一次(对应类组件 componentDidMount);
  • 依赖数组有值 [todos]:组件首次渲染执行 + 每次依赖项(todos)变化后执行(对应 componentDidMount + componentDidUpdate);
  • 无依赖数组:组件每次渲染完成后都执行(极少用,易导致性能问题);
  • 清理函数:组件卸载前 / 下一次副作用执行前触发(比如监听窗口大小变化后,卸载组件时要取消监听,避免内存泄漏)。

(2)在 TodoList 中的核心应用:数据持久化

代码中,useEffect 用来将 todos 同步到 localStorage,这是前端 “数据持久化” 的经典场景,我们拆解其价值:

useEffect(() => {
  localStorage.setItem('todos', JSON.stringify(todos));
}, [todos]);
  • 为什么 localStorage 只能存字符串? localStorage 是浏览器提供的本地存储 API,其底层设计只支持字符串键值对存储,因此存储数组 / 对象时,必须用 JSON.stringify 转为字符串;读取时用 JSON.parse 转回原数据类型,这是前端本地存储的通用规则。

(3)useEffect 在这里的核心价值(为什么非它不可)

1. 精准触发:只在需要时执行,保证性能

useEffect 的第二个参数(依赖数组 [todos])是关键:

  • 组件首次渲染时,执行一次(把初始的 todos 保存到本地);
  • 只有 todos 发生实际变化时,才会再次执行(新增 / 删除 / 切换状态 / 清除已完成,只要 todos 变了,就同步保存);
  • todos 没变化时(比如组件因其他状态重新渲染),完全不执行,避免无效操作。

对比 “写在组件顶层” 的无差别执行,useEffect 实现了 “按需执行”,既保证数据同步,又不浪费性能。

2. 时机正确:拿到最新的状态,避免数据不一致

useEffect 的执行时机是「组件渲染完成后」—— 也就是说,当 useEffect 里的代码执行时,setTodos 已经完成了状态更新,todos 一定是最新的。

比如新增待办时:

  1. 调用 addTodo → 执行 setTodos → 组件重新渲染(todos 变为新值);
  2. 渲染完成后,useEffect 检测到 todos 变化 → 执行保存逻辑 → 拿到的是最新的 todos

这就避免了 “异步更新导致保存旧值” 的问题,保证本地存储的数据和组件状态完全一致。

3. 逻辑聚合:一处监听,全场景生效

不管是新增、删除、切换状态、清除已完成,只要最终导致 todos 变化,useEffect 都会自动触发保存 —— 无需在每个修改 todos 的函数里重复写保存逻辑,代码简洁、易维护,后续新增修改 todos 的逻辑(比如批量修改),完全不用动保存代码,天然符合 “开闭原则”。

(4)useEffect 的设计价值:分离 “渲染逻辑” 与 “副作用逻辑”

React 追求 “组件核心逻辑纯净”—— 组件顶层只关注 “根据状态渲染什么”,副作用全部交给 useEffect 处理,这样:

  • 代码结构更清晰:渲染和副作用分离,一眼能区分 “视图相关” 和 “非视图相关” 逻辑;
  • 便于调试:副作用的执行时机由依赖数组明确控制,能精准定位 “什么时候执行、为什么执行”;
  • 避免内存泄漏:通过清理函数可优雅处理 “组件卸载后仍执行副作用” 的问题(比如请求数据时组件卸载了,清理函数可取消请求)。

3. 受控组件:模拟双向绑定的底层逻辑

Vue 中用 v-model 就能实现 “表单值 ↔ 数据” 的双向绑定,但 React 没有内置的双向绑定语法 —— 不是 “做不到”,而是 React 坚持单向数据流,通过 “受控组件” 手动模拟双向绑定,虽然代码多了几行,但能完全掌控数据流转。

(1)双向绑定的本质:视图 ↔ 数据同步

不管是 Vue 的 v-model 还是 React 的受控组件,双向绑定的核心是两件事:

  1. 数据 → 视图:数据(状态)变化,视图(输入框)自动更新;
  2. 视图 → 数据:视图(用户输入)变化,数据(状态)自动更新。

(2)React 受控组件的实现:拆解每一步

以 TodoInput 组件为例,逐行解析双向绑定的实现逻辑:

const TodoInput = ({ onAdd }) => {
  // 步骤1:定义状态存储输入框值(数据层)
  const [inputValue, setInputValue] = useState('');

  // 步骤2:处理表单提交
  const handleSubmit = (e) => {
    // 关键:阻止表单默认提交行为
    e.preventDefault();
    // 输入内容校验:去除首尾空格,避免空提交
    const text = inputValue.trim();
    if (!text) return;
    // 步骤3:将输入内容传给父组件(父子通信)
    onAdd(text);
    // 步骤4:清空输入框(修改状态 → 视图清空)
    setInputValue('');
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="text"
        // 核心1数据视图状态控制输入框显示value={inputValue}
        // 核心2视图数据输入变化同步更新状态onChange={e => setInputValue(e.target.value)}
        placeholder="请输入待办事项..."
      />
      <button type="submit">Add</button>
    </form>
  );
};

逐点深度解析

  • 数据 → 视图value={inputValue} 是 “单向绑定” 的核心 —— 输入框显示的内容完全由 inputValue 状态决定,而非 DOM 自身的 value。比如执行 setInputValue('')inputValue 变为空,输入框就会立刻清空,这是 “状态驱动视图” 的体现。
  • 视图 → 数据onChange 事件监听输入框的每一次字符变化,e.target.value 是输入框当前的 DOM 取值,通过 setInputValue 将其同步到 inputValue 状态 —— 这一步是 “手动补全” 双向绑定的反向流程,也是 React 与 Vue 的核心区别(Vue 把这一步封装成了 v-model,React 让开发者手动控制,更灵活)。
  • e.preventDefault() :表单的默认行为是 “提交并刷新页面”,而 React 是单页应用,刷新页面会导致所有状态丢失,因此必须阻止这个默认行为 —— 这是前端开发的通用知识点,也是 React 处理表单的 “必做步骤”。
  • 为什么用 form + onSubmit 而非 button + onClick除了点击按钮提交,用户在输入框按回车键也能触发 onSubmit,而单纯的 onClick 无法响应回车提交,这是语义化 + 用户体验的双重考量。

(3)受控组件的核心优势:完全可控

相比 Vue 的 v-model 黑盒封装,React 受控组件的 “手动操作” 带来了两个核心价值:

  • 可校验性:在 onChange 或 handleSubmit 中可随时对输入内容做校验(比如禁止输入特殊字符、限制长度、去除空格),比如在代码中 inputValue.trim() 就是简单的校验,若需要更复杂的校验(比如手机号格式),可直接在这一步处理;
  • 可追溯性:输入框的每一次值变化都必须通过 setInputValue 触发,在调试工具中能清晰看到 inputValue 的每一次更新记录,便于定位 “输入异常” 问题(比如输入框值不变,可直接查 setInputValue 是否执行)。

4. 父子组件通信:单向数据流的极致体现

React 的 “单向数据流” 不是 “限制”,而是 “保障”—— 数据只能从父组件通过 props 流向子组件,子组件不能直接修改父组件的状态,只能通过父组件传递的回调函数 “通知” 父组件修改状态。这种设计让数据流转路径清晰,避免了 “多个组件随意修改数据导致的混乱”。

(1)通信流程:以 “清除已完成任务” 为例

  1. 父组件(App) :定义状态修改逻辑 + 传递回调函数
// 步骤1:父组件定义修改状态的核心逻辑
const clearCompleted = () => {
  setTodos(todos.filter(todo => !todo.completed));
};

// 步骤2:通过 props 将回调函数传递给子组件
<TodoStats 
  total={todos.length}
  completed={completedCount}
  active={activeCount}
  onClearCompleted={clearCompleted} // 传递回调
/>
  1. 子组件(TodoStats) :接收回调函数 + 触发回调
const TodoStats = ({ total, completed, active, onClearCompleted }) => {
  return (
    <div>
      <p>Total: {total}</p>
      <p>Completed: {completed}</p>
      <p>Active: {active}</p>
      {/* 条件渲染:有已完成任务才显示按钮 */}
      {completed > 0 && (
        <button onClick={onClearCompleted} className="clear-btn">
          清除已完成任务
        </button>
      )}
    </div>
  );
};

深度解析

  • 子组件 TodoStats 只负责 “展示数据 + 触发交互”,不关心 “清除已完成任务” 的具体逻辑 —— 哪怕后续修改清除逻辑(比如加确认弹窗),只需改父组件的 clearCompleted,子组件完全不用动,符合 “开闭原则”。
  • 回调函数是 “子组件通知父组件” 的唯一方式:子组件无法直接访问父组件的 todos 状态,也不能直接调用 setTodos,只能通过父组件传递的 onClearCompleted 回调,触发父组件的状态修改逻辑 —— 这就是 “单向数据流”:数据向下传(父→子),事件向上传(子→父),所有状态修改都集中在父组件,便于追踪和调试。

(2)props 的本质:只读的 “数据桥梁” (后面会单独来讲)

props 是父子组件通信的唯一桥梁,但有一个核心规则:子组件不能修改 props。比如 TodoStats 接收的 completed total 等 props,子组件只能读取,不能修改 —— 因为 props 是父组件状态的 “快照”,修改 props 会导致数据源头混乱(比如子组件改了 completed,父组件的 completedCount 却没变化,数据不一致)。

image.png

三、核心设计思想:从 TodoList 看 React 的底层逻辑

通过这个 TodoList 案例,我们能提炼出 React 最核心的 4 个设计思想,这也是理解 React 的关键:

1. 状态驱动视图

React 中 “视图是什么样” 完全由 “状态是什么样” 决定,没有 “手动操作 DOM” 的场景(比如不用 document.getElementById 改输入框值,不用 appendChild 加待办项)。所有视图变化,都是先修改状态,再由 React 自动更新 DOM—— 这避免了手动操作 DOM 的繁琐和易出错,也让代码更易维护(只需关注状态变化,不用关注 DOM 变化)。

2. 单向数据流

数据只有一个流向:父组件 → 子组件,状态只有一个修改入口:定义状态的组件(比如 todos 定义在 App,只有 App 能改,子组件只能通过回调通知 App 改)。这种设计让数据流转 “可预测”—— 不管项目多复杂,都能顺着 props 找到数据的源头,顺着回调找到状态修改的地方。

3. 组件化与单一职责

每个组件只做一件事:TodoInput 只处理输入,TodoList 只渲染列表,TodoStats 只展示统计。这种拆分让组件 “高内聚、低耦合”:

  • 高内聚:组件内部逻辑围绕核心职责展开,不掺杂其他功能;
  • 低耦合:组件之间通过 props 通信,修改一个组件不会影响其他组件。

4. 副作用与渲染分离

useEffect 将 “副作用逻辑”(比如本地存储)与 “渲染逻辑”(比如展示待办列表)分离,让组件的核心逻辑(根据状态渲染视图)保持 “纯净”—— 纯净的组件逻辑更易测试、更易复用,这也是 React 推崇的 “函数式编程” 思想的体现。

四、总结:从 TodoList 到 React 核心能力

这个看似简单的 TodoList,实则涵盖了 React 日常开发的核心知识点:

  • useState 实现状态管理,理解 “不可变更新” 和 “惰性初始化”;
  • useEffect 处理副作用,理解 “依赖数组” 和 “数据持久化”;
  • 受控组件模拟双向绑定,理解 “状态驱动视图” 和 “单向数据流”;
  • 父子组件通信,理解 props 的 “只读特性” 和回调函数的作用。
昨天 — 2025年12月24日首页

从零实现前端监控告警系统:SMTP + Node.js + 个人邮箱 完整免费方案

2025年12月24日 16:22

本文将详细介绍如何为前端监控平台设计并实现一套完整的邮件告警系统,包括架构设计、核心原理、代码实现和最佳实践。

一、为什么需要告警系统?

在前端监控平台中,我们通常会采集大量的错误和性能数据。但如果只是被动地等待开发者登录 Dashboard 查看,很多问题可能已经影响了大量用户。

告警系统的核心价值:

  • 🚨 实时感知:错误发生时第一时间通知相关人员
  • 🎯 精准触达:根据规则过滤,避免告警轰炸
  • 快速响应:缩短问题发现到修复的时间窗口

二、整体架构设计

┌─────────────────────────────────────────────────────────────────┐
│                        前端应用                                  │
│                    (SDK 错误采集)                                │
└─────────────────────┬───────────────────────────────────────────┘
                      │ HTTP POST /api/report
                      ▼
┌─────────────────────────────────────────────────────────────────┐
│                      Server 层                                   │
│  ┌─────────────┐   ┌─────────────┐   ┌─────────────┐           │
│  │  数据接收    │──▶│  错误聚合   │──▶│  告警检查   │           │
│  │  (report)   │   │ (fingerprint)│   │  (rules)   │           │
│  └─────────────┘   └─────────────┘   └──────┬──────┘           │
│                                              │                   │
│                                              ▼                   │
│                                     ┌─────────────┐             │
│                                     │  规则评估   │             │
│                                     │ (evaluate)  │             │
│                                     └──────┬──────┘             │
│                                              │ 触发              │
│                                              ▼                   │
│  ┌─────────────┐   ┌─────────────┐   ┌─────────────┐           │
│  │  告警历史   │◀──│  邮件发送   │◀──│  冷却检查   │           │
│  │  (history)  │   │   (SMTP)    │   │ (cooldown)  │           │
│  └─────────────┘   └─────────────┘   └─────────────┘           │
└─────────────────────────────────────────────────────────────────┘
                      │
                      ▼ SMTP
┌─────────────────────────────────────────────────────────────────┐
│                    邮件服务器                                    │
│              (QQ邮箱/163/企业邮箱)                               │
└─────────────────────────────────────────────────────────────────┘
                      │
                      ▼ 📧
                  开发者邮箱

三、核心模块设计

3.1 告警规则模型

告警规则是整个系统的核心,定义了"什么情况下触发告警":

interface AlertRule {
  id: number;
  dsn: string;           // 项目标识
  name: string;          // 规则名称
  type: AlertType;       // 告警类型
  enabled: boolean;      // 是否启用
  threshold?: number;    // 阈值
  timeWindow?: number;   // 时间窗口(分钟)
  recipients: string[];  // 收件人列表
  cooldown: number;      // 冷却时间(分钟)
}

type AlertType = 
  | 'new_error'        // 新错误首次出现
  | 'error_threshold'  // 错误累计次数超过阈值
  | 'error_spike';     // 时间窗口内错误激增

三种告警类型的适用场景:

类型 场景 示例
new_error 捕获未知错误 新上线功能出现 bug
error_threshold 监控已知问题 某接口错误超过 100 次
error_spike 检测异常波动 5 分钟内错误数突增

3.2 冷却机制

为了避免同一个错误短时间内重复告警(告警轰炸),我们引入了冷却机制:

// 内存缓存:记录最近告警时间
const alertCooldowns = new Map<string, number>();

function shouldTrigger(rule: AlertRule, fingerprint: string): boolean {
  const cooldownKey = `${rule.id}-${fingerprint}`;
  const lastAlert = alertCooldowns.get(cooldownKey);
  
  // 检查是否在冷却期内
  if (lastAlert && Date.now() - lastAlert < rule.cooldown * 60 * 1000) {
    return false; // 冷却中,不触发
  }
  
  return true;
}

// 触发告警后更新冷却时间
function updateCooldown(rule: AlertRule, fingerprint: string) {
  const cooldownKey = `${rule.id}-${fingerprint}`;
  alertCooldowns.set(cooldownKey, Date.now());
}

冷却机制的关键点:

  1. 按规则+错误指纹组合作为冷却 key,而不是全局冷却
  2. 使用内存 Map 存储,重启后冷却状态重置(可接受)
  3. 冷却时间可配置,建议默认 30 分钟

3.3 规则评估引擎

规则评估是告警系统的"大脑",决定是否触发告警:

async function evaluateRule(rule: AlertRule, errorData: ErrorData): Promise<boolean> {
  // 1. 先检查冷却
  if (!shouldTrigger(rule, errorData.fingerprint)) {
    return false;
  }

  // 2. 根据规则类型评估
  switch (rule.type) {
    case 'new_error':
      // 新错误:检查是否首次出现
      return errorData.isNew;

    case 'error_threshold':
      // 阈值:检查累计次数
      return errorData.count >= rule.threshold;

    case 'error_spike': {
      // 激增:查询时间窗口内的错误数
      const windowStart = Date.now() - rule.timeWindow * 60 * 1000;
      const recentCount = await getErrorCountSince(
        errorData.dsn, 
        errorData.fingerprint, 
        windowStart
      );
      return recentCount >= rule.threshold;
    }

    default:
      return false;
  }
}

四、邮件服务实现

4.1 Nodemailer 配置

使用 nodemailer 库发送邮件,支持主流 SMTP 服务:

import nodemailer, { Transporter } from 'nodemailer';

let transporter: Transporter | null = null;

function initEmailService(config: EmailConfig) {
  transporter = nodemailer.createTransport({
    host: config.host,      // smtp.qq.com
    port: config.port,      // 465
    secure: true,           // 使用 SSL
    auth: {
      user: config.user,    // 邮箱账号
      pass: config.pass     // 授权码(不是登录密码!)
    }
  });
}

常见 SMTP 配置:

服务商 Host Port 备注
QQ邮箱 smtp.qq.com 465 需开启 SMTP 服务,使用授权码
163邮箱 smtp.163.com 465 需开启 SMTP 服务
Gmail smtp.gmail.com 587 需开启两步验证,使用应用密码
企业微信 smtp.exmail.qq.com 465 企业邮箱

4.2 邮件模板设计

告警邮件需要清晰展示关键信息:

image.png

image.png邮件设计要点:

  1. 使用内联样式(邮件客户端不支持外部 CSS)
  2. 关键信息突出显示(错误消息、次数)
  3. 提供快捷操作入口(查看详情按钮)
  4. 移动端适配(响应式布局)

4.3 发送邮件

async function sendAlertEmail(data: AlertEmailData): Promise<boolean> {
  if (!transporter) {
    console.warn('[Email] Service not initialized');
    return false;
  }

  try {
    const info = await transporter.sendMail({
      from: '"Sentinel 监控" <monitor@example.com>',
      to: data.recipients.join(', '),
      subject: `🚨 [${data.ruleName}] ${data.errorMessage.slice(0, 50)}`,
      html: generateAlertEmailHtml(data)
    });
    
    console.log('[Email] Sent:', info.messageId);
    return true;
  } catch (error) {
    console.error('[Email] Failed:', error);
    return false;
  }
}

五、完整告警流程

5.1 错误上报时触发检查

在错误数据入库后,异步检查告警规则:

// routes/report.ts
async function saveErrorEvent(dsn: string, event: ErrorEvent) {
  const { fingerprint } = generateFingerprint(event);
  
  // 1. 检查是否新错误
  const existing = await db.query(
    'SELECT id, count FROM errors WHERE fingerprint = $1',
    [fingerprint]
  );
  const isNew = existing.rows.length === 0;
  const count = isNew ? 1 : existing.rows[0].count + 1;
  
  // 2. 保存/更新错误记录
  if (isNew) {
    await db.query('INSERT INTO errors ...', [...]);
  } else {
    await db.query('UPDATE errors SET count = $1 ...', [count]);
  }
  
  // 3. 异步检查告警(不阻塞响应)
  checkAndTriggerAlerts({
    dsn,
    type: event.type,
    message: event.message,
    fingerprint,
    url: event.url,
    isNew,
    count
  }).catch(err => console.error('[Alert] Check failed:', err));
}

5.2 告警检查主流程

async function checkAndTriggerAlerts(errorData: ErrorData) {
  // 1. 检查邮件服务是否可用
  if (!isEmailConfigured()) return;

  // 2. 获取该项目的所有启用规则
  const rules = await getAlertRules(errorData.dsn);
  const enabledRules = rules.filter(r => r.enabled);

  // 3. 逐个评估规则
  for (const rule of enabledRules) {
    const shouldTrigger = await evaluateRule(rule, errorData);
    
    if (shouldTrigger) {
      // 4. 触发告警
      await triggerAlert(rule, errorData);
    }
  }
}

async function triggerAlert(rule: AlertRule, errorData: ErrorData) {
  // 1. 发送邮件
  const emailSent = await sendAlertEmail({
    to: rule.recipients,
    subject: `🚨 [${rule.name}] ${errorData.message.slice(0, 50)}`,
    errorMessage: errorData.message,
    errorType: errorData.type,
    errorCount: errorData.count,
    url: errorData.url,
    timestamp: Date.now()
  });

  // 2. 记录告警历史
  await saveAlertHistory(rule.id, errorData, emailSent);

  // 3. 更新冷却时间
  updateCooldown(rule, errorData.fingerprint);
  
  console.log(`[Alert] Triggered: ${rule.name}, sent: ${emailSent}`);
}

六、数据库设计

6.1 告警规则表

CREATE TABLE alert_rules (
  id SERIAL PRIMARY KEY,
  dsn TEXT NOT NULL,                    -- 项目标识
  name VARCHAR(100) NOT NULL,           -- 规则名称
  type VARCHAR(20) NOT NULL,            -- 告警类型
  enabled BOOLEAN DEFAULT true,         -- 是否启用
  threshold INTEGER,                    -- 阈值
  time_window INTEGER DEFAULT 60,       -- 时间窗口(分钟)
  recipients TEXT[] NOT NULL,           -- 收件人数组
  cooldown INTEGER DEFAULT 30,          -- 冷却时间(分钟)
  created_at TIMESTAMP DEFAULT NOW(),
  updated_at TIMESTAMP DEFAULT NOW()
);

CREATE INDEX idx_alert_rules_dsn ON alert_rules(dsn);

6.2 告警历史表

CREATE TABLE alert_history (
  id SERIAL PRIMARY KEY,
  rule_id INTEGER REFERENCES alert_rules(id),
  dsn TEXT NOT NULL,
  fingerprint TEXT,                     -- 错误指纹
  error_message TEXT,                   -- 错误消息
  triggered_at TIMESTAMP DEFAULT NOW(), -- 触发时间
  email_sent BOOLEAN DEFAULT false      -- 邮件是否发送成功
);

CREATE INDEX idx_alert_history_dsn ON alert_history(dsn, triggered_at);

七、API 设计

7.1 告警规则 CRUD

// 获取规则列表
GET /api/alerts/rules?dsn=demo-app

// 创建规则
POST /api/alerts/rules
{
  "dsn": "demo-app",
  "name": "生产环境错误告警",
  "type": "new_error",
  "recipients": ["dev@example.com"],
  "cooldown": 30
}

// 更新规则
PATCH /api/alerts/rules/:id
{
  "enabled": false,
  "threshold": 50
}

// 删除规则
DELETE /api/alerts/rules/:id

7.2 告警历史查询

// 获取告警历史
GET /api/alerts/history?dsn=demo-app&limit=50

// 响应
{
  "history": [
    {
      "id": 1,
      "ruleId": 1,
      "errorMessage": "Cannot read property 'x' of undefined",
      "triggeredAt": "2024-01-15T10:30:00Z",
      "emailSent": true
    }
  ]
}

7.3 邮件服务状态

// 检查邮件服务状态
GET /api/alerts/email-status
// { "configured": true, "connected": true }

// 发送测试邮件
POST /api/alerts/test-email
{ "email": "test@example.com" }

八、最佳实践

8.1 告警规则配置建议

// 推荐的规则组合
const recommendedRules = [
  {
    name: '新错误告警',
    type: 'new_error',
    cooldown: 60,        // 1小时内同一错误不重复告警
    recipients: ['oncall@team.com']
  },
  {
    name: '错误激增告警',
    type: 'error_spike',
    threshold: 100,      // 5分钟内超过100次
    timeWindow: 5,
    cooldown: 30,
    recipients: ['oncall@team.com', 'manager@team.com']
  },
  {
    name: '关键错误阈值',
    type: 'error_threshold',
    threshold: 1000,     // 累计超过1000次
    cooldown: 120,       // 2小时冷却
    recipients: ['dev@team.com']
  }
];

8.2 避免告警疲劳

  1. 合理设置冷却时间:避免同一问题反复告警
  2. 分级告警:不同严重程度发送给不同人员
  3. 聚合告警:相似错误合并为一条告警
  4. 静默时段:非工作时间降低告警频率

8.3 邮件发送优化

// 使用队列异步发送,避免阻塞主流程
import { Queue } from 'bull';

const emailQueue = new Queue('email-alerts');

emailQueue.process(async (job) => {
  await sendAlertEmail(job.data);
});

// 触发告警时加入队列
async function triggerAlert(rule, errorData) {
  await emailQueue.add({
    to: rule.recipients,
    subject: `🚨 ${errorData.message}`,
    // ...
  });
}

九、扩展方向

当前实现了邮件告警,后续可以扩展:

  1. 多渠道通知

    • 钉钉/飞书 Webhook
    • 企业微信机器人
    • Slack 集成
    • 短信通知(严重告警)
  2. 智能告警

    • 基于历史数据的异常检测
    • 告警收敛和去重
    • 根因分析关联
  3. 告警升级

    • 未处理告警自动升级
    • 值班表集成
    • 告警认领机制

十、总结

本文介绍了前端监控告警系统的完整实现方案:

  • 架构设计:错误上报 → 规则评估 → 冷却检查 → 邮件发送
  • 核心机制:三种告警类型 + 冷却防抖 + 异步处理
  • 技术选型:Node.js + Nodemailer + PostgreSQL

告警系统是监控平台的"最后一公里",让被动查看变为主动通知,大大提升了问题响应效率。


完整代码已开源:GitHub - Sentinel 前端监控平台

如果觉得有帮助,欢迎 Star ⭐️

JS原型链详解

2025年12月24日 13:52

原型链是 JavaScript 实现继承的核心机制,本质是一条「实例与原型之间的引用链条」,用于解决属性和方法的查找、共享与继承问题,理解原型链是掌握 JavaScript 面向对象编程的关键。

一、先搞懂 3 个核心概念(原型链的基础)

在讲原型链之前,必须先明确 prototype__proto__constructor 这三个不可分割的概念,它们是构成原型链的基本单元。

1. prototype(原型属性 / 显式原型)

  • 定义:只有函数(构造函数)才拥有 prototype 属性,它指向一个对象(称为「原型对象」),这个对象的作用是存放所有实例需要共享的属性和方法

  • 通俗理解:构造函数的「原型仓库」,所有通过该构造函数创建的实例,都能共享这个仓库里的内容,避免方法重复创建浪费内存。

  • 示例

    // 构造函数
    function Person(name) {
      this.name = name; // 实例私有属性
    }
    // prototype 指向原型对象,存放共享方法
    Person.prototype.sayName = function() {
      console.log('我的名字:', this.name);
    };
    
    console.log(Person.prototype); // { sayName: ƒ, constructor: ƒ Person() }
    

2. __proto__(原型链指针 / 隐式原型)

  • 定义:几乎所有对象(除 null/undefined都拥有 __proto__ 属性(ES6 规范中称为 [[Prototype]]__proto__ 是浏览器提供的访问接口),它指向创建该对象的构造函数的原型对象(prototype

  • 通俗理解:对象的「原型导航器」,通过它可以找到自己的 “原型仓库”,进而向上查找属性 / 方法。

  • 示例

    const person1 = new Person('张三');
    // person1 的 __proto__ 指向 Person.prototype
    console.log(person1.__proto__ === Person.prototype); // true
    console.log(person1.__proto__.sayName === Person.prototype.sayName); // true
    

3. constructor(构造函数指向)

  • 定义:原型对象(prototype)中默认包含 constructor 属性,它指向对应的构造函数本身,用于标识对象的创建来源。

  • 作用:修复原型指向后,保证实例能正确追溯到构造函数(避免继承时构造函数指向混乱)。

  • 示例

    // 原型对象的 constructor 指向构造函数
    console.log(Person.prototype.constructor === Person); // true
    // 实例可通过 __proto__ 找到 constructor
    console.log(person1.__proto__.constructor === Person); // true
    console.log(person1.constructor === Person); // true(自动向上查找)
    

二、原型链的核心定义与形成过程

1. 核心定义

原型链是由 __proto__ 串联起来的「对象 → 原型对象 → 上层原型对象 → ... → null」的链式结构,当访问一个对象的属性 / 方法时,JavaScript 会先在对象自身查找,找不到则通过 __proto__ 向上查找原型对象,依次类推,直到找到属性 / 方法或到达原型链末端(null)。

2. 原型链的形成过程(三步成型)

我们以 Person 实例为例,拆解原型链的形成:

  1. 第一步:创建构造函数 Person,其 prototype 指向 Person 原型对象(包含 sayName 方法和 constructor);
  2. 第二步:通过 new Person() 创建实例 person1person1.__proto__ 指向 Person.prototype(形成第一层链接);
  3. 第三步Person.prototype 是一个普通对象,它的 __proto__ 指向 Object.prototype(JavaScript 所有对象的根原型),Object.prototype.__proto__ 指向 null(原型链末端)。

最终形成的原型链:

plaintext

person1(实例)
  ↓ __proto__
Person.prototypePerson 原型对象)
  ↓ __proto__
Object.prototype(根原型对象)
  ↓ __proto__
null(原型链末端)

可视化示例

// 验证原型链结构
const person1 = new Person('张三');

// 第一层:person1 -> Person.prototype
console.log(person1.__proto__ === Person.prototype); // true
// 第二层:Person.prototype -> Object.prototype
console.log(Person.prototype.__proto__ === Object.prototype); // true
// 第三层:Object.prototype -> null
console.log(Object.prototype.__proto__ === null); // true
// 完整原型链:person1 -> Person.prototype -> Object.prototype -> null

三、原型链的核心作用:属性 / 方法查找机制

这是原型链最核心的功能,遵循「自身优先,向上追溯,末端终止」的规则:

1. 查找规则步骤

  1. 当访问对象的某个属性 / 方法时,先在对象自身的属性中查找(比如 person1.name,直接在 person1 上找到);
  2. 如果自身没有,就通过 __proto__ 向上查找原型对象(比如 person1.sayName(),自身没有,找到 Person.prototype 上的 sayName);
  3. 如果原型对象也没有,继续通过原型对象的 __proto__ 向上查找上层原型(比如 person1.toString()Person.prototype 没有,找到 Object.prototype 上的 toString);
  4. 直到找到目标属性 / 方法,或到达原型链末端 null,此时返回 undefined(属性)或报错(方法)。

2. 代码示例

const person1 = new Person('张三');

// 1. 查找自身属性:name
console.log(person1.name); // 张三(自身存在,直接返回)

// 2. 查找原型方法:sayName
console.log(person1.sayName()); // 我的名字:张三(自身没有,向上找到 Person.prototype)

// 3. 查找上层原型方法:toString
console.log(person1.toString()); // [object Object](Person.prototype 没有,向上找到 Object.prototype)

// 4. 查找不存在的属性:age
console.log(person1.age); // undefined(原型链末端仍未找到,返回 undefined)

3. 注意:属性修改仅影响自身,不影响原型

原型链是「只读」的查找链路,修改对象的属性时,只会修改对象自身,不会改变原型对象的属性(除非直接显式修改原型):

// 错误:试图修改原型方法(实际是给 person1 新增了一个私有方法 sayName,覆盖了原型查找)
person1.sayName = function() {
  console.log('我是私有方法:', this.name);
};
person1.sayName(); // 我是私有方法:张三(优先访问自身方法)
console.log(Person.prototype.sayName()); // 我的名字:undefined(原型方法未被修改)

四、原型链与继承的关系

原型链是 JavaScript 继承的底层支撑,所有继承方式(原型链继承、组合继承等)本质都是通过修改 __proto__ 或 prototype,构建新的原型链结构,实现子类对父类属性 / 方法的继承。

示例:简单继承的原型链结构

// 父类构造函数
function Animal(name) {
  this.name = name;
}
Animal.prototype.sayName = function() {
  console.log('名称:', this.name);
};

// 子类构造函数
function Dog(name, breed) {
  Animal.call(this, name);
  this.breed = breed;
}
// 构建继承:让 Dog.prototype.__proto__ 指向 Animal.prototype
Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog;

// 子类实例的原型链
const dog1 = new Dog('旺财', '中华田园犬');
// 原型链:dog1 -> Dog.prototype -> Animal.prototype -> Object.prototype -> null
console.log(dog1.__proto__ === Dog.prototype); // true
console.log(Dog.prototype.__proto__ === Animal.prototype); // true
console.log(Animal.prototype.__proto__ === Object.prototype); // true
// 继承生效:dog1 能访问 Animal.prototype 的 sayName 方法
dog1.sayName(); // 名称:旺财

五、原型链的末端:Object.prototype 与 null

  1. Object.prototype:是 JavaScript 所有对象的「根原型」,所有对象最终都会继承它的属性和方法(如 toString()hasOwnProperty()valueOf() 等);

  2. null:是原型链的「终点」,Object.prototype.__proto__ 指向 null,表示没有上层原型,查找过程到此终止;

  3. 验证

    console.log(Object.prototype.__proto__); // null
    console.log(Object.prototype.hasOwnProperty('toString')); // true(根原型的自有方法)
    console.log(person1.hasOwnProperty('name')); // true(自身属性)
    console.log(person1.hasOwnProperty('sayName')); // false(原型上的方法,非自身属性)
    

六、常见误区

  1. 混淆 prototype 和 __proto__prototype 是函数的属性,__proto__ 是对象的属性,两者的关联是「对象.proto = 构造函数.prototype」;
  2. 原型链是可写的__proto__ 可以手动修改(不推荐,会破坏原有继承结构,影响性能);
  3. 所有对象都有 prototype:只有函数才有 prototype,普通对象只有 __proto__
  4. hasOwnProperty 能查找原型属性hasOwnProperty 仅判断对象自身是否有该属性,不会向上查找原型链。

总结

  1. 原型链的核心是 __proto__ 串联的链式结构,末端是 null,根节点是 Object.prototype
  2. 3 个核心概念:prototype(函数的原型仓库)、__proto__(对象的原型指针)、constructor(原型的构造函数指向);
  3. 核心功能:实现属性 / 方法的分层查找(自身 → 原型 → 上层原型 → ... → null),支撑 JavaScript 继承机制;
  4. 本质:通过共享原型对象的属性 / 方法,实现代码复用,减少内存消耗。

JS继承方式详解

2025年12月24日 13:47

JavaScript 继承基于原型链实现,不存在类继承的原生语法(ES6 class 是语法糖,底层仍为原型继承),常见继承方式按演进逻辑可分为以下 6 种,各有优劣与适用场景:

一、原型链继承(最基础的继承方式)

核心原理

将父类的实例作为子类的原型(SubType.prototype = new SuperType()),子类实例通过原型链向上查找父类的属性和方法,实现继承。

代码示例

javascript

运行

// 父类
function Animal(name) {
  this.name = name; // 实例属性
  this.colors = ['black', 'white']; // 引用类型实例属性
}
Animal.prototype.sayName = function() { // 原型方法
  console.log('动物名称:', this.name);
};

// 子类
function Dog() {}
// 核心:将父类实例赋值给子类原型
Dog.prototype = new Animal('小狗');
Dog.prototype.constructor = Dog; // 修复构造函数指向

// 测试
const dog1 = new Dog();
const dog2 = new Dog();
dog1.colors.push('brown');
console.log(dog1.colors); // ['black', 'white', 'brown']
console.log(dog2.colors); // ['black', 'white', 'brown'](引用类型属性被共享)
dog1.sayName(); // 动物名称:小狗

优点与缺点

  • 优点:实现简单,子类可继承父类原型上的所有方法;

  • 缺点

    1. 父类的引用类型实例属性会被所有子类实例共享(一个实例修改会影响其他实例);
    2. 无法向父类构造函数传递参数(子类实例创建时,无法自定义父类实例属性)。

二、构造函数继承(借用父类构造函数)

核心原理

在子类构造函数中,通过 call()/apply() 调用父类构造函数,将父类的实例属性绑定到子类实例上,实现实例属性的继承。

代码示例

javascript

运行

// 父类
function Animal(name) {
  this.name = name;
  this.colors = ['black', 'white'];
  this.sayName = function() {
    console.log('动物名称:', this.name);
  };
}

// 子类
function Dog(name, breed) {
  // 核心:借用父类构造函数,传递参数
  Animal.call(this, name);
  this.breed = breed; // 子类自有属性
}

// 测试
const dog1 = new Dog('旺财', '中华田园犬');
const dog2 = new Dog('小白', '萨摩耶');
dog1.colors.push('brown');
console.log(dog1.colors); // ['black', 'white', 'brown']
console.log(dog2.colors); // ['black', 'white'](引用类型属性不共享)
dog1.sayName(); // 动物名称:旺财
console.log(dog1.breed); // 中华田园犬

优点与缺点

  • 优点

    1. 解决了原型链继承中引用类型属性共享的问题;
    2. 可以向父类构造函数传递参数;
  • 缺点

    1. 只能继承父类的实例属性和方法,无法继承父类原型上的方法(每个子类实例都会复制一份父类方法,浪费内存);
    2. 子类实例无法共享父类方法,违背原型链的设计初衷。

三、组合继承(原型链 + 构造函数,最常用)

核心原理

结合原型链继承和构造函数继承的优点:

  1. 原型链继承继承父类原型上的方法(实现方法共享);
  2. 构造函数继承继承父类的实例属性(避免引用类型共享,支持传参)。

代码示例

javascript

运行

// 父类
function Animal(name) {
  this.name = name;
  this.colors = ['black', 'white'];
}
Animal.prototype.sayName = function() {
  console.log('动物名称:', this.name);
};

// 子类
function Dog(name, breed) {
  // 构造函数继承:继承实例属性,传参
  Animal.call(this, name);
  this.breed = breed;
}
// 原型链继承:继承原型方法,实现方法共享
Dog.prototype = new Animal();
Dog.prototype.constructor = Dog; // 修复构造函数指向
// 子类原型方法
Dog.prototype.sayBreed = function() {
  console.log('犬种:', this.breed);
};

// 测试
const dog1 = new Dog('旺财', '中华田园犬');
const dog2 = new Dog('小白', '萨摩耶');
dog1.colors.push('brown');
console.log(dog1.colors); // ['black', 'white', 'brown']
console.log(dog2.colors); // ['black', 'white'](引用类型不共享)
dog1.sayName(); // 动物名称:旺财(继承父类原型方法)
dog1.sayBreed(); // 犬种:中华田园犬(子类自有方法)
console.log(dog1 instanceof Animal); // true( instanceof 检测正常)

优点与缺点

  • 优点

    1. 兼顾了原型链继承和构造函数继承的优点,既实现了方法共享,又避免了引用类型属性共享;
    2. 支持向父类传参,instanceof 检测正常;
  • 缺点:父类构造函数被调用了两次(一次是创建子类原型时 new Animal(),一次是子类构造函数中 Animal.call(this)),导致子类原型上存在多余的父类实例属性(虽不影响使用,但造成内存冗余)。

四、原型式继承(基于已有对象创建新对象)

核心原理

通过 Object.create()(或手动封装的原型方法),以一个已有对象为原型,创建新的对象,实现对已有对象属性和方法的继承。

代码示例

javascript

运行

// 已有对象(作为原型)
const animal = {
  name: '动物',
  colors: ['black', 'white'],
  sayName: function() {
    console.log('动物名称:', this.name);
  }
};

// 核心:用 Object.create 创建新对象,继承 animal
const dog = Object.create(animal);
dog.name = '旺财'; // 重写实例属性
dog.breed = '中华田园犬'; // 新增自有属性

// 测试
const cat = Object.create(animal);
dog.colors.push('brown');
console.log(dog.colors); // ['black', 'white', 'brown']
console.log(cat.colors); // ['black', 'white', 'brown'](引用类型属性共享)
dog.sayName(); // 动物名称:旺财
console.log(dog.breed); // 中华田园犬

优点与缺点

  • 优点:无需定义构造函数,实现简单,适合快速创建基于已有对象的新对象;

  • 缺点

    1. 引用类型属性会被所有新对象共享(与原型链继承一致);
    2. 无法向父对象传递参数,只能在创建新对象后手动修改属性。

五、寄生式继承(原型式继承的增强版)

核心原理

在原型式继承的基础上,封装一个创建对象的函数,在函数内部为新对象添加自有属性和方法,增强新对象的功能,最后返回新对象。

代码示例

javascript

运行

// 封装创建继承对象的函数(寄生函数)
function createAnimal(proto, name, breed) {
  // 原型式继承:创建新对象
  const obj = Object.create(proto);
  // 增强新对象:添加自有属性和方法
  obj.name = name;
  obj.breed = breed;
  obj.sayBreed = function() {
    console.log('犬种/品种:', this.breed);
  };
  return obj;
}

// 原型对象
const animal = {
  colors: ['black', 'white'],
  sayName: function() {
    console.log('名称:', this.name);
  }
};

// 测试
const dog = createAnimal(animal, '旺财', '中华田园犬');
const cat = createAnimal(animal, '咪咪', '橘猫');
dog.colors.push('brown');
console.log(dog.colors); // ['black', 'white', 'brown']
console.log(cat.colors); // ['black', 'white', 'brown'](引用类型共享)
dog.sayName(); // 名称:旺财
dog.sayBreed(); // 犬种/品种:中华田园犬

优点与缺点

  • 优点:无需定义构造函数,可灵活增强新对象的功能,实现简单;

  • 缺点

    1. 引用类型属性共享问题依然存在;
    2. 每个新对象的自有方法都是独立的(无法共享),浪费内存;
    3. 无法实现方法的复用,类似构造函数继承的缺点。

六、寄生组合式继承(完美继承方案)

核心原理

结合组合继承和寄生式继承的优点,解决组合继承中父类构造函数被调用两次的问题:

  1. 寄生式继承继承父类的原型(仅继承原型方法,不调用父类构造函数);
  2. 构造函数继承继承父类的实例属性(支持传参,避免引用类型共享)。

代码示例

javascript

运行

// 寄生函数:继承父类原型,不调用父类构造函数
function inheritPrototype(SubType, SuperType) {
  // 创建父类原型的副本(避免直接修改父类原型)
  const prototype = Object.create(SuperType.prototype);
  prototype.constructor = SubType; // 修复构造函数指向
  SubType.prototype = prototype; // 将副本赋值给子类原型
}

// 父类
function Animal(name) {
  this.name = name;
  this.colors = ['black', 'white'];
}
Animal.prototype.sayName = function() {
  console.log('名称:', this.name);
};

// 子类
function Dog(name, breed) {
  // 构造函数继承:继承实例属性,传参(仅调用一次父类构造函数)
  Animal.call(this, name);
  this.breed = breed;
}

// 核心:寄生式继承父类原型
inheritPrototype(Dog, Animal);

// 子类原型方法
Dog.prototype.sayBreed = function() {
  console.log('犬种:', this.breed);
};

// 测试
const dog1 = new Dog('旺财', '中华田园犬');
const dog2 = new Dog('小白', '萨摩耶');
dog1.colors.push('brown');
console.log(dog1.colors); // ['black', 'white', 'brown']
console.log(dog2.colors); // ['black', 'white'](引用类型不共享)
dog1.sayName(); // 名称:旺财
dog1.sayBreed(); // 犬种:中华田园犬
console.log(dog1 instanceof Animal); // true
console.log(Dog.prototype.constructor === Dog); // true(构造函数指向正确)

优点与缺点

  • 优点

    1. 父类构造函数仅被调用一次,避免了内存冗余;
    2. 实现了方法共享,避免了引用类型属性共享;
    3. 支持向父类传参,instanceof 检测和构造函数指向均正常;
    4. 是 JavaScript 继承的 “完美方案”,ES6 class extends 底层基于此实现。
  • 缺点:实现相对复杂(需封装寄生函数),但可复用该函数。

七、ES6 Class 继承(语法糖)

核心原理

通过 class 定义类,extends 关键字实现继承,super() 调用父类构造函数,底层仍是寄生组合式继承,只是语法更简洁、更接近传统类继承。

代码示例

javascript

运行

// 父类
class Animal {
  constructor(name) {
    this.name = name;
    this.colors = ['black', 'white'];
  }

  sayName() {
    console.log('名称:', this.name);
  }
}

// 子类:extends 实现继承
class Dog extends Animal {
  constructor(name, breed) {
    super(name); // 必须先调用 super(),才能使用 this
    this.breed = breed;
  }

  sayBreed() {
    console.log('犬种:', this.breed);
  }
}

// 测试
const dog1 = new Dog('旺财', '中华田园犬');
const dog2 = new Dog('小白', '萨摩耶');
dog1.colors.push('brown');
console.log(dog1.colors); // ['black', 'white', 'brown']
console.log(dog2.colors); // ['black', 'white'](引用类型不共享)
dog1.sayName(); // 名称:旺财
dog1.sayBreed(); // 犬种:中华田园犬
console.log(dog1 instanceof Animal); // true

优点与缺点

  • 优点:语法简洁直观,符合面向对象编程习惯,易于理解和维护,支持静态方法继承(static 关键字);
  • 缺点:本质是语法糖,底层仍依赖原型链,新手可能忽略原型继承的本质。

八、各类继承方式对比与选型建议

继承方式 核心优点 核心缺点 适用场景
原型链继承 实现简单,方法共享 引用类型共享,无法传参 简单场景,无需传参,不关心引用类型共享
构造函数继承 支持传参,引用类型不共享 无法继承原型方法,方法冗余 仅需继承实例属性,无需共享方法
组合继承 方法共享,支持传参,功能完善 父类构造函数调用两次 常规业务场景,兼容性要求高
原型式继承 无需构造函数,快速创建对象 引用类型共享,无法传参 基于已有对象快速创建新对象
寄生式继承 灵活增强对象功能 方法冗余,引用类型共享 快速创建并增强新对象,简单场景
寄生组合式继承 完美解决所有缺陷,性能最优 实现复杂 追求性能和严谨性的场景,框架开发
ES6 Class 继承 语法简洁,符合 OOP 习惯 底层仍是原型继承 现代项目开发,兼容性良好(ES6+)

总结

  1. 原型链是 JavaScript 继承的基础,所有继承方式均围绕原型链展开;
  2. 寄生组合式继承是 “完美方案”,ES6 class extends 是其语法糖,推荐现代项目优先使用;
  3. 简单场景可使用原型式 / 寄生式继承,兼容旧环境可使用组合继承,仅需实例属性继承可使用构造函数继承。
昨天以前首页

Vue 转盘抽奖 transform

作者 小丑755
2025年12月22日 16:48

Vue 转盘抽奖 transform

简介:电商食用级转盘抽奖

讲在前面

在我们日常生活,电子购物已是必不可少的环节了。营销手段更是层出不穷,要数经典的还是转盘抽奖了,紧张又刺激(其实概率还不都是咱们程序猿弄的,刺激个der~)

虽说如此...

但 还是决定自己搞一个试试!

核心 transform

transform 属性向元素应用 2D 或 3D 转换。该属性允许我们对元素进行旋转、缩放、移动或倾斜。

但是既然我们说转盘,当然用到的是旋转啦:rotate

简单示例 顺时针旋转10deg

transform:rotate(10deg);

什么?你已经会这个css属性了? 那恭喜你,你已经能自己独立制作转盘抽奖啦~

核心代码

1. 转盘UI

<template>

    <view class="">

      
            <!-- 转盘包裹 -->
            <view class="rotate">
              <!-- 绘制圆点 -->
              <view :class="'circle circle_' + index" v-for="(item, index) in circleList" :key="index"
                :style="{ background: index % 2 == 0 ? colorCircleFirst : colorCircleSecond }"></view>
              <!-- 转盘图片 -->
              <image class="dish" src="/static/demo/pan.png" :style="{ transform: rotate_deg, transition: rotate_transition }" ></image>
              <!-- 指针图片 -->
              <image class="pointer" src="/static/demo/zhen.png" @click="start" ></image>
              
            
            </view>



      
    </view>

</template>


<style lang="scss" scoped>
.rotate {
  width: 600rpx;
  height: 600rpx;
  background: #ffbe04;
  border-radius: 50%;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  position: absolute;
  top: 48%;
  left: 50%;
  transform: translate(-50%, -50%);
}

.rotate .dish {
  width: 550rpx;
  height: 550rpx;
}

.pointer {
  width: 142rpx;
  height: 200rpx;
  position: absolute;
  top: 46%;
  left: 50%;
  transform: translate(-50%, -50%);
}

/* 圆点 */
.rotate .circle {
  position: absolute;
  display: block;
  border-radius: 50%;
  height: 20rpx;
  width: 20rpx;
  background: black;
}

/*这里只写了一个点的位置,其他的自己补充一下 调调位置就好啦*/
.rotate .circle_0 {
  top: 2rpx;
  left: 284rpx;
}
    
</style>

2.让转盘转动起来

var light_timer; //灯光定时器

data() {
        return {
            circleList: [], //圆点列表
            colorCircleFirst: "#FF0000", //圆点闪烁颜色一
            colorCircleSecond: "#fff", //圆点闪烁颜色二
            cat: 45, //总共8个扇形区域,每个区域45度,这就取决去奖品池的UI图了
            isAllowClick: true, //是否能够点击
            rotate_deg: 0, //指针旋转的角度
            rotate_transition: "transform 3s ease-in-out" //过渡属性,渐入渐出

        };
    },
        
onLoad() {
    this.showcircleList();
},
    
    methods: {
        // 设置边缘一圈16个圆点,可以根据需要修改
        showcircleList() {
            let circleList = [];
            for (var i = 0; i < 16; i++) {
                circleList.push(i);
            }
            this.circleList = circleList;
            this.light();   
        },

        //设置边缘灯光闪动效果
        light: function() {
            var that = this;
            clearInterval(light_timer);
            light_timer = setInterval(function() {
                if (that.colorCircleFirst == "#FF0000") {
                    that.colorCircleFirst = "#fff";
                    that.colorCircleSecond = "#FF0000";
                } else {
                    that.colorCircleFirst = "#FF0000";
                    that.colorCircleSecond = "#fff";
                }
            }, 300); //设置圆点闪烁的间隔时间效果
        },
        //点击开始抽奖
        start() {
            this.rotating();
        },
        //旋转
        rotating() {
            if (!this.isAllowClick) return;
            this.isAllowClick = false;
            this.rotate_transition = "transform 3s ease-in-out";
            this.LuckyClick--;
            var rand_circle = 5; //默认多旋转5圈
            var winningIndex = this.set(); //设置概率
            console.log(winningIndex);
            var randomDeg = 360 - winningIndex * 45; //8个区域。一圈是360度,对应区域旋转度数就是顺时针的 360 - winningIndex*45°
            var deg = rand_circle * 360 + randomDeg; //把本来定义多转的圈数度数也加上
            this.rotate_deg = "rotate(" + deg + "deg)";

            var that = this;
            setTimeout(function() {
                that.isAllowClick = true;
                that.rotate_deg = "rotate(" + randomDeg + "deg)"; //定时器关闭的时候角度调回五圈之前相同位置,依照产品需求可以自己更改
            that.rotate_transition = "";

                if (winningIndex == 0) {
                    console.log("恭喜您,IphoneX");
                } else if (winningIndex == 1) {
                    console.log("恭喜您,获得10元现金");
                } else if (winningIndex == 2) {
                    console.log("很遗憾,重在参与");
                    uni.showToast({
                        title:"很遗憾,重在参与",
                        icon:"none"
                    })
                } else if (winningIndex == 3) {
                    console.log("恭喜您,获得30元现金");
                } else if (winningIndex == 4) {
                    console.log("恭喜您,获得20元现金");
                } else if (winningIndex == 5) {
                    console.log("恭喜您,获得50元现金");
                } else if (winningIndex == 6) {
                    console.log("恭喜您,获得5元现金");
                } else if (winningIndex == 7) {
                    console.log("恭喜您,获得100元现金");
                }
            }, 3500);
        },

        //设置概率
        set() {
            var winIndex;
            var __rand__ = Math.random();
            // 随机数 设置抽奖概率 winIndex 记得参考奖品池的UI图
            if (__rand__ < 0.30) winIndex = 2;
            else if (__rand__ < 0.55) winIndex = 6;
            else if (__rand__ < 0.75) winIndex = 1;
            else if (__rand__ < 0.85) winIndex = 4;
            else if (__rand__ < 0.92) winIndex = 3;
            else if (__rand__ < 0.97) winIndex = 5;
            else if (__rand__ < 0.99) winIndex = 7;
            else if (__rand__ == 0.99) winIndex = 0;
            return winIndex;
        },



}

最终效果展示

zhuanpan.png

总结

其实就是利用背景图进行旋转,设置好旋转角度!如果有兴趣的话就快速行动吧,冲冲冲!!!

vue中hash模式和history模式的区别

2025年12月22日 16:47

一句话总结

  • Hash 模式:利用 URL 中 # 后的内容变化实现前端路由,不触发页面刷新,兼容性好。
  • History 模式:基于 HTML5 的 history.pushState()popstate 事件,URL 更干净,但需要服务端配合。

一、Hash 模式(默认模式)

1. 基本形式

https://example.com/#/user/profilehash 部分

2. 工作原理

  • 核心机制:监听 window.onhashchange 事件。
  • 当用户点击链接或调用 router.push() 时,Vue Router 修改 location.hash(如 #/home#/about)。
  • 浏览器不会向服务器发起请求,因为 # 及其后的内容不会发送给服务器
  • 页面 URL 改变但不刷新,前端根据新的 hash 值匹配路由并渲染对应组件。

3. 特点

优点 缺点
✅ 兼容性极好(IE8+ 支持) ❌ URL 中带有 #,不够美观
✅ 无需服务端配置 ❌ SEO 友好性略差(部分爬虫可能忽略 hash)
✅ 天然避免 404 问题 ❌ 不符合传统 URL 语义

4. 示例代码(Vue Router 配置)

const router = new VueRouter({
  mode: 'hash', // 默认值,可省略
  routes: [...]
});

二、History 模式(推荐用于现代项目)

1. 基本形式

https://example.com/user/profile

URL 看起来和传统多页应用一致,无 # 符号。

2. 工作原理

  • 核心技术
    • history.pushState(state, title, url):在不刷新页面的情况下修改浏览器历史记录和 URL。
    • history.replaceState(...):替换当前历史记录。
    • window.onpopstate:监听浏览器前进/后退操作(如点击 ← → 按钮)。
  • 流程示例
    1. 用户访问 /home → 前端加载,Vue Router 渲染 Home 组件。
    2. 点击“关于”链接 → 调用 router.push('/about') → 执行 history.pushState(null, '', '/about')
    3. URL 变为 https://example.com/about,页面不刷新,About 组件被渲染。
    4. 用户刷新页面 → 浏览器向服务器请求 /about 资源。

3. 关键问题:刷新 404

  • 原因:服务器收到 /about 请求时,若未配置,会尝试查找物理路径下的 about.html 或目录,找不到则返回 404。
  • 解决方案服务端需配置“兜底路由” ,将所有前端路由请求重定向到 index.html

Nginx 配置示例:

location / {
  try_files $uri $uri/ /index.html;
}

4. 特点

优点 缺点
✅ URL 简洁美观,符合 REST 风格 ❌ 需要服务端支持(部署配置)
✅ 更好的 SEO(主流爬虫已支持) ❌ 在纯静态托管(如 GitHub Pages)中需额外处理
✅ 用户体验更接近原生 Web ❌ 旧浏览器(IE9 以下)不支持

5. 示例代码(Vue Router 配置)

const router = new VueRouter({
  mode: 'history',
  routes: [...]
});

🔁 三、对比总结

特性 Hash 模式 History 模式
URL 样式 example.com/#/path example.com/path
刷新是否 404 ❌ 不会(# 后内容不发给服务器) ✅ 会(需服务端配置兜底)
浏览器兼容性 IE8+ IE10+(HTML5 History API)
服务端要求 必须配置 fallback 到 index.html
SEO 友好性 一般 较好(现代爬虫支持)
使用场景 快速原型、老旧环境、无服务端控制权 正式项目、追求用户体验、有运维支持

💡 最佳实践建议

  • 开发阶段:两种模式均可,推荐 history 提前暴露部署问题。
  • 生产部署
    • 若使用 Nginx/Apache/Caddy → 优先选 history + 配置 fallback。
  • 无法控制服务端? → 用 hash 模式最稳妥。

补充知识

  • 为什么 hash 不发给服务器?
    根据 HTTP 规范,URL 中 #fragment 部分仅用于客户端定位(如锚点),不会包含在 HTTP 请求中
  • History API 安全限制
    pushState 只能修改同源下的路径,不能跨域篡改 URL,保障了安全性。

顶层元素问题:popover vs. dialog

2025年12月22日 14:21

原文:Top layer troubles: popover vs. dialog 作者:Stephanie Eckles 日期:2025年12月1日 翻译:田八

来源:前端周刊

你是否曾尝试通过设置 z-index: 9999 解决元素层级问题?如果是,那你其实是在与一个基础的CSS概念 ——层叠上下文—— 斗争。

层叠上下文定义了元素在第三维度(即“z轴”)上的排列顺序。你可以把z轴想象成视口中层叠上下文根节点与用户(即通过浏览器视口观察的你)之间的DOM元素的层级。

image.png

一个元素只能在同一层叠上下文中重新调整层级。虽然 z-index 是实现这一点的工具,但失败往往源于层叠上下文的变化。这种变化可能通过多种方式发生,例如使用固定定位(fixed)、粘性定位(sticky)元素,或是将绝对定位(absolute)/相对定位(relative)与 z-index 结合使用等,MDN 上列出了更多原因

现代网页设计有一个“顶层”特性,它保证使其位于所有其他层叠上下文的最顶层。它覆盖整个视口,不过顶层中的元素实际可见尺寸可能更小。

将元素提升到顶层,可使其摆脱原本所在的任何层叠上下文。

虽然顶层直接解决了一个与CSS相关的问题,但目前还没有属性可用于将元素提升到顶层。取而代之的是,某些元素和特定条件可以访问顶层,例如通过 <div> 标签显示的原生对话框 showModal() 和被指定为 Popover 的元素。

Popover API是一项新推出的 HTML 功能,它允许你声明式的创建非模态覆盖元素。通过使用 Popover API 用来摆脱任何层叠上下文,这是它的一个理想特性。然而,在急于选择这种原生能力之前,需要注意一个潜在的问题。

场景设定

想象一下,在2025年的网络世界:你的网页应用包含一个通过“Toast”消息显示通知的服务。你知道的,就是那些通常出现在角落或其他不太可能遮挡其他用户界面(UI)位置的弹出消息。

通常,这些Toast通知通常用于实时提醒,比如保存成功,或者表单提交失败等错误提示。它们有时有时间限制,或者包含如关闭按钮这样的关闭机制。有时它们还包含额外操作,例如“重试”选项,用于重新提交失败的工作流。

既然您的应用紧跟时代潮流,你最近决定将Toast升级为使用Popover API。这样你就可以将Toast组件放置在应用的任何结构中,而无需为了解决层叠上下文问题而采用一些变通方法。毕竟,Toast必须显示在所有其他元素之上,因此通过 Popover 实现顶层访问是明智之举!

你发布了改进版本,并为自己的工作感到自豪。

发布的当周晚些时候,你收到了一份紧急错误报告。不是普通的错误报告,而是一个可访问性违规报告。

Dialog vs. popover

你的应用很新潮,你之前也升级使用了原生HTML对话框。那是一次很棒的升级,因为你用原生 Web 功能取代了对 JavaScript 的依赖。这也是你兴奋地将Toast也升级为使用Popover的另一个原因。

那么,错误是什么呢?一位键盘用户正在使用一个包含对话框的工作流程,对话框打开期间,后台进程触发了一个弹出式通知。该通知提示存在错误,需要用户进行交互。

当这位键盘用户试图将焦点切换到Toast上时,出现了错误。他们虽然在视觉上能看到Toast显示在对话框背景之上,但焦点无法成功进入Toast,而是意外地跳到了浏览器UI上。

你可以在这个CodePen示例中亲自体验这个错误,使用Tab键,你会发现你永远无法访问到Toast。你也可以尝试使用屏幕阅读器,会发现虚拟光标也无法进入Toast。

CodePen

如果你能够点击弹出框,可能会觉得至少点击操作是可行的。但很快我们就会发现,事情并非如此。

为什么Toast弹出框无法访问

虽然顶层可以超越标准的层叠上下文,但顶层中的元素仍然受分层顺序的影响。最近添加到顶层的元素会显示在之前添加的顶层元素之上。这就是为什么Toast在视觉上会显示在对话框背景之上。

如果弹出框在视觉上可用,那为什么通过键盘或屏幕阅读器的虚拟光标却无法访问呢?

原因在于弹出框与 模态 对话框之间存在竞争关系。当通过 showModal()方法启动原生HTML对话框时,对话框外部的页面会变为 惰性状态惰性状态 是一种必要的可访问性行为,它会隔离对话框内容,并阻止通过Tab键和虚拟光标访问背景页面。

这个错误是由于Toast弹出框是背景页面DOM的一部分。这意味着由于它位于对话框DOM边界之外,所以也变成了惰性状态。

但是,由于顶层顺序的原因,因为它是在对话框打开后创建的,所以在视觉上,它被放置在对话框的顶部,这一点可能会让你感到困惑。

如果你以为点击弹出框就能关闭它,实际上并非如此,尽管弹出框确实会消失。真正发生的情况是,你触发了弹出框的 轻触关闭 行为。这意味着它关闭是因为你实际上点击了它的边界之外,因为对话框捕获了点击操作。

所以,虽然弹出框被关闭了,但“重试”按钮实际上并没有被点击,这意味着任何关联的事件监听器都不会被触发。

即使你创建了一个自动化测试来专门检查当对话框打开时Toast的提醒功能,该自动化测试仍可能出现误报,因为它触发了对Toast按钮的编程式点击。这种伪点击错误地绕过了由于对话框导致页面变为惰性状态所引发的问题。

重新获得弹出框访问权限

解决方案有两个方面:

  1. 将弹出框(popover)在DOM中实际放置在对话框(dialog)内部。
  2. 确保使用 popover="manual",以防止对话框内的点击操作过早触发弹出框的轻触关闭。

完成这两步后,弹出框现在既在视觉上可用,又可以通过任何方式完全交互。

Codepan

经验教训与额外考虑

我们了解到,如果你的网站或应用有可能同时显示弹出框和对话框,并且它们有独立的时间线,那么你需要想出一种在对话框内启动弹出框的机制。

或者,您可以选择在对话框关闭之前禁用后台页面弹出窗口。但如果通知需要及时交互,或者对话框内容有可能触发 Toast 提示,则此方法可能并不理想。

除了可见性和交互性之外,您可能还需要考虑另一个问题:弹出窗口是否需要在对话框关闭后继续保持打开状态。也就是说,即使对话框关闭,弹出窗口也需要保持打开状态,例如继续等待用户执行操作。

虽然我非常支持使用原生平台功能,而且我认为弹出框(popover)尤其出色,但有时冲突是无法完全避免的。事实上,您可能已经遇到过类似的问题,即模态对话框的惰性行为。因此,本文的主要目的是提醒您,如果同时显示背景弹出框和模态对话框,可能会出现问题,因此不要完全放弃之前自定义的弹出框架构。

如果这个问题目前或将来会影响到你的工作,请关注这个HTML问题,其中正在讨论解决方案。

关于斯蒂芬妮·埃克尔斯

Stephanie Eckles 是 Adobe Spectrum CSS 的高级设计工程师,也是 CSSWG 的成员,同时还是 ModernCSS.dev 的作者。Steph 拥有超过 15 年的 Web 开发经验,她乐于以作家、研讨会讲师和会议演讲者的身份分享这些经验。她致力于倡导无障碍设计、可扩展 CSS 和 Web 标准。业余时间,她是两个女儿的妈妈,喜欢烘焙和水彩画。

博客:ModernCSS.dev Mastodon:@5t3ph

译者注:

  1. popover:弹出框指的是轻提示的弹出式框,没有过多的交互逻辑
  2. dialog:对话框指的是带有交互逻辑的弹出框,例如存在确认和取消按钮,输入框等

这两个都是新特性,具体内容可参考MDN

React 的新时代已经到来:你需要知道的一切

2025年12月22日 14:20
构建异步 UI 向来都是一件非常困难的事情。导航操作将内容隐藏在加载指示器之后,搜索框在响应无序到达时会产生竞态条件,表单提交则需要手动管理每一个加载状态标志和错误信息。每个异步操作都迫使你手动进行协

TypeScript 严格性是非单调的:strict-null-checks 和 no-implicit-any 的相互影响

2025年12月22日 14:15

原文: TypeScript strictness is non-monotonic: strict-null-checks and no-implicit-any interact

翻译: 嘿嘿

来源:前端周刊

TypeScript 编译器选项 strictNullChecksnoImplicitAny 以一种奇怪的方式相互作用:仅启用 strictNullChecks 会导致类型错误,而在同时启用 noImplicitAny 后这些错误却消失了。这意味着更严格的设置反而导致更少的错误!

这虽然是一个影响不大的奇闻异事,但我在实际工作中确实遇到了它,当时我正在将一些模块更新为更严格的设置。

背景

TypeScript 是驯服 JavaScript 代码库的强大工具,但要获得最大的保障,需要在“严格”模式下使用它。

在现有的 JavaScript 代码库中采用 TypeScript 可以逐步完成:逐个打开每个严格的子设置,并逐一处理出现的错误。这种渐进式方法使得采用变得可行:不要在一次大爆炸中修复整个世界,而是进行多次较小的更改,直到最终世界被修复。

在工作中,我们最近一直在以这种方式逐步提高代码的严格性,然后我遇到了这种相互作用。

示例

下面这段代码中,array 的类型是什么?

const array = [];
array.push(123);

作为一个独立的代码片段,它看起来奇怪且毫无意义(“为什么不直接用 const array = [123];?”),但它是真实代码的最小化版本。

const featureFlags = [];

if (enableRocket()) {
  featureFlags.push("rocket");
}
if (enableParachute()) {
  featureFlags.push("parachute");
}

prepareForLandSpeedRecord(featureFlags);

这里没有显式的类型注解,所以 TypeScript 需要推断它。这种推断有点巧妙,因为它需要“时间旅行”(指需要运行后续语句后回头去修改推断的类型,类似正则回溯):const array = [] 这个声明并没有说明数组中可能包含什么,这个信息只来自代码后面出现的 push

考虑到所有这些,推断出的确切类型依赖于两个 TypeScript 语言选项也就不足为奇了:

strictNullChecks noImplicitAny 推断类型
最不严格 any[]
number[]
never[]
最严格 number[]

选项说明

这里影响推断类型的两个选项是:

  • strictNullChecks:正确强制处理可选/可为空的值。例如,启用后,一个可为空的字符串变量(类型为 string | null)不能直接用在期望普通 string 值的地方。
  • noImplicitAny:避免在一些模棱两可的情况下推断出“全能”的 any 类型。

最好同时启用它们:strictNullChecks 解决了“十亿美元的错误”,而 noImplicitAny 减少了感染代码库的容易出错的 any 的数量。

问题所在

我们上表中第三种配置,即启用 strictNullChecks 但禁用 noImplicitAny 时,推断出 array: never[]。因此,代码片段无效并被报错(在线示例):

array.push(123);
//         ^^^ 错误:类型“123”的参数不能赋给类型“never”的参数。

没有任何东西(既不是字面量 123,也不是任何其他 number,也不是任何其他东西)是 never 的“子类型”,所以,是的,这段代码无效是合理的。

奇怪之处

“启用一些更严格的要求,然后得到一个错误”并不令人惊讶,也不值得注意……但让我们再仔细看看表格:

strictNullChecks noImplicitAny 推断类型
最不严格 any[]
number[]
报错! never[]
最严格 number[]

所以,如果我们从一个宽松的代码库开始,并希望使其变得严格,我们可能会:

  1. 启用 strictNullChecks,然后遇到一个新错误(不奇怪),然后
  2. 解决这个错误,无需更改代码,只需启用 noImplicitAny(奇怪!)。

当我们朝着完全严格的方向前进时,逐个启用严格选项可能会导致一些“虚假的”错误短暂出现,仅仅出现在中间的半严格状态。随着我们打开设置,错误数量会先上升后下降!

我个人期望启用严格选项是单调的:启用的选项越多 = 报错越多。但这一对选项违反了这种期望。

解决方案

在尝试使 TypeScript 代码库变得严格时,有几种方法可以“解决”这种奇怪现象:

  1. 直接用显式注解修复错误,例如 const array: number[] = []
  2. 使用不同的逐个启用顺序:先启用 noImplicitAny,然后再启用 strictNullChecks。如上表所示,按照这个顺序,两个步骤的推断结果都是 array: number[],因此没有错误。
  3. 同时启用它们:不要试图完全渐进,而是将这两个选项作为一步启用。

解释

为什么启用 strictNullChecks 并禁用 noImplicitAny 会导致一个在其他地方不出现的错误?jcalz 在 StackOverflow 上解释得很好,其核心是:

  • 这种有问题的组合是一个为了向后兼容而留下的边缘情况,其中 array 的类型在其声明处被推断为 never[],并在后续代码中被锁定。
  • 启用 noImplicitAny 会使编译器在模棱两可的位置(在没有 noImplicitAny 时会推断为 any 的地方)使用“演化”类型(evolving types,可理解为先推断为 any/never 然后后续追加推断的类型):因此,array 的类型不会在其声明行被确定,并且可以结合来自 push 的信息进行推断。

评论

这感觉像是一个有趣的脑筋急转弯,而不是一个重大问题:

  • 修复这些虚假错误并不是一个重大的负担或显著的浪费时间,而且可以说,添加注解可能使这类代码更清晰。
  • 半严格状态可能有奇怪的行为是可以理解的:我想 TypeScript 开发者更关心完全严格模式下的良好体验,希望中间状态只是垫脚石,而不是长期状态。

总结

TypeScript 选项 strictNullChecksnoImplicitAny 以一种奇怪的方式相互作用:以“错误”的顺序逐个启用它们会导致错误出现然后又消失,违反了单调性的期望(启用的严格选项越多 = 错误越多)。这可能发生在真实代码中,但影响极小,因为很容易解决和/或规避。

告别杂乱数字:用 Intl.NumberFormat 打造全球友好的前端体验

作者 CC码码
2025年12月22日 13:04

大家好,我是CC,在这里欢迎大家的到来~

开场

书接上文,Intl 下的 Segmenter 对象可以实现对文本的分割,Collator 对象可以处理字符串的比较,除此之外,还有数字格式化、日期格式化等其他功能。

这篇文章先来看看数字格式化,现在来理论加实践一下。

数字格式化

Intl.NumberFormat使数字在特定语言环境下格式化。

配置项

为了方便阅读,属性列表根据用途划分为多个部分,包括区域选项、样式选项、数字选项和其他选项。

区域选项

  • localeMatcher
    • 使用的区域匹配算法,可能的值包括:
    • 默认值为 best fit,还有 lookup
  • numberingSystem
    • 数字格式化的数字系统,像阿拉伯数字 arab、简体中文数字 hans、无衬线数字 mathsans
    • 默认值取决于区域
    • 同 locales 的 Unicode 扩展键 nu 设置,但优先级高于他

样式选项

  • style
    • 使用的格式化样式,可选的值包括:
    • decimal: 普通数字格式化
    • currency: 货币格式化
    • percent: 百分比格式化
    • unit: 单位格式化
    • 默认值是 decimal
  • currency
    • 货币格式化中使用的货币,像美元 USD、欧元 EUR 和人民币 CNY。
    • 没有默认值,style 为 currency 时必须提供,内容会被转换为大写。
  • currencyDisplay
    • 货币格式化中如何显示货币,可选的值包括:
    • code: 使用 ISO 货币代码
    • symbol: 使用本地化货币符号
    • narrowSymbol: 使用窄格式符号,像 100而不是US100 而不是 US100
    • name: 使用本地化货币名称,像 dollar
  • currencySign
    • 使用括号将数字括起来,而不是添加负号,可选的值包括:
    • standard: 默认值
    • accounting: 会计
  • unit
    • 格式化的单位
    • style 为 unit 时必填
  • unitDisplay
    • unit 格式化时使用的格式化风格,可选的值包括:
    • short: 默认值,例如 16 l
    • narrow: 例如 16l
    • long: 例如 16 litres

数字选项,由 Intl.PluralRules 支持

  • minimumIntegerDigits
    • 最小整数位数,默认值为 1,范围是 1~21
    • 若实际整数位数不足会在左侧用 0 补足,比如对于数字 5 该值设置为 3 则显示为“005”
  • minimumFractionDigits
    • 小数部分的最小位数,范围是 0~100
    • 若小数位数不足时会在右侧补 0,超过时会按四舍五入截断
    • 默认值对于普通数字和百分比是 0,对于 currency 是 2(ISO 4217 标准小数位数)
  • maximumFractionDigits
    • 小数部分的最大位数,范围是 0~100
    • 若小数位数不足时会在右侧补 0,超过时会按四舍五入截断
    • 默认值对于普通数字和百分比是 3,对于 currency 是 2(ISO 4217 标准小数位数)
  • minimumSignificanntDigits
    • 最小有效数字,默认值为 1。范围是 1~21。
    • 优先级高于 minimumFractionDigits
  • maximumSignificanntDigits
    • 最大有效数字,默认值为 21。范围是 1~21。
    • 优先级高于 maximumFractionDigits
  • roundingPriority
    • 当同时使用 FractionDigits 和 SignificantDigits 时指定如何解决四舍五入冲突,可选的值包括:
    • auto: 默认值,使用有效数字属性
    • morePrecision: 使用精度更高的属性
    • lessPrecision: 使用精度更低的属性
    • auto 属性会在 natation 为 compact 时且未设置任何四个 FractionDigits/SignificantDigits 时会被设置为 morePrecision
    • 除 auto 属性以外的值会根据 maximumSignificanntDigits 和 maximumFractionDigits 计算出更高精度,忽略最小小数位和有效数字位
  • roundingIncrement
    • 相对于计算出的舍入单位的舍入增量
    • 默认值为 1,其他值包括 1、2、5、10、20、25、50、100、200、250、500、1000、2000、5000
    • 不能与有效数字位舍入或任何 roundingPriority(除了 auto) 混合使用
  • roundingMode
    • 对小数进行舍入,可选的值包括:
    • ceil: 向正无穷舍入,正数向上,负数“向正”
    • floor: 向负无穷舍入,正数向下,负数“向负”
    • expand: 四舍五入远离 0,绝对值增大
    • trunc: 四舍五入朝向 0,绝对值减小
    • halfCeil: 趋向于正无穷舍入,包括半值
    • halfFloor: 趋向于负无穷舍入,包括半值
    • halfExpand: 默认值,半值远离 0 舍入
    • halfTrunc: 向 0 取整,包括半值
    • halfEven: 半值向最接近的偶数整数舍入,常用于统计,减少片差
  • trailingZeroDisplay
    • 整数末尾 0 的显示策略,可选的值包括:
    • auto: 默认值,根据 minimumFractionDigits 和 minimumSignificanntDigits 保持末尾 0
    • stripIfInteger: 如果小数部分全为 0 则删除小数部分,如果小数部分有任何非零数则与 auto 相同

其他选项

  • notation
    • 数字的显示格式,可选的值包括:
    • standard: 默认值,纯数字格式
    • scientific: 返回格式化数字的数量级
    • engineering: 返回能被 3 整除的 10 的指数
    • compact: 表示指数的字符串,默认使用 short 形式
  • compactDisplay
    • 仅当 notation 为 compact 时使用,可选的值包括:
    • short: 默认值
    • long
  • useGrouping
    • 是否使用分组分隔符,像千位分隔符或者千/十万/千万分隔符,可选的值包括:
    • always: 即使 locale 偏好不同也展示分组分隔符
    • auto: 根据 locale 偏好显示分组分隔符,也取决于货币
    • min2: 当一组数字至少有 2 位数字时显示分组分隔符
    • true: 同 always
    • false: 不展示分组分隔符
    • 当 notation 为 compact 时默认值为 min2,否则默认值为 auto
    • 字符串 true 和 false 会被转化为默认值
  • signDisplay
    • 何时显示数字符号,可选的值包括:
    • auto: 默认值
    • always: 总是显示
    • exceptZero: 正数和负数显示符号,但 0 不显示
    • negative: 仅显示负数的符号,不包括负零
    • never: 从不展示

格式化

format()方法会基于区域和格式化选项进行数字格式化。支持数字、大数和字符串。

数字可能因为太大或太小而丢失精度

const numberFormat = new Intl.NumberFormat("en-US");
console.log(numberFormat.format(987654321987654321));
// 987,654,321,987,654,300

但是使用大数就不会有问题

const numberFormat = new Intl.NumberFormat("en-US");
console.log(numberFormat.format(987654321987654321n));
// 987,654,321,987,654,321

字符串也不会丢失精度

const numberFormat = new Intl.NumberFormat("en-US");
console.log(numberFormat.format("987654321987654321"));
// 987,654,321,987,654,321

使用指数表示

const numberFormat = new Intl.NumberFormat("en-US");
const bigNum = 987654321987654321n;
console.log(numberFormat.format(`${bigNum}E-6`));
// 987,654,321,987.654

格式化分割成多部分

formatToParts()将会返回一个对象数组,包含格式化后的每一部分,适合用来自定义字符串格式化。

const number = 3500;

const formatter = new Intl.NumberFormat("de-DE", {
  style: "currency",
  currency: "EUR",
});

console.log(formatter.format(number));
// "3.500,00 €"

console.log(formatter.formatToParts(number));
// [
//   { type: "integer", value: "3" },
//   { type: "group", value: "." },
//   { type: "integer", value: "500" },
//   { type: "decimal", value: "," },
//   { type: "fraction", value: "00" },
//   { type: "literal", value: " " },
//   { type: "currency", value: "€" },
// ];

格式化数字范围

formatRange()返回一个字符串表示数字范围格式化后的内容。

const nf = new Intl.NumberFormat("en-US", {
  style: "currency",
  currency: "USD",
  maximumFractionDigits: 0,
});

console.log(nf.formatRange(3, 5));
// "$3 – $5"

如果开始值和结束值四舍五入值相同或者完全相同时则会添加近似等于符号。

console.log(nf.formatRange(2.9, 3.1));
// "~$3"

格式化数字范围分割成多部分

formatRangeToParts()返回一个对象数组,包含格式化后的每一部分,适合用来自定义数字字符串的格式化范围。

const startRange = 3500;
const endRange = 9500;

const formatter = new Intl.NumberFormat("de-DE", {
  style: "currency",
  currency: "EUR",
});

console.log(formatter.formatRange(startRange, endRange));
// "3.500,00–9.500,00 €"

console.log(formatter.formatRangeToParts(startRange, endRange));
// [
//   { type: "integer", value: "3", source: "startRange" },
//   { type: "group", value: ".", source: "startRange" },
//   { type: "integer", value: "500", source: "startRange" },
//   { type: "decimal", value: ",", source: "startRange" },
//   { type: "fraction", value: "00", source: "startRange" },
//   { type: "literal", value: "–", source: "shared" },
//   { type: "integer", value: "9", source: "endRange" },
//   { type: "group", value: ".", source: "endRange" },
//   { type: "integer", value: "500", source: "endRange" },
//   { type: "decimal", value: ",", source: "endRange" },
//   { type: "fraction", value: "00", source: "endRange" },
//   { type: "literal", value: " ", source: "shared" },
//   { type: "currency", value: "€", source: "shared" },
// ]

获取配置项

const de = new Intl.NumberFormat("de-DE", {
  style: "currency",
  currency: "USD",
  maximumFractionDigits: 2,
  roundingIncrement: 5,
  roundingMode: "halfCeil",
});

const usedOptions = de.resolvedOptions();
console.log(usedOptions.locale); // "de-DE"
console.log(usedOptions.numberingSystem); // "latn"
console.log(usedOptions.compactDisplay); // undefined ("notation" not set to "compact")
console.log(usedOptions.currency); // "USD"
console.log(usedOptions.currencyDisplay); // "symbol"
console.log(usedOptions.currencySign); // "standard"
console.log(usedOptions.minimumIntegerDigits); // 1
console.log(usedOptions.minimumFractionDigits); // 2
console.log(usedOptions.maximumFractionDigits); // 2
console.log(usedOptions.minimumSignificantDigits); // undefined (maximumFractionDigits is set)
console.log(usedOptions.maximumSignificantDigits); // undefined (maximumFractionDigits is set)
console.log(usedOptions.notation); // "standard"
console.log(usedOptions.roundingIncrement); // 5
console.log(usedOptions.roundingMode); // halfCeil
console.log(usedOptions.roundingPriority); // auto
console.log(usedOptions.signDisplay); // "auto"
console.log(usedOptions.style); // "currency"
console.log(usedOptions.trailingZeroDisplay); // auto
console.log(usedOptions.useGrouping); // auto

判断返回支持的 locale

在给定的 locales 数组中判断出 NumberFormat 支持的 locales。但是可能每个浏览器支持的不大一样。

const locales = ["ban", "id-u-co-pinyin", "de-ID"];
const options = { localeMatcher: "lookup" };

console.log(Intl.NumberFormat.supportedLocalesOf(locales, options));
// ["id-u-co-pinyin", "de-ID"]

总结

Intl.NumberFormat用于根据语言和地区格式化数字内容,像把数字格式化为货币、百分比或带单位的本地化字符串,精确控制数字的小数位数、有效数字和整数部分的最小位数,设置丰富的舍入模式像四舍五入、向零舍入或银行家舍入法这些场景下都十分适用。

HTTP一些问题的解答(接上篇)

作者 小熊哥722
2025年12月21日 17:03

一、在弱网环境下HTTP1会比HTTP2更快的原因是啥?

在弱网环境(高延迟、高丢包率)下,HTTP/1.x 有时比 HTTP/2 表现更好,核心原因是 HTTP/2 的多路复用机制与 TCP 协议的固有缺陷在弱网下产生了 “负协同效应” ,而 HTTP/1.x 的多连接策略反而规避了这种风险。具体可从以下几个角度拆解:

1. 多路复用放大了 TCP 队头阻塞的影响

HTTP/2 的核心优势是 “多路复用”—— 所有请求通过单个 TCP 连接传输,不同请求的帧(Frame)在该连接中交错发送。但这也意味着:单个 TCP 数据包的丢失会阻塞所有请求

  • 弱网下的问题:弱网环境丢包率高(比如 5% 以上),TCP 层一旦丢失一个数据包(可能包含某个请求的帧),会触发重传机制。由于 TCP 是 “按序交付” 的,重传期间,该连接上所有后续数据包(无论属于哪个请求)都会被暂存在接收端的 TCP 缓冲区,无法提交给 HTTP/2 应用层。例如:一个 TCP 连接上有 10 个并发请求的帧在传输,若第 3 个请求的某个帧丢失,TCP 重传期间,后面 7 个请求的帧即使已到达,也会被阻塞,导致所有 10 个请求都变慢。
  • HTTP/1.x 的规避方式:HTTP/1.x 依赖多个并行 TCP 连接(浏览器通常限制为 6-8 个),每个连接处理一个串行请求。若某个连接发生丢包,仅影响该连接上的请求,其他连接的请求仍可正常传输。例如:8 个连接中 1 个丢包,仅 1 个请求受影响,其余 7 个可继续,整体效率反而更高。

2. HTTP/2 的复杂机制在弱网下 “水土不服”

HTTP/2 为优化性能引入的机制(如头部压缩、流优先级),在弱网环境下可能变成负担:

  • HPACK 头部压缩的脆弱性:HTTP/2 用 HPACK 算法压缩请求头,依赖客户端和服务器维护 “共享压缩上下文”(记录已传输的头部字段)。若传输过程中某个头部帧丢失,可能导致双方压缩上下文不一致,需要重新同步,反而增加额外的传输开销和延迟。而 HTTP/1.x 的头部虽未压缩(冗余字节多),但结构简单,单个请求的头部丢失仅影响该请求,无需复杂同步。
  • 流优先级调度的失效:HTTP/2 允许标记请求的优先级(如 CSS/JS 优先于图片),但弱网下,TCP 层的丢包和重传会打乱优先级 —— 高优先级请求的帧可能因低优先级帧的丢包而被阻塞,导致优先级机制失效。而 HTTP/1.x 虽无优先级调度,但多连接天然隔离了不同请求,重要资源可通过独立连接传输,受其他请求影响更小。

3. TCP 拥塞控制对单连接的 “惩罚” 更重

TCP 有拥塞控制机制(如慢启动、拥塞避免),当检测到丢包时,会剧烈降低发送窗口(减少数据发送速率),直到网络恢复。

  • HTTP/2 的单连接劣势:单个 TCP 连接发生丢包后,拥塞窗口会被大幅缩减,导致所有请求的传输速率同时下降。弱网下丢包频繁,单连接可能长期处于低速率状态,整体吞吐量骤降。
  • HTTP/1.x 的多连接优势:多个 TCP 连接独立进行拥塞控制,某个连接因丢包被降速时,其他连接可保持正常速率。即使部分连接受影响,整体吞吐量仍能维持在较高水平。

总结:弱网下 “简单” 比 “高效” 更可靠

HTTP/2 的设计是为了在良好网络环境下通过多路复用提升效率(减少连接开销、优化资源调度),但依赖 TCP 作为底层传输协议。而弱网环境的高丢包、高延迟会放大 TCP 队头阻塞的影响,让多路复用从 “优势” 变成 “劣势”。

HTTP/1.x 虽然存在连接开销大、串行请求的问题,但多连接的 “分散风险” 特性在弱网下更稳健 —— 丢包的影响被限制在单个连接内,整体表现反而更优。这也是为什么在 3G 网络、高丢包的偏远地区,部分场景下 HTTP/1.x 反而比 HTTP/2 更快的核心原因。

二、HTTP2只能有一个连接吗?

HTTP/2 不是 “只能有一个连接” ,而是推荐使用单个 TCP 连接实现多路复用,但协议本身并不禁止建立多个连接。是否使用单连接,更多是基于性能优化的 “最佳实践”,而非强制限制。

1. 为什么 HTTP/2 通常用 “单个连接”?

HTTP/2 的核心设计目标之一是解决 HTTP/1.x “多连接开销大” 的问题,因此推荐通过单个 TCP 连接承载所有请求,利用 “多路复用”(多个 Stream 在同一连接中交错传输)提升效率:

  • 减少连接建立成本:TCP 三次握手、TLS 握手都有延迟(尤其是首次连接),单个连接可避免多次握手的开销;
  • 优化拥塞控制:单个连接的 TCP 拥塞窗口(发送速率)可集中利用带宽,多个连接可能因各自的拥塞控制相互竞争带宽,反而降低整体效率;
  • 简化多路复用逻辑:单个连接中,所有 Stream 的帧(Frame)通过Stream ID区分,接收端更容易管理和调度,多个连接会增加状态同步的复杂度。

2. 什么情况下 HTTP/2 会用 “多个连接”?

虽然不推荐,但 HTTP/2 协议允许同一客户端与服务器建立多个 TCP 连接,常见场景包括:

  • 兼容性兜底:部分老旧服务器或中间代理(如 CDN 节点)对 HTTP/2 的多路复用支持不完善(比如不识别 Stream ID),客户端可能 fallback 到多个连接以确保通信正常;
  • 域名分片残留:HTTP/1.x 常用 “域名分片”(将资源分散到多个子域名,突破浏览器单域名连接数限制),迁移到 HTTP/2 后,若未完全改造,可能仍保留多个子域名的连接(每个子域名一个 HTTP/2 连接);
  • 故障隔离:若单个连接因网络问题(如长时间卡顿、丢包)不可用,客户端可新建一个 HTTP/2 连接继续传输,避免整体中断;
  • 带宽限制突破:某些场景下(如超大文件传输),单个 TCP 连接的拥塞控制可能无法充分利用带宽,通过多个连接 “并行” 传输(类似 HTTP/1.x)可提升吞吐量(但这是对 HTTP/2 设计的 “反用”,较少见)。

3. 浏览器的实际行为:单连接为主,多连接为辅

现代浏览器(如 Chrome、Firefox)对 HTTP/2 的实现遵循 “单连接优先” 原则:

  • 同一域名,默认只建立1 个 HTTP/2 连接,所有请求通过该连接的多路复用传输;
  • 若该连接出现异常(如 TCP 断连),浏览器会自动新建一个 HTTP/2 连接替代;
  • 不同域名,仍会建立独立的 HTTP/2 连接(与 HTTP/1.x 一致,因跨域连接无法共享)。

总结

HTTP/2 的 “单个连接” 是推荐的最佳实践(为了最大化多路复用的优势),但协议本身不限制连接数量。实际应用中,绝大多数场景会用单连接,仅在兼容性、故障恢复等特殊情况下使用多个连接。这种设计既保留了灵活性,又通过单连接默认策略解决了 HTTP/1.x 的核心性能问题。

三、HTTP2中,多路复用的原理是什么?

HTTP/2 的多路复用(Multiplexing)是其核心特性之一,本质是在单一 TCP 连接上同时处理多个请求 - 响应事务,解决了 HTTP/1.x 中 “队头阻塞”(Head-of-Line Blocking)和连接效率低下的问题。其实现原理可拆解为三个关键机制:

1. 帧(Frame):数据传输的最小单位

HTTP/2 将所有传输的数据(请求头、响应体等)拆分为二进制帧,每个帧大小固定(默认最大 16KB),并包含以下关键信息:

  • 流标识符(Stream ID) :标记该帧属于哪个 “流”(对应一个请求 - 响应);
  • 类型(Type) :区分帧的用途(如HEADERS帧承载请求头,DATA帧承载正文,SETTINGS帧配置参数等);
  • 长度(Length) :帧的实际数据大小;
  • 标志位(Flags) :附加控制信息(如END_STREAM标记流结束)。

二进制帧的设计相比 HTTP/1.x 的文本格式,不仅解析效率更高,更重要的是为 “交错传输” 奠定了基础。

2. 流(Stream):请求 - 响应的逻辑通道

每个请求 - 响应事务对应一个,流是 TCP 连接内的 “虚拟通道”,具有以下特性:

  • 双向性:一个流中可同时传输客户端到服务器(请求)和服务器到客户端(响应)的帧;
  • 唯一标识:每个流有唯一的Stream ID(客户端发起的流 ID 为奇数,服务器发起的为偶数);
  • 优先级:可通过PRIORITY帧指定流的优先级(如 CSS/JS 资源优先于图片),服务器据此调整帧的发送顺序;
  • 可中断与复用:流可被暂停、恢复或终止,释放的 ID 可被新流复用。

通过流的隔离,多个请求 - 响应的帧可以在同一 TCP 连接上 “交错传输”(如请求 A 的DATA帧和请求 B 的HEADERS帧交替发送),接收方再根据Stream ID将帧重新组装成完整的请求 / 响应。

3. 单一 TCP 连接的复用

HTTP/2 通过上述 “帧 + 流” 机制,实现了单一 TCP 连接上的多路复用

  • 所有请求 / 响应共享一个 TCP 连接,无需为每个请求建立新连接(减少三次握手 / 慢启动的开销);
  • 多个流的帧可并行传输,避免了 HTTP/1.x 中 “一个请求阻塞导致后续请求排队” 的队头阻塞问题;
  • 即使某个流因网络问题阻塞,其他流的帧仍可正常传输(仅影响单个流,不阻塞整个连接)。

对比 HTTP/1.x 的核心优势

HTTP/1.x HTTP/2 多路复用
多个请求需建立多个 TCP 连接(或串行复用同一连接) 所有请求共享单一 TCP 连接
文本格式传输,解析效率低 二进制帧传输,解析更快
一个请求阻塞会导致后续请求排队(队头阻塞) 流隔离,单个流阻塞不影响其他流

简言之,HTTP/2 的多路复用通过 “帧拆分 + 流标识 + 单连接复用”,彻底解决了 HTTP/1.x 的连接效率问题,大幅提升了高并发场景下的性能(如网页加载大量资源时)。

四、HTTP/1为啥会一个请求阻塞会导致后续请求排队(队头阻塞)?

HTTP/1.x 出现 “队头阻塞”(Head-of-Line Blocking)的核心原理,是由TCP 协议的 “按序交付” 特性HTTP/1.x 协议的 “串行请求 - 响应” 设计共同决定的,两者叠加导致了 “前一个请求阻塞后续所有请求” 的现象。

原理拆解:两层机制的叠加限制

1. 底层 TCP 协议的 “按序交付” 特性(根本原因)

TCP 是面向连接的 “可靠字节流协议”,其核心特性之一是 “按序交付”

  • 发送方会给每个数据包分配一个唯一的 “序号”,接收方必须按序号从小到大的顺序接收并组装数据;
  • 若中间某个数据包丢失(如网络波动),接收方会触发 “超时重传” 机制,等待发送方重新发送丢失的数据包;
  • 在丢失的数据包被重传并接收前,后续所有已到达的数据包即使完整,也会被暂存队列中,无法提交给应用层处理(因为顺序被打乱,无法保证数据完整性)。

这就是 “TCP 层的队头阻塞”—— 单个数据包的问题会阻塞后续所有数据的处理。

2. HTTP/1.x 协议的 “串行请求 - 响应” 设计(放大问题)

HTTP/1.x 运行在 TCP 之上,但其协议设计进一步放大了 TCP 的队头阻塞问题:

  • 无 “流标识” 机制:HTTP/1.x 没有像 HTTP/2 那样的 “流 ID” 来区分不同请求的数据包。接收方(如浏览器)只能通过 “请求发送顺序” 来匹配对应的响应。
  • 严格串行处理:在同一个 TCP 连接上,HTTP/1.x 要求:
    • 必须等前一个请求的完整响应被接收后,才能发送下一个请求;
    • 响应也必须按请求发送的顺序返回(否则接收方无法判断哪个响应对应哪个请求)。

最终导致队头阻塞的过程

假设在一个 TCP 连接上,浏览器按顺序发送 3 个请求:请求A → 请求B → 请求C,过程如下:

  1. 服务器正常返回响应A的部分数据,但中途某个数据包丢失;
  2. 由于 TCP 按序交付特性,接收方会等待丢失的数据包重传,此时响应A的后续数据和已到达的响应B响应C的完整数据,都会被暂存在 TCP 缓冲区中,无法提交给浏览器处理;
  3. 同时,由于 HTTP/1.x 的串行规则,浏览器必须等响应A完全接收后,才能处理响应B响应C—— 即使响应B响应C的数据早已到达,也只能排队等待。

最终,单个请求(A)的阻塞会像 “多米诺骨牌” 一样,导致后续所有请求(B、C)被卡住,这就是 HTTP/1.x 队头阻塞的完整原理。

总结

TCP 的 “按序交付” 导致单个数据包问题阻塞后续数据,而 HTTP/1.x 缺乏 “流标识” 和 “并行处理” 能力,只能通过 “串行请求 - 响应” 来保证数据匹配,两者叠加使得一个请求的延迟会阻塞同一连接上所有后续请求,这就是队头阻塞的本质。

JavaScript 列表转树(List to Tree)详解:前端面试中如何从递归 O(n²) 优化到一次遍历 O(n)

2025年12月21日 10:53

前言:Offer 是怎么没的?

在前端面试的江湖里,「列表转树(List to Tree)」 是一道妥妥的高频题。

很多同学一看到这道题,内心 OS 都是:

😎「简单啊,递归!」

代码写完,自信抬头。
面试官却慢悠悠地问了一句:

🤨「如果是 10 万条数据 呢?
👉 时间复杂度多少?
👉 会不会栈溢出?」

空气突然安静。

今天这篇文章,我们就把这道题彻底拆开:
从「能写」到「写得对」,再到「写得漂亮」。


一、为什么面试官总盯着这棵“树”?

因为在真实业务中,后端给你的几乎永远是扁平数据

例如:

const list = [
  { id: 1, parentId: 0, name: '北京市' },
  { id: 2, parentId: 1, name: '顺义区' },
  { id: 3, parentId: 1, name: '朝阳区' },
  { id: 4, parentId: 2, name: '后沙峪' },
  { id: 121, parentId: 0, name: '江西省' },
  { id: 155, parentId: 121, name: '抚州市' }
];

而前端组件(Menu、Tree、Cascader)要的却是👇

省
 └─ 市
     └─ 区

🎯 面试官的真实考点

  • 数据结构理解:是否真正理解 parentId
  • 递归意识 & 代价:不只会写,还要知道坑在哪
  • 性能优化能力:能否从 O(n²) 优化到 O(n)
  • JS 引用理解:是否理解对象在内存中的表现

二、第一重境界:递归法(能写,但不稳)

1️⃣ 最基础的递归写法

function list2tree(list, parentId = 0) {
  return list
    .filter(item => item.parentId === parentId)
    .map(item => ({
      ...item,
      children: list2tree(list, item.id)
    }));
}

逻辑非常直观:

  • 找当前 parentId 的所有子节点
  • 对每个子节点继续递归
  • 没有子节点时自然退出

三、进阶:ES6 优雅写法(看起来很高级)

如果你在面试中写出下面这段代码👇
面试官大概率会先点头。

const list2tree = (list, parentId = 0) =>
  list
    .filter(item => item.parentId === parentId)
    .map(item => ({
      ...item,              // 解构赋值,保持原对象纯净
      children: list2tree(list, item.id)
    }));

这一版代码:

  • ✅ 箭头函数
  • filter + map 链式调用
  • ✅ 解构赋值,不污染原数据
  • ✅ 可读性很好,看起来很“ES6”

👉 很多同学到这一步就觉得稳了。


🤔 面试官的经典追问

「这个方案,有什么问题?」


🎯 标准回答(一定要说出来)

「这个方案的本质是 嵌套循环
每一层递归,都会遍历一次完整的 list

👉 时间复杂度是 O(n²)
👉 如果层级过深,还可能导致 栈溢出(Stack Overflow) 。」

📌 一句话总结

ES6 写法只是“看起来优雅”,
性能问题不会因为代码好看就自动消失。


四、第二重境界:Map 优化(面试及格线)

既然慢,是因为反复遍历找父节点
那就用 Map 建立索引

👉 典型的:空间换时间


核心思路

  1. 第一遍:把所有节点放进 Map
  2. 第二遍:通过 parentId 直接挂载
  3. 利用 JS 对象引用,自动同步树结构

代码实现

function listToTreeWithMap(list) {
  const map = new Map();
  const tree = [];

  // 初始化
  for (const item of list) {
    map.set(item.id, { ...item, children: [] });
  }

  // 构建树
  for (const item of list) {
    const node = map.get(item.id);
    if (item.parentId === 0) {
      tree.push(node);
    } else {
      const parent = map.get(item.parentId);
      parent && parent.children.push(node);
    }
  }

  return tree;
}

⏱ 复杂度分析

  • 时间复杂度:O(n)
  • 空间复杂度:O(n)

📌 到这一步,已经可以应付大多数面试了。


五、终极奥义:一次遍历 + 引用魔法(Top Tier)

面试官:
「能不能只遍历一次?」

答案是:能,而且这才是天花板解法。


核心精髓:占位 + 引用同步

  • 子节点可能先于父节点出现
  • 先在 Map 里给父节点 占位
  • 后续再补全数据
  • 引用地址始终不变,树会“自己长好”

代码实现(一次遍历)

function listToTreePerfect(list) {
  const map = new Map();
  const tree = [];

  for (const item of list) {
    const { id, parentId } = item;

    if (!map.has(id)) {
      map.set(id, { children: [] });
    }

    const node = map.get(id);
    Object.assign(node, item);

    if (parentId === 0) {
      tree.push(node);
    } else {
      if (!map.has(parentId)) {
        map.set(parentId, { children: [] });
      }
      map.get(parentId).children.push(node);
    }
  }

  return tree;
}

🏆 为什么这是王者解法?

  • ✅ 一次遍历,O(n)
  • ✅ 支持乱序数据
  • ✅ 深度理解 JS 引用机制
  • ✅ 面试官一眼就懂你是“真会”

六、真实开发中的应用场景

  • 🔹 权限 / 菜单树(Ant Design / Element)
  • 🔹 省市区 / Cascader
  • 🔹 文件目录结构(云盘、编辑器)

七、面试总结 & 避坑指南

方案 时间复杂度 评价
递归 O(n²) 能写,但危险
Map 两次遍历 O(n) 面试合格
一次遍历 O(n) 面试加分

面试加分表达

  • 主动提 空间换时间
  • 点出 JS 对象是引用类型
  • 询问 parentId 是否可能为 null
  • 说明是否会修改原数据(必要时深拷贝)

结语

算法不是为了为难人,
而是为了在复杂业务中,
选出那条最稳、最优雅的路。

如果这篇文章对你有帮助👇
👍 点个赞
💬 评论区聊聊你在项目里遇到过的奇葩数据结构

工程化工具类:模块化系统全解析与实践

作者 1024肥宅
2025年12月20日 13:11

引言

在前端开发的演进历程中,模块化一直是工程化实践的核心。从早期的脚本标签堆砌到现代的ES Modules,模块化技术极大地提升了代码的可维护性、复用性和协作效率。本文将深入探讨模块化的各个方面,包括模块加载器实现、规范演化、Polyfill技术,并补充构建工具、性能优化等工程化实践,全面解析模块化在现代前端开发中的应用。

一、实现简单的模块加载器

在理解复杂模块系统之前,我们先实现一个简单的模块加载器,了解其核心原理。

1.1 基础模块加载器实现
// 简单的模块注册表
const moduleRegistry = {};
const moduleCache = {};

// 模块定义函数
function define(name, dependencies, factory) {
  if (!moduleRegistry[name]) {
    moduleRegistry[name] = {
      dependencies,
      factory,
      resolved: false,
      exports: null
    };
  }
}

// 模块加载函数
function require(name) {
  // 检查缓存
  if (moduleCache[name]) {
    return moduleCache[name];
  }
  
  const module = moduleRegistry[name];
  if (!module) {
    throw new Error(`Module ${name} not found`);
  }
  
  // 解析依赖
  const resolvedDeps = module.dependencies.map(dep => {
    if (dep === 'exports' || dep === 'module') {
      return null; // 特殊处理
    }
    return require(dep);
  });
  
  // 执行工厂函数获取模块导出
  const factoryResult = module.factory.apply(null, resolvedDeps);
  
  // 缓存模块导出
  moduleCache[name] = factoryResult || {};
  module.resolved = true;
  
  return moduleCache[name];
}

// 使用示例
define('math', [], function() {
  return {
    add: (a, b) => a + b,
    multiply: (a, b) => a * b
  };
});

define('calculator', ['math'], function(math) {
  return {
    calculate: (x, y) => math.multiply(math.add(x, y), 2)
  };
});

// 使用模块
const calculator = require('calculator');
console.log(calculator.calculate(2, 3)); // 10
1.2 异步模块加载器
class AsyncModuleLoader {
  constructor() {
    this.modules = new Map();
    this.loading = new Map();
  }
  
  // 定义模块
  define(name, deps, factory) {
    this.modules.set(name, {
      deps,
      factory,
      exports: null,
      resolved: false
    });
  }
  
  // 异步加载模块
  async require(name) {
    if (this.modules.get(name)?.resolved) {
      return this.modules.get(name).exports;
    }
    
    // 防止重复加载
    if (this.loading.has(name)) {
      return this.loading.get(name);
    }
    
    // 创建加载Promise
    const loadPromise = this._loadModule(name);
    this.loading.set(name, loadPromise);
    
    return loadPromise;
  }
  
  async _loadModule(name) {
    const module = this.modules.get(name);
    if (!module) {
      throw new Error(`Module ${name} not found`);
    }
    
    // 加载所有依赖
    const depPromises = module.deps.map(dep => this.require(dep));
    const deps = await Promise.all(depPromises);
    
    // 执行工厂函数
    const exports = module.factory.apply(null, deps);
    
    // 更新模块状态
    module.exports = exports || {};
    module.resolved = true;
    this.loading.delete(name);
    
    return module.exports;
  }
}

// 使用示例
const loader = new AsyncModuleLoader();

loader.define('utils', [], () => ({
  format: str => str.toUpperCase()
}));

loader.define('app', ['utils'], (utils) => {
  return {
    run: () => console.log(utils.format('hello'))
  };
});

loader.require('app').then(app => app.run()); // 输出: HELLO

二、AMD规范实现

AMD(Asynchronous Module Definition)规范是RequireJS推广的异步模块定义标准。

2.1 简化的AMD实现
(function(global) {
  // 模块缓存
  const modules = {};
  const inProgress = {};
  
  // 定义函数
  function define(id, dependencies, factory) {
    if (arguments.length === 2) {
      factory = dependencies;
      dependencies = [];
    }
    
    modules[id] = {
      id: id,
      dependencies: dependencies,
      factory: factory,
      exports: null,
      resolved: false
    };
    
    // 尝试解析模块
    resolveModule(id);
  }
  
  // 依赖解析
  function resolveModule(id) {
    const module = modules[id];
    if (!module || module.resolved) return;
    
    // 检查依赖是否都可用
    const deps = module.dependencies;
    const missingDeps = deps.filter(dep => 
      !modules[dep] || !modules[dep].resolved
    );
    
    if (missingDeps.length === 0) {
      // 所有依赖已就绪,执行工厂函数
      executeModule(id);
    } else {
      // 等待依赖
      missingDeps.forEach(dep => {
        if (!inProgress[dep]) {
          inProgress[dep] = [];
        }
        inProgress[dep].push(id);
      });
    }
  }
  
  // 执行模块
  function executeModule(id) {
    const module = modules[id];
    if (module.resolved) return;
    
    // 获取依赖的exports
    const depExports = module.dependencies.map(dep => {
      if (dep === 'exports') return {};
      if (dep === 'require') return createRequire();
      if (dep === 'module') return { id: module.id, exports: {} };
      return modules[dep].exports;
    });
    
    // 执行工厂函数
    const exports = module.factory.apply(null, depExports);
    
    // 设置exports
    module.exports = exports || 
      (depExports[module.dependencies.indexOf('exports')] || {});
    module.resolved = true;
    
    // 通知等待此模块的其他模块
    if (inProgress[id]) {
      inProgress[id].forEach(dependentId => resolveModule(dependentId));
      delete inProgress[id];
    }
  }
  
  // 创建require函数
  function createRequire() {
    return function(ids, callback) {
      if (typeof ids === 'string') ids = [ids];
      
      Promise.all(ids.map(loadModule))
        .then(modules => {
          if (callback) callback.apply(null, modules);
        });
    };
  }
  
  // 异步加载模块
  function loadModule(id) {
    return new Promise((resolve, reject) => {
      if (modules[id] && modules[id].resolved) {
        resolve(modules[id].exports);
        return;
      }
      
      // 动态加载脚本
      const script = document.createElement('script');
      script.src = id + '.js';
      script.onload = () => {
        // 等待模块解析
        const checkInterval = setInterval(() => {
          if (modules[id] && modules[id].resolved) {
            clearInterval(checkInterval);
            resolve(modules[id].exports);
          }
        }, 10);
      };
      script.onerror = reject;
      document.head.appendChild(script);
    });
  }
  
  // 暴露到全局
  global.define = define;
  global.require = createRequire();
  
})(typeof window !== 'undefined' ? window : global);

// 使用示例
define('math', [], function() {
  return {
    add: function(a, b) { return a + b; }
  };
});

define('app', ['math', 'require'], function(math, require) {
  return {
    calculate: function() {
      return math.add(1, 2);
    },
    loadExtra: function() {
      require(['utils'], function(utils) {
        console.log('Utils loaded');
      });
    }
  };
});

require(['app'], function(app) {
  console.log(app.calculate()); // 3
});

三、CMD规范实现

CMD(Common Module Definition)规范由Sea.js推广,强调就近依赖。

3.1 简化的CMD实现
(function(global) {
  const modules = {};
  const factories = {};
  const cache = {};
  
  // 模块状态
  const STATUS = {
    PENDING: 0,
    LOADING: 1,
    LOADED: 2,
    EXECUTING: 3,
    EXECUTED: 4
  };
  
  // 定义函数
  function define(factory) {
    // 获取当前脚本
    const scripts = document.getElementsByTagName('script');
    const currentScript = scripts[scripts.length - 1];
    const id = currentScript.src.replace(/\.js$/, '');
    
    factories[id] = factory;
    modules[id] = {
      id: id,
      factory: factory,
      deps: [],
      exports: null,
      status: STATUS.PENDING,
      callbacks: []
    };
    
    // 解析依赖
    parseDependencies(id);
  }
  
  // 解析依赖
  function parseDependencies(id) {
    const factory = factories[id];
    if (!factory) return;
    
    const source = factory.toString();
    const requireRegex = /require\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
    const deps = [];
    let match;
    
    while ((match = requireRegex.exec(source)) !== null) {
      deps.push(match[1]);
    }
    
    modules[id].deps = deps;
  }
  
  // 异步加载模块
  function require(id, callback) {
    const module = modules[id];
    
    if (module && module.status === STATUS.EXECUTED) {
      // 模块已执行,直接返回
      if (callback) {
        callback(module.exports);
      }
      return module.exports;
    }
    
    // 模块未加载,开始加载
    if (!module || module.status === STATUS.PENDING) {
      return loadModule(id, callback);
    }
    
    // 模块加载中,添加回调
    if (module.status < STATUS.EXECUTED) {
      module.callbacks.push(callback);
    }
  }
  
  // 加载模块
  function loadModule(id, callback) {
    const module = modules[id] || (modules[id] = {
      id: id,
      deps: [],
      exports: null,
      status: STATUS.LOADING,
      callbacks: callback ? [callback] : []
    });
    
    // 创建script标签加载
    const script = document.createElement('script');
    script.src = id + '.js';
    script.async = true;
    
    script.onload = function() {
      module.status = STATUS.LOADED;
      executeModule(id);
    };
    
    script.onerror = function() {
      console.error(`Failed to load module: ${id}`);
    };
    
    document.head.appendChild(script);
    
    return null;
  }
  
  // 执行模块
  function executeModule(id) {
    const module = modules[id];
    if (!module || module.status >= STATUS.EXECUTING) return;
    
    module.status = STATUS.EXECUTING;
    
    // 收集依赖
    const deps = module.deps;
    const depValues = deps.map(depId => {
      const depModule = modules[depId];
      if (depModule && depModule.status === STATUS.EXECUTED) {
        return depModule.exports;
      }
      // 同步加载依赖(简化实现)
      return require(depId);
    });
    
    // 执行工厂函数
    const factory = factories[id];
    if (!factory) {
      throw new Error(`Factory not found for module: ${id}`);
    }
    
    // 提供require、exports、module参数
    const localRequire = function(depId) {
      return require(depId);
    };
    
    const localExports = {};
    const localModule = { exports: localExports };
    
    // 执行
    const result = factory.call(null, localRequire, localExports, localModule);
    
    // 设置exports
    module.exports = localModule.exports || result || localExports;
    module.status = STATUS.EXECUTED;
    
    // 执行回调
    module.callbacks.forEach(cb => cb(module.exports));
    module.callbacks = [];
  }
  
  // 暴露全局
  global.define = define;
  global.require = require;
  
})(typeof window !== 'undefined' ? window : global);

// 使用示例
// 文件: math.js
define(function(require, exports, module) {
  module.exports = {
    add: function(a, b) {
      return a + b;
    }
  };
});

// 文件: app.js
define(function(require, exports, module) {
  var math = require('math');
  
  exports.calculate = function() {
    return math.add(1, 2);
  };
});

// 主文件
require('app', function(app) {
  console.log(app.calculate()); // 3
});

四、ES Module的简单Polyfill

虽然现代浏览器支持ES Modules,但在某些场景下,我们仍需要Polyfill支持。

4.1 基础ESM Polyfill实现
// ES Module Polyfill
(function() {
  const moduleMap = new Map();
  const moduleCache = new Map();
  
  // 拦截import语句(通过动态import实现)
  window.importModule = async function(modulePath) {
    // 检查缓存
    if (moduleCache.has(modulePath)) {
      return moduleCache.get(modulePath);
    }
    
    // 加载模块代码
    const code = await fetchModule(modulePath);
    
    // 解析依赖
    const deps = extractDependencies(code);
    
    // 加载依赖
    const depPromises = deps.map(dep => 
      importModule(resolvePath(modulePath, dep))
    );
    const dependencies = await Promise.all(depPromises);
    
    // 执行模块
    const moduleExports = {};
    const module = {
      exports: moduleExports
    };
    
    // 创建包装函数
    const wrapper = createWrapper(code, dependencies);
    wrapper(
      moduleExports, // exports
      module,        // module
      modulePath     // __filename(模拟)
    );
    
    // 缓存结果
    const exports = module.exports === moduleExports ? 
      moduleExports : module.exports;
    moduleCache.set(modulePath, exports);
    
    return exports;
  };
  
  // 提取依赖
  function extractDependencies(code) {
    const importRegex = /import\s+.*?\s+from\s+['"](.*?)['"]/g;
    const dynamicImportRegex = /import\s*\(['"](.*?)['"]\)/g;
    const deps = new Set();
    
    let match;
    while ((match = importRegex.exec(code)) !== null) {
      deps.add(match[1]);
    }
    
    // 重置正则
    importRegex.lastIndex = 0;
    
    while ((match = dynamicImportRegex.exec(code)) !== null) {
      deps.add(match[1]);
    }
    
    return Array.from(deps);
  }
  
  // 创建包装函数
  function createWrapper(code, dependencies) {
    const wrapperCode = `
      (function(exports, module, __filename, __dirname) {
        // 注入依赖
        const [
          ${dependencies.map((_, i) => `__dep${i}`).join(', ')}
        ] = arguments[4];
        
        ${code}
        
        // 返回默认导出
        return module.exports && module.exports.default ?
          module.exports.default : module.exports;
      })
    `;
    
    return eval(wrapperCode);
  }
  
  // 解析路径
  function resolvePath(basePath, targetPath) {
    if (targetPath.startsWith('./') || targetPath.startsWith('../')) {
      const baseDir = basePath.substring(0, basePath.lastIndexOf('/'));
      return new URL(targetPath, baseDir + '/').pathname;
    }
    return targetPath;
  }
  
  // 获取模块代码
  async function fetchModule(path) {
    const response = await fetch(path);
    if (!response.ok) {
      throw new Error(`Failed to load module: ${path}`);
    }
    return response.text();
  }
  
  // 拦截script type="module"
  interceptModuleScripts();
  
  function interceptModuleScripts() {
    const originalCreateElement = document.createElement;
    
    document.createElement = function(tagName) {
      const element = originalCreateElement.call(document, tagName);
      
      if (tagName === 'script') {
        const originalSetAttribute = element.setAttribute.bind(element);
        
        element.setAttribute = function(name, value) {
          originalSetAttribute(name, value);
          
          if (name === 'type' && value === 'module') {
            // 拦截模块脚本
            const src = element.getAttribute('src');
            if (src) {
              element.type = 'text/javascript';
              importModule(src).then(() => {
                if (element.onload) element.onload();
              }).catch(err => {
                if (element.onerror) element.onerror(err);
              });
            }
          }
        };
      }
      
      return element;
    };
  }
})();

// 使用示例
// 模块文件: utils.js
export function capitalize(str) {
  return str.charAt(0).toUpperCase() + str.slice(1);
}

export default function greet(name) {
  return `Hello, ${capitalize(name)}!`;
}

// 主文件
importModule('./utils.js').then(utils => {
  console.log(utils.default('world')); // Hello, World!
  console.log(utils.capitalize('test')); // Test
});
4.2 支持Tree Shaking的ESM Polyfill
class ESMCompat {
  constructor() {
    this.modules = new Map();
    this.usedExports = new Set();
  }
  
  // 注册模块
  register(name, code) {
    const ast = this.parse(code);
    const exports = this.extractExports(ast);
    
    this.modules.set(name, {
      code,
      ast,
      exports,
      used: new Set()
    });
  }
  
  // 解析代码为AST(简化版)
  parse(code) {
    // 简化实现:实际应使用Babel等解析器
    const exportMatches = code.match(/export\s+(const|let|var|function|class|default)\s+(\w+)/g) || [];
    const imports = code.match(/import\s+\{([^}]+)\}\s+from\s+['"]([^'"]+)['"]/g) || [];
    
    return {
      exports: exportMatches.map(match => ({
        type: match.split(' ')[1],
        name: match.split(' ')[2]
      })),
      imports: imports.map(match => {
        const parts = match.match(/import\s+\{([^}]+)\}\s+from\s+['"]([^'"]+)['"]/);
        return {
          specifiers: parts[1].split(',').map(s => s.trim()),
          source: parts[2]
        };
      })
    };
  }
  
  // 提取导出
  extractExports(ast) {
    return ast.exports.map(exp => exp.name);
  }
  
  // 使用模块(标记使用的导出)
  use(name, ...exports) {
    const module = this.modules.get(name);
    if (module) {
      exports.forEach(exp => {
        if (module.exports.includes(exp)) {
          module.used.add(exp);
        }
      });
    }
  }
  
  // 生成优化后的代码
  generateOptimized(name) {
    const module = this.modules.get(name);
    if (!module) return '';
    
    let code = module.code;
    
    // 移除未使用的导出(简化实现)
    module.exports.forEach(exp => {
      if (!module.used.has(exp)) {
        const regex = new RegExp(`export\\s+.*?\\b${exp}\\b[^;]*;`, 'g');
        code = code.replace(regex, '');
      }
    });
    
    return code;
  }
}

// 使用示例
const compat = new ESMCompat();

compat.register('math', `
export const PI = 3.14159;
export function add(a, b) { return a + b; }
export function multiply(a, b) { return a * b; }
export function unusedFunction() { return 'unused'; }
`);

// 标记使用的导出
compat.use('math', 'PI', 'add');

// 生成优化代码
console.log(compat.generateOptimized('math'));
// 输出将只包含PI和add的导出

五、模块化构建工具集成

现代开发中,我们使用构建工具处理模块化。以下展示如何集成Webpack-like的简单打包器。

5.1 简易模块打包器
const fs = require('fs');
const path = require('path');
const { parse } = require('@babel/parser');
const traverse = require('@babel/traverse').default;
const generate = require('@babel/generator').default;
const t = require('@babel/types');

class SimpleBundler {
  constructor(entry) {
    this.entry = entry;
    this.modules = new Map();
    this.moduleId = 0;
  }
  
  // 构建
  build(outputPath) {
    const entryModule = this.collectDependencies(this.entry);
    const bundleCode = this.generateBundle(entryModule);
    
    fs.writeFileSync(outputPath, bundleCode);
    console.log(`Bundle generated: ${outputPath}`);
  }
  
  // 收集依赖
  collectDependencies(filePath) {
    const fileContent = fs.readFileSync(filePath, 'utf-8');
    const ast = parse(fileContent, {
      sourceType: 'module',
      plugins: ['jsx']
    });
    
    const dependencies = [];
    const dirname = path.dirname(filePath);
    
    // 遍历AST收集import语句
    traverse(ast, {
      ImportDeclaration: ({ node }) => {
        const importPath = node.source.value;
        const absolutePath = this.resolvePath(importPath, dirname);
        dependencies.push(absolutePath);
      },
      CallExpression: ({ node }) => {
        if (node.callee.type === 'Import') {
          const importPath = node.arguments[0].value;
          const absolutePath = this.resolvePath(importPath, dirname);
          dependencies.push(absolutePath);
        }
      }
    });
    
    const moduleId = this.moduleId++;
    const module = {
      id: moduleId,
      filePath,
      code: fileContent,
      dependencies,
      mapping: {}
    };
    
    this.modules.set(filePath, module);
    
    // 递归收集依赖
    dependencies.forEach(dep => {
      if (!this.modules.has(dep)) {
        this.collectDependencies(dep);
      }
    });
    
    return module;
  }
  
  // 解析路径
  resolvePath(importPath, baseDir) {
    if (importPath.startsWith('.')) {
      return path.resolve(baseDir, importPath);
    }
    // 处理node_modules(简化)
    const nodeModulePath = path.resolve(process.cwd(), 'node_modules', importPath);
    if (fs.existsSync(nodeModulePath)) {
      return nodeModulePath;
    }
    return importPath;
  }
  
  // 生成打包代码
  generateBundle(entryModule) {
    const modules = [];
    
    // 创建模块映射
    this.modules.forEach(module => {
      const transformedCode = this.transformModule(module);
      modules.push(`
        ${module.id}: {
          factory: function(require, module, exports) {
            ${transformedCode}
          },
          mapping: ${JSON.stringify(module.mapping)}
        }
      `);
    });
    
    // 生成运行时
    return `
      (function(modules) {
        const moduleCache = {};
        
        function require(id) {
          if (moduleCache[id]) {
            return moduleCache[id].exports;
          }
          
          const mod = modules[id];
          const localRequire = function(modulePath) {
            return require(mod.mapping[modulePath]);
          };
          
          const module = { exports: {} };
          mod.factory(localRequire, module, module.exports);
          
          moduleCache[id] = module;
          return module.exports;
        }
        
        // 启动入口模块
        require(0);
      })({
        ${modules.join(',\n')}
      });
    `;
  }
  
  // 转换模块代码
  transformModule(module) {
    const ast = parse(module.code, {
      sourceType: 'module'
    });
    
    // 构建路径映射
    let importIndex = 0;
    
    traverse(ast, {
      ImportDeclaration: ({ node }) => {
        const importPath = node.source.value;
        const depModule = this.modules.get(
          this.resolvePath(importPath, path.dirname(module.filePath))
        );
        
        if (depModule) {
          const importName = `__import_${importIndex++}`;
          module.mapping[importPath] = depModule.id;
          
          // 替换import语句
          const specifiers = node.specifiers.map(spec => {
            if (t.isImportDefaultSpecifier(spec)) {
              return t.variableDeclarator(
                spec.local,
                t.memberExpression(
                  t.identifier(importName),
                  t.identifier('default')
                )
              );
            } else {
              return t.variableDeclarator(
                spec.local,
                t.memberExpression(
                  t.identifier(importName),
                  spec.imported || spec.local
                )
              );
            }
          });
          
          return t.variableDeclaration('const', specifiers);
        }
      }
    });
    
    // 移除export语句
    traverse(ast, {
      ExportNamedDeclaration: ({ node, remove }) => {
        if (node.declaration) {
          return node.declaration;
        }
        remove();
      },
      ExportDefaultDeclaration: ({ node }) => {
        return t.expressionStatement(
          t.assignmentExpression(
            '=',
            t.memberExpression(
              t.identifier('module'),
              t.identifier('exports')
            ),
            t.objectExpression([
              t.objectProperty(
                t.identifier('default'),
                node.declaration
              )
            ])
          )
        );
      }
    });
    
    const { code } = generate(ast);
    return code;
  }
}

// 使用示例
const bundler = new SimpleBundler('./src/index.js');
bundler.build('./dist/bundle.js');

六、模块联邦与微前端架构

模块联邦(Module Federation)是Webpack 5引入的重要特性,支持跨应用共享模块。

6.1 简易模块联邦实现
// 模块联邦管理器
class ModuleFederation {
  constructor(config) {
    this.config = config;
    this.remotes = new Map();
    this.exposes = new Map();
    this.shared = new Map();
    this.init();
  }
  
  init() {
    // 初始化共享模块
    if (this.config.shared) {
      Object.entries(this.config.shared).forEach(([name, config]) => {
        this.shared.set(name, {
          module: require(name),
          version: config.version,
          singleton: config.singleton || false
        });
      });
    }
    
    // 初始化暴露模块
    if (this.config.exposes) {
      Object.entries(this.config.exposes).forEach(([name, modulePath]) => {
        this.exposes.set(name, require(modulePath));
      });
    }
  }
  
  // 注册远程应用
  async registerRemote(name, url) {
    try {
      const remoteManifest = await this.fetchRemoteManifest(url);
      this.remotes.set(name, {
        url,
        manifest: remoteManifest
      });
      console.log(`Remote ${name} registered`);
    } catch (error) {
      console.error(`Failed to register remote ${name}:`, error);
    }
  }
  
  // 获取远程清单
  async fetchRemoteManifest(url) {
    const response = await fetch(`${url}/federation-manifest.json`);
    return response.json();
  }
  
  // 获取模块
  async getModule(remoteName, moduleName) {
    // 检查共享模块
    if (this.shared.has(moduleName)) {
      return this.shared.get(moduleName).module;
    }
    
    // 检查本地暴露
    if (this.exposes.has(moduleName)) {
      return this.exposes.get(moduleName);
    }
    
    // 检查远程模块
    const remote = this.remotes.get(remoteName);
    if (remote) {
      return this.loadRemoteModule(remote, moduleName);
    }
    
    throw new Error(`Module ${moduleName} not found`);
  }
  
  // 加载远程模块
  async loadRemoteModule(remote, moduleName) {
    const moduleUrl = `${remote.url}/${moduleName}.js`;
    
    // 动态加载脚本
    return new Promise((resolve, reject) => {
      const script = document.createElement('script');
      script.src = moduleUrl;
      
      script.onload = () => {
        // 假设远程模块会暴露到全局
        const module = window[`${remote.name}_${moduleName}`];
        if (module) {
          resolve(module);
        } else {
          reject(new Error(`Module ${moduleName} not found in remote`));
        }
      };
      
      script.onerror = reject;
      document.head.appendChild(script);
    });
  }
  
  // 暴露模块
  expose(name, module) {
    this.exposes.set(name, module);
    // 暴露到全局(供远程访问)
    window[`${this.config.name}_${name}`] = module;
  }
}

// 使用示例
// App 1 配置
const federation1 = new ModuleFederation({
  name: 'app1',
  exposes: {
    './Button': './src/components/Button.js'
  },
  shared: {
    react: { singleton: true, version: '17.0.0' },
    'react-dom': { singleton: true, version: '17.0.0' }
  }
});

// App 2 配置
const federation2 = new ModuleFederation({
  name: 'app2',
  remotes: {
    app1: 'http://localhost:3001'
  },
  shared: {
    react: { singleton: true, version: '17.0.0' }
  }
});

// App2中使用App1的模块
federation2.getModule('app1', 'Button').then(Button => {
  // 使用远程Button组件
  console.log('Remote Button loaded:', Button);
});
七、模块化性能优化
7.1 代码分割与懒加载
class CodeSplitter {
  constructor() {
    this.chunks = new Map();
    this.loadedChunks = new Set();
  }
  
  // 定义代码分割点
  defineChunk(name, getChunk) {
    this.chunks.set(name, getChunk);
  }
  
  // 懒加载代码块
  async loadChunk(name) {
    if (this.loadedChunks.has(name)) {
      return;
    }
    
    const getChunk = this.chunks.get(name);
    if (!getChunk) {
      throw new Error(`Chunk ${name} not found`);
    }
    
    // 标记为加载中
    this.loadedChunks.add(name);
    
    try {
      await getChunk();
      console.log(`Chunk ${name} loaded`);
    } catch (error) {
      this.loadedChunks.delete(name);
      throw error;
    }
  }
  
  // 预加载代码块
  preloadChunk(name) {
    if (this.loadedChunks.has(name)) return;
    
    const link = document.createElement('link');
    link.rel = 'preload';
    link.as = 'script';
    
    const getChunk = this.chunks.get(name);
    if (getChunk && getChunk.chunkPath) {
      link.href = getChunk.chunkPath;
      document.head.appendChild(link);
    }
  }
}

// Webpack动态导入兼容
function dynamicImport(modulePath) {
  if (typeof __webpack_require__ !== 'undefined') {
    // Webpack环境
    return import(/* webpackChunkName: "[request]" */ modulePath);
  } else {
    // 原生环境
    return import(modulePath);
  }
}

// 使用示例
const splitter = new CodeSplitter();

// 定义代码块
splitter.defineChunk('dashboard', () => 
  dynamicImport('./Dashboard.js')
);

splitter.defineChunk('analytics', () => 
  dynamicImport('./Analytics.js')
);

// 路由懒加载
async function loadRoute(routeName) {
  switch (routeName) {
    case 'dashboard':
      await splitter.loadChunk('dashboard');
      break;
    case 'analytics':
      await splitter.loadChunk('analytics');
      break;
  }
}

// 预加载
window.addEventListener('mouseover', (e) => {
  if (e.target.href && e.target.href.includes('dashboard')) {
    splitter.preloadChunk('dashboard');
  }
});
7.2 模块缓存策略
class ModuleCache {
  constructor() {
    this.cache = new Map();
    this.ttl = 5 * 60 * 1000; // 5分钟
    this.maxSize = 100; // 最大缓存模块数
  }
  
  // 获取模块
  async get(key, fetchModule) {
    const cached = this.cache.get(key);
    
    // 检查缓存是否有效
    if (cached && Date.now() - cached.timestamp < this.ttl) {
      console.log(`Cache hit: ${key}`);
      return cached.module;
    }
    
    // 缓存失效或不存在,重新获取
    console.log(`Cache miss: ${key}`);
    const module = await fetchModule();
    
    // 更新缓存
    this.set(key, module);
    
    return module;
  }
  
  // 设置缓存
  set(key, module) {
    // 清理过期缓存
    this.cleanup();
    
    this.cache.set(key, {
      module,
      timestamp: Date.now()
    });
  }
  
  // 清理缓存
  cleanup() {
    const now = Date.now();
    
    // 清理过期
    for (const [key, value] of this.cache) {
      if (now - value.timestamp > this.ttl) {
        this.cache.delete(key);
      }
    }
    
    // 清理超出大小限制的(LRU策略)
    if (this.cache.size > this.maxSize) {
      const entries = Array.from(this.cache.entries());
      entries.sort((a, b) => a[1].timestamp - b[1].timestamp);
      
      for (let i = 0; i < entries.length - this.maxSize; i++) {
        this.cache.delete(entries[i][0]);
      }
    }
  }
  
  // 清空缓存
  clear() {
    this.cache.clear();
  }
}

// 使用示例
const moduleCache = new ModuleCache();

async function loadModuleWithCache(modulePath) {
  return moduleCache.get(modulePath, async () => {
    const response = await fetch(modulePath);
    return response.text();
  });
}

八、模块化最佳实践与工程化

8.1 模块设计原则
// 1. 单一职责原则
// 不好的例子
class UserManager {
  // 混合了用户管理、验证、通知等多个职责
}

// 好的例子
class UserRepository {
  // 只负责数据访问
}

class UserValidator {
  // 只负责验证
}

class UserNotifier {
  // 只负责通知
}

// 2. 依赖注入
class UserService {
  constructor(userRepository, validator, notifier) {
    this.userRepository = userRepository;
    this.validator = validator;
    this.notifier = notifier;
  }
  
  async register(user) {
    if (!this.validator.validate(user)) {
      throw new Error('Invalid user');
    }
    
    await this.userRepository.save(user);
    await this.notifier.sendWelcome(user.email);
  }
}

// 3. 接口抽象
// 定义接口
class IStorage {
  async save(key, value) {}
  async get(key) {}
  async delete(key) {}
}

// 具体实现
class LocalStorage extends IStorage {
  async save(key, value) {
    localStorage.setItem(key, JSON.stringify(value));
  }
  
  async get(key) {
    return JSON.parse(localStorage.getItem(key));
  }
  
  async delete(key) {
    localStorage.removeItem(key);
  }
}

class APIService {
  constructor(storage) {
    if (!(storage instanceof IStorage)) {
      throw new Error('Invalid storage implementation');
    }
    this.storage = storage;
  }
}
8.2 模块版本管理与升级
class ModuleVersionManager {
  constructor() {
    this.versions = new Map();
    this.deprecations = new Map();
  }
  
  // 注册模块版本
  register(moduleName, version, module) {
    if (!this.versions.has(moduleName)) {
      this.versions.set(moduleName, new Map());
    }
    
    this.versions.get(moduleName).set(version, module);
  }
  
  // 获取模块(支持语义化版本)
  get(moduleName, versionRange = 'latest') {
    const moduleVersions = this.versions.get(moduleName);
    if (!moduleVersions) {
      throw new Error(`Module ${moduleName} not found`);
    }
    
    if (versionRange === 'latest') {
      const latestVersion = Array.from(moduleVersions.keys())
        .sort(this.compareVersions)
        .pop();
      return moduleVersions.get(latestVersion);
    }
    
    // 简化的版本范围解析
    const availableVersions = Array.from(moduleVersions.keys())
      .filter(v => this.satisfiesVersion(v, versionRange))
      .sort(this.compareVersions);
    
    if (availableVersions.length === 0) {
      throw new Error(`No version of ${moduleName} satisfies ${versionRange}`);
    }
    
    return moduleVersions.get(availableVersions.pop());
  }
  
  // 比较版本
  compareVersions(v1, v2) {
    const parts1 = v1.split('.').map(Number);
    const parts2 = v2.split('.').map(Number);
    
    for (let i = 0; i < 3; i++) {
      if (parts1[i] !== parts2[i]) {
        return parts1[i] - parts2[i];
      }
    }
    
    return 0;
  }
  
  // 检查版本是否满足范围
  satisfiesVersion(version, range) {
    // 简化实现,实际应使用semver库
    if (range === '*') return true;
    
    const [op, versionRange] = range.match(/^([>=<~^]*)(\d+\.\d+\.\d+)$/).slice(1);
    const vParts = version.split('.').map(Number);
    const rParts = versionRange.split('.').map(Number);
    
    switch (op) {
      case '^': // 兼容版本
        return vParts[0] === rParts[0] && vParts[1] >= rParts[1];
      case '~': // 近似版本
        return vParts[0] === rParts[0] && 
               vParts[1] === rParts[1] && 
               vParts[2] >= rParts[2];
      case '>=':
        return this.compareVersions(version, versionRange) >= 0;
      case '>':
        return this.compareVersions(version, versionRange) > 0;
      case '<=':
        return this.compareVersions(version, versionRange) <= 0;
      case '<':
        return this.compareVersions(version, versionRange) < 0;
      default:
        return version === versionRange;
    }
  }
  
  // 弃用通知
  deprecate(moduleName, version, message) {
    if (!this.deprecations.has(moduleName)) {
      this.deprecations.set(moduleName, new Map());
    }
    
    this.deprecations.get(moduleName).set(version, {
      message,
      deprecatedAt: new Date()
    });
    
    // 添加控制台警告
    console.warn(`Module ${moduleName}@${version} is deprecated: ${message}`);
  }
}

// 使用示例
const versionManager = new ModuleVersionManager();

// 注册不同版本
versionManager.register('utils', '1.0.0', {
  oldMethod: () => 'old'
});

versionManager.register('utils', '1.1.0', {
  oldMethod: () => 'old',
  newMethod: () => 'new'
});

versionManager.register('utils', '2.0.0', {
  newMethod: () => 'new',
  betterMethod: () => 'better'
});

// 标记弃用
versionManager.deprecate('utils', '1.0.0', '请升级到1.1.0+版本');

// 获取模块
const utilsV1 = versionManager.get('utils', '^1.0.0');
console.log(utilsV1); // 1.1.0版本

const utilsLatest = versionManager.get('utils');
console.log(utilsLatest); // 2.0.0版本

总结

模块化是现代前端工程化的基石,从前端的脚本标签到ES Modules,再到模块联邦等高级模式,模块化技术不断演进。本文从简单模块加载器实现开始,逐步深入AMD、CMD规范,探讨ES Module的Polyfill技术,并补充了构建工具集成、模块联邦、性能优化等工程化实践。

关键要点总结:

  1. 模块加载器核心原理: 依赖管理、缓存、异步加载
  2. 规范演进: 从AMD/CMD到ES Modules的统一
  3. 工程化实践: 代码分割、懒加载、版本管理、依赖注入
  4. 未来趋势: 模块联邦、微前端架构、Web Assembly模块化

模块化不仅仅是技术选择,更是一种设计哲学。良好的模块化设计能够提升代码的可维护性、可测试性和团队协作效率。在实际项目中,应根据团队规模、项目复杂度和技术栈选择合适的模块化方案,并不断优化模块边界和依赖关系。

随着前端技术的不断发展,模块化将继续演进,但核心原则——关注点分离、接口抽象、依赖管理——将始终保持不变。掌握模块化的核心原理和实践,能够帮助开发者构建更健壮、可维护的前端应用。

从零搭一个 Vue 小家:用 Vite + 路由轻松入门现代前端开发

作者 鱼鱼块
2025年12月20日 12:08

从零开始,轻松走进 Vue 的世界:一个“全家桶”小项目的搭建之旅

如果你刚刚接触前端开发,听到“Vue”、“Vite”、“路由”这些词时是不是有点懵?别担心!我们可以把写代码想象成搭积木、装修房子、甚至安排一场家庭旅行。今天,我们就通过一个名为 all-vue 的小项目,带你一步步理解现代 Vue 应用是怎么“搭起来”的。


🏠 第一步:选好地基——用 Vite 快速建项目

什么是vite?

Vite(法语,意为“快”)是一个由 Vue.js 作者 尤雨溪(Evan You) 主导开发的现代化前端构建工具。它旨在解决传统打包工具(如 Webpack)在开发阶段启动慢、热更新(HMR)延迟高等问题,提供极速的开发体验。

想象你要盖一栋房子。传统方式可能要先打地基、砌砖、铺电线……繁琐又耗时。而 Vite 就像一位超级高效的建筑承包商,你只要说一句:“我要一个 Vue 房子”,它立刻给你搭好框架,连水电都通好了!

在终端里运行:

npm init vite@latest all-vue -- --template vue

几秒钟后,你就得到了一个结构清晰的项目目录。其中最关键的是:

  • index.html:这是你房子的“大门”,浏览器一打开就看到它。
  • src/main.js:这是整栋房子的“总开关”,负责启动整个应用。
  • src/App.vue:这是“客厅”,所有房间(页面)都要从这里进出。

Vite 的优势在于——修改代码后,浏览器几乎瞬间刷新,就像你换了个沙发,家人马上就能坐上去试舒服不舒服。


🏗️ 第二步:认识整栋楼——项目结构概览

运行 npm init vite@latest all-vue -- --template vue 后,你会得到这样一栋“数字公寓”:

项目结构简略预览:

/all-vue
├── public/            # 公共资源(如 logo.png)
├── src/
│   ├── assets/        # 图片、字体等静态资源
│   ├── components/    # 可复用的小部件(按钮、卡片等)
│   ├── views/         # 独立页面(首页、关于页等)
|   |     |—— About.vue # 关于页面的Vue组件
|   |     |—— Home.vue # 主页的vue组件
│   ├── router/        # 室内导航系统
|   |     |—— index.js # 路由总控
│   ├── App.vue        # 中央控制台(客厅)
│   └── main.js        # 智能钥匙
├── index.html         # 入户大门
├── package.json       # 公寓的“住户手册 + 装修清单”
└── vite.config.js     # 建筑规范说明书

其中,package.json 就像这栋楼的住户手册 + 装修材料清单。打开它,你会看到:

{
  "name": "all-vue",
  "version": "0.0.0",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "vue": "^3.4.0",
    "vue-router": "^4.3.0"
  },
  "devDependencies": {
    "@vitejs/plugin-vue": "^5.0.0",
    "vite": "^5.0.0"
  }
}
  • dependencies:这是“入住必需品”,比如 Vue 框架本身、路由系统——没有它们,房子没法正常运转;
  • devDependencies:这是“装修工具包”,只在开发时用(比如 Vite 构建工具),住户入住后就不需要了;
  • scripts:这是“快捷指令”,比如 npm run dev 就是“启动预览模式”,npm run build 是“打包交付”。

有了这份清单,任何开发者都能一键还原你的整套环境——就像照着宜家说明书组装家具一样可靠。


🚪 第三步:认识“大门”——index.html 的两个秘密

虽然现代 Vue 应用的逻辑几乎全在 JavaScript 和 .vue 文件里,但一切的起点,其实是这个看似简单的 index.html

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <link rel="icon" type="image/svg+xml" href="/vite.svg" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>all-vue</title>
</head>
<body>
  <div id="app"></div>
  <script type="module" src="/src/main.js"></script>
</body>
</html>

别小看这十几行代码,它藏着两个关键设计:

🔌 1. <div id="app"></div>:Vue 的“插座”

你可以把它想象成墙上预留的一个智能插座面板。它本身空无一物,但一旦通电(Vue 应用启动),就会自动“投影”出整个用户界面。

main.js 中,我们这样写:

createApp(App).mount('#app')

这句话的意思就是:“请把 App.vue 这个‘客厅’的内容,投射到 id 为 app 的那个插座上。”
没有这个插座,Vue 再厉害也无处施展;有了它,动态内容才能在静态 HTML 中生根发芽。

⚡ 2. <script type="module" src="/src/main.js"></script>:原生 ES 模块的魔法

注意这里的 type="module"。这是现代浏览器支持的一种原生模块加载方式。传统脚本是“一股脑全塞进来”,而模块化脚本则像快递包裹——每个文件独立打包,按需引用,互不干扰。

Vite 正是利用了这一特性,无需打包即可直接在浏览器中运行模块化的代码。这意味着:

  • 开发时启动飞快(冷启动快);
  • 修改文件后热更新极快(HMR 精准替换);
  • 代码结构清晰,符合现代工程规范。

所以,index.html 不仅是入口,更是连接静态 HTML 世界动态 Vue 世界的桥梁。


🔑 第四步:打造“钥匙”——main.js 如何启动应用

有了大门,就得有钥匙。main.js 就是这把精密的电子钥匙,负责激活整套智能家居系统:

import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import './style.css'

createApp(App).use(router).mount('#app')

这段代码做了三件事,环环相扣:

  1. 引入核心模块:从 Vue 拿到“造房子”的工具(createApp),从本地拿到“客厅设计图”(App.vue)和“导航系统”(router);
  2. 组装系统:用 .use(router) 把导航插件装进主程序;
  3. 插入插座.mount('#app') 表示:“请把这套系统通电安装在 index.html 中 id 为 app 的插座上。”

没有这把钥匙,再漂亮的客厅也只是一堆图纸;有了它,整个房子才真正“活”起来。


💡 第五步:点亮客厅——根组件 App.vue

钥匙转动,门开了,我们走进 App.vue —— 这是所有功能的总控中心:

<template>
  <div id="app">
    <nav>
      <router-link to="/">Home</router-link> |
      <router-link to="/about">About</router-link>
    </nav>
    <router-view />
  </div>
</template>

多人一开始会直接写 <div>Home | About</div>,但这只是静态文字。要让它们变成可点击的导航,就得用 Vue Router 提供的 <router-link> 组件。

这里有两个核心元素:

  • <router-link> :智能门把手,点击不刷新页面,只切换内容;
  • <router-view /> :魔法地板,当前该展示哪个房间,它就实时投影出来。

虽然原始文件只写了 HomeAbout,但正确的写法应如上所示——让文字变成可交互的导航。


🗺️ 第六步:装上导航系统——配置 Vue Router

路由,就像是你家里的智能导航系统。没有它,你只能待在客厅;有了它,你才能自由穿梭于各个房间。

我们在 src/router/index.js 中这样配置:

import { createRouter, createWebHashHistory } from 'vue-router';
import Home from '../views/Home.vue'
import About from '../views/About.vue'

const routes = [
  { path: '/', name: 'Home', component: Home },
  { path: '/about', name: 'About', component: About }
]

const router = createRouter({
  history: createWebHashHistory(),
  routes
})

export default router

这段代码的意思是:

  • 当用户访问 /(也就是主页),就显示 Home.vue 这个房间;
  • 当用户访问 /about,就带他去 About.vue 那个房间。

注意这里用了 createWebHashHistory(),这意味着网址会变成 http://localhost:5173/#/about。那个 # 就像门牌号里的“分隔符”,告诉系统:“后面的部分是内部房间号,不是新地址”。


🛋️ 第七步:布置房间——编写页面组件

现在,我们来装修两个房间。

首页(Home.vue)

<template>
  <div>
    <h1>Home</h1>
  </div>
</template>

关于页(About.vue)

<template>
  <div>
    <h1>About</h1>
  </div>
</template>

每个 .vue 文件都是一个自包含的“功能单元”:有自己的结构(template)、逻辑(script)和样式(style)。它们彼此隔离,却能通过路由无缝切换。


🎨 第八步:美化家园——全局样式 style.css

虽然功能齐备,但房子还是灰扑扑的。这时候,style.css 就派上用场了。你可以在这里写:

body {
  font-family: 'Arial', sans-serif;
  background-color: #f5f5f5;
}

nav {
  padding: 1rem;
  background: white;
  box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}

就像给墙壁刷漆、给地板打蜡,让整个家更温馨舒适。


▶️ 最后一步:启动你的 Vue 家园!

现在,所有“装修材料”都已就位——地基打好了(Vite 项目)、大门装上了(index.html)、钥匙配好了(main.js)、客厅布置妥当(App.vue),连房间(Home.vueAbout.vue)和导航系统(Vue Router)也都调试完毕。是时候打开电闸,点亮整栋房子了!

请在终端(命令行)中依次执行以下两条命令(确保你已在 all-vue 项目目录下):

# 第一步:安装“住户手册”里列出的所有依赖(比如 Vue 和路由)
npm install

# 第二步:启动开发服务器——相当于按下“智能家居总开关”
npm run dev

运行成功后,你会看到类似这样的提示:

  VITE v5.0.0  ready in 320 ms

  ➜  Local:   http://localhost:5173/
  ➜  Network: use --host to expose

这时,只需打开浏览器,访问 http://localhost:5173/ (端口号可能略有不同),就能看到你的 Vue 小家啦!

image.png

  • 点击 Home,客厅中央显示 “Home”;
  • 点击 About,瞬间切换到 “About” 页面——全程无需刷新,就像在家自由走动一样丝滑。

🎉 恭喜你!你不仅看懂了代码,还亲手让它跑起来了!

这不再是一堆抽象的文件,而是一个真正能交互的 Web 应用。你已经完成了从“零”到“一”的飞跃——而这,正是所有伟大项目的起点。


🧩 总结:Vue 项目的“生活化”逻辑链

让我们用一次智能家居入住体验来串起全过程:

  1. Vite 是开发商:提供标准化精装修样板间;
  2. index.html 是入户门:设有智能插座(#app)和模块化接线口(type="module");
  3. main.js 是电子钥匙:插入后激活整套系统;
  4. App.vue 是中央控制台:集成导航与内容展示区;
  5. Vue Router 是室内导航图:定义各房间路径;
  6. Home.vue / About.vue 是功能房间:各自独立,按需进入;
  7. style.css 是全屋软装方案:统一视觉风格。

✨ 写在最后:你已经站在 Vue 的门口

这个 all-vue 项目虽小,却包含了现代 Vue 应用的核心骨架:组件化 + 路由 + 响应式 + 工程化构建。你不需要一开始就懂所有细节,就像学骑自行车,先扶稳车把,再慢慢蹬脚踏。

当你运行 npm run dev,看到浏览器里出现“Home”和“About”两个链接,并能自由切换时——恭喜你,你已经成功迈出了 Vue 开发的第一步!

接下来,你可以:

  • 在 Home 里加一张图片;
  • 在 About 里写一段自我介绍;
  • 用 CSS 让导航栏变彩色;
  • 甚至添加第三个页面……

编程不是魔法,而是一步步搭建的过程。而你,已经搭好了第一块积木。

爬楼梯?不,你在攀登算法的珠穆朗玛峰!

作者 wwwwW
2025年12月20日 11:19

爬楼梯?不,你在攀登算法的珠穆朗玛峰!

一道看似“幼儿园难度”的面试题:
“每次能爬1阶或2阶,问爬到第n阶有几种方法?”
却暗藏递归、动态规划、记忆化、空间优化四大内功心法——
它不是考你会不会算数,而是看你有没有系统性思维


🧗‍♂️ 初见:天真递归 —— “我能行!”(然后爆栈了)

最直觉的解法?当然是递归!

function climbStairs(n) {
  if (n === 1) return 1;
  if (n === 2) return 2;
  return climbStairs(n - 1) + climbStairs(n - 2);
}

逻辑完美

  • 要到第 n 阶,要么从 n-1 上来,要么从 n-2 跳上来
  • 所以 f(n) = f(n-1) + f(n-2) —— 这不就是斐波那契?

但问题来了:
当你调用 climbStairs(45),电脑会疯狂重复计算:

  • f(43) 被算两次
  • f(42) 被算三次
  • ……
    时间复杂度 O(2ⁿ) —— 指数爆炸!

就像你让一个人背完整本字典来查一个词——可行,但荒谬。


🧠 进阶:记忆化递归 —— “我记住了!”

既然重复计算是罪魁祸首,那就把算过的答案存起来

const memo = {};
function climbStairs(n) {
  if (n === 1) return 1;
  if (n === 2) return 2;
  if (memo[n]) return memo[n]; // ← 关键:查缓存!
  memo[n] = climbStairs(n - 1) + climbStairs(n - 2);
  return memo[n];
}

效果:每个 f(k) 只算一次 → 时间复杂度 O(n)
思想空间换时间,典型的自顶向下动态规划(Top-down DP)

但有个小瑕疵:memo 是全局变量,容易被污染。


🔒 优雅封装:闭包 + 记忆化 —— “我的缓存,外人别碰!”

闭包memo 私有化,打造一个“智能函数”:

const climbStairs = (function() {
  const memo = {}; // ← 外部无法访问!
  return function climb(n) {
    if (n === 1) return 1;
    if (n === 2) return 2;
    if (memo[n]) return memo[n];
    memo[n] = climb(n - 1) + climb(n - 2);
    return memo[n];
  };
})();

优势

  • 多次调用共享缓存(越用越快)
  • 状态私有,安全可靠
  • 接口干净:用户只需 climbStairs(n)

这不是函数,这是一个会学习、有记忆、懂封装的智能体


🚀 终极优化:自底向上 + 滚动变量 —— “我不需要递归!”

其实,我们根本不需要递归,也不需要存所有中间值!

观察规律:

f(1) = 1
f(2) = 2
f(3) = f(2) + f(1) = 3
f(4) = f(3) + f(2) = 5
...

只需要两个变量,就能滚动计算出结果:

function climbStairs(n) {
  if (n === 1) return 1;
  if (n === 2) return 2;
  
  let prevPrev = 1; // f(i-2)
  let prev = 2;     // f(i-1)
  
  for (let i = 3; i <= n; i++) {
    const current = prev + prevPrev; // f(i)
    prevPrev = prev;   // 滚动窗口
    prev = current;
  }
  
  return prev;
}

时间复杂度:O(n)
空间复杂度:O(1) —— 极致优化!
无递归:避免调用栈溢出(n 很大时更安全)

这就是自底向上的动态规划(Bottom-up DP) —— 从已知出发,一步步推导未知。


📊 四种解法对比

方法 时间复杂度 空间复杂度 是否递归 适用场景
暴力递归 O(2ⁿ) O(n) 教学演示
记忆化递归 O(n) O(n) 中等规模,逻辑清晰
闭包记忆化 O(n) O(n) 需要缓存复用
滚动变量 O(n) O(1) 生产环境首选

💡 面试加分回答

当面试官问这道题,你可以这样说:

“我会根据场景选择方案:

  • 如果是教学或快速原型,用记忆化递归,逻辑直观;
  • 如果是高性能生产环境,用滚动变量的迭代法,O(1) 空间且无栈溢出风险。
    此外,我还会考虑边界情况(如 n ≤ 0)、类型校验,以及是否需要支持‘每次可爬1~k阶’的扩展。”

——瞬间从“会写代码”升级到“有工程思维”。


🌟 结语:小题大智慧

“爬楼梯”从来不是一道数学题,而是一面镜子:

  • 它照出你是否理解递归的本质
  • 它检验你是否掌握动态规划的思想
  • 它考验你能否在简洁、性能、可维护性之间做权衡

下次再有人说“这题太简单”,你可以微笑回应:

“是啊,简单到能写出四种境界。”

而这,正是优秀工程师和普通 coder 的分水岭。

🚀别再卷 Redux 了!Zustand 才是 React 状态管理的躺平神器

2025年12月20日 10:50

Zustand VS Redux

在文章开始前咱们先唠嗑一下,各位平时用哪个更多点呢?大数据不会骗人:

首先GitHub上的 Star 数量比较: image.png

image.png

其次每周的下载数量比较:

image.png

image.png

显然,想必用Zustand的可能大概也许应该会居多(单纯看数据来讲)。那么明明Redux才是大哥,为啥被Zustand这个小弟后来居上了?

给大家一个表:

对比项 Redux(老牌流程派) Zustand(新晋清爽党)
上手门槛 高:得记 action type、reducer、Provider 等一堆概念 低:会用 React Hook 就能写,几行代码起手
代码量 多:改个 count 得写 action、reducer 一堆模板代码 少:创建 store + 组件调用,加起来不到 20 行
组件里怎么用 得用 useSelector 取数据 + useDispatch 发动作 直接 useStore( state => state.xxx ) 一步到位
要不要包 Provider 必须包:得用 <Provider store={store}> 裹整个 App 不用包:组件直接调用 store,省一层嵌套
适合场景 大型复杂项目(多人协作、状态逻辑多) 中小型项目 / 快速开发(想少写代码、快速落地)

相信看完表大家已经很明了了,那么如果还想深入了解的可以自行去搜搜,我们唠嗑就到这,开始今天的学习。

具体资料大家去官网看:

www.npmjs.com/package/zus…

www.npmjs.com/package/rea…

前言

想象一下:你正在开发一个 React 项目,Home 组件要改个数字,About 组件得同步显示,List 组件还要从接口拉数据 —— 要是每个组件都自己存状态,代码早乱成一锅粥了!今天咱们就用 Zustand 这个躺平神器,把这些组件串成丝滑的整体,顺便解锁 React 全局状态的 “极简玩法”

一、先搭个 “状态仓库”:Zustand 初体验

Zustand 是啥?你可以把它理解成一个 “共享储物柜”:组件们不用再互相传 props,直接从这个柜子里拿数据、调方法就行。

首先你需要下载Zustand(在开篇的资料里也可以找到~):

image.png

先看我们的第一个 “储物格”——count.js(负责管理计数状态):

// src/store/count.js
import { create } from "zustand";

// 用 create 造一个“状态仓库”
const useCountStore = create((set) => ({
    // 存数据:初始计数是0,还有个默认年龄19
    count: 0,
    age: 19,
    // 存方法:点一下计数+1(set会自动更新视图)
    increase: () => set((state) => ({ count: state.count + 1 })),
    // 传个参数,计数直接减val
    decrease: (val) => set((state) => ({ count: state.count - val }))
}))

export default useCountStore;

就这么几行,一个能 “存数据 + 改数据” 的全局状态就搞定了 —— 比 Redux 轻量到没朋友!

二、组件 “抢着用”:状态共享原来这么丝滑

有了仓库,组件们就能 “按需取货” 了。先看 Home 组件(负责操作计数):

// src/components/Home.jsx
import useCountStore from '../store/count.js'

export default function Home() {
    // 从仓库里“拿”count数据
    let count = useCountStore((state) => state.count);
    // 从仓库里“拿”increase、decrease方法
    const increase = useCountStore((state) => state.increase);
    const decrease = useCountStore((state) => state.decrease);
    return (
        <div>
            {/* 点按钮直接调仓库里的方法,不用传参! */}
            <button onClick={increase}>发送-{count}</button>
            <button onClick={() => decrease(10)}>减少-{count}</button>
        </div>
    )
}

再看 About 组件(负责显示计数):

// src/components/About.jsx
import useCountStore from "../store/count"

export default function About() {
    // 同样从仓库拿count,Home改了这里自动更!
    let count = useCountStore((state) => state.count);
    return (
        <div>
            <h2>title-{count}</h2>
        </div>
    )
}

点击前:

image.png

点击10次发送后:

image.png

刷新然后点击10次减少后:

image.png

你看你看你看看看,Home 点按钮改了 count,About 里的标题直接同步更新 —— 连 props 都不用传,这丝滑感谁用谁知道!

三、进阶玩法:状态里塞接口请求

光存数字才哪到哪,还不够炫!咱们给仓库加个 “拉接口” 的功能。先写 list.js(负责管理列表数据):

// src/store/list.js
import { create } from "zustand";

// 先写个请求接口的函数
const fetchApi = async () => {
    const response = await fetch('https://mock.mengxuegu.com/mock/66585c4db462b81cb3916d3e/songer/songer');
    const res = await response.json();
    return res.data; // async函数的return会变成Promise的resolve值
}

// 造个存列表的仓库
const useListStore = create((set) => ({
    list: [], // 初始列表是空数组
    // 存个“拉列表”的方法,里面调用接口
    fetchList: async () => {
        const res = await fetchApi();
        set({ list: res }) // 拿到数据后更新list
    }
}))

export default useListStore;

然后让 List 组件 用这个仓库:

// src/components/List.jsx
import { useEffect } from "react";
import useListStore from "../store/list"

export default function List() {
    // 从仓库拿list数据和fetchList方法
    const list = useListStore((state) => state.list);
    const fetchList = useListStore((state) => state.fetchList);

    // 组件一加载就调用接口拉数据
    useEffect(() => {
        fetchList()
    }, [])

    return (
        <div>
            {/* 拿到数据直接map渲染 */}
            {list.map((item) => {
                return <div key={item.name}>{item.name}</div>
            })}
        </div>
    )
}

接口数据就出现在浏览器上啦:

image.png

打开页面,List 组件会自动拉接口、存数据、渲染列表 —— 状态管理 + 接口请求,一套流程直接在仓库里包圆了!

四、最后一步:把组件都塞进 App

最后在 App.jsx 里把这些组件拼起来:

import Home from "./components/Home"
import About from "./components/About"
import List from "./components/List"

export default function App() {
    return (
        <div>
            <Home></Home>
            <About></About>
            <List></List>
        </div>
    )
}

image.png

启动项目,你会看到:About 显示着计数,List 自动渲染接口数据 —— 这就是 Zustand 给 React 带来的 “状态自由”

总结

Zustand 堪称 React 状态管理的 “轻骑兵”:无需写冗余的 reducer、不用嵌套 Provider 包裹组件树,几行代码就能搭建全局状态仓库。它剥离了传统状态管理的繁琐仪式感,让我们彻底摆脱模板代码的束缚,聚焦业务本身。

结语

相比 Redux 的 “厚重” 和 Context API 在高频更新下的性能短板,Zustand 就像一把恰到好处的 “瑞士军刀”,轻巧却锋利,用最简单的方式解决了 React 组件间的状态共享难题,让开发者能把更多精力放在业务逻辑本身,而不是状态管理的 “套路” 里。

好的工具从来不是炫技的枷锁,而是让开发者回归创造本身的桥梁。

❌
❌