普通视图

发现新文章,点击刷新页面。
今天 — 2026年4月27日掘金 前端

Hello 算法:“走一步看一步”的智慧

作者 灵感__idea
2026年4月27日 00:55

每个系列一本前端好书,帮你轻松学重点。

本系列来自上海交通大学硕士,华为高级算法工程师 靳宇栋《Hello,算法》

“走一步看一步”,是我们面对不断变化的世界所采取的应对策略。

多数时候,我们无法对未来做出准确预测,只能根据上一件事的结果对下一件事做决策。介绍“分治”的时候,我们已经接触过这种策略。本篇主角依然如此,但又有所不同。

先看个例子。

爬楼梯

给一个 n 阶楼梯,每步可以上 1 阶或者 2 阶,问有多少种方案可以爬到楼顶?

假设 n 是3,那么方案共 3 种。如下图所示。

微信图片_2026-04-27_004502_056.jpg

这种方案采用的是“穷举”,每轮选择上 1 阶或 2 阶,每当到达顶部时就将方案数量加 1,当越过顶部时就将其剪枝。

这是一种将问题看做一系列决策步骤的方案,我们还可以尝试从问题分解的角度分析。

暴力搜索

假设,爬到第 i 阶有 dp(i)种方案,那么dp(i)就是原问题,子问题包括:

www.hello-algo.com_chapter_dynamic_programming_intro_to_dynamic_programming_.png

由于每轮只能上 1 阶或 2 阶,因此,我们只能从第 i-1阶或第 i-2 阶迈向第 i 阶。

可得出一个重要推论:爬到第 i -2 阶的方案数加上爬到第 i-1 阶的方案数就等于爬到第 i 阶的方案数。

www.hello-algo.com_chapter_dynamic_programming_intro_to_dynamic_programming_ (1).png

这意味着在爬楼梯问题中,各个子问题之间存在递推关系,原问题的解可以由子问题的解构建得来。如下图所示:

微信图片_2026-04-27_004511_440.jpg

代码实现:

/* 搜索 */
function dfs(i) {
    // 已知 dp[1] 和 dp[2] ,返回之
    if (i === 1 || i === 2return i;
    // dp[i] = dp[i-1] + dp[i-2]
    const count = dfs(i - 1) + dfs(i - 2);
    return count;
}

/* 爬楼梯:搜索 */
function climbingStairsDFS(n) {
    return dfs(n);
}

看到一个熟悉的身影—“递归”,暴力搜索会形成一个递归树,对于问题 dp[n],其递归树的深度为 n,时间复杂度为O(2n)。

情况不太妙,因为指数阶具备爆炸式增长的特点,如果输入一个比较大的 n ,会陷入漫长的等待。

时间复杂度为何如此之高?我们观察一下递归树。

微信图片_2026-04-27_004517_283.jpg

聪明的你一眼就看出,这棵树出现了多个相同的“子问题”,大部分计算资源都浪费在这些重叠的子问题上。

那么,优化的措施就有了。

记忆化搜索

为提升效率,需要做的改进是:重叠子问题只计算一次

为此,我们声明一个数组 mem 来记录子问题的解,并在搜索过程中将重叠子问题剪枝。

  1. 首次计算 dp[i] 时,将其记录至 mem[i] ,以便之后使用。
  2. 再次需要计算 dp[i] 时,直接从 mem[i] 中获取结果,避免重复计算。

代码实现:

/* 记忆化搜索 */
function dfs(i, mem) {
    // 已知 dp[1] 和 dp[2] ,返回之
    if (i === 1 || i === 2return i;
    // 若存在记录 dp[i] ,则直接返回之
    if (mem[i] != -1return mem[i];
    // dp[i] = dp[i-1] + dp[i-2]
    const count = dfs(i - 1, mem) + dfs(i - 2, mem);
    // 记录 dp[i]
    mem[i] = count;
    return count;
}

/* 爬楼梯:记忆化搜索 */
function climbingStairsDFSMem(n) {
    // mem[i] 记录爬到第 i 阶的方案总数,-1 代表无记录
    const mem = new Array(n + 1).fill(-1);
    return dfs(n, mem);
}

简化后,所有重复子问题都只计算1次,时间复杂度为O(n),这是一个巨大的飞跃。

那么,记忆化搜索是否还存在短板,有继续优化的可能吗?

细心的朋友发现了,记忆化搜索“基于递归”的,这意味着:

1、每个子问题都需要一次函数调用,函数调用需要时间成本;

2、当数据量很大时,可能产生调用栈溢出;

怎么办?

动态规划

记忆化搜索流程是“始于原问题,分解成小问题”,通过回溯收集子问题的解,构建原问题的解。可称为 “从顶至底”。

与之相反,动态规划是一种 “从底至顶” 的方法,“从最小子问题的解开始,迭代地构建更大子问题的解”,直至得到原问题的解。

由于动态规划不包含“回溯”过程,就无须使用递归,只需循环迭代。

代码实现:

/* 爬楼梯:动态规划 */
function climbingStairsDP(n) {
    if (n === 1 || n === 2return n;
    // 初始化 dp 表,用于存储子问题的解
    const dp = new Array(n + 1).fill(-1);
    // 初始状态:预设最小子问题的解
    dp[1] = 1;
    dp[2] = 2;
    // 状态转移:从较小子问题逐步求解较大子问题
    for (let i = 3; i <= n; i++) {
        dp[i] = dp[i - 1] + dp[i - 2];
    }
    return dp[n];
}

空间优化

如果继续“吹毛求疵”,会发现,我们要求解的是dp[i],而dp[i] 只与 dp[i-1] 和 dp[i-2] 有关,上面代码返回的却是dp[n]?

完全没必要,所以它还有改进空间,便是去除数组,采用两个变量滚动前进。

/* 爬楼梯:空间优化后的动态规划 */
function climbingStairsDPComp(n) {
    if (n === 1 || n === 2return n;
    let a = 1,
        b = 2;
    for (let i = 3; i <= n; i++) {
        const tmp = b;
        b = a + b;
        a = tmp;
    }
    return b;
}

由于省去了数组占用的空间,空间复杂度从 O(n) 降至 O(1) ,再次大幅优化。

我们可以触类旁通地认为,在动态规划问题中,当前状态往往仅与前面有限个状态有关,可以只保留必要的状态,通过“降维”来节省内存空间。

这种空间优化技巧被称为 “滚动变量”“滚动数组”

子问题玄机

本篇文章,我们再次提到“子问题”,“子问题”分解是一种通用的算法思路,之前你肯定就见过,那么,它们之间的区别是什么?

  • 分治算法:强调子问题相互独立。
  • 动态规划:子问题是相互依赖的,在分解过程中会出现许多重叠子问题。
  • 回溯算法:原问题的解由一系列决策步骤构成,每个决策步骤之前的子序列可看作一个子问题。

识别动态规划

如何判断一个问题是不是动态规划?

总的来说,如果问题包含明确的决策概念,并且解是通过一系列决策产生的,那么它就满足决策树模型。

在此基础上,还有一些判断的“加分项”。

  • 问题包含最大(小)或最多(少)等最优化描述。
  • 问题的状态能够使用一个列表、多维矩阵或树来表示,并且一个状态与其周围的状态存在递推关系。

小结

本篇文章系统讲解了动态规划的特点和核心实现,除了上面介绍的“爬楼梯”,还有什么常见适用场景吗?

0-1 背包:求解“在限定背包容量下能放入物品的最大价值”,满足决策树模型,含有“最大”关键词。

编辑距离:求解两个字符串之间互相转换的最少修改次数,通常用于在信息检索和自然语言处理中度量两个序列的相似度。当下最火的大语言模型中,就广泛存在此类应用。

动态规划还有很多应用场景,且不易掌握和解决,需要大家正确理解和多练习才行,一起加油!~

更多好文章第一时间推送,可关注公众号:前端说书匠

我决定写一个 3D 地球仪来记录下我要去的地方

作者 Mh
2026年4月26日 23:00

我要去南极。 那里是地球最后的留白。去看那万年不化的冰川在阳光下透着幽幽的深蓝色。

我要去北极。 站在世界的顶点,等一场美到窒息的极光。

我要去非洲萨瓦那。 那是离赤道最近的金色草原。听万千角马奔腾而过的蹄声,感受那种野性而滚烫的生命力,在耳边呼啸而过。

我要去南美亚马逊。 钻进那片被称为“地球肺叶”的雨林,听雨水噼里啪啦地敲在宽大的树叶上。

我要去热带海岛。 去看海水从浅浅的薄荷绿慢慢变成深邃的宝石蓝。

我要去崖边海岸。 去海边的悬崖。看守护在悬崖尽头的灯塔。

我要去欧洲古镇。 踩在湿漉漉的石板路上,听风铃在街角清脆地响。

我要去赛博都市。 去感受雨后的街道的霓虹倒影,高耸入云的大楼在水雾里若隐若现。

image.png

虽然作为社会主义的接班人,至今没有走出过国门。但是没有关系,我还另一个身份。作为一名程序员,我只需要动动手指就可以在地球上看到它们。

话不多说,说干就干!!

globe.gl 的介绍

这次我决定用 globe.gl 去实现,至于啥是 globe.gl 呢?

简单来说,它是一个基于 Three.js 封装的开源 JavaScript 组件,专门用来进行 地球空间数据的可视化。它的强大之处在于:你不需要写复杂的 WebGL 底层代码,就可以做出一个 3D 交互式地球。

为什么选择它呢?

因为它足够简单,下面是实用资源。

快速开始

先看效果图

202604116223817.gif

代码预览

<template>
  <section class="floor3-container floor-container" ref="containerRef">
    <div class="sticky">
      <div id="chart__container" ref="chartRef"></div>
      <div class="text" ref="textRef">
        <p class="title">Earth: A Never-Ending Dream</p>
        <p class="desc">The flickers on this map are more than just distant auroras and waves; they are our deepest gaze upon this planet. From the golden savannas to the neon-lit streets after rain, stories are unfolding quietly in every corner of the world.</p>
      </div>
    </div>
  </section>
</template>

<script setup>
import { onMounted, ref, onBeforeUnmount } from 'vue';
import Globe from 'globe.gl';
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
import earthImg from '@/images/earth-night.jpg';
import skyImg from '@/images/night-sky.png';


gsap.registerPlugin(ScrollTrigger);
const containerRef = ref(null);
const chartRef = ref(null);
const textRef = ref(null);
const highlightIndex = ref(-1);
let world = null;

const initData = [
  { name: "Antarctica", value: [0, -82.8628, 0], zIndex: 0 },
  { name: "The Arctic", value: [0, 90, 0], zIndex: 1 },
  { name: "Savanna", value: [34.8888, -2.3333, 0], zIndex: 2 },
  { name: "Amazon", value: [-62.2159, -3.4653, 0], zIndex: 3 },
  { name: "Maldives", value: [73.2207, 3.2028, 0], zIndex: 4 },
  { name: "Cliffs of Moher", value: [-9.4309, 52.9719, 0], zIndex: 5 },
  { name: "Prague", value: [14.4378, 50.0755, 0], zIndex: 6 },
  { name: "Tokyo", value: [139.6503, 35.6762, 0], zIndex: 7 }
];

onMounted(() => {
  const width = window.innerWidth;
  const height = window.innerHeight

  // 初始化地球
  world = Globe()(chartRef.value)
    .width(width) // 设置地球画布的宽度
    .height(height)
    .globeImageUrl(earthImg) // 设置地球表面的贴图
    .backgroundImageUrl(skyImg)  // 设置背景图
    .atmosphereColor("#ffb4ff") // 设置地球周围“大气层”的光晕颜色
    .atmosphereAltitude(0.06) // 设置大气层的厚度
    .pointOfView({
      lat: 36.818188, // 设置相机初始化时正对着的经纬度
      lng: 12.227512,
      altitude: 2.5, // 相机距离地表的高度
    })
    .labelsData(initData)  // 注入数据源
    .labelLat(d => d.value[1]) // 数据里的纬度在 value 数组的第 2 个位置
    .labelLng(d => d.value[0])
    .labelText(d => d.name) // 显示在地球上的文字内容
    .labelSize(d => initData.indexOf(d) === highlightIndex.value ? 2.5 : 1.6) // 文字的大小
    .labelColor(() => "rgba(255, 165, 0, 0.75)") // 文字的颜色
    .labelDotRadius(d => {
      return initData.indexOf(d) === highlightIndex.value ? 1.2 : 0.5;
    })
    .enablePointerInteraction(true); // 开启鼠标交互

  const controls = world.controls(); // 交互控制器
  controls.enableZoom = false; // 禁用缩放
  controls.enablePan = false; // 禁用平移
  controls.autoRotate = true; // 开启自动旋转
  controls.autoRotateSpeed = -1; // 设置旋转速度和方向 负值代表是逆时针

  // 自动高亮循环 
  const interval = setInterval(() => {
    window.requestIdleCallback(() => {
      highlightIndex.value = (highlightIndex.value + 1) % initData.length;
      world.labelsData([...initData]);
    });
  }, 2000);

  // GSAP 滚动动画逻辑
  gsap.fromTo(textRef.value,
    { y: 100, opacity: 0, zIndex: -1 },
    {
      y: 0, opacity: 0.8, duration: 1, zIndex: 1,
      scrollTrigger: {
        trigger: containerRef.value,
        start: "top top",
        end: "+=50%",
        scrub: 1,
        markers: false
      }
    }
  );

  // 监听窗口变化
  const handleResize = () => {
    world.width(window.innerWidth);
    world.height(window.innerHeight);
  };
  window.addEventListener("resize", handleResize);

  // 清理函数
  onBeforeUnmount(() => {
    if (interval) clearInterval(interval);
    window.removeEventListener("resize", handleResize);
    if (world) world._destructor?.(); // 销毁地球实例防止内存泄漏
  });
});
</script>

<style scoped>
#chart__container {
  width: 100%;
  height: 100%;
  overflow: hidden;
}

.sticky {
  position: sticky;
  top: 0;
  left: 0;
  width: 100%;
  height: 100vh;
}

.text {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  text-align: center;
  width: 162rem;
}

.title {
  font-size: 6rem;
}

.desc {
  font-size: 3rem;
  margin-top: 3rem;
}

.floor-container {
  width: 100%;
  height: 200vh;
  position: relative;
  color: #fff;
  background-color: #000;
}
</style>

代码分析

需要用到的工具:

  1. Globe.gl: 基于 Three.js 的封装,将复杂的 WebGL 地球渲染简化为数据驱动的API组合。
  2. GSAP & ScrollTrigger: 写动效的神器,这里主要负责处理文案随页面滚动的平滑视觉过渡。

核心代码分析:

  1. 这里选择 Globe.gl 的原因是其拥有强大的数据映射能力, 可以轻松的将地理坐标 (GPS) 轻松转换为 3D 空间坐标
  .labelsData(initData)
  .labelLat(d => d.value[1])
  .labelLng(d => d.value[0])
  1. 这里利用 setInterval 配合 requestIdleCallback,动态调整标签大小 (labelSize),增加“呼吸感”。
  const interval = setInterval(() => {
    window.requestIdleCallback(() => {
      highlightIndex.value = (highlightIndex.value + 1) % initData.length;
      world.labelsData([...initData]);
    });
  }, 2000);
  1. 这里引入 gsap 动画滚动控制文字浮现,增加整体趣味。
  gsap.fromTo(textRef.value,
    { y: 100, opacity: 0, zIndex: -1 },
    {
      y: 0, opacity: 0.8, duration: 1, zIndex: 1,
      scrollTrigger: {
        trigger: containerRef.value,
        start: "top top",
        end: "+=50%",
        scrub: 1,
        markers: false
      }
    }
  );
  1. 销毁定时器(interval)、解绑全局事件(resize)、销毁地球实例(world),以上这些做法都是防止内存泄露。
  onBeforeUnmount(() => {
    if (interval) clearInterval(interval);
    window.removeEventListener("resize", handleResize);
    if (world) world._destructor?.(); 
  });

写在最后

以上就是使用 globe.gl 创建 3D 交互式动画的全部内容了,其实相对比较简单,大多数都是 API 的配置,后期如果有时间,研究一下出个2.0版本,可以在坐标的位置添加对应的图片,点击图片放大。或者增加国家地区的选择,可以让用户自定义选择国家地区,增加功能交互。

最后多说一句,人生是旷野不是轨道。如果有机会希望我们都能去世界看看,如果你喜欢我的分享,不要忘记 “一键三连” 哈!!!

break跳出语句块的神奇技巧

作者 柚子816
2026年4月26日 21:05

大多数开发者都知道break语句可以用来中断循环执行,甚至配合label跳出外层循环。但你知道吗?break最强大的功能其实是能够跳出任意语句块!本文将重点介绍这个被很多人忽略的高级用法。

基础用法回顾

break语句的基础用法主要包括两种:

1. 跳出单层循环

// 跳出单层循环
for (let i = 0; i < 10; i++) {
  if (i === 5) break;
  console.log(i);
}

2. 配合label跳出外层循环

当存在嵌套循环时,普通的break只能跳出当前层级的循环。如果我们想直接跳出外层循环,就需要使用label。

outerLoop: for (let i = 0; i < 3; i++) {
  for (let j = 0; j < 3; j++) {
    if (i === 1 && j === 1) {
      console.log('找到目标');
      break outerLoop;
    }
  }
}

高级用法:配合label跳出语句块

这是很多开发者不知道的用法:break可以配合label跳出任意语句块,不仅仅是循环。这才是break语句真正的高级用法!

示例:复杂的条件判断

function processData(data) {
  processBlock: {
    if (!data) {
      console.log('数据为空');
      break processBlock;
    }
    
    if (data.length === 0) {
      console.log('数据长度为0');
      break processBlock;
    }
    
    console.log('开始处理数据...');
  }
}

示例:API请求处理和错误管理

function processApiRequest(requestData) {
  apiProcess: {
    if (!requestData?.url) {
      console.log('请求数据无效');
      break apiProcess;
    }
    
    if (!navigator.onLine) {
      console.log('网络连接异常');
      break apiProcess;
    }
    
    console.log('发送API请求');
  }
}

使用建议

命名规范

  • 使用有意义的label名称,如validationprocessBlock
  • 避免使用过于简单的名称如block1loop2

适度使用

  • 不要过度使用,保持代码逻辑清晰
  • 在复杂条件判断中使用效果最佳

总结

break语句配合label跳出语句块的功能,其实可以类比编码规范中的early return模式:

  • early return:在函数中遇到错误条件时return,提前结束整个函数
  • break label:在语句块中遇到错误条件时break,提前结束整个语句块

两者都是为了减少嵌套层级,提高代码可读性,但作用范围不同:

  • return 作用于整个函数
  • break label 作用于标记的语句块

核心价值

  1. 代码更清晰:替代复杂的条件嵌套
  2. 逻辑更直观:一次性退出多个检查点
  3. 统一管理:错误处理和资源清理更一致

记住这个技巧:当你遇到多层嵌套的条件判断时,不妨试试break label,它就像语句块版本的early return!

在职前端 Agent 配置分享

作者 菠萝的蜜
2026年4月26日 20:59

前言

去年花了半年时间对公司旧业务代码做了不少架构优化,今年开始陆续就要开始业务开发了。

不得不说在 AI 时代背景下,开发范式每天都在变化,prompt engineering -> context engineering -> agent engineering -> harness engineering,一路狂飙,看似每天都有新东西要学习,到最后大多都是 FOMO。

然而在显而易见的不确定性面前,总有一些东西是固定不变的。今天我来分享在 AI 冲击下我的前端 Agent 开发配置,这些内容个人认为属于长期不变的地基。

(本文以 Mac 为例)

基本工具

首先是两个配置工具:

  1. cc-switch
  2. skills.sh

前者用于接入不同 AI 供应商,例如业内熟知的 Claude、Codex、Gemini、OpenCode 等等;后者用来添加 skills,一些固定的工作流被总结为技能供模型识别和调用。

CC Switch

安装

以 Homebrew(macOS)为例:

brew tap farion1231/ccswitch
brew install --cask cc-switch

# 更新
brew upgrade --cask cc-switch

其他平台也可以在 Release 找到对应的安装包。

image.png

更新

APP 的关于页可以检查更新、同时还兼具了本地环境检查:

image.png

我觉得特别好的一点就是还提供了一键安装的脚本:

image.png

以往我都是要去官方文档上找,这里一键复制更方便。

设置 Skills 存储位置

打开「设置 > 通用」面板,修改如下:

image.png

默认情况下,Skills 被存储在 ~/.cc-switch/skills/ 下,换成 ~/.agents/skills/,因为 skills.sh 的脚本安装的 skills 默认也是后者,这遵循了 Agent Skills 开放标准,很多 Agent 都能主动发现此处的 skills。

设置自动故障转移

打开「设置 > 路由」面板,依次打开本地路由、自动故障转移的开关:

image.png

image.png

回到 APP 首页打开两个开关:

image.png

下面可以选择需要加入的服务,按照优先级每次使用 cc 时会先用高优先级的,出现熔断就会退到下一级:

image.png

开启用量查询

以 kimi code 为例,点击列表中某项的用量查询:

image.png

在预设模板中找到合适的配置:

image.png

回到首页可以看到能够自动查询用量了:

image.png

点击顶部图标也能看到用量:

image.png

Skills 和 MCP

Skills 和 MCP 在右上角:

image.png

Skills

Skills 管理面板中,比较常用的是「导入已有」,然后点亮需要加入该 skill 的 Agent 工具:

image.png

点击「发现技能」可以搜索开头我们提到的 skills.sh 中的技能,下面以 code-simplifier 为例:

image.png

点击安装就能加入 Skills 列表。

这和在 skills.sh 上获取安装指令是一样的:

image.png

需要注意的是,使用命令行安装时默认安装到 ~/.agents/skills/ 下,想要同时支持 cc,就要自己勾选:

npx skills add https://github.com/ant-design/ant-design-cli --skill antd

image.png

通用的放全局:

image.png

安装方式推荐使用 Symlink:

image.png

安装完成后,命令行输入 claude 打开 cc,看到如下界面说明 CC Switch 的配置是有效的,一定要选 Yes,否则会让登录官方的账号:

image.png

输入 /skills 可以看到 antd skill 确实被安装进来了:

image.png

gemini、codex、opencode 也有:

image.png

image.png

image.png

刚刚安装列表中的 kimi 也有:

image.png

而未主动选择的 kilo 没有该技能:

image.png

在 Skills 管理中可以导入已有技能:

image.png

注意:由于 codex、gemini、opencode 都能从 ~/.agents/skills/ 下读取到技能,即便在 CC Switch 中取消引用,还是能搜到;cc 从 ~/.claude/skills/ 下读取技能,关闭了引用就搜不到了。

image.png

总结一下,如果按照我说的修改了 Skills 存储位置,那么想要 cc 加入对应 skill 就打开对应 skill 开关,其他剩下几个 Agent 开不开都可以。

MCP

MCP 服务器管理面板没有 Skills 那么复杂,且功能类似,大家可以自己研究下。

(其实是不想写了,哈哈)

image.png

Skills、MCP 推荐

Skills、MCP 都装了不少,但是本着如无必要、勿增实体的原则,最低限度推荐以下几个:

Skills:

MCP:

昨天 — 2026年4月26日掘金 前端

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

作者 SmalBox
2026年4月26日 19:05

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

Clamp节点的数学原理

Clamp节点是ShaderGraph中基础且关键的数学运算模块,其核心算法基于线性代数中的区间映射理论。在图形学实践中,该节点通过以下数学公式确保数值稳定:

Output = (Input < Min) ? Min : (Input > Max) ? Max : Input

这种三段式条件判断机制保证输出值始终处于[Min,Max]闭区间内。从工程角度看,Clamp节点不仅有效防止数值溢出,还在以下场景中展现独特价值:

  • 物理准确性维护:在PBR材质系统中,确保金属度、粗糙度等物理参数符合现实约束
  • 艺术控制强化:为美术人员提供可视化参数安全边界,避免数值输入失误导致的视觉异常
  • 性能安全保障:防止极端数值在GPU计算中引发异常分支或计算溢出

核心功能:多维约束与动态控制

随着图形渲染需求的演进,Clamp节点已从简单数值限制发展为多维控制系统:

矢量维度智能处理

处理多维矢量时,Clamp节点支持分通道独立运算。以HSV颜色空间转换为例:

  • 对Hue分量实施环形钳制(0-1循环)
  • 对Saturation分量进行非对称限制(Min=0.3, Max=1.0)
  • 对Value分量执行动态范围压缩

时间轴集成方案

结合Time节点构建动画约束系统:

// 脉动光环效果示例 
float pulse = sin(_Time.y * 3.0) * 0.5 + 0.5; float clampedPulse = clamp(pulse, 0.2, 0.8);

此方案适用于UI动效、场景过渡等需要平滑节奏控制的场景。

参数配置:工程化实践指南

大型项目开发中,Clamp节点的配置需遵循严格工程规范:

数据类型一致性原则

  • 标量对齐:Min/Max为标量时自动广播至输入矢量所有分量
  • 维度匹配:矢量输入需确保Min/Max维度相同,避免隐式转换误差
  • 精度优化:移动端建议使用half精度,主机/PC平台可采用float精度

动态参数绑定策略

通过Blackboard实现运行时调控:

  1. 创建MaterialParameter类型的Range参数
  2. 设置合适默认值与边界条件
  3. 添加Tooltip注释说明参数用途
  4. 建立参数变更回调机制

实践案例

基础案例进阶:智能颜色管理系统

构建自适应环境光照的材质系统:

  1. 通过Light Probe获取场景光照强度
  2. 使用Clamp节点限制Albedo颜色反射率
  3. 根据平台性能动态调整钳制范围:
// 移动端使用更严格的范围 
#if defined(SHADER_API_MOBILE)
     float minReflectance = 0.1;
     float maxReflectance = 0.7;
#else
     float minReflectance = 0.05;
     float maxReflectance = 0.9;
#endif

进阶案例扩展:物理准确的天气系统

实现动态天气转换的着色器方案:

  1. 采集环境湿度、温度等物理参数
  2. 使用多层Noise模拟云层运动
  3. 通过Clamp控制降水强度与能见度范围
  4. 结合URP Volume系统实现无缝过渡

性能优化深度方案

针对不同硬件架构的优化策略:

  • TBDR架构(移动平台):利用片上内存减少钳制操作带宽
  • IMR架构(桌面平台):使用计算着色器批量处理钳制运算
  • 混合架构(游戏主机):基于Command Buffer的异步计算

常见问题与系统性解决方案

数值异常诊断体系

建立完整调试工作流:

  1. 可视化诊断:通过Custom Function节点输出中间值
  2. 范围追溯:使用Debug模式逐节点检查数值流
  3. 单元测试:创建Shader Graph测试场景验证边界条件

跨平台兼容性矩阵

平台 等效实现 注意事项
Unity URP Clamp节点 原生支持
Unreal Engine Clamp材质表达式 参数顺序差异
Godot Engine clamp()函数 需要手动编码
Three.js GLSL clamp() 语法差异

扩展应用:现代渲染管线集成

与Shader Feature深度集成

利用URP的Shader Keyword系统:

#pragma shader_feature_local _CLAMP_MODE_SOFT 
#ifdef _CLAMP_MODE_SOFT     
// 软钳制实现     
output = smoothstep(Min, Max, Input); 
#else     
// 硬钳制实现     
output = clamp(Input, Min, Max); 
#endif

实时GI系统协同

在全局光照计算中的特殊应用:

  • 限制反射探针强度避免过曝光
  • 控制光照贴图采样范围减少 artifacts
  • 管理体积雾浓度提升视觉层次感

最佳实践:企业级开发标准

代码质量管理

  1. 静态分析:使用Shader Graph linter检查节点连接合理性
  2. 性能剖析:集成Frame Debugger验证钳制操作开销
  3. 版本管理:建立Shader Graph资产变更追踪机制

团队协作规范

  • 文档标准化:每个Clamp节点必须包含设计意图说明
  • 参数审计:定期检查Blackboard参数的有效范围
  • 知识传承:建立Clamp节点使用案例库与反模式清单

持续集成流程

将Shader验证纳入CI/CD管道:

  • 自动化功能测试(边界值、异常值)
  • 性能基准测试(帧时间、内存占用)
  • 视觉回归测试(截图对比、差异分析)

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

Polyline 组件如何绘制渐变区域?

作者 光影少年
2026年4月26日 17:07

✅ 方案一:用 Polygon 替代 Polyline(最推荐)

如果你是想做“线下方渐变”(类似折线图面积图),可以:

  1. 把 Polyline 的点复制一份
  2. 补齐底部闭合路径
  3. Polygon 填充渐变

示例(以高德地图 JS API 为例)

const path = [
  [116.3, 39.9],
  [116.4, 39.8],
  [116.5, 39.85],
];

// 构造闭合区域(补到底部)
const polygonPath = [
  ...path,
  [116.5, 39.7],
  [116.3, 39.7],
];

const polygon = new AMap.Polygon({
  path: polygonPath,
  fillColor: 'rgba(0, 0, 255, 0.5)', // 基础色
  fillOpacity: 0.5,
});

👉 渐变实现:
高德原生不支持渐变填充,但你可以:

  • CanvasLayer 自绘渐变
  • 或使用 自定义覆盖物

✅ 方案二:CanvasLayer + 渐变(高级玩法)

如果你需要真正的渐变(linear-gradient),可以:

const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');

// 创建渐变
const gradient = ctx.createLinearGradient(0, 0, 0, 200);
gradient.addColorStop(0, 'rgba(0,0,255,0.8)');
gradient.addColorStop(1, 'rgba(0,0,255,0)');

ctx.fillStyle = gradient;

// 画路径(类似 polygon)
ctx.beginPath();
ctx.moveTo(...);
ctx.lineTo(...);
ctx.fill();

然后通过:

new AMap.CanvasLayer({
  canvas: canvas,
  bounds: ...
});

👉 优点:

  • 完全自定义渐变方向/颜色
  • 可做动态效果

👉 缺点:

  • 需要自己处理坐标转换(经纬度 → 像素)

✅ 方案三:ECharts(如果你是数据可视化场景)

你之前用过 ECharts,这个其实最简单:

series: [{
  type: 'line',
  data: [...],
  areaStyle: {
    color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
      { offset: 0, color: 'blue' },
      { offset: 1, color: 'transparent' }
    ])
  }
}]

👉 直接支持渐变区域,不用自己画


✅ 方案四:Polyline + 多条叠加(伪渐变)

如果不想用 Canvas,可以:

  • 画多条宽度不同、透明度不同的 Polyline
  • 模拟渐变效果
// 多层叠加
strokeOpacity: 0.3 / 0.2 / 0.1
strokeWeight: 10 / 20 / 30

👉 效果有限,但实现简单


💡 总结

你要的是“渐变区域”,关键不是 Polyline,而是:

需求 推荐方案
简单渐变区域 Polygon + 伪渐变
高质量渐变 CanvasLayer
数据图表 ECharts
快速hack 多Polyline叠加

构建无障碍组件之Spinbutton Pattern

作者 anOnion
2026年4月26日 16:58

Spinbutton Pattern 详解:构建无障碍数字输入控件

Spinbutton(旋转按钮,也称为 Number InputStepperNumeric SpinnerCounter)是一种输入控件,用于在预定义范围内选择离散数值。本文基于 W3C WAI-ARIA Spinbutton Pattern 规范,详解如何构建无障碍的数字输入组件。

一、Spinbutton 的定义与核心概念

1.1 什么是 Spinbutton

Spinbutton 是一种受限的数字输入控件,具有以下特征:

  • 值被限制在一组或一个范围内的离散值
  • 通常包含三个组件:
    • 文本输入框:显示当前值,通常是唯一可聚焦的组件
    • 增加按钮:用于增加数值
    • 减少按钮:用于减少数值
  • 支持直接编辑按钮调整两种方式
  • 支持小步长大步长调整

1.2 核心术语

术语 说明
Text Field 显示当前值的文本输入框
Increase Button 增加数值的按钮
Decrease Button 减少数值的按钮
Small Step 小步长调整(如按 1 增减)
Large Step 大步长调整(如按 10 增减)
Valid Value 允许范围内的有效值
┌─────────────────────────────────────────────────────────────┐
│                      Spinbutton Container                   │
│                                                             │
│  ┌─────────────────────────────────────────────────────┐    │
│  │                                                     │    │
│  │  ┌─────────────────┐  ┌──────┐  ┌──────┐            │    │
│  │  │                 │  │  ▲   │  │  ▼   │            │    │
│  │  │   Value: 30     │  │  +   │  │  -   │            │    │
│  │  │                 │  │      │  │      │            │    │
│  │  └─────────────────┘  └──────┘  └──────┘            │    │
│  │                                                     │    │
│  │  ┌─────────────────────────────────────────────┐    │    │
│  │  │  role="spinbutton"                          │    │    │
│  │  │  aria-valuenow="30"                         │    │    │
│  │  │  aria-valuemin="0"                          │    │    │
│  │  │  aria-valuemax="100"                        │    │    │
│  │  │  aria-label="Quantity"                      │    │    │
│  │  └─────────────────────────────────────────────┘    │    │
│  │                                                     │    │
│  └─────────────────────────────────────────────────────┘    │
│                                                             │
│  Keyboard: ↑↓ (±1) | Page Up/Down (±10) | Home/End (Min/Max)│
│                                                             │
└─────────────────────────────────────────────────────────────┘

1.3 典型应用场景

  • 数量选择器:购物车商品数量、酒店预订人数
  • 时间选择器:小时、分钟选择
  • 日期选择器:日、月、年选择
  • 数值调节:音量控制、亮度调节
  • 评分输入:1-5 星评分

二、WAI-ARIA 角色与属性

2.1 基本角色

Spinbutton 使用 role="spinbutton" 标记。

<input
  type="text"
  role="spinbutton"
  aria-label="数量"
  aria-valuenow="1"
  aria-valuemin="0"
  aria-valuemax="10"
  value="1" />

2.2 必需属性

属性 说明 示例值
role="spinbutton" 标记为旋转按钮角色 -
aria-valuenow 当前值 "1"
aria-valuemin 最小值(如果有) "0"
aria-valuemax 最大值(如果有) "10"
aria-labelaria-labelledby 可访问标签 "数量"

2.3 可选属性

属性 说明 示例值
aria-valuetext 用户友好的值描述 "Monday"
aria-invalid 值是否无效 "true" / "false"

2.4 属性详解

aria-valuetext

aria-valuenow 的值不够友好时,使用 aria-valuetext 提供更易理解的描述:

<!-- 星期选择器:数值 1 显示为 "Monday" -->
<input
  type="text"
  role="spinbutton"
  aria-label="星期"
  aria-valuenow="1"
  aria-valuemin="1"
  aria-valuemax="7"
  aria-valuetext="Monday"
  value="Monday" />
aria-invalid

当值超出允许范围时,设置 aria-invalid="true"

<input
  type="text"
  role="spinbutton"
  aria-label="数量"
  aria-valuenow="15"
  aria-valuemin="0"
  aria-valuemax="10"
  aria-invalid="true"
  value="15" />

注意:大多数实现会阻止输入无效值,但在某些场景下可能无法完全阻止。

三、键盘交互规范

3.1 基本键盘交互

按键 功能
↑ Up Arrow 增加数值(小步长)
↓ Down Arrow 减少数值(小步长)
Home 设置值为最小值
End 设置值为最大值
Page Up(可选) 增加数值(大步长)
Page Down(可选) 减少数值(大步长)

3.2 文本编辑键盘交互

如果文本框允许直接编辑,还支持以下标准单行文本编辑键:

  • 可打印字符:在文本框中输入字符
  • 光标移动键:左右箭头、Home、End
  • 选择键:Shift + 方向键
  • 文本操作键:复制、粘贴、删除等

重要提示:确保 JavaScript 不干扰浏览器提供的文本编辑功能。

3.3 焦点行为

  • 操作过程中焦点始终保持在文本框
  • 不需要将焦点移到增减按钮上

四、实现方式

4.1 基础 Spinbutton 结构

<div class="spinbutton-container">
  <label for="quantity">数量</label>
  <div class="spinbutton-wrapper">
    <input
      type="text"
      id="quantity"
      class="spinbutton"
      role="spinbutton"
      aria-label="数量"
      aria-valuenow="1"
      aria-valuemin="0"
      aria-valuemax="10"
      value="1" />
    <div class="spinbutton-buttons">
      <button
        type="button"
        class="spinbutton-up"
        aria-label="增加"
        tabindex="-1"></button>
      <button
        type="button"
        class="spinbutton-down"
        aria-label="减少"
        tabindex="-1"></button>
    </div>
  </div>
</div>

4.2 JavaScript 实现

class Spinbutton {
  constructor(element) {
    this.input = element;
    this.min = parseFloat(this.input.getAttribute('aria-valuemin')) || 0;
    this.max = parseFloat(this.input.getAttribute('aria-valuemax')) || 100;
    this.smallStep = 1;
    this.largeStep = 10;

    this.init();
  }

  init() {
    // 键盘事件
    this.input.addEventListener('keydown', this.handleKeyDown.bind(this));

    // 直接编辑
    this.input.addEventListener('change', this.handleChange.bind(this));
    this.input.addEventListener('blur', this.handleBlur.bind(this));

    // 按钮点击
    const container = this.input.closest('.spinbutton-wrapper');
    const upButton = container.querySelector('.spinbutton-up');
    const downButton = container.querySelector('.spinbutton-down');

    if (upButton) {
      upButton.addEventListener('click', () => this.increment(this.smallStep));
    }
    if (downButton) {
      downButton.addEventListener('click', () =>
        this.decrement(this.smallStep),
      );
    }
  }

  handleKeyDown(e) {
    const currentValue =
      parseFloat(this.input.getAttribute('aria-valuenow')) || 0;

    switch (e.key) {
      case 'ArrowUp':
        e.preventDefault();
        this.increment(this.smallStep);
        break;
      case 'ArrowDown':
        e.preventDefault();
        this.decrement(this.smallStep);
        break;
      case 'PageUp':
        e.preventDefault();
        this.increment(this.largeStep);
        break;
      case 'PageDown':
        e.preventDefault();
        this.decrement(this.largeStep);
        break;
      case 'Home':
        e.preventDefault();
        this.setValue(this.min);
        break;
      case 'End':
        e.preventDefault();
        this.setValue(this.max);
        break;
    }
  }

  handleChange() {
    const value = parseFloat(this.input.value);
    if (!isNaN(value)) {
      this.setValue(value);
    }
  }

  handleBlur() {
    // 失去焦点时验证并修正值
    const value = parseFloat(this.input.value);
    if (isNaN(value)) {
      this.setValue(this.min);
    } else {
      this.setValue(value);
    }
  }

  increment(step) {
    const currentValue =
      parseFloat(this.input.getAttribute('aria-valuenow')) || 0;
    this.setValue(currentValue + step);
  }

  decrement(step) {
    const currentValue =
      parseFloat(this.input.getAttribute('aria-valuenow')) || 0;
    this.setValue(currentValue - step);
  }

  setValue(value) {
    // 限制在范围内
    value = Math.max(this.min, Math.min(this.max, value));

    // 更新 ARIA 属性
    this.input.setAttribute('aria-valuenow', value);

    // 更新显示值
    this.input.value = value;

    // 更新有效性状态
    const isValid = value >= this.min && value <= this.max;
    this.input.setAttribute('aria-invalid', !isValid);
  }
}

// 初始化
const spinbuttons = document.querySelectorAll('[role="spinbutton"]');
spinbuttons.forEach((spinbutton) => new Spinbutton(spinbutton));

4.3 带 aria-valuetext 的示例

class WeekdaySpinbutton extends Spinbutton {
  constructor(element) {
    super(element);
    this.weekdays = [
      '',
      'Monday',
      'Tuesday',
      'Wednesday',
      'Thursday',
      'Friday',
      'Saturday',
      'Sunday',
    ];
    this.smallStep = 1;
    this.largeStep = 1; // 星期没有大步长
  }

  setValue(value) {
    // 限制在范围内
    value = Math.max(this.min, Math.min(this.max, value));

    // 更新 ARIA 属性
    this.input.setAttribute('aria-valuenow', value);
    this.input.setAttribute('aria-valuetext', this.weekdays[value]);

    // 显示星期名称
    this.input.value = this.weekdays[value];
  }
}

五、最佳实践

5.1 提供清晰的标签

始终为 Spinbutton 提供描述性的标签:

<!-- 好的示例 -->
<label for="adults">成人数量</label>
<input
  type="text"
  id="adults"
  role="spinbutton"
  aria-label="成人数量"
  ... />

<!-- 不好的示例 -->
<input
  type="text"
  role="spinbutton"
  ... />

5.2 设置合理的范围

根据实际场景设置最小值和最大值:

<!-- 好的示例:酒店预订成人数量 -->
<input
  type="text"
  role="spinbutton"
  aria-label="成人数量"
  aria-valuemin="1"
  aria-valuemax="10"
  ... />

<!-- 不好的示例:没有限制 -->
<input
  type="text"
  role="spinbutton"
  ... />

5.3 使用 aria-valuetext 增强可读性

当数值不够直观时,使用 aria-valuetext

<!-- 好的示例:月份选择 -->
<input
  type="text"
  role="spinbutton"
  aria-label="月份"
  aria-valuenow="1"
  aria-valuemin="1"
  aria-valuemax="12"
  aria-valuetext="January"
  value="January" />

5.4 验证用户输入

阻止无效字符输入,或在失去焦点时修正值:

// 阻止非数字输入
spinbutton.addEventListener('keypress', (e) => {
  if (!/\d/.test(e.key)) {
    e.preventDefault();
  }
});

// 失去焦点时验证
spinbutton.addEventListener('blur', () => {
  const value = parseInt(spinbutton.value);
  if (isNaN(value) || value < min || value > max) {
    // 修正为有效值
    setValue(Math.max(min, Math.min(max, value || min)));
  }
});

5.5 考虑移动端体验

在移动设备上,考虑使用数字键盘:

<input
  type="number"
  inputmode="numeric"
  pattern="[0-9]*"
  role="spinbutton"
  ... />

5.6 提供视觉反馈

  • 无效值时显示错误状态
  • 焦点状态清晰可见
  • 按钮悬停效果
[role='spinbutton'][aria-invalid='true'] {
  border-color: #ef4444;
  background-color: #fef2f2;
}

[role='spinbutton']:focus {
  outline: 2px solid #3b82f6;
  outline-offset: 2px;
}

六、常见错误

6.1 忘记设置 aria-valuenow

<!-- 错误 -->
<input
  type="text"
  role="spinbutton"
  value="5" />

<!-- 正确 -->
<input
  type="text"
  role="spinbutton"
  aria-valuenow="5"
  value="5" />

6.2 按钮可聚焦

<!-- 错误:按钮不应该可聚焦 -->
<button class="spinbutton-up"></button>

<!-- 正确:按钮设置 tabindex="-1" -->
<button
  class="spinbutton-up"
  tabindex="-1"></button>

6.3 忽略键盘交互

只实现按钮点击,不实现键盘支持(方向键、Home/End)。

6.4 不验证输入值

允许用户输入超出范围的值或无效字符。

七、Spinbutton vs 其他输入控件

7.1 Spinbutton vs Slider

特性 Spinbutton Slider
输入方式 键盘输入 + 按钮 拖拽滑块
适用场景 精确数值、离散值 连续范围、粗略选择
精度 中等
典型用例 数量、时间 音量、亮度

7.2 Spinbutton vs 普通文本输入

特性 Spinbutton 普通文本输入
值限制 有最小/最大值 无限制
步长调整 支持 不支持
辅助技术 读出当前值和范围 只读出文本
典型用例 年龄、评分 姓名、地址

八、总结

构建无障碍的 Spinbutton 组件需要关注:

  1. 正确的角色:使用 role="spinbutton"
  2. 必需的属性aria-valuenowaria-valueminaria-valuemaxaria-label
  3. 可选属性aria-valuetextaria-invalid
  4. 完整的键盘支持:方向键调整、Page Up/Down 大步长、Home/End 快捷键
  5. 直接编辑支持:允许用户直接输入值
  6. 输入验证:阻止无效字符,修正超出范围的值
  7. 清晰的标签:帮助用户理解控件用途
  8. 按钮不可聚焦:只有文本框可聚焦

遵循 W3C Spinbutton Pattern 规范,我们能够创建既实用又无障碍的数字输入控件,为所有用户提供便捷的数值选择体验。

文章同步于 an-Onion 的 Github。码字不易,欢迎点赞。

古法编程: React思维模型快速建立

作者 Pkmer
2026年4月26日 16:54

d179110117a86708aa993eb40f13a3c4_720.jpg

使用React的方法论与思想,方便我这个古法编程者Vibe Coding的时候更能游刃有余。

从原始操作DOM API,编写每个操作步骤的命令式,到使用React负责处理DOM的细节,程序员只描述最终结果的声明式编程,最终实现DOM响应数据的变化而自发做出更改的响应式。其中声明式编程,以现代的我的视角了看很像Vibe Coding,我们只需要描述需求即可。

一层一层的抽象出来。命令式->声明式->Vibe Coding(氛围编程)

React思维模型

HTML上凿洞,动态数据露脸

就像旅游景区镂空的拍照墙,我们只需要露个头,一个完美的姿势拍照就好了。

import { useState } from "react";

export default function App() {
  let [who, setWho] = useState("Pkmer");
  return (
    <div className="icons">💪{who}👊</div>
  );
}

JSX是伪装成HTML的JavaScript代码

React开发工具将JSX标签自动转化为相应的JavaScript代码

function App(){
    let [username] = userState(() => "Pkmer")
    return <div className="container">
        <text className="name">💪{username}👊</text>
        <button />
    <div>
}

转化成的JavaScript

import {jsx as _jsx} from "react"

function App(){
    let [username] = userState(() => "Pkmer")
    return _jsx("div",{
        className: "container",
        children: [
            _jsx("text",{className: "name",children: ["💪",username,"👊"]}),
            _jsx("button")
        ]
    })
}

一次组件渲染,一页手翻书

组件每次渲染的时候,都会重新执行一次

function ComponentXxx(){
    console.log("run run run...")
    return // ...jsx...
}

不可变特性(快照)

组件的state数据是不可变的,每一次都只是一个快照,要想更新数据,需要推倒重建。一个组件就像一座大厦一样,就算只给窗户换一种颜色,那么这个大厦就得重建(重新渲染)

下面的代码第一种错误的示范中:在React看来house还是原来的那个house,同一个引用,React会不会重新渲染。第二种正确的方式: house已经不再是原来的house了,尽管大部分内容是一样的,但是它已经是一个新的house.

const [house,setHouse] = useState({windowColor: "蓝色",floors: 2});

// ❌️这样修改不会有效果,house只是快照
house.windowColor = "白色"
setHouse(house)  


// 正确✅️
const newHouse = [...hourse,windowColor: "白色"]
setHouse(newHouse)

可以这样理解:React为了性能考虑,没有进行深层比较,这里只是浅层的比较,发现house的引用变了,才更新。

组件的动态组合方式:children

相比搭积木一样一个一个组件固定的组合方式,children这个特殊的属性,提供了动态组件组合方式

function Dialog({children}){
    return <div>
        <div>{children}</div>
    </div>
}

// 使用
function App(){
    return <Dialog>
        <Heading>I Love Coding</Heading>
        <Slogon>此刻我在深圳图书馆-北馆,充电学习</Slogon>
    </Dialog>
}

传送工程师的接力:单向数据流

props层层传递

数据所有者要想将数据传递给消费者,需要进行层层传递,尽管中间传递者并不消费这个数据

image.png

context电梯,按需取货

在提供数据的楼层(上层)将包裹(数据),放入电梯(context)。电梯往下走,下面的哪个楼层需要这个包裹(数据),自己在对应的楼层打开电梯取走。

const ThemeContext = React.createContext("light")

// 上层数据所有者,提供数据,放入电梯
function Home(){
    const [theme,setTheme] = useState("light")
    
    return <ThemeContext.Provider value={theme}>
        <Page />
    </ThemeContext.Provider>
}

App->Page->Header->Logo,中间层Page,Header都不需要数据,只有Logo需要。

function Page(){
    return <div>
        <Header />
        <Content />
        <Footer />
    </div>
}

function Header(){
    return <div>
        <Logo />
        <Title />
    </div>
}

function Logo(){
    // 在我这层,打开电梯取出包裹(数据)
    const theme = useContext(ThemContext)
    
    return theme === "dark" ? <DarkLogo /> : <LightLogo />
}

便携式虫洞

由于数据是单向传递的,如果子组件要想改变数据,需要数据提供层进行修改。想要修改上层数据,上层提供则需要将权力下放。而这个下放的过程,就像虫洞一样,子组件员工可以将手伸向上层老板的办公室,直接进行签合同。

function BossOffice() {
  const [contract, setContract] = useState('初始合同');

  // 老板下放修改权限的方法
  const signContract = (newContract) => {
    setContract(newContract);
  };

  return (
    <div>
      <h1>老板办公室 - 当前合同: {contract}</h1>
      {/* 将修改权限通过 props 下放给子组件 */}
      <EmployeePortal onSignContract={signContract} />
    </div>
  );
}

function EmployeePortal({ onSignContract }) {
  const handleSign = () => {
    // 子组件员工直接调用上层传来的方法,就像穿过虫洞伸手改数据
    onSignContract('员工新签的合同');
  };

  return (
    <div>
      <h2>员工虫洞通道</h2>
      <button onClick={handleSign}>伸向老板办公室签合同</button>
    </div>
  );
}

Hook勾子将数据放入React大海又勾回来

Hook将函数组件内的数据保存到外部环境,以备下次渲染所用。

  • 保存只读数据: useMemo(保存函数的返回值),useCallback(保存的是回调函数本身)
  • 保存可变数据,更改时触发渲染: useState和useReducer(更底层)
  • 保存可变数据,更改时不触发渲染: useRef

useEffect与生命周期回调方法

useEffect完全可以代替类组件中的三个生命周期回调方法

class Xxx extends Component{
    // 挂载以后运行
    componentDidMount{}
    
    // 每次更新以后运行
    componentDidUpdate(prevProps){}
    
    // 将要卸载前运行
    componentWillUnmount(){}

}

useEffect对应的行为方式

useEffect(() => {
    // 数组参数为空,只在组件第一次渲染时调用
},[])

useEffect(() => {
    // 当数组中的元素变化更新时会执行
},[要变化的值,或者函数]) // 要诚实的告诉哪些值会变。


useEffect(() => {
    // 省略数组。将在组件每次渲染后运行此处的代码
})

useEffect的真正职责:管理组件副作用

药物的副作用并不是药物的目的,当然是越少越好。而程序里的副作用却是我们有意而为,是程序的功能之一

所谓副作用,是函数组件与其周边环境发生了交互的额外任务,比如操作window对象,访问网络请求后端api,读取本地文件等,这些作用都超出了当前函数组件的范围。函数组件关心的是state和props

function Boat(props){
    useEffect(() => {
        const listener = ...
        window.addEventListener('keydown',listener)
        ...
        return () => {
            window.removeListener('keydown',listener)
        }
    },[])

}

幽灵依赖:本地跑得好好的,线上部署却炸了

作者 Daybreak
2026年4月26日 16:41

这是我在开发 My-Notion 项目时踩的一个真实坑——本地开发一切正常,推到 GitHub 后 CI/CD 构建直接失败。排查后发现是幽灵依赖(Phantom Dependencies)问题,而罪魁祸首竟然是 AI Agent 用错了包管理器。

问题复现

某天我 push 代码后,GitHub Actions 的 Build 流水线报红了:

Error: Cannot find module '@qdrant/js-client-rest'

奇怪,我本地跑得好好的啊。

看了一下代码,packages/ai/rag/qdrantVectorStore.ts 里确实用了 @qdrant/js-client-rest

import { QdrantClient } from "@qdrant/js-client-rest";

再看 packages/ai/package.json,依赖声明是这样的:

{
  "dependencies": {
    "@langchain/qdrant": "^1.0.1",
    // ... 其他依赖
  }
}

注意——@qdrant/js-client-rest 并没有在 package.json 中声明,但代码里直接 import 了它。

本地能跑是因为 @langchain/qdrant 依赖了 @qdrant/js-client-rest,而 npm 在安装时会把它提升(hoist)到 node_modules 根目录,所以代码能找到这个包。但线上用 pnpm 构建时,pnpm 严格的依赖结构不允许访问未声明的依赖,直接报错。

什么是幽灵依赖

幽灵依赖(Phantom Dependencies)是指代码中实际使用了某个包,但该包没有在 package.json 中显式声明,而是通过其他包的依赖间接引入的

用一张图来解释:

你的代码
  └─ import { QdrantClient } from "@qdrant/js-client-rest"  ← 直接使用
       ↑
       │  (没有在 package.json 中声明)
       │
@langchain/qdrant (package.json 中声明了)
  └─ @qdrant/js-client-rest  ← 间接依赖

你的代码能访问 @qdrant/js-client-rest,完全是因为 @langchain/qdrant 装了它。但这个关系是隐式的、脆弱的——一旦 @langchain/qdrant 升级版本不再依赖 @qdrant/js-client-rest,或者换了一个替代包,你的代码就会莫名其妙地挂掉。

为什么 npm 会有幽灵依赖,pnpm 不会

核心区别在于 node_modules 的目录结构。

npm 的扁平结构(Flat)

npm v3+ 采用扁平化安装,所有依赖(包括间接依赖)都会被提升到 node_modules 根目录:

node_modules/
├── @qdrant/js-client-rest/     ← 被提升上来了,你的代码能直接访问
├── @langchain/qdrant/
│   └── node_modules/
│       └── (空的,因为被提升了)
├── langchain/
├── openai/
└── ...

这种设计的好处是安装快、兼容性好,但代价就是幽灵依赖——你可以 import 任何被提升到根目录的包,不管你有没有声明它。

pnpm 的严格结构(Strict)

pnpm 采用软链接 + 硬链接的方式,每个包只能访问自己声明的依赖:

node_modules/
├── .pnpm/                           ← 真实存储位置
│   ├── @qdrant+js-client-rest@1.17.0/
│   │   └── node_modules/
│   │       └── @qdrant/js-client-rest/
│   └── @langchain+qdrant@1.0.1/
│       └── node_modules/
│           ├── @langchain/qdrant/
│           └── @qdrant/js-client-rest/  ← 软链接,只有 @langchain/qdrant 能访问
├── @langchain/qdrant/               ← 软链接到 .pnpm
├── langchain/                        ← 软链接到 .pnpm
└── (没有 @qdrant/js-client-rest!)   ← 你的代码找不到它

在 pnpm 的结构下,@qdrant/js-client-rest 只存在于 @langchain/qdrant 的依赖树中,你的代码如果不显式声明,根本访问不到。

这正是 pnpm 的设计初衷——通过严格的依赖隔离,在开发阶段就暴露幽灵依赖问题,而不是等到线上部署才炸。

这个坑是怎么产生的

在我的场景中,问题出在 AI Agent 用了 npm install 而不是 pnpm add 来安装包。

我:帮我安装 @langchain/qdrant
Agent:npm install @langchain/qdrant   ← 用了 npm!

npm 安装后,@qdrant/js-client-rest 被提升到了 node_modules 根目录。Agent 在写代码时,直接 import 了 @qdrant/js-client-rest,本地运行完全没问题——因为 npm 的扁平结构让它"看得见"这个包。

但 CI/CD 环境用的是 pnpm,严格的依赖结构直接暴露了这个幽灵依赖。

怎么解决

1. 显式声明依赖

把代码中实际使用的间接依赖,显式添加到 package.json 中:

pnpm add @qdrant/js-client-rest
  {
    "dependencies": {
      "@langchain/qdrant": "^1.0.1",
+     "@qdrant/js-client-rest": "^1.17.0"
    }
  }

这是最根本的解决方案——你用了什么就声明什么,不依赖其他包的间接引入。

2. 清理并重装依赖

如果项目之前用 npm 装过包,node_modules 里可能残留着扁平结构下的幽灵依赖。需要彻底清理后用 pnpm 重装:

# 删除所有 node_modules
find . -name "node_modules" -type d -prune -exec rm -rf {} +

# 删除 lock 文件(如果有 package-lock.json)
find . -name "package-lock.json" -delete

# 用 pnpm 重新安装
pnpm install

重装后,pnpm 的严格结构会立刻暴露所有幽灵依赖——import 不到的包就是没声明的,一个个补上就行。

3. 让 AI Agent 统一使用 pnpm

问题的根源是 Agent 用了 npm。为了防止再犯,我让 Agent 写了一个全局 Skill,后续所有安装包的操作都强制使用 pnpm:

## 包管理器规则
- 本项目使用 pnpm 作为包管理器
- 安装依赖:pnpm add <package>
- 安装开发依赖:pnpm add -D <package>
- 全局禁止使用 npm install / yarn add
- Monorepo 中安装到指定包:pnpm --filter <package-name> add <dep>

这样 Agent 每次对话都会读取这条规则,不会再出现用错包管理器的问题。

如何检测幽灵依赖

除了等 pnpm 报错,还有更主动的检测方式:

pnpm 的 --strict-peer-dependencies

pnpm install --strict-peer-dependencies

安装时严格检查 peer dependencies,有冲突直接报错而不是静默跳过。

dpdm 工具

dpdm 可以扫描代码中的依赖引用,找出未声明的依赖:

npx dpdm src/index.ts

knip 工具

knip 可以检测未使用的依赖、未声明的依赖、以及各种死代码:

npx knip

总结

npm pnpm
依赖结构 扁平化,间接依赖提升到根目录 严格隔离,只能访问声明的依赖
幽灵依赖 本地不会报错,线上可能炸 开发阶段直接暴露
安装速度 较慢 快(硬链接 + 内容寻址)
磁盘占用 每个项目独立存储 全局存储,多项目共享

幽灵依赖的本质是依赖声明和实际使用不一致。npm 的扁平结构掩盖了这个问题,让它在本地"看起来没问题",但线上部署时就会暴露。pnpm 的严格结构在开发阶段就强制你声明所有使用的依赖,虽然前期多写几行 package.json,但换来的是部署时的安心。

如果你也在用 pnpm + Monorepo,建议:

  1. 永远不要混用 npm 和 pnpm——一旦用 npm 装过包,node_modules 结构就被污染了
  2. 代码中 import 了什么,package.json 就声明什么——不要依赖间接依赖
  3. 让 AI Agent 也遵守包管理器规则——写好项目规则文件,防止 Agent 用错工具
  4. CI/CD 用 pnpm 构建——线上构建和本地开发保持一致,问题在本地就能发现

本文基于 My-Notion 项目的真实踩坑经历撰写,项目是一个 AI 原生的个人版 Notion,欢迎 Star ⭐

lint-staged与ls-lint配合使用时的陷阱

作者 Jesse121
2026年4月26日 15:58

问题背景

在最近的一个 React + TypeScript 项目中,我使用了 lint-staged 配合 ls-lint 来实现 Git 提交时的文件名规范自动检查。配置看起来很简单:

{
  "lint-staged": {
    "*.{ts,tsx,js,jsx}": ["eslint --fix", "ls-lint"]
  }
}

.ls-lint.yml 配置文件规定了命名规则:

ignore:
  - node_modules
  - .git
  - dist

ls:
  .tsx: PascalCase # .tsx 文件应该使用帕斯卡命名(首字母大写)
  .ts: kebab-case # .ts 文件使用短横线命名
  .js: kebab-case
  .css: kebab-case

按照这个规则,src/main.tsx 这样的文件名(小写开头)应该在提交时被拦截并报错。但奇怪的是,git commit 时检测通过了,而手动执行却报错了。

问题复现

场景一:手动执行 ls-lint(相对路径)

$ npx ls-lint src/main.tsx
src/main.tsx failed for `.tsx` rules: pascalcase

✅ 符合预期:检测到命名不规范,退出码为 1。

场景二:Git 提交时通过 lint-staged 执行

$ git add src/main.tsx
$ git commit -m "test"
✔ Preparing...
✔ Running tasks...
  ✔ eslint --fix
  ✔ ls-lint
✔ Applying modifications...

❌ 不符合预期:ls-lint git提交成功,没有检测到命名问题!

深入调试

为了找出原因,我创建了一个调试脚本来查看 lint-staged 实际传递给 ls-lint 的参数:

#!/bin/bash
# test-ls-lint.sh
echo "Arguments received: $@" >> /tmp/lint-staged-debug.log
echo "Number of arguments: $#" >> /tmp/lint-staged-debug.log
npx ls-lint "$@" 2>&1
exit $?

修改 package.json 临时使用这个脚本:

{
  "lint-staged": {
    "*.{ts,tsx,js,jsx}": ["eslint --fix", "./test-ls-lint.sh"]
  }
}

再次提交后查看日志:

$ cat /tmp/lint-staged-debug.log
Arguments received: /Users/jesse/Web/study/React/my-app/src/main.tsx
Number of arguments: 1

关键发现lint-staged 传递的是绝对路径,而不是相对路径!

验证假设

我分别测试了相对路径和绝对路径的情况:

# 测试 1:相对路径
$ npx ls-lint src/main.tsx
src/main.tsx failed for `.tsx` rules: pascalcase
$ echo $?
1  # 报错,符合预期

# 测试 2:绝对路径
$ npx ls-lint /Users/jesse/Web/study/React/my-app/src/main.tsx
$ echo $?
0  # 通过,不符合预期!

真相大白ls-lint 在处理绝对路径时,无法正确应用 .ls-lint.yml 中定义的命名规则,导致检测被静默绕过。

根本原因分析

经过分析和查阅 ls-lint 的文档,我发现:

  1. ls-lint 的设计初衷:它是一个基于项目结构的文件命名 lint 工具,需要根据文件相对于项目根目录的路径来应用规则。

  2. 绝对路径的问题:当传入绝对路径时,ls-lint 无法正确解析文件在项目中的相对位置,导致规则匹配失败或被忽略。

  3. lint-staged 的行为:默认情况下,lint-staged 会将暂存文件的绝对路径作为参数传递给命令,这是为了保证命令在任何工作目录下都能正确找到文件。

这就造成了一个矛盾:

  • lint-staged 传递绝对路径(为了保证可靠性)
  • ls-lint 需要相对路径(为了正确应用规则)

官方回应

这个问题已经被社区发现并报告给 ls-lint 开发团队。在 GitHub Issue #321 中,有开发者提出了相同的疑问。

好消息:ls-lint 开发团队已经确认了这个问题,并计划在 v2.4.0 版本中添加对绝对路径的支持。 但在 v2.4.0 发布之前,我们仍然需要使用本文提到的解决方案来确保文件名校验正常工作。

最终解决方案

经过多次尝试,我找到了最优雅的解决方案: 在 Husky 的 pre-commit 钩子中直接调用 ls-lint,并使用 git diff --staged --name-only 获取暂存文件的相对路径列表。

实现步骤

1. 从 lint-staged 配置中移除 ls-lint

修改 package.json,将 ls-lintlint-staged 配置中移除:

{
  "lint-staged": {
    "*.{ts,tsx,js,jsx}": ["eslint --fix"],
    "*.{css,scss}": "stylelint --fix"
  }
}

2. 在 pre-commit 钩子中添加 ls-lint 检查

编辑 .husky/pre-commit 文件,在执行 lint-staged 之前添加 ls-lint 检查:

#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

# 检查暂存文件的命名规范(使用相对路径)
pnpm exec ls-lint $(git diff --staged --name-only)

# 执行其他 lint-staged 检查
pnpm exec lint-staged

关键点

  • git diff --staged --name-only 返回的是相对路径(如 src/main.tsx),这正是 ls-lint 所需要的
  • lint-staged 之前执行,可以尽早发现命名问题
  • 使用 pnpm exec 确保使用项目本地安装的 ls-lint 版本

验证效果

现在再次提交不符合命名规范的文件:

$ git add src/main.tsx
$ git commit -m "test"

src/main.tsx failed for `.tsx` rules: pascalcase
husky - pre-commit hook exited with code 1 (error)

✅ 成功拦截:现在可以正确检测到命名问题了!

提交符合规范的文件:

$ git add src/MainComponent.tsx
$ git commit -m "feat: add main component"

✔ Preparing...
✔ Running tasks...
✔ Applying modifications...

✅ 正常通过:符合规范的文件可以正常提交。

注意事项

1. 首次提交时的边界情况

如果是第一次提交(没有任何历史提交),git diff --staged --name-only 仍然可以正常工作,它会列出所有暂存的文件。

2. 空暂存区的处理

如果暂存区为空,git diff --staged --name-only 会返回空字符串,ls-lint 会自动跳过检查,不会报错。

3. 删除文件的处理

git diff --staged --name-only 也会包含被删除的文件。如果这些文件已经不存在,ls-lint 会忽略它们,不会影响检查结果。

4. 团队协作建议

  • 确保所有团队成员都安装了 Husky:pnpm exec husky install
  • 在项目的 README.md 中说明命名规范和检查机制
  • 考虑在 CI/CD 流程中也加入 ls-lint 检查,作为双重保障

总结

这次问题的根本原因是:lint-staged 传递绝对路径,而 ls-lint 需要相对路径才能正确应用规则。

最终的解决方案是:在 Husky 的 pre-commit 钩子中使用 git diff --staged --name-only 获取相对路径列表,然后直接传递给 ls-lint,同时从 lint-staged 配置中移除 ls-lint

这个方案简洁、高效、可靠,完美解决了绝对路径导致的检测失效问题。希望这篇文章能帮助你避免类似的坑!

参考资料

Vue 响应式对象异步赋值作为 Props:二次渲染问题与组件设计哲学

2026年4月26日 15:39

前言:一个看似简单的场景

<!-- 父组件 -->
<template>
  <Article
    :title="articleTitle"
    :description="articleDescription"
  />
</template>

<script setup>
import { ref } from 'vue'
import Article from './Article.vue'

const articleTitle = ref("")
const articleDescription = ref("")

// 这里发起请求
fetchArticles(user, publishedDate).then(response => {
  articleTitle.value = response.data.title // 响应式数据发生变化,派发更新
  articleDescription.value = response.data.description
})
</script>

上面的代码会导致子组件 <Article> 渲染两次:第一次收到空字符串,第二次收到真实数据。

这看起来只是一个技术细节。但当开发者把目光从“如何解决”转向“为什么会产生这个问题”时,会发现它触及了 Vue 组件设计中一个深层的问题——数据所有权与副作用边界之间的张力


问题复现——二次渲染的本质

执行过程

  1. 组件挂载前articleTitlearticleDescription 初始值为空字符串。
  2. 首次渲染:子组件收到 { title: "", description: "" },完成第一次渲染(空白或加载占位)。
  3. 异步数据返回articleTitle.value = ... 触发 ref 的 setter。
  4. 父组件重新渲染:Vue 检测到响应式数据变化,重新执行父组件的 render 函数。
  5. 子组件二次渲染:子组件因为 props 变化而再次更新,显示正确内容。

结果:子组件渲染了两次(一次空数据,一次真实数据)。

为什么需要关注这个问题?

并非所有场景都需要关注二次渲染。但在以下情况中,它会成为实际问题:

  • 子组件内部开销较大:图表库、大量 DOM 计算等重复执行,造成性能浪费。
  • 子组件依赖 props 发起副作用:比如 watchEffect 根据 props 去请求图片或接口,导致请求重复发送。
  • 动画或过渡异常:元素从无到有,又从有到空再到有,造成视觉闪烁。
  • 表单组件收到两次初始值:可能导致用户输入被意外重置。

一个常见的“改进”及其设计困境

面对上述问题,很多开发者会做出一个看似更优的选择:

<!-- 父组件只传递 ID,让子组件自己获取数据 -->
<UserArticleDisplay :article-id="articleId" />

子组件内部:

<script setup>
const props = defineProps<{ articleId: string }>()
const article = ref(null)

watch(() => props.articleId, async (id) => {
  if (id) {
    article.value = await fetchArticle(id)
  }
}, { immediate: true })
</script>

效果:子组件只渲染一次(数据加载完成后直接渲染真实内容,中间用 loading 态占位)。

设计困境:副作用归属问题

传递的内容 副作用的承担者 渲染次数 副作用可见性
title / description(数据) 父组件 2 次 副作用在父组件,透明
articleId(标识符) 子组件 1 次 副作用被子组件隐藏,不透明

传递 articleId 意味着:子组件不仅接收一个 ID,还被默认有能力、有责任去获取数据并处理网络请求。这相当于将副作用责任从父组件转移到了子组件。

更深层的矛盾:声明式与命令式的冲突

Vue 本质上是声明式的:开发者声明“UI 应该是什么样”,框架帮助实现。

但网络请求本质上是命令式的:在某个时刻“命令”组件去获取数据。

当传递 articleId 时,实际上是在声明式的外壳里隐藏了一个命令式的副作用:

<!-- 从代码上看是声明 -->
<Article :article-id="id" />

<!-- 实际运行时等价于命令 -->
<Article @mount="fetchArticle(id)" @update:id="fetchArticle(newId)" />

这是声明式 UI 与命令式副作用之间的一个固有矛盾。没有绝对正确的答案,只有基于具体场景的权衡。


解决方案的分类与取舍

方案一:显式副作用设计

明确告知子组件需要产生副作用,并暴露钩子供父组件参与。

<Article 
  :article-id="id" 
  :fetch-on-mount="true"
  @loading="showSpinner"
  @error="handleError"
/>

设计立场:副作用是必要的,但必须可见、可控。使用者清楚知道这个组件会发起网络请求。

方案二:副作用保留在父组件,保持子组件纯净

保持子组件为纯展示组件,父组件负责所有数据获取。

<!-- 父组件获取数据,子组件只负责渲染 -->
<Article :title="title" :description="description" />

配合 v-if 缓解二次渲染:

<Article
  v-if="articleTitle && articleDescription"
  :title="articleTitle"
  :description="articleDescription"
/>

设计立场:子组件应该是可预测的纯函数。二次渲染是声明式 UI 的合理代价,可以通过条件渲染避免。

方案三:提取独立服务层

// 独立的 ArticleService
const articleService = useArticleService()

// 父组件调用服务,把结果传给子组件
const { data: article, execute } = useAsyncState(
  () => articleService.fetch(id),
  null
)
<Article :data="article" v-if="article" />

设计立场:副作用既不在父组件也不在子组件,而在独立的服务层。这是最符合关注点分离原则的方案。

方案四:接受双重渲染,优化中间状态

承认异步 Props 必然导致多次渲染,但把中间状态(loading/error)作为一等公民暴露出来。

<Article :article-id="id">
  <template #loading>加载中...</template>
  <template #error="{ retry }">加载失败,<button @click="retry">重试</button></template>
</Article>

设计立场:与其隐藏副作用,不如将其显式化、可定制化,让使用者拥有更好的控制权。


如何做出选择

在组件设计时,需要明确回答三个问题:

  1. 谁负责发起副作用?(父组件?子组件?服务层?)
  2. 副作用的可见性如何?(用户是否应该看到 loading?其他开发者是否应该知道组件会发请求?)
  3. 可测试性优先还是渲染次数优先?
优先级 推荐方案 副作用归属
子组件纯净、易测试 传递数据,接受二次渲染 + v-if 父组件
子组件自包含、减少渲染 传递 ID,子组件自治,暴露 loading/error 子组件(显式声明)
架构清晰、可维护 独立服务层 + 传递数据 服务层
用户体验优先 传递 ID + 子组件智能加载(骨架屏 + 一次渲染) 子组件

何时不必过度设计

以下场景中,最简单的方案(即最初的双重渲染方案)完全够用:

  • 子组件非常轻量,二次渲染开销可忽略。
  • 产品明确需要 loading 状态作为用户体验的一部分。
  • 数据请求速度极快(有缓存或 Service Worker),用户感知不到两次渲染。

在这些场景下,无需引入复杂的设计模式。


结语

Vue 响应式系统与异步数据流结合时,ref 的初始值与最终值必然导致响应式派发更新。这不是 Vue 的设计缺陷,而是声明式 UI 框架的固有特性。

真正的组件设计不是消灭副作用或消灭二次渲染,而是:

  1. 明确决定副作用归属于谁
  2. 让这个决定在代码中显而易见
  3. 根据场景选择在哪个环节承担中间状态(父组件、子组件、服务层,或 Suspense)

传递 articleId 确实会将副作用责任转移给子组件。这本身不是错误——前提是开发者有意识地做出这个选择,并理解其代价(可测试性降低、副作用隐藏)。

优秀的组件设计在于理解每个决策的含义后,做出符合当前场景的权衡。


快速参考

场景 推荐方案
子组件渲染开销大,需要避免二次渲染 v-if 就绪后渲染
子组件有独立的数据获取逻辑 传递 ID + 显式 loading/error 钩子
需要 loading 态作为产品需求 保留两次渲染,优化默认占位内容
追求架构清晰、组件可复用 独立服务层 + 传递数据
极致性能,数据返回极快 使用 Suspense 或预取数据

Promise的理解

作者 张西餐
2026年4月26日 15:16

Promise为什么会产生?

在开发中,我们经常需要写出这样的代码:当某件事完成后,才去做另一件事。比如:先登录,拿到用户信息后,再去获取订单列表。

我们可以通过回调函数来实现,简单场景还好,但如果逻辑嵌套多了(比如登录 → 用户信息 → 订单列表 → 订单详情 → 支付信息…),代码就会一层套一层,这就是“回调地狱”。

// 登录 → 获取用户信息 → 获取用户的订单
login('张三', '123456', function(result) {
  getUserInfo(result.userId, function(user) {
    getOrders(user.id, function(orders) {
      console.log(orders)
    }, function(error) {
      console.log('获取订单失败', error)
    })
  }, function(error) {
    console.log('获取用户失败', error)
  })
}, function(error) {
  console.log('登录失败', error)
})

从以上示例可以看出回调地狱的缺点:

  1. 最直观的就是阅读困难,理解难度大
  2. 修改困难,想要加一步或者改某处都需要重新理解,然后再往这层层嵌套中添加代码

于是,Promise应运而生,它解决了回调地狱的问题,很容易理解。

Promise的写法?

上述回调地狱的Promise写法:

function login(username, password) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (username === 'zhang' && password === '123') {
        resolve({ userId: 1001, token: 'abc' })
      } else {
        reject('登录失败')
      }
    }, 500)
  })
}

function getUserInfo(userId, token) {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve({ userId, name: '张三', vipLevel: 3 })
    }, 500)
  })
}

function getOrders(userId) {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(['订单1', '订单2', '订单3'])
    }, 500)
  })
}

// 链式调用,简单易懂
login('zhang', '123')
  .then(user => {
    return getUserInfo(user.userId, user.token)
  })
  .then(userInfo => {
    console.log('用户信息:', userInfo)
    return getOrders(userInfo.userId)
  })
  .then(orders => {
    console.log('订单列表:', orders)
  })
  .catch(err => {
    console.log('出错:', err)
  })

Promise的根本作用?

首先要明确Promise的好处:链式调用,先执行谁,然后才能再执行谁,代码非常直观。

比如在调用第二个then之前,我们需要先确定第一个then调用成功了,然后才能调用第二个,第一个失败了就不会再调用第二个了,会直接catch错误。那么怎么知道前一段代码执行成功了呢?resolve和reject就出现了。前一段成功时,会调用resolve,前一段的resolve会引发后一个then的调用,而前一段的resolve是怎么引发后一个then的呢?Promise的功劳。

那么Promise的作用就是:建立联系。建立resolve和then的联系,建立reject和catch的联系。

为了方便大家理解,我举个例子:传话。第一个人(第一个then)知道了(resolve)才能传给第二个人(第二个then),第一个人怎么传给第二个人?打电话,Promise就是那个电话,就是传话的工具。

resolve是什么呢?

resolve是Promise底层传过来的函数,不是由我们编写定义的,我们只负责在new Promise的时候拿到它,然后使用就可以了。

new Promise((resolve, reject) => {}) // 拿到 resolve 和 reject 函数

接下来有对resolve更加详细的讲解,现在可以先不纠结这个。

Promise具体做了什么?

Promise有三种状态:待定(pending)、履行(fulfilled)、拒绝(rejected),初始状态为pending,成功时变为fulfilled并执行相应代码,失败时变为rejected并执行相应代码。

接下来我们通过示例来解释这段话:

function checkNum(num) {
  return new Promise((resolve, reject) => {
    if (num === 1) {
      resolve('数字是1,成功') // 状态变为fulfilled
    } else {
      reject('数字不是1,失败') // 状态变为rejected
    }
  })
}

checkNum(1)
  .then(res => console.log(res)) // fulfilled状态下才执行的回调函数
  .catch(err => console.log(err)) // rejected状态下才执行的回调函数

执行checkNum(1),也就是执行:

new Promise((resolve, reject) => {
    if (num === 1) {
      resolve('数字是1,成功') // 状态变为fulfilled
    } else {
      reject('数字不是1,失败') // 状态变为rejected
    }
  })
上面的话提到“成功时变为fulfilled并执行相应代码”,那么我们怎么定义成功呢?

其实是很简单的问题,执行resolve就是成功,反之执行reject就是失败。比如当前示例num===1的结果是true,所以能执行resolve,那么这就是成功。代码如下:

if(num===1){
    resolve('数字是1,成功')
}

resolve(1)底层做了什么呢?

  1. 改状态:把pending改为fulfilled
  2. 由于状态为fulfilled,所以把参数1传给then(res => console.log(res))(res的值就是1),并将这个回调函数放到微任务队列(这里明天可以再拓展一下)(reject会将状态改为rejected,并将catch的回调函数放到任务队列)
  3. 待当前所有同步代码执行完成后,执行任务队列中的res => console.log(res)回调函数

总结:resolve(1) 的执行决定了 Promise 的状态变为 fulfilled,因此会执行 .then() 的回调函数,而不是 .catch() 的。

笔者有点累了,明天再回来写(●'◡'●)~

收藏即复用!50个极致实用JavaScript单行代码,前端开发效率直接拉满

作者 悟空瞎说
2026年4月26日 14:58

50个原生JS/TS高频单行工具函数!零依赖、生产可用,告别重复造轮子

前言

作为前端开发者,日常业务开发中,字符串处理、数组运算、日期格式化、浏览器API、对象数据清洗等基础逻辑几乎无处不在。

很多小伙伴为了省事,项目里习惯性引入 Lodash、Dayjs 等第三方工具库。但绝大多数场景下,完全不需要引入庞大依赖。

几行原生 JS/TS 代码,就能优雅实现需求,不仅可以减少项目打包体积、降低项目依赖,还能提升代码熟练度,写出更简洁优雅的业务代码。

今天给大家整理了 50个生产可用的原生单行代码片段,覆盖前端9大高频开发场景。

告别玩具代码,全部适配浏览器/Node.js/Vue/React 所有前端项目,开箱即用,建议收藏!

一、字符串操作(最高频)

所有方法默认空值兜底,防止传参 undefined 导致代码报错

1. 字符串首字母大写

const capitalize = (str = '') => str.charAt(0).toUpperCase() + str.slice(1);

2. 反转字符串

const reverseString = (str = '') => str.split('').reverse().join('');

3. 判断字符串是否为回文

const isPalindrome = (str = '') => str === str.split('').reverse().join('');

二、数组操作

1. 数组扁平化一层

const flatArr = arr => arr.flat(1);

2. 移除数组所有假值

自动过滤:false、0、空字符串、null、undefined、NaN

const removeFalsy = arr => arr.filter(Boolean);

3. 快速生成 0-99 连续数组

const createArr = () => Array.from({length: 100}, (_, i) => i);

4. 随机打乱数组(标准洗牌算法)

Fisher–Yates 算法

const shuffleArr = arr => {
  const list = [...arr];
  for (let i = list.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1));
    [list[i], list[j]] = [list[j], list[i]];
  }
  return list;
};

5. 基础数组去重

const uniqueArr = arr => [...new Set(arr)];

6. 对象数组根据指定字段去重

const uniqueByKey = (arr, key) => [...new Map(arr.map(item => [item[key], item])).values()];

7. 获取多个数组交集

const getIntersection = (a = [], ...arr) => [...new Set(a)].filter(v => arr.every(b => b.includes(v)));

8. 查找数组最大值索引

const maxIndex = (arr = []) => arr.length ? arr.indexOf(Math.max(...arr)) : -1;

9. 查找数组最小值索引

const minIndex = (arr = []) => arr.length ? arr.indexOf(Math.min(...arr)) : -1;

10. 找到数组中最接近指定数字的值

const closestNum = (arr = [], n = 0) => arr.reduce((a, b) => Math.abs(b - n) < Math.abs(a - n) ? b : a);

11. 多个数组合并为二维数组

const merge2D = (...arrList) => [...arrList];

12. 矩阵行列转置

const transpose = (matrix = []) => matrix[0]?.map((_, i) => matrix.map(row => row[i])) ?? [];

三、数制转换

原生 API 一行搞定,无需手写复杂计算公式

1. 十进制转换为任意 n 进制

const decToBase = (num = 0, base = 10) => num.toString(base);

2. 任意 n 进制转换为十进制

const baseToDec = (str = '', base = 10) => parseInt(str, base);

四、正则与文本处理

全部增加异常捕获,适配不规则入参

1. 从URL中提取域名

const getDomain = (url = '') => {
  try { return new URL(url).hostname; } catch { return ''; }
};

2. 验证电子邮箱格式

const isEmail = (mail = '') => /^[^\s@]+@[^\s@]+.[^\s@]+$/.test(mail);

3. 移除文本所有多余空格

const trimAll = (str = '') => str.replace(/\s+/g, ' ').trim();

五、浏览器原生 Web 操作

零框架依赖,兼容所有现代浏览器

1. 重新加载当前页面

const reloadPage = () => location.reload();

2. 平滑滚动到页面顶部

const scrollToTop = () => window.scrollTo({ top: 0, behavior: 'smooth' });

3. 平滑滚动到指定元素

const scrollToEl = (el) => el?.scrollIntoView({ behavior: 'smooth' });

4. 检测当前浏览器是否为IE

const isIE = () => !!window.ActiveXObject || /msie|trident/i.test(navigator.userAgent);

5. 移除文本中所有 HTML 标签

const stripHtml = (html = '') => html.replace(/<[^>]*>/g, '');

6. 页面重定向跳转

const redirect = (url = '') => location.href = url;

7. 一键复制文本到剪贴板

const copyText = async (text = '') => {
  try { await navigator.clipboard.writeText(text); return true; } 
  catch { return false; }
};

六、日期时间处理(重点修复时区BUG)

1. 判断日期是否为今天

const isToday = (date) => {
  const d1 = new Date(date);
  const d2 = new Date();
  return d1.getFullYear() === d2.getFullYear() &&
         d1.getMonth() === d2.getMonth() &&
         d1.getDate() === d2.getDate();
};

2. 日期转为标准 YYYY-MM-DD

const formatDate = (date = new Date()) => {
  const d = new Date(date);
  const y = d.getFullYear();
  const m = String(d.getMonth() + 1).padStart(2, '0');
  const day = String(d.getDate()).padStart(2, '0');
  return `${y}-${m}-${day}`;
};

3. 秒数转为 hh:mm:ss 时长格式

const secToTime = (s = 0) => {
  const t = Math.floor(s);
  const h = String(Math.floor(t / 3600)).padStart(2, '0');
  const m = String(Math.floor((t % 3600) / 60)).padStart(2, '0');
  const ss = String(t % 60).padStart(2, '0');
  return `${h}:${m}:${ss}`;
};

4. 获取指定年月的第一天

const firstDay = (y, m) => new Date(y, m - 1, 1);

5. 获取指定年月的最后一天

const lastDay = (y, m) => new Date(y, m, 0);

七、函数相关操作

1. 判断是否为异步 async 函数

const isAsyncFn = (fn) => fn?.constructor.name === 'AsyncFunction';

八、数字精度处理(金额展示必备)

专门用于前端金额、小数展示,精准可控

1. 截断小数(不四舍五入)

const toFixedFloor = (num = 0, len = 2) => Math.trunc(num * Math.pow(10, len)) / Math.pow(10, len);

2. 截断小数(自动四舍五入)

const toFixedRound = (num = 0, len = 2) => Number(num.toFixed(len));

3. 数字前置补零

const padNum = (num = 0, len = 2) => num.toString().padStart(len, '0');

九、对象常用操作(接口数据清洗神器)

1. 清除对象 null、undefined 空属性

const cleanObj = (obj = {}) => Object.fromEntries(Object.entries(obj).filter(([_, v]) => v != null));

2. 交换对象键值

const invertObj = (obj = {}) => Object.fromEntries(Object.entries(obj).map(([k, v]) => [v, k]));

3. JSON 字符串转对象

增加异常捕获,非法字符串不报错

const strToObj = (str = '') => {
  try { return JSON.parse(str); } catch { return null; }
};

4. 生产级对象深度对比(重点推荐)

避坑说明: 网上主流的 JSON.stringify 对比方式存在大量BUG,键顺序、undefined、NaN、日期都会对比失效。以下是轻量递归深对比方案,生产稳定可用

const deepEqual = (a, b) => {
  if (a === b) return true;
  if (!(a && b) || typeof a !== typeof b) return false;
  if (typeof a !== 'object') return false;
  const keysA = Object.keys(a);
  const keysB = Object.keys(b);
  if (keysA.length !== keysB.length) return false;
  return keysA.every(k => deepEqual(a[k], b[k]));
};

十、通用万能工具函数

1. 生成随机十六进制颜色

const randomColor = () => '#' + Math.floor(Math.random() * 0xffffff).toString(16).padStart(6, '0');

2. RGB 转 HEX

const rgbToHex = (r = 0, g = 0, b = 0) => '#' + [r, g, b].map(x => String(x.toString(16)).padStart(2, '0')).join('');

3. HEX 转 RGB

const hexToRgb = (hex = '') => {
  const h = hex.replace('#', '');
  return {
    r: parseInt(h.slice(0, 2), 16),
    g: parseInt(h.slice(2, 4), 16),
    b: parseInt(h.slice(4, 6), 16)
  };
};

4. 生成全局唯一 UUID

const getUUID = () => crypto.randomUUID();

5. 获取当前页面 Cookie

const getCookie = () => document.cookie;

6. 延迟等待函数

const wait = (ms = 0) => new Promise(resolve => setTimeout(resolve, ms));

写在最后

本文所有代码全部修复网络通用BUG,解决了市面上大部分前端工具合集存在的:时区错误、算法不均、空值报错、对象对比失效、浏览器报错等问题。

所有方法零第三方依赖、轻量简洁,兼容浏览器、Node.js、Vue、React、uniapp 等绝大部分前端项目。

日常开发中,大家可以将这些工具函数统一封装到项目的 utils.ts / utils.js 工具文件中,全局复用,彻底告别重复造轮子,大幅提升开发效率,写出更优雅、更健壮的业务代码。

文章干货满满,建议收藏+点赞,开发随时查阅!也欢迎各位大佬在评论区补充更多优质工具函数,一起交流精进✨

拒绝“首屏爆炸”:用 React 哨兵模式与懒加载打造丝滑列表

作者 AI的主人
2026年4月26日 13:31

拒绝“首屏爆炸”:用 React 哨兵模式与懒加载打造丝滑列表

想象一下,你开了一家名为“无限画廊”的餐厅(也就是你的 Web 应用)。

如果你的做法是:先把菜单上的一万道菜全部做好,堆在门口(首屏加载),然后让顾客自己找想吃的。

结果会怎样?门口堵死了,服务员累瘫了,顾客还没进门就被吓跑了。这就是典型的性能灾难

今天,我们就来聊聊如何用 React 哨兵模式(Infinite Scroll)图片懒加载(Lazy Load) 这两把利器,把你的“餐厅”改造成米其林级别的流畅体验。

️‍♂️ 第一章:守门员——IntersectionObserver 哨兵模式

传统的滚动加载是怎么做的?监听 windowscroll 事件,疯狂计算 (scrollTop + clientHeight) >= scrollHeight。这就像是你雇了个保安,每过一毫秒就问你一次:“到底了吗?到底了吗?到底了吗?” —— 太吵了,而且费脑子(主线程阻塞)。

现代浏览器的救星来了:IntersectionObserver

它的逻辑是:“嘿,浏览器大哥,帮我盯着那个叫‘哨兵’的 <div>。只要它一露脸,你就喊我一声。” 浏览器内部优化极佳,完全不用我们操心性能。

让我们看看你提供的这个“通用哨兵组件”是如何工作的:

// InfiniteScroll.js - 我们的核心守卫
const InfiniteScroll = ({ hasMore, onLoadMore, isLoading, children }) => {
  const sentinelRef = useRef(null); // 这是一个隐形的“间谍”节点

  useEffect(() => {
    // 1. 安全检查:没数据了或者正在加载中,就别折腾了
    if (!hasMore || isLoading) return;

    // 2. 雇佣观察员 (Observer)
    const observer = new IntersectionObserver((entries) => {
      // 3. 只要哨兵出现在视野里(哪怕只露出一像素)
      if (entries[0].isIntersecting) {
        onLoadMore(); // 吹哨子:该上菜了!
      }
    }, { threshold: 0 }); // threshold: 0 意味着“只要看见一点点就算”

    // 4. 告诉观察员盯着谁
    if (sentinelRef.current) {
      observer.observe(sentinelRef.current);
    }

    // 5.  cleanup:组件卸载或更新时,记得解雇观察员,防止内存泄漏
    return () => {
      if (sentinelRef.current) {
        observer.unobserve(sentinelRef.current);
      }
    };
  }, [onLoadMore, hasMore, isLoading]); // 依赖项要写对,不然哨兵会罢工

  return (
    <>
      {children} {/* 这里放你的列表内容 */}
      
      {/* 这是一个高度极小的隐形 div,它就是我们的“哨兵” */}
      <div ref={sentinelRef} className="h-4" />
      
      {isLoading && <div className="text-center py-4">加载中...</div>}
    </>
  );
};

为什么叫它“哨兵”? 因为它混在列表的最底部。当用户滚动页面,列表内容被顶上去,原本藏在底部的“哨兵”就会暴露在视口(Viewport)中。一旦暴露,IntersectionObserver 捕捉到信号,立即触发 onLoadMore,新数据进来,把哨兵继续往下顶。完美闭环!

️ 第二章:视觉欺诈——图片懒加载的艺术

解决了列表的分页,我们还得解决列表里的“胖子”——图片

如果你的列表有 100 项,每项一张图,那就是 100 个 HTTP 请求。用户打开页面的瞬间,带宽直接被占满,白屏时间长得让人想关掉网页。

懒加载的核心思想: “不见兔子不撒鹰”。只有当图片快要进入屏幕时,才给它真正的 src 地址。

虽然原生的 <img loading="lazy" /> 已经很强了,但在 React 生态中,我们通常会结合 react-lazy-load 这样的库,利用它们封装好的 IntersectionObserver 能力,实现更精细的控制(比如提前加载、占位符防抖动)。

实战代码长这样:

import LazyLoad from 'react-lazy-load';

const PostItem = ({ post }) => {
  return (
    <div className="card">
      <h3>{post.title}</h3>
      {/* 
         方案 A: 使用第三方库(推荐用于复杂场景,如瀑布流)
         height 属性很重要,用来撑开高度,防止图片加载前布局塌陷
      */}
      <LazyLoad height={200} offset={100}>
        <img src={post.thumbnail} alt={post.title} className="w-full h-auto" />
      </LazyLoad>

      {/* 
         方案 B: 原生偷懒法 (简单粗暴,兼容性也不错)
         <img src={post.thumbnail} loading="lazy" /> 
      */}
    </div>
  );
};

双重保障: 你可以同时使用 loading="lazy" 属性和 LazyLoad 组件。前者是给浏览器的指令,后者是 React 层面的兜底,两者结合,稳如老狗。

第三章:终极合体——打造无限流

现在,我们将这两个概念结合起来。InfiniteScroll 负责宏观的节奏(什么时候加载下一页数据),而内部的 LazyLoad 负责微观的体验(图片按需显示)。

使用场景模拟:

  1. Store/State: 维护一个 posts 数组,page 页码,hasMore 是否还有下一页。
  2. UI 层:
    • 外层包裹 <InfiniteScroll ...>
    • 中间是 .map() 渲染出来的文章列表。
    • 每篇文章里的图片都被 <LazyLoad> 包裹。
  3. 交互流程:
    • 用户刷刷刷,看到了第 10 篇文章。
    • 第 10 篇的图片因为快进视口了,自动加载高清大图(懒加载生效)。
    • 用户继续刷到底部,踩到了“哨兵”。
    • onLoadMore 触发,API 请求第 2 页数据。
    • 新数据拼接到 posts 数组,React 重新渲染,列表变长。

避坑指南(老司机的经验)

  • 锁住并发:一定要用 isLoading 状态锁!千万别让用户在数据请求回来的那几百毫秒内,连续触发两次哨兵,导致发了两个一样的 API 请求。
  • 高度塌陷:做图片懒加载时,如果图片没加载出来,容器高度为 0,页面会发生剧烈的跳动(Layout Shift)。解决办法:给图片容器设置固定的宽高比(aspect-ratio)或者预设高度。
  • 路由切换:记得在 useEffect 的清理函数中 observer.disconnect()unobserve。否则当你跳转到详情页再回来时,可能会发现旧的观察器还在后台幽灵般地运行。

总结

前端开发的艺术,往往就在于**“拖延”**。

能晚点加载的代码(Code Splitting),就晚点加载;能晚点请求的数据(Infinite Scroll),就晚点请求;能晚点下载的图片(Lazy Load),就晚点下载。

用好 IntersectionObserver 和 React 的组合模式,让你的应用像丝绸一样顺滑。

React 常用 Hooks 函数及使用方法完全指南(useState / useEffect / useRef / useContext / useCallback / useMemo / useReducer)

作者 玖玖passion
2026年4月26日 12:54

前言

React Hooks 自 React 16.8 引入以来,已经彻底改变了我们编写 React 组件的方式。Hooks 让我们在函数组件中使用状态和生命周期能力,告别了类组件的繁琐写法。本文将从最常用的几个 Hook 入手,详细介绍它们的使用方法、最佳实践和常见陷阱。

如果你刚开始接触 React Hooks,或者想系统地梳理一遍常用 Hook 的用法,这篇文章应该能帮到你。


一、useState — 组件状态管理

useState 是最基础、最常用的 Hook,用于在函数组件中声明和管理状态。

基本用法

import { useState } from 'react';

function Counter() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>计数:{count}</p>
      <button onClick={() => setCount(count + 1)}>+1</button>
    </div>
  );
}

useState 接收一个初始值,返回一个长度为 2 的数组:当前状态更新状态的函数

更新状态的两种方式

方式一:直接传入新值

setCount(count + 1);

方式二:传入更新函数(推荐,当新值依赖旧值时)

setCount(prev => prev + 1);

第二种方式可以避免闭包陷阱。如果你的 setCount 在异步回调中调用,使用函数式更新能确保拿到最新的状态值。

状态更新的异步性

很多人刚接触时会被这个问题困扰:setState 之后立刻读取状态,发现值没变。

const [count, setCount] = useState(0);

setCount(1);
console.log(count); // 还是 0,不是 1

这是因为 React 的状态更新是异步且批量的。实际的重新渲染会在当前事件循环结束后进行。

复杂状态:多个字段

如果状态是一个对象,更新时要手动合并:

const [form, setForm] = useState({ name: '', age: 0 });

// 错误:会丢失 name 字段
setForm({ age: 18 });

// 正确:需要手动合并
setForm(prev => ({ ...prev, age: 18 }));

💡 如果你的状态逻辑比较复杂(多个子字段、相互依赖),考虑用 useReducer 替代。


二、useEffect — 副作用处理

useEffect 用于处理组件中的副作用:数据请求、DOM 操作、订阅、计时器等。

基本用法

import { useEffect } from 'react';

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(data => setUser(data));
  }, [userId]); // 依赖项

  return <div>{user?.name}</div>;
}

理解依赖数组

依赖数组是 useEffect 的灵魂,它决定了 effect 何时执行:

依赖数组 执行时机
undefined (不传) 每次渲染后执行
[] (空数组) 只在挂载后执行一次
[a, b] 当 a 或 b 发生变化时执行

清理函数

如果 effect 产生了订阅、计时器等需要清理的资源,返回一个清理函数:

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

  return () => {
    clearInterval(timer); // 组件卸载时清理
  };
}, []);

这里的 return () => clearInterval(timer) 就是清理函数,会在组件卸载时执行,防止内存泄漏。

常见陷阱

陷阱一:忘记添加依赖

useEffect(() => {
  fetch(`/api/users/${userId}`).then(...)
}, []); // ❌ userId 变了也不会重新请求

陷阱二:不必要的依赖导致死循环

useEffect(() => {
  setCount(count + 1); // ❌ count 变化 → 重新渲染 → effect 触发 → count 变化 → 死循环
}, [count]);

陷阱三:在 effect 中使用旧值(闭包问题)

function Timer() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      setCount(count + 1); // ❌ count 永远是 0
    }, 1000);
    return () => clearInterval(id);
  }, []); // 没有依赖 count
}

解决方案:使用函数式更新 setCount(c => c + 1),或者把 count 加入依赖数组并清理/重建定时器。

useEffect 与 useLayoutEffect 的区别

  • useEffect异步执行,在浏览器绘制之后触发。适合数据请求、事件绑定等不需要阻塞视觉更新的操作。
  • useLayoutEffect同步执行,在 DOM 更新后、浏览器绘制前触发。适合需要读取 DOM 布局的场景(如测量元素尺寸)。

绝大多数情况下用 useEffect 就够了,只有当你遇到闪烁(flicker)问题时才考虑使用 useLayoutEffect


三、useRef — 引用 DOM 和可变值

useRef 有两个主要用途:引用 DOM 元素存储可变值(不触发重新渲染)。

引用 DOM 元素

import { useRef, useEffect } from 'react';

function AutoFocusInput() {
  const inputRef = useRef(null);

  useEffect(() => {
    inputRef.current?.focus(); // 组件挂载后自动聚焦
  }, []);

  return <input ref={inputRef} />;
}

存储可变值(改变不触发重渲染)

function Timer() {
  const [count, setCount] = useState(0);
  const countRef = useRef(count);

  // 始终保持 ref 与 state 同步
  countRef.current = count;

  useEffect(() => {
    const id = setInterval(() => {
      console.log('当前 count:', countRef.current); // 能拿到最新值
    }, 1000);
    return () => clearInterval(id);
  }, []); // 空依赖,定时器只创建一次
}

这个模式常用于解决闭包陷阱——ref.current 永远指向最新的值,因为它是一个可变对象。

useRef vs useState 的关键区别

特性 useState useRef
修改触发重渲染 ✅ 是 ❌ 否
跨渲染周期保存数据 ✅ 是 ✅ 是
在异步回调中获取最新值 ❌ 闭包问题 ✅ 始终最新
修改方式 setState(newVal) ref.current = newVal

四、useContext — 跨组件数据共享

useContext 让你在不使用 props 层层传递的情况下,在组件树中共享数据。

三步使用法

第一步:创建 Context

import { createContext } from 'react';

const ThemeContext = createContext('light');

第二步:使用 Provider 提供数据

function App() {
  const [theme, setTheme] = useState('light');

  return (
    <ThemeContext.Provider value={{ theme, setTheme }}>
      <ThemedComponent />
    </ThemeContext.Provider>
  );
}

第三步:子组件消费数据

import { useContext } from 'react';

function ThemedComponent() {
  const { theme, setTheme } = useContext(ThemeContext);

  return (
    <div className={theme}>
      当前主题:{theme}
      <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
        切换主题
      </button>
    </div>
  );
}

性能注意点

当 Provider 的 value 发生变化时,所有使用 useContext 的子组件都会重新渲染。如果 value 是一个对象,每次父组件渲染都会创建新引用,导致所有消费者重渲染。

解决方案:用 useMemo 包裹 value,或者将 Context 拆分为多个(读写分离)。

// 分离读和写,避免不必要的重渲染
const ThemeContext = createContext('light');
const ThemeUpdateContext = createContext(() => {});

function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('light');
  const toggleTheme = useCallback(
    () => setTheme(t => (t === 'light' ? 'dark' : 'light')),
    []
  );

  return (
    <ThemeContext.Provider value={theme}>
      <ThemeUpdateContext.Provider value={toggleTheme}>
        {children}
      </ThemeUpdateContext.Provider>
    </ThemeContext.Provider>
  );
}

五、useCallback — 缓存函数引用

useCallback 用于缓存函数的引用,避免因函数重新创建导致子组件不必要的重新渲染。

import { useCallback } from 'react';

function Parent() {
  const [count, setCount] = useState(0);

  // 每次 Parent 渲染都会创建新的函数引用
  const handleClick = () => setCount(c => c + 1);

  // useCallback 缓存函数,只有依赖变化时才重建
  const handleClickCached = useCallback(
    () => setCount(c => c + 1),
    []
  );

  return <ExpensiveChild onClick={handleClickCached} />;
}

什么时候用 useCallback

不是所有函数都需要包裹 useCallback。过度使用反而会降低可读性和性能(因为 useCallback 本身也有开销)。

适合的场景:

  • 函数作为 props 传给使用了 React.memo 的子组件
  • 函数作为其他 Hook 的依赖项(比如 useEffect 的依赖)
  • 函数在自定义 Hook 中返回给外部使用

💡 一句话法则:当你确定不缓存会导致不必要的性能问题时再用。初期先正常写函数,遇到性能瓶颈再优化。


六、useMemo — 缓存计算结果

useMemo 用于缓存复杂计算的结果,避免每次渲染都重复执行。

import { useMemo } from 'react';

function Dashboard({ transactions }) {
  // 复杂计算:过滤 + 聚合
  const summary = useMemo(() => {
    return transactions
      .filter(t => t.amount > 0)
      .reduce((acc, t) => ({
        total: acc.total + t.amount,
        count: acc.count + 1,
        avg: (acc.total + t.amount) / (acc.count + 1)
      }), { total: 0, count: 0, avg: 0 });
  }, [transactions]);

  return <div>总金额:{summary.total},平均:{summary.avg}</div>;
}

useMemo vs useCallback

  • useCallback(fn, deps) 等价于 useMemo(() => fn, deps)
  • useCallback 缓存的是函数本身
  • useMemo 缓存的是计算的结果

不要滥用 useMemo

useCallback 一样,useMemo 也不是免费的。简单的计算(如数组 map、filter)其开销可能还不如 useMemo 的对比开销大。

适合 useMemo 的场景:

  • 计算复杂度较高(O(n²) 及以上)
  • 计算结果作为 props 传给 React.memo 子组件
  • 计算结果作为其他 Hook 的依赖项

七、useReducer — 复杂状态管理

当状态逻辑变得复杂(多个子值、相互依赖、多层次更新),useState 就不太够用了。这时 useReducer 是更好的选择。

import { useReducer } from 'react';

// 1. 定义 reducer 函数
function todoReducer(state, action) {
  switch (action.type) {
    case 'ADD':
      return [...state, { id: Date.now(), text: action.payload, done: false }];
    case 'TOGGLE':
      return state.map(t =>
        t.id === action.payload ? { ...t, done: !t.done } : t
      );
    case 'DELETE':
      return state.filter(t => t.id !== action.payload);
    default:
      return state;
  }
}

function TodoApp() {
  // 2. 使用 useReducer
  const [todos, dispatch] = useReducer(todoReducer, []);

  return (
    <div>
      <button onClick={() => dispatch({ type: 'ADD', payload: '新任务' })}>
        添加
      </button>
      {todos.map(todo => (
        <div key={todo.id}>
          <span style={{ textDecoration: todo.done ? 'line-through' : 'none' }}>
            {todo.text}
          </span>
          <button onClick={() => dispatch({ type: 'TOGGLE', payload: todo.id })}>
            完成
          </button>
        </div>
      ))}
    </div>
  );
}

useReducer vs useState 的选择

场景 推荐
独立、简单的状态 useState
包含多个子值的复杂状态 useReducer
下一个状态依赖前一个 useReducer(或函数式 setState)
更新逻辑在组件外可独立测试 useReducer
只需浅层更新表单字段 useState

八、自定义 Hooks — 逻辑复用

自定义 Hook 是 React Hooks 的精髓之一。当你发现多个组件中有相似的逻辑时,可以提取成一个自定义 Hook。

import { useState, useEffect } from 'react';

// 自定义 Hook:获取数据
function useFetch(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    let cancelled = false;

    setLoading(true);
    fetch(url)
      .then(res => {
        if (!res.ok) throw new Error('请求失败');
        return res.json();
      })
      .then(data => {
        if (!cancelled) {
          setData(data);
          setLoading(false);
        }
      })
      .catch(err => {
        if (!cancelled) {
          setError(err);
          setLoading(false);
        }
      });

    return () => { cancelled = true; }; // 清理:防止竞态
  }, [url]);

  return { data, loading, error };
}

// 使用
function UserList() {
  const { data, loading, error } = useFetch('/api/users');

  if (loading) return <div>加载中...</div>;
  if (error) return <div>出错了:{error.message}</div>;
  return <ul>{data.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}

自定义 Hook 的命名规则

  • 必须以 use 开头(React 官方约定,linter 也会检查)
  • 内部可以调用其他 Hook
  • 普通函数不能调用 Hook,但自定义 Hook 可以

九、Hooks 使用规则

最后,牢记 React Hooks 的两条铁律:

规则一:只在顶层调用 Hooks

不要在循环、条件语句或嵌套函数中调用 Hook。

// ❌ 错误:在条件中调用
if (isLoading) {
  useEffect(() => { ... }, []);
}

// ✅ 正确:始终在顶层
useEffect(() => {
  if (!isLoading) { ... }
}, [isLoading]);

规则二:只在 React 函数中调用 Hooks

  • 在函数组件中调用 ✅
  • 在自定义 Hook 中调用 ✅
  • 在普通函数中调用 ❌
  • 在类组件中调用 ❌
  • 在回调中调用 ❌

总结

本文介绍了 React 中最常用的 7 个核心 Hook:

Hook 用途
useState 声明和管理组件状态
useEffect 处理副作用(请求、订阅、DOM 操作)
useRef 引用 DOM 元素、存储可变值
useContext 跨组件层级共享数据
useCallback 缓存函数引用
useMemo 缓存计算结果
useReducer 管理复杂状态逻辑

记住:Hooks 是工具,不是目的。不要在不需要的地方强行使用性能优化 Hook(useCallback、useMemo),先写出清晰的代码,遇到性能问题再针对性地优化。

希望这篇文章能帮你更好地理解和运用 React Hooks。有什么问题欢迎在评论区交流讨论~

彻底弄懂async/await!解决回调地狱,Vue异步开发必备(超全实战)

2026年4月26日 10:49

一、async/await 核心简介

async/await 是 ES7 推出的异步语法糖,基于 Promise 封装,彻底解决了传统 Promise 链式调用代码嵌套层级深、可读性差、维护困难的「回调地狱」问题。

该语法可以让异步代码像同步代码一样自上而下顺序执行,逻辑清晰、调试简单,是 Vue 项目接口请求、异步逻辑处理的主流规范写法。

核心语法规则

  • async:修饰函数,写在函数定义前,标识该函数为异步函数,返回值为 Promise 对象
  • await:修饰异步操作,只能在 async 函数内部使用,作用是阻塞代码,等待异步请求执行完成后,再执行后续代码
  • 异常捕获:await 异步报错无法直接捕获,必须搭配 try/catch 捕获异常

二、传统 Promise 链式调用(弊端演示)

在多个接口串行依赖调用场景下(后一个接口依赖前一个接口的返回数据),传统 Promise 的 then 链式调用会出现多层嵌套,代码冗余、层级混乱、极难维护。

业务场景:先通过手机号获取用户属地,再根据属地省市信息请求充值面额列表

methods: {
  // 获取用户所属属地
  getLocation(phoneNum) {
    return axios.post('/location', { phoneNum });
  },
  // 根据属地省市获取充值面额列表
  getFaceList(province, city) {
    return axios.post('/location', { province, city });
  },
  // 传统Promise链式调用(多层嵌套,可读性差)
  getFaceResult() {
    this.getLocation(this.phoneNum).then(res => {
      if (res.status === 200 && res.data.success) {
        let province = res.data.province;
        let city = res.data.city;
        // 二次嵌套调用,层级堆积
        this.getFaceList(province, city).then(res => {
          if (res.status === 200 && res.data.success) {
            this.faceList = res.data
          }
        })
      }
    }).catch(err => {
      console.log(err)
    })
  }
}

传统写法痛点:多接口串行嵌套、代码层级深、逻辑割裂、异常处理集中、后续扩展难度大。

三、async/await 标准优雅写法(推荐生产使用)

使用 async/await 重构后,异步逻辑完全同步化,代码扁平化无嵌套,执行顺序一目了然,完美适配接口串行依赖场景。

methods: {
  // 获取用户所属属地
  getLocation(phoneNum) {
    return axios.post('/location', { phoneNum });
  },
  // 根据属地省市获取充值面额列表
  getFaceList(province, city) {
    return axios.post('/location', { province, city });
  },
  // async/await 优雅串行调用
  async getFaceResult() {
    // 所有异步操作统一捕获异常
    try {
      // 等待第一个接口执行完成,获取返回结果
      let location = await this.getLocation(this.phoneNum);
      // 上一个接口执行完毕,才会执行后续逻辑
      if (location.data.success) {
        let province = location.data.province;
        let city = location.data.city;
        // 等待第二个依赖接口执行完成
        let result = await this.getFaceList(province, city);
        if (result.data.success) {
          this.faceList = result.data;
        }
      }
    } catch (err) {
      // 统一处理所有异步异常
      console.log(err);
    }
  }
}

四、核心执行逻辑解析

  1. 给函数添加 async 修饰,将普通函数转为异步函数,支持内部使用 await;
  2. await 强制阻塞代码执行,等待 getLocation 接口请求完毕、返回结果后,才会向下执行;
  3. 解析第一个接口返回的省市数据,作为第二个接口的请求参数;
  4. 再次通过 await 等待 getFaceList 接口执行完成,最终赋值渲染数据;
  5. 所有异步请求的报错、异常,全部被 try/catch 统一捕获,避免页面报错崩溃。

五、async/await 关键使用规则(必记)

  • await 必须在 async 函数内部使用,普通函数内直接使用 await 会报语法错误;
  • await 默认串行执行,代码自上而下依次执行,天然适配接口依赖场景;
  • 必须搭配 try/catch:Promise 链式可单独 catch,await 异步异常无法自动捕获,不加 try/catch 会导致程序报错中断;
  • async 函数始终返回 Promise 对象,可正常搭配 then 继续链式调用,兼容性极强;
  • 无依赖的并行接口不建议串行 await,会造成不必要的请求耗时。

六、核心优势总结

  • 代码扁平化:彻底消除回调嵌套,告别回调地狱,代码整洁优雅;
  • 逻辑更清晰:异步代码同步化写法,执行顺序直观,可读性大幅提升;
  • 维护性更强:新增、删减异步逻辑无需调整嵌套层级,迭代成本低;
  • 异常统一处理:通过 try/catch 集中捕获所有异步异常,报错管理更规范。

七、场景选型总结

  • 接口串行依赖、多步骤异步逻辑:优先使用 async/await(本文核心场景);
  • 简单单次异步请求:可使用简易 Promise then 写法;
  • 无依赖并行请求:搭配 Promise.all + async/await 实现最优性能。

Openclaw 快速接入 DeepSeek V4 Pro 指南

作者 VagueVibes
2026年4月26日 05:19

DeepSeek v4 重磅发布,博查 Model API 在首发当日便已支持v4 全系的调用,那么如何在 OpenClaw 平台中通过修改配置文件接入博查 Model API 以使用 DeepSeek V4 系列模型?

主要步骤包括定位 openclaw.json 文件、添加自定义 Provider 配置、设置默认调用模型以及重启网关验证。配置过程中需注意 contextWindow 参数调整及 api 协议凭证的正确填写,同时确保 API Key 安全。

0 前置准备

  1. 已安装 OpenClaw(版本 ≥ 1.8.0,低于该版本请先执行 brew upgrade openclaw 升级);如果尚未安装,可以查看新手快速安装入门步骤。
  2. 已获取博查 API Key;(获取地址 open.bocha.cn)
  3. 熟悉基础的 JSON 语法。

1 新手快速安装入门

  1. 安装 OpenClaw
# macOS / Linux
curl -fsSL https://openclaw.ai/install.sh | bash

# Windows(PowerShell)
iwr -useb https://openclaw.ai/install.ps1 | iex

001.PNG

  1. 运行配置引导
openclaw onboard --install-daemon

选择自定义模型供应商 Custom Provider

002.png

配置信息如下:

# Model/auth provider
Custom Provider

# API Base URL
https://api.bocha.cn/v1

# How do you want to provide this API key?
Paste API key now

# API Key (leave blank if not required)
sk-******

# Endpoint compatibility
Anthropic-compatible

# Model ID
deepseek-v4-pro

# Verification successful.

# Endpoint ID
custom-api-bocha-cn

# Model alias (optional)
deepseek-v4-pro

003.png

2 更新模型配置

配置文件定位

安装完 OpenClaw 后,其所有模型、渠道配置都集中在 openclaw.json 文件中,不同操作系统的默认路径如下:

操作系统 配置文件默认路径 快速打开方式
Windows C:\Users<你的用户名>.openclaw\openclaw.json 按 Win+R,输入路径直接跳转
macOS ~/.openclaw/openclaw.json 终端执行 open ~/.openclaw/openclaw.json
Linux ~/.openclaw/openclaw.json 终端执行 vim ~/.openclaw/openclaw.json

提示:如果找不到文件,先执行 openclaw init 初始化配置(执行后会自动生成配置文件)。

找到对应的 openclaw.json 配置文件,变更配置如下:

{
  "agents": {
    "defaults": {
      "models": {
        "custom-api-bocha-cn/deepseek-v4-pro": {
          "alias": "deepseek-v4-pro"
        }
      },
      "model": {
        "primary": "custom-api-bocha-cn/deepseek-v4-pro"
      }
    }
  },
  "tools": {
    "profile": "coding"
  },
  "models": {
    "mode": "merge",
    "providers": {
      "custom-api-bocha-cn": {
        "baseUrl": "https://api.bocha.cn/v1",
        "api": "anthropic-messages",
        "apiKey": "sk-******",
        "models": [
          {
            "id": "deepseek-v4-pro",
            "name": "deepseek-v4-pro (Custom Provider)",
            "api": "anthropic-messages",
            "baseUrl": "https://api.bocha.cn/v1",
            "reasoning": false,
            "input": [
              "text"
            ],
            "cost": {
              "input": 0,
              "output": 0,
              "cacheRead": 0,
              "cacheWrite": 0
            },
            "contextWindow": 1000000,
            "maxTokens": 384000
          }
        ]
      }
    }
  }
}

重要参数:

contextWindow 1000000 上下文窗口:1M tokens(V4 全系)

maxTokens 384000 最大输出:384K tokens(官方上限)

保存并验证

完成以上修改后,保存文件。建议先重启 OpenClaw 网关让配置生效。

  1. 重启 OpenClaw 网关:
openclaw gateway restart
  1. 验证模型挂载状态:
openclaw models status

在 Web UI 聊天中,输入一条消息测试模型:

004.png

或者在命令行输入 openclaw chat,再与 agent 对话:

005.png

006.png

记录一下自动化构建中 SSE 与子进程管理的三个坑

作者 heyCHEEMS
2026年4月25日 23:57

最近在写一个博客后台管理系统的轻量自动化部署接口,用 SSE 来流传输给前端打印实时构建日志,简单记录一下遇到的最主要的三个坑。

坑一:子进程杀不死

在 Node.js 中,我们习惯用 childProcess.kill()。但在运行 pnpm build 这种命令时,它会衍生出一大堆子进程(如 Vite 或 Webpack)。如果你只杀掉父进程,那些构建进程就会变成“孤儿进程”继续运行。

解决办法是 在 Windows 下要用 taskkill 配合 /T 参数杀掉整棵进程树,在 Linux 下则要开启 detached 模式并用负数 PID 来杀掉整个进程组。

这里涉及到了进程树与进程组,操作系统中,程序启动另一个程序即为父子关系。Linux 的 detached 模式相当于让子进程自立门户当“组长”,通过组 ID 即可实现“一锅端”。

// 后端执行终止子进程
stopProcess() {
    if (this.childProcess && this.childProcess.pid) {
        const pid = this.childProcess.pid;
        if (this.isWindows) {
            // Windows 通过 /T 杀掉子进程树,/F 强制终止
            exec(`taskkill /PID ${pid} /T /F`);
        } else {
            // Linux 或 Mac 通过负数 PID 杀死整个进程组
            process.kill(-pid, 'SIGKILL');
        }
        this.childProcess.removeAllListeners();
        this.childProcess = null;
    }
}

坑二:SSE 切换页面自动重连

当你开启构建后切换到其他标签页,浏览器为了节能会挂起网络请求。等你切回来时,浏览器发现连接断开并自动尝试重连。由于后端为了防止并发给任务加了锁,重连请求撞上正在运行的任务,后端就返回了错误。

fetch-event-source 库提供了一个参数叫 openWhenHidden,把它设为 true,就能绕过浏览器的节能限制,当浏览器最小化或切换到其它标签页时也保持连接。

// 前端请求配置
await fetchEventSource('/sse-api/deploy', {
    method: 'POST',
    openWhenHidden: true, // 切换标签页时不中断连接
    onmessage(ev) {
        const data = JSON.parse(ev.data);
        setLog(prev => prev + data.log);
    },
    onerror(err) {
        if (err.code === 409) {
            message.warning('后台已有任务在运行');
        }
    }
});

坑三:原生 API 的局限性

很多人觉得原生 EventSource 更轻量,但它其实是个黑盒:原生的 EventSource 默认不支持在请求中添加自定义请求头(如 Authorization)。如果你的博客后台接口需要 Token 验证,原生 API 只能被迫将 Token 挂在 URL 参数里。这会让 Token 暴露在服务器日志中,还显得非常不专业。

原生 SSE 强制要求必须是 GET 请求,但如果我们需要向后端发送一些复杂数据时,把这些东西塞进 URL 参数里既臃肿又不安全。用 Fetch 模拟 SSE,就可以轻松发起 POST 请求,把参数优雅地放在 Request Body 里。

原生 API 一旦断开,会按照浏览器内置的逻辑盲目重连。而 Fetch 模式配合 AbortController,可以让我们精准控制,什么时候该彻底断开,什么时候该带着上一次的 Last-Event-ID 重新寻找断点。

在 Node.js 文件上传中集成 ClamAV 扫描

作者 SonoTommy
2026年4月25日 23:09

文件上传是常见的攻击面。用户上传的文件可能包含恶意软件、ZIP 炸弹或 伪造的 MIME 类型。大多数 Node.js 项目只做扩展名检查,这远远不够。

pompelmi 是一个 Node.js 库,在文件落盘之前完成扫描,返回类型化的 verdict symbol,不依赖任何第三方运行时。

GitHub: github.com/pompelmi/po…


工作原理

  1. 验证参数是否为字符串,文件是否存在
  2. 通过 child_process 调用 clamscan,读取退出码
  3. 将退出码映射为 Symbol

没有 stdout 解析,没有正则,没有隐式状态。


安装

需要 Node.js 和 ClamAV。

npm install pompelmi

安装 ClamAV:

# macOS
brew install clamav && freshclam

# Debian / Ubuntu
sudo apt-get install -y clamav clamav-daemon && sudo freshclam

# Windows
choco install clamav -y

基本用法

const { scan, Verdict } = require('pompelmi');

const result = await scan('/path/to/file.zip');

switch (result) {
  case Verdict.Clean:
    // 文件安全,继续处理
    break;
  case Verdict.Malicious:
    throw new Error('检测到恶意软件,文件已拒绝');
  case Verdict.ScanError:
    // 扫描未完成,按不可信文件处理
    console.warn('扫描失败,拒绝文件');
    break;
}

返回值:

结果 ClamAV 退出码 含义
Verdict.Clean 0 未发现威胁
Verdict.Malicious 1 匹配到已知病毒签名
Verdict.ScanError 2 扫描本身失败,文件状态未知

在 Express 中集成

const express = require('express');
const multer  = require('multer');
const { scan, Verdict } = require('pompelmi');
const path = require('path');
const fs   = require('fs');

const app    = express();
const upload = multer({ dest: 'tmp/' });

app.post('/upload', upload.single('file'), async (req, res) => {
  const filePath = path.resolve(req.file.path);

  try {
    const result = await scan(filePath);

    if (result === Verdict.Malicious) {
      fs.unlinkSync(filePath);
      return res.status(422).json({ error: '文件包含恶意软件' });
    }

    if (result === Verdict.ScanError) {
      fs.unlinkSync(filePath);
      return res.status(422).json({ error: '扫描失败,文件已拒绝' });
    }

    // Verdict.Clean — 继续保存文件
    return res.status(200).json({ verdict: 'clean' });

  } catch (err) {
    fs.unlinkSync(filePath);
    return res.status(500).json({ error: err.message });
  }
});

远程扫描(Docker)

如果 ClamAV 运行在容器中,通过 TCP socket 连接:

const result = await scan('/path/to/file.zip', {
  host: '127.0.0.1',
  port: 3310,
});

API 保持不变,verdict 类型不变。


错误处理

try {
  const result = await scan(path.resolve(filePath));
  return result;
} catch (err) {
  // filePath 不是字符串      → 'filePath must be a string'
  // 文件不存在               → 'File not found: <path>'
  // clamscan 不在 PATH 中    → ENOENT
  // 未知退出码               → 'Unexpected exit code: N'
  console.error('扫描异常:', err.message);
  return null;
}

特性

  • 零运行时依赖,仅使用 Node.js 内置 child_process
  • 不解析 stdout,直接读取退出码
  • 支持 TypeScript,verdict 为 Symbol 类型,防止拼写错误
  • 支持本地 clamscan 和远程 clamd TCP socket
  • 跨平台:macOS、Linux、Windows

相关链接

❌
❌