普通视图

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

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

作者 SmalBox
2026年2月18日 09:17

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

在Unity URP(Universal Render Pipeline)着色器图形(Shader Graph)中,Main Light Direction节点是一个功能强大且常用的工具节点,它为着色器开发者提供了访问场景中主要光源方向的能力。这个节点在创建各种光照效果、阴影计算和视觉渲染方面发挥着至关重要的作用。通过准确获取主光源的方向信息,开发者能够实现更加真实和动态的光照交互效果,提升项目的视觉质量和用户体验。

Main Light Direction节点的核心价值在于它能够智能地识别场景中的主要光源,无论是用于阴影投射的主方向光,还是作为备用的第一个非阴影投射方向光。这种智能回退机制确保了在各种光照配置下都能获得可用的光源方向数据,使得着色器开发更加灵活和可靠。在URP渲染管线中,正确理解和应用Main Light Direction节点对于创建高质量、性能优化的实时渲染效果至关重要。

随着现代游戏和实时应用对视觉效果要求的不断提高,对光照系统的精细控制变得愈发重要。Main Light Direction节点作为URP着色器图形中光照系统的关键组成部分,为开发者提供了直接访问引擎底层光照数据的接口。通过掌握这个节点的使用方法和应用场景,开发者能够创建出更加生动、响应迅速的光照效果,从而提升整体项目的视觉表现力。

描述

Main Light Direction节点是URP着色器图形中专门用于获取场景中主方向光方向信息的核心节点。在实时渲染中,光源方向是计算光照、阴影和各种光学效果的基础参数,而Main Light Direction节点正是提供这一关键数据的桥梁。该节点设计精巧,能够适应不同的光照场景配置,确保在各种情况下都能返回有意义的光源方向值。

主方向光的定义与识别机制

在URP渲染管线中,主方向光通常指的是场景中最主要的方向光源,这个光源负责提供场景的基础照明和投射主要阴影。Main Light Direction节点通过一套智能的识别机制来确定哪个光源应该被视为"主方向光":

  • 首先,节点会搜索场景中所有设置了投射阴影(Cast Shadows)属性的方向光
  • 如果存在多个投射阴影的方向光,节点会选择其中强度最高或者被认为是最主要的那一个
  • 如果场景中没有任何方向光设置了投射阴影属性,节点会回退到选择第一个不投射阴影的方向光
  • 这种回退机制确保了即使在没有阴影投射光源的情况下,节点仍然能够提供可用的方向数据

光源方向的计算与标准化

Main Light Direction节点输出的方向向量是经过归一化处理的,这意味着向量的长度始终为1。归一化处理在光照计算中非常重要,因为它确保了方向向量只表示方向信息而不包含强度或距离因素。这种标准化输出使得该节点可以直接用于点积计算、反射计算和其他需要纯方向数据的着色器操作。

光源方向的计算基于世界空间坐标系,这意味着无论相机如何移动或旋转,返回的方向向量都始终保持在世界空间中的一致性。这种世界空间的表示方式使得光照计算更加直观和一致,开发者不需要担心相机变换对光照方向的影响。

节点在渲染管线中的角色

在URP渲染管线的光照处理流程中,Main Light Direction节点扮演着信息传递的角色。它从URP的光照系统中获取当前帧的主光源方向数据,并将其提供给着色器图形使用。这个过程发生在每一帧的渲染过程中,因此即使光源在运行时发生移动或变化,节点也能实时更新方向信息。

该节点的设计考虑了性能优化因素,它通过URP的内部接口直接访问已经计算好的光源数据,避免了在着色器中重复计算光源方向的性能开销。这种高效的数据访问方式使得即使在性能受限的平台上,使用Main Light Direction节点也不会对渲染性能造成显著影响。

与其他光照节点的协同工作

Main Light Direction节点通常不单独使用,而是与其他光照相关的节点配合工作,共同构建完整的光照解决方案:

  • 与Main Light Color节点配合,可以同时获取光源的方向和颜色信息
  • 与光照计算节点(如Dot Product、Reflection等)结合,实现复杂的光照效果
  • 在自定义光照模型中作为关键输入参数,替代标准的URP光照计算

这种协同工作的能力使得Main Light Direction节点成为构建高级自定义着色效果的基础构建块。通过将其与其他节点组合,开发者可以创建出从简单的朗伯反射到复杂的各向异性高光等各种光照效果。

端口

Main Light Direction节点的端口设计简洁而高效,只包含一个输出端口,这反映了其功能的专一性——专注于提供主光源的方向信息。这种简洁的设计使得节点易于理解和使用,同时也保证了其在着色器图中的高效执行。

Direction输出端口

Direction端口是Main Light Direction节点唯一的输出接口,它负责提供世界空间中主方向光的归一化方向向量。理解这个端口的特性和正确使用其输出数据对于实现准确的光照效果至关重要。

端口数据类型与特性

Direction端口输出的是Vector 3类型的数据,包含三个浮点数值,分别表示在世界空间坐标系中X、Y、Z轴方向上的分量:

  • X分量:表示光源方向在世界空间X轴上的投影
  • Y分量:表示光源方向在世界空间Y轴上的投影
  • Z分量:表示光源方向在世界空间Z轴上的投影

向量的归一化特性意味着无论实际光源的强度或距离如何,这个方向向量的长度(模)始终为1。数学上表示为:√(X² + Y² + Z²) = 1。这种特性简化了后续的光照计算,因为开发者不需要手动对向量进行归一化处理。

方向向量的几何意义

从几何角度理解,Direction端口输出的向量表示从场景中的表面点指向光源的方向。这一点在光照计算中非常重要,因为标准的光照模型(如Phong或Blinn-Phon模型)通常要求光向量指向光源而非从光源发出。

在实际使用时需要注意,某些光照计算(特别是基于物理的渲染PBR)可能需要不同定义的光向量。在这种情况下,可能需要对Direction端口的输出取反,以获得从光源发出的方向向量。

世界空间坐标系的重要性

Direction端口输出的是世界空间中的方向向量,这一特性具有重要优势:

  • 一致性:世界空间坐标与场景的全局坐标系一致,不受相机或物体变换的影响
  • 预测性:向量的值在场景布局不变的情况下是稳定的,便于调试和效果预测
  • 通用性:世界空间是大多数光照计算和物理模拟的自然选择

当需要在其他坐标系(如视图空间或切线空间)中进行计算时,开发者可以使用相应的变换节点将世界空间的方向向量转换到目标空间。

端口数据的实时性

Direction端口输出的数据是实时更新的,这意味着当场景中的主光源发生移动、旋转或被替换时,端口的输出值会立即反映这些变化。这种实时性使得基于Main Light Direction节点的着色器效果能够动态响应光照环境的变化,创造出更加生动和沉浸式的视觉体验。

在动画或游戏场景中,这种实时更新特性特别有价值。例如,当实现日夜循环系统时,Main Light Direction节点可以自动提供不断变化的太阳方向,而不需要额外的脚本或手动调整。

与其他节点的连接方式

Direction输出端口可以连接到任何接受Vector 3类型数据的输入端口,这种灵活性使得Main Light Direction节点能够与着色器图中的多种节点配合使用:

  • 直接连接到光照计算节点的向量输入
  • 作为参数传递给自定义函数节点
  • 与其他向量运算节点结合,构建复杂的光照模型

在实际连接时,通常需要使用适当的向量运算节点(如Negate、Transform或Normalize)来调整方向向量,使其符合特定光照计算的要求。

使用场景与示例

Main Light Direction节点在URP着色器开发中有着广泛的应用场景,从基础的光照计算到高级的渲染效果都能见到它的身影。理解这些应用场景并通过实际示例学习其使用方法,对于掌握该节点的全面应用至关重要。

基础光照计算

在实现自定义光照模型时,Main Light Direction节点是最基础的构建块之一。通过将其与简单的数学运算节点结合,可以创建各种基本的光照效果。

朗伯反射(漫反射)计算

朗伯反射是模拟粗糙表面光照的最基本模型,它计算光线方向与表面法线之间的夹角:

  • 将Main Light Direction的Direction输出与表面法线向量进行点积计算
  • 使用Dot Product节点计算两个向量的点积结果
  • 使用Saturate节点将结果限制在0-1范围内,避免负值
  • 将结果与主光源颜色相乘,得到最终的漫反射光照

这种简单的漫反射计算能够为物体提供基础的立体感和形状定义,是大多数着色器的起点。

镜面高光计算

基于主光源方向的镜面高光计算可以增加表面的光泽感和材质感:

  • 使用Main Light Direction和相机方向计算半角向量(Half Vector)
  • 将半角向量与表面法线进行点积计算
  • 使用Power节点对结果进行指数运算,控制高光的锐利度
  • 结合光源颜色和强度参数,输出镜面高光分量

通过调整高光的强度和范围,可以模拟从塑料到金属等各种不同材质的表面特性。

高级渲染效果

除了基础光照,Main Light Direction节点在实现各种高级渲染效果中也发挥着关键作用。

动态阴影效果

虽然URP提供了内置的阴影映射系统,但有时需要实现自定义的阴影效果:

  • 使用Main Light Direction确定阴影投射的方向
  • 基于光源方向计算虚拟的阴影投影矩阵
  • 实现屏幕空间或物体空间的阴影映射
  • 创建软阴影或特殊风格的阴影效果

这种自定义阴影系统可以用于实现风格化渲染或特殊视觉效果。

环境光遮蔽与全局光照

在实现简化的环境光遮蔽或全局光照效果时,主光源方向可以作为重要的参考:

  • 基于主光源方向调整环境光遮蔽的强度和分布
  • 实现方向性的环境光遮蔽,增强场景的立体感
  • 结合主光源方向模拟简单的全局光照效果
  • 创建基于光源方向的环境光反射和折射

这些效果可以显著提升场景的真实感和视觉质量。

风格化与非真实感渲染

在风格化渲染中,Main Light Direction节点可以用于创建各种艺术化的光照效果:

卡通着色(Cel Shading)

实现卡通渲染中的硬边缘光照效果:

  • 使用Main Light Direction计算基础的光照强度
  • 通过Step或SmoothStep节点将连续的光照强度量化为离散的色阶
  • 基于光源方向添加轮廓线或边缘高光
  • 创建方向性的色调分离效果

这种技术常用于动漫风格或低多边形风格的游戏中。

水墨与绘画风格

模拟传统艺术媒介的渲染效果:

  • 基于主光源方向控制笔触的方向和密度
  • 实现方向性的纹理化或噪波效果
  • 创建光源方向影响的色彩扩散或混合
  • 模拟光线在特定方向上的散射效果

这些效果可以创造出独特的视觉风格和艺术表达。

性能优化实践

在使用Main Light Direction节点时,合理的性能优化策略非常重要:

计算复杂度管理

  • 避免在片段着色器中进行复杂的光照计算,尽可能在顶点着色器阶段处理
  • 使用适当的精度修饰符(如half或fixed)减少计算开销
  • 将复杂的光照计算预处理为查找表或简化公式

分支优化策略

  • 尽量减少基于光源方向的条件分支
  • 使用数学技巧替代条件判断,如使用max、saturate等函数
  • 将光源方向相关的计算分组,提高缓存效率

通过这些优化实践,可以在保持视觉效果的同时确保渲染性能。

常见问题与解决方案

在使用Main Light Direction节点的过程中,开发者可能会遇到各种问题和技术挑战。了解这些常见问题及其解决方案有助于提高开发效率和代码质量。

光源方向不正确

有时可能会发现Main Light Direction节点返回的方向与预期不符,这通常由以下原因引起:

坐标系理解错误

  • 问题描述:开发者可能误解了方向向量的几何意义,错误地认为向量是从光源发出而非指向光源
  • 解决方案:在使用方向向量前,明确其几何定义。如需从光源发出的方向,对向量取反即可
  • 验证方法:在简单场景中测试,确认光照效果与场景中实际的光源方向一致

空间变换问题

  • 问题描述:在世界空间中进行计算时,忽略了物体的变换关系,导致光照方向不正确
  • 解决方案:确保所有参与计算的向量都在同一坐标系中,必要时使用Transform节点进行空间转换
  • 调试技巧:使用可视化节点将方向向量显示为颜色,直观检查向量的正确性

性能相关问题

在复杂场景或低性能平台上,基于Main Light Direction节点的着色器可能会遇到性能瓶颈。

计算开销过大

  • 问题描述:在片段着色器中进行基于光源方向的复杂计算,导致填充率受限
  • 解决方案:将计算上移到顶点着色器,或使用简化计算模型
  • 优化策略:使用插值方式在顶点和片段间传递光照计算结果,减少每像素计算量

频繁的向量运算

  • 问题描述:不必要的向量归一化、变换或其他运算重复执行
  • 解决方案:缓存常用计算结果,避免重复运算
  • 最佳实践:在着色器图的子图中封装常用的光照计算,确保计算的一致性

平台兼容性问题

不同平台对着色器的支持和优化程度不同,可能会导致Main Light Direction节点在不同设备上表现不一致。

移动平台限制

  • 问题描述:在移动设备上,复杂的光照计算可能导致性能下降或精度问题
  • 解决方案:使用简化光照模型,减少基于光源方向的复杂运算
  • 适配策略:为移动平台创建专门简化版本的着色器,保持核心视觉效果的同时优化性能

图形API差异

  • 问题描述:不同图形API对向量运算的精度和处理方式可能存在细微差异
  • 解决方案:使用URP提供的跨平台兼容函数和数据类型
  • 测试建议:在目标平台上进行全面测试,确保光照效果的一致性

调试与验证技巧

有效的调试方法对于解决Main Light Direction节点相关的问题至关重要。

方向向量可视化

  • 将Direction输出直接连接到基础色,通过颜色直观判断方向向量的值和变化
  • 使用不同的颜色映射方案表示向量的不同分量或方向
  • 创建调试视图,同时显示光源方向和其他相关参数

数值验证方法

  • 在简单测试场景中验证方向向量的准确性
  • 使用脚本输出光源方向的实际值,与着色器中的计算结果对比
  • 创建单元测试场景,自动化验证光照计算的正确性

最佳实践与高级技巧

掌握Main Light Direction节点的高级使用技巧和最佳实践,可以帮助开发者创建出更加高效、美观的视觉效果。

高效的光照模型设计

设计基于Main Light Direction节点的光照模型时,应考虑计算效率和视觉质量的平衡。

多光源支持策略

虽然Main Light Direction节点只提供主光源方向,但可以通过特定技术模拟多光源效果:

  • 使用光照贴图或光照探针提供额外的静态光照信息
  • 实现简化的多光源累积模型,将次要光源作为环境光处理
  • 结合屏幕空间光照信息,增强场景的光照丰富度

实时全局光照技巧

利用主光源方向实现近似的实时全局光照效果:

  • 基于光源方向预计算环境光的分布
  • 使用球谐函数或其它基函数表示方向性的环境光照
  • 实现简化的光线追踪或光线步进效果,增强场景的真实感

艺术导向的视觉效果

将技术实现与艺术表达相结合,创建具有独特视觉风格的效果。

风格化光照控制

通过参数化控制实现灵活的艺术化光照:

  • 创建可调节的光照方向偏移,用于艺术夸张或风格化表达
  • 实现非真实的光照衰减模型,增强视觉冲击力
  • 基于光源方向控制特效的生成和表现

动态效果集成

将Main Light Direction节点与各种动态效果系统集成:

  • 与天气系统结合,实现基于光源方向的风、雨、雪等效果
  • 集成到材质系统中,实现光源方向敏感的动态材质变化
  • 与后期处理效果配合,创建方向性的色彩分级或光晕效果

性能与质量平衡

在保持高质量视觉效果的同时,确保渲染性能的优化。

多层次细节策略

实现基于距离或重要性的多层次光照计算:

  • 在远距离使用简化的光照模型,减少计算开销
  • 根据表面特性动态调整光照计算的复杂度
  • 使用计算着色器或GPU实例化优化批量对象的光照计算

自适应质量调整

根据运行时的性能指标动态调整光照质量:

  • 监控帧率并相应调整光照计算的采样率或精度
  • 在性能受限时使用预计算的光照数据替代实时计算
  • 实现可伸缩的光照系统,适应不同的硬件能力

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

《吃透防抖与节流:从原理到实战,彻底解决高频事件性能问题》

作者 随逸177
2026年2月17日 21:53

吃透防抖与节流:从原理到实战,彻底解决高频事件性能问题

在前端开发中,我们经常会遇到高频触发的事件——比如搜索框输入、页面滚动、按钮连续点击、窗口缩放等。如果不对这些事件进行处理,频繁执行回调函数(尤其是复杂任务如AJAX请求),会导致页面卡顿、请求开销激增,严重影响用户体验和系统性能。

而防抖(Debounce)和节流(Throttle),就是解决这类高频事件性能问题的两大“神器”。它们基于闭包原理实现,用法相似但场景不同,很多新手容易混淆。今天就结合实战代码,从原理、区别、场景到实战,彻底吃透这两个知识点,帮你在项目中精准落地性能优化。

一、先搞懂核心痛点:为什么需要防抖节流?

我们先看一个真实场景:百度搜索建议(baidu ajax suggest)。当你在搜索框输入关键词时,每输入一个字符,浏览器都会触发一次keyup事件,若直接绑定AJAX请求,就会出现高频请求的问题。

如果不做任何处理,会出现两个核心问题:

  • 执行太密集:用户输入速度快(比如每秒输入3个字符),会在1秒内触发3次keyup事件、发送3次AJAX请求,不仅服务器压力大,也会浪费前端性能;
  • 用户体验失衡:请求太快,频繁发送请求可能导致响应混乱、页面卡顿;请求太慢,又会让联想建议延迟,影响使用体验。

类似的场景还有很多,比如代码编辑器的代码提示(code suggest)、页面滚动加载、按钮重复提交、窗口resize等——这些高频触发的事件,都需要通过防抖或节流来优化,避免“性能浪费”。

而这一切的实现,都离不开 闭包 的支持:利用闭包保留定时器ID、上一次执行时间等状态,让函数能够“记住”之前的执行情况,从而实现精准的触发控制,这也是防抖节流的核心底层逻辑。

二、防抖(Debounce):管你触发多少次,我只执行最后一次

1. 防抖核心定义

防抖的核心逻辑:在规定时间内,无论事件触发多少次,都只执行最后一次回调。就像你反复按电梯按钮,电梯只会在你停止按按钮后的一定时间内关门,不会因为你按了多次就多次关门。

对应到前端场景:搜索框keyup事件太频繁,没必要每次触发都执行AJAX请求,我们用防抖控制——无论用户快速输入多少字符,都只在用户停止输入500ms(可自定义)后,发送一次AJAX请求,既节约请求资源,又保证用户体验。

2. 防抖的关键实现(基于闭包+定时器)

以下是防抖的实战实现代码,逐行解析核心逻辑,可直接复制到HTML中运行:

// 模拟AJAX请求(复杂任务,频繁执行会消耗性能)
function ajax(content) {
  console.log('ajax request', content);
}

// 防抖函数(高阶函数:参数或返回值是函数,依托闭包实现)
function debounce(fn, delay) {
  var id; // 自由变量(闭包核心):保存定时器ID,方便后续清除
  return function(args) {
    if(id) clearTimeout(id); // 每次触发事件,先清除之前的定时器,重置倒计时
    var that = this; // 保存当前this指向,避免定时器内this丢失
    id = setTimeout(function(){
      fn.call(that, args); // 推迟执行:延迟delay毫秒后,执行目标函数(最后一次触发的回调)
    }, delay);
  }
}

// 生成防抖后的AJAX函数(延迟500ms执行)
let debounceAjax = debounce(ajax, 500);

// 给防抖输入框绑定keyup事件(高频触发)
const inputb = document.getElementById('debounce');
inputb.addEventListener('keyup', function(e) {
  debounceAjax(e.target.value); // 触发防抖后的函数,而非直接执行ajax
});

3. 防抖核心逻辑拆解(新手必看)

  • 闭包的作用:变量id是定义在debounce函数内部的自由变量,被返回的匿名函数引用。因此即使debounce执行完毕,id也不会被垃圾回收,能持续保存定时器ID,实现“记住”上一次定时器的效果——这是防抖能“重置倒计时”的关键。
  • 定时器的作用:通过setTimeout推迟目标函数(ajax)的执行,每次触发keyup事件时,先清除上一次的定时器(clearTimeout(id)),再重新设置新的定时器。这样无论触发多少次,只有最后一次的定时器会生效,实现“只执行最后一次”。
  • this指向问题:定时器内部的this默认指向window,因此用var that = this保存当前事件触发的上下文(比如input元素),再通过fn.call(that, args)绑定this,确保目标函数(ajax)内的this指向正确,避免出现bug。

4. 防抖的典型应用场景

  • 搜索框输入联想(百度搜索、谷歌搜索):用户不断输入值时,用防抖节约请求资源;
  • 代码编辑器的代码提示(code suggest):避免输入时频繁触发提示逻辑;
  • 按钮防重复提交:比如表单提交按钮,避免用户连续点击发送多次请求;
  • 窗口resize事件:调整窗口大小时,避免频繁执行布局调整逻辑。

三、节流(Throttle):每隔一定时间,只执行一次

1. 节流核心定义

节流的核心逻辑:在规定时间内,无论事件触发多少次,都只执行一次回调。它和防抖的区别在于:防抖是“最后一次触发后延迟执行”,节流是“间隔固定时间执行一次”。

用一个形象的比喻:函数节流就像是FPS游戏的射速,就算你一直按着鼠标射击,也只会在规定射速内射出子弹(比如每秒3发),不会无限制触发——无论触发多频繁,都严格按照固定间隔执行。

对应到前端场景:页面滚动加载数据时,用户可能会一直滚动页面,若每次滚动都触发AJAX请求,会导致请求密集。用节流控制后,每隔500ms只执行一次请求,既保证数据及时加载,又避免性能浪费。

2. 节流的关键实现(基于闭包+时间戳+定时器)

以下是节流的实战实现代码,可直接和防抖代码配合运行,拆解核心逻辑:

// 节流函数(依托闭包,保留上一次执行时间和定时器状态)
function throttle(fn, delay) {
  let last, // 闭包变量:记录上一次执行目标函数的时间戳(毫秒数)
      deferTimer; // 闭包变量:保存尾部执行的定时器ID
  return function() {
    let that = this; // 保存当前this指向,避免this丢失
    let _args = arguments; // 保存事件参数(类数组对象),方便传递给目标函数
    let now = + new Date(); // 类型转换:获取当前时间戳(毫秒数),等价于Date.now()
    
    // 核心判断:上次执行过,且当前时间还没到“上一次执行时间+节流间隔”
    if(last && now < last + delay) {
      clearTimeout(deferTimer); // 清除之前的尾部定时器,避免重复执行
      // 重新设置定时器,延迟执行(尾部补执行,避免最后一次触发被忽略)
      deferTimer = setTimeout(function(){
        last = now; // 更新上一次执行时间为当前时间
        fn.apply(that, _args); // 执行目标函数,绑定this和参数
      }, delay);
    } else {
      // 否则:第一次执行,或已过节流间隔,立即执行目标函数
      last = now; // 更新上一次执行时间为当前时间
      fn.apply(that, _args); // 立即执行目标函数
    }
  }
}

// 生成节流后的AJAX函数(每隔500ms执行一次)
let throttleAjax = throttle(ajax, 500);

// 给节流输入框绑定keyup事件(高频触发)
const inputc = document.getElementById('throttle');
inputc.addEventListener('keyup', function(e) {
  throttleAjax(e.target.value); // 触发节流后的函数
});

3. 节流核心逻辑拆解(新手必看)

  • 闭包的作用:变量last(上一次执行时间)和deferTimer(定时器ID)都是闭包变量,被返回的匿名函数引用,持续保留状态——即使节流函数执行完毕,这两个变量也不会被销毁,确保每次触发都能判断“是否到了执行时间”。
  • 时间戳的作用+ new Date() 将日期对象转为毫秒级时间戳,通过now < last + delay 判断当前时间是否在节流间隔内,决定是否立即执行目标函数。
  • 尾部补执行逻辑:当触发时间在节流间隔内时,通过定时器实现“尾部补执行”——避免最后一次触发被忽略(比如用户滚动页面停止后,确保最后一次滚动能触发数据加载)。
  • 参数和this处理_args = arguments 保存事件参数(比如keyup事件的e对象),that = this 保存当前上下文,确保目标函数(ajax)能正确接收参数、this指向正确。

4. 节流的典型应用场景

  • 页面滚动加载:用户不断滚动页面时,用节流节约请求资源,固定间隔加载数据;
  • 鼠标移动事件:比如拖拽元素时,避免频繁触发位置更新逻辑;
  • 高频点击按钮:比如游戏中的攻击按钮,限制每秒点击次数;
  • 窗口scroll事件:监听页面滚动位置,固定间隔执行导航栏样式切换逻辑。

四、防抖与节流的核心区别(必记,避免混淆)

很多新手会把防抖和节流搞混,其实两者的核心区别很简单,用一句话就能分清,整理如下:

1. 核心逻辑区别

  • 防抖(Debounce) :在一定时间内,只执行最后一次触发的回调(依托setTimeout实现);
  • 节流(Throttle) :每隔一定时间,只执行一次回调(依托时间戳+setTimeout实现,类似setInterval,但更灵活)。

2. 形象对比

  • 防抖:像按电梯,反复按,只在最后一次按完后延迟关门;
  • 节流:像FPS游戏射速,一直按鼠标,只按固定间隔射出子弹。

3. 场景对比(精准落地,避免用错)

特性 防抖(Debounce) 节流(Throttle)
核心逻辑 最后一次触发后延迟执行 固定间隔执行一次
依托技术 闭包 + setTimeout 闭包 + 时间戳 + setTimeout
典型场景 搜索建议、按钮防重复提交 滚动加载、鼠标拖拽
核心目的 避免“无效触发”(比如输入时的中间字符) 避免“密集触发”(比如滚动时的连续触发)

五、实战演示:三者对比(无处理、防抖、节流)

为了让你更直观看到效果,以下是“无处理、防抖、节流”三种效果的完整对比代码,复制到本地即可运行,清晰感受三者差异:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>防抖与节流实战对比</title>
  <style>
    input { margin: 10px 0; padding: 8px; width: 300px; }
    div { font-size: 14px; color: #666; }
  </style>
</head>
<body>
  <div>无处理(高频触发):</div>
  <input type="text" id="undebounce" />
  <br>
  <div>防抖(500ms,只执行最后一次):</div>
  <input type="text" id="debounce" />
  <br>
  <div>节流(500ms,每隔500ms执行一次):</div>
  <input type="text" id="throttle" />

  <script>
  // 模拟AJAX请求(复杂任务)
  function ajax(content) {
    console.log('ajax request', content);
  }

  // 防抖函数
  function debounce(fn, delay) {
    var id;
    return function(args) {
      if(id) clearTimeout(id);
      var that = this;
      id = setTimeout(function(){
        fn.call(that, args)
      }, delay);
    }
  }

  // 节流函数
  function throttle(fn, delay) {
    let last, deferTimer;
    return function() {
      let that = this;
      let _args = arguments;
      let now = + new Date();
      if(last && now < last + delay) {
        clearTimeout(deferTimer);
        deferTimer = setTimeout(function(){
          last = now;
          fn.apply(that, _args);
        }, delay);
      } else {
        last = now;
        fn.apply(that, _args);
      }
    }
  }
  
  // 获取三个输入框元素
  const inputa = document.getElementById('undebounce');
  const inputb = document.getElementById('debounce');
  const inputc = document.getElementById('throttle');

  // 生成防抖、节流函数
  let debounceAjax = debounce(ajax, 500);
  let throttleAjax = throttle(ajax, 500);

  // 1. 无处理:keyup每次触发都执行ajax(高频触发)
  inputa.addEventListener('keyup', function(e) {
    ajax(e.target.value); // 频繁触发,控制台会疯狂打印
  })

  // 2. 防抖处理:keyup触发后,500ms内无新触发才执行ajax
  inputb.addEventListener('keyup', function(e) {
    debounceAjax(e.target.value);
  })

  // 3. 节流处理:keyup触发后,每隔500ms只执行一次ajax
  inputc.addEventListener('keyup', function(e) {
    throttleAjax(e.target.value);
  })
  </script>
</body>
</html>

运行效果说明

  • 无处理输入框:快速输入字符,控制台会疯狂打印“ajax request”,触发频率和keyup一致;
  • 防抖输入框:快速输入字符,控制台只在停止输入500ms后,打印最后一次输入的内容;
  • 节流输入框:快速输入字符,控制台每隔500ms打印一次当前输入内容,严格按照固定间隔执行。

六、总结与注意事项(新手避坑)

1. 核心总结

  • 防抖和节流的核心目的一致:优化高频事件的性能,避免频繁执行复杂任务(如AJAX请求、DOM操作);
  • 两者的核心区别:防抖“只执行最后一次”,节流“间隔固定时间执行一次”;
  • 底层依赖:两者都基于闭包实现,通过闭包保留状态(定时器ID、上一次执行时间),实现精准控制;
  • 场景选择:需要“最后一次触发生效”用防抖,需要“固定间隔生效”用节流。

2. 新手避坑点

  • 不要混淆防抖和节流的场景:比如搜索建议用防抖(避免中间输入触发请求),滚动加载用节流(保证固定间隔加载),用反会影响用户体验;
  • 注意this指向:定时器内部this默认指向window,一定要提前保存this(如var that = this),避免出现this丢失问题;
  • 参数传递:若目标函数需要接收参数(如ajax的content),要保存事件参数(如_args = arguments),并通过call/apply传递;
  • 延迟时间选择:根据场景调整delay(如搜索建议500ms,滚动加载1000ms),太快达不到优化效果,太慢影响用户体验。

防抖和节流是前端性能优化的基础知识点,也是面试高频考点。掌握它们的原理和场景,能帮你在实际项目中解决很多性能问题,提升页面体验。建议把上面的实战代码复制到本地运行,亲手感受三者的区别,加深理解~

面试官 : “ 请说一下 JS 的常见的数组 和 字符串方法有哪些 ? ”

2026年2月17日 21:25

盘点 JS 数组和字符串的核心方法,我会按「常用场景 + 功能分类」整理,每个方法标注作用 + 示例 + 关键说明,既好记又能直接用,适合复习和开发时快速查阅。

一、数组(Array)方法

数组方法是 JS 高频考点,按「增删改查、遍历、转换、排序 / 过滤 / 聚合」分类,重点标⭐️

1. 增删改查(修改原数组)

方法 作用 示例 关键说明
⭐️ push() 末尾添加元素 [1,2].push(3) → [1,2,3] 返回新长度,修改原数组
⭐️ pop() 末尾删除元素 [1,2,3].pop() → 3 返回删除的元素,修改原数组
⭐️ unshift() 头部添加元素 [2,3].unshift(1) → [1,2,3] 返回新长度,修改原数组
⭐️ shift() 头部删除元素 [1,2,3].shift() → 1 返回删除的元素,修改原数组
⭐️ splice(start, delNum, ...add) 任意位置增删改 [1,2,3].splice(1,1,4) → [1,4,3] 返回删除的元素,修改原数组
fill(val, start, end) 填充数组 [1,2,3].fill(0, 1, 2) → [1,0,3] 修改原数组

2. 遍历(不修改原数组)

方法 作用 示例 关键说明
⭐️ forEach() 遍历数组,无返回值 [1,2].forEach(item => console.log(item)) 无法中断(break 无效)
⭐️ map() 遍历 + 返回新数组 [1,2].map(item => item*2) → [2,4] 不修改原数组,必用 return
⭐️ filter() 过滤符合条件的元素 [1,2,3].filter(item => item>1) → [2,3] 返回新数组,保留满足条件的元素
⭐️ find() 找第一个符合条件的元素 [1,2,3].find(item => item>1) → 2 找到即返回,无则 undefined
⭐️ findIndex() 找第一个符合条件的索引 [1,2,3].findIndex(item => item>1) → 1 无则返回 -1
every() 所有元素满足条件? [1,2,3].every(item => item>0) → true 全满足返回 true
some() 至少一个元素满足条件? [1,2,3].some(item => item>2) → true 有一个满足就返回 true
reduce() 聚合(求和 / 拼接等) [1,2,3].reduce((sum, item) => sum+item, 0) → 6 第二个参数是初始值,核心是 “累积”

3. 转换 / 拼接(不修改原数组)

方法 作用 示例 关键说明
⭐️ join(sep) 数组转字符串 [1,2].join('-') → "1-2" sep 是分隔符,默认逗号
⭐️ concat() 拼接数组 [1,2].concat([3,4]) → [1,2,3,4] 返回新数组,不修改原数组
⭐️ slice(start, end) 截取数组(左闭右开) [1,2,3].slice(0,2) → [1,2] 不修改原数组,end 可选(默认到末尾)
flat(depth) 扁平化数组 [1,[2,[3]]].flat(2) → [1,2,3] depth 是层级,默认 1,Infinity 拍平所有
flatMap() map + flat(1) [1,2].flatMap(item => [item, item*2]) → [1,2,2,4] 比先 map 再 flat 高效

4. 排序 / 查找(部分修改原数组)

方法 作用 示例 关键说明
⭐️ sort(compare) 排序 [3,1,2].sort((a,b) => a-b) → [1,2,3] 修改原数组,默认按字符串排序(需传比较函数)
⭐️ reverse() 反转数组 [1,2,3].reverse() → [3,2,1] 修改原数组
⭐️ includes(val) 判断是否包含元素 [1,2].includes(2) → true 区分类型(1 !== '1')
indexOf(val) 找元素首次出现的索引 [1,2,1].indexOf(1) → 0 无则返回 -1
lastIndexOf(val) 找元素最后出现的索引 [1,2,1].lastIndexOf(1) → 2 无则返回 -1

二、字符串(String)方法

字符串方法均不修改原字符串(字符串是不可变类型),按「查找 / 截取、替换 / 分割、转换、判断」分类。

1. 查找 / 截取

方法 作用 示例 关键说明
⭐️ charAt(index) 获取指定位置字符 "abc".charAt(1) → "b" 索引越界返回空字符串
⭐️ indexOf(str) 找子串首次出现的索引 "abcab".indexOf("ab") → 0 无则返回 -1
⭐️ lastIndexOf(str) 找子串最后出现的索引 "abcab".lastIndexOf("ab") → 3 无则返回 -1
⭐️ slice(start, end) 截取字符串(左闭右开) "abcde".slice(1,3) → "bc" start 负数表示从末尾数
substring(start, end) 截取字符串 "abcde".substring(1,3) → "bc" 类似 slice,但 start>end 会自动交换
substr(start, length) 按长度截取 "abcde".substr(1,2) → "bc" 已废弃,优先用 slice
⭐️ includes(str) 判断是否包含子串 "abc".includes("b") → true 区分大小写
startsWith(str) 判断是否以子串开头 "abc".startsWith("ab") → true 可传第二个参数(起始位置)
endsWith(str) 判断是否以子串结尾 "abc".endsWith("bc") → true 可传第二个参数(截取长度)

2. 替换 / 分割

方法 作用 示例 关键说明
⭐️ replace(str/regex, newStr) 替换子串 "abc".replace("b", "x") → "axc" 只替换第一个,全局替换用 /g 正则
⭐️ split(sep) 字符串转数组 "a-b-c".split("-") → ["a","b","c"] sep 为空字符串则拆成单个字符
replaceAll(str/regex, newStr) 全局替换 "abab".replaceAll("a", "x") → "xbxb" ES2021 新增,无需 /g 正则

3. 转换 / 格式化

方法 作用 示例 关键说明
⭐️ toLowerCase() 转小写 "ABC".toLowerCase() → "abc" 不修改原字符串
⭐️ toUpperCase() 转大写 "abc".toUpperCase() → "ABC" 不修改原字符串
⭐️ trim() 去除首尾空格 " abc ".trim() → "abc" 不处理中间空格
trimStart()/trimLeft() 去除开头空格 " abc".trimStart() → "abc" 别名,作用一致
trimEnd()/trimRight() 去除结尾空格 "abc ".trimEnd() → "abc" 别名,作用一致
repeat(n) 重复字符串 "ab".repeat(2) → "abab" n 为 0 返空,负数报错
padStart(len, str) 头部补全 "123".padStart(5, "0") → "00123" 常用于补零
padEnd(len, str) 尾部补全 "123".padEnd(5, "0") → "12300" 超出长度则截断

三、数组 & 字符串互通方法

场景 实现方式 示例
数组 → 字符串 arr.join(sep) [1,2].join("") → "12"
字符串 → 数组 str.split(sep) "abc".split("") → ["a","b","c"]
遍历字符串 转数组后用数组遍历方法 "abc".split("").forEach(char => console.log(char))

总结

  1. 数组核心:修改原数组的方法(push/pop/splice/sort)要注意副作用,遍历优先用 map/filter/reduce(返回新数组),列表查找用 find/findIndex 更高效;
  2. 字符串核心:所有方法不修改原字符串,截取用 slice、替换用 replace/replaceAll、分割用 split,判断包含用 includes;
  3. 高频互通:数组转字符串用 join,字符串转数组用 split,是开发中最常用的联动操作。

LeetCode 100. 相同的树:两种解法(递归+迭代)详解

作者 Wect
2026年2月17日 20:50

LeetCode简单难度的经典二叉树题目——100. 相同的树,这道题虽然难度不高,但非常适合入门二叉树的遍历思想,尤其是递归和迭代两种核心思路的对比练习,新手朋友可以重点看看,老手也可以快速回顾巩固一下。

先简单梳理一下题目要求,避免踩坑:给两棵二叉树的根节点p和q,判断这两棵树是否“相同”。这里的相同有两个核心条件,缺一不可:结构上完全一致,并且对应位置的节点值完全相等

举个直观的例子:如果p是一棵只有根节点(值为1)的树,q也是只有根节点(值为1),那它们是相同的;但如果p的根节点是1、左孩子是2,q的根节点是1、右孩子是2,哪怕节点值都一样,结构不同,也不算相同。

一、题目前置准备

题目已经给出了二叉树节点的定义,用TypeScript实现的,这里再贴一遍,方便大家对照代码理解(注释已补充,新手可重点看构造函数的逻辑):

class TreeNode {
  val: number; // 节点值
  left: TreeNode | null; // 左孩子,可能为null(没有左孩子)
  right: TreeNode | null; // 右孩子,可能为null(没有右孩子)
  // 构造函数:初始化节点,val默认0,左右孩子默认null
  constructor(val?: number, left?: TreeNode | null, right?: TreeNode | null) {
    this.val = (val === undefined ? 0 : val); // 节点值为空时,默认设为0
    this.left = (left === undefined ? null : left); // 左孩子为空时,默认设为null
    this.right = (right === undefined ? null : right); // 右孩子为空时,默认设为null
  }
}

二、解法一:递归解法

1. 递归思路分析

递归的核心思想是“分而治之”,把判断两棵大树是否相同,拆解成判断无数个“小问题”——判断当前两个节点是否相同,以及它们的左孩子、右孩子是否分别相同。

递归的终止条件(也是边界情况)很关键,分三步判断,逻辑层层递进:

  • 如果p和q都为null(两个节点都不存在):说明这两个位置的节点是相同的,返回true;

  • 如果p和q中一个为null、另一个不为null(一个节点存在,一个不存在):说明结构不同,返回false;

  • 如果p和q都不为null,但它们的val不相等(节点值不同):说明节点不相同,返回false;

如果以上三种情况都不满足,说明当前两个节点是相同的,接下来就递归判断它们的左孩子(p.left和q.left)和右孩子(p.right和q.right),只有左右孩子都相同,整棵树才相同(用&&连接两个递归结果)。

2. 递归代码实现

// 递归解法:isSameTree_1
function isSameTree_1(p: TreeNode | null, q: TreeNode | null): boolean {
  // 边界情况1:两个节点都为空,相同
  if (p === null && q === null) {
    return true;
  }
  // 边界情况2:一个为空,一个不为空,结构不同,不相同
  if (p === null || q === null) {
    return false;
  }
  // 边界情况3:两个节点都不为空,但值不同,不相同
  if (p.val !== q.val) {
    return false;
  }
  // 递归:当前节点相同,判断左孩子和右孩子是否都相同
  return isSameTree_1(p.left, q.left) && isSameTree_1(p.right, q.right);
};

3. 递归解法总结

优点:代码极度简洁,逻辑清晰,完全贴合二叉树的递归特性,容易理解和编写,新手友好;

缺点:递归依赖调用栈,如果二叉树深度极深(比如链式二叉树),可能会出现栈溢出的情况(但LeetCode的测试用例一般不会卡这种极端情况,日常刷题完全够用);

时间复杂度:O(n),n是两棵树中节点数较少的那一个,每个节点只会被访问一次;

空间复杂度:O(h),h是树的高度,最坏情况下(链式树)h=n,最好情况下(平衡树)h=logn。

三、解法二:迭代解法(用栈模拟递归,避免栈溢出)

1. 迭代思路分析

迭代解法的核心是“用栈模拟递归的调用过程”,通过手动维护一个栈,把需要判断的节点对(p的节点和q的对应节点)压入栈中,然后循环弹出节点对进行判断,本质上和递归的逻辑是一致的,只是实现方式不同。

具体步骤:

  1. 先判断两棵树的根节点是否都为空(和递归边界1一致),如果是,直接返回true;

  2. 如果根节点一个为空、一个不为空(和递归边界2一致),直接返回false;

  3. 初始化一个栈,把根节点对(p和q)压入栈中(注意压入顺序,后续弹出时要对应);

  4. 循环:只要栈不为空,就弹出两个节点(pNode和qNode),进行判断;

  5. 判断弹出的两个节点:如果都为空,跳过(继续判断下一组节点);如果一个为空一个不为空,返回false;如果值不相等,返回false;

  6. 如果当前节点对相同,就把它们的左孩子对、右孩子对依次压入栈中(注意压入顺序,先压右孩子,再压左孩子,因为栈是“后进先出”,和递归的顺序保持一致);

  7. 循环结束后,说明所有节点对都判断完毕,没有发现不相同的情况,返回true。

这里有个小细节:压入栈的顺序是“p.left、q.left、p.right、q.right”,弹出的时候会先弹出p.right和q.right,再弹出p.left和q.left,和递归时“先判断左孩子,再判断右孩子”的顺序是一致的,不影响结果,但要注意保持对应关系,不能压混。

2. 迭代代码实现

// 迭代解法:isSameTree_2(栈模拟递归)
function isSameTree_2(p: TreeNode | null, q: TreeNode | null): boolean {
  // 先处理根节点的边界情况(和递归一致)
  if (p === null && q === null) {
    return true;
  }
  if (p === null || q === null) {
    return false;
  }
  // 初始化栈,压入根节点对(p和q)
  let stack: (TreeNode | null)[] = [];
  stack.push(p);
  stack.push(q);
  
  // 循环:栈不为空时,持续判断节点对
  while (stack.length > 0) {
    // 弹出两个节点,注意栈是后进先出,所以先弹出q,再弹出p(对应压入顺序)
    let qNode: TreeNode | null = stack.pop() ?? null;
    let pNode: TreeNode | null = stack.pop() ?? null;
    
    // 两个节点都为空,跳过(继续判断下一组)
    if (pNode === null && qNode === null) {
      continue;
    }
    // 一个为空一个不为空,结构不同,返回false
    if (pNode === null || qNode === null) {
      return false;
    }
    // 节点值不同,返回false
    if (pNode.val !== qNode.val) {
      return false;
    }
    
    // 当前节点对相同,压入它们的左孩子对和右孩子对(保持对应关系)
    stack.push(pNode.left);
    stack.push(qNode.left);
    stack.push(pNode.right);
    stack.push(qNode.right);
  }
  
  // 所有节点对都判断完毕,没有不相同的情况,返回true
  return true;
}

3. 迭代解法总结

优点:不依赖递归调用栈,避免了极端情况下的栈溢出问题,稳定性更好;

缺点:代码比递归稍长,需要手动维护栈和循环逻辑,对新手来说稍微复杂一点;

时间复杂度:O(n),和递归一致,每个节点只会被压入栈、弹出栈各一次,访问一次;

空间复杂度:O(n),最坏情况下(平衡树),栈中会存储n/2个节点对,空间复杂度为O(n);最好情况下(链式树),栈中最多存储2个节点对,空间复杂度为O(1)。

四、两种解法对比 & 刷题建议

解法类型 优点 缺点 适用场景
递归 代码简洁、逻辑清晰、易编写 极端情况下可能栈溢出 日常刷题、二叉树深度不深的场景
迭代(栈) 无栈溢出问题、稳定性好 代码稍长、需维护栈逻辑 二叉树深度极深、生产环境场景

刷题建议:新手先掌握递归解法,因为它最贴合二叉树的特性,后续做二叉树的遍历、对称树、翻转树等题目时,思路可以无缝迁移;掌握递归后,再理解迭代解法,重点体会“栈模拟递归”的思想,这是二叉树迭代题目的核心套路。

五、常见踩坑点提醒

  • 踩坑点1:忽略“结构不同”的情况,只判断节点值。比如p有左孩子、q没有左孩子,但其他节点值都相同,此时会误判为相同;

  • 踩坑点2:递归时忘记写终止条件,导致无限递归,栈溢出;

  • 踩坑点3:迭代时压入栈的节点对顺序混乱,导致弹出时判断的不是“对应节点”(比如把p.left和q.right压在一起);

  • 踩坑点4:处理节点为null时不严谨,比如用p.val === q.val时,没有先判断p和q是否为null,导致空指针错误(代码中已规避此问题)。

六、总结

LeetCode 100. 相同的树,本质上是二叉树“同步遍历”的入门练习,核心是判断“结构+节点值”是否双匹配。递归解法胜在简洁,迭代解法胜在稳定,两种解法的逻辑完全一致,只是实现方式不同。

刷这道题的重点不在于“写出代码”,而在于理解“如何同步判断两棵树的对应节点”,这个思路后续会用到很多类似题目中(比如101. 对称二叉树,只是判断的是“自身的左孩子和右孩子”,逻辑高度相似)。

面试必考:如何优雅地将列表转换为树形结构?

2026年2月17日 19:41

面试必考:如何优雅地将列表转换为树形结构?

前言

在前端开发中,我们经常会遇到这样一个场景:后端返回的是一个扁平的列表数据,但前端需要渲染成树形结构。比如:

  • 省市区三级联动
  • 组织架构树
  • 权限菜单树
  • 商品分类树

今天我们就来深入探讨这个经典面试题,从递归到优化,一步步掌握列表转树的精髓。

第一章:理解数据结构

1.1 什么是扁平列表?

想象一下,你有一张Excel表格,每一行都是一个独立的数据,它们之间通过某个字段(比如parentId)来表明谁是谁的“爸爸”:

// 这是一个扁平的列表
const list = [
    {id: 1, name: 'A', parentId: 0},  // A是根节点(parentId为0表示没有父节点)
    {id: 2, name: 'B', parentId: 1},  // B的爸爸是A(parentId=1)
    {id: 3, name: 'C', parentId: 1},  // C的爸爸也是A
    {id: 4, name: 'D', parentId: 2}   // D的爸爸是B
]

这种数据的特点:

  • 每条数据都有一个唯一的 id(就像每个人的身份证号)
  • 通过 parentId 来表示父子关系(就像你知道你爸爸的身份证号)
  • parentId: 0 表示根节点(没有爸爸,或者爸爸是“虚拟”的根)

1.2 什么是树形结构?

树形结构就像你家的家族族谱:爷爷下面有爸爸和叔叔,爸爸下面有你和你兄弟姐妹:

// 我们希望转换成的树形结构
[
  {
    id: 1,
    name: 'A',
    parentId: 0,
    children: [  // children表示“孩子”们
      {
        id: 2,
        name: 'B',
        parentId: 1,
        children: [
          { id: 4, name: 'D', parentId: 2 }  // D是B的孩子
        ]
      },
      { id: 3, name: 'C', parentId: 1 }  // C是A的孩子,但没有自己的孩子
    ]
  }
]

1.3 为什么要转换?

后端为什么给扁平列表?因为存数据方便(只需要一张表)。 前端为什么要树结构?因为展示数据方便(树形菜单、级联选择器等)。

第二章:递归法(最直观的思路)

2.1 什么是递归?

递归就像俄罗斯套娃:一个大娃娃里面套着一个小娃娃,小娃娃里面还套着更小的娃娃...用程序的话说,就是函数调用自身

2.2 思路分析

想象你在整理家族族谱:

  1. 先找到所有没有爸爸的人(parentId: 0),他们是第一代人
  2. 然后为每个人找他的孩子:遍历整个列表,找到所有爸爸ID等于这个人ID的人
  3. 对每个孩子重复第2步(递归!)

2.3 基础版代码实现(逐行解释)

function listToTree(list, parentId = 0) {
    // result用来存放最终的结果
    // 比如第一次调用时,它用来存放所有根节点
    const result = []
    
    // 遍历列表中的每一项
    list.forEach(item => {
        // 检查当前项的父亲是不是我们要找的那个父亲
        // 比如parentId=0时,我们就在找所有根节点
        if (item.parentId === parentId) {
            
            // ★ 关键递归:找当前项的孩子
            // 把当前项的id作为新的parentId,去找它的孩子
            const children = listToTree(list, item.id)
            
            // 如果找到了孩子(children数组不为空)
            if (children.length) {
                // 给当前项添加一个children属性,把孩子们放进去
                item.children = children
            }
            
            // 把处理好的当前项放进结果数组
            result.push(item)
        }
    })
    
    // 返回这一层找到的所有人
    return result
}

2.4 代码执行过程演示

假设我们有这样的数据:

const list = [
    {id: 1, name: 'A', parentId: 0},  // 爷爷
    {id: 2, name: 'B', parentId: 1},  // 爸爸
    {id: 3, name: 'C', parentId: 1},  // 叔叔
    {id: 4, name: 'D', parentId: 2}   // 孙子
]

第一次调用listToTree(list, 0)

  • 找爸爸ID为0的人 → 找到A(id=1)
  • 调用listToTree(list, 1)找A的孩子

第二次调用listToTree(list, 1)

  • 找爸爸ID为1的人 → 找到B(id=2)和C(id=3)
  • 先处理B:调用listToTree(list, 2)找B的孩子
  • 再处理C:调用listToTree(list, 3)找C的孩子

第三次调用listToTree(list, 2)

  • 找爸爸ID为2的人 → 找到D(id=4)
  • 调用listToTree(list, 4)找D的孩子(没找到)
  • 返回[D],作为B的children

第四次调用listToTree(list, 3)

  • 找爸爸ID为3的人 → 没找到
  • 返回[],作为C的children(所以C没有children属性)

2.5 进阶版:使用ES6简化(逐行解释)

function listToTree(list, parentId = 0) {
    // 1. 先用filter过滤出当前层的所有节点
    // 比如找所有parentId等于0的根节点
    return list
        .filter(item => item.parentId === parentId)
        
        // 2. 然后用map对每个节点进行处理
        .map(item => ({
            // 这里用了三个点,后面会详细解释
            ...item,
            
            // 3. 递归找当前节点的孩子
            children: listToTree(list, item.id)
        }))
}

这段代码虽然简洁,但做了三件事:

  1. filter:从列表中筛选出符合条件的节点(比如所有根节点)
  2. map:对每个筛选出的节点进行处理
  3. 递归:为每个节点找它的孩子

2.6 递归法的优缺点

优点

  • 逻辑清晰,容易理解
  • 代码简洁优雅
  • 符合人的思维习惯

缺点

  • 时间复杂度 O(n²) - 每个节点都需要遍历整个列表
  • 列表越长,性能越差
  • 可能造成栈溢出(数据量极大时)

第三章:深入理解 ...item 的作用

3.1 如果不使用 ...item 会怎样?

很多初学者可能会这样写:

// 错误示例 ❌
map[item.id] = item
map[item.id].children = []  // 这样会修改原始数据!

3.2 为什么不能直接使用原对象?

让我们用一个生活例子来理解:

假设你有一张原始的家族成员名单

const originalList = [
    {id: 1, name: '爷爷'}
]

情况1:直接使用原对象(坏的做法)

const map = {}
map[1] = originalList[0]  // 把爷爷的原始记录放进map
map[1].children = ['孙子']  // 在原始记录上添加孙子信息

console.log(originalList[0])  
// 输出:{id: 1, name: '爷爷', children: ['孙子']}
// 哎呀!原始名单被修改了!

情况2:使用 ...item 复制(好的做法)

const map = {}
map[1] = { ...originalList[0] }  // 复制一份爷爷的记录
map[1].children = ['孙子']  // 在**副本**上添加孙子信息

console.log(originalList[0])  
// 输出:{id: 1, name: '爷爷'}
// 太好了!原始名单完好无损!

3.3 ...item 到底在做什么?

... 是JavaScript的扩展运算符,它的作用就像复印机:

const 原件 = { name: '张三', age: 18 }

// 用...复制一份
const 复印件 = { ...原件 }

// 现在原件和复印件是两份独立的数据
复印件.age = 19

console.log(原件.age)    // 18(没变)
console.log(复印件.age)  // 19(变了)

3.4 在列表转树中的应用

在我们的代码中:

map[item.id] = {
    ...item,        // 把item的所有属性复制过来
    children: []    // 再添加一个新的children属性
}

这相当于:

// 如果item是 {id: 1, name: 'A', parentId: 0}
// 那么新对象就是:
{
    id: 1,           // 从item复制来的
    name: 'A',       // 从item复制来的
    parentId: 0,     // 从item复制来的
    children: []     // 新添加的
}

3.5 什么时候必须用 ...item

必须用的场景:当你不想修改原始数据时

// 场景1:后端返回的数据,你不想污染它
const dataFromServer = [...]
const myCopy = { ...dataFromServer[0] }

// 场景2:多次使用同一份数据
const baseConfig = { theme: 'dark' }
const user1Config = { ...baseConfig, name: '张三' }
const user2Config = { ...baseConfig, name: '李四' }
// user1Config和user2Config互不影响

// 场景3:需要添加新属性,又不影响原对象
const original = { x: 1, y: 2 }
const enhanced = { ...original, z: 3 }
// original还是{x:1,y:2},enhanced是{x:1,y:2,z:3}

第四章:Map优化法(空间换时间)

4.1 为什么要优化?

递归法虽然好理解,但有个严重的问题:太慢了

想象一下:

  • 100个节点:递归法要做100×100=10000次操作
  • 1000个节点:要做1000×1000=1000000次操作
  • 10000个节点:...算了,太可怕了!

这就是我们常说的时间复杂度O(n²),数据量越大越慢。

4.2 优化思路

就像你去图书馆找书:

  • 递归法:每次找一本书都要把整个图书馆逛一遍
  • 优化法:先做一个索引表,想看什么书直接查索引

4.3 基础版代码实现(逐行解释)

function listToTree(list) {
    // 1. 第一步:创建"索引表"(map)
    // 这个map就像一个电话簿,通过id能直接找到对应的人
    const map = {}
    
    // 2. 第二步:存放最终结果(根节点们)
    const result = []
    
    // 3. 第一次遍历:把所有人都放进"电话簿"
    list.forEach(item => {
        // 对每个人,都做一份复印件(用...item复制)
        // 并且给复印件加一个空的"孩子名单"(children数组)
        map[item.id] = {
            ...item,        // 复印个人信息
            children: []    // 准备一个空的孩子名单
        }
    })
    
    // 4. 第二次遍历:建立父子关系
    list.forEach(item => {
        // 判断:这个人是不是根节点(没有爸爸)?
        if (item.parentId === 0) {
            // 是根节点:直接放进最终结果
            result.push(map[item.id])
        } else {
            // 不是根节点:找到他爸爸,把自己加入爸爸的孩子名单
            
            // map[item.parentId] 通过爸爸的ID找到爸爸
            // ?. 是可选链操作符,意思是:如果爸爸存在,就执行后面的操作
            // .children.push() 把自己加入爸爸的孩子名单
            map[item.parentId]?.children.push(map[item.id])
        }
    })
    
    // 5. 返回最终结果
    return result
}

4.4 图解Map优化法

假设有这样的数据:

原始列表:
[  {id:1, parentId:0, name:'A'},  // 根节点  {id:2, parentId:1, name:'B'},  // A的孩子  {id:3, parentId:1, name:'C'}   // A的孩子]

第一次遍历后(建立索引表):
map = {
  1: {id:1, name:'A', children:[]},
  2: {id:2, name:'B', children:[]},
  3: {id:3, name:'C', children:[]}
}

第二次遍历(建立关系):
- 处理item1: parentId=0 → result = [map[1]]
- 处理item2: parentId=1 → map[1].children.push(map[2])
- 处理item3: parentId=1 → map[1].children.push(map[3])

最终result:
[{
  id:1, name:'A',
  children: [
    {id:2, name:'B', children:[]},
    {id:3, name:'C', children:[]}
  ]
}]

4.5 使用ES6 Map版本(更专业的写法)

function listToTree(list) {
    // 使用ES6的Map数据结构代替普通对象
    // Map相比普通对象有更多优点:键可以是任何类型,有size属性等
    const nodeMap = new Map()
    const tree = []
    
    // 第一次遍历:初始化所有节点
    list.forEach(item => {
        nodeMap.set(item.id, {
            ...item,
            children: []
        })
    })
    
    // 第二次遍历:构建树结构
    list.forEach(item => {
        if (item.parentId === 0) {
            // 根节点直接加入树
            tree.push(nodeMap.get(item.id))
        } else {
            // 非根节点找爸爸
            const parentNode = nodeMap.get(item.parentId)
            if (parentNode) {
                // 把自己加入爸爸的孩子名单
                parentNode.children.push(nodeMap.get(item.id))
            }
        }
    })
    
    return tree
}

4.6 为什么返回result就是返回所有树的元素?

这是一个非常关键的知识点!很多初学者会疑惑:为什么最后返回result就能得到完整的树?

让我们用一个比喻来理解:

想象你是一个班主任,要整理全校学生的家族关系:

  1. 你有一张全校学生名单(list
  2. 你做了一个索引表(map),通过学号能快速找到每个学生
  3. 你有一个空的花名册(result),用来放每个家族的"族长"(根节点)

关键理解:当你把"族长"放进result时,这个"族长"已经通过索引表关联了所有的子孙后代!

// 实际内存中的关系
map[1] = { id:1, name:'A', children: [] }  // 族长A
map[2] = { id:2, name:'B', children: [] }  // A的儿子B
map[3] = { id:3, name:'C', children: [] }  // A的儿子C

// 建立关系后
map[1].children.push(map[2])  // 现在 map[1].children 里有 map[2] 的引用
map[1].children.push(map[3])  // 现在 map[1].children 里有 map[3] 的引用

// 把map[1]放入result
result.push(map[1])

// 此时的map[1]长这样:
{
    id: 1,
    name: 'A',
    children: [
        { id:2, name:'B', children:[] },  // 注意:这里是完整的B对象
        { id:3, name:'C', children:[] }   // 注意:这里是完整的C对象
    ]
}

重点来了:虽然我们只把A放进了result,但是A的children数组里直接存储了B和C对象的引用。也就是说:

  • result[0] 就是 A
  • result[0].children[0] 就是 B
  • result[0].children[1] 就是 C

所以通过result,我们就能访问到整棵树的所有节点!

如果有多个根节点:

// 假设还有另一个家族:D是族长,E是D的儿子
map[4] = { id:4, name:'D', children: [] }
map[5] = { id:5, name:'E', children: [] }
map[4].children.push(map[5])

// 把map[4]也放入result
result.push(map[4])

// 最终result:
[
    {  // 第一棵树
        id: 1, name:'A',
        children: [ { id:2, name:'B' }, { id:3, name:'C' } ]
    },
    {  // 第二棵树
        id: 4, name:'D',
        children: [ { id:5, name:'E' } ]
    }
]

所以返回result就是返回了所有的树,因为:

  1. 每个根节点都包含了它的所有子孙节点(通过引用)
  2. result数组收集了所有的根节点
  3. 通过这些根节点,我们可以访问到整个森林的所有节点

4.7 为什么说"空间换时间"?

  • 递归法:速度快吗?慢!占内存吗?少!(时间多,空间少)
  • Map优化法:速度快吗?快!占内存吗?多!(时间少,空间多)

就像搬家:

  • 递归法:每次需要什么都临时去买(耗时但省地方)
  • Map优化法:先把所有东西都买好放仓库(费地方但省时间)

第五章:两种方法的详细对比

对比维度 递归法 Map优化法 通俗解释
时间复杂度 O(n²) O(n) 100个数据:递归法要查10000次,Map法只要查200次
空间复杂度 O(1) O(n) 递归法基本不占额外内存,Map法需要建一个索引表
代码长度 短(3-5行) 稍长(10-15行) 递归法更简洁
可读性 ⭐⭐⭐⭐⭐ ⭐⭐⭐ 递归法更容易理解
适用场景 小数据量(<100条) 大数据量(>100条) 根据数据量选择

第六章:实际应用场景(详细版)

6.1 省市区三级联动

// 实际开发中,后端通常只返回扁平列表
const areas = [
    {id: 1, parentId: 0, name: '中国'},
    {id: 2, parentId: 1, name: '北京'},
    {id: 3, parentId: 1, name: '上海'},
    {id: 4, parentId: 2, name: '东城区'},
    {id: 5, parentId: 2, name: '西城区'},
    {id: 6, parentId: 3, name: '黄浦区'}
]

// 转换后,就可以方便地实现三级联动选择器
const areaTree = listToTree(areas)
// 用户选择"中国"后,自动显示"北京"、"上海"
// 选择"北京"后,自动显示"东城区"、"西城区"

6.2 组织架构树

// 公司人员列表
const employees = [
    {id: 1, parentId: 0, name: '张总', position: 'CEO'},
    {id: 2, parentId: 1, name: '李经理', position: '技术总监'},
    {id: 3, parentId: 1, name: '王经理', position: '市场总监'},
    {id: 4, parentId: 2, name: '赵前端', position: '前端工程师'},
    {id: 5, parentId: 2, name: '钱后端', position: '后端工程师'},
    {id: 6, parentId: 3, name: '孙策划', position: '市场专员'}
]

// 转换后可以渲染出组织架构图
const orgTree = listToTree(employees)

6.3 权限菜单树

// 后台管理系统的菜单
const menus = [
    {id: 1, parentId: 0, name: '系统管理', icon: '⚙️'},
    {id: 2, parentId: 1, name: '用户管理', icon: '👤'},
    {id: 3, parentId: 1, name: '角色管理', icon: '🔑'},
    {id: 4, parentId: 2, name: '新增用户', permission: 'user:add'},
    {id: 5, parentId: 2, name: '编辑用户', permission: 'user:edit'}
]

// 转换后可以方便地渲染侧边栏菜单
const menuTree = listToTree(menus)

第七章:常见问题解答(FAQ)

Q1: 如果数据中有多个根节点怎么办?

A: 没问题!result 数组会包含所有根节点,形成一个"森林"(多棵树)。每个根节点都带着自己的子树。

Q2: 如果数据中有循环引用(A的爸爸是B,B的爸爸是A)怎么办?

A: 这会导致无限递归!需要先做数据校验,或者设置最大递归深度。

Q3: 什么情况下用递归法,什么情况下用Map法?

A:

  • 数据量小(<100条):用递归法,简单易懂
  • 数据量大(>100条):用Map法,性能好
  • 面试时:先说递归法展示思路,再说Map法展示优化能力

Q4: 为什么 map[item.parentId]?.children 要加问号?

A: 问号是可选链操作符,意思是:如果 map[item.parentId] 存在,才执行后面的 .children.push。防止出现"找不到爸爸"的错误。

Q5: 为什么返回result就能得到完整的树?

A: 因为每个根节点的children数组里存储的是子节点的引用,而不是复制品。当你通过result访问根节点时,实际上可以通过引用链访问到所有后代节点。

第八章:面试技巧

当面试官问到这个问题时,可以这样回答:

  1. 第一层(基础):"我可以用递归实现,先找根节点,再递归找每个节点的子节点。"

  2. 第二层(优化):"但递归的时间复杂度是O(n²),数据量大时会很慢。我可以用Map优化到O(n)。"

  3. 第三层(细节):"在实现时要注意用...item复制对象,避免修改原始数据。还要处理边界情况,比如找不到父节点的情况。"

  4. 第四层(原理):"Map优化法的核心是空间换时间,通过索引表实现O(1)查找。返回result之所以能得到完整的树,是因为JavaScript的对象引用机制——根节点的children里存储的是子节点的引用。"

  5. 第五层(应用):"这个算法在实际开发中很常用,比如我在做省市区选择器、后台管理系统菜单时都用到了。"

第九章:总结与思考

通过这篇文章,我们学习了:

  1. 什么是列表转树:把扁平数据变成树形结构
  2. 递归法:直观但性能较差
  3. ...item的作用:复制对象,避免修改原始数据
  4. Map优化法:性能好但稍微复杂
  5. 返回结果的原理:通过引用机制,根节点包含所有子孙节点
  6. 实际应用场景:省市区联动、组织架构、权限菜单等

掌握了这个算法,你不仅能应对面试,在实际开发中也能游刃有余地处理各种树形结构数据。


如果这篇文章对你有帮助,欢迎点赞收藏,也欢迎在评论区提出你的问题!

LeetCode 104. 二叉树的最大深度:解题思路+代码解析

作者 Wect
2026年2月17日 18:29

LeetCode基础题第104题「二叉树的最大深度」,这道题是二叉树遍历的经典入门题,核心考察对二叉树层次遍历(BFS)的理解和应用,适合新手入门练手。今天就来详细拆解这道题,从题目理解到代码实现,再到细节优化,一步步讲清楚,看完就能轻松掌握。

一、题目解读

题目描述

给定一个二叉树 root ,返回其最大深度。二叉树的最大深度是指从根节点到最远叶子节点的最长路径上的节点数。

简单来说,就是要找到二叉树“最深”的那一层,统计从根到这一层的所有节点个数。举个例子:

  • 如果二叉树只有根节点,没有左右子节点,最大深度就是1;

  • 如果根节点有左子节点,左子节点又有一个子节点,那么最大深度就是3;

  • 如果二叉树是空树(root为null),最大深度就是0。

核心考点

这道题的核心是「二叉树的遍历」,常见的解法有两种:

  1. 层次遍历(BFS,广度优先搜索):按层遍历二叉树,每遍历完一层,深度加1,最终的深度就是最大深度(本文重点讲解这种解法,对应给出的代码);

  2. 深度优先搜索(DFS):递归遍历左右子树,取左右子树的最大深度,再加上当前根节点的深度1,即为整个树的最大深度(后续会补充备选代码)。

二、代码解析(TypeScript)

先贴出完整代码(已优化,解决原代码潜在问题),再逐行拆解思路,新手也能看懂~

/**
 * Definition for a binary tree node.
 * 二叉树节点的定义(题目已给出,无需修改)
 */
class TreeNode {
  val: number
  left: TreeNode | null
  right: TreeNode | null
  constructor(val?: number, left?: TreeNode | null, right?: TreeNode | null) {
    this.val = (val === undefined ? 0 : val)
    this.left = (left === undefined ? null : left)
    this.right = (right === undefined ? null : right)
  }
}

/**
 * 计算二叉树最大深度(层次遍历/BFS解法)
 * @param root 二叉树根节点
 * @returns 二叉树的最大深度
 */
function maxDepth(root: TreeNode | null): number {
  // 边界处理:空树直接返回深度0(避免后续无效循环)
  if (root === null) {
    return 0;
  }

  let depth = 0; // 记录当前深度,初始化为0
  // 用数组模拟队列,存储当前层的所有节点(初始存入根节点)
  const queue: TreeNode[] = [root];

  // 队列不为空,说明还有节点未遍历,继续循环
  while (queue.length > 0) {
    const levelSize = queue.length; // 当前层的节点个数
    // 遍历当前层的所有节点
    for (let i = 0; i < levelSize; i++) {
      // 取出当前层的节点(优化:用pop+unshift替代shift,提升性能)
      const node = queue.pop()!;
      
      // 若当前节点有右子节点,存入队列(先存右,再存左,保证遍历顺序)
      if (node.right) {
        queue.unshift(node.right);
      }
      // 若当前节点有左子节点,存入队列
      if (node.left) {
        queue.unshift(node.left);
      }
    }
    // 可选:打印队列,观察每一层遍历后的节点变化(调试用)
    
    
    // 当前层遍历完毕,深度加1
    depth++;
  }

  return depth;
}

逐行拆解思路

1. 边界处理(关键!)

if (root === null) { return 0; }

这一步是很多新手容易忽略的点:如果二叉树是空树(root为null),没有任何节点,最大深度自然是0。如果不做这个判断,后续队列会存入null,导致循环多执行一次,最终返回错误的深度1。

2. 初始化变量

  • let depth = 0;:用于记录二叉树的深度,初始值为0(因为还未开始遍历任何一层);

  • const queue: TreeNode[] = [root];:用数组模拟队列(队列是BFS的核心数据结构),初始时将根节点存入队列,代表从根节点开始遍历。

3. 层次遍历核心循环

while (queue.length > 0) { ... }:队列不为空,说明还有节点未遍历,循环继续。每一次循环,都代表遍历完「一层」节点。

4. 遍历当前层节点

const levelSize = queue.length;:获取当前层的节点个数,这个值是固定的(因为后续队列会存入下一层的节点,不能直接用queue.length判断当前层节点数)。

for (let i = 0; i < levelSize; i++) { ... }:循环遍历当前层的每一个节点,把每个节点的左右子节点(如果有的话)存入队列,为下一层遍历做准备。

5. 节点取出与子节点入队(性能优化点)

原代码中如果用queue.shift()取出节点,会有性能问题——因为数组的shift()方法是O(n)时间复杂度(需要将数组中所有元素向前移动一位),当二叉树节点较多时,效率会很低。

优化方案:用queue.pop()(O(1)时间复杂度)取出队列尾部的节点,同时调整子节点入队顺序(先存右子节点,再存左子节点),保证遍历顺序和shift()一致,既提升性能,又不影响结果。

这里的!是非空断言,因为我们已经通过边界处理和循环条件,确保队列中的节点一定是TreeNode类型(不会为null),所以可以安全使用非空断言。

6. 深度递增

depth++;:每遍历完一层,说明二叉树的深度增加了1,所以深度加1。当循环结束时,depth就是二叉树的最大深度。

三、代码优化说明

对比LeetCode题目给出的初始模板,这段代码做了3个关键优化,兼顾性能和正确性:

  1. 新增边界处理:解决空树返回错误深度的问题,让代码更健壮;

  2. 性能优化:用pop() + unshift()替代shift(),将节点取出操作的时间复杂度从O(n)降至O(1),适合处理节点较多的二叉树;

可读性优化:添加详细注释,变量命名语义化(如levelSize代表当前层节点数),方便新手理解每一步的执行过程。

四、备选解法(DFS递归版)

除了上述层次遍历(BFS)解法,这道题还可以用深度优先搜索(DFS)的递归写法,代码更简洁,适合树深度不大的场景(避免递归栈溢出),新手也可以了解一下:

function maxDepthRecursive(root: TreeNode | null): number {
  // 空树返回0
  if (root === null) {
    return 0;
  }
  // 递归计算左右子树的最大深度,当前深度 = 左右子树最大深度 + 1(当前节点)
  return Math.max(maxDepthRecursive(root.left), maxDepthRecursive(root.right)) + 1;
}

递归解法的核心思路:二叉树的最大深度 = 左子树最大深度和右子树最大深度的最大值 + 1(当前根节点),本质是遍历到最底层的叶子节点,再回溯计算深度。

五、总结

LeetCode 104题是二叉树遍历的入门题,难度简单,但能很好地巩固BFS和DFS的基础思路。本文讲解的层次遍历(BFS)解法,适合所有场景(包括极深的二叉树,避免递归栈溢出),优化后的代码兼顾性能和可读性,新手可以重点掌握。

解题关键记住两点:

  1. 边界处理:空树直接返回0,避免错误;

  2. 层次遍历核心:用队列存储每一层的节点,每遍历完一层,深度加1。

实战|DeLinkedIn 全栈开发:Web3 身份验证 + 数字资产确权,搭建职场社交新生态

作者 木西
2026年2月17日 18:19

前言

本文主要整合往期发布的 DAO、SSI 身份、社区所有权社交 等相关内容,实现一个简洁的去中心化社区实例。延续以往风格 理论加代码实践相结合。

概述

在 2026 年,职场社交正在经历从“平台信用”向“加密证明”的范式转移。传统的 LinkedIn 依赖于用户自述的简历,而 DeLinkedIn 则是通过 SSI (自主主权身份)Social NFT (内容所有权)  和 DAO (去中心化治理)  的三位一体架构,构建了一个真实、透明且价值对等的职业生态。

核心架构理论:三权分立

  1. 身份层 (SSI/SBT):真实性的根基

    • 理论:简历不再是 PDF,而是由大学、前雇主或开源组织签发的灵魂绑定代币 (SBT)
    • 解决痛点:消除简历造假。只有持有特定技能凭证的用户才能进入高阶人才库。
  2. 社交层 (Community Ownership):内容即资产

    • 理论:每一次职场深度分享都铸造为 ERC-721 NFT
    • 解决痛点:创作者拥有粉丝关系和内容的所有权,平台无法通过流量抽成剥削职场博主。
  3. 治理层 (DAO/Token):共建者激励

    • 理论:平台由持有 Governance Token 的成员共有。优质内容的产出直接由 DAO 金库进行代币奖励。
    • 解决痛点:将“用户流量”转化为“社区股份”,实现利益共担。

猎头赏金:智能合约如何重构招聘经济学

  1. 企业发布(Post & Lock):企业发布职位并锁定一定数额的平台代币(赏金)到合约。
  2. 用户推荐(Referral):用户通过自己的 DID 身份推荐好友。
  3. 多签结算(Settlement):当好友入职通过试用期,企业或 DAO 触发结算,赏金自动拨付给推荐人。

DeLinkedIn的BountyLinkedIn合约将传统猎头的"人治"流程改造为无需信任的自动化协议

三步闭环

步骤 角色 链上动作 传统痛点 合约解决方案
1. 锁仓 企业 postJobBounty() 锁定赏金 口头承诺无保障 资金托管在合约,无法撤回
2. 推荐 专业人士 referCandidate() 记录关系 推荐关系难证明 DID身份绑定,链上可追溯
3. 结算 企业/DAO fulfillBounty() 自动拨付 结算周期长、扯皮多 条件触发,秒级到账

智能合约落地全流程

智能合约

  • 去中心化领英
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/access/AccessControl.sol";

// 身份接口:用于核验用户是否拥有“技能凭证”
interface ISoulboundIdentity {
    function balanceOf(address owner) external view returns (uint256);
}

contract DeLinkedIn is ERC721, AccessControl {
    bytes32 public constant MODERATOR_ROLE = keccak256("MODERATOR_ROLE");
    IERC20 public platformToken;
    ISoulboundIdentity public sbtIdentity;

    uint256 private _nextPostId;
    uint256 public constant POST_REWARD = 10 * 10**18; // 发帖奖励 10 Token

    struct WorkPost {
        address author;
        string metadataUri; // 职业动态内容
        bool isVerifiedPro; // 是否为核验专家
    }

    mapping(uint256 => WorkPost) public posts;

    error NotSkillCertified(); // 未获得技能认证(SSI 拦截)
    error rewardTransferFailed();

    constructor(address _token, address _sbt) ERC721("DeLinkedInPost", "DLP") {
        _grantRole(DEFAULT_ADMIN_ROLE, msg.sender);
        platformToken = IERC20(_token);
        sbtIdentity = ISoulboundIdentity(_sbt);
    }
    function supportsInterface(bytes4 interfaceId)
            public
            view
            override(ERC721, AccessControl)
            returns (bool)
        {
            return super.supportsInterface(interfaceId);
        }
    /**
     * @dev 发布职场动态:只有持有 SSI 技能凭证的用户才能发布
     */
    function publishProfessionalInsight(string memory _uri) external {
        // 1. SSI 身份核验:检查用户是否持有灵魂绑定技能凭证
        if (sbtIdentity.balanceOf(msg.sender) == 0) {
            revert NotSkillCertified();
        }

        // 2. 社区所有权:内容 NFT 化
        uint256 tokenId = _nextPostId++;
        _safeMint(msg.sender, tokenId);
        posts[tokenId] = WorkPost(msg.sender, _uri, true);

        // 3. 经济激励:给创作者发放平台代币奖励(由 DAO 金库支持)
        bool success = platformToken.transfer(msg.sender, POST_REWARD);
        if (!success) revert rewardTransferFailed();
    }
}

  • 猎头赏金:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import "./DeLinkedIn.sol"; // 继承之前的逻辑

contract BountyLinkedIn is DeLinkedIn {
    struct JobBounty {
        address employer;
        uint256 rewardAmount;
        bool isActive;
    }

    // 职位 ID => 赏金信息
    mapping(uint256 => JobBounty) public jobBounties;
    // 推荐记录:候选人地址 => 推荐人地址
    mapping(address => address) public referrals;

    event BountyPosted(uint256 jobId, uint256 amount);
    event BountyClaimed(uint256 jobId, address indexed referrer, address indexed candidate);

    constructor(address _token, address _sbt) DeLinkedIn(_token, _sbt) {}

    /**
     * @dev 企业发布赏金职位
     */
    function postJobBounty(uint256 _jobId, uint256 _amount) external {
        platformToken.transferFrom(msg.sender, address(this), _amount);
        jobBounties[_jobId] = JobBounty(msg.sender, _amount, true);
        emit BountyPosted(_jobId, _amount);
    }

    /**
     * @dev 用户提交推荐:记录推荐关系
     */
    function referCandidate(address _candidate) external {
        if (sbtIdentity.balanceOf(msg.sender) == 0) revert NotSkillCertified();
        referrals[_candidate] = msg.sender;
    }

    /**
     * @dev 企业确认入职,拨付赏金给推荐人
     */
    function fulfillBounty(uint256 _jobId, address _candidate) external {
        JobBounty storage bounty = jobBounties[_jobId];
        require(msg.sender == bounty.employer, "Only employer can fulfill");
        require(bounty.isActive, "Bounty not active");

        address referrer = referrals[_candidate];
        require(referrer != address(0), "No referrer found");

        bounty.isActive = false;
        platformToken.transfer(referrer, bounty.rewardAmount);

        emit BountyClaimed(_jobId, referrer, _candidate);
    }
}

测试脚本

测试用例:DeLinkedIn 综合项目测试 (SSI + Social + DAO)

  • 核验通过的专业人士应能发布动态并获得代币奖励
  • 未获得 SSI 认证的‘游客’尝试发布应被拒绝
import assert from "node:assert/strict";
import { describe, it, beforeEach } from "node:test";
import { network } from "hardhat";
import { parseEther } from 'viem';

describe("DeLinkedIn 综合项目测试 (SSI + Social + DAO)", function () {
    let delinkedIn: any, token: any, sbt: any;
    let publicClient, testClient;
    let admin: any, user: any, stranger: any;

    beforeEach(async function () {
        const { viem: v } = await (network as any).connect();
        publicClient = await v.getPublicClient();
        testClient = await v.getTestClient();
        [admin, user, stranger] = await v.getWalletClients();

        // 1. 部署基础设施
        token = await v.deployContract("contracts/DAO.sol:MyToken", []); 
        sbt = await v.deployContract("contracts/SoulboundIdentity.sol:SoulboundIdentity", []);
        
        // 2. 部署领英主合约
        delinkedIn = await v.deployContract("contracts/DeLinkedIn.sol:DeLinkedIn", [
            token.address, 
            sbt.address
        ]);

        // 3. 注入 DAO 奖励金库资金
        await token.write.transfer([delinkedIn.address, parseEther("1000")]);

        // 4. 为 SSI 合约设置签发者并给 user 签发一个技能凭证
        const ISSUER_ROLE = await sbt.read.ISSUER_ROLE();
        await sbt.write.grantRole([ISSUER_ROLE, admin.account.address]);
        await sbt.write.issueIdentity([user.account.address, "ipfs://Senior-Dev-Cert", 0n]);
    });

    it("核验通过的专业人士应能发布动态并获得代币奖励", async function () {
        const initialBalance = await token.read.balanceOf([user.account.address]);
        
        // 用户发布动态
        await delinkedIn.write.publishProfessionalInsight(["ipfs://My-Web3-Insight"], { account: user.account });

        // 验证 1: 内容所有权 (NFT)
        const nftBalance = await delinkedIn.read.balanceOf([user.account.address]);
        assert.equal(nftBalance, 1n, "应获得内容所有权 NFT");

        // 验证 2: 经济激励 (Token)
        const finalBalance = await token.read.balanceOf([user.account.address]);
        assert.equal(finalBalance - initialBalance, parseEther("10"), "应获得 10 枚代币奖励");
    });

    it("未获得 SSI 认证的‘游客’尝试发布应被拒绝", async function () {
        await assert.rejects(
            delinkedIn.write.publishProfessionalInsight(["ipfs://Fake-Insight"], { account: stranger.account }),
            /NotSkillCertified/,
            "未认证用户必须被 SSI 逻辑拦截"
        );
    });
});

测试用例:猎头赏金流程测试

  • 发布赏金 -> 推荐好友 -> 入职结算的闭环
import assert from "node:assert/strict";
import { describe, it, beforeEach } from "node:test";
import { network} from "hardhat";
import { parseEther } from 'viem';

describe("DeLinkedIn 猎头赏金流程测试", function () {
    let bountyContract: any, token: any, sbt: any;
    let publicClient;
    let employer: any, referrer: any, candidate: any;

    beforeEach(async function () {
        const { viem: v } = await (network as any).connect();
        publicClient = await v.getPublicClient();
        [employer, referrer, candidate] = await v.getWalletClients();

        // 部署
        token = await v.deployContract("contracts/DAO.sol:MyToken", []);
        sbt = await v.deployContract("contracts/SoulboundIdentity.sol:SoulboundIdentity", []);
        bountyContract = await v.deployContract("contracts/BountyLinkedIn.sol:BountyLinkedIn", [token.address, sbt.address]);

        // 初始化:给推荐人签发技能身份,给企业发钱
        const ISSUER_ROLE = await sbt.read.ISSUER_ROLE();
        await sbt.write.grantRole([ISSUER_ROLE, employer.account.address]);
        await sbt.write.issueIdentity([referrer.account.address, "ipfs://Expert", 0n]);
        await token.write.transfer([employer.account.address, parseEther("500")]);
    });

    it("应该完成:发布赏金 -> 推荐好友 -> 入职结算的闭环", async function () {
        const bountyAmount = parseEther("100");

        // 1. 企业发布赏金
        await token.write.approve([bountyContract.address, bountyAmount], { account: employer.account });
        await bountyContract.write.postJobBounty([1n, bountyAmount], { account: employer.account });

        // 2. 推荐人推荐候选人
        await bountyContract.write.referCandidate([candidate.account.address], { account: referrer.account });

        // 3. 企业确认入职并拨付
        const initialBalance = await token.read.balanceOf([referrer.account.address]);
        await bountyContract.write.fulfillBounty([1n, candidate.account.address], { account: employer.account });

        // 4. 验证推荐人收到赏金
        const finalBalance = await token.read.balanceOf([referrer.account.address]);
        assert.equal(finalBalance - initialBalance, bountyAmount, "推荐人应收到 100 枚代币赏金");
    });
});

部署脚本

// scripts/deploy.js
import { network, artifacts } from "hardhat";
async function main() {
  // 连接网络
  const { viem } = await network.connect({ network: network.name });//指定网络进行链接
  
  // 获取客户端
  const [deployer] = await viem.getWalletClients();
  const publicClient = await viem.getPublicClient();
 
  const deployerAddress = deployer.account.address;
   console.log("部署者的地址:", deployerAddress);
  // 加载合约
  const TokenArtifact = await artifacts.readArtifact("contracts/DAO.sol:MyToken");
  const SoulboundIdentityArtifact = await artifacts.readArtifact("contracts/SoulboundIdentity.sol:SoulboundIdentity");
  const DeLinkedInArtifact = await artifacts.readArtifact("contracts/DeLinkedIn.sol:DeLinkedIn");
  const BountyLinkedInArtifact = await artifacts.readArtifact("contracts/BountyLinkedIn.sol:BountyLinkedIn");
 const TokenHash = await deployer.deployContract({
    abi: TokenArtifact.abi,//获取abi
    bytecode: TokenArtifact.bytecode,//硬编码
    args: [],
  });
  const TokenReceipt = await publicClient.waitForTransactionReceipt({ hash: TokenHash });
  console.log("Token合约地址:", TokenReceipt.contractAddress);
  // 部署
  const SoulboundIdentityHash = await deployer.deployContract({
    abi: SoulboundIdentityArtifact.abi,//获取abi
    bytecode: SoulboundIdentityArtifact.bytecode,//硬编码
    args: [],
  });
   const SoulboundIdentityReceipt = await publicClient.waitForTransactionReceipt({ hash: SoulboundIdentityHash });
   console.log("SoulboundIdentity合约地址:", SoulboundIdentityReceipt.contractAddress);
   const DeLinkedInHash = await deployer.deployContract({
    abi: DeLinkedInArtifact.abi,//获取abi
    bytecode: DeLinkedInArtifact.bytecode,//硬编码
    args: [TokenReceipt.contractAddress, SoulboundIdentityReceipt.contractAddress],
  });
   const DeLinkedInReceipt = await publicClient.waitForTransactionReceipt({ hash: DeLinkedInHash });
   console.log("DeLinkedIn合约地址:", DeLinkedInReceipt.contractAddress);
   const BountyLinkedInHash = await deployer.deployContract({
    abi: BountyLinkedInArtifact.abi,//获取abi
    bytecode: BountyLinkedInArtifact.bytecode,//硬编码
    args: [TokenReceipt.contractAddress, SoulboundIdentityReceipt.contractAddress],
  });
   const BountyLinkedInReceipt = await publicClient.waitForTransactionReceipt({ hash: BountyLinkedInHash });
   console.log("BountyLinkedIn合约地址:", BountyLinkedInReceipt.contractAddress);
}
main().catch(console.error);

结语

至此,基于DAO、SSI身份、社区所有权社交三大核心技术的综合案例——2026去中心化领英(DeLinkedIn)已全部完成。本文延续了“理论+代码”的呈现风格,从项目架构设计、核心业务逻辑拆解,到智能合约开发、测试脚本编写、部署脚本实现,完整呈现了去中心化社区的落地过程。

【翻译】我竟渐渐迷上了生成器的设计巧思

2026年2月17日 18:05

原文链接:macarthur.me/posts/gener…

我花了些功夫深入学习迭代器、可迭代对象和生成器,如今总算开始体会到其中的精妙之处。

我钟爱过去十年里 JavaScript 出现的那些 “语法糖”(箭头函数、模板字符串、解构赋值等等)。究其原因,是这些特性大多解决了我实际开发中的痛点 —— 有些痛点我甚至自己都未曾察觉。它们的优势显而易见,也让我有大把机会在开发中大展拳脚。

但这些新特性里,也有一些 “异类”,比如生成器函数。它和 ES2015 的其他核心特性诞生于同一时期,实用性却始终没怎么被大众认可,你甚至可能一眼都认不出它的写法:

function* generateAlphabet() {
  yield "a";
  yield "b";
  // … 依次生成
  yield "z";
}

平心而论,我至少发现过一次它的实用之处。我曾写过一篇文章,讲如何用生成器按需解构任意数量的元素,直到现在我依然觉得这个用法超棒。但我总忍不住想,自己是不是还错过了它更多的妙用。

于是,我决定认认真真、沉下心来研究它一番。或许深入了解后,就能发现它的更多适用场景。没想到,我竟真的开始欣赏起它的设计巧思,以及它所传递的编程思维模式 —— 至少在某些场景下是这样。接下来,我就和大家聊聊我的心得体会。首先,我们先退一步,把相关的基础概念梳理清楚。

迭代器协议与可迭代协议

生成器的底层依赖两个截然不同的协议,不了解它们,就无法真正理解生成器:迭代器协议和可迭代协议。这两个协议都用于生成一组长度不确定的序列值,后者是在前者的基础上构建而来的。

迭代器协议

该协议标准化了生成序列的对象的结构和行为。一个对象只要满足以下条件,就是一个迭代器:它暴露一个next()方法,该方法返回一个包含两个属性的对象:

  • value: any:序列中的当前值
  • done: boolean:标记序列是否遍历完毕

仅此而已。来看一个简单易懂的示例:

const gospelIterator = {
  index: -1,

  next() {
    const gospels = ["马太福音", "马可福音", "路加福音", "约翰福音"];
    this.index++;

    return {
      value: gospels.at(this.index),
      done: this.index + 1 > gospels.length,
    };
  },
};

gospelIterator.next(); // {value: '马太福音', done: false}
gospelIterator.next(); // {value: '马可福音', done: false}
gospelIterator.next(); // {value: '路加福音', done: false}
gospelIterator.next(); // {value: '约翰福音', done: false}
gospelIterator.next(); // {value: undefined, done: true}

顺带一提,迭代器生成的序列并非必须有终点,无限迭代器是完全合法的:

const infiniteIterator = {
  count: 0,
  next() {
    this.count++;

    return {
      value: this.count,
      done: false,
    };
  },
};

infiniteIterator.next(); // 会一直生成递增的数字…

单看这个协议,除了能让迭代行为保持一致,似乎没什么实际用处。而可迭代协议,会让它的价值显现出来。

可迭代协议

一个对象如果拥有[Symbol.iterator]()方法,且该方法返回一个迭代器对象,那么这个对象就是可迭代对象。我们平时使用的for...of循环、数组解构,底层都是基于这个协议实现的。JavaScript 的常见原生类型StringArrayMap)都内置了这个协议。

自己实现可迭代协议,就能自定义for...of循环的行为。基于上面的示例,我们来实现一个可迭代对象:

const gospelIterable = {
  [Symbol.iterator]() {
    return {
      index: -1,

      next() {
        const gospels = ["马太福音", "马可福音", "路加福音", "约翰福音"];
        this.index++;

        return {
          value: gospels.at(this.index),
          done: this.index + 1 > gospels.length,
        };
      },
    };
  },
};

现在,这个对象就能直接用for...of循环遍历,也能进行解构操作了:

for (const author of gospelIterable) {
  console.log(author); // 马太福音、马可福音、路加福音、约翰福音
}

console.log([...gospelIterable]);
// ['马太福音', '马可福音', '路加福音', '约翰福音']

接下来我们看一个更进阶的示例,这个示例的效果很难用简单的数组实现:生成 1900 年之后的所有闰年并遍历:

function isLeapYear(year) {
  // 闰年判断规则:整百年能被400整除,非整百年能被4整除
  return year % 100 === 0 ? year % 400 === 0 : year % 4 === 0;
}

const leapYears = {
  [Symbol.iterator]() {
    return {
      startYear: 1900,
      currentYear: new Date().getFullYear(),
      next() {
        this.startYear++;

        // 找到下一个闰年
        while (!isLeapYear(this.startYear)) {
          this.startYear++;
        }

        return {
          value: this.startYear,
          done: this.startYear > this.currentYear,
        };
      },
    };
  },
};

for (const leapYear of leapYears) {
  console.log(leapYear);
}

注意看,我们无需提前生成所有的闰年序列,所有状态都存储在可迭代对象内部,下一个值会在需要时才计算生成。这一点非常重要,值得我们重点关注。

惰性求值

惰性求值是可迭代对象最受推崇的优势之一:我们无需从一开始就生成序列中的所有值。在某些情况下,这能有效避免性能问题。

再看上面的闰年可迭代对象示例。如果不用可迭代对象,而是用普通的for循环实现相同的功能,你大概率会提前生成一个包含所有闰年的数组:

const leapYears = [];
const startYear = 1900;
const currentYear = new Date().getFullYear();

// 先遍历生成所有闰年,存入数组
for (let year = startYear + 1; year <= currentYear; year++) {
  if (isLeapYear(year)) {
      leapYears.push(year);
    }
}

// 再遍历数组使用闰年
for (const leapYear of leapYears) {
  console.log(leapYear);
}

这段代码的可读性确实很高(很多人会觉得比可迭代对象的版本更易读),但也存在明显的取舍:需要执行两层for循环,更重要的是,所有值都会被提前计算并存储。在这个示例中,性能影响微乎其微,但如果是计算成本极高的操作,或是处理超大规模的数据集,这种方式的性能损耗就会非常明显。比如这样的场景:

for (const thing of getExpensiveThings(1000)) {
  // 对每个元素执行重要操作
}

如果getExpensiveThings()底层没有自定义可迭代对象支撑,那么循环执行前,必须先生成包含 1000 个元素的完整数组。从执行脚本到真正开始处理业务逻辑,会产生不必要的时间损耗。

同理,当我们不需要序列中的所有值时,惰性求值的优势会更突出。比如,我们想根据一个人的出生年份,找到其经历的第一个闰年。一旦找到目标值,就无需继续计算后续的闰年了。如果用提前生成数组的方式,数组中后续的元素就相当于白生成了。

function getFirstLeapYear(birthYear) {
  for (const leapYear of leapYears) {
    if (leapYear >= birthYear) return leapYear;
  }
  
  return null;
}

// 只会计算到1992年的闰年,后续会直接终止
getFirstLeapYear(1989) // 1992

显然,在计算资源高度密集的场景中,惰性求值带来的效率提升会更显著,相信你已经明白其中的道理了:不需要的元素,就不会浪费计算资源去生成

顺带说一句,如果你觉得手动实现可迭代对象的过程过于繁琐,其实很多人都有同感。所以,我们终于可以聊生成器了 —— 这个特性的诞生,就是为了让这一切实现起来更简洁、更优雅。

用生成器简化协议实现

下面我们用生成器函数重写上面的可迭代对象,生成器函数会返回一个生成器对象

function* generateGospels() {
  yield "马太福音";
  yield "马可福音";
  yield "路加福音";
  yield "约翰福音";
}

这里有两个关键的语法:function*yield关键字。前者标记这是一个生成器函数;而yield关键字你可以理解为 “暂停键”—— 每当生成器被请求获取下一个值时,执行就会在yield处暂停。

生成器的底层,依然会调用next()方法。每次调用该方法,执行都会推进到下一个yield语句(如果还有的话)。

const generator = generateGospels();

console.log(generator.next()); // {value: '马太福音', done: false}

当然,生成器对象也能直接用for...of循环遍历,效果和预期一致:

for (const gospel of generateGospels()) {
  console.log(gospel);
}

// 马太福音
// 马可福音
// 路加福音
// 约翰福音

记住:可迭代对象(包括生成器)可以是无限的,所以你可能会在实际开发中看到这样的代码:

function* multipleGenerator(base) {
  let current = base;

  while (true) {
    yield current;
    current += base;
  }
}

这样的无限循环看起来很吓人,但并不会导致浏览器卡死。因为每次迭代之间都有yield语句,每当请求下一个值时,执行就会暂停,主线程就能继续处理其他任务。

const multiplier = multipleGenerator(22);

multiplier.next(); // {value: 22, done: false}
multiplier.next(); // {value: 44, done: false}
multiplier.next(); // {value: 66, done: false}

// … 并不会出现无限循环!

不过有一点需要注意:生成器的执行是同步的,所以依然有可能阻塞主线程。好在我们有办法避免这个问题,比如AsyncGenerator异步生成器)对象,就能帮我们解决这类问题。

我开始偏爱生成器的几个原因

生成器并非什么具有突破性的特性,我很难找到一个只能用它解决、而普通方法无法实现的问题。但随着使用次数的增多,我对它的好感也与日俱增。总结下来,主要有这几个原因:

减少代码的紧耦合

生成器(以及所有迭代器)的一大优势是高度的封装性,包括自身的状态管理。我越来越发现,这一特性能有效降低组件之间的耦合度 —— 而我以前总是下意识地让组件之间产生不必要的依赖。

举个场景:点击按钮时,需要按时间顺序展示某一价格过去五年里的移动平均值,从最早的时间段开始。我们每次只需要一个时间段的平均值,甚至可能用不到所有的结果(用户可能不会一直点击按钮)。用普通方法实现的代码大概是这样的:

// 全局作用域的状态变量,用于标记当前计算的起始位置
let windowStart = 0;

function calculateMovingAverage(values, windowSize) {
  // 截取当前窗口的数值
  const section = values.slice(windowStart, windowStart + windowSize);

  if (section.length < windowSize) return null;

  // 计算移动平均值
  return section.reduce((sum, val) => sum + val, 0) / windowSize;
}

loadButton.addEventListener("click", function () {
  const avg = calculateMovingAverage(prices, 5);
  average.innerHTML = `平均值:$${avg}`;
  // 事件监听器需要负责更新状态
  windowStart++;
});

每次点击按钮,页面就会渲染下一个平均值。但这种实现有个明显的问题:我们需要在高层作用域定义一个持久化的windowStart变量,而且让事件监听器负责更新状态,这让我很不舒服 —— 我希望监听器只专注于更新 UI。

除此之外,如果页面的其他地方也需要计算这个移动平均值,这种实现方式会让代码变得一团糟:各个逻辑相互交织,边界模糊,更谈不上可移植性。

而生成器能完美解决这些问题:

function* calculateMovingAverage(values, windowSize) {
  // 状态变量封装在生成器内部,仅在需要时暴露
  let windowStart = 0;

  while (windowStart <= values.length - 1) {
    const section = values.slice(windowStart, windowStart + windowSize);
    
    yield section.reduce((sum, val) => sum + val, 0) / windowSize;
    
    windowStart++;
  }
}

// 初始化生成器,传入参数
const generator = calculateMovingAverage(prices, 5);

loadButton.addEventListener("click", function () {
  // 监听器只需要请求值并更新UI,无需关心内部逻辑
  const { value } = generator.next();
  average.innerHTML = `平均值:$${value}`;
});

这样的实现有很多优点:

  • windowStart变量只在需要它的地方暴露,不会污染外部作用域;
  • 状态和逻辑自包含,我们可以同时创建多个独立的生成器实例,彼此互不影响;
  • 职责更单一:生成器负责计算和状态管理,点击监听器只负责更新 DOM,代码边界清晰。

我很喜欢这种编程模式,而且我们还能把它做得更极致。到目前为止,都是由点击监听器主动请求下一个值,直接依赖生成器的返回结果。但我们可以反过来,让生成器只负责生成就绪的值,监听器只负责消费这些值 —— 两者都无需知道对方的内部实现细节。

// 生成器控制迭代节奏,监听器只负责响应事件
for (const value of calculateMovingAverage(prices, 5)) {
  await new Promise((r) => {
    loadButton.addEventListener(
      "click",
      function () {
        average.innerHTML = `平均值:$${value}`;
        r();
      },
      { once: true } // 事件只触发一次,自动移除监听器
    );
  });
}

我猜你看到这段代码可能会感到诧异,甚至有点费解。这确实不是一种很自然的编程模式,但我很认可它的实现思路 ——控制反转。这段代码中,两个模块之间几乎没有任何依赖,彼此都不需要知道对方的实现细节。事件处理完成后,监听器会被自动清理,执行权交还给生成器。我觉得,鲍勃大叔(《代码整洁之道》作者)至少会认可这个设计思路(如果不认可,那就让他穿着浴袍吐槽我吧🤞)。

避开那些令人 “心烦” 的写法

我惊讶地发现,过去开发中很多不得不使用的繁琐写法,都能用生成器替代。比如递归、回调函数等等 —— 这些写法本身没有问题,但用起来总让人觉得不爽

其中一个典型场景就是循环执行的任务。比如,仪表盘需要每秒刷新一次应用的最新运行指标。这个需求可以拆分成两个职责:请求数据、渲染 UI。实现的方式有很多种:

方式 1:使用 setInterval

你可以选择经典的setInterval—— 它的设计初衷就是重复执行某个操作,看起来是最适合的选择:

// 一直重复执行!
function monitorVitals(cb) {
  setInterval(async () => {
    const vitals = await requestVitals();
    // 借助回调函数传递数据,更新UI
    cb(vitals);
  }, 1000);
}

// 传入回调函数处理UI更新
monitorVitals((vitals) => {
  console.log("更新UI...", vitals);
});

但这种写法也有两个让人不爽的点:为了分离 “请求数据” 和 “渲染 UI” 的职责,必须传递回调函数,这可能会让人想起 Promise 出现前 JavaScript 的 “回调地狱”;此外,setInterval不会关心数据请求的耗时,如果请求耗时超过 1 秒,就会出现数据返回顺序错乱的问题。

方式 2:使用 setTimeout + 递归

作为替代方案,你可能会用 Promise 封装setTimeout,再结合递归实现:

async function monitorVitals(cb) {
  const vitals = await requestVitals();
  cb(vitals);

  await new Promise((r) => {
    // 递归调用,实现循环执行
    setTimeout(() => monitorVitals(cb), 1000);
  });
}

monitorVitals((vitals) => {
  console.log("更新UI...", vitals);
});

这种写法能解决时序问题,但递归可能会让你产生心理阴影(毕竟很多人都踩过递归的坑),而且依然需要传递回调函数。

方式 3:使用无限 while 循环

还可以用无限while循环,结合异步代码实现:

async function monitorVitals(cb) {
  while (true) {
    await new Promise((r) => setTimeout(r, 1000));
    const vitals = await requestVitals();
    cb(vitals);
  }
}

monitorVitals((vitals) => {
  console.log("更新UI...", vitals);
});

这种写法没有了递归,但回调函数依然存在。

再次强调,上面这些写法本身都没有本质问题,只是用起来总觉得有那么点别扭。好在,我们还有另一种选择。

方式 4:使用异步生成器

我之前简单提过异步生成器,在生成器函数前加上async关键字,普通生成器就变成了异步生成器。这个写法看似理所当然,却有一个特殊的能力:异步生成器可以配合for await...of循环,遍历所有解析后的异步值。

async function* generateVitals() {
  while (true) {
    const result = await requestVitals();
    await new Promise((r) => setTimeout(r, 1000));
    // 生成异步结果
    yield result
  }
}

// 用for await...of循环遍历消费
for await (const vitals of generateVitals()) {
  console.log("更新UI...", vitals);
}

实现的效果和前面的方式一致,但却避开了那些让人不舒服的写法:没有时序问题、没有递归、没有回调函数,各个职责之间完美解耦,你只需要专注于处理序列本身即可。

让全量分页查询更高效

如果你做过分页接口的全量数据查询,大概率写过这样的代码:

async function fetchAllItems() {
  let currentPage = 1;
  let hasMore = true;
  let items = [];

  while (hasMore) {
    const data = await requestFromApi(currentPage);
    hasMore = data.hasMore;
    currentPage++;
    // 拼接每一页的结果,存入数组
    items = items.concat(data.items);
  }

  // 所有数据查询完成后,才返回结果
  return items;
}

看着这些辅助变量和数组拼接的逻辑,很少有人能觉得这种写法优雅。更重要的是,你必须等所有分页数据都查询完成,才能开始处理数据:

const allItems = await fetchAllItems();

// 必须等所有数据查询完成,这段代码才会执行
for (const item of items) {
  // 处理数据
}

从时间和内存效率来看,这都不是最优解。我们可以重构代码,让每查询一页数据就立即处理,但这样又会遇到前面提到的那些问题。

不妨试试用异步生成器实现:

async function* fetchAllItems() {
  let currentPage = 1;

  while (true) {
    const data = await requestFromApi(currentPage);
    // 没有更多数据时,直接终止
    if (!data.hasMore) return;

    currentPage++;
    // 每查询一页,就生成一页的数据,立即供外部处理
    yield data.items;
  }
}

// 边查询边处理,无需等待全量数据
for await (const items of fetchAllItems()) {
  // 处理当前页的数据
}

这种写法的辅助变量更少,能更快开始处理数据,而且各个职责依然保持解耦,效果非常不错。

便捷地按需生成批量元素

我在文章开头提过这个用法,它实在太好用了,我必须再夸一遍。因为生成器是可迭代对象,所以它能像数组一样被解构。如果你需要一个工具函数来批量生成任意元素,生成器会让这个过程变得无比简单:

function* getElements(tagName = 'div') {
  // 无限生成指定标签的DOM元素
  while (true) yield document.createElement(tagName);
}

现在,你可以随心所欲地解构生成器,获取任意数量的元素:

// 解构生成3个div元素
const [el1, el2, el3] = getElements('div');

客观来说,这个写法简直太优雅了。想了解这个技巧的更多细节,可以看看我之前写的完整文章。

未来可期

我还不确定自己对生成器的这份喜爱能持续多久(可能现在还处于 “蜜月期”)。

但即便这份热情明天就消退,我也很庆幸自己多了一项编程技能。掌握一个工具固然重要,但被迫重新思考自己的常规解题思路,收获会更大 —— 这绝对是一笔划算的投入。

无感监控:深度拆解监控 SDK 的性能平衡术与调度策略

2026年2月18日 07:25

对于正在自研监控系统的架构师来说,“无感监控”不仅是一个性能指标,更是一场对浏览器底层调度机制的深度极限利用

如果 SDK 导致用户页面出现 50ms 以上的 Long Task,或者因为上报请求过多导致业务接口排队(Connection Queueing),那监控系统本身就成了“最大的线上事故”。


一、 算力调度:别在主线程“虎口夺食”

浏览器主线程(Main Thread)是极其珍贵的资源。监控 SDK 涉及大量的 DOM 访问、对象序列化和字符串拼接,处理不好就会触发“卡顿(Jank)”。

1. 任务切片与 requestIdleCallback

监控脚本的初始化和历史数据扫描往往属于“非紧急任务”。

  • 底层机制:利用浏览器在每一帧渲染完成后的空闲时间(Idle Period)执行。
  • 进阶技巧:由于 requestIdleCallback 的优先级极低,在页面高频交互时可能永远不被触发。
  • 实战代码策略:设置一个 timeout(如 2000ms)。如果在 2 秒内主线程一直很忙,SDK 会强制在下一个事件循环中执行,平衡了“不阻塞”与“不丢失”。

2. 规避重排陷阱:静态属性抓取

很多 SDK 在捕获点击事件时,为了获取元素位置,会频繁调用 getBoundingClientRect()

  • 风险点:这类 API 会强制浏览器立即重新计算样式和布局(Reflow),导致主线程瞬间阻塞。
  • 优化方案:尽量使用 IntersectionObserver 异步监听元素可见性,或者直接通过 event 对象获取 clientX/Y 等预计算好的坐标,严禁在全局滚动事件中进行同步 DOM 测量。

二、 传输链路:打通“只发不接”的特权通道

在大规模数据上报时,网络请求的开销(建立连接、占用并发数)往往比计算开销更致命。

1. navigator.sendBeacon:浏览器的“离线快递”

这是无感监控的核心利器。

  • 非阻塞:它将数据交给浏览器管理的独立队列。即使你的页面逻辑已经开始处理复杂的动画,浏览器也会在后台悄悄把数据发出去。
  • 生存保障:在页面卸载(beforeunload/unload)时,普通的 XHR 或 Fetch 请求大概率会被截断,导致关键的延迟数据丢失。sendBeacon 能确保即使窗口关闭,数据也能安全到达服务器。

2. Fetch 的 keepalive 选项

如果你需要处理更复杂的响应(虽然监控通常不需要),可以给 fetch 设置 keepalive: true。它的作用类似于 sendBeacon,允许请求在页面销毁后继续在后台存活。


三、 内存管理:警惕监控 SDK 的“自增长”

监控系统需要监听全局的 PromiseConsoleNetwork。这些“劫持”行为极易产生长期持有的闭包。

1. 影子 DOM(Shadow DOM)隔离

如果你的 SDK 需要在页面上注入 UI(如录屏控制、错误弹窗),请务必使用 Shadow DOM

  • 价值:它可以防止 SDK 的样式污染业务页面,同时避免业务代码的 CSS 选择器误伤 SDK 元素,减少浏览器的样式重算(Recalculate Style)范围。

2. 对象池与缓冲区(Buffer)

  • 按需序列化:不要捕获整个 Error 对象,它包含极其复杂的原型链。只抽取 messagestack 和自定义上下文。
  • 弱引用利用:在一些需要暂存 DOM 节点的场景,使用 WeakMapWeakSet,确保当业务代码删除 DOM 后,SDK 不会成为阻碍 GC 回收的罪魁祸首。

四、 采样与降级:稳健策略

你应该明白“全量监控”在超大规模流量下是不可持续的。

1. 动态采样率(Sampling Rate)

  • 逻辑:针对 200 OK 的请求,采样率设为 1%;针对 5xx 错误或 Long Task,采样率设为 100%。
  • 实现:由后端下发控制指令,SDK 动态调整收集频率,实现“平时安静,出事警觉”。

2. 自我熔断机制

  • 监控 SDK 的监控:在 SDK 内部记录自身的执行耗时。
  • 熔断条件:如果 SDK 连续多次初始化耗时超过 100ms,或者本地队列堆积超过 1000 条,SDK 应当自动进入“休眠模式”,停止一切捕获,保护主业务不崩溃。

向 Native 借力:深度拆解 SIMD 加速与 Node.js 异步原生解析

2026年2月18日 07:23

当 Node.js 的内置 JSON.parse 成为系统吞吐量的瓶颈时,你已经触及了 V8 引擎的物理边界。

此时,代码优化的边际效应已经极低,真正的破局点在于**“降维打击” :利用 Node-API (N-API) 绕过 JavaScript 的单线程限制,直接调度 CPU 的 SIMD 指令集多线程并行能力**。


一、 V8 的天花板:为什么内置解析器跑不动了?

尽管 V8 引擎是工业界的巅峰之作,但它的 JSON.parse 在处理大规模监控原始数据(GB 级别)时,存在三个结构性缺陷:

  1. 单线程阻塞(Stop-the-world)

    由于 JS 是单线程的,执行 JSON.parse 时,V8 必须停止所有业务逻辑。解析 500MB 的 JSON 字符串通常需要数百毫秒,这在高性能网关中会导致灾难性的请求堆积。

  2. 非必要的中间表示(Double Allocation)

    V8 必须先将原始二进制 Buffer 转换为 UTF-16 字符串,然后再解析成 JS 对象图。这个过程涉及大量的内存分配和垃圾回收(GC)压力。

  3. 串行化解析逻辑

    传统的解析器是“标量”的,即一次只能读取并判断一个字符。它无法利用现代 CPU 一次处理多个数据块的并发特性。


二、 极致黑科技:SIMD 与 simdjson 的并行艺术

在 Native 领域,simdjson 的出现彻底重塑了 JSON 解析的性能标准。它的核心秘诀在于利用现代 CPU 的 SIMD(Single Instruction, Multiple Data,单指令多数据流)

1. 结构化索引(Stage 1:Rapid Identification)

传统的解析器在遇到逗号或冒号时需要进行分支判断。而 SIMD 允许 CPU 通过位掩码(Bitmask)一次性检查 64 个字节

  • 原理:它利用 _mm512_cmpeq_epi8 等指令,瞬间在内存中标记出所有语法关键符号({, }, [, ], :, ,)。
  • 收益:解析器在处理业务逻辑前,就已经拥有了一张完整的“地图”,跳过了 90% 的低效分支预测。

2. 异步并行架构(Stage 2:Multi-threading)

通过原生扩展,我们可以真正实现后台解析

  • 逻辑:Node.js 接收到日志 Buffer 后,仅传递一个内存地址给 C++/Rust 扩展。
  • 执行:原生插件在 Libuv 线程池中开启多个子线程并行处理。
  • 同步:主线程继续处理其他请求,待解析完成后,通过异步回调将结果返回给 JS。

三、 工程化落地:利用 napi-rs 构建原生利刃

作为 8 年资深开发,推荐使用 napi-rs。它比传统的 C++ node-gyp 更安全且更高效。

1. 零拷贝(Zero-copy)的终极优化

在监控场景中,我们往往只需要 JSON 中的某几个字段。传统的解析会把整个 JSON 变成巨大的 JS 对象。

  • 原生方案:在 Native 层解析后,不将其转换成 JS 对象,而是建立一个内存索引树
  • 按需读取:JS 层通过 Getter 函数访问属性。只有当 JS 真正访问某个字段时,才进行必要的转换。
  • 效果:对于 1GB 的日志,如果只读 10% 的字段,内存占用能从数 GB 降至数百 MB。

2. 线程安全的回调(Thread-safe Function)

在 Native 层完成繁重的解析后,如何安全地把数据塞回 JS 环境?

  • 机制:利用 napi_threadsafe_function。它能确保即使 Rust 在后台多线程并行,最终返回 JS 时的上下文也是线程安全的,避免了 Node.js 进程莫名崩溃(Segment Fault)。

四、成本与红线

在追求极致性能时,必须保持清醒的架构判断:

  1. 边界跨越开销:JS 调用 Native 是有成本的(Context Switch)。对于小于 50KB 的数据,JSON.parse 依然是最快的。只有在处理持续高频大容量数据时,Native 扩展才具有性价比。
  2. 内存生命周期管理:在 Native 层操作 Buffer 时,必须确保 JS 端的 Buffer 不会被 GC 回收。你需要手动使用 napi_ref 来锁定内存地址,否则会发生内存踩踏。
  3. SIMD 兼容性:不同的 CPU 支持不同的指令集(AVX2, AVX-512, NEON)。你的扩展必须具备动态指令集探测能力,否则在旧机器上会直接退出。

💡 结语

JSON 的优化已经聊到了底层硬件级别。如果你的监控系统依然面临压力,那么下一步就不是优化 JSON,而是更换协议

每日一题-交替位二进制数🟢

2026年2月18日 00:00

给定一个正整数,检查它的二进制表示是否总是 0、1 交替出现:换句话说,就是二进制表示中相邻两位的数字永不相同。

 

示例 1:

输入:n = 5
输出:true
解释:5 的二进制表示是:101

示例 2:

输入:n = 7
输出:false
解释:7 的二进制表示是:111.

示例 3:

输入:n = 11
输出:false
解释:11 的二进制表示是:1011.

 

提示:

  • 1 <= n <= 231 - 1

React 性能优化:图片懒加载

作者 NEXT06
2026年2月17日 23:00

引言

在现代 Web 应用开发中,首屏加载速度(FCP)和最大内容绘制(LCP)是衡量用户体验的核心指标。随着富媒体内容的普及,图片资源往往占据了页面带宽的大部分。如果一次性加载页面上的所有图片,不仅会阻塞关键渲染路径,导致页面长时间处于“白屏”或不可交互状态,还会浪费用户的流量带宽。

图片懒加载(Lazy Loading)作为一种经典的性能优化策略,其核心思想是“按需加载”:即只有当图片出现在浏览器可视区域(Viewport)或即将进入可视区域时,才触发网络请求进行加载。这一策略能显著减少首屏 HTTP 请求数量,降低服务器压力,并提升页面的交互响应速度。

本文将基于 React 生态,从底层原理出发,深入探讨图片懒加载的多种实现方案,并重点分析如何解决布局偏移(CLS)等用户体验问题。

核心原理剖析

图片懒加载的本质是一个“可见性检测”问题。我们需要实时判断目标图片元素是否与浏览器的可视区域发生了交叉。在技术实现上,主要依赖以下两种依据:

  1. 几何计算:通过监听滚动事件,实时计算元素的位置坐标。核心公式通常涉及 scrollTop(滚动距离)、clientHeight(视口高度)与元素 offsetTop(偏移高度)的比较,或者使用 getBoundingClientRect() 获取元素相对于视口的位置。
  2. API 监测:利用浏览器提供的 IntersectionObserver API。这是一个异步观察目标元素与祖先元素或顶级文档视窗交叉状态的方法,它将复杂的几何计算交由浏览器底层处理,性能表现更优。

方案一:原生 HTML 属性(最简方案)

HTML5 标准为  标签引入了 loading 属性,这是实现懒加载最简单、成本最低的方式。

Jsx

const NativeLazyLoad = ({ src, alt }) => {
  return (
    <img 
      src={src} 
      alt={alt} 
      loading="lazy" 
      width="300" 
      height="200"
    />
  );
};

分析:

  • 优点:零 JavaScript 代码,完全依赖浏览器原生行为,不会阻塞主线程。

  • 缺点

    • 兼容性:虽然现代浏览器支持度已较高,但在部分旧版 Safari 或 IE 中无法工作。
    • 不可控:开发者无法精确控制加载的阈值(Threshold),也无法在加载失败或加载中注入自定义逻辑(如骨架屏切换)。
    • 功能单一:仅适用于 img 和 iframe 标签,无法用于 background-image。

方案二:传统 Scroll 事件监听(兼容方案)

在 IntersectionObserver 普及之前,监听 scroll 事件是主流做法。其原理是在 React 组件挂载后绑定滚动监听器,在回调中计算图片位置。

React 实现示例:

Jsx

import React, { useState, useEffect, useRef } from 'react';
import placeholder from './assets/placeholder.png';

// 简单的节流函数,生产环境建议使用 lodash.throttle
const throttle = (func, limit) => {
  let inThrottle;
  return function() {
    const args = arguments;
    const context = this;
    if (!inThrottle) {
      func.apply(context, args);
      inThrottle = true;
      setTimeout(() => inThrottle = false, limit);
    }
  }
};

const ScrollLazyImage = ({ src, alt }) => {
  const [imageSrc, setImageSrc] = useState(placeholder);
  const imgRef = useRef(null);
  const [isLoaded, setIsLoaded] = useState(false);

  useEffect(() => {
    const checkVisibility = () => {
      if (isLoaded || !imgRef.current) return;

      const rect = imgRef.current.getBoundingClientRect();
      const windowHeight = window.innerHeight || document.documentElement.clientHeight;

      // 设置 100px 的缓冲区,提前加载
      if (rect.top <= windowHeight + 100) {
        setImageSrc(src);
        setIsLoaded(true);
      }
    };

    // 必须使用节流,否则滚动时会频繁触发重排和重绘,导致性能灾难
    const throttledCheck = throttle(checkVisibility, 200);

    window.addEventListener('scroll', throttledCheck);
    window.addEventListener('resize', throttledCheck);
    
    // 初始化检查,防止首屏图片不加载
    checkVisibility();

    return () => {
      window.removeEventListener('scroll', throttledCheck);
      window.removeEventListener('resize', throttledCheck);
    };
  }, [src, isLoaded]);

  return <img ref={imgRef} src={imageSrc} alt={alt} />;
};

关键点分析:

  1. 节流(Throttle) :scroll 事件触发频率极高,若不加节流,每次滚动都会执行 DOM 查询和几何计算,占用大量主线程资源,导致页面掉帧。
  2. 回流(Reflow)风险:频繁调用 getBoundingClientRect() 或 offsetTop 会强制浏览器进行同步布局计算(Synchronous Layout),这是性能杀手。

方案三:IntersectionObserver API(现代标准方案)

这是目前最推荐的方案。IntersectionObserver 运行在独立线程中,不会阻塞主线程,且浏览器对其进行了内部优化。

React 实现示例:

我们可以将其封装为一个通用的组件 LazyImage。

Jsx

import React, { useState, useEffect, useRef } from 'react';
import './LazyImage.css'; // 假设包含样式

const LazyImage = ({ src, alt, placeholderSrc, width, height }) => {
  const [imageSrc, setImageSrc] = useState(placeholderSrc || '');
  const [isVisible, setIsVisible] = useState(false);
  const imgRef = useRef(null);

  useEffect(() => {
    let observer;
    
    if (imgRef.current) {
      observer = new IntersectionObserver((entries) => {
        const entry = entries[0];
        // 当元素进入视口
        if (entry.isIntersecting) {
          setImageSrc(src);
          setIsVisible(true);
          // 关键:图片加载触发后,立即停止观察,释放资源
          observer.unobserve(imgRef.current);
          observer.disconnect();
        }
      }, {
        rootMargin: '100px', // 提前 100px 加载
        threshold: 0.01
      });

      observer.observe(imgRef.current);
    }

    // 组件卸载时的清理逻辑
    return () => {
      if (observer) {
        observer.disconnect();
      }
    };
  }, [src]);

  return (
    <img
      ref={imgRef}
      src={imageSrc}
      alt={alt}
      width={width}
      height={height}
      className={`lazy-image ${isVisible ? 'loaded' : ''}`}
    />
  );
};

export default LazyImage;

优势分析:

  • 高性能:异步检测,无回流风险。
  • 资源管理:通过 unobserve 和 disconnect 及时释放观察者,避免内存泄漏。
  • 灵活性:rootMargin 允许我们轻松设置预加载缓冲区。

进阶:用户体验与 CLS 优化

仅仅实现“懒加载”是不够的。在工程实践中,如果处理不当,懒加载会导致严重的累积布局偏移(CLS, Cumulative Layout Shift) 。即图片加载前高度为 0,加载后撑开高度,导致页面内容跳动。这不仅体验极差,也是 Google Core Web Vitals 的扣分项。

1. 预留空间(Aspect Ratio)

必须在图片加载前确立其占据的空间。现代 CSS 提供了 aspect-ratio 属性,配合宽度即可自动计算高度。

CSS

/* LazyImage.css */
.img-wrapper {
  width: 100%;
  /* 假设图片比例为 16:9,或者由后端返回具体宽高计算 */
  aspect-ratio: 16 / 9; 
  background-color: #f0f0f0; /* 骨架屏背景色 */
  overflow: hidden;
  position: relative;
}

.lazy-image {
  width: 100%;
  height: 100%;
  object-fit: cover;
  opacity: 0;
  transition: opacity 0.3s ease-in-out;
}

.lazy-image.loaded {
  opacity: 1;
}

2. 结合数据的完整 React 组件

结合后端返回的元数据(如宽高、主色调),我们可以构建一个体验极佳的懒加载组件。

Jsx

const AdvancedLazyImage = ({ data }) => {
  // data 结构示例: { url: '...', width: 800, height: 600, basicColor: '#a44a00' }
  const imgRef = useRef(null);
  const [isLoaded, setIsLoaded] = useState(false);

  useEffect(() => {
    const observer = new IntersectionObserver(([entry]) => {
      if (entry.isIntersecting) {
        const img = entry.target;
        // 使用 dataset 获取真实地址,或者直接操作 state
        img.src = img.dataset.src;
        
        img.onload = () => setIsLoaded(true);
        observer.unobserve(img);
      }
    });

    if (imgRef.current) observer.observe(imgRef.current);

    return () => observer.disconnect();
  }, []);

  return (
    <div 
      className="img-container"
      style={{
        // 核心:使用 aspect-ratio 防止 CLS
        aspectRatio: `${data.width} / ${data.height}`,
        // 核心:使用图片主色调作为占位背景,提供渐进式体验
        backgroundColor: data.basicColor 
      }}
    >
      <img
        ref={imgRef}
        data-src={data.url} // 暂存真实地址
        alt="Lazy load content"
        style={{
          opacity: isLoaded ? 1 : 0,
          transition: 'opacity 0.5s ease'
        }}
      />
    </div>
  );
};

方案对比与场景选择

方案 实现难度 性能 兼容性 适用场景
原生属性 (loading="lazy") 中 (现代浏览器) 简单的 CMS 内容页、对交互要求不高的场景。
Scroll 监听 低 (需节流) 高 (全兼容) 必须兼容 IE 等老旧浏览器,或有特殊的滚动容器逻辑。
IntersectionObserver 极高 高 (需 Polyfill) 现代 Web 应用、无限滚动列表、对性能和体验有高要求的场景。

结语

图片懒加载是前端性能优化的基石之一。从早期的 Scroll 事件监听,到如今标准化的 IntersectionObserver API,再到原生 HTML 属性的支持,技术在不断演进。

在 React 项目中落地懒加载时,我们不能仅满足于“功能实现”。作为架构师,更应关注性能损耗(如避免主线程阻塞)、资源管理(及时销毁 Observer)以及用户体验(防止 CLS、优雅的过渡动画)。通过合理利用 aspect-ratio 和占位策略,我们可以让懒加载不仅“快”,而且“稳”且“美”。

别让字体拖了后腿:FOIT/FOUT 深度解析与字体加载优化全攻略

2026年2月17日 22:56

🖋️ 别让字体拖了后腿:FOIT/FOUT 深度解析与字体加载优化全攻略

前端性能优化专栏 - 第九篇

在网页设计中,字体往往被视为“灵魂”。它不仅关乎品牌识别视觉统一,更能直接影响界面的质感专业感。好的字体能降低用户的认知成本,传递出产品想要营造的独特氛围。

然而,字体作为一种静态资源,必须经过下载和管理。如果处理不当,它就会变成性能的“累赘”,甚至引发一些让用户抓狂的“怪现象”。


⚠️ 字体加载中的两大“怪现象”

你是否遇到过这样的场景:页面打开了,但文字部分是一片空白,过了好几秒才突然蹦出来?或者文字先是以一种普通的系统字体显示,然后突然“闪”一下,变成了精美的设计字体?

这可不是浏览器的 Bug,而是浏览器默认的字体加载策略在作祟。

1. FOIT:不可见文本闪现 (Flash of Invisible Text)

这是 Chrome 等现代浏览器的典型行为。当自定义字体还没下载完时,浏览器会选择完全不渲染文本

  • 用户体验:网速慢时,用户看到的是大片空白,甚至会误以为页面挂了。
  • 本质:先保证样式统一,再显示内容。
  • 优点:避免了字体闪变,视觉风格高度一致。
  • 缺点:对弱网用户极其不友好,内容被阻塞。

2. FOUT:无样式文本闪现 (Flash of Unstyled Text)

这是 IE 等浏览器的传统行为。当自定义字体未加载完时,浏览器先用后备字体(系统自带字体)渲染。

  • 用户体验:页面一开始就能阅读,但字体样式会明显“跳”一下。
  • 本质:先保证内容可见,再追求样式正确。
  • 优点:文字优先可见,对内容型页面(如博客、资讯)非常友好。
  • 缺点:字体闪变会瞬间破坏设计感。

FOIT 与 FOUT 现象对比图


✨ 浏览器如何做权衡?

字体加载本质上是一个异步网络请求。在等待期间,浏览器必须决定:

  1. 是否渲染文本?
  2. 用什么字体渲染?
  3. 多久之后放弃等待?

为了不让浏览器“瞎猜”,我们需要一种方式显式地告诉它:“在这个项目里,你应该如何平衡内容、样式和性能。”


🔧 终极武器:font-display

@font-face 中使用 font-display 属性,可以精准控制字体在不同加载阶段的渲染策略。

@font-face {
  font-family: 'My Custom Font';
  src: url(/fonts/my-font.woff2) format('woff2');
  font-display: swap; /* 关键控制位 */
}

font-display 四种策略示意图

✅ 1. font-display: swap (强烈推荐)

这是目前最主流、最被推荐的策略。

  • 行为:立即使用后备字体渲染,等自定义字体准备好后再替换。
  • 特点:主动选择 FOUT,几乎没有文本阻塞时间。
  • 适用场景:新闻站、博客、社区等以内容为主的页面。

🚫2. font-display: block

  • 行为:设置约 3 秒的阻塞期。期间文本不可见(FOIT),超时后才回退到后备字体。
  • 特点:偏向 FOIT,强调视觉一致性。
  • 适用场景:品牌 Logo、视觉主标题等字体是核心识别部分的区域。切记不要对全站正文使用!

⚖️3. font-display: fallback

  • 行为:设置极短的阻塞期(约 100ms)。如果字体没到,立即用后备字体,且通常不再替换。
  • 特点:折中方案。快则用好字体,慢则全程用旧字体,不折腾,不闪变
  • 适用场景:希望有自定义字体,但不愿为此牺牲太多性能,也不希望看到闪变的场景。

⚡4. font-display: optional

  • 行为:阻塞期极短,且赋予浏览器更大的决策权。网络差时可能直接不加载字体。
  • 特点:性能优先级最高,字体属于“锦上添花”。
  • 适用场景:装饰性字体、非核心模块的氛围字体。

💡 总结

字体优化不是简单的“全都要”,而是一场关于内容可见性视觉一致性的博弈。

  • 如果你追求内容第一,请毫不犹豫地选择 swap
  • 如果你追求品牌至上,可以在局部使用 block
  • 如果你想要稳定体验fallbackoptional 是你的好伙伴。

合理利用 font-display,让你的网页在保持美感的同时,也能拥有丝滑的加载体验!


下一篇预告: 页面加载完了,但一滚动就发现元素在“乱跳”?这种让人头大的现象叫布局抖动(Layout Thrashing) 。下一篇我们将深入探讨如何识别并优化布局抖动,让你的页面稳如泰山!敬请期待!

【宫水三叶】位运算应用题

作者 AC_OIer
2022年3月28日 07:06

遍历

根据题意,对 $n$ 的每一位进行遍历检查。

代码:

###Java

class Solution {
    public boolean hasAlternatingBits(int n) {
        int cur = -1;
        while (n != 0) {
            int u = n & 1;
            if ((cur ^ u) == 0) return false;
            cur = u; n >>= 1;
        }
        return true;
    }
}
  • 时间复杂度:$O(\log{n})$
  • 空间复杂度:$O(1)$

位运算

另外一种更为巧妙的方式是利用交替位二进制数性质。

当给定值 $n$ 为交替位二进制数时,将 $n$ 右移一位得到的值 $m$ 仍为交替位二进制数,且与原数 $n$ 错开一位,两者异或能够得到形如 $0000...1111$ 的结果 $x$,此时对 $x$ 执行加法(进位操作)能够得到形如 $0000...10000$ 的结果,将该结果与 $x$ 执行按位与后能够得到全 $0$ 结果。

代码:

###Java

class Solution {
    public boolean hasAlternatingBits(int n) {
        int x = n ^ (n >> 1);
        return (x & (x + 1)) == 0;
    }
}
  • 时间复杂度:$O(1)$
  • 空间复杂度:$O(1)$

同类型加餐

今日份加餐:经典「状态压缩 + 位运算」入门题 🎉🎉

或是考虑加练如下「位运算」题目 🍭🍭🍭

题目 题解 难度 推荐指数
137. 只出现一次的数字 II LeetCode 题解链接 中等 🤩🤩🤩
190. 颠倒二进制位 LeetCode 题解链接 简单 🤩🤩🤩
191. 位1的个数 LeetCode 题解链接 简单 🤩🤩🤩
260. 只出现一次的数字 III LeetCode 题解链接 中等 🤩🤩🤩🤩
405. 数字转换为十六进制数 LeetCode 题解链接 简单 🤩🤩🤩🤩
461. 汉明距离 LeetCode 题解链接 简单 🤩🤩🤩🤩
477. 汉明距离总和 LeetCode 题解链接 简单 🤩🤩🤩🤩
526. 优美的排列 LeetCode 题解链接 中等 🤩🤩🤩

注:以上目录整理来自 wiki,任何形式的转载引用请保留出处。


最后

如果有帮助到你,请给题解点个赞和收藏,让更多的人看到 ~ ("▔□▔)/

也欢迎你 关注我 和 加入我们的「组队打卡」小群 ,提供写「证明」&「思路」的高质量题解。

所有题解已经加入 刷题指南,欢迎 star 哦 ~

交替位二进制数

2022年3月26日 09:47

方法一:模拟

思路

从最低位至最高位,我们用对 $2$ 取模再除以 $2$ 的方法,依次求出输入的二进制表示的每一位,并与前一位进行比较。如果相同,则不符合条件;如果每次比较都不相同,则符合条件。

代码

###Python

class Solution:
    def hasAlternatingBits(self, n: int) -> bool:
        prev = 2
        while n:
            cur = n % 2
            if cur == prev:
                return False
            prev = cur
            n //= 2
        return True

###Java

class Solution {
    public boolean hasAlternatingBits(int n) {
        int prev = 2;
        while (n != 0) {
            int cur = n % 2;
            if (cur == prev) {
                return false;
            }
            prev = cur;
            n /= 2;
        }
        return true;
    }
}

###C#

public class Solution {
    public bool HasAlternatingBits(int n) {
        int prev = 2;
        while (n != 0) {
            int cur = n % 2;
            if (cur == prev) {
                return false;
            }
            prev = cur;
            n /= 2;
        }
        return true;
    }
}

###C++

class Solution {
public:
    bool hasAlternatingBits(int n) {
        int prev = 2;
        while (n != 0) {
            int cur = n % 2;
            if (cur == prev) {
                return false;
            }
            prev = cur;
            n /= 2;
        }
        return true;
    }
};

###C

bool hasAlternatingBits(int n) {
    int prev = 2;
    while (n != 0) {
        int cur = n % 2;
        if (cur == prev) {
            return false;
        }
        prev = cur;
        n /= 2;
    }
    return true;
} 

###go

func hasAlternatingBits(n int) bool {
    for pre := 2; n != 0; n /= 2 {
        cur := n % 2
        if cur == pre {
            return false
        }
        pre = cur
    }
    return true
}

###JavaScript

var hasAlternatingBits = function(n) {
    let prev = 2;
    while (n !== 0) {
        const cur = n % 2;
        if (cur === prev) {
            return false;
        }
        prev = cur;
        n = Math.floor(n / 2);
    }
    return true;
};

复杂度分析

  • 时间复杂度:$O(\log n)$。输入 $n$ 的二进制表示最多有 $O(\log n)$ 位。

  • 空间复杂度:$O(1)$。使用了常数空间来存储中间变量。

方法二:位运算

思路

对输入 $n$ 的二进制表示右移一位后,得到的数字再与 $n$ 按位异或得到 $a$。当且仅当输入 $n$ 为交替位二进制数时,$a$ 的二进制表示全为 $1$(不包括前导 $0$)。这里进行简单证明:当 $a$ 的某一位为 $1$ 时,当且仅当 $n$ 的对应位和其前一位相异。当 $a$ 的每一位为 $1$ 时,当且仅当 $n$ 的所有相邻位相异,即 $n$ 为交替位二进制数。

将 $a$ 与 $a + 1$ 按位与,当且仅当 $a$ 的二进制表示全为 $1$ 时,结果为 $0$。这里进行简单证明:当且仅当 $a$ 的二进制表示全为 $1$ 时,$a + 1$ 可以进位,并将原最高位置为 $0$,按位与的结果为 $0$。否则,不会产生进位,两个最高位都为 $1$,相与结果不为 $0$。

结合上述两步,可以判断输入是否为交替位二进制数。

代码

###Python

class Solution:
    def hasAlternatingBits(self, n: int) -> bool:
        a = n ^ (n >> 1)
        return a & (a + 1) == 0

###Java

class Solution {
    public boolean hasAlternatingBits(int n) {
        int a = n ^ (n >> 1);
        return (a & (a + 1)) == 0;
    }
}

###C#

public class Solution {
    public bool HasAlternatingBits(int n) {
        int a = n ^ (n >> 1);
        return (a & (a + 1)) == 0;
    }
}

###C++

class Solution {
public:
    bool hasAlternatingBits(int n) {
        long a = n ^ (n >> 1);
        return (a & (a + 1)) == 0;
    }
};

###C

bool hasAlternatingBits(int n) {
    long a = n ^ (n >> 1);
    return (a & (a + 1)) == 0;
}

###go

func hasAlternatingBits(n int) bool {
    a := n ^ n>>1
    return a&(a+1) == 0
}

###JavaScript

var hasAlternatingBits = function(n) {
    const a = n ^ (n >> 1);
    return (a & (a + 1)) === 0;
};

复杂度分析

  • 时间复杂度:$O(1)$。仅使用了常数时间来计算。

  • 空间复杂度:$O(1)$。使用了常数空间来存储中间变量。

昨天 — 2026年2月17日技术

后端跑路了怎么办?前端工程师用 Mock.js 自救实录

作者 NEXT06
2026年2月17日 22:22

在现代 Web 应用的开发流程中,前后端分离已成为行业标准。然而,在实际协作中,前端工程师常常面临“后端接口未就绪、联调环境不稳定、异常场景难以复现”等痛点。这些问题导致前端开发进度被迫依赖后端,严重制约了交付效率。

Mock.js 作为一种数据模拟解决方案,不仅能解除这种依赖,还能通过工程化的方式提升代码的健壮性。本文将从架构视角出发,深入剖析 Mock.js 的核心价值、技术原理,并结合 Vite 生态展示如何在现代项目中落地最佳实践,同时客观分析其局限性与应对策略。

一、 核心价值:为何引入 Mock.js

在工程化体系中,Mock.js 不仅仅是一个生成随机数据的库,它是实现“并行开发”的关键基础设施。

  1. 解除依赖,并行开发
    传统模式下,前端需等待后端 API 开发完成并部署后才能进行数据交互逻辑的编写。引入 Mock.js 后,只要前后端约定好接口文档(API Contract),前端即可通过模拟数据独立完成 UI 渲染和交互逻辑,将开发流程从“串行”转变为“并行”。
  2. 高保真的数据仿真
    相比于手动硬编码的 test 或 123 等无意义数据,Mock.js 提供了丰富的数据模板定义(Schema)。它能生成具有语义化的数据,如随机生成的中文姓名、身份证号、布尔值、图片 URL、时间戳等。这使得前端在开发阶段就能发现因数据长度、类型或格式引发的 UI 适配问题。
  3. 边界条件与异常流测试
    真实后端环境往往难以稳定复现 500 服务器错误、404 资源丢失或超长网络延迟。Mock.js 允许开发者通过配置轻松模拟这些极端情况,验证前端在异常状态下的容错机制(如 Loading 状态、错误提示、重试逻辑)是否健壮。

二、 技术原理与现代实现方案

1. 原生拦截原理

Mock.js 的核心原理是重写浏览器原生的 XMLHttpRequest 对象。当代码发起请求时,Mock.js 会在浏览器端拦截该请求,判断 URL 是否匹配预定义的规则。如果匹配,则阻止网络请求的发出,并直接返回本地生成的模拟数据;如果不匹配,则放行请求。

2. 现代工程化方案:Vite + vite-plugin-mock

直接在业务代码(如 main.js)中引入 Mock.js 是一种侵入性较强的做法,且原生 Mock.js 拦截请求后,浏览器的 Network 面板无法抓取到请求记录,给调试带来不便。

在 Vite 生态中,推荐使用 vite-plugin-mock。该插件在开发环境(serve)下,通过 Node.js 中间件的形式拦截请求。这意味着请求真正从浏览器发出并到达了本地开发服务器,因此可以在 Network 面板清晰查看请求详情,体验与真实接口完全一致。

三、 实战演练:构建可分页的列表接口

以下将展示如何在 Vite + TypeScript 项目中集成 Mock.js,并实现一个包含逻辑处理(分页、切片)的模拟接口。

1. 项目目录结构

建议将 Mock 数据与业务代码分离,保持目录结构清晰:

Text

project-root/
├── src/
├── mock/
│   ├── index.ts        # Mock 服务配置
│   └── user.ts         # 用户模块接口
│   └── list.ts         # 列表模块接口(本例重点)
├── vite.config.ts      # Vite 配置
└── package.json

2. 环境配置 (vite.config.ts)

通过配置插件,确保 Mock 服务仅在开发模式下启动,生产构建时自动剔除。

TypeScript

import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import { viteMockServe } from 'vite-plugin-mock';

export default defineConfig(({ command }) => {
  return {
    plugins: [
      vue(),
      viteMockServe({
        // mock 文件存放目录
        mockPath: 'mock',
        // 仅在开发环境开启 mock
        localEnabled: command === 'serve',
        // 生产环境关闭,避免 mock 代码打包到生产包中
        prodEnabled: false, 
      }),
    ],
  };
});

3. 编写复杂分页接口 (mock/list.ts)

模拟接口不仅仅是返回死数据,更需要具备一定的逻辑处理能力。以下代码演示了如何利用 Mock.js 生成海量数据,并根据前端传入的 page 和 pageSize 参数进行数组切片,模拟真实的数据库查询行为。

TypeScript

import { MockMethod } from 'vite-plugin-mock';
import Mock from 'mockjs';

// 1. 生成模拟数据池
// 使用 Mock.js 模板语法生成 100 条具有语义的列表数据
const dataPool = Mock.mock({
  'list|100': [
    {
      'id|+1': 1, // ID 自增
      author: '@cname', // 随机中文名
      title: '@ctitle(10, 20)', // 10-20字的中文标题
      summary: '@cparagraph(2)', // 随机段落
      'tags|1-3': ['@string("lower", 5)'], // 随机标签数组
      publishDate: '@datetime', // 随机时间
      cover: '@image("200x100", "#50B347", "#FFF", "Mock.js")', // 占位图
      views: '@integer(100, 5000)', // 随机阅读量
    },
  ],
});

// 2. 定义接口逻辑
export default [
  {
    url: '/api/get-article-list',
    method: 'get',
    response: ({ query }) => {
      // 获取前端传递的分页参数,默认为第一页,每页10条
      const page = Number(query.page) || 1;
      const pageSize = Number(query.pageSize) || 10;

      const list = dataPool.list;
      const total = list.length;

      // 核心逻辑:计算分页切片
      const start = (page - 1) * pageSize;
      const end = start + pageSize;
      // 模拟数组切片,返回对应页的数据
      const pageData = list.slice(start, end);

      // 返回标准响应结构
      return {
        code: 200,
        message: 'success',
        data: {
          items: pageData,
          total: total,
          currentPage: page,
          pageSize: pageSize,
        },
      };
    },
  },
] as MockMethod[];

四、 Mock.js 的典型使用场景

  1. 项目原型与演示:在后端架构尚未搭建之前,前端可快速构建包含完整数据流的高保真原型,用于产品评审或客户演示。
  2. 单元测试与集成测试:在 Jest 等测试框架中,利用 Mock 屏蔽外部网络依赖,确保测试用例的运行速度和结果的确定性。
  3. 离线开发:在高铁、飞机等无网络环境下,通过本地 Mock 服务继续进行业务逻辑开发。
  4. 异常流复现:针对超时、空数据、字段缺失等后端难以配合构造的场景进行针对性开发。

五、 深度解析:局限性与弊端

尽管 Mock.js 极大提升了开发效率,但作为一名架构师,必须清晰认知其局限性,以避免在工程落地时产生负面影响。

1. Network 面板不可见问题

原生 Mock.js 通过重写 window.XMLHttpRequest 实现拦截。这种机制发生在浏览器脚本执行层面,请求并未真正进入网络层。因此,开发者在 Chrome DevTools 的 Network 面板中无法看到这些请求,导致调试困难(只能依赖 console.log)。

  • 解决方案:使用 vite-plugin-mock 或 webpack-dev-server 的中间件模式。这种模式在本地 Node 服务端拦截请求,浏览器感知到的是真实的 HTTP 请求,从而解决了 Network 面板不可见的问题。

2. Fetch API 兼容性

原生 Mock.js 仅拦截 XMLHttpRequest,而现代前端项目大量使用 fetch API。若直接使用原生 Mock.js,fetch 请求将无法被拦截,直接穿透到网络。

  • 解决方案:使用 mockjs-fetch 等补丁库,或者坚持使用基于 Node 中间件的拦截方案(如上述 Vite 插件方案),因为中间件方案对前端请求库(Axios/Fetch)是透明的。

3. 数据契约的一致性风险(联调火葬场)

这是 Mock.js 使用中最大的风险点。前端编写的 Mock 数据结构(字段名、类型、层级)完全依赖于开发者的主观定义或早期的接口文档。一旦后端在开发过程中修改了字段(例如将 userId 改为 uid,或将 money 类型由数字改为字符串),而前端 Mock 未及时同步,就会导致“本地开发一切正常,上线联调全面崩溃”的现象。

六、 最佳实践与总结

为了最大化 Mock.js 的收益并规避风险,建议团队遵循以下最佳实践:

  1. 严格的环境隔离:务必在构建配置中通过 Tree Shaking 或环境变量控制,确保 Mock 相关代码(包括 mockjs 库本身和 mock 数据文件)绝对不会被打入生产环境的包中,避免增加包体积或泄露开发逻辑。
  2. 统一接口契约:不要依赖口头约定。建议引入 Swagger (OpenAPI) 或 YAPI 等工具管理接口文档。理想情况下,应编写脚本根据 Swagger 文档自动生成 Mock 数据文件,保证 Mock 数据与后端接口定义的一致性。
  3. 适度模拟:Mock 的目的是打通前端逻辑,而非复刻后端业务。对于极度复杂的业务逻辑(如复杂的权限校验、支付流程),应尽早与后端联调,避免在 Mock 层投入过高成本。
  4. 规范化目录:将 Mock 文件视为项目源代码的一部分进行版本管理,保持清晰的模块化结构,便于团队成员协作和维护。

综上所述,Mock.js 是现代前端工程化中不可或缺的利器。通过合理的架构设计和工具选型,它能显著提升前后端协作效率,但开发者也需时刻警惕数据一致性问题,确保从模拟环境到真实环境的平滑过渡。

【翻译】用生成器实现可续充队列

2026年2月17日 17:36

原文链接:macarthur.me/posts/queue…

生成器执行完毕后便无法 “复活”,但借助 Promise,我们能打造出一个可续充的版本。接下来就动手试试吧。

作者:Alex MacArthur

自从深入研究并分享过生成器的相关内容后,JavaScript 生成器就成了我的 “万能工具”—— 只要有机会,我总会想方设法用上它。通常我会用它来分批处理有限的数据集,比如,遍历一系列闰年并执行相关操作:

function* generateYears(start = 1900) {
  const currentYear = new Date().getFullYear();
  
  for (let year = start + 1; year <= currentYear; year++) {
    if (isLeapYear(year)) {
      yield year;
    }
  }
}

for (const year of generateYears()) {
  console.log('下一个闰年是:', year);
}

又或者惰性处理一批文件:

const csvFiles = ["file1.csv", "file2.csv", "file3.csv"];

function *processFiles(files) {
  for (const file of files) {
    // 加载并处理文件
    yield `处理结果:${file}`;
  }
}

for(const result of processFiles(csvFiles)) {
  console.log(result);
}

这两个示例中,数据都会被一次性遍历完毕,且无法再补充新数据。for 循环执行结束后,迭代器返回的最后一个结果中会包含done: true,一切就此终止。

这种行为本就符合生成器的设计初衷 —— 它从一开始就不是为了执行完毕后能 “复活” 而设计的,其执行过程是一条单行道。但我至少有一次迫切希望它能支持续充,就在最近为 PicPerf 开发文件上传工具时。我当时铁了心要让生成器来实现一个可续充的先进先出(FIFO)队列,一番摸索后,最终的实现效果让我很满意。

可续充队列的设计思路

先明确一下,我所说的 “可续充” 具体是什么意思。生成器无法重启,但我们可以在队列数据耗尽时让它保持等待状态,而非直接终止,Promise 恰好能完美实现这个需求!

我们先从一个基础示例开始:实现一个队列,每隔 500 毫秒逐个处理队列中的圆点元素。

<html>
  <ul id="queue">
    <li class="item"></li>
    <li class="item"></li>
    <li class="item"></li>
  </ul>

  已处理总数:<span id="totalProcessed">0</span>
</html>

<script>
  async function* go() {
    // 初始化队列,包含页面中的初始元素
    const queue = Array.from(document.querySelectorAll("#queue .item"));

    for (const item of queue) {
      yield item;
    }
  }

  // 遍历队列,逐个处理并移除元素
  for await (const value of go()) {
    await new Promise((res) => setTimeout(res, 500));
    value.remove();

    totalProcessed.textContent = Number(totalProcessed.textContent) + 1;
  }
</script>

这就是一个典型的 “单行道” 队列:

如果我们加一个按钮,用于向队列添加新元素,若在生成器执行完毕后点击按钮,页面不会有任何反应 —— 因为生成器已经 “失效” 了。所以,我们需要对代码做一些重构。

实现队列的可续充功能

首先,我们用while(true)让循环无限执行,不再依赖队列初始的固定数据。

async function* go() {
  const queue = Array.from(document.querySelectorAll("#queue .item"));

  while (true) {
    if (!queue.length) {
      return;
    }

    yield queue.shift();
  }
}

现在只剩一个问题:代码中的return语句会让生成器在队列为空时直接终止。我们将其替换为一个 Promise,让循环在无数据可处理时暂停,直到有新数据加入:

let resolve = () => {};
const queue = Array.from(document.querySelectorAll('#queue .item'));
const queueElement = document.querySelector('#queue');
const addToQueueButton = document.querySelector('#addToQueueButton');

async function* go() {  
  while (true) {
    // 创建Promise,并为本次生成器迭代绑定resolve方法
    const promise = new Promise((res) => (resolve = res));

    // 队列为空时,等待Promise解析
    if (!queue.length) await promise;

    yield queue.shift();
  }
}

addToQueueButton.addEventListener("click", () => {
  const newElement = document.createElement("li");
  newElement.classList.add("item");
  queueElement.appendChild(newElement);

  // 添加新元素,唤醒队列
  queue.push(newElement);
  resolve();
});

// 后续处理代码不变
for await (const value of go()) {
  await new Promise((res) => setTimeout(res, 500));
  value.remove();
  totalProcessed.textContent = Number(totalProcessed.textContent) + 1;
}

这次的实现中,生成器的每次迭代都会创建一个新的 Promise。当队列为空时,代码会await这个 Promise 解析,而解析的时机就是我们点击按钮、向队列添加新元素的时刻。

最后,我们对代码做一层封装,打造一个更优雅的 API:

function buildQueue<T>(queue: T[] = []) {
  let resolve: VoidFunction = () => {};

  async function* go() {
    while (true) {
      const promise = new Promise((res) => (resolve = res));

      if (!queue.length) await promise;

      yield queue.shift();
    }
  }

  function push(items: T[]) {
    queue.push(...items);
    resolve();
  }

  return {
    go,
    push,
  };
}

这里补充一个小技巧:你并非一定要将队列中的元素逐个移除。如果希望保留所有元素,只需通过一个索引指针来遍历队列即可:

async function* go() {
  let currentIndex = 0;

  while (true) {
    const promise = new Promise((res) => (resolve = res));

    // 索引指向的位置无数据时,等待新数据
    if (!queue[currentIndex]) await promise;

    yield queue[currentIndex];
    currentIndex++;
  }
}

大功告成!接下来,我们将这个实现落地到实际开发场景中。

在 React 中落地可续充队列

正如前文所说,PicPerf 是一个图片优化、托管和缓存平台,支持用户上传多张图片进行处理。其界面采用了一个常见的交互模式:用户拖拽图片到指定区域,图片会按顺序逐步完成上传。 这正是可续充先进先出队列的适用场景:即便 “待上传” 的图片全部处理完毕,用户依然可以拖拽新的图片进来,上传流程会自动继续,队列会直接从新添加的文件开始处理。

React 中的基础实现方案

首先,我们尝试纯 React 的实现思路,充分利用 React 的状态与渲染生命周期,核心依赖两个状态:

  • files: UploadedFile[]:存储所有拖拽到界面的文件,每个文件自身维护一个状态:pending(待上传)、uploading(上传中)、completed(已完成)。
  • isUploading: boolean:标记当前是否正在上传文件,作为一个 “锁”,防止在已有上传任务执行时,启动新的上传循环。

这个组件的核心逻辑是监听files状态的变化,一旦有新文件加入,useEffect钩子就会触发上传流程;当一个文件上传完成后,将isUploading置为false,又会触发另一次useEffect执行,进而处理队列中的下一张图片。

以下是简化后的核心代码:

import { processUpload } from './wherever';

export default function MediaUpload() {
  const [files, setFiles] = useState([]);
  const [isUploading, setIsUploading] = useState(false);

  const updateFileStatus = useEffectEvent((id, status) => {
    setFiles((prev) =>
      prev.map((file) => (file.id === id ? { ...file, status } : file))
    );
  });

  useEffect(() => {
    // 已有上传任务执行时,直接返回
    if (isUploading) return;

    // 找到队列中第一个待上传的文件
    const nextPending = files.find((f) => f.status === 'pending');

    // 无待上传文件时,直接返回
    if (!nextPending) return;

    // 加锁,标记为上传中
    setIsUploading(true);
    updateFileStatus(nextPending.id, 'uploading');

    // 执行上传,完成后解锁并更新状态
    processUpload(nextPending).then(() => {
      updateFileStatus(nextPending.id, 'complete');
      setIsUploading(false);
    });
  }, [files, isUploading]);

  return <UploadComponent files={files} setFiles={setFiles} />;
}

在有文件正在上传时,用户依然可以添加新文件,新文件会被追加到队列末尾,等待后续逐个处理: 从 React 组件的设计角度来看,这种方案并非不可行,监听状态变化并做出相应响应也是很常见的实现方式。

但说实话,很难有人会觉得这个思路直观易懂。useEffect钩子的设计初衷是让组件与外部系统保持同步,而在这里,它却被用作了事件驱动的状态机调度工具,成了组件的核心行为逻辑,这显然偏离了其设计本意。

所以,我们不妨换掉这些useEffect钩子,用生成器实现的可续充队列来重构这个组件。

结合外部状态仓库实现

我们不再让 React 完全托管所有文件及其状态,而是将这些数据抽离到外部,从其他地方触发组件的重新渲染。这样一来,组件会变得更 “纯”,只专注于其核心职责 —— 渲染界面。

React 恰好提供了一个适配该场景的工具useSyncExternalStore。这个钩子能让组件监听外部管理的数据变化,组件的 “React 特性” 会适当让步,等待外部的指令,而非全权掌控所有状态。在本次实现中,这个 “外部状态仓库” 就是一个独立的模块,专门负责文件的处理逻辑。

useSyncExternalStore至少需要两个方法:一个用于监听数据变化(让 React 知道何时需要重新渲染组件),另一个用于返回数据的最新快照。以下是仓库的基础骨架:

// store.ts

let listeners: Function[] = [];
let files: UploadableFile[] = [];

// 必须返回一个取消监听的方法(供React内部使用)
export function subscribe(listener: Function) {
  listeners.push(listener);

  return () => {
    listeners = listeners.filter((l) => l !== listener);
  };
}

// 返回数据最新快照
export function getSnapshot() {
  return files;
}

接下来,我们补充实现所需的其他方法:

  • updateStatus():更新文件状态(待上传、上传中、已完成);
  • add():向队列中添加新文件;
  • process():启动并执行文件上传队列;
  • emitChange():通知 React 的监听器数据发生变化,触发组件重新渲染。

最终,状态仓库的完整代码如下:

// store.ts

import { buildQueue, processUpload } from './whatever';

let listeners: Function[] = [];
let files: any[] = [];
// 初始化可续充队列
const queue = buildQueue();

// 通知监听器,触发组件重渲染
function emitChange() {
  // 外部仓库的一个关键要点:数据变化时,必须返回新的引用
  files = [...queue.queue];

  for (let listener of listeners) {
    listener();
  }
}

// 更新文件状态
function updateStatus(file: any, status: string) {
  file.status = status;
  emitChange();
}

// 公共方法
export function getSnapshot() {
  return files;
}

export function subscribe(listener: Function) {
  listeners.push(listener);

  return () => {
    listeners = listeners.filter((l) => l !== listener);
  };
}

// 向队列添加新文件
export function add(newFiles: any[]) {
  queue.push(newFiles);
  emitChange();
}

// 执行文件上传流程
export async function process() {
  for await (const file of queue.go()) {
    updateStatus(file, 'uploading');
    await processUpload(file);
    updateStatus(file, 'complete');
  }
}

此时,我们的 React 组件会变得异常简洁:

import { 
  add,
  process, 
  subscribe,
  getSnapshot
} from './store';

export default function MediaUpload() {
  // 监听外部仓库的数据变化
  const files = useSyncExternalStore(subscribe, getSnapshot);

  // 组件挂载时启动上传队列
  useEffect(() => {
    process();
  }, []);

  // 将文件数据和添加方法传递给子组件
  return <UploadComponent files={files} setFiles={add} />;
}

现在只剩一个细节需要完善:合理的清理逻辑。当组件卸载时,我们不希望还有未完成的上传任务在后台执行。因此,我们为仓库添加一个abort方法,强制终止生成器,并在组件的useEffect中执行清理:

// store.ts

// 其他代码不变

let iterator = null;

export async function process() {
  // 保存生成器迭代器的引用
  iterator = queue.go();

  for await (const file of iterator) {
    updateStatus(file, 'uploading');
    await processUpload(file);
    updateStatus(file, 'complete');
  }

  iterator = null;
}

// 强制终止生成器
export function abort() {
  return iterator?.return();
}
function MediaUpload() {
  const files = useSyncExternalStore(subscribe, getSnapshot);

  useEffect(() => {
    process();
    // 组件卸载时执行清理,终止上传队列
    return () => abort();
  }, []);

  return <UploadComponent files={files} setFiles={add} />;
}

需要说明的是,为了简化代码,这里做了一些大胆的假设:上传过程永远不会失败、process方法同一时间只会被调用一次、该仓库只有一个使用者。请忽略这些细节以及其他可能的疏漏,重点来看这种实现方案带来的诸多优势:

  1. 组件的行为不再依赖useEffect的反复触发,逻辑更清晰;
  2. 所有文件上传的业务逻辑都被抽离到独立模块中,与 React 解耦,可单独维护和复用;
  3. 终于有机会实际使用useSyncExternalStore这个 React 钩子;
  4. 我们成功在 React 中用异步生成器实现了一个可续充队列。

对有些人来说,这种方案可能比最初的纯 React 方案复杂得多,我完全理解这种感受。但不妨换个角度想:现在把代码写得复杂一点,就能多拖延一点时间,避免 AI 工具完全取代我们的工作、毁掉我们的职业未来,甚至 “收割” 我们的价值。带着这个目标去写代码吧!

当然,说句正经的:要让 AI 辅助开发持续发挥价值,需要人类帮助 AI 理解底层技术原语的设计目的、取舍原则和发展前景。掌握这些底层知识,永远有其不可替代的价值。

Chmod Cheatsheet

Basic Syntax

Use these core command forms for chmod.

Command Description
chmod MODE FILE General chmod syntax
chmod 644 file.txt Set numeric permissions
chmod u+x script.sh Add execute for owner
chmod g-w file.txt Remove write for group
chmod o=r file.txt Set others to read-only

Numeric Modes

Common numeric permission combinations.

Mode Meaning
600 Owner read/write
644 Owner read/write, group+others read
640 Owner read/write, group read
700 Owner full access only
755 Owner full access, group+others read/execute
775 Owner+group full access, others read/execute
444 Read-only for everyone

Symbolic Modes

Change specific permissions without replacing all bits.

Command Description
chmod u+x file Add execute for owner
chmod g-w file Remove write for group
chmod o-rwx file Remove all permissions for others
chmod ug+rw file Add read/write for owner and group
chmod a+r file Add read for all users
chmod a-x file Remove execute for all users

Files and Directories

Typical permission patterns for files and directories.

Command Description
chmod 644 file.txt Standard file permissions
chmod 755 dir/ Standard executable directory permissions
chmod u=rw,go=r file.txt Symbolic equivalent of 644
chmod u=rwx,go=rx dir/ Symbolic equivalent of 755
chmod +x script.sh Make script executable

Recursive Changes

Apply permission updates to directory trees.

Command Description
chmod -R 755 project/ Recursively set mode for all entries
chmod -R u+rwX project/ Add read/write and smart execute recursively
find project -type f -exec chmod 644 {} + Set files to 644
find project -type d -exec chmod 755 {} + Set directories to 755
chmod -R g-w shared/ Remove group write recursively

Special Bits

Setuid, setgid, and sticky bit examples.

Command Description
chmod 4755 /usr/local/bin/tool Setuid on executable
chmod 2755 /srv/shared Setgid on directory
chmod 1777 /tmp/mytmp Sticky bit on world-writable directory
chmod u+s file Add setuid (symbolic)
chmod g+s dir Add setgid (symbolic)
chmod +t dir Add sticky bit (symbolic)

Safe Patterns

Use these patterns to avoid unsafe permission changes.

Command Description
chmod 600 ~/.ssh/id_ed25519 Secure SSH private key
chmod 700 ~/.ssh Secure SSH directory
chmod 644 ~/.ssh/id_ed25519.pub Public key permissions
chmod 750 /var/www/app Limit web root access
chmod 755 script.sh Safer than 777 for scripts

Common Errors

Quick checks when permission changes do not work.

Issue Check
Operation not permitted Check file ownership with ls -l and apply with the correct user or sudo
Permission still denied after chmod Parent directory may block access; check directory execute (x) bit
Cannot chmod symlink target as expected chmod applies to target file, not link metadata
Recursive mode broke app files Reset with separate file/dir modes using find ... -type f/-type d
Changes revert on mounted share Filesystem mount options/ACL may override mode bits

Related Guides

Use these guides for full permission and ownership workflows.

Guide Description
How to Change File Permissions in Linux (chmod command) Full chmod guide with examples
Chmod Recursive: Change File Permissions Recursively in Linux Recursive permission strategies
What Does chmod 777 Mean Security impact of 777
Chown Command in Linux (File Ownership) Change file and directory ownership
Umask Command in Linux Default permissions for new files
Understanding Linux File Permissions Permission model explained
❌
❌