阅读视图

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

拥抱PostgreSQL支持UI配置化

前言

前阵子写的日志分析工具NginxPulse,自开源以来,已过去2周时间,目前GitHub已收获1.5k的star。收到了不少用户的反馈建议,花了点时间将这些问题都处理了下。

本文就跟大家分享下新版本都解决了哪些问题,优化了哪些内容,欢迎各位感兴趣的开发者阅读本文。

抛弃SQLite

有不少用户反馈说日志文件很大的时候(10G+),解析速度非常慢,需要解析好几个小时,解析完成之后数据看板的查询也比较慢(接口响应在5秒左右)。

于是,我重写了日志解析策略(解析阶段不做IP归属地查询,仅入库其他数据,将日志中IP记录起来),日志解析完毕后,将记录的IP做去重处理,随后去做归属地的查询处理(优先本地的ip2region库,远程的API调用查询做兜底),最后将解析到的归属地回填至对应的数据库表中,这样一套下来就可以大大提升日志的解析速度。

数据库的数据量大了之后,SQLite的表现就有点差强人意了,请教了一些后端朋友,他们给了我一些方案,结合我自身的实际场景后,最后选定了PostgreSQL作为新的数据库选型。

这套方案落地后,用户群的好兄弟说:他原先需要解析1个小时的日志,新版只需要10多分钟。

6c1c8781ddb810d57c9f508fdaf47025

UI配置可视化使用

有一部分用户反馈说他非专业人士,这些晦涩的配置对他来说使用门槛太高了,希望能有一个UI配置页面,他只需要点一点、敲敲键盘,就能完成这些配置。

我将整个配置流程做成了4步,同时也准备一个演示视频 - www.bilibili.com/video/BV1hq…

  • 配置站点
  • 配置数据库
  • 配置运行参数
  • 确认最终配置

image-20260125235847464

新增wiki文档

因为配置过于庞大,仓库主页浏览README.md比较费劲,希望能整理一份wiki文档发上去。

花了点时间,简化了下README,整理了一份:github.com/likaia/ngin…

image-20260126000555725

访问明细模块优化

有部分用户反馈说希望增加更多的筛选条件以及导出Excel功能,现在它来了:

image-20260126001010068

概况模块优化

概况页面的日期筛选之前放在趋势分析卡片的上方,但是他的切换影响的维度还包含了指标,于是我就调整了下它的位置,新版如下图所示:

image-20260126001325265

项目地址

写在最后

至此,文章就分享完毕了。

我是神奇的程序员,一位前端开发工程师。

如果你对我感兴趣,请移步我的个人网站,进一步了解。

Button Pattern 详解

Button Pattern 详解:构建无障碍按钮组件

按钮是 Web 界面中最基础的交互元素之一,它让用户能够触发特定的操作或事件,如提交表单、打开对话框、取消操作或执行删除操作。根据 W3C WAI-ARIA Button Pattern 规范,正确实现的按钮组件不仅要具备良好的视觉效果,更需要确保所有用户都能顺利使用,包括依赖屏幕阅读器等辅助技术的用户。本文将深入探讨 Button Pattern 的核心概念、实现要点以及最佳实践。

一、按钮的定义与核心功能

按钮是一个允许用户触发动作或事件的界面组件。从功能角度来看,按钮执行的是动作而非导航,这是按钮与链接的本质区别。常见的按钮功能包括:提交表单数据、打开对话框窗口、取消正在进行的操作、删除特定内容等。一个设计良好的按钮应当让用户清晰地感知到点击它将产生什么效果,这种可预期性是良好用户体验的重要组成部分。

在实际开发中,有一个广为接受的约定值得注意:如果按钮的操作会打开一个对话框或其他需要进一步交互的界面,应该在按钮标签后加上省略号(...)来提示用户。例如,**保存为...**这样的标签能够告诉用户,点击这个按钮后会弹出额外的对话框需要填写。这种细节虽然看似微小,却能显著提升用户对界面行为的理解。

二、按钮的三种类型

WAI-ARIA 规范支持三种类型的按钮,每种类型都有其特定的用途和实现要求。理解这三种类型的区别对于构建正确无障碍的界面至关重要。

2.1 普通按钮

普通按钮是最常见的按钮类型,它执行单一的操作而不涉及状态的切换。提交表单的提交按钮、触发某个动作的执行按钮都属于这一类别。普通按钮在激活时会执行预定义的操作,操作完成后通常会根据操作的性质决定焦点的移动位置。例如,打开对话框的按钮在激活后,焦点应移动到对话框内部;而执行原地操作的按钮则可能保持焦点在原位。

<button type="submit">提交表单</button>

2.2 切换按钮

切换按钮是一种具有两种状态的按钮,可以处于未按下已按下的状态。这种按钮通过 aria-pressed 属性向辅助技术传达其当前状态。例如,音频播放器中的静音按钮就可以实现为切换按钮:当声音处于静音状态时,按钮的 aria-pressed 值为 true;当声音正常播放时,该值为 false。

实现切换按钮时有一个关键原则需要牢记:按钮的标签在状态改变时不应发生变化。无论按钮是处于按下还是未按下状态,其可访问名称应该保持一致。屏幕阅读器用户依赖这个稳定的标签来理解按钮的功能。如果设计要求在状态改变时显示不同的文本,那么就不应使用 aria-pressed 属性,而是应该通过其他方式传达状态变化。

<button
  type="button"
  aria-pressed="false"
  id="muteButton">
  静音
</button>

<script>
  muteButton.addEventListener('click', function () {
    const isMuted = this.getAttribute('aria-pressed') === 'true';
    this.setAttribute('aria-pressed', !isMuted);
  });
</script>

2.3 菜单按钮

菜单按钮是一种特殊的按钮,点击后会展开一个菜单或其他弹出式界面。根据 WAI-ARIA 规范,通过将 aria-haspopup 属性设置为 menu 或 true,可以将按钮向辅助技术揭示为菜单按钮。这种按钮在用户界面中非常常见,例如许多应用中的文件菜单、编辑菜单等。

菜单按钮的实现需要遵循菜单模式的相关规范,确保用户能够通过键盘导航菜单项,屏幕阅读器能够正确播报菜单状态,视觉用户能够清晰地看到菜单的展开和收起状态。正确实现的菜单按钮应当提供平滑的用户体验,无论用户使用何种输入方式或辅助技术。

<button
  type="button"
  aria-haspopup="menu"
  id="fileMenu">
  文件
</button>

三、键盘交互规范

键盘可访问性是 Web 无障碍设计的核心要素之一。按钮组件必须支持完整的键盘交互,确保无法使用鼠标的用户也能顺利操作。根据 Button Pattern 规范,当按钮获得焦点时,用户应能通过以下按键与按钮交互:

空格键和回车键是激活按钮的主要方式。当用户按下空格键或回车键时,按钮被触发执行其预定义的操作。这个设计遵循了用户对表单控件的既有认知,与传统桌面应用的交互模式保持一致。

按钮激活后焦点的处理需要根据具体情境来决定,这是实现良好键盘体验的关键。如果按钮打开了一个对话框,焦点应移动到对话框内部,通常是对话框的第一个可聚焦元素或默认焦点元素。如果按钮关闭了对话框,焦点通常应返回到打开该对话框的按钮,除非对话框中的操作逻辑上应该导致焦点移动到其他位置。例如,在确认删除操作的对话框中点击确认后,焦点可能会移动到页面上的其他相关元素。

对于不会关闭当前上下文的按钮(如应用按钮、重新计算按钮),激活后焦点通常应保持在原位。如果按钮的操作表示上下文将要发生变化(如向导中的下一步),则应将焦点移动到该操作的起始位置。对于通过快捷键触发的按钮,焦点通常应保持在触发快捷键时的上下文中。

四、WAI-ARIA 角色、状态和属性

正确使用 WAI-ARIA 属性是构建无障碍按钮组件的技术基础。虽然语义化的 HTML 按钮元素(button)本身已经具备正确的角色和基本行为,但在某些情况下需要使用自定义实现或 ARIA 属性来增强可访问性。

角色声明是基础要求。按钮元素的 role 属性应设置为 button,向辅助技术表明这是一个按钮组件。对于使用 button 这样的原生 HTML 元素,浏览器会自动处理角色声明,无需开发者手动添加。

示例:使用 div 元素模拟按钮时需要添加 role="button":

<div
  role="button"
  tabindex="0"
  onclick="handleClick()">
  提交
</div>

可访问名称是按钮最重要的可访问性特征之一。按钮必须有可访问的名称,这个名称可以通过多种方式提供:按钮内部的文本内容是最常见的来源;在某些情况下,可以使用 aria-labelledby 引用页面上的其他元素作为标签;或者使用 aria-label 直接提供标签文本。屏幕阅读器用户主要依赖这个名称来理解按钮的功能。

示例 1:使用 aria-labelledby 引用其他元素作为标签:

<h2 id="save-heading">保存设置</h2>
<button
  role="button"
  aria-labelledby="save-heading">
  图标
</button>

示例 2:使用 aria-label 直接提供标签文本:

<button
  aria-label="关闭对话框"
  onclick="closeDialog()">
  ×
</button>

描述信息可以通过 aria-describedby 属性关联。如果页面上存在对按钮功能的详细描述说明,应将描述元素的 ID 赋给这个属性,辅助技术会在播报按钮名称后继续播报描述内容。

示例:使用 aria-describedby 提供详细描述:

<button aria-describedby="delete-warning">删除</button>
<p id="delete-warning">此操作无法撤销,将永久删除所选数据。</p>

禁用状态需要正确使用 aria-disabled 属性。当按钮的关联操作不可用时,应设置 aria-disabled="true"。这个属性向辅助技术传达按钮当前处于禁用状态,用户无法与之交互。需要注意的是,对于原生 HTML button 元素,应使用 disabled 属性而非 aria-disabled。

示例:使用 aria-disabled 禁用非原生按钮:

<div
  role="button"
  tabindex="-1"
  aria-disabled="true"
  aria-label="保存">
  保存
</div>

切换状态使用 aria-pressed 属性来传达,这个属性只用于实现为切换按钮的组件。属性值应为 true(按下状态)、false(未按下状态)或 mixed(部分选中状态,用于三态树节点等场景)。

示例:使用 aria-pressed 实现切换按钮:

<button
  type="button"
  aria-pressed="false"
  id="toggleBtn"
  onclick="toggleState()">
  夜间模式
</button>

五、按钮与链接的区别

在 Web 开发中,一个常见的混淆点是何时应该使用按钮,何时应该使用链接。这两者的功能定位有着本质的区别,理解这个区别对于构建语义正确的页面至关重要。

按钮用于触发动作,如提交表单、打开对话框、执行计算、删除数据等。这些操作会产生副作用,改变应用的状态或数据。链接用于导航,将用户带到另一个页面、页面的不同位置或不同的应用状态。链接的本质是超文本引用,它告诉用户这里有你可能感兴趣的另一个资源

从技术实现角度,这个区别直接影响了可访问性。屏幕阅读器对按钮和链接的播报方式不同,用户会根据这些提示形成对界面功能的预期。如果一个元素看起来像链接(蓝色下划线文本)但点击后执行的是按钮的动作(提交表单),会给用户造成困惑。即使出于设计考虑必须使用这种视觉与功能的组合,也应通过 role="button" 属性明确告诉辅助技术这个元素的真实功能,避免给依赖辅助技术的用户带来困惑。

更好的做法是调整视觉设计,使其与功能保持一致。如果某个元素执行的是动作,就应该看起来像一个按钮;如果用户需要被导航到新页面,就应该使用标准的链接样式。这种设计上的统一能够减少所有用户的认知负担。

六、其他示例

以下是一个常见按钮场景的实现示例——打开对话框的按钮,展示了如何正确应用 Button Pattern 规范。

使用 HTML 原生 <dialog> 元素配合按钮实现对话框功能:

<button
  type="button"
  aria-haspopup="dialog"
  aria-expanded="false"
  id="openDialog">
  设置...
</button>

<dialog id="settingsDialog">
  <form method="dialog">
    <label> <input type="checkbox" /> 启用通知 </label>
    <button value="confirm">确定</button>
  </form>
</dialog>

<script>
  const dialog = document.getElementById('settingsDialog');
  const openBtn = document.getElementById('openDialog');

  openBtn.addEventListener('click', () => {
    dialog.showModal();
    openBtn.setAttribute('aria-expanded', 'true');
  });

  dialog.addEventListener('close', () => {
    openBtn.setAttribute('aria-expanded', 'false');
  });
</script>

当按钮会打开对话框时,使用省略号提示用户后面还有额外交互。aria-haspopup 表明按钮会弹出内容,aria-expanded 用于传达弹出内容的当前状态。

七、CSS 伪类与交互样式

以下 CSS 伪类可用于增强按钮的键盘交互体验:

/* Tab 键导航到按钮时显示焦点框 */
button:focus {
  outline: 2px solid blue;
  outline-offset: 2px;
}

/* 仅键盘焦点显示样式,鼠标点击不显示 */
button:focus-visible {
  outline: 2px solid currentColor;
  outline-offset: 2px;
}

/* 空格键或回车键按下时的样式 */
button:active {
  transform: scale(0.98);
}

/* 鼠标悬停效果(可选,增强视觉反馈) */
button:hover {
  opacity: 0.9;
}

/* Tab + Space 组合键激活样式(需 JS 添加类) */
button.keyboard-active {
  transform: scale(0.95);
  background-color: oklch(from currentColor 0.8);
}

/* Tab + Enter 组合键激活样式(需 JS 添加类) */
button.keyboard-enter {
  transform: scale(0.95);
  background-color: oklch(from currentColor 0.8);
}

各伪类说明:

伪类 触发方式 用途
:focus Tab 键/鼠标点击 元素获得焦点时
:focus-visible 仅键盘 Tab 仅键盘焦点显示,避免鼠标点击时出现框
:active 按下空格/回车/鼠标 元素被激活时
:hover 鼠标悬停 鼠标悬停时的视觉反馈

7.1 组合键交互示例

CSS 本身无法直接检测组合键,但可以通过 JavaScript 增强体验:

<button id="submitBtn">提交</button>

<style>
  /* Tab + Space 激活状态 */
  button.space-pressed {
    transform: scale(0.95);
    box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.2);
  }

  /* Tab + Enter 激活状态 */
  button.enter-pressed {
    transform: scale(0.95);
    background-color: oklch(from var(--btn-bg, currentColor) 0.8);
  }
</style>

<script>
  const btn = document.getElementById('submitBtn');

  // Tab + Space 组合键
  btn.addEventListener('keydown', (e) => {
    if (e.key === ' ' && e.target === document.activeElement) {
      btn.classList.add('space-pressed');
    }
    if (e.key === 'Enter' && e.target === document.activeElement) {
      btn.classList.add('enter-pressed');
    }
  });

  btn.addEventListener('keyup', (e) => {
    if (e.key === ' ') {
      btn.classList.remove('space-pressed');
    }
    if (e.key === 'Enter') {
      btn.classList.remove('enter-pressed');
    }
  });
</script>

组合键说明:

组合键 效果 触发元素
Tab + Space 聚焦并激活按钮 <button>
Tab + Enter 聚焦并触发按钮 <button><div role="button">

原生 HTML 按钮的行为:

  • <button>:Tab 聚焦后按 Space/Enter 都会触发点击
  • <div role="button">:需要额外 JS 处理 Space 键

八、总结

构建无障碍的按钮组件需要关注多个层面的细节。从视觉设计角度,按钮应该让用户清晰地感知到它是一个可交互的元素;从键盘交互角度,必须支持空格键和回车键的激活操作;从 ARIA 属性角度,需要正确使用角色、状态和属性来传达组件的语义和当前状态。

按钮与链接的功能区分是 Web 语义化的基础之一,遵循这个原则不仅有助于辅助技术用户理解页面结构,也能提升所有用户的使用体验。在实际开发中,优先使用语义化的原生 HTML 元素,只有在必要时才考虑使用自定义实现,并确保为这些实现添加完整的无障碍支持。

WAI-ARIA Button Pattern 为我们提供了清晰的指导方针,将这些规范内化为开发习惯,能够帮助我们创建更加包容和易用的 Web 应用。每一个正确实现的按钮组件,都是构建无障碍网络环境的重要一步。

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

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

在Unity的Shader Graph中,NormalVector节点是一个基础且重要的工具,它允许着色器访问网格的法线矢量信息。法线矢量在计算机图形学中扮演着关键角色,它定义了表面的朝向,是光照计算、材质表现和各种视觉效果的基础。

节点概述

NormalVector节点为着色器编写者提供了获取网格法线数据的便捷途径。无论是顶点法线还是片元法线,这个节点都能让开发者轻松地在不同的坐标空间中操作这些数据。通过简单的参数设置,就可以将法线矢量转换到所需的坐标空间,大大简化了复杂着色器的开发过程。

法线矢量的本质是垂直于表面的单位向量,在三维空间中表示为(x, y, z)坐标。在Shader Graph中,这些数据通常来自3D模型的顶点数据,或者通过法线贴图等技术进行修改和增强。

参数详解

Space参数

Space参数决定了法线矢量输出的坐标空间,这是NormalVector节点最核心的功能。不同的坐标空间适用于不同的着色场景和计算需求。

  • Object空间:也称为模型空间,这是法线数据最原始的存储空间。在Object空间中,法线相对于模型本身的坐标系定义,不考虑模型的旋转、缩放或平移变换。当模型发生变换时,Object空间中的法线不会自动更新,需要手动进行相应的变换计算。
  • View空间:也称为相机空间或眼睛空间,在这个空间中,所有坐标都是相对于相机的位置和方向定义的。View空间的原点通常是相机的位置,Z轴指向相机的观察方向。这个空间特别适合与视角相关的效果,如边缘光、反射和折射。
  • World空间:World空间中的坐标是相对于场景的世界坐标系定义的。无论模型如何移动或旋转,World空间提供了统一的参考框架。这个空间常用于光照计算、阴影生成和全局效果。
  • Tangent空间:这是一个特殊的局部空间,主要用于法线贴图。在Tangent空间中,法线是相对于表面本身定义的,Z轴与表面法线对齐,X轴与切向量对齐,Y轴与副法线对齐。这种表示方法使得法线贴图可以在不同朝向的表面上重复使用。

选择正确的坐标空间对着色器的正确性和性能至关重要。错误的空间选择可能导致光照计算错误、视觉效果异常或性能下降。

端口信息

NormalVector节点只有一个输出端口:

  • Out:输出类型为Vector 3,表示三维矢量。这个端口输出的是根据Space参数选择在对应坐标空间中的法线矢量。输出值通常是归一化的单位矢量,但在某些情况下(如使用非统一缩放时)可能需要重新归一化。

使用场景与示例

基础光照计算

法线矢量的一个主要应用是光照计算。在Lambert光照模型中,表面亮度取决于光线方向与表面法线之间的夹角。

HLSL

// 简化的Lambert光照计算
float3 lightDir = normalize(_WorldSpaceLightPos0.xyz);
float3 worldNormal = NormalVector节点输出(World空间);
float NdotL = max(0, dot(worldNormal, lightDir));
float3 diffuse = _LightColor0 * NdotL;

在这个示例中,我们首先获取世界空间中的法线矢量和光线方向,然后计算它们的点积。点积结果决定了表面接收到的光照强度,这是大多数基础光照模型的核心计算。

法线贴图应用

法线贴图是现代实时渲染中增强表面细节的关键技术。NormalVector节点在应用法线贴图时起着桥梁作用。

HLSL

// 法线贴图应用流程
float3 tangentNormal = tex2D(_NormalMap, uv).xyz * 2 - 1; // 从[0,1]转换到[-1,1]
float3 worldNormal = NormalVector节点输出(World空间);
// 使用TBN矩阵将切线空间法线转换到世界空间
float3x3 TBN = float3x3(
    IN.tangent.xyz,
    cross(IN.normal, IN.tangent.xyz) * IN.tangent.w,
    IN.normal
);
float3 mappedNormal = mul(TBN, tangentNormal);

这个示例展示了如何将切线空间中的法线贴图数据转换到世界空间。首先从法线贴图中采样并调整数值范围,然后使用TBN(切线-副切线-法线)矩阵进行空间转换。

边缘检测与轮廓光

利用View空间中的法线可以创建各种与视角相关的效果,如边缘光和轮廓检测。

HLSL

// 边缘光效果
float3 viewNormal = normalize(mul((float3x3)UNITY_MATRIX_V, NormalVector节点输出(World空间)));
float3 viewDir = normalize(UnityWorldToViewPos(IN.worldPos));
float rim = 1 - abs(dot(viewNormal, viewDir));
float rimLight = pow(rim, _RimPower) * _RimIntensity;

在这个示例中,我们首先将世界空间法线转换到View空间,然后计算法线与视角方向的点积。当表面几乎垂直于视角方向时(即边缘处),点积接近0,从而产生边缘光效果。

环境遮挡与全局光照

法线信息对于环境遮挡和全局光照计算也至关重要。

HLSL

// 简化的环境遮挡
float3 worldNormal = NormalVector节点输出(World空间);
float ambientOcclusion = 1.0;

// 基于法线方向的简单环境光遮蔽
// 这里可以使用更复杂的算法,如SSAO或烘焙的AO贴图
ambientOcclusion *= (worldNormal.y * 0.5 + 0.5); // 模拟顶部光照更多

// 应用环境光
float3 ambient = UNITY_LIGHTMODEL_AMBIENT * ambientOcclusion;

这个简单的示例展示了如何用法线方向来模拟环境光遮蔽效果。在实际项目中,通常会结合更复杂的算法或预计算的数据。

高级应用技巧

法线重定向与混合

在某些情况下,需要将法线从一个表面重定向到另一个表面,或者在不同法线源之间进行混合。

HLSL

// 法线混合示例
float3 normalA = tex2D(_NormalMapA, uv).xyz;
float3 normalB = tex2D(_NormalMapB, uv).xyz;
float blendFactor = _BlendFactor;

// 使用线性插值混合法线
float3 blendedNormal = lerp(normalA, normalB, blendFactor);

// 或者使用更精确的球面线性插值
// float3 blendedNormal = normalize(lerp(normalA, normalB, blendFactor));

法线混合是一个复杂的话题,因为简单的线性插值可能不会保持法线的单位长度。在实际应用中,可能需要重新归一化或使用更高级的插值方法。

法线空间转换优化

在性能关键的场景中,法线空间转换可能需要优化。

HLSL

// 优化的世界空间法线计算
// 传统方法
float3 worldNormal = normalize(mul(IN.normal, (float3x3)unity_WorldToObject));

// 优化方法 - 使用逆转置矩阵(处理非统一缩放)
float3 worldNormal = normalize(mul(transpose((float3x3)unity_WorldToObject), IN.normal));

当模型应用了非统一缩放时,直接使用模型矩阵变换法线会导致错误的结果。在这种情况下,需要使用模型矩阵的逆转置矩阵来正确变换法线。

法线可视化与调试

在开发过程中,可视化法线矢量对于调试着色器非常有用。

HLSL

// 法线可视化
float3 worldNormal = NormalVector节点输出(World空间);
// 将法线从[-1,1]范围映射到[0,1]范围以便可视化
float3 normalColor = worldNormal * 0.5 + 0.5;
return float4(normalColor, 1.0);

这个简单的着色器将法线矢量的各个分量映射到颜色通道,从而可以直观地查看法线的方向和分布。

常见问题与解决方案

法线不连续问题

当使用低多边形模型或不当的UV展开时,可能会遇到法线不连续的问题。

  • 问题表现:表面出现不自然的硬边或接缝
  • 解决方案
    • 确保模型有适当的平滑组设置
    • 检查UV展开是否导致法线贴图采样错误
    • 考虑使用更高精度的模型或细分表面

性能考量

法线计算可能会成为性能瓶颈,特别是在移动设备或复杂场景中。

  • 优化策略
    • 在顶点着色器中计算法线,而不是片元着色器
    • 使用更简单的法线计算,如省略归一化步骤(如果对视觉效果影响不大)
    • 考虑使用法线贴图的压缩格式以减少内存带宽

法线精度问题

在特定情况下,法线计算可能会遇到精度问题,导致视觉瑕疵。

  • 问题表现:闪烁的表面、带状伪影或不准确的光照
  • 解决方案
    • 使用更高精度的数据类型(如half改为float)
    • 确保法线贴图使用适当的格式和压缩
    • 检查法线变换矩阵的精度和正确性

与其他节点的配合使用

NormalVector节点很少单独使用,通常与其他Shader Graph节点结合以实现复杂的效果。

  • 与Dot Product节点结合:用于计算光照强度、菲涅尔效应等
  • 与Transform节点结合:在不同坐标空间之间转换法线
  • 与Normalize节点结合:确保法线保持单位长度
  • 与Sample Texture 2D节点结合:应用法线贴图
  • 与Fresnel Effect节点结合:创建基于视角的效果

最佳实践

为了确保NormalVector节点的正确使用和最佳性能,建议遵循以下最佳实践:

  • 始终考虑法线是否需要归一化,特别是在进行数学运算或空间变换后
  • 选择最适合当前计算任务的坐标空间,避免不必要的空间转换
  • 在性能敏感的场景中,尽可能在顶点着色器中计算法线相关数据
  • 使用适当的数据类型平衡精度和性能
  • 定期验证法线计算的正确性,特别是在使用复杂变换或混合时

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

JavaScript 函数入门:从基础调用到闭包与模块化

在 JavaScript 的世界里,函数不仅是代码的执行单元,更是构建复杂应用的核心积木。正如你的笔记所言:“函数包含一组语句,它们是 JavaScript 的基础模块单元,用于代码复用、信息隐藏和组合调用。” 本文将系统梳理 JavaScript 函数的核心特性——从其对象本质、四种调用模式,到闭包、模块化等高级概念,助你真正掌握这门语言的灵魂。


一、函数即对象:一切皆可赋值

JavaScript 中最颠覆传统编程认知的一点是:函数是头等对象(First-class Object)

function add(a, b) {
    return a + b;
  }
  
  // 函数可以像普通变量一样被赋值、传递、存储
  var myFunc = add;
  console.log(myFunc(2, 3)); // 5
  
  // 甚至可以作为对象属性(方法)
  var calculator = { operate: add };

✅ 函数对象的特殊性:

  • 它拥有普通对象的所有能力(可添加属性、可作为参数传递);
  • 唯一区别:它可以通过 () 被调用(invoked)
  • 其原型链为:add → Function.prototype → Object.prototype

💡 正因函数是对象,我们才能实现回调、高阶函数、闭包等强大模式。


二、函数字面量:声明的四种方式

最常用的是函数字面量(Function Literal)

// 命名函数(推荐,便于调试)
function greet(name) {
    return "Hello, " + name;
  }
  
  // 匿名函数(常用于回调)
  setTimeout(function() {
    console.log("Delayed!");
  }, 1000);

此外还有:

  • 函数表达式const fn = function() {}
  • 箭头函数(ES6+)const fn = () => {}

📌 命名建议:除非作为简短回调,否则优先使用命名函数,提升堆栈可读性。


三、四大调用模式:this 的命运由谁决定?

函数调用时,会自动获得两个“免费”参数:this 和 arguments。而 this 的指向,取决于调用方式

1. 方法调用模式(Method Invocation)

var obj = {
    name: "Alice",
    sayHi: function() {
      console.log(this.name); // "Alice" —— this 指向 obj
    }
  };
  obj.sayHi();

✅ this 绑定到调用对象,这是面向对象编程的基础。

2. 函数调用模式(Function Invocation)

function sayName() {
    console.log(this); // 非严格模式:window;严格模式:undefined
  }
  sayName(); // 直接调用

⚠️ 危险!  在非严格模式下,this 意外指向全局对象,易引发 bug。

3. 构造器调用模式(Constructor Invocation)

function Person(name) {
    this.name = name; // this 指向新创建的实例
  }
  var p = new Person("Bob");

✅ 使用 new 时:

  • 创建新对象;
  • this 绑定到该对象;
  • 若无显式 return 对象,则返回 this

4. Apply/Call 调用模式(Explicit Invocation)

function introduce() {
    console.log("I'm " + this.name);
  }
  
  var user = { name: "Carol" };
  introduce.call(user);   // "I'm Carol"
  introduce.apply(user);  // 同上(参数以数组形式传入)

✅ 显式指定 this,是实现函数借用、绑定上下文的关键。

🌟 现代替代:ES5+ 的 bind() 可创建永久绑定 this 的新函数。


四、参数与返回:灵活但需谨慎

参数:arguments 对象

function sum() {
    let total = 0;
    for (let i = 0; i < arguments.length; i++) {
      total += arguments[i];
    }
    return total;
  }
  sum(1, 2, 3); // 6
  • arguments 是类数组对象,无 map/forEach 等方法;
  • 现代替代:使用 Rest 参数(...args  获取真数组:
function sum(...numbers) {
    return numbers.reduce((a, b) => a + b, 0);
  }

返回值规则

  • 无 return → 返回 undefined
  • 构造函数中若 return 非对象 → 忽略,仍返回 this
  • 若 return 对象 → 返回该对象(覆盖 this)。

五、闭包:函数的“记忆”能力

闭包 = 内部函数 + 外部作用域的引用

function counter() {
    var count = 0;
    return function() {
      count++;
      return count;
    };
  }
  
  var c = counter();
  console.log(c()); // 1
  console.log(c()); // 2 —— count 被“记住”了!

✅ 闭包的价值:

  • 数据私有化count 外部无法直接访问;
  • 状态保持:函数“记住”了创建时的环境;
  • 模块化基础:实现信息隐藏。

六、模块模式:告别全局污染

利用闭包,可构建模块(Module) ——提供接口但隐藏内部状态:

var MyModule = (function() {
    var privateVar = "secret";
  
    function privateMethod() {
      console.log(privateVar);
    }
  
    return {
      publicMethod: function() {
        privateMethod();
      }
    };
  })();
  
  MyModule.publicMethod(); // "secret"
  // MyModule.privateVar → undefined(无法访问)

✅ 优势

  • 避免全局变量冲突;
  • 实现封装与解耦;
  • 是现代 ES6 模块(import/export)的思想前身。

七、高级技巧:记忆化、套用与级联

1. 记忆化(Memoization)

缓存计算结果,避免重复运算:

function fibonacci(n, memo = {}) {
    if (n in memo) return memo[n];
    if (n <= 1) return n;
    memo[n] = fibonacci(n - 1, memo) + fibonacci(n - 2, memo);
    return memo[n];
  }

2. 套用(Currying)

将多参数函数转换为一系列单参数函数:

function add(a) {
    return function(b) {
      return a + b;
    };
  }
  var add5 = add(5);
  add5(3); // 8

3. 级联(Chaining)

方法返回对象自身,支持链式调用:

var obj = {
    value: 0,
    add: function(x) {
      this.value += x;
      return this; // 关键:返回 this
    },
    log: function() {
      console.log(this.value);
      return this;
    }
  };
  
  obj.add(2).add(3).log(); // 5

八、异常处理:优雅应对错误

try {
    throw new Error("Something went wrong!");
  } catch (e) {
    console.error(e.message);
  } finally {
    console.log("Cleanup");
  }
  • throw 抛出异常对象(建议用 Error 实例);
  • catch 捕获并处理;
  • finally 无论是否出错都会执行。

结语:函数是 JavaScript 的灵魂

从简单的代码复用,到复杂的闭包、模块、高阶函数,JavaScript 的函数机制赋予了开发者极大的表达力。理解其对象本质、this 绑定规则、作用域链与闭包原理,是写出健壮、可维护代码的前提。

正如 Douglas Crockford 所言: “JavaScript 的精华,就在于它的函数。”  掌握函数,你就掌握了这门语言的钥匙。

Server Components vs Client Components:Next.js 开发者的选择指南

Server Components vs Client Components:Next.js 开发者的选择指南

在 Next.js 的世界里,理解这两种组件的区别,就像掌握武术中的“刚柔并济”

大家好!今天我们来深入探讨 Next.js 13+ 中最重要的架构变革:Server Components(服务端组件)Client Components(客户端组件)。这两者的选择不仅影响性能,更关乎应用架构的根本决策。

📊 快速对比:一图看懂核心差异

先来个直观对比,让大家有个整体概念:

特性维度 Server Components Client Components
渲染位置 服务端 客户端
Bundle大小 零打包,不发送到客户端 需要打包并发送到客户端
数据获取 直接访问数据库/API 通过API端点获取
交互性 无(纯展示) 完全交互式
生命周期 无(每次请求重新渲染) 完整React生命周期
DOM API 不可用 完全可用
状态管理 无状态 useState、useReducer等
第三方库 需兼容服务端渲染 无限制

🔍 深入解析:它们到底做了什么?

Server Components:服务端的“魔法”

// app/products/page.js - 默认就是Server Component
import { db } from '@/lib/db'

// 服务端组件可以直接访问数据库!
export default async function ProductsPage() {
  // 直接读取数据库,不需要API路由
  const products = await db.products.findMany({
    where: { isPublished: true }
  })
  
  return (
    <div>
      <h1>产品列表</h1>
      {/* 数据直接嵌入HTML,对SEO友好 */}
      <ul>
        {products.map(product => (
          <li key={product.id}>
            <h2>{product.name}</h2>
            <p>{product.description}</p>
            {/* 注意:这里不能有事件处理器 */}
          </li>
        ))}
      </ul>
    </div>
  )
}

Server Components的优势:

  • 零客户端Bundle:代码永远不会发送到浏览器
  • 直接数据访问:减少客户端-服务器往返
  • 自动代码分割:只发送当前路由需要的代码
  • 敏感信息安全:API密钥、数据库凭证安全保留在服务端

Client Components:客户端的“灵魂”

'use client' // 这个指令至关重要!

import { useState, useEffect } from 'react'
import { addToCart } from '@/actions/cart'
import { LikeButton } from './LikeButton'

export default function ProductCard({ initialProduct }) {
  const [product, setProduct] = useState(initialProduct)
  const [isLiked, setIsLiked] = useState(false)
  
  // 客户端特有的生命周期
  useEffect(() => {
    // 可以访问浏览器API
    const viewed = localStorage.getItem(`viewed_${product.id}`)
    if (!viewed) {
      localStorage.setItem(`viewed_${product.id}`, 'true')
      // 发送浏览记录到分析服务
      analytics.track('product_view', { id: product.id })
    }
  }, [product.id])
  
  // 交互事件处理
  const handleAddToCart = async () => {
    await addToCart(product.id)
    // 显示动画反馈
    // 更新购物车图标数量
  }
  
  return (
    <div className="product-card">
      <h3>{product.name}</h3>
      <p>{product.price}元</p>
      
      {/* 交互式组件 */}
      <button 
        onClick={handleAddToCart}
        className="add-to-cart-btn"
      >
        加入购物车
      </button>
      
      {/* 使用第三方UI库 */}
      <LikeButton 
        isLiked={isLiked}
        onChange={setIsLiked}
      />
      
      {/* 使用状态驱动的UI */}
      <div className={`stock-status ${product.stock < 10 ? 'low' : ''}`}>
        库存: {product.stock}
      </div>
    </div>
  )
}

🎯 黄金选择法则:什么时候用什么?

默认选择 Server Component 当:

  • ✅ 纯数据展示,无需交互
  • ✅ 访问后端资源(数据库、文件系统)
  • ✅ 需要减少客户端JavaScript体积
  • ✅ 包含敏感逻辑或数据
  • ✅ SEO是关键考虑因素
  • ✅ 内容基本静态,变化不频繁
// ✅ 应该用 Server Component
// 博客文章页面
export default async function BlogPost({ slug }) {
  const post = await db.posts.findUnique({ where: { slug } })
  const relatedPosts = await db.posts.findMany({
    where: { category: post.category },
    take: 3
  })
  
  return <Article content={post.content} related={relatedPosts} />
}

必须使用 Client Component 当:

  • ✅ 需要用户交互(点击、输入、拖拽)
  • ✅ 使用浏览器API(localStorage、geolocation)
  • ✅ 需要状态管理(useState、useReducer)
  • ✅ 使用第三方交互式库(地图、图表、富文本编辑器)
  • ✅ 需要生命周期效果(useEffect)
  • ✅ 实现动画或过渡效果
'use client'
// ✅ 必须用 Client Component
// 实时搜索组件
export default function SearchBox() {
  const [query, setQuery] = useState('')
  const [results, setResults] = useState([])
  const [isSearching, setIsSearching] = useState(false)
  
  // 防抖搜索
  useEffect(() => {
    if (!query.trim()) return
    
    const timer = setTimeout(async () => {
      setIsSearching(true)
      const res = await fetch(`/api/search?q=${query}`)
      const data = await res.json()
      setResults(data)
      setIsSearching(false)
    }, 300)
    
    return () => clearTimeout(timer)
  }, [query])
  
  return (
    <div>
      <input 
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="搜索..."
      />
      {isSearching && <Spinner />}
      <SearchResults results={results} />
    </div>
  )
}

🛠️ 混合使用:现实世界的案例

真正的应用往往是混合使用的,下面看一个电商产品页面的例子:

// app/product/[id]/page.js - Server Component
import { db } from '@/lib/db'
import ProductDetails from './ProductDetails' // Client Component
import ProductReviews from './ProductReviews' // Server Component
import AddToCartButton from '@/components/AddToCartButton' // Client Component

export default async function ProductPage({ params }) {
  // 服务端:获取核心数据
  const product = await db.products.findUnique({
    where: { id: params.id },
    include: { category: true }
  })
  
  // 服务端:获取评论(SEO重要)
  const reviews = await db.reviews.findMany({
    where: { productId: params.id, isVerified: true },
    take: 10
  })
  
  // 服务端:获取推荐(个性化)
  const recommendations = await getRecommendations(product.id)
  
  return (
    <div className="product-page">
      {/* 服务器组件传递数据到客户端组件 */}
      <ProductDetails 
        product={product} 
        // 客户端交互收藏分享放大图片
      />
      
      {/* 服务器组件:纯展示评论 */}
      <ProductReviews 
        reviews={reviews}
        // 客户端交互点赞回复评论嵌套的客户端组件)
      />
      
      {/* 客户端组件:购物车交互 */}
      <AddToCartButton 
        productId={product.id}
        stock={product.stock}
      />
      
      {/* 服务端组件:推荐列表 */}
      <RecommendationList 
        products={recommendations}
        // 每个推荐项内部可能有客户端交互
      />
    </div>
  )
}

💡 高级模式与最佳实践

1. 组件边界优化

// 不好的做法:整个页面都是客户端组件
'use client' // ❌ 不要轻易在顶层加这个

// 好的做法:精确控制客户端边界
export default function UserDashboard() {
  return (
    <div>
      {/* 服务端组件:用户信息(静态) */}
      <UserProfile />
      
      {/* 服务端组件:统计数据 */}
      <AnalyticsSummary />
      
      {/* 精确的客户端边界:交互式图表 */}
      <div className="interactive-section">
        <RealTimeChart />
        <FilterControls />
      </div>
    </div>
  )
}

2. 数据传递模式

// ✅ 模式:服务端获取数据,传递给客户端
// Server Component
export default async function Dashboard() {
  const initialData = await fetchDashboardData()
  
  return <InteractiveDashboard initialData={initialData} />
}

// Client Component
'use client'
function InteractiveDashboard({ initialData }) {
  const [data, setData] = useState(initialData)
  
  // 客户端更新数据
  const refreshData = async () => {
    const newData = await fetch('/api/dashboard')
    setData(newData)
  }
  
  return (
    <>
      <DashboardUI data={data} />
      <button onClick={refreshData}>刷新</button>
    </>
  )
}

3. 性能优化策略

// 策略:代码分割 + 懒加载客户端组件
import dynamic from 'next/dynamic'

// 重交互组件动态导入
const HeavyChart = dynamic(
  () => import('@/components/HeavyChart'),
  { 
    ssr: false, // 不在服务端渲染
    loading: () => <ChartSkeleton /> 
  }
)

export default function AnalyticsPage() {
  return (
    <div>
      <h1>数据分析</h1>
      {/* 这个组件只在客户端加载 */}
      <HeavyChart />
    </div>
  )
}

🚨 常见陷阱与解决方案

陷阱1:在Server Component中使用客户端特性

// ❌ 错误:在服务端组件中使用useState
export default function ServerComponent() {
  const [count, setCount] = useState(0) // 编译错误!
  return <div>{count}</div>
}

// ✅ 解决方案:提取为客户端组件
'use client'
function Counter() {
  const [count, setCount] = useState(0)
  return <div>{count}</div>
}

export default function Page() {
  return <Counter />
}

陷阱2:不必要的客户端边界

// ❌ 不必要的客户端标记
'use client'
export default function Page() {
  // 这个组件没有任何交互,却标记为客户端!
  return <div>静态内容</div>
}

// ✅ 保持为服务端组件
export default function Page() {
  return <div>静态内容</div>
}

陷阱3:过度嵌套导致的序列化问题

// ❌ 传递无法序列化的数据
export default async function Page() {
  const data = await fetchData()
  // 函数、Date对象等无法序列化
  return <ClientComponent data={data} callback={() => {}} />
}

// ✅ 仅传递可序列化数据
export default async function Page() {
  const data = await fetchData()
  // 清理数据,确保可序列化
  const serializableData = JSON.parse(JSON.stringify(data))
  return <ClientComponent data={serializableData} />
}

📈 性能影响:真实数据对比

根据Vercel的测试数据:

场景 纯客户端渲染 混合渲染(推荐) 纯服务端组件
首屏加载时间 2.8s 1.2s ⭐ 1.0s
可交互时间 2.8s 1.4s ⭐ N/A
Bundle大小 245KB 78KB ⭐ 12KB
SEO友好度 高 ⭐

结论:混合方案在绝大多数场景下是最佳选择!

🔮 未来趋势

  1. Partial Prerendering(部分预渲染):Next.js 14+ 的新特性,自动混合静态和动态内容
  2. Server Actions:更深度集成服务端逻辑
  3. Edge Runtime优化:组件级别的边缘计算部署

🎓 总结:决策流程图

这里给你一个快速决策流程图:

开始
  ↓
组件需要交互吗?
  ↓
是 → 需要浏览器API吗? → 是 → Client Component ✅
  ↓                ↓
否               否 → 有状态吗? → 是 → Client Component ✅
  ↓                ↓           ↓
Server Component ← 否 ← 否 ← 仅展示数据?
  ↓
需要考虑Bundle大小吗? → 是 → Server Component ✅
  ↓
否
↓
Client Component ✅

💬 互动讨论

话题讨论

  1. 你在项目中最大的 Server/Client Component 挑战是什么?
  2. 有没有遇到性能大幅提升的成功案例?
  3. 你如何向团队成员解释这两种组件的区别?

欢迎在评论区分享你的经验和见解!

小白理解Catalog 协议

Catalog 协议不是安装机制,而是版本管理机制。 它在 monorepo 中提供了一个“单一版本源头”, 子包只负责声明依赖,版本号由根目录统一控制。 与 workspace 协议配合使用,可以实

普通Fetch和Fetch 流式的区别?

你想弄清楚 Fetch 流式的核心定义、工作原理和实际价值,简单来说,Fetch 流式是 Fetch API 提供的「边接收、边处理」数据的能力,它让前端不再需要等待服务端返回完整的响应数据,而是能逐

promise-logic -- 声明式 Promise 逻辑组合

用逻辑概念替代 API 记忆
promise-logic 的设计是:开发者应专注于业务逻辑,而非 Promise API 的细节
传统 Promise 组合(如 Promise.allPromise.race)的命名与语义不够直观,尤其在复杂异步场景下,代码可读性迅速下降。
promise-logic 通过逻辑门(Logic Gate) 的方式,将异步组合抽象为 andorxor 等逻辑操作,使代码语义清晰、逻辑自解释。


相关功能

  1. 逻辑语义化

    • and:所有任务必须成功(等价于 Promise.all

    • or:至少一个任务成功(等价于 Promise.race

    • xor有且仅有一个任务成功

    • nand:所有任务均失败

    • not:反转单个 Promise 的结果

    • majority:多数任务成功

  2. 零依赖
    仅依赖原生 Promise,无额外运行时依赖。

  3. 全测试覆盖
    所有逻辑门均经过严格单元测试,确保行为符合预期。

  4. 错误分类明确

    • PromiseLogicError 统一错误类型
    • error.type 区分具体逻辑错误(如 'XOR_ERROR'

安装

npm install promise-logic

快速开始

示例:主备服务调用(XOR 场景)

import { PromiseLogic } from 'promise-logic';

// 主服务调用
const primary = fetch('https://api.main.com/data');
// 备用服务调用
const backup = fetch('https://api.backup.com/data');

// 执行 XOR 逻辑:有且仅有一个成功
PromiseLogic.xor([primary, backup])
  .then(result => {
    console.log('成功获取数据:', result);
  })
  .catch(error => {
    if (error.type === 'XOR_ERROR') {
      console.error('主备服务均成功或均失败,不符合 XOR 语义');
    } else {
      console.error('网络错误:', error);
    }
  });

示例:多数决决策(Majority 场景)

import { PromiseLogic } from 'promise-logic';

const services = [
  fetch('https://api.node1.com/vote'),
  fetch('https://api.node2.com/vote'),
  fetch('https://api.node3.com/vote')
];

PromiseLogic.majority(services)
  .then(results => {
    console.log('多数服务返回成功:', results);
  })
  .catch(error => {
    console.error('多数服务失败:', error);
  });

typescript类型断言场景

import { PromiseLogic } from 'promise-logic/typescript';

const services = [
  fetch('https://api.node1.com/vote'),
  fetch('https://api.node2.com/vote'),
  fetch('https://api.node3.com/vote')
];

//可以进行类型断言,也可以默认让PromiseLogic自动推断类型
PromiseLogic.majority<Response>(services)
  .then(results => {
    console.log('多数服务返回成功:', results);
  })
  .catch(error => {
    console.error('多数服务失败:', error);
  });

API 参考

API 说明
and 所有 Promise 成功,返回结果数组;任一失败则整体失败。
or 至少一个 Promise 成功,返回首个成功结果;全部失败则整体失败。
xor 有且仅有一个 Promise 成功,返回该结果;否则抛出 XOR_ERROR
nand 所有 Promise 均失败,返回错误数组;任一成功则整体失败。
not 反转单个 Promise 的结果
majority 超过半数 Promise 成功,返回成功结果数组;否则整体失败。

资源链接

pnpm+pnpm-workspace怎么关联本地包?

一、最核心的问题先回答

为什么我只在 pnpm-workspace.yaml 里写了 apps/*
pnpm 就能知道 apps/webapps/api 这些包,并把它们用起来?

答案只有一句话:

因为 pnpm 在启动时,
先用 Glob 规则找目录 → 再识别哪些是真正的包 → 在内存里记住它们的 name 和路径


二、什么是 Glob?(一定要先懂这个)

1️⃣ Glob 是什么

Glob 是一种“用模式匹配文件路径”的规则

你每天其实都在用,比如:

ls *.js
ls apps/*

这里的 * 就是 Glob。


2️⃣ Glob 的“全称”是啥?

  • 没有严格官方全称
  • 约定俗成理解为:Global Pattern Matching
  • 起源于 Unix Shell

👉 它不是 pnpm 发明的,是整个操作系统层面的东西。


3️⃣ Glob 能干什么?

Glob 只做一件事

👉 在磁盘上找出“路径长得像”的文件或目录

⚠️ 重要:

  • Glob 不懂什么是包
  • Glob 不看 package.json
  • 它只认路径形状

三、apps/* 到底是什么意思?

packages:
  - apps/*

这句话的真实含义是:

“请在项目根目录下,
找出所有路径形状像 apps/某个名字 的目录。”

比如磁盘上有:

apps/web
apps/api
apps/docs

Glob 匹配结果就是这三个。


四、pnpm 是怎么一步步工作的?(重点)

第一步:pnpm 判断是不是 Workspace

pnpm 启动时先看:

有没有 pnpm-workspace.yaml

  • 有 → Workspace 模式
  • 没有 → 单包模式

⚠️ pnpm 只认这个文件


第二步:Glob 展开(找目录)

pnpm 读取:

packages:
  - apps/*

然后:

  • 使用 Glob 规则
  • 遍历文件系统
  • 找到所有匹配的目录:
apps/web
apps/api
apps/docs

⚠️ 此时 pnpm 还不知道谁是包


第三步:识别“真正的包”

pnpm 接下来会逐个检查:

  • apps/web → 有 package.json
  • apps/api → 有 package.json
  • apps/docs → 没有 ❌

只有package.json 的目录才算 workspace 包。


第四步:建立 Package Map(最关键)

pnpm 会读取每个包的 package.json 里的:

{
  "name": "@my-org/web",
  "version": "1.0.0"
}

然后在内存中建立一张表

@my-org/web  -> apps/web
@my-org/api  -> apps/api
@my-org/ui   -> packages/ui

👉 这一步非常重要:

  • pnpm 认的是 name
  • 不是目录名
  • 不是路径

五、pnpm 是什么时候把包“连起来”的?

❌ 不是扫描时

✅ 是安装依赖时

比如 apps/web/package.json

{
  "dependencies": {
    "@my-org/ui": "^1.0.0"
  }
}

pnpm 在安装时会想:

  1. 我要找 @my-org/ui
  2. Workspace 里有没有同名包?
  3. 有 → 用本地的
  4. 没有 → 去 npm 仓库下载

👉 所谓“连起来”,本质是:

把依赖指向本地 workspace 包,而不是远程包


六、如果没有 pnpm-workspace.yaml 会怎样?

pnpm 会:

  • ❌ 不扫描其他目录
  • ❌ 不建立包映射表
  • ❌ 不知道本地还有同名包

结果就是:

即使你本地有 packages/ui
pnpm 也会去 npm 仓库下载一个同名包。


七、mac 上怎么自己“看到” Glob 在干嘛?

macOS 默认用的是 zsh,它天生支持 Glob。

你可以直接在终端试:

# 看 Glob 匹配了哪些目录
ls apps/*

再试:

# 看哪些是真正的包
ls apps/*/package.json

👉 这两步,和 pnpm 内部做的事情几乎一模一样。


八、最容易踩的坑(小白必看)

❌ 错误写法

packages:
  - apps

只会尝试:

apps/package.json

✅ 正确写法

packages:
  - apps/*

九、终极一句话总结(背下来就够了)

Glob 只是用来找目录的规则。
pnpm 用 Glob 找到目录后,再通过 package.json 判断哪些是包,
并把它们的 name 和路径记在内存里。
真正把包“连起来”的,是后面的依赖解析,而不是 Glob 本身。


pnpm Workspace 全流程图

┌────────────────────────────┐
│ 你运行 pnpm install        │
└─────────────┬──────────────┘
              │
              ▼
┌────────────────────────────┐
│ ① 是否存在 pnpm-workspace   │
│    .yaml ?                 │
└─────────────┬──────────────┘
      有      │        没有
      ▼       │         ▼
┌──────────────────┐   ┌──────────────────┐
│ Workspace 模式    │   │ 单包模式          │
└────────┬─────────┘   │(不扫描别的包)     │
         │             └──────────────────┘
         ▼
┌────────────────────────────┐
│ ② 读取 pnpm-workspace.yaml │
│    packages:               │
│    - apps/*                │
│    - packages/*            │
└─────────────┬──────────────┘
              ▼
┌────────────────────────────┐
│ ③ Glob 展开(找目录)        │
│ apps/* →                   │
│   apps/web                 │
│   apps/api                 │
│   apps/docs                │
└─────────────┬──────────────┘
              ▼
┌────────────────────────────┐
│ ④ 判断是否是包              │
│   有没有 package.json ?     │
│   web  ✅                  │
│   api  ✅                  │
│   docs ❌                  │
└─────────────┬──────────────┘
              ▼
┌────────────────────────────┐
│ ⑤ 读取 package.json        │
│   name / version           │
└─────────────┬──────────────┘
              ▼
┌────────────────────────────┐
│ ⑥ 建立 Package Map(内存)   │
│   @my-org/web → apps/web   │
│   @my-org/api → apps/api   │
└─────────────┬──────────────┘
              ▼
┌────────────────────────────┐
│ ⑦ 依赖解析(install 阶段)   │
│ web 依赖 @my-org/ui ?       │
│ → workspace 里有吗?         │
│ → 有 → 用本地包              │
│ → 没有 → 去 npm 仓库         │
└────────────────────────────┘

Vue 3中watch如何高效监听多数据源、计算结果与数组变化?

多数据源监听

在Vue 3中,watch 允许我们同时监听多个响应式数据源,当其中任意一个数据源发生变化时,都会触发回调函数。这在需要同步处理多个数据变化的场景中非常实用,比如表单多字段联动验证、多条件组合筛选等。

基本用法

我们可以将多个数据源(ref、reactive对象或getter函数)放入一个数组中,作为watch的第一个参数。回调函数的第一个参数是所有数据源的新值组成的数组,第二个参数是旧值组成的数组。

import { ref, watch } from 'vue'

// 定义多个响应式数据
const username = ref('')
const password = ref('')
const rememberMe = ref(false)

// 同时监听三个数据源
watch(
  [username, password, rememberMe],
  ([newUsername, newPassword, newRememberMe], [oldUsername, oldPassword, oldRememberMe]) => {
    console.log(`用户名从 ${oldUsername} 变为 ${newUsername}`)
    console.log(`密码从 ${oldPassword} 变为 ${newPassword}`)
    console.log(`记住我状态从 ${oldRememberMe} 变为 ${newRememberMe}`)
    
    // 实际场景中可以在这里进行表单验证
    if (newUsername && newPassword) {
      console.log('表单字段已填写完整')
    }
  }
)

执行流程

flowchart LR
A[定义多个响应式数据] --> B[将数据源放入数组作为watch的监听源]
B --> C[任意数据源发生变化]
C --> D[触发回调函数]
D --> E[解构新值和旧值数组,处理业务逻辑]

Getter函数监听

当我们需要监听的目标不是直接的响应式数据,而是基于响应式数据计算出的值时,可以使用getter函数作为watch的监听源。这种方式让我们能够灵活定义监听的计算逻辑。

基本用法

Getter函数需要返回我们想要监听的计算结果,当这个结果发生变化时,watch就会触发回调函数。

import { reactive, watch } from 'vue'

// 定义响应式状态对象
const cart = reactive({
  items: [
    { id: 1, name: 'Vue 3 实战教程', price: 59, quantity: 1 },
    { id: 2, name: 'Vuex 从入门到精通', price: 39, quantity: 2 }
  ]
})

// 监听购物车的总金额
watch(
  // Getter函数:计算总金额
  () => cart.items.reduce((total, item) => total + item.price * item.quantity, 0),
  (newTotal, oldTotal) => {
    console.log(`购物车总金额从 ${oldTotal} 元变为 ${newTotal} 元`)
    
    // 实际场景中可以在这里更新结算按钮状态或显示优惠信息
    if (newTotal >= 100) {
      console.log('满足满减条件,可享受10元优惠')
    }
  }
)

// 修改购物车商品数量,触发watch
cart.items[0].quantity = 2

执行流程

flowchart LR
A[定义响应式对象] --> B[创建getter函数,返回计算后的值]
B --> C[将getter函数作为watch的监听源]
C --> D[计算值发生变化]
D --> E[触发回调函数]
E --> F[处理新的计算结果]

数组监听

在Vue 3中监听数组需要注意一些细节,因为Vue的响应式系统对数组的处理和普通对象有所不同。默认情况下,watch会监听数组的引用变化和数组方法(如pushpopsplice等)的调用,但不会监听数组元素的直接索引修改。

往期文章归档
免费好用的热门在线工具

监听数组整体变化

当使用数组方法修改数组时,watch会自动触发:

import { ref, watch } from 'vue'

const todoList = ref(['学习Vue 3', '编写项目实战'])

// 监听数组整体变化
watch(todoList, (newList, oldList) => {
  console.log('待办事项列表发生变化:', newList)
})

// 使用数组方法修改数组,触发watch
todoList.value.push('优化代码性能')
todoList.value.pop()

监听数组内部元素变化

如果需要监听数组元素的直接修改(如arr[0] = '新值'),需要开启deep选项:

import { ref, watch } from 'vue'

const numbers = ref([1, 2, 3, 4])

// 开启deep选项,监听数组内部元素变化
watch(numbers, (newNumbers, oldNumbers) => {
  console.log('数组元素发生变化:', newNumbers)
}, { deep: true })

// 直接修改数组元素,触发watch
numbers.value[0] = 100

执行流程

flowchart LR
A[定义响应式数组] --> B[使用watch监听数组,可选开启deep]
B --> C[修改数组]
C --> D{修改方式?}
D -->|数组方法| E[触发watch回调]
D -->|索引修改| F{是否开启deep?}
F -->|是| E
F -->|否| G[不触发watch回调]

课后Quiz

问题1

如何在Vue 3中同时监听多个响应式数据的变化?请写出代码示例。

答案解析: 可以将多个数据源放入数组中作为watch的第一个参数,回调函数会接收新值数组和旧值数组:

import { ref, watch } from 'vue'

const name = ref('')
const age = ref(0)

watch(
  [name, age],
  ([newName, newAge], [oldName, oldAge]) => {
    console.log(`姓名从 ${oldName} 变为 ${newName}`)
    console.log(`年龄从 ${oldAge} 变为 ${newAge}`)
  }
)

问题2

当需要监听响应式对象中多个属性的计算结果时,应该使用什么方式?请写出代码示例。

答案解析: 使用getter函数作为watch的监听源,在getter函数中计算需要监听的结果:

import { reactive, watch } from 'vue'

const product = reactive({
  stock: 100,
  sales: 30
})

// 监听剩余库存
watch(
  () => product.stock - product.sales,
  (newStock, oldStock) => {
    console.log(`剩余库存从 ${oldStock} 变为 ${newStock}`)
  }
)

问题3

为什么直接修改数组的索引元素时,watch默认不会触发?如何解决这个问题?

答案解析: Vue的响应式系统默认不会监听数组的索引修改,因为这在性能上是低效的。解决方法有两种:

  1. 开启deep选项,深度监听数组内部元素变化
  2. 使用Vue提供的数组方法(如pushsplice等)来修改数组

常见报错解决方案

报错1:watch source must be a ref, reactive object, getter function, or array of these

  • 原因watch的监听源类型不正确,不是Vue支持的响应式数据源类型。
  • 解决方法:确保监听源是ref、reactive对象、getter函数或这些类型的数组。例如,如果你想监听普通变量,需要先将其转换为ref:
// 错误用法:监听普通变量
let count = 0
watch(count, () => { /* ... */ })

// 正确用法:转换为ref
const count = ref(0)
watch(count, () => { /* ... */ })

报错2:数组元素修改后watch不触发

  • 原因:直接修改数组索引元素,Vue默认不监听这种变化。
  • 解决方法:开启deep选项,或者使用数组方法修改数组:
// 方法1:开启deep选项
watch(numbers, () => { /* ... */ }, { deep: true })

// 方法2:使用数组方法
numbers.value.splice(0, 1, 100)

报错3:Cannot read property 'value' of undefined

  • 原因:在getter函数或回调函数中访问了未定义的响应式属性。
  • 解决方法:确保所有访问的属性都已正确定义,或者添加可选链操作符:
// 错误用法:访问未定义的属性
watch(() => user.address.city, () => { /* ... */ })

// 正确用法:添加可选链
watch(() => user?.address?.city, () => { /* ... */ })

参考链接

参考链接:vuejs.org/guide/essen…

❌