普通视图

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

拥抱PostgreSQL支持UI配置化

2026年1月26日 00:19

前言

前阵子写的日志分析工具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

项目地址

写在最后

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

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

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

每日一题-最小绝对差🟢

2026年1月26日 00:00

给你个整数数组 arr,其中每个元素都 不相同

请你找到所有具有最小绝对差的元素对,并且按升序的顺序返回。

每对元素对 [a,b] 如下:

  • a , b 均为数组 arr 中的元素
  • a < b
  • b - a 等于 arr 中任意两个元素的最小绝对差

 

示例 1:

输入:arr = [4,2,1,3]
输出:[[1,2],[2,3],[3,4]]

示例 2:

输入:arr = [1,3,6,10,15]
输出:[[1,3]]

示例 3:

输入:arr = [3,8,-10,23,19,-4,-14,27]
输出:[[-14,-10],[19,23],[23,27]]

 

提示:

  • 2 <= arr.length <= 10^5
  • -10^6 <= arr[i] <= 10^6

Button Pattern 详解

作者 anOnion
2026年1月25日 23:24

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 应用。每一个正确实现的按钮组件,都是构建无障碍网络环境的重要一步。

【宫水三叶】简单排序模拟题

作者 AC_OIer
2022年7月4日 09:28

排序 + 模拟

数据范围为 $1e5$,我们不能通过枚举的方式遍历所有的点对找最小值。

我们可以对 arr 进行排序,容易得知差值最小值必然发生在排序数组的相邻元素之间,此时我们可以通过遍历排序数组并使用变量 min 记录当前差值最小值来统计答案。

代码:

###Java

class Solution {
    public List<List<Integer>> minimumAbsDifference(int[] arr) {
        Arrays.sort(arr);
        List<List<Integer>> ans = new ArrayList<>();
        int n = arr.length, min = arr[1] - arr[0];
        for (int i = 0; i < n - 1; i++) {
            int cur = arr[i + 1] - arr[i];
            if (cur < min) {
                ans.clear();
                min = cur;
            }
            if (cur == min) {
                List<Integer> temp = new ArrayList<>();
                temp.add(arr[i]); temp.add(arr[i + 1]);
                ans.add(temp);
            }
        }
        return ans;
    }
}
  • 时间复杂度:$O(n\log{n})$
  • 空间复杂度:$O(\log{n})$

同类型加餐

题太简单?今日份加餐:【面试高频题】难度 1.5/5,数据结构运用题 🎉 🎉

或是考虑加练如下「模拟」题 🍭🍭🍭

题目 题解 难度 推荐指数
6. Z 字形变换 LeetCode 题解链接 中等 🤩🤩🤩
8. 字符串转换整数 (atoi) LeetCode 题解链接 中等 🤩🤩🤩
12. 整数转罗马数字 LeetCode 题解链接 中等 🤩🤩
59. 螺旋矩阵 II LeetCode 题解链接 中等 🤩🤩🤩🤩
65. 有效数字 LeetCode 题解链接 困难 🤩🤩🤩
73. 矩阵置零 LeetCode 题解链接 中等 🤩🤩🤩🤩
89. 格雷编码 LeetCode 题解链接 中等 🤩🤩🤩🤩
166. 分数到小数 LeetCode 题解链接 中等 🤩🤩🤩🤩
260. 只出现一次的数字 III LeetCode 题解链接 中等 🤩🤩🤩🤩
414. 第三大的数 LeetCode 题解链接 中等 🤩🤩🤩🤩
419. 甲板上的战舰 LeetCode 题解链接 中等 🤩🤩🤩🤩
443. 压缩字符串 LeetCode 题解链接 中等 🤩🤩🤩🤩
457. 环形数组是否存在循环 LeetCode 题解链接 中等 🤩🤩🤩🤩
528. 按权重随机选择 LeetCode 题解链接 中等 🤩🤩🤩🤩
539. 最小时间差 LeetCode 题解链接 中等 🤩🤩🤩🤩
726. 原子的数量 LeetCode 题解链接 困难 🤩🤩🤩🤩

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


最后

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

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

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

[Python/Java/TypeScript/Go] 排序模拟

作者 himymBen
2022年7月4日 07:06

解题思路

排序后所有可能的最小绝对值由每对儿相邻的差构成

代码

###Python3

class Solution:
    def minimumAbsDifference(self, arr: List[int]) -> List[List[int]]:
        m, ans = inf, []
        for a, b in pairwise(sorted(arr)):
            if (cur := b - a) < m:
                m, ans = cur, [[a, b]]
            elif cur == m:
                ans.append([a, b])
        return ans

###Java

class Solution {
    public List<List<Integer>> minimumAbsDifference(int[] arr) {
        Arrays.sort(arr);
        List<List<Integer>> list = new ArrayList<>();
        int min = Integer.MAX_VALUE;
        for (int i = 0; i < arr.length - 1; i++) {
            int diff = arr[i + 1] - arr[i];
            if (diff < min) {
                min = diff;
                list.clear();
                List<Integer> cur = new ArrayList<>();
                cur.add(arr[i]);
                cur.add(arr[i + 1]);
                list.add(cur);
            } else if (diff == min) {
                List<Integer> cur = new ArrayList<>();
                cur.add(arr[i]);
                cur.add(arr[i + 1]);
                list.add(cur);
            }
        }
        return list;
    }
}

###TypeScript

function minimumAbsDifference(arr: number[]): number[][] {
    arr.sort((a, b) => a - b)
    let ans = new Array<Array<number>>(), min = Number.MAX_SAFE_INTEGER
    for (let i = 0; i < arr.length - 1; i++) {
        const cur = arr[i + 1] - arr[i]
        if (cur < min) {
            min = cur
            ans = [[arr[i], arr[i + 1]]]
        } else if (cur == min) {
            ans.push([arr[i], arr[i + 1]])
        }
    }
    return ans
};

###Go

func minimumAbsDifference(arr []int) (ans [][]int) {
    sort.Ints(arr)
    for i, min := 0, math.MaxInt32; i < len(arr) - 1; i++ {
        if diff := arr[i + 1] - arr[i]; diff < min {
            min = diff
            ans = [][]int{[]int{arr[i], arr[i + 1]}}
        } else if diff == min {
            ans = append(ans, []int{arr[i], arr[i + 1]})
        }
    }
    return
}

最小绝对差

2022年7月3日 09:07

方法一:排序 + 一次遍历

思路与算法

首先我们对数组 $\textit{arr}$ 进行升序排序。这样一来,拥有「最小绝对差」的元素对只能由有序数组中相邻的两个元素构成。

随后我们对数组 $\textit{arr}$ 进行一次遍历。当遍历到 $\textit{arr}[i]$ 和 $\textit{arr}[i+1]$ 时,它们的差为 $\delta = \textit{arr}[i+1] - \textit{arr}[i]$。我们使用一个变量 $\textit{best}$ 存储当前遇到的最小差,以及一个数组 $\textit{ans}$ 存储答案:

  • 如果 $\delta < \textit{best}$,那么说明我们遇到了更小的差值,需要更新 $\textit{best}$,同时 $\textit{ans}$ 清空并放入 $\textit{arr}[i]$ 和 $\textit{arr}[i+1]$;

  • 如果 $\delta = \textit{best}$,那么我们只需要将 $\textit{arr}[i]$ 和 $\textit{arr}[i+1]$ 放入答案数组即可。

代码

###C++

class Solution {
public:
    vector<vector<int>> minimumAbsDifference(vector<int>& arr) {
        int n = arr.size();
        sort(arr.begin(), arr.end());

        int best = INT_MAX;
        vector<vector<int>> ans;
        for (int i = 0; i < n - 1; ++i) {
            if (int delta = arr[i + 1] - arr[i]; delta < best) {
                best = delta;
                ans = {{arr[i], arr[i + 1]}};
            }
            else if (delta == best) {
                ans.emplace_back(initializer_list<int>{arr[i], arr[i + 1]});
            }
        }

        return ans;
    }
};

###Java

class Solution {
    public List<List<Integer>> minimumAbsDifference(int[] arr) {
        int n = arr.length;
        Arrays.sort(arr);

        int best = Integer.MAX_VALUE;
        List<List<Integer>> ans = new ArrayList<List<Integer>>();
        for (int i = 0; i < n - 1; ++i) {
            int delta = arr[i + 1] - arr[i];
            if (delta < best) {
                best = delta;
                ans.clear();
                List<Integer> pair = new ArrayList<Integer>();
                pair.add(arr[i]);
                pair.add(arr[i + 1]);
                ans.add(pair);
            } else if (delta == best) {
                List<Integer> pair = new ArrayList<Integer>();
                pair.add(arr[i]);
                pair.add(arr[i + 1]);
                ans.add(pair);
            }
        }

        return ans;
    }
}

###C#

public class Solution {
    public IList<IList<int>> MinimumAbsDifference(int[] arr) {
        int n = arr.Length;
        Array.Sort(arr);

        int best = int.MaxValue;
        IList<IList<int>> ans = new List<IList<int>>();
        for (int i = 0; i < n - 1; ++i) {
            int delta = arr[i + 1] - arr[i];
            if (delta < best) {
                best = delta;
                ans.Clear();
                IList<int> pair = new List<int>();
                pair.Add(arr[i]);
                pair.Add(arr[i + 1]);
                ans.Add(pair);
            } else if (delta == best) {
                IList<int> pair = new List<int>();
                pair.Add(arr[i]);
                pair.Add(arr[i + 1]);
                ans.Add(pair);
            }
        }

        return ans;
    }
}

###Python

class Solution:
    def minimumAbsDifference(self, arr: List[int]) -> List[List[int]]:
        n = len(arr)
        arr.sort()

        best, ans = float('inf'), list()
        for i in range(n - 1):
            if (delta := arr[i + 1] - arr[i]) < best:
                best = delta
                ans = [[arr[i], arr[i + 1]]]
            elif delta == best:
                ans.append([arr[i], arr[i + 1]])
        
        return ans

###C

static inline int cmp(const void *pa, const void *pb) {
    return *(int *)pa - *(int *)pb;
}

int** minimumAbsDifference(int* arr, int arrSize, int* returnSize, int** returnColumnSizes){
    qsort(arr, arrSize, sizeof(int), cmp);
    int best = INT_MAX;
    int **ans = (int **)malloc(sizeof(int *) * (arrSize - 1));
    int pos = 0;
    for (int i = 0; i < arrSize - 1; ++i) {
        int delta = arr[i + 1] - arr[i];
        if (delta < best) {
            best = delta;
            for (int j = 0; j < pos; j++) {
                free(ans[j]);
            }
            pos = 0;
            ans[pos] = (int *)malloc(sizeof(int) * 2);
            memcpy(ans[pos], arr + i, sizeof(int) * 2);
            pos++;
        }
        else if (delta == best) {
            ans[pos] = (int *)malloc(sizeof(int) * 2);
            memcpy(ans[pos], arr + i, sizeof(int) * 2);
            pos++;
        }
    }
    *returnSize = pos;
    *returnColumnSizes = (int *)malloc(sizeof(int) * pos);
    for (int i = 0; i < pos; i++) {
        (*returnColumnSizes)[i] = 2;
    }
    return ans;
}

###JavaScript

var minimumAbsDifference = function(arr) {
    const n = arr.length;
    arr.sort((a, b) => a - b);

    let best = Number.MAX_VALUE;
    let ans = [];
    for (let i = 0; i < n - 1; ++i) {
        let delta = arr[i + 1] - arr[i];
        if (delta < best) {
            best = delta;
            ans = [];
            const pair = [];
            pair.push(arr[i]);
            pair.push(arr[i + 1]);
            ans.push(pair);
        } else if (delta === best) {
            const pair = [];
            pair.push(arr[i]);
            pair.push(arr[i + 1]);
            ans.push(pair);
        }
    }

    return ans;
};

###go

func minimumAbsDifference(arr []int) (ans [][]int) {
    sort.Ints(arr)
    for i, best := 0, math.MaxInt32; i < len(arr)-1; i++ {
        if delta := arr[i+1] - arr[i]; delta < best {
            best = delta
            ans = [][]int{{arr[i], arr[i+1]}}
        } else if delta == best {
            ans = append(ans, []int{arr[i], arr[i+1]})
        }
    }
    return
}

复杂度分析

  • 时间复杂度:$O(n \log n)$,其中 $n$ 是数组 $\textit{arr}$ 的长度。排序需要的时间为 $O(n \log n)$,遍历需要的是时间为 $O(n)$,因此总时间复杂度为 $O(n \log n)$。

  • 空间复杂度:$O(\log n)$,即为排序需要使用的栈空间。这里不计入返回值需要使用的空间。

昨天 — 2026年1月25日技术

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

作者 SmalBox
2026年1月25日 18:03

【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 函数入门:从基础调用到闭包与模块化

2026年1月25日 18:02

在 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 开发者的选择指南

作者 北辰alk
2026年1月25日 17:51

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. 你如何向团队成员解释这两种组件的区别?

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

❌
❌