阅读视图

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

【节点】[Slider节点]原理解析与实际应用

【Unity Shader Graph 使用与特效实现】专栏-直达

在Unity URP Shader Graph中,Slider节点是一个功能强大且常用的工具节点,它为着色器开发提供了直观的参数控制方式。通过Slider节点,开发者可以创建可调节的浮点数值,这些数值在材质检视器中以滑动条的形式呈现,极大地方便了材质的参数调整和实时预览。

Slider节点的基本概念

Slider节点在Shader Graph中属于常量值节点的一种特殊形式。与普通的Float节点不同,Slider节点不仅提供了数值输出功能,更重要的是它能够在材质检视器中生成一个可视化的滑动条控件。这种可视化控制方式让非技术背景的艺术家和设计师也能够轻松调整着色器参数,无需直接修改代码或节点连接。

Slider节点的核心价值在于其能够将技术性的数值参数转化为直观的交互控件。在游戏开发流程中,这种转化具有重要意义。美术人员可以通过拖动滑动条实时观察材质效果的变化,快速迭代和优化视觉效果,而不必依赖程序员进行每次的参数调整。

从技术实现角度来看,Slider节点在Shader Graph内部被处理为一个浮点数常量,但其特殊的属性标记使得Unity编辑器能够识别并在UI层面提供滑动条控件。这种设计分离了数据表示和用户界面,既保证了着色器计算的高效性,又提供了友好的用户体验。

节点创建与基本配置

在Shader Graph中创建Slider节点有多种方式。最直接的方法是在Shader Graph窗口的空白区域右键点击,从上下文菜单中选择"Create Node",然后在搜索框中输入"Slider"即可找到该节点。另一种便捷的方式是通过黑场区域的右键菜单,选择"Create Node"后导航至"Input/Basic/Slider"路径。

创建Slider节点后,可以看到其简洁的节点结构:一个输出端口和三个可配置参数。输出端口标记为"Out",类型为Float,用于将滑动条的当前值传递给图中的其他节点。三个配置参数包括滑动条本身的值,以及最小值和最大值范围。

配置Slider节点时,首先需要设置合理的数值范围。Min和Max参数定义了滑动条的理论边界,这些值应该根据实际应用场景来设定。例如,控制透明度的滑动条通常设置在0到1之间,而控制纹理重复次数的滑动条可能需要更大的范围。

端口详解与数据流

Slider节点的端口配置相对简单但功能明确。输出端口"Out"是节点的唯一数据出口,负责将滑动条的当前数值传递给连接的下游节点。这个输出值的类型始终为Float,符合大多数着色器计算对数值精度的要求。

数据流通过Slider节点时,节点本身不执行任何计算或变换,它仅仅作为一个数值源存在。当材质检视器中的滑动条被拖动时,Slider节点的输出值会实时更新,进而触发整个着色器的重新计算和渲染更新。

输出端口的绑定特性为"无",这意味着Slider节点的输出不依赖于任何外部输入或纹理采样。这种独立性使得Slider节点非常适合用作着色器的参数控制点,因为它不会引入额外的依赖关系或计算复杂度。

在实际使用中,Slider节点的输出可以直接连接到各种接受Float输入的节点,如数学运算节点、纹理坐标节点、颜色混合节点等。这种灵活性让Slider节点成为控制着色器各种特性的通用工具。

控件参数深度解析

Slider节点的控件参数虽然数量不多,但每个参数都有其特定的用途和配置考量。

滑动条值控件

这是Slider节点的核心参数,决定了当前输出的数值。在Shader Graph编辑器中,这个值可以通过数字输入框精确设置,也可以通过点击并拖动滑动条来直观调整。这个值的设置应当考虑实际应用需求,比如如果用于控制高光强度,初始值可能需要设置为一个较小的正数。

最小值参数

Min参数定义了滑动条的理论下限。这个值可以是任意浮点数,包括负数。在设置最小值时,需要考虑物理合理性,比如透明度不应小于0,但颜色偏移量可能允许负值。最小值还影响着滑动条的灵敏度,范围越大,单位移动对应的数值变化就越大。

最大值参数

Max参数与Min参数协同工作,定义了滑动条的数值上限。最大值的选择同样需要基于实际应用场景,过大的最大值可能导致滑动条控制不够精细,过小的最大值则可能限制效果的表达。

范围设置的策略

合理的范围设置是Slider节点使用的关键。以下是一些常见的使用场景和推荐范围:

  • 透明度控制:0.0 - 1.0
  • 高光强度:0.0 - 5.0
  • 纹理缩放:0.1 - 10.0
  • 颜色通道偏移:-1.0 - 1.0
  • 时间系数:0.0 - 10.0

属性转换与材质实例化

Slider节点的一个强大特性是能够转换为着色器属性。通过节点的上下文菜单,选择"Convert To Property"选项,可以将Slider节点转换为一个正式的着色器属性。这一转换带来了几个重要优势:

材质实例化支持

转换为属性后,每个使用该着色器的材质实例都可以拥有自己独立的Slider值。这意味着可以在不同材质中设置不同的参数,而无需创建多个着色器变体。

运行时修改能力

作为属性的Slider值可以在游戏运行时通过脚本动态修改,这为创建交互式视觉效果提供了可能。比如,可以根据游戏事件调整材质的发光强度或透明度。

属性配置选项

转换为属性后,可以配置更多属性相关设置,如属性名称、默认值、以及是否在材质检视器中隐藏该属性。这些选项提供了更精细的属性管理能力。

属性名称的命名应当具有描述性且符合项目命名规范。好的属性名称能够让其他团队成员更容易理解该参数的作用,如"_SpecularIntensity"比"_Float1"更能清晰表达参数用途。

生成的代码分析

理解Slider节点在背后生成的代码有助于更深入地掌握其工作原理。当Slider节点被转换为属性后,在生成的着色器代码中会产生相应的数据结构和处理逻辑。

基础声明

在着色器的Properties块中,会生成类似以下的属性声明:

_SliderProperty("Slider Display Name", Range(0.0, 1.0)) = 0.5

这里的"Slider Display Name"是在材质检视器中显示的名称,Range(0.0, 1.0)定义了滑动条的范围,0.5是默认值。

变量定义

在CGPROGRAM部分,会生成对应的变量声明:

float _SliderProperty;

这个变量可以在片段着色器或其他计算部分直接使用。

默认值处理

如示例代码所示,Slider节点生成的默认值表达式简单直接:

float _Slider_Out = 1.0;

这行代码创建了一个浮点变量并将其初始化为1.0。在实际生成的着色器中,这个值会被替换为属性系统中存储的实际数值。

材质序列化

转换为属性后,Slider的值会与材质资源一起被序列化,这意味着设置的值会在编辑器会话之间保持持久化。

实际应用案例

Slider节点在URP着色器开发中有着广泛的应用场景,以下通过几个具体案例展示其实际用法。

基础透明度控制

创建一个简单的透明度控制着色器:

  • 在Shader Graph中添加Slider节点,设置范围为0.0到1.0
  • 将Slider输出连接到PBR主节点的Alpha输入
  • 将材质表面类型设置为Transparent
  • 这样就可以通过滑动条实时调整材质透明度

动态高光调节

实现可调节的高光效果:

  • 使用Slider节点控制高光强度
  • 将Slider输出连接到高光计算节点的强度参数
  • 设置合适的范围,如0.0到3.0
  • 结合其他节点创建复杂的高光响应

纹理变换动画

创建基于时间的纹理变换:

  • 使用Slider节点控制动画速度
  • 将Slider输出与Time节点相乘
  • 结果用于驱动纹理偏移或旋转
  • 通过调整Slider值控制动画快慢

多重Slider协同工作

复杂效果通常需要多个Slider配合:

  • 使用多个Slider节点控制不同方面的参数
  • 例如,一个控制颜色饱和度,一个控制对比度,一个控制亮度
  • 通过合理的节点连接实现复杂的颜色调整效果

高级技巧与最佳实践

掌握Slider节点的高级用法可以显著提升着色器开发效率和质量。

范围优化策略

根据使用场景优化Slider范围:

  • 对于感知线性的参数(如透明度),使用0-1范围
  • 对于指数性感知的参数(如光照强度),考虑使用0-10范围
  • 使用适当的默认值,减少每次材质创建的调整需求

分组与组织

当使用多个Slider时,合理的组织很重要:

  • 在Shader Graph中使用注释框对相关Slider进行分组
  • 为Slider属性使用一致的命名前缀
  • 在材质检视器中利用属性抽屉进行逻辑分组

性能考量

虽然Slider节点本身对性能影响很小,但使用时仍需注意:

  • 避免创建过多不必要的Slider属性
  • 对于不需要在运行时修改的参数,考虑使用常量而非属性
  • 合理使用属性变体,避免不必要的着色器变体生成

调试技巧

Slider节点可以用于着色器调试:

  • 临时连接Slider到不同节点以隔离问题
  • 使用Slider控制调试信息的显示阈值
  • 通过动画Slider值观察效果变化,识别异常行为

常见问题与解决方案

在使用Slider节点过程中可能会遇到一些典型问题,以下是常见问题及其解决方法。

滑动条响应不灵敏

当滑动条范围设置过大时,可能会出现控制不够精细的问题:

  • 解决方案:调整Min和Max值到更合理的范围
  • 替代方案:使用两个Slider,一个用于粗调,一个用于微调

属性不显示在材质检视器

有时转换为属性后,在材质中看不到对应的滑动条:

  • 检查属性是否被意外标记为隐藏
  • 确认着色器编译没有错误
  • 检查属性名称是否包含特殊字符或空格

运行时修改不生效

通过脚本修改Slider属性值但没有效果:

  • 确认使用的是材质属性名称而非节点名称
  • 检查材质实例是否正确引用
  • 确认在修改属性后调用了material.SetFloat方法

数值跳跃或不平滑

滑动条移动时数值变化不连续:

  • 这通常是范围设置过大导致
  • 可以尝试减小范围或使用对数尺度处理

与其他节点的配合使用

Slider节点很少单独使用,更多的是与其他节点配合创建复杂效果。

与数学节点配合

Slider节点与数学节点的组合是最常见的用法:

  • 使用Multiply节点缩放Slider输出
  • 使用Add节点偏移Slider基准值
  • 使用Power节点创建非线性响应
  • 使用Clamp节点限制最终输出范围

与纹理节点结合

通过Slider控制纹理参数:

  • 控制纹理平铺次数
  • 调整纹理混合权重
  • 控制法线强度
  • 调节视差遮挡映射强度

与时间节点协同

创建动态效果:

  • 控制动画速度
  • 调节脉冲频率
  • 管理过渡持续时间
  • 控制效果触发时机

【Unity Shader Graph 使用与特效实现】专栏-直达 (欢迎点赞留言探讨,更多人加入进来能更加完善这个探索的过程,🙏)

以逻辑门角度深入理解promise组合方法

在数字电路设计中,逻辑门是构建复杂系统的基础元件。同样,在现代 JavaScript 异步编程中,Promise 的组合方法(allallSettledanyrace)构成了异步流程控制的基础电路。
本文将深入探讨这些方法的行为模式,通过逻辑门比喻建立直观理解,并帮助你彻底掌握 Promise 的组合方法——甚至推演出新的或者你未发现的异步控制模式。


核心概念定义

我们先建立统一的逻辑系统:

  • Promise 状态映射
    • Fulfilled (1):成功,异步操作完成并返回结果
    • Rejected (0):失败,异步操作抛出错误
  • 输入向量:一个 Promise 数组,每个 Promise 是一个输入信号
  • 输出:一个新的 Promise,其状态由输入向量的状态组合决定

状态常量

const PENDING = "pending";
const FULFILLED = "fulfilled";  
const REJECTED = "rejected";     

1️.Promise.all():逻辑与门(AND Gate)

// 逻辑表达式: OUTPUT = INPUT₁ ∧ INPUT₂ ∧ ... ∧ INPUTₙ
// 所有输入为 1 时,输出为 1;任一输入为 0 时,输出为 0

逻辑门类比

  • AND 门特性:全高电平 → 高电平;任一低电平 → 低电平
  • 对应行为:所有 Promise 成功才成功,任一失败立即失败
  • 典型场景:多个依赖接口必须全部加载成功(如用户资料 + 权限 + 配置)

真值表

Promise₁ Promise₂ Promise.all 解释
1 (成功) 1 (成功) 1 (成功) 全部成功
1 (成功) 0 (失败) 0 (失败) 快速失败
0 (失败) 1 (成功) 0 (失败) 快速失败
0 (失败) 0 (失败) 0 (失败) 快速失败

特性

  1. 快速失败:第一个 reject 立即 reject 整体
  2. 顺序结果:成功时按输入顺序返回值数组
  3. 短路求值:失败后不再等待其他 Promise

实现

Promise.myAll = function (iterable) {
  if (iterable == null || typeof iterable[Symbol.iterator] !== "function") {
    return Promise.reject(new TypeError("Argument is not iterable"));
  }

  const promises = Array.from(iterable);
  if (promises.length === 0) return Promise.resolve([]);

  return new Promise((resolve, reject) => {
    const result = new Array(promises.length);
    let settledCount = 0;

    promises.forEach((item, index) => {
      Promise.resolve(item)
        .then(value => {
          result[index] = value;
          settledCount++;
          if (settledCount === promises.length) resolve(result);
        })
        .catch(reject); // 任一失败,整体失败
    });
  });
};

2. Promise.any():逻辑或门(OR Gate)

// 逻辑表达式: OUTPUT = INPUT₁ ∨ INPUT₂ ∨ ... ∨ INPUTₙ
// 任一输入为 1 时,输出为 1;所有输入为 0 时,输出为 0

逻辑门类比

  • OR 门特性:任一高电平 → 高电平;全低电平 → 低电平
  • 对应行为:任一 Promise 成功即成功,全部失败才失败
  • 典型场景:多 CDN 加载资源,取最快成功的响应

真值表

Promise₁ Promise₂ Promise.any 解释
1 (成功) 1 (成功) 1 (成功) 任一成功即可
1 (成功) 0 (失败) 1 (成功) 任一成功即可
0 (失败) 1 (成功) 1 (成功) 任一成功即可
0 (失败) 0 (失败) 0 (失败) 全部失败

特性

  1. 快速成功:第一个 fulfill 立即 resolve 整体
  2. AggregateError:全部失败时,聚合所有错误
  3. 乐观策略:优先寻找成功路径

实现

Promise.myAny = function (iterable) {
  if (iterable == null || typeof iterable[Symbol.iterator] !== "function") {
    return Promise.reject(new TypeError("Argument is not iterable"));
  }

  const promises = Array.from(iterable);
  if (promises.length === 0) {
    return Promise.reject(
      new AggregateError([], "All promises were rejected")
    );
  }

  return new Promise((resolve, reject) => {
    const errors = new Array(promises.length);
    let settledCount = 0;
    let resolved = false;

    promises.forEach((item, index) => {
      Promise.resolve(item)
        .then(value => {
          if (!resolved) {
            resolved = true;
            resolve(value); // 第一个成功即返回
          }
        })
        .catch(reason => {
          errors[index] = reason;
          settledCount++;
          if (settledCount === promises.length && !resolved) {
            reject(new AggregateError(errors, "All promises were rejected"));
          }
        });
    });
  });
};

3️.Promise.race():选择器 / 锁存器(Selector / Latch)

// 逻辑表达式: OUTPUT = FIRST_SETTLED(INPUT₁, INPUT₂, ..., INPUTₙ)
// 输出等于第一个 settled(fulfilled 或 rejected)的输入

逻辑门类比

  • 选择器特性:捕获第一个变化的信号
  • 对应行为:谁先 settle(无论成功/失败),谁决定结果
  • 典型场景:超时控制、竞态请求、最快响应获取

行为表

第一个 settled 结果 说明
fulfilled 成功 返回其值
rejected 失败 抛出其错误

特性

  1. 赢家通吃:第一个 settle 的 Promise 决定一切
  2. 忽略后续:其余 Promise 被丢弃(但仍在后台运行)
  3. 非确定性:结果取决于执行时序

实现

Promise.myRace = function (iterable) {
  if (iterable == null || typeof iterable[Symbol.iterator] !== "function") {
    return Promise.reject(new TypeError("Argument is not iterable"));
  }

  const promises = Array.from(iterable);
  // 规范:Promise.race([]) 永远 pending
  if (promises.length === 0) {
    return new Promise(() => {}); // 永不 settle
  }

  return new Promise((resolve, reject) => {
    promises.forEach(item => {
      Promise.resolve(item).then(resolve, reject);
    });
  });
};

4️.Promise.allSettled():状态收集器(State Collector)

// 逻辑表达式: OUTPUT = COLLECT(STATUS(INPUT₁), ..., STATUS(INPUTₙ))
// 收集所有 Promise 的最终状态,永不 reject

逻辑门类比

  • 状态寄存器:记录每一位的最终状态,不做逻辑运算
  • 对应行为:等待所有 Promise settle,返回完整状态快照
  • 典型场景:批量任务日志、结果分析、容错汇总

输出

// 成功
{ status: 'fulfilled', value: result }

// 失败
{ status: 'rejected', reason: error } 

特性

  1. 永不失败:返回的 Promise 总是 fulfilled
  2. 完整诊断:每个 Promise 的状态和数据都可追溯
  3. 无短路:必须等所有 Promise settle

规范

Promise.myAllSettled = function (iterable) {
  if (iterable == null || typeof iterable[Symbol.iterator] !== "function") {
    return Promise.reject(new TypeError("Argument is not iterable"));
  }

  const promises = Array.from(iterable);
  if (promises.length === 0) return Promise.resolve([]);

  return new Promise(resolve => {
    const result = new Array(promises.length);
    let completedCount = 0;

    promises.forEach((item, index) => {
      Promise.resolve(item)
        .then(
          value => {
            result[index] = { status: FULFILLED, value };
          },
          reason => {
            result[index] = { status: REJECTED, reason }; 
          }
        )
        .finally(() => {
          completedCount++;
          if (completedCount === promises.length) resolve(result);
        });
    });
  });
};

四方法对比矩阵

方法 逻辑类比 成功条件 失败条件 输出类型 是否短路
Promise.all AND 门 全部成功 任一失败 值数组 ✅(失败)
Promise.any OR 门 任一成功 全部失败 单个值 ✅(成功)
Promise.race 选择器 第一个成功 第一个失败 单个值
Promise.allSettled 状态寄存器 总是成功 永不失败 状态对象数组

高级组合模式(异步“组合电路”)

1. 带超时的 AND 运算

function allWithTimeout(promises, timeoutMs) {
  return Promise.race([
    Promise.all(promises),
    new Promise((_, reject) =>
      setTimeout(() => reject(new Error('Timeout')), timeoutMs)
    )
  ]);
}
// 电路:AND 门 + 超时选择器

2. 多数表决电路(Majority Vote)

function majority(promises) {
  return Promise.allSettled(promises).then(results => {
    const successes = results.filter(r => r.status === 'fulfilled');
    const failures = results.filter(r => r.status === 'rejected');

    if (successes.length > failures.length) {
      return successes.map(s => s.value);
    } else {
      throw new AggregateError(
        failures.map(f => f.reason),
        'Majority of promises rejected'
      );
    }
  });
}
// 电路:状态寄存器 + 比较器 + 选择器

3. 优先级仲裁器(顺序尝试)

async function priorityArbitrator(promiseFactories) {
  for (const factory of promiseFactories) {
    try {
      return await factory();
    } catch {
      continue; // 尝试下一个
    }
  }
  throw new Error('All attempts failed');
}
// 电路:优先级编码器 + OR 门

通过逻辑门视角理解 Promise 组合方法,我们能:

  1. 建立直觉模型:将抽象异步流映射到熟悉电路概念
  2. 预测行为:像分析真值表一样推理复杂异步逻辑
  3. 设计新模式:借鉴电路设计思想构建自定义控制流
  4. 调试优化:识别“短路点”、“竞争条件”、“状态丢失”等问题

Web 多媒体技术栈简述

随着 Web 平台能力的不断增强,浏览器已经可以实现复杂的音视频处理功能。从视频会议、在线直播到音频剪辑、实时特效,这些应用背后依赖着一整套多媒体技术体系。本文从原理层面解析这套体系的工作机制:媒体如何被采集、编码、传输、解码、渲染,以及如何在工程中处理版权保护和性能优化等实际问题。

核心概念与术语

在深入技术细节前,先了解一些多媒体领域的核心概念:

基础术语:

  • 分辨率(Resolution):视频画面的像素尺寸,如 1920×1080(宽 × 高)。简称有两种命名方式:
    • 按垂直像素命名:720p(1280×720)、1080p(1920×1080)、1440p(2560×1440),p 表示逐行扫描。这些通常默认 16:9 比例
    • 按水平像素命名:2K(约 2000 像素宽)、4K(3840×2160,约 4000 像素宽)、8K(7680×4320,约 8000 像素宽)
    • 命名方式不同是历史原因:p 系列(720p/1080p)源自电视广播标准,K 系列(2K/4K)源自数字电影标准。实际使用中 1080p 和 2K 接近,4K 也叫 2160p
  • 帧率(fps):每秒显示的画面数量。常见值有 24fps(电影)、30fps(标准视频)、60fps(高流畅度)
  • 码率(Bitrate):视频每秒传输的数据量,单位通常为 Kbps/Mbps。码率由分辨率、帧率、画面复杂度决定:分辨率越高像素越多,帧率越高传输帧数越多,都需要更高码率。例如 1080p 30fps 视频通常需要 5-10 Mbps
  • 编解码器(Codec):压缩(编码)和解压(解码)音视频数据的算法,如 H.264、AAC

视频相关:

  • YUV/RGB:颜色空间格式。RGB 每个像素用红绿蓝三色表示,YUV 分离亮度(Y)和色度(UV),更适合压缩
  • I/P/B 帧:视频编码的三种帧类型
    • I 帧(关键帧):完整画面,解码不依赖其他帧
    • P 帧(预测帧):存储与前一帧的差异
    • B 帧(双向帧):参考前后帧,压缩率最高
  • PTS/DTS:视频帧的时间戳数值,标记该帧在时间轴上的位置
    • PTS(Presentation Time Stamp):该帧应该在视频的第几秒显示。例如 PTS=3000 表示视频播放到第 3 秒时显示这一帧
    • DTS(Decode Time Stamp):该帧应该在第几秒开始解码
    • 为什么需要两个时间戳:B 帧依赖前后帧,解码顺序(DTS)和显示顺序(PTS)不同。例如显示顺序是 I-B-P,解码顺序必须是 I-P-B(先解码 P 帧,B 帧才能参考)

音频相关:

  • PCM(Pulse Code Modulation):脉冲编码调制,音频的原始数字格式。麦克风采集的模拟声波转换为数字数据的过程和结果,类似视频中的 YUV 原始像素。PCM 格式由两个参数描述:
    • 采样率(Sample Rate):每秒采样次数。44.1kHz 表示每秒采样 44100 次(CD 标准),48kHz 是专业音频标准
    • 采样深度(Bit Depth):每个采样点的精度。16bit 可表示 65536 个音量级别,24bit 可表示 1677 万个级别
    • 例如 CD 音质 PCM 是 44.1kHz/16bit 立体声,每秒数据量约 176KB(44100 × 16bit × 2 声道 ÷ 8)

流媒体相关:

  • HLS/DASH:HTTP 自适应流协议,将视频切分成小片段通过 HTTP 传输
  • ABR(Adaptive Bitrate):自适应码率,根据网络状况动态切换不同码率档位
  • MSE(Media Source Extensions):允许 JavaScript 控制视频流的播放
  • CDN(Content Delivery Network):内容分发网络,加速视频传输

实时通信相关:

  • WebRTC:Web 实时通信技术,支持浏览器间点对点音视频传输
  • ICE/STUN/TURN:WebRTC 连接建立相关协议
  • 延迟(Latency):从发送端到接收端的时间差,实时通信要求低延迟(<200ms)

多媒体处理全流程

理解 Web 多媒体技术,需要先了解多媒体数据从采集到播放的完整流程。这个流程涉及多个关键环节,每个环节都有对应的浏览器 API 支持。

核心流程

采集 → 编码 → 封装 → 传输 → 解封装 → 解码 → 渲染

1. 采集(Capture)

从物理设备获取原始音视频数据:

  • 视频采集:摄像头输出 YUV/RGB 原始像素数据。每个像素包含颜色信息(RGB 各占 1 字节),1080p(1920×1080)一帧约 6MB,30fps 视频流达到约 180MB/秒
  • 音频采集:麦克风输出 PCM(Pulse Code Modulation,脉冲编码调制)原始音频数据。采样率通常为 48kHz(每秒采样 48000 次),16bit 采样深度,立体声约 192KB/秒

2. 编码(Encoding)

原始数据体积巨大,必须压缩才能传输和存储:

  • 视频编码:H.264/H.265/VP9/AV1 等编码器将原始像素压缩为码流,通过 I/P/B 帧组合实现压缩比可达 100:1
  • 音频编码:AAC/Opus/MP3 将 PCM 压缩为码流,通过去除人耳不敏感频率实现压缩比约 10:1

3. 封装(Muxing)

将编码后的音视频流、字幕、元数据等打包到容器格式:

  • MP4:最通用的容器格式,包含 ftyp/moov/mdat 等 Box 结构
  • WebM:开源容器,基于 Matroska
  • FLV:Flash Video 容器,逐渐被淘汰

容器负责将多个流(音频、视频、字幕)交织存储,并记录时间戳(PTS/DTS)用于同步。

4. 传输(Transmission)

通过网络协议传输媒体数据:

  • HTTP + 自适应流协议
    • HLS (HTTP Live Streaming):Apple 提出,将视频切分成 TS 片段(通常 6-10 秒),通过 m3u8 索引文件描述片段列表。客户端根据网络状况选择不同码率的片段
    • DASH (Dynamic Adaptive Streaming over HTTP):国际标准,使用 MPD(Media Presentation Description)描述片段,支持 MP4/WebM 容器
    • 自适应原理:同一视频准备多个码率版本(如 480p/720p/1080p),客户端监测带宽动态切换
  • RTP/RTCP:WebRTC 实时传输协议,基于 UDP 传输,容忍丢包换取低延迟
  • WebSocket:信令通道和自定义传输

5. 解封装(Demuxing)

从容器格式中分离音视频流:

  • 解析容器结构,提取音频流、视频流、字幕流
  • 获取每个流的编码参数(codec、分辨率、码率等)

6. 解码(Decoding)

将压缩的码流还原为原始数据:

  • 硬件解码:调用 GPU 解码单元(如 NVDEC、VideoToolbox、MediaCodec),功耗低、性能高
  • 软件解码:使用 CPU 解码,兼容性好但性能受限

7. 渲染(Rendering)

将解码后的数据输出到显示设备:

  • 视频渲染:YUV → RGB 颜色空间转换 → 输出到 Canvas/WebGL
  • 音频渲染:PCM 数据 → 音频驱动 → 扬声器
  • 音视频同步(A/V Sync):根据 PTS(Presentation Time Stamp)时间戳对齐音视频帧

典型场景流程

场景 1:本地视频播放

网络请求 → 下载 MP4 → 浏览器解封装 → 解码 → 渲染
(HTMLMediaElement 自动完成整个流程)

场景 2:直播推流

getUserMedia 采集 → MediaRecorder 编码 → WebSocket 传输 → 服务端转发

场景 3:自适应流播放(HLS/DASH)

MSE 请求片段 → 分段下载 → JavaScript 控制追加数据 → 浏览器解码渲染
(根据带宽动态切换码率)

场景 4:WebRTC 视频通话

发送端:getUserMedia 采集 → 编码 → RTP 打包 → UDP 传输
接收端:接收 RTP → 解包 → 解码 → 渲染
(端到端低延迟,无需服务端转码)

媒体捕获与输入技术

媒体捕获与输入技术是 Web 多媒体应用的起点,负责获取用户设备的音视频输入。通过这些 API,浏览器可以访问摄像头、麦克风、屏幕等媒体源,为视频会议、直播推流、在线录制等应用提供基础能力。

API 主要功能 输入源 输出 典型用途
getUserMedia 访问音视频设备 摄像头、麦克风 MediaStream 视频通话、直播采集
getDisplayMedia 捕获屏幕内容 屏幕、窗口、标签页 MediaStream 屏幕共享、录屏
MediaRecorder 录制媒体流 MediaStream Blob(视频文件) 录制保存、上传
ImageCapture 拍照 VideoTrack Blob/ImageBitmap 高质量截图、证件照
MediaStream 流对象管理 - 轨道操作接口 轨道的添加、移除

MediaDevices.getUserMedia - 采集摄像头与麦克风

getUserMedia 是 MediaDevices API 的核心方法,用于请求访问用户的摄像头和麦克风设备。

基础用法:

navigator.mediaDevices
  .getUserMedia({ video: true, audio: true })
  .then((stream) => {
    const video = document.querySelector("video");
    video.srcObject = stream; // 将媒体流绑定到 video 元素
  })
  .catch((error) => {
    console.error("无法访问媒体设备:", error);
  });

核心能力:

  1. 设备访问控制 - 请求摄像头、麦克风权限,浏览器会弹出授权提示
  2. 约束参数(Constraints) - 指定分辨率、帧率、设备 ID 等参数
    • 视频约束:widthheightframeRatefacingMode
    • 音频约束:echoCancellation(回声消除)、noiseSuppression(噪声抑制)、autoGainControl(自动增益)
  3. 多设备支持 - 通过 facingMode 选择前置或后置摄像头
  4. 返回 MediaStream - 获取包含音视频轨道的流对象,可用于播放、录制或传输

MediaDevices.getDisplayMedia - 捕获屏幕内容

getDisplayMedia 用于捕获用户屏幕、窗口或标签页的内容,是实现屏幕共享功能的核心 API。

基础用法:

navigator.mediaDevices
  .getDisplayMedia({ video: true, audio: true })
  .then((stream) => {
    const video = document.querySelector("video");
    video.srcObject = stream; // 显示捕获的屏幕内容
  })
  .catch((error) => {
    console.error("无法捕获屏幕:", error);
  });

核心能力:

  1. 捕获源选择 - 浏览器弹出选择器,用户可选择整个屏幕、特定窗口或浏览器标签页
  2. 视频捕获 - 以视频流形式获取屏幕内容,支持设置分辨率和帧率
  3. 音频捕获 - 可同时捕获系统音频或标签页音频(浏览器支持有限)
  4. 光标捕获控制 - 通过 cursor 参数控制是否显示鼠标光标
  5. 返回 MediaStream - 与 getUserMedia 相同的流对象,可用于录制或 WebRTC 传输

MediaRecorder - 录制音视频流

MediaRecorder 用于将 MediaStream 录制为音视频文件,支持实时录制和保存。

基础用法:

const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
const recorder = new MediaRecorder(stream);

const chunks = [];
recorder.ondataavailable = (e) => {
  chunks.push(e.data); // 收集录制的数据块
};

recorder.onstop = () => {
  const blob = new Blob(chunks, { type: "video/webm" }); // 生成视频文件
  const url = URL.createObjectURL(blob);
};

recorder.start(); // 开始录制
// recorder.stop();  // 停止录制

核心能力:

  1. 录制 MediaStream - 将音视频流录制为 Blob 数据
  2. 编码格式支持 - 支持 WebM、MP4 等容器格式,编码器为 VP8/VP9/H.264
  3. 实时数据输出 - 通过 dataavailable 事件分段输出数据,支持流式保存
  4. 录制控制 - 提供 start()stop()pause()resume() 方法
  5. 码率控制 - 可设置 videoBitsPerSecondaudioBitsPerSecond 控制录制质量

ImageCapture - 拍照与图像捕获

ImageCapture 用于从视频流中捕获高质量的静态图像,提供比 Canvas 截图更精确的控制。

基础用法:

const stream = await navigator.mediaDevices.getUserMedia({ video: true });
const track = stream.getVideoTracks()[0];
const imageCapture = new ImageCapture(track);

// 拍照
const blob = await imageCapture.takePhoto();
const img = document.querySelector("img");
img.src = URL.createObjectURL(blob);

// 获取当前帧
const bitmap = await imageCapture.grabFrame();
// 在 Canvas 中渲染 ImageBitmap

核心能力:

  1. 高质量拍照 - takePhoto() 使用设备的最高分辨率和图像处理能力
  2. 实时帧捕获 - grabFrame() 快速获取当前视频帧的 ImageBitmap 对象
  3. 摄像头参数控制 - 可调整焦距、曝光、白平衡等摄像头参数(取决于硬件支持)
  4. 能力查询 - 通过 getPhotoCapabilities() 获取设备支持的拍照参数范围

MediaStream - 媒体流管理

MediaStream 是表示音视频流的核心对象,包含一个或多个媒体轨道(MediaStreamTrack)。

基础用法:

const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });

// 获取轨道
const videoTrack = stream.getVideoTracks()[0];
const audioTrack = stream.getAudioTracks()[0];

// 停止特定轨道
videoTrack.stop();

// 添加/移除轨道
stream.addTrack(newTrack);
stream.removeTrack(audioTrack);

核心能力:

  1. 轨道管理 - 包含多个 MediaStreamTrack(音频轨、视频轨),可独立操作
  2. 轨道控制 - 启用/禁用轨道(track.enabled),停止轨道(track.stop())
  3. 约束调整 - 运行时通过 applyConstraints() 修改分辨率、帧率等参数
  4. 克隆流 - clone() 创建独立的流副本,用于不同的处理管道
  5. 事件监听 - 监听轨道添加(addtrack)、移除(removetrack)等事件

MediaStreamTrack 关键属性:

  • kind - 轨道类型('audio''video')
  • label - 设备名称
  • enabled - 是否启用(mute/unmute)
  • readyState - 轨道状态('live''ended')

Blob 与 File - 媒体数据封装

Blob(Binary Large Object)是浏览器中表示二进制数据的对象,File 是 Blob 的子类。媒体捕获和录制的输出通常是 Blob 对象。

原理:

Blob 对象是对二进制数据的引用,不直接将数据加载到内存,而是按需读取。URL.createObjectURL() 为 Blob 创建临时 URL,可直接用于 <video> 播放或下载。

基础用法:

// MediaRecorder 输出 Blob
const recorder = new MediaRecorder(stream);
const chunks = [];
recorder.ondataavailable = (e) => chunks.push(e.data);
recorder.onstop = () => {
  const blob = new Blob(chunks, { type: "video/webm" });

  // 直接播放
  video.src = URL.createObjectURL(blob);

  // 下载保存
  const a = document.createElement("a");
  a.href = URL.createObjectURL(blob);
  a.download = "recording.webm";
  a.click();

  // 释放 URL
  URL.revokeObjectURL(blob);
};

// 读取本地视频文件
const input = document.querySelector('input[type="file"]');
input.addEventListener("change", (e) => {
  const file = e.target.files[0]; // File 是 Blob 子类
  video.src = URL.createObjectURL(file);
});

典型场景:

  • 录制保存:MediaRecorder 生成 Blob,创建下载链接
  • 本地预览:用户选择文件后即时预览,无需上传服务器
  • 分片上传:blob.slice() 切分大文件,实现断点续传

媒体播放与控制技术

媒体播放与控制技术是 Web 多媒体的核心能力,从基础的 HTML5 Video/Audio 到高级的流媒体播放、加密内容保护,为各类音视频应用提供完整的播放解决方案。

技术 主要功能 使用场景 浏览器支持
HTMLMediaElement 基础播放控制 简单音视频播放 全平台支持
MSE 流媒体播放 自适应码率、直播 现代浏览器
EME 加密内容播放 DRM 保护内容 现代浏览器
Picture-in-Picture 画中画模式 悬浮播放 主流浏览器
MediaCapabilities 能力检测 编解码能力查询 现代浏览器

HTMLMediaElement - 基础播放 API

HTMLMediaElement 封装了浏览器内置的媒体解码器和渲染器,将底层的解复用、解码、音视频同步等复杂操作抽象为简单的 DOM API。

原理:

浏览器内部完成以下流程:

  1. 网络请求 - 通过 HTTP 请求获取媒体文件
  2. 解复用(Demuxing) - 从容器格式(MP4/WebM)中分离音频流、视频流
  3. 解码(Decoding) - 使用硬件或软件解码器解码音视频数据
  4. 音视频同步(A/V Sync) - 根据 PTS(Presentation Time Stamp)同步音视频帧
  5. 渲染 - 视频帧渲染到 Canvas/GPU,音频输出到扬声器

基础用法:

<video src="video.mp4" controls></video>
const video = document.querySelector("video");
video.play(); // 触发解码和渲染管线
video.currentTime = 10; // Seek 操作:定位到关键帧,重新解码

核心能力:

  1. 统一接口 - 屏蔽不同平台(Windows/macOS/Linux)的解码器差异
  2. 自动同步 - 内部维护音视频时间戳对齐,保证同步播放
  3. 缓冲管理 - 预加载一定时长的数据,平衡加载速度和内存占用
  4. Seek 优化 - 定位到最近的关键帧(I-frame),避免解码整个 GOP

Media Source Extensions - 流媒体播放

MSE 将媒体数据的"获取"和"解码"分离,让 JavaScript 控制向解码器输送数据的时机和内容,打破了 <video> 只能播放完整文件的限制。

原理:

传统 <video src="url"> 模式下,浏览器负责整个流程:下载 → 解复用 → 解码。MSE 改变了这个流程:

  1. JavaScript 控制数据流 - 通过 SourceBuffer.appendBuffer() 手动向解码器输送数据
  2. 分段传输 - 视频被切分成小片段(通常 2-10 秒),按需获取
  3. ABR(Adaptive Bitrate)实现 - JavaScript 根据网络带宽选择不同码率的片段
  4. 时间轴拼接 - 多个片段在时间轴上无缝连接,用户感知为连续播放

基础用法:

const mediaSource = new MediaSource();
video.src = URL.createObjectURL(mediaSource);

mediaSource.addEventListener("sourceopen", () => {
  const sourceBuffer = mediaSource.addSourceBuffer('video/mp4; codecs="avc1.42E01E"');

  // JavaScript 主动送入数据
  sourceBuffer.appendBuffer(segmentData);
});

核心能力:

  1. 解耦数据获取与播放 - JavaScript 决定获取哪个片段、何时获取
  2. 无缝码率切换 - 在片段边界切换不同清晰度,不中断播放
  3. 缓冲区精确控制 - 通过 remove() 清理过期数据,节省内存
  4. 直播支持 - 持续 append 新数据实现无限时长直播

Encrypted Media Extensions - 加密内容播放

EME 在浏览器和 CDM(Content Decryption Module)之间建立通信通道,让 Web 应用可以播放加密内容,同时保证解密密钥对 JavaScript 不可见。

原理:

加密视频播放流程:

  1. 检测加密数据 - 浏览器解析媒体文件,发现 PSSH(Protection System Specific Header),触发 encrypted 事件
  2. 选择 DRM 系统 - JavaScript 请求对应的 CDM(如 Widevine)
  3. 许可证交换 - CDM 生成许可证请求 → 发送到许可证服务器 → 获取解密密钥
  4. 解密播放 - CDM 在安全环境中解密数据,解密后的数据直接送入解码器,JavaScript 无法访问

安全隔离:

  • 解密密钥存储在 TEE(Trusted Execution Environment)或硬件安全模块中
  • 解密过程对 JavaScript 和操作系统透明,防止密钥泄露

完整用法示例:

const video = document.querySelector('video');

// 1. 配置 DRM 系统支持的能力
const config = [{
  initDataTypes: ['cenc'],  // 加密数据格式(Common Encryption)
  videoCapabilities: [{
    contentType: 'video/mp4; codecs="avc1.42E01E"',
    robustness: 'SW_SECURE_CRYPTO' // 软件级加密(还有 HW_SECURE_ALL 硬件级)
  }],
  audioCapabilities: [{
    contentType: 'audio/mp4; codecs="mp4a.40.2"',
    robustness: 'SW_SECURE_CRYPTO'
  }]
}];

// 2. 加载加密视频
video.src = 'https://example.com/encrypted-video.mp4';

// 3. 监听加密事件 - 视频解析时发现 PSSH 加密头触发
video.addEventListener('encrypted', async (event) => {
  console.log('检测到加密视频');

  // event.initDataType: 'cenc' - 加密格式类型
  // event.initData: ArrayBuffer - 包含视频 ID、加密方式等元信息(不是密钥!)

  try {
    // 4. 请求浏览器的 DRM 系统访问权限
    const keySystemAccess = await navigator.requestMediaKeySystemAccess(
      'com.widevine.alpha',  // 指定使用 Widevine DRM
      config
    );
    console.log('浏览器支持 Widevine');

    // 5. 创建 MediaKeys 对象 - Widevine CDM 实例
    const mediaKeys = await keySystemAccess.createMediaKeys();

    // 6. 将 CDM 绑定到 video 元素
    await video.setMediaKeys(mediaKeys);
    console.log('CDM 已绑定到视频');

    // 7. 创建密钥会话 - 用于管理这个视频的密钥
    const keySession = mediaKeys.createSession();

    // 8. 监听会话消息 - CDM 生成许可证请求后触发
    keySession.addEventListener('message', async (messageEvent) => {
      console.log('CDM 生成了许可证请求');

      // messageEvent.message: ArrayBuffer - CDM 生成的许可证请求数据
      // 这是一段加密的数据,包含设备信息、视频 ID 等,用于向服务器证明身份

      // 9. 向许可证服务器请求密钥
      const response = await fetch('https://license-server.example.com/widevine', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/octet-stream',
          'Authorization': 'Bearer user-token-xyz'  // 用户身份验证
        },
        body: messageEvent.message  // 发送 CDM 生成的请求
      });

      // 服务器做了什么:
      // - 验证用户是否付费订阅
      // - 验证设备是否安全(是否越狱、是否支持 HDCP)
      // - 检查地区限制、并发播放数限制
      // - 生成临时密钥(通常有时效性,1-8 小时后失效)
      // - 用设备公钥加密密钥,返回许可证(License)

      const license = await response.arrayBuffer();
      console.log('收到许可证,大小:', license.byteLength, '字节');

      // 10. 将许可证加载到 CDM
      await keySession.update(license);
      console.log('密钥已加载到 Widevine CDM');

      // 此时发生了什么:
      // - CDM 用设备私钥解密许可证,提取出解密密钥
      // - 密钥存储在 TEE(可信执行环境)或硬件安全模块中
      // - JavaScript 永远无法访问这个密钥
      // - 视频数据在 CDM 内部解密,解密后直接送入视频解码器
      // - 即使用调试器也无法拦截解密后的数据

      console.log('视频开始播放');
    });

    // 11. 生成许可证请求
    await keySession.generateRequest(
      event.initDataType,  // 'cenc'
      event.initData       // 视频的加密元信息
    );
    // 这会触发 CDM 生成许可证请求,然后触发上面的 'message' 事件

  } catch (error) {
    console.error('DRM 初始化失败:', error);
    // 可能的错误:
    // - 浏览器不支持 Widevine
    // - 设备安全级别不足
    // - 许可证服务器拒绝请求(未付费、地区限制等)
  }
});

// 监听密钥状态变化
video.addEventListener('waitingforkey', () => {
  console.log('等待密钥...');
});

关键步骤解析:

步骤 代码 作用 数据流向
1 requestMediaKeySystemAccess 检查浏览器是否支持 Widevine 浏览器 → JS
2 createMediaKeys 创建 Widevine CDM 实例 浏览器内部
3 setMediaKeys 将 CDM 绑定到 video 元素 JS → video 元素
4 createSession 创建密钥会话管理对象 JS → CDM
5 generateRequest CDM 生成许可证请求 CDM 内部
6 message 事件 CDM 将请求数据传给 JS CDM → JS
7 fetch 许可证服务器 发送请求到服务器获取密钥 JS → 服务器 → JS
8 session.update(license) 将许可证加载到 CDM JS → CDM
9 CDM 解密许可证 提取密钥存入安全区域 CDM 内部(JS 不可见)
10 CDM 解密视频 用密钥解密视频数据 CDM 内部(JS 不可见)

核心能力:

  1. 密钥隔离 - 解密密钥对 Web 层不可见,防止盗版
  2. 硬件加速解密 - 使用 TEE/Secure Path 实现硬件级保护
  3. 灵活的 DRM 方案 - 支持 Widevine(Chrome/Android)、FairPlay(Safari/iOS)、PlayReady(Edge)
  4. 离线播放 - 持久化许可证支持下载后离线观看

Picture-in-Picture - 画中画模式

Picture-in-Picture 将视频渲染管线从网页的渲染层分离出来,创建独立的系统级悬浮窗口,实现视频与页面内容的解耦。

原理:

  1. 渲染分离 - 视频帧不再渲染到网页的 Canvas 层,而是输出到操作系统提供的独立窗口
  2. 系统集成 - 浏览器调用操作系统的窗口管理 API(macOS 的 PiP、Windows 的 Compact Overlay)
  3. Z-index 最高 - 悬浮窗始终处于所有窗口之上,包括全屏应用
  4. 独立生命周期 - 关闭网页标签,画中画窗口可继续播放

基础用法:

video.requestPictureInPicture().then((pipWindow) => {
  // 视频已转移到系统窗口
});

核心能力:

  1. 系统级悬浮 - 视频悬浮在所有应用之上,不受浏览器窗口限制
  2. 跨标签页持久化 - 切换标签页、最小化浏览器,视频继续播放
  3. 窗口尺寸控制 - 用户可拖拽调整大小,JavaScript 可读取窗口尺寸
  4. 自定义控制按钮 - 通过 Media Session API 在画中画窗口添加操作按钮

媒体能力检测与自动播放策略

MediaCapabilities API - 能力检测

查询浏览器对特定编解码格式的支持和性能信息。

// 检测解码能力
navigator.mediaCapabilities
  .decodingInfo({
    type: "file", // 'file' 或 'media-source'
    video: {
      contentType: 'video/mp4; codecs="avc1.42E01E"',
      width: 1920,
      height: 1080,
      bitrate: 5000000,
      framerate: 30,
    },
  })
  .then((result) => {
    console.log("支持:", result.supported);
    console.log("流畅:", result.smooth);
    console.log("省电:", result.powerEfficient);
  });

canPlayType - 基础格式检测

const video = document.createElement("video");
const canPlay = video.canPlayType('video/mp4; codecs="avc1.42E01E"');
// 返回 'probably' | 'maybe' | ''

自动播放策略

现代浏览器限制自动播放以改善用户体验,必须满足以下条件之一:

  1. 用户与页面有过交互
  2. 视频静音播放
  3. 用户在该站点有媒体播放历史
// 静音自动播放
video.muted = true;
video.play().catch((error) => {
  console.log("自动播放失败,需要用户交互");
});

流媒体传输技术

流媒体传输技术解决音视频内容如何通过网络高效传输和播放的问题。不同于下载完整文件后播放,流媒体采用边传输边播放的方式,在延迟、流畅性、传输成本之间取得平衡。

媒体流传输协议核心要解决四个问题:

  1. 流式传输 vs 完整文件 - 将视频分段或持续推送数据,边下载边播放
  2. 实时性与缓冲 - 在延迟和流畅性之间平衡,直播需要低延迟,但网络抖动需要缓冲区平滑
  3. 自适应码率(ABR) - 网络带宽波动时动态切换不同码率的视频流,保证不卡顿
  4. 传输层选择 - TCP 可靠但有队头阻塞,UDP 低延迟但可能丢包,不同场景选择不同传输层

主流媒体流传输协议:

协议 传输方式 延迟 适用场景
HLS/DASH HTTP 自适应流 6-30 秒 点播、直播(可接受延迟)
FLV HTTP 流式 3-10 秒 低延迟直播
RTMP/RTSP TCP 流式 1-3 秒 服务端推流
SRT UDP 可靠传输 <1 秒 专业直播传输

底层传输通道(可与上述协议组合使用):

通道 特性 适用场景
WebSocket 双向通信、持久连接 信令交换、实时消息
SSE 服务端推送、自动重连 通知推送、实时更新
WebTransport QUIC 低延迟、多路复用 低延迟流媒体传输
WebRTC P2P 直连、端到端加密 实时音视频通话

HLS/DASH - HTTP 自适应流

HLS/DASH 是流媒体直播和点播的主流方案,通过 HTTP 分段传输实现准实时播放。(HLS 规范 | DASH 规范)

原理:

传统方式下,视频是一个完整的大文件,必须完整下载或使用 RTMP 等专用流协议。HLS/DASH 的核心思想是"化整为零":

  1. 服务端处理流程:

    • 切片(Segmentation) - 编码器将连续的视频流按时间切分成独立的小文件
      • HLS:生成 .ts 文件(MPEG-TS 容器),每段 2-10 秒
      • DASH:生成 .m4s 文件(fMP4 容器),每段 2-10 秒
    • 多码率转码 - 同一内容生成多个码率版本(如 360p/720p/1080p)
    • 索引文件生成 - 创建描述片段列表的清单文件
      • HLS:.m3u8 文件,文本格式,列出所有 .ts 片段的 URL 和时长
      • DASH:.mpd 文件(XML),描述不同码率的片段位置
  2. 客户端播放流程:

    • 下载索引 - 请求 m3u8/mpd 文件,解析片段列表
    • 带宽检测 - 测量当前网络速度
    • 片段选择 - 根据带宽选择合适码率的片段 URL
    • 下载与播放 - 下载片段 → 通过 MSE 送入解码器 → 播放
    • 循环更新 - 定期请求索引文件获取新片段(直播场景)
  3. 自适应切换机制:

    • 客户端持续监测下载速度和缓冲区状态
    • 网速下降:切换到低码率片段,避免卡顿
    • 网速提升:切换到高码率片段,提升画质
    • 切换发生在片段边界,用户无感知

延迟来源:

  • 切片时长(6-10 秒) - 必须等待完整片段生成
  • 服务端缓冲(2-3 个片段) - 保证切换的平滑性
  • 客户端播放缓冲(1-2 个片段) - 抵抗网络抖动
  • 总延迟:15-30 秒(标准 HLS),3-5 秒(LL-HLS 低延迟模式)

基础用法:

// HLS 播放(使用 hls.js)
import Hls from "hls.js";
const hls = new Hls();
hls.loadSource("https://example.com/live.m3u8");
hls.attachMedia(video);

核心能力:

  1. 无需专用协议 - 复用 HTTP,穿透防火墙,利用现有 CDN
  2. 大规模分发 - 片段为静态文件,CDN 缓存命中率高
  3. 自适应码率 - 网络波动时动态调整,保证流畅播放
  4. 无状态 - 服务端无需维护连接状态,易于横向扩展

FLV - 低延迟直播流

FLV(Flash Video)是 Adobe 设计的轻量级容器格式,通过 HTTP 流式传输实现低延迟直播。

原理:

不同于 HLS/DASH 的切片模式,FLV 采用连续流式传输:

  1. 流式封装 - FLV 容器结构简单,由 FLV Header + Tag 序列组成
    • 每个 Tag 包含一个视频帧、音频帧或脚本数据
    • Tag 之间独立,可以逐个解析,无需等待完整文件
  2. HTTP 长连接推送 - 服务端通过 HTTP 长连接持续推送 FLV Tag
    • 使用 Transfer-Encoding: chunked 分块传输
    • 客户端边接收边解析,实时送入解码器
  3. 无需切片 - 数据连续推送,避免了 HLS 等待片段生成的延迟
  4. 浏览器播放 - Flash 已淘汰,现代浏览器通过 flv.js 解析 FLV 并用 MSE 播放

延迟来源:

  • 编码延迟(1-2 秒) - 视频采集、编码
  • 网络传输(0.5-1 秒) - 推流到服务器、CDN 分发
  • 播放缓冲(1-2 秒) - 客户端缓冲区
  • 总延迟:3-10 秒

基础用法:

import flvjs from "flv.js";
const player = flvjs.createPlayer({
  type: "flv",
  url: "http://example.com/live.flv",
});
player.attachMediaElement(video);
player.load();
player.play();

核心能力:

  1. 低延迟 - 无需切片,连续推送,延迟低于 HLS
  2. 简单高效 - 容器格式简单,解析开销小
  3. HTTP 传输 - 复用 HTTP 协议,穿透防火墙
  4. 逐帧解析 - Tag 独立封装,支持实时流式解析

RTMP/RTSP - 服务端推流协议

RTMP(Real-Time Messaging Protocol)和 RTSP(Real-Time Streaming Protocol)是传统的流媒体协议,主要用于服务端推流。

原理:

RTMP:

  1. 握手协商 - 客户端和服务端建立 TCP 连接,握手协商版本和参数
  2. 消息分块(Chunk) - 将音视频数据分割为固定大小的 Chunk,交织传输
    • 音频、视频、元数据共用一个 TCP 连接
    • 使用 Chunk Stream ID 区分不同类型的数据
  3. 时间戳同步 - 每个 Chunk 携带时间戳,接收端根据时间戳同步音视频
  4. 低延迟传输 - TCP 保证可靠性,数据实时推送,延迟 1-3 秒

RTSP:

  1. 会话控制 - RTSP 类似 HTTP,使用文本命令控制流媒体会话(SETUP、PLAY、PAUSE、TEARDOWN)
  2. 媒体传输 - RTSP 本身不传输媒体数据,媒体通过 RTP/RTCP 传输
    • RTP(Real-time Transport Protocol):传输音视频数据包
    • RTCP(RTP Control Protocol):监控传输质量
  3. 分离控制和数据 - RTSP 控制通道(TCP)和 RTP 数据通道(UDP)分离

浏览器限制:

  • 浏览器原生不支持 RTMP/RTSP
  • 需要服务端转换为 HLS/FLV/WebRTC 后才能在 Web 播放
  • 主要用于推流端(如 OBS 推流到服务器)

使用场景:

# OBS 推流到 RTMP 服务器
rtmp://live.example.com/app/stream_key

# 服务端将 RTMP 转换为 HLS 供浏览器播放
ffmpeg -i rtmp://input -f hls output.m3u8

核心能力:

  1. 低延迟推流 - 实时传输,延迟 1-3 秒
  2. 可靠传输 - 基于 TCP,保证数据完整性
  3. 广泛支持 - 推流软件(OBS、FFmpeg)、流媒体服务器(Nginx-RTMP)广泛支持
  4. 成熟稳定 - 协议成熟,生态完善

SRT - 安全可靠传输

SRT(Secure Reliable Transport)是基于 UDP 的新一代流媒体传输协议,为专业直播场景设计。

原理:

传统 TCP 协议在弱网环境下性能差(队头阻塞、丢包重传导致延迟),纯 UDP 又不可靠。SRT 在 UDP 基础上实现了可靠传输机制:

  1. 基于 UDP - 避免 TCP 的队头阻塞问题
  2. ARQ 自动重传 - 检测到丢包后,选择性重传丢失的数据包
    • 接收端发送 NAK(Negative Acknowledgment)通知丢包
    • 发送端重传丢失的包,而不是整个流
  3. 前向纠错(FEC) - 可选的 FEC 机制,发送冗余数据用于纠错
    • 轻微丢包可通过 FEC 直接恢复,无需重传
  4. 动态缓冲 - 根据网络状况动态调整缓冲区大小
    • 平衡延迟和抗丢包能力
  5. AES 加密 - 内置端到端加密,保护传输内容安全
  6. 带宽自适应 - 检测网络拥塞,动态调整发送速率

延迟特性:

  • 可配置延迟(通常 200ms-2s)
  • 延迟越高,抗丢包能力越强
  • 适合专业直播场景(演唱会、体育赛事转播)

浏览器限制:

  • 浏览器不直接支持 SRT
  • 需要服务端接收 SRT 流,转换为 HLS/WebRTC 供浏览器播放

使用场景:

# FFmpeg 使用 SRT 推流
ffmpeg -i input.mp4 -f mpegts "srt://server:port?streamid=live/stream"

# 服务端接收 SRT,转发为 HLS
srt-live-transmit srt://:9000 http://localhost:8080/live.m3u8

核心能力:

  1. 抗丢包 - ARQ + FEC 机制,弱网环境下保持稳定传输
  2. 低延迟 - 基于 UDP,避免 TCP 队头阻塞,延迟 <1 秒
  3. 安全传输 - AES 加密,保护内容安全
  4. 穿透 NAT - 内置打洞机制,简化部署
  5. 开源协议 - 社区活跃,工具链完善

流媒体缓存技术

流媒体播放中,缓存技术用于实现离线播放、减少重复请求、降低带宽成本。

IndexedDB - 离线视频存储

IndexedDB 可以存储大容量 Blob 数据,实现视频离线播放。

// 存储视频片段
const request = indexedDB.open("VideoCache", 1);
request.onupgradeneeded = (e) => {
  const db = e.target.result;
  db.createObjectStore("videos", { keyPath: "id" });
};

request.onsuccess = async (e) => {
  const db = e.target.result;
  const tx = db.transaction("videos", "readwrite");
  const store = tx.objectStore("videos");

  // 下载并缓存视频
  const response = await fetch("video.mp4");
  const blob = await response.blob();
  await store.put({ id: "video123", blob, timestamp: Date.now() });

  // 离线播放
  const cachedVideo = await store.get("video123");
  video.src = URL.createObjectURL(cachedVideo.blob);
};

Cache API - HLS 片段缓存

Cache API 配合 Service Worker 缓存 HLS 片段,实现流媒体离线播放。

// Service Worker 中缓存 HLS 片段
self.addEventListener("fetch", (e) => {
  if (e.request.url.includes(".ts") || e.request.url.includes(".m3u8")) {
    e.respondWith(
      caches.match(e.request).then((response) => {
        if (response) return response; // 返回缓存

        return fetch(e.request).then((response) => {
          const cloned = response.clone();
          caches.open("hls-cache").then((cache) => {
            cache.put(e.request, cloned); // 缓存片段
          });
          return response;
        });
      })
    );
  }
});

典型场景:

  • PWA 离线播放:预缓存视频资源,用户离线时仍可观看
  • HLS 片段优化:缓存已下载的 .ts 片段,用户 seek 时无需重复请求
  • 直播回看:缓存直播片段到 IndexedDB,用户可回看最近内容

媒体处理与编辑技术

媒体处理与编辑技术提供对音视频数据的像素级、采样级操作能力,从音频分析、视频特效、到自定义编解码,实现复杂的多媒体处理需求。

技术 处理对象 性能 适用场景
Web Audio API 音频采样 实时 音频合成、特效、可视化
Canvas 2D 视频帧(像素) 中等 水印、滤镜、截图
WebGL 视频帧(GPU) 实时特效、3D 渲染
WebGPU 通用计算 极高 AI 推理、复杂计算
WebCodecs 编解码 自定义编解码、转码
WebAssembly 通用计算 FFmpeg、自定义算法
OffscreenCanvas 离屏渲染 后台处理、多线程

Web Audio API - 音频处理

Web Audio API 提供音频处理的模块化节点系统,可对音频流进行分析、合成和特效处理。

原理:

传统方式下,音频播放是黑盒操作,无法访问音频数据。Web Audio API 将音频处理抽象为"节点图"模型:

  1. 音频上下文(AudioContext) - 管理和协调所有音频操作
  2. 节点(AudioNode) - 音频处理的基本单元,每个节点执行特定功能:
    • 源节点 - 产生音频:MediaStreamSource、BufferSource、Oscillator(振荡器)
    • 效果节点 - 处理音频:GainNode(音量)、BiquadFilterNode(滤波器)、ConvolverNode(混响)
    • 分析节点 - 分析音频:AnalyserNode(频谱分析)
    • 目标节点 - 输出音频:AudioDestination(扬声器)
  3. 节点连接 - 节点之间通过 connect() 连接,形成音频处理管线

数据流:源节点 → 效果节点 → 分析节点 → 目标节点(扬声器)

基础用法:

const audioContext = new AudioContext();

// 从 <audio> 创建源节点
const source = audioContext.createMediaElementSource(audioElement);

// 创建音量控制节点
const gainNode = audioContext.createGain();
gainNode.gain.value = 0.5; // 50% 音量

// 创建频谱分析节点
const analyser = audioContext.createAnalyser();

// 连接节点
source.connect(gainNode).connect(analyser).connect(audioContext.destination);

// 获取频谱数据
const dataArray = new Uint8Array(analyser.frequencyBinCount);
analyser.getByteFrequencyData(dataArray); // 实时频谱数据

核心能力:

  1. 模块化处理 - 通过连接不同节点实现复杂音频处理
  2. 实时分析 - 获取波形、频谱数据,实现音频可视化
  3. 音频合成 - 使用振荡器合成声音,实现电子音乐
  4. 空间音效 - PannerNode 实现 3D 音频定位

典型场景:

  • 音频可视化:通过 AnalyserNode 实时获取频谱数据,无需解码整个音频文件,降低内存占用
  • 在线 DAW(数字音频工作站):模块化节点架构天然适合音轨混音、效果器叠加等音乐制作场景
  • 语音通话降噪:GainNode + BiquadFilterNode 实时处理 getUserMedia 音频流,无需服务端处理

Canvas 2D - 视频帧处理

Canvas 2D 提供像素级的图像操作能力,可将视频帧绘制到画布后进行处理。

原理:

视频播放时,每一帧是一张图像。Canvas 可以将视频帧读取为像素数据,进行像素级操作后重新绘制:

  1. 绘制视频帧 - drawImage(video, 0, 0) 将当前帧绘制到 Canvas
  2. 读取像素数据 - getImageData() 获取 RGBA 像素数组,每 4 个值表示一个像素(R, G, B, A)
  3. 处理像素 - 遍历像素数组,修改颜色值实现滤镜、特效
  4. 写回像素 - putImageData() 将处理后的像素写回 Canvas

基础用法:

const canvas = document.querySelector("canvas");
const ctx = canvas.getContext("2d");
const video = document.querySelector("video");

function processFrame() {
  // 绘制视频帧到 Canvas
  ctx.drawImage(video, 0, 0, canvas.width, canvas.height);

  // 读取像素数据
  const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
  const data = imageData.data; // RGBA 数组

  // 像素处理:灰度化
  for (let i = 0; i < data.length; i += 4) {
    const gray = (data[i] + data[i + 1] + data[i + 2]) / 3;
    data[i] = data[i + 1] = data[i + 2] = gray;
  }

  // 写回 Canvas
  ctx.putImageData(imageData, 0, 0);

  requestAnimationFrame(processFrame);
}

核心能力:

  1. 像素级操作 - 直接访问和修改每个像素的 RGBA 值
  2. 实时处理 - 配合 requestAnimationFrame 实时处理视频帧
  3. 滤镜效果 - 实现灰度、反色、模糊等图像滤镜
  4. 水印叠加 - 在视频上绘制文字、图像水印

典型场景:

  • 视频截图:drawImage 将当前帧绘制到 Canvas,toBlob 导出图片,避免依赖服务端截图
  • 简单滤镜:遍历像素数组修改 RGB 值,无需依赖 GPU,适合轻量级处理(如灰度、反色)
  • 隐私保护:实时检测人脸区域后,修改该区域像素为模糊或马赛克,在客户端完成敏感信息脱敏

WebGL - GPU 加速渲染

WebGL 利用 GPU 并行计算能力,实现高性能的视频处理和特效渲染。

原理:

Canvas 2D 在 CPU 上逐像素处理,对于高分辨率视频性能不足。WebGL 将视频作为纹理(Texture)上传到 GPU,通过着色器(Shader)并行处理所有像素:

  1. 纹理上传 - 将视频帧上传为 GPU 纹理
  2. 顶点着色器 - 处理几何变换(位置、缩放、旋转)
  3. 片段着色器 - 处理每个像素的颜色,实现特效
  4. GPU 并行计算 - 所有像素同时处理,速度远超 CPU

使用流程:

WebGL 处理视频有两种数据来源:

方式 1:使用 video 元素解码

// 1. video 自动解码视频文件
const video = document.querySelector("video");
video.src = "video.mp4";
video.play();

// 2. WebGL 将 video 当前帧上传为纹理
const gl = canvas.getContext("webgl");
const texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);

function render() {
  // video 内部已完成解码,这里直接将解码后的帧上传 GPU
  gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, video);

  // 使用着色器处理纹理
  gl.useProgram(shaderProgram);
  gl.drawArrays(gl.TRIANGLES, 0, 6);

  requestAnimationFrame(render);
}

方式 2:使用 WebCodecs 手动解码

// 1. WebCodecs 解码器
const decoder = new VideoDecoder({
  output: (videoFrame) => {
    // videoFrame 是解码后的原始帧对象
    // 2. 将 VideoFrame 上传为 WebGL 纹理(与 video 用法相同)
    gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, videoFrame);

    // 3. WebGL 着色器处理
    gl.useProgram(shaderProgram);
    gl.drawArrays(gl.TRIANGLES, 0, 6);

    // 4. 释放帧对象
    videoFrame.close();
  },
  error: (e) => console.error(e),
});

decoder.configure({ codec: "vp8", codedWidth: 1920, codedHeight: 1080 });

// 送入压缩数据,解码器会自动解码并调用 output 回调
decoder.decode(new EncodedVideoChunk({ type: "key", timestamp: 0, data: encodedData }));

关键点:

  • gl.texImage2D() 可以接受 video 元素或 VideoFrame 对象
  • 无论哪种方式,传给 WebGL 的都是解码后的原始像素数据
  • video 方式更简单,WebCodecs 方式提供更精细的控制

核心能力:

  1. GPU 加速 - 利用并行计算,处理 4K 视频仍保持 60fps
  2. 复杂特效 - 实时模糊、色彩校正、绿幕抠图
  3. 3D 变换 - 视频作为纹理贴图到 3D 模型
  4. 着色器编程 - GLSL 编写自定义像素处理逻辑

典型场景:

  • 实时美颜直播:着色器并行处理所有像素实现磨皮,1080p 保持 60fps,Canvas 2D 逐像素处理会严重掉帧
  • 绿幕抠图:着色器判断色度范围替换背景,GPU 百万像素并行处理实时完成,CPU 串行计算无法达到实时要求
  • VR 视频播放器:将 360° 视频映射为球体纹理并渲染视角变换,WebGL 3D 能力保证 90fps,满足 VR 低延迟需求

WebCodecs - 底层编解码控制

WebCodecs 提供对视频编解码器的直接访问,实现自定义编解码流程。

原理:

传统方式下,编解码由浏览器内部处理,开发者无法干预。WebCodecs 暴露了编解码器接口,允许 JavaScript 直接控制:

  1. 解码器(VideoDecoder) - 将编码后的视频帧(EncodedVideoChunk)解码为原始帧(VideoFrame)
  2. 编码器(VideoEncoder) - 将原始帧编码为压缩数据
  3. 帧级控制 - 逐帧处理,可在编解码过程中插入自定义逻辑

基础用法:

const decoder = new VideoDecoder({
  output: (frame) => {
    // 处理解码后的原始帧
    ctx.drawImage(frame, 0, 0);
    frame.close();
  },
  error: (e) => console.error(e),
});

decoder.configure({
  codec: "vp8",
  codedWidth: 1920,
  codedHeight: 1080,
});

// 送入编码数据
decoder.decode(
  new EncodedVideoChunk({
    type: "key",
    timestamp: 0,
    data: encodedData,
  })
);

核心能力:

  1. 自定义编解码 - 不依赖 video 元素,完全控制编解码流程
  2. 格式转换 - 解码后重新编码,实现格式转换
  3. 帧级处理 - 在解码后、编码前插入自定义处理
  4. 性能优化 - 直接访问硬件编解码器,性能接近原生

典型场景:

  • 浏览器端转码:解码 H.264 → 帧级处理 → 重新编码为 VP9,无需上传服务器,节省带宽和隐私保护
  • WebRTC 自定义编码:捕获流后自定义编码参数(码率、关键帧间隔),video 元素无法精细控制编码过程
  • 视频编辑器:逐帧解码、剪辑、特效处理后重新编码,video 元素只支持播放无法逐帧控制

WebAssembly - 高性能计算

WebAssembly 将 C/C++ 等语言编译为浏览器可执行的二进制格式,性能接近原生代码。

原理:

JavaScript 是解释执行,性能有限。WebAssembly 是编译型二进制格式,在浏览器中接近原生性能运行:

  1. 编译 - 将 C/C++ 代码编译为 .wasm 文件
  2. 加载 - JavaScript 加载 .wasm 模块
  3. 调用 - JavaScript 调用 WASM 导出的函数,传递音视频数据

典型应用:FFmpeg.wasm 将 FFmpeg 编译为 WASM,在浏览器中实现视频转码、剪辑等复杂操作。

基础用法:

import { createFFmpeg } from "@ffmpeg/ffmpeg";

const ffmpeg = createFFmpeg({ log: true });
await ffmpeg.load();

// 在浏览器中转码视频
ffmpeg.FS("writeFile", "input.mp4", await fetchFile(videoFile));
await ffmpeg.run("-i", "input.mp4", "-vf", "scale=640:480", "output.mp4");
const data = ffmpeg.FS("readFile", "output.mp4");

核心能力:

  1. 接近原生性能 - 执行速度是纯 JavaScript 的数倍
  2. 复用现有代码 - 将 C/C++ 库(FFmpeg、OpenCV)移植到浏览器
  3. CPU 密集计算 - 适合视频编解码、图像处理等计算密集任务
  4. 跨平台 - 一次编译,所有浏览器运行

典型场景:

  • FFmpeg.wasm 视频处理:复用 FFmpeg C 代码实现复杂转码、剪辑,JavaScript 重写性能和工程量不可接受
  • OpenCV.js 计算机视觉:人脸识别、物体检测算法需要大量矩阵运算,WASM 比 JS 快 5-10 倍
  • 音频处理算法:Opus 编解码器、音频降噪算法,C 实现性能远超 JavaScript 且算法库已成熟

OffscreenCanvas - 离屏渲染

OffscreenCanvas 允许在 Web Worker 中进行 Canvas 渲染,避免阻塞主线程。

原理:

Canvas 渲染在主线程执行,复杂计算会阻塞 UI。OffscreenCanvas 将 Canvas 转移到 Worker 线程:

  1. 创建离屏 Canvas - canvas.transferControlToOffscreen()
  2. 转移到 Worker - 通过 postMessage 将 OffscreenCanvas 发送到 Worker
  3. Worker 中渲染 - Worker 线程独立渲染,不阻塞主线程
  4. 自动同步 - 渲染结果自动同步到页面 Canvas

基础用法:

// 主线程
const canvas = document.querySelector("canvas");
const offscreen = canvas.transferControlToOffscreen();
const worker = new Worker("worker.js");
worker.postMessage({ canvas: offscreen }, [offscreen]);

// worker.js
self.onmessage = (e) => {
  const canvas = e.data.canvas;
  const ctx = canvas.getContext("2d");

  function render() {
    ctx.drawImage(video, 0, 0);
    // 复杂的像素处理...
    requestAnimationFrame(render);
  }
  render();
};

核心能力:

  1. 多线程渲染 - 渲染操作在 Worker 执行,主线程流畅
  2. 并行处理 - 多个 Worker 同时处理不同帧
  3. 不阻塞 UI - 即使复杂计算也不影响用户交互
  4. 自动同步 - 无需手动传递渲染结果

典型场景:

  • 视频水印批处理:多个 Worker 并行处理不同片段,充分利用多核 CPU,主线程 Canvas 会阻塞 UI
  • 实时视频处理:Worker 执行复杂像素计算(如风格化滤镜),主线程保持 60fps 交互响应
  • 后台视频渲染:切换标签页后 Worker 继续渲染,主线程 Canvas 在后台会暂停

编解码与容器格式

编解码与容器格式是多媒体技术的基础,决定了音视频的压缩效率、传输成本和播放兼容性。编码器(Encoder)将原始音视频数据压缩为码流,解码器(Decoder)将码流还原为可播放的数据,容器格式(Container)负责将音频流、视频流、字幕等多个流封装在一起。理解编解码原理和格式选择,对优化文件大小、画质、加载速度至关重要。

技术分层关系:

原始数据 → 编码器(Encoder) → 压缩码流 → 容器封装(Muxer) → 媒体文件
媒体文件 → 容器解封装(Demuxer) → 压缩码流 → 解码器(Decoder) → 原始数据

视频编码格式

视频编码通过时间冗余(帧间预测)和空间冗余(帧内压缩)实现高压缩比,将原始像素数据压缩至原大小的 1%。

主流编码格式对比:

编码格式 标准组织 压缩效率 计算复杂度 浏览器支持 专利/授权
H.264/AVC ITU/MPEG 基准(1x) 全平台 专利(免费上限)
H.265/HEVC ITU/MPEG 2x H.264 部分 专利(复杂费用)
VP8 Google 0.8x H.264 Chrome/Firefox 免费开源
VP9 Google 1.5x H.264 Chrome/Firefox/Edge 免费开源
AV1 AOMedia 2x H.264 极高 现代浏览器 免费开源
H.266/VVC ITU/MPEG 2.5x H.264 极高 专利

H.264/AVC - 最广泛支持的编码格式

H.264(Advanced Video Coding)是目前兼容性最好的视频编码格式,几乎所有设备和浏览器都支持硬件解码。

原理:

  1. 帧内预测(Intra Prediction) - I 帧内部,从相邻像素预测当前像素,去除空间冗余
  2. 帧间预测(Inter Prediction) - P/B 帧参考其他帧,只存储差异(运动矢量 + 残差)
  3. 变换编码(DCT) - 将像素数据转换为频域,高频分量(细节)可以量化丢弃
  4. 熵编码(CABAC/CAVLC) - 对量化后的数据进行无损压缩,进一步减小体积

编码档次(Profile):

  • Baseline - 低复杂度,适合移动设备,不支持 B 帧
  • Main - 中等复杂度,支持 B 帧,适合大多数场景
  • High - 高压缩率,支持 8×8 变换,适合高清视频

浏览器支持:

// 检测 H.264 支持
const video = document.createElement("video");
const canPlay = video.canPlayType('video/mp4; codecs="avc1.42E01E"');
// 'probably' - 完全支持, 'maybe' - 可能支持, '' - 不支持

典型场景:

  • 广泛分发:需要最大兼容性时首选,所有平台硬件解码
  • 实时通信:WebRTC 默认编码,硬件编解码降低功耗
  • 流媒体直播:HLS/DASH 主流编码,CDN 缓存友好

H.265/HEVC - 高效但授权复杂

H.265(High Efficiency Video Coding)在相同画质下码率减半,但专利授权费用复杂,浏览器支持有限。

原理:

相比 H.264 的改进:

  1. 更大的编码块 - 支持最大 64×64 CTU(Coding Tree Unit),更适合高分辨率
  2. 更多预测模式 - 35 种帧内预测方向(H.264 仅 9 种)
  3. 更灵活的变换 - 支持 4×4 到 32×32 的多种变换尺寸
  4. 并行处理优化 - Tile、WPP 等技术提高编码并行度

浏览器支持:

// Safari(macOS/iOS)和 Edge 支持,Chrome/Firefox 需硬件支持
video.canPlayType('video/mp4; codecs="hev1.1.6.L120.90"');

典型场景:

  • 4K/8K 视频:高分辨率下压缩优势明显,减少带宽成本
  • 专业制作:后期制作保留更多细节,减少存储成本
  • Apple 生态:iOS/macOS 全平台硬件支持

VP9 - Google 开源编码

VP9 是 Google 开发的开源编码格式,压缩效率接近 H.265,YouTube 大量使用。

原理:

  1. 超级块(Superblock) - 最大支持 64×64 块大小
  2. 自适应环路滤波 - 减少方块效应,提升主观画质
  3. 10bit 色深 - 支持 HDR 视频
  4. 并行编码 - 支持 Tile 并行,提高编码速度

浏览器支持:

// Chrome/Firefox/Edge 原生支持
video.canPlayType('video/webm; codecs="vp9"');

典型场景:

  • YouTube/Netflix:主流流媒体平台使用,减少 CDN 成本
  • 开源项目:无专利费用,适合开源应用
  • WebM 容器:与 WebM 搭配,完全开源栈

AV1 - 下一代开源编码

AV1(AOMedia Video 1)是由 AOMedia 联盟(Google、Mozilla、Netflix 等)开发的免费开源编码格式,压缩效率超越 H.265。

原理:

  1. 更复杂的预测 - 帧内预测支持 71 种模式
  2. 卷积神经网络滤波 - AI 辅助去块、去噪
  3. 全局运动补偿 - 处理摄像机运动
  4. 超分辨率 - 解码端放大画面,降低传输码率

浏览器支持:

// Chrome 90+、Firefox 67+、Edge 90+ 支持
video.canPlayType('video/mp4; codecs="av01.0.05M.08"');

挑战:

  • 编码慢 - 编码复杂度是 H.264 的 100 倍+
  • 硬件支持不足 - 硬件编解码器普及较慢

典型场景:

  • 流媒体优化:Netflix、YouTube 逐步迁移,节省 30-40% 带宽
  • 存档压缩:长期存储视频,空间节省显著
  • 未来标准:免专利费,长期替代 H.264

音频编码格式

音频编码通过心理声学模型去除人耳不敏感的频率,实现 10:1 的压缩比。

主流编码格式对比:

编码格式 开发者 压缩效率 延迟 浏览器支持 专利/授权
MP3 Fraunhofer 基准(1x) ~50ms 全平台 专利已过期
AAC MPEG 1.3x MP3 ~50ms 全平台 专利(免费上限)
Opus Xiph/IETF 1.5x MP3 5-66ms 现代浏览器 免费开源
Vorbis Xiph 1.2x MP3 ~50ms Chrome/Firefox 免费开源

AAC - 最广泛的音频编码

AAC(Advanced Audio Coding)是 MP3 的继任者,在相同码率下音质更好,是 MP4 容器的标准音频编码。

原理:

  1. 改进的滤波器组 - 更精确的频域分解
  2. 时域噪声整形(TNS) - 处理瞬态信号(如打击乐)
  3. 联合立体声编码 - 更高效的双声道编码
  4. 更灵活的码率控制 - VBR(可变码率)更好地适应复杂度

档次(Profile):

  • AAC-LC - 低复杂度,通用场景
  • HE-AAC - 高效,低码率语音/音乐
  • HE-AACv2 - 超低码率,适合流媒体

浏览器支持:

audio.canPlayType('audio/mp4; codecs="mp4a.40.2"'); // AAC-LC

典型场景:

  • 流媒体音频:YouTube、Spotify 标准音频编码
  • 移动设备:硬件编解码,低功耗
  • MP4 视频:标配音频轨道

Opus - 低延迟高质量

Opus 是为实时通信和流媒体设计的编码格式,延迟低至 5ms,压缩效率超越 AAC。

原理:

结合两种编码器:

  1. SILK - 处理语音(低频),基于线性预测
  2. CELT - 处理音乐(全频),基于 MDCT 变换
  3. 自适应切换 - 根据内容特性动态选择编码器

码率范围: 6 kbps(窄带语音) 到 510 kbps(全频立体声)

浏览器支持:

audio.canPlayType('audio/webm; codecs="opus"');

典型场景:

  • WebRTC 音频:默认音频编码,低延迟高质量
  • 游戏语音:5-10ms 延迟,实时互动流畅
  • 播客流媒体:低码率高质量,节省带宽

容器格式

容器格式负责将音频流、视频流、字幕、元数据封装在一起,并记录时间戳、索引信息。

主流容器格式对比:

容器格式 常见编码 流式支持 浏览器支持 特点
MP4/fMP4 H.264+AAC fMP4 支持 全平台 通用、索引在尾部
WebM VP8/VP9+Opus 支持 Chrome/Firefox 开源、流式友好
TS H.264+AAC 支持 MSE 解析 HLS 标准、容错性好
MKV 任意 支持 需转换 功能最强、开源
FLV H.264+AAC 支持 需 flv.js 简单、直播常用

MP4 与 fMP4

MP4(MPEG-4 Part 14)是最通用的容器格式,fMP4(Fragmented MP4)是为流媒体优化的变体。

原理:

传统 MP4 结构:

[ftyp][mdat(视频数据)][moov(索引)]
  • moov box 在文件末尾,记录所有帧的位置和时间戳
  • 必须完整下载才能 seek,不适合流媒体

fMP4(Fragmented MP4)结构:

[ftyp][moov(初始化)][moof(片段索引)][mdat(片段数据)][moof][mdat]...
  • moov 提前,只包含初始化信息
  • 每个片段独立,支持流式播放和 DASH

浏览器使用:

// MP4 直接播放
video.src = "video.mp4";

// fMP4 通过 MSE 播放(DASH)
const mediaSource = new MediaSource();
video.src = URL.createObjectURL(mediaSource);
mediaSource.addEventListener("sourceopen", () => {
  const sourceBuffer = mediaSource.addSourceBuffer('video/mp4; codecs="avc1.42E01E,mp4a.40.2"');
  sourceBuffer.appendBuffer(fmp4Segment);
});

典型场景:

  • MP4:点播视频、录制保存、兼容性优先
  • fMP4:DASH 流媒体、低延迟直播

WebM

WebM 是 Google 推出的开源容器格式,配合 VP8/VP9 编码使用。

原理:

基于 Matroska(MKV)的子集:

  • 仅支持 VP8/VP9 视频 + Vorbis/Opus 音频
  • 流式友好,无需完整文件即可开始播放
  • 支持自适应流(WebM DASH)

浏览器使用:

video.src = "video.webm";
video.canPlayType('video/webm; codecs="vp9,opus"');

典型场景:

  • 开源项目:完全免费,无专利限制
  • 屏幕录制:MediaRecorder API 默认输出格式
  • Chrome 优化:Chrome 原生支持,性能最佳

TS(MPEG Transport Stream)

TS 是为广播电视设计的容器格式,容错性强,HLS 协议的标准容器。

原理:

  1. 固定长度包(188 字节) - 每个包独立,丢包不影响后续数据
  2. 无全局索引 - 支持从任意位置开始播放
  3. 同步字节(0x47) - 快速定位包边界
  4. 多路复用 - 音视频交织,易于实时传输

浏览器使用:

// 通过 MSE 解析 TS(需库如 hls.js)
import Hls from "hls.js";
const hls = new Hls();
hls.loadSource("stream.m3u8"); // HLS 索引,指向 .ts 片段
hls.attachMedia(video);

典型场景:

  • HLS 直播:Apple HLS 协议标准容器
  • 数字电视:IPTV、DVB 广播标准
  • 弱网环境:容错性强,部分丢包不影响播放

编码参数与质量控制

编码参数直接影响文件大小、画质和兼容性。

关键参数:

  1. 码率(Bitrate) - 每秒数据量,直接决定文件大小和画质

    • 1080p H.264: 3-8 Mbps(高质量), 1-3 Mbps(流媒体)
    • 720p H.264: 1.5-4 Mbps
    • CBR(固定码率) vs VBR(可变码率):VBR 同体积下画质更好
  2. 分辨率(Resolution) - 画面尺寸

    • 常见:480p(640×480)、720p(1280×720)、1080p(1920×1080)、4K(3840×2160)
    • 网络适配:准备多个分辨率实现 ABR(自适应码率)
  3. 帧率(Frame Rate) - 每秒帧数

    • 电影:24fps,网络视频:25/30fps,游戏/体育:60fps
    • 帧率越高越流畅,但文件更大
  4. GOP(Group of Pictures) - 关键帧间隔

    • I 帧:完整图像,体积大
    • P 帧:参考前一帧,体积中
    • B 帧:参考前后帧,体积小
    • GOP 越大压缩率越高,但 seek 慢(需定位到最近的 I 帧)
    • 典型值:GOP=60(2 秒一个 I 帧 @ 30fps)

质量与大小平衡:

// FFmpeg 编码示例
// 高质量(大文件)
ffmpeg -i input.mp4 -c:v libx264 -crf 18 -preset slow output.mp4

// 流媒体优化(中等质量)
ffmpeg -i input.mp4 -c:v libx264 -crf 23 -preset medium -maxrate 3M -bufsize 6M output.mp4

// 低码率(小文件)
ffmpeg -i input.mp4 -c:v libx264 -crf 28 -preset fast output.mp4

CRF(Constant Rate Factor):

  • 0-51,值越小质量越高
  • 推荐:18(视觉无损)、23(高质量)、28(可接受)

典型场景:

  • 高质量存档:CRF 18,慢速预设,保留细节
  • 流媒体分发:CRF 23,多码率,自适应播放
  • 社交媒体:CRF 28,快速编码,文件小

前端多媒体库与框架

虽然浏览器提供了原生的多媒体 API,但实际开发中直接使用原生 API 存在浏览器兼容性、协议解析、UI 定制等诸多问题。多媒体库和框架封装了底层复杂性,提供开箱即用的解决方案。本章介绍主流的播放器库、WebRTC 库、音视频处理库,重点对比各库的技术差异和适用场景。

视频播放器库

核心差异维度: 协议支持、UI 定制能力、插件生态、体积性能

主流播放器库对比:

定位 协议支持 UI 体积 插件生态 典型场景
Video.js 通用播放器框架 需插件扩展 完整 UI ~250KB 丰富 通用视频网站
hls.js HLS 专用解析器 HLS 无 UI ~100KB 轻量 HLS 播放
Shaka Player 专业流媒体 DASH + HLS 基础 UI ~300KB 少量 DRM 商业平台
DPlayer 弹幕播放器 需插件 精美 UI ~200KB 少量 弹幕视频网站
flv.js FLV 直播专用 FLV 无 UI ~200KB 低延迟直播
xgplayer 西瓜播放器 可扩展 现代 UI ~150KB 中等 移动端视频

Video.js - 最流行的通用播放器,插件架构支持功能扩展,兼容性好覆盖旧浏览器。核心最小化,功能通过插件实现(HLS、DASH、广告、字幕等)。社区活跃,第三方插件丰富。

hls.js - 纯 HLS 解析器,不提供 UI,专注协议解析和 ABR 逻辑。体积小巧适合性能敏感场景,需自己实现播放控制界面。内置自适应码率算法成熟稳定。

Shaka Player - Google 开发的专业流媒体播放器,同时支持 DASH 和 HLS,内置完整 DRM 支持(Widevine/PlayReady/FairPlay)。商业级解决方案,适合付费视频平台。

DPlayer - 国内开发的弹幕播放器,提供精美 UI 和弹幕功能。支持 HLS、FLV、DASH(通过插件),API 简洁易用。适合需要弹幕功能的视频网站。

flv.js - B 站开源的 FLV 解析器,专门用于低延迟直播(3-10 秒延迟)。仅支持 FLV 容器,不支持其他格式。针对直播场景优化缓冲策略。

xgplayer - 字节跳动开源的播放器,提供现代化 UI 和移动端优化。插件化架构,支持 HLS、FLV、DASH。性能优化和移动端体验较好。

技术差异总结:

  • Video.js:插件生态最丰富,适合需要大量定制功能的场景
  • hls.js/flv.js:纯解析器无 UI,适合已有 UI 框架或需要完全自定义的场景
  • Shaka Player:DRM 支持最完善,适合商业付费内容
  • DPlayer/xgplayer:UI 精美现代,适合快速搭建视频网站

WebRTC 库与框架

核心差异维度: 架构模式(P2P/SFU)、信令处理、API 复杂度、扩展性

客户端库对比:

架构 信令 API 风格 体积 学习曲线 适用场景
simple-peer P2P 需自实现 事件驱动 ~20KB 简单 P2P 应用
PeerJS P2P 提供托管服务 回调风格 ~50KB 快速原型开发
mediasoup-client SFU 配合服务端 Promise ~200KB 大规模会议
Agora SDK 商业方案 云服务 Promise ~1MB 企业级应用

simple-peer - 最轻量的 WebRTC 封装,将 RTCPeerConnection 简化为事件驱动 API。信令交换由开发者自行实现(WebSocket/HTTP 等)。适合已有后端信令服务器的场景,仅支持 P2P(1 对 1)。

PeerJS - 提供托管信令服务器的 P2P 库,通过唯一 Peer ID 标识用户。零配置快速开发,适合原型验证和小规模应用。公共信令服务器免费但不适合生产环境,P2P 架构限制参与人数。

mediasoup-client - mediasoup SFU 服务器的配套客户端,支持大规模多人会议(数百人)。SFU 架构服务器转发流,支持 Simulcast 多路发送。精细控制编码参数,适合专业视频会议产品,但学习曲线陡峭需要部署服务器。

服务端方案对比:

方案 架构 语言 并发能力 部署复杂度 特点
Janus Gateway SFU/MCU C 插件架构、性能优秀
mediasoup SFU C++/Node.js 极高 Simulcast、录制、转码
Jitsi SFU Java 一体化方案、开箱即用
Kurento MCU Java 媒体处理能力强

技术差异总结:

  • simple-peer/PeerJS:P2P 架构,适合 1 对 1 或小规模(2-4 人)场景,延迟最低
  • mediasoup:SFU 架构,适合大规模会议(10+ 人),服务器转发降低客户端压力
  • 商业方案:声网/腾讯云等提供完整云服务,免运维但成本较高

音频处理库

核心差异维度: 使用场景(游戏/音乐/分析)、API 复杂度、功能深度

定位 基础技术 核心功能 体积 学习曲线 典型场景
Tone.js 音乐创作框架 Web Audio API 合成器、音序器、效果器 ~200KB DAW、电子音乐
Howler.js 游戏音频库 HTML5 Audio 播放控制、空间音效 ~20KB 游戏音效、BGM
WaveSurfer.js 波形可视化 Web Audio + Canvas 波形绘制、区域选择 ~100KB 音频编辑器
Pizzicato.js 音效处理 Web Audio API 音效库(混响/延迟) ~50KB 音效增强

Tone.js - 专业音乐创作框架,内置音阶、节奏、和声等音乐概念。提供 Transport 时间轴实现 DAW 级精确时序控制。适合在线 DAW、音乐可视化、电子音乐应用,但学习曲线陡峭。

Howler.js - 简单易用的游戏音频库,自动降级(Web Audio → HTML5 Audio)保证兼容性。提供音效池、预加载、空间音频等游戏优化功能。API 简洁,适合游戏音效和背景音乐播放。

WaveSurfer.js - 音频波形可视化库,绘制波形图并支持区域选择、缩放、播放控制。适合音频编辑器、播客剪辑、音频分析工具。

技术差异总结:

  • Tone.js:音乐导向,复杂度高,适合专业音乐制作
  • Howler.js:游戏导向,简单易用,适合音效播放
  • WaveSurfer.js:可视化导向,适合音频编辑和分析

视频处理库

定位 核心能力 体积 性能 适用场景
FFmpeg.wasm 完整视频处理 转码、剪辑、滤镜 ~25MB 格式转换、复杂处理
Remotion 程序化视频生成 React 组件 → 视频 ~5MB 高(服务端渲染) 模板视频生成
fabric.js Canvas 视频编辑 图层、滤镜、合成 ~200KB 实时视频编辑

FFmpeg.wasm - FFmpeg 的 WebAssembly 版本,支持所有 FFmpeg 命令。功能完整但体积大(~25MB),首次加载慢。WASM 性能接近原生,比纯 JS 快 5-10 倍。适合浏览器端视频转码、格式转换、添加水印等复杂操作。

Remotion - 用 React 组件编写视频,支持程序化生成。在服务端渲染为视频文件,支持模板变量替换。适合批量生成营销视频、数据可视化视频、动态模板视频。

fabric.js - Canvas 库,支持图层、滤镜、图像合成。可用于实时视频编辑(逐帧处理),提供丰富的图形绘制能力。适合视频贴纸、水印、滤镜等实时编辑场景。

安全与版权保护

在线音视频内容面临盗版、盗链、非法下载等威胁。安全与版权保护技术通过内容加密、数字版权管理(DRM)、访问控制等手段,确保内容只能被授权用户在合法条件下访问和播放。

DRM 数字版权管理

DRM(Digital Rights Management)通过加密内容和密钥管理,防止未授权的复制和传播。浏览器通过 EME(Encrypted Media Extensions)标准支持 DRM 播放。

主流 DRM 方案对比:

DRM 方案 开发者 平台支持 安全级别 授权费用 典型应用
Widevine Google Chrome/Firefox/Edge/安卓 L1-L3 商业授权 YouTube/Netflix
FairPlay Apple Safari/iOS/tvOS 硬件级 商业授权 Apple TV+
PlayReady Microsoft Edge/Xbox/Windows 硬件级 商业授权 Microsoft 生态
ClearKey W3C 所有现代浏览器 软件级 免费开源 测试/低安全场景

原理:

DRM 保护分为三个关键环节:

  1. 内容加密 - 服务器使用密钥加密视频内容,生成加密视频文件
  2. 密钥服务器 - 客户端播放时向许可证服务器请求解密密钥
  3. 解密播放 - 浏览器 CDM(Content Decryption Module)在沙箱中解密并播放,密钥不暴露给 JavaScript

Widevine 安全级别:

  • L1(Level 1) - 硬件级保护,解密和解码在 TEE(可信执行环境)中进行,最高安全
  • L2 - 解码在 TEE,但视频解码在非安全区域
  • L3 - 纯软件实现,最低安全等级,易被破解

EME - Encrypted Media Extensions

EME 是 W3C 标准,定义了浏览器如何播放加密媒体内容。通过 MediaKeys API 与 CDM 通信,获取解密密钥。

基本流程:

const video = document.querySelector("video");
const config = [
  {
    initDataTypes: ["cenc"],
    videoCapabilities: [
      {
        contentType: 'video/mp4; codecs="avc1.42E01E"',
      },
    ],
  },
];

// 1. 检查浏览器是否支持该 DRM 方案
navigator
  .requestMediaKeySystemAccess("com.widevine.alpha", config)
  .then((keySystemAccess) => {
    // 2. 创建 MediaKeys 对象
    return keySystemAccess.createMediaKeys();
  })
  .then((mediaKeys) => {
    // 3. 将 MediaKeys 绑定到 video 元素
    return video.setMediaKeys(mediaKeys);
  })
  .then(() => {
    // 4. 播放加密内容,触发 encrypted 事件
    video.src = "encrypted-video.mp4";
    video.play();
  });

// 5. 处理 encrypted 事件,请求许可证
video.addEventListener("encrypted", (event) => {
  const session = video.mediaKeys.createSession();

  // 6. 向许可证服务器请求密钥
  session
    .generateRequest(event.initDataType, event.initData)
    .then(() => {
      // 7. 获取许可证服务器响应
      return fetch("https://license-server.com/license", {
        method: "POST",
        body: session.message, // 包含设备信息和内容 ID
      });
    })
    .then((response) => response.arrayBuffer())
    .then((license) => {
      // 8. 更新会话,CDM 解密内容
      return session.update(license);
    });
});

关键点:

  • 解密密钥在 CDM 沙箱中,JavaScript 无法访问
  • 许可证服务器验证用户身份、设备、订阅状态等
  • L1 级别 DRM 要求硬件 TEE 支持(如 ARM TrustZone)

典型场景:

  • 付费视频平台:Netflix、Disney+ 使用 Widevine/FairPlay/PlayReady 三套 DRM 覆盖所有平台
  • 在线教育:防止课程视频被录屏和分享,通常使用 L1 级 Widevine
  • 企业培训:内部敏感内容加密播放,限制播放设备和次数

HLS 内容加密

HLS 支持 AES-128 加密,无需 DRM 即可实现基础内容保护。适合对安全性要求不高的场景。

原理:

  1. 密钥文件 - 服务器生成 AES-128 密钥,存储在密钥服务器
  2. m3u8 索引 - 播放列表中声明密钥 URL:#EXT-X-KEY:METHOD=AES-128,URI="https://key-server.com/key"
  3. 客户端解密 - 播放器请求密钥,使用 AES-128 解密 TS 片段

基础用法:

#EXTM3U
#EXT-X-VERSION:3
#EXT-X-KEY:METHOD=AES-128,URI="https://example.com/key?token=abc123"
#EXTINF:10.0,
segment0.ts
#EXTINF:10.0,
segment1.ts

安全性分析:

  • 优点:实现简单,无需 DRM 授权费用,所有浏览器支持
  • 缺点:密钥在 JavaScript 中暴露,容易被抓包获取,安全性低于 DRM

增强方案:

  • 动态密钥 - 每个片段使用不同密钥,增加破解难度
  • Token 鉴权 - 密钥 URL 带时效 Token,防止密钥被盗用
  • 密钥轮换 - 定期更换密钥,限制密钥有效期

典型场景:

  • UGC 平台:B 站、抖音等防止视频被直接下载,但不要求 DRM 级安全
  • 企业内网:内部培训视频,物理隔离环境无需高安全性
  • 低成本保护:小型视频平台,无预算采购 DRM 授权

访问控制技术

通过鉴权机制控制谁可以访问视频资源,防止盗链和未授权访问。

主流访问控制方案对比:

方案 原理 安全性 实现复杂度 适用场景
Token 鉴权 URL 带时效签名 Token 付费内容、直播
URL 签名 基于密钥的 HMAC 签名 CDN 防盗链
Referer 检查 HTTP Referer 头 基础防盗链
IP 白名单 限制允许访问的 IP 段 企业内网、VPN
Cookie 鉴权 检查登录 Cookie 登录用户验证

Token 鉴权原理:

// 服务端生成带签名的 URL(Node.js 示例)
const crypto = require("crypto");

function generateSecureUrl(videoPath, secretKey, expireSeconds) {
  const expireTime = Math.floor(Date.now() / 1000) + expireSeconds;
  const message = `${videoPath}${expireTime}`;
  const signature = crypto.createHmac("sha256", secretKey).update(message).digest("hex");

  return `${videoPath}?expire=${expireTime}&sign=${signature}`;
}

// 生成 1 小时有效的视频 URL
const secureUrl = generateSecureUrl("/videos/movie.m3u8", "my-secret-key", 3600);
// /videos/movie.m3u8?expire=1704067200&sign=a3f2c9...
// CDN 边缘节点验证(伪代码)
function validateToken(url, secretKey) {
  const { videoPath, expire, sign } = parseUrl(url);

  // 检查是否过期
  if (Date.now() / 1000 > expire) {
    return false; // 过期
  }

  // 重新计算签名
  const message = `${videoPath}${expire}`;
  const expectedSign = crypto.createHmac("sha256", secretKey).update(message).digest("hex");

  return sign === expectedSign; // 签名匹配
}

关键点:

  • 时效性 - Token 带过期时间,防止 URL 被长期盗用
  • 不可伪造 - 签名基于服务端密钥,攻击者无法伪造有效签名
  • 单次使用 - 可增加随机 nonce,防止 URL 被重复使用

Referer 防盗链:

# Nginx 配置示例
location ~* \.(m3u8|ts|mp4)$ {
    valid_referers none blocked *.example.com;
    if ($invalid_referer) {
        return 403;
    }
}

局限性 - Referer 可被伪造,仅适合基础防护

典型场景:

  • 付费视频 - Token 鉴权 + DRM,双重保护高价值内容
  • 直播鉴权 - 动态生成推流/拉流 Token,防止未授权推流
  • CDN 防盗链 - URL 签名防止视频被其他网站盗链消耗带宽

性能优化与质量监控

多媒体应用的用户体验直接取决于播放性能和质量稳定性。本章介绍关键性能指标(QoE/QoS)、优化策略(ABR、预加载、多线程)、以及监控工具,帮助开发者构建高性能、低卡顿的音视频应用。

关键性能指标

QoE(Quality of Experience)用户体验质量指标:

指标 定义 目标值 影响因素
首屏时间 点击播放到显示首帧的时间 <1 秒(点播)/<3 秒(直播) 网络延迟、DNS 解析、CDN
卡顿率 播放过程中卡顿时长占比 <0.5% 缓冲策略、网络抖动
卡顿次数 播放过程中卡顿发生次数 <2 次/小时 带宽波动、ABR 切换
播放码率 实际播放的码率档位 自适应最高 带宽、设备性能
播放成功率 成功播放占播放请求的比例 >99% 格式兼容性、DRM 错误

QoS(Quality of Service)网络质量指标:

指标 定义 目标值 影响
码率 视频传输速率 根据分辨率选择 画质、带宽消耗
丢包率 丢失的数据包占比 <1%(直播/<0.1%点播) 画面失真、卡顿
RTT 往返时延 <100ms(实时通信) 交互延迟感
抖动 延迟的变化程度 <30ms 播放流畅性
带宽 可用网络传输速度 >码率 1.5 倍 能否流畅播放

监控实现:

// 使用 HTMLMediaElement 监控播放指标
const video = document.querySelector("video");
const metrics = {
  startTime: Date.now(),
  bufferingCount: 0,
  bufferingDuration: 0,
  currentBitrate: 0,
};

// 首屏时间
video.addEventListener("loadeddata", () => {
  const ttfb = Date.now() - metrics.startTime;
  console.log(`首屏时间: ${ttfb}ms`);
  // 上报监控系统
  reportMetric("ttfb", ttfb);
});

// 卡顿监控
let bufferingStart = 0;
video.addEventListener("waiting", () => {
  bufferingStart = Date.now();
  metrics.bufferingCount++;
});

video.addEventListener("playing", () => {
  if (bufferingStart) {
    const bufferingTime = Date.now() - bufferingStart;
    metrics.bufferingDuration += bufferingTime;
    console.log(`卡顿: ${bufferingTime}ms, 总卡顿: ${metrics.bufferingCount} 次`);
  }
});

// 计算卡顿率
video.addEventListener("ended", () => {
  const totalDuration = video.duration * 1000;
  const bufferingRate = (metrics.bufferingDuration / totalDuration) * 100;
  console.log(`卡顿率: ${bufferingRate.toFixed(2)}%`);
});

典型场景:

  • 视频平台 - 实时监控卡顿率和首屏时间,发现 CDN 节点故障和网络拥塞
  • 直播应用 - 监控端到端延迟和丢包率,保证实时性
  • 教育平台 - 监控播放成功率,及时发现格式兼容性和 DRM 授权问题

优化策略

主流优化策略对比:

策略 原理 效果 实现成本 适用场景
预加载 提前加载关键资源 减少首屏时间 50%+ 点播、预知播放
ABR 自适应码率 动态切换码率档位 减少卡顿 70%+ 所有流媒体
分片加载 按需加载视频片段 减少初始加载 90%+ 长视频、点播
P2P CDN 用户间共享数据 节省带宽 30-70% 大规模直播
多线程解码 Web Workers 解码 提升解码性能 2-3 倍 软解复杂编码
Service Worker 离线缓存资源 离线播放、秒开 PWA、重复观看

预加载优化

Link Preload - 提前加载关键资源:

<!-- 预加载视频文件 -->
<link rel="preload" as="video" href="intro.mp4" type="video/mp4" />

<!-- 预加载 HLS 播放列表 -->
<link rel="preload" as="fetch" href="video.m3u8" crossorigin />

<!-- 预加载海报图 -->
<link rel="preload" as="image" href="poster.jpg" />

Video Preload 属性:

const video = document.querySelector("video");

// none - 不预加载(省流量)
video.preload = "none";

// metadata - 仅加载元数据(时长、尺寸、首帧)
video.preload = "metadata"; // 默认值

// auto - 预加载整个视频(适合 Wi-Fi)
video.preload = "auto";

智能预加载策略:

// 根据网络类型决定预加载策略
function getPreloadStrategy() {
  const connection = navigator.connection || navigator.mozConnection || navigator.webkitConnection;

  if (!connection) return "metadata";

  // 4G/5G 预加载视频
  if (connection.effectiveType === "4g" || connection.effectiveType === "5g") {
    return "auto";
  }

  // 3G 仅加载元数据
  if (connection.effectiveType === "3g") {
    return "metadata";
  }

  // 2G/慢速网络不预加载
  return "none";
}

video.preload = getPreloadStrategy();

典型场景:

  • 短视频列表 - 预加载可视区域的下一个视频,实现快速切换
  • 付费试看 - 仅 preload metadata,避免浪费带宽
  • 自动播放 - Wi-Fi 下 preload auto,移动网络 preload none

ABR 自适应码率

ABR(Adaptive Bitrate)根据网络带宽动态切换视频码率档位,平衡画质和流畅性。

原理:

  1. 带宽检测 - 测量当前下载速度
  2. 码率选择 - 选择略低于带宽的码率档位(如 480p/720p/1080p)
  3. 平滑切换 - 在片段边界切换,用户无感知
// 使用 hls.js 的 ABR 配置
import Hls from "hls.js";

const hls = new Hls({
  // ABR 算法配置
  abrEwmaDefaultEstimate: 500000, // 初始带宽估计(500 Kbps)
  abrEwmaSlowVoD: 3, // 慢速网络衰减因子
  abrEwmaFastVoD: 3, // 快速网络衰减因子
  abrBandWidthFactor: 0.95, // 带宽安全系数(选择 95% 码率)
  abrBandWidthUpFactor: 0.7, // 上调码率阈值(带宽需达到 70%)
});

hls.loadSource("video.m3u8");
hls.attachMedia(video);

// 监听码率切换
hls.on(Hls.Events.LEVEL_SWITCHED, (event, data) => {
  const level = hls.levels[data.level];
  console.log(`切换到: ${level.height}p, 码率: ${level.bitrate / 1000} Kbps`);
});

// 手动锁定码率档位
hls.currentLevel = 2; // 锁定到第 3 个码率档位
hls.currentLevel = -1; // 恢复自动 ABR

典型场景:

  • 移动网络 - 网络波动大,ABR 自动降低码率避免卡顿
  • 弱网环境 - 2G/3G 网络自动播放低码率,保证流畅
  • Wi-Fi 切换 - 从移动网络切到 Wi-Fi,自动提升码率

多线程处理

利用 Web Workers 在后台线程处理解码、转码等 CPU 密集任务,避免阻塞主线程。

Web Workers 视频处理:

// main.js - 主线程
const worker = new Worker("video-processor.worker.js");

// 发送视频帧到 Worker
worker.postMessage({
  type: "process",
  frame: videoFrame,
  filter: "grayscale",
});

// 接收处理后的帧
worker.onmessage = (event) => {
  const processedFrame = event.data.frame;
  drawToCanvas(processedFrame);
};
// video-processor.worker.js - Worker 线程
self.onmessage = (event) => {
  const { frame, filter } = event.data;

  // CPU 密集型处理(不阻塞主线程)
  const processed = applyFilter(frame, filter);

  // 返回结果
  self.postMessage({ frame: processed });
};

function applyFilter(frame, filter) {
  // 像素处理逻辑
  // ...
  return processedFrame;
}

SharedArrayBuffer - 零拷贝数据共享:

// 创建共享内存
const sharedBuffer = new SharedArrayBuffer(1920 * 1080 * 4); // 1080p RGBA
const sharedArray = new Uint8ClampedArray(sharedBuffer);

// 主线程写入帧数据
ctx.getImageData(0, 0, 1920, 1080).data.set(sharedArray);

// Worker 直接读取,无需拷贝
worker.postMessage({ buffer: sharedBuffer }, []);

典型场景:

  • 软件解码 - WebCodecs 解码在 Worker,避免主线程掉帧
  • 实时滤镜 - 美颜、滤镜在 Worker 处理,保持 UI 流畅
  • 视频转码 - FFmpeg.wasm 在 Worker 运行,不阻塞界面

Service Worker 离线缓存

Service Worker 拦截网络请求,实现视频资源的离线缓存和秒开。

基础实现:

// sw.js - Service Worker
const CACHE_NAME = "video-cache-v1";
const urlsToCache = ["/video.m3u8", "/segment-0.ts", "/segment-1.ts"];

// 安装时缓存资源
self.addEventListener("install", (event) => {
  event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(urlsToCache)));
});

// 拦截请求,优先返回缓存
self.addEventListener("fetch", (event) => {
  event.respondWith(
    caches.match(event.request).then((response) => {
      // 缓存命中,直接返回
      if (response) {
        return response;
      }

      // 缓存未命中,请求网络
      return fetch(event.request).then((networkResponse) => {
        // 缓存响应
        if (networkResponse.status === 200) {
          const responseClone = networkResponse.clone();
          caches.open(CACHE_NAME).then((cache) => {
            cache.put(event.request, responseClone);
          });
        }
        return networkResponse;
      });
    })
  );
});

智能缓存策略:

// 根据文件类型采用不同策略
self.addEventListener("fetch", (event) => {
  const url = new URL(event.request.url);

  // m3u8 - 网络优先(及时更新)
  if (url.pathname.endsWith(".m3u8")) {
    event.respondWith(networkFirst(event.request));
  }

  // ts 片段 - 缓存优先(不变内容)
  if (url.pathname.endsWith(".ts")) {
    event.respondWith(cacheFirst(event.request));
  }
});

async function cacheFirst(request) {
  const cache = await caches.open(CACHE_NAME);
  const cached = await cache.match(request);
  return cached || fetch(request);
}

async function networkFirst(request) {
  try {
    const response = await fetch(request);
    const cache = await caches.open(CACHE_NAME);
    cache.put(request, response.clone());
    return response;
  } catch (error) {
    return caches.match(request); // 网络失败回退缓存
  }
}

典型场景:

  • 离线播放 - 缓存完整视频,支持飞行模式观看
  • 秒开优化 - 缓存首个片段,播放立即开始
  • 减少流量 - 重复观看的视频从缓存加载

监控工具

Chrome DevTools Media Panel:

// 访问 chrome://media-internals/ 查看:
// - 解码器信息(硬件/软件)
// - 缓冲状态
// - 网络请求时间线
// - 丢帧统计

WebRTC internals:

// 访问 chrome://webrtc-internals/ 查看:
// - ICE 连接状态
// - 实时码率/丢包率/RTT
// - 编解码器参数

Performance API 监控:

// 使用 Performance Observer 监控
const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    console.log(`${entry.name}: ${entry.duration}ms`);
    reportMetric(entry);
  }
});

observer.observe({ entryTypes: ["measure", "resource"] });

// 标记关键时间点
performance.mark("video-load-start");
video.addEventListener("loadeddata", () => {
  performance.mark("video-load-end");
  performance.measure("video-load-duration", "video-load-start", "video-load-end");
});

典型场景:

  • 性能回归 - CI/CD 中自动化监控首屏时间,发现性能劣化
  • A/B 测试 - 对比不同优化策略的实际效果
  • 用户监控 - 收集真实用户的播放质量数据,优化 CDN 策略

探索现代化 i18n 方案:从工程自动化到 AI 驱动的演进

在最近参与的一个中大型前端项目中,随着业务需求的快速变化和代码的频繁重构,国际化(i18n)逐渐从“基础设施”演变成了一个明显的工程负担。

一个非常典型的场景是:组件逻辑已经修改完成,但 locales 目录下的 JSON 文件却长期处于滞后状态。新增的文案没有及时补齐,删除的页面却遗留了一堆无人使用的 Key。随着项目规模扩大,这类问题会被不断放大。

这背后其实暴露的是一个更本质的问题:传统 i18n 的工作流,与现代前端开发节奏并不匹配

在深入探讨改进方案之前,我们有必要先回顾一下当前主流 i18n 方案的设计思路及其局限。

1. 传统基石:vue-i18n 的设计取舍

在 Vue 生态中,vue-i18n 几乎是事实标准。它成熟、稳定,并且覆盖了大多数国际化场景。

从实现机制上看,vue-i18n 的核心是 运行时(Runtime)替换

  • 开发者在模板或脚本中通过 $t('key') 访问文案
  • 运行时根据当前语言环境,从预先加载的 JSON 语言包中查找并返回对应字符串
  • 同时支持复数规则、日期/数字格式化等高级能力

从“框架插件”的角度来看,这样的设计并没有问题,但在真实工程实践中,它也带来了一些长期被忽视的成本。

常见痛点包括:

  • Key 设计成本高
    为每一条文案设计一个“语义清晰、层级合理、可长期维护”的 Key,本身就是一项隐性工作量。
  • 代码与文案强解耦
    Key 分散在业务代码中,真实文案却集中在 JSON 文件里,删除或重构页面时,很容易留下大量“无效翻译”。
  • 上下文缺失导致翻译质量不稳定
    无论是人工翻译还是机器翻译,单独面对一个 Key,很难准确理解其真实使用场景。
    这些问题并非 vue-i18n 本身的缺陷,而是 “Key 驱动”这一设计范式的天然代价

2. 自动化方向的探索:基于 AST 的 i18n 工具

为了降低 Key 维护和手工同步的成本,社区中逐渐出现了一类自动化工具,例如
auto-i18n-translation-plugins

这类方案的核心思想是:让工具理解代码,而不是让人维护映射关系

其典型流程如下:

  1. 静态扫描
    基于 AST 分析源代码,提取其中的中文字符串
  2. 自动替换
    将源码中的中文替换为生成的 Key(通常是 Hash 值)
  3. 自动翻译
    调用 Google / 百度等翻译 API,生成多语言文案
  4. 配置写入
    自动维护语言包文件
    在工程效率层面,这一步已经是一次明显的跃迁:
    开发者几乎可以忽略 i18n 的存在,先完成业务,再由工具兜底。

但当项目进入更复杂的业务领域后,新的问题也随之出现。

主要瓶颈在于翻译质量:

  • 通用翻译 API 缺乏领域上下文
  • 无法区分业务语义(如金融、医疗、后台系统等)
  • 仍然需要大量人工校对

这类工具解决了“效率问题”,但并没有真正解决“准确性问题”。

3. 进一步演进:AI 驱动的 i18n 设计思路

在当前 LLM 已经高度成熟的背景下,我认为 i18n 方案的设计目标可以进一步升级:

以 Developer Experience 为核心,尽可能贴近自然语言,并把翻译质量交给更“理解上下文”的模型。

3.1 语法层面的取舍:回归自然语言

首先,一个关键决策是:不再强制开发者手动定义 Key

代码中的国际化调用,应该尽量接近自然语言本身:

// 基础用法:直接使用中文
const msg = t`你好`;

// 带上下文的用法
const status = t("待审核", "金融风控业务状态");

这样的语法带来几个直接收益:

  • 文案在代码中是可读的,而不是抽象的 Key
  • Code Review 时无需在 JSON 文件和业务代码之间来回切换
  • 上下文信息可以显式传递给翻译系统

3.2 更合理的 Key 生成策略

在生成语言包时,我们同样可以放弃不可读的 Hash Key,而采用:

「中文原文 + 上下文注释」作为唯一标识

示例:

{
  "待审核#金融风控业务状态": {
    "english": "Pending Review"
  }
}

这种设计的优势在于:

  • 天然避免语义冲突 同样是“取消”,在“订单操作”和“账户注销”场景下可以通过上下文区分
  • Key 本身即文档 语言包文件在人工审核时非常直观
  • 更适合多语言扩展 在后续生成日文、法文、韩文等语言时,可以复用同一套 Key 体系,无需额外维护映射关系

3.3 虚拟模块:让翻译数据成为构建产物

为了避免手动管理中间文件,可以利用 Vite / Rollup 的 Virtual Module(虚拟模块) 能力。

简单来说,插件可以在构建阶段动态生成一个模块,例如:

import { t } from "virtual:ai-i18n";

构建工具会拦截该导入,并返回由插件实时生成的运行时代码,其中已经包含:

  • 当前语言包
  • tsetLang 等辅助函数

这样一来:

  • 翻译数据成为构建流程的一部分
  • 不需要关心 JSON 文件的加载和同步问题
  • 工程结构更加清晰

虚拟模块的设计意义

  1. 语言包属于构建产物,而不是源码文件
  2. 无需生成中间文件,也无需 commit
  3. 极大提升开发者体验,让文案写作像原生语言特性一样自然

3.4 自动导入,进一步压缩心智负担

配合 unplugin-auto-import,甚至可以省略显式的 import:

t`你好`;

从开发体验上看,这已经接近“原生写文案”的感觉。

4. 插件选项:目标语言与默认语言

插件提供灵活配置:

aiI18nPlugin({
  targetLangs: ["english", "ja", "fr"], // 所有需要生成的目标语言
  defaultLang: "english", // 默认翻译语言
});
  • 如果未传 defaultLang,默认使用 'english'
  • targetLangs 至少包含默认语言
  • 后续生成语言包的结构统一为单文件 JSON

5. 插件输出文件:单 JSON + 可人工校准

为了最大化可维护性,输出语言包为:

locales/i18n.json

格式示例:

{
  "提交#表单操作": {
    "english": "Submit",
    "ja": "",
    "fr": ""
  },
  "待审核#金融风控业务状态": {
    "english": "Pending Review"
  }
}

特点:

  • 单文件管理:避免多语言文件分散,方便查找与审核
  • 只处理未翻译 Key:插件不会覆盖已有翻译
  • 支持人工校准:你可以在 locales/i18n.json 中直接修改或补充翻译
  • 增量更新:每次构建只生成缺失翻译,保证历史翻译安全

6. 为什么这里选择 LLM,而不是传统翻译 API?

引入 LLM 并不是为了追逐概念,而是为了解决传统翻译 API 的结构性短板。

6.1 领域语义的理解能力

通过 Prompt Engineering,可以显式告诉模型当前的业务背景,例如:

  • 金融风控系统
  • SaaS 管理后台
  • 电商交易流程

配合 Few-Shot 示例或术语表注入,生成的翻译在准确性和专业度上,明显优于通用 API。

6.2 成本与隐私的可控性

翻译任务本身是低复杂度任务,非常适合:

  • 本地运行 4B~8B 级别模型(如 Qwen、Llama 系列)
  • 通过 Ollama 等工具进行部署
  • 结合 LangChain 进行批处理调用

这种方式的优势包括:

  • 无网络依赖,隐私可控
  • 无调用费用
  • 批量翻译效率高

在企业环境中,也可以直接替换为 GPT-4、DeepSeek 等商业 API,方案本身并不受限。

7. 插件实现要点(Vite Plugin)

插件的核心职责可以归纳为两点:

  1. 收集需要翻译的文案
  2. 在合适的时机批量调用 LLM,并持久化结果到单文件 JSON

关键实现细节包括:

  • 翻译队列与批处理机制
  • 本地缓存与持久化
  • 避免重复翻译已存在 Key
/**
 * vite-plugin-ai-i18n.ts
 *
 * 说明:
 * 这是一个用于解释 AI i18n 插件核心流程的伪代码示例。
 * 重点在于架构、数据流和设计思路,而非具体 API 或可运行实现。
 */

import fs from "fs";
import path from "path";

// -------------------------
// 虚拟模块定义
// -------------------------
const VIRTUAL_MODULE_ID = "virtual:ai-i18n";
const RESOLVED_VIRTUAL_MODULE_ID = "\0" + VIRTUAL_MODULE_ID;

// -------------------------
// 输出文件与默认语言
// -------------------------
const LOCALES_DIR = path.resolve(process.cwd(), "locales");
const LOCALE_FILE = path.join(LOCALES_DIR, "i18n.json");
const DEFAULT_LANG = "english";

// -------------------------
// 类型定义(简化)
// -------------------------
type Lang = string;
type TranslationKey = string;

interface PendingItem {
  key: TranslationKey; // 唯一标识:原文#上下文
  text: string; // 原文
  context?: string; // 可选上下文信息
}

type LangMessages = Record<Lang, string>;
type AllMessages = Record<TranslationKey, LangMessages>;

// -------------------------
// 工具函数(伪实现)
// -------------------------

/**
 * 读取已有 JSON 语言包
 * 如果文件不存在,返回空对象
 */
function loadLocales(): AllMessages {
  if (!fs.existsSync(LOCALE_FILE)) return {};
  return JSON.parse(fs.readFileSync(LOCALE_FILE, "utf-8"));
}

/**
 * 将最终语言包写入本地 JSON
 * 自动创建目录
 */
function saveLocales(messages: AllMessages) {
  if (!fs.existsSync(LOCALES_DIR)) fs.mkdirSync(LOCALES_DIR);
  fs.writeFileSync(LOCALE_FILE, JSON.stringify(messages, null, 2));
}

/**
 * 扫描源码中所有 t(...) / t`...` 的调用
 * 实际实现应使用 AST
 */
function scanForI18nTexts(code: string): PendingItem[] {
  // 伪逻辑示意:
  // 1. 遍历代码 AST
  // 2. 找到 t`xxx` 或 t('xxx', 'context')
  // 3. 返回 PendingItem 列表
  return [];
}

// -------------------------
// 插件主逻辑
// -------------------------
export default function aiI18nPlugin(options: {
  targetLangs?: Lang[]; // 目标语言列表
  defaultLang?: Lang; // 默认语言
}) {
  const defaultLang = options.defaultLang || DEFAULT_LANG;
  const targetLangs = options.targetLangs || [defaultLang];

  // 加载已有翻译(人工可校准)
  const allMessages: AllMessages = loadLocales();

  // 待翻译队列,只收集尚未存在的 Key
  const pendingQueue: Map<string, PendingItem> = new Map();

  return {
    name: "vite-plugin-ai-i18n",

    // -------------------------
    // 虚拟模块解析
    // -------------------------
    resolveId(id: string) {
      if (id === VIRTUAL_MODULE_ID) return RESOLVED_VIRTUAL_MODULE_ID;
    },

    // -------------------------
    // 虚拟模块加载
    // -------------------------
    load(id: string) {
      if (id === RESOLVED_VIRTUAL_MODULE_ID) {
        // 这里返回运行时代码:
        // - messages: 当前语言包
        // - t: 翻译函数
        // - setLang/getCurrentLang: 语言切换函数
        return `
          const messages = ${JSON.stringify(allMessages)};
          let currentLang = '${defaultLang}';

          export function t(text, context = '') {
            const key = context ? \`\${text}#\${context}\` : text;
            return messages[key]?.[currentLang] || text;
          }

          export function setLang(lang) { currentLang = lang; }
          export function getCurrentLang() { return currentLang; }
        `;
      }
    },

    // -------------------------
    // 源码扫描阶段
    // -------------------------
    transform(code: string, id: string) {
      // 忽略 node_modules
      if (id.includes("node_modules")) return;

      // 扫描源码,收集 t(...) / t`...` 调用
      const foundItems = scanForI18nTexts(code);

      for (const item of foundItems) {
        // key = 原文 + 可选上下文
        const key = item.context ? `${item.text}#${item.context}` : item.text;

        // 针对每个目标语言,判断是否已存在翻译
        for (const lang of targetLangs) {
          const langMessages = allMessages[key] || {};
          if (!langMessages[lang]) {
            // 尚未存在翻译,加入待翻译队列
            pendingQueue.set(`${lang}:${key}`, { ...item, key });
          }
        }
      }

      // 返回原始代码,不修改
      return code;
    },

    // -------------------------
    // 构建结束 / 批量翻译
    // -------------------------
    async buildEnd() {
      if (!pendingQueue.size) return;

      // 按语言分组
      const groupedByLang: Record<Lang, PendingItem[]> = {};
      for (const [compoundKey, item] of pendingQueue) {
        const [lang] = compoundKey.split(":");
        groupedByLang[lang] ||= [];
        groupedByLang[lang].push(item);
      }

      // 对每个目标语言调用 LLM 翻译(伪逻辑)
      for (const lang in groupedByLang) {
        const items = groupedByLang[lang];

        // === 这里可以调用 LLM API ===
        // const results = await llm.translateBatch(items)

        // 伪结果示例
        const results: { key: string; value: string }[] = [];

        // 将翻译结果写入内存缓存
        for (const { key, value } of results) {
          allMessages[key] ||= {};
          allMessages[key][lang] = value;
        }
      }

      // 持久化到单 JSON 文件,人工可校准
      saveLocales(allMessages);

      // 清空队列,避免重复翻译
      pendingQueue.clear();
    },
  };
}

8. 实际使用体验

封装后,开发侧使用方式非常简单:

<div>{{ t`你好` }}</div>
<button>{{ t('提交', '表单操作') }}</button>

切换语言:

setLang("english");

扩展新语言(如日文、法文、韩文)时,只需调整插件配置中的 targetLangs无需额外维护 Key 或复制文案文件

9. 总结

这套 i18n 方案的核心价值不在于“AI 翻译”本身,而在于:

  1. 文案回归自然语言,而不是 Key
  2. 翻译与维护成本前移到工具链
  3. 通过上下文 + LLM 提升翻译质量
  4. 单 JSON 文件 + 虚拟模块 + 增量翻译降低多语言长期成本
  5. 支持人工校准,只处理未翻译 Key,安全可靠

目前这仍是一个持续演进中的实践方案,但在复杂业务、多语言项目中,已经展现出明显的工程价值。

如果你对 i18n、工程自动化或 AI 在前端工具链中的应用有不同看法,欢迎一起交流和探讨。

LeetCode 11. 盛最多水的容器

图解算法:为什么一定要移动那个短板?| LeetCode 11. 盛最多水的容器

前言:在面试中,有一类题目看似简单,暴力解法也能做,但面试官真正想看的是你如何将 

O(N2)

 的复杂度优化到 

O(N)

。LeetCode 11 题“盛最多水的容器”就是这类题目的典范。今天我们不背代码,而是深入探讨背后的贪心策略双指针思维。

一、 题目直觉与“木桶效应”

题目的目标非常直观:在一个数组中找到两条垂线,使得它们与 X 轴围成的容器能盛最多的水。

我们要计算的是矩形面积:

Area=Width×HeightArea=Width×Height

这里有一个物理常识至关重要,那就是木桶效应 (Short Board Effect)
一个木桶能装多少水,取决于最短的那块木板。

映射到题目中:

  • 宽度 (Width) :两条垂线在 X 轴上的距离 right - left。
  • 高度 (Height) :两条垂线中较矮的那一条,即 Math.min(height[left], height[right])。

二、 痛点:为什么暴力解法不行?

最容易想到的思路是双重循环:计算所有两两组合的面积,然后取最大值。

然而以我的经验,当你写下双循环的时候,你自己心中的无奈,没有人会比你更了解

面试官在了解到你的解题思路时,就已经将你pass掉了

任何算法题,写双循环的结果只有死路一条(因为他会认为你对空间与时间复杂度没有概念,或者你的实力就这么多)

JavaScript

//  暴力解法
let max = 0;
for (let i = 0; i < len; i++) {
    for (let j = i + 1; j < len; j++) {
        // 计算每一对组合...
    }
}

这种解法的时间复杂度是

O(N2)


题目提示中数组长度 

NN

 可达 

105105

。这意味着计算量高达 

10101010

 次。在通常的算法竞赛或面试标准中,这绝对会触发 TLE (Time Limit Exceeded)  超时错误。

我们需要一种更聪明的做法,将复杂度降维打击到

O(N)

三、 核心:双指针法与贪心策略

我们要优化的核心是:如何尽可能少地遍历,却能保证不漏掉最大值?

1. 初始布局:拉满宽度

既然面积 = 宽 × 高,我们不妨先让宽度最大
我们在数组的头尾各放置一个指针:left 指向开头,right 指向结尾。

此时,容器的底宽是最大的。接下来的每一步移动,宽度必然减小。为了弥补宽度的损失,我们必须寻找更高的垂线。

2. 决策困境:移动哪一根?

这是本题最难理解的点。假设现在的状况是:

  • 左边柱子高度 left_h = 2

  • 右边柱子高度 right_h = 8

  • 当前宽度 w = 10

  • 当前面积 = 

    10=202×10=20
    

现在我们需要向内移动一个指针,是移左边的(矮的),还是移右边的(高的)?

假设我们移动高的那一边(右边):

宽度肯定变小了(变成 9)。
而水位高度取决于谁?依然是左边那个不动的短板(高度 2)。
无论右边新遇到的柱子是高耸入云还是矮小不堪,容器的有效高度最高只能是 2

新面积=9×min⁡(2,新高度)≤18新面积=9×min(2,新高度)≤18

结论:  移动高板,宽度减小,高度受限于不动的短板(无法增加)。面积只会变小,绝对不可能变大。  这是一条死路。

贪心策略:移动矮的那一边(左边):

虽然宽度变小了(变成 9),但我们抛弃了当前的短板(高度 2)。
如果运气好,左边新遇到的柱子高度是 10,那么新的有效高度就变成了 8(受限于右边)。

新面积=9×8=72新面积=9×8=72

结论:  只有移动短板,我们才有可能找到更高的柱子来弥补宽度的损失。

这就是本题的贪心逻辑:  每一步我们都排除掉那个“导致当前高度受限”的短板,因为它已经发挥了它的最大潜力(在当前最宽的情况下),保留它没有任何意义。

四、 代码实现

理解了上述逻辑,代码实现就非常简单了。

JavaScript

/**
 * @param {number[]} height
 * @return {number}
 */
var maxArea = function(height) {
    // 1. 定义双指针,分别指向头尾
    let left = 0;
    let right = height.length - 1;
    let maxWater = 0;
    
    // 2. 当指针未相遇时循环
    while (left < right) {
        // 3. 计算当前面积
        // 高度取决于短板 (木桶效应)
        const currentHeight = Math.min(height[left], height[right]);
        const currentWidth = right - left;
        
        // 更新历史最大值
        maxWater = Math.max(maxWater, currentHeight * currentWidth);
        
        // 4. 核心决策:移动较矮的一侧
        // 如果左边是短板,那左边这块板子在当前宽度下已经发挥了最大价值,
        // 再往里缩宽度只会变小,保留左边没意义,不如向右移试试看有没有更高的。
        if (height[left] < height[right]) {
            left++;
        } else {
            right--;
        }
    }
    
    return maxWater;
};

五、 复杂度分析

  • 时间复杂度:

    O(N)
    

    双指针 left 和 right 总共遍历整个数组一次。相比于暴力解法的 

    O(N2)
    

    ,效率提升是巨大的。

  • 空间复杂度:

    O(1)O(1) 
    

    我们只需要存储指针索引和 maxWater 几个变量,不需要额外的数组空间。

六、 总结

所谓算法优化,往往不是代码写得有多复杂,而是思维模型的转换

LeetCode 11 题通过观察“木桶效应”,让我们明白:保留长板、抛弃短板是唯一可能获得更大收益的路径。这种通过排除法将搜索空间从二维矩阵(所有组合)压缩到一维线性扫描(双指针)的过程,就是算法中的降维打击

希望这篇文章能帮你彻底搞懂双指针解法!

JS-深度解构JS事件循环(Event Loop)

前言

为什么 JavaScript 是单线程的却能处理异步 IO?为什么 setTimeout 并不总是准时?本文将从宏观的执行栈、任务队列,一直深入到浏览器底层的任务调度逻辑,带你彻底看透事件循环。

一、 为什么需要事件循环?

JavaScript 的核心是单线程的,这意味着它只有一个主线程来处理 DOM 解析、样式计算、脚本执行等。如果某个任务耗时过长,页面就会“卡死”。为了协调同步任务与异步任务(输入事件、网络请求、定时器),浏览器引入了事件循环系统来统一调度和处理这些任务。


二、 核心组件:执行栈与任务队列

1. 执行栈 (Execution Stack)

当多个方法被调用的时候,因为js是单线程的,所以每次只能执行一个方法,于是这些方法被排到了一个单独的地方,这个地方就是执行栈。执行栈里面执行的都是同步的操作。

2. 事件队列 (Task Queue)

  • 在js执行过程中如果遇到异步事件(如 Ajax、定时器),就会首先将这个异步事件交给对应的浏览器模块(如网络进程),继续执行执行栈里面的任务。
  • 当异步事件返回结果后,js不会立即执行这个回调,会将事件加入到事件队列中,只有当执行栈里面的全部执行完以后,主线程才会去查找事件队列中是否有任务。
  • 如果有,那么主线程会取出事件队列里面排在最前面的事件,将这个事件对应的回调加入到执行栈中,然后执行其中的同步代码。然后在继续观察执行栈里面是否有任务,依次反复...就形成了一个无限的循环。
  • 这就是这个过程被称为事件循环(Event loop)的原因。

循环逻辑:

  1. 检查执行栈是否为空。
  2. 若为空,从事件队列头部取出一个任务推入执行栈。
  3. 循环往复。

三、 异步任务的“等级”:宏任务与微任务

并非所有的异步任务优先级都一样。在同一次循环中,微任务永远在下一次宏任务之前执行!!!

类型 包含任务 执行时机
宏任务 (MacroTask) setTimeout, setInterval, ajax, dom事件 每次事件循环开始时处理一个
微任务 (MicroTask) Promise.then/catch, MutaionObserver, process.nextTick (Node.js) 当前执行栈清空后,立即清空整个微任务队列

注意: new Promise() 构造函数内部的代码是同步执行的,只有 .then().catch() 里的回调才是微任务。(后续会专门出一篇promise相关文章)


四、 底层揭秘:定时器是如何实现的?

很多开发者认为 setTimeout 是直接进入消息队列的,但浏览器底层其实维护了一个延迟执行队列 (Delayed Incoming Queue)

1. 任务数据结构

当调用 setTimeout 时,渲染进程内部会创建一个任务结构体:

struct DelayTask{
  int64 id;
  CallBackFunction cbf;
  int start_time;
  int delay_time;
};

2. 执行循环模拟

浏览器的主线程循环逻辑伪代码如下:

void MainThread() {
  for(;;) {
    // 1. 执行普通消息队列中的一个任务 (宏任务)
    Task task = task_queue.takeTask();
    ProcessTask(task);
    
    // 2. 执行微任务队列 (本阶段由 JS 引擎控制)
    // ProcessMicrotasks(); 

    // 3. 执行延迟队列中到期的任务 (定时器任务在此处理)
    ProcessDelayTask();

    if(!keep_running) break; 
  }
}

关键点: 浏览器会在处理完一个普通宏任务后,去检查延迟队列中是否有任务到期(ProcessDelayTask),并依次执行它们。


五、 面试模拟题

Q1:为什么 setTimeout(fn, 0) 并不一定是 0ms 后执行?

参考回答:

  1. 浏览器最小限制:HTML5 规范规定,如果定时器嵌套超过 5 层,最小延迟为 4ms。
  2. Event Loop 阻塞:由于定时器任务是在 ProcessDelayTask 中处理的,如果当前的宏任务(比如一个复杂的计算循环)执行时间过长,主线程就无法及时跳转到延迟队列的检查步骤,导致定时器推迟执行。

Q2:说出以下代码的打印顺序:

console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => console.log('3'));
console.log('4');

参考回答:

1 -> 4 -> 3 -> 2。

  • 1, 4 是同步任务,直接输出。
  • 3 是微任务,在当前脚本(宏任务)执行完后立即执行。
  • 2 是下一次宏任务。

Q3:MutationObserver 属于什么任务?它有什么应用场景?

参考回答:

MutationObserver 属于微任务。它用于监听 DOM 树的变化。由于它是微任务,它会在 DOM 变化引起的多次修改全部完成后,在浏览器重新渲染之前异步执行,这比传统的 Mutation Events 性能更高,且不会阻塞主线程渲染。


六、 总结建议

  • 理解微任务的优先级:微任务是在当前宏任务结束后的“插队”行为,适合处理需要立即反馈的异步逻辑。

JS-new 操作符

前言

在 JavaScript 面向对象编程中,new 关键字是实例化对象的核心。面试官常常通过“手写 new”来考察你对原型链this 绑定以及构造函数返回值的理解。本文将带你从原理到实现,彻底搞懂 new 背后的魔法。

一、 new 到底干了什么?

当我们使用 new Person() 时,JS 引擎在背后默默执行了以下 4 个步骤

  1. 创建一个新对象:在内存中创建一个新的空对象(例如 obj = {})。

  2. 链接原型:将新对象的 __proto__ 属性指向构造函数的 prototype,从而实现原型继承(让实例能访问原型上的方法)。

  3. 绑定 this:将构造函数内部的 this 绑定到这个新对象上,并执行构造函数(为新对象添加属性)。

  4. 返回对象

    • 如果构造函数显式返回了一个对象(或函数),则返回该结果。
    • 如果构造函数没有返回对象(返回基本类型或无返回值),则返回步骤 1 创建的新对象

二、 手写 myNew 实现

根据上述原理,我们可以实现一个自己的 myNew 函数。

/**
 * 手写 new 操作符
 * @param {Function} Constructor 构造函数
 * @param  {...any} args 传递的参数
 */
function myNew(Constructor, ...args) {
  // 1. 创建一个新对象,并将其原型指向构造函数的 prototype
  const obj = Object.create(Constructor.prototype);

  // 2. 将构造函数的 this 绑定到新对象上,并执行构造函数
  const result = Constructor.apply(obj, args);

  // 3. 处理返回值逻辑 (这是面试中最容易忽视的细节!)
  // 如果构造函数返回的是对象(不为null)或函数,则返回该结果;否则返回新创建的 obj
  if ((typeof result === 'object' && result !== null) || typeof result === 'function') {
    return result;
  }
  
  // 4. 返回新对象
  return obj;
}

测试用例:

function Person(name, age) {
  this.name = name;
  this.age = age;
  // 情况 1: 没有返回值(默认返回 this)
}

function Student(name) {
  this.name = name;
  // 情况 2: 返回一个对象
  return { name: 'Special Student', grade: 100 };
}

function NumberObj() {
  this.a = 1;
  // 情况 3: 返回一个基本类型
  return 123;
}

// 测试 1:正常情况
const per = myNew(Person, 'Ouyang', 23);
console.log(per); // Person { name: 'Ouyang', age: 23 }
console.log(per instanceof Person); // true

// 测试 2:构造函数返回对象
const stu = myNew(Student, 'XiaoMing');
console.log(stu); // { name: 'Special Student', grade: 100 } (this 被忽略了)

// 测试 3:构造函数返回基本类型
const num = myNew(NumberObj);
console.log(num); // NumberObj { a: 1 } (返回值 123 被忽略)

三、 深度解析:返回值陷阱

这是面试中最常挖的坑。

  • 场景 A:构造函数内部没有 return,或者 return 一个基本数据类型(Number, String, Boolean, null, undefined)。

    • 结果new 操作符会忽略这个返回值,直接返回新创建的实例对象
  • 场景 B:构造函数内部 return 一个引用类型(Object, Array, Function)。

    • 结果new 操作符会直接返回这个引用类型,新创建的实例对象会被丢弃(且 this 上的属性赋值也会失效)。

四、 面试模拟题(挑战一下)

Q1:Object.create()new 有什么区别?

参考回答:

  • new:不仅创建新对象并继承原型,还会执行构造函数,进行属性初始化。
  • Object.create():只负责创建一个新对象并继承原型,不会执行构造函数

Q2:为什么代码中建议使用 Object.create 而不是 obj.__proto__

参考回答: __proto__ 是非标准属性(虽然浏览器支持),直接修改它会破坏 JS 引擎的优化,严重影响性能。Object.create() 是 ES5 标准方法,更规范且性能更好。

Q3:如果构造函数返回 nullnew 出来的结果是什么?

参考回答: 结果是新创建的实例对象。 因为 typeof null === 'object',但 null 是个特殊值。在 new 的规范中,如果返回的是对象类型但值为 null,仍然会忽略它,返回实例对象。这就是为什么在手写代码中我们要判断 result !== null


结语

手写 new 是前端基础能力的试金石。理解了这 4 个步骤,你不仅能轻松应对面试,还能更深刻地理解 JavaScript 的继承机制。

如果你觉得这篇笔记对你有帮助,欢迎点赞收藏! 🚀

从 Generator 到 Async/Await:彻底搞懂 JS 异步编程的终极解决方案

在 JavaScript 的发展长河中,异步编程一直是开发者最头疼的痛点之一。从最早的回调函数,到 Promise 的链式调用,再到如今的 Async/Await,我们一直在追求一个终极目标:用同步的思维,写异步的代码。

今天,我们不谈枯燥的 API 文档,而是深入底层,从 Generator 原理出发,彻底搞懂为什么 Async/Await 被称为 JS 异步编程的“终极解决方案”。

一、 为什么我们需要 Async/Await?

要理解一项技术,必须先理解它要解决的问题。

1. 回调地狱(Callback Hell)的梦魇

在 ES6 之前,异步操作严重依赖回调函数。一旦业务逻辑复杂,比如需要串行请求 A、B、C 三个接口,代码就会变成这样:

JavaScript

getData(function(a) {
    getMoreData(a, function(b) {
        getEvenMoreData(b, function(c) {
            console.log(c); // 著名的“金字塔”代码
        });
    });
});

这种代码可读性差、难以调试、且错误处理极其繁琐

2. Promise 的进步与局限

Promise 的出现将回调嵌套扁平化了,它通过链式调用(.then())解决了“金字塔”问题:

JavaScript

getData()
  .then(a => getMoreData(a))
  .then(b => getEvenMoreData(b))
  .catch(err => console.error(err));

这无疑是巨大的进步。但它依然不够完美:大量的 .then 破坏了代码的语义连续性,我们依然无法像写同步代码那样直观地表达逻辑。

我们的终极诉求是:能否让异步代码看起来就像 const a = logic(); const b = logic(a); 这样符合人类线性直觉?

答案就是 Async/Await

二、 核心原理:并非魔法,而是语法糖

Async/Await 并没有引入全新的底层机制,它本质上是 Generator 函数 + Promise + 自动执行器 的语法糖。

要理解它,必须理解 Generator(生成器)  的核心能力:暂停与恢复

1. Generator:交出执行权

Generator 函数(function*)通过 yield 关键字,可以让函数在执行过程中暂停,将 CPU 控制权交还给外部,并在未来某个时刻从断点处恢复执行。

JavaScript

function* generatorFn() {
    console.log('Start');
    // 1. 函数执行到这里暂停,交出控制权,并返回 'Hello'
    const result = yield 'Hello'; 
    // 3. 外部调用 next(val) 后,函数从这里恢复,result 接收外部传入的值
    console.log('Resumed with:', result); 
}

const iterator = generatorFn();
const first = iterator.next(); // 输出: Start, first.value = 'Hello'
// 2. 这里可以做任何异步操作...
iterator.next('World');        // 输出: Resumed with: World

2. Async/Await 的实现公式

如果我们将 Generator 和 Promise 结合起来,就得到了 Async/Await 的雏形:

  1. 暂停:遇到 await (即 yield),函数暂停执行。
  2. 等待:await 后面通常跟着一个 Promise(异步状态容器)。
  3. 恢复:当 Promise 状态变为 Resolved,自动执行器调用 next(data),将结果传回函数内部,代码继续向下执行。

公式总结
async function ≈ function* + 自动执行器(自动处理 yield 和 next)

三、 实战:从错误示范到最佳实践

基于大家提供的素材,我们来看看在浏览器和 Node.js 环境下,如何正确使用 Async/Await(包含对原始素材中错误的修正)。

场景一:浏览器端 Fetch 请求

原始素材中直接 console.log(res) 是拿不到数据的,因为 fetch 返回的 Response 对象解析 JSON 也是异步的。

最佳实践:

Html

<script>
// ES8 async 修饰函数
const main = async () => {
    try {
        console.log('开始请求...');
        
        // 1. await 等待 fetch 完成,拿到响应头
        // 这里的 await 相当于暂停函数,直到网络请求返回
        const response = await fetch('https://api.github.com/users/shunwuyu/repos');
        
        // 2. 注意!解析 JSON 也是异步操作,必须再次 await
        const data = await response.json();
        
        console.log('数据获取成功:', data);
    } catch (error) {
        // 同步写法的最大优势:可以直接用 try-catch 捕获异步错误
        console.error('请求失败:', error);
    }
}
main();
</script>

场景二:Node.js 文件读取

在现代 Node.js 中,我们常用 fs/promises。

修正后的最佳实践:

JavaScript

import fs from 'fs/promises'; // 引入返回 Promise 的 fs 模块
import path from 'path';

const main = async () => {
    const filePath = './1.html';
    
    try {
        // 像写同步代码一样读取文件
        // 甚至不需要回调函数,也不需要 .then
        const html = await fs.readFile(filePath, 'utf-8');
        
        console.log('文件读取成功,长度:', html.length);
        console.log(html.substring(0, 50) + '...'); // 打印前50个字符
        
    } catch (err) {
        console.error('文件读取出错:', err);
    }
}

main();

四、 总结

Async/Await 的出现,标志着 JavaScript 异步编程的成熟。

  1. 它利用 Generator 实现了函数的暂停与恢复。
  2. 它利用 Promise 封装了异步操作的状态。
  3. 它通过 自动执行 机制,让我们能以符合直觉的线性逻辑编写复杂的异步代码。

掌握了 Async/Await,不仅仅是掌握了一个关键字,更是掌握了 JavaScript 协程控制的精髓。拒绝回调地狱,从今天开始。


JS -彻底搞懂 call、apply、bind 的区别与应用

前言

在 JavaScript 中,this 的指向是动态的,这虽然灵活,但也常让我们头疼。而 callapplybind 就是我们手中的“魔法棒”,专门用来手动控制 this 的指向。它们有什么区别?分别在什么场景下使用?本文带你一探究竟。

一、 三大方法详解

这三个方法都挂载在 Function.prototype 上,这意味着所有的函数都可以调用它们。

1. call()

  • 作用:修改函数的 this 指向,并立即执行该函数。

  • 参数

    1. thisArgthis 需要绑定的对象。
    2. arg1, arg2, ...参数列表,直接按顺序传入。
  • 默认行为:如果不传 thisArg 或传 null/undefined,在非严格模式下指向 window

fn.call(obj, agr1,agr2,arg3,arg4,.....)

2. apply()

  • 作用:修改函数的 this 指向,并立即执行该函数。

  • 参数

    1. thisArgthis 需要绑定的对象。
    2. argsArray数组(或类数组) ,数组内的元素会被展开传入函数。
fn.apply(obj, [agr1,agr2,arg3,arg4,.....])

3. bind()

  • 作用:修改函数的 this 指向,但不会立即执行
  • 返回值:返回一个新的函数(称为绑定函数)。
  • 硬绑定bind 返回的新函数,其 this 指向一旦被绑定,后续再使用 callapply 都无法再次修改。
  • 参数:与 call 相同,接受参数列表。支持柯里化(预设部分参数)。
bind(thisArg, arg1, arg2, arg3, ...)

二、 核心区别对比(一张表看懂)

方法 执行时机 参数格式 返回值 核心场景
call 立即执行 参数列表 (arg1, arg2) 函数执行结果 对象继承、借用方法
apply 立即执行 数组 ([arg1, arg2]) 函数执行结果 数学计算、数组合并
bind 稍后执行 参数列表 (arg1, arg2) 新函数 事件绑定、回调函数

三、 代码实战与纠错

让我们通过一个经典的例子来看它们的具体表现。

const obj = {
  name: 'Original',
  fn: function(a, b) {
    console.log(this.name, a, b);
  }
}

const db = { name: 'DataBase' };

// 1. 原始调用
obj.fn(1, 2); 
// 输出: "Original" 1 2

// 2. call 调用:传参列表
obj.fn.call(db, 3, 4); 
// 输出: "DataBase" 3 4

// 3. apply 调用:传参数组
obj.fn.apply(db, [5, 6]); 
// 输出: "DataBase" 5 6

// 4. bind 调用:返回新函数,手动执行
const boundFn = obj.fn.bind(db, 7, 8);
boundFn(); 
// 输出: "DataBase" 7 8

// 5. bind 的连续修改无效性(面试坑点)
const doubleBind = obj.fn.bind(db).bind({ name: 'Error' });
doubleBind();
// 输出: "DataBase" undefined undefined (第二次 bind 无效)

四、 常见应用场景(面试加分项)

仅仅知道语法是不够的,面试官更看重你知道怎么用。

1. 数组求最大值 (apply)

利用 apply 接受数组参数的特性,结合 Math.max

const nums = [5, 10, 20, 1];
const max = Math.max.apply(null, nums); // 20
// ES6 写法: Math.max(...nums)

2. 类数组转数组 (call)

利用 call 借用数组的 slice 方法。

function func() {
  const args = Array.prototype.slice.call(arguments);
  console.log(args); // 变成了真数组
}

3. React/Vue 中的事件绑定 (bind)

防止回调函数在执行时 this 丢失(指向 undefinedwindow)。

this.handleClick = this.handleClick.bind(this);

五、 面试模拟题

Q1:callapply 的唯一区别是什么?

参考回答:

它们的唯一区别在于传参方式。call 需要把参数按顺序一个个传进去(参数列表),而 apply 需要把参数放在一个数组(或类数组)里传进去。助记口诀:"a" for array (apply), "c" for comma (call)。

Q2:为什么 bind 返回的函数,再次使用 call 无法修改 this

参考回答:

这涉及 bind 的内部实现。bind 返回的函数内部已经通过闭包锁定了 this(通常称为硬绑定)。也就是类似 return function() { return originalFn.apply(that, arguments) } 的结构。无论外部怎么 call,内部的 apply 永远使用的是第一次绑定的 that。

Q3:手写一个简单的 bind

      Function.prototype.myBind = function (context, ...args) {
        // 1. 保存当前的函数(this 指向原函数)
        const fn = this;
        // 2. 返回一个新的函数
        return function (...innerArgs) {
          // 3. 将预设参数和新参数合并,并用 apply 执行原函数
          return fn.apply(context, args.concat(innerArgs));
        };
      };
      const obj = {
        name: "Original",
        fn: function (a, b) {
          console.log(this.name, a, b);
        },
      };
      const boundFn = obj.fn.myBind({ name: 'DataBase' }, 7, 8);
      boundFn();

| ES6 | 异步 | 闭包 | 原型链 | DOM操作 | 事件处理 |

一、ES6+ 新特性

ES6(ECMAScript 2015)及后续的 ES7-ES14 被统称为 ES6+,是 JavaScript 语言的重大升级,解决了 ES5 时代的语法冗余、作用域混乱、功能缺失等问题,大幅提升了代码的可读性、可维护性和开发效率。

1. 块级作用域与变量声明

ES5 中只有全局作用域和函数作用域,var 声明的变量存在 “变量提升” 和 “作用域穿透” 问题,极易引发 bug。ES6 新增 letconst 关键字,引入块级作用域({} 包裹的区域):

  • let:声明可变变量,仅在当前块级作用域有效,无变量提升,不允许重复声明;
  • const:声明常量,一旦赋值不可修改(引用类型仅保证地址不变),同样遵循块级作用域规则。示例:
// ES5 问题:变量提升+作用域穿透
if (true) {
  var a = 10;
}
console.log(a); // 10(全局作用域可访问)

// ES6 解决
if (true) {
  let b = 20;
  const c = 30;
}
console.log(b); // ReferenceError: b is not defined
console.log(c); // ReferenceError: c is not defined

2. 箭头函数

简化函数声明语法,核心特性:

  • 语法简洁:单参数可省略括号,单返回语句可省略大括号和 return
  • 无独立 this:箭头函数的 this 继承自外层作用域,解决了 ES5 中 this 指向混乱的问题(如回调函数中 this 丢失);
  • 不能作为构造函数:无法使用 new 调用,无 arguments 对象(可改用剩余参数)。示例:
// ES5 函数
const add = function(a, b) {
  return a + b;
};

// ES6 箭头函数
const add = (a, b) => a + b;

// this 指向示例
const obj = {
  name: "张三",
  fn1: function() {
    setTimeout(function() {
      console.log(this.name); // undefined(this 指向全局)
    }, 100);
  },
  fn2: function() {
    setTimeout(() => {
      console.log(this.name); // 张三(this 继承自 fn2 的作用域)
    }, 100);
  }
};
obj.fn1();
obj.fn2();

3. 解构赋值

允许从数组 / 对象中提取值,赋值给变量,简化数据提取逻辑:

  • 数组解构:按索引匹配,支持默认值;
  • 对象解构:按属性名匹配,支持重命名和默认值。示例:
// 数组解构
const [a, b, c = 30] = [10, 20];
console.log(a, b, c); // 10 20 30

// 对象解构
const { name: userName, age = 18 } = { name: "李四" };
console.log(userName, age); // 李四 18

4. 扩展运算符与剩余参数

  • 扩展运算符(...):将数组 / 对象展开为单个元素,用于合并数据、传递参数;
  • 剩余参数(...):收集剩余的参数,转为数组,替代 arguments。示例:
// 扩展运算符
const arr1 = [1, 2, 3];
const arr2 = [4, 5, 6];
const arr3 = [...arr1, ...arr2]; // [1,2,3,4,5,6]

const obj1 = { a: 1 };
const obj2 = { b: 2 };
const obj3 = { ...obj1, ...obj2 }; // {a:1, b:2}

// 剩余参数
const sum = (...args) => args.reduce((total, cur) => total + cur, 0);
console.log(sum(1,2,3)); // 6

5. 模板字符串

用反引号()包裹字符串,支持换行和变量插值(${变量}`),解决 ES5 字符串拼接繁琐的问题:

const name = "王五";
const age = 20;
// ES5 拼接
const str1 = "姓名:" + name + ",年龄:" + age + "岁";
// ES6 模板字符串
const str2 = `姓名:${name},年龄:${age}岁`;

6. 其他核心特性

  • Set/Map 数据结构:Set 用于存储唯一值(数组去重),Map 键值对集合(键可为任意类型,替代对象);
  • Class 类:语法糖,简化原型链继承,提供 constructorextendssuper 等关键字;
  • 模块化(import/export):替代 CommonJS/AMD,实现按需加载,提升代码模块化程度;
  • 可选链(?.)、空值合并(??):ES2020 特性,简化空值判断,避免 Cannot read property 'xxx' of undefined 错误。

ES6+ 新特性的核心价值在于 “语法简化” 和 “功能补全”,让 JavaScript 从 “脚本语言” 向 “工程化语言” 迈进,是现代前端开发(React/Vue/TypeScript)的基础。

二、异步(Promise, async/await)

JavaScript 是单线程语言,默认同步执行代码,但网络请求、定时器、文件操作等场景需要异步处理,否则会阻塞主线程。异步编程经历了 “回调函数 → Promise → async/await” 的演进,核心目标是解决 “回调地狱”,让异步代码更易读、易维护。

1. 异步编程的核心问题:回调地狱

ES5 中异步操作依赖回调函数,多个异步嵌套时会出现 “回调地狱”(代码层级深、可读性差、错误处理繁琐):

// 回调地狱:获取用户信息 → 获取用户订单 → 获取订单详情
$.get("/api/user", (user) => {
  $.get(`/api/order?userId=${user.id}`, (order) => {
    $.get(`/api/orderDetail?orderId=${order.id}`, (detail) => {
      console.log(detail);
    }, (err) => {
      console.error("获取订单详情失败", err);
    });
  }, (err) => {
    console.error("获取订单失败", err);
  });
}, (err) => {
  console.error("获取用户失败", err);
});

问题:层级嵌套过深,错误处理分散,代码难以调试和维护。

2. Promise:异步操作的标准化封装

Promise 是 ES6 引入的异步编程解决方案,本质是一个对象,代表异步操作的 “未完成 / 成功 / 失败” 状态,核心特性:

  • 三种状态:pending(进行中)、fulfilled(已成功)、rejected(已失败),状态一旦改变不可逆转;
  • 两个回调:then() 处理成功结果,catch() 处理失败结果,支持链式调用;
  • 解决回调地狱:通过链式调用替代嵌套,错误可统一捕获。

(1)Promise 基本用法

// 创建 Promise 对象
const getPromise = (url) => {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.open("GET", url);
    xhr.onload = () => {
      if (xhr.status === 200) {
        resolve(JSON.parse(xhr.responseText)); // 成功:调用 resolve
      } else {
        reject(new Error(xhr.statusText)); // 失败:调用 reject
      }
    };
    xhr.onerror = () => {
      reject(new Error("网络请求失败"));
    };
    xhr.send();
  });
};

// 链式调用:解决回调地狱
getPromise("/api/user")
  .then((user) => getPromise(`/api/order?userId=${user.id}`))
  .then((order) => getPromise(`/api/orderDetail?orderId=${order.id}`))
  .then((detail) => console.log(detail))
  .catch((err) => console.error("请求失败", err)); // 统一捕获所有错误

(2)Promise 常用方法

  • Promise.all():接收多个 Promise 数组,全部成功才返回结果数组,一个失败则立即失败;
  • Promise.race():接收多个 Promise 数组,返回第一个完成的 Promise 结果(无论成功 / 失败);
  • Promise.resolve()/Promise.reject():快速创建成功 / 失败的 Promise 对象;
  • Promise.allSettled():等待所有 Promise 完成(无论成功 / 失败),返回所有结果(包含状态和值)。

示例(Promise.all):

// 同时请求多个接口,全部完成后处理
const promise1 = getPromise("/api/user");
const promise2 = getPromise("/api/goods");
Promise.all([promise1, promise2])
  .then(([user, goods]) => {
    console.log("用户信息", user);
    console.log("商品信息", goods);
  })
  .catch((err) => console.error("某个请求失败", err));

3. async/await:异步代码同步化

ES2017 引入的 async/await 是 Promise 的语法糖,允许用 “同步代码的写法” 处理异步操作,核心规则:

  • async 修饰函数:使函数返回一个 Promise 对象;
  • await 修饰 Promise:暂停函数执行,直到 Promise 状态变为成功,返回结果;若 Promise 失败,需用 try/catch 捕获错误。

(1)基本用法(解决回调地狱的终极方案)

// 封装异步请求函数(返回 Promise)
const getUser = () => getPromise("/api/user");
const getOrder = (userId) => getPromise(`/api/order?userId=${userId}`);
const getOrderDetail = (orderId) => getPromise(`/api/orderDetail?orderId=${orderId}`);

// async/await 写法:同步风格的异步代码
const getOrderInfo = async () => {
  try {
    const user = await getUser(); // 等待 getUser 完成
    const order = await getOrder(user.id); // 等待 getOrder 完成
    const detail = await getOrderDetail(order.id); // 等待 getOrderDetail 完成
    console.log(detail);
  } catch (err) {
    console.error("请求失败", err); // 统一捕获所有错误
  }
};

getOrderInfo();

(2)async/await 优势

  • 代码扁平化:无嵌套,可读性接近同步代码;
  • 错误处理统一:通过 try/catch 捕获所有异步错误,替代 Promise 的 catch()
  • 调试友好:可在 await 处打断点,调试流程与同步代码一致。

4. 异步编程的核心原则

  • 避免同步阻塞:异步操作始终不阻塞主线程(如定时器、网络请求由浏览器内核的线程处理);
  • 错误处理全覆盖:Promise 需加 catch(),async/await 需包 try/catch,避免未捕获的异步错误;
  • 并行处理优化:多个无依赖的异步操作,用 Promise.all() 替代串行 await,提升执行效率。

异步编程是前端开发的核心难点,Promise 解决了 “回调地狱” 的结构问题,async/await 则让异步代码的可读性达到了同步代码的水平,是现代前端处理网络请求、异步数据加载的标配。

三、闭包和原型链

闭包和原型链是 JavaScript 的两大核心特性,也是面试高频考点。闭包关乎作用域和变量生命周期,原型链则是 JavaScript 实现继承的底层机制,理解这两个概念能帮你突破 “语法使用” 到 “原理理解” 的瓶颈。

1. 闭包(Closure)

(1)闭包的定义

闭包是指 “有权访问另一个函数作用域中变量的函数”,本质是函数作用域链的保留:当内部函数被外部引用时,其所在的作用域不会被垃圾回收机制销毁,从而可以持续访问外层函数的变量。

(2)闭包的形成条件

  1. 存在嵌套函数(内部函数 + 外部函数);
  2. 内部函数引用外部函数的变量 / 参数;
  3. 外部函数执行后,内部函数被外部环境引用(如返回、赋值给全局变量)。

(3)基本用法与示例

// 基础闭包:外部函数执行后,内部函数仍能访问其变量
function outer() {
  const num = 10; // 外部函数的变量
  // 内部函数引用外部变量
  function inner() {
    console.log(num);
  }
  return inner; // 返回内部函数,使其被外部引用
}

const fn = outer(); // outer 执行完毕,但其作用域未被销毁
fn(); // 10(inner 仍能访问 num)

(4)闭包的核心应用场景

  • 封装私有变量:模拟 “私有属性 / 方法”,避免全局变量污染;

    // 封装计数器:count 是私有变量,只能通过方法修改
    function createCounter() {
      let count = 0;
      return {
        increment: () => count++,
        decrement: () => count--,
        getCount: () => count
      };
    }
    
    const counter = createCounter();
    counter.increment();
    counter.increment();
    console.log(counter.getCount()); // 2
    console.log(counter.count); // undefined(无法直接访问)
    
  • 防抖 / 节流函数:利用闭包保存定时器 ID、上次执行时间等状态;

    // 防抖函数(闭包保存 timer 变量)
    function debounce(fn, delay) {
      let timer = null; // 闭包保存 timer,多次调用共享同一个 timer
      return (...args) => {
        clearTimeout(timer);
        timer = setTimeout(() => {
          fn.apply(this, args);
        }, delay);
      };
    }
    
  • 柯里化函数:将多参数函数转为单参数函数,利用闭包缓存已传入的参数。

(5)闭包的注意事项

  • 内存泄漏风险:闭包会保留外层作用域,若长期引用未释放(如赋值给全局变量),会导致变量无法被垃圾回收,占用内存;
  • 解决:使用完闭包后,手动解除引用(如 fn = null),让作用域可以被回收。

2. 原型链(Prototype Chain)

JavaScript 是 “基于原型的面向对象语言”,没有类(ES6 Class 是语法糖),所有对象都通过 “原型” 实现属性和方法的继承,原型链是实现继承的核心机制。

(1)核心概念

  • 原型(prototype):函数特有的属性,指向一个对象,该对象是当前函数创建的所有实例的原型;
  • 隐式原型(__proto__):所有对象(包括函数)都有的属性,指向其构造函数的 prototype
  • 原型链:当访问对象的属性 / 方法时,先在自身查找,找不到则通过 __proto__ 向上查找,直到 Object.prototype,这个查找链条就是原型链。

(2)原型链的基本结构

// 构造函数
function Person(name) {
  this.name = name;
}
// 给原型添加方法
Person.prototype.sayHello = function() {
  console.log(`Hello, ${this.name}`);
};

// 创建实例
const p1 = new Person("张三");

// 原型链查找:p1 → Person.prototype → Object.prototype → null
console.log(p1.name); // 自身属性,直接返回
p1.sayHello(); // p1 自身无 sayHello,查找 p1.__proto__(Person.prototype)找到
console.log(p1.toString()); // p1 和 Person.prototype 无 toString,查找 Object.prototype 找到
console.log(p1.xxx); // 原型链末端为 null,返回 undefined

(3)原型链的核心应用:继承

ES5 中通过修改原型链实现继承(ES6 Class 的 extends 底层仍是原型链):

// 父类
function Parent(name) {
  this.name = name;
}
Parent.prototype.eat = function() {
  console.log(`${this.name} 吃饭`);
};

// 子类
function Child(name, age) {
  Parent.call(this, name); // 继承父类实例属性
  this.age = age;
}
// 继承父类原型方法
Child.prototype = Object.create(Parent.prototype);
Child.prototype.constructor = Child; // 修正构造函数指向

// 子类添加自有方法
Child.prototype.run = function() {
  console.log(`${this.name} 跑步,年龄 ${this.age}`);
};

const child = new Child("李四", 10);
child.eat(); // 继承父类方法
child.run(); // 子类自有方法

(4)原型链的关键规则

  • 所有对象的最终原型是 Object.prototype,其 __proto__null
  • 函数的 prototype 是普通对象,Function.prototype 是函数(特殊);
  • 修改原型会影响所有实例(原型共享特性)。

3. 闭包与原型链的关联

闭包关注 “作用域和变量保留”,原型链关注 “对象属性继承”,二者共同构成 JavaScript 的核心底层逻辑:闭包让函数可以突破作用域限制访问变量,原型链让对象可以突破自身结构继承方法,是理解 JavaScript 设计思想的关键。

四、DOM 操作和事件处理

DOM(文档对象模型)是浏览器将 HTML 文档解析成的树形结构,前端开发的核心是通过 JavaScript 操作 DOM 实现页面交互,事件处理则是响应用户操作(点击、输入、滚动等)的核心机制。

1. DOM 操作

DOM 操作分为 “查找节点”“创建 / 插入节点”“修改节点”“删除节点” 四类,核心是操作 DOM 树的节点(元素节点、文本节点、属性节点)。

(1)查找 DOM 节点(核心)

查找是 DOM 操作的第一步,常用方法:

  • 按 ID 查找:document.getElementById("id") → 返回单个元素(效率最高);
  • 按类名查找:document.getElementsByClassName("className") → 返回 HTMLCollection(动态集合);
  • 按标签名查找:document.getElementsByTagName("tagName") → 返回 HTMLCollection;
  • 按选择器查找:document.querySelector("selector")(返回第一个匹配元素)、document.querySelectorAll("selector")(返回 NodeList,静态集合)→ 最灵活,支持 CSS 选择器。

示例:

// 按 ID 查找
const box = document.getElementById("box");

// 按选择器查找
const item = document.querySelector(".list .item");
const items = document.querySelectorAll(".list .item"); // NodeList 可通过 forEach 遍历

(2)创建与插入节点

动态生成页面内容的核心,常用方法:

  • 创建元素:document.createElement("tagName")

  • 创建文本节点:document.createTextNode("text")

  • 插入节点:

    • parent.appendChild(child):将子节点插入父节点末尾;
    • parent.insertBefore(newNode, referenceNode):将新节点插入参考节点之前;
    • element.innerHTML:直接通过 HTML 字符串插入节点(简洁但有 XSS 风险)。

示例:

// 创建元素并插入
const ul = document.querySelector("ul");
const li = document.createElement("li");
li.textContent = "新列表项"; // 设置文本内容(安全,无 XSS)
ul.appendChild(li);

// innerHTML 方式(慎用,避免用户输入内容)
ul.innerHTML += "<li>新列表项</li>";

(3)修改 DOM 节点

  • 修改属性:element.setAttribute("attr", "value")(设置属性)、element.getAttribute("attr")(获取属性)、element.removeAttribute("attr")(移除属性);

    const img = document.querySelector("img");
    img.setAttribute("src", "new.jpg");
    console.log(img.getAttribute("src")); // new.jpg
    
  • 修改样式:

    • 行内样式:element.style.cssProperty = "value"(驼峰命名,如 backgroundColor);
    • 类名样式:element.classList.add("className")element.classList.remove("className")element.classList.toggle("className")(推荐,分离样式和逻辑)。
    const div = document.querySelector(".box");
    div.style.width = "200px";
    div.classList.add("active"); // 添加类名
    div.classList.toggle("show"); // 切换类名
    
  • 修改文本 / HTML:element.textContent(纯文本,安全)、element.innerHTML(HTML 字符串,有 XSS 风险)。

(4)删除 DOM 节点

  • parent.removeChild(child):父节点移除子节点;
  • element.remove():元素自身移除(ES6+ 方法,更简洁)。

示例:

const li = document.querySelector("li");
li.parentElement.removeChild(li); // 传统方式
// 或
li.remove(); // 简洁方式

(5)DOM 操作的性能优化

DOM 操作是 “重操作”,频繁修改会触发浏览器重排(Reflow)/ 重绘(Repaint),导致页面卡顿,优化手段:

  • 批量操作:先将节点脱离文档流(如隐藏父节点),操作完成后再恢复;

  • 使用文档碎片:document.createDocumentFragment(),批量插入节点仅触发一次重排;

    const fragment = document.createDocumentFragment();
    for (let i = 0; i < 1000; i++) {
      const li = document.createElement("li");
      li.textContent = `项 ${i}`;
      fragment.appendChild(li); // 先插入碎片,无重排
    }
    document.querySelector("ul").appendChild(fragment); // 仅一次重排
    
  • 避免频繁查询 DOM:将查询结果缓存到变量,减少 DOM 遍历。

2. 事件处理

事件是浏览器触发的 “信号”(如点击、输入、加载),事件处理是 JavaScript 响应用户操作的核心,分为 “事件绑定”“事件流”“事件对象”“事件优化” 四部分。

(1)事件绑定方式

  • 行内绑定(不推荐):<button onclick="handleClick()">点击</button> → 耦合度高,不利于维护;

  • DOM0 级绑定:element.onclick = function() {} → 简单,但一个事件只能绑定一个处理函数;

  • DOM2 级绑定:element.addEventListener("eventName", handler, useCapture) → 推荐,支持绑定多个处理函数,可控制事件阶段;

  • DOM0 级:浏览器原生支持,无官方规范 → element.onclick = function() {}

  • DOM1 级:仅规范 DOM 结构,未新增事件绑定方式 → 无事件相关内容

  • DOM2 级:W3C 发布标准,新增 addEventListener → 支持多绑定、事件阶段

  • DOM3 级:在 DOM2 基础上新增了更多事件类型(如键盘、鼠标滚轮事件)

const btn = document.querySelector("button");
// DOM0 级
btn.onclick = function() {
  console.log("点击1");
};
btn.onclick = function() {
  console.log("点击2"); // 覆盖上一个处理函数
};

// DOM2 级
const handleClick = () => console.log("点击1");
btn.addEventListener("click", handleClick);
btn.addEventListener("click", () => console.log("点击2")); // 可绑定多个
btn.removeEventListener("click", handleClick); // 可移除

(2)事件流(事件传播机制)

事件流分为三个阶段:

  1. 捕获阶段:事件从 document 向下传播到目标元素;
  2. 目标阶段:事件到达目标元素;
  3. 冒泡阶段:事件从目标元素向上传播到 document

addEventListener 的第三个参数 useCapturetrue 表示在捕获阶段触发,false(默认)表示在冒泡阶段触发。

(3)事件对象(Event)

事件处理函数的第一个参数是事件对象,包含事件的核心信息:

  • event.target:触发事件的原始元素(事件源);
  • event.currentTarget:绑定事件的元素;
  • event.preventDefault():阻止默认行为(如表单提交、链接跳转);
  • event.stopPropagation():阻止事件传播(冒泡 / 捕获);
  • event.stopImmediatePropagation():阻止事件传播,且阻止当前元素后续的事件处理函数执行。

示例:

// 阻止链接跳转
const a = document.querySelector("a");
a.addEventListener("click", (e) => {
  e.preventDefault(); // 阻止默认跳转
  console.log("点击链接,不跳转");
});

// 事件委托(利用事件冒泡)
const ul = document.querySelector("ul");
ul.addEventListener("click", (e) => {
  if (e.target.tagName === "LI") { // 判断点击的是 li 元素
    console.log("点击了列表项", e.target.textContent);
  }
});

(4)核心优化:事件委托

利用事件冒泡,将子元素的事件绑定到父元素,减少事件绑定数量,优化性能(尤其适合动态生成的元素):

// 动态生成的 li 无需单独绑定事件,父元素 ul 委托处理
const ul = document.querySelector("ul");
ul.addEventListener("click", (e) => {
  if (e.target.classList.contains("item")) {
    console.log("点击了动态生成的列表项");
  }
});

// 动态添加 li
const li = document.createElement("li");
li.classList.add("item");
li.textContent = "动态项";
ul.appendChild(li);

(5)常见事件类型

  • 鼠标事件:clickdblclickmouseovermouseoutmousedownmouseup
  • 键盘事件:keydownkeyupkeypress
  • 表单事件:inputchangesubmitfocusblur
  • 页面事件:loadDOMContentLoaded(DOM 解析完成)、scrollresize

DOM 操作和事件处理是前端交互的基础,核心原则是 “减少 DOM 操作次数”“合理利用事件机制”,既保证交互的流畅性,又避免性能问题。

总结

  1. ES6+ 新特性核心是简化语法、补全功能,是现代前端开发的基础,重点掌握块级作用域、箭头函数、解构、async/await 等高频用法;
  2. 异步编程从回调地狱演进到 Promise/async/await,核心是让异步代码更易读、易维护,async/await 是当前最优写法;
  3. 闭包是作用域链的保留,用于封装私有变量、实现防抖节流,需注意内存泄漏;原型链是 JS 继承的底层机制,所有对象通过 __proto__ 形成继承链条;
  4. DOM 操作需注重性能(批量操作、文档碎片),事件处理核心是事件委托,利用冒泡减少绑定数量,提升页面性能。

Promise基础知识整理,看看还有你不清楚的吗

背景

Promise 作为 异步编程的老生常谈,这里不免俗也整理一番,以后关于 Promise基础知识看这篇就好了。整理过后,我想说一句话:回调函数可以说是javascript中,所有异步编程方式的根基,Promise 无非是 以更好维护更优雅的形式让我们使用回调函数,并不算是一个 全新的摆脱回调函数的解法。

Promise 构造函数

完整代码示例

// 1. 使用Promise构造函数创建一个新的Promise实例(承诺)
// 构造函数接收一个「兑现承诺的逻辑函数」,这个函数会被同步执行
const promise = new Promise((resolve, reject) => {
  console.log('Promise构造函数的执行函数:同步执行');
  
  // resolve和reject都是函数,用于修改Promise状态
  // 2. 调用resolve:将Promise状态改为fulfilled(成功),并传递结果
  resolve(100); // 这里传入固定值100作为异步任务的操作结果
  
  // 3. Promise状态一旦确定就无法修改,所以下面的reject不会生效(注释掉更直观)
  // reject(new Error('promise rejected')); // 将状态改为rejected(失败),传递错误理由
});

// 4. 用then方法指定状态变更后的回调
// then接收两个参数:onFulfilled(成功回调)、onRejected(失败回调)
promise.then(
  // onFulfilled:Promise状态为fulfilled时执行,接收resolve传递的结果
  (value) => {
    console.log('Promise成功:', value); // 输出 Promise成功: 100
  },
  // onRejected:Promise状态为rejected时执行,接收reject传递的错误
  (error) => {
    console.log('Promise失败:', error.message);
  }
);

console.log('同步代码:在Promise创建后执行');

代码输出结果

Promise构造函数的执行函数:同步执行
同步代码:在Promise创建后执行
Promise成功: 100

逐点解读(核心解释)

  1. 构造函数参数(兑现承诺的逻辑)

    • new Promise((resolve, reject) => { ... }) 中的箭头函数就是「兑现承诺的逻辑」。
    • 这个函数同步执行:所以先输出 Promise构造函数的执行函数:同步执行,再执行后续的同步代码。
  2. resolve 和 reject 参数

    • 二者都是浏览器内置的函数,不是我们定义的。
    • resolve(100):把 Promise 状态改为 fulfilled(成功),并把 100 作为「成功结果」传递给 then 的第一个回调。
    • reject(new Error('promise rejected')):把状态改为 rejected(失败),并把错误对象作为「失败理由」传递给 then 的第二个回调。
  3. 状态一旦确定就不能修改

    • 代码中先调用了 resolve(100),此时 Promise 状态已经固定为成功,即便后续调用 reject(哪怕取消注释),也不会改变状态,then 的失败回调永远不会执行。
  4. then 方法的回调

    • then 的第一个参数:只有 Promise 状态为 fulfilled 时才执行,接收 resolve 传递的值。
    • then 的第二个参数:只有 Promise 状态为 rejected 时才执行,接收 reject 传递的错误。
    • 注意:then 的回调是微任务,所以会等所有同步代码执行完后才执行(先输出 同步代码:在Promise创建后执行,再输出 Promise成功: 100)。

总结

  1. Promise 构造函数的执行函数是同步执行的,里面的 resolve/reject 用于修改 Promise 状态(成功/失败)。
  2. Promise 状态一旦通过 resolvereject 确定,就永久不可修改,后续调用另一个函数也无效。
  3. then 方法的两个回调分别对应「成功状态」和「失败状态」的处理逻辑,且回调是微任务(晚于同步代码执行)。

Promise 链式调用 用起来!

错误写法:嵌套then(回调地狱)

使用Promise「常见误区」,本质和传统回调嵌套没区别,完全浪费了Promise的优势:

// 错误:嵌套使用then,形成回调地狱
ajax('urls.json').then(
  (res) => {
    console.log('第一步:拿到urls.json结果', res);
    // 误区:在第一个then的回调里嵌套第二个then
    ajax(res.userUrl).then(
      (userRes) => {
        console.log('第二步:拿到用户数据', userRes);
        // 如果还有第三个请求,会继续嵌套,代码越来越深
      },
      (err) => {
        console.log('第二步请求失败', err);
      }
    );
  },
  (err) => {
    console.log('第一步请求失败', err);
  }
);

这种写法的问题:

  • 代码层级嵌套,越往后越深,可读性差(回调地狱)
  • 错误处理需要在每个嵌套的then里单独写,冗余且麻烦

正确写法:then链式调用(扁平化)

核心原理:then 方法会返回一个新的 Promise,所以可以直接链式调用,而非嵌套。

// 正确:链式调用then,扁平化代码
ajax('urls.json')
  .then((res) => {
    console.log('第一步:拿到urls.json结果', res);
    // 关键:返回下一个异步任务的Promise
    return ajax(res.userUrl); 
  })
  .then((userRes) => {
    console.log('第二步:拿到用户数据', userRes);
    // 可以继续链式调用第三个异步任务
    // return ajax(第三个地址);
  })
  .catch((err) => {
    // 统一错误处理:任何一步失败都会走到这里
    console.log('请求失败:', err.message);
  });
执行结果(500ms后)
第一步:拿到urls.json结果 { userUrl: '/user' }
第二步:拿到用户数据 { name: '张三', age: 20 }

核心逻辑拆解(为什么链式调用能避免回调地狱)

  1. ajax('urls.json').then(...) 执行后,返回一个新的 Promise(记为 P1)。
  2. 第一个 then 的回调里 return ajax(res.userUrl),这个 ajax 调用会返回另一个 Promise(记为 P2)。
  3. Promise 的规则:如果 then 的回调返回一个 Promise(P2),那么 then 对应的新 Promise(P1)会「继承」P2 的状态——P2 成功,P1 就成功;P2 失败,P1 就失败。
  4. 第二个 then(...) 其实是挂载在 P1 上的回调,而非嵌套在第一个 then 内部,所以代码是扁平的。

总结

  1. Promise 避免回调地狱的核心是 then 的链式调用,而非嵌套使用 then。
  2. 关键规则:then 会返回新 Promise,若 then 回调返回 Promise,则新 Promise 继承该 Promise 的状态。
  3. 链式调用+catch 可以实现扁平化代码结构统一错误处理,这是 Promise 对比传统回调的核心优势。

Promise 链式调用 的 特殊性

先对比两种链式调用的本质区别

1. 传统链式调用(返回 this)

比如 jQuery 的链式调用,核心是方法内部返回 this(自身),所有方法都操作同一个对象:

// 传统链式调用:返回this
class MyObj {
  name = '';
  setName(name) {
    this.name = name;
    return this; // 返回自身(同一个对象)
  }
  logName() {
    console.log(this.name);
    return this; // 返回自身
  }
}

const obj = new MyObj();
obj.setName('张三').logName(); 
// 这里 setName 和 logName 操作的是同一个 obj 对象
console.log(obj.setName('张三') === obj); // true(返回的是同一个对象)
2. Promise 的链式调用(返回新对象)

Promise 的 then 每调用一次,都会生成一个全新的 Promise,和原对象毫无关系:

// Promise链式调用:返回新对象
const p1 = new Promise((resolve) => resolve(100));
const p2 = p1.then((res) => res + 10); // p2 是全新的Promise
const p3 = p2.then((res) => res + 10); // p3 是全新的Promise

console.log(p1 === p2); // false(不是同一个对象)
console.log(p2 === p3); // false(不是同一个对象)

// 执行结果:验证每个then对应不同的Promise
p3.then((res) => console.log(res)); // 120

逐句拆那段「绕口的话」

如果我们这里不断的链式调用then方法,然后呢这里每一个then方法,它实际上都是在为上一个then方法返回的promise对象去添加状态明确过后的回调。

我用「分步拆解+代码标注」的方式解释:

// 第一步:创建原始Promise p1(第一个承诺)
const p1 = new Promise((resolve) => resolve('初始值'));

// 第二步:调用p1.then() → 返回新Promise p2(第二个承诺)
// 这个then是给p1加回调:p1成功后执行回调,然后决定p2的状态
const p2 = p1.then((res) => {
  console.log('p1的回调:', res); // 输出:p1的回调: 初始值
  return 'p1回调的返回值'; // 这个返回值会决定p2的状态(成功,值为这个字符串)
});

// 第三步:调用p2.then() → 返回新Promise p3(第三个承诺)
// 这个then是给p2加回调:p2成功后执行回调,然后决定p3的状态
const p3 = p2.then((res) => {
  console.log('p2的回调:', res); // 输出:p2的回调: p1回调的返回值
  return 'p2回调的返回值';
});

// 第四步:调用p3.then() → 返回新Promise p4(第四个承诺)
// 这个then是给p3加回调:p3成功后执行回调,然后决定p4的状态
const p4 = p3.then((res) => {
  console.log('p3的回调:', res); // 输出:p3的回调: p2回调的返回值
});
拆解逻辑(对应你那段话):
  1. 「不断链式调用 then」→ 代码中 p1.then() → p2.then() → p3.then() 就是链式调用。
  2. 「每一个 then 方法,都是为上一个 then 返回的 Promise 对象加回调」:
    • p2.then(...) → 是给「p1.then() 返回的 p2」加回调;
    • p3.then(...) → 是给「p2.then() 返回的 p3」加回调;
    • 每个 then 都不是给原始的 p1 加回调,而是给「上一个 then 生成的新 Promise」加回调。
  3. 「状态明确过后的回调」:只有当被绑定的 Promise(比如 p2)状态变为 fulfilled/rejected,这个 then 的回调才会执行。

为什么要返回全新的 Promise?(核心目的)

返回全新Promise的目的是实现Promise链条,一个承诺结束后返回新承诺,每个承诺负责一个异步任务,相互无影响」,用例子验证:

// 场景:第一个异步任务(延迟1s),第二个异步任务(延迟2s)
const p1 = new Promise((resolve) => {
  setTimeout(() => resolve('第一个异步任务完成'), 1000);
});

// 第一个then:负责第一个异步任务的结果处理,返回新Promise(第二个异步任务)
const p2 = p1.then((res) => {
  console.log(res); // 1s后输出:第一个异步任务完成
  // 返回新Promise(第二个异步任务),和p1完全独立
  return new Promise((resolve) => {
    setTimeout(() => resolve('第二个异步任务完成'), 2000);
  });
});

// 第二个then:只关心p2(第二个异步任务)的状态,和p1无关
p2.then((res) => {
  console.log(res); // 再等2s后输出:第二个异步任务完成
});

// 此时操作p1,不会影响p2
p1.then(() => console.log('p1的另一个回调')); // 1s后输出,和p2无关
  • 每个 Promise(p1/p2)都是独立的,p1 完成不影响 p2 的执行逻辑,p2 延迟也不会干扰 p1 的其他回调;
  • 若 then 返回 this(同一个对象),则无法实现「一个异步任务完成后,再启动下一个独立的异步任务」,因为所有 then 都绑定在同一个对象上,状态只能变一次。

总结

  1. Promise 链式调用≠传统链式调用:传统是返回 this(同一对象),Promise 是返回全新的 Promise 对象
  2. 链式调用的本质:每个 then 都是给「上一个 then 返回的新 Promise」绑定回调,而非给原始 Promise 绑定。
  3. 返回新 Promise 的核心价值:让每个异步任务都对应一个独立的「承诺」,任务之间相互独立、按顺序执行,实现真正的异步链条。
补充:then 回调返回 Promise → 后一个 then 等待该 Promise 结束
  • 第二个 then 回调返回 delayTask(1000, ...)(一个需要等待1s的 Promise);
  • 此时第三个 then 不会立即执行,而是等待这个返回的 Promise 状态变为 fulfilled(1s后完成);
  • 等价于:第三个 then 直接绑定到「第二个 then 返回的这个 delayTask Promise」上,成为它的回调。

为了让你更清楚「后一个 then 等价于给返回的 Promise 注册回调」,把上面的代码拆成非链式写法,逻辑完全一致:

// 拆分成非链式写法,等价于上面的链式调用
const p1 = delayTask(1000, '第一个异步任务结果');

// 第一个then:返回p2
const p2 = p1.then((res1) => {
  console.log('第一个then回调执行:', res1);
  return '第一个then的返回值(普通值)';
});

// 第二个then:返回p3
const p3 = p2.then((res2) => {
  console.log('第二个then回调执行:', res2);
  // 返回一个新的Promise p_temp
  const p_temp = delayTask(1000, '第二个then返回的Promise结果');
  return p_temp;
});

// 第三个then:等价于给p_temp注册回调(因为p3的状态由p_temp决定)
const p4 = p3.then((res3) => {
  console.log('第三个then回调执行:', res3);
  return '最终结果';
});

// 等价于直接给p_temp注册回调:
// p_temp.then((res3) => {
//   console.log('第三个then回调执行:', res3);
// });

catch() 与 then(成功, 失败) 的失败回调 是否完全等价?

先明确核心结论(先记重点)

  • catch() 等价于 then(undefined, 失败回调),但绑定的是上一个 then 返回的新 Promise
  • then(成功回调, 失败回调) 中的失败回调,只绑定当前 Promise,管不到后续 then 里的新 Promise 异常;
  • Promise 链条中,异常会「向后传递」,直到被某个失败回调捕获。

对比示例:then第二个参数 vs catch(直观看差异)

我们用「两步异步任务」的场景,模拟第一步成功、第二步失败的情况,对比两种写法的结果:

第一步:封装模拟异步函数
// 模拟异步任务1:一定成功,返回"第一步结果"
function task1() {
  return new Promise((resolve) => {
    resolve("第一步结果");
  });
}

// 模拟异步任务2:一定失败,抛出异常
function task2() {
  return new Promise((resolve, reject) => {
    reject(new Error("第二步执行失败"));
  });
}
场景1:用 then 的第二个参数注册失败回调(只能捕获第一步异常)
// 写法1:then(成功回调, 失败回调)
task1()
  .then(
    // 成功回调:第一步成功后执行,调用task2(返回失败的Promise)
    (res) => {
      console.log("第一步成功:", res);
      return task2(); // 返回一个失败的新Promise(记为P2)
    },
    // 失败回调:只绑定task1返回的Promise(记为P1),只能捕获P1的异常
    (err) => {
      console.log("捕获到异常:", err.message);
    }
  );

/* 输出结果:
第一步成功: 第一步结果
Uncaught (in promise) Error: 第二步执行失败
*/

关键问题:第二步的异常没被捕获!因为 then 的第二个参数只负责「task1 返回的 P1」,管不到「第一个 then 返回的 P2(task2 的 Promise)」的异常。

场景2:用 catch 注册失败回调(能捕获整个链条的异常)
// 写法2:then(成功回调) + catch(失败回调)
task1()
  .then((res) => {
    console.log("第一步成功:", res);
    return task2(); // 返回失败的P2
  })
  .catch((err) => {
    // catch绑定的是「上一个then返回的P2」,能捕获P2的异常
    console.log("捕获到异常:", err.message);
  });

/* 输出结果:
第一步成功: 第一步结果
捕获到异常: 第二步执行失败
*/

核心原因:catch 等价于 then(undefined, 失败回调),这个失败回调绑定在「第一个 then 返回的 P2」上,刚好能捕获 P2 的异常。

拆解异常传递+回调绑定逻辑(为什么会这样?)

我们用「Promise 链条对象关系」来拆解上面的代码:

task1() → 返回 P1(成功状态)
↓
P1.then(成功回调, 失败回调) → 返回 P2(由成功回调的返回值决定:task2() 返回失败的Promise → P2 失败)
↓
P2.catch(失败回调) → 绑定在 P2 上,捕获 P2 的失败
关键细节:
  1. then(成功回调, 失败回调) 的失败回调 → 只绑定 P1,只能处理 P1 的异常(比如 task1 失败);
  2. catch() → 绑定 P2,能处理 P2 的异常(包括 P2 自身失败、或 P1 未被捕获的异常向后传递过来);
  3. 异常传递规则:如果一个 Promise 失败且没有对应的失败回调,异常会「顺着链条往后传」,直到被某个 catch/then 失败回调捕获。

补充:如果第一步就失败,两种写法的表现

// 改造task1:让第一步直接失败
function task1() {
  return new Promise((resolve, reject) => {
    reject(new Error("第一步执行失败"));
  });
}

// 写法1:then的第二个参数 → 能捕获P1的异常
task1().then(
  (res) => console.log(res),
  (err) => console.log("then捕获:", err.message) // 输出:then捕获:第一步执行失败
);

// 写法2:catch → 也能捕获(因为P1的异常传递到P2,被catch捕获)
task1()
  .then((res) => console.log(res))
  .catch((err) => console.log("catch捕获:", err.message)); // 输出:catch捕获:第一步执行失败

这说明:catch 能捕获整个链条中「前面所有未被处理的异常」,而 then 第二个参数只能捕获「当前 Promise」的异常

为什么 catch 更适合链式调用?

  • 链式调用的核心是「多个异步任务依次执行」,每个任务对应链条中的一个 Promise;
  • 用 catch 可以「统一捕获整个链条的所有异常」,无需在每个 then 里写失败回调;
  • 用 then 第二个参数则需要「每个 then 都写失败回调」,否则后续 Promise 的异常会逃逸(未捕获)。

总结

  1. catch()then(undefined, 失败回调) 的语法糖,但绑定的是上一个 then 返回的新 Promise,而非原始 Promise;
  2. then(成功, 失败) 的失败回调仅绑定当前 Promise,无法捕获后续 then 中返回的新 Promise 异常;
  3. Promise 异常会「向后传递」,catch 因绑定在链条末端的 Promise 上,能捕获整个链条的所有未处理异常,这也是它更适合链式调用的核心原因。

unhandledrejection 是否推荐使用

一、先简单了解全局捕获(仅作认知,不推荐使用)

1. 浏览器环境(window 上注册 unhandledrejection)
// 浏览器中全局捕获未处理的Promise异常(仅演示,不推荐)
window.addEventListener('unhandledrejection', (event) => {
  // 阻止浏览器默认的错误提示(比如控制台的红色报错)
  event.preventDefault();
  console.log('全局捕获未处理的Promise异常:', event.reason.message);
});

// 测试:抛出一个未手动捕获的Promise异常
new Promise((resolve, reject) => {
  reject(new Error('这是一个未被手动捕获的异常'));
});
// 控制台会输出:全局捕获未处理的Promise异常:这是一个未被手动捕获的异常
2. Node.js 环境(process 上注册 unhandledRejection)
// Node.js中全局捕获(仅演示,不推荐)
process.on('unhandledRejection', (reason, promise) => {
  console.log('全局捕获未处理的Promise异常:', reason.message);
});

// 测试
new Promise((resolve, reject) => {
  reject(new Error('Node中未被手动捕获的异常'));
});

二、为什么强烈不推荐全局捕获?(核心原因)

「不推荐全局统一处理」,核心问题有这几点:

  1. 调试困难:全局捕获会「兜底」所有未处理的异常,但无法精准定位异常发生的位置——一个大型项目中,你无法从全局回调里快速知道是哪一行代码、哪个异步任务抛出的异常。
  2. 掩盖问题:全局捕获会让开发者产生「反正有兜底,不用手动写 catch」的惰性,导致代码中大量异常没有被「针对性处理」(比如某个接口失败需要重试,另一个需要提示用户,全局捕获只能统一打印,无法差异化处理)。
  3. 不可控性:全局事件是「最后一道防线」,若代码中漏写 catch,全局捕获会接住异常,但这属于「被动补救」,而非「主动处理」,容易埋下线上bug(比如异常处理逻辑不匹配场景)。

三、更优的做法:显式捕获每一个可能的异常

最佳实践是「链式调用末尾加 catch」+「针对不同场景差异化处理异常」,甚至可以给不同异步任务加「专属的异常处理」。

示例1:基础版——链式末尾统一 catch
// 模拟两个异步任务,第二步可能失败
function task1() {
  return new Promise((resolve) => resolve('第一步成功'));
}

function task2() {
  return new Promise((resolve, reject) => {
    // 模拟随机失败
    Math.random() > 0.5 
      ? resolve('第二步成功') 
      : reject(new Error('第二步接口调用失败'));
  });
}

// 显式捕获:链式末尾加catch,针对性处理
task1()
  .then((res) => {
    console.log(res);
    return task2();
  })
  .then((res) => {
    console.log(res);
  })
  .catch((err) => {
    // 精准处理:区分不同异常,做不同操作
    if (err.message === '第二步接口调用失败') {
      console.log('处理第二步失败:', '重试一次或提示用户');
    } else {
      console.log('其他异常:', err.message);
    }
  });
示例2:进阶版——分阶段捕获(不同任务单独处理)

如果某个异步任务的异常需要「单独处理,不中断后续流程」,可以在该任务的 then 后紧跟 catch:

task1()
  .then((res) => {
    console.log(res);
    // 第二步失败后单独处理,不影响后续流程
    return task2().catch((err) => {
      console.log('第二步单独处理失败:', err.message);
      return '第二步失败后的兜底值'; // 返回兜底值,让链条继续
    });
  })
  .then((res) => {
    // `无论第二步成功/失败,都会执行这里`--- 重点!!!
    console.log('第三步:接收第二步结果', res);
  })
  .catch((err) => {
    // 捕获其他未处理的异常
    console.log('全局兜底(极少触发):', err.message);
  });

总结

  1. 全局 unhandledrejection/unhandledRejection 是「兜底方案」,仅适合临时调试或紧急补救,不推荐作为常规异常处理方式
  2. 最佳实践是「显式捕获」:在 Promise 链条末尾加 catch,针对不同异常做「差异化处理」(重试、兜底、提示用户等);
  3. 若需要保留链条执行,可在单个异步任务后紧跟 catch,返回兜底值,避免整个链条中断。
  4. 关于catch返回值:
    • 如果 catch 回调返回正常值(普通值 / 成功的 Promise)→ 新 Promise 状态为 fulfilled(成功);
    • 如果 catch 回调抛出异常 / 返回失败的 Promise → 新 Promise 状态为 rejected(失败)
  • 如果 catch 回调抛出异常 / 返回失败的 Promise → 新 Promise 状态为 rejected(失败)

Promise.reslove

一、Promise.resolve() 基本用法

Promise.resolve(value) 是创建「已成功 Promise」的快捷方式,无需手动写 new Promise + resolve,核心逻辑就是:接收一个值,返回一个状态为 fulfilled 的 Promise,且该值会作为 Promise 的成功结果

代码示例:Promise.resolve() 基础使用
// 用 Promise.resolve 快速创建成功的 Promise
const p1 = Promise.resolve('foo');

// 调用 then 接收结果(你提到的 unfulfilled 是笔误,正确是 fulfilled)
p1.then((res) => {
  console.log('成功回调拿到的值:', res); // 输出:成功回调拿到的值:foo
});

二、等价逻辑:Promise.resolve() ≈ new Promise + resolve

你提到「这种方式完全等价于 new Promise 然后直接 resolve 该值」,我们用代码验证这个等价性:

// 方式1:Promise.resolve 快捷写法
const p1 = Promise.resolve('foo');

// 方式2:new Promise 完整写法(和方式1完全等价)
const p2 = new Promise((resolve) => {
  // 在执行函数中直接 resolve 'foo',Promise 状态立即变为 fulfilled
  resolve('foo');
});

// 测试两个 Promise 的执行结果(完全一致)
p1.then(res => console.log('p1结果:', res)); // p1结果:foo
p2.then(res => console.log('p2结果:', res)); // p2结果:foo
核心等价点:
  • 两者创建的 Promise 状态都是 fulfilled(成功);
  • 两者的成功回调拿到的参数都是传入的 'foo'
  • 两者的执行时机一致:Promise.resolve() 内部的逻辑和 new Promise 的执行函数一样,是同步执行的(但回调仍为微任务)。

三、Promise.resolve() 的进阶场景(拓展理解)

除了传入普通值(字符串、数字等),Promise.resolve() 还有两个常见场景,帮你全面掌握:

场景1:传入 Promise 对象

如果传入的是一个已存在的 Promise,Promise.resolve() 会直接返回这个 Promise(不会创建新对象):

const originalPromise = new Promise((resolve) => resolve('原始Promise'));
const wrappedPromise = Promise.resolve(originalPromise);

console.log(originalPromise === wrappedPromise); // true(返回同一个对象)
wrappedPromise.then(res => console.log(res)); // 原始Promise
场景2:传入「类 Promise 对象」(thenable)

如果传入的是有 then 方法的对象(称为 thenable),Promise.resolve() 会执行其 then 方法,将其转换成标准 Promise:

// 定义一个 thenable 对象(有 then 方法,但不是真正的 Promise)
const thenable = {
  then(resolve) {
    resolve('thenable 转换的结果');
  }
};

// Promise.resolve 会执行 then 方法,转换成标准 Promise
Promise.resolve(thenable).then(res => {
  console.log(res); // 输出:thenable 转换的结果
});

四、为什么要用 Promise.resolve()?

相比 new Promise 写法,Promise.resolve() 的优势在于:

  1. 简化代码:创建已成功的 Promise 时,少写嵌套的执行函数,代码更简洁;
  2. 统一接口:当你不确定一个值是普通值还是 Promise 时,用 Promise.resolve() 可以「归一化」成 Promise,方便链式调用:
    // 假设 fn 可能返回普通值,也可能返回 Promise
    function fn() {
      return Math.random() > 0.5 ? '普通值' : Promise.resolve('Promise值');
    }
    
    // 用 Promise.resolve 统一处理,无需区分类型
    Promise.resolve(fn()).then(res => {
      console.log('统一拿到结果:', res);
    });
    

总结

  1. Promise.resolve(value) 是创建状态为 fulfilled 的 Promise 的快捷方式,等价于 new Promise((resolve) => resolve(value))
  2. 传入普通值时,该值会作为 Promise 的成功结果,在 then 的成功回调中获取;
  3. 传入 Promise/thenable 对象时,Promise.resolve() 会适配并返回标准 Promise,核心作用是「归一化」值的类型,方便异步处理。

核心记住:Promise.resolve() 的本质是「快速生成成功的 Promise」,减少冗余代码,统一异步/同步值的处理逻辑。

Promise.reject

Promise.reject() 快速创建失败的 Promise

Promise.reject() 是创建「状态为 rejected(失败)」Promise 的快捷方式,你提到「无论传入什么参数,都会作为失败理由」,代码验证:

// 1. 传入普通值(字符串)
const p1 = Promise.reject('普通错误信息');
p1.catch(err => console.log('p1失败理由:', err)); // 输出:p1失败理由:普通错误信息

// 2. 传入 Error 对象(推荐写法,包含堆栈信息)
const p2 = Promise.reject(new Error('标准错误对象'));
p2.catch(err => console.log('p2失败理由:', err.message)); // 输出:p2失败理由:标准错误对象

// 3. 传入 Promise 对象(和 resolve 不同,不会原样返回,而是直接作为失败理由)
const originalPromise = Promise.resolve('成功的Promise');
const p3 = Promise.reject(originalPromise);
p3.catch(err => {
  console.log('p3失败理由是原Promise:', err === originalPromise); // 输出:true
});
关键区别(和 Promise.resolve 对比):
  • Promise.resolve(已存在的Promise) → 返回原 Promise;
  • Promise.reject(已存在的Promise) → 不会返回原 Promise,而是把这个 Promise 对象直接作为失败理由

总结

  1. Promise.resolve(x) 规则:
    • x 是普通值 → 返回 fulfilled 状态的 Promise,x 为成功结果;
    • x 是 Promise → 原样返回 x;
    • x 是 thenable 对象 → 转换成原生 Promise,执行其 then 方法。
  2. Promise.reject(reason) 规则:
    • 无论 reason 是普通值、Error 对象、甚至 Promise 对象,都会直接作为「失败理由」,返回 rejected 状态的 Promise;
    • 推荐传入 Error 对象(而非字符串),便于调试(包含错误堆栈)。
  3. Promise.resolve/reject 的核心价值:简化 Promise 创建代码,统一异步值的处理逻辑(尤其是 resolve 对 thenable 的兼容)。

为什么 Promise 的递归调用会导致浏览器卡死,而 setTimeout 的递归调用通常不会?

先看直观对比(代码+现象)

先跑两段代码,直观感受差异:

示例1:Promise 递归(卡死浏览器)
// Promise 递归:同步占用主线程,无喘息机会
function promiseRecursion() {
  Promise.resolve().then(() => {
    console.log("Promise 递归执行");
    promiseRecursion(); // 递归调用
  });
}
promiseRecursion();

现象:浏览器标签页卡顿、无响应,控制台疯狂输出,但页面无法交互,甚至会触发「页面无响应」提示。

示例2:setTimeout 递归(不卡死)
// setTimeout 递归:每次执行后释放主线程
function timeoutRecursion() {
  setTimeout(() => {
    console.log("setTimeout 递归执行");
    timeoutRecursion(); // 递归调用
  }, 0);
}
timeoutRecursion();

现象:控制台持续输出,但页面仍能点击、滚动,浏览器完全不卡顿。

核心原因拆解(事件循环+调用栈)

浏览器的主线程是「单线程」,所有 JS 执行、DOM 渲染、事件响应都在这一个线程里,能否「释放主线程」是是否卡死的关键:

1. Promise 递归:微任务「抢占式」执行,调用栈永不清空
  • Promise.then 的回调属于「微任务」:微任务的执行规则是「当前宏任务执行完毕后,立即清空所有微任务队列,再执行下一个宏任务/渲染/事件」。
  • 递归逻辑
    1. 第一次调用 promiseRecursion()Promise.resolve() 生成微任务 A;
    2. 当前宏任务执行完,执行微任务 A → 打印日志,调用 promiseRecursion() → 生成微任务 B;
    3. 微任务 A 执行完,立即执行微任务 B → 打印日志,生成微任务 C;
    4. 这个过程无限循环,微任务队列永远有新任务,主线程被微任务「占满」,没有任何时间片分配给:
      • DOM 渲染(页面卡死);
      • 鼠标点击/滚动等事件响应(交互失效);
      • 其他宏任务(比如 setTimeout、网络请求)。
  • 调用栈角度:虽然每次 then 回调执行完会清空当前调用栈,但微任务的「连续执行」让主线程没有「空闲期」,本质是「无限的同步执行流」。
2. setTimeout 递归:宏任务「排队式」执行,每次释放主线程
  • setTimeout 的回调属于「宏任务」:宏任务的执行规则是「执行完一个宏任务后,先执行所有微任务,再处理渲染,再取下一个宏任务」。
  • 递归逻辑
    1. 第一次调用 timeoutRecursion()setTimeout 把回调 A 加入「宏任务队列」;
    2. 当前宏任务执行完,执行微任务 → 渲染页面 → 处理事件(点击/滚动)→ 再执行宏任务 A;
    3. 宏任务 A 执行:打印日志,调用 timeoutRecursion() → 把回调 B 加入宏任务队列;
    4. 宏任务 A 执行完,主线程会「释放」,先处理渲染、事件响应,再执行下一个宏任务 B;
    5. 这个过程虽然无限,但每次宏任务执行完都会给主线程喘息机会,页面渲染、事件响应能正常进行,因此不会卡死。

补充:为什么 Promise 微任务要「立即执行」?

微任务的设计初衷是「处理异步但需要尽快完成的逻辑」(比如 Promise 回调、async/await),优先级高于宏任务和渲染,这保证了异步逻辑的执行顺序,但无限递归的微任务会滥用这个优先级,导致主线程阻塞。

总结

  1. 核心差异:Promise 递归是「微任务无限连续执行」,主线程无喘息机会;setTimeout 递归是「宏任务排队执行」,每次执行后释放主线程,允许渲染/事件响应。
  2. 调用栈/队列:Promise 递归让微任务队列永远非空,主线程被占满;setTimeout 递归的宏任务队列虽有任务,但每次执行完会处理渲染和事件。
  3. 本质:浏览器卡死的核心是「主线程无法处理渲染/交互」,而非「递归本身」——setTimeout 递归给了主线程处理这些的时间,而 Promise 递归没有。

如果想让 Promise 递归不卡死,可在递归中加入 setTimeout 「让出主线程」:

// 改进版 Promise 递归:不卡死
function promiseRecursion() {
  Promise.resolve().then(() => {
    console.log("Promise 递归执行");
    setTimeout(() => promiseRecursion(), 0); // 用setTimeout让出主线程
  });
}
promiseRecursion();

Promise 的 then 方法的核心实现

先明确 then 方法的核心需求

  1. then 接收两个参数:onFulfilled(成功回调)、onRejected(失败回调);
  2. 回调需异步执行(微任务,这里用 setTimeout 模拟);
  3. 若 Promise 状态未确定(pending),需先存储回调;若已确定,直接执行回调;
  4. then 必须返回新的 Promise,实现链式调用;
  5. 上一个 then 的回调返回值,决定新 Promise 的状态。

极简版 Promise + then 实现

// 模拟 Promise 的核心实现(仅保留 then 方法的核心逻辑)
class MyPromise {
  // 定义三种状态
  static PENDING = 'pending';
  static FULFILLED = 'fulfilled';
  static REJECTED = 'rejected';

  constructor(executor) {
    // 初始状态
    this.status = MyPromise.PENDING;
    // 成功结果
    this.value = undefined;
    // 失败原因
    this.reason = undefined;
    // 存储 pending 状态时的回调(因为此时状态未确定,需等待)
    this.onFulfilledCallbacks = [];
    this.onRejectedCallbacks = [];

    // resolve 函数:修改状态为成功,执行存储的成功回调
    const resolve = (value) => {
      // 状态不可逆:只有 pending 时才能修改
      if (this.status === MyPromise.PENDING) {
        this.status = MyPromise.FULFILLED;
        this.value = value;
        // 执行所有存储的成功回调
        this.onFulfilledCallbacks.forEach(callback => callback());
      }
    };

    // reject 函数:修改状态为失败,执行存储的失败回调
    const reject = (reason) => {
      if (this.status === MyPromise.PENDING) {
        this.status = MyPromise.REJECTED;
        this.reason = reason;
        // 执行所有存储的失败回调
        this.onRejectedCallbacks.forEach(callback => callback());
      }
    };

    // 执行器函数同步执行,捕获执行过程中的异常
    try {
      executor(resolve, reject);
    } catch (error) {
      reject(error);
    }
  }

  // 核心:实现 then 方法
  then(onFulfilled, onRejected) {
    // 兼容:如果没传回调,透传结果(比如 then().then() 的场景)
    onFulfilled = typeof onFulfilled === 'function' ? onFulfilled : value => value;
    onRejected = typeof onRejected === 'function' ? onRejected : reason => { throw reason; };

    // 关键:then 返回新的 Promise,实现链式调用
    const newPromise = new MyPromise((resolve, reject) => {
      // 封装回调执行逻辑(复用代码)
      const executeCallback = (callback, data) => {
        // 异步执行回调(用 setTimeout 模拟微任务)
        setTimeout(() => {
          try {
            // 执行回调,获取返回值
            const result = callback(data);
            // 核心规则:回调返回值决定新 Promise 的状态
            resolvePromise(newPromise, result, resolve, reject);
          } catch (error) {
            // 回调执行出错,新 Promise 状态为失败
            reject(error);
          }
        }, 0);
      };

      // 1. 如果当前 Promise 已成功
      if (this.status === MyPromise.FULFILLED) {
        executeCallback(onFulfilled, this.value);
      }

      // 2. 如果当前 Promise 已失败
      if (this.status === MyPromise.REJECTED) {
        executeCallback(onRejected, this.reason);
      }

      // 3. 如果当前 Promise 还是 pending(状态未确定),存储回调
      if (this.status === MyPromise.PENDING) {
        this.onFulfilledCallbacks.push(() => {
          executeCallback(onFulfilled, this.value);
        });
        this.onRejectedCallbacks.push(() => {
          executeCallback(onRejected, this.reason);
        });
      }
    });

    return newPromise;
  }
}

// 辅助函数:处理 then 回调的返回值,决定新 Promise 的状态
function resolvePromise(newPromise, result, resolve, reject) {
  // 避免循环引用(比如回调返回 newPromise 本身)
  if (result === newPromise) {
    return reject(new TypeError('Chaining cycle detected for promise'));
  }

  // 1. 如果返回值是 Promise 实例
  if (result instanceof MyPromise) {
    // 等待该 Promise 完成,再决定新 Promise 的状态
    result.then(resolve, reject);
  } else {
    // 2. 如果返回值是普通值,直接 resolve 新 Promise
    resolve(result);
  }
}

核心逻辑拆解(重点理解)

1. 状态管理
  • 初始状态为 pending,只有调用 resolve/reject 且状态为 pending 时,才能修改状态;
  • 状态不可逆,一旦变为 fulfilled/rejected,无法再改。
2. 回调存储(pending 状态)
  • 如果调用 then 时,Promise 还处于 pending(比如异步任务没完成),会把回调存储到数组中;
  • 等状态确定后(调用 resolve/reject),遍历执行存储的回调。
3. 异步执行回调
  • setTimeout 模拟微任务(真实 Promise 是微任务,优先级比宏任务高,这里简化);
  • 确保回调不会同步执行,符合 Promise 规范。
4. 链式调用的核心(返回新 Promise)
  • then 必须返回新的 MyPromise,而非 this
  • 回调的返回值通过 resolvePromise 处理:
    • 返回普通值 → 新 Promise 状态为 fulfilled
    • 返回 Promise 实例 → 等待该实例完成,继承其状态;
    • 回调抛出异常 → 新 Promise 状态为 rejected

测试代码(验证 then 功能)

// 测试1:基础使用
const p1 = new MyPromise((resolve) => {
  setTimeout(() => resolve(100), 1000);
});

p1.then(res => {
  console.log('第一次then:', res); // 1s后输出:第一次then:100
  return res + 10; // 返回普通值
}).then(res => {
  console.log('第二次then:', res); // 输出:第二次then:110
  return new MyPromise(resolve => resolve(res + 10)); // 返回Promise
}).then(res => {
  console.log('第三次then:', res); // 输出:第三次then:120
});

// 测试2:失败场景
const p2 = new MyPromise((_, reject) => {
  reject(new Error('失败了'));
});

p2.then(
  res => console.log(res),
  err => {
    console.log('失败回调:', err.message); // 输出:失败回调:失败了
    throw new Error('回调里抛错');
  }
).catch(err => { // 注:catch 本质是 then(undefined, onRejected),可自行补充实现
  console.log('捕获回调错误:', err.message); // 输出:捕获回调错误:回调里抛错
});

补充:catch 方法(可选)

如果想补充 catch 方法,只需在 MyPromise 中加一行:

catch(onRejected) {
  return this.then(undefined, onRejected);
}

总结

  1. then 方法的核心是「状态判断 + 回调存储/执行 + 返回新 Promise」;
  2. 异步执行回调、状态不可逆、链式调用(返回新 Promise)是 then 的三大关键特性;
  3. 这个极简实现去掉了复杂的边界处理(如 thenable 对象、多次调用 then 等),但保留了 then 最核心的逻辑,能帮你理解原生 Promise 的 then 是如何工作的。

Promise 设计模式

Promise 的实现并非单一设计模式,而是多个模式的组合,每个模式解决一个核心问题,先看关键模式及对应作用:

设计模式 核心作用(Promise 中的体现) 对应实现代码(极简版 MyPromise)
状态模式 管理 Promise 的三种状态(pending/fulfilled/rejected),且状态不可逆 1. 定义 status 属性,初始为 pending;
2. resolve/reject 仅在 pending 时修改状态;
3. then 方法根据不同状态执行不同逻辑(存储回调/直接执行)。
观察者模式 解决「状态变更后通知所有回调」的问题(比如 pending 时多次调用 then,状态确定后全部执行) 1. 定义 onFulfilledCallbacks/onRejectedCallbacks 数组(存储观察者);
2. 状态变更时(resolve/reject),遍历执行数组中的回调(通知观察者)。
工厂模式 then 方法返回新的 Promise 实例(无需手动 new,由 then 内部创建),实现链式调用 1. then 内部创建 newPromise 并返回;
2. resolvePromise 辅助函数根据回调返回值「生产」新 Promise 的状态。
策略模式 允许动态传入不同的回调策略(onFulfilled/onRejected),状态变更时执行对应策略 1. then 接收两个回调参数(不同的处理策略);
2. 成功时执行 onFulfilled,失败时执行 onRejected。

微任务小迷思:then 里「push 回调到数组」和「推到微任务队列」是一回事吗

先给核心结论

  • push 回调到数组:解决「Promise 还在 pending 状态时,回调该存哪」的问题(存储逻辑);
  • 推到微任务队列:解决「回调该什么时候执行」的问题(执行时机逻辑);
  • 二者关系:push 是「保存回调」,微任务队列是「调度执行」—— 先保存,再在合适的时机丢到微任务队列执行。

一、先分清两个「队列」:回调存储数组 vs 微任务队列

这是最容易混淆的点,先明确二者的定位:

类型 作用 时机 对应 Promise 状态
回调存储数组(如 onFulfilledCallbacks 临时保存回调,避免丢失 调用 then 时,Promise 是 pending 状态 pending(异步任务未完成)
微任务队列(浏览器/Node 内置) 调度回调的执行时机,保证异步 回调准备执行时(Promise 状态确定后) fulfilled/rejected(异步任务完成)

二、分步拆解:两个操作的配合流程(结合代码)

用我们之前写的 MyPromise 代码,还原完整执行流程:

场景:异步 Promise + 调用 then
// 1. 创建异步 Promise(pending 状态,1s 后 resolve)
const p = new MyPromise((resolve) => {
  setTimeout(() => resolve(100), 1000);
});

// 2. 调用 then:此时 Promise 还是 pending,执行「push 回调到数组」
p.then(res => console.log('回调执行:', res));
步骤1:push 回调到数组(存储)
// MyPromise 的 then 方法中
if (this.status === MyPromise.PENDING) {
  // 关键:把回调逻辑包装后,push 到存储数组
  this.onFulfilledCallbacks.push(() => {
    executeCallback(onFulfilled, this.value);
  });
}
  • 此时 Promise 还在 pending(1s 后才 resolve),无法执行回调,所以先把「回调执行逻辑」push 到 onFulfilledCallbacks 数组里保存;
  • 这一步和「微任务队列」无关,只是「临时存档」。
步骤2:状态确定后,执行存储的回调 → 推到微任务队列(调度)

1s 后,调用 resolve(100),Promise 状态变为 fulfilled

// resolve 函数中
this.status = MyPromise.FULFILLED;
this.value = value;
// 遍历执行存储数组中的回调
this.onFulfilledCallbacks.forEach(callback => callback());

执行 callback() 时,会调用 executeCallback 函数:

const executeCallback = (callback, data) => {
  // 关键:用 setTimeout 模拟微任务,把回调推到微任务队列
  setTimeout(() => {
    const result = callback(data);
    resolvePromise(newPromise, result, resolve, reject);
  }, 0);
};
  • 此时才把「真正的回调执行逻辑」推到微任务队列(用 setTimeout 模拟);
  • 这一步是「调度执行时机」,保证回调异步执行,而非同步阻塞。

三、特殊场景:Promise 已完成(非 pending)

如果调用 then 时,Promise 已经是 fulfilled/rejected,就不会 push 到存储数组,而是直接把回调推到微任务队列

// MyPromise 的 then 方法中
if (this.status === MyPromise.FULFILLED) {
  // 直接执行 executeCallback → 推到微任务队列
  executeCallback(onFulfilled, this.value);
}

示例:

// Promise 立即 resolve(状态为 fulfilled)
const p = new MyPromise((resolve) => resolve(100));
// 调用 then 时,状态已确定,直接把回调推到微任务队列
p.then(res => console.log(res));

四、关键区别:用生活例子类比

把 Promise 比作「奶茶店」:

  • push 回调到数组:你点单时,奶茶还没做好(pending),店员把你的「取餐需求」(回调)记在小本本(存储数组)上,避免漏单;
  • 推到微任务队列:奶茶做好了(fulfilled),店员喊你取餐,但店里规定「先做完所有即时单(同步代码),再叫号取餐(微任务)」—— 把你的「取餐动作」排到微任务队列,按顺序执行;
  • 若你到店时,奶茶已经做好了(非 pending):店员直接把你的「取餐动作」排到微任务队列,不用记小本本。

五、总结

  1. 不是一回事
    • push 回调到数组:是「存储行为」,解决 pending 状态下回调的保存问题,和执行时机无关;
    • 推到微任务队列:是「调度行为」,解决回调的异步执行时机问题,保证符合 Promise 规范;
  2. 关联关系
    • 若 Promise 是 pending → 先 push 到存储数组,状态确定后,再从数组取出回调,推到微任务队列执行;
    • 若 Promise 已完成 → 跳过存储数组,直接把回调推到微任务队列;
  3. 核心目的
    • 存储数组:保证回调不丢失;
    • 微任务队列:保证回调异步执行,且执行顺序符合规范(微任务优先级 > 宏任务)。

记住一句话就能分清:先存(push 数组),后调(微任务队列) —— 存储是为了不丢,微任务是为了异步。

【前端入门】如何用JS做侧边栏展开和关闭动画?

摘要

在很多电商的pc端网页,我们往往能看到那个再熟悉不过的侧边栏。当我们鼠标放上去时,它就会丝滑的弹出一个内容的盒子,当你离开,这个盒子又像弹簧一样收缩回去。接下来我们就要用JS做出这个简单的动画效果...

一、缓动动画原理

注:以左右移动效果为例

1.首先获得盒子原始的位置 (盒子要有定位,因为是利用盒子的left属性移动)
2.让盒子在当前位置上移动一个距离
3.添加定时器setInterval()重复2中的操作
4.到达指定位置后清除定时器 clearInterval

二、代码实现

1.首先准备一个大盒子,包含一个span和一个用来滑动的盒子

<div class = "slider_bar">
    <span>展开</span>
    <div class = "con">内容</div>
</div>

2.为盒子设置样式

.slider_bar{
    position:absolute;
    right:0;
    top:500px;
    width:100px;
    height:100px;
}
.span{
    display:block;
    width:100px;
    height:100px;
    background-color:pink;
}
.con{
    position:absolute;
    left:0;
    top:0;
    width:300px;
    height:100px;
    z-index:-1;
}

因为css的层叠性,要给.con设置z-index:-1,把滑动盒子放在span下面隐藏起来

3.写一个能够实现左右移动动画函数

function animate(obj,target,callback){
    clearInterval(obj.timer);
    obj.timer = setInterval(function(){
        var step = (target - obj.offsetLeft)/10;
        step = step > 0 ? Math.ceil(step) : Math.floor(step); 
        if(obj.offsetLeft == target){
            clearInterval(obj.timer);
            if(callback){
                callback();
            }
        }
        else{
            obj.style.left = obj.offsetLeft + step + 'px';
        }
    },30)
}

4.绑定显示和隐藏事件

<script>
    var sliderbar = document.querySelector(".slider_bar");
    var con = document.querySelector(".con");
    sliderbar.addEventListener("mouseenter",function(){
      animate(con,-200,function(){
          sliderbar.children[0].innerHTML = '关闭';
     ) })  
    }
    sliderbar.addEventListener("mouseleave",function(){
      animate(con,0,function(){
          sliderbar.children[0].innerHTML = '展开';
     ) })  
    }
</script> 

三、特别标注

1.当步长(step)为正数时,要向上取整(Math.ceil());为负数时,要向下取整(Math.floor())。 2.运动速度先快后慢,缓动效果,用公式:(目标位置-现在位置)/10. 3.回调函数做参数,callback就相当于声明的函数体,所以函数调用直接写callback();

四、完整代码示例

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>缓动动画原理</title>
  <style>
      .slider_bar {
          right: 0;
          top: 500px;
          position: absolute;
          width: 100px;
          height: 100px;
      }

      span {
          display: block;
          width: 100px;
          height: 100px;
          background-color: pink;
      }

      .con {
          position: absolute;
          left: 0px;
          top: 0px;
          width: 300px;
          height: 100px;
          background-color: blueviolet;
          z-index: -1;
      }
  </style>
  <script src="/practice1/animate.js"></script>
</head>

<body>
  <div class="slider_bar">
      <span>展开</span>
      <div class="con">问题反馈</div>
  </div>
  <script>
      var sliderBar = document.querySelector(".slider_bar");
      var con = document.querySelector(".con");
      sliderBar.addEventListener("mouseenter", function () {
          animate(con, -200, function () {
              sliderBar.children[0].innerHTML = '关闭';
          });
      })
      sliderBar.addEventListener("mouseleave", function () {
          animate(con, 0, function () {
              sliderBar.children[0].innerHTML = '展开';
          });
      })
  </script>
</body>

</html>

JS文件

function animate(obj, target, callback) {
    clearInterval(obj.timer);
    obj.timer = setInterval(function () {
        var step = (target - obj.offsetLeft) / 10;
        step = step > 0 ? Math.ceil(step) : Math.floor(step);
        if (obj.offsetLeft == target) {
            clearInterval(obj.timer);
            if (callback) {
                callback();
            }
        }
        else {
            obj.style.left = obj.offsetLeft + step + 'px';
        }
    }, 30)
}

完整代码示例的前面纯手搓,没在编辑器里面写,如果有错误希望掘友们帮我指出我再改正!

CSS Container Queries:实现响应式设计的新思路

CSS Container Queries:实现响应式设计的新思路

作为一名前端开发者,我相信你一定对媒体查询(Media Queries)不陌生。多年来,我们一直依赖 @media 规则来创建响应式设计,根据屏幕尺寸调整样式。但随着组件化开发的普及和设计复杂性的增加,我们逐渐发现了媒体查询的局限性。今天,我想和大家分享一个激动人心的新特性——CSS Container Queries,它正在改变我们思考和实现响应式设计的方式。

媒体查询的困境

在深入了解 Container Queries 之前,让我们先回顾一下传统媒体查询的限制。

想象这样一个场景:你正在开发一个卡片组件,这个组件可能会出现在页面的不同位置——有时占据整个宽度,有时只占据侧边栏的一小部分。使用传统的媒体查询,我们只能基于整个视口的尺寸来调整样式:

.card {
  padding: 1rem;
  background: white;
  border-radius: 8px;
}

.card h2 {
  font-size: 1.2rem;
}

@media (min-width: 768px) {
  .card h2 {
    font-size: 1.5rem;
  }
}

这种方法的问题在于,即使卡片本身很小(比如在侧边栏中),但如果视口宽度超过了768px,标题仍然会使用较大的字体,这可能导致布局问题。

Container Queries 的革命性思路

Container Queries 的出现解决了这个根本问题。它允许我们基于容器的尺寸而不是视口的尺寸来应用样式。这意味着组件可以根据自己的实际可用空间来调整外观,真正实现了组件级别的响应式设计。

基本语法和使用

要使用 Container Queries,首先需要定义一个容器:

.card-container {
  container-type: inline-size;
  /* 或者使用简写 */
  container: card-container / inline-size;
}

然后就可以使用 @container 规则了:

.card {
  padding: 1rem;
  background: white;
  border-radius: 8px;
}

.card h2 {
  font-size: 1.2rem;
}

@container (min-width: 400px) {
  .card h2 {
    font-size: 1.5rem;
  }
  
  .card {
    padding: 2rem;
  }
}

@container (min-width: 600px) {
  .card {
    display: flex;
    align-items: center;
  }
  
  .card h2 {
    font-size: 1.8rem;
  }
}

实际应用案例

让我通过一个完整的例子来展示 Container Queries 的强大之处。假设我们要创建一个产品卡片组件,它需要在不同的容器中表现出不同的布局:

<div class="main-content">
  <div class="product-card">
    <img src="product.jpg" alt="产品图片">
    <div class="product-info">
      <h3>产品标题</h3>
      <p>产品描述文本...</p>
      <div class="product-price">¥199</div>
      <button>立即购买</button>
    </div>
  </div>
</div>

<aside class="sidebar">
  <div class="product-card">
    <!-- 相同的HTML结构 -->
  </div>
</aside>

CSS实现:

/* 定义容器 */
.main-content,
.sidebar {
  container-type: inline-size;
}

/* 基础样式 */
.product-card {
  background: white;
  border-radius: 12px;
  box-shadow: 0 2px 8px rgba(0,0,0,0.1);
  overflow: hidden;
}

.product-card img {
  width: 100%;
  height: 200px;
  object-fit: cover;
}

.product-info {
  padding: 1rem;
}

.product-info h3 {
  font-size: 1.1rem;
  margin-bottom: 0.5rem;
}

.product-price {
  font-size: 1.2rem;
  font-weight: bold;
  color: #e74c3c;
  margin: 0.5rem 0;
}

/* 中等尺寸容器 */
@container (min-width: 320px) {
  .product-card {
    display: flex;
  }
  
  .product-card img {
    width: 150px;
    height: 120px;
    flex-shrink: 0;
  }
  
  .product-info h3 {
    font-size: 1.2rem;
  }
}

/* 大尺寸容器 */
@container (min-width: 500px) {
  .product-card img {
    width: 200px;
    height: 150px;
  }
  
  .product-info {
    padding: 1.5rem;
  }
  
  .product-info h3 {
    font-size: 1.4rem;
  }
  
  .product-price {
    font-size: 1.4rem;
  }
}

容器类型详解

Container Queries 支持几种不同的容器类型:

1. inline-size

这是最常用的类型,监听容器的内联尺寸(通常是宽度):

.container {
  container-type: inline-size;
}

2. size

监听容器的所有尺寸(宽度和高度):

.container {
  container-type: size;
}

@container (min-width: 400px) and (min-height: 300px) {
  /* 样式规则 */
}

3. normal

默认值,不创建容器查询上下文。

命名容器查询

为了更好地组织代码,我们可以给容器命名:

.sidebar {
  container: sidebar-container / inline-size;
}

.main-content {
  container: main-container / inline-size;
}

@container sidebar-container (max-width: 300px) {
  .product-card {
    /* 侧边栏特定样式 */
  }
}

@container main-container (min-width: 800px) {
  .product-card {
    /* 主内容区特定样式 */
  }
}

与CSS Grid/Flexbox的完美结合

Container Queries 与现代布局技术结合使用时威力更大:

.grid-container {
  container-type: inline-size;
  display: grid;
  gap: 1rem;
  grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
}

.grid-item {
  container-type: inline-size;
}

@container (min-width: 300px) {
  .grid-item .content {
    display: flex;
    flex-direction: column;
    gap: 1rem;
  }
}

@container (min-width: 400px) {
  .grid-item .content {
    flex-direction: row;
    align-items: center;
  }
}

性能考量和最佳实践

在使用 Container Queries 时,有几个重要的性能和使用原则:

1. 避免循环依赖

确保容器的尺寸不依赖于其内容的查询结果:

/* 避免这样做 */
.container {
  container-type: inline-size;
  width: fit-content; /* 可能导致循环依赖 */
}

2. 合理使用容器类型

只有在真正需要时才设置 container-type: size,因为它的性能开销比 inline-size 更大。

3. 渐进增强

为不支持 Container Queries 的浏览器提供回退方案:

/* 回退样式 */
.card {
  padding: 1rem;
}

.card h2 {
  font-size: 1.2rem;
}

/* 支持 Container Queries 时的增强 */
@supports (container-type: inline-size) {
  .card-container {
    container-type: inline-size;
  }
  
  @container (min-width: 400px) {
    .card {
      padding: 2rem;
    }
    
    .card h2 {
      font-size: 1.5rem;
    }
  }
}

浏览器兼容性和Polyfill

截至2024年,Container Queries 已经在现代浏览器中得到了良好支持:

  • Chrome 105+
  • Firefox 110+
  • Safari 16+

对于需要支持旧版浏览器的项目,可以考虑使用 polyfill 或采用渐进增强的策略。

实际项目中的应用场景

1. 组件库开发

在开发可复用组件时,Container Queries 让组件真正做到了自适应:

.button-group {
  container-type: inline-size;
  display: flex;
  gap: 0.5rem;
}

@container (max-width: 200px) {
  .button-group {
    flex-direction: column;
  }
}

2. 复杂布局系统

在复杂的后台管理系统中,不同区域的组件可以根据实际空间灵活调整:

.dashboard-widget {
  container-type: inline-size;
}

@container (min-width: 300px) {
  .chart-widget {
    /* 显示完整图表 */
  }
}

@container (max-width: 299px) {
  .chart-widget {
    /* 显示简化版本 */
  }
}

CSS Container Queries 代表了响应式设计思维的重大转变。从关注全局视口到关注局部容器,这种变化让我们能够创建更加灵活、可复用的组件。虽然它还是一个相对较新的特性,但我相信随着浏览器支持的完善和开发者认知的提升,Container Queries 将成为现代前端开发的重要工具。

作为前端开发者,我建议大家开始在新项目中尝试使用 Container Queries,特别是在组件化开发中。它不仅能解决传统媒体查询的局限性,更能让我们的代码更加模块化和可维护。

响应式设计的未来已经到来,你准备好拥抱这个变化了吗?

Vue3的v-model如何实现表单双向绑定?

一、为什么需要表单输入绑定?

你有没有过这样的经历?做登录页时,想让用户输入的用户名实时显示在页面上;或者做设置页时,修改开关按钮的状态要同步到后台数据。这时候,如果手动监听每个输入框的事件、手动更新数据,代码会变得非常繁琐——比如:

<input type="text" id="username" oninput="updateUsername(event)">
function updateUsername(e) {
  this.username = e.target.value;
}

不仅要写一堆事件监听,还要处理不同表单元素的差异(比如复选框的checked属性、下拉框的selected属性)。而Vue3的表单输入绑定就是为了解决这个问题——它帮你把“输入→数据→视图”的同步逻辑封装成了一个简单的指令:v-model

二、双向绑定:Vue3的“数据-视图”同步魔法

在讲v-model之前,我们得先搞懂双向绑定的核心逻辑。简单来说,双向绑定就是:

  • 当用户修改视图(比如输入文字、点击复选框),数据自动更新;
  • 当代码修改数据(比如this.username = 'admin'),视图自动同步。

双向绑定的原理流程图

graph TD
A[用户修改视图 输入/点击] --> B[触发对应事件 input/change]
B --> C[更新数据如username 输入内容]
C --> D[Vue响应式系统检测到数据变化]
D --> E[自动更新视图显示]

举个例子:当你在输入框里敲“hello”,Vue会做这几件事:

  1. 监听输入框的input事件,拿到你输入的“hello”;
  2. username数据更新为“hello”;
  3. 响应式系统发现username变了,立刻通知输入框显示“hello”。

三、v-model指令:双向绑定的语法糖

Vue3为双向绑定提供了语法糖——v-model,它把“绑定value+监听事件”的逻辑封装成了一个指令。比如:

<input v-model="username">

等价于:

<input :value="username" @input="username = $event.target.value">

是不是简洁多了?v-model帮你省掉了手动写事件监听的麻烦,而且适用于所有表单元素。

四、v-model在不同表单元素中的应用

v-model不是只能用在文本输入框,它支持所有常见的表单元素,我们逐个看:

1. 文本输入框(input[type="text"])与多行文本(textarea)

  • 文本输入框:直接绑定字符串类型的响应式数据;
  • 多行文本(textarea):不能用插值表达式{{ message }}),必须用v-model

示例代码:

<script setup>
import { ref } from 'vue'
const username = ref('') // 字符串类型
const intro = ref('')    // 多行文本内容
</script>

<template>
  <div>
    <label>用户名:<input type="text" v-model="username"></label>
    <label>个人简介:<textarea v-model="intro" rows="3"></textarea></label>
  </div>
</template>

2. 复选框(input[type="checkbox"])

复选框分两种情况:

  • 单个复选框:绑定布尔值(true/false),表示“是否选中”;
  • 多个复选框:绑定数组,数组元素是选中的value值。

示例代码:

<script setup>
import { ref } from 'vue'
const rememberMe = ref(false) // 单个复选框(布尔值)
const hobbies = ref([])       // 多个复选框(数组)
</script>

<template>
  <div>
    <!-- 单个复选框:记住我 -->
    <label><input type="checkbox" v-model="rememberMe"> 记住我</label>
    
    <!-- 多个复选框:爱好 -->
    <label><input type="checkbox" value="reading" v-model="hobbies"> 阅读</label>
    <label><input type="checkbox" value="sports" v-model="hobbies"> 运动</label>
    <label><input type="checkbox" value="coding" v-model="hobbies"> 编程</label>
  </div>
</template>

3. 单选按钮(input[type="radio"])

单选按钮绑定字符串,值为选中的value属性。

示例代码:

<script setup>
import { ref } from 'vue'
const gender = ref('male') // 默认选中“男”
</script>

<template>
  <div>
    <label><input type="radio" value="male" v-model="gender"></label>
    <label><input type="radio" value="female" v-model="gender"></label>
  </div>
</template>

4. 下拉框(select)

下拉框的v-model绑定选中的value值,optionvalue属性对应选项值。

示例代码:

<script setup>
import { ref } from 'vue'
const city = ref('beijing') // 默认选中“北京”
</script>

<template>
  <div>
    <label>城市:
      <select v-model="city">
        <option value="beijing">北京</option>
        <option value="shanghai">上海</option>
        <option value="guangzhou">广州</option>
      </select>
    </label>
  </div>
</template>

五、数据响应式:双向绑定的底层支撑

你可能会问:“为什么数据变了,视图会自动更新?”这要归功于Vue3的响应式系统

往期文章归档
免费好用的热门在线工具

Vue3用refreactive创建响应式数据,当数据变化时,Vue会自动追踪依赖(比如模板中用到username的地方),并更新对应的视图。而v-model正是利用了这个系统,让数据和视图双向同步。

比如用ref创建username

const username = ref('')

ref会把username包装成一个响应式对象,当你修改username.value(或通过v-model修改),Vue会立刻知道,并更新视图。

六、实际案例:打造一个注册表单

让我们把前面的知识点整合起来,做一个注册表单,包含用户名、密码、记住我、性别、爱好、城市,提交时打印表单数据。

完整代码(带样式)

<script setup>
import { ref } from 'vue'

// 用ref创建表单对象,包含所有字段
const form = ref({
  username: '',
  password: '',
  rememberMe: false,
  gender: 'male',
  hobbies: [],
  city: 'beijing'
})

// 提交处理函数:阻止默认刷新,打印表单数据
const handleSubmit = (e) => {
  e.preventDefault()
  console.log('表单数据:', form.value)
  // 这里可以加发送请求到后台的逻辑,比如axios.post('/api/register', form.value)
}
</script>

<template>
  <div class="register-form">
    <h2>用户注册</h2>
    <form @submit.prevent="handleSubmit">
      <!-- 用户名 -->
      <div class="form-group">
        <label for="username">用户名:</label>
        <input 
          type="text" 
          id="username" 
          v-model="form.username" 
          placeholder="请输入用户名"
          required
        >
      </div>
      
      <!-- 密码 -->
      <div class="form-group">
        <label for="password">密码:</label>
        <input 
          type="password" 
          id="password" 
          v-model="form.password" 
          placeholder="请输入密码"
          required
        >
      </div>
      
      <!-- 记住我 -->
      <div class="form-group">
        <label><input type="checkbox" v-model="form.rememberMe"> 记住登录状态</label>
      </div>
      
      <!-- 性别 -->
      <div class="form-group">
        <label>性别:</label>
        <input type="radio" value="male" v-model="form.gender"><input type="radio" value="female" v-model="form.gender"></div>
      
      <!-- 爱好 -->
      <div class="form-group">
        <label>爱好:</label>
        <input type="checkbox" value="reading" v-model="form.hobbies"> 阅读
        <input type="checkbox" value="sports" v-model="form.hobbies"> 运动
        <input type="checkbox" value="coding" v-model="form.hobbies"> 编程
      </div>
      
      <!-- 城市 -->
      <div class="form-group">
        <label for="city">城市:</label>
        <select id="city" v-model="form.city">
          <option value="beijing">北京</option>
          <option value="shanghai">上海</option>
          <option value="guangzhou">广州</option>
          <option value="shenzhen">深圳</option>
        </select>
      </div>
      
      <!-- 提交按钮 -->
      <button type="submit" class="submit-btn">注册</button>
    </form>
  </div>
</template>

<style scoped>
.register-form {
  max-width: 400px;
  margin: 20px auto;
  padding: 20px;
  border: 1px solid #eee;
  border-radius: 8px;
}
.form-group {
  margin-bottom: 15px;
}
label {
  display: block;
  margin-bottom: 5px;
}
input, select {
  width: 100%;
  padding: 8px;
  border: 1px solid #ddd;
  border-radius: 4px;
}
.submit-btn {
  width: 100%;
  padding: 10px;
  background-color: #42b983;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}
.submit-btn:hover {
  background-color: #3aa776;
}
</style>

代码说明

  1. 表单数据管理:用ref创建form对象,把所有表单字段放在一起,方便管理;
  2. 提交处理:用@submit.prevent阻止表单默认的刷新行为,打印表单数据;
  3. 响应式同步:每个字段用v-model绑定到form对象的属性,输入时自动同步。

七、课后Quiz:巩固你的理解

来做两个小练习,检验一下学习成果~

1. 问题:v-model的语法糖本质是什么?请写出等价的原生绑定代码。

答案解析
v-model是value属性绑定 + input事件监听的语法糖。比如<input v-model="message">等价于:

<input :value="message" @input="message = $event.target.value">
  • :value="message":把数据绑定到输入框的value属性;
  • @input:监听输入事件,把输入内容更新到message

2. 问题:多个复选框如何用v-model实现多选?请写出示例代码。

答案解析
多个复选框需要绑定到数组类型的响应式数据。每个复选框的value对应数组中的元素,选中时加入数组,取消时移除。示例:

<script setup>
import { ref } from 'vue'
const hobbies = ref([]) // 数组类型
</script>

<template>
  <label><input type="checkbox" value="reading" v-model="hobbies"> 阅读</label>
  <label><input type="checkbox" value="sports" v-model="hobbies"> 运动</label>
  <label><input type="checkbox" value="coding" v-model="hobbies"> 编程</label>
</template>

比如选中“阅读”和“编程”,hobbies.value会变成['reading', 'coding']

八、常见报错与解决方案

学习过程中遇到报错别慌,以下是表单绑定常见的3个错误及解决办法:

1. 报错:v-model is not allowed on <input type="file">

  • 原因:文件输入框(type="file")的value只读的,无法通过v-model修改。
  • 解决办法:用ref获取DOM元素,监听change事件拿文件:
    <script setup>
    import { ref } from 'vue'
    const fileInput = ref(null)
    const handleFile = () => {
      const file = fileInput.value.files[0] // 获取选中的文件
      console.log('文件:', file)
    }
    </script>
    
    <template>
      <input type="file" ref="fileInput" @change="handleFile">
    </template>
    

2. 报错:Property "message" was accessed during render but is not defined

  • 原因:模板里用了message,但没在setup中定义响应式数据。
  • 解决办法:用refreactive定义message
    import { ref } from 'vue'
    const message = ref('') // 必须定义!
    

3. 报错:v-model requires a valid Vue instance

  • 原因:可能在非Vue组件中用了v-model(比如纯HTML文件没挂载Vue),或组件未正确注册。
  • 解决办法:确保在Vue组件中使用,并正确挂载应用:
    // main.js
    import { createApp } from 'vue'
    import App from './App.vue'
    createApp(App).mount('#app') // 挂载到#app元素
    

参考链接

官网表单处理文档:vuejs.org/guide/essen…

每日一题-最大的幻方🟡

一个 k x k 的 幻方 指的是一个 k x k 填满整数的方格阵,且每一行、每一列以及两条对角线的和 全部相等 。幻方中的整数 不需要互不相同 。显然,每个 1 x 1 的方格都是一个幻方。

给你一个 m x n 的整数矩阵 grid ,请你返回矩阵中 最大幻方 的 尺寸 (即边长 k)。

 

示例 1:

输入:grid = [[7,1,4,5,6],[2,5,1,6,4],[1,5,4,3,2],[1,2,7,3,4]]
输出:3
解释:最大幻方尺寸为 3 。
每一行,每一列以及两条对角线的和都等于 12 。
- 每一行的和:5+1+6 = 5+4+3 = 2+7+3 = 12
- 每一列的和:5+5+2 = 1+4+7 = 6+3+3 = 12
- 对角线的和:5+4+3 = 6+4+2 = 12

示例 2:

输入:grid = [[5,1,3,1],[9,3,3,1],[1,3,3,8]]
输出:2

 

提示:

  • m == grid.length
  • n == grid[i].length
  • 1 <= m, n <= 50
  • 1 <= grid[i][j] <= 106

[Python3/Java/C++/Go] 一题一解:前缀和 + 枚举

方法一:前缀和 + 枚举

先求每行、每列的前缀和。然后从大到小枚举尺寸 $k$,找到第一个符合条件的 $k$,然后返回即可。否则最后返回 $1$。

class Solution:
    def largestMagicSquare(self, grid: List[List[int]]) -> int:
        m, n = len(grid), len(grid[0])
        rowsum = [[0] * (n + 1) for _ in range(m + 1)]
        colsum = [[0] * (n + 1) for _ in range(m + 1)]
        for i in range(1, m + 1):
            for j in range(1, n + 1):
                rowsum[i][j] = rowsum[i][j - 1] + grid[i - 1][j - 1]
                colsum[i][j] = colsum[i - 1][j] + grid[i - 1][j - 1]

        def check(x1, y1, x2, y2):
            val = rowsum[x1 + 1][y2 + 1] - rowsum[x1 + 1][y1]
            for i in range(x1 + 1, x2 + 1):
                if rowsum[i + 1][y2 + 1] - rowsum[i + 1][y1] != val:
                    return False
            for j in range(y1, y2 + 1):
                if colsum[x2 + 1][j + 1] - colsum[x1][j + 1] != val:
                    return False
            s, i, j = 0, x1, y1
            while i <= x2:
                s += grid[i][j]
                i += 1
                j += 1
            if s != val:
                return False
            s, i, j = 0, x1, y2
            while i <= x2:
                s += grid[i][j]
                i += 1
                j -= 1
            if s != val:
                return False
            return True

        for k in range(min(m, n), 1, -1):
            i = 0
            while i + k - 1 < m:
                j = 0
                while j + k - 1 < n:
                    i2, j2 = i + k - 1, j + k - 1
                    if check(i, j, i2, j2):
                        return k
                    j += 1
                i += 1
        return 1
class Solution {
    private int[][] rowsum;
    private int[][] colsum;

    public int largestMagicSquare(int[][] grid) {
        int m = grid.length, n = grid[0].length;
        rowsum = new int[m + 1][n + 1];
        colsum = new int[m + 1][n + 1];
        for (int i = 1; i <= m; ++i) {
            for (int j = 1; j <= n; ++j) {
                rowsum[i][j] = rowsum[i][j - 1] + grid[i - 1][j - 1];
                colsum[i][j] = colsum[i - 1][j] + grid[i - 1][j - 1];
            }
        }
        for (int k = Math.min(m, n); k > 1; --k) {
            for (int i = 0; i + k - 1 < m; ++i) {
                for (int j = 0; j + k - 1 < n; ++j) {
                    int i2 = i + k - 1, j2 = j + k - 1;
                    if (check(grid, i, j, i2, j2)) {
                        return k;
                    }
                }
            }
        }
        return 1;
    }

    private boolean check(int[][] grid, int x1, int y1, int x2, int y2) {
        int val = rowsum[x1 + 1][y2 + 1] - rowsum[x1 + 1][y1];
        for (int i = x1 + 1; i <= x2; ++i) {
            if (rowsum[i + 1][y2 + 1] - rowsum[i + 1][y1] != val) {
                return false;
            }
        }
        for (int j = y1; j <= y2; ++j) {
            if (colsum[x2 + 1][j + 1] - colsum[x1][j + 1] != val) {
                return false;
            }
        }
        int s = 0;
        for (int i = x1, j = y1; i <= x2; ++i, ++j) {
            s += grid[i][j];
        }
        if (s != val) {
            return false;
        }
        s = 0;
        for (int i = x1, j = y2; i <= x2; ++i, --j) {
            s += grid[i][j];
        }
        if (s != val) {
            return false;
        }
        return true;
    }
}
class Solution {
public:
    int largestMagicSquare(vector<vector<int>> &grid) {
        int m = grid.size(), n = grid.size();
        vector<vector<int>> rowsum(m + 1, vector<int>(n + 1));
        vector<vector<int>> colsum(m + 1, vector<int>(n + 1));
        for (int i = 1; i <= m; ++i)
        {
            for (int j = 1; j <= n; ++j)
            {
                rowsum[i][j] = rowsum[i][j - 1] + grid[i - 1][j - 1];
                colsum[i][j] = colsum[i - 1][j] + grid[i - 1][j - 1];
            }
        }
        for (int k = min(m, n); k > 1; --k)
        {
            for (int i = 0; i + k - 1 < m; ++i)
            {
                for (int j = 0; j + k - 1 < n; ++j)
                {
                    int i2 = i + k - 1, j2 = j + k - 1;
                    if (check(grid, rowsum, colsum, i, j, i2, j2))
                        return k;
                }
            }
        }
        return 1;
    }

    bool check(vector<vector<int>> &grid, vector<vector<int>> &rowsum, vector<vector<int>> &colsum, int x1, int y1, int x2, int y2)
    {
        int val = rowsum[x1 + 1][y2 + 1] - rowsum[x1 + 1][y1];
        for (int i = x1 + 1; i <= x2; ++i)
            if (rowsum[i + 1][y2 + 1] - rowsum[i + 1][y1] != val)
                return false;
        for (int j = y1; j <= y2; ++j)
            if (colsum[x2 + 1][j + 1] - colsum[x1][j + 1] != val)
                return false;
        int s = 0;
        for (int i = x1, j = y1; i <= x2; ++i, ++j)
            s += grid[i][j];
        if (s != val)
            return false;
        s = 0;
        for (int i = x1, j = y2; i <= x2; ++i, --j)
            s += grid[i][j];
        if (s != val)
            return false;
        return true;
    }
};
func largestMagicSquare(grid [][]int) int {
m, n := len(grid), len(grid[0])
rowsum := make([][]int, m+1)
colsum := make([][]int, m+1)
for i := 0; i <= m; i++ {
rowsum[i] = make([]int, n+1)
colsum[i] = make([]int, n+1)
}
for i := 1; i < m+1; i++ {
for j := 1; j < n+1; j++ {
rowsum[i][j] = rowsum[i][j-1] + grid[i-1][j-1]
colsum[i][j] = colsum[i-1][j] + grid[i-1][j-1]
}
}
for k := min(m, n); k > 1; k-- {
for i := 0; i+k-1 < m; i++ {
for j := 0; j+k-1 < n; j++ {
i2, j2 := i+k-1, j+k-1
if check(grid, rowsum, colsum, i, j, i2, j2) {
return k
}
}
}
}
return 1
}

func check(grid, rowsum, colsum [][]int, x1, y1, x2, y2 int) bool {
val := rowsum[x1+1][y2+1] - rowsum[x1+1][y1]
for i := x1 + 1; i < x2+1; i++ {
if rowsum[i+1][y2+1]-rowsum[i+1][y1] != val {
return false
}
}
for j := y1; j < y2+1; j++ {
if colsum[x2+1][j+1]-colsum[x1][j+1] != val {
return false
}
}
s := 0
for i, j := x1, y1; i <= x2; i, j = i+1, j+1 {
s += grid[i][j]
}
if s != val {
return false
}
s = 0
for i, j := x1, y2; i <= x2; i, j = i+1, j-1 {
s += grid[i][j]
}
if s != val {
return false
}
return true
}

func min(a, b int) int {
if a > b {
return a
}
return b
}

复杂度O(MNmin(M,N))的算法,比官方解答低一个量级

###python3

class Solution:
    def largestMagicSquare(self, grid: List[List[int]]) -> int:
        N = len(grid)
        M = len(grid[0])
        up = [[0] * M for _ in range(N)]
        left = [[0] * M for _ in range(N)]
        up_left = [[0] * M for _ in range(N)]
        up_right = [[0] * M for _ in range(N)]

        def check_get(arr, i, j):
            if i >= 0 and 0 <= j < M:
                return arr[i][j]
            else:
                return 0

        for i in range(N):
            for j in range(M):
                up[i][j] = check_get(up, i - 1, j) + grid[i][j]
                left[i][j] = check_get(left, i, j - 1) + grid[i][j]
                up_left[i][j] = check_get(up_left, i - 1, j - 1) + grid[i][j]
                up_right[i][j] = check_get(up_right, i - 1, j + 1) + grid[i][j]
        for k in range(min(M, N), 1, -1):
            candidates = set()
            for i in range(k - 1, N):
                last = up[i][0] - check_get(up, i - k, 0)
                count = 1
                for j in range(1, M):
                    curr = up[i][j] - check_get(up, i - k, j)
                    if curr == last:
                        count += 1
                    else:
                        last = curr
                        count = 1
                    if count >= k:
                        # Check diagonal
                        if up_left[i][j] - check_get(up_left, i - k, j - k) == last\
                                and up_right[i][j - k + 1] - check_get(up_right, i - k, j + 1) == last:
                            candidates.add((i, j))
            if candidates:
                for j in range(k - 1, M):
                    last = left[0][j] - check_get(left, 0, j - k)
                    count = 1
                    for i in range(1, N):
                        curr = left[i][j] - check_get(left, i, j - k)
                        if curr == last:
                            count += 1
                        else:
                            last = curr
                            count = 1
                        if count >= k and (i, j) in candidates:
                            return k
        else:
            return 1

算法原理并不算复杂,仍然是用前缀和来优化计算,不过这里有个额外的优化:

选定某个k的情况下,如果某个k*k的正方形中每一列的和都相等,我们称之为列准幻方。现在希望一次找到所有的列准幻方,首先穷举幻方的最后一行(由于k已经选定第一行也就确定了),然后扫描每一列,注意到新扫描进来的这一列的和可以用前缀和在O(1)时间内计算出来,同时可以立即判断出它是否和前一列相等,随时记录当前相等的列数,就可以判断出以当前列为最后一列的k*k正方形是不是一个列准幻方。对于找到的每个列准幻方,接下来可以校验它的两条对角线是不是和最后一列的和相等,通过前缀和也可以做到O(1)复杂度,这样可以筛选出所有对角线也符合条件的列准幻方。

如果对于每个列准幻方都校验各行的和,则复杂度会变成四次方级别。这里有个非常简单的技巧解决这个问题:我们把行列倒换,用相同的方法求出所有的行准幻方,然后用hash表判断每个行准幻方是否同时也是列准幻方。显然如果一个正方形既是行准幻方又是列准幻方,同时对角线也符合条件,那么它就是一个幻方。

这样总的复杂度就可以降到O(NM min(M,N))。

从 O(N^4) 优化到 O(N^3)(Python/Java/C++/Go)

注:本题不能二分答案。「每行每列的元素和都相等」是一个非常刁钻的要求,可能中间的某个 $k$ 满足要求,$k$ 大一点或小一点都无法让每行每列的元素和都相等。

方法一:四种前缀和

从大到小枚举 $k$,判断 $\textit{grid}$ 是否存在一个 $k\times k$ 的子矩阵 $M$,满足如下要求:

  • 设 $M$ 第一行的元素和为 $s$。
  • $M$ 每行的元素和都是 $s$。
  • $M$ 每列的元素和都是 $s$。
  • $M$ 主对角线的元素和为 $s$。
  • $M$ 反对角线的元素和为 $s$。

这些参与求和的元素,在 $\textit{grid}$ 中都是连续的,我们可以用四种前缀和计算:

  • $\textit{rowSum}[i][j+1]$ 表示 $\textit{grid}$ 的 $i$ 行的前缀 $[0,j]$ 的元素和,即 $(i,0),(i,1),\ldots,(i,j)$ 的元素和。
  • $\textit{colSum}[i+1][j]$ 表示 $\textit{grid}$ 的 $j$ 列的前缀 $[0,i]$ 的元素和,即 $(0,j),(1,j),\ldots,(i,j)$ 的元素和。
  • $\textit{diagSum}[i+1][j+1]$ 表示从最上边或最左边出发,向右下↘到 $(i,j)$ 这条线上的元素和。
  • $\textit{antiSum}[i+1][j]$ 表示从最上边或最右边出发,向左下↙到 $(i,j)$ 这条线上的元素和。

为什么这里有一些 $+1$?原理在 前缀和 中讲了,是为了兼容子数组恰好是前缀的情况,此时仍然可以用两个前缀和之差算出子数组和,无需特判。

写个三重循环,依次枚举 $k,i,j$,其中 $k\times k$ 子矩阵的左上角为 $(i-k,j-k)$,右下角为 $(i-1,j-1)$,那么:

  • 主对角线的元素和为 $\textit{diagSum}[i][j] - \textit{diagSum}[i-k][j-k]$。
  • 反对角线的元素和为 $\textit{antiSum}[i][j-k]-\textit{antiSum}[i-k][j]$。
  • 在 $[i-k,k-1]$ 中枚举行号 $r$,行元素和为 $\textit{rowSum}[r][j] - \textit{rowSum}[r][j-k]$。
  • 在 $[j-k,j-1]$ 中枚举列号 $c$,列元素和为 $\textit{colSum}[i][c] - \textit{colSum}[i-k][c]$。

代码实现时,可以先求主对角线的元素和、反对角线的元素和,如果二者不相等,则无需枚举 $r$ 和 $c$。

class Solution:
    def largestMagicSquare(self, grid: List[List[int]]) -> int:
        m, n = len(grid), len(grid[0])
        row_sum = [[0] * (n + 1) for _ in range(m)]       # → 前缀和
        col_sum = [[0] * n for _ in range(m + 1)]         # ↓ 前缀和
        diag_sum = [[0] * (n + 1) for _ in range(m + 1)]  # ↘ 前缀和
        anti_sum = [[0] * (n + 1) for _ in range(m + 1)]  # ↙ 前缀和

        for i, row in enumerate(grid):
            for j, x in enumerate(row):
                row_sum[i][j + 1] = row_sum[i][j] + x
                col_sum[i + 1][j] = col_sum[i][j] + x
                diag_sum[i + 1][j + 1] = diag_sum[i][j] + x
                anti_sum[i + 1][j] = anti_sum[i][j + 1] + x

        # k×k 子矩阵的左上角为 (i−k, j−k),右下角为 (i−1, j−1)
        for k in range(min(m, n), 0, -1):
            for i in range(k, m + 1):
                for j in range(k, n + 1):
                    # 子矩阵主对角线的和
                    s = diag_sum[i][j] - diag_sum[i - k][j - k]

                    # 子矩阵反对角线的和等于 s
                    # 子矩阵每行的和都等于 s
                    # 子矩阵每列的和都等于 s
                    if anti_sum[i][j - k] - anti_sum[i - k][j] == s and \
                       all(row_sum[r][j] - row_sum[r][j - k] == s for r in range(i - k, i)) and \
                       all(col_sum[i][c] - col_sum[i - k][c] == s for c in range(j - k, j)):
                        return k
class Solution {
    public int largestMagicSquare(int[][] grid) {
        int m = grid.length;
        int n = grid[0].length;
        int[][] rowSum = new int[m][n + 1];      // → 前缀和
        int[][] colSum = new int[m + 1][n];      // ↓ 前缀和
        int[][] diagSum = new int[m + 1][n + 1]; // ↘ 前缀和
        int[][] antiSum = new int[m + 1][n + 1]; // ↙ 前缀和

        for (int i = 0; i < m; i++) {
            for (int j = 0; j < n; j++) {
                int x = grid[i][j];
                rowSum[i][j + 1] = rowSum[i][j] + x;
                colSum[i + 1][j] = colSum[i][j] + x;
                diagSum[i + 1][j + 1] = diagSum[i][j] + x;
                antiSum[i + 1][j] = antiSum[i][j + 1] + x;
            }
        }

        // k×k 子矩阵的左上角为 (i−k, j−k),右下角为 (i−1, j−1)
        for (int k = Math.min(m, n); ; k--) {
            for (int i = k; i <= m; i++) {
                next:
                for (int j = k; j <= n; j++) {
                    // 子矩阵主对角线的和
                    int sum = diagSum[i][j] - diagSum[i - k][j - k];

                    // 子矩阵反对角线的和
                    if (antiSum[i][j - k] - antiSum[i - k][j] != sum) {
                        continue;
                    }

                    // 子矩阵每行的和
                    for (int r = i - k; r < i; r++) {
                        if (rowSum[r][j] - rowSum[r][j - k] != sum) {
                            continue next;
                        }
                    }

                    // 子矩阵每列的和
                    for (int c = j - k; c < j; c++) {
                        if (colSum[i][c] - colSum[i - k][c] != sum) {
                            continue next;
                        }
                    }

                    return k;
                }
            }
        }
    }
}
class Solution {
public:
    int largestMagicSquare(vector<vector<int>>& grid) {
        int m = grid.size(), n = grid[0].size();
        vector row_sum(m, vector<int>(n + 1));      // → 前缀和
        vector col_sum(m + 1, vector<int>(n));      // ↓ 前缀和
        vector diag_sum(m + 1, vector<int>(n + 1)); // ↘ 前缀和
        vector anti_sum(m + 1, vector<int>(n + 1)); // ↙ 前缀和

        for (int i = 0; i < m; i++) {
            for (int j = 0; j < n; j++) {
                int x = grid[i][j];
                row_sum[i][j + 1] = row_sum[i][j] + x;
                col_sum[i + 1][j] = col_sum[i][j] + x;
                diag_sum[i + 1][j + 1] = diag_sum[i][j] + x;
                anti_sum[i + 1][j] = anti_sum[i][j + 1] + x;
            }
        }

        // k×k 子矩阵的左上角为 (i−k, j−k),右下角为 (i−1, j−1)
        for (int k = min(m, n); ; k--) {
            for (int i = k; i <= m; i++) {
                for (int j = k; j <= n; j++) {
                    // 子矩阵主对角线的和
                    int sum = diag_sum[i][j] - diag_sum[i - k][j - k];

                    // 子矩阵反对角线的和
                    if (anti_sum[i][j - k] - anti_sum[i - k][j] != sum) {
                        continue;
                    }

                    // 子矩阵每行的和
                    bool ok = true;
                    for (int r = i - k; r < i; r++) {
                        if (row_sum[r][j] - row_sum[r][j - k] != sum) {
                            ok = false;
                            break;
                        }
                    }
                    if (!ok) {
                        continue;
                    }

                    // 子矩阵每列的和
                    for (int c = j - k; c < j; c++) {
                        if (col_sum[i][c] - col_sum[i - k][c] != sum) {
                            ok = false;
                            break;
                        }
                    }
                    if (ok) {
                        return k;
                    }
                }
            }
        }
    }
};
func largestMagicSquare(grid [][]int) int {
m, n := len(grid), len(grid[0])
rowSum := make([][]int, m)    // → 前缀和
colSum := make([][]int, m+1)  // ↓ 前缀和
diagSum := make([][]int, m+1) // ↘ 前缀和
antiSum := make([][]int, m+1) // ↙ 前缀和
for i := range m + 1 {
colSum[i] = make([]int, n)
diagSum[i] = make([]int, n+1)
antiSum[i] = make([]int, n+1)
}

for i, row := range grid {
rowSum[i] = make([]int, n+1)
for j, x := range row {
rowSum[i][j+1] = rowSum[i][j] + x
colSum[i+1][j] = colSum[i][j] + x
diagSum[i+1][j+1] = diagSum[i][j] + x
antiSum[i+1][j] = antiSum[i][j+1] + x
}
}

// k×k 子矩阵的左上角为 (i−k, j−k),右下角为 (i−1, j−1)
for k := min(m, n); ; k-- {
for i := k; i <= m; i++ {
next:
for j := k; j <= n; j++ {
// 子矩阵主对角线的和
sum := diagSum[i][j] - diagSum[i-k][j-k]

// 子矩阵反对角线的和
if antiSum[i][j-k]-antiSum[i-k][j] != sum {
continue
}

// 子矩阵每行的和
for _, rowS := range rowSum[i-k : i] {
if rowS[j]-rowS[j-k] != sum {
continue next
}
}

// 子矩阵每列的和
for c := j - k; c < j; c++ {
if colSum[i][c]-colSum[i-k][c] != sum {
continue next
}
}

return k
}
}
}
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(mn\min(m,n)^2)$,其中 $m$ 和 $n$ 分别是 $\textit{grid}$ 的行数和列数。
  • 空间复杂度:$\mathcal{O}(mn)$。

方法二:维护连续等和行列的个数

从大到小枚举 $k$,判断 $\textit{grid}$ 是否存在一个 $k\times k$ 的子矩阵 $M$,满足如下要求:

  • 设 $M$ 第一行的元素和为 $s$。
  • $M$ 每行的元素和都是 $s$。优化:想象有一个 $k\times k$ 的窗口在向下滑动,我们可以维护到第 $i$ 行时,有连续多少行的和都等于 $s$。维护一个计数器 $\textit{sameCnt}$,如果当前行的和等于前一行的和,那么把 $\textit{sameCnt}$ 加一,否则把 $\textit{sameCnt}$ 重置为 $1$。如果 $\textit{sameCnt}\ge k$,则说明子矩阵每行的元素和都相等。
  • $M$ 每列的元素和都是 $s$。优化:想象有一个 $k\times k$ 的窗口在向右滑动,我们可以维护到第 $j$ 列时,有连续多少列的和都等于 $s$。算法同上。
  • $M$ 主对角线的元素和为 $s$。
  • $M$ 反对角线的元素和为 $s$。
class Solution:
    def largestMagicSquare(self, grid: List[List[int]]) -> int:
        m, n = len(grid), len(grid[0])
        row_sum = [[0] * (n + 1) for _ in range(m)]       # → 前缀和
        col_sum = [[0] * n for _ in range(m + 1)]         # ↓ 前缀和
        diag_sum = [[0] * (n + 1) for _ in range(m + 1)]  # ↘ 前缀和
        anti_sum = [[0] * (n + 1) for _ in range(m + 1)]  # ↙ 前缀和

        for i, row in enumerate(grid):
            for j, x in enumerate(row):
                row_sum[i][j + 1] = row_sum[i][j] + x
                col_sum[i + 1][j] = col_sum[i][j] + x
                diag_sum[i + 1][j + 1] = diag_sum[i][j] + x
                anti_sum[i + 1][j] = anti_sum[i][j + 1] + x

        # is_same_col_sum[i][j] 表示右下角为 (i, j) 的子矩形,每列元素和是否都相等
        is_same_col_sum = [[False] * n for _ in range(m)]

        for k in range(min(m, n), 1, -1):
            for i in range(k, m + 1):
                # 想象有一个 k×k 的窗口在向右滑动
                same_cnt = 1
                for j in range(1, n):
                    if col_sum[i][j] - col_sum[i - k][j] == col_sum[i][j - 1] - col_sum[i - k][j - 1]:
                        same_cnt += 1
                    else:
                        same_cnt = 1
                    # 连续 k 列元素和是否都一样
                    is_same_col_sum[i - 1][j] = same_cnt >= k

            for j in range(k, n + 1):
                # 想象有一个 k×k 的窗口在向下滑动
                sum_row = row_sum[0][j] - row_sum[0][j - k]
                same_cnt = 1
                for i in range(2, m + 1):
                    row_s = row_sum[i - 1][j] - row_sum[i - 1][j - k]
                    if row_s == sum_row:
                        same_cnt += 1
                        if (same_cnt >= k and  # 连续 k 行元素和都一样
                            is_same_col_sum[i - 1][j - 1] and  # 连续 k 列元素和都一样
                            col_sum[i][j - 1] - col_sum[i - k][j - 1] == sum_row and  # 列和 = 行和
                            diag_sum[i][j] - diag_sum[i - k][j - k] == sum_row and  # 主对角线和 = 行和
                            anti_sum[i][j - k] - anti_sum[i - k][j] == sum_row):  # 反对角线和 = 行和
                            return k
                    else:
                        sum_row = row_s
                        same_cnt = 1

        return 1
class Solution {
    public int largestMagicSquare(int[][] grid) {
        int m = grid.length;
        int n = grid[0].length;
        int[][] rowSum = new int[m][n + 1];      // → 前缀和
        int[][] colSum = new int[m + 1][n];      // ↓ 前缀和
        int[][] diagSum = new int[m + 1][n + 1]; // ↘ 前缀和
        int[][] antiSum = new int[m + 1][n + 1]; // ↙ 前缀和

        for (int i = 0; i < m; i++) {
            for (int j = 0; j < n; j++) {
                int x = grid[i][j];
                rowSum[i][j + 1] = rowSum[i][j] + x;
                colSum[i + 1][j] = colSum[i][j] + x;
                diagSum[i + 1][j + 1] = diagSum[i][j] + x;
                antiSum[i + 1][j] = antiSum[i][j + 1] + x;
            }
        }

        // isSameColSum[i][j] 表示右下角为 (i, j) 的子矩形,每列元素和是否都相等
        boolean[][] isSameColSum = new boolean[m][n];

        for (int k = Math.min(m, n); k > 1; k--) {
            for (int i = k; i <= m; i++) {
                // 想象有一个 k×k 的窗口在向右滑动
                int sameCnt = 1;
                for (int j = 1; j < n; j++) {
                    if (colSum[i][j] - colSum[i - k][j] == colSum[i][j - 1] - colSum[i - k][j - 1]) {
                        sameCnt++;
                    } else {
                        sameCnt = 1;
                    }
                    // 连续 k 列元素和是否都一样
                    isSameColSum[i - 1][j] = sameCnt >= k;
                }
            }

            for (int j = k; j <= n; j++) {
                // 想象有一个 k×k 的窗口在向下滑动
                int sum = rowSum[0][j] - rowSum[0][j - k];
                int sameCnt = 1;
                for (int i = 2; i <= m; i++) {
                    int rowS = rowSum[i - 1][j] - rowSum[i - 1][j - k];
                    if (rowS == sum) {
                        sameCnt++;
                        if (sameCnt >= k && // 连续 k 行元素和都一样
                            isSameColSum[i - 1][j - 1] && // 连续 k 列元素和都一样
                            colSum[i][j - 1] - colSum[i - k][j - 1] == sum && // 列和 = 行和
                            diagSum[i][j] - diagSum[i - k][j - k] == sum && // 主对角线和 = 行和
                            antiSum[i][j - k] - antiSum[i - k][j] == sum) { // 反对角线和 = 行和
                            return k;
                        }
                    } else {
                        sum = rowS;
                        sameCnt = 1;
                    }
                }
            }
        }

        return 1;
    }
}
class Solution {
public:
    int largestMagicSquare(vector<vector<int>>& grid) {
        int m = grid.size(), n = grid[0].size();
        vector row_sum(m, vector<int>(n + 1));      // → 前缀和
        vector col_sum(m + 1, vector<int>(n));      // ↓ 前缀和
        vector diag_sum(m + 1, vector<int>(n + 1)); // ↘ 前缀和
        vector anti_sum(m + 1, vector<int>(n + 1)); // ↙ 前缀和

        for (int i = 0; i < m; i++) {
            for (int j = 0; j < n; j++) {
                int x = grid[i][j];
                row_sum[i][j + 1] = row_sum[i][j] + x;
                col_sum[i + 1][j] = col_sum[i][j] + x;
                diag_sum[i + 1][j + 1] = diag_sum[i][j] + x;
                anti_sum[i + 1][j] = anti_sum[i][j + 1] + x;
            }
        }

        // is_same_col_sum[i][j] 表示右下角为 (i, j) 的子矩形,每列元素和是否都相等
        vector is_same_col_sum(m, vector<int8_t>(n));

        for (int k = min(m, n); k > 1; k--) {
            for (int i = k; i <= m; i++) {
                // 想象有一个 k×k 的窗口在向右滑动
                int same_cnt = 1;
                for (int j = 1; j < n; j++) {
                    if (col_sum[i][j] - col_sum[i - k][j] == col_sum[i][j - 1] - col_sum[i - k][j - 1]) {
                        same_cnt++;
                    } else {
                        same_cnt = 1;
                    }
                    // 连续 k 列元素和是否都一样
                    is_same_col_sum[i - 1][j] = same_cnt >= k;
                }
            }

            for (int j = k; j <= n; j++) {
                // 想象有一个 k×k 的窗口在向下滑动
                int sum_row = row_sum[0][j] - row_sum[0][j - k];
                int same_cnt = 1;
                for (int i = 2; i <= m; i++) {
                    int row_s = row_sum[i - 1][j] - row_sum[i - 1][j - k];
                    if (row_s == sum_row) {
                        same_cnt++;
                        if (same_cnt >= k && // 连续 k 行元素和都一样
                            is_same_col_sum[i - 1][j - 1] && // 连续 k 列元素和都一样
                            col_sum[i][j - 1] - col_sum[i - k][j - 1] == sum_row && // 列和 = 行和
                            diag_sum[i][j] - diag_sum[i - k][j - k] == sum_row && // 主对角线和 = 行和
                            anti_sum[i][j - k] - anti_sum[i - k][j] == sum_row) { // 反对角线和 = 行和
                            return k;
                        }
                    } else {
                        sum_row = row_s;
                        same_cnt = 1;
                    }
                }
            }
        }

        return 1;
    }
};
func largestMagicSquare(grid [][]int) int {
m, n := len(grid), len(grid[0])
rowSum := make([][]int, m)    // → 前缀和
colSum := make([][]int, m+1)  // ↓ 前缀和
diagSum := make([][]int, m+1) // ↘ 前缀和
antiSum := make([][]int, m+1) // ↙ 前缀和
for i := range m + 1 {
colSum[i] = make([]int, n)
diagSum[i] = make([]int, n+1)
antiSum[i] = make([]int, n+1)
}
for i, row := range grid {
rowSum[i] = make([]int, n+1)
for j, x := range row {
rowSum[i][j+1] = rowSum[i][j] + x
colSum[i+1][j] = colSum[i][j] + x
diagSum[i+1][j+1] = diagSum[i][j] + x
antiSum[i+1][j] = antiSum[i][j+1] + x
}
}

// isSameColSum[i][j] 表示右下角为 (i, j) 的子矩形,每列元素和是否都相等
isSameColSum := make([][]bool, m)
for i := range isSameColSum {
isSameColSum[i] = make([]bool, n)
}
for k := min(m, n); k > 1; k-- {
for i := k; i <= m; i++ {
// 想象有一个 k×k 的窗口在向右滑动
sameCnt := 1
for j := 1; j < n; j++ {
if colSum[i][j]-colSum[i-k][j] == colSum[i][j-1]-colSum[i-k][j-1] {
sameCnt++
} else {
sameCnt = 1
}
// 连续 k 列元素和是否都一样
isSameColSum[i-1][j] = sameCnt >= k
}
}

for j := k; j <= n; j++ {
// 想象有一个 k×k 的窗口在向下滑动
sum := rowSum[0][j] - rowSum[0][j-k]
sameCnt := 1
for i := 2; i <= m; i++ {
rowS := rowSum[i-1][j] - rowSum[i-1][j-k]
if rowS == sum {
sameCnt++
if sameCnt >= k && // 连续 k 行元素和都一样
isSameColSum[i-1][j-1] && // 连续 k 列元素和都一样
colSum[i][j-1]-colSum[i-k][j-1] == sum && // 列和 = 行和
diagSum[i][j]-diagSum[i-k][j-k] == sum && // 主对角线和 = 行和
antiSum[i][j-k]-antiSum[i-k][j] == sum {  // 反对角线和 = 行和
return k
}
} else {
sum = rowS
sameCnt = 1
}
}
}
}

return 1
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(mn\min(m,n))$,其中 $m$ 和 $n$ 分别是 $\textit{grid}$ 的行数和列数。
  • 空间复杂度:$\mathcal{O}(mn)$。

相似题目

1878. 矩阵中最大的三个菱形和

专题训练

见下面数据结构题单的「一、前缀和」。

分类题单

如何科学刷题?

  1. 滑动窗口与双指针(定长/不定长/单序列/双序列/三指针/分组循环)
  2. 二分算法(二分答案/最小化最大值/最大化最小值/第K小)
  3. 单调栈(基础/矩形面积/贡献法/最小字典序)
  4. 网格图(DFS/BFS/综合应用)
  5. 位运算(基础/性质/拆位/试填/恒等式/思维)
  6. 图论算法(DFS/BFS/拓扑排序/基环树/最短路/最小生成树/网络流)
  7. 动态规划(入门/背包/划分/状态机/区间/状压/数位/数据结构优化/树形/博弈/概率期望)
  8. 常用数据结构(前缀和/差分/栈/队列/堆/字典树/并查集/树状数组/线段树)
  9. 数学算法(数论/组合/概率期望/博弈/计算几何/随机算法)
  10. 贪心与思维(基本贪心策略/反悔/区间/字典序/数学/思维/脑筋急转弯/构造)
  11. 链表、树与回溯(前后指针/快慢指针/DFS/BFS/直径/LCA)
  12. 字符串(KMP/Z函数/Manacher/字符串哈希/AC自动机/后缀数组/子序列自动机)

我的题解精选(已分类)

欢迎关注 B站@灵茶山艾府

Monorepo入门

1. Monorepo 介绍

核心价值:把“需要一起演进的一组项目”放在同一个版本空间里,从而让跨项目改动(API 变更、重构、升级)能在一次提交里完成并验证

Monorepo 是把多个相关项目/包放在同一个 Git 仓库中管理的策略,有助于跨项目联动修改、内部包共享更顺畅、统一规范与 CI、版本控制、构建和部署等方面的复杂性,并提供更好的可重用性和协作性。

Monorepo 提倡了开放、透明、共享的组织文化,这种方法已经被很多大型公司广泛使用,如 Google、Facebook 和 Microsoft 等。

2. Monorepo 演进

image.png

2.1 阶段一:单仓库巨石应用(Monolith)

初期很爽:一个仓库、一个 package.json、一个 node_modules、一个构建流程,但随着迭代业务复杂度的提升,项目代码会变得越来越多,越来越复杂,大量代码构建效率也会降低,最终导致了单体巨石应用,这种代码管理方式称之为 Monolith。

问题在于:业务一旦变大,就容易出现:

  • 模块边界不清晰、改动影响范围越来越大
  • 构建/测试变慢
  • 多人协作冲突多

于是团队会自然想到:“拆开”,故此迎来阶段二。

注意:这里的 Monolith 是“一个应用越长越大”。它和后面的 Monorepo(多个包/项目同仓)不是同一个概念。

2.2 阶段二:多仓库多模块应用

把系统拆成多个仓库(例如:组件库仓库、业务 A 仓库、业务 B 仓库),会带来立竿见影的收益:

  • 每个仓库更小、owner 更明确、权限更清晰
  • 每个模块可以独立发版
  • 单仓库的 CI 看起来更快(只跑自己的)

代码管理变得简化,构建效率也得以提升,这种代码管理方式称之为 MultiRepo。

但当仓库越来越多,新的成本也会越来越明显:

  • 联动修改很难“原子化”:改组件库 API 后,你需要发布组件库,然后业务仓库分别升级、分别修、分别跑 CI。
  • 版本同步链路变长:底层库升级,上层一堆仓库要跟着升级验证。
  • 工程配置容易漂移:eslint/tsconfig/构建脚本在多个仓库逐渐不一致,治理难度上升。

这时候团队会意识到:拆仓库解决了局部自治,但放大了“协作与一致性”的成本。

2.3 阶段三:单仓库多模块应用

随着业务复杂度的提升,模块仓库越来越多,MultiRepo这种方式虽然从业务上解耦了,但增加了项目工程管理的难度,随着模块仓库达到一定数量级,会有几个问题:跨仓库代码难共享;分散在单仓库的模块依赖管理复杂(底层模块升级后,其他上层依赖需要及时更新,否则有问题);增加了构建耗时。于是将多个项目集成到一个仓库下,共享工程配置,同时又快捷地共享模块代码,成为趋势,这种代码管理方式称之为 Monorepo。

当“跨仓库联动成本”超过收益时,Monorepo 就变得有吸引力:

  • 改公共包 + 改所有使用方,可以在一个 PR 一次性完成并验证
  • 配置集中化,工程规范更易统一
  • 公共能力更容易沉淀成 packages,减少复制粘贴和重复造轮子

当然,Monorepo 也不是没有代价:

  • 仓库会变大(clone、索引、IDE 负担上升)
  • 如果没有“按影响范围执行(affected)+ 缓存”,CI 可能会变慢)

3. Monorepo 优劣

image.png

场景 MultiRepo MonoRepo
代码可见性 ✅ 代码隔离,研发者只需关注自己负责的仓库
❌ 包管理按照各自owner划分,当出现问题时,需要到依赖包中进行判断并解决。
✅ 一个仓库中多个相关项目,很容易看到整个代码库的变化趋势,更好的团队协作。
❌ 增加了非owner改动代码的风险
依赖管理 ❌ 多个仓库都有自己的 node_modules,存在依赖重复安装情况,占用磁盘内存大。 ✅ 多项目代码都在一个仓库中,相同版本依赖提升到顶层只安装一次,节省磁盘内存,
代码权限 ✅ 各项目单独仓库,不会出现代码被误改的情况,单个项目出现问题不会影响其他项目。 ❌ 多个项目代码都在一个仓库中,没有项目粒度的权限管控,一个项目出问题,可能影响所有项目。(
开发迭代 ✅ 仓库体积小,模块划分清晰,可维护性强。
❌ 多仓库来回切换(编辑器及命令行),项目多的话效率很低。多仓库见存在依赖时,需要手动 npm link,操作繁琐。
❌ 依赖管理不便,多个依赖可能在多个仓库中存在不同版本,重复安装,npm link 时不同项目的依赖会存在冲突。
✅ 多个项目都在一个仓库中,可看到相关项目全貌,编码非常方便。
✅ 代码复用高,方便进行代码重构。
❌ 多项目在一个仓库中,代码体积多大几个 G,git clone时间较长。
✅ 依赖调试方便,依赖包迭代场景下,借助工具自动 npm link,直接使用最新版本依赖,简化了操作流程。
工程配置 ❌ 各项目构建、打包、代码校验都各自维护,不一致时会导致代码差异或构建差异。 ✅ 多项目在一个仓库,工程配置一致,代码质量标准及风格也很容易一致。
构建部署 ❌ 多个项目间存在依赖,部署时需要手动到不同的仓库根据先后顺序去修改版本及进行部署,操作繁琐效率低。 ✅ 构建性 Monorepo 工具可以配置依赖项目的构建优先级,可以实现一次命令完成所有的部署。

4. Monorepo 场景

场景一:大型项目与多项目协作

  • 场景:企业或团队维护多个紧密关联的项目(如前端、后端、工具库等)。
  • 优势:集中管理代码,方便跨项目修改和协作,避免代码分散导致的重复劳动。

场景二:共享代码与依赖

  • 场景:多个项目共用组件库、工具函数或配置(如 UI 组件、通用 SDK)。
  • 优势:直接引用内部模块,避免多仓库的版本同步问题,确保依赖一致性。

场景三:统一构建与持续集成(CI/CD)

  • 场景:需要标准化构建、测试和部署流程。
  • 优势:集中配置 CI/CD,仅针对变更部分触发构建(增量构建),提升效率。

何时谨慎使用?

  • 代码量过大:需要考虑构建性能、代码可维护性
  • 权限管理复杂:需细化目录权限控制
  • 团队独立性高:若子团队高度自治,多仓库可能更灵活

5. Monorepo 工具

在采用 Monorepo(单一仓库)架构的软件开发中,工具的选择是至关重要的。合适的 Monorepo 工具能够帮助团队更高效地管理大规模代码库、提升协同开发体验以及优化构建和部署流程。

直至 2026 年年初,目前在前端界比较流行的 Monorepo 工具有 Pnpm WorkspacesYarn Workspacesnpm WorkspacesRushTurborepoYalc、和 Nx

5.1 依赖管理工具

没有 workspace/工具链时:A 包要用 B 包,只能 npm link、复制代码、或走相对/绝对路径,非常别扭且容易错。

负责“怎么安装依赖、怎么把 workspace 包链接起来”

pnpm workspace 是包管理器层面的工作区能力

  • 支持 monorepo 内部包之间用“包名”互相依赖(不是路径引用),并自动链接到本地源码
  • pnpm 有全局的内容存储(store),不同项目/不同 workspace 之间可以复用同版本依赖;再通过链接把依赖组织到各包的 node_modules 结构中。:直观效果:同一个依赖不需要在 N 个地方复制 N 份。
  • 依赖安装更快、更省空间(全局 store 复用 + 链接)
  • 默认依赖隔离更严格,可显著减少“幽灵依赖”

强烈推荐使用Pnpm Workspaces 作为 Monorepo 项目的依赖管理工具😍😍😍

  • pnpm:通过全局 store + 链接方式,通常既省空间又更严格

5.1.1 避免幽灵依赖

npm/yarn 安装依赖时,存在依赖提升,某个项目使用的依赖,并没有在其 package.json 中声明,也可以直接使用,这种现象称之为 “幽灵依赖”;随着项目迭代,这个依赖不再被其他项目使用,不再被安装,使用幽灵依赖的项目,会因为无法找到依赖而报错,而 pnpm 彻底解决这个问题

所谓幽灵依赖,可以理解为:

某个包没有在自己的 package.json 声明依赖,但因为安装结构/提升等原因,代码依然能 import 到它,直到某天依赖结构变化才突然报错。

pnpm 默认对依赖访问更严格,能更早暴露“未声明却在使用”的问题,让错误更早出现、定位更容易。

什么是幽灵依赖

先提问:你觉得“一个包能 import 某个依赖”的前提是什么?

正常答案应该是:

这个包的 package.json 里 dependencies/devDependencies 声明了它。

幽灵依赖就是:没声明,但居然还能 import 并运行成功

最小例子(用 npm/yarn 经典安装方式更容易出现):

假设是 monorepo:

  • 根 package.json 没有 lodash
  • packages/a/package.json 声明了 lodash
  • packages/b/package.json 没声明 lodash

但在 packages/b/src/index.ts 里写了:

import _ from "lodash";

在 npm/yarn(node_modules 提升/hoist)  的某些安装结果下,lodash 可能被“提升”到了更上层的 node_modules,导致 b 虽然没声明,也能“碰巧”找到 lodash,于是:

  • 开发阶段:你以为没问题
  • 某天 a 删除了 lodash 或版本变化/安装结构变化:b 突然就挂了

这就像:你家隔壁有个锤子,你没买但你一直去借用;直到隔壁搬家,你才发现自己其实从来没拥有它。

为什么 pnpm 更容易避免?

pnpm 的默认策略更“严格”:

  • 每个 package 能访问到的依赖,基本只限于它声明的那一圈(通过链接+隔离结构实现)
  • 所以 b 没声明 lodash,就更容易直接报错(这反而是好事:早发现早修)

一句话总结你可以写进文章:

幽灵依赖:未在当前包的 package.json 声明,却因为依赖提升等原因在运行时能被解析到的依赖;pnpm 通过更严格的依赖隔离,能显著减少这类问题。

5.1.2 依赖安装耗时长

MonoRepo 中每个项目都有自己的 package.json 依赖列表,随着 MonoRepo 中依赖总数的增长,每次 install 时,耗时会较长。使用 pnpm 按需安装及依赖缓存,相同版本依赖提升到 Monorepo 根目录下,减少冗余依赖安装;

那么 Monorepo 与包管理工具(npm、yarn、pnpm)之间是一种怎样的关系?

这些包管理工具与 monorepo 的关系在于,它们可以为 monorepo 提供依赖安装与依赖管理的支持,借助自身对 workspace 的支持,允许在 monorepo 中的不同子项目之间共享依赖项,并提供一种管理这些共享依赖项的方式,这可以简化依赖项管理和构建过程,并提高开发效率。

5.1.3 构建打包耗时长

问题:多个项目构建任务存在依赖时,往往是串行构建 或 全量构建,导致构建时间较长,可以使用增量构建,而非全量构建;也可以将串行构建,优化成并行构建。

npm、yarn、pnpm 等是用来管理项目依赖、发布包、安装依赖的工具,他们都提供了对工作区(workspace)的支持,允许在单个代码库中管理多个项目或包。这种工作区支持在单个代码库中同时开发、测试和管理多个的项目,而无需使用多个独立的代码仓库。

这些包管理工具与 monorepo 的关系在于他们可以为 monorepo 提供依赖安装与依赖管理的支持,借助自身对workspace的支持,允许在monorepo中的不同子项目之间共享依赖项,并提供一种管理这些共享以来想的方式,这可以简化依赖项管理和构建过程,并提高开发效率。

硬链接指向同一份文件数据,因此可以复用磁盘空间。

5.2 任务编排/构建系统

如果说 Workspace(pnpm/yarn/npm)解决的是“依赖怎么装、包怎么互相链接”,那么任务编排/构建系统解决的就是 “该跑哪些任务、按什么顺序跑、能不能并行、能不能跳过,以及结果能不能复用”

它通常会提供几类核心能力:

  • 依赖图(Task Graph):根据 package 之间的依赖关系,自动推导任务执行顺序
    例如:先 build packages/ui,再 build apps/web
  • 受影响范围计算(Affected):只要改动没有影响到某些包,就不跑它们的 build/test/lint
    例如:只改了 packages/ui,就只重跑 ui 及其依赖它的应用(web/admin),而不是全仓库全量跑。
  • 并行执行(Parallelism):没有依赖关系的任务可以并行跑,显著缩短 CI 时间。
  • 缓存与增量构建(Caching):同样的输入(代码、锁文件、环境)产生同样的输出时,可以直接复用上次结果
    支持本地缓存和远程缓存(Remote Cache),对 CI 提速非常明显。
  • 统一脚本入口:把“每个包自己维护的一堆脚本”抽象成可治理、可观测的流水线。

没有任务编排/增量构建时,Monorepo 常见痛点会很快出现:

  • CI 只能“全量 build / 全量 test”,仓库越大越慢
  • 任务执行顺序靠人维护,容易写错、漏跑
  • 同样的构建在本地和 CI 反复做,重复劳动
  • 发布流程复杂:多个包的 build/test/version/publish 缺少统一的依赖顺序和可追溯性

因此,在中大型 Monorepo 里,通常会引入以下工具来解决“任务编排 + 缓存 + 增量构建”的问题:

  • Turborepo:上手快、理念清晰,适合前端 Monorepo 的 build/test/lint 编排与缓存。
  • Nx:能力更完整(affected、代码生成、约束治理、可视化依赖图等),适合更复杂的多应用/多语言场景。
  • Rush:偏企业级工程治理与发布流程管理,常见于大型组织的多包管理体系中。

6. 总结

  • Monorepo 并不是银弹,而是一种权衡工程管理与项目协作复杂性的最佳实践之一。适用于项目关联紧密、需频繁联动、强调一致性的中大型团队/企业。
  • 通过引入现代的包管理工具(如 pnpm workspace)和任务编排系统(如 Turborepo、Nx),Monorepo 管理的优势可以最大化,同时减轻依赖和构建上的压力。
  • 采用 Monorepo 可以促进团队协作、统一规范和复用代码,但也需留意仓库增大、权限细化等实际挑战。
  • 是否采纳 Monorepo,需结合企业项目规模、团队协作方式、基础设施支持等多方面因素综合考量。
  • 总之,合理组合工具和规范,才能真正发挥 Monorepo 的价值,为团队降本增效。
❌